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] the result of the query - # - # @since 0.1.0 - # - # @example - # require 'hanami/model' - # - # class ArticleRepository - # include Hanami::Repository - # end - # - # ArticleRepository.new.all # => [ # ] - def all - adapter.all(collection) + blk = lambda do |_| + model e + register_as MAPPER_NAME + instance_exec(&m) unless m.nil? end - # Finds an entity by its identity. - # - # If used with a SQL database, it corresponds to the primary key. - # - # @param id [Object] the identity of the entity - # - # @return [Object,NilClass] the result of the query, if present - # - # @since 0.1.0 - # - # @example - # require 'hanami/model' - # - # class ArticleRepository - # include Hanami::Repository - # end - # - # ArticleRepository.new.find(23) # => # - # ArticleRepository.new.find(9999) # => nil - def find(id) - adapter.find(collection, id) - end + root = self.root + configuration.mappers { define(root, &blk) } + configuration.define_mappings(root, &blk) + configuration.register_entity(relation, entity_name.underscore, e) + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength - # Returns the first entity in the database. - # - # @return [Object,nil] the result of the query - # - # @since 0.1.0 - # - # @see Hanami::Repository#last - # - # @example With at least one persisted entity - # require 'hanami/model' - # - # class ArticleRepository - # include Hanami::Repository - # end - # - # ArticleRepository.new.first # => # - # - # @example With an empty collection - # require 'hanami/model' - # - # class ArticleRepository - # include Hanami::Repository - # end - # - # ArticleRepository.new.first # => nil - def first - adapter.first(collection) - end + # It defines associations, by adding relations to the repository + # + # @since x.x.x + # @api private + # + # @see Hanami::Model::Associations::Dsl + def self.define_associations + Model::Associations::Dsl.new(self, &@associations) unless @associations.nil? + end - # Returns the last entity in the database. - # - # @return [Object,nil] the result of the query - # - # @since 0.1.0 - # - # @see Hanami::Repository#last - # - # @example With at least one persisted entity - # require 'hanami/model' - # - # class ArticleRepository - # include Hanami::Repository - # end - # - # ArticleRepository.new.last # => # - # - # @example With an empty collection - # require 'hanami/model' - # - # class ArticleRepository - # include Hanami::Repository - # end - # - # ArticleRepository.new.last # => nil - def last - adapter.last(collection) - end + # Declare associations for the repository + # + # NOTE: This is an experimental feature + # + # @since x.x.x + # @api private + # + # @example + # class BookRepository < Hanami::Repository + # associations do + # has_many :books + # end + # end + def self.associations(&blk) + @associations = blk + end - # Deletes all the records from the current collection. - # - # If used with a SQL database it executes a `DELETE FROM `. - # - # @since 0.1.0 - # - # @example - # require 'hanami/model' - # - # class ArticleRepository - # include Hanami::Repository - # end - # - # ArticleRepository.new.clear # deletes all the records - def clear - adapter.clear(collection) - end + # Declare mapping between database columns and entity's attributes + # + # NOTE: This should be used **only** when there is a name mismatch (eg. in legacy databases). + # + # @since x.x.x + # + # @example + # class BookRepository < Hanami::Repository + # self.relation = :t_operator + # + # mapping do + # attribute :id, from: :operator_id + # attribute :name, from: :s_name + # end + # end + def self.mapping(&blk) + @mapping = 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. - # - # Please note that it's only supported by some databases. - # For this reason, the accepted options may be different from adapter to - # adapter. - # - # For advanced scenarios, please check the documentation of each adapter. - # - # @param options [Hash] options for transaction - # - # @see Hanami::Model::Adapters::SqlAdapter#transaction - # @see Hanami::Model::Adapters::MemoryAdapter#transaction - # - # @since 0.2.3 - # - # @example Basic usage with SQL adapter - # 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 - def transaction(options = {}) - adapter.transaction(options) do - yield - end - end + # Define relations, mapping and associations + # + # @since x.x.x + # @api private + def self.load! + define_relation + define_mapping + define_associations + end - private + # @since x.x.x + # @api private + def self.inherited(klass) # rubocop:disable Metrics/MethodLength + klass.class_eval do + include Utils::ClassAttribute - # Executes the given raw statement on the adapter. - # - # Please note that it's only supported by some databases, - # a `NotImplementedError` will be raised when the adapter does not - # responds to the `execute` method. - # - # For advanced scenarios, please check the documentation of each adapter. - # - # @param raw [String] the raw statement to execute on the connection - # - # @return [NilClass] - # - # @raise [NotImplementedError] if current Hanami::Model adapter doesn't - # implement `execute`. - # - # @raise [Hanami::Model::InvalidCommandError] if the raw statement is invalid - # - # @see Hanami::Model::Adapters::Abstract#execute - # @see Hanami::Model::Adapters::SqlAdapter#execute - # - # @since 0.3.1 - # - # @example Basic usage with SQL adapter - # require 'hanami/model' - # - # class Article - # include Hanami::Entity - # attributes :title, :body - # end - # - # class ArticleRepository - # include Hanami::Repository - # - # def self.reset_comments_count - # execute "UPDATE articles SET comments_count = 0" - # end - # end - # - # ArticleRepository.reset_comments_count - def execute(raw) - adapter.execute(raw) + class_attribute :entity + + class_attribute :entity_name + self.entity_name = Model::EntityName.new(name) + + class_attribute :relation + self.relation = Model::RelationName.new(name) + + commands :create, update: :by_primary_key, delete: :by_primary_key, mapper: MAPPER_NAME, use: COMMAND_PLUGINS + prepend Commands end - # Fetch raw result sets for the the given statement. - # - # PLEASE NOTE: The returned result set contains an array of hashes. - # The columns are returned as they are from the database, - # the mapper is bypassed here. - # - # @param raw [String] the raw statement used to fetch records - # @param blk [Proc] an optional block that is yielded for each record - # - # @return [Enumerable,Array] the collection of raw records - # - # @raise [NotImplementedError] if current Hanami::Model adapter doesn't - # implement `fetch`. - # - # @raise [Hanami::Model::InvalidQueryError] if the raw statement is invalid - # - # @since 0.5.0 - # - # @example Basic Usage - # require 'hanami/model' - # - # mapping do - # collection :articles do - # attribute :id, Integer, as: :s_id - # attribute :title, String, as: :s_title - # end - # end - # - # class Article - # include Hanami::Entity - # attributes :title, :body - # end - # - # class ArticleRepository - # include Hanami::Repository - # - # def self.all_raw - # fetch("SELECT * FROM articles") - # end - # end - # - # ArticleRepository.new.all_raw - # # => [{:_id=>1, :user_id=>nil, :s_title=>"Art 1", :comments_count=>nil, :umapped_column=>nil}] - # - # @example Map A Value From Result Set - # require 'hanami/model' - # - # mapping do - # collection :articles do - # attribute :id, Integer, as: :s_id - # attribute :title, String, as: :s_title - # end - # end - # - # class Article - # include Hanami::Entity - # attributes :title, :body - # end - # - # class ArticleRepository - # include Hanami::Repository - # - # def self.titles - # fetch("SELECT s_title FROM articles").map do |article| - # article[:s_title] - # end - # end - # end - # - # ArticleRepository.titles # => ["Announcing Hanami v0.5.0"] - # - # @example Passing A Block - # require 'hanami/model' - # - # mapping do - # collection :articles do - # attribute :id, Integer, as: :s_id - # attribute :title, String, as: :s_title - # end - # end + Hanami::Model.repositories << klass + end + + # Extend commands from ROM::Repository with error management + # + # @since x.x.x + module Commands + # Create a new record # - # class Article - # include Hanami::Entity - # attributes :title, :body - # end + # @return [Hanami::Entity] an new created entity # - # class ArticleRepository - # include Hanami::Repository + # @raise [Hanami::Model::Error] an error in case the command fails # - # def self.titles - # result = [] + # @since x.x.x # - # fetch("SELECT s_title FROM articles") do |article| - # result << article[:s_title] - # end + # @example Create From Hash + # user = UserRepository.new.create(name: 'Luca') # - # result - # end - # end + # @example Create From Entity + # entity = User.new(name: 'Luca') + # user = UserRepository.new.create(entity) # - # ArticleRepository.titles # => ["Announcing Hanami v0.5.0"] - def fetch(raw, &blk) - adapter.fetch(raw, &blk) + # user.id # => 23 + # entity.id # => nil - It doesn't mutate original entity + def create(*args) + super + rescue => e + raise Hanami::Model::Error.for(e) end - # Fabricates a query and yields the given block to access the low level - # APIs exposed by the query itself. - # - # This is a Ruby private method, because we wanted to prevent outside - # objects to query directly the database. However, this is a public API - # method, and this is the only way to filter entities. - # - # The returned query SHOULD be lazy: the entities should be fetched by - # the database only when needed. - # - # The returned query SHOULD refer to the entire collection by default. - # - # Queries can be reused and combined together. See the example below. - # IMPORTANT: This feature works only with the Sql adapter. - # - # A repository is storage independent. - # All the queries are delegated to the current adapter, which is - # responsible to implement a querying API. - # - # Hanami::Model is shipped with two adapters: - # - # * SqlAdapter, which yields a Hanami::Model::Adapters::Sql::Query - # * MemoryAdapter, which yields a Hanami::Model::Adapters::Memory::Query - # - # @param blk [Proc] a block of code that is executed in the context of a - # query + # Update a record # - # @return a query, the type depends on the current adapter + # @return [Hanami::Entity] an updated entity # - # @api public - # @since 0.1.0 + # @raise [Hanami::Model::Error] an error in case the command fails # - # @see Hanami::Model::Adapters::Sql::Query - # @see Hanami::Model::Adapters::Memory::Query + # @since x.x.x # - # @example - # require 'hanami/model' - # - # class ArticleRepository - # include Hanami::Repository - # - # def self.most_recent_by_author(author, limit = 8) - # query do - # where(author_id: author.id). - # desc(:published_at). - # limit(limit) - # end - # end + # @example Update From Data + # repository = UserRepository.new + # user = repository.create(name: 'Luca') # - # def self.most_recent_published_by_author(author, limit = 8) - # # combine .most_recent_published_by_author and .published queries - # most_recent_by_author(author, limit).published - # end + # user = repository.update(user.id, age: 34) # - # def self.published - # query do - # where(published: true) - # end - # end + # @example Update From Entity + # repository = UserRepository.new + # user = repository.create(name: 'Luca') # - # def self.rank - # # reuse .published, which returns a query that respond to #desc - # published.desc(:comments_count) - # end + # entity = User.new(age: 34) + # user = repository.update(user.id, entity) # - # def self.best_article_ever - # # reuse .published, which returns a query that respond to #limit - # rank.limit(1) - # end - # - # def self.comments_average - # query.average(:comments_count) - # end - # end - def query(&blk) - adapter.query(collection, self, &blk) + # user.age # => 34 + # entity.id # => nil - It doesn't mutate original entity + def update(*args) + super + rescue => e + raise Hanami::Model::Error.for(e) end - # Negates the filtering conditions of a given query with the logical - # opposite operator. - # - # This is only supported by the SqlAdapter. + # Delete a record # - # @param query [Object] a query + # @return [Hanami::Entity] a deleted entity # - # @return a negated query, the type depends on the current adapter + # @raise [Hanami::Model::Error] an error in case the command fails # - # @api public - # @since 0.1.0 - # - # @see Hanami::Model::Adapters::Sql::Query#negate! + # @since x.x.x # # @example - # require 'hanami/model' - # - # class ProjectRepository - # include Hanami::Repository - # - # def self.cool - # query do - # where(language: 'ruby') - # end - # end - # - # def self.not_cool - # exclude cool - # end - # end - def exclude(query) - query.negate! - query + # repository = UserRepository.new + # user = repository.create(name: 'Luca') + # + # user = repository.delete(user.id) + def delete(*args) + super + rescue => e + raise Hanami::Model::Error.for(e) end + end - # This is a method to check entity persited or not - # - # @param entity - # @return a boolean value - # @since 0.3.1 - def _persisted?(entity) - !!entity.id - end + # Initialize a new instance + # + # @return [Hanami::Repository] the new instance + # + # @since x.x.x + def initialize + super(self.class.container) + end - # Update timestamps - # - # @param entity [Object, Hanami::Entity] the entity - # - # @api private - # @since 0.3.1 - def _touch(entity) - now = Time.now.utc + # Find by primary key + # + # @return [Hanami::Entity,NilClass] the entity, if found + # + # @since x.x.x + # + # @example + # repository = UserRepository.new + # user = repository.create(name: 'Luca') + # + # user = repository.find(user.id) + def find(id) + root.by_primary_key(id).as(:entity).one + end - if _has_timestamp?(entity, :created_at) - entity.created_at ||= now - end + # Return all the records for the relation + # + # @return [Array] all the entities + # + # @since x.x.x + # + # @example + # UserRepository.new.all + def all + root.as(:entity) + end - if _has_timestamp?(entity, :updated_at) - entity.updated_at = now - end - end + # Returns the first record for the relation + # + # @return [Hanami::Entity,NilClass] first entity, if any + # + # @since x.x.x + # + # @example + # UserRepository.new.first + def first + root.as(:entity).first + end - # Check if the given entity has the given timestamp - # - # @param entity [Object, Hanami::Entity] the entity - # @param timestamp [Symbol] the timestamp name - # - # @return [TrueClass,FalseClass] - # - # @api private - # @since 0.3.1 - def _has_timestamp?(entity, timestamp) - entity.respond_to?(timestamp) && - entity.respond_to?("#{ timestamp }=") - end + # Returns the last record for the relation + # + # @return [Hanami::Entity,NilClass] last entity, if any + # + # @since x.x.x + # + # @example + # UserRepository.new.last + def last + root.order(Model::Sql.desc(root.primary_key)).as(:entity).first + end + + # Deletes all the records from the relation + # + # @since x.x.x + # + # @example + # UserRepository.new.clear + def clear + root.delete + end + + private + + # Returns an association + # + # NOTE: This is an experimental feature + # + # @since x.x.x + # @api private + def assoc(target, subject) + Hanami::Model::Association.build(self, target, subject) end end end diff --git a/test/configuration_test.rb b/test/configuration_test.rb new file mode 100644 index 00000000..a5e1040c --- /dev/null +++ b/test/configuration_test.rb @@ -0,0 +1,78 @@ +require 'test_helper' + +describe Hanami::Model::Configuration do + before do + database_directory = Pathname.pwd.join('tmp', 'db') + database_directory.join('migrations').mkpath + + FileUtils.touch database_directory.join('schema.sql') + end + + let(:subject) { Hanami::Model::Configuration.new(configurator) } + + let(:configurator) do + adapter_url = url + + Hanami::Model::Configurator.build do + adapter :sql, adapter_url + + migrations 'tmp/db/migrations' + schema 'tmp/db/schema.sql' + end + end + + let(:url) do + db = 'tmp/db/bookshelf.sqlite' + + Platform.match do + engine(:ruby) { "sqlite://#{db}" } + engine(:jruby) { "jdbc:sqlite://#{db}" } + end + end + + describe '#url' do + it 'equals to the configured url' do + subject.url.must_equal url + end + end + + describe '#connection' do + it 'returns a raw connection aganist the database' do + connection = subject.connection + + connection.must_be_kind_of Sequel::Database + connection.url.must_equal url + end + end + + describe '#gateway' do + it 'returns default ROM gateway' do + gateway = subject.gateway + + gateway.must_be_kind_of ROM::Gateway + gateway.connection.must_equal subject.connection + end + end + + describe '#root' do + it 'returns current directory' do + subject.root.must_equal Pathname.pwd + end + end + + describe '#migrations' do + it 'returns path to migrations' do + expected = subject.root.join('tmp', 'db', 'migrations') + + subject.migrations.must_equal expected + end + end + + describe '#schema' do + it 'returns path to database schema' do + expected = subject.root.join('tmp', 'db', 'schema.sql') + + subject.schema.must_equal expected + end + end +end diff --git a/test/entity/automatic_schema_test.rb b/test/entity/automatic_schema_test.rb new file mode 100644 index 00000000..f27c6f68 --- /dev/null +++ b/test/entity/automatic_schema_test.rb @@ -0,0 +1,175 @@ +require 'test_helper' + +describe Hanami::Entity do + describe 'automatic schema' do + let(:described_class) { Author } + + let(:input) do + Class.new do + def to_hash + Hash[id: 1] + end + end.new + end + + describe '#initialize' do + it 'can be instantiated without attributes' do + entity = described_class.new + + entity.must_be_kind_of(described_class) + end + + it 'accepts a hash' do + entity = described_class.new(id: 1, name: 'Luca', books: books = [Book.new], created_at: now = Time.now.utc) + + entity.id.must_equal 1 + entity.name.must_equal 'Luca' + entity.books.must_equal books + entity.created_at.must_be_close_to(now, 2) + end + + it 'accepts object that implements #to_hash' do + entity = described_class.new(input) + + entity.id.must_equal 1 + end + + it 'freezes the intance' do + entity = described_class.new + + entity.must_be :frozen? + end + + it 'coerces values' do + now = Time.now + entity = described_class.new(created_at: now.to_s) + + entity.created_at.must_be_close_to(now, 2) + end + + it 'coerces values for array of objects' do + entity = described_class.new(books: books = [{ title: 'TDD' }, { title: 'Refactoring' }]) + + books.each_with_index do |book, i| + b = entity.books[i] + + b.must_be_kind_of(Book) + b.title.must_equal book.fetch(:title) + end + end + + it 'raises error if initialized with wrong primitive' do + exception = lambda do + described_class.new(id: :foo) + end.must_raise(ArgumentError) + + exception.message.must_equal('comparison of Symbol with 0 failed') + end + + it 'raises error if initialized with wrong array object' do + object = Object.new + exception = lambda do + described_class.new(books: [object]) + end.must_raise(TypeError) + + exception.message.must_include('[#] (Array) has invalid type for :books') + end + end + + describe '#id' do + it 'returns the value' do + entity = described_class.new(id: 1) + + entity.id.must_equal 1 + end + + it 'returns nil if not present in attributes' do + entity = described_class.new + + entity.id.must_equal nil + end + end + + describe 'accessors' do + it 'exposes accessors from schema' do + entity = described_class.new(name: 'Luca') + + entity.name.must_equal 'Luca' + end + + it 'raises error for unknown methods' do + entity = described_class.new + + exception = lambda do + entity.foo + end.must_raise(NoMethodError) + + exception.message.must_include "undefined method `foo'" + end + + it 'raises error when #attributes is invoked' do + entity = described_class.new + + exception = lambda do + entity.attributes + end.must_raise(NoMethodError) + + exception.message.must_include "private method `attributes' called for # 'bar') + + result.must_equal(foo: 'bar') + end + end + + describe '#attribute?' do + it 'always returns true' do + subject.attribute?(:foo).must_equal true + end + end + end + + describe 'with definition' do + let(:subject) do + described_class.new do + attribute :id, Hanami::Model::Types::Coercible::Int + end + end + + describe '#call' do + it 'processes attributes' do + result = subject.call(id: '1') + + result.must_equal(id: 1) + end + + it 'ignores unknown attributes' do + result = subject.call(foo: 'bar') + + result.must_equal({}) + end + end + + describe '#attribute?' do + it 'returns true for known attributes' do + subject.attribute?(:id).must_equal true + end + + it 'returns false for unknown attributes' do + subject.attribute?(:foo).must_equal false + end + end + end +end diff --git a/test/entity/schemaless_test.rb b/test/entity/schemaless_test.rb new file mode 100644 index 00000000..bc66931e --- /dev/null +++ b/test/entity/schemaless_test.rb @@ -0,0 +1,126 @@ +require 'test_helper' + +describe Hanami::Entity do + describe 'schemaless' do + let(:described_class) do + Class.new(Hanami::Entity) + end + + let(:input) do + Class.new do + def to_hash + Hash[a: 1] + end + end.new + end + + describe '#initialize' do + it 'can be instantiated without attributes' do + entity = described_class.new + + entity.must_be_kind_of(described_class) + end + + it 'accepts a hash' do + entity = described_class.new(foo: 1, 'bar' => 2) + + entity.foo.must_equal 1 + entity.bar.must_equal 2 + end + + it 'accepts object that implements #to_hash' do + entity = described_class.new(input) + + entity.a.must_equal 1 + end + + it 'freezes the intance' do + entity = described_class.new + + entity.must_be :frozen? + end + end + + describe '#id' do + it 'returns the value' do + entity = described_class.new(id: 1) + + entity.id.must_equal 1 + end + + it 'returns nil if not present in attributes' do + entity = described_class.new + + entity.id.must_equal nil + end + end + + describe 'accessors' do + it 'exposes accessors for given keys' do + entity = described_class.new(name: 'Luca') + + entity.name.must_equal 'Luca' + end + + it 'returns nil for unknown methods' do + entity = described_class.new + + entity.foo.must_equal nil + end + + it 'returns nil for #attributes' do + entity = described_class.new + + entity.attributes.must_equal nil + end + end + + describe '#to_h' do + it 'serializes attributes into hash' do + entity = described_class.new(foo: 1, 'bar' => { 'baz' => 2 }) + + entity.to_h.must_equal Hash[foo: 1, bar: { baz: 2 }] + end + + it 'must be an instance of ::Hash' do + entity = described_class.new + + entity.to_h.must_be_instance_of(::Hash) + end + + it 'prevents information escape' do + entity = described_class.new(a: [1, 2, 3]) + + entity.to_h[:a].reverse! + entity.a.must_equal([1, 2, 3]) + end + + it 'is aliased as #to_hash' do + entity = described_class.new(foo: 'bar') + + entity.to_h.must_equal entity.to_hash + end + end + + describe '#respond_to?' do + it 'returns ture for id' do + entity = described_class.new + + entity.must_respond_to(:id) + end + + it 'returns true for present keys' do + entity = described_class.new(foo: 1, 'bar' => 2) + + entity.must_respond_to(:foo) + entity.must_respond_to(:bar) + end + + it 'returns false for missing keys' do + entity = described_class.new + + entity.must_respond_to(:baz) + end + end + end +end diff --git a/test/entity_test.rb b/test/entity_test.rb index 47ac8ea2..afa52b21 100644 --- a/test/entity_test.rb +++ b/test/entity_test.rb @@ -1,367 +1,75 @@ require 'test_helper' +require 'ostruct' describe Hanami::Entity do - before do - class Car - include Hanami::Entity - end - - class Book - include Hanami::Entity - include Hanami::Entity::DirtyTracking - attributes :title, :author, :published, :tags - end - - class NonFictionBook < Book - attributes :price - end - - class CoolNonFictionBook < NonFictionBook - attributes :coolness - end - - class Camera - include Hanami::Entity - attr_accessor :analog - end - end - - after do - [:Car, :Book, :NonFictionBook, :CoolNonFictionBook, :Camera].each do |const| - Object.send(:remove_const, const) - end - end - - describe '.attributes' do - it 'defines attributes' do - Car.attributes :model - Car.attributes.must_equal Set.new([:id, :model]) - end - - it 'rejects existed instance methods' do - Car.attributes :object_id - Car.attributes.must_equal Set.new([:id]) - end - - describe 'params is array' do - it 'defines attributes' do - Car.attributes [:model] - Car.attributes.must_equal Set.new([:id, :model]) - end - end - end - - describe '.allowed_attribute_name?' do - it 'returns true if attrubute not defined' do - Car.allowed_attribute_name?(:model).must_equal true - end - - it 'returns false if attribute defined' do - Car.attributes :model - Car.allowed_attribute_name?(:model).must_equal false - end - - it 'returns false for reserved word' do - require "json" - Car.allowed_attribute_name?(:to_json).must_equal false - end - end - - describe '#initialize' do - describe 'with defined attributes' do - it 'accepts given attributes' do - book = Book.new(title: "A Lover's Discourse: Fragments", author: 'Roland Barthes', published: false) - - book.instance_variable_get(:@title).must_equal "A Lover's Discourse: Fragments" - book.instance_variable_get(:@author).must_equal 'Roland Barthes' - book.instance_variable_get(:@published).must_equal false - end - - it 'accepts given attributes as string keys' do - book = Book.new('title' => "A Lover's Discourse: Fragments", 'author' => 'Roland Barthes', 'published' => false) - - book.instance_variable_get(:@title).must_equal "A Lover's Discourse: Fragments" - book.instance_variable_get(:@author).must_equal 'Roland Barthes' - book.instance_variable_get(:@published).must_equal false - end - - it 'ignores unknown attributes' do - book = Book.new(unknown: 'x') - book.instance_variable_get(:@unknown).must_be_nil - end - - it 'accepts given attributes for subclass' do - book = NonFictionBook.new(title: 'Refactoring', author: 'Martin Fowler', published: false, price: 50) - - book.instance_variable_get(:@title).must_equal 'Refactoring' - book.instance_variable_get(:@author).must_equal 'Martin Fowler' - book.instance_variable_get(:@published).must_equal false - book.instance_variable_get(:@price).must_equal 50 - end - - it 'accepts given attributes for subclass of subclass' do - book = CoolNonFictionBook.new(title: 'Refactoring', author: 'Martin Fowler', published: false, price: 50, coolness: 'awesome') - - book.instance_variable_get(:@title).must_equal 'Refactoring' - book.instance_variable_get(:@author).must_equal 'Martin Fowler' - book.instance_variable_get(:@published).must_equal false - book.instance_variable_get(:@price).must_equal 50 - book.instance_variable_get(:@coolness).must_equal 'awesome' - end - - it "doesn't interfer with superclass attributes" do - book = CoolNonFictionBook.new(title: "Good Math", author: "Mark C. Chu-Carroll", published: false, coolness: true) - - book.instance_variable_get(:@title).must_equal 'Good Math' - book.instance_variable_get(:@author).must_equal 'Mark C. Chu-Carroll' - book.instance_variable_get(:@published).must_equal false - book.instance_variable_get(:@coolness).must_equal true - end - end - - describe 'with undefined attributes' do - it 'has default accessor for id' do - camera = Camera.new - camera.must_respond_to :id - camera.must_respond_to :id= - end - - it 'is able to initialize an entity without given attributes' do - camera = Camera.new - camera.analog.must_be_nil - end - - it 'is able to initialize an entity if it has the right accessors' do - camera = Camera.new(analog: true) - camera.analog.must_equal(true) - end - - it "ignores given attributes that don't correspond to known accessors" do - camera = Camera.new(digital: true) - camera.wont_respond_to :digital - camera.wont_respond_to :digital= - end - end + let(:described_class) do + Class.new(Hanami::Entity) end - describe 'accessors' do - it 'exposes getters for attributes' do - book = Book.new(title: 'High Fidelity') + describe 'equality' do + it 'returns true if same class and same id' do + entity1 = described_class.new(id: 1) + entity2 = described_class.new(id: 1) - book.title.must_equal 'High Fidelity' + assert entity1 == entity2, "Expected #{entity1.inspect} to equal #{entity2.inspect}" end - it 'exposes setters for attributes' do - book = Book.new - book.title = 'A Man' + it 'returns false if same class but different id' do + entity1 = described_class.new(id: 1) + entity2 = described_class.new(id: 1000) - book.instance_variable_get(:@title).must_equal 'A Man' - book.title.must_equal 'A Man' + refute entity1 == entity2, "Expected #{entity1.inspect} to NOT equal #{entity2.inspect}" end - it 'exposes accessor for id' do - book = Book.new - book.id.must_be_nil + it 'returns false if different class but same id' do + entity1 = described_class.new(id: 1) + entity2 = OpenStruct.new(id: 1) - book.id = 23 - book.id.must_equal 23 + refute entity1 == entity2, "Expected #{entity1.inspect} to NOT equal #{entity2.inspect}" end - end - - describe '#==' do - before do - @book1 = Book.new - @book1.id = 23 - - @book2 = Book.new - @book2.id = 23 - - @book3 = Book.new - @car = Car.new - end - - it 'returns true if they have the same class and id' do - @book1.must_equal @book2 - end - - it 'returns false if they have the same class but different id' do - @book1.wont_equal @book3 - end - - it 'returns false if they have different class' do - @book1.wont_equal @car - end - end - - describe '#to_h' do - before do - @book = Book.new(id: 100, title: 'Wuthering Heights', author: 'Emily Brontë', published: false) - end - - it 'returns an attributes hash' do - @book.to_h.must_equal({id: 100, title: 'Wuthering Heights', author: 'Emily Brontë', published: false, tags: nil}) - end - end - - describe '#attribute_names' do - before do - @book = Book.new(id: 100, title: 'Wuthering Heights', author: 'Emily Brontë', published: false) - end - - it 'returns an attribute names' do - @book.attribute_names.must_equal Set.new([:id, :title, :author, :published, :tags]) - end - end - describe "#inspect, #to_s" do - before do - @book = Book.new(id: 100, author: 'Emily Brontë', published: false) - end - - it 'returns all assigned attributes' do - @book.stub(:__id__, 70179622946220) do - @book.inspect.must_equal "#" - @book.to_s.must_equal "#" - end - end - end - - describe '#update' do - let(:book) { Book.new(id: nil, title: 'Wuthering Meadow', author: 'J. K. Rowling', published: true ) } - let(:attributes) { Hash[title: 'Wuthering Heights', author: 'Emily Brontë', published: false] } + it 'returns false if different class and different id' do + entity1 = described_class.new(id: 1) + entity2 = OpenStruct.new(id: 1000) - it 'updates the attributes' do - book.update(attributes) - book.title.must_equal 'Wuthering Heights' - book.author.must_equal 'Emily Brontë' - book.published.must_equal false + refute entity1 == entity2, "Expected #{entity1.inspect} to NOT equal #{entity2.inspect}" end - describe 'when update non-existing attribute' do - let(:attributes) { Hash[rating: '5.0'] } + it 'returns true when both the ids are nil' do + entity1 = described_class.new + entity2 = described_class.new - it 'raises error' do - exception = -> { book.update(attributes) }.must_raise(NoMethodError) - exception.message.must_include "undefined method `rating=' for" - end + assert entity1 == entity2, "Expected #{entity1.inspect} to equal #{entity2.inspect}" end end - describe 'dirty tracking' do - describe "initialization" do - describe "without identity" do - describe "without attributes" do - it "hasn't dirty state" do - book = Book.new - - book.changed?.must_equal false - book.changed_attributes.must_equal({}) - end - end - - describe "with attributes" do - it "has dirty state" do - book = Book.new({title: 'Crime and Punishment', author: 'Fyodor Dostoyevsky'}) - - book.changed?.must_equal false - book.changed_attributes.must_equal({}) - end - end - end - - describe "with identity" do - describe "with attributes" do - it "has't dirty state" do - book = Book.new(id: 1, title: 'Crime and Punishment', author: 'Fyodor Dostoyevsky') - - book.changed?.must_equal false - book.changed_attributes.must_equal({}) - end - end - end - end - - describe "attr writer" do - it "tracks dirty state" do - book = Book.new - book.changed?.must_equal false - - book.title = 'War and Peace' - book.author = nil - book.changed?.must_equal true - - book.changed_attributes.must_equal(title: nil) - end + describe '#hash' do + it 'returns predictable object hashing' do + entity1 = described_class.new(id: 1) + entity2 = described_class.new(id: 1) - it "doesn't track dirty state for unchanged values" do - book = Book.new(title: "Fight Club", author: "Chuck Palahniuk") - book.title = "Choke" - book.author = "Chuck Palahniuk" - - book.changed?.must_equal true - book.changed_attributes.must_equal(title: "Fight Club") - end + assert entity1.hash == entity2.hash, "Expected #{entity1.hash} to equal #{entity2.hash}" end - describe "#update" do - it "tracks dirty state" do - book = Book.new - book.changed?.must_equal false - - book.update(title: 'War and Peace') - book.changed?.must_equal true - - book.changed_attributes.must_equal(title: nil) - end + it 'returns different object hash for same class but different id' do + entity1 = described_class.new(id: 1) + entity2 = described_class.new(id: 1000) - it "doesn't track dirty state for unchanged values" do - book = Book.new(title: "Fight Club", author: "Chuck Palahniuk") - book.update(title: "Choke", author: "Chuck Palahniuk") - - book.changed?.must_equal true - book.changed_attributes.must_equal(title: "Fight Club") - end + refute entity1.hash == entity2.hash, "Expected #{entity1.hash} to NOT equal #{entity2.hash}" end - describe "#changed_attributes" do - it "prevents data escape" do - book = Book.new title: 'Master and Margarita' - book.title = 'Crime and Punishment' - - book.changed_attributes.must_equal({title: 'Master and Margarita'}) - book.changed_attributes.delete(:title) - book.changed_attributes.must_equal({title: 'Master and Margarita'}) - end - - it "track changes after inplace modification" do - book = Book.new(title: "Master and margarita", tags: ["rus", "classic"]) - book.tags << "fantasy" - - book.changed?.must_equal true - book.changed_attributes.must_equal({tags: ["rus", "classic"]}) - end - - it "show all inplace changes" do - book = Book.new title: 'Master and Margarita', tags: %w(rus classic), author: 'Dostoyevsky' - book.tags << "fantasy" + it 'returns different object hash for different class but same id' do + entity1 = described_class.new(id: 1) + entity2 = Class.new(Hanami::Entity).new(id: 1) - book.changed?.must_equal true - book.changed_attributes.must_equal({tags: %w(rus classic)}) - book.author = 'Bulgakov' - book.changed_attributes.must_equal({tags: %w(rus classic), author: 'Dostoyevsky'}) - end + refute entity1.hash == entity2.hash, "Expected #{entity1.hash} to NOT equal #{entity2.hash}" end - describe "inheritance" do - it "make dirty tracking available to descendants" do - book = NonFictionBook.new - book.changed?.must_equal false - end + it 'returns different object hash for different class and different id' do + entity1 = described_class.new(id: 1) + entity2 = Class.new(Hanami::Entity).new(id: 2) - it "doesn't interfer with other classes" do - camera = Camera.new - camera.wont_respond_to(:changed?) - end + refute entity1.hash == entity2.hash, "Expected #{entity1.hash} to NOT equal #{entity2.hash}" end end end diff --git a/test/error_test.rb b/test/error_test.rb deleted file mode 100644 index 2eadd7a3..00000000 --- a/test/error_test.rb +++ /dev/null @@ -1,29 +0,0 @@ -require 'test_helper' -require_relative '../lib/hanami/model/migrator.rb' - -describe Hanami::Model::Error do - it 'inherits from ::StandardError' do - Hanami::Model::Error.superclass.must_equal StandardError - end - - it 'is parent to all custom exception' do - Hanami::Model::NonPersistedEntityError.superclass.must_equal Hanami::Model::Error - Hanami::Model::InvalidMappingError.superclass.must_equal Hanami::Model::Error - Hanami::Model::InvalidCommandError.superclass.must_equal Hanami::Model::Error - Hanami::Model::CheckConstraintViolationError.superclass.must_equal Hanami::Model::Error - Hanami::Model::ForeignKeyConstraintViolationError.superclass.must_equal Hanami::Model::Error - Hanami::Model::NotNullConstraintViolationError.superclass.must_equal Hanami::Model::Error - Hanami::Model::UniqueConstraintViolationError.superclass.must_equal Hanami::Model::Error - Hanami::Model::InvalidQueryError.superclass.must_equal Hanami::Model::Error - Hanami::Model::Adapters::DatabaseAdapterNotFound.superclass.must_equal Hanami::Model::Error - Hanami::Model::Adapters::NotSupportedError.superclass.must_equal Hanami::Model::Error - Hanami::Model::Adapters::DisconnectedAdapterError.superclass.must_equal Hanami::Model::Error - Hanami::Model::Adapters::NoAdapterError.superclass.must_equal Hanami::Model::Error - Hanami::Model::Config::AdapterNotFound.superclass.must_equal Hanami::Model::Error - Hanami::Model::NoMappingError.superclass.must_equal Hanami::Model::Error - Hanami::Model::Mapping::UnmappedCollectionError.superclass.must_equal Hanami::Model::Error - Hanami::Model::Mapping::EntityNotFound.superclass.must_equal Hanami::Model::Error - Hanami::Model::Mapping::RepositoryNotFound.superclass.must_equal Hanami::Model::Error - Hanami::Model::MigrationError.superclass.must_equal Hanami::Model::Error - end -end diff --git a/test/fixtures.rb b/test/fixtures.rb deleted file mode 100644 index a76c545b..00000000 --- a/test/fixtures.rb +++ /dev/null @@ -1,196 +0,0 @@ -require 'sequel/extensions/pg_array' - -class User - include Hanami::Entity - attributes :name, :age, :created_at, :updated_at -end - -class Article - include Hanami::Entity - include Hanami::Entity::DirtyTracking - attributes :user_id, :unmapped_attribute, :title, :comments_count, :tags -end - -class Repository - include Hanami::Entity - attributes :id, :name -end - -class CustomUserRepository - include Hanami::Repository -end - -class UserRepository - include Hanami::Repository -end - -class SubclassedUserRepository < UserRepository -end - -class UnmappedRepository - include Hanami::Repository -end - -class ArticleRepository - include Hanami::Repository - - def rank - query do - desc(:comments_count) - end - end - - def by_user(user) - query do - where(user_id: user.id) - end - end - - def not_by_user(user) - exclude by_user(user) - end - - def rank_by_user(user) - rank.by_user(user) - end - - def reset_comments_count - execute("UPDATE articles SET comments_count = '0'") - end - - def find_raw - fetch("SELECT * FROM articles") - end - - def each_titles - result = [] - - fetch("SELECT s_title FROM articles") do |article| - result << article[:s_title] - end - - result - end - - def map_titles - fetch("SELECT s_title FROM articles").map do |article| - article[:s_title] - end - end -end - -class Robot - include Hanami::Entity - attributes :name, :build_date -end - -class RobotRepository - include Hanami::Repository -end - -[SQLITE_CONNECTION_STRING, POSTGRES_CONNECTION_STRING].each do |conn_string| - require 'hanami/utils/io' - - Hanami::Utils::IO.silence_warnings do - DB = Sequel.connect(conn_string) - end - - DB.create_table :users do - primary_key :id - Integer :country_id - String :name - Integer :age - DateTime :created_at - DateTime :updated_at - end - - DB.create_table :articles do - primary_key :_id - Integer :user_id - String :s_title - String :comments_count # Not an error: we're testing String => Integer coercion - String :umapped_column - - if conn_string.match(/postgres/) - column :tags, 'text[]' - else - column :tags, String - end - end - - DB.create_table :devices do - primary_key :id - Integer :u_id # user_id: legacy schema simulation - end - - DB.create_table :orders do - primary_key :id - Integer :user_id - Integer :total - end - - DB.create_table :ages do - primary_key :id - Integer :value - String :label - end - - DB.create_table :countries do - primary_key :country_id - String :code - end - - if conn_string.match(/postgres/) - DB.run('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') - DB.create_table :robots do - column :id, :uuid, :default => Sequel.function(:uuid_generate_v1mc), primary_key: true - String :name - DateTime :build_date - end - end -end - -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 - -#FIXME this should be passed by the framework internals. -MAPPER = Hanami::Model::Mapper.new do - collection :users do - entity User - - attribute :id, Integer - attribute :name, String - attribute :age, Integer - attribute :created_at, DateTime - attribute :updated_at, DateTime - end - - collection :articles do - entity Article - - attribute :id, Integer, as: :_id - attribute :user_id, Integer - attribute :title, String, as: 's_title' - attribute :comments_count, Integer - attribute :tags, PGArray - - identity :_id - end - - collection :robots do - entity Robot - - attribute :id, String - attribute :name, String - attribute :build_date, DateTime - end -end - -MAPPER.load! diff --git a/test/fixtures/20150611165922_create_authors.rb b/test/fixtures/20150611165922_create_authors.rb deleted file mode 100644 index be78f063..00000000 --- a/test/fixtures/20150611165922_create_authors.rb +++ /dev/null @@ -1,10 +0,0 @@ -# This file is intentionally placed outside of test/fixtures/migrations to -# simulate a pending migration. -Hanami::Model.migration do - change do - create_table :authors do - primary_key :id - column :name, String - end - end -end diff --git a/test/fixtures/database_migrations/20150612081248_column_types.rb b/test/fixtures/database_migrations/20150612081248_column_types.rb index 2b14a256..079a958b 100644 --- a/test/fixtures/database_migrations/20150612081248_column_types.rb +++ b/test/fixtures/database_migrations/20150612081248_column_types.rb @@ -1,39 +1,147 @@ Hanami::Model.migration do change do - create_table :column_types do - column :integer1, Integer - column :integer2, :integer - column :integer3, 'integer' - - column :string1, String - column :string2, :string - column :string3, 'string' - column :string4, 'varchar(3)' - - column :string5, String, size: 50 - column :string6, String, fixed: true - column :string7, String, fixed: true, size: 64 - column :string8, String, text: true - - column :file1, File - column :file2, 'blob' - - column :number1, Fixnum - column :number2, Bignum - column :number3, Float - column :number4, BigDecimal - column :number5, BigDecimal, size: 10 - column :number6, BigDecimal, size: [10,2] - column :number7, Numeric - - column :date1, Date - column :date2, DateTime - - column :time1, Time - column :time2, Time, only_time: true - - column :boolean1, TrueClass - column :boolean2, FalseClass + case Database.engine + when :sqlite + create_table :column_types do + column :integer1, Integer + column :integer2, :integer + column :integer3, 'integer' + + column :string1, String + column :string2, :string + column :string3, 'string' + column :string4, 'varchar(3)' + + column :string5, String, size: 50 + column :string6, String, fixed: true + column :string7, String, fixed: true, size: 64 + column :string8, String, text: true + + column :file1, File + column :file2, 'blob' + + column :number1, Fixnum # rubocop:disable Lint/UnifiedInteger + column :number2, :Bignum + column :number3, Float + column :number4, BigDecimal + column :number5, BigDecimal, size: 10 + column :number6, BigDecimal, size: [10, 2] + column :number7, Numeric + + column :date1, Date + column :date2, DateTime + + column :time1, Time + column :time2, Time, only_time: true + + column :boolean1, TrueClass + column :boolean2, FalseClass + end + when :postgresql + execute 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"' + execute "CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy');" + execute %{ + CREATE TYPE inventory_item AS ( + name text, + supplier_id integer, + price numeric + ); + } + + create_table :column_types do + column :integer1, Integer + column :integer2, :integer + column :integer3, 'integer' + + column :string1, String + column :string2, 'text' + column :string3, 'character varying(1)' + column :string4, 'varchar(2)' + column :string5, 'character(3)' + column :string6, 'char(4)' + + column :string7, String, size: 50 + column :string8, String, fixed: true + column :string9, String, fixed: true, size: 64 + column :string10, String, text: true + + column :file1, File + column :file2, 'bytea' + + column :number1, Fixnum # rubocop:disable Lint/UnifiedInteger + column :number2, :Bignum + column :number3, Float + column :number4, BigDecimal + column :number5, BigDecimal, size: 10 + column :number6, BigDecimal, size: [10, 2] + column :number7, Numeric + + column :date1, Date + column :date2, DateTime + + column :time1, Time + column :time2, Time, only_time: true + + column :boolean1, TrueClass + column :boolean2, FalseClass + + column :array1, 'integer[]' + column :array2, 'integer[3]' + column :array3, 'text[][]' + + column :money1, 'money' + + column :enum1, 'mood' + + column :geometric1, 'point' + column :geometric2, 'line' + column :geometric3, 'circle', default: '<(15,15), 1>' + + column :net1, 'cidr', default: '192.168/24' + + column :uuid1, 'uuid', default: Hanami::Model::Sql.function(:uuid_generate_v4) + + column :xml1, 'xml' + + column :json1, 'json' + column :json2, 'jsonb' + + column :composite1, 'inventory_item', default: Hanami::Model::Sql.literal("ROW('fuzzy dice', 42, 1.99)") + end + when :mysql + create_table :column_types do + column :integer1, Integer + column :integer2, :integer + column :integer3, 'integer' + + column :string1, String + column :string2, 'varchar(3)' + + column :string5, String, size: 50 + column :string6, String, fixed: true + column :string7, String, fixed: true, size: 64 + column :string8, String, text: true + + column :file1, File + column :file2, 'blob' + + column :number1, Fixnum # rubocop:disable Lint/UnifiedInteger + column :number2, :Bignum + column :number3, Float + column :number4, BigDecimal + column :number5, BigDecimal, size: 10 + column :number6, BigDecimal, size: [10, 2] + column :number7, Numeric + + column :date1, Date + column :date2, DateTime + + column :time1, Time + column :time2, Time, only_time: true + + column :boolean1, TrueClass + column :boolean2, FalseClass + end end end end diff --git a/test/fixtures/database_migrations/20150612084656_default_values.rb b/test/fixtures/database_migrations/20150612084656_default_values.rb index 10a99df5..1c0bb791 100644 --- a/test/fixtures/database_migrations/20150612084656_default_values.rb +++ b/test/fixtures/database_migrations/20150612084656_default_values.rb @@ -1,18 +1,51 @@ Hanami::Model.migration do change do - create_table :default_values do - column :a, Integer, default: 23 - column :b, String, default: "Hanami" - column :c, Fixnum, default: -1 - column :d, Bignum, default: 0 - column :e, Float, default: 3.14 - column :f, BigDecimal, default: 1.0 - column :g, Numeric, default: 943943 - column :h, Date, default: Date.new - column :i, DateTime, default: DateTime.now - column :j, Time, default: Time.now - column :k, TrueClass, default: true - column :l, FalseClass, default: false + case Database.engine + when :sqlite + create_table :default_values do + column :a, Integer, default: 23 + column :b, String, default: 'Hanami' + column :c, Fixnum, default: -1 # rubocop:disable Lint/UnifiedInteger + column :d, :Bignum, default: 0 + column :e, Float, default: 3.14 + column :f, BigDecimal, default: 1.0 + column :g, Numeric, default: 943_943 + column :h, Date, default: Date.new + column :i, DateTime, default: DateTime.now + column :j, Time, default: Time.now + column :k, TrueClass, default: true + column :l, FalseClass, default: false + end + when :postgresql + create_table :default_values do + column :a, Integer, default: 23 + column :b, String, default: 'Hanami' + column :c, Fixnum, default: -1 # rubocop:disable Lint/UnifiedInteger + column :d, :Bignum, default: 0 + column :e, Float, default: 3.14 + column :f, BigDecimal, default: 1.0 + column :g, Numeric, default: 943_943 + column :h, Date, default: 'now' + column :i, DateTime, default: DateTime.now + column :j, Time, default: Time.now + column :k, TrueClass, default: true + column :l, FalseClass, default: false + end + when :mysql + create_table :default_values do + column :a, Integer, default: 23 + column :b, String, default: 'Hanami' + column :c, Fixnum, default: -1 # rubocop:disable Lint/UnifiedInteger + column :d, :Bignum, default: 0 + column :e, Float, default: 3.14 + column :f, BigDecimal, default: 1.0 + column :g, Numeric, default: 943_943 + column :h, Date # , default: 'CURRENT_TIMESTAMP' + column :i, DateTime, default: DateTime.now + column :j, Time, default: Time.now + column :k, TrueClass, default: true + column :l, FalseClass, default: false + end end end end diff --git a/test/fixtures/database_migrations/20150612122233_table_constraints.rb b/test/fixtures/database_migrations/20150612122233_table_constraints.rb index 105b5cce..3a148cc5 100644 --- a/test/fixtures/database_migrations/20150612122233_table_constraints.rb +++ b/test/fixtures/database_migrations/20150612122233_table_constraints.rb @@ -1,11 +1,30 @@ Hanami::Model.migration do change do - create_table :table_constraints do - column :age, Integer - constraint(:age_constraint) { age > 18 } + case ENV['HANAMI_DATABASE_TYPE'] + when 'sqlite' + create_table :table_constraints do + column :age, Integer + constraint(:age_constraint) { age > 18 } - column :role, String - check %(role IN("contributor", "manager", "owner")) + column :role, String + check %(role IN("contributor", "manager", "owner")) + end + when 'postgresql' + create_table :table_constraints do + column :age, Integer + constraint(:age_constraint) { age > 18 } + + column :role, String + check %(role IN('contributor', 'manager', 'owner')) + end + when 'mysql' + create_table :table_constraints do + column :age, Integer + constraint(:age_constraint) { age > 18 } + + column :role, String + check %(role IN("contributor", "manager", "owner")) + end end end end diff --git a/test/fixtures/database_migrations/20150612124205_table_alterations.rb b/test/fixtures/database_migrations/20150612124205_table_alterations.rb index 334414bc..5e19c750 100644 --- a/test/fixtures/database_migrations/20150612124205_table_alterations.rb +++ b/test/fixtures/database_migrations/20150612124205_table_alterations.rb @@ -1,36 +1,72 @@ Hanami::Model.migration do change do - create_table :songs do - column :title, String - column :useless, String + case ENV['HANAMI_DATABASE_TYPE'] + when 'sqlite' + create_table :songs do + column :title, String + column :useless, String - foreign_key :artist_id, :artists - index :artist_id + foreign_key :artist_id, :artists + index :artist_id - add_constraint(:useless_min_length) { char_length(useless) > 2 } - end + add_constraint(:useless_min_length) { char_length(useless) > 2 } + end + + alter_table :songs do + add_primary_key :id + + add_column :downloads_count, Integer + set_column_type :useless, File + + rename_column :title, :primary_title + set_column_default :primary_title, 'Unknown title' + + # add_index :album_id + # drop_index :artist_id + + # add_foreign_key :album_id, :albums, on_delete: :cascade + # drop_foreign_key :artist_id + + # add_constraint(:title_min_length) { char_length(title) > 2 } + + # add_unique_constraint [:album_id, :title] + + drop_constraint :useless_min_length + drop_column :useless + end + when 'postgresql' + create_table :songs do + column :title, String + column :useless, String + + foreign_key :artist_id, :artists + index :artist_id + + # add_constraint(:useless_min_length) { char_length(useless) > 2 } + end - alter_table :songs do - add_primary_key :id + alter_table :songs do + add_primary_key :id - add_column :downloads_count, Integer - set_column_type :useless, File + add_column :downloads_count, Integer + # set_column_type :useless, File - rename_column :title, :primary_title - set_column_default :primary_title, 'Unknown title' + rename_column :title, :primary_title + set_column_default :primary_title, 'Unknown title' - # add_index :album_id - # drop_index :artist_id + # add_index :album_id + # drop_index :artist_id - # add_foreign_key :album_id, :albums, on_delete: :cascade - # drop_foreign_key :artist_id + # add_foreign_key :album_id, :albums, on_delete: :cascade + # drop_foreign_key :artist_id - # add_constraint(:title_min_length) { char_length(title) > 2 } + # add_constraint(:title_min_length) { char_length(title) > 2 } - # add_unique_constraint [:album_id, :title] + # add_unique_constraint [:album_id, :title] - drop_constraint :useless_min_length - drop_column :useless + # drop_constraint :useless_min_length + drop_column :useless + end end end end diff --git a/test/fixtures/database_migrations/20160830094800_create_users.rb b/test/fixtures/database_migrations/20160830094800_create_users.rb new file mode 100644 index 00000000..8ff31616 --- /dev/null +++ b/test/fixtures/database_migrations/20160830094800_create_users.rb @@ -0,0 +1,20 @@ +Hanami::Model.migration do + change do + drop_table? :users + create_table? :users do + primary_key :id + column :name, String + column :email, String + column :age, Integer, null: false, default: 19 + column :comments_count, Integer, null: false, default: 0 + column :active, TrueClass, null: false, default: true + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + + check { age > 18 } + constraint(:comments_count_constraint) { comments_count >= 0 } + end + + add_index :users, :email, unique: true + end +end diff --git a/test/fixtures/database_migrations/20160830094851_create_authors.rb b/test/fixtures/database_migrations/20160830094851_create_authors.rb new file mode 100644 index 00000000..6395f63c --- /dev/null +++ b/test/fixtures/database_migrations/20160830094851_create_authors.rb @@ -0,0 +1,11 @@ +Hanami::Model.migration do + change do + drop_table? :authors + create_table? :authors do + primary_key :id + column :name, String + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + end + end +end diff --git a/test/fixtures/database_migrations/20160830094941_create_books.rb b/test/fixtures/database_migrations/20160830094941_create_books.rb new file mode 100644 index 00000000..d14f9722 --- /dev/null +++ b/test/fixtures/database_migrations/20160830094941_create_books.rb @@ -0,0 +1,13 @@ +Hanami::Model.migration do + change do + drop_table? :books + create_table? :books do + primary_key :id + foreign_key :author_id, :authors, on_delete: :cascade + column :title, String + column :on_sale, TrueClass, null: false, default: false + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + end + end +end diff --git a/test/fixtures/database_migrations/20160830095033_create_t_operator.rb b/test/fixtures/database_migrations/20160830095033_create_t_operator.rb new file mode 100644 index 00000000..b75167b2 --- /dev/null +++ b/test/fixtures/database_migrations/20160830095033_create_t_operator.rb @@ -0,0 +1,9 @@ +Hanami::Model.migration do + change do + drop_table? :t_operator + create_table? :t_operator do + primary_key :operator_id + column :s_name, String + end + end +end diff --git a/test/fixtures/database_migrations/20160905125728_create_source_files.rb b/test/fixtures/database_migrations/20160905125728_create_source_files.rb new file mode 100644 index 00000000..78ac8514 --- /dev/null +++ b/test/fixtures/database_migrations/20160905125728_create_source_files.rb @@ -0,0 +1,20 @@ +Hanami::Model.migration do + change do + case Database.engine + when :postgresql + create_table :source_files do + column :id, 'uuid', primary_key: true, default: Hanami::Model::Sql.function(:uuid_generate_v4) + column :name, String, null: false + column :languages, 'text[]' + column :metadata, 'jsonb', null: false + column :content, File, null: false + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + end + else + create_table :source_files do + primary_key :id + end + end + end +end diff --git a/test/fixtures/database_migrations/20160909150704_create_avatars.rb b/test/fixtures/database_migrations/20160909150704_create_avatars.rb new file mode 100644 index 00000000..67880a12 --- /dev/null +++ b/test/fixtures/database_migrations/20160909150704_create_avatars.rb @@ -0,0 +1,11 @@ +Hanami::Model.migration do + change do + drop_table? :avatars + create_table? :avatars do + primary_key :id + foreign_key :user_id, :users, on_delete: :cascade, null: false + + column :url, String + end + end +end diff --git a/test/fixtures/database_migrations/20161104143844_create_wharehouses.rb b/test/fixtures/database_migrations/20161104143844_create_wharehouses.rb new file mode 100644 index 00000000..c472f3fc --- /dev/null +++ b/test/fixtures/database_migrations/20161104143844_create_wharehouses.rb @@ -0,0 +1,12 @@ +Hanami::Model.migration do + change do + drop_table? :wharehouses + create_table? :wharehouses do + primary_key :id + column :name, String + column :code, String + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + end + end +end diff --git a/test/fixtures/empty_migrations/.gitkeep b/test/fixtures/empty_migrations/.gitkeep index fb129429..e69de29b 100644 --- a/test/fixtures/empty_migrations/.gitkeep +++ b/test/fixtures/empty_migrations/.gitkeep @@ -1 +0,0 @@ -# gitkeep diff --git a/test/fixtures/mapping.rb b/test/fixtures/mapping.rb deleted file mode 100644 index 8a3039ec..00000000 --- a/test/fixtures/mapping.rb +++ /dev/null @@ -1,6 +0,0 @@ -collection :users do - entity User - - attribute :id, Integer - attribute :name, String -end diff --git a/test/fixtures/migrations/20150610141017_add_price_to_books.rb b/test/fixtures/migrations/20150610141017_add_price_to_books.rb deleted file mode 100644 index bb09e55c..00000000 --- a/test/fixtures/migrations/20150610141017_add_price_to_books.rb +++ /dev/null @@ -1,9 +0,0 @@ -Hanami::Model.migration do - up do - add_column :books, :price, 'integer', default: 100 - end - - down do - drop_column :books, :price - end -end diff --git a/test/fixtures/migrations/20150610133853_create_books.rb b/test/fixtures/migrations/20160831073534_create_reviews.rb similarity index 71% rename from test/fixtures/migrations/20150610133853_create_books.rb rename to test/fixtures/migrations/20160831073534_create_reviews.rb index bfba9ab5..a934c6d8 100644 --- a/test/fixtures/migrations/20150610133853_create_books.rb +++ b/test/fixtures/migrations/20160831073534_create_reviews.rb @@ -1,12 +1,12 @@ Hanami::Model.migration do up do - create_table :books do + create_table :reviews do primary_key :id column :title, String, null: false end end down do - drop_table :title + drop_table :reviews end end diff --git a/test/fixtures/migrations/20160831090612_add_rating_to_reviews.rb b/test/fixtures/migrations/20160831090612_add_rating_to_reviews.rb new file mode 100644 index 00000000..6c65a540 --- /dev/null +++ b/test/fixtures/migrations/20160831090612_add_rating_to_reviews.rb @@ -0,0 +1,9 @@ +Hanami::Model.migration do + up do + add_column :reviews, :rating, 'integer', default: 0 + end + + down do + drop_column :reviews, :rating + end +end diff --git a/test/integration/associations/has_many_test.rb b/test/integration/associations/has_many_test.rb new file mode 100644 index 00000000..bc31d2cb --- /dev/null +++ b/test/integration/associations/has_many_test.rb @@ -0,0 +1,182 @@ +require 'test_helper' + +describe 'Associations (has_many)' do + it "returns nil if association wasn't preloaded" do + repository = AuthorRepository.new + author = repository.create(name: 'L') + found = repository.find(author.id) + + found.books.must_equal nil + end + + it 'preloads associated records' do + repository = AuthorRepository.new + + author = repository.create(name: 'Umberto Eco') + book = BookRepository.new.create(author_id: author.id, title: 'Foucault Pendulum') + + found = repository.find_with_books(author.id) + + found.must_equal author + found.books.must_equal [book] + end + + it 'creates an object with a collection of associated objects' + # it 'creates an object with a collection of associated objects' do + # repository = AuthorRepository.new + # author = repository.create_with_books(name: 'Henry Thoreau', books: [{ title: 'Walden' }]) + + # author.name.must_equal 'Fowler' + # end + + ############################################################################## + # OPERATIONS # + ############################################################################## + + ## + # ADD + # + it 'adds an object to the collection' do + repository = AuthorRepository.new + + author = repository.create(name: 'Alexandre Dumas') + book = repository.add_book(author, title: 'The Count of Monte Cristo') + + book.id.wont_be_nil + book.title.must_equal 'The Count of Monte Cristo' + book.author_id.must_equal author.id + end + + ## + # REMOVE + # + it 'removes an object from the collection' + # it 'removes an object from the collection' do + # repository = AuthorRepository.new + # books = BookRepository.new + + # # Book under test + # author = repository.create(name: 'Douglas Adams') + # book = books.create(author_id: author.id, title: "The Hitchhiker's Guide to the Galaxy") + + # # Different book + # a = repository.create(name: 'William Finnegan') + # b = books.create(author_id: a.id, title: 'Barbarian Days: A Surfing Life') + + # repository.remove_book(author, book.id) + + # # Check the book under test has removed foreign key + # found_book = books.find(book.id) + # found_book.wont_be_nil + # found_book.author_id.must_be_nil + + # found_author = repository.find_with_books(author.id) + # found_author.books.map(&:id).wont_include(found_book.id) + + # # Check that the other book was left untouched + # found_b = books.find(b.id) + # found_b.author_id.must_equal(a.id) + # end + + ## + # TO_A + # + it 'returns an array of books' do + repository = AuthorRepository.new + books = BookRepository.new + + author = repository.create(name: 'Nikolai Gogol') + expected = books.create(author_id: author.id, title: 'Dead Souls') + expected.must_be_instance_of(Book) + + actual = repository.books_for(author).to_a + actual.must_equal [expected] + end + + ## + # EACH + # + it 'iterates through the books' do + repository = AuthorRepository.new + books = BookRepository.new + + author = repository.create(name: 'José Saramago') + expected = books.create(author_id: author.id, title: 'The Cave') + + actual = [] + repository.books_for(author).each do |book| + book.must_be_instance_of(Book) + actual << book + end + + actual.must_equal [expected] + end + + ## + # MAP + # + it 'iterates through the books and returns an array' do + repository = AuthorRepository.new + books = BookRepository.new + + author = repository.create(name: 'José Saramago') + expected = books.create(author_id: author.id, title: 'The Cave') + expected.must_be_instance_of(Book) + + actual = repository.books_for(author).map { |book| book } + actual.must_equal [expected] + end + + ## + # COUNT + # + it 'returns the count of the associated books' do + repository = AuthorRepository.new + books = BookRepository.new + + author = repository.create(name: 'Fyodor Dostoevsky') + books.create(author_id: author.id, title: 'Crime and Punishment') + books.create(author_id: author.id, title: 'The Brothers Karamazov') + + repository.books_count(author).must_equal(2) + end + + it 'returns the count of on sale associated books' do + repository = AuthorRepository.new + books = BookRepository.new + + author = repository.create(name: 'Steven Pinker') + books.create(author_id: author.id, title: 'The Sense of Style', on_sale: true) + + repository.on_sales_books_count(author).must_equal(1) + end + + ## + # DELETE + # + it 'deletes all the books' do + repository = AuthorRepository.new + books = BookRepository.new + + author = repository.create(name: 'Grazia Deledda') + book = books.create(author_id: author.id, title: 'Reeds In The Wind') + + repository.delete_books(author) + + books.find(book.id).must_be_nil + end + + it 'deletes scoped books' do + repository = AuthorRepository.new + books = BookRepository.new + + author = repository.create(name: 'Harper Lee') + book = books.create(author_id: author.id, title: 'To Kill A Mockingbird') + on_sale = books.create(author_id: author.id, title: 'Go Set A Watchman', on_sale: true) + + repository.delete_on_sales_books(author) + + books.find(book.id).must_equal(book) + books.find(on_sale.id).must_be_nil + end +end diff --git a/test/integration/configuration_test.rb b/test/integration/configuration_test.rb deleted file mode 100644 index 8bb1f33c..00000000 --- a/test/integration/configuration_test.rb +++ /dev/null @@ -1,67 +0,0 @@ -require 'test_helper' - -describe 'Configuration DSL' do - before do - Hanami::Model.configure do - adapter type: :memory, uri: 'memory://localhost' - - mapping do - collection :users do - entity User - repository UserRepository - - attribute :id, Integer - attribute :name, String - end - end - end - - Hanami::Model.load! - end - - after do - Hanami::Model.unload! - end - - describe 'when creating new user' do - before do - @user = User.new(name: 'Trung') - end - - it 'add the entity to repositories' do - @user_counter = UserRepository.new.all.size - - @user = UserRepository.new.create(@user) - - users = UserRepository.new.all - users.size.must_equal(@user_counter + 1) - users.first.must_equal(@user) - end - end - - describe "when a repository isn't mapped" do - it 'raises an error when try to use it' do - exception = -> { UnmappedRepository.new.find(1) }.must_raise(Hanami::Model::Adapters::NoAdapterError) - exception.message.must_equal( - "Cannot invoke `find' on repository. "\ - "Please check if `adapter' and `mapping' are set, "\ - "and that you call `.load!' on the configuration." - ) - end - end - - describe 'when mapping is not set' do - before do - Hanami::Model.unload! - - Hanami::Model.configure do - adapter type: :memory, uri: 'memory://localhost' - end - end - - it 'raises an error when try to use it' do - exception = -> { Hanami::Model.load! }.must_raise(Hanami::Model::NoMappingError) - exception.message.must_equal("Mapping is missing. Please check your framework configuration.") - end - end -end diff --git a/test/integration/migration/memory_test.rb b/test/integration/migration/memory_test.rb deleted file mode 100644 index 1dba174b..00000000 --- a/test/integration/migration/memory_test.rb +++ /dev/null @@ -1,79 +0,0 @@ -require 'test_helper' -require 'hanami/model/migrator' - -describe 'Memory Database Migration' do - let(:adapter_prefix) { 'jdbc:' if Hanami::Utils.jruby? } - - # Unfornatelly we need to explicitly include `::memory:` for JDBC - # https://github.com/jeremyevans/sequel/blob/master/lib/sequel/adapters/jdbc/sqlite.rb#L61 - # - let(:adapter_sufix) { Hanami::Utils.jruby? ? ':memory:' : '/' } - - before do - Hanami::Model.unload! - end - - after do - Hanami::Model::Migrator.drop rescue nil - Hanami::Model.unload! - end - - describe 'SQLite In Memory' do - before do - @uri = uri = "#{ adapter_prefix }sqlite:#{ adapter_sufix }" - - Hanami::Model.configure do - adapter type: :sql, uri: uri - migrations __dir__ + '/../../fixtures/migrations' - end - end - - after(:each) do - SQLite3::Database.new(@uri) { |db| db.close } if File.exist?(@uri) - end - - describe "create" do - it "does nothing" do - Hanami::Model::Migrator.create - - connection = Sequel.connect(@uri) - connection.tables.must_be :empty? - end - end - - describe "drop" do - before do - Hanami::Model::Migrator.create - end - - it "does nothing" do - Hanami::Model::Migrator.drop - - connection = Sequel.connect(@uri) - connection.tables.must_be :empty? - end - end - - describe "migrate" do - before do - Hanami::Model::Migrator.create - end - - describe "when no migrations" do - before do - @migrations_root = migrations_root = Pathname.new(__dir__ + '/../../fixtures/empty_migrations') - - Hanami::Model.configure do - migrations migrations_root - end - - Hanami::Model::Migrator.create - end - - it "it doesn't alter database" do - Hanami::Model::Migrator.migrate - end - end - end - end -end diff --git a/test/integration/migration/mysql.rb b/test/integration/migration/mysql.rb new file mode 100644 index 00000000..d7b685dd --- /dev/null +++ b/test/integration/migration/mysql.rb @@ -0,0 +1,463 @@ +describe 'MySQL' do + before do + @schema = Pathname.new("#{__dir__}/../../../tmp/schema.sql").expand_path + @connection = Sequel.connect(ENV['HANAMI_DATABASE_URL']) + + Hanami::Model::Migrator::Adapter.for(Hanami::Model.configuration).dump + end + + describe 'columns' do + it 'defines column types' do + table = @connection.schema(:column_types) + + name, options = table[0] + name.must_equal :integer1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal false + + name, options = table[1] + name.must_equal :integer2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal false + + name, options = table[2] + name.must_equal :integer3 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal false + + name, options = table[3] + name.must_equal :string1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[4] + name.must_equal :string2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(3)' + options.fetch(:primary_key).must_equal false + + name, options = table[5] + name.must_equal :string5 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(50)' + options.fetch(:max_length).must_equal 50 + options.fetch(:primary_key).must_equal false + + name, options = table[6] + name.must_equal :string6 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'char(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[7] + name.must_equal :string7 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'char(64)' + options.fetch(:max_length).must_equal 64 + options.fetch(:primary_key).must_equal false + + name, options = table[8] + name.must_equal :string8 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'text' + options.fetch(:primary_key).must_equal false + + name, options = table[9] + name.must_equal :file1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :blob + options.fetch(:db_type).must_equal 'blob' + options.fetch(:primary_key).must_equal false + + name, options = table[10] + name.must_equal :file2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :blob + options.fetch(:db_type).must_equal 'blob' + options.fetch(:primary_key).must_equal false + + name, options = table[11] + name.must_equal :number1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal false + + name, options = table[12] + name.must_equal :number2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'bigint(20)' + options.fetch(:primary_key).must_equal false + + name, options = table[13] + name.must_equal :number3 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :float + options.fetch(:db_type).must_equal 'double' + options.fetch(:primary_key).must_equal false + + name, options = table[14] + name.must_equal :number4 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'decimal(10,0)' + options.fetch(:primary_key).must_equal false + + name, options = table[15] + name.must_equal :number5 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + # options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'decimal(10,0)' + options.fetch(:primary_key).must_equal false + + name, options = table[16] + name.must_equal :number6 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'decimal(10,2)' + options.fetch(:primary_key).must_equal false + + name, options = table[17] + name.must_equal :number7 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'decimal(10,0)' + options.fetch(:primary_key).must_equal false + + name, options = table[18] + name.must_equal :date1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :date + options.fetch(:db_type).must_equal 'date' + options.fetch(:primary_key).must_equal false + + name, options = table[19] + name.must_equal :date2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :datetime + options.fetch(:db_type).must_equal 'datetime' + options.fetch(:primary_key).must_equal false + + name, options = table[20] + name.must_equal :time1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :datetime + options.fetch(:db_type).must_equal 'datetime' + options.fetch(:primary_key).must_equal false + + name, options = table[21] + name.must_equal :time2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :time + options.fetch(:db_type).must_equal 'time' + options.fetch(:primary_key).must_equal false + + name, options = table[22] + name.must_equal :boolean1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'tinyint(1)' + options.fetch(:primary_key).must_equal false + + name, options = table[23] + name.must_equal :boolean2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'tinyint(1)' + options.fetch(:primary_key).must_equal false + end + + it 'defines column defaults' do + table = @connection.schema(:default_values) + + name, options = table[0] + name.must_equal :a + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '23' + options.fetch(:ruby_default).must_equal 23 + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal false + + name, options = table[1] + name.must_equal :b + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal 'Hanami' + options.fetch(:ruby_default).must_equal 'Hanami' + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[2] + name.must_equal :c + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '-1' + options.fetch(:ruby_default).must_equal(-1) + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal false + + name, options = table[3] + name.must_equal :d + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '0' + options.fetch(:ruby_default).must_equal 0 + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'bigint(20)' + options.fetch(:primary_key).must_equal false + + name, options = table[4] + name.must_equal :e + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '3.14' + options.fetch(:ruby_default).must_equal 3.14 + options.fetch(:type).must_equal :float + options.fetch(:db_type).must_equal 'double' + options.fetch(:primary_key).must_equal false + + name, options = table[5] + name.must_equal :f + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '1' + options.fetch(:ruby_default).must_equal 1.0 + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'decimal(10,0)' + options.fetch(:primary_key).must_equal false + + name, options = table[6] + name.must_equal :g + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '943943' + options.fetch(:ruby_default).must_equal 943_943 + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'decimal(10,0)' + options.fetch(:primary_key).must_equal false + + name, options = table[10] + name.must_equal :k + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '1' + options.fetch(:ruby_default).must_equal true + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'tinyint(1)' + options.fetch(:primary_key).must_equal false + + name, options = table[11] + name.must_equal :l + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '0' + options.fetch(:ruby_default).must_equal false + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'tinyint(1)' + options.fetch(:primary_key).must_equal false + end + + it 'defines null constraint' do + table = @connection.schema(:null_constraints) + + name, options = table[0] + name.must_equal :a + + options.fetch(:allow_null).must_equal true + + name, options = table[1] + name.must_equal :b + + options.fetch(:allow_null).must_equal false + + name, options = table[2] + name.must_equal :c + + options.fetch(:allow_null).must_equal true + end + + it 'defines column index' do + indexes = @connection.indexes(:column_indexes) + + indexes.fetch(:column_indexes_a_index, nil).must_be_nil + indexes.fetch(:column_indexes_b_index, nil).must_be_nil + + index = indexes.fetch(:column_indexes_c_index) + index[:unique].must_equal false + index[:columns].must_equal [:c] + end + + it 'defines index via #index' do + indexes = @connection.indexes(:column_indexes) + + index = indexes.fetch(:column_indexes_d_index) + index[:unique].must_equal true + index[:columns].must_equal [:d] + + index = indexes.fetch(:column_indexes_b_c_index) + index[:unique].must_equal false + index[:columns].must_equal [:b, :c] + + index = indexes.fetch(:column_indexes_coords_index) + index[:unique].must_equal false + index[:columns].must_equal [:lat, :lng] + end + + it 'defines primary key (via #primary_key :id)' do + table = @connection.schema(:primary_keys_1) + + name, options = table[0] + name.must_equal :id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + end + + it 'defines composite primary key (via #primary_key [:column1, :column2])' do + table = @connection.schema(:primary_keys_3) + + name, options = table[0] + name.must_equal :group_id + + options.fetch(:allow_null).must_equal false + + expected = Platform.match do + os(:linux) { '0' } + os(:macos) { nil } + end + + options.fetch(:default).must_equal expected + + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal false + + name, options = table[1] + name.must_equal :position + + options.fetch(:allow_null).must_equal false + + expected = Platform.match do + os(:linux) { '0' } + os(:macos) { nil } + end + + options.fetch(:default).must_equal expected + + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal false + end + + it 'defines primary key (via #column primary_key: true)' do + table = @connection.schema(:primary_keys_2) + + name, options = table[0] + name.must_equal :name + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(255)' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal false + end + + it 'defines foreign key (via #foreign_key)' do + table = @connection.schema(:albums) + + name, options = table[1] + name.must_equal :artist_id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal false + + foreign_key = @connection.foreign_key_list(:albums).first + foreign_key.fetch(:columns).must_equal [:artist_id] + foreign_key.fetch(:table).must_equal :artists + foreign_key.fetch(:key).must_equal [:id] + # foreign_key.fetch(:on_update).must_equal :no_action + # foreign_key.fetch(:on_delete).must_equal :cascade + end + + it 'defines column constraint and check' + # it 'defines column constraint and check' do + # @schema.read.must_include %(CREATE TABLE `table_constraints` (`age` integer, `role` varchar(255), CONSTRAINT `age_constraint` CHECK (`age` > 18), CHECK (role IN("contributor", "manager", "owner")));) + # end + end +end diff --git a/test/integration/migration/mysql_test.rb b/test/integration/migration/mysql_test.rb deleted file mode 100644 index ec85cf1d..00000000 --- a/test/integration/migration/mysql_test.rb +++ /dev/null @@ -1,325 +0,0 @@ -require 'test_helper' -require 'hanami/model/migrator' - -describe 'Mysql database migrations' do - let(:adapter_prefix) { 'jdbc:' if Hanami::Utils.jruby? } - - let(:db_prefix) { name.gsub(/[^\w]/, '_') } - let(:random_token) { SecureRandom.hex(4) } - - before do - Hanami::Model.unload! - end - - after do - Hanami::Model::Migrator.drop rescue nil - Hanami::Model.unload! - end - - ['mysql', 'mysql2'].each do |scheme| - describe "MySQL" do - let(:adapter) { Hanami::Utils.jruby? ? 'mysql' : scheme } - - before do - @database = "#{ db_prefix }_#{ random_token }" - @uri = uri = "#{ adapter_prefix }#{ adapter }://localhost/#{ @database }?user=#{ MYSQL_USER }" - - Hanami::Model.configure do - adapter type: :sql, uri: uri - migrations __dir__ + '/../../fixtures/migrations' - end - end - - describe "create" do - before do - Hanami::Model::Migrator.create - end - - it "creates the database" do - connection = Sequel.connect(@uri) - connection.tables.must_be :empty? - end - - it 'raises error if database is busy' do - Sequel.connect(@uri).tables - exception = -> { Hanami::Model::Migrator.create }.must_raise Hanami::Model::MigrationError - exception.message.must_include 'Database creation failed' - exception.message.must_include 'There is 1 other session using the database' - end - end - - describe "drop" do - before do - Hanami::Model::Migrator.create - end - - it "drops the database" do - Hanami::Model::Migrator.drop - - -> { Sequel.connect(@uri).tables }.must_raise Sequel::DatabaseConnectionError - end - - it "raises error if database doesn't exist" do - Hanami::Model::Migrator.drop # remove the first time - - exception = -> { Hanami::Model::Migrator.drop }.must_raise Hanami::Model::MigrationError - exception.message.must_equal "Cannot find database: #{ @database }" - end - end - - describe "migrate" do - before do - Hanami::Model::Migrator.create - end - - describe "when no migrations" do - before do - @migrations_root = migrations_root = Pathname.new(__dir__ + '/../../fixtures/empty_migrations') - - Hanami::Model.configure do - migrations migrations_root - end - end - - it "it doesn't alter database" do - Hanami::Model::Migrator.migrate - - connection = Sequel.connect(@uri) - connection.tables.must_be :empty? - end - end - - describe "when migrations are present" do - it "migrates the database" do - Hanami::Model::Migrator.migrate - - connection = Sequel.connect(@uri) - connection.tables.wont_be :empty? - - table = connection.schema(:books) - - name, options = table[0] # id - name.must_equal :id - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "int(11)" - options.fetch(:primary_key).must_equal true - options.fetch(:auto_increment).must_equal true - - name, options = table[1] # title - name.must_equal :title - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "varchar(255)" - options.fetch(:primary_key).must_equal false - - name, options = table[2] # price (second migration) - name.must_equal :price - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "100" - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "int(11)" - options.fetch(:primary_key).must_equal false - end - end - - describe "when migrations are ran twice" do - before do - Hanami::Model::Migrator.migrate - end - - it "doesn't alter the schema" do - Hanami::Model::Migrator.migrate - - connection = Sequel.connect(@uri) - connection.tables.wont_be :empty? - connection.tables.must_equal [:books, :schema_migrations] - end - end - - describe "migrate down" do - before do - Hanami::Model::Migrator.migrate - end - - it "migrates the database" do - Hanami::Model::Migrator.migrate(version: '20150610133853') - - connection = Sequel.connect(@uri) - connection.tables.wont_be :empty? - - table = connection.schema(:books) - - name, options = table[0] # id - name.must_equal :id - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "int(11)" - options.fetch(:primary_key).must_equal true - options.fetch(:auto_increment).must_equal true - - name, options = table[1] # title - name.must_equal :title - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "varchar(255)" - options.fetch(:primary_key).must_equal false - - name, options = table[2] # price (rolled back second migration) - name.must_be_nil - options.must_be_nil - end - end - end - - describe "apply" do - before do - uri = @uri - @migrations_root = migrations_root = Pathname.new(__dir__ + '/../../../tmp') - @fixtures_root = fixtures_root = Pathname.new(__dir__ + '/../../fixtures/migrations') - - migrations_root.mkpath - FileUtils.cp_r(fixtures_root, migrations_root) - - Hanami::Model.unload! - Hanami::Model.configure do - adapter type: :sql, uri: uri - - migrations migrations_root.join('migrations') - schema migrations_root.join('schema-mysql.sql') - end - - Hanami::Model::Migrator.create - Hanami::Model::Migrator.apply - end - - it "migrates to latest version" do - connection = Sequel.connect(@uri) - migration = connection[:schema_migrations].to_a.last - - migration.fetch(:filename).must_include("20150610141017") - end - - it "dumps database schema.sql" do - schema = @migrations_root.join('schema-mysql.sql').read - - schema.must_include %(DROP TABLE IF EXISTS `books`;) - - schema.must_include %(CREATE TABLE `books`) - schema.must_include %(`id` int\(11\) NOT NULL AUTO_INCREMENT,) - - schema.must_include %(`title` varchar(255)) - - schema.must_include %(`price` int\(11\) DEFAULT '100',) - schema.must_include %(PRIMARY KEY \(`id`\)) - - schema.must_include %(DROP TABLE IF EXISTS `schema_migrations`;) - - schema.must_include %(CREATE TABLE `schema_migrations` \() - - schema.must_include %(`filename` varchar(255)) - schema.must_include %(PRIMARY KEY (`filename`)) - - schema.must_include %(LOCK TABLES `schema_migrations` WRITE;) - - schema.must_include %(INSERT INTO `schema_migrations` VALUES \('20150610133853_create_books.rb'\),\('20150610141017_add_price_to_books.rb'\);) - - schema.must_include %(UNLOCK TABLES;) - end - - it "deletes all the migrations" do - @migrations_root.join('migrations').children.must_be :empty? - end - end - - describe "prepare" do - before do - uri = @uri - @migrations_root = migrations_root = Pathname.new(__dir__ + '/../../../tmp') - @fixtures_root = fixtures_root = Pathname.new(__dir__ + '/../../fixtures/migrations') - - migrations_root.mkpath - FileUtils.cp_r(fixtures_root, migrations_root) - - Hanami::Model.unload! - Hanami::Model.configure do - adapter type: :sql, uri: uri - - migrations migrations_root.join('migrations') - schema migrations_root.join('schema-mysql.sql') - end - end - - it "creates database, loads schema and migrate" do - # Simulate already existing schema.sql, without existing database and pending migrations - connection = Sequel.connect(@uri) - - FileUtils.cp 'test/fixtures/20150611165922_create_authors.rb', - @migrations_root.join('migrations/20150611165922_create_authors.rb') - - Hanami::Model::Migrator.prepare - - connection.tables.must_include(:schema_migrations) - connection.tables.must_include(:books) - connection.tables.must_include(:authors) - - FileUtils.rm_f @migrations_root.join('migrations/20150611165922_create_authors.rb') - end - - it "works even if schema doesn't exist" do - # Simulate no database, no schema and pending migrations - @migrations_root.join('migrations/20150611165922_create_authors.rb').delete rescue nil - @migrations_root.join('schema-mysql.sql').delete rescue nil - - Hanami::Model::Migrator.prepare - - connection = Sequel.connect(@uri) - connection.tables.must_include(:schema_migrations) - connection.tables.must_include(:books) - end - - it "drops the database and recreate it" do - Hanami::Model::Migrator.create - Hanami::Model::Migrator.prepare - - connection = Sequel.connect(@uri) - connection.tables.must_include(:schema_migrations) - connection.tables.must_include(:books) - end - end - - describe "version" do - before do - Hanami::Model::Migrator.create - end - - describe "when no migrations were ran" do - it "returns nil" do - version = Hanami::Model::Migrator.version - version.must_be_nil - end - end - - describe "with migrations" do - before do - Hanami::Model::Migrator.migrate - end - - it "returns current database version" do - version = Hanami::Model::Migrator.version - version.must_equal "20150610141017" - end - end - end - end - end -end diff --git a/test/integration/migration/postgres_test.rb b/test/integration/migration/postgres_test.rb deleted file mode 100644 index 24a7689a..00000000 --- a/test/integration/migration/postgres_test.rb +++ /dev/null @@ -1,371 +0,0 @@ -require 'test_helper' -require 'hanami/model/migrator' - -describe 'PostgreSQL Database migrations' do - let(:adapter_prefix) { 'jdbc:' if Hanami::Utils.jruby? } - let(:db_prefix) { name.gsub(/[^\w]/, '_') } - let(:random_token) { SecureRandom.hex(4) } - - before do - Hanami::Model.unload! - end - - after do - Hanami::Model::Migrator.drop rescue nil - Hanami::Model.unload! - end - - describe "PostgreSQL" do - before do - @database = "#{ db_prefix }_#{ random_token }" - @uri = uri = "#{ adapter_prefix }postgresql://127.0.0.1/#{ @database }?user=#{ POSTGRES_USER }" - - Hanami::Model.configure do - adapter type: :sql, uri: uri - migrations __dir__ + '/../../fixtures/migrations' - end - end - - after do - SQLite3::Database.new(@uri) { |db| db.close } if File.exist?(@database) - end - - describe "create" do - before do - Hanami::Model::Migrator.create - end - - it "creates the database" do - connection = Sequel.connect(@uri) - connection.tables.must_be :empty? - end - - it 'raises error if database is busy' do - Sequel.connect(@uri).tables - exception = -> { Hanami::Model::Migrator.create }.must_raise Hanami::Model::MigrationError - exception.message.must_include 'createdb: database creation failed' - exception.message.must_include 'issues/250' - end - end - - describe "drop" do - before do - Hanami::Model::Migrator.create - end - - it "drops the database" do - Hanami::Model::Migrator.drop - - -> { Sequel.connect(@uri).tables }.must_raise Sequel::DatabaseConnectionError - end - - it "raises error if database doesn't exist" do - Hanami::Model::Migrator.drop # remove the first time - exception = -> { Hanami::Model::Migrator.drop }.must_raise Hanami::Model::MigrationError - exception.message.must_equal "Cannot find database: #{ @database }" - end - - it 'raises error if database is busy' do - Sequel.connect(@uri).tables - exception = -> { Hanami::Model::Migrator.drop }.must_raise Hanami::Model::MigrationError - exception.message.must_include 'dropdb: database removal failed' - exception.message.must_include 'There is 1 other session using the database' - end - - describe "when a command isn't available" do - before do - # We accomplish having a command not be available by setting PATH - # to an empty string, which means *no commands* are available. - @original_path = ENV['PATH'] - ENV['PATH'] = '' - end - - after do - ENV['PATH'] = @original_path - end - - it "raises MigrationError on create" do - exception = -> { Hanami::Model::Migrator.create }.must_raise Hanami::Model::MigrationError - exception.message.must_equal "No such file or directory - createdb" - end - end - end - - describe "migrate" do - before do - Hanami::Model::Migrator.create - end - - describe "when no migrations" do - before do - @migrations_root = migrations_root = Pathname.new(__dir__ + '/../../fixtures/empty_migrations') - - Hanami::Model.configure do - migrations migrations_root - end - end - - it "it doesn't alter database" do - Hanami::Model::Migrator.migrate - - connection = Sequel.connect(@uri) - connection.tables.must_be :empty? - end - end - - describe "when migrations are present" do - it "migrates the database" do - Hanami::Model::Migrator.migrate - - connection = Sequel.connect(@uri) - connection.tables.wont_be :empty? - - table = connection.schema(:books) - - name, options = table[0] # id - name.must_equal :id - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal "nextval('books_id_seq'::regclass)" - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal true - options.fetch(:auto_increment).must_equal true - - name, options = table[1] # title - name.must_equal :title - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "text" - options.fetch(:primary_key).must_equal false - - name, options = table[2] # price (second migration) - name.must_equal :price - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "100" - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal false - end - end - - describe "when migrations are ran twice" do - before do - Hanami::Model::Migrator.migrate - end - - it "doesn't alter the schema" do - Hanami::Model::Migrator.migrate - - connection = Sequel.connect(@uri) - connection.tables.wont_be :empty? - connection.tables.must_equal [:schema_migrations, :books] - end - end - - describe "migrate down" do - before do - Hanami::Model::Migrator.migrate - end - - it "migrates the database" do - Hanami::Model::Migrator.migrate(version: '20150610133853') - - connection = Sequel.connect(@uri) - connection.tables.wont_be :empty? - - table = connection.schema(:books) - - name, options = table[0] # id - name.must_equal :id - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal "nextval('books_id_seq'::regclass)" - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal true - options.fetch(:auto_increment).must_equal true - - name, options = table[1] # title - name.must_equal :title - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "text" - options.fetch(:primary_key).must_equal false - - name, options = table[2] # price (rolled back second migration) - name.must_be_nil - options.must_be_nil - end - end - end - - describe "apply" do - before do - uri = @uri - @migrations_root = migrations_root = Pathname.new(__dir__ + '/../../../tmp') - @fixtures_root = fixtures_root = Pathname.new(__dir__ + '/../../fixtures/migrations') - - migrations_root.mkpath - FileUtils.cp_r(fixtures_root, migrations_root) - - Hanami::Model.unload! - Hanami::Model.configure do - adapter type: :sql, uri: uri - - migrations migrations_root.join('migrations') - schema migrations_root.join('schema-postgres.sql') - end - - Hanami::Model::Migrator.create - Hanami::Model::Migrator.apply - end - - it "migrates to latest version" do - connection = Sequel.connect(@uri) - migration = connection[:schema_migrations].to_a[1] - - migration.fetch(:filename).must_include("20150610141017") - end - - it "dumps database schema.sql" do - schema = @migrations_root.join('schema-postgres.sql').read - - schema.must_include <<-SQL -CREATE TABLE books ( - id integer NOT NULL, - title text NOT NULL, - price integer DEFAULT 100 -); -SQL - - schema.must_include <<-SQL -CREATE SEQUENCE books_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; -SQL - - schema.must_include <<-SQL -ALTER SEQUENCE books_id_seq OWNED BY books.id; -SQL - - schema.must_include <<-SQL -ALTER TABLE ONLY books ALTER COLUMN id SET DEFAULT nextval('books_id_seq'::regclass); -SQL - - schema.must_include <<-SQL -ALTER TABLE ONLY books - ADD CONSTRAINT books_pkey PRIMARY KEY (id); -SQL - - schema.must_include <<-SQL -CREATE TABLE schema_migrations ( - filename text NOT NULL -); -SQL - - schema.must_include <<-SQL -COPY schema_migrations (filename) FROM stdin; -20150610133853_create_books.rb -20150610141017_add_price_to_books.rb -SQL - - schema.must_include <<-SQL -ALTER TABLE ONLY schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (filename); -SQL - end - - it "deletes all the migrations" do - @migrations_root.join('migrations').children.must_be :empty? - end - end - - describe "prepare" do - before do - uri = @uri - @migrations_root = migrations_root = Pathname.new(__dir__ + '/../../../tmp') - @fixtures_root = fixtures_root = Pathname.new(__dir__ + '/../../fixtures/migrations') - - migrations_root.mkpath - FileUtils.cp_r(fixtures_root, migrations_root) - - Hanami::Model.unload! - Hanami::Model.configure do - adapter type: :sql, uri: uri - - migrations migrations_root.join('migrations') - schema migrations_root.join('schema-postgres.sql') - end - end - - it "creates database, loads schema and migrate" do - # Simulate already existing schema.sql, without existing database and pending migrations - connection = Sequel.connect(@uri) - - FileUtils.cp 'test/fixtures/20150611165922_create_authors.rb', - @migrations_root.join('migrations/20150611165922_create_authors.rb') - - Hanami::Model::Migrator.prepare - - connection.tables.must_include(:schema_migrations) - connection.tables.must_include(:books) - connection.tables.must_include(:authors) - - FileUtils.rm_f @migrations_root.join('migrations/20150611165922_create_authors.rb') - end - - it "works even if schema doesn't exist" do - # Simulate no database, no schema and pending migrations - @migrations_root.join('migrations/20150611165922_create_authors.rb').delete rescue nil - @migrations_root.join('schema-postgres.sql').delete rescue nil - - Hanami::Model::Migrator.prepare - - connection = Sequel.connect(@uri) - connection.tables.must_equal [:schema_migrations, :books] - end - - it "drops the database and recreate it" do - Hanami::Model::Migrator.create - Hanami::Model::Migrator.prepare - - connection = Sequel.connect(@uri) - connection.tables.must_include(:schema_migrations) - connection.tables.must_include(:books) - end - end - - describe "version" do - before do - Hanami::Model::Migrator.create - end - - describe "when no migrations were ran" do - it "returns nil" do - version = Hanami::Model::Migrator.version - version.must_be_nil - end - end - - describe "with migrations" do - before do - Hanami::Model::Migrator.migrate - end - - it "returns current database version" do - version = Hanami::Model::Migrator.version - version.must_equal "20150610141017" - end - end - end - end -end diff --git a/test/integration/migration/postgresql.rb b/test/integration/migration/postgresql.rb new file mode 100644 index 00000000..94a11db0 --- /dev/null +++ b/test/integration/migration/postgresql.rb @@ -0,0 +1,622 @@ +describe 'PostgreSQL' do + before do + @schema = Pathname.new("#{__dir__}/../../../tmp/schema.sql").expand_path + @connection = Sequel.connect(ENV['HANAMI_DATABASE_URL']) + + Hanami::Model::Migrator::Adapter.for(Hanami::Model.configuration).dump + end + + describe 'columns' do + it 'defines column types' do + table = @connection.schema(:column_types) + + name, options = table[0] + name.must_equal :integer1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[1] + name.must_equal :integer2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[2] + name.must_equal :integer3 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[3] + name.must_equal :string1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'text' + options.fetch(:primary_key).must_equal false + + name, options = table[4] + name.must_equal :string2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'text' + options.fetch(:primary_key).must_equal false + + name, options = table[5] + name.must_equal :string3 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'character varying(1)' + options.fetch(:primary_key).must_equal false + + name, options = table[6] + name.must_equal :string4 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'character varying(2)' + options.fetch(:primary_key).must_equal false + + name, options = table[7] + name.must_equal :string5 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'character(3)' + options.fetch(:primary_key).must_equal false + + name, options = table[8] + name.must_equal :string6 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'character(4)' + options.fetch(:primary_key).must_equal false + + name, options = table[9] + name.must_equal :string7 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'character varying(50)' + options.fetch(:max_length).must_equal 50 + options.fetch(:primary_key).must_equal false + + name, options = table[10] + name.must_equal :string8 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'character(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[11] + name.must_equal :string9 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'character(64)' + options.fetch(:max_length).must_equal 64 + options.fetch(:primary_key).must_equal false + + name, options = table[12] + name.must_equal :string10 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'text' + options.fetch(:primary_key).must_equal false + + name, options = table[13] + name.must_equal :file1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :blob + options.fetch(:db_type).must_equal 'bytea' + options.fetch(:primary_key).must_equal false + + name, options = table[14] + name.must_equal :file2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :blob + options.fetch(:db_type).must_equal 'bytea' + options.fetch(:primary_key).must_equal false + + name, options = table[15] + name.must_equal :number1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[16] + name.must_equal :number2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'bigint' + options.fetch(:primary_key).must_equal false + + name, options = table[17] + name.must_equal :number3 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :float + options.fetch(:db_type).must_equal 'double precision' + options.fetch(:primary_key).must_equal false + + name, options = table[18] + name.must_equal :number4 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric' + options.fetch(:primary_key).must_equal false + + name, options = table[19] + name.must_equal :number5 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + # options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric(10,0)' + options.fetch(:primary_key).must_equal false + + name, options = table[20] + name.must_equal :number6 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric(10,2)' + options.fetch(:primary_key).must_equal false + + name, options = table[21] + name.must_equal :number7 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric' + options.fetch(:primary_key).must_equal false + + name, options = table[22] + name.must_equal :date1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :date + options.fetch(:db_type).must_equal 'date' + options.fetch(:primary_key).must_equal false + + name, options = table[23] + name.must_equal :date2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :datetime + options.fetch(:db_type).must_equal 'timestamp without time zone' + options.fetch(:primary_key).must_equal false + + name, options = table[24] + name.must_equal :time1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :datetime + options.fetch(:db_type).must_equal 'timestamp without time zone' + options.fetch(:primary_key).must_equal false + + name, options = table[25] + name.must_equal :time2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :time + options.fetch(:db_type).must_equal 'time without time zone' + options.fetch(:primary_key).must_equal false + + name, options = table[26] + name.must_equal :boolean1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'boolean' + options.fetch(:primary_key).must_equal false + + name, options = table[27] + name.must_equal :boolean2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'boolean' + options.fetch(:primary_key).must_equal false + + name, options = table[28] + name.must_equal :array1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer[]' + options.fetch(:primary_key).must_equal false + + name, options = table[29] + name.must_equal :array2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer[]' + options.fetch(:primary_key).must_equal false + + name, options = table[30] + name.must_equal :array3 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'text[]' + options.fetch(:primary_key).must_equal false + + name, options = table[31] + name.must_equal :money1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + # options.fetch(:type).must_equal :money + options.fetch(:db_type).must_equal 'money' + options.fetch(:primary_key).must_equal false + + name, options = table[32] + name.must_equal :enum1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + # options.fetch(:type).must_equal :mood + options.fetch(:db_type).must_equal 'mood' + options.fetch(:primary_key).must_equal false + + name, options = table[33] + name.must_equal :geometric1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + # options.fetch(:type).must_equal :point + options.fetch(:db_type).must_equal 'point' + options.fetch(:primary_key).must_equal false + + name, options = table[34] + name.must_equal :geometric2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + # options.fetch(:type).must_equal :line + options.fetch(:db_type).must_equal 'line' + options.fetch(:primary_key).must_equal false + + name, options = table[35] + name.must_equal :geometric3 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal "'<(15,15),1>'::circle" + # options.fetch(:type).must_equal :circle + options.fetch(:db_type).must_equal 'circle' + options.fetch(:primary_key).must_equal false + + name, options = table[36] + name.must_equal :net1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal "'192.168.0.0/24'::cidr" + # options.fetch(:type).must_equal :cidr + options.fetch(:db_type).must_equal 'cidr' + options.fetch(:primary_key).must_equal false + + name, options = table[37] + name.must_equal :uuid1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal 'uuid_generate_v4()' + # options.fetch(:type).must_equal :uuid + options.fetch(:db_type).must_equal 'uuid' + options.fetch(:primary_key).must_equal false + + name, options = table[38] + name.must_equal :xml1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + # options.fetch(:type).must_equal :xml + options.fetch(:db_type).must_equal 'xml' + options.fetch(:primary_key).must_equal false + + name, options = table[39] + name.must_equal :json1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + # options.fetch(:type).must_equal :json + options.fetch(:db_type).must_equal 'json' + options.fetch(:primary_key).must_equal false + + name, options = table[40] + name.must_equal :json2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + # options.fetch(:type).must_equal :jsonb + options.fetch(:db_type).must_equal 'jsonb' + options.fetch(:primary_key).must_equal false + + name, options = table[41] + name.must_equal :composite1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal "ROW('fuzzy dice'::text, 42, 1.99)" + # options.fetch(:type).must_equal :inventory_item + options.fetch(:db_type).must_equal 'inventory_item' + options.fetch(:primary_key).must_equal false + end + + it 'defines column defaults' do + table = @connection.schema(:default_values) + + name, options = table[0] + name.must_equal :a + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '23' + options.fetch(:ruby_default).must_equal 23 + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[1] + name.must_equal :b + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal "'Hanami'::text" + options.fetch(:ruby_default).must_equal 'Hanami' + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'text' + options.fetch(:primary_key).must_equal false + + name, options = table[2] + name.must_equal :c + + options.fetch(:allow_null).must_equal true + + expected = Platform.match do + os(:linux) { '(-1)' } + os(:macos) { "'-1'::integer" } + end + + options.fetch(:default).must_equal expected + + # options.fetch(:ruby_default).must_equal(-1) + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[3] + name.must_equal :d + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '0' + options.fetch(:ruby_default).must_equal 0 + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'bigint' + options.fetch(:primary_key).must_equal false + + name, options = table[4] + name.must_equal :e + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '3.14' + options.fetch(:ruby_default).must_equal 3.14 + options.fetch(:type).must_equal :float + options.fetch(:db_type).must_equal 'double precision' + options.fetch(:primary_key).must_equal false + + name, options = table[5] + name.must_equal :f + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '1.0' + options.fetch(:ruby_default).must_equal 1.0 + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric' + options.fetch(:primary_key).must_equal false + + name, options = table[6] + name.must_equal :g + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '943943' + options.fetch(:ruby_default).must_equal 943_943 + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric' + options.fetch(:primary_key).must_equal false + + name, options = table[10] + name.must_equal :k + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal 'true' + options.fetch(:ruby_default).must_equal true + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'boolean' + options.fetch(:primary_key).must_equal false + + name, options = table[11] + name.must_equal :l + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal 'false' + options.fetch(:ruby_default).must_equal false + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'boolean' + options.fetch(:primary_key).must_equal false + end + + it 'defines null constraint' do + table = @connection.schema(:null_constraints) + + name, options = table[0] + name.must_equal :a + + options.fetch(:allow_null).must_equal true + + name, options = table[1] + name.must_equal :b + + options.fetch(:allow_null).must_equal false + + name, options = table[2] + name.must_equal :c + + options.fetch(:allow_null).must_equal true + end + + it 'defines column index' do + indexes = @connection.indexes(:column_indexes) + + indexes.fetch(:column_indexes_a_index, nil).must_be_nil + indexes.fetch(:column_indexes_b_index, nil).must_be_nil + + index = indexes.fetch(:column_indexes_c_index) + index[:unique].must_equal false + index[:columns].must_equal [:c] + end + + it 'defines index via #index' do + indexes = @connection.indexes(:column_indexes) + + index = indexes.fetch(:column_indexes_d_index) + index[:unique].must_equal true + index[:columns].must_equal [:d] + + index = indexes.fetch(:column_indexes_b_c_index) + index[:unique].must_equal false + index[:columns].must_equal [:b, :c] + + index = indexes.fetch(:column_indexes_coords_index) + index[:unique].must_equal false + index[:columns].must_equal [:lat, :lng] + end + + it 'defines primary key (via #primary_key :id)' do + table = @connection.schema(:primary_keys_1) + + name, options = table[0] + name.must_equal :id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal "nextval('primary_keys_1_id_seq'::regclass)" + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + end + + it 'defines composite primary key (via #primary_key [:column1, :column2])' do + table = @connection.schema(:primary_keys_3) + + name, options = table[0] + name.must_equal :group_id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal false + + name, options = table[1] + name.must_equal :position + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal false + end + + it 'defines primary key (via #column primary_key: true)' do + table = @connection.schema(:primary_keys_2) + + name, options = table[0] + name.must_equal :name + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'text' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal false + end + + it 'defines foreign key (via #foreign_key)' do + table = @connection.schema(:albums) + + name, options = table[1] + name.must_equal :artist_id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + foreign_key = @connection.foreign_key_list(:albums).first + foreign_key.fetch(:columns).must_equal [:artist_id] + foreign_key.fetch(:table).must_equal :artists + foreign_key.fetch(:key).must_equal [:id] + foreign_key.fetch(:on_update).must_equal :no_action + foreign_key.fetch(:on_delete).must_equal :cascade + end + + unless Platform.ci? + it 'defines column constraint and check' do + actual = @schema.read + + actual.must_include %(CONSTRAINT age_constraint CHECK ((age > 18))) + actual.must_include %(CONSTRAINT table_constraints_role_check CHECK ((role = ANY (ARRAY['contributor'::text, 'manager'::text, 'owner'::text])))) + end + end + end +end diff --git a/test/integration/migration/sqlite.rb b/test/integration/migration/sqlite.rb new file mode 100644 index 00000000..77a417f8 --- /dev/null +++ b/test/integration/migration/sqlite.rb @@ -0,0 +1,466 @@ +describe 'SQLite' do + before do + @schema = Pathname.new("#{__dir__}/../../../tmp/schema.sql").expand_path + @connection = Sequel.connect(ENV['HANAMI_DATABASE_URL']) + + Hanami::Model::Migrator::Adapter.for(Hanami::Model.configuration).dump + end + + describe 'columns' do + it 'defines column types' do + table = @connection.schema(:column_types) + + name, options = table[0] + name.must_equal :integer1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[1] + name.must_equal :integer2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[2] + name.must_equal :integer3 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[3] + name.must_equal :string1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[4] + name.must_equal :string2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'string' + options.fetch(:primary_key).must_equal false + + name, options = table[5] + name.must_equal :string3 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'string' + options.fetch(:primary_key).must_equal false + + name, options = table[6] + name.must_equal :string4 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(3)' + options.fetch(:primary_key).must_equal false + + name, options = table[7] + name.must_equal :string5 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(50)' + options.fetch(:max_length).must_equal 50 + options.fetch(:primary_key).must_equal false + + name, options = table[8] + name.must_equal :string6 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'char(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[9] + name.must_equal :string7 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'char(64)' + options.fetch(:max_length).must_equal 64 + options.fetch(:primary_key).must_equal false + + name, options = table[10] + name.must_equal :string8 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'text' + options.fetch(:primary_key).must_equal false + + name, options = table[11] + name.must_equal :file1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :blob + options.fetch(:db_type).must_equal 'blob' + options.fetch(:primary_key).must_equal false + + name, options = table[12] + name.must_equal :file2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :blob + options.fetch(:db_type).must_equal 'blob' + options.fetch(:primary_key).must_equal false + + name, options = table[13] + name.must_equal :number1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[14] + name.must_equal :number2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'bigint' + options.fetch(:primary_key).must_equal false + + name, options = table[15] + name.must_equal :number3 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :float + options.fetch(:db_type).must_equal 'double precision' + options.fetch(:primary_key).must_equal false + + name, options = table[16] + name.must_equal :number4 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric' + options.fetch(:primary_key).must_equal false + + name, options = table[17] + name.must_equal :number5 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + # options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric(10)' + options.fetch(:primary_key).must_equal false + + name, options = table[18] + name.must_equal :number6 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric(10, 2)' + options.fetch(:primary_key).must_equal false + + name, options = table[19] + name.must_equal :number7 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric' + options.fetch(:primary_key).must_equal false + + name, options = table[20] + name.must_equal :date1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :date + options.fetch(:db_type).must_equal 'date' + options.fetch(:primary_key).must_equal false + + name, options = table[21] + name.must_equal :date2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :datetime + options.fetch(:db_type).must_equal 'timestamp' + options.fetch(:primary_key).must_equal false + + name, options = table[22] + name.must_equal :time1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :datetime + options.fetch(:db_type).must_equal 'timestamp' + options.fetch(:primary_key).must_equal false + + name, options = table[23] + name.must_equal :time2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :time + options.fetch(:db_type).must_equal 'time' + options.fetch(:primary_key).must_equal false + + name, options = table[24] + name.must_equal :boolean1 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'boolean' + options.fetch(:primary_key).must_equal false + + name, options = table[25] + name.must_equal :boolean2 + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'boolean' + options.fetch(:primary_key).must_equal false + end + + it 'defines column defaults' do + table = @connection.schema(:default_values) + + name, options = table[0] + name.must_equal :a + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '23' + options.fetch(:ruby_default).must_equal 23 + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[1] + name.must_equal :b + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal "'Hanami'" + options.fetch(:ruby_default).must_equal 'Hanami' + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[2] + name.must_equal :c + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '-1' + options.fetch(:ruby_default).must_equal(-1) + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + name, options = table[3] + name.must_equal :d + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '0' + options.fetch(:ruby_default).must_equal 0 + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'bigint' + options.fetch(:primary_key).must_equal false + + name, options = table[4] + name.must_equal :e + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '3.14' + options.fetch(:ruby_default).must_equal 3.14 + options.fetch(:type).must_equal :float + options.fetch(:db_type).must_equal 'double precision' + options.fetch(:primary_key).must_equal false + + name, options = table[5] + name.must_equal :f + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '1.0' + options.fetch(:ruby_default).must_equal 1.0 + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric' + options.fetch(:primary_key).must_equal false + + name, options = table[6] + name.must_equal :g + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '943943' + options.fetch(:ruby_default).must_equal 943_943 + options.fetch(:type).must_equal :decimal + options.fetch(:db_type).must_equal 'numeric' + options.fetch(:primary_key).must_equal false + + name, options = table[10] + name.must_equal :k + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '1' + options.fetch(:ruby_default).must_equal true + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'boolean' + options.fetch(:primary_key).must_equal false + + name, options = table[11] + name.must_equal :l + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '0' + options.fetch(:ruby_default).must_equal false + options.fetch(:type).must_equal :boolean + options.fetch(:db_type).must_equal 'boolean' + options.fetch(:primary_key).must_equal false + end + + it 'defines null constraint' do + table = @connection.schema(:null_constraints) + + name, options = table[0] + name.must_equal :a + + options.fetch(:allow_null).must_equal true + + name, options = table[1] + name.must_equal :b + + options.fetch(:allow_null).must_equal false + + name, options = table[2] + name.must_equal :c + + options.fetch(:allow_null).must_equal true + end + + it 'defines column index' do + indexes = @connection.indexes(:column_indexes) + + indexes.fetch(:column_indexes_a_index, nil).must_be_nil + indexes.fetch(:column_indexes_b_index, nil).must_be_nil + + index = indexes.fetch(:column_indexes_c_index) + index[:unique].must_equal false + index[:columns].must_equal [:c] + end + + it 'defines index via #index' do + indexes = @connection.indexes(:column_indexes) + + index = indexes.fetch(:column_indexes_d_index) + index[:unique].must_equal true + index[:columns].must_equal [:d] + + index = indexes.fetch(:column_indexes_b_c_index) + index[:unique].must_equal false + index[:columns].must_equal [:b, :c] + + index = indexes.fetch(:column_indexes_coords_index) + index[:unique].must_equal false + index[:columns].must_equal [:lat, :lng] + end + + it 'defines primary key (via #primary_key :id)' do + table = @connection.schema(:primary_keys_1) + + name, options = table[0] + name.must_equal :id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + end + + it 'defines composite primary key (via #primary_key [:column1, :column2])' do + table = @connection.schema(:primary_keys_3) + + name, options = table[0] + name.must_equal :group_id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + + name, options = table[1] + name.must_equal :position + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + end + + it 'defines primary key (via #column primary_key: true)' do + table = @connection.schema(:primary_keys_2) + + name, options = table[0] + name.must_equal :name + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(255)' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal false + end + + it 'defines foreign key (via #foreign_key)' do + table = @connection.schema(:albums) + + name, options = table[1] + name.must_equal :artist_id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + + foreign_key = @connection.foreign_key_list(:albums).first + foreign_key.fetch(:columns).must_equal [:artist_id] + foreign_key.fetch(:table).must_equal :artists + foreign_key.fetch(:key).must_equal nil + foreign_key.fetch(:on_update).must_equal :no_action + foreign_key.fetch(:on_delete).must_equal :cascade + end + + it 'defines column constraint and check' do + @schema.read.must_include %(CREATE TABLE `table_constraints` (`age` integer, `role` varchar(255), CONSTRAINT `age_constraint` CHECK (`age` > 18), CHECK (role IN("contributor", "manager", "owner")));) + end + end +end diff --git a/test/integration/migration/sqlite_test.rb b/test/integration/migration/sqlite_test.rb deleted file mode 100644 index b13f2ec9..00000000 --- a/test/integration/migration/sqlite_test.rb +++ /dev/null @@ -1,306 +0,0 @@ -require 'test_helper' -require 'hanami/model/migrator' - -describe 'Filesystem SQLite Database migrations' do - let(:adapter_prefix) { 'jdbc:' if Hanami::Utils.jruby? } - - before do - Hanami::Model.unload! - end - - after do - Hanami::Model::Migrator.drop rescue nil - Hanami::Model.unload! - end - - describe "SQLite filesystem" do - before do - @database = Pathname.new("#{ __dir__ }/../../../tmp/create-#{ SecureRandom.hex }.sqlite3").expand_path - @uri = uri = "#{ adapter_prefix }sqlite://#{ @database }" - - Hanami::Model.configure do - adapter type: :sql, uri: uri - migrations __dir__ + '/../../fixtures/migrations' - end - end - - describe "create" do - it "creates the database" do - Hanami::Model::Migrator.create - assert File.exist?(@database), "Expected database #{ @database } to exist" - end - - describe "when it doesn't have write permissions" do - before do - @database = '/usr/bin/create.sqlite3' - @uri = uri = "#{ adapter_prefix }sqlite://#{ @database }" - - Hanami::Model.unload! - Hanami::Model.configure do - adapter type: :sql, uri: uri - end - end - - it "raises an error" do - exception = -> { Hanami::Model::Migrator.create }.must_raise Hanami::Model::MigrationError - exception.message.must_equal "Permission denied: /usr/bin/create.sqlite3" - end - end - end - - describe "drop" do - before do - Hanami::Model::Migrator.create - end - - it "drops the database" do - Hanami::Model::Migrator.drop - assert !File.exist?(@database), "Expected database #{ @database } to NOT exist" - end - - it "raises error if database doesn't exist" do - Hanami::Model::Migrator.drop # remove the first time - - exception = -> { Hanami::Model::Migrator.drop }.must_raise Hanami::Model::MigrationError - exception.message.must_equal "Cannot find database: #{ @database }" - end - end - - describe "migrate" do - before do - Hanami::Model::Migrator.create - end - - describe "when no migrations" do - before do - @migrations_root = migrations_root = Pathname.new(__dir__ + '/../../fixtures/empty_migrations') - - Hanami::Model.configure do - migrations migrations_root - end - - Hanami::Model::Migrator.create - end - - it "it doesn't alter database" do - Hanami::Model::Migrator.migrate - - connection = Sequel.connect(@uri) - connection.tables.must_be :empty? - end - end - - describe "when migrations are present" do - it "migrates the database" do - Hanami::Model::Migrator.migrate - - connection = Sequel.connect(@uri) - connection.tables.wont_be :empty? - - table = connection.schema(:books) - - name, options = table[0] # id - name.must_equal :id - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal true - options.fetch(:auto_increment).must_equal true - - name, options = table[1] # title - name.must_equal :title - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "varchar(255)" - options.fetch(:primary_key).must_equal false - - name, options = table[2] # price (second migration) - name.must_equal :price - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "100" - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal false - end - end - - describe "when migrations are ran twice" do - before do - Hanami::Model::Migrator.migrate - end - - it "doesn't alter the schema" do - Hanami::Model::Migrator.migrate - - connection = Sequel.connect(@uri) - connection.tables.wont_be :empty? - connection.tables.must_equal [:schema_migrations, :books] - end - end - - describe "migrate down" do - before do - Hanami::Model::Migrator.migrate - end - - it "migrates the database" do - Hanami::Model::Migrator.migrate(version: '20150610133853') - - connection = Sequel.connect(@uri) - connection.tables.wont_be :empty? - - table = connection.schema(:books) - - name, options = table[0] # id - name.must_equal :id - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal true - options.fetch(:auto_increment).must_equal true - - name, options = table[1] # title - name.must_equal :title - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "varchar(255)" - options.fetch(:primary_key).must_equal false - - name, options = table[2] # price (rolled back second migration) - name.must_be_nil - options.must_be_nil - end - end - end - - describe "apply" do - before do - uri = @uri - @migrations_root = migrations_root = Pathname.new(__dir__ + '/../../../tmp') - @fixtures_root = fixtures_root = Pathname.new(__dir__ + '/../../fixtures/migrations') - - migrations_root.mkpath - FileUtils.cp_r(fixtures_root, migrations_root) - - Hanami::Model.unload! - Hanami::Model.configure do - adapter type: :sql, uri: uri - - migrations migrations_root.join('migrations') - schema migrations_root.join('schema-sqlite.sql') - end - - Hanami::Model::Migrator.apply - end - - after do - Hanami::Model.unload! - end - - it "migrates to latest version" do - connection = Sequel.connect(@uri) - migration = connection[:schema_migrations].to_a.last - - migration.fetch(:filename).must_include("20150610141017") - end - - it "dumps database schema.sql" do - schema = @migrations_root.join('schema-sqlite.sql').read - - schema.must_include %(CREATE TABLE `schema_migrations` (`filename` varchar(255) NOT NULL PRIMARY KEY);) - schema.must_include %(CREATE TABLE `books` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `title` varchar(255) NOT NULL, `price` integer DEFAULT (100));) - schema.must_include %(INSERT INTO "schema_migrations" VALUES('20150610133853_create_books.rb');) - schema.must_include %(INSERT INTO "schema_migrations" VALUES('20150610141017_add_price_to_books.rb');) - end - - it "deletes all the migrations" do - @migrations_root.join('migrations').children.must_be :empty? - end - end - - describe "prepare" do - before do - uri = @uri - @migrations_root = migrations_root = Pathname.new(__dir__ + '/../../../tmp') - @fixtures_root = fixtures_root = Pathname.new(__dir__ + '/../../fixtures/migrations') - - migrations_root.mkpath - FileUtils.cp_r(fixtures_root, migrations_root) - - Hanami::Model.unload! - Hanami::Model.configure do - adapter type: :sql, uri: uri - - migrations migrations_root.join('migrations') - schema migrations_root.join('schema-sqlite.sql') - end - end - - it "creates database, loads schema and migrate" do - # Simulate already existing schema.sql, without existing database and pending migrations - connection = Sequel.connect(@uri) - Hanami::Model::Migrator::Adapter.for(connection).dump - - FileUtils.cp 'test/fixtures/20150611165922_create_authors.rb', - @migrations_root.join('migrations/20150611165922_create_authors.rb') - - Hanami::Model::Migrator.prepare - - connection.tables.must_equal [:schema_migrations, :books, :authors] - - FileUtils.rm_f @migrations_root.join('migrations/20150611165922_create_authors.rb') - end - - it "works even if schema doesn't exist" do - # Simulate no database, no schema and pending migrations - FileUtils.rm_f @migrations_root.join('schema-sqlite.sql') - Hanami::Model::Migrator.prepare - - connection = Sequel.connect(@uri) - connection.tables.must_equal [:schema_migrations, :books] - end - - it "drops the database and recreate it" do - Hanami::Model::Migrator.create - Hanami::Model::Migrator.prepare - - connection = Sequel.connect(@uri) - connection.tables.must_include(:schema_migrations) - connection.tables.must_include(:books) - end - end - - describe "version" do - before do - Hanami::Model::Migrator.create - end - - describe "when no migrations were ran" do - it "returns nil" do - version = Hanami::Model::Migrator.version - version.must_be_nil - end - end - - describe "with migrations" do - before do - Hanami::Model::Migrator.migrate - end - - it "returns current database version" do - version = Hanami::Model::Migrator.version - version.must_equal "20150610141017" - end - end - end - end -end diff --git a/test/integration/migration_test.rb b/test/integration/migration_test.rb index 500ab12b..fa226823 100644 --- a/test/integration/migration_test.rb +++ b/test/integration/migration_test.rb @@ -1,524 +1,5 @@ require 'test_helper' -require 'hanami/model/migrator' -describe "Hanami::Model.migration" do - let(:adapter_prefix) { 'jdbc:' if Hanami::Utils.jruby? } - - after(:each) do - Hanami::Model.unload! - @connection && @connection.disconnect - end - - describe "SQLite" do - before do - @database = Pathname.new("#{ __dir__ }/../../tmp/migration.sqlite3").expand_path - @schema = schema_path = Pathname.new("#{ __dir__ }/../../tmp/schema.sql").expand_path - @uri = uri = "#{ adapter_prefix }sqlite://#{ @database }" - - Hanami::Model.configure do - adapter type: :sql, uri: uri - migrations __dir__ + '/../fixtures/database_migrations' - schema schema_path - end - - Hanami::Model::Migrator.create - Hanami::Model::Migrator.migrate - - @connection = Sequel.connect(@uri) - Hanami::Model::Migrator::Adapter.for(@connection).dump - end - - after(:each) do - File.delete(@database) - File.delete(@schema) - end - - describe "columns" do - it "defines column types" do - table = @connection.schema(:column_types) - - name, options = table[0] - name.must_equal :integer1 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal false - - name, options = table[1] - name.must_equal :integer2 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal false - - name, options = table[2] - name.must_equal :integer3 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal false - - name, options = table[3] - name.must_equal :string1 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "varchar(255)" - options.fetch(:primary_key).must_equal false - - name, options = table[4] - name.must_equal :string2 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "string" - options.fetch(:primary_key).must_equal false - - name, options = table[5] - name.must_equal :string3 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "string" - options.fetch(:primary_key).must_equal false - - name, options = table[6] - name.must_equal :string4 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "varchar(3)" - options.fetch(:primary_key).must_equal false - - name, options = table[7] - name.must_equal :string5 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "varchar(50)" - options.fetch(:max_length).must_equal 50 - options.fetch(:primary_key).must_equal false - - name, options = table[8] - name.must_equal :string6 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "char(255)" - options.fetch(:primary_key).must_equal false - - name, options = table[9] - name.must_equal :string7 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "char(64)" - options.fetch(:max_length).must_equal 64 - options.fetch(:primary_key).must_equal false - - name, options = table[10] - name.must_equal :string8 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "text" - options.fetch(:primary_key).must_equal false - - name, options = table[11] - name.must_equal :file1 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :blob - options.fetch(:db_type).must_equal "blob" - options.fetch(:primary_key).must_equal false - - name, options = table[12] - name.must_equal :file2 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :blob - options.fetch(:db_type).must_equal "blob" - options.fetch(:primary_key).must_equal false - - name, options = table[13] - name.must_equal :number1 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal false - - name, options = table[14] - name.must_equal :number2 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "bigint" - options.fetch(:primary_key).must_equal false - - name, options = table[15] - name.must_equal :number3 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :float - options.fetch(:db_type).must_equal "double precision" - options.fetch(:primary_key).must_equal false - - name, options = table[16] - name.must_equal :number4 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :decimal - options.fetch(:db_type).must_equal "numeric" - options.fetch(:primary_key).must_equal false - - name, options = table[17] - name.must_equal :number5 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - # options.fetch(:type).must_equal :decimal - options.fetch(:db_type).must_equal "numeric(10)" - options.fetch(:primary_key).must_equal false - - name, options = table[18] - name.must_equal :number6 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :decimal - options.fetch(:db_type).must_equal "numeric(10, 2)" - options.fetch(:primary_key).must_equal false - - name, options = table[19] - name.must_equal :number7 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :decimal - options.fetch(:db_type).must_equal "numeric" - options.fetch(:primary_key).must_equal false - - name, options = table[20] - name.must_equal :date1 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :date - options.fetch(:db_type).must_equal "date" - options.fetch(:primary_key).must_equal false - - name, options = table[21] - name.must_equal :date2 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :datetime - options.fetch(:db_type).must_equal "timestamp" - options.fetch(:primary_key).must_equal false - - name, options = table[22] - name.must_equal :time1 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :datetime - options.fetch(:db_type).must_equal "timestamp" - options.fetch(:primary_key).must_equal false - - name, options = table[23] - name.must_equal :time2 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :time - options.fetch(:db_type).must_equal "time" - options.fetch(:primary_key).must_equal false - - name, options = table[24] - name.must_equal :boolean1 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :boolean - options.fetch(:db_type).must_equal "boolean" - options.fetch(:primary_key).must_equal false - - name, options = table[25] - name.must_equal :boolean2 - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :boolean - options.fetch(:db_type).must_equal "boolean" - options.fetch(:primary_key).must_equal false - end - - it "defines column defaults" do - table = @connection.schema(:default_values) - - name, options = table[0] - name.must_equal :a - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "23" - options.fetch(:ruby_default).must_equal 23 - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal false - - name, options = table[1] - name.must_equal :b - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "'Hanami'" - options.fetch(:ruby_default).must_equal "Hanami" - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "varchar(255)" - options.fetch(:primary_key).must_equal false - - name, options = table[2] - name.must_equal :c - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "-1" - options.fetch(:ruby_default).must_equal(-1) - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal false - - name, options = table[3] - name.must_equal :d - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "0" - options.fetch(:ruby_default).must_equal 0 - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "bigint" - options.fetch(:primary_key).must_equal false - - name, options = table[4] - name.must_equal :e - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "3.14" - options.fetch(:ruby_default).must_equal 3.14 - options.fetch(:type).must_equal :float - options.fetch(:db_type).must_equal "double precision" - options.fetch(:primary_key).must_equal false - - name, options = table[5] - name.must_equal :f - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "1.0" - options.fetch(:ruby_default).must_equal 1.0 - options.fetch(:type).must_equal :decimal - options.fetch(:db_type).must_equal "numeric" - options.fetch(:primary_key).must_equal false - - name, options = table[6] - name.must_equal :g - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "943943" - options.fetch(:ruby_default).must_equal 943943 - options.fetch(:type).must_equal :decimal - options.fetch(:db_type).must_equal "numeric" - options.fetch(:primary_key).must_equal false - - name, options = table[10] - name.must_equal :k - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "1" - options.fetch(:ruby_default).must_equal true - options.fetch(:type).must_equal :boolean - options.fetch(:db_type).must_equal "boolean" - options.fetch(:primary_key).must_equal false - - name, options = table[11] - name.must_equal :l - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal "0" - options.fetch(:ruby_default).must_equal false - options.fetch(:type).must_equal :boolean - options.fetch(:db_type).must_equal "boolean" - options.fetch(:primary_key).must_equal false - end - - it "defines null constraint" do - table = @connection.schema(:null_constraints) - - name, options = table[0] - name.must_equal :a - - options.fetch(:allow_null).must_equal true - - name, options = table[1] - name.must_equal :b - - options.fetch(:allow_null).must_equal false - - name, options = table[2] - name.must_equal :c - - options.fetch(:allow_null).must_equal true - end - - it "defines column index" do - indexes = @connection.indexes(:column_indexes) - - indexes.fetch(:column_indexes_a_index, nil).must_be_nil - indexes.fetch(:column_indexes_b_index, nil).must_be_nil - - index = indexes.fetch(:column_indexes_c_index) - index[:unique].must_equal false - index[:columns].must_equal [:c] - end - - it "defines index via #index" do - indexes = @connection.indexes(:column_indexes) - - index = indexes.fetch(:column_indexes_d_index) - index[:unique].must_equal true - index[:columns].must_equal [:d] - - index = indexes.fetch(:column_indexes_b_c_index) - index[:unique].must_equal false - index[:columns].must_equal [:b, :c] - - index = indexes.fetch(:column_indexes_coords_index) - index[:unique].must_equal false - index[:columns].must_equal [:lat, :lng] - end - - it "defines primary key (via #primary_key :id)" do - table = @connection.schema(:primary_keys_1) - - name, options = table[0] - name.must_equal :id - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal true - options.fetch(:auto_increment).must_equal true - end - - it "defines composite primary key (via #primary_key [:column1, :column2])" do - table = @connection.schema(:primary_keys_3) - - name, options = table[0] - name.must_equal :group_id - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal true - options.fetch(:auto_increment).must_equal true - - name, options = table[1] - name.must_equal :position - - options.fetch(:allow_null).must_equal true - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal true - options.fetch(:auto_increment).must_equal true - end - - it "defines primary key (via #column primary_key: true)" do - table = @connection.schema(:primary_keys_2) - - name, options = table[0] - name.must_equal :name - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :string - options.fetch(:db_type).must_equal "varchar(255)" - options.fetch(:primary_key).must_equal true - options.fetch(:auto_increment).must_equal false - end - - it "defines foreign key (via #foreign_key)" do - table = @connection.schema(:albums) - - name, options = table[1] - name.must_equal :artist_id - - options.fetch(:allow_null).must_equal false - options.fetch(:default).must_equal nil - options.fetch(:type).must_equal :integer - options.fetch(:db_type).must_equal "integer" - options.fetch(:primary_key).must_equal false - - foreign_key = @connection.foreign_key_list(:albums).first - foreign_key.fetch(:columns).must_equal [:artist_id] - foreign_key.fetch(:table).must_equal :artists - foreign_key.fetch(:key).must_equal nil - foreign_key.fetch(:on_update).must_equal :no_action - foreign_key.fetch(:on_delete).must_equal :cascade - end - - it "defines column constraint and check" do - @schema.read.must_include %(CREATE TABLE `table_constraints` (`age` integer, `role` varchar(255), CONSTRAINT `age_constraint` CHECK (`age` > 18), CHECK (role IN("contributor", "manager", "owner")));) - end - end - end - - describe "File system" do - before do - Hanami::Model.configure do - adapter type: :file_system, uri: "file:///db/test417_development" - end - end - - describe "connection" do - it "return error" do - exception = -> { Hanami::Model::Migrator.create }.must_raise Hanami::Model::MigrationError - exception.message.must_include "Current adapter (file_system) doesn't support SQL database operations." - end - end - end - - describe "Memory" do - before do - Hanami::Model.configure do - adapter type: :memory, uri: "memory://localhost" - end - end - - describe "connection" do - it "return error" do - exception = -> { Hanami::Model::Migrator.create }.must_raise Hanami::Model::MigrationError - exception.message.must_include "Current adapter (memory) doesn't support SQL database operations." - end - end - end +describe 'Hanami::Model.migration' do + load "test/integration/migration/#{Database.engine}.rb" end diff --git a/test/integration/repository/base_test.rb b/test/integration/repository/base_test.rb new file mode 100644 index 00000000..9a5f5cc0 --- /dev/null +++ b/test/integration/repository/base_test.rb @@ -0,0 +1,489 @@ +require 'test_helper' + +describe 'Repository (base)' do + extend PlatformHelpers + + describe '#find' do + it 'finds record by primary key' do + repository = UserRepository.new + user = repository.create(name: 'L') + found = repository.find(user.id) + + found.must_equal(user) + end + + it 'returns nil for missing record' do + repository = UserRepository.new + found = repository.find('9999999') + + found.must_be_nil + end + end + + describe '#all' do + it 'returns all the records' do + repository = UserRepository.new + user = repository.create(name: 'L') + + repository.all.to_a.must_include user + end + end + + describe '#first' do + it 'returns first record from table' do + repository = UserRepository.new + repository.clear + + user = repository.create(name: 'James Hetfield') + repository.create(name: 'Tom') + + repository.first.must_equal user + end + end + + describe '#last' do + it 'returns last record from table' do + repository = UserRepository.new + repository.clear + + repository.create(name: 'Tom') + user = repository.create(name: 'Ella Fitzgerald') + + repository.last.must_equal user + end + end + + describe '#clear' do + it 'clears all the records' do + repository = UserRepository.new + repository.create(name: 'L') + + repository.clear + repository.all.to_a.must_be :empty? + end + end + + describe '#execute' do + end + + describe '#fetch' do + end + + describe '#create' do + it 'creates record from data' do + repository = UserRepository.new + user = repository.create(name: 'L') + + user.must_be_instance_of(User) + user.id.wont_be_nil + user.name.must_equal 'L' + end + + it 'creates record from entity' do + entity = User.new(name: 'L') + repository = UserRepository.new + user = repository.create(entity) + + # It doesn't mutate original entity + entity.id.must_be_nil + + user.must_be_instance_of(User) + user.id.wont_be_nil + user.name.must_equal 'L' + end + + with_platform(engine: :jruby, db: :sqlite) do + it 'automatically touches timestamps' + end + + unless_platform(engine: :jruby, db: :sqlite) do + it 'automatically touches timestamps' do + repository = UserRepository.new + user = repository.create(name: 'L') + + user.created_at.must_be_close_to Time.now.utc, 2 + user.updated_at.must_be_close_to Time.now.utc, 2 + end + end + + # Bug: https://github.com/hanami/model/issues/237 + it 'respects database defaults' do + repository = UserRepository.new + user = repository.create(name: 'L') + + user.comments_count.must_equal 0 + end + + # Bug: https://github.com/hanami/model/issues/272 + it 'accepts booleans as attributes' do + user = UserRepository.new.create(name: 'L', active: false) + user.active.must_equal false + end + + it 'raises error when generic database error is raised' + # it 'raises error when generic database error is raised' do + # error = Hanami::Model::DatabaseError + # message = Platform.match do + # engine(:ruby).db(:sqlite) { 'SQLite3::SQLException: table users has no column named bogus' } + # engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: table users has no column named bogus' } + + # engine(:ruby).db(:postgresql) { 'PG::UndefinedColumn: ERROR: column "bogus" of relation "users" does not exist' } + # engine(:jruby).db(:postgresql) { 'bogus' } + + # engine(:ruby).db(:mysql) { "Mysql2::Error: Unknown column 'bogus' in 'field list'" } + # engine(:jruby).db(:mysql) { 'bogus' } + # end + + # exception = -> { UserRepository.new.create(name: 'L', bogus: 23) }.must_raise(error) + # exception.message.must_include message + # end + + it 'raises error when "not null" database constraint is violated' do + error = Hanami::Model::NotNullConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { 'SQLite3::ConstraintException' } + engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: NOT NULL constraint failed: users.active' } + + engine(:ruby).db(:postgresql) { 'PG::NotNullViolation: ERROR: null value in column "active" violates not-null constraint' } + engine(:jruby).db(:postgresql) { 'Java::OrgPostgresqlUtil::PSQLException: ERROR: null value in column "active" violates not-null constraint' } + + engine(:ruby).db(:mysql) { "Mysql2::Error: Column 'active' cannot be null" } + engine(:jruby).db(:mysql) { "Java::ComMysqlJdbcExceptionsJdbc4::MySQLIntegrityConstraintViolationException: Column 'active' cannot be null" } + end + + exception = -> { UserRepository.new.create(name: 'L', active: nil) }.must_raise(error) + exception.message.must_include message + end + + it 'raises error when "unique constraint" is violated' do + email = "user@#{SecureRandom.uuid}.test" + + error = Hanami::Model::UniqueConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { 'SQLite3::ConstraintException' } + engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: UNIQUE constraint failed: users.email' } + + engine(:ruby).db(:postgresql) { 'PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "users_email_index"' } + engine(:jruby).db(:postgresql) { %(Java::OrgPostgresqlUtil::PSQLException: ERROR: duplicate key value violates unique constraint "users_email_index"\n Detail: Key (email)=(#{email}) already exists.) } + + engine(:ruby).db(:mysql) { "Mysql2::Error: Duplicate entry '#{email}' for key 'users_email_index'" } + engine(:jruby).db(:mysql) { "Java::ComMysqlJdbcExceptionsJdbc4::MySQLIntegrityConstraintViolationException: Duplicate entry '#{email}' for key 'users_email_index'" } + end + + repository = UserRepository.new + repository.create(name: 'Test', email: email) + + exception = -> { repository.create(name: 'L', email: email) }.must_raise(error) + exception.message.must_include message + end + + it 'raises error when "foreign key" constraint is violated' do + error = Hanami::Model::ForeignKeyConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { 'SQLite3::ConstraintException' } + engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: FOREIGN KEY constraint failed' } + + engine(:ruby).db(:postgresql) { 'PG::ForeignKeyViolation: ERROR: insert or update on table "avatars" violates foreign key constraint "avatars_user_id_fkey"' } + engine(:jruby).db(:postgresql) { 'Java::OrgPostgresqlUtil::PSQLException: ERROR: insert or update on table "avatars" violates foreign key constraint "avatars_user_id_fkey"' } + + engine(:ruby).db(:mysql) { 'Mysql2::Error: Cannot add or update a child row: a foreign key constraint fails' } + engine(:jruby).db(:mysql) { 'Java::ComMysqlJdbcExceptionsJdbc4::MySQLIntegrityConstraintViolationException: Cannot add or update a child row: a foreign key constraint fails (`hanami_model`.`avatars`, CONSTRAINT `avatars_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE)' } + end + + exception = -> { AvatarRepository.new.create(user_id: 999_999_999) }.must_raise(error) + exception.message.must_include message + end + + # For MySQL [...] The CHECK clause is parsed but ignored by all storage engines. + # http://dev.mysql.com/doc/refman/5.7/en/create-table.html + unless_platform(db: :mysql) do + it 'raises error when "check" constraint is violated' do + error = Platform.match do + os(:linux).engine(:ruby).db(:sqlite) { Hanami::Model::ConstraintViolationError } + default { Hanami::Model::CheckConstraintViolationError } + end + + message = Platform.match do + engine(:ruby).db(:sqlite) { 'SQLite3::ConstraintException' } + engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: CHECK constraint failed: users' } + + engine(:ruby).db(:postgresql) { 'PG::CheckViolation: ERROR: new row for relation "users" violates check constraint "users_age_check"' } + engine(:jruby).db(:postgresql) { 'Java::OrgPostgresqlUtil::PSQLException: ERROR: new row for relation "users" violates check constraint "users_age_check"' } + end + + exception = -> { UserRepository.new.create(name: 'L', age: 1) }.must_raise(error) + exception.message.must_include message + end + + it 'raises error when constraint is violated' do + error = Platform.match do + os(:linux).engine(:ruby).db(:sqlite) { Hanami::Model::ConstraintViolationError } + default { Hanami::Model::CheckConstraintViolationError } + end + + message = Platform.match do + engine(:ruby).db(:sqlite) { 'SQLite3::ConstraintException' } + engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: CHECK constraint failed: comments_count_constraint' } + + engine(:ruby).db(:postgresql) { 'PG::CheckViolation: ERROR: new row for relation "users" violates check constraint "comments_count_constraint"' } + engine(:jruby).db(:postgresql) { 'Java::OrgPostgresqlUtil::PSQLException: ERROR: new row for relation "users" violates check constraint "comments_count_constraint"' } + end + + exception = -> { UserRepository.new.create(name: 'L', comments_count: -1) }.must_raise(error) + exception.message.must_include message + end + end + end + + describe '#update' do + it 'updates record from data' do + repository = UserRepository.new + user = repository.create(name: 'L') + updated = repository.update(user.id, name: 'Luca') + + updated.must_be_instance_of(User) + updated.id.must_equal user.id + updated.name.must_equal 'Luca' + end + + it 'updates record from entity' do + entity = User.new(name: 'Luca') + repository = UserRepository.new + user = repository.create(name: 'L') + updated = repository.update(user.id, entity) + + # It doesn't mutate original entity + entity.id.must_be_nil + + updated.must_be_instance_of(User) + updated.id.must_equal user.id + updated.name.must_equal 'Luca' + end + + it 'returns nil when record cannot be found' do + repository = UserRepository.new + updated = repository.update('9999999', name: 'Luca') + + updated.must_be_nil + end + + with_platform(engine: :jruby, db: :sqlite) do + it 'automatically touches timestamps' + end + + unless_platform(engine: :jruby, db: :sqlite) do + it 'automatically touches timestamps' do + repository = UserRepository.new + user = repository.create(name: 'L') + sleep 0.1 + updated = repository.update(user.id, name: 'Luca') + + updated.created_at.must_be_close_to user.created_at, 2 + updated.updated_at.must_be_close_to Time.now, 2 + end + end + + it 'raises error when generic database error is raised' + # it 'raises error when generic database error is raised' do + # error = Hanami::Model::DatabaseError + # message = Platform.match do + # engine(:ruby).db(:sqlite) { 'SQLite3::SQLException: no such column: bogus' } + # engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: no such column: bogus' } + + # engine(:ruby).db(:postgresql) { 'PG::UndefinedColumn: ERROR: column "bogus" of relation "users" does not exist' } + # engine(:jruby).db(:postgresql) { 'bogus' } + + # engine(:ruby).db(:mysql) { "Mysql2::Error: Unknown column 'bogus' in 'field list'" } + # engine(:jruby).db(:mysql) { 'bogus' } + # end + + # repository = UserRepository.new + # user = repository.create(name: 'L') + + # exception = -> { repository.update(user.id, bogus: 23) }.must_raise(error) + # exception.message.must_include message + # end + + # MySQL doesn't raise an error on CI + unless_platform(os: :linux, engine: :ruby, db: :mysql) do + it 'raises error when "not null" database constraint is violated' do + error = Hanami::Model::NotNullConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { 'SQLite3::ConstraintException' } + engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: NOT NULL constraint failed: users.active' } + + engine(:ruby).db(:postgresql) { 'PG::NotNullViolation: ERROR: null value in column "active" violates not-null constraint' } + engine(:jruby).db(:postgresql) { 'Java::OrgPostgresqlUtil::PSQLException: ERROR: null value in column "active" violates not-null constraint' } + + engine(:ruby).db(:mysql) { "Mysql2::Error: Column 'active' cannot be null" } + engine(:jruby).db(:mysql) { "Java::ComMysqlJdbcExceptionsJdbc4::MySQLIntegrityConstraintViolationException: Column 'active' cannot be null" } + end + + repository = UserRepository.new + user = repository.create(name: 'L') + + exception = -> { repository.update(user.id, active: nil) }.must_raise(error) + exception.message.must_include message + end + end + + it 'raises error when "unique constraint" is violated' do + email = "update@#{SecureRandom.uuid}.test" + + error = Hanami::Model::UniqueConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { 'SQLite3::ConstraintException' } + engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: UNIQUE constraint failed: users.email' } + + engine(:ruby).db(:postgresql) { 'PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "users_email_index"' } + engine(:jruby).db(:postgresql) { 'Java::OrgPostgresqlUtil::PSQLException: ERROR: duplicate key value violates unique constraint "users_email_index"' } + + engine(:ruby).db(:mysql) { "Mysql2::Error: Duplicate entry '#{email}' for key 'users_email_index'" } + engine(:jruby).db(:mysql) { "Java::ComMysqlJdbcExceptionsJdbc4::MySQLIntegrityConstraintViolationException: Duplicate entry '#{email}' for key 'users_email_index'" } + end + + repository = UserRepository.new + user = repository.create(name: 'L') + repository.create(name: 'UpdateTest', email: email) + + exception = -> { repository.update(user.id, email: email) }.must_raise(error) + exception.message.must_include message + end + + it 'raises error when "foreign key" constraint is violated' do + error = Hanami::Model::ForeignKeyConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { 'SQLite3::ConstraintException' } + engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: FOREIGN KEY constraint failed' } + + engine(:ruby).db(:postgresql) { 'PG::ForeignKeyViolation: ERROR: insert or update on table "avatars" violates foreign key constraint "avatars_user_id_fkey"' } + engine(:jruby).db(:postgresql) { 'Java::OrgPostgresqlUtil::PSQLException: ERROR: insert or update on table "avatars" violates foreign key constraint "avatars_user_id_fkey"' } + + engine(:ruby).db(:mysql) { 'Mysql2::Error: Cannot add or update a child row: a foreign key constraint fails' } + engine(:jruby).db(:mysql) { 'Java::ComMysqlJdbcExceptionsJdbc4::MySQLIntegrityConstraintViolationException: Cannot add or update a child row: a foreign key constraint fails (`hanami_model`.`avatars`, CONSTRAINT `avatars_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE)' } + end + + user = UserRepository.new.create(name: 'L') + repository = AvatarRepository.new + avatar = repository.create(user_id: user.id) + + exception = -> { repository.update(avatar.id, user_id: 999_999_999) }.must_raise(error) + exception.message.must_include message + end + + # For MySQL [...] The CHECK clause is parsed but ignored by all storage engines. + # http://dev.mysql.com/doc/refman/5.7/en/create-table.html + unless_platform(db: :mysql) do + it 'raises error when "check" constraint is violated' do + error = Platform.match do + os(:linux).engine(:ruby).db(:sqlite) { Hanami::Model::ConstraintViolationError } + default { Hanami::Model::CheckConstraintViolationError } + end + + message = Platform.match do + engine(:ruby).db(:sqlite) { 'SQLite3::ConstraintException' } + engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: CHECK constraint failed: users' } + + engine(:ruby).db(:postgresql) { 'PG::CheckViolation: ERROR: new row for relation "users" violates check constraint "users_age_check"' } + engine(:jruby).db(:postgresql) { 'Java::OrgPostgresqlUtil::PSQLException: ERROR: new row for relation "users" violates check constraint "users_age_check"' } + end + + repository = UserRepository.new + user = repository.create(name: 'L') + + exception = -> { repository.update(user.id, age: 17) }.must_raise(error) + exception.message.must_include message + end + + it 'raises error when constraint is violated' do + error = Platform.match do + os(:linux).engine(:ruby).db(:sqlite) { Hanami::Model::ConstraintViolationError } + default { Hanami::Model::CheckConstraintViolationError } + end + + message = Platform.match do + engine(:ruby).db(:sqlite) { 'SQLite3::ConstraintException' } + engine(:jruby).db(:sqlite) { 'Java::JavaSql::SQLException: CHECK constraint failed: comments_count_constraint' } + + engine(:ruby).db(:postgresql) { 'PG::CheckViolation: ERROR: new row for relation "users" violates check constraint "comments_count_constraint"' } + engine(:jruby).db(:postgresql) { 'Java::OrgPostgresqlUtil::PSQLException: ERROR: new row for relation "users" violates check constraint "comments_count_constraint"' } + end + + repository = UserRepository.new + user = repository.create(name: 'L') + + exception = -> { repository.update(user.id, comments_count: -2) }.must_raise(error) + exception.message.must_include message + end + end + end + + describe '#delete' do + it 'deletes record' do + repository = UserRepository.new + user = repository.create(name: 'L') + deleted = repository.delete(user.id) + + deleted.must_be_instance_of(User) + deleted.id.must_equal user.id + deleted.name.must_equal 'L' + + found = repository.find(user.id) + found.must_be_nil + end + + it 'returns nil when record cannot be found' do + repository = UserRepository.new + deleted = repository.delete('9999999') + + deleted.must_be_nil + end + end + + describe '#transaction' do + end + + describe 'custom finder' do + it 'returns records' do + repository = UserRepository.new + user = repository.create(name: 'L') + found = repository.by_name('L') + + found.to_a.must_include user + end + end + + with_platform(db: :postgresql) do + describe 'PostgreSQL' do + it 'finds record by primary key (UUID)' do + repository = SourceFileRepository.new + file = repository.create(name: 'path/to/file.rb', languages: ['ruby'], metadata: { coverage: 100.0 }, content: 'class Foo; end') + found = repository.find(file.id) + + file.languages.must_equal ['ruby'] + file.metadata.must_equal(coverage: 100.0) + + found.must_equal(file) + end + + it 'returns nil for nil primary key (UUID)' do + repository = SourceFileRepository.new + + found = repository.find(nil) + found.must_be_nil + end + + # FIXME: This raises the following error + # + # Sequel::DatabaseError: PG::InvalidTextRepresentation: ERROR: invalid input syntax for uuid: "9999999" + # LINE 1: ...", "updated_at" FROM "source_files" WHERE ("id" = '9999999')... + it 'returns nil for missing record (UUID)' + # it 'returns nil for missing record (UUID)' do + # repository = SourceFileRepository.new + + # found = repository.find('9999999') + # found.must_be_nil + # end + end + end +end diff --git a/test/integration/repository/legacy_test.rb b/test/integration/repository/legacy_test.rb new file mode 100644 index 00000000..52e288f5 --- /dev/null +++ b/test/integration/repository/legacy_test.rb @@ -0,0 +1,124 @@ +require 'test_helper' + +describe 'Repository (legacy)' do + describe '#find' do + it 'finds record by primary key' do + repository = OperatorRepository.new + operator = repository.create(name: 'F') + found = repository.find(operator.id) + + operator.must_equal(found) + end + + it 'returns nil for missing record' do + repository = OperatorRepository.new + found = repository.find('9999999') + + found.must_be_nil + end + end + + describe '#all' do + it 'returns all the records' do + repository = OperatorRepository.new + operator = repository.create(name: 'F') + + repository.all.to_a.must_include operator + end + end + + describe '#first' do + it 'returns first record from table' do + repository = OperatorRepository.new + repository.clear + + operator = repository.create(name: 'Janis Joplin') + repository.create(name: 'Jon') + + repository.first.must_equal operator + end + end + + describe '#last' do + it 'returns last record from table' do + repository = OperatorRepository.new + repository.clear + + repository.create(name: 'Rob') + operator = repository.create(name: 'Amy Winehouse') + + repository.last.must_equal operator + end + end + + describe '#clear' do + it 'clears all the records' do + repository = OperatorRepository.new + repository.create(name: 'F') + + repository.clear + repository.all.to_a.must_be :empty? + end + end + + describe '#execute' do + end + + describe '#fetch' do + end + + describe '#create' do + it 'creates record' do + repository = OperatorRepository.new + operator = repository.create(name: 'F') + + operator.must_be_instance_of(Operator) + operator.id.wont_be_nil + operator.name.must_equal 'F' + end + end + + describe '#update' do + it 'updates record' do + repository = OperatorRepository.new + operator = repository.create(name: 'F') + updated = repository.update(operator.id, name: 'Flo') + + updated.must_be_instance_of(Operator) + updated.id.must_equal operator.id + updated.name.must_equal 'Flo' + end + + it 'returns nil when record cannot be found' do + repository = OperatorRepository.new + updated = repository.update('9999999', name: 'Flo') + + updated.must_be_nil + end + end + + describe '#delete' do + it 'deletes record' do + repository = OperatorRepository.new + operator = repository.create(name: 'F') + deleted = repository.delete(operator.id) + + deleted.must_be_instance_of(Operator) + deleted.id.must_equal operator.id + deleted.name.must_equal 'F' + + found = repository.find(operator.id) + found.must_be_nil + end + + it 'returns nil when record cannot be found' do + repository = OperatorRepository.new + deleted = repository.delete('9999999') + + deleted.must_be_nil + end + end + + describe '#transaction' do + end +end diff --git a/test/integration/repository_test.rb b/test/integration/repository_test.rb deleted file mode 100644 index 7a472712..00000000 --- a/test/integration/repository_test.rb +++ /dev/null @@ -1,652 +0,0 @@ -require 'test_helper' - -describe Hanami::Repository do - let(:user1) { User.new(name: 'L') } - let(:user2) { User.new(name: 'MG') } - let(:users) { [user1, user2] } - - let(:article1) { Article.new(user_id: user1.id, title: 'Introducing Hanami::Model', comments_count: '23') } - let(:article2) { Article.new(user_id: user1.id, title: 'Thread safety', comments_count: '42') } - let(:article3) { Article.new(user_id: user2.id, title: 'Love Relationships', comments_count: '4') } - - { - memory: [Hanami::Model::Adapters::MemoryAdapter, MEMORY_CONNECTION_STRING, MAPPER], - file_system: [Hanami::Model::Adapters::FileSystemAdapter, FILE_SYSTEM_CONNECTION_STRING, MAPPER], - sqlite: [Hanami::Model::Adapters::SqlAdapter, SQLITE_CONNECTION_STRING, MAPPER], - postgres: [Hanami::Model::Adapters::SqlAdapter, POSTGRES_CONNECTION_STRING, MAPPER], - }.each do |adapter_name, (adapter,uri,mapper)| - describe "with #{ adapter_name } adapter" do - before do - UserRepository.adapter = adapter.new(mapper, uri) - ArticleRepository.adapter = adapter.new(mapper, uri) - - UserRepository.collection = :users - ArticleRepository.collection = :articles - - UserRepository.new.clear - ArticleRepository.new.clear - end - - after(:each) do - UserRepository.adapter.disconnect - ArticleRepository.adapter.disconnect - end - - describe '.collection' do - it 'returns the collection name' do - UserRepository.collection.must_equal :users - ArticleRepository.collection.must_equal :articles - end - - it 'allows different collections by subclass' do - UserRepository.collection = :users - SubclassedUserRepository.collection = :special_users - - UserRepository.collection.must_equal :users - SubclassedUserRepository.collection.must_equal :special_users - end - end - - describe '#adapter' do - it 'returns the adapter configured on class level' do - UserRepository.new.adapter.must_equal UserRepository.adapter - end - end - - describe '#collection' do - it 'returns the collection name configured on the class level' do - UserRepository.new.collection.must_equal :users - ArticleRepository.new.collection.must_equal :articles - end - end - - - describe '.persist' do - describe 'when passed a non-persisted entity' do - let(:unpersisted_user) { User.new(name: 'Don', age: '25') } - - it 'should return that entity' do - persisted_user = UserRepository.new.persist(unpersisted_user) - - persisted_user.id.wont_be_nil - persisted_user.name.must_equal(unpersisted_user.name) - persisted_user.age.must_equal(unpersisted_user.age.to_i) - end - - it 'returns a copy of the entity passed as argument' do - persisted_user = UserRepository.new.persist(unpersisted_user) - refute_same persisted_user, unpersisted_user - end - - it 'does not assign an id on the entity passed as argument' do - UserRepository.new.persist(unpersisted_user) - unpersisted_user.id.must_be_nil - end - - it 'should coerce attributes' do - persisted_user = UserRepository.new.persist(unpersisted_user) - persisted_user.age.must_equal(25) - end - - if adapter_name == :postgres - it 'should use custom coercers' do - article = Article.new(title: 'Coercer', tags: tags = ['ruby', 'hanami']) - article = ArticleRepository.new.persist(article) - - article.tags.must_equal tags - article.tags.class.must_equal ::Array - end - end - - it 'assigns and persist created_at attribute' do - persisted_user = UserRepository.new.persist(unpersisted_user) - persisted_user.created_at.wont_be_nil - end - - it 'assigns and persist updated_at attribute' do - persisted_user = UserRepository.new.persist(unpersisted_user) - persisted_user.updated_at.must_equal persisted_user.created_at - end - end - - describe 'when passed a persisted entity' do - let(:user) { UserRepository.new.create(User.new(name: 'Don')) } - let(:persisted_user) { UserRepository.new.persist(user) } - - before do - @updated_at = user.updated_at - - # Ensure we're updating sufficiently later of the creation, we can get realy close dates in - # concurrent platforms like jRuby - sleep 2 if Hanami::Utils.jruby? - end - - it 'should return that entity' do - UserRepository.new.persist(persisted_user).must_equal(user) - end - - it 'does not touch created_at' do - UserRepository.new.persist(persisted_user) - - persisted_user.created_at.wont_be_nil - end - - it 'touches updated_at' do - updated_user = UserRepository.new.persist(user) - - assert updated_user.updated_at > @updated_at - end - end - end - - describe '.create' do - before do - @users = [ - UserRepository.new.create(user1), - UserRepository.new.create(user2) - ] - end - - it 'persist entities' do - UserRepository.new.all.must_equal(@users) - end - - it 'creates different kind of entities' do - result = ArticleRepository.new.create(article1) - ArticleRepository.new.all.must_equal([result]) - end - - it 'does nothing when already persisted' do - id = user1.id - - UserRepository.new.create(user1) - user1.id.must_equal id - end - - it 'returns nil when trying to create an already persisted entity' do - created_user = UserRepository.new.create(User.new(name: 'Pascal')) - value = UserRepository.new.create(created_user) - value.must_be_nil - end - - describe 'when entity is not persisted' do - let(:unpersisted_user) { User.new(name: 'My', age: '23') } - - it 'assigns and persists created_at attribute' do - result = UserRepository.new.create(unpersisted_user) - result.created_at.wont_be_nil - end - - it 'assigns and persists updated_at attribute' do - result = UserRepository.new.create(unpersisted_user) - result.updated_at.must_equal result.created_at - end - end - - describe 'when entity is already persisted' do - before do - @persisted_user = UserRepository.new.create(User.new(name: 'My', age: '23')) - end - - after do - UserRepository.new.delete(@persisted_user) - end - - it 'does not touch created_at' do - UserRepository.new.create(@persisted_user) - @persisted_user.created_at.wont_be_nil - end - end - end - - describe '.update' do - before do - @user1 = UserRepository.new.create(user1) - @updated_at = @user1.updated_at - - # Ensure we're updating sufficiently later of the creation, we can get realy close dates in - # concurrent platforms like jRuby - sleep 2 if Hanami::Utils.jruby? - end - - it 'updates entities' do - user = User.new(name: 'Luca') - user.id = @user1.id - - updated_user = UserRepository.new.update(user) - - updated_user.name.must_equal('Luca') - end - - it 'touches updated_at' do - updated_user = UserRepository.new.update(@user1) - - assert updated_user.updated_at > @updated_at - end - - it 'raises an error when not persisted' do - -> { UserRepository.new.update(user2) }.must_raise(Hanami::Model::NonPersistedEntityError) - end - end - - describe '.delete' do - before do - @user = UserRepository.new.create(user) - UserRepository.new.delete(@user) - end - - let(:user) { User.new(name: 'D') } - - it 'delete entity' do - UserRepository.new.all.wont_include(@user) - end - - it 'raises error when the given entity is not persisted' do - -> { UserRepository.new.delete(user2) }.must_raise(Hanami::Model::NonPersistedEntityError) - end - end - - describe '.all' do - describe 'without data' do - it 'returns an empty collection' do - UserRepository.new.all.must_be_empty - end - end - - describe 'with data' do - before do - @users = [ - UserRepository.new.create(user1), - UserRepository.new.create(user2) - ] - end - - it 'returns all the entities' do - UserRepository.new.all.must_equal(@users) - end - end - end - - describe '.find' do - describe 'without data' do - it 'returns nil' do - UserRepository.new.find(1).must_be_nil - end - end - - describe 'with wrong type' do - it 'returns nil' do - UserRepository.new.find('incorrect-type').must_be_nil - end - end - - if adapter_name == :postgres - describe 'with id as uuid type' do - before do - RobotRepository.adapter = adapter.new(mapper, uri) - RobotRepository.collection = :robots - RobotRepository.new.clear - - @robot = RobotRepository.new.create(Robot.new(name: 'R2D2', build_date: Time.new(1970, 1, 1))) - end - - after(:each) do - RobotRepository.adapter.disconnect - end - - it 'returns correctly' do - RobotRepository.new.find(@robot.id).must_equal @robot - end - - it 'returns nil for invalid uuid' do - RobotRepository.new.find('6ca6bcaa-d36a-3be9-839c-21d6bce84e63').must_be_nil - end - - it 'returns nil for invalid id type' do - RobotRepository.new.find(1234).must_be_nil - end - end - end - - describe 'with data' do - before do - TestPrimaryKey = Struct.new(:id) do - def to_int - id - end - end - - @user1 = UserRepository.new.create(user1) - @user2 = UserRepository.new.create(user2) - - @article1 = ArticleRepository.new.create(article1) - end - - after do - Object.send(:remove_const, :TestPrimaryKey) - end - - it 'returns the entity associated with the given id' do - UserRepository.new.find(@user1.id).must_equal(@user1) - end - - it 'accepts a string as argument' do - UserRepository.new.find(@user2.id.to_s).must_equal(@user2) - end - - it 'accepts an object that can be coerced to integer' do - id = TestPrimaryKey.new(@user2.id) - UserRepository.new.find(id).must_equal(@user2) - end - - it 'coerces attributes as indicated by the mapper' do - result = ArticleRepository.new.find(@article1.id) - result.comments_count.must_be_kind_of(Integer) - end - - it "doesn't assign a value to unmapped attributes" do - ArticleRepository.new.find(@article1.id).unmapped_attribute.must_be_nil - end - - it "returns nil when the given id isn't associated with any entity" do - UserRepository.new.find(1_000_000).must_be_nil - end - end - end - - describe '.first' do - describe 'without data' do - it 'returns nil' do - UserRepository.new.first.must_be_nil - end - end - - describe 'with data' do - before do - @user1 = UserRepository.new.create(user1) - UserRepository.new.create(user2) - end - - it 'returns first record' do - UserRepository.new.first.must_equal(@user1) - end - end - end - - describe '.last' do - describe 'without data' do - it 'returns nil' do - UserRepository.new.last.must_be_nil - end - end - - describe 'with data' do - before do - UserRepository.new.create(user1) - @user2 = UserRepository.new.create(user2) - end - - it 'returns last record' do - UserRepository.new.last.must_equal(@user2) - end - end - end - - describe '.clear' do - describe 'without data' do - it 'removes all the records' do - UserRepository.new.clear - UserRepository.new.all.must_be_empty - end - end - - describe 'with data' do - before do - UserRepository.new.create(user1) - UserRepository.new.create(user2) - end - - it 'removes all the records' do - UserRepository.new.clear - UserRepository.new.all.must_be_empty - end - end - end - - describe 'querying' do - before do - @user1 = UserRepository.new.create(user1) - article1.user_id = article2.user_id = @user1.id - - @article1 = ArticleRepository.new.create(article1) - @article2 = ArticleRepository.new.create(article2) - ArticleRepository.new.create(article3) - end - - it 'defines custom finders' do - actual = ArticleRepository.new.by_user(@user1) - actual.all.must_equal [@article1, @article2] - end - - if adapter_name == :sql - it 'combines queries' do - actual = ArticleRepository.new.rank_by_user(@user1) - actual.all.must_equal [@article2, @article1] - end - - it 'negates a query' do - actual = ArticleRepository.new.not_by_user(@user1) - actual.all.must_equal [] - end - end - end - - describe 'dirty tracking' do - before do - @article = ArticleRepository.new.create(article1) - end - - it "hasn't dirty state after creation" do - @article.changed?.must_equal false - end - - it "hasn't dirty state after finding" do - found = ArticleRepository.new.find(@article.id) - found.changed?.must_equal false - end - - it "hasn't dirty state after update" do - @article.title = 'Dirty tracking' - @article = ArticleRepository.new.update(@article) - - @article.changed?.must_equal false - end - end - - describe 'missing timestamps attribute' do - describe '.persist' do - before do - @article = ArticleRepository.new.persist(Article.new(title: 'Hanami', comments_count: '4')) - @article.instance_eval do - def created_at - @created_at - end - - def updated_at - @updated_at - end - end - end - - after do - ArticleRepository.new.delete(@article) - end - - describe 'when entity does not have created_at accessor' do - it 'does not touch created_at' do - @article.created_at.must_be_nil - end - end - - describe 'when entity does not have updated_at accessor' do - it 'does not touch updated_at' do - @article.updated_at.must_be_nil - end - end - end - end - end - end - - describe "with sql adapter" do - before do - UserRepository.adapter = Hanami::Model::Adapters::SqlAdapter.new(MAPPER, SQLITE_CONNECTION_STRING) - ArticleRepository.adapter = Hanami::Model::Adapters::SqlAdapter.new(MAPPER, SQLITE_CONNECTION_STRING) - - UserRepository.collection = :users - ArticleRepository.collection = :articles - - UserRepository.new.clear - ArticleRepository.new.clear - - ArticleRepository.new.create(article1) - end - - after do - UserRepository.adapter.disconnect - end - - describe '.transaction' do - it 'if an exception is raised the size of articles is equal to 1' do - ArticleRepository.new.all.size.must_equal 1 - exception = -> { ArticleRepository.new.transaction do - ArticleRepository.new.create(article2) - raise Exception - end } - exception.must_raise Exception - ArticleRepository.new.all.size.must_equal 1 - end - - it "if an exception isn't raised the size of articles is equal to 2" do - ArticleRepository.new.all.size.must_equal 1 - ArticleRepository.new.transaction do - ArticleRepository.new.create(article2) - end - ArticleRepository.new.all.size.must_equal 2 - end - - describe 'using options' do - it 'rollback: :always option' do - ArticleRepository.new.all.size.must_equal 1 - ArticleRepository.new.transaction(rollback: :always) do - ArticleRepository.new.create(article2) - end - ArticleRepository.new.all.size.must_equal 1 - end - - it 'rollback: :reraise option' do - ArticleRepository.new.all.size.must_equal 1 - -> { ArticleRepository.new.transaction(rollback: :reraise) do - ArticleRepository.new.create(article2) - raise Exception - end }.must_raise Exception - ArticleRepository.new.all.size.must_equal 1 - end - end - end - - describe '.execute' do - before do - ArticleRepository.new.clear - @article = ArticleRepository.new.create(Article.new(title: 'WAT', comments_count: 100)) - end - - it 'is a private method' do - -> { ArticleRepository.execute("UPDATE articles SET comments_count = '0'") }.must_raise NoMethodError - end - - it 'executes the command and returns nil' do - result = ArticleRepository.new.reset_comments_count - result.must_be_nil - - ArticleRepository.new.find(@article.id).comments_count.must_equal 0 - end - end - - describe '.fetch' do - before do - ArticleRepository.new.clear - @article = ArticleRepository.new.create(Article.new(title: 'Art 1')) - end - - it 'is a private method' do - -> { ArticleRepository.fetch("SELECT s_title FROM articles") }.must_raise NoMethodError - end - - it 'returns the raw ResultSet for the given SQL' do - result = ArticleRepository.new.find_raw - - result.must_be_kind_of(::Array) - result.size.must_equal(1) - - article = result.first - article[:_id].must_equal @article.id - article[:user_id].must_equal @article.user_id - article[:s_title].must_equal @article.title - article[:comments_count].must_equal @article.comments_count - article[:unmapped_column].must_be_nil - end - - it 'yields a block' do - result = ArticleRepository.new.each_titles - result.must_equal ['Art 1'] - end - - it 'returns an enumerable' do - result = ArticleRepository.new.map_titles - result.must_equal ['Art 1'] - end - end - end - - describe "with memory adapter" do - before do - UserRepository.adapter = Hanami::Model::Adapters::MemoryAdapter.new(MAPPER, MEMORY_CONNECTION_STRING) - ArticleRepository.adapter = Hanami::Model::Adapters::MemoryAdapter.new(MAPPER, MEMORY_CONNECTION_STRING) - - UserRepository.collection = :users - ArticleRepository.collection = :articles - - UserRepository.new.clear - ArticleRepository.new.clear - end - - describe '.transaction' do - it "an exception is raised because of memory adapter doesn't support transactions" do - -> { ArticleRepository.new.transaction do - raise "boom" - end }.must_raise RuntimeError - end - end - - describe '.execute' do - it 'is a private method' do - -> { ArticleRepository.execute("UPDATE articles SET comments_count = '0'") }.must_raise NoMethodError - end - - it "raises an exception because memory adapter doesn't support execute" do - -> { ArticleRepository.new.reset_comments_count }.must_raise NotImplementedError - end - end - - describe '.fetch' do - it 'is a private method' do - -> { ArticleRepository.fetch("SELECT * FROM articles") }.must_raise NoMethodError - end - - it "raises an exception because memory adapter doesn't support fetch" do - -> { ArticleRepository.new.find_raw }.must_raise NotImplementedError - end - end - end -end diff --git a/test/migrator/mysql.rb b/test/migrator/mysql.rb new file mode 100644 index 00000000..4f5505ec --- /dev/null +++ b/test/migrator/mysql.rb @@ -0,0 +1,332 @@ +require 'ostruct' + +describe 'MySQL Database migrations' do + let(:migrator) do + Hanami::Model::Migrator.new(configuration: configuration) + end + + let(:random) { SecureRandom.hex(4) } + + # General variables + let(:migrations) { Pathname.new(__dir__ + '/../fixtures/migrations') } + let(:schema) { nil } + let(:config) { OpenStruct.new(backend: :sql, url: url, _migrations: migrations, _schema: schema) } + let(:configuration) { Hanami::Model::Configuration.new(config) } + + # Variables for `apply` and `prepare` + let(:root) { Pathname.new("#{__dir__}/../../tmp").expand_path } + let(:source_migrations) { Pathname.new("#{__dir__}/../fixtures/migrations") } + let(:target_migrations) { root.join("migrations-#{random}") } + + after do + migrator.drop rescue nil # rubocop:disable Style/RescueModifier + end + + describe 'MySQL' do + let(:database) { "#{name.gsub(/[^\w]/, '_')}_#{random}" } + + let(:url) do + db = database + + Platform.match do + engine(:ruby) { "mysql2://localhost/#{db}?user=#{ENV['HANAMI_DATABASE_USERNAME']}" } + engine(:jruby) { "jdbc:mysql://localhost/#{db}?user=#{ENV['HANAMI_DATABASE_USERNAME']}&useSSL=false" } + end + end + + describe 'create' do + before do + migrator.create + end + + it 'creates the database' do + connection = Sequel.connect(url) + connection.tables.must_be :empty? + end + + it 'raises error if database is busy' do + Sequel.connect(url).tables + exception = -> { migrator.create }.must_raise Hanami::Model::MigrationError + exception.message.must_include 'Database creation failed. If the database exists,' + exception.message.must_include 'then its console may be open. See this issue for more details:' + exception.message.must_include 'https://github.com/hanami/model/issues/250' + end + end + + describe 'drop' do + before do + migrator.create + end + + it 'drops the database' do + migrator.drop + + -> { Sequel.connect(url).tables }.must_raise Sequel::DatabaseConnectionError + end + + it "raises error if database doesn't exist" do + migrator.drop # remove the first time + + exception = -> { migrator.drop }.must_raise Hanami::Model::MigrationError + exception.message.must_equal "Cannot find database: #{database}" + end + end + + describe 'migrate' do + before do + migrator.create + end + + describe 'when no migrations' do + let(:migrations) { Pathname.new(__dir__ + '/../fixtures/empty_migrations') } + + it "it doesn't alter database" do + migrator.migrate + + connection = Sequel.connect(url) + connection.tables.must_be :empty? + end + end + + describe 'when migrations are present' do + it 'migrates the database' do + migrator.migrate + + connection = Sequel.connect(url) + connection.tables.wont_be :empty? + + table = connection.schema(:reviews) + + name, options = table[0] # id + name.must_equal :id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + + name, options = table[1] # title + name.must_equal :title + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[2] # rating (second migration) + name.must_equal :rating + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '0' + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal false + end + end + + describe 'when migrations are ran twice' do + before do + migrator.migrate + end + + it "doesn't alter the schema" do + migrator.migrate + + connection = Sequel.connect(url) + connection.tables.wont_be :empty? + connection.tables.must_equal [:reviews, :schema_migrations] + end + end + + describe 'migrate down' do + before do + migrator.migrate + end + + it 'migrates the database' do + migrator.migrate(version: '20160831073534') # see test/fixtures/migrations + + connection = Sequel.connect(url) + connection.tables.wont_be :empty? + + table = connection.schema(:reviews) + + name, options = table[0] # id + name.must_equal :id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'int(11)' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + + name, options = table[1] # title + name.must_equal :title + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[2] # rating (rolled back second migration) + name.must_be_nil + options.must_be_nil + end + end + end + + describe 'apply' do + let(:migrations) { target_migrations } + let(:schema) { root.join("schema-#{random}.sql") } + + before do + prepare_migrations_directory + migrator.create + migrator.apply + end + + after do + clean_migrations + end + + it 'migrates to latest version' do + connection = Sequel.connect(url) + migration = connection[:schema_migrations].to_a.last + + migration.fetch(:filename).must_include('20160831090612') # see test/fixtures/migrations + end + + it 'dumps database schema.sql' do + actual = schema.read + + actual.must_include %(DROP TABLE IF EXISTS `reviews`;) + + actual.must_include %(CREATE TABLE `reviews`) + actual.must_include %(`id` int\(11\) NOT NULL AUTO_INCREMENT,) + + actual.must_include %(`title` varchar(255)) + + actual.must_include %(`rating` int\(11\) DEFAULT '0',) + actual.must_include %(PRIMARY KEY \(`id`\)) + + actual.must_include %(DROP TABLE IF EXISTS `schema_migrations`;) + + actual.must_include %(CREATE TABLE `schema_migrations` \() + + actual.must_include %(`filename` varchar(255)) + actual.must_include %(PRIMARY KEY (`filename`)) + + actual.must_include %(LOCK TABLES `schema_migrations` WRITE;) + + # actual.must_include %(INSERT INTO `schema_migrations` VALUES \('20150610133853_create_books.rb'\),\('20150610141017_add_price_to_books.rb'\);) + + actual.must_include %(UNLOCK TABLES;) + end + + it 'deletes all the migrations' do + target_migrations.children.must_be :empty? + end + end + + describe 'prepare' do + let(:migrations) { target_migrations } + let(:schema) { root.join("schema-#{random}.sql") } + + before do + prepare_migrations_directory + migrator.create + migrator.migrate + end + + after do + clean_migrations + end + + it 'creates database, loads schema and migrate' do + # Simulate already existing schema.sql, without existing database and pending migrations + connection = Sequel.connect(url) + Hanami::Model::Migrator::Adapter.for(configuration).dump + + migration = target_migrations.join('20160831095616_create_abuses.rb') + File.open(migration, 'w+') do |f| + f.write <<-RUBY +Hanami::Model.migration do + change do + create_table :abuses do + primary_key :id + end + end +end +RUBY + end + + migrator.prepare + + tables = connection.tables + tables.must_include(:schema_migrations) + tables.must_include(:reviews) + tables.must_include(:abuses) + + FileUtils.rm_f migration + end + + it "works even if schema doesn't exist" do + # Simulate no database, no schema and pending migrations + FileUtils.rm_f schema + + migrator.prepare + + connection = Sequel.connect(url) + connection.tables.must_include(:schema_migrations) + connection.tables.must_include(:reviews) + end + + it 'drops the database and recreates it' do + migrator.prepare + + connection = Sequel.connect(url) + connection.tables.must_include(:schema_migrations) + connection.tables.must_include(:reviews) + end + end + + describe 'version' do + before do + migrator.create + end + + describe 'when no migrations were ran' do + it 'returns nil' do + migrator.version.must_be_nil + end + end + + describe 'with migrations' do + before do + migrator.migrate + end + + it 'returns current database version' do + migrator.version.must_equal '20160831090612' # see test/fixtures/migrations + end + end + end + end + + private + + def prepare_migrations_directory + target_migrations.mkpath + FileUtils.cp_r(Dir.glob("#{source_migrations}/*.rb"), target_migrations) + end + + def clean_migrations + FileUtils.rm_rf(target_migrations) + FileUtils.rm(schema) if schema.exist? + end +end diff --git a/test/migrator/postgresql.rb b/test/migrator/postgresql.rb new file mode 100644 index 00000000..0211668f --- /dev/null +++ b/test/migrator/postgresql.rb @@ -0,0 +1,380 @@ +require 'ostruct' + +describe 'PostgreSQL Database migrations' do + let(:migrator) do + Hanami::Model::Migrator.new(configuration: configuration) + end + + let(:random) { SecureRandom.hex(4) } + + # General variables + let(:migrations) { Pathname.new(__dir__ + '/../fixtures/migrations') } + let(:schema) { nil } + let(:config) { OpenStruct.new(backend: :sql, url: url, _migrations: migrations, _schema: schema) } + let(:configuration) { Hanami::Model::Configuration.new(config) } + + # Variables for `apply` and `prepare` + let(:root) { Pathname.new("#{__dir__}/../../tmp").expand_path } + let(:source_migrations) { Pathname.new("#{__dir__}/../fixtures/migrations") } + let(:target_migrations) { root.join("migrations-#{random}") } + + after do + migrator.drop rescue nil # rubocop:disable Style/RescueModifier + end + + describe 'PostgreSQL' do + let(:database) { "#{name.gsub(/[^\w]/, '_')}_#{random}" } + + let(:url) do + db = database + + Platform.match do + engine(:ruby) { "postgresql://127.0.0.1/#{db}?user=#{ENV['HANAMI_DATABASE_USERNAME']}" } + engine(:jruby) { "jdbc:postgresql://127.0.0.1/#{db}?user=#{ENV['HANAMI_DATABASE_USERNAME']}" } + end + end + + describe 'create' do + before do + migrator.create + end + + it 'creates the database' do + connection = Sequel.connect(url) + connection.tables.must_be :empty? + end + + it 'raises error if database is busy' do + Sequel.connect(url).tables + exception = -> { migrator.create }.must_raise Hanami::Model::MigrationError + + exception.message.must_include 'createdb: database creation failed. If the database exists,' + exception.message.must_include 'then its console may be open. See this issue for more details:' + exception.message.must_include 'https://github.com/hanami/model/issues/250' + end + + describe "when a command isn't available" do + before do + # We accomplish having a command not be available by setting PATH + # to an empty string, which means *no commands* are available. + @original_path = ENV['PATH'] + ENV['PATH'] = '' + end + + after do + ENV['PATH'] = @original_path + end + + it 'raises MigrationError on create' do + message = Platform.match do + os(:macos).engine(:jruby) { 'Unknown error - createdb' } + default { 'No such file or directory - createdb' } + end + + exception = -> { migrator.create }.must_raise Hanami::Model::MigrationError + exception.message.must_equal message + end + end + end + + describe 'drop' do + before do + migrator.create + end + + it 'drops the database' do + migrator.drop + + -> { Sequel.connect(url).tables }.must_raise Sequel::DatabaseConnectionError + end + + it "raises error if database doesn't exist" do + migrator.drop # remove the first time + + exception = -> { migrator.drop }.must_raise Hanami::Model::MigrationError + exception.message.must_equal "Cannot find database: #{database}" + end + end + + describe 'migrate' do + before do + migrator.create + end + + describe 'when no migrations' do + let(:migrations) { Pathname.new(__dir__ + '/../fixtures/empty_migrations') } + + it "it doesn't alter database" do + migrator.migrate + + connection = Sequel.connect(url) + connection.tables.must_be :empty? + end + end + + describe 'when migrations are present' do + it 'migrates the database' do + migrator.migrate + + connection = Sequel.connect(url) + connection.tables.wont_be :empty? + + table = connection.schema(:reviews) + + name, options = table[0] # id + name.must_equal :id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal "nextval('reviews_id_seq'::regclass)" + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + + name, options = table[1] # title + name.must_equal :title + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'text' + options.fetch(:primary_key).must_equal false + + name, options = table[2] # rating (second migration) + name.must_equal :rating + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '0' + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + end + end + + describe 'when migrations are ran twice' do + before do + migrator.migrate + end + + it "doesn't alter the schema" do + migrator.migrate + + connection = Sequel.connect(url) + connection.tables.wont_be :empty? + connection.tables.must_include :schema_migrations + connection.tables.must_include :reviews + end + end + + describe 'migrate down' do + before do + migrator.migrate + end + + it 'migrates the database' do + migrator.migrate(version: '20160831073534') # see test/fixtures/migrations + + connection = Sequel.connect(url) + connection.tables.wont_be :empty? + + table = connection.schema(:reviews) + + name, options = table[0] # id + name.must_equal :id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal "nextval('reviews_id_seq'::regclass)" + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + + name, options = table[1] # title + name.must_equal :title + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'text' + options.fetch(:primary_key).must_equal false + + name, options = table[2] # rating (rolled back second migration) + name.must_be_nil + options.must_be_nil + end + end + end + + describe 'apply' do + let(:migrations) { target_migrations } + let(:schema) { root.join("schema-postgresql-#{random}.sql") } + + before do + prepare_migrations_directory + migrator.create + migrator.apply + end + + after do + clean_migrations + end + + it 'migrates to latest version' do + connection = Sequel.connect(url) + migration = connection[:schema_migrations].to_a.last + + migration.fetch(:filename).must_include('20160831090612') # see test/fixtures/migrations + end + + it 'dumps database schema.sql' do + actual = schema.read + + actual.must_include <<-SQL +CREATE TABLE reviews ( + id integer NOT NULL, + title text NOT NULL, + rating integer DEFAULT 0 +); + SQL + + actual.must_include <<-SQL +CREATE SEQUENCE reviews_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + SQL + + actual.must_include <<-SQL +ALTER SEQUENCE reviews_id_seq OWNED BY reviews.id; + SQL + + actual.must_include <<-SQL +ALTER TABLE ONLY reviews ALTER COLUMN id SET DEFAULT nextval('reviews_id_seq'::regclass); + SQL + + actual.must_include <<-SQL +ALTER TABLE ONLY reviews + ADD CONSTRAINT reviews_pkey PRIMARY KEY (id); + SQL + + actual.must_include <<-SQL +CREATE TABLE schema_migrations ( + filename text NOT NULL +); + SQL + + actual.must_include <<-SQL +COPY schema_migrations (filename) FROM stdin; +20160831073534_create_reviews.rb +20160831090612_add_rating_to_reviews.rb + SQL + + actual.must_include <<-SQL +ALTER TABLE ONLY schema_migrations + ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (filename); + SQL + end + + it 'deletes all the migrations' do + target_migrations.children.must_be :empty? + end + end + + describe 'prepare' do + let(:migrations) { target_migrations } + let(:schema) { root.join("schema-postgresql-#{random}.sql") } + + before do + prepare_migrations_directory + migrator.create + end + + after do + clean_migrations + end + + it 'creates database, loads schema and migrate' do + # Simulate already existing schema.sql, without existing database and pending migrations + connection = Sequel.connect(url) + Hanami::Model::Migrator::Adapter.for(configuration).dump + + migration = target_migrations.join('20160831095616_create_abuses.rb') + File.open(migration, 'w+') do |f| + f.write <<-RUBY +Hanami::Model.migration do + change do + create_table :abuses do + primary_key :id + end + end +end + RUBY + end + + migrator.prepare + + tables = connection.tables + tables.must_include(:schema_migrations) + tables.must_include(:reviews) + tables.must_include(:abuses) + + FileUtils.rm_f migration + end + + it "works even if schema doesn't exist" do + # Simulate no database, no schema and pending migrations + FileUtils.rm_f schema + + migrator.prepare + + connection = Sequel.connect(url) + connection.tables.must_include(:schema_migrations) + connection.tables.must_include(:reviews) + end + + it 'drops the database and recreates it' do + migrator.prepare + + connection = Sequel.connect(url) + connection.tables.must_include(:schema_migrations) + connection.tables.must_include(:reviews) + end + end + + describe 'version' do + before do + migrator.create + end + + describe 'when no migrations were ran' do + it 'returns nil' do + migrator.version.must_be_nil + end + end + + describe 'with migrations' do + before do + migrator.migrate + end + + it 'returns current database version' do + migrator.version.must_equal '20160831090612' # see test/fixtures/migrations + end + end + end + end + + private + + def prepare_migrations_directory + target_migrations.mkpath + FileUtils.cp_r(Dir.glob("#{source_migrations}/*.rb"), target_migrations) + end + + def clean_migrations + FileUtils.rm_rf(target_migrations) + FileUtils.rm(schema) if schema.exist? + end +end diff --git a/test/migrator/sqlite.rb b/test/migrator/sqlite.rb new file mode 100644 index 00000000..515358c6 --- /dev/null +++ b/test/migrator/sqlite.rb @@ -0,0 +1,314 @@ +require 'ostruct' + +describe 'Filesystem SQLite Database migrations' do + let(:migrator) do + Hanami::Model::Migrator.new(configuration: configuration) + end + + let(:random) { SecureRandom.hex } + + # General variables + let(:migrations) { Pathname.new(__dir__ + '/../fixtures/migrations') } + let(:schema) { nil } + let(:config) { OpenStruct.new(backend: :sql, url: url, _migrations: migrations, _schema: schema) } + let(:configuration) { Hanami::Model::Configuration.new(config) } + let(:url) do + db = database + + Platform.match do + engine(:ruby) { "sqlite://#{db}" } + engine(:jruby) { "jdbc:sqlite://#{db}" } + end + end + + # Variables for `apply` and `prepare` + let(:root) { Pathname.new("#{__dir__}/../../tmp").expand_path } + let(:source_migrations) { Pathname.new("#{__dir__}/../fixtures/migrations") } + let(:target_migrations) { root.join("migrations-#{random}") } + + after do + migrator.drop rescue nil # rubocop:disable Style/RescueModifier + end + + describe 'SQLite filesystem' do + let(:database) do + Pathname.new("#{__dir__}/../../tmp/create-#{random}.sqlite3").expand_path + end + + describe 'create' do + it 'creates the database' do + migrator.create + assert File.exist?(database), "Expected database #{database} to exist" + end + + describe "when it doesn't have write permissions" do + let(:database) { '/usr/bin/create.sqlite3' } + + it 'raises an error' do + error = Platform.match do + os(:macos).engine(:jruby) { Java::JavaLang::RuntimeException } + default { Hanami::Model::MigrationError } + end + + message = Platform.match do + os(:macos).engine(:jruby) { 'Unhandled IOException: java.io.IOException: unhandled errno: Operation not permitted' } + default { 'Permission denied: /usr/bin/create.sqlite3' } + end + + exception = -> { migrator.create }.must_raise error + exception.message.must_equal message + end + end + end + + describe 'drop' do + before do + migrator.create + end + + it 'drops the database' do + migrator.drop + assert !File.exist?(database), "Expected database #{database} to NOT exist" + end + + it "raises error if database doesn't exist" do + migrator.drop # remove the first time + + exception = -> { migrator.drop }.must_raise Hanami::Model::MigrationError + exception.message.must_equal "Cannot find database: #{database}" + end + end + + describe 'migrate' do + before do + migrator.create + end + + describe 'when no migrations' do + let(:migrations) { Pathname.new(__dir__ + '/../fixtures/empty_migrations') } + + it "it doesn't alter database" do + migrator.migrate + + connection = Sequel.connect(url) + connection.tables.must_be :empty? + end + end + + describe 'when migrations are present' do + it 'migrates the database' do + migrator.migrate + + connection = Sequel.connect(url) + connection.tables.wont_be :empty? + + table = connection.schema(:reviews) + + name, options = table[0] # id + name.must_equal :id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + + name, options = table[1] # title + name.must_equal :title + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[2] # rating (second migration) + name.must_equal :rating + + options.fetch(:allow_null).must_equal true + options.fetch(:default).must_equal '0' + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal false + end + end + + describe 'when migrations are ran twice' do + before do + migrator.migrate + end + + it "doesn't alter the schema" do + migrator.migrate + + connection = Sequel.connect(url) + connection.tables.wont_be :empty? + connection.tables.must_equal [:schema_migrations, :reviews] + end + end + + describe 'migrate down' do + before do + migrator.migrate + end + + it 'migrates the database' do + migrator.migrate(version: '20160831073534') # see test/fixtures/migrations + + connection = Sequel.connect(url) + connection.tables.wont_be :empty? + + table = connection.schema(:reviews) + + name, options = table[0] # id + name.must_equal :id + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :integer + options.fetch(:db_type).must_equal 'integer' + options.fetch(:primary_key).must_equal true + options.fetch(:auto_increment).must_equal true + + name, options = table[1] # title + name.must_equal :title + + options.fetch(:allow_null).must_equal false + options.fetch(:default).must_equal nil + options.fetch(:type).must_equal :string + options.fetch(:db_type).must_equal 'varchar(255)' + options.fetch(:primary_key).must_equal false + + name, options = table[2] # rating (rolled back second migration) + name.must_be_nil + options.must_be_nil + end + end + end + + describe 'apply' do + let(:migrations) { target_migrations } + let(:schema) { root.join("schema-sqlite-#{random}.sql") } + + before do + prepare_migrations_directory + migrator.apply + end + + after do + clean_migrations + end + + it 'migrates to latest version' do + connection = Sequel.connect(url) + migration = connection[:schema_migrations].to_a.last + + migration.fetch(:filename).must_include('20160831090612') # see test/fixtures/migrations + end + + it 'dumps database schema.sql' do + actual = schema.read + + actual.must_include %(CREATE TABLE `schema_migrations` (`filename` varchar(255) NOT NULL PRIMARY KEY);) + actual.must_include %(CREATE TABLE `reviews` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `title` varchar(255) NOT NULL, `rating` integer DEFAULT (0));) + actual.must_include %(INSERT INTO "schema_migrations" VALUES('20160831073534_create_reviews.rb');) + actual.must_include %(INSERT INTO "schema_migrations" VALUES('20160831090612_add_rating_to_reviews.rb');) + end + + it 'deletes all the migrations' do + target_migrations.children.must_be :empty? + end + end + + describe 'prepare' do + let(:migrations) { target_migrations } + let(:schema) { root.join("schema-sqlite-#{random}.sql") } + + before do + prepare_migrations_directory + end + + after do + clean_migrations + end + + it 'creates database, loads schema and migrate' do + # Simulate already existing schema.sql, without existing database and pending migrations + connection = Sequel.connect(url) + Hanami::Model::Migrator::Adapter.for(configuration).dump + + migration = target_migrations.join('20160831095616_create_abuses.rb') + File.open(migration, 'w+') do |f| + f.write <<-RUBY +Hanami::Model.migration do + change do + create_table :abuses do + primary_key :id + end + end +end +RUBY + end + + migrator.prepare + + connection.tables.must_equal [:schema_migrations, :reviews, :abuses] + + FileUtils.rm_f migration + end + + it "works even if schema doesn't exist" do + # Simulate no database, no schema and pending migrations + FileUtils.rm_f schema + migrator.prepare + + connection = Sequel.connect(url) + connection.tables.must_equal [:schema_migrations, :reviews] + end + + it 'drops the database and recreate it' do + migrator.create + migrator.prepare + + connection = Sequel.connect(url) + connection.tables.must_include(:schema_migrations) + connection.tables.must_include(:reviews) + end + end + + describe 'version' do + before do + migrator.create + end + + describe 'when no migrations were ran' do + it 'returns nil' do + migrator.version.must_be_nil + end + end + + describe 'with migrations' do + before do + migrator.migrate + end + + it 'returns current database version' do + migrator.version.must_equal '20160831090612' # see test/fixtures/migrations + end + end + end + end + + private + + def prepare_migrations_directory + target_migrations.mkpath + FileUtils.cp_r(Dir.glob("#{source_migrations}/*.rb"), target_migrations) + end + + def clean_migrations + FileUtils.rm_rf(target_migrations) + FileUtils.rm(schema) if schema.exist? + end +end diff --git a/test/migrator_test.rb b/test/migrator_test.rb new file mode 100644 index 00000000..ab75cf8d --- /dev/null +++ b/test/migrator_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +require 'hanami/model/migrator' + +describe Hanami::Model::Migrator do + load "test/migrator/#{Database.engine}.rb" +end diff --git a/test/model/adapters/abstract_test.rb b/test/model/adapters/abstract_test.rb deleted file mode 100644 index 73eed3f1..00000000 --- a/test/model/adapters/abstract_test.rb +++ /dev/null @@ -1,113 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Adapters::Abstract do - let(:adapter) { Hanami::Model::Adapters::Abstract.new(mapper, 'test://uri') } - let(:mapper) { Object.new } - let(:entity) { Object.new } - let(:query) { Object.new } - let(:collection) { :collection } - - describe '#persist' do - it 'raises error' do - -> { adapter.persist(collection, entity) }.must_raise NotImplementedError - end - end - - describe '#create' do - it 'raises error' do - -> { adapter.create(collection, entity) }.must_raise NotImplementedError - end - end - - describe '#update' do - it 'raises error' do - -> { adapter.update(collection, entity) }.must_raise NotImplementedError - end - end - - describe '#delete' do - it 'raises error' do - -> { adapter.delete(collection, entity) }.must_raise NotImplementedError - end - end - - describe '#all' do - it 'raises error' do - -> { adapter.all(collection) }.must_raise NotImplementedError - end - end - - describe '#find' do - it 'raises error' do - -> { adapter.find(collection, 1) }.must_raise NotImplementedError - end - end - - describe '#first' do - it 'raises error' do - -> { adapter.first(collection) }.must_raise NotImplementedError - end - end - - describe '#last' do - it 'raises error' do - -> { adapter.last(collection) }.must_raise NotImplementedError - end - end - - describe '#clear' do - it 'raises error' do - -> { adapter.clear(collection) }.must_raise NotImplementedError - end - end - - describe '#command' do - it 'raises error' do - -> { adapter.command(query) }.must_raise NotImplementedError - end - end - - describe '#query' do - it 'raises error' do - -> { adapter.query(collection) }.must_raise NotImplementedError - end - end - - describe '#transaction' do - it 'raises error' do - -> { adapter.transaction({}) }.must_raise NotImplementedError - end - end - - describe '#connection_string' do - it 'raises error' do - -> { adapter.connection_string }.must_raise Hanami::Model::Adapters::NotSupportedError - end - end - - describe '#adapter_name' do - it 'returns demodulized, underscored class name' do - adapter.adapter_name.must_equal 'abstract' - end - end - - describe 'empty uri' do - it 'raises MissingURIError' do - exception = -> { - Hanami::Model::Adapters::Abstract.new(@mapper, nil) - }.must_raise(Hanami::Model::Adapters::MissingURIError) - - exception.message.must_equal "URI for `abstract' adapter is nil or empty. Please check env variables like `DATABASE_URL'." - end - end - - describe 'nil path' do - it 'raises MissingURIError' do - exception = -> { - Hanami::Model::Adapters::Abstract.new(@mapper, "") - }.must_raise(Hanami::Model::Adapters::MissingURIError) - - exception.message.must_equal "URI for `abstract' adapter is nil or empty. Please check env variables like `DATABASE_URL'." - end - end -end diff --git a/test/model/adapters/file_system_adapter_test.rb b/test/model/adapters/file_system_adapter_test.rb deleted file mode 100644 index fe162a12..00000000 --- a/test/model/adapters/file_system_adapter_test.rb +++ /dev/null @@ -1,1185 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Adapters::FileSystemAdapter do - before do - TestUser = Struct.new(:id, :name, :age) do - include Hanami::Entity - end - - class TestUserRepository - include Hanami::Repository - end - - TestDevice = Struct.new(:id) do - include Hanami::Entity - end - - class TestDeviceRepository - include Hanami::Repository - end - - @mapper = Hanami::Model::Mapper.new do - collection :users do - entity TestUser - - attribute :id, Integer - attribute :name, String - attribute :age, Integer - end - - collection :devices do - entity TestDevice - - attribute :id, Integer - end - - collection :articles do - entity Article - - attribute :id, Integer, as: :_id - attribute :user_id, Integer - attribute :title, String, as: 's_title' - attribute :comments_count, Integer - - identity :_id - end - - end.load! - - @adapter = Hanami::Model::Adapters::FileSystemAdapter.new(@mapper, FILE_SYSTEM_CONNECTION_STRING) - @adapter.clear(collection) - - @verifier = Hanami::Model::Adapters::FileSystemAdapter.new(@mapper, FILE_SYSTEM_CONNECTION_STRING) - end - - after do - @adapter.disconnect - Object.send(:remove_const, :TestUser) - Object.send(:remove_const, :TestUserRepository) - Object.send(:remove_const, :TestDevice) - Object.send(:remove_const, :TestDeviceRepository) - end - - let(:collection) { :users } - - describe 'multiple collections' do - it 'create records' do - user = TestUser.new - device = TestDevice.new - - user = @adapter.create(:users, user) - device = @adapter.create(:devices, device) - - @verifier.all(:users).must_equal [user] - @verifier.all(:devices).must_equal [device] - end - end - - describe '#initialize' do - before do - @adapter = Hanami::Model::Adapters::FileSystemAdapter.new(@mapper, uri) - end - - after do - @adapter.disconnect - end - - describe 'absolute path' do - let(:uri) { "file:///#{ Pathname.new(__dir__).join('../../../tmp/db/filesystem').realpath }" } - - it 'connects to the database' do - @adapter.info.must_be_kind_of(Hash) - end - end - - describe 'relative path' do - let(:uri) { 'file:///./tmp/db/filesystem' } - - it 'connects to the database' do - @adapter.info.must_be_kind_of(Hash) - end - end - end - - describe 'when new database' do - before do - data = Pathname.new(FILE_SYSTEM_CONNECTION_STRING) - data.rmtree if data.exist? - end - - it 'returns an empty collection' do - @adapter.all(collection).must_be_empty - end - end - - # BUG - # See: https://github.com/hanami/model/issues/151 - describe 'when already present database' do - before do - data = Pathname.new(FILE_SYSTEM_CONNECTION_STRING) - data.rmtree if data.exist? - - @user1 = TestUser.new(name: 'L') - @user2 = TestUser.new(name: 'MG') - old_data = Hanami::Model::Adapters::FileSystemAdapter.new(@mapper, FILE_SYSTEM_CONNECTION_STRING) - @user1 = old_data.persist(collection, @user1) - - @adapter = Hanami::Model::Adapters::FileSystemAdapter.new(@mapper, FILE_SYSTEM_CONNECTION_STRING) - end - - it 'reads the old data' do - @adapter.all(collection).must_equal [@user1] - end - - it 'writes without erasing old data' do - @user2 = @adapter.persist(collection, @user2) - @adapter.all(collection).must_equal [@user1, @user2] - end - end - - describe '#persist' do - after do - @adapter.disconnect - end - - describe 'when the given entity is not persisted' do - let(:entity) { TestUser.new } - - it 'stores the record and assigns an id' do - result = @adapter.persist(collection, entity) - - result.id.wont_be_nil - @verifier.find(collection, result.id).must_equal result - end - end - - describe 'when the given entity is persisted' do - before do - @entity = @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'updates the record and leaves untouched the id' do - id = @entity.id - id.wont_be_nil - - @entity.name = 'L' - @adapter.persist(collection, @entity) - - @entity.id.must_equal(id) - @verifier.find(collection, @entity.id).name.must_equal @entity.name - end - end - end - - describe '#create' do - let(:entity) { TestUser.new } - - it 'stores the record and assigns an id' do - result = @adapter.create(collection, entity) - - result.id.wont_be_nil - @verifier.find(collection, result.id).must_equal result - end - end - - describe '#update' do - before do - @entity = @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new(id: nil, name: 'L') } - - it 'stores the changes and leave the id untouched' do - id = @entity.id - - @entity.name = 'MG' - @adapter.update(collection, @entity) - - @entity.id.must_equal id - @verifier.find(collection, @entity.id).name.must_equal @entity.name - end - end - - describe '#delete' do - before do - @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'removes the given identity' do - @adapter.delete(collection, entity) - @verifier.find(collection, entity.id).must_be_nil - end - end - - describe '#all' do - after do - @adapter.disconnect - end - - describe 'when no records are persisted' do - before do - @adapter.clear(collection) - end - - it 'returns an empty collection' do - @verifier.all(collection).must_be_empty - end - end - - describe 'when some records are persisted' do - before do - @entity = @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'returns all of them' do - @verifier.all(collection).must_equal [@entity] - end - end - end - - describe '#find' do - after do - @adapter.disconnect - end - - before do - @entity = @adapter.create(collection, entity) - @adapter.instance_variable_get(:@collections).fetch(collection).records.store(nil, nil_entity) - end - - let(:entity) { TestUser.new } - let(:nil_entity) { {id: 0} } - - it 'returns the record by id' do - @verifier.find(collection, @entity.id).must_equal @entity - end - - it 'returns nil when the record cannot be found' do - @verifier.find(collection, 1_000_000).must_be_nil - end - - it 'returns nil when the given id is nil' do - @verifier.find(collection, nil).must_be_nil - end - end - - describe '#first' do - describe 'when no records are peristed' do - before do - @adapter.clear(collection) - end - - it 'returns nil' do - @verifier.first(collection).must_be_nil - end - end - - describe 'when some records are persisted' do - before do - @entity1 = @adapter.create(collection, entity1) - @entity2 = @adapter.create(collection, entity2) - end - - let(:entity1) { TestUser.new } - let(:entity2) { TestUser.new } - - it 'returns the first record' do - @verifier.first(collection).must_equal @entity1 - end - end - end - - describe '#last' do - describe 'when no records are peristed' do - before do - @adapter.clear(collection) - end - - it 'returns nil' do - @verifier.last(collection).must_be_nil - end - end - - describe 'when some records are persisted' do - before do - @entity1 = @adapter.create(collection, entity1) - @entity2 = @adapter.create(collection, entity2) - end - - let(:entity1) { TestUser.new } - let(:entity2) { TestUser.new } - - it 'returns the last record' do - @verifier.last(collection).must_equal @entity2 - end - end - end - - describe '#clear' do - before do - @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'removes all the records' do - @adapter.clear(collection) - @verifier.all(collection).must_be_empty - end - - it 'resets the id counter' do - @adapter.clear(collection) - - result = @adapter.create(collection, entity) - result.id.must_equal 1 - end - end - - describe '#query' do - before do - @adapter.clear(collection) - end - - let(:user1) { TestUser.new(name: 'L', age: '32') } - let(:user2) { TestUser.new(name: 'MG', age: 31) } - let(:user3) { TestUser.new(name: 'JS', age: 31) } - - describe 'where' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @verifier.query(collection) do - where(id: 23) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, user3) - end - - it 'returns selected records' do - id = @user1.id - - query = Proc.new { - where(id: id) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user1] - end - - it 'can use multiple where conditions' do - id = @user1.id - name = @user1.name - - query = Proc.new { - where(id: id).where(name: name) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user1] - end - - it 'can use multiple where conditions with "and" alias' do - id = @user1.id - name = @user1.name - - query = Proc.new { - where(id: id).and(name: name) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user1] - end - - it 'requires all conditions be satisfied' do - name = @user3.name - age = @user3.age - - query = Proc.new { - where(age: age).where(name: name) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user3] - end - end - end - - describe 'exclude' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @verifier.query(collection) do - exclude(id: 23) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, user3) - end - - let(:user3) { TestUser.new(name: 'S', age: 2) } - - it 'returns selected records' do - id = @user1.id - - query = Proc.new { - exclude(id: id) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user2, @user3] - end - - it 'can use multiple exclude conditions' do - id = @user1.id - name = @user2.name - - query = Proc.new { - exclude(id: id).exclude(name: name) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user3] - end - - it 'can use multiple exclude conditions with "not" alias' do - id = @user1.id - name = @user2.name - - query = Proc.new { - self.not(id: id).not(name: name) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user3] - end - end - end - - describe 'or' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @verifier.query(collection) do - where(name: 'L').or(name: 'MG') - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns selected records' do - name1 = @user1.name - name2 = @user2.name - - query = Proc.new { - where(name: name1).or(name: name2) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user1, @user2] - end - - it 'returns selected records only from the "or" condition' do - name2 = @user2.name - - query = Proc.new { - where(name: 'unknown').or(name: name2) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user2] - end - end - end - - describe 'select' do - describe 'with an empty collection' do - it 'returns an empty result' do - result = @verifier.query(collection) do - select(:age) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, user3) - end - - let(:user1) { TestUser.new(name: 'L', age: 32) } - let(:user3) { TestUser.new(name: 'S') } - let(:users) { [user1, user2, user3] } - - it 'returns the selected columnts from all the records' do - query = Proc.new { - select(:age) - } - - result = @verifier.query(collection, &query).all - - users.each do |user| - record = result.find {|r| r.age == user.age } - record.wont_be_nil - record.name.must_be_nil - end - end - - it 'returns only the select of requested records' do - name = user2.name - - query = Proc.new { - where(name: name).select(:age) - } - - result = @verifier.query(collection, &query).all - - record = result.first - record.age.must_equal(user2.age) - record.name.must_be_nil - end - - it 'returns only the multiple select of requested records' do - name = user2.name - - query = Proc.new { - where(name: name).select(:name, :age) - } - - result = @verifier.query(collection, &query).all - - record = result.first - record.name.must_equal(user2.name) - record.age.must_equal(user2.age) - record.id.must_be_nil - end - end - end - - describe 'order' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @verifier.query(collection) do - order(:id) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns sorted records' do - query = Proc.new { - order(:id) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user1, @user2] - end - end - end - - describe 'asc' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @verifier.query(collection) do - asc(:id) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns sorted records' do - query = Proc.new { - asc(:id) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user1, @user2] - end - end - end - - describe 'desc' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @verifier.query(collection) do - desc(:id) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns reverse sorted records' do - query = Proc.new { - desc(:id) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user2, @user1] - end - end - end - - describe 'limit' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @verifier.query(collection) do - limit(1) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, TestUser.new(name: user2.name)) - end - - it 'returns only the number of requested records' do - name = @user2.name - - query = Proc.new { - where(name: name).limit(1) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user2] - end - end - end - - describe 'offset' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @verifier.query(collection) do - limit(1).offset(1) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, user3) - @user4 = @adapter.create(collection, user4) - end - - let(:user3) { TestUser.new(name: user2.name, age: 31) } - let(:user4) { TestUser.new(name: user2.name, age: 32) } - - it 'returns only the number of requested records' do - name = @user2.name - - query = Proc.new { - where(name: name).limit(2).offset(1) - } - - result = @verifier.query(collection, &query).all - result.must_equal [@user3, @user4] - end - end - end - - describe 'exist?' do - describe 'with an empty collection' do - it 'returns false' do - result = @verifier.query(collection) do - where(id: 23) - end.exist? - - result.must_equal false - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns true when there are matched records' do - id = @user1.id - - query = Proc.new { - where(id: id) - } - - result = @verifier.query(collection, &query).exist? - result.must_equal true - end - - it 'returns false when there are matched records' do - query = Proc.new { - where(id: 'unknown') - } - - result = @verifier.query(collection, &query).exist? - result.must_equal false - end - end - end - - describe 'count' do - describe 'with an empty collection' do - it 'returns 0' do - result = @verifier.query(collection) do - all - end.count - - result.must_equal 0 - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - end - - it 'returns the count of all the records' do - query = Proc.new { - all - } - - result = @verifier.query(collection, &query).count - result.must_equal 2 - end - - it 'returns the count from an empty query block' do - query = Proc.new { - } - - result = @verifier.query(collection, &query).count - result.must_equal 2 - end - - it 'returns only the count of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @verifier.query(collection, &query).count - result.must_equal 1 - end - end - end - - describe 'sum' do - describe 'with an empty collection' do - it 'returns nil' do - result = @verifier.query(collection) do - all - end.sum(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the sum of all the records' do - query = Proc.new { - all - } - - result = @verifier.query(collection, &query).sum(:age) - result.must_equal 63 - end - - it 'returns the sum from an empty query block' do - query = Proc.new { - } - - result = @verifier.query(collection, &query).sum(:age) - result.must_equal 63 - end - - it 'returns only the sum of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @verifier.query(collection, &query).sum(:age) - result.must_equal 31 - end - end - end - - describe 'average' do - describe 'with an empty collection' do - it 'returns nil' do - result = @verifier.query(collection) do - all - end.average(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the average of all the records' do - query = Proc.new { - all - } - - result = @verifier.query(collection, &query).average(:age) - result.must_equal 31.5 - end - - it 'returns the average from an empty query block' do - query = Proc.new { - } - - result = @verifier.query(collection, &query).average(:age) - result.must_equal 31.5 - end - - it 'returns only the average of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @verifier.query(collection, &query).average(:age) - result.must_equal 31.0 - end - end - end - - describe 'avg' do - describe 'with an empty collection' do - it 'returns nil' do - result = @verifier.query(collection) do - all - end.avg(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the average of all the records' do - query = Proc.new { - all - } - - result = @verifier.query(collection, &query).avg(:age) - result.must_equal 31.5 - end - - it 'returns the average from an empty query block' do - query = Proc.new { - } - - result = @verifier.query(collection, &query).avg(:age) - result.must_equal 31.5 - end - - it 'returns only the average of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @verifier.query(collection, &query).avg(:age) - result.must_equal 31.0 - end - end - end - - describe 'max' do - describe 'with an empty collection' do - it 'returns nil' do - result = @verifier.query(collection) do - all - end.max(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the maximum of all the records' do - query = Proc.new { - all - } - - result = @verifier.query(collection, &query).max(:age) - result.must_equal 32 - end - - it 'returns the maximum from an empty query block' do - query = Proc.new { - } - - result = @verifier.query(collection, &query).max(:age) - result.must_equal 32 - end - - it 'returns only the maximum of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @verifier.query(collection, &query).max(:age) - result.must_equal 31 - end - end - end - - describe 'min' do - describe 'with an empty collection' do - it 'returns nil' do - result = @verifier.query(collection) do - all - end.min(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the minimum of all the records' do - query = Proc.new { - all - } - - result = @verifier.query(collection, &query).min(:age) - result.must_equal 31 - end - - it 'returns the minimum from an empty query block' do - query = Proc.new { - } - - result = @verifier.query(collection, &query).min(:age) - result.must_equal 31 - end - - it 'returns only the minimum of requested records' do - name = user1.name - - query = Proc.new { - where(name: name) - } - - result = @verifier.query(collection, &query).min(:age) - result.must_equal 32 - end - end - end - - describe 'interval' do - describe 'with an empty collection' do - it 'returns nil' do - result = @verifier.query(collection) do - all - end.interval(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the interval of all the records' do - query = Proc.new { - all - } - - result = @verifier.query(collection, &query).interval(:age) - result.must_equal 1 - end - - it 'returns the interval from an empty query block' do - query = Proc.new { - } - - result = @verifier.query(collection, &query).interval(:age) - result.must_equal 1 - end - - it 'returns only the interval of requested records' do - name = user1.name - - query = Proc.new { - where(name: name) - } - - result = @verifier.query(collection, &query).interval(:age) - result.must_equal 0 - end - end - end - - describe 'range' do - describe 'with an empty collection' do - it 'returns nil' do - result = @verifier.query(collection) do - all - end.range(:age) - - result.must_equal nil..nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the range of all the records' do - query = Proc.new { - all - } - - result = @verifier.query(collection, &query).range(:age) - result.must_equal 31..32 - end - - it 'returns the range from an empty query block' do - query = Proc.new { - } - - result = @verifier.query(collection, &query).range(:age) - result.must_equal 31..32 - end - - it 'returns only the range of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @verifier.query(collection, &query).range(:age) - result.must_equal 31..31 - end - end - end - end - - describe '#info' do - before do - @adapter.create(collection, TestUser.new) - end - - it 'returns infos per each collection' do - @adapter.info.fetch(collection).must_equal 1 - end - end - - describe '#disconnect' do - before do - @adapter.disconnect - end - - it 'raises error' do - exception = -> { @adapter.create(collection, TestUser.new) }.must_raise Hanami::Model::Adapters::DisconnectedAdapterError - exception.message.must_match "You have tried to perform an operation on a disconnected adapter" - end - - describe '#adapter_name' do - it "equals to 'file_system'" do - @adapter.adapter_name.must_equal 'file_system' - end - end - end -end diff --git a/test/model/adapters/implementation_test.rb b/test/model/adapters/implementation_test.rb deleted file mode 100644 index 80982ed0..00000000 --- a/test/model/adapters/implementation_test.rb +++ /dev/null @@ -1,22 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Adapters::Implementation do - before do - TestAdapter = Class.new(Hanami::Model::Adapters::Abstract) do - include Hanami::Model::Adapters::Implementation - end - - mapper = Object.new - @adapter = TestAdapter.new(mapper, "test://uri") - end - - after do - Object.send(:remove_const, :TestAdapter) - end - - it 'must implement #_collection' do - -> { - @adapter.send(:_collection, :x) - }.must_raise NotImplementedError - end -end diff --git a/test/model/adapters/memory/query_test.rb b/test/model/adapters/memory/query_test.rb deleted file mode 100644 index 48c90e76..00000000 --- a/test/model/adapters/memory/query_test.rb +++ /dev/null @@ -1,91 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Adapters::Memory::Query do - before do - MockDataset = Struct.new(:records) do - def all - records - end - - def to_s - records.to_s - end - end - - class MockCollection - def deserialize(array) - array - end - end - - collection = MockCollection.new - @query = Hanami::Model::Adapters::Memory::Query.new(dataset, collection) - end - - after do - Object.send(:remove_const, :MockDataset) - Object.send(:remove_const, :MockCollection) - end - - let(:dataset) { MockDataset.new([]) } - - describe '#negate!' do - it 'raises an error' do - -> { @query.negate! }.must_raise NotImplementedError - end - end - - describe '#to_s' do - let(:dataset) { MockDataset.new([1, 2, 3]) } - - it 'delegates to the wrapped dataset' do - @query.to_s.must_equal dataset.to_s - end - end - - describe '#empty?' do - describe "when it's empty" do - it 'returns true' do - @query.must_be_empty - end - end - - describe "when it's filled with elements" do - let(:dataset) { MockDataset.new([1, 2, 3]) } - - it 'returns false' do - @query.wont_be_empty - end - end - end - - describe '#any?' do - describe "when it's empty" do - it 'returns false' do - assert !@query.any? - end - end - - describe "when it's filled with elements" do - let(:dataset) { MockDataset.new([1, 2, 3]) } - - it 'returns true' do - assert @query.any? - end - - describe "when a block is passed" do - describe "and it doesn't match elements" do - it 'returns false' do - assert !@query.any? {|e| e > 100 } - end - end - - describe "and it matches elements" do - it 'returns true' do - assert @query.any? {|e| e % 2 == 0 } - end - end - end - end - end -end diff --git a/test/model/adapters/memory_adapter_test.rb b/test/model/adapters/memory_adapter_test.rb deleted file mode 100644 index 17dcccf8..00000000 --- a/test/model/adapters/memory_adapter_test.rb +++ /dev/null @@ -1,1228 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Adapters::MemoryAdapter do - before do - TestUser = Struct.new(:id, :name, :age) do - include Hanami::Entity - end - - class TestUserRepository - include Hanami::Repository - end - - TestDevice = Struct.new(:id) do - include Hanami::Entity - end - - class TestDeviceRepository - include Hanami::Repository - end - - @mapper = Hanami::Model::Mapper.new do - collection :users do - entity TestUser - - attribute :id, Integer - attribute :name, String - attribute :age, Integer - end - - collection :devices do - entity TestDevice - - attribute :id, Integer - end - end.load! - - @adapter = Hanami::Model::Adapters::MemoryAdapter.new(@mapper, MEMORY_CONNECTION_STRING) - end - - after do - Object.send(:remove_const, :TestUser) - Object.send(:remove_const, :TestUserRepository) - Object.send(:remove_const, :TestDevice) - Object.send(:remove_const, :TestDeviceRepository) - end - - let(:collection) { :users } - - describe 'multiple collections' do - it 'create records' do - user = TestUser.new - device = TestDevice.new - - user = @adapter.create(:users, user) - device = @adapter.create(:devices, device) - - @adapter.all(:users).must_equal [user] - @adapter.all(:devices).must_equal [device] - end - end - - describe '#persist' do - describe 'when the given entity is not persisted' do - let(:entity) { TestUser.new } - - it 'stores the record and assigns an id' do - result = @adapter.persist(collection, entity) - - result.id.wont_be_nil - @adapter.find(collection, result.id).must_equal result - end - end - - describe 'when the given entity is persisted' do - before do - @entity = @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'updates the record and leaves untouched the id' do - id = @entity.id - id.wont_be_nil - - @entity.name = 'L' - @adapter.persist(collection, @entity) - - @entity.id.must_equal(id) - @adapter.find(collection, @entity.id).name.must_equal @entity.name - end - end - end - - describe '#create' do - let(:entity) { TestUser.new } - - it 'stores the record and assigns an id' do - result = @adapter.create(collection, entity) - - result.id.wont_be_nil - @adapter.find(collection, result.id).must_equal result - end - end - - describe '#update' do - before do - @entity = @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new(id: nil, name: 'L') } - - it 'stores the changes and leave the id untouched' do - id = @entity.id - - entity.name = 'MG' - @adapter.update(collection, @entity) - - @entity.id.must_equal id - @adapter.find(collection, @entity.id).name.must_equal @entity.name - end - end - - describe '#delete' do - before do - @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'removes the given identity' do - @adapter.delete(collection, entity) - @adapter.find(collection, entity.id).must_be_nil - end - end - - describe '#all' do - describe 'when no records are persisted' do - before do - @adapter.clear(collection) - end - - it 'returns an empty collection' do - @adapter.all(collection).must_be_empty - end - end - - describe 'when some records are persisted' do - before do - @entity = @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'returns all of them' do - @adapter.all(collection).must_equal [@entity] - end - end - end - - describe '#find' do - before do - @entity = @adapter.create(collection, entity) - @adapter.instance_variable_get(:@collections).fetch(collection).records.store(nil, nil_entity) - end - - let(:entity) { TestUser.new } - let(:nil_entity) { {id: 0} } - - it 'returns the record by id' do - @adapter.find(collection, @entity.id).must_equal @entity - end - - it 'returns nil when the record cannot be found' do - @adapter.find(collection, 1_000_000).must_be_nil - end - - it 'returns nil when the given id is nil' do - @adapter.find(collection, nil).must_be_nil - end - end - - describe '#first' do - describe 'when no records are peristed' do - before do - @adapter.clear(collection) - end - - it 'returns nil' do - @adapter.first(collection).must_be_nil - end - end - - describe 'when some records are persisted' do - before do - @entity1 = @adapter.create(collection, entity1) - @entity2 = @adapter.create(collection, entity2) - end - - let(:entity1) { TestUser.new } - let(:entity2) { TestUser.new } - - it 'returns the first record' do - @adapter.first(collection).must_equal @entity1 - end - end - end - - describe '#last' do - describe 'when no records are peristed' do - before do - @adapter.clear(collection) - end - - it 'returns nil' do - @adapter.last(collection).must_be_nil - end - end - - describe 'when some records are persisted' do - before do - @entity1 = @adapter.create(collection, entity1) - @entity2 = @adapter.create(collection, entity2) - end - - let(:entity1) { TestUser.new } - let(:entity2) { TestUser.new } - - it 'returns the last record' do - @adapter.last(collection).must_equal @entity2 - end - end - end - - describe '#clear' do - before do - @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'removes all the records' do - @adapter.clear(collection) - @adapter.all(collection).must_be_empty - end - - it 'resets the id counter' do - @adapter.clear(collection) - - result = @adapter.create(collection, entity) - result.id.must_equal 1 - end - end - - describe '#query' do - before do - @adapter.clear(collection) - end - - let(:user1) { TestUser.new(name: 'L', age: '32') } - let(:user2) { TestUser.new(name: 'MG', age: 31) } - let(:user3) { TestUser.new(name: 'JS', age: 31) } - - describe 'where' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - where(id: 23) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, user3) - end - - it 'returns selected records' do - id = @user1.id - - query = Proc.new { - where(id: id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1] - end - - it 'can use multiple where conditions' do - id = @user1.id - name = @user1.name - - query = Proc.new { - where(id: id).where(name: name) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1] - end - - it 'can use multiple where conditions with "and" alias' do - id = @user1.id - name = @user1.name - - query = Proc.new { - where(id: id).and(name: name) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1] - end - - it 'requires all conditions be satisfied' do - name = @user3.name - age = @user3.age - - query = Proc.new { - where(age: age).where(name: name) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user3] - end - - it 'accepts an array as condition' do - names = [@user1.name, @user2.name] - - query = Proc.new { - where(name: names) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - - it 'accepts a set as condition' do - names = Set.new([@user1.name, @user2.name]) - - query = Proc.new { - where(name: names) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - - it 'accepts a range as condition' do - ages = @user3.age..@user2.age - - query = Proc.new { - where(age: ages) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2, @user3] - end - - it 'accepts a block as condition' do - query = Proc.new { - where { age > 31 } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1] - end - - it "raises InvalidQueryError when columns are invalid in expression" do - exception = -> { - query = Proc.new { where { a > 32 } } - @adapter.query(collection, &query).all - }.must_raise(Hanami::Model::InvalidQueryError) - - exception.message.must_equal "Invalid query" - end - - it "accepts nested block conditions" do - query = Proc.new { - where { age > 31 }.where { name == 'L' } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1] - end - - it "takes into account both hash and block conditions" do - query = Proc.new { - where(age: 32).where { name == 'L' } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1] - end - end - end - - describe 'exclude' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - exclude(id: 23) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, user3) - end - - let(:user3) { TestUser.new(name: 'S', age: 2) } - - it 'returns selected records' do - id = @user1.id - - query = Proc.new { - exclude(id: id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2, @user3] - end - - it 'can use multiple exclude conditions' do - id = @user1.id - name = @user2.name - - query = Proc.new { - exclude(id: id).exclude(name: name) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user3] - end - - it 'can use multiple exclude conditions with "not" alias' do - id = @user1.id - name = @user2.name - - query = Proc.new { - self.not(id: id).not(name: name) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user3] - end - - it 'accepts a block as condition' do - query = Proc.new { - exclude { age > 31 } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2, @user3] - end - - it "raises InvalidQueryError when columns are invalid in expression" do - exception = -> { - query = Proc.new { exclude { a > 32 } } - @adapter.query(collection, &query).all - }.must_raise(Hanami::Model::InvalidQueryError) - - exception.message.must_equal "Invalid query" - end - - it "accepts nested block conditions" do - query = Proc.new { - exclude { age > 31 }.exclude { name == 'MG' } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user3] - end - - it "takes into account both hash and block conditions" do - query = Proc.new { - exclude(age: 32).exclude { name == 'MG' } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user3] - end - end - end - - describe 'or' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - where(name: 'L').or(name: 'MG') - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, user3) - end - - it 'returns selected records' do - name1 = @user1.name - name2 = @user2.name - - query = Proc.new { - where(name: name1).or(name: name2) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - - it 'returns selected records only from the "or" condition' do - name2 = @user2.name - - query = Proc.new { - where(name: 'unknown').or(name: name2) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2] - end - - it 'accepts a block as condition' do - query = Proc.new { - where(name: "MG").or { age > 31 } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2, @user1] - end - - it "raises InvalidQueryError when columns are invalid in expression" do - exception = -> { - query = Proc.new { where(name: "MG").or { a > 31 } } - @adapter.query(collection, &query).all - }.must_raise(Hanami::Model::InvalidQueryError) - - exception.message.must_equal "Invalid query" - end - - it "accepts nested block conditions" do - query = Proc.new { - where { name == "L" }.or { name == 'MG' }.or { name == "JS" } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2, @user3] - end - - it "takes into account both hash and block conditions" do - query = Proc.new { - where(age: 32).or { name == 'MG' } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - end - end - - describe 'select' do - describe 'with an empty collection' do - it 'returns an empty result' do - result = @adapter.query(collection) do - select(:age) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, user3) - - @users = [@user1, @user2, @user3] - end - - let(:user1) { TestUser.new(name: 'L', age: 32) } - let(:user3) { TestUser.new(name: 'S') } - - it 'returns the selected columnts from all the records' do - query = Proc.new { - select(:age) - } - - result = @adapter.query(collection, &query).all - - @users.each do |user| - record = result.find {|r| r.age == user.age } - record.wont_be_nil - record.name.must_be_nil - end - end - - it 'returns only the select of requested records' do - name = @user2.name - - query = Proc.new { - where(name: name).select(:age) - } - - result = @adapter.query(collection, &query).all - - record = result.first - record.age.must_equal(@user2.age) - record.name.must_be_nil - end - - it 'returns only the multiple select of requested records' do - name = @user2.name - - query = Proc.new { - where(name: name).select(:name, :age) - } - - result = @adapter.query(collection, &query).all - - record = result.first - record.name.must_equal(@user2.name) - record.age.must_equal(@user2.age) - record.id.must_be_nil - end - end - end - - describe 'order' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - order(:id) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns sorted records' do - query = Proc.new { - order(:id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - end - end - - describe 'asc' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - asc(:id) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns sorted records' do - query = Proc.new { - asc(:id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - end - end - - describe 'desc' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - desc(:id) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns reverse sorted records' do - query = Proc.new { - desc(:id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2, @user1] - end - end - end - - describe 'limit' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - limit(1) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, TestUser.new(name: user2.name)) - end - - it 'returns only the number of requested records' do - name = @user2.name - - query = Proc.new { - where(name: name).limit(1) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2] - end - end - end - - describe 'offset' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - limit(1).offset(1) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, user3) - @user4 = @adapter.create(collection, user4) - end - - let(:user3) { TestUser.new(name: user2.name, age: 31) } - let(:user4) { TestUser.new(name: user2.name, age: 32) } - - it 'returns only the number of requested records' do - name = @user2.name - - query = Proc.new { - where(name: name).limit(2).offset(1) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user3, @user4] - end - end - end - - describe 'exist?' do - describe 'with an empty collection' do - it 'returns false' do - result = @adapter.query(collection) do - where(id: 23) - end.exist? - - result.must_equal false - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns true when there are matched records' do - id = @user1.id - - query = Proc.new { - where(id: id) - } - - result = @adapter.query(collection, &query).exist? - result.must_equal true - end - - it 'returns false when there are matched records' do - query = Proc.new { - where(id: 'unknown') - } - - result = @adapter.query(collection, &query).exist? - result.must_equal false - end - end - end - - describe 'count' do - describe 'with an empty collection' do - it 'returns 0' do - result = @adapter.query(collection) do - all - end.count - - result.must_equal 0 - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - end - - it 'returns the count of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).count - result.must_equal 2 - end - - it 'returns the count from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).count - result.must_equal 2 - end - - it 'returns only the count of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).count - result.must_equal 1 - end - end - end - - describe 'sum' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.sum(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the sum of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).sum(:age) - result.must_equal 63 - end - - it 'returns the sum from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).sum(:age) - result.must_equal 63 - end - - it 'returns only the sum of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).sum(:age) - result.must_equal 31 - end - end - end - - describe 'average' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.average(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the average of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).average(:age) - result.must_equal 31.5 - end - - it 'returns the average from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).average(:age) - result.must_equal 31.5 - end - - it 'returns only the average of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).average(:age) - result.must_equal 31.0 - end - end - end - - describe 'avg' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.avg(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the average of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).avg(:age) - result.must_equal 31.5 - end - - it 'returns the average from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).avg(:age) - result.must_equal 31.5 - end - - it 'returns only the average of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).avg(:age) - result.must_equal 31.0 - end - end - end - - describe 'max' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.max(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the maximum of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).max(:age) - result.must_equal 32 - end - - it 'returns the maximum from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).max(:age) - result.must_equal 32 - end - - it 'returns only the maximum of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).max(:age) - result.must_equal 31 - end - end - end - - describe 'min' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.min(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the minimum of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).min(:age) - result.must_equal 31 - end - - it 'returns the minimum from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).min(:age) - result.must_equal 31 - end - - it 'returns only the minimum of requested records' do - name = user1.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).min(:age) - result.must_equal 32 - end - end - end - - describe 'interval' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.interval(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the interval of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).interval(:age) - result.must_equal 1 - end - - it 'returns the interval from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).interval(:age) - result.must_equal 1 - end - - it 'returns only the interval of requested records' do - name = user1.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).interval(:age) - result.must_equal 0 - end - end - end - - describe 'range' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.range(:age) - - result.must_equal nil..nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the range of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).range(:age) - result.must_equal 31..32 - end - - it 'returns the range from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).range(:age) - result.must_equal 31..32 - end - - it 'returns only the range of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).range(:age) - result.must_equal 31..31 - end - end - end - - describe '#disconnect' do - before do - @adapter.disconnect - end - - it 'raises error' do - exception = -> { @adapter.create(collection, TestUser.new) }.must_raise Hanami::Model::Adapters::DisconnectedAdapterError - exception.message.must_match "You have tried to perform an operation on a disconnected adapter" - end - end - - describe '#adapter_name' do - it "equals to 'memory'" do - @adapter.adapter_name.must_equal 'memory' - end - end - end -end diff --git a/test/model/adapters/sql/command_test.rb b/test/model/adapters/sql/command_test.rb deleted file mode 100644 index a84e1f7a..00000000 --- a/test/model/adapters/sql/command_test.rb +++ /dev/null @@ -1,123 +0,0 @@ -require 'test_helper' - -SEQUEL_TO_HANAMI_MAPPING = { - 'Sequel::UniqueConstraintViolation' => 'Hanami::Model::UniqueConstraintViolationError', - 'Sequel::ForeignKeyConstraintViolation' => 'Hanami::Model::ForeignKeyConstraintViolationError', - 'Sequel::NotNullConstraintViolation' => 'Hanami::Model::NotNullConstraintViolationError', - 'Sequel::CheckConstraintViolation' => 'Hanami::Model::CheckConstraintViolationError', - 'Sequel::SerializationFailure' => 'Hanami::Model::InvalidCommandError' -} - -describe Hanami::Model::Adapters::Sql::Command do - describe 'when create new entity successfully' do - it 'delegates the entity to the collection correctly' do - entity = Object.new - query = SqlQueryFake.new - - collection = Minitest::Mock.new - collection.expect(:insert, true, [entity]) - - query.stub(:scoped, collection) do - command = Hanami::Model::Adapters::Sql::Command.new(query) - command.create(entity) - - collection.verify - end - end - end - - describe 'when #create raises Sequel Database Violation Error' do - SEQUEL_TO_HANAMI_MAPPING.each do |sequel_error, hanami_error| - - it "raises #{hanami_error} instead of #{sequel_error}" do - query = SqlQueryFake.new - command = Hanami::Model::Adapters::Sql::Command.new(query) - - query.scoped.stub(:insert, ->(entity) { raise Object.const_get(sequel_error).new }) do - assert_raises(Object.const_get(hanami_error)) { command.create(Object.new) } - end - end - - end - end - - describe 'when update an entity successfully' do - it 'delegates the entity to the collection correctly' do - entity = Object.new - query = SqlQueryFake.new - - collection = Minitest::Mock.new - collection.expect(:update, true, [entity]) - - query.stub(:scoped, collection) do - command = Hanami::Model::Adapters::Sql::Command.new(query) - command.update(entity) - - collection.verify - end - end - end - - describe 'when #update raises Sequel Database Violation Error' do - SEQUEL_TO_HANAMI_MAPPING.each do |sequel_error, hanami_error| - - it "raises #{hanami_error} instead of #{sequel_error}" do - query = SqlQueryFake.new - command = Hanami::Model::Adapters::Sql::Command.new(query) - - query.scoped.stub(:update, ->(entity) { raise Object.const_get(sequel_error).new }) do - assert_raises(Object.const_get(hanami_error)) { command.update(Object.new) } - end - end - - end - end - - describe 'when delete an entity successfully' do - it 'delegates to delete method on collection' do - entity = Object.new - query = SqlQueryFake.new - - collection = Minitest::Mock.new - collection.expect(:delete, true) - - query.stub(:scoped, collection) do - command = Hanami::Model::Adapters::Sql::Command.new(query) - command.delete - - collection.verify - end - end - end - - describe 'when #delete raises Sequel Database Violation Error' do - SEQUEL_TO_HANAMI_MAPPING.each do |sequel_error, hanami_error| - - it "raises #{hanami_error} instead of #{sequel_error}" do - query = SqlQueryFake.new - command = Hanami::Model::Adapters::Sql::Command.new(query) - - query.scoped.stub(:delete, -> { raise Object.const_get(sequel_error).new }) do - assert_raises(Object.const_get(hanami_error)) { command.delete } - end - end - end - end - - class SqlQueryFake - def scoped - @collection ||= CollectionFake.new - end - end - - class CollectionFake - def insert(entity) - end - - def update(entity) - end - - def delete - end - end -end diff --git a/test/model/adapters/sql/console_test.rb b/test/model/adapters/sql/console_test.rb deleted file mode 100644 index 6df672e7..00000000 --- a/test/model/adapters/sql/console_test.rb +++ /dev/null @@ -1,27 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Adapters::Sql::Console do - describe 'deciding on which SQL console class to use, based on URI scheme' do - let(:uri) { 'username:password@localhost:1234/foo_development' } - - it 'mysql:// uri returns an instance of Console::Mysql' do - console = Hanami::Model::Adapters::Sql::Console.new("mysql://#{uri}").send(:console) - console.must_be_kind_of(Hanami::Model::Adapters::Sql::Consoles::Mysql) - end - - it 'mysql2:// uri returns an instance of Console::Mysql' do - console = Hanami::Model::Adapters::Sql::Console.new("mysql2://#{uri}").send(:console) - console.must_be_kind_of(Hanami::Model::Adapters::Sql::Consoles::Mysql) - end - - it 'postgres:// uri returns an instance of Console::Postgresql' do - console = Hanami::Model::Adapters::Sql::Console.new("postgres://#{uri}").send(:console) - console.must_be_kind_of(Hanami::Model::Adapters::Sql::Consoles::Postgresql) - end - - it 'sqlite:// uri returns an instance of Console::Sqlite' do - console = Hanami::Model::Adapters::Sql::Console.new("sqlite://#{uri}").send(:console) - console.must_be_kind_of(Hanami::Model::Adapters::Sql::Consoles::Sqlite) - end - end -end diff --git a/test/model/adapters/sql/query_test.rb b/test/model/adapters/sql/query_test.rb deleted file mode 100644 index 3a4cc5e5..00000000 --- a/test/model/adapters/sql/query_test.rb +++ /dev/null @@ -1,121 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Adapters::Sql::Query do - before do - @query = Hanami::Model::Adapters::Sql::Query.new(collection) - end - - let(:collection) { [] } - - describe '#negate!' do - describe 'where' do - before do - @query.where(id: 1) - end - - it 'negates with exclude' do - @query.negate! - operator, condition = *@query.conditions.first - - operator.must_equal :exclude - condition.must_equal({id: 1}) - end - end - - describe 'multipe where conditions' do - before do - @query.where(id: 1).and(name: 'L') - end - - it 'negates with exclude' do - @query.negate! - operator, condition = *@query.conditions.last - - operator.must_equal :exclude - condition.must_equal({name: 'L'}) - end - end - - describe 'exclude' do - before do - @query.exclude(published: false) - end - - it 'negates with where' do - @query.negate! - operator, condition = *@query.conditions.first - - operator.must_equal :where - condition.must_equal({published: false}) - end - end - - describe 'multiple exclude conditions' do - before do - @query.exclude(published: false).not(comments_count: 0) - end - - it 'negates with where' do - @query.negate! - operator, condition = *@query.conditions.last - - operator.must_equal :where - condition.must_equal({comments_count: 0}) - end - end - end - - describe '#to_s' do - let(:collection) { [1, 2, 3] } - - it 'delegates to the wrapped collection' do - @query.to_s.must_equal collection.to_s - end - end - - describe '#empty?' do - describe "when it's empty" do - it 'returns true' do - @query.must_be_empty - end - end - - describe "when it's filled with elements" do - let(:collection) { [1, 2, 3] } - - it 'returns false' do - @query.wont_be_empty - end - end - end - - describe '#any?' do - describe "when it's empty" do - it 'returns false' do - assert !@query.any? - end - end - - describe "when it's filled with elements" do - let(:collection) { [1, 2, 3] } - - it 'returns true' do - assert @query.any? - end - - describe "when a block is passed" do - describe "and it doesn't match elements" do - it 'returns false' do - assert !@query.any? {|e| e > 100 } - end - end - - describe "and it matches elements" do - it 'returns true' do - assert @query.any? {|e| e % 2 == 0 } - end - end - end - end - end -end diff --git a/test/model/adapters/sql_adapter_test.rb b/test/model/adapters/sql_adapter_test.rb deleted file mode 100644 index 6497c106..00000000 --- a/test/model/adapters/sql_adapter_test.rb +++ /dev/null @@ -1,1402 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Adapters::SqlAdapter do - before do - class TestUser - include Hanami::Entity - - attributes :country_id, :name, :age - end - - class TestUserRepository - include Hanami::Repository - end - - class TestDevice - include Hanami::Entity - - attributes :u_id - end - - class TestDeviceRepository - include Hanami::Repository - end - - class TestOrder - include Hanami::Entity - - attributes :user_id, :total - end - - class TestOrderRepository - include Hanami::Repository - end - - class TestAge - include Hanami::Entity - - attributes :value, :label - end - - class TestAgeRepository - include Hanami::Repository - end - - class TestCountry - include Hanami::Entity - - attributes :code, :country_id - end - - class TestCountryRepository - include Hanami::Repository - end - - @mapper = Hanami::Model::Mapper.new do - collection :users do - entity TestUser - - attribute :id, Integer - attribute :country_id, Integer - attribute :name, String - attribute :age, Integer - end - - collection :devices do - entity TestDevice - - attribute :id, Integer - attribute :u_id, Integer - end - - collection :orders do - entity TestOrder - - attribute :id, Integer - attribute :user_id, Integer - attribute :total, Integer - end - - collection :ages do - entity TestAge - - attribute :id, Integer - attribute :value, Integer - attribute :label, String - end - - collection :countries do - entity TestCountry - - identity :country_id - attribute :id, Integer, as: :country_id - attribute :code, String - end - end.load! - - @adapter = Hanami::Model::Adapters::SqlAdapter.new(@mapper, SQLITE_CONNECTION_STRING) - @adapter.clear(collection) - end - - after do - Object.send(:remove_const, :TestUser) - Object.send(:remove_const, :TestUserRepository) - Object.send(:remove_const, :TestDevice) - Object.send(:remove_const, :TestDeviceRepository) - Object.send(:remove_const, :TestOrder) - Object.send(:remove_const, :TestOrderRepository) - Object.send(:remove_const, :TestAge) - Object.send(:remove_const, :TestAgeRepository) - Object.send(:remove_const, :TestCountry) - Object.send(:remove_const, :TestCountryRepository) - end - - let(:collection) { :users } - - describe 'multiple collections' do - before do - @adapter.clear(:devices) - end - - it 'create records' do - user = TestUser.new - device = TestDevice.new - - user = @adapter.create(:users, user) - device = @adapter.create(:devices, device) - - @adapter.all(:users).must_equal [user] - @adapter.all(:devices).must_equal [device] - end - end - - describe '#initialize' do - it 'raises an error when the given URI refers to a non registered database adapter' do - -> { - Hanami::Model::Adapters::SqlAdapter.new(@mapper, 'oracle://host') - }.must_raise(Hanami::Model::Adapters::DatabaseAdapterNotFound) - end - - it 'raises an error when the given URI refers to an unknown database adapter' do - -> { - Hanami::Model::Adapters::SqlAdapter.new(@mapper, 'unknown://host') - }.must_raise(Hanami::Model::Adapters::DatabaseAdapterNotFound) - end - - it 'raises an error when the given URI is malformed' do - -> { - Hanami::Model::Adapters::SqlAdapter.new(@mapper, 'unknown_db:host') - }.must_raise(URI::InvalidURIError) - end - - it 'supports non-mandatory adapter configurations' do - spy = nil - after_connect_spy_proc = Proc.new { spy = true } - - adapter = Hanami::Model::Adapters::SqlAdapter.new(@mapper, - SQLITE_CONNECTION_STRING, after_connect: after_connect_spy_proc) - - # Sequel lazily connects - adapter.execute('select 1 as dummy') - - spy.must_equal true - end - - end - - describe '#persist' do - describe 'when the given entity is not persisted' do - let(:entity) { TestUser.new } - - it 'stores the record and assigns an id' do - result = @adapter.persist(collection, entity) - - result.id.wont_be_nil - @adapter.find(collection, result.id).must_equal result - end - end - - describe 'when the given entity is persisted' do - before do - @entity = @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'updates the record and leaves untouched the id' do - id = @entity.id - id.wont_be_nil - - @entity.name = 'L' - @adapter.persist(collection, @entity) - - @entity.id.must_equal(id) - @adapter.find(collection, @entity.id).name.must_equal @entity.name - end - end - end - - describe '#create' do - let(:entity) { TestUser.new } - - it 'stores the record and assigns an id' do - result = @adapter.create(collection, entity) - - result.id.wont_be_nil - @adapter.find(collection, result.id).must_equal result - end - end - - describe '#update' do - before do - @entity = @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new(id: nil, name: 'L') } - - it 'stores the changes and leave the id untouched' do - id = @entity.id - - @entity.name = 'MG' - @adapter.update(collection, @entity) - - @entity.id.must_equal id - @adapter.find(collection, @entity.id).name.must_equal @entity.name - end - end - - describe '#delete' do - before do - @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'removes the given identity' do - @adapter.delete(collection, entity) - @adapter.find(collection, entity.id).must_be_nil - end - end - - describe '#all' do - describe 'when no records are persisted' do - before do - @adapter.clear(collection) - end - - it 'returns an empty collection' do - @adapter.all(collection).must_be_empty - end - end - - describe 'when some records are persisted' do - before do - @entity = @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'returns all of them' do - @adapter.all(collection).must_equal [@entity] - end - end - end - - describe '#find' do - before do - @entity = @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'returns the record by id' do - @adapter.find(collection, @entity.id).must_equal @entity - end - - it 'returns nil when the record cannot be found' do - @adapter.find(collection, 1_000_000).must_be_nil - end - - it 'returns nil when the given id is nil' do - @adapter.find(collection, nil).must_be_nil - end - end - - describe '#first' do - describe 'when no records are peristed' do - before do - @adapter.clear(collection) - end - - it 'returns nil' do - @adapter.first(collection).must_be_nil - end - end - - describe 'when some records are persisted' do - before do - @entity1 = @adapter.create(collection, entity1) - @entity2 = @adapter.create(collection, entity2) - end - - let(:entity1) { TestUser.new } - let(:entity2) { TestUser.new } - - it 'returns the first record' do - @adapter.first(collection).must_equal @entity1 - end - end - end - - describe '#last' do - describe 'when no records are peristed' do - before do - @adapter.clear(collection) - end - - it 'returns nil' do - @adapter.last(collection).must_be_nil - end - end - - describe 'when some records are persisted' do - before do - @entity1 = @adapter.create(collection, entity1) - @entity2 = @adapter.create(collection, entity2) - end - - let(:entity1) { TestUser.new } - let(:entity2) { TestUser.new } - - it 'returns the last record' do - @adapter.last(collection).must_equal @entity2 - end - end - end - - describe '#clear' do - before do - @adapter.create(collection, entity) - end - - let(:entity) { TestUser.new } - - it 'removes all the records' do - @adapter.clear(collection) - @adapter.all(collection).must_be_empty - end - end - - describe '#query' do - before do - @adapter.clear(collection) - end - - let(:user1) { TestUser.new(name: 'L', age: '32') } - let(:user2) { TestUser.new(name: 'MG', age: 31) } - let(:user3) { TestUser.new(name: 'S', age: 2) } - - describe 'where' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - where(id: 23) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns selected records' do - id = @user1.id - - query = Proc.new { - where(id: id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1] - end - - it 'can use multiple where conditions' do - id = @user1.id - name = @user1.name - - query = Proc.new { - where(id: id).where(name: name) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1] - end - - it 'can use multiple where conditions with "and" alias' do - id = @user1.id - name = @user1.name - - query = Proc.new { - where(id: id).and(name: name) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1] - end - - it 'can use lambda to describe where conditions' do - query = Proc.new { - where{ age > 31 } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1] - end - - it 'raises InvalidQueryError if you use wrong column names' do - exception = -> { - query = Proc.new { where { a > 31 } } - @adapter.query(collection, &query).all - }.must_raise(Hanami::Model::InvalidQueryError) - - exception.message.wont_be_nil - end - - it 'raises an error if you dont specify condition or block' do - -> { - query = Proc.new { - where() - } - @adapter.query(collection, &query).all - }.must_raise(ArgumentError) - end - end - end - - describe 'exclude' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - exclude(id: 23) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, user3) - end - - let(:user3) { TestUser.new(name: 'S', age: 2) } - - it 'returns selected records' do - id = @user1.id - - query = Proc.new { - exclude(id: id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2, @user3] - end - - it 'can use multiple exclude conditions' do - id = @user1.id - name = @user2.name - - query = Proc.new { - exclude(id: id).exclude(name: name) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user3] - end - - it 'can use multiple exclude conditions with "not" alias' do - id = @user1.id - name = @user2.name - - query = Proc.new { - self.not(id: id).not(name: name) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user3] - end - - it 'raises InvalidQueryError if you use wrong column names' do - exception = -> { - query = Proc.new { exclude{ a > 32 } } - @adapter.query(collection, &query).all - }.must_raise(Hanami::Model::InvalidQueryError) - - exception.message.wont_be_nil - end - - it 'can use lambda to describe exclude conditions' do - query = Proc.new { - exclude{ age > 31 } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2, @user3] - end - - it 'raises an error if you dont specify condition or block' do - -> { - query = Proc.new { - exclude() - } - @adapter.query(collection, &query).all - }.must_raise(ArgumentError) - end - end - end - - describe 'or' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - where(name: 'L').or(name: 'MG') - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns selected records' do - name1 = @user1.name - name2 = @user2.name - - query = Proc.new { - where(name: name1).or(name: name2) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - - it 'can use lambda to describe or conditions' do - name1 = @user1.name - - query = Proc.new { - where(name: name1).or{ age < 32 } - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - - it 'raises an error if you dont specify condition or block' do - -> { - name1 = @user1.name - - query = Proc.new { - where(name: name1).or() - } - @adapter.query(collection, &query).all - }.must_raise(ArgumentError) - end - - it 'raises InvalidQueryError if you use wrong column names' do - exception = -> { - name1 = @user1.name - name2 = @user2.name - query = Proc.new { where(name: name1).or(n: name2) } - - @adapter.query(collection, &query).all - }.must_raise(Hanami::Model::InvalidQueryError) - - exception.message.wont_be_nil - end - end - end - - describe 'select' do - describe 'with an empty collection' do - it 'returns an empty result' do - result = @adapter.query(collection) do - select(:age) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, user3) - end - - let(:user1) { TestUser.new(name: 'L', age: 32) } - let(:user3) { TestUser.new(name: 'S') } - let(:users) { [user1, user2, user3] } - - it 'returns the selected columnts from all the records' do - query = Proc.new { - select(:age) - } - - result = @adapter.query(collection, &query).all - - users.each do |user| - record = result.find {|r| r.age == user.age } - record.wont_be_nil - record.name.must_be_nil - end - end - - it 'returns only the select of requested records' do - name = user2.name - - query = Proc.new { - where(name: name).select(:age) - } - - result = @adapter.query(collection, &query).all - - record = result.first - record.age.must_equal(user2.age) - record.name.must_be_nil - end - - it 'returns only the multiple select of requested records' do - name = user2.name - - query = Proc.new { - where(name: name).select(:name, :age) - } - - result = @adapter.query(collection, &query).all - - record = result.first - record.name.must_equal(user2.name) - record.age.must_equal(user2.age) - record.id.must_be_nil - end - end - end - - describe 'order' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - order(:id) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns sorted records' do - query = Proc.new { - order(:id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - - it 'returns sorted records, using multiple columns' do - query = Proc.new { - order(:age, :id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2, @user1] - end - - it 'returns sorted records, using multiple invokations' do - query = Proc.new { - order(:age).order(:id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2, @user1] - end - end - end - - describe 'asc' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - asc(:id) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns sorted records' do - query = Proc.new { - asc(:id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - end - end - - describe 'desc' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - desc(:id) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns reverse sorted records' do - query = Proc.new { - desc(:id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2, @user1] - end - - it 'returns sorted records, using multiple columns' do - query = Proc.new { - desc(:age, :id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - - it 'returns sorted records, using multiple invokations' do - query = Proc.new { - desc(:age).desc(:id) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user1, @user2] - end - end - end - - describe 'limit' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - limit(1) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, TestUser.new(name: user2.name)) - end - - it 'returns only the number of requested records' do - name = @user2.name - - query = Proc.new { - where(name: name).limit(1) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user2] - end - end - end - - describe 'offset' do - describe 'with an empty collection' do - it 'returns an empty result set' do - result = @adapter.query(collection) do - limit(1).offset(1) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - @user3 = @adapter.create(collection, user3) - @user4 = @adapter.create(collection, user4) - end - - let(:user3) { TestUser.new(name: user2.name, age: 31) } - let(:user4) { TestUser.new(name: user2.name, age: 32) } - - it 'returns only the number of requested records' do - name = @user2.name - - query = Proc.new { - where(name: name).limit(2).offset(1) - } - - result = @adapter.query(collection, &query).all - result.must_equal [@user3, @user4] - end - end - end - - describe 'exist?' do - describe 'with an empty collection' do - it 'returns false' do - result = @adapter.query(collection) do - where(id: 23) - end.exist? - - result.must_equal false - end - end - - describe 'with a filled collection' do - before do - @user1 = @adapter.create(collection, user1) - @user2 = @adapter.create(collection, user2) - end - - it 'returns true when there are matched records' do - id = @user1.id - - query = Proc.new { - where(id: id) - } - - result = @adapter.query(collection, &query).exist? - result.must_equal true - end - - it 'returns false when there are matched records' do - query = Proc.new { - where(id: 'unknown') - } - - result = @adapter.query(collection, &query).exist? - result.must_equal false - end - end - end - - describe 'count' do - describe 'with an empty collection' do - it 'returns 0' do - result = @adapter.query(collection) do - all - end.count - - result.must_equal 0 - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - end - - it 'returns the count of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).count - result.must_equal 2 - end - - it 'returns the count from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).count - result.must_equal 2 - end - - it 'returns only the count of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).count - result.must_equal 1 - end - end - end - - describe 'sum' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.sum(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the sum of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).sum(:age) - result.must_equal 63 - end - - it 'returns the sum from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).sum(:age) - result.must_equal 63 - end - - it 'returns only the sum of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).sum(:age) - result.must_equal 31 - end - end - end - - describe 'average' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.average(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the average of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).average(:age) - result.must_equal 31.5 - end - - it 'returns the average from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).average(:age) - result.must_equal 31.5 - end - - it 'returns only the average of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).average(:age) - result.must_equal 31 - end - end - end - - describe 'avg' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.avg(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the average of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).avg(:age) - result.must_equal 31.5 - end - - it 'returns the average from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).avg(:age) - result.must_equal 31.5 - end - - it 'returns only the average of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).avg(:age) - result.must_equal 31 - end - end - end - - describe 'max' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.max(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the maximum of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).max(:age) - result.must_equal 32 - end - - it 'returns the maximum from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).max(:age) - result.must_equal 32 - end - - it 'returns only the maximum of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).max(:age) - result.must_equal 31 - end - end - end - - describe 'min' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.min(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the minimum of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).min(:age) - result.must_equal 31 - end - - it 'returns the minimum from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).min(:age) - result.must_equal 31 - end - - it 'returns only the minimum of requested records' do - name = user1.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).min(:age) - result.must_equal 32 - end - end - end - - describe 'interval' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.interval(:age) - - result.must_be_nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the interval of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).interval(:age) - result.must_equal 1 - end - - it 'returns the interval from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).interval(:age) - result.must_equal 1 - end - - it 'returns only the interval of requested records' do - name = user1.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).interval(:age) - result.must_equal 0 - end - end - end - - describe 'range' do - describe 'with an empty collection' do - it 'returns nil' do - result = @adapter.query(collection) do - all - end.range(:age) - - result.must_equal nil..nil - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, TestUser.new(name: 'S')) - end - - it 'returns the range of all the records' do - query = Proc.new { - all - } - - result = @adapter.query(collection, &query).range(:age) - result.must_equal 31..32 - end - - it 'returns the range from an empty query block' do - query = Proc.new { - } - - result = @adapter.query(collection, &query).range(:age) - result.must_equal 31..32 - end - - it 'returns only the range of requested records' do - name = user2.name - - query = Proc.new { - where(name: name) - } - - result = @adapter.query(collection, &query).range(:age) - result.must_equal 31..31 - end - end - end - - describe 'execute' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - end - - it 'runs the command and returns nil' do - raw = "UPDATE users SET name='CP'" - - result = @adapter.execute(raw) - result.must_be_nil - - records = @adapter.all(:users) - records.all? {|r| r.name == 'CP' }.must_equal true - end - - it 'raises an exception when invalid sql is provided' do - raw = "UPDATE users SET foo=22" - - -> { @adapter.execute(raw) }.must_raise Hanami::Model::InvalidCommandError - end - end - - describe 'fetch' do - before do - UserRepository.adapter = @adapter - @user1 = @adapter.create(collection, user1) - end - - after do - UserRepository.adapter = nil - end - - it 'returns the an array from the raw sql' do - raw = "SELECT * FROM users" - - result = @adapter.fetch(raw) - result.count.must_equal UserRepository.new.all.count - - user = result.first - user[:id].must_equal @user1.id - user[:name].must_equal @user1.name - user[:age].must_equal @user1.age - user[:created_at].must_be_nil - user[:updated_at].must_be_nil - end - - it 'yields the given block' do - raw = "SELECT * FROM users" - - # In theory `execute` yields result set in a block - # https://github.com/jeremyevans/sequel/blob/54fa82326d3319d9aca4409c07f79edc09da3837/lib/sequel/adapters/sqlite.rb#L126-L129 - # - # Would be interesting in future to wrap these results into Hanami result_sets, independent from - # Sequel adapter - # - records = [] - - @adapter.fetch raw do |result_set| - records << result_set - end - - records.count.must_equal UserRepository.new.all.count - end - - it 'raises an exception when an invalid sql is provided' do - raw = "SELECT foo FROM users" - -> { @adapter.fetch(raw) }.must_raise Hanami::Model::InvalidQueryError - end - end - - describe 'group' do - describe 'with an empty collection' do - it 'returns an empty result' do - result = @adapter.query(collection) do - group(:name) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - before do - @adapter.create(collection, user1) - @adapter.create(collection, user2) - @adapter.create(collection, user3) - @adapter.create(collection, user4) - @adapter.create(collection, user5) - @adapter.create(collection, user6) - @adapter.create(collection, user7) - end - - let(:user1) { TestUser.new(name: 'L', age: 32) } - let(:user2) { TestUser.new(name: 'L', age: 10) } - let(:user3) { TestUser.new(name: 'L', age: 11) } - let(:user4) { TestUser.new(name: 'A', age: 12) } - let(:user5) { TestUser.new(name: 'A', age: 12) } - let(:user6) { TestUser.new(name: 'T', age: 11) } - let(:user7) { TestUser.new(name: 'O', age: 10) } - - it 'returns grouped records with one column' do - query = Proc.new { - group(:name) - } - - result = @adapter.query(collection, &query).all - result.size.must_equal 4 - end - - it 'returns grouped records with 2 columns' do - query = Proc.new { - group(:name, :age) - } - - result = @adapter.query(collection, &query).all - result.size.must_equal 6 - end - end - end - - describe '#disconnect' do - before do - @adapter.disconnect - end - - it 'raises error' do - exception = -> { @adapter.create(collection, user1) }.must_raise Hanami::Model::Adapters::DisconnectedAdapterError - exception.message.must_match "You have tried to perform an operation on a disconnected adapter" - end - end - - describe '#adapter_name' do - it "equals to 'sql'" do - @adapter.adapter_name.must_equal 'sql' - end - end - end -end diff --git a/test/model/adapters/sql_joins_test.rb b/test/model/adapters/sql_joins_test.rb deleted file mode 100644 index 3d5044f6..00000000 --- a/test/model/adapters/sql_joins_test.rb +++ /dev/null @@ -1,323 +0,0 @@ -require 'test_helper' -require 'hanami/model/migrator' - -describe 'SQL joins test' do - before do - class TestUser - include Hanami::Entity - - attributes :country_id, :name, :age - end - - class TestUserRepository - include Hanami::Repository - end - - class TestDevice - include Hanami::Entity - - attributes :u_id - end - - class TestDeviceRepository - include Hanami::Repository - end - - class TestOrder - include Hanami::Entity - - attributes :user_id, :total - end - - class TestOrderRepository - include Hanami::Repository - end - - class TestAge - include Hanami::Entity - - attributes :value, :label - end - - class TestAgeRepository - include Hanami::Repository - end - - class TestCountry - include Hanami::Entity - - attributes :code, :country_id - end - - class TestCountryRepository - include Hanami::Repository - end - - @mapper = Hanami::Model::Mapper.new do - collection :users do - entity TestUser - - attribute :id, Integer - attribute :country_id, Integer - attribute :name, String - attribute :age, Integer - end - - collection :devices do - entity TestDevice - - attribute :id, Integer - attribute :u_id, Integer - end - - collection :orders do - entity TestOrder - - attribute :id, Integer - attribute :user_id, Integer - attribute :total, Integer - end - - collection :ages do - entity TestAge - - attribute :id, Integer - attribute :value, Integer - attribute :label, String - end - - collection :countries do - entity TestCountry - - identity :country_id - attribute :id, Integer, as: :country_id - attribute :code, String - end - end.load! - - @adapter = Hanami::Model::Adapters::SqlAdapter.new(@mapper, SQLITE_CONNECTION_STRING) - end - - after do - Object.send(:remove_const, :TestUser) - Object.send(:remove_const, :TestUserRepository) - Object.send(:remove_const, :TestDevice) - Object.send(:remove_const, :TestDeviceRepository) - Object.send(:remove_const, :TestOrder) - Object.send(:remove_const, :TestOrderRepository) - Object.send(:remove_const, :TestAge) - Object.send(:remove_const, :TestAgeRepository) - Object.send(:remove_const, :TestCountry) - Object.send(:remove_const, :TestCountryRepository) - end - - - describe '#query' do - let(:user1) { TestUser.new(name: 'L', age: '32') } - let(:user2) { TestUser.new(name: 'MG', age: 31) } - let(:user3) { TestUser.new(name: 'S', age: 2) } - - describe 'join' do - describe 'inner' do - describe 'with an empty collection' do - before do - @adapter.clear(:orders) - @adapter.clear(:users) - end - - it 'returns an empty result set' do - result = @adapter.query(:orders) do - join(:users) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - describe 'and default options' do - before do - @adapter.clear(:users) - @adapter.clear(:orders) - - @created_user = @adapter.create(:users, user1) - - @order1 = TestOrder.new(user_id: @created_user.id, total: 100) - @order2 = TestOrder.new(user_id: @created_user.id, total: 200) - @order3 = TestOrder.new(user_id: nil, total: 300) - - @adapter.create(:orders, @order1) - @adapter.create(:orders, @order2) - @adapter.create(:orders, @order3) - - TestUserRepository.adapter = @adapter - TestOrderRepository.adapter = @adapter - end - - it 'returns records' do - created_user = @created_user - - query_where = Proc.new { - where(user_id: created_user.id) - } - query_join = Proc.new { - join(:users) - } - - result_query_where = @adapter.query(:orders, &query_where).all - result_query_join = @adapter.query(:orders, &query_join).all - - result_query_join.must_equal result_query_where - end - end - - describe 'and explicit key' do - before do - @adapter.clear(:users) - @adapter.clear(:countries) - - @country1 = TestCountry.new(code: 'IT') - @country2 = TestCountry.new(code: 'US') - - country1 = @adapter.create(:countries, @country1) - country2 = @adapter.create(:countries, @country2) - - user = TestUser.new(country_id: country2.id) - @created_user = @adapter.create(:users, user) - - TestUserRepository.adapter = @adapter - TestCountryRepository.adapter = @adapter - end - - it 'returns records' do - query = Proc.new { - join(:countries, key: :country_id) - } - - result = @adapter.query(:users, &query).all - result.must_equal [@created_user] - end - end - - describe 'and explicit foreign key' do - before do - @adapter.clear(:users) - @adapter.clear(:devices) - - @created_user = @adapter.create(:users, user1) - - @device1 = TestDevice.new(u_id: @created_user.id) - @device2 = TestDevice.new(u_id: @created_user.id) - @device3 = TestDevice.new(u_id: nil) - - @adapter.create(:devices, @device1) - @adapter.create(:devices, @device2) - @adapter.create(:devices, @device3) - - TestUserRepository.adapter = @adapter - end - - it 'returns records' do - created_user = @created_user - - query_where = Proc.new { - where(u_id: created_user.id) - } - query_join = Proc.new { - join(:users, foreign_key: :u_id) - } - - result_query_where = @adapter.query(:devices, &query_where).all - result_query_join = @adapter.query(:devices, &query_join).all - - result_query_join.must_equal result_query_where - end - end - - describe 'and explicit key and foreign key' do - before do - @adapter.clear(:users) - @adapter.clear(:ages) - - created_user1 = @adapter.create(:users, user1) - @adapter.create(:users, user2) - created_user3 = @adapter.create(:users, user3) - - @age1 = TestAge.new(value: created_user1.age, label: 'Adulthood') - @age2 = TestAge.new(value: created_user3.age, label: 'Childhood') - - @adapter.create(:ages, @age1) - @adapter.create(:ages, @age2) - - TestUserRepository.adapter = @adapter - end - - it 'returns records' do - user_first = TestUserRepository.new.first - user_last = TestUserRepository.new.last - - query_join = Proc.new { - join(:ages, key: :value, foreign_key: :age) - } - - result = @adapter.query(:users, &query_join).all - result.must_equal [user_first, user_last] - end - end - end - end - - describe 'left join' do - describe 'with an empty collection' do - before do - @adapter.clear(:orders) - @adapter.clear(:users) - end - - it 'returns an empty result set' do - result = @adapter.query(:orders) do - left_join(:users) - end.all - - result.must_be_empty - end - end - - describe 'with a filled collection' do - describe 'and default options' do - before do - @adapter.clear(:users) - @adapter.clear(:orders) - - @created_user = @adapter.create(:users, user1) - - @order1 = TestOrder.new(user_id: @created_user.id, total: 100) - @order2 = TestOrder.new(user_id: @created_user.id, total: 200) - @order3 = TestOrder.new(user_id: nil, total: 300) - - @adapter.create(:orders, @order1) - @adapter.create(:orders, @order2) - @adapter.create(:orders, @order3) - - TestUserRepository.adapter = @adapter - TestOrderRepository.adapter = @adapter - end - - it 'returns records' do - created_user = TestUserRepository.new.first - - query_join = Proc.new { - left_join(:users) - } - - result_query_join = @adapter.query(:orders, &query_join).all - result_query_join.select{ |order| order.user_id == created_user.id }.size.must_equal 2 - result_query_join.select{ |order| order.user_id.nil? }.size.must_equal 1 - result_query_join.size.must_equal 3 - end - end - end - end - end - end -end diff --git a/test/model/coercer_test.rb b/test/model/coercer_test.rb deleted file mode 100644 index 302348c8..00000000 --- a/test/model/coercer_test.rb +++ /dev/null @@ -1,15 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Coercer do - describe '.load' do - it 'raises error' do - -> { Hanami::Model::Coercer.load(23) }.must_raise NotImplementedError - end - end - - describe '.dump' do - it 'raises error' do - -> { Hanami::Model::Coercer.dump(23) }.must_raise NotImplementedError - end - end -end diff --git a/test/model/config/adapter_test.rb b/test/model/config/adapter_test.rb deleted file mode 100644 index 5427ce49..00000000 --- a/test/model/config/adapter_test.rb +++ /dev/null @@ -1,68 +0,0 @@ -require 'test_helper' -require 'hanami/utils' - -describe Hanami::Model::Config::Adapter do - - describe 'initialize' do - it 'sets other adapater options' do - after_connect_proc = -> {} - config = Hanami::Model::Config::Adapter.new(type: :memory, uri: MEMORY_CONNECTION_STRING, after_connect: after_connect_proc) - - config.options.must_equal({after_connect: after_connect_proc}) - end - end - - describe '#build' do - let(:mapper) { Hanami::Model::Mapper.new } - let(:adapter) { config.build(mapper) } - - describe 'given adapter type is memory' do - let(:config) { Hanami::Model::Config::Adapter.new(type: :memory, uri: MEMORY_CONNECTION_STRING) } - - it 'instantiates memory adapter' do - adapter = config.build(mapper) - adapter.must_be_kind_of Hanami::Model::Adapters::MemoryAdapter - end - end - - describe 'given adapter type is SQL' do - let(:config) { Hanami::Model::Config::Adapter.new(type: :sql, uri: SQLITE_CONNECTION_STRING) } - - it 'instantiates SQL adapter' do - adapter = config.build(mapper) - adapter.must_be_kind_of Hanami::Model::Adapters::SqlAdapter - end - end - - describe 'given adapter type is not found' do - let(:config) { Hanami::Model::Config::Adapter.new(type: :redis, uri: 'redis://not_exist') } - - it 'raises an error' do - exception = -> { config.build(mapper) }.must_raise(Hanami::Model::Error) - - if Hanami::Utils.jruby? - exception.message.must_equal "Cannot find Hanami::Model adapter `Hanami::Model::Adapters::RedisAdapter' (no such file to load -- hanami/model/adapters/redis_adapter)" - else - exception.message.must_equal "Cannot find Hanami::Model adapter `Hanami::Model::Adapters::RedisAdapter' (cannot load such file -- hanami/model/adapters/redis_adapter)" - end - end - end - - describe 'given adapter class is not found' do - let(:config) { Hanami::Model::Config::Adapter.new(type: :redis, uri: SQLITE_CONNECTION_STRING) } - - it 'raises an error' do - config.stub(:load_adapter, nil) do - exception = -> { config.build(mapper) }.must_raise(Hanami::Model::Error) - - if RUBY_VERSION >= "2.3" || Hanami::Utils.jruby? - exception.message.must_equal "uninitialized constant Hanami::Model::Adapters::RedisAdapter" - else - exception.message.must_equal "uninitialized constant RedisAdapter" - end - end - end - end - end - -end diff --git a/test/model/config/mapper_test.rb b/test/model/config/mapper_test.rb deleted file mode 100644 index ff139d66..00000000 --- a/test/model/config/mapper_test.rb +++ /dev/null @@ -1,66 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Config::Mapper do - describe '#initialize' do - describe 'when no block or file path is given' do - it 'raises error' do - exception = -> { Hanami::Model::Config::Mapper.new }.must_raise Hanami::Model::InvalidMappingError - exception.message.must_equal 'You must specify a block or a file.' - end - end - - describe "when a block is given" do - it 'converts block to proc and save in an instance' do - config = Hanami::Model::Config::Mapper.new do - collection :users do - entity User - - attribute :id, Integer - attribute :name, String - end - end - - assert config.instance_variable_get(:@blk).is_a?(Proc) - end - end - - describe "when a path is given" do - it 'stores the path to mapping file' do - config = Hanami::Model::Config::Mapper.new('test/fixtures/mapping') - config.instance_variable_get(:@path).must_equal Pathname.new("#{Dir.pwd}/test/fixtures/mapping") - end - end - end - - describe '#to_proc' do - describe 'when block is given' do - it 'returns the proc of the block' do - config = Hanami::Model::Config::Mapper.new do - collection :users do - entity User - - attribute :id, Integer - attribute :name, String - end - end - - assert config.to_proc.is_a?(Proc) - end - end - - describe 'when a file is given' do - it 'reads the content of the file and return a proc of content' do - config = Hanami::Model::Config::Mapper.new('test/fixtures/mapping') - - assert config.to_proc.is_a?(Proc) - end - end - - describe 'when an invalid file is given' do - it 'raises error' do - exception = -> { Hanami::Model::Config::Mapper.new('test/fixtures/invalid').to_proc }.must_raise ArgumentError - exception.message.must_equal 'You must specify a valid filepath.' - end - end - end -end \ No newline at end of file diff --git a/test/model/configuration_test.rb b/test/model/configuration_test.rb deleted file mode 100644 index 76522404..00000000 --- a/test/model/configuration_test.rb +++ /dev/null @@ -1,199 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Configuration do - let(:configuration) { Hanami::Model::Configuration.new } - - describe '#adapter_config' do - it 'defaults to an empty set' do - configuration.adapter_config.must_be_nil - configuration.instance_variable_get(:@adapter).must_be_nil - end - - it 'allows to register adapter configuration' do - configuration.adapter(type: :sql, uri: SQLITE_CONNECTION_STRING) - - adapter_config = configuration.adapter_config - adapter_config.must_be_instance_of Hanami::Model::Config::Adapter - adapter_config.uri.must_equal SQLITE_CONNECTION_STRING - end - - if Hanami::Utils.jruby? - it 'avoids duplication' do - configuration.adapter(type: :sql, uri: 'jdbc:sqlite:uri') - configuration.adapter(type: :memory, uri: 'memory://uri') - - configuration.adapter_config.type.must_equal :sql - end - else - it 'avoids duplication' do - configuration.adapter(type: :sql, uri: 'sqlite3://uri') - configuration.adapter(type: :memory, uri: 'memory://uri') - - configuration.adapter_config.type.must_equal :sql - end - end - - it 'raises error when :type is omitted' do - exception = -> { configuration.adapter(uri: SQLITE_CONNECTION_STRING) }.must_raise(ArgumentError) - exception.message.must_equal 'missing keyword: type' - end - - it 'raises error when :uri is omitted' do - exception = -> { configuration.adapter(type: :memory) }.must_raise(ArgumentError) - exception.message.must_equal 'missing keyword: uri' - end - end - - describe '#load!' do - before do - configuration.mapping do - collection :users do - entity User - - attribute :id, Integer - attribute :name, String - end - end - end - - it 'instantiates the registered adapter (memory)' do - configuration.adapter(type: :memory, uri: 'memory://localhost') - configuration.load! - - adapter = configuration.instance_variable_get(:@adapter) - adapter.must_be_instance_of Hanami::Model::Adapters::MemoryAdapter - end - - it 'instantiates the registered adapter (file system)' do - configuration.adapter(type: :file_system, uri: FILE_SYSTEM_CONNECTION_STRING) - configuration.load! - - adapter = configuration.instance_variable_get(:@adapter) - adapter.must_be_instance_of Hanami::Model::Adapters::FileSystemAdapter - end - - it 'instantiates the registered adapter (sql)' do - configuration.adapter(type: :sql, uri: SQLITE_CONNECTION_STRING) - configuration.load! - - adapter = configuration.instance_variable_get(:@adapter) - adapter.must_be_instance_of Hanami::Model::Adapters::SqlAdapter - end - - it 'builds collections from mapping' do - configuration.adapter(type: :memory, uri: 'memory://localhost') - configuration.load! - - collection = configuration.mapper.collection(:users) - collection.must_be_kind_of Hanami::Model::Mapping::Collection - collection.name.must_equal :users - end - end - - describe '#mapping' do - describe "when a block is given" do - it 'configures the global persistence mapper through block' do - configuration.mapping do - collection :users do - entity User - - attribute :id, Integer - attribute :name, String - end - end - - mapper_config = configuration.instance_variable_get(:@mapper_config) - mapper_config.must_be_instance_of Hanami::Model::Config::Mapper - end - end - - describe "when a path is given" do - it 'configures the global persistence mapper through block' do - configuration.mapping 'test/fixtures/mapping' - - mapper_config = configuration.instance_variable_get(:@mapper_config) - mapper_config.must_be_instance_of Hanami::Model::Config::Mapper - end - end - - describe "when block and path are not given" do - it 'raise error' do - exception = -> { configuration.mapping }.must_raise Hanami::Model::InvalidMappingError - exception.message.must_equal 'You must specify a block or a file.' - end - end - end - - describe "#migrations" do - describe "when no value was set" do - it "defaults to db/migrations" do - configuration.migrations.must_equal Pathname.new('db/migrations') - end - end - - describe "set a value" do - describe "to unexisting directory" do - it "raises error" do - -> { configuration.migrations('path/to/unknown') }.must_raise Errno::ENOENT - end - end - - describe "to existing directory" do - it "sets value" do - configuration.migrations 'test/fixtures/migrations' - configuration.migrations.must_equal Pathname.new('test/fixtures/migrations').realpath - end - end - end - end - - describe "#schema" do - describe "when no value was set" do - it "defaults to db/schema.sql" do - configuration.schema.must_equal Pathname.new('db/schema.sql') - end - end - - describe "set a value" do - describe "to existing directory" do - it "sets value" do - configuration.migrations 'test/fixtures/migrations' - configuration.migrations.must_equal Pathname.new('test/fixtures/migrations').realpath - end - end - end - end - - describe '#reset!' do - before do - configuration.adapter(type: :sql, uri: SQLITE_CONNECTION_STRING) - configuration.mapping do - collection :users do - entity User - - attribute :id, Integer - attribute :name, String - end - end - - configuration.migrations 'test/fixtures/migrations' - - configuration.load! - configuration.reset! - end - - it 'resets adapter' do - configuration.adapter_config.must_be_nil - configuration.instance_variable_get(:@adapter).must_be_nil - end - - it 'resets mapper' do - configuration.instance_variable_get(:@mapper_config).must_be_nil - configuration.mapper.must_be_instance_of Hanami::Model::NullMapper - end - - it 'resets migrations' do - configuration.migrations.must_equal Pathname.new('db/migrations') - end - end -end diff --git a/test/model/mapper_test.rb b/test/model/mapper_test.rb deleted file mode 100644 index 2daeb65d..00000000 --- a/test/model/mapper_test.rb +++ /dev/null @@ -1,94 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Mapper do - before do - @mapper = Hanami::Model::Mapper.new - end - - describe '#initialize' do - before do - class FakeCoercer - end - end - - after do - Object.send(:remove_const, :FakeCoercer) - end - - it 'uses the given coercer' do - mapper = Hanami::Model::Mapper.new(FakeCoercer) do - collection :articles do - end - end - - mapper.collection(:articles).coercer_class.must_equal(FakeCoercer) - end - - it 'executes the given block' do - mapper = Hanami::Model::Mapper.new do - collection :articles do - entity Article - end - end.load! - - mapper.collection(:articles).must_be_kind_of Hanami::Model::Mapping::Collection - end - end - - describe '#collections' do - before do - @mapper = Hanami::Model::Mapper.new do - collection :teas do - end - end - end - - it 'returns the mapped collections' do - name, collection = @mapper.collections.first - - name.must_equal :teas - collection.must_be_kind_of Hanami::Model::Mapping::Collection - end - end - - describe '#collection' do - describe 'when a block is given' do - it 'register a collection' do - @mapper.collection :users do - entity User - end - - @mapper.load! - - collection = @mapper.collection(:users) - collection.must_be_kind_of Hanami::Model::Mapping::Collection - collection.name.must_equal :users - end - end - - describe 'when only the name is passed' do - describe 'and the collection is present' do - before do - @mapper.collection :users do - entity User - end - - @mapper.load! - end - - it 'returns the collection' do - collection = @mapper.collection(:users) - - collection.must_be_kind_of(Hanami::Model::Mapping::Collection) - collection.name.must_equal :users - end - end - - describe 'and the collection is missing' do - it 'raises an error' do - -> { @mapper.collection(:unknown) }.must_raise(Hanami::Model::Mapping::UnmappedCollectionError) - end - end - end - end -end diff --git a/test/model/mapping/coercers_test.rb b/test/model/mapping/coercers_test.rb deleted file mode 100644 index 183abd09..00000000 --- a/test/model/mapping/coercers_test.rb +++ /dev/null @@ -1,200 +0,0 @@ -require 'test_helper' - -SIGNATURE_METHODS = %w(load dump).freeze - -describe Hanami::Model::Mapping::Coercers::Array do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into an array' do - actual = Hanami::Model::Mapping::Coercers::Array.__send__(m, 1) - actual.must_equal [1] - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::Array.__send__(m, nil) - actual.must_be_nil - end - - it 'preserves data structure' do - actual = Hanami::Model::Mapping::Coercers::Array.__send__(m, expected = [['hanami-controller', '~> 0.4'], ['hanami-view', '~> 0.4']]) - actual.must_equal expected - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::Boolean do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into a boolean' do - actual = Hanami::Model::Mapping::Coercers::Boolean.__send__(m, '1') - actual.must_equal true - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::Boolean.__send__(m, nil) - actual.must_be_nil - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::Date do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into a date' do - actual = Hanami::Model::Mapping::Coercers::Date.__send__(m, Date.today) - actual.must_equal Date.today - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::Date.__send__(m, nil) - actual.must_be_nil - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::DateTime do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into a datetime' do - actual = Hanami::Model::Mapping::Coercers::DateTime.__send__(m, DateTime.now) - actual.to_s.must_equal DateTime.now.to_s - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::DateTime.__send__(m, nil) - actual.must_be_nil - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::Float do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into a float' do - actual = Hanami::Model::Mapping::Coercers::Float.__send__(m, 1) - actual.must_equal 1.0 - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::Float.__send__(m, nil) - actual.must_be_nil - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::Hash do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into a hash' do - actual = Hanami::Model::Mapping::Coercers::Hash.__send__(m, []) - actual.must_equal({}) - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::Hash.__send__(m, nil) - actual.must_be_nil - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::Integer do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into an integer' do - actual = Hanami::Model::Mapping::Coercers::Integer.__send__(m, '23') - actual.must_equal 23 - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::Integer.__send__(m, nil) - actual.must_be_nil - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::BigDecimal do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into a BigDecimal' do - actual = Hanami::Model::Mapping::Coercers::BigDecimal.__send__(m, '23') - actual.must_equal BigDecimal.new(23) - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::BigDecimal.__send__(m, nil) - actual.must_be_nil - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::Set do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into a set' do - actual = Hanami::Model::Mapping::Coercers::Set.__send__(m, [1,1]) - actual.must_equal Set.new([1]) - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::Set.__send__(m, nil) - actual.must_be_nil - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::String do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into a string' do - actual = Hanami::Model::Mapping::Coercers::String.__send__(m, 12) - actual.must_equal '12' - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::String.__send__(m, nil) - actual.must_be_nil - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::Symbol do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into a symbol' do - actual = Hanami::Model::Mapping::Coercers::Symbol.__send__(m, 'wat') - actual.must_equal :wat - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::Symbol.__send__(m, nil) - actual.must_equal nil - end - end - end -end - -describe Hanami::Model::Mapping::Coercers::Time do - SIGNATURE_METHODS.each do |m| - describe ".#{ m }" do - it 'converts the input into a string' do - actual = Hanami::Model::Mapping::Coercers::Time.__send__(m, 0) - actual.must_equal Time.at(0) - end - - it 'returns nil when nil is given' do - actual = Hanami::Model::Mapping::Coercers::Time.__send__(m, nil) - actual.must_be_nil - end - end - end -end diff --git a/test/model/mapping/collection_coercer_test.rb b/test/model/mapping/collection_coercer_test.rb deleted file mode 100644 index 9cbf8081..00000000 --- a/test/model/mapping/collection_coercer_test.rb +++ /dev/null @@ -1,59 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Mapping::CollectionCoercer do - let(:entity) { User.new(name: 'Tyrion Lannister') } - let(:collection) { Hanami::Model::Mapping::Collection.new(:users, Hanami::Model::Mapping::CollectionCoercer) } - let(:coercer) { Hanami::Model::Mapping::CollectionCoercer.new(collection) } - - before do - collection.entity(User) - collection.attribute :id, Integer - collection.attribute :name, String - collection.attribute :age, String - end - - describe '#to_record' do - it 'should not return identity column' do - coercer.to_record(entity).must_equal(name: 'Tyrion Lannister') - end - - describe 'new record' do - # Bug: - # https://github.com/hanami/model/issues/155 - it 'ignores unset values' do - entity = User.new(name: 'Daenerys Targaryen') - coercer.to_record(entity).must_equal(name: 'Daenerys Targaryen') - end - - it 'forces nil values' do - entity = User.new(name: 'Daenerys Targaryen', age: nil) - coercer.to_record(entity).must_equal(name: 'Daenerys Targaryen') - end - end - - it 'should set keys for nil values when updating' do - entity = User.new(id: 4, name: 'Daenerys Targaryen', age: nil) - coercer.to_record(entity).must_equal(id: 4, name: 'Daenerys Targaryen', age: nil) - end - - describe 'when identity is set' do - let(:entity) { User.new(id: 3, name: 'Tyrion Lannister') } - - it 'should return identity as well' do - coercer.to_record(entity).must_equal(id: 3, name: 'Tyrion Lannister', age: nil) - end - end - end - - describe '#from_record' do - before do - collection.entity(::Repository) - collection.attribute :id, Integer - collection.attribute :name, String - end - - it 'should use the correct entity class' do - coercer.from_record(name: 'production').class.must_equal(::Repository) - end - end -end diff --git a/test/model/mapping/collection_test.rb b/test/model/mapping/collection_test.rb deleted file mode 100644 index 95f06627..00000000 --- a/test/model/mapping/collection_test.rb +++ /dev/null @@ -1,173 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::Mapping::Collection do - before do - @collection = Hanami::Model::Mapping::Collection.new(:users, Hanami::Model::Mapping::CollectionCoercer) - end - - describe '#initialize' do - it 'assigns the name' do - @collection.name.must_equal :users - end - - it 'assigns the coercer class' do - @collection.coercer_class.must_equal Hanami::Model::Mapping::CollectionCoercer - end - - it 'executes the given block' do - collection = Hanami::Model::Mapping::Collection.new(:users, Hanami::Model::Mapping::CollectionCoercer) do - entity User - end - - collection.entity.must_equal User - end - end - - describe '#entity' do - describe 'when a value is given' do - describe 'when the value is a class' do - before do - @collection.entity(User) - end - - it 'sets the value' do - @collection.entity.must_equal User - end - end - - describe 'when the value is a string' do - before do - @collection.entity('User') - end - - it 'sets the value' do - @collection.entity.must_equal 'User' - end - end - end - - describe 'when a value is not given' do - it 'returns the value' do - @collection.entity.must_be_nil - end - end - end - - describe '#repository' do - before do - @collection.entity User - end - - describe 'when a value is given' do - describe 'when the value is a class' do - before do - @collection.repository(CustomUserRepository) - end - - it 'sets the value' do - @collection.repository.must_equal CustomUserRepository - end - end - - describe 'when the value is a string' do - before do - @collection.repository('CustomUserRepository') - end - - it 'sets the value' do - @collection.repository.must_equal 'CustomUserRepository' - end - end - end - - describe 'when a value is not given' do - it 'returns the default value' do - @collection.repository.must_equal 'UserRepository' - end - end - end - - describe '#identity' do - describe 'when a value is given' do - before do - @collection.identity(:_id) - end - - it 'sets the value' do - @collection.identity.must_equal :_id - end - end - - describe 'when a value is not given' do - it 'returns the value' do - @collection.identity.must_equal(:id) - end - end - end - - describe '#attribute' do - before do - @collection.attribute :id, Integer - @collection.attribute :name, String, as: 't_name' - end - - it 'defines an attribute' do - @collection.attributes[:id].must_equal Hanami::Model::Mapping::Attribute.new(:id, Integer, {}) - end - - it 'defines a mapped attribute' do - @collection.attributes[:name].must_equal Hanami::Model::Mapping::Attribute.new(:name, String, as: :t_name) - end - end - - describe '#load!' do - before do - @adapter = Hanami::Model::Adapters::SqlAdapter.new(nil, SQLITE_CONNECTION_STRING) - @collection.entity('User') - @collection.repository('UserRepository') - @collection.adapter = @adapter - @collection.load! - end - - it 'converts entity to class' do - @collection.entity.must_equal User - end - - it 'converts repository to class' do - @collection.repository.must_equal UserRepository - end - - it 'sets up repository' do - UserRepository.collection.must_equal :users - UserRepository.instance_variable_get(:@adapter).must_equal @adapter - end - - it 'instantiates coercer' do - @collection.instance_variable_get(:@coercer).must_be_instance_of Hanami::Model::Mapping::CollectionCoercer - end - - describe 'when entity class does not exist' do - before do - @collection = Hanami::Model::Mapping::Collection.new(:users, Hanami::Model::Mapping::CollectionCoercer) - @collection.entity('NonExistingUser') - @collection.repository('UserRepository') - end - - it 'raises error' do - -> { @collection.load! }.must_raise(Hanami::Model::Mapping::EntityNotFound) - end - end - - describe 'when repository class does not exist' do - before do - @collection = Hanami::Model::Mapping::Collection.new(:users, Hanami::Model::Mapping::CollectionCoercer) - @collection.entity('User') - @collection.repository('NonExistingUserRepository') - end - - it 'raises error' do - -> { @collection.load! }.must_raise(Hanami::Model::Mapping::RepositoryNotFound) - end - end - end -end diff --git a/test/model_test.rb b/test/model_test.rb deleted file mode 100644 index ae92bc11..00000000 --- a/test/model_test.rb +++ /dev/null @@ -1,129 +0,0 @@ -require 'test_helper' - -describe Hanami::Model do - before do - Hanami::Model.unload! - end - - describe '.configuration' do - it 'exposes class configuration' do - Hanami::Model.configuration.must_be_kind_of(Hanami::Model::Configuration) - end - end - - describe '.duplicate' do - before do - Hanami::Model.configure do - adapter type: :sql, uri: 'postgres://localhost/database' - - mapping do - collection :users do - entity User - - attribute :id, Integer - attribute :name, String - end - end - end - - module Duplicated - Model = Hanami::Model.duplicate(self) - end - - module DuplicatedConfigure - Model = Hanami::Model.duplicate(self) do - reset! - - if Hanami::Utils.jruby? - adapter type: :sql, uri: 'jdbc:sqlite:path/database.sqlite3' - else - adapter type: :sql, uri: 'sqlite3://path/database.sqlite3' - end - end - end - end - - after do - Hanami::Model.configuration.reset! - - Object.send(:remove_const, :Duplicated) - Object.send(:remove_const, :DuplicatedConfigure) - end - - # Bug - # See https://github.com/hanami/model/issues/154 - it 'duplicates the configuration of the framework' do - actual = Duplicated::Model.configuration - assert actual == Hanami::Model::Configuration.new - end - - it 'duplicates a namespace for entity' do - assert defined?(Duplicated::Entity), 'Duplicated::Entity expected' - end - - it 'duplicates a namespace for repository' do - assert defined?(Duplicated::Repository), 'Duplicated::Repository expected' - end - - if Hanami::Utils.jruby? - it 'optionally accepts a block to configure the duplicated module' do - configuration = DuplicatedConfigure::Model.configuration - - configuration.adapter_config.uri.wont_equal 'postgres://localhost/database' - configuration.adapter_config.uri.must_equal 'jdbc:sqlite:path/database.sqlite3' - end - else - it 'optionally accepts a block to configure the duplicated module' do - configuration = DuplicatedConfigure::Model.configuration - - configuration.adapter_config.uri.wont_equal 'postgres://localhost/database' - configuration.adapter_config.uri.must_equal 'sqlite3://path/database.sqlite3' - end - end - end - - describe '.configure' do - after do - Hanami::Model.unload! - end - - it 'returns self' do - returning = Hanami::Model.configure { } - returning.must_equal(Hanami::Model) - end - - describe '.adapter' do - before do - Hanami::Model.configure do - adapter type: :sql, uri: 'postgres://localhost/database' - end - end - - it 'allows to register SQL adapter configuration' do - adapter_config = Hanami::Model.configuration.adapter_config - adapter_config.type.must_equal :sql - adapter_config.uri.must_equal 'postgres://localhost/database' - end - end - - describe '.mapping' do - before do - Hanami::Model.configure do - mapping do - collection :users do - entity User - - attribute :id, Integer - attribute :name, String - end - end - end - end - - it 'configures the global persistence mapper' do - mapper_config = Hanami::Model.configuration.instance_variable_get(:@mapper_config) - mapper_config.must_be_instance_of Hanami::Model::Config::Mapper - end - end - end -end diff --git a/test/model/adapters/sql/consoles/mysql_test.rb b/test/sql/console/mysql.rb similarity index 63% rename from test/model/adapters/sql/consoles/mysql_test.rb rename to test/sql/console/mysql.rb index 3882785b..01524029 100644 --- a/test/model/adapters/sql/consoles/mysql_test.rb +++ b/test/sql/console/mysql.rb @@ -1,8 +1,8 @@ require 'test_helper' -require 'hanami/model/adapters/sql/consoles/mysql' +require 'hanami/model/sql/consoles/mysql' -describe Hanami::Model::Adapters::Sql::Consoles::Mysql do - let(:console) { Hanami::Model::Adapters::Sql::Consoles::Mysql.new(uri) } +describe Hanami::Model::Sql::Consoles::Mysql do + let(:console) { Hanami::Model::Sql::Consoles::Mysql.new(uri) } describe '#connection_string' do let(:uri) { URI.parse('mysql://username:password@localhost:1234/foo_development') } diff --git a/test/model/adapters/sql/consoles/postgresql_test.rb b/test/sql/console/postgresql.rb similarity index 67% rename from test/model/adapters/sql/consoles/postgresql_test.rb rename to test/sql/console/postgresql.rb index 76479b78..6c0e792a 100644 --- a/test/model/adapters/sql/consoles/postgresql_test.rb +++ b/test/sql/console/postgresql.rb @@ -1,8 +1,7 @@ -require 'test_helper' -require 'hanami/model/adapters/sql/consoles/postgresql' +require 'hanami/model/sql/consoles/postgresql' -describe Hanami::Model::Adapters::Sql::Consoles::Postgresql do - let(:console) { Hanami::Model::Adapters::Sql::Consoles::Postgresql.new(uri) } +describe Hanami::Model::Sql::Consoles::Postgresql do + let(:console) { Hanami::Model::Sql::Consoles::Postgresql.new(uri) } describe '#connection_string' do let(:uri) { URI.parse('postgres://username:password@localhost:1234/foo_development') } diff --git a/test/model/adapters/sql/consoles/sqlite_test.rb b/test/sql/console/sqlite.rb similarity index 72% rename from test/model/adapters/sql/consoles/sqlite_test.rb rename to test/sql/console/sqlite.rb index 685d577f..2c4de3ef 100644 --- a/test/model/adapters/sql/consoles/sqlite_test.rb +++ b/test/sql/console/sqlite.rb @@ -1,8 +1,7 @@ -require 'test_helper' -require 'hanami/model/adapters/sql/consoles/sqlite' +require 'hanami/model/sql/consoles/sqlite' -describe Hanami::Model::Adapters::Sql::Consoles::Sqlite do - let(:console) { Hanami::Model::Adapters::Sql::Consoles::Sqlite.new(uri) } +describe Hanami::Model::Sql::Consoles::Sqlite do + let(:console) { Hanami::Model::Sql::Consoles::Sqlite.new(uri) } describe '#connection_string' do describe 'with shell ok database uri' do diff --git a/test/sql/console_test.rb b/test/sql/console_test.rb new file mode 100644 index 00000000..2dce739c --- /dev/null +++ b/test/sql/console_test.rb @@ -0,0 +1,35 @@ +require 'test_helper' +require 'hanami/model/sql/console' + +describe Hanami::Model::Sql::Console do + describe 'deciding on which SQL console class to use, based on URI scheme' do + let(:uri) { 'username:password@localhost:1234/foo_development' } + + case Database.engine + when :sqlite + it 'sqlite:// uri returns an instance of Console::Sqlite' do + console = Hanami::Model::Sql::Console.new("sqlite://#{uri}").send(:console) + console.must_be_kind_of(Hanami::Model::Sql::Consoles::Sqlite) + end + when :postgresql + it 'postgres:// uri returns an instance of Console::Postgresql' do + console = Hanami::Model::Sql::Console.new("postgres://#{uri}").send(:console) + console.must_be_kind_of(Hanami::Model::Sql::Consoles::Postgresql) + end + when :mysql + it 'mysql:// uri returns an instance of Console::Mysql' do + console = Hanami::Model::Sql::Console.new("mysql://#{uri}").send(:console) + console.must_be_kind_of(Hanami::Model::Sql::Consoles::Mysql) + end + + it 'mysql2:// uri returns an instance of Console::Mysql' do + console = Hanami::Model::Sql::Console.new("mysql2://#{uri}").send(:console) + console.must_be_kind_of(Hanami::Model::Sql::Consoles::Mysql) + end + end + end + + describe Database.engine.to_s do + load "test/sql/console/#{Database.engine}.rb" + end +end diff --git a/test/sql/entity/schema/automatic_test.rb b/test/sql/entity/schema/automatic_test.rb new file mode 100644 index 00000000..483f046f --- /dev/null +++ b/test/sql/entity/schema/automatic_test.rb @@ -0,0 +1,53 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Entity::Schema do + describe 'automatic' do + let(:subject) { Author.schema } + + describe '#initialize' do + it 'returns frozen instance' do + subject.must_be :frozen? + end + end + + describe '#call' do + it 'returns empty hash when nil is given' do + result = subject.call(nil) + + result.must_equal({}) + end + + it 'processes attributes' do + now = Time.now + result = subject.call(id: 1, created_at: now.to_s) + + result.fetch(:id).must_equal(1) + result.fetch(:created_at).must_be_close_to(now, 2) + end + + it 'ignores unknown attributes' do + result = subject.call(foo: 'bar') + + result.must_equal({}) + end + + it 'raises error if the process fails' do + exception = lambda do + subject.call(id: :foo) + end.must_raise(ArgumentError) + + exception.message.must_equal 'comparison of Symbol with 0 failed' + end + end + + describe '#attribute?' do + it 'returns true for known attributes' do + subject.attribute?(:id).must_equal true + end + + it 'returns false for unknown attributes' do + subject.attribute?(:foo).must_equal false + end + end + end +end diff --git a/test/sql/entity/schema/mapping_test.rb b/test/sql/entity/schema/mapping_test.rb new file mode 100644 index 00000000..6dc405b9 --- /dev/null +++ b/test/sql/entity/schema/mapping_test.rb @@ -0,0 +1,51 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Entity::Schema do + describe 'mapping' do + let(:subject) { Operator.schema } + + describe '#initialize' do + it 'returns frozen instance' do + subject.must_be :frozen? + end + end + + describe '#call' do + it 'returns empty hash when nil is given' do + result = subject.call(nil) + + result.must_equal({}) + end + + it 'processes attributes' do + result = subject.call(id: 1, name: :foo) + + result.must_equal(id: 1, name: 'foo') + end + + it 'ignores unknown attributes' do + result = subject.call(foo: 'bar') + + result.must_equal({}) + end + + it 'raises error if the process fails' do + exception = lambda do + subject.call(id: :foo) + end.must_raise(ArgumentError) + + exception.message.must_equal 'comparison of Symbol with 0 failed' + end + end + + describe '#attribute?' do + it 'returns true for known attributes' do + subject.attribute?(:id).must_equal true + end + + it 'returns false for unknown attributes' do + subject.attribute?(:foo).must_equal false + end + end + end +end diff --git a/test/sql/schema/array_test.rb b/test/sql/schema/array_test.rb new file mode 100644 index 00000000..e2a9bb9c --- /dev/null +++ b/test/sql/schema/array_test.rb @@ -0,0 +1,107 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Types::Schema::Array do + let(:described_class) { Hanami::Model::Sql::Types::Schema::Array } + let(:input) do + Class.new do + def to_ary + [] + end + end.new + end + + it 'returns nil for nil' do + input = nil + described_class[input].must_equal input + end + + it 'coerces object that respond to #to_ary' do + described_class[input].must_equal input.to_ary + end + + it 'coerces string' do + input = 'foo' + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Array(): #{input.inspect}" + end + + it 'raises error for symbol' do + input = :foo + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Array(): #{input.inspect}" + end + + it 'raises error for integer' do + input = 11 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Array(): #{input.inspect}" + end + + it 'raises error for float' do + input = 3.14 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Array(): #{input.inspect}" + end + + it 'raises error for bigdecimal' do + input = BigDecimal.new(3.14, 10) + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Array(): #{input.inspect}" + end + + it 'raises error for date' do + input = Date.today + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Array(): #{input.inspect}" + end + + it 'raises error for datetime' do + input = DateTime.new + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Array(): #{input.inspect}" + end + + it 'raises error for time' do + input = Time.now + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Array(): #{input.inspect}" + end + + it 'coerces array' do + input = [] + described_class[input].must_equal input + end + + it 'raises error for hash' do + input = {} + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Array(): #{input.inspect}" + end +end diff --git a/test/sql/schema/bool_test.rb b/test/sql/schema/bool_test.rb new file mode 100644 index 00000000..18b685a4 --- /dev/null +++ b/test/sql/schema/bool_test.rb @@ -0,0 +1,110 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Types::Schema::Bool do + let(:described_class) { Hanami::Model::Sql::Types::Schema::Bool } + + it 'returns nil for nil' do + input = nil + described_class[input].must_equal input + end + + it 'returns true for true' do + input = true + described_class[input].must_equal input + end + + it 'returns false for false' do + input = true + described_class[input].must_equal input + end + + it 'raises error for string' do + input = 'foo' + exception = lambda do + described_class[input] + end.must_raise(TypeError) + + exception.message.must_equal "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)" + end + + it 'raises error for symbol' do + input = :foo + exception = lambda do + described_class[input] + end.must_raise(TypeError) + + exception.message.must_equal "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)" + end + + it 'raises error for integer' do + input = 11 + exception = lambda do + described_class[input] + end.must_raise(TypeError) + + exception.message.must_equal "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)" + end + + it 'raises error for float' do + input = 3.14 + exception = lambda do + described_class[input] + end.must_raise(TypeError) + + exception.message.must_equal "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)" + end + + it 'raises error for bigdecimal' do + input = BigDecimal.new(3.14, 10) + exception = lambda do + described_class[input] + end.must_raise(TypeError) + + exception.message.must_equal "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)" + end + + it 'raises error for date' do + input = Date.today + exception = lambda do + described_class[input] + end.must_raise(TypeError) + + exception.message.must_equal "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)" + end + + it 'raises error for datetime' do + input = DateTime.new + exception = lambda do + described_class[input] + end.must_raise(TypeError) + + exception.message.must_equal "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)" + end + + it 'raises error for time' do + input = Time.now + exception = lambda do + described_class[input] + end.must_raise(TypeError) + + exception.message.must_equal "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)" + end + + it 'raises error for array' do + input = [] + exception = lambda do + described_class[input] + end.must_raise(TypeError) + + exception.message.must_equal "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)" + end + + it 'raises error for hash' do + input = {} + exception = lambda do + described_class[input] + end.must_raise(TypeError) + + exception.message.must_equal "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)" + end +end diff --git a/test/sql/schema/date_test.rb b/test/sql/schema/date_test.rb new file mode 100644 index 00000000..bc4d8646 --- /dev/null +++ b/test/sql/schema/date_test.rb @@ -0,0 +1,117 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Types::Schema::Date do + let(:described_class) { Hanami::Model::Sql::Types::Schema::Date } + let(:input) do + Class.new do + def to_date + Date.today + end + end.new + end + + it 'returns nil for nil' do + input = nil + described_class[input].must_equal input + end + + it 'coerces object that respond to #to_date' do + described_class[input].must_equal input.to_date + end + + it 'coerces string' do + date = Date.today + input = date.to_s + + described_class[input].must_equal date + end + + it 'coerces Hanami string' do + input = Hanami::Utils::String.new(Date.today) + described_class[input].must_equal Date.parse(input) + end + + it 'raises error for meaningless string' do + input = 'foo' + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal 'invalid date' + end + + it 'raises error for symbol' do + input = :foo + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Date(): #{input.inspect}" + end + + it 'raises error for integer' do + input = 11 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Date(): #{input.inspect}" + end + + it 'raises error for float' do + input = 3.14 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Date(): #{input.inspect}" + end + + it 'raises error for bigdecimal' do + input = BigDecimal.new(3.14, 10) + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Date(): #{input.inspect}" + end + + it 'coerces date' do + input = Date.today + date = input + + described_class[input].must_equal date + end + + it 'coerces datetime' do + input = DateTime.new + date = input.to_date + + described_class[input].must_equal date + end + + it 'coerces time' do + input = Time.now + date = input.to_date + + described_class[input].must_equal date + end + + it 'raises error for array' do + input = [] + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Date(): #{input.inspect}" + end + + it 'raises error for hash' do + input = {} + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Date(): #{input.inspect}" + end +end diff --git a/test/sql/schema/date_time_test.rb b/test/sql/schema/date_time_test.rb new file mode 100644 index 00000000..4e1ddbe5 --- /dev/null +++ b/test/sql/schema/date_time_test.rb @@ -0,0 +1,117 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Types::Schema::DateTime do + let(:described_class) { Hanami::Model::Sql::Types::Schema::DateTime } + let(:input) do + Class.new do + def to_datetime + DateTime.new + end + end.new + end + + it 'returns nil for nil' do + input = nil + described_class[input].must_equal input + end + + it 'coerces object that respond to #to_datetime' do + described_class[input].must_equal input.to_datetime + end + + it 'coerces string' do + date = DateTime.new + input = date.to_s + + described_class[input].must_equal date + end + + it 'coerces Hanami string' do + input = Hanami::Utils::String.new(DateTime.new) + described_class[input].must_equal DateTime.parse(input) + end + + it 'raises error for meaningless string' do + input = 'foo' + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal 'invalid date' + end + + it 'raises error for symbol' do + input = :foo + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for DateTime(): #{input.inspect}" + end + + it 'raises error for integer' do + input = 11 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for DateTime(): #{input.inspect}" + end + + it 'raises error for float' do + input = 3.14 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for DateTime(): #{input.inspect}" + end + + it 'raises error for bigdecimal' do + input = BigDecimal.new(3.14, 10) + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for DateTime(): #{input.inspect}" + end + + it 'coerces date' do + input = Date.today + date_time = input.to_datetime + + described_class[input].must_equal date_time + end + + it 'coerces datetime' do + input = DateTime.new + date_time = input + + described_class[input].must_equal date_time + end + + it 'coerces time' do + input = Time.now + date_time = input.to_datetime + + described_class[input].must_be_close_to(date_time, 2) + end + + it 'raises error for array' do + input = [] + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for DateTime(): #{input.inspect}" + end + + it 'raises error for hash' do + input = {} + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for DateTime(): #{input.inspect}" + end +end diff --git a/test/sql/schema/decimal_test.rb b/test/sql/schema/decimal_test.rb new file mode 100644 index 00000000..831ad9ab --- /dev/null +++ b/test/sql/schema/decimal_test.rb @@ -0,0 +1,120 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Types::Schema::Decimal do + let(:described_class) { Hanami::Model::Sql::Types::Schema::Decimal } + let(:input) do + Class.new do + def to_d + BigDecimal.new(10) + end + end.new + end + + it 'returns nil for nil' do + input = nil + described_class[input].must_equal input + end + + it 'coerces object that respond to #to_d' do + described_class[input].must_equal input.to_d + end + + it 'coerces string representing int' do + input = '1' + described_class[input].must_equal input.to_d + end + + it 'coerces Hanami string representing int' do + input = Hanami::Utils::String.new('1') + described_class[input].must_equal input.to_d + end + + it 'coerces string representing float' do + input = '3.14' + described_class[input].must_equal input.to_d + end + + it 'coerces Hanami string representing float' do + input = Hanami::Utils::String.new('3.14') + described_class[input].must_equal input.to_d + end + + it 'raises error for meaningless string' + # it 'raises error for meaningless string' do + # exception = lambda do + # input = 'foo' + # described_class[input] + # end.must_raise(ArgumentError) + + # exception.message.must_equal 'invalid value for BigDecimal(): "foo"' + # end + + it 'raises error for symbol' do + input = :house_11 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for BigDecimal(): #{input.inspect}" + end + + it 'coerces integer' do + input = 23 + described_class[input].must_equal input.to_d + end + + it 'coerces float' do + input = 3.14 + described_class[input].must_equal input.to_d + end + + it 'coerces bigdecimal' do + input = BigDecimal.new(3.14, 10) + described_class[input].must_equal input.to_d + end + + it 'raises error for date' do + input = Date.today + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for BigDecimal(): #{input.inspect}" + end + + it 'raises error for datetime' do + input = DateTime.new + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for BigDecimal(): #{input.inspect}" + end + + it 'raises error for time' do + input = Time.now + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for BigDecimal(): #{input.inspect}" + end + + it 'raises error for array' do + input = [] + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for BigDecimal(): #{input.inspect}" + end + + it 'raises error for hash' do + input = {} + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for BigDecimal(): #{input.inspect}" + end +end diff --git a/test/sql/schema/float_test.rb b/test/sql/schema/float_test.rb new file mode 100644 index 00000000..cbe46e07 --- /dev/null +++ b/test/sql/schema/float_test.rb @@ -0,0 +1,119 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Types::Schema::Float do + let(:described_class) { Hanami::Model::Sql::Types::Schema::Float } + let(:input) do + Class.new do + def to_f + 3.14 + end + end.new + end + + it 'returns nil for nil' do + input = nil + described_class[input].must_equal input + end + + it 'coerces object that respond to #to_f' do + described_class[input].must_equal input.to_f + end + + it 'coerces string representing int' do + input = '1' + described_class[input].must_equal input.to_f + end + + it 'coerces Hanami string representing int' do + input = Hanami::Utils::String.new('1') + described_class[input].must_equal input.to_f + end + + it 'coerces string representing float' do + input = '3.14' + described_class[input].must_equal input.to_f + end + + it 'coerces Hanami string representing float' do + input = Hanami::Utils::String.new('3.14') + described_class[input].must_equal input.to_f + end + + it 'raises error for meaningless string' do + input = 'foo' + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Float(): #{input.inspect}" + end + + it 'raises error for symbol' do + input = :house_11 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Float(): #{input.inspect}" + end + + it 'coerces integer' do + input = 23 + described_class[input].must_equal input.to_f + end + + it 'coerces float' do + input = 3.14 + described_class[input].must_equal input.to_f + end + + it 'coerces bigdecimal' do + input = BigDecimal.new(3.14, 10) + described_class[input].must_equal input.to_f + end + + it 'raises error for date' do + input = Date.today + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Float(): #{input.inspect}" + end + + it 'raises error for datetime' do + input = DateTime.new + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Float(): #{input.inspect}" + end + + it 'raises error for time' do + input = Time.now + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Float(): #{input.inspect}" + end + + it 'raises error for array' do + input = [] + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Float(): #{input.inspect}" + end + + it 'raises error for hash' do + input = {} + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Float(): #{input.inspect}" + end +end diff --git a/test/sql/schema/hash_test.rb b/test/sql/schema/hash_test.rb new file mode 100644 index 00000000..7e7332bd --- /dev/null +++ b/test/sql/schema/hash_test.rb @@ -0,0 +1,113 @@ +require 'test_helper' +require 'hanami/utils/hash' + +describe Hanami::Model::Sql::Types::Schema::Hash do + let(:described_class) { Hanami::Model::Sql::Types::Schema::Hash } + let(:input) do + Class.new do + def to_hash + Hash[] + end + end.new + end + + it 'returns nil for nil' do + input = nil + described_class[input].must_equal input + end + + it 'coerces object that respond to #to_hash' do + described_class[input].must_equal input.to_hash + end + + it 'coerces string' do + input = 'foo' + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Hash(): #{input.inspect}" + end + + it 'raises error for symbol' do + input = :foo + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Hash(): #{input.inspect}" + end + + it 'raises error for integer' do + input = 11 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Hash(): #{input.inspect}" + end + + it 'raises error for float' do + input = 3.14 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Hash(): #{input.inspect}" + end + + it 'raises error for bigdecimal' do + input = BigDecimal.new(3.14, 10) + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Hash(): #{input.inspect}" + end + + it 'raises error for date' do + input = Date.today + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Hash(): #{input.inspect}" + end + + it 'raises error for datetime' do + input = DateTime.new + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Hash(): #{input.inspect}" + end + + it 'raises error for time' do + input = Time.now + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Hash(): #{input.inspect}" + end + + it 'raises error for array' do + input = [] + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Hash(): #{input.inspect}" + end + + it 'coerces hash' do + input = {} + described_class[input].must_equal input + end + + it 'coerces Hanami hash' do + input = Hanami::Utils::Hash.new({}) + described_class[input].must_equal input.to_h + end +end diff --git a/test/sql/schema/int_test.rb b/test/sql/schema/int_test.rb new file mode 100644 index 00000000..71911832 --- /dev/null +++ b/test/sql/schema/int_test.rb @@ -0,0 +1,109 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Types::Schema::Int do + let(:described_class) { Hanami::Model::Sql::Types::Schema::Int } + let(:input) do + Class.new do + def to_int + 23 + end + end.new + end + + it 'returns nil for nil' do + input = nil + described_class[input].must_equal input + end + + it 'coerces object that respond to #to_int' do + described_class[input].must_equal input.to_int + end + + it 'coerces string representing int' do + input = '1' + described_class[input].must_equal input.to_i + end + + it 'coerces Hanami string representing int' do + input = Hanami::Utils::String.new('1') + described_class[input].must_equal input.to_i + end + + it 'raises error for meaningless string' do + input = 'foo' + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Integer(): #{input.inspect}" + end + + it 'raises error for symbol' do + input = :house_11 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Integer(): #{input.inspect}" + end + + it 'coerces integer' do + input = 23 + described_class[input].must_equal input + end + + it 'coerces float' do + input = 3.14 + described_class[input].must_equal input.to_i + end + + it 'coerces bigdecimal' do + input = BigDecimal.new(3.14, 10) + described_class[input].must_equal input.to_i + end + + it 'raises error for date' do + input = Date.today + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Integer(): #{input.inspect}" + end + + it 'raises error for datetime' do + input = DateTime.new + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Integer(): #{input.inspect}" + end + + it 'raises error for time' do + input = Time.now + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Integer(): #{input.inspect}" + end + + it 'raises error for array' do + input = [] + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Integer(): #{input.inspect}" + end + + it 'raises error for hash' do + input = {} + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Integer(): #{input.inspect}" + end +end diff --git a/test/sql/schema/string_test.rb b/test/sql/schema/string_test.rb new file mode 100644 index 00000000..6bc482d2 --- /dev/null +++ b/test/sql/schema/string_test.rb @@ -0,0 +1,60 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Types::Schema::String do + let(:described_class) { Hanami::Model::Sql::Types::Schema::String } + + it 'returns nil for nil' do + input = nil + described_class[input].must_equal input + end + + it 'coerces string' do + input = 'foo' + described_class[input].must_equal input.to_s + end + + it 'coerces symbol' do + input = :foo + described_class[input].must_equal input.to_s + end + + it 'coerces integer' do + input = 23 + described_class[input].must_equal input.to_s + end + + it 'coerces float' do + input = 3.14 + described_class[input].must_equal input.to_s + end + + it 'coerces bigdecimal' do + input = BigDecimal.new(3.14, 10) + described_class[input].must_equal input.to_s + end + + it 'coerces date' do + input = Date.today + described_class[input].must_equal input.to_s + end + + it 'coerces datetime' do + input = DateTime.new + described_class[input].must_equal input.to_s + end + + it 'coerces time' do + input = Time.now + described_class[input].must_equal input.to_s + end + + it 'coerces array' do + input = [] + described_class[input].must_equal input.to_s + end + + it 'coerces hash' do + input = {} + described_class[input].must_equal input.to_s + end +end diff --git a/test/sql/schema/time_test.rb b/test/sql/schema/time_test.rb new file mode 100644 index 00000000..a81c6b32 --- /dev/null +++ b/test/sql/schema/time_test.rb @@ -0,0 +1,115 @@ +require 'test_helper' + +describe Hanami::Model::Sql::Types::Schema::Time do + let(:described_class) { Hanami::Model::Sql::Types::Schema::Time } + let(:input) do + Class.new do + def to_time + Time.now + end + end.new + end + + it 'returns nil for nil' do + input = nil + described_class[input].must_equal input + end + + it 'coerces object that respond to #to_time' do + described_class[input].must_be_close_to(input.to_time, 2) + end + + it 'coerces string' do + time = Time.now + input = time.to_s + + described_class[input].must_be_close_to(time, 2) + end + + it 'coerces Hanami string' do + input = Hanami::Utils::String.new(Time.now) + described_class[input].must_be_close_to(Time.parse(input), 2) + end + + it 'raises error for meaningless string' do + input = 'foo' + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "no time information in #{input.inspect}" + end + + it 'raises error for symbol' do + input = :foo + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Time(): #{input.inspect}" + end + + it 'coerces integer' do + input = 11 + time = Time.at(input) + + described_class[input].must_be_close_to(time, 2) + end + + it 'raises error for float' do + input = 3.14 + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Time(): #{input.inspect}" + end + + it 'raises error for bigdecimal' do + input = BigDecimal.new(3.14, 10) + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Time(): #{input.inspect}" + end + + it 'coerces date' do + input = Date.today + time = input.to_time + + described_class[input].must_be_close_to(time, 2) + end + + it 'coerces datetime' do + input = DateTime.new + time = input.to_time + + described_class[input].must_be_close_to(time, 2) + end + + it 'coerces time' do + input = Time.now + time = input + + described_class[input].must_be_close_to(time, 2) + end + + it 'raises error for array' do + input = [] + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Time(): #{input.inspect}" + end + + it 'raises error for hash' do + input = {} + exception = lambda do + described_class[input] + end.must_raise(ArgumentError) + + exception.message.must_equal "invalid value for Time(): #{input.inspect}" + end +end diff --git a/test/support/database.rb b/test/support/database.rb new file mode 100644 index 00000000..913f073d --- /dev/null +++ b/test/support/database.rb @@ -0,0 +1,47 @@ +module Database + class Setup + DEFAULT_ADAPTER = 'sqlite'.freeze + + def initialize(adapter: ENV['DB']) + @strategy = Strategy.for(adapter || DEFAULT_ADAPTER) + end + + def run + @strategy.run + end + end + + module Strategies + require_relative './database/strategies/sqlite' + require_relative './database/strategies/postgresql' + require_relative './database/strategies/mysql' + + def self.strategies + constants.map do |const| + const_get(const) + end + end + end + + class Strategy + class << self + def for(adapter) + strategies.find do |strategy| + strategy.eligible?(adapter) + end.new + end + + private + + def strategies + Strategies.strategies + end + end + end + + def self.engine + ENV['HANAMI_DATABASE_TYPE'].to_sym + end +end + +Database::Setup.new.run diff --git a/test/support/database/strategies/abstract.rb b/test/support/database/strategies/abstract.rb new file mode 100644 index 00000000..c73a8cc2 --- /dev/null +++ b/test/support/database/strategies/abstract.rb @@ -0,0 +1,63 @@ +module Database + module Strategies + class Abstract + def self.eligible?(_adapter) + false + end + + def run + before + load_dependencies + export_env + create_database + configure + after + sleep 1 + end + + protected + + def before + # Optional hook for subclasses + end + + def database_name + 'hanami_model' + end + + def load_dependencies + raise NotImplementedError + end + + def export_env + ENV['HANAMI_DATABASE_NAME'] = database_name + end + + def create_database + raise NotImplementedError + end + + def configure + returing = Hanami::Model.configure do + adapter ENV['HANAMI_DATABASE_ADAPTER'].to_sym, ENV['HANAMI_DATABASE_URL'] + end + + returing == Hanami::Model or raise 'Hanami::Model.configure should return Hanami::Model' # rubocop:disable Style/AndOr + end + + def after + # Optional hook for subclasses + end + + private + + def jruby? + Platform::Engine.engine?(:jruby) + end + + def ci? + Platform.ci? + end + end + end +end diff --git a/test/support/database/strategies/mysql.rb b/test/support/database/strategies/mysql.rb new file mode 100644 index 00000000..3d8acc81 --- /dev/null +++ b/test/support/database/strategies/mysql.rb @@ -0,0 +1,87 @@ +require_relative 'sql' + +module Database + module Strategies + class Mysql < Sql + module JrubyImplementation + protected + + def load_dependencies + require 'hanami/model/sql' + require 'jdbc/mysql' + end + + def export_env + super + ENV['HANAMI_DATABASE_URL'] = "jdbc:mysql://#{host}/#{database_name}?#{credentials}" + end + + def host + ENV['HANAMI_DATABASE_HOST'] || '127.0.0.1' + end + + def credentials + Hash[ + 'user' => ENV['HANAMI_DATABASE_USERNAME'], + 'password' => ENV['HANAMI_DATABASE_PASSWORD'], + 'useSSL' => 'false' + ].map do |key, value| + "#{key}=#{value}" unless Hanami::Utils::Blank.blank?(value) + end.compact.join('&') + end + end + + module CiImplementation + protected + + def export_env + super + ENV['HANAMI_DATABASE_USERNAME'] = 'travis' + end + + private + + def run_command(command) + result = system %(mysql -u root -e "#{command}") + raise "Failed command:\n#{command}" unless result + end + end + + def self.eligible?(adapter) + adapter.start_with?('mysql') + end + + def initialize + extend(CiImplementation) if ci? + extend(JrubyImplementation) if jruby? + end + + protected + + def load_dependencies + require 'hanami/model/sql' + require 'mysql2' + end + + def export_env + super + ENV['HANAMI_DATABASE_TYPE'] = 'mysql' + ENV['HANAMI_DATABASE_USERNAME'] ||= 'root' + ENV['HANAMI_DATABASE_PASSWORD'] ||= '' + ENV['HANAMI_DATABASE_URL'] = "mysql2://#{credentials}@#{host}/#{database_name}" + end + + def create_database + run_command "DROP DATABASE IF EXISTS #{database_name}" + run_command "CREATE DATABASE #{database_name}" + run_command "GRANT ALL PRIVILEGES ON #{database_name}.* TO '#{ENV['HANAMI_DATABASE_USERNAME']}'@'#{host}'; FLUSH PRIVILEGES;" + end + + private + + def run_command(command) + system %(mysql -u #{ENV['HANAMI_DATABASE_USERNAME']} -e "#{command}") + end + end + end +end diff --git a/test/support/database/strategies/postgresql.rb b/test/support/database/strategies/postgresql.rb new file mode 100644 index 00000000..2fec682b --- /dev/null +++ b/test/support/database/strategies/postgresql.rb @@ -0,0 +1,73 @@ +require_relative 'sql' + +module Database + module Strategies + class Postgresql < Sql + module JrubyImplementation + protected + + def load_dependencies + require 'hanami/model/sql' + require 'jdbc/postgres' + + Jdbc::Postgres.load_driver + end + + def export_env + super + ENV['HANAMI_DATABASE_URL'] = "jdbc:postgresql://#{host}/#{database_name}" + end + end + + module CiImplementation + protected + + def export_env + super + ENV['HANAMI_DATABASE_USERNAME'] = 'postgres' + end + end + + def self.eligible?(adapter) + adapter.start_with?('postgres') + end + + def initialize + extend(CiImplementation) if ci? + extend(JrubyImplementation) if jruby? + end + + protected + + def load_dependencies + require 'hanami/model/sql' + require 'pg' + end + + def create_database + try("Failed to drop Postgres database: #{database_name}") do + system "dropdb #{database_name}" + end + + try("Failed to create Postgres database: #{database_name}") do + system "createdb #{database_name}" + end + end + + def export_env + super + ENV['HANAMI_DATABASE_TYPE'] = 'postgresql' + ENV['HANAMI_DATABASE_URL'] = "postgres://#{host}/#{database_name}" + ENV['HANAMI_DATABASE_USERNAME'] = `whoami`.strip.freeze + end + + private + + def try(message) + yield + rescue + warn message + end + end + end +end diff --git a/test/support/database/strategies/sql.rb b/test/support/database/strategies/sql.rb new file mode 100644 index 00000000..f21083e7 --- /dev/null +++ b/test/support/database/strategies/sql.rb @@ -0,0 +1,48 @@ +require_relative 'abstract' +require 'hanami/utils/blank' + +module Database + module Strategies + class Sql < Abstract + def self.eligible?(_adapter) + false + end + + protected + + def export_env + super + ENV['HANAMI_DATABASE_ADAPTER'] = 'sql' + end + + def configure + Hanami::Model.configure do + adapter ENV['HANAMI_DATABASE_ADAPTER'].to_sym, ENV['HANAMI_DATABASE_URL'] + migrations Dir.pwd + '/test/fixtures/database_migrations' + schema Dir.pwd + '/tmp/schema.sql' + end + end + + def after + migrate + puts "Testing with `#{ENV['HANAMI_DATABASE_ADAPTER']}' adapter (#{ENV['HANAMI_DATABASE_TYPE']}) - jruby: #{jruby?}, ci: #{ci?}" + puts "Env: #{ENV.inspect}" if ci? + end + + def migrate + require 'hanami/model/migrator' + Hanami::Model::Migrator.migrate + end + + def credentials + [ENV['HANAMI_DATABASE_USERNAME'], ENV['HANAMI_DATABASE_PASSWORD']].reject do |token| + Hanami::Utils::Blank.blank?(token) + end.join(':') + end + + def host + ENV['HANAMI_DATABASE_HOST'] || 'localhost' + end + end + end +end diff --git a/test/support/database/strategies/sqlite.rb b/test/support/database/strategies/sqlite.rb new file mode 100644 index 00000000..3eb6d8e2 --- /dev/null +++ b/test/support/database/strategies/sqlite.rb @@ -0,0 +1,59 @@ +require_relative 'sql' +require 'pathname' + +module Database + module Strategies + class Sqlite < Sql + module JrubyImplementation + protected + + def load_dependencies + require 'hanami/model/sql' + require 'jdbc/sqlite3' + Jdbc::SQLite3.load_driver + end + + def export_env + super + ENV['HANAMI_DATABASE_URL'] = "jdbc:sqlite:#{database_name}" + end + end + + module CiImplementation + end + + def self.eligible?(adapter) + adapter.start_with?('sqlite') + end + + def initialize + extend(CiImplementation) if ci? + extend(JrubyImplementation) if jruby? + end + + protected + + def database_name + Pathname.new(__dir__).join('..', '..', '..', '..', 'tmp', 'sqlite', "#{super}.sqlite3").to_s + end + + def load_dependencies + require 'hanami/model/sql' + require 'sqlite3' + end + + def create_database + path = Pathname.new(database_name) + path.dirname.mkpath # create directory if not exist + + path.delete if path.exist? # delete file if exist + end + + def export_env + super + ENV['HANAMI_DATABASE_TYPE'] = 'sqlite' + ENV['HANAMI_DATABASE_URL'] = "sqlite://#{database_name}" + end + end + end +end diff --git a/test/support/fixtures.rb b/test/support/fixtures.rb new file mode 100644 index 00000000..9e0f6306 --- /dev/null +++ b/test/support/fixtures.rb @@ -0,0 +1,120 @@ +class User < Hanami::Entity +end + +class Avatar < Hanami::Entity +end + +class Author < Hanami::Entity +end + +class Book < Hanami::Entity +end + +class Operator < Hanami::Entity +end + +class SourceFile < Hanami::Entity +end + +class Wharehouse < Hanami::Entity + attributes do + attribute :id, Types::Int + attribute :name, Types::String + attribute :code, Types::String.constrained(format: /\Awh\-/) + end +end + +class Account < Hanami::Entity + attributes do + attribute :id, Types::Strict::Int + attribute :name, Types::String + attribute :codes, Types::Collection(Types::Coercible::Int) + attribute :users, Types::Collection(User) + attribute :email, Types::String.constrained(format: /@/) + attribute :created_at, Types::DateTime.constructor(->(dt) { ::DateTime.parse(dt.to_s) }) + end +end + +class UserRepository < Hanami::Repository + def by_name(name) + users.where(name: name).as(:entity) + end +end + +class AvatarRepository < Hanami::Repository +end + +class AuthorRepository < Hanami::Repository + associations do + has_many :books + end + + def find_with_books(id) + aggregate(:books).where(authors__id: id).as(Author).one + end + + def books_for(author) + assoc(:books, author) + end + + def add_book(author, data) + assoc(:books, author).add(data) + end + + def remove_book(author, id) + assoc(:books, author).remove(id) + end + + def delete_books(author) + assoc(:books, author).delete + end + + def delete_on_sales_books(author) + assoc(:books, author).where(on_sale: true).delete + end + + def books_count(author) + assoc(:books, author).count + end + + def on_sales_books_count(author) + assoc(:books, author).where(on_sale: true).count + end + + def find_book(author, id) + book_for(author, id).one + end + + def book_exists?(author, id) + book_for(author, id).exists? + end + + private + + def book_for(author, id) + assoc(:books, author).where(id: id) + end +end + +class BookRepository < Hanami::Repository + associations do + belongs_to :author + end +end + +class OperatorRepository < Hanami::Repository + self.relation = :t_operator + + mapping do + attribute :id, from: :operator_id + attribute :name, from: :s_name + end +end + +class SourceFileRepository < Hanami::Repository +end + +class WharehouseRepository < Hanami::Repository +end + +Hanami::Model.load! diff --git a/test/support/platform.rb b/test/support/platform.rb new file mode 100644 index 00000000..daddace0 --- /dev/null +++ b/test/support/platform.rb @@ -0,0 +1,28 @@ +module Platform + require_relative 'platform/os' + require_relative 'platform/engine' + require_relative 'platform/db' + require_relative 'platform/matcher' + + def self.ci? + ENV['TRAVIS'] == 'true' + end + + def self.match(&blk) + Matcher.match(&blk) + end + + def self.match?(**args) + Matcher.match?(**args) + end +end + +module PlatformHelpers + def with_platform(**args) + yield if Platform.match?(**args) + end + + def unless_platform(**args) + yield unless Platform.match?(**args) + end +end diff --git a/test/support/platform/db.rb b/test/support/platform/db.rb new file mode 100644 index 00000000..a0c62b59 --- /dev/null +++ b/test/support/platform/db.rb @@ -0,0 +1,11 @@ +module Platform + module Db + def self.db?(name) + current == name + end + + def self.current + Database.engine + end + end +end diff --git a/test/support/platform/engine.rb b/test/support/platform/engine.rb new file mode 100644 index 00000000..8c6d83b4 --- /dev/null +++ b/test/support/platform/engine.rb @@ -0,0 +1,27 @@ +require 'hanami/utils' + +module Platform + module Engine + def self.engine?(name) + current == name + end + + def self.current + if ruby? then :ruby + elsif jruby? then :jruby + end + end + + class << self + private + + def ruby? + RUBY_ENGINE == 'ruby' + end + + def jruby? + Hanami::Utils.jruby? + end + end + end +end diff --git a/test/support/platform/matcher.rb b/test/support/platform/matcher.rb new file mode 100644 index 00000000..10c2547e --- /dev/null +++ b/test/support/platform/matcher.rb @@ -0,0 +1,80 @@ +require 'hanami/utils/basic_object' + +module Platform + class Matcher + class Nope < Hanami::Utils::BasicObject + def or(other, &blk) + blk.nil? ? other : blk.call # rubocop:disable Performance/RedundantBlockCall + end + + def method_missing(*) + self.class.new + end + end + + def self.match(&blk) + catch :match do + new.__send__(:match, &blk) + end + end + + def self.match?(os: Os.current, engine: Engine.current, db: Db.current) + catch :match do + new.os(os).engine(engine).db(db) { true }.or(false) + end + end + + def initialize + freeze + end + + def os(name, &blk) + return nope unless os?(name) + block_given? ? resolve(&blk) : yep + end + + def engine(name, &blk) + return nope unless engine?(name) + block_given? ? resolve(&blk) : yep + end + + def db(name, &blk) + return nope unless db?(name) + block_given? ? resolve(&blk) : yep + end + + def default(&blk) + resolve(&blk) + end + + private + + def match(&blk) + instance_exec(&blk) + end + + def nope + Nope.new + end + + def yep + self.class.new + end + + def resolve + throw :match, yield + end + + def os?(name) + Os.os?(name) + end + + def engine?(name) + Engine.engine?(name) + end + + def db?(name) + Db.db?(name) + end + end +end diff --git a/test/support/platform/os.rb b/test/support/platform/os.rb new file mode 100644 index 00000000..a617f506 --- /dev/null +++ b/test/support/platform/os.rb @@ -0,0 +1,16 @@ +require 'rbconfig' + +module Platform + module Os + def self.os?(name) + current == name + end + + def self.current + case RbConfig::CONFIG['host_os'] + when /linux/ then :linux + when /darwin/ then :macos + end + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 69825a21..3e898cc4 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,75 +1,16 @@ require 'rubygems' require 'bundler/setup' -if ENV['COVERAGE'] == 'true' - require 'simplecov' +if ENV['COVERALL'] require 'coveralls' - - SimpleCov.formatters = [ - SimpleCov::Formatter::HTMLFormatter, - Coveralls::SimpleCov::Formatter - ] - - SimpleCov.start do - command_name 'test' - add_filter 'test' - end + Coveralls.wear! end require 'minitest/autorun' -$:.unshift 'lib' -require 'hanami/utils' -require 'hanami-model' -require 'hanami/model/adapters/memory_adapter' -require 'hanami/model/adapters/file_system_adapter' -require 'hanami/model/adapters/sql_adapter' - -db = Pathname.new(__dir__).join('../tmp/db') -db.dirname.mkpath # create directory if not exist - -sql = db.join('sql.db') -sql.delete if sql.exist? # delete file if exist - -filesystem = db.join('filesystem') -filesystem.rmtree if filesystem.exist? -filesystem.dirname.mkpath # recreate directory - -postgres_database = "hanami_model_test" -if Hanami::Utils.jruby? - require 'jdbc/sqlite3' - require 'jdbc/postgres' - Jdbc::SQLite3.load_driver - Jdbc::Postgres.load_driver - SQLITE_CONNECTION_STRING = "jdbc:sqlite:#{ sql }" - POSTGRES_CONNECTION_STRING = "jdbc:postgresql://localhost/#{ postgres_database }" -else - require 'sqlite3' - require 'pg' - SQLITE_CONNECTION_STRING = "sqlite://#{ sql }" - POSTGRES_CONNECTION_STRING = "postgres://localhost/#{ postgres_database }" -end - -MEMORY_CONNECTION_STRING = "memory://test" -FILE_SYSTEM_CONNECTION_STRING = "file:///#{ filesystem }" - -if ENV['TRAVIS'] == 'true' - POSTGRES_USER = 'postgres' - MYSQL_USER = 'travis' -else - POSTGRES_USER = `whoami`.strip - MYSQL_USER = 'hanami' -end - -system "dropdb #{ postgres_database }" rescue nil -system "createdb #{ postgres_database }" rescue nil -sleep 1 -require 'fixtures' +$LOAD_PATH.unshift 'lib' +require 'hanami/model' -Hanami::Model::Configuration.class_eval do - def ==(other) - other.kind_of?(self.class) && - other.adapter == adapter && - other.mapper.kind_of?(mapper.class) - end -end +require_relative './support/platform' +require_relative './support/database' +require_relative './support/fixtures' diff --git a/test/version_test.rb b/test/version_test.rb deleted file mode 100644 index c99379cf..00000000 --- a/test/version_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'test_helper' - -describe Hanami::Model::VERSION do - it 'returns current version' do - Hanami::Model::VERSION.must_equal '0.7.0' - end -end