diff --git a/model-main/.github/FUNDING.yml b/model-main/.github/FUNDING.yml new file mode 100644 index 0000000000000000000000000000000000000000..31b7f31ffa31fed204a3bfe85d7e4207219729a7 --- /dev/null +++ b/model-main/.github/FUNDING.yml @@ -0,0 +1 @@ +github: hanami diff --git a/model-main/.github/workflows/ci.yml b/model-main/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..55abaa7dbed5ac6632c11490098424a5af676f19 --- /dev/null +++ b/model-main/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: ci + +"on": + push: + paths: + - ".github/workflows/ci.yml" + - "lib/**" + - "*.gemspec" + - "spec/**" + - "Rakefile" + - "Gemfile" + - ".rubocop.yml" + - "script/ci" + pull_request: + branches: + - main + create: + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: + - "2.7" + db: + - sqlite3 + - mysql + - postgresql + env: + DB: ${{matrix.db}} + steps: + - uses: actions/checkout@v1 + - name: Install package dependencies + run: "[ -e $APT_DEPS ] || sudo apt-get install -y --no-install-recommends $APT_DEPS" + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: ${{matrix.ruby}} + - name: Run all tests + env: + HANAMI_DATABASE_USERNAME: root + HANAMI_DATABASE_PASSWORD: root + HANAMI_DATABASE_HOST: 127.0.0.1 + HANAMI_DATABASE_NAME: hanami_model + run: script/ci + services: + mysql: + image: mysql:8 + env: + ALLOW_EMPTY_PASSWORD: true + MYSQL_AUTHENTICATION_PLUGIN: mysql_native_password + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: hanami_model + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + postgres: + image: postgres:13 + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + POSTGRES_USER: root + POSTGRES_PASSWORD: root + POSTGRES_DB: hanami_model diff --git a/model-main/.gitignore b/model-main/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..3dee18fb8cc92e4a8397cd9ac3d0f0877bcd51c5 --- /dev/null +++ b/model-main/.gitignore @@ -0,0 +1,21 @@ +*.gem +*.rbc +.bundle +.config +.DS_Store +.greenbar +.ruby-version +.yardoc +.rubocop-* +_yardoc +coverage +doc/ +Gemfile.lock +InstalledFiles +lib/bundler/man +pkg +rdoc +spec/reports +test/tmp +test/version_tmp +tmp diff --git a/model-main/.jrubyrc b/model-main/.jrubyrc new file mode 100644 index 0000000000000000000000000000000000000000..ec033eeb2f74b98248dcc770dd827b91f5f0c451 --- /dev/null +++ b/model-main/.jrubyrc @@ -0,0 +1 @@ +debug.fullTrace=true diff --git a/model-main/.rspec b/model-main/.rspec new file mode 100644 index 0000000000000000000000000000000000000000..83e16f80447460c937aeaa44a64d743b27863077 --- /dev/null +++ b/model-main/.rspec @@ -0,0 +1,2 @@ +--color +--require spec_helper diff --git a/model-main/.rubocop.yml b/model-main/.rubocop.yml new file mode 100644 index 0000000000000000000000000000000000000000..5586e337efec592da6a1c25e3a02e3bd0101d9bb --- /dev/null +++ b/model-main/.rubocop.yml @@ -0,0 +1,18 @@ +# Please keep AllCops, Bundler, Style, Metrics groups and then order cops +# alphabetically +inherit_from: + - https://raw.githubusercontent.com/hanami/devtools/1.3.x/.rubocop.yml +Naming/MethodParameterName: + AllowedNames: + - ci + - db + - id + - os +Layout/LineLength: + Enabled: false +Naming/RescuedExceptionsVariableName: + PreferredName: "exception" +Style/RescueStandardError: + Enabled: false +Style/DateTime: + Enabled: false diff --git a/model-main/.yardopts b/model-main/.yardopts new file mode 100644 index 0000000000000000000000000000000000000000..c35f90b9bd65b2f3fc60d60b2b7304daa07b7d4b --- /dev/null +++ b/model-main/.yardopts @@ -0,0 +1,5 @@ +--protected +--private +- +LICENSE.md +lib/**/*.rb diff --git a/model-main/CHANGELOG.md b/model-main/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..c235088b2f85ad763d008ac820cad39b7030f987 --- /dev/null +++ b/model-main/CHANGELOG.md @@ -0,0 +1,316 @@ +# Hanami::Model +A persistence layer for Hanami + +## v1.3.3 - 2021-05-22 +### Fixed +- [Sean Collins] Specify dependency on BigDecimal v1.4 +- [Adam Daniels] Use environment variables for PostgreSQL CLI tools + +## v1.3.2 - 2019-01-31 +### Fixed +- [Luca Guidi] Depend on `dry-logic` `~> 0.4.2`, `< 0.5` + +## v1.3.1 - 2019-01-18 +### Added +- [Luca Guidi] Official support for Ruby: MRI 2.6 +- [Luca Guidi] Support `bundler` 2.0+ + +## v1.3.0 - 2018-10-24 + +## v1.3.0.beta1 - 2018-08-08 +### Fixed +- [Luca Guidi] Print meaningful error message when connection URL is misconfigured (eg. `Unknown database adapter for URL: "". Please check your database configuration (hint: ENV['DATABASE_URL']).`) +- [Ian Ker-Seymer] Reliably parse query params from connection string + +## v1.2.0 - 2018-04-11 + +## v1.2.0.rc2 - 2018-04-06 + +## v1.2.0.rc1 - 2018-03-30 +### Fixed +- [Marcello Rocha & Luca Guidi] Ensure repository relations to access database attributes via `#[]` (eg. `projects[:name].ilike("Hanami")`) + +## v1.2.0.beta2 - 2018-03-23 + +## v1.2.0.beta1 - 2018-02-28 +### Added +- [Luca Guidi] Official support for Ruby: MRI 2.5 +- [Marcello Rocha] Introduce `Hanami::Repository#command` as a factory for custom database commands. This is useful to create custom bulk operations. + +## v1.1.0 - 2017-10-25 +### Fixed +- [Luca Guidi] Ensure associations to always accept objects that are serializable into `::Hash` + +## v1.1.0.rc1 - 2017-10-16 +### Added +- [Marcello Rocha] Added support for associations aliasing via `:as` option (`has_many :users, through: :comments, as: :authors`) +- [Luca Guidi] Allow entities to be used as type in entities manual schema (`attribute :owner, Types::Entity(User)`) + +## v1.1.0.beta3 - 2017-10-04 + +## v1.1.0.beta2 - 2017-10-03 +### Added +- [Alfonso Uceda] Introduce `Hanami::Model::Migrator#rollback` to provide database migrations rollback +- [Alfonso Uceda] Improve connection string for PostgreSQL in order to pass credentials as URI query string + +### Fixed +- [Marcello Rocha] One-To-Many properly destroy the associated methods + +## v1.1.0.beta1 - 2017-08-11 +### Added +- [Marcello Rocha] Many-To-One association (aka `belongs_to`) +- [Marcello Rocha] One-To-One association (aka `has_one`) +- [Marcello Rocha] Many-To-Many association (aka `has_many :through`) +- [Luca Guidi] Introduced new extra behaviors for entity manual schema: `:schema` (default), `:strict`, `:weak`, and `:permissive` + +### Fixed +- [Sean Collins] Enhanced error message for Postgres `db create` and `db drop` when `createdb` and `dropdb` aren't in `PATH` + +## v1.0.4 - 2017-10-14 +### Fixed +- [Nikita Shilnikov] Keep the dependency on `rom-sql` at `~> 1.3`, which is compatible with `dry-types` `~> 0.11.0` +- [Nikita Shilnikov] Ensure to write Postgres JSON (`PGJSON`) type for nested associated records +- [Nikita Shilnikov] Ensure `Repository#select` to work with `Hanami::Model::MappedRelation` + +## v1.0.3 - 2017-10-11 +### Fixed +- [Luca Guidi] Keep the dependency on `dry-types` at `~> 0.11.0` + +## v1.0.2 - 2017-08-04 +### Fixed +- [Maurizio De Magnis] URI escape for Postgres password +- [Marion Duprey] Ensure repository to generate timestamps values even when only one between `created_at` and `updated_at` is present +- [Paweł Świątkowski] Make Postgres JSON(B) to work with Ruby arrays +- [Luca Guidi] Don't remove migrations when running `Hanami::Model::Migrator#apply` fails to dump the database + +## v1.0.1 - 2017-06-23 +### Fixed +- [Kai Kuchenbecker & Marcello Rocha & Luca Guidi] Ensure `Hanami::Entity#initialize` to not serialize (into `Hash`) other entities passed as an argument +- [Luca Guidi] Let `Hanami::Repository.relation=` to accept strings as an argument +- [Nikita Shilnikov] Prevent stack-overflow when `Hanami::Repository#update` is called thousand times + +## v1.0.0 - 2017-04-06 + +## v1.0.0.rc1 - 2017-03-31 + +## v1.0.0.beta3 - 2017-03-17 +### Added +- [Luca Guidi] Introduced `Hanami::Model.disconnect` to disconnect all the active database connections + +## v1.0.0.beta2 - 2017-03-02 +### Added +- [Semyon Pupkov] Allow to define Postgres connection URL as `"postgresql:///mydb?host=localhost&port=6433&user=postgres&password=testpasswd"` + +### Fixed +- [Marcello Rocha] Fixed migrations MySQL detection of username and password +- [Luca Guidi] Fixed migrations creation/drop of a MySQL database with a dash in the name +- [Semyon Pupkov] Ensure `db console` to work when Postgres connection URL is defined with `"postgresql://"` scheme + +## v1.0.0.beta1 - 2017-02-14 +### Added +- [Luca Guidi] Official support for Ruby: MRI 2.4 +- [Luca Guidi] Introduced `Repository#read` to fetch from database with raw SQL string +- [Luca Guidi] Introduced `Repository.schema` to manually configure the schema of a database table. This is useful for legacy databases where Hanami::Model autoinferring doesn't map correctly the schema. +- [Luca Guidi & Alfonso Uceda] Added `Hanami::Model::Configuration#gateway` to configure gateway and the raw connection +- [Luca Guidi] Added `Hanami::Model::Configuration#logger` to configure a logger +- [Luca Guidi] Database operations (including migrations) print informations to standard output + +### Fixed +- [Thorbjørn Hermansen] Ensure repository to not override given timestamps +- [Luca Guidi] Raise `Hanami::Model::MissingPrimaryKeyError` if `Repository#find` is ran against a database w/o a primary key +- [Alfonso Uceda] Ensure SQLite databases to be used on JRuby when the database path is in the same directory of the Ruby script (eg. `./test.sqlite`) + +### Changed +- [Luca Guidi] Automap the main relation in a repository, by removing the need of use `.as(:entity)` +- [Luca Guidi] Raise an `Hanami::Model::UnknownDatabaseTypeError` when the application is loaded and there is an unknown column type in the database + +## v0.7.0 - 2016-11-15 +### 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] 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 accept hashes for CRUD operations +- [Luca Guidi] `Hanami::Repository#create` now accepts: hash (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 +- [James Hamilton] Print appropriate error message when trying to create a PostgreSQL database that is already existing + +## v0.6.2 - 2016-06-01 +### Changed +- [Kjell-Magne Øierud] Ensure inherited entities to expose attributes from base class + +## v0.6.1 - 2016-02-05 +### Changed +- [Hélio Costa e Silva & Pascal Betz] Mapping SQL Adapter's errors as `Hanami::Model` errors + +## v0.6.1 - 2016-02-05 +### Changed +- [Hélio Costa e Silva & Pascal Betz] Mapping SQL Adapter's errors as `Hanami::Model` errors + +## v0.6.0 - 2016-01-22 +### Changed +- [Luca Guidi] Renamed the project + +## v0.5.2 - 2016-01-19 +### Changed +- [Sean Collins] Improved error message for `Lotus::Model::Adapters::NoAdapterError` + +### Fixed +- [Kyle Chong & Trung Lê] Catch Sequel exceptions and re-raise as `Lotus::Model::Error` + +## v0.5.1 - 2016-01-12 +### Added +- [Taylor Finnell] Let `Lotus::Model::Configuration#adapter` to accept arbitrary options (eg. `adapter type: :sql, uri: 'jdbc:...', after_connect: Proc.new { |connection| connection.auto_commit(true) }`) + +### Changed +- [Andrey Deryabin] Improved `Entity#inspect` +- [Karim Tarek] Introduced `Lotus::Model::Error` and let all the framework exceptions to inherit from it. + +### Fixed +- [Luca Guidi] Improved error message when trying to use a repository without mapping the corresponding collections +- [Sean Collins] Improved error message when trying to create database, but it fails (eg. missing `createdb` executable) +- [Andrey Deryabin] Improved error message when trying to drop database, but a client is still connected (useful for PostgreSQL) +- [Hiếu Nguyễn] Improved error message when trying to "prepare" database, but it fails + +## v0.5.0 - 2015-09-30 +### Added +- [Brenno Costa] Official support for JRuby 9k+ +- [Luca Guidi] Command/Query separation via `Repository.execute` and `Repository.fetch` +- [Luca Guidi] Custom attribute coercers for data mapper +- [Alfonso Uceda] Added `#join` and `#left_join` and `#group` to SQL adapter + +### Changed +- [Luca Guidi] `Repository.execute` no longer returns a result from the database. + +### Fixed +- [Manuel Corrales] Use `dropdb` to drop PostgreSQL database. +- [Luca Guidi & Bohdan V.] Ignore dotfiles while running migrations. + +## v0.4.1 - 2015-07-10 +### Fixed +- [Nick Coyne] Fixed database creation for PostgreSQL (now it uses `createdb`). + +## v0.4.0 - 2015-06-23 +### Added +- [Luca Guidi] Database migrations + +### Changed +- [Matthew Bellantoni] Made `Repository.execute` not callable from the outside (private Ruby method, public API). + +## v0.3.2 - 2015-05-22 +### Added +- [Dmitry Tymchuk & Luca Guidi] Fix for dirty tracking of attributes changed in place (eg. `book.tags << 'non-fiction'`) + +## v0.3.1 - 2015-05-15 +### Added +- [Dmitry Tymchuk] Dirty tracking for entities (via `Lotus::Entity::DirtyTracking` module to include) +- [My Mai] Automatic update of timestamps when an entity is persisted. +- [Peter Berkenbosch] Introduced `Lotus::Repository#execute`, to execute raw query/commands against database (eg. `BookRepository.execute "SELECT * FROM users"` or `BookRepository.execute "UPDATE users SET admin = 'f'"`) +- [Guilherme Franco] Memory and File System adapters now accept a block for `where`, `or`, `and` conditions (eg `where { age > 33 }`). + +### Fixed +- [Luca Guidi] Ensure Array coercion to preserve original data structure + +## v0.3.0 - 2015-03-23 +### Added +- [Linus Pettersson] Database console + +### Fixed +- [Alfonso Uceda Pompa] Don't send unwanted null values to the database, while coercing entities +- [Jan Lelis] Do not define top-level `Boolean`, because it is already defined by `hanami-utils` +- [Vsevolod Romashov] Fix entity class resolving in `Coercer#from_record` +- [Jason Harrelson] Add file and line to `instance_eval` in `Coercer` to make backtrace more usable + +## v0.2.4 - 2015-02-20 +### Fixed +- [Luca Guidi] When duplicate the framework don't copy over the original `Lotus::Model` configuration + +## v0.2.3 - 2015-02-13 +### Added +- [Alfonso Uceda Pompa] Added support for database transactions in repositories + +### Fixed +- [Luca Guidi] Ensure file system adapter old data is read when a new process is started + +## v0.2.2 - 2015-01-18 +### Added +- [Luca Guidi] Coerce entities when persisted + +## v0.2.1 - 2015-01-12 +### Added +- [Luca Guidi] Compatibility between Lotus::Entity and Lotus::Validations + +## v0.2.0 - 2014-12-23 +### Added +- [Luca Guidi] Introduced file system adapter +– [Benny Klotz & Trung Lê] Introduced `Entity` inheritance of attributes +- [Trung Lê] Introduced `Entity#update` for bulk update of attributes +- [Luca Guidi] Improved error when try to use a repository which wasn't configured or when the framework wasn't loaded yet +- [Trung Lê] Introduced `Entity#to_h` +- [Trung Lê] Introduced `Lotus::Model.duplicate` +- [Trung Lê] Made `Lotus::Mapper` lazy +- [Trung Lê] Introduced thread safe autoloading for adapters +- [Felipe Sere] Add support for `Symbol` coercion +- [Celso Fernandes] Add support for `BigDecimal` coercion +- [Trung Lê] Introduced `Lotus::Model.load!` as entry point for loading +- [Trung Lê] Introduced `Mapper#repository` as DSL to associate a repository to a collection +- [Trung Lê & Tao Guo] Introduced `Configuration#mapping` as DSL to configure the mapping +- [Coen Wessels] Allow `where`, `exclude` and `or` to accept blocks +- [Trung Lê & Tao Guo] Introduced `Configuration#adapter` as DSL to configure the adapter +- [Trung Lê] Introduced `Lotus::Model::Configuration` + +### Changed +- [Trung Lê] Changed `Entity.attributes=` to `Entity.attributes` +- [Trung Lê] In case of missing entity, let `Repository#find` returns `nil` instead of raise an exception + +### Fixed +- [Rik Tonnard] Ensure correct behavior of `#offset` in memory adapter +- [Benny Klotz] Ensure `Entity` to set the attributes even when the given Hash uses strings as keys +- [Ben Askins] Always return the entity from `Repository#persist` +- [Jeremy Stephens] Made `Memory::Query#where` and `#or` behave more like the SQL counter-part + +## v0.1.2 - 2014-06-26 +### Fixed +- [Stanislav Spiridonov] Ensure to require `'hanami/model/mapping/coercions'` +- [Krzysztof Zalewski] `Entity` defines `#id` accessor by default + + +## v0.1.1 - 2014-06-23 +### Added +- [Luca Guidi] Introduced `Lotus::Model::Mapping::Coercions` in order to decouple from `Lotus::Utils::Kernel` +- [Luca Guidi] Official support for Ruby 2.1 + +## v0.1.0 - 2014-04-23 +### Added +- [Luca Guidi] Allow to inject coercer into mapper +- [Luca Guidi] Introduced database mapping +- [Luca Guidi] Introduced `Lotus::Entity` +- [Luca Guidi] Introduced SQL adapter +- [Luca Guidi] Introduced memory adapter +– [Luca Guidi] Introduced adapters for repositories +- [Luca Guidi] Introduced `Lotus::Repository` diff --git a/model-main/Gemfile b/model-main/Gemfile new file mode 100644 index 0000000000000000000000000000000000000000..ffb33133eb61d94e466983c467e17471f1d98921 --- /dev/null +++ b/model-main/Gemfile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +source "https://rubygems.org" +gemspec + +unless ENV["CI"] + gem "byebug", require: false, platforms: :mri + gem "yard", require: false +end + +gem "hanami-utils", "~> 1.3", require: false, git: "https://github.com/hanami/utils.git", branch: "1.3.x" + +gem "sqlite3", require: false, platforms: :mri, group: :sqlite +gem "pg", require: false, platforms: :mri, group: :postgres +gem "mysql2", require: false, platforms: :mri, group: :mysql + +gem "jdbc-sqlite3", require: false, platforms: :jruby, group: :sqlite +gem "jdbc-postgres", require: false, platforms: :jruby, group: :postgres +gem "jdbc-mysql", require: false, platforms: :jruby, group: :mysql + +gem "hanami-devtools", require: false, git: "https://github.com/hanami/devtools.git", branch: "1.3.x" diff --git a/model-main/LICENSE.md b/model-main/LICENSE.md new file mode 100644 index 0000000000000000000000000000000000000000..ebdf22bb3c7c6f22b3ab27cfdfdf7af008fab502 --- /dev/null +++ b/model-main/LICENSE.md @@ -0,0 +1,22 @@ +Copyright © 2014-2021 Luca Guidi + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/model-main/README.md b/model-main/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d2d5868ac757a1d2d41981fd42e30f049b942147 --- /dev/null +++ b/model-main/README.md @@ -0,0 +1,303 @@ +# Hanami::Model + +A persistence framework for [Hanami](http://hanamirb.org). + +It delivers a convenient public API to execute queries and commands against a database. +The architecture eases keeping the business logic (entities) separated from details such as persistence or validations. + +It implements the following concepts: + + * [Entity](#entities) - A model domain object defined by its identity. + * [Repository](#repositories) - An object that mediates between the entities and the persistence layer. + +Like all the other Hanami components, it can be used as a standalone framework or within a full Hanami application. + +## Version + +**This branch contains the code for `hanami-model` 2.x.** + +## Status + +[![Gem Version](https://badge.fury.io/rb/hanami-model.svg)](https://badge.fury.io/rb/hanami-model) +[![CI](https://github.com/hanami/model/workflows/ci/badge.svg?branch=main)](https://github.com/hanami/model/actions?query=workflow%3Aci+branch%3Amain) +[![Test Coverage](https://codecov.io/gh/hanami/model/branch/main/graph/badge.svg)](https://codecov.io/gh/hanami/model) +[![Depfu](https://badges.depfu.com/badges/3a5d3f9e72895493bb6f39402ac4f129/overview.svg)](https://depfu.com/github/hanami/model?project=Bundler) +[![Inline Docs](http://inch-ci.org/github/hanami/model.svg)](http://inch-ci.org/github/hanami/model) + +## Contact + +* Home page: http://hanamirb.org +* Mailing List: http://hanamirb.org/mailing-list +* API Doc: http://rdoc.info/gems/hanami-model +* Bugs/Issues: https://github.com/hanami/model/issues +* Support: http://stackoverflow.com/questions/tagged/hanami +* Chat: https://chat.hanamirb.org + +## Rubies + +__Hanami::Model__ supports Ruby (MRI) 2.6+ + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'hanami-model' +``` + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install hanami-model + +## Usage + +This class provides a DSL to configure the connection. + +```ruby +require 'hanami/model' +require 'hanami/model/sql' + +class User < Hanami::Entity +end + +class UserRepository < Hanami::Repository +end + +Hanami::Model.configure do + adapter :sql, 'postgres://username:password@localhost/bookshelf' +end.load! + +repository = UserRepository.new +user = repository.create(name: 'Luca') + +puts user.id # => 1 + +found = repository.find(user.id) +found == user # => true + +updated = repository.update(user.id, age: 34) +updated.age # => 34 + +repository.delete(user.id) +``` + +## Concepts + +### Entities + +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. +It's a small, cohesive object that expresses coherent and meaningful behaviors. + +It deals with one and only one responsibility that is pertinent to the +domain of the application, without caring about details such as persistence +or validations. + +This simplicity of design allows developers to focus on behaviors, or +message passing if you will, which is the quintessence of Object Oriented Programming. + +```ruby +require 'hanami/model' + +class Person < Hanami::Entity +end +``` + +### Repositories + +An object that mediates between entities and the persistence layer. +It offers a standardized API to query and execute commands on a database. + +A repository is **storage independent**, all the queries and commands are +delegated to the current adapter. + +This architecture has several advantages: + + * Applications depend on a standard API, instead of low level details + (Dependency Inversion principle) + + * Applications depend on a stable API, that doesn't change if the + storage changes + + * Developers can postpone storage decisions + + * Confines persistence logic at a low level + + * Multiple data sources can easily coexist in an application + +When a class inherits from `Hanami::Repository`, it will receive the following interface: + + * `#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**. +This decision forces developers to define intention revealing API, instead of leaking storage API details outside of a repository. + +Look at the following code: + +```ruby +ArticleRepository.new.where(author_id: 23).order(:published_at).limit(8) +``` + +This is **bad** for a variety of reasons: + + * The caller has an intimate knowledge of the internal mechanisms of the Repository. + + * The caller works on several levels of abstraction. + + * It doesn't express a clear intent, it's just a chain of methods. + + * The caller can't be easily tested in isolation. + + * If we change the storage, we are forced to change the code of the caller(s). + +There is a better way: + +```ruby +require 'hanami/model' + +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 +``` + +This is a **huge improvement**, because: + + * The caller doesn't know how the repository fetches the entities. + + * The caller works on a single level of abstraction. It doesn't even know about records, only works with entities. + + * It expresses a clear intent. + + * The caller can be easily tested in isolation. It's just a matter of stubbing this method. + + * If we change the storage, the callers aren't affected. + +### Mapping + +Hanami::Model can **_automap_** columns from relations and entities attributes. + +When using a `sql` adapter, you must require `hanami/model/sql` before `Hanami::Model.load!` is called so the relations are loaded correctly. + +However, there are cases where columns and attribute names do not match (mainly **legacy databases**). + +```ruby +require 'hanami/model' + +class UserRepository < Hanami::Repository + self.relation = :t_user_archive + + mapping do + attribute :id, from: :i_user_id + attribute :name, from: :s_name + attribute :age, from: :i_age + end +end +``` +**NOTE:** This feature should be used only when **_automapping_** fails because of the naming mismatch. + +### Conventions + + * A repository must be named after an entity, by appending `"Repository"` to the entity class name (eg. `Article` => `ArticleRepository`). + +### Thread safety + +**Hanami::Model**'s is thread safe during the runtime, but it isn't during the loading process. +The mapper compiles some code internally, so be sure to safely load it before your application starts. + +```ruby +Mutex.new.synchronize do + Hanami::Model.load! +end +``` + +**This is not necessary when Hanami::Model is used within a Hanami application.** + +## Features + +### Timestamps + +If an entity has the following accessors: `:created_at` and `:updated_at`, they will be automatically updated when the entity is persisted. + +```ruby +require 'hanami/model' +require 'hanami/model/sql' + +class User < Hanami::Entity +end + +class UserRepository < Hanami::Repository +end + +Hanami::Model.configure do + adapter :sql, 'postgresql://localhost/bookshelf' +end.load! + +repository = UserRepository.new + +user = repository.create(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" + +sleep 3 +user = repository.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" +``` + +## Configuration + +### Logging + +In order to log database operations, you can configure a logger: + +```ruby +Hanami::Model.configure do + # ... + logger "log/development.log", level: :debug +end +``` + +It accepts the following arguments: + + * `stream`: a Ruby StringIO object - it can be `$stdout` or a path to file (eg. `"log/development.log"`) - Defaults to `$stdout` + * `:level`: logging level - it can be: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, `:unknown` - Defaults to `:debug` + * `:formatter`: logging formatter - it can be: `:default` or `:json` - Defaults to `:default` + +## Versioning + +__Hanami::Model__ uses [Semantic Versioning 2.0.0](http://semver.org) + +## Contributing + +1. Fork it ( https://github.com/hanami/model/fork ) +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create new Pull Request + +## Copyright + +Copyright © 2014-2021 Luca Guidi – Released under MIT License + +This project was formerly known as Lotus (`lotus-model`). diff --git a/model-main/Rakefile b/model-main/Rakefile new file mode 100644 index 0000000000000000000000000000000000000000..8d1de97ae472def95eba23a76dad761293869b57 --- /dev/null +++ b/model-main/Rakefile @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rake" +require "bundler/gem_tasks" +require "rspec/core/rake_task" +require "hanami/devtools/rake_tasks" + +namespace :spec do + RSpec::Core::RakeTask.new(:unit) do |task| + task.pattern = FileList["spec/**/*_spec.rb"] + end +end + +task default: "spec:unit" diff --git a/model-main/hanami-model.gemspec b/model-main/hanami-model.gemspec new file mode 100644 index 0000000000000000000000000000000000000000..b99d0037c670f4c6c8aa6e957376ac7452bcf850 --- /dev/null +++ b/model-main/hanami-model.gemspec @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +lib = File.expand_path("../lib", __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require "hanami/model/version" + +Gem::Specification.new do |spec| + spec.name = "hanami-model" + spec.version = Hanami::Model::VERSION + spec.authors = ["Luca Guidi"] + spec.email = ["me@lucaguidi.com"] + spec.summary = "A persistence layer for Hanami" + spec.description = "A persistence framework with entities and repositories" + spec.homepage = "http://hanamirb.org" + spec.license = "MIT" + + spec.files = `git ls-files -z -- lib/* CHANGELOG.md EXAMPLE.md LICENSE.md README.md hanami-model.gemspec`.split("\x0") + spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } + spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) + spec.require_paths = ["lib"] + spec.required_ruby_version = ">= 2.3.0", "< 3" + + spec.add_runtime_dependency "hanami-utils", "~> 1.3" + spec.add_runtime_dependency "rom", "~> 3.3", ">= 3.3.3" + spec.add_runtime_dependency "rom-sql", "~> 1.3", ">= 1.3.5" + spec.add_runtime_dependency "rom-repository", "~> 1.4" + spec.add_runtime_dependency "dry-types", "~> 0.11.0" + spec.add_runtime_dependency "dry-logic", "~> 0.4.2", "< 0.5" + spec.add_runtime_dependency "concurrent-ruby", "~> 1.0" + spec.add_runtime_dependency "bigdecimal", "~> 1.4" + + spec.add_development_dependency "bundler", ">= 1.6", "< 3" + spec.add_development_dependency "rake", "~> 12" + spec.add_development_dependency "rspec", "~> 3.7" + spec.add_development_dependency "rubocop", "0.81" # rubocop 0.81+ removed support for Ruby 2.3 +end diff --git a/model-main/lib/hanami-model.rb b/model-main/lib/hanami-model.rb new file mode 100644 index 0000000000000000000000000000000000000000..44ad14596747c0eb9e2c4e9b3e5da277d2c10352 --- /dev/null +++ b/model-main/lib/hanami-model.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "hanami/model" diff --git a/model-main/lib/hanami/entity.rb b/model-main/lib/hanami/entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..2dc28caa0f0446c27fffced723cdef2732a1c141 --- /dev/null +++ b/model-main/lib/hanami/entity.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require "hanami/model/types" + +module Hanami + # An 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. It's a small, cohesive object that expresses coherent + # and meaningful behaviors. + # + # It deals with one and only one responsibility that is pertinent to the + # domain of the application, without caring about details such as persistence + # or validations. + # + # This simplicity of design allows developers to focus on behaviors, or + # message passing if you will, which is the quintessence of Object Oriented + # Programming. + # + # @example With Hanami::Entity + # require 'hanami/model' + # + # class Person < Hanami::Entity + # end + # + # If we expand the code above in **pure Ruby**, it would be: + # + # @example Pure 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' 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 for more on interfaces. + # + # @since 0.1.0 + # + # @see Hanami::Repository + class Entity + require "hanami/entity/schema" + + # Syntactic shortcut to reference types in custom schema DSL + # + # @since 0.7.0 + module Types + include Hanami::Model::Types + end + + # Class level interface + # + # @since 0.7.0 + # @api private + module ClassMethods + # Define manual entity schema + # + # 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. + # + # 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. + # + # @param type [Symbol] the type of schema to build + # @param blk [Proc] the block that defines the attributes + # + # @since 0.7.0 + # + # @see Hanami::Entity + def attributes(type = nil, &blk) + self.schema = Schema.new(type, &blk) + @attributes = true + end + + # Assign a schema + # + # @param value [Hanami::Entity::Schema] the schema + # + # @since 0.7.0 + # @api private + def schema=(value) + return if defined?(@attributes) + + @schema = value + end + + # @since 0.7.0 + # @api private + attr_reader :schema + end + + # @since 0.7.0 + # @api private + def self.inherited(klass) + klass.class_eval do + @schema = Schema.new + extend ClassMethods + end + end + + # Instantiate a new entity + # + # @param attributes [Hash,#to_h,NilClass] data to initialize the entity + # + # @return [Hanami::Entity] the new entity instance + # + # @raise [TypeError] if the given attributes are invalid + # + # @since 0.1.0 + def initialize(attributes = nil) + @attributes = self.class.schema[attributes] + freeze + end + + # Entity ID + # + # @return [Object,NilClass] the ID, if present + # + # @since 0.7.0 + def id + attributes.fetch(:id, nil) + end + + # Handle dynamic accessors + # + # If internal attributes set has the requested key, it returns the linked + # value, otherwise it raises a NoMethodError + # + # @since 0.7.0 + def method_missing(method_name, *) + attribute?(method_name) or super + attributes.fetch(method_name, nil) + end + + # Implement generic equality for entities + # + # Two entities are equal if they are instances of the same class and they + # have the same id. + # + # @param other [Object] the object of comparison + # + # @return [FalseClass,TrueClass] the result of the check + # + # @since 0.1.0 + def ==(other) + self.class == other.class && + id == other.id + end + + # Implement predictable hashing for hash equality + # + # @return [Integer] the object hash + # + # @since 0.7.0 + def hash + [self.class, id].hash + end + + # Freeze the entity + # + # @since 0.7.0 + def freeze + attributes.freeze + super + end + + # Serialize entity to a Hash + # + # @return [Hash] the result of serialization + # + # @since 0.1.0 + def to_h + Utils::Hash.deep_dup(attributes) + end + + # @since 0.7.0 + alias_method :to_hash, :to_h + + protected + + # Check if the attribute is allowed to be read + # + # @since 0.7.0 + # @api private + def attribute?(name) + self.class.schema.attribute?(name) + end + + private + + # @since 0.1.0 + # @api private + attr_reader :attributes + + # @since 0.7.0 + # @api private + def respond_to_missing?(name, _include_all) + attribute?(name) + end + end +end diff --git a/model-main/lib/hanami/entity/schema.rb b/model-main/lib/hanami/entity/schema.rb new file mode 100644 index 0000000000000000000000000000000000000000..7f41e78818d634e68c2062f245333684bbcda4fa --- /dev/null +++ b/model-main/lib/hanami/entity/schema.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +require "hanami/model/types" +require "hanami/utils/hash" + +module Hanami + class Entity + # Entity schema is a definition of a set of typed attributes. + # + # @since 0.7.0 + # @api private + # + # @example SQL Automatic Setup + # require 'hanami/model' + # + # class Account < 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 < 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 < 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 0.7.0 + # @api private + class Schemaless + # @since 0.7.0 + # @api private + def initialize + freeze + end + + # @param attributes [#to_hash] the attributes hash + # + # @return [Hash] + # + # @since 0.7.0 + # @api private + def call(attributes) + if attributes.nil? + {} + else + Utils::Hash.deep_symbolize(attributes.to_hash.dup) + end + end + + # @since 0.7.0 + # @api private + def attribute?(_name) + true + end + end + + # Schema definition + # + # @since 0.7.0 + # @api private + class Definition + # Schema DSL + # + # @since 0.7.0 + class Dsl + # @since 1.1.0 + # @api private + TYPES = %i[schema strict weak permissive strict_with_defaults symbolized].freeze + + # @since 1.1.0 + # @api private + DEFAULT_TYPE = TYPES.first + + # @since 0.7.0 + # @api private + def self.build(type, &blk) + type ||= DEFAULT_TYPE + raise Hanami::Model::Error.new("Unknown schema type: `#{type.inspect}'") unless TYPES.include?(type) + + attributes = new(&blk).to_h + [attributes, Hanami::Model::Types::Coercible::Hash.__send__(type, attributes)] + end + + # @since 0.7.0 + # @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 0.7.0 + # + # @example + # require 'hanami/model' + # + # class Account < 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 + def attribute(name, type) + @attributes[name] = type + end + + # @since 0.7.0 + # @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 0.7.0 + # @api private + def initialize(type = nil, &blk) + raise LocalJumpError unless block_given? + + @attributes, @schema = Dsl.build(type, &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 + # @raise [ArgumentError] if data is missing, or unknown keys are given + # + # @since 0.7.0 + # @api private + def call(attributes) + schema.call(attributes) + rescue Dry::Types::SchemaError => exception + raise TypeError.new(exception.message) + rescue Dry::Types::MissingKeyError, Dry::Types::UnknownKeysError => exception + raise ArgumentError.new(exception.message) + end + + # Check if the attribute is known + # + # @param name [Symbol] the attribute name + # + # @return [TrueClass,FalseClass] the result of the check + # + # @since 0.7.0 + # @api private + def attribute?(name) + attributes.key?(name) + end + + private + + # @since 0.7.0 + # @api private + attr_reader :schema + + # @since 0.7.0 + # @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 0.7.0 + # @api private + def initialize(type = nil, &blk) + @schema = if block_given? + Definition.new(type, &blk) + else + Schemaless.new + end + end + + # Process attributes + # + # @param attributes [#to_hash] the attributes hash + # + # @raise [TypeError] if the process fails + # + # @since 0.7.0 + # @api private + def call(attributes) + Utils::Hash.deep_symbolize( + schema.call(attributes) + ) + end + + # @since 0.7.0 + # @api private + alias_method :[], :call + + # Check if the attribute is known + # + # @param name [Symbol] the attribute name + # + # @return [TrueClass,FalseClass] the result of the check + # + # @since 0.7.0 + # @api private + def attribute?(name) + schema.attribute?(name) + end + + protected + + # @since 0.7.0 + # @api private + attr_reader :schema + end + end +end diff --git a/model-main/lib/hanami/model.rb b/model-main/lib/hanami/model.rb new file mode 100644 index 0000000000000000000000000000000000000000..c56ed73d628ce088711ea973367e758695756ff6 --- /dev/null +++ b/model-main/lib/hanami/model.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rom" +require "concurrent" +require "hanami/entity" +require "hanami/repository" + +# Hanami +# +# @since 0.1.0 +module Hanami + # Hanami persistence + # + # @since 0.1.0 + module Model + require "hanami/model/version" + require "hanami/model/error" + require "hanami/model/configuration" + require "hanami/model/configurator" + require "hanami/model/mapping" + require "hanami/model/plugins" + + # @api private + # @since 0.7.0 + @__repositories__ = Concurrent::Array.new + + class << self + # @since 0.7.0 + # @api private + attr_reader :config + + # @since 0.7.0 + # @api private + attr_reader :loaded + + # @since 0.7.0 + # @api private + alias_method :loaded?, :loaded + end + + # Configure the framework + # + # @since 0.1.0 + # + # @example + # require 'hanami/model' + # + # Hanami::Model.configure do + # adapter :sql, ENV['DATABASE_URL'] + # + # migrations 'db/migrations' + # schema 'db/schema.sql' + # end + def self.configure(&block) + @config = Configurator.build(&block) + self + end + + # Current configuration + # + # @since 0.1.0 + def self.configuration + @configuration ||= Configuration.new(config) + end + + # @since 0.7.0 + # @api private + def self.repositories + @__repositories__ + end + + # @since 0.7.0 + # @api private + def self.container + raise "Not loaded" unless loaded? + + @container + end + + # @since 0.1.0 + def self.load!(&blk) + @container = configuration.load!(repositories, &blk) + @loaded = true + end + + # Disconnect from the database + # + # This is useful for rebooting applications in production and to ensure that + # the framework prunes stale connections. + # + # @since 1.0.0 + # + # @example With Full Stack Hanami Project + # # config/puma.rb + # # ... + # on_worker_boot do + # Hanami.boot + # end + # + # @example With Standalone Hanami::Model + # # config/puma.rb + # # ... + # on_worker_boot do + # Hanami::Model.disconnect + # Hanami::Model.load! + # end + def self.disconnect + configuration.connection&.disconnect + end + end +end diff --git a/model-main/lib/hanami/model/association.rb b/model-main/lib/hanami/model/association.rb new file mode 100644 index 0000000000000000000000000000000000000000..529e5532dd5999e2424b4a8538413811d65efcbe --- /dev/null +++ b/model-main/lib/hanami/model/association.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rom-sql" +require "hanami/model/associations/belongs_to" +require "hanami/model/associations/has_many" +require "hanami/model/associations/has_one" +require "hanami/model/associations/many_to_many" + +module Hanami + module Model + # Association factory + # + # @since 0.7.0 + # @api private + class Association + # Instantiate an association + # + # @since 0.7.0 + # @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 0.7.0 + # @api private + def self.lookup(association) + case association + when ROM::SQL::Association::ManyToMany + Associations::ManyToMany + when ROM::SQL::Association::OneToOne + Associations::HasOne + 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/model-main/lib/hanami/model/associations/belongs_to.rb b/model-main/lib/hanami/model/associations/belongs_to.rb new file mode 100644 index 0000000000000000000000000000000000000000..4583d7803216ec2378a3c624abeca42893da8846 --- /dev/null +++ b/model-main/lib/hanami/model/associations/belongs_to.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "hanami/model/types" + +module Hanami + module Model + module Associations + # Many-To-One association + # + # @since 1.1.0 + # @api private + class BelongsTo + # @since 1.1.0 + # @api private + def self.schema_type(entity) + Sql::Types::Schema::AssociationType.new(entity) + end + + # @since 1.1.0 + # @api private + attr_reader :repository + + # @since 1.1.0 + # @api private + attr_reader :source + + # @since 1.1.0 + # @api private + attr_reader :target + + # @since 1.1.0 + # @api private + attr_reader :subject + + # @since 1.1.0 + # @api private + attr_reader :scope + + # @since 1.1.0 + # @api private + def initialize(repository, source, target, subject, scope = nil) + @repository = repository + @source = source + @target = target + @subject = subject.to_hash unless subject.nil? + @scope = scope || _build_scope + freeze + end + + # @since 1.1.0 + # @api private + def one + scope.one + end + + private + + # @since 1.1.0 + # @api private + def container + repository.container + end + + # @since 1.1.0 + # @api private + def primary_key + association_keys.first + end + + # @since 1.1.0 + # @api private + def relation(name) + repository.relations[Hanami::Utils::String.pluralize(name)] + end + + # @since 1.1.0 + # @api private + def foreign_key + association_keys.last + end + + # Returns primary key and foreign key + # + # @since 1.1.0 + # @api private + def association_keys + association + .__send__(:join_key_map, container.relations) + end + + # Return the ROM::Associations for the source relation + # + # @since 1.1.9 + # @api private + def association + relation(source).associations[target] + end + + # @since 1.1.0 + # @api private + def _build_scope + result = relation(association.target.to_sym) + result = result.where(foreign_key => subject.fetch(primary_key)) unless subject.nil? + result.as(Model::MappedRelation.mapper_name) + end + end + end + end +end diff --git a/model-main/lib/hanami/model/associations/dsl.rb b/model-main/lib/hanami/model/associations/dsl.rb new file mode 100644 index 0000000000000000000000000000000000000000..b2a140a7922da8efb2841171699a94024e0440e9 --- /dev/null +++ b/model-main/lib/hanami/model/associations/dsl.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Hanami + module Model + module Associations + # Auto-infer relations linked to repository's associations + # + # @since 0.7.0 + # @api private + # + class Dsl + # @since 0.7.0 + # @api private + def initialize(repository, &blk) + @repository = repository + instance_eval(&blk) + end + + # @since 0.7.0 + # @api private + def has_many(relation, **args) + @repository.__send__(:relations, relation) + @repository.__send__(:relations, args[:through]) if args[:through] + end + + # @since 1.1.0 + # @api private + def has_one(relation, *) + @repository.__send__(:relations, Hanami::Utils::String.pluralize(relation).to_sym) + end + + # @since 1.1.0 + # @api private + def belongs_to(relation, *) + @repository.__send__(:relations, Hanami::Utils::String.pluralize(relation).to_sym) + end + end + end + end +end diff --git a/model-main/lib/hanami/model/associations/has_many.rb b/model-main/lib/hanami/model/associations/has_many.rb new file mode 100644 index 0000000000000000000000000000000000000000..3c87fa751caf299f0479bbf39f5101ec0589b843 --- /dev/null +++ b/model-main/lib/hanami/model/associations/has_many.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require "hanami/model/types" + +module Hanami + module Model + module Associations + # One-To-Many association + # + # @since 0.7.0 + # @api private + class HasMany + # @since 0.7.0 + # @api private + def self.schema_type(entity) + type = Sql::Types::Schema::AssociationType.new(entity) + Types::Strict::Array.member(type) + end + + # @since 0.7.0 + # @api private + attr_reader :repository + + # @since 0.7.0 + # @api private + attr_reader :source + + # @since 0.7.0 + # @api private + attr_reader :target + + # @since 0.7.0 + # @api private + attr_reader :subject + + # @since 0.7.0 + # @api private + attr_reader :scope + + # @since 0.7.0 + # @api private + def initialize(repository, source, target, subject, scope = nil) + @repository = repository + @source = source + @target = target + @subject = subject.to_hash unless subject.nil? + @scope = scope || _build_scope + freeze + end + + # @since 0.7.0 + # @api private + def create(data) + entity.new(command(:create, aggregate(target), mapper: nil, use: [:timestamps]) + .call(serialize(data))) + rescue => exception + raise Hanami::Model::Error.for(exception) + end + + # @since 0.7.0 + # @api private + def add(data) + command(:create, relation(target), use: [:timestamps]) + .call(associate(serialize(data))) + rescue => exception + raise Hanami::Model::Error.for(exception) + end + + # @since 0.7.0 + # @api private + def remove(id) + command(:update, relation(target), use: [:timestamps]) + .by_pk(id) + .call(unassociate) + end + + # @since 0.7.0 + # @api private + def delete + scope.delete + end + + # @since 0.7.0 + # @api private + def each(&blk) + scope.each(&blk) + end + + # @since 0.7.0 + # @api private + def map(&blk) + to_a.map(&blk) + end + + # @since 0.7.0 + # @api private + def to_a + scope.to_a + end + + # @since 0.7.0 + # @api private + def where(condition) + __new__(scope.where(condition)) + end + + # @since 0.7.0 + # @api private + def count + scope.count + end + + private + + # @since 0.7.0 + # @api private + def command(target, relation, options = {}) + repository.command(target, relation, options) + end + + # @since 0.7.0 + # @api private + def entity + repository.class.entity + end + + # @since 0.7.0 + # @api private + def relation(name) + repository.relations[name] + end + + # @since 0.7.0 + # @api private + def aggregate(name) + repository.aggregate(name) + end + + # @since 0.7.0 + # @api private + def association(name) + relation(target).associations[name] + end + + # @since 0.7.0 + # @api private + def associate(data) + relation(source) + .associations[target] + .associate(container.relations, data, subject) + end + + # @since 0.7.0 + # @api private + def unassociate + {foreign_key => nil} + end + + # @since 0.7.0 + # @api private + def container + repository.container + end + + # @since 0.7.0 + # @api private + def primary_key + association_keys.first + end + + # @since 0.7.0 + # @api private + def foreign_key + association_keys.last + end + + # Returns primary key and foreign key + # + # @since 0.7.0 + # @api private + def association_keys + target_association + .__send__(:join_key_map, container.relations) + end + + # Returns the targeted association for a given source + # + # @since 0.7.0 + # @api private + def target_association + relation(source).associations[target] + end + + # @since 0.7.0 + # @api private + def _build_scope + result = relation(target_association.target.to_sym) + result = result.where(foreign_key => subject.fetch(primary_key)) unless subject.nil? + result.as(Model::MappedRelation.mapper_name) + end + + # @since 0.7.0 + # @api private + def __new__(new_scope) + self.class.new(repository, source, target, subject, new_scope) + end + + def serialize(data) + Utils::Hash.deep_serialize(data) + end + end + end + end +end diff --git a/model-main/lib/hanami/model/associations/has_one.rb b/model-main/lib/hanami/model/associations/has_one.rb new file mode 100644 index 0000000000000000000000000000000000000000..de78f64496a665d3ddd29525ab020be0d3b1125b --- /dev/null +++ b/model-main/lib/hanami/model/associations/has_one.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require "hanami/utils/hash" + +module Hanami + module Model + module Associations + # Many-To-One association + # + # @since 1.1.0 + # @api private + class HasOne + # @since 1.1.0 + # @api private + def self.schema_type(entity) + Sql::Types::Schema::AssociationType.new(entity) + end + # + # @since 1.1.0 + # @api private + attr_reader :repository + + # @since 1.1.0 + # @api private + attr_reader :source + + # @since 1.1.0 + # @api private + attr_reader :target + + # @since 1.1.0 + # @api private + attr_reader :subject + + # @since 1.1.0 + # @api private + attr_reader :scope + + # @since 1.1.0 + # @api private + def initialize(repository, source, target, subject, scope = nil) + @repository = repository + @source = source + @target = target + @subject = subject.to_hash unless subject.nil? + @scope = scope || _build_scope + freeze + end + + def one + scope.one + end + + def create(data) + entity.new( + command(:create, aggregate(target), mapper: nil).call(serialize(data)) + ) + rescue => exception + raise Hanami::Model::Error.for(exception) + end + + def add(data) + command(:create, relation(target), mapper: nil).call(associate(serialize(data))) + rescue => exception + raise Hanami::Model::Error.for(exception) + end + + def update(data) + command(:update, relation(target), mapper: nil) + .by_pk( + one.public_send(relation(target).primary_key) + ).call(serialize(data)) + rescue => exception + raise Hanami::Model::Error.for(exception) + end + + def delete + scope.delete + end + alias_method :remove, :delete + + def replace(data) + repository.transaction do + delete + add(serialize(data)) + end + end + + private + + # @since 1.1.0 + # @api private + def entity + repository.class.entity + end + + # @since 1.1.0 + # @api private + def aggregate(name) + repository.aggregate(name) + end + + # @since 1.1.0 + # @api private + def command(target, relation, options = {}) + repository.command(target, relation, options) + end + + # @since 1.1.0 + # @api private + def relation(name) + repository.relations[Hanami::Utils::String.pluralize(name)] + end + + # @since 1.1.0 + # @api private + def container + repository.container + end + + # @since 1.1.0 + # @api private + def primary_key + association_keys.first + end + + # @since 1.1.0 + # @api private + def foreign_key + association_keys.last + end + + # @since 1.1.0 + # @api private + def associate(data) + relation(source) + .associations[target] + .associate(container.relations, data, subject) + end + + # Returns primary key and foreign key + # + # @since 1.1.0 + # @api private + def association_keys + relation(source) + .associations[target] + .__send__(:join_key_map, container.relations) + end + + # @since 1.1.0 + # @api private + def _build_scope + result = relation(target) + result = result.where(foreign_key => subject.fetch(primary_key)) unless subject.nil? + result.as(Model::MappedRelation.mapper_name) + end + + # @since 1.1.0 + # @api private + def serialize(data) + Utils::Hash.deep_serialize(data) + end + end + end + end +end diff --git a/model-main/lib/hanami/model/associations/many_to_many.rb b/model-main/lib/hanami/model/associations/many_to_many.rb new file mode 100644 index 0000000000000000000000000000000000000000..8f7e81df6d5e8de4fa4d858328447d3685691f22 --- /dev/null +++ b/model-main/lib/hanami/model/associations/many_to_many.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "hanami/utils/hash" + +module Hanami + module Model + module Associations + # Many-To-Many association + # + # @since 0.7.0 + # @api private + class ManyToMany + # @since 0.7.0 + # @api private + def self.schema_type(entity) + type = Sql::Types::Schema::AssociationType.new(entity) + Types::Strict::Array.member(type) + end + + # @since 1.1.0 + # @api private + attr_reader :repository + + # @since 1.1.0 + # @api private + attr_reader :source + + # @since 1.1.0 + # @api private + attr_reader :target + + # @since 1.1.0 + # @api private + attr_reader :subject + + # @since 1.1.0 + # @api private + attr_reader :scope + + # @since 1.1.0 + # @api private + attr_reader :through + + def initialize(repository, source, target, subject, scope = nil) + @repository = repository + @source = source + @target = target + @subject = subject.to_hash unless subject.nil? + @through = relation(source).associations[target].through.to_sym + @scope = scope || _build_scope + freeze + end + + def to_a + scope.to_a + end + + def map(&blk) + to_a.map(&blk) + end + + def each(&blk) + scope.each(&blk) + end + + def count + scope.count + end + + def where(condition) + __new__(scope.where(condition)) + end + + # Return the association table object. Would need an aditional query to return the entity + # + # @since 1.1.0 + # @api private + def add(*data) + command(:create, relation(through), use: [:timestamps]) + .call(associate(serialize(data))) + rescue => exception + raise Hanami::Model::Error.for(exception) + end + + # @since 1.1.0 + # @api private + def delete + relation(through).where(source_foreign_key => subject.fetch(source_primary_key)).delete + end + + # @since 1.1.0 + # @api private + def remove(target_id) + association_record = relation(through) + .where(target_foreign_key => target_id, source_foreign_key => subject.fetch(source_primary_key)) + .one + + return if association_record.nil? + + ar_id = association_record.public_send relation(through).primary_key + command(:delete, relation(through)).by_pk(ar_id).call + end + + private + + # @since 1.1.0 + # @api private + def container + repository.container + end + + # @since 1.1.0 + # @api private + def relation(name) + repository.relations[name] + end + + # @since 1.1.0 + # @api private + def command(target, relation, options = {}) + repository.command(target, relation, options) + end + + # @since 1.1.0 + # @api private + def associate(data) + relation(target) + .associations[source] + .associate(container.relations, data, subject) + end + + # @since 1.1.0 + # @api private + def source_primary_key + association_keys[0].first + end + + # @since 1.1.0 + # @api private + def source_foreign_key + association_keys[0].last + end + + # @since 1.1.0 + # @api private + def association_keys + relation(source) + .associations[target] + .__send__(:join_key_map, container.relations) + end + + # @since 1.1.0 + # @api private + def target_foreign_key + association_keys[1].first + end + + # @since 1.1.0 + # @api private + def target_primary_key + association_keys[1].last + end + + # Return the ROM::Associations for the source relation + # + # @since 1.1.0 + # @api private + def association + relation(source).associations[target] + end + + # @since 1.1.0 + # + # @api private + def _build_scope + result = relation(association.target.to_sym).qualified + unless subject.nil? + result = result + .join(through, target_foreign_key => target_primary_key) + .where(source_foreign_key => subject.fetch(source_primary_key)) + end + result.as(Model::MappedRelation.mapper_name) + end + + # @since 1.1.0 + # @api private + def __new__(new_scope) + self.class.new(repository, source, target, subject, new_scope) + end + + # @since 1.1.0 + # @api private + def serialize(data) + data.map do |d| + Utils::Hash.deep_serialize(d) + end + end + end + end + end +end diff --git a/model-main/lib/hanami/model/configuration.rb b/model-main/lib/hanami/model/configuration.rb new file mode 100644 index 0000000000000000000000000000000000000000..1688bed19a35e2d25ed543dd808151b3172490d0 --- /dev/null +++ b/model-main/lib/hanami/model/configuration.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require "rom/configuration" + +module Hanami + module Model + # Configuration for the framework, models and adapters. + # + # Hanami::Model has its own global configuration that can be manipulated + # via `Hanami::Model.configure`. + # + # @since 0.2.0 + class Configuration + # @since 0.7.0 + # @api private + attr_reader :mappings + + # @since 0.7.0 + # @api private + attr_reader :entities + + # @since 1.0.0 + # @api private + attr_reader :logger + + # @since 1.0.0 + # @api private + attr_reader :migrations_logger + + # @since 0.2.0 + # @api private + def initialize(configurator) + @backend = configurator.backend + @url = configurator.url + @migrations = configurator._migrations + @schema = configurator._schema + @gateway_config = configurator._gateway + @logger = configurator._logger + @migrations_logger = configurator.migrations_logger + @mappings = {} + @entities = {} + end + + # NOTE: This must be changed when we want to support several adapters at the time + # + # @since 0.7.0 + # @api private + attr_reader :url + + # NOTE: This must be changed when we want to support several adapters at the time + # + # @raise [Hanami::Model::UnknownDatabaseAdapterError] if `url` is blank, + # or it uses an unknown adapter. + # + # @since 0.7.0 + # @api private + def connection + gateway.connection + end + + # NOTE: This must be changed when we want to support several adapters at the time + # + # @raise [Hanami::Model::UnknownDatabaseAdapterError] if `url` is blank, + # or it uses an unknown adapter. + # + # @since 0.7.0 + # @api private + def gateway + gateways[:default] + end + + # Root directory + # + # @since 0.4.0 + # @api private + def root + Hanami.respond_to?(:root) ? Hanami.root : Pathname.pwd + end + + # Migrations directory + # + # @since 0.4.0 + def migrations + (@migrations.nil? ? root : root.join(@migrations)).realpath + end + + # Path for schema dump file + # + # @since 0.4.0 + def schema + @schema.nil? ? root : root.join(@schema) + end + + # @since 0.7.0 + # @api private + def define_mappings(root, &blk) + @mappings[root] = Mapping.new(&blk) + end + + # @since 0.7.0 + # @api private + def register_entity(plural, singular, klass) + @entities[plural] = klass + @entities[singular] = klass + end + + # @since 0.7.0 + # @api private + def define_entities_mappings(container, repositories) + return unless defined?(Sql::Entity::Schema) + + repositories.each do |r| + relation = r.relation + entity = r.entity + + entity.schema = Sql::Entity::Schema.new(entities, container.relations[relation], mappings.fetch(relation)) + end + end + + # @since 1.0.0 + # @api private + def configure_gateway + @gateway_config&.call(gateway) + end + + # @since 1.0.0 + # @api private + def logger=(value) + return if value.nil? + + gateway.use_logger(@logger = value) + end + + # @raise [Hanami::Model::UnknownDatabaseAdapterError] if `url` is blank, + # or it uses an unknown adapter. + # + # @since 1.0.0 + # @api private + def rom + @rom ||= ROM::Configuration.new(@backend, @url, infer_relations: false) + rescue => exception + raise UnknownDatabaseAdapterError.new(@url) if exception.message =~ /adapters/ + + raise exception + end + + # @raise [Hanami::Model::UnknownDatabaseAdapterError] if `url` is blank, + # or it uses an unknown adapter. + # + # @since 1.0.0 + # @api private + def load!(repositories, &blk) + rom.setup.auto_registration(config.directory.to_s) unless config.directory.nil? + rom.instance_eval(&blk) if block_given? + configure_gateway + repositories.each(&:load!) + self.logger = logger + + container = ROM.container(rom) + define_entities_mappings(container, repositories) + container + rescue => exception + raise Hanami::Model::Error.for(exception) + end + + # @since 1.0.0 + # @api private + def method_missing(method_name, *args, &blk) + if rom.respond_to?(method_name) + rom.__send__(method_name, *args, &blk) + else + super + end + end + + # @since 1.1.0 + # @api private + def respond_to_missing?(method_name, include_all) + rom.respond_to?(method_name, include_all) + end + end + end +end diff --git a/model-main/lib/hanami/model/configurator.rb b/model-main/lib/hanami/model/configurator.rb new file mode 100644 index 0000000000000000000000000000000000000000..a8c5d07c04fd98a42bcf009707289489d7999895 --- /dev/null +++ b/model-main/lib/hanami/model/configurator.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Hanami + module Model + # Configuration DSL + # + # @since 0.7.0 + # @api private + class Configurator + # @since 0.7.0 + # @api private + attr_reader :backend + + # @since 0.7.0 + # @api private + attr_reader :url + + # @since 0.7.0 + # @api private + attr_reader :directory + + # @since 0.7.0 + # @api private + attr_reader :_migrations + + # @since 0.7.0 + # @api private + attr_reader :_schema + + # @since 1.0.0 + # @api private + attr_reader :_logger + + # @since 1.0.0 + # @api private + attr_reader :_gateway + + # @since 0.7.0 + # @api private + def self.build(&block) + new.tap { |config| config.instance_eval(&block) } + end + + # @since 1.0.0 + # @api private + def migrations_logger(stream = $stdout) + require "hanami/model/migrator/logger" + @migrations_logger ||= Hanami::Model::Migrator::Logger.new(stream) + end + + private + + # @since 0.7.0 + # @api private + def adapter(backend, url) + @backend = backend + @url = url + end + + # @since 0.7.0 + # @api private + def path(path) + @directory = path + end + + # @since 0.7.0 + # @api private + def migrations(path) + @_migrations = path + end + + # @since 0.7.0 + # @api private + def schema(path) + @_schema = path + end + + # @since 1.0.0 + # @api private + def logger(stream, options = {}) + require "hanami/logger" + + opts = options.merge(stream: stream) + @_logger = Hanami::Logger.new("hanami.model", **opts) + end + + # @since 1.0.0 + # @api private + def gateway(&blk) + @_gateway = blk + end + end + end +end diff --git a/model-main/lib/hanami/model/entity_name.rb b/model-main/lib/hanami/model/entity_name.rb new file mode 100644 index 0000000000000000000000000000000000000000..d490db5945de2044623203f28c2c99c72dc20145 --- /dev/null +++ b/model-main/lib/hanami/model/entity_name.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Hanami + module Model + # Conventional name for entities. + # + # Given a repository named SourceFileRepository, the associated + # entity will be SourceFile. + # + # @since 0.7.0 + # @api private + class EntityName + # @since 0.7.0 + # @api private + SUFFIX = /Repository\z/.freeze + + # @param name [Class,String] the class or its name + # @return [String] the entity name + # + # @since 0.7.0 + # @api private + def initialize(name) + @name = name.sub(SUFFIX, "") + end + + # @since 0.7.0 + # @api private + def underscore + Utils::String.underscore(@name).to_sym + end + + # @since 0.7.0 + # @api private + def to_s + @name + end + end + end +end diff --git a/model-main/lib/hanami/model/error.rb b/model-main/lib/hanami/model/error.rb new file mode 100644 index 0000000000000000000000000000000000000000..888fe3fc57f5924f5737da12947f408b75561c7f --- /dev/null +++ b/model-main/lib/hanami/model/error.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "concurrent" + +module Hanami + module Model + # Default Error class + # + # @since 0.5.1 + class Error < ::StandardError + # @api private + # @since 0.7.0 + @__mapping__ = Concurrent::Map.new + + # @api private + # @since 0.7.0 + def self.for(exception) + mapping.fetch(exception.class, self).new(exception) + end + + # @api private + # @since 0.7.0 + def self.register(external, internal) + mapping.put_if_absent(external, internal) + end + + # @api private + # @since 0.7.0 + def self.mapping + @__mapping__ + end + end + + # Generic database error + # + # @since 0.7.0 + class DatabaseError < Error + end + + # Error for invalid raw command syntax + # + # @since 0.5.0 + class InvalidCommandError < Error + # @since 0.5.0 + # @api private + def initialize(message = "Invalid command") + super + end + end + + # Error for Constraint Violation + # + # @since 0.7.0 + class ConstraintViolationError < Error + # @since 0.7.0 + # @api private + def initialize(message = "Constraint has been violated") + super + end + end + + # Error for Unique Constraint Violation + # + # @since 0.6.1 + class UniqueConstraintViolationError < ConstraintViolationError + # @since 0.6.1 + # @api private + def initialize(message = "Unique constraint has been violated") + super + end + end + + # Error for Foreign Key Constraint Violation + # + # @since 0.6.1 + class ForeignKeyConstraintViolationError < ConstraintViolationError + # @since 0.6.1 + # @api private + def initialize(message = "Foreign key constraint has been violated") + super + end + end + + # Error for Not Null Constraint Violation + # + # @since 0.6.1 + class NotNullConstraintViolationError < ConstraintViolationError + # @since 0.6.1 + # @api private + def initialize(message = "NOT NULL constraint has been violated") + super + end + end + + # Error for Check Constraint Violation raised by Sequel + # + # @since 0.6.1 + class CheckConstraintViolationError < ConstraintViolationError + # @since 0.6.1 + # @api private + def initialize(message = "Check constraint has been violated") + super + end + end + + # Unknown database type error for repository auto-mapping + # + # @since 1.0.0 + class UnknownDatabaseTypeError < Error + end + + # Unknown primary key error + # + # @since 1.0.0 + class MissingPrimaryKeyError < Error + end + + # Unknown attribute error + # + # @since 1.2.0 + class UnknownAttributeError < Error + end + + # Unknown database adapter error + # + # @since 1.2.1 + class UnknownDatabaseAdapterError < Error + def initialize(url) + super("Unknown database adapter for URL: #{url.inspect}. Please check your database configuration (hint: ENV['DATABASE_URL']).") + end + end + end +end diff --git a/model-main/lib/hanami/model/mapped_relation.rb b/model-main/lib/hanami/model/mapped_relation.rb new file mode 100644 index 0000000000000000000000000000000000000000..962b0a03e11ec131cca7c3c098b7379f511aa9f2 --- /dev/null +++ b/model-main/lib/hanami/model/mapped_relation.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Hanami + module Model + # Mapped proxy for ROM relations. + # + # It eliminates the need to use #as for repository queries + # + # @since 1.0.0 + # @api private + class MappedRelation < SimpleDelegator + # Mapper name. + # + # With ROM mapping there is a link between the entity class and a generic + # reference for it. Example: BookRepository references Book + # as :entity. + # + # @since 1.0.0 + # @api private + MAPPER_NAME = :entity + + # @since 1.0.0 + # @api private + def self.mapper_name + MAPPER_NAME + end + + # @since 1.0.0 + # @api private + def initialize(relation) + @relation = relation + super(relation.as(self.class.mapper_name)) + end + + # Access low level relation's attribute + # + # @param attribute [Symbol] the attribute name + # + # @return [ROM::SQL::Attribute] the attribute + # + # @raise [Hanami::Model::UnknownAttributeError] if the attribute cannot be found + # + # @since 1.2.0 + # + # @example + # class UserRepository < Hanami::Repository + # def by_matching_name(name) + # users + # .where(users[:name].ilike(name)) + # .map_to(User) + # .to_a + # end + # end + def [](attribute) + @relation[attribute] + rescue KeyError => exception + raise UnknownAttributeError.new(exception.message) + end + end + end +end diff --git a/model-main/lib/hanami/model/mapping.rb b/model-main/lib/hanami/model/mapping.rb new file mode 100644 index 0000000000000000000000000000000000000000..c3ff60f1fe7c5c58db0aa00ff54a261c15e015fc --- /dev/null +++ b/model-main/lib/hanami/model/mapping.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "transproc/all" + +module Hanami + module Model + # Mapping + # + # @since 0.1.0 + # @api private + class Mapping + extend Transproc::Registry + + import Transproc::HashTransformations + + # @since 0.1.0 + # @api private + def initialize(&blk) + @attributes = {} + @r_attributes = {} + instance_eval(&blk) + @processor = @attributes.empty? ? ::Hash : t(:rename_keys, @attributes) + end + + # @api private + def t(name, *args) + self.class[name, *args] + end + + # @api private + def model(entity) + end + + # @api private + def register_as(name) + end + + # @api private + def attribute(name, options) + from = options.fetch(:from, name) + + @attributes[name] = from + @r_attributes[from] = name + end + + # @api private + def process(input) + @processor[input] + end + + # @api private + def reverse? + @r_attributes.any? + end + + # @api private + def translate(attribute) + @r_attributes.fetch(attribute) + end + end + end +end diff --git a/model-main/lib/hanami/model/migration.rb b/model-main/lib/hanami/model/migration.rb new file mode 100644 index 0000000000000000000000000000000000000000..ca4a0bc83f3100b962b8d3d573948c84a76703c2 --- /dev/null +++ b/model-main/lib/hanami/model/migration.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Hanami + module Model + # Database migration + # + # @since 0.7.0 + # @api private + class Migration + # @since 0.7.0 + # @api private + attr_reader :gateway + + # @since 0.7.0 + # @api private + attr_reader :migration + + # @since 0.7.0 + # @api private + def initialize(gateway, &block) + @gateway = gateway + @migration = gateway.migration(&block) + freeze + end + + # @since 0.7.0 + # @api private + def run(direction = :up) + migration.apply(gateway.connection, direction) + end + end + end +end diff --git a/model-main/lib/hanami/model/migrator.rb b/model-main/lib/hanami/model/migrator.rb new file mode 100644 index 0000000000000000000000000000000000000000..f066ab08766e2121c935ee2fd75ba219f5221ed9 --- /dev/null +++ b/model-main/lib/hanami/model/migrator.rb @@ -0,0 +1,394 @@ +# frozen_string_literal: true + +require "sequel" +require "sequel/extensions/migration" + +module Hanami + module Model + # Migration error + # + # @since 0.4.0 + class MigrationError < Hanami::Model::Error + end + + # Database schema migrator + # + # @since 0.4.0 + 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: + # + # * SQLite3 + # * PostgreSQL + # * MySQL + # + # @raise [Hanami::Model::MigrationError] if an error occurs + # + # @since 0.4.0 + # + # @see Hanami::Model::Configuration#adapter + # + # @example + # require 'hanami/model' + # require 'hanami/model/migrator' + # + # Hanami::Model.configure do + # # ... + # 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 + new.create + end + + # Drop database defined by current configuration. + # + # It's only implemented for the following databases: + # + # * SQLite3 + # * PostgreSQL + # * MySQL + # + # @raise [Hanami::Model::MigrationError] if an error occurs + # + # @since 0.4.0 + # + # @see Hanami::Model::Configuration#adapter + # + # @example + # require 'hanami/model' + # require 'hanami/model/migrator' + # + # Hanami::Model.configure do + # # ... + # 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 + new.drop + end + + # Migrate database schema + # + # It's possible to migrate "down" by specifying a version + # (eg. "20150610133853") + # + # @param version [String,NilClass] target version + # + # @raise [Hanami::Model::MigrationError] if an error occurs + # + # @since 0.4.0 + # + # @see Hanami::Model::Configuration#adapter + # @see Hanami::Model::Configuration#migrations + # @see Hanami::Model::Configuration#rollback + # + # @example Migrate Up + # require 'hanami/model' + # require 'hanami/model/migrator' + # + # Hanami::Model.configure do + # # ... + # adapter :sql, 'postgres://localhost/foo' + # migrations 'db/migrations' + # end + # + # # Reads all files from "db/migrations" and apply them + # Hanami::Model::Migrator.migrate + # + # @example Migrate Down + # require 'hanami/model' + # require 'hanami/model/migrator' + # + # Hanami::Model.configure do + # # ... + # adapter :sql, 'postgres://localhost/foo' + # migrations 'db/migrations' + # end + # + # # Reads all files from "db/migrations" and apply them + # Hanami::Model::Migrator.migrate + # + # # Migrate to a specific version + # Hanami::Model::Migrator.migrate(version: "20150610133853") + # + # NOTE: Class level interface SHOULD be removed in Hanami 2.0 + def self.migrate(version: nil) + new.migrate(version: version) + end + + # Rollback database schema + # + # @param steps [Number,NilClass] number of versions to rollback + # + # @raise [Hanami::Model::MigrationError] if an error occurs + # + # @since 1.1.0 + # + # @see Hanami::Model::Configuration#adapter + # @see Hanami::Model::Configuration#migrations + # @see Hanami::Model::Configuration#migrate + # + # @example Rollback + # require 'hanami/model' + # require 'hanami/model/migrator' + # + # Hanami::Model.configure do + # # ... + # adapter :sql, 'postgres://localhost/foo' + # migrations 'db/migrations' + # end + # + # # Reads all files from "db/migrations" and apply them + # Hanami::Model::Migrator.migrate + # + # # By default only rollback one version + # Hanami::Model::Migrator.rollback + # + # # Use a hash passing a number of versions to rollback, it will rollbacks those versions + # Hanami::Model::Migrator.rollback(versions: 2) + # + # NOTE: Class level interface SHOULD be removed in Hanami 2.0 + def self.rollback(steps: 1) + new.rollback(steps: steps) + end + + # Migrate, dump schema, delete migrations. + # + # This is an experimental feature. + # It may change or be removed in the future. + # + # Actively developed applications accumulate tons of migrations. + # In the long term they are hard to maintain and slow to execute. + # + # "Apply" feature solves this problem. + # + # It keeps an updated SQL file with the structure of the database. + # This file can be used to create fresh databases for developer machines + # or during testing. This is faster than to run dozen or hundred migrations. + # + # When we use "apply", it eliminates all the migrations that are no longer + # necessary. + # + # @raise [Hanami::Model::MigrationError] if an error occurs + # + # @since 0.4.0 + # + # @see Hanami::Model::Configuration#adapter + # @see Hanami::Model::Configuration#migrations + # + # @example Apply Migrations + # require 'hanami/model' + # require 'hanami/model/migrator' + # + # Hanami::Model.configure do + # # ... + # adapter :sql, 'postgres://localhost/foo' + # migrations 'db/migrations' + # schema 'db/schema.sql' + # end + # + # # 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 + new.apply + end + + # Prepare database: drop, create, load schema (if any), migrate. + # + # This is designed for development machines and testing mode. + # It works faster if used with apply. + # + # @raise [Hanami::Model::MigrationError] if an error occurs + # + # @since 0.4.0 + # + # @see Hanami::Model::Migrator.apply + # + # @example Prepare Database + # require 'hanami/model' + # require 'hanami/model/migrator' + # + # Hanami::Model.configure do + # # ... + # adapter :sql, 'postgres://localhost/foo' + # migrations 'db/migrations' + # end + # + # Hanami::Model::Migrator.prepare # => creates `foo' and runs migrations + # + # @example Prepare Database (with schema dump) + # require 'hanami/model' + # require 'hanami/model/migrator' + # + # Hanami::Model.configure do + # # ... + # 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 + new.prepare + end + + # Return current database version timestamp + # + # If no migrations were ran, it returns nil. + # + # @return [String,NilClass] current version, if previously migrated + # + # @since 0.4.0 + # + # @example + # # Given last migrations is: + # # 20150610133853_create_books.rb + # + # Hanami::Model::Migrator.version # => "20150610133853" + # + # NOTE: Class level interface SHOULD be removed in Hanami 2.0 + def self.version + new.version + end + + # Instantiate a new migrator + # + # @param configuration [Hanami::Model::Configuration] framework configuration + # + # @return [Hanami::Model::Migrator] a new instance + # + # @since 0.7.0 + # @api private + def initialize(configuration: self.class.configuration) + @configuration = configuration + @adapter = Adapter.for(configuration) + end + + # @since 0.7.0 + # @api private + # + # @see Hanami::Model::Migrator.create + def create + adapter.create + end + + # @since 0.7.0 + # @api private + # + # @see Hanami::Model::Migrator.drop + def drop + adapter.drop + end + + # @since 0.7.0 + # @api private + # + # @see Hanami::Model::Migrator.migrate + def migrate(version: nil) + adapter.migrate(migrations, version) if migrations? + end + + # @since 1.1.0 + # @api private + # + # @see Hanami::Model::Migrator.rollback + def rollback(steps: 1) + adapter.rollback(migrations, steps.abs) if migrations? + end + + # @since 0.7.0 + # @api private + # + # @see Hanami::Model::Migrator.apply + def apply + migrate + adapter.dump + delete_migrations + end + + # @since 0.7.0 + # @api private + # + # @see Hanami::Model::Migrator.prepare + def prepare + drop + rescue # rubocop:disable Lint/SuppressedException + ensure + create + adapter.load + migrate + end + + # @since 0.7.0 + # @api private + # + # @see Hanami::Model::Migrator.version + def version + adapter.version + end + + # Hanami::Model configuration + # + # @since 0.4.0 + # @api private + def self.configuration + Model.configuration + end + + private + + # @since 0.7.0 + # @api private + attr_reader :configuration + + # @since 0.7.0 + # @api private + attr_reader :connection + + # @since 0.7.0 + # @api private + attr_reader :adapter + + # Migrations directory + # + # @since 0.7.0 + # @api private + def migrations + configuration.migrations + end + + # Check if there are migrations + # + # @since 0.7.0 + # @api private + def migrations? + Dir["#{migrations}/*.rb"].any? + end + + # Delete all the migrations + # + # @since 0.7.0 + # @api private + def delete_migrations + migrations.each_child(&:delete) + end + end + end +end diff --git a/model-main/lib/hanami/model/migrator/adapter.rb b/model-main/lib/hanami/model/migrator/adapter.rb new file mode 100644 index 0000000000000000000000000000000000000000..c56da608cd250ffa67ce70edfa0bc11078cf807c --- /dev/null +++ b/model-main/lib/hanami/model/migrator/adapter.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require "uri" +require "shellwords" +require "open3" + +module Hanami + module Model + class Migrator + # Migrator base adapter + # + # @since 0.4.0 + # @api private + class Adapter + # Migrations table to store migrations metadata. + # + # @since 0.4.0 + # @api private + MIGRATIONS_TABLE = :schema_migrations + + # Migrations table version column + # + # @since 0.4.0 + # @api private + MIGRATIONS_TABLE_VERSION_COLUMN = :filename + + # Loads and returns a specific adapter for the given connection. + # + # @since 0.4.0 + # @api private + def self.for(configuration) + connection = Connection.new(configuration) + + case connection.database_type + when :sqlite + require "hanami/model/migrator/sqlite_adapter" + SQLiteAdapter + when :postgres + require "hanami/model/migrator/postgres_adapter" + PostgresAdapter + when :mysql + require "hanami/model/migrator/mysql_adapter" + MySQLAdapter + else + self + end.new(connection) + end + + # Initialize an adapter + # + # @since 0.4.0 + # @api private + def initialize(connection) + @connection = connection + end + + # Create database. + # It must be implemented by subclasses. + # + # @since 0.4.0 + # @api private + # + # @see Hanami::Model::Migrator.create + def create + raise MigrationError.new("Current adapter (#{connection.database_type}) doesn't support create.") + end + + # Drop database. + # It must be implemented by subclasses. + # + # @since 0.4.0 + # @api private + # + # @see Hanami::Model::Migrator.drop + def drop + raise MigrationError.new("Current adapter (#{connection.database_type}) doesn't support drop.") + end + + # @since 0.4.0 + # @api private + 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 => exception + raise MigrationError.new(exception.message) + end + + # @since 1.1.0 + # @api private + def rollback(migrations, steps) + table = migrations_table_dataset + version = version_to_rollback(table, steps) + + Sequel::Migrator.run(connection.raw, migrations, target: version, allow_missing_migration_files: true) + rescue Sequel::Migrator::Error => exception + raise MigrationError.new(exception.message) + end + + # Load database schema. + # It must be implemented by subclasses. + # + # @since 0.4.0 + # @api private + # + # @see Hanami::Model::Migrator.prepare + def load + raise MigrationError.new("Current adapter (#{connection.database_type}) doesn't support load.") + end + + # Database version. + # + # @since 0.4.0 + # @api private + def version + table = migrations_table_dataset + return if table.nil? + + record = table.order(MIGRATIONS_TABLE_VERSION_COLUMN).last + return if record.nil? + + record.fetch(MIGRATIONS_TABLE_VERSION_COLUMN).scan(MIGRATIONS_FILE_NAME_PATTERN).first.to_s + end + + private + + # @since 1.1.0 + # @api private + MIGRATIONS_FILE_NAME_PATTERN = /\A[\d]{14}/.freeze + + # @since 1.1.0 + # @api private + def version_to_rollback(table, steps) + record = table.order(Sequel.desc(MIGRATIONS_TABLE_VERSION_COLUMN)).all[steps] + return 0 unless record + + record.fetch(MIGRATIONS_TABLE_VERSION_COLUMN).scan(MIGRATIONS_FILE_NAME_PATTERN).first.to_i + end + + # @since 1.1.0 + # @api private + def migrations_table_dataset + connection.table(MIGRATIONS_TABLE) + end + + # @since 0.5.0 + # @api private + attr_reader :connection + + # @since 0.4.0 + # @api private + def schema + connection.schema + end + + # Returns a database connection + # + # Given a DB connection URI we can connect to a specific database or not, we need this when creating + # or dropping a database. Important to notice that we can't always open a _global_ DB connection, + # because most of the times application's DB user has no rights to do so. + # + # @param global [Boolean] determine whether or not a connection should specify a database. + # + # @since 0.5.0 + # @api private + def new_connection(global: false) + uri = global ? connection.global_uri : connection.uri + + Sequel.connect(uri) + end + + # @since 0.4.0 + # @api private + def database + escape connection.database + end + + # @since 0.4.0 + # @api private + def port + escape connection.port + end + + # @since 0.4.0 + # @api private + def host + escape connection.host + end + + # @since 0.4.0 + # @api private + def username + escape connection.user + end + + # @since 0.4.0 + # @api private + def password + escape connection.password + end + + # @since 0.4.0 + # @api private + def migrations_table + escape MIGRATIONS_TABLE + end + + # @since 0.4.0 + # @api private + def escape(string) + Shellwords.escape(string) unless string.nil? + end + + # @since 1.0.2 + # @api private + def execute(command, env: {}, error: ->(err) { raise MigrationError.new(err) }) + Open3.popen3(env, command) do |_, stdout, stderr, wait_thr| + error.call(stderr.read) unless wait_thr.value.success? + yield stdout if block_given? + end + end + end + end + end +end diff --git a/model-main/lib/hanami/model/migrator/connection.rb b/model-main/lib/hanami/model/migrator/connection.rb new file mode 100644 index 0000000000000000000000000000000000000000..ba7ed4456fe5d9d1bbe09386312538e27dba15b2 --- /dev/null +++ b/model-main/lib/hanami/model/migrator/connection.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "cgi" + +module Hanami + module Model + class Migrator + # Sequel connection wrapper + # + # Normalize external adapters interfaces + # + # @since 0.5.0 + # @api private + class Connection + # @since 0.5.0 + # @api private + def initialize(configuration) + @configuration = configuration + end + + # @since 0.7.0 + # @api private + def raw + @raw ||= begin + Sequel.connect( + configuration.url, + loggers: [configuration.migrations_logger] + ) + rescue Sequel::AdapterNotFound + raise MigrationError.new("Current adapter (#{configuration.adapter.type}) doesn't support SQL database operations.") + end + end + + # Returns DB connection host + # + # Even when adapter doesn't provide it explicitly it tries to parse + # + # @since 0.5.0 + # @api private + def host + @host ||= parsed_uri.host || parsed_opt("host") + end + + # Returns DB connection port + # + # Even when adapter doesn't provide it explicitly it tries to parse + # + # @since 0.5.0 + # @api private + def port + @port ||= parsed_uri.port || parsed_opt("port").to_i.nonzero? + end + + # Returns DB name from conenction + # + # Even when adapter doesn't provide it explicitly it tries to parse + # + # @since 0.5.0 + # @api private + def database + @database ||= parsed_uri.path[1..-1] + end + + # Returns DB type + # + # @example + # connection.database_type + # # => 'postgres' + # + # @since 0.5.0 + # @api private + def database_type + case uri + when /sqlite/ + :sqlite + when /postgres/ + :postgres + when /mysql/ + :mysql + end + end + + # Returns user from DB connection + # + # Even when adapter doesn't provide it explicitly it tries to parse + # + # @since 0.5.0 + # @api private + def user + @user ||= parsed_opt("user") || parsed_uri.user + end + + # Returns user from DB connection + # + # Even when adapter doesn't provide it explicitly it tries to parse + # + # @since 0.5.0 + # @api private + def password + @password ||= parsed_opt("password") || parsed_uri.password + end + + # Returns DB connection URI directly from adapter + # + # @since 0.5.0 + # @api private + def uri + @configuration.url + end + + # Returns DB connection wihout specifying database name + # + # @since 0.5.0 + # @api private + def global_uri + uri.sub(parsed_uri.select(:path).first, "") + end + + # Returns a boolean telling if a DB connection is from JDBC or not + # + # @since 0.5.0 + # @api private + def jdbc? + !uri.scan("jdbc:").empty? + end + + # Returns database connection URI instance without JDBC namespace + # + # @since 0.5.0 + # @api private + def parsed_uri + @parsed_uri ||= URI.parse(uri.sub("jdbc:", "")) + end + + # @api private + def schema + configuration.schema + end + + # Return the database table for the given name + # + # @since 0.7.0 + # @api private + def table(name) + raw[name] if raw.tables.include?(name) + end + + private + + # @since 1.0.0 + # @api private + attr_reader :configuration + + # Returns a value of a given query string param + # + # @param option [String] which option from database connection will be extracted from URI + # + # @since 0.5.0 + # @api private + def parsed_opt(option, query: parsed_uri.query) + return if query.nil? + + @parsed_query_opts ||= CGI.parse(query) + @parsed_query_opts[option].to_a.last + end + end + end + end +end diff --git a/model-main/lib/hanami/model/migrator/logger.rb b/model-main/lib/hanami/model/migrator/logger.rb new file mode 100644 index 0000000000000000000000000000000000000000..aa45f32539caa1963463dd8688541cb8e019c728 --- /dev/null +++ b/model-main/lib/hanami/model/migrator/logger.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "hanami/logger" + +module Hanami + module Model + class Migrator + # Automatic logger for migrations + # + # @since 1.0.0 + # @api private + class Logger < Hanami::Logger + # Formatter for migrations logger + # + # @since 1.0.0 + # @api private + class Formatter < Hanami::Logger::Formatter + private + + # @since 1.0.0 + # @api private + def _format(hash) + "[hanami] [#{hash.fetch(:severity)}] #{hash.fetch(:message)}\n" + end + end + + # @since 1.0.0 + # @api private + def initialize(stream) + super(nil, stream: stream, formatter: Formatter.new) + end + end + end + end +end diff --git a/model-main/lib/hanami/model/migrator/mysql_adapter.rb b/model-main/lib/hanami/model/migrator/mysql_adapter.rb new file mode 100644 index 0000000000000000000000000000000000000000..1d6404dd12865d94ec00ab411c5b91089be91134 --- /dev/null +++ b/model-main/lib/hanami/model/migrator/mysql_adapter.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Hanami + module Model + class Migrator + # MySQL adapter + # + # @since 0.4.0 + # @api private + class MySQLAdapter < Adapter + # @since 0.7.0 + # @api private + PASSWORD = "MYSQL_PWD" + + # @since 1.3.3 + # @api private + DEFAULT_PORT = 3306 + + # @since 1.0.0 + # @api private + DB_CREATION_ERROR = "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" + + # @since 0.4.0 + # @api private + def create + new_connection(global: true).run %(CREATE DATABASE `#{database}`;) + rescue Sequel::DatabaseError => exception + message = if exception.message.match(/database exists/) + DB_CREATION_ERROR + else + exception.message + end + + raise MigrationError.new(message) + end + + # @since 0.4.0 + # @api private + def drop + new_connection(global: true).run %(DROP DATABASE `#{database}`;) + rescue Sequel::DatabaseError => exception + message = if exception.message.match(/doesn\'t exist/) + "Cannot find database: #{database}" + else + exception.message + end + + raise MigrationError.new(message) + end + + # @since 0.4.0 + # @api private + def dump + dump_structure + dump_migrations_data + end + + # @since 0.4.0 + # @api private + def load + load_structure + end + + private + + # @since 0.7.0 + # @api private + def password + connection.password + end + + def port + super || DEFAULT_PORT + end + + # @since 0.4.0 + # @api private + def dump_structure + execute "mysqldump --host=#{host} --port=#{port} --user=#{username} --no-data --skip-comments --ignore-table=#{database}.#{migrations_table} #{database} > #{schema}", env: {PASSWORD => password} + end + + # @since 0.4.0 + # @api private + def load_structure + execute("mysql --host=#{host} --port=#{port} --user=#{username} #{database} < #{escape(schema)}", env: {PASSWORD => password}) if schema.exist? + end + + # @since 0.4.0 + # @api private + def dump_migrations_data + execute "mysqldump --host=#{host} --port=#{port} --user=#{username} --skip-comments #{database} #{migrations_table} >> #{schema}", env: {PASSWORD => password} + end + end + end + end +end diff --git a/model-main/lib/hanami/model/migrator/postgres_adapter.rb b/model-main/lib/hanami/model/migrator/postgres_adapter.rb new file mode 100644 index 0000000000000000000000000000000000000000..01836e20755e535918abb5470847d4587e86d7cb --- /dev/null +++ b/model-main/lib/hanami/model/migrator/postgres_adapter.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "hanami/utils/blank" + +module Hanami + module Model + class Migrator + # PostgreSQL adapter + # + # @since 0.4.0 + # @api private + class PostgresAdapter < Adapter + # @since 0.4.0 + # @api private + HOST = "PGHOST" + + # @since 0.4.0 + # @api private + PORT = "PGPORT" + + # @since 0.4.0 + # @api private + USER = "PGUSER" + + # @since 0.4.0 + # @api private + PASSWORD = "PGPASSWORD" + + # @since 1.0.0 + # @api private + DB_CREATION_ERROR = "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" + + # @since 0.4.0 + # @api private + def create + call_db_command("createdb") + end + + # @since 0.4.0 + # @api private + def drop + call_db_command("dropdb") + end + + # @since 0.4.0 + # @api private + def dump + dump_structure + dump_migrations_data + end + + # @since 0.4.0 + # @api private + def load + load_structure + end + + private + + # @since 1.3.3 + # @api private + def environment_variables + {}.tap do |env| + env[HOST] = host unless host.nil? + env[PORT] = port.to_s unless port.nil? + env[PASSWORD] = password unless password.nil? + env[USER] = username unless username.nil? + end + end + + # @since 0.4.0 + # @api private + def dump_structure + execute "pg_dump -s -x -O -T #{migrations_table} -f #{escape(schema)} #{database}", env: environment_variables + end + + # @since 0.4.0 + # @api private + def load_structure + return unless schema.exist? + + execute "psql -X -q -f #{escape(schema)} #{database}", env: environment_variables + end + + # @since 0.4.0 + # @api private + def dump_migrations_data + error = ->(err) { raise MigrationError.new(err) unless err =~ /no matching tables/i } + execute "pg_dump -t #{migrations_table} #{database} >> #{escape(schema)}", error: error, env: environment_variables + end + + # @since 0.5.1 + # @api private + def call_db_command(command) + require "open3" + + begin + Open3.popen3(environment_variables, command, database) do |_stdin, _stdout, stderr, wait_thr| + raise MigrationError.new(modified_message(stderr.read)) unless wait_thr.value.success? # wait_thr.value is the exit status + end + rescue SystemCallError => exception + raise MigrationError.new(modified_message(exception.message)) + end + end + + # @since 1.1.0 + # @api private + def modified_message(original_message) + case original_message + when /already exists/ + DB_CREATION_ERROR + when /does not exist/ + "Cannot find database: #{database}" + when /No such file or directory/ + "Could not find executable in your PATH: `#{original_message.split.last}`" + else + original_message + end + end + end + end + end +end diff --git a/model-main/lib/hanami/model/migrator/sqlite_adapter.rb b/model-main/lib/hanami/model/migrator/sqlite_adapter.rb new file mode 100644 index 0000000000000000000000000000000000000000..9bcf83df7b02d2cd80b9f9239dc8e9b851485122 --- /dev/null +++ b/model-main/lib/hanami/model/migrator/sqlite_adapter.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "pathname" +require "hanami/utils" +require "English" + +module Hanami + module Model + class Migrator + # SQLite3 Migrator + # + # @since 0.4.0 + # @api private + class SQLiteAdapter < Adapter + # No-op for in-memory databases + # + # @since 0.4.0 + # @api private + module Memory + # @since 0.4.0 + # @api private + def create + end + + # @since 0.4.0 + # @api private + def drop + end + end + + # Initialize adapter + # + # @since 0.4.0 + # @api private + def initialize(configuration) + super + extend Memory if memory? + end + + # @since 0.4.0 + # @api private + def create + path.dirname.mkpath + FileUtils.touch(path) + rescue Errno::EACCES, Errno::EPERM + raise MigrationError.new("Permission denied: #{path.sub(/\A\/\//, '')}") + end + + # @since 0.4.0 + # @api private + def drop + path.delete + rescue Errno::ENOENT + raise MigrationError.new("Cannot find database: #{path.sub(/\A\/\//, '')}") + end + + # @since 0.4.0 + # @api private + def dump + dump_structure + dump_migrations_data + end + + # @since 0.4.0 + # @api private + def load + load_structure + end + + private + + # @since 0.4.0 + # @api private + def path + root.join( + @connection.uri.sub(/\A(jdbc:sqlite:\/\/|sqlite:\/\/)/, "") + ) + end + + # @since 0.4.0 + # @api private + def root + Hanami::Model.configuration.root + end + + # @since 0.4.0 + # @api private + def memory? + uri = path.to_s + uri.match(/sqlite\:\/\z/) || + uri.match(/\:memory\:/) + end + + # @since 0.4.0 + # @api private + def dump_structure + execute "sqlite3 #{escape(path)} .schema > #{escape(schema)}" + end + + # @since 0.4.0 + # @api private + def load_structure + execute "sqlite3 #{escape(path)} < #{escape(schema)}" if schema.exist? + end + + # @since 0.4.0 + # @api private + # + def dump_migrations_data + execute "sqlite3 #{escape(path)} .dump" do |stdout| + begin + contents = stdout.read.split($INPUT_RECORD_SEPARATOR) + contents = contents.grep(/^INSERT INTO "?#{migrations_table}"?/) + + ::File.open(schema, ::File::CREAT | ::File::BINARY | ::File::WRONLY | ::File::APPEND) do |file| + file.write(contents.join($INPUT_RECORD_SEPARATOR)) + end + rescue => exception + raise MigrationError.new(exception.message) + end + end + end + end + end + end +end diff --git a/model-main/lib/hanami/model/plugins.rb b/model-main/lib/hanami/model/plugins.rb new file mode 100644 index 0000000000000000000000000000000000000000..228086cef6c5f8c32afabe3e338c0a9a90744e60 --- /dev/null +++ b/model-main/lib/hanami/model/plugins.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Hanami + module Model + # Plugins to extend read/write operations from/to the database + # + # @since 0.7.0 + # @api private + module Plugins + # Wrapping input + # + # @since 0.7.0 + # @api private + class WrappingInput + # @since 0.7.0 + # @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/model-main/lib/hanami/model/plugins/mapping.rb b/model-main/lib/hanami/model/plugins/mapping.rb new file mode 100644 index 0000000000000000000000000000000000000000..dedf1725630d790878f876de3d4ce2ddb52618b0 --- /dev/null +++ b/model-main/lib/hanami/model/plugins/mapping.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Hanami + module Model + module Plugins + # Transform output into model domain types (entities). + # + # @since 0.7.0 + # @api private + module Mapping + # Takes the output and applies the transformations + # + # @since 0.7.0 + # @api private + class InputWithMapping < WrappingInput + # @since 0.7.0 + # @api private + def initialize(relation, input) + super + @mapping = Hanami::Model.configuration.mappings[relation.name.to_sym] + end + + # Processes the output + # + # @since 0.7.0 + # @api private + def [](value) + @input[@mapping.process(value)] + end + end + + # Class interface + # + # @since 0.7.0 + # @api private + module ClassMethods + # Builds the output processor + # + # @since 0.7.0 + # @api private + def build(relation, options = {}) + wrapped_input = InputWithMapping.new(relation, options.fetch(:input) { input }) + super(relation, options.merge(input: wrapped_input)) + end + end + + # @since 0.7.0 + # @api private + def self.included(klass) + super + + klass.extend ClassMethods + end + end + end + end +end diff --git a/model-main/lib/hanami/model/plugins/schema.rb b/model-main/lib/hanami/model/plugins/schema.rb new file mode 100644 index 0000000000000000000000000000000000000000..bac8c096b6551d7ffcdf299e2768c2acbf554983 --- /dev/null +++ b/model-main/lib/hanami/model/plugins/schema.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Hanami + module Model + module Plugins + # Transform input values into database specific types (primitives). + # + # @since 0.7.0 + # @api private + module Schema + # Takes the input and applies the values transformations. + # + # @since 0.7.0 + # @api private + class InputWithSchema < WrappingInput + # @since 0.7.0 + # @api private + def initialize(relation, input) + super + @schema = relation.input_schema + end + + # Processes the input + # + # @since 0.7.0 + # @api private + def [](value) + @schema[@input[value]] + end + end + + # Class interface + # + # @since 0.7.0 + # @api private + module ClassMethods + # Builds the input processor + # + # @since 0.7.0 + # @api private + def build(relation, options = {}) + wrapped_input = InputWithSchema.new(relation, options.fetch(:input) { input }) + super(relation, options.merge(input: wrapped_input)) + end + end + + # @since 0.7.0 + # @api private + def self.included(klass) + super + + klass.extend ClassMethods + end + end + end + end +end diff --git a/model-main/lib/hanami/model/plugins/timestamps.rb b/model-main/lib/hanami/model/plugins/timestamps.rb new file mode 100644 index 0000000000000000000000000000000000000000..30fa9dff5511e5af74e5635b214b705b7e401af9 --- /dev/null +++ b/model-main/lib/hanami/model/plugins/timestamps.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Hanami + module Model + module Plugins + # Automatically set/update timestamp columns for create/update commands + # + # @since 0.7.0 + # @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 0.7.0 + # @api private + class InputWithTimestamp < WrappingInput + # Conventional timestamp names + # + # @since 0.7.0 + # @api private + TIMESTAMPS = %i[created_at updated_at].freeze + + # @since 0.7.0 + # @api private + def initialize(relation, input) + super + @timestamps = relation.columns & TIMESTAMPS + end + + # Processes the input + # + # @since 0.7.0 + # @api private + def [](value) + return @input[value] unless timestamps? + + _touch(@input[value], Time.now) + end + + protected + + # @since 0.7.0 + # @api private + def _touch(_value) + raise NoMethodError + end + + private + + # @since 0.7.0 + # @api private + def timestamps? + !@timestamps.empty? + end + end + + # Updates updated_at timestamp for update commands + # + # @since 0.7.0 + # @api private + class InputWithUpdateTimestamp < InputWithTimestamp + protected + + # @since 0.7.0 + # @api private + def _touch(value, now) + value[:updated_at] ||= now if @timestamps.include?(:updated_at) + value + end + end + + # Sets created_at and updated_at timestamps for create commands + # + # @since 0.7.0 + # @api private + class InputWithCreateTimestamp < InputWithUpdateTimestamp + protected + + # @since 0.7.0 + # @api private + def _touch(value, now) + super + value[:created_at] ||= now if @timestamps.include?(:created_at) + value + end + end + + # Class interface + # + # @since 0.7.0 + # @api private + module ClassMethods + # Build an input processor according to the current command (create or update). + # + # @since 0.7.0 + # @api private + def build(relation, options = {}) + plugin = if self < ROM::Commands::Create + InputWithCreateTimestamp + else + InputWithUpdateTimestamp + end + + wrapped_input = plugin.new(relation, options.fetch(:input) { input }) + super(relation, options.merge(input: wrapped_input)) + end + end + + # @since 0.7.0 + # @api private + def self.included(klass) + super + + klass.extend ClassMethods + end + end + end + end +end diff --git a/model-main/lib/hanami/model/relation_name.rb b/model-main/lib/hanami/model/relation_name.rb new file mode 100644 index 0000000000000000000000000000000000000000..24438f73cd0e230199fba0998b9c42ba5215a398 --- /dev/null +++ b/model-main/lib/hanami/model/relation_name.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +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 0.7.0 + # @api private + class RelationName < EntityName + # @param name [Class,String] the class or its name + # @return [String] the relation name + # + # @since 0.7.0 + # @api private + def self.new(name) + Utils::String.transform(super, :underscore, :pluralize) + end + end + end +end diff --git a/model-main/lib/hanami/model/sql.rb b/model-main/lib/hanami/model/sql.rb new file mode 100644 index 0000000000000000000000000000000000000000..67078ccbe46324660e01c5a27119fdbd514e0c78 --- /dev/null +++ b/model-main/lib/hanami/model/sql.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +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 0.7.0 + 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 0.7.0 + # + # @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 0.7.0 + # + # @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 :items + # 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 0.7.0 + 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 0.7.0 + 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(ROM::SQL::UnknownDBTypeError, UnknownDatabaseTypeError) + Error.register(ROM::SQL::MissingPrimaryKeyError, MissingPrimaryKeyError) + + 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/model-main/lib/hanami/model/sql/console.rb b/model-main/lib/hanami/model/sql/console.rb new file mode 100644 index 0000000000000000000000000000000000000000..9b0d3f39bbd6851087064baeeb2b04d07e503e76 --- /dev/null +++ b/model-main/lib/hanami/model/sql/console.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "uri" + +module Hanami + module Model + module Sql + # SQL console + # + # @since 0.7.0 + # @api private + class Console + extend Forwardable + + # @since 0.7.0 + # @api private + def_delegator :console, :connection_string + + # @since 0.7.0 + # @api private + def initialize(uri) + @uri = URI.parse(uri) + end + + private + + # @since 0.7.0 + # @api private + def console + case @uri.scheme + when "sqlite" + require "hanami/model/sql/consoles/sqlite" + Sql::Consoles::Sqlite.new(@uri) + when "postgres", "postgresql" + 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/model-main/lib/hanami/model/sql/consoles/abstract.rb b/model-main/lib/hanami/model/sql/consoles/abstract.rb new file mode 100644 index 0000000000000000000000000000000000000000..f2569d8405ec4f99bef55e82825191514b9c2355 --- /dev/null +++ b/model-main/lib/hanami/model/sql/consoles/abstract.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Hanami + module Model + module Sql + module Consoles + # Abstract adapter + # + # @since 0.7.0 + # @api private + class Abstract + # @since 0.7.0 + # @api private + def initialize(uri) + @uri = uri + end + + private + + # @since 0.7.0 + # @api private + def database_name + @uri.path.sub(/^\//, "") + end + + # @since 0.7.0 + # @api private + def concat(*tokens) + tokens.join + end + end + end + end + end +end diff --git a/model-main/lib/hanami/model/sql/consoles/mysql.rb b/model-main/lib/hanami/model/sql/consoles/mysql.rb new file mode 100644 index 0000000000000000000000000000000000000000..30e5ca3c38c9c13ca4f02cd9234626b005fd9d01 --- /dev/null +++ b/model-main/lib/hanami/model/sql/consoles/mysql.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative "abstract" + +module Hanami + module Model + module Sql + module Consoles + # MySQL adapter + # + # @since 0.7.0 + # @api private + class Mysql < Abstract + # @since 0.7.0 + # @api private + COMMAND = "mysql" + + # @since 0.7.0 + # @api private + def connection_string + concat(command, host, database, port, username, password) + end + + private + + # @since 0.7.0 + # @api private + def command + COMMAND + end + + # @since 0.7.0 + # @api private + def host + " -h #{@uri.host}" + end + + # @since 0.7.0 + # @api private + def database + " -D #{database_name}" + end + + # @since 0.7.0 + # @api private + def port + " -P #{@uri.port}" unless @uri.port.nil? + end + + # @since 0.7.0 + # @api private + def username + " -u #{@uri.user}" unless @uri.user.nil? + end + + # @since 0.7.0 + # @api private + def password + " -p #{@uri.password}" unless @uri.password.nil? + end + end + end + end + end +end diff --git a/model-main/lib/hanami/model/sql/consoles/postgresql.rb b/model-main/lib/hanami/model/sql/consoles/postgresql.rb new file mode 100644 index 0000000000000000000000000000000000000000..f04133bcd521da293e2a2f613aba841d101c691e --- /dev/null +++ b/model-main/lib/hanami/model/sql/consoles/postgresql.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative "abstract" +require "cgi" + +module Hanami + module Model + module Sql + module Consoles + # PostgreSQL adapter + # + # @since 0.7.0 + # @api private + class Postgresql < Abstract + # @since 0.7.0 + # @api private + COMMAND = "psql" + + # @since 0.7.0 + # @api private + PASSWORD = "PGPASSWORD" + + # @since 0.7.0 + # @api private + def connection_string + configure_password + concat(command, host, database, port, username) + end + + private + + # @since 0.7.0 + # @api private + def command + COMMAND + end + + # @since 0.7.0 + # @api private + def host + " -h #{query['host'] || @uri.host}" + end + + # @since 0.7.0 + # @api private + def database + " -d #{database_name}" + end + + # @since 0.7.0 + # @api private + def port + port = query["port"] || @uri.port + " -p #{port}" if port + end + + # @since 0.7.0 + # @api private + def username + username = query["user"] || @uri.user + " -U #{username}" if username + end + + # @since 0.7.0 + # @api private + def configure_password + password = query["password"] || @uri.password + ENV[PASSWORD] = CGI.unescape(query["password"] || @uri.password) if password + end + + # @since 1.1.0 + # @api private + def query + return {} if @uri.query.nil? || @uri.query.empty? + + parsed_query = @uri.query.split("&").map { |a| a.split("=") } + @query ||= Hash[parsed_query] + end + end + end + end + end +end diff --git a/model-main/lib/hanami/model/sql/consoles/sqlite.rb b/model-main/lib/hanami/model/sql/consoles/sqlite.rb new file mode 100644 index 0000000000000000000000000000000000000000..7548518b640f393c62fe0f24e87312237a11219a --- /dev/null +++ b/model-main/lib/hanami/model/sql/consoles/sqlite.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "abstract" +require "shellwords" + +module Hanami + module Model + module Sql + module Consoles + # SQLite adapter + # + # @since 0.7.0 + # @api private + class Sqlite < Abstract + # @since 0.7.0 + # @api private + COMMAND = "sqlite3" + + # @since 0.7.0 + # @api private + def connection_string + concat(command, " ", host, database) + end + + private + + # @since 0.7.0 + # @api private + def command + COMMAND + end + + # @since 0.7.0 + # @api private + def host + @uri.host unless @uri.host.nil? + end + + # @since 0.7.0 + # @api private + def database + Shellwords.escape(@uri.path) + end + end + end + end + end +end diff --git a/model-main/lib/hanami/model/sql/entity/schema.rb b/model-main/lib/hanami/model/sql/entity/schema.rb new file mode 100644 index 0000000000000000000000000000000000000000..eb8a2f5223441dd2e297cfba7dc62b09b5467b53 --- /dev/null +++ b/model-main/lib/hanami/model/sql/entity/schema.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +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 0.7.0 + # @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 class 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 0.7.0 + # @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 + + # Process attributes + # + # @param attributes [#to_hash] the attributes hash + # + # @raise [TypeError] if the process fails + # + # @since 1.0.1 + # @api private + def call(attributes) + schema.call(attributes) + end + + # @since 1.0.1 + # @api private + alias_method :[], :call + + # Check if the attribute is known + # + # @param name [Symbol] the attribute name + # + # @return [TrueClass,FalseClass] the result of the check + # + # @since 0.7.0 + # @api private + def attribute?(name) + attributes.key?(name) + end + + private + + # @since 0.7.0 + # @api private + attr_reader :attributes + + # Build the schema + # + # @param registry [Hash] a registry that keeps reference between + # entities class 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 0.7.0 + # @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 0.7.0 + # @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 class and their underscored names + # @param associations [ROM::AssociationSet] a set of associations for + # the current relation + # + # @return [Hash] attributes with associations + # + # @since 0.7.0 + # @api private + def build_associations(registry, associations) + associations.each_with_object({}) do |(name, association), result| + target = registry.fetch(association.target.to_sym) + result[name] = Association.lookup(association).schema_type(target) + end + end + + # Converts given ROM type into coercible type for entity attribute + # + # @since 0.7.0 + # @api private + def coercible(type) + Types::Schema.coercible(type) + end + end + end + end + end +end diff --git a/model-main/lib/hanami/model/sql/types.rb b/model-main/lib/hanami/model/sql/types.rb new file mode 100644 index 0000000000000000000000000000000000000000..979e9adcd4b2f903983db47594f145ca34fa7890 --- /dev/null +++ b/model-main/lib/hanami/model/sql/types.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "hanami/model/types" +require "rom/types" + +module Hanami + module Model + module Sql + # Types definitions for SQL databases + # + # @since 0.7.0 + module Types + include Dry::Types.module + + # Types for schema definitions + # + # @since 0.7.0 + 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::Hash.constructor(Coercions.method(:hash)) + + PG_JSON = Types::Strict::Nil | Types::Any.constructor(Coercions.method(:pg_json)) + + # @since 0.7.0 + # @api private + MAPPING = { + Types::String.pristine => Schema::String, + Types::Int.pristine => Schema::Int, + Types::Float.pristine => Schema::Float, + Types::Decimal.pristine => Schema::Decimal, + Types::Bool.pristine => Schema::Bool, + Types::Date.pristine => Schema::Date, + Types::DateTime.pristine => Schema::DateTime, + Types::Time.pristine => Schema::Time, + Types::Array.pristine => Schema::Array, + Types::Hash.pristine => Schema::Hash, + Types::String.optional.pristine => Schema::String, + Types::Int.optional.pristine => Schema::Int, + Types::Float.optional.pristine => Schema::Float, + Types::Decimal.optional.pristine => Schema::Decimal, + Types::Bool.optional.pristine => Schema::Bool, + Types::Date.optional.pristine => Schema::Date, + Types::DateTime.optional.pristine => Schema::DateTime, + Types::Time.optional.pristine => Schema::Time, + Types::Array.optional.pristine => Schema::Array, + Types::Hash.optional.pristine => Schema::Hash + }.freeze + + # Convert given type into coercible + # + # @since 0.7.0 + # @api private + def self.coercible(attribute) + return attribute if attribute.constrained? + + type = attribute.type + unwrapped = type.optional? ? type.right : type + + # NOTE: In the future rom-sql should be able to always return Ruby + # types instead of Sequel types. When that will happen we can get + # rid of this logic in the block and fall back to: + # + # MAPPING.fetch(unwrapped.pristine, attribute) + MAPPING.fetch(unwrapped.pristine) do + if pg_json?(unwrapped.pristine) + Schema::PG_JSON + else + attribute + end + end + end + + # @since 1.0.4 + # @api private + def self.pg_json_pristines + @pg_json_pristines ||= ::Hash.new do |hash, type| + hash[type] = (ROM::SQL::Types::PG.const_get(type).pristine if defined?(ROM::SQL::Types::PG)) + end + end + + # @since 1.0.2 + # @api private + def self.pg_json?(pristine) + pristine == pg_json_pristines["JSONB"] || # rubocop:disable Style/MultipleComparison + pristine == pg_json_pristines["JSON"] + end + + private_class_method :pg_json? + + # Coercer for SQL associations target + # + # @since 0.7.0 + # @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 0.7.0 + # @api private + def valid?(value) + value.inspect =~ /\[#{primitive}\]/ || super + end + + # @since 0.7.0 + # @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/model-main/lib/hanami/model/sql/types/schema/coercions.rb b/model-main/lib/hanami/model/sql/types/schema/coercions.rb new file mode 100644 index 0000000000000000000000000000000000000000..12a02cb7bf773d3caef76a03baaedc0480c20827 --- /dev/null +++ b/model-main/lib/hanami/model/sql/types/schema/coercions.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require "hanami/utils/string" +require "hanami/utils/hash" + +module Hanami + module Model + module Sql + module Types + module Schema + # Coercions for schema types + # + # @since 0.7.0 + # @api private + # + # rubocop:disable Metrics/ModuleLength + 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 0.7.0 + # @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 0.7.0 + # @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 0.7.0 + # @api private + def self.decimal(arg) + case arg + when ::BigDecimal + arg + when ::Integer, ::Float, ::String, ::Hanami::Utils::String + ::Kernel.BigDecimal(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 0.7.0 + # @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 0.7.0 + # @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 0.7.0 + # @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 0.7.0 + # @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 0.7.0 + # @api private + def self.hash(arg) + case arg + when ::Hash + arg + when ->(a) { a.respond_to?(:to_hash) } + Utils::Hash.deep_symbolize( + ::Kernel.Hash(arg) + ) + else + raise ArgumentError.new("invalid value for Hash(): #{arg.inspect}") + end + end + + # Coerces given argument to appropriate Postgres JSON(B) type, i.e. Hash or Array + # + # @param arg [Object] the object to coerce + # + # @return [Hash, Array] the result of the coercion + # + # @raise [ArgumentError] if the coercion fails + # + # @since 1.0.2 + # @api private + def self.pg_json(arg) + case arg + when ->(a) { a.respond_to?(:to_hash) } + hash(arg) + when ->(a) { a.respond_to?(:to_a) } + array(arg) + else + raise ArgumentError.new("invalid value for PG_JSON(): #{arg.inspect}") + end + end + end + + # rubocop:enable Metrics/ModuleLength + end + end + end + end +end diff --git a/model-main/lib/hanami/model/types.rb b/model-main/lib/hanami/model/types.rb new file mode 100644 index 0000000000000000000000000000000000000000..c43620fb800c689e0941e26bf21d3a8e3231d139 --- /dev/null +++ b/model-main/lib/hanami/model/types.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "rom/types" + +module Hanami + module Model + # Types definitions + # + # @since 0.7.0 + module Types + include ROM::Types + + # @since 0.7.0 + # @api private + def self.included(mod) + mod.extend(ClassMethods) + end + + # Class level interface + # + # @since 0.7.0 + module ClassMethods + # Define an entity of the given type + # + # @param type [Hanami::Entity] an entity + # + # @since 1.1.0 + # + # @example + # require "hanami/model" + # + # class Account < Hanami::Entity + # attributes do + # # ... + # attribute :owner, Types::Entity(User) + # end + # end + # + # account = Account.new(owner: User.new(name: "Luca")) + # account.owner.class # => User + # account.owner.name # => "Luca" + # + # account = Account.new(owner: { name: "MG" }) + # account.owner.class # => User + # account.owner.name # => "MG" + def Entity(type) + type = Schema::CoercibleType.new(type) unless type.is_a?(Dry::Types::Definition) + type + end + + # Define an array of given type + # + # @param type [Object] an object + # + # @since 0.7.0 + # + # @example + # require "hanami/model" + # + # class Account < Hanami::Entity + # attributes do + # # ... + # attribute :users, Types::Collection(User) + # end + # end + # + # account = Account.new(users: [User.new(name: "Luca")]) + # user = account.users.first + # user.class # => User + # user.name # => "Luca" + # + # account = Account.new(users: [{ name: "MG" }]) + # user = account.users.first + # user.class # => User + # user.name # => "MG" + def Collection(type) + type = Schema::CoercibleType.new(type) unless type.is_a?(Dry::Types::Definition) + Types::Array.member(type) + end + end + + # Types for schema definitions + # + # @since 0.7.0 + module Schema + # Coercer for objects within custom schema definition + # + # @since 0.7.0 + # @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 0.7.0 + # @api private + def call(value) + return if value.nil? + + 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 + # responds to `#to_hash`. + # + # @param value [Object] the value + # + # @return [TrueClass,FalseClass] the result of the check + # + # @since 0.7.0 + # @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 0.7.0 + # @api private + def object + result = primitive + return result unless result.respond_to?(:primitive) + + result.primitive + end + end + end + end + end +end diff --git a/model-main/lib/hanami/model/version.rb b/model-main/lib/hanami/model/version.rb new file mode 100644 index 0000000000000000000000000000000000000000..7c011ff434ee6580e7beecf8a231b5e14f7368da --- /dev/null +++ b/model-main/lib/hanami/model/version.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Hanami + module Model + # Defines the version + # + # @since 0.1.0 + VERSION = "1.3.3" + end +end diff --git a/model-main/lib/hanami/repository.rb b/model-main/lib/hanami/repository.rb new file mode 100644 index 0000000000000000000000000000000000000000..a2e7c5a33c389acf8ed3875372492306fc347d8b --- /dev/null +++ b/model-main/lib/hanami/repository.rb @@ -0,0 +1,490 @@ +# frozen_string_literal: true + +require "rom-repository" +require "hanami/model/entity_name" +require "hanami/model/relation_name" +require "hanami/model/mapped_relation" +require "hanami/model/associations/dsl" +require "hanami/model/association" +require "hanami/utils/class" +require "hanami/utils/class_attribute" +require "hanami/utils/io" + +module Hanami + # Mediates between the entities and the persistence layer, by offering an API + # to query and execute commands on a database. + # + # + # + # By default, a repository is named after an entity, by appending the + # `Repository` suffix to the entity class name. + # + # @example + # require 'hanami/model' + # + # class Article < Hanami::Entity + # end + # + # # valid + # class ArticleRepository < Hanami::Repository + # end + # + # # not valid for Article + # class PostRepository < Hanami::Repository + # end + # + # A repository is storage independent. + # All the queries and commands are delegated to the current adapter. + # + # This architecture has several advantages: + # + # * Applications depend on an abstract API, instead of low level details + # (Dependency Inversion principle) + # + # * Applications depend on a stable API, that doesn't change if the + # storage changes + # + # * Developers can postpone storage decisions + # + # * Isolates the persistence logic at a low level + # + # Hanami::Model is shipped with one adapter: + # + # * SqlAdapter + # + # + # + # All the queries and commands are private. + # This decision forces developers to define intention revealing API, instead + # of leaking storage API details outside of a repository. + # + # @example + # require 'hanami/model' + # + # # This is bad for several reasons: + # # + # # * The caller has an intimate knowledge of the internal mechanisms + # # of the Repository. + # # + # # * The caller works on several levels of abstraction. + # # + # # * It doesn't express a clear intent, it's just a chain of methods. + # # + # # * The caller can't be easily tested in isolation. + # # + # # * If we change the storage, we are forced to change the code of the + # # caller(s). + # + # ArticleRepository.new.where(author_id: 23).order(:published_at).limit(8) + # + # + # + # # This is a huge improvement: + # # + # # * The caller doesn't know how the repository fetches the entities. + # # + # # * The caller works on a single level of abstraction. + # # It doesn't even know about records, only works with entities. + # # + # # * It expresses a clear intent. + # # + # # * The caller can be easily tested in isolation. + # # It's just a matter of stubbing this method. + # # + # # * If we change the storage, the callers aren't affected. + # + # ArticleRepository.new.most_recent_by_author(author) + # + # 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 + # + # @since 0.1.0 + # + # @see Hanami::Entity + # @see http://martinfowler.com/eaaCatalog/repository.html + # @see http://en.wikipedia.org/wiki/Dependency_inversion_principle + class Repository < ROM::Repository::Root + # Plugins for database commands + # + # @since 0.7.0 + # @api private + # + # @see Hanami::Model::Plugins + COMMAND_PLUGINS = %i[schema mapping timestamps].freeze + + # Configuration + # + # @since 0.7.0 + # @api private + def self.configuration + Hanami::Model.configuration + end + + # Container + # + # @since 0.7.0 + # @api private + def self.container + Hanami::Model.container + end + + # Define a new ROM::Command while preserving the defaults used by Hanami itself. + # + # It allows the user to define a new command to, for example, + # create many records at the same time and still get entities back. + # + # The first argument is the command and relation it will operate on. + # + # @return [ROM::Command] the created command + # + # @example + # # In this example, calling the create_many method with and array of data, + # # would result in the creation of records and return an Array of Task entities. + # + # class TaskRepository < Hanami::Repository + # def create_many(data) + # command(create: :tasks, result: :many).call(data) + # end + # end + # + # @since 1.2.0 + def command(*args, **opts, &block) + opts[:use] = COMMAND_PLUGINS | Array(opts[:use]) + opts[:mapper] = opts.fetch(:mapper, Model::MappedRelation.mapper_name) + super(*args, **opts, &block) + end + + # Define a database relation, which describes how data is fetched from the + # database. + # + # It auto-infers the underlying database table. + # + # @since 0.7.0 + # @api private + # + def self.define_relation + a = @associations + s = @schema + + configuration.relation(relation) do + if s.nil? + schema(infer: true) do + associations(&a) unless a.nil? + end + else + schema(&s) + end + end + + relations(relation) + root(relation) + class_eval %{ + def #{relation} + Hanami::Model::MappedRelation.new(@#{relation}) + end + }, __FILE__, __LINE__ - 4 + end + + # Defines the mapping between a database table and an entity. + # + # It's also responsible to associate table columns to entity attributes. + # + # @since 0.7.0 + # @api private + # + def self.define_mapping + self.entity = Utils::Class.load!(entity_name) + e = entity + m = @mapping + + blk = lambda do |_| + model e + register_as Model::MappedRelation.mapper_name + instance_exec(&m) unless m.nil? + end + + root = self.root + configuration.mappers { define(root, &blk) } + configuration.define_mappings(root, &blk) + configuration.register_entity(relation, entity_name.underscore, e) + end + + # It defines associations, by adding relations to the repository + # + # @since 0.7.0 + # @api private + # + # @see Hanami::Model::Associations::Dsl + def self.define_associations + Model::Associations::Dsl.new(self, &@associations) unless @associations.nil? + end + + # Declare associations for the repository + # + # NOTE: This is an experimental feature + # + # @since 0.7.0 + # @api private + # + # @example + # class BookRepository < Hanami::Repository + # associations do + # has_many :books + # end + # end + def self.associations(&blk) + @associations = blk + end + + # Declare database schema + # + # NOTE: This should be used **only** when Hanami can't find a corresponding Ruby type for your column. + # + # @since 1.0.0 + # + # @example + # # In this example `name` is a PostgreSQL Enum type that we want to treat like a string. + # + # class ColorRepository < Hanami::Repository + # schema do + # attribute :id, Hanami::Model::Sql::Types::Int + # attribute :name, Hanami::Model::Sql::Types::String + # attribute :created_at, Hanami::Model::Sql::Types::DateTime + # attribute :updated_at, Hanami::Model::Sql::Types::DateTime + # end + # end + def self.schema(&blk) + @schema = blk + 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 0.7.0 + # + # @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 + + # Define relations, mapping and associations + # + # @since 0.7.0 + # @api private + def self.load! + define_relation + define_mapping + define_associations + end + + # @since 0.7.0 + # @api private + # + def self.inherited(klass) + klass.class_eval do + include Utils::ClassAttribute + auto_struct true + + @associations = nil + @mapping = nil + @schema = nil + + class_attribute :entity + class_attribute :entity_name + class_attribute :relation + + Hanami::Utils::IO.silence_warnings do + def self.relation=(name) + @relation = name.to_sym + end + end + + self.entity_name = Model::EntityName.new(name) + self.relation = Model::RelationName.new(name) + + commands :create, update: :by_pk, delete: :by_pk, mapper: Model::MappedRelation.mapper_name, use: COMMAND_PLUGINS + prepend Commands + end + + Hanami::Model.repositories << klass + end + + # Extend commands from ROM::Repository with error management + # + # @since 0.7.0 + module Commands + # Create a new record + # + # @return [Hanami::Entity] a new created entity + # + # @raise [Hanami::Model::Error] an error in case the command fails + # + # @since 0.7.0 + # + # @example Create From Hash + # user = UserRepository.new.create(name: 'Luca') + # + # @example Create From Entity + # entity = User.new(name: 'Luca') + # user = UserRepository.new.create(entity) + # + # user.id # => 23 + # entity.id # => nil - It doesn't mutate original entity + def create(*args) + super + rescue => exception + raise Hanami::Model::Error.for(exception) + end + + # Update a record + # + # @return [Hanami::Entity] an updated entity + # + # @raise [Hanami::Model::Error] an error in case the command fails + # + # @since 0.7.0 + # + # @example Update From Data + # repository = UserRepository.new + # user = repository.create(name: 'Luca') + # + # user = repository.update(user.id, age: 34) + # + # @example Update From Entity + # repository = UserRepository.new + # user = repository.create(name: 'Luca') + # + # entity = User.new(age: 34) + # user = repository.update(user.id, entity) + # + # user.age # => 34 + # entity.id # => nil - It doesn't mutate original entity + def update(*args) + super + rescue => exception + raise Hanami::Model::Error.for(exception) + end + + # Delete a record + # + # @return [Hanami::Entity] a deleted entity + # + # @raise [Hanami::Model::Error] an error in case the command fails + # + # @since 0.7.0 + # + # @example + # repository = UserRepository.new + # user = repository.create(name: 'Luca') + # + # user = repository.delete(user.id) + def delete(*args) + super + rescue => exception + raise Hanami::Model::Error.for(exception) + end + end + + # Initialize a new instance + # + # @return [Hanami::Repository] the new instance + # + # @since 0.7.0 + def initialize + super(self.class.container) + end + + # Find by primary key + # + # @return [Hanami::Entity,NilClass] the entity, if found + # + # @raise [Hanami::Model::MissingPrimaryKeyError] if the table doesn't + # define a primary key + # + # @since 0.7.0 + # + # @example + # repository = UserRepository.new + # user = repository.create(name: 'Luca') + # + # user = repository.find(user.id) + def find(id) + root.by_pk(id).as(:entity).one + rescue => exception + raise Hanami::Model::Error.for(exception) + end + + # Return all the records for the relation + # + # @return [Array] all the entities + # + # @since 0.7.0 + # + # @example + # UserRepository.new.all + def all + root.as(:entity).to_a + end + + # Returns the first record for the relation + # + # @return [Hanami::Entity,NilClass] first entity, if any + # + # @since 0.7.0 + # + # @example + # UserRepository.new.first + def first + root.as(:entity).limit(1).one + end + + # Returns the last record for the relation + # + # @return [Hanami::Entity,NilClass] last entity, if any + # + # @since 0.7.0 + # + # @example + # UserRepository.new.last + def last + root.as(:entity).limit(1).reverse.one + end + + # Deletes all the records from the relation + # + # @since 0.7.0 + # + # @example + # UserRepository.new.clear + def clear + root.delete + end + + private + + # Returns an association + # + # NOTE: This is an experimental feature + # + # @since 0.7.0 + # @api private + def assoc(target, subject = nil) + Hanami::Model::Association.build(self, target, subject) + end + end +end diff --git a/model-main/script/ci b/model-main/script/ci new file mode 100644 index 0000000000000000000000000000000000000000..859cc471c6f579b5ade635e151dc80a45b203717 --- /dev/null +++ b/model-main/script/ci @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail +IFS=$'\n\t' + +prepare_build() { + if [ -d coverage ]; then + rm -rf coverage + fi +} + +print_ruby_version() { + echo "Using $(ruby -v)" + echo +} + +run_code_quality_checks() { + bundle exec rubocop . +} + +run_unit_tests() { + bundle exec rake spec:unit --trace +} + +upload_code_coverage() { + bundle exec rake codecov:upload +} + +main() { + prepare_build + print_ruby_version + run_code_quality_checks + run_unit_tests + upload_code_coverage +} + +main diff --git a/model-main/spec/integration/hanami/model/associations/belongs_to_spec.rb b/model-main/spec/integration/hanami/model/associations/belongs_to_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..416db8c59ba8dca88566d7e31bb575b2aeef5b88 --- /dev/null +++ b/model-main/spec/integration/hanami/model/associations/belongs_to_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe "Associations (belongs_to)" do + it "returns nil if association wasn't preloaded" do + repository = BookRepository.new + book = repository.create(name: "L") + found = repository.find(book.id) + + expect(found.author).to be(nil) + end + + it "preloads the associated record" do + repository = BookRepository.new + author = AuthorRepository.new.create(name: "Michel Foucault") + book = repository.create(author_id: author.id, title: "Surveiller et punir") + found = repository.find_with_author(book.id) + + expect(found).to eq(book) + expect(found.author).to eq(author) + end + + it "returns an author" do + repository = BookRepository.new + author = AuthorRepository.new.create(name: "Maurice Leblanc") + book = repository.create(author_id: author.id, title: "L'Aguille Creuse") + found = repository.author_for(book) + + expect(found).to eq(author) + end + + it "returns nil if there's no associated record" do + repository = BookRepository.new + book = repository.create(title: "The no author book") + + expect { repository.find_with_author(book.id) }.to_not raise_error + end +end diff --git a/model-main/spec/integration/hanami/model/associations/has_many_spec.rb b/model-main/spec/integration/hanami/model/associations/has_many_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4716546cefa1db81d6bb129f6e74eacd58e52e31 --- /dev/null +++ b/model-main/spec/integration/hanami/model/associations/has_many_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +RSpec.describe "Associations (has_many)" do + let(:authors) { AuthorRepository.new } + let(:books) { BookRepository.new } + + it "returns nil if association wasn't preloaded" do + author = authors.create(name: "L") + found = authors.find(author.id) + + expect(found.books).to be_nil + end + + it "preloads associated records" do + author = authors.create(name: "Umberto Eco") + book = books.create(author_id: author.id, title: "Foucault Pendulum") + + found = authors.find_with_books(author.id) + + expect(found).to eq(author) + expect(found.books).to eq([book]) + end + + it "creates an object with a collection of associated objects" do + author = authors.create_with_books(name: "Henry Thoreau", books: [{title: "Walden"}]) + + expect(author).to be_an_instance_of(Author) + expect(author.name).to eq("Henry Thoreau") + expect(author.books).to be_an_instance_of(Array) + expect(author.books.first).to be_an_instance_of(Book) + expect(author.books.first.title).to eq("Walden") + end + + it "creates associated records when it receives a collection of serializable data" do + author = authors.create_with_books(name: "Sandi Metz", books: [BaseParams.new(title: "Practical Object-Oriented Design in Ruby")]) + + expect(author).to be_an_instance_of(Author) + expect(author.name).to eq("Sandi Metz") + expect(author.books).to be_an_instance_of(Array) + expect(author.books.first).to be_an_instance_of(Book) + expect(author.books.first.title).to eq("Practical Object-Oriented Design in Ruby") + end + + ############################################################################## + # OPERATIONS # + ############################################################################## + + ## + # ADD + # + it "adds an object to the collection" do + author = authors.create(name: "Alexandre Dumas") + book = authors.add_book(author, title: "The Count of Monte Cristo") + + expect(book.id).to_not be_nil + expect(book.title).to eq("The Count of Monte Cristo") + expect(book.author_id).to eq(author.id) + end + + it "adds an object to the collection with serializable data" do + author = authors.create(name: "David Foster Wallace") + book = authors.add_book(author, BaseParams.new(title: "Infinite Jest")) + + expect(book.id).to_not be_nil + expect(book.title).to eq("Infinite Jest") + expect(book.author_id).to eq(author.id) + end + + ## + # REMOVE + # + it "removes an object from the collection" do + authors = AuthorRepository.new + books = BookRepository.new + + # Book under test + author = authors.create(name: "Douglas Adams") + book = books.create(author_id: author.id, title: "The Hitchhiker's Guide to the Galaxy") + + # Different book + a = authors.create(name: "William Finnegan") + b = books.create(author_id: a.id, title: "Barbarian Days: A Surfing Life") + + authors.remove_book(author, book.id) + + # Check the book under test has removed foreign key + found_book = books.find(book.id) + expect(found_book).to_not be_nil + expect(found_book.author_id).to be_nil + + found_author = authors.find_with_books(author.id) + expect(found_author.books.map(&:id)).to_not include(found_book.id) + + # Check that the other book was left untouched + found_b = books.find(b.id) + expect(found_b.author_id).to eq(a.id) + end + + ## + # TO_A + # + it "returns an array of books" do + author = authors.create(name: "Nikolai Gogol") + expected = books.create(author_id: author.id, title: "Dead Souls") + expect(expected).to be_an_instance_of(Book) + + actual = authors.books_for(author).to_a + expect(actual).to eq([expected]) + end + + ## + # EACH + # + it "iterates through the books" do + author = authors.create(name: "José Saramago") + expected = books.create(author_id: author.id, title: "The Cave") + + actual = [] + authors.books_for(author).each do |book| + expect(book).to be_an_instance_of(Book) + actual << book + end + + expect(actual).to eq([expected]) + end + + ## + # MAP + # + it "iterates through the books and returns an array" do + author = authors.create(name: "José Saramago") + expected = books.create(author_id: author.id, title: "The Cave") + expect(expected).to be_an_instance_of(Book) + + actual = authors.books_for(author).map { |book| book } + expect(actual).to eq([expected]) + end + + ## + # COUNT + # + it "returns the count of the associated books" do + author = authors.create(name: "Fyodor Dostoevsky") + books.create(author_id: author.id, title: "Crime and Punishment") + books.create(author_id: author.id, title: "The Brothers Karamazov") + + expect(authors.books_count(author)).to eq(2) + end + + it "returns the count of on sale associated books" do + author = authors.create(name: "Steven Pinker") + books.create(author_id: author.id, title: "The Sense of Style", on_sale: true) + + expect(authors.on_sales_books_count(author)).to eq(1) + end + + ## + # DELETE + # + it "deletes all the books" do + author = authors.create(name: "Grazia Deledda") + book = books.create(author_id: author.id, title: "Reeds In The Wind") + + authors.delete_books(author) + + expect(books.find(book.id)).to be_nil + end + + it "deletes scoped books" do + author = authors.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) + + authors.delete_on_sales_books(author) + + expect(books.find(book.id)).to eq(book) + expect(books.find(on_sale.id)).to be_nil + end + + context "raises a Hanami::Model::Error wrapped exception on" do + it "#create" do + expect do + authors.create_with_books(name: "Noam Chomsky") + end.to raise_error Hanami::Model::Error + end + + it "#add" do + author = authors.create(name: "Machado de Assis") + expect do + authors.add_book(author, title: "O Alienista", on_sale: nil) + end.to raise_error Hanami::Model::NotNullConstraintViolationError + end + + # skipped spec + it "#remove" + end +end diff --git a/model-main/spec/integration/hanami/model/associations/has_one_spec.rb b/model-main/spec/integration/hanami/model/associations/has_one_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1372e83c5df9eadd53753f5d0661b2f009b6f777 --- /dev/null +++ b/model-main/spec/integration/hanami/model/associations/has_one_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Associations (has_one)" do + extend PlatformHelpers + + let(:users) { UserRepository.new } + let(:avatars) { AvatarRepository.new } + + it "returns nil if the association wasn't preloaded" do + user = users.create(name: "John Doe") + found = users.find(user.id) + + expect(found.avatar).to be_nil + end + + it "preloads the associated record" do + user = users.create(name: "Baruch Spinoza") + avatar = avatars.create(user_id: user.id, url: "http://www.notarealurl.com/avatar.png") + found = users.find_with_avatar(user.id) + expect(found).to eq(user) + expect(found.avatar).to eq(avatar) + end + + it "returns an Avatar" do + user = users.create(name: "Simone de Beauvoir") + avatar = avatars.create(user_id: user.id, url: "http://www.notarealurl.com/simone.png") + found = users.avatar_for(user) + + expect(found).to eq(avatar) + end + + it "returns nil if the association was preloaded but no associated object is set" do + user = users.create(name: "Henry Jenkins") + found = users.find_with_avatar(user.id) + + expect(found).to eq(user) + expect(found.avatar).to be_nil + end + + context "#add" do + it "adds an an Avatar to an existing User" do + user = users.create(name: "Jean Paul-Sartre") + avatar = users.add_avatar(user, url: "http://www.notarealurl.com/sartre.png") + found = users.find_with_avatar(user.id) + + expect(found).to eq(user) + expect(found.avatar.id).to eq(avatar.id) + expect(found.avatar.url).to eq("http://www.notarealurl.com/sartre.png") + end + + it "adds an an Avatar to an existing User when serializable data is received" do + user = users.create(name: "Jean Paul-Sartre") + avatar = users.add_avatar(user, BaseParams.new(url: "http://www.notarealurl.com/sartre.png")) + found = users.find_with_avatar(user.id) + + expect(found).to eq(user) + expect(found.avatar.id).to eq(avatar.id) + expect(found.avatar.url).to eq("http://www.notarealurl.com/sartre.png") + end + end + + context "#update" do + it "updates the avatar" do + user = users.create_with_avatar(name: "Bakunin", avatar: {url: "bakunin.jpg"}) + users.update_avatar(user, url: url = "http://history.com/bakunin.png") + + found = users.find_with_avatar(user.id) + + expect(found).to eq(user) + expect(found.avatar).to eq(user.avatar) + expect(found.avatar.url).to eq(url) + end + + it "updates the avatar when serializable data is received" do + user = users.create_with_avatar(name: "Bakunin", avatar: {url: "bakunin.jpg"}) + users.update_avatar(user, BaseParams.new(url: url = "http://history.com/bakunin.png")) + + found = users.find_with_avatar(user.id) + + expect(found).to eq(user) + expect(found.avatar).to eq(user.avatar) + expect(found.avatar.url).to eq(url) + end + end + + context "#create" do + it "creates a User and an Avatar" do + user = users.create_with_avatar(name: "Lao Tse", avatar: {url: "http://lao-tse.io/me.jpg"}) + found = users.find_with_avatar(user.id) + + expect(found.name).to eq(user.name) + expect(found.avatar).to eq(user.avatar) + expect(found.avatar.url).to eq("http://lao-tse.io/me.jpg") + end + + it "creates a User and an Avatar when serializable data is received" do + user = users.create_with_avatar(name: "Lao Tse", avatar: BaseParams.new(url: "http://lao-tse.io/me.jpg")) + found = users.find_with_avatar(user.id) + + expect(found.name).to eq(user.name) + expect(found.avatar).to eq(user.avatar) + expect(found.avatar.url).to eq("http://lao-tse.io/me.jpg") + end + end + + context "#delete" do + it "removes the Avatar" do + user = users.create_with_avatar(name: "Bob Ross", avatar: {url: "http://bobross/happy_little_avatar.jpg"}) + other = users.create_with_avatar(name: "Candido Portinari", avatar: {url: "some_mugshot.jpg"}) + users.remove_avatar(user) + found = users.find_with_avatar(user.id) + other_found = users.find_with_avatar(other.id) + + expect(found.avatar).to be_nil + expect(other_found.avatar).to be_an Avatar + end + end + + context "#replace" do + it "replaces the associated object" do + user = users.create_with_avatar(name: "Frank Herbert", avatar: {url: "http://not-real.com/avatar.jpg"}) + users.replace_avatar(user, url: "http://totally-correct.com/avatar.jpg") + found = users.find_with_avatar(user.id) + + expect(found.avatar).to_not eq(user.avatar) + + expect(avatars.by_user(user.id).size).to eq(1) + end + + it "replaces the associated object when serializable data is received" do + user = users.create_with_avatar(name: "Frank Herbert", avatar: {url: "http://not-real.com/avatar.jpg"}) + users.replace_avatar(user, BaseParams.new(url: "http://totally-correct.com/avatar.jpg")) + found = users.find_with_avatar(user.id) + + expect(found.avatar).to_not eq(user.avatar) + + expect(avatars.by_user(user.id).size).to eq(1) + end + end + + context "raises a Hanami::Model::Error wrapped exception on" do + it "#create" do + expect do + users.create_with_avatar(name: "Noam Chomsky") + end.to raise_error Hanami::Model::Error + end + + it "#add" do + user = users.create_with_avatar(name: "Stephen Fry", avatar: {url: "fry_mugshot.png"}) + expect { users.add_avatar(user, url: "new_mugshot.png") }.to raise_error Hanami::Model::UniqueConstraintViolationError + end + + # by default it seems that MySQL allows you to update a NOT NULL column to a NULL value + unless_platform(db: :mysql) do + it "#update" do + user = users.create_with_avatar(name: "Dan North", avatar: {url: "bdd_creator.png"}) + + expect do + users.update_avatar(user, url: nil) + end.to raise_error Hanami::Model::NotNullConstraintViolationError + end + end + + it "#replace" do + user = users.create_with_avatar(name: "Eric Evans", avatar: {url: "ddd_man.png"}) + expect { users.replace_avatar(user, url: nil) }.to raise_error Hanami::Model::NotNullConstraintViolationError + end + end +end diff --git a/model-main/spec/integration/hanami/model/associations/many_to_many_spec.rb b/model-main/spec/integration/hanami/model/associations/many_to_many_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ebf132ffd8303673717b80428caf07887bd87097 --- /dev/null +++ b/model-main/spec/integration/hanami/model/associations/many_to_many_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +RSpec.describe "Associations (has_many :through)" do + #### REPOS + let(:books) { BookRepository.new } + let(:categories) { CategoryRepository.new } + let(:ontologies) { BookOntologyRepository.new } + + ### ENTITIES + let(:book) { books.create(title: "Ontology: Encyclopedia of Database Systems") } + let(:category) { categories.create(name: "information science") } + + it "returns nil if association wasn't preloaded" do + found = books.find(book.id) + expect(found.categories).to be(nil) + end + + it "preloads the associated record" do + ontologies.create(book_id: book.id, category_id: category.id) + found = books.find_with_categories(book.id) + + expect(found).to eq(book) + expect(found.categories).to eq([category]) + end + + it "returns an array of Categories" do + ontologies.create(book_id: book.id, category_id: category.id) + + found = books.categories_for(book) + expect(found).to eq([category]) + end + + it "returns the count of on sale associated books" do + on_sale = books.create(title: "The Sense of Style", on_sale: true) + ontologies.create(book_id: on_sale.id, category_id: category.id) + + expect(categories.on_sales_books_count(category)).to eq(1) + end + + context "#add" do + it "adds an object to the collection" do + books.add_category(book, category) + + found_book = books.find_with_categories(book.id) + found_category = categories.find_with_books(category.id) + + expect(found_book).to eq(book) + expect(found_book.categories).to eq([category]) + expect(found_category).to eq(category) + expect(found_category.books).to eq([book]) + end + + it "associates a collection of records" do + other_book = books.create(title: "Ontological Engineering") + categories.add_books(category, book, other_book) + found = categories.find_with_books(category.id) + + expect(found.books).to match_array([book, other_book]) + end + end + + context "#delete" do + it "removes all association information" do + books.add_category(book, category) + categorized = books.find_with_categories(book.id) + books.clear_categories(book) + found = books.find_with_categories(book.id) + + expect(categorized.categories).to be_an Array + expect(categorized.categories).to match_array([category]) + expect(found.categories).to be_empty + expect(found).to eq(categorized) + end + + it "does not touch other books" do + other_book = books.create(title: "Do not meddle with") + books.add_category(other_book, category) + books.add_category(book, category) + + books.clear_categories(book) + found = books.find_with_categories(book.id) + other_found = books.find_with_categories(other_book.id) + + expect(found).to eq(book) + expect(other_book).to eq(other_found) + expect(other_found.categories).to eq([category]) + expect(found.categories).to be_empty + end + end + + context "#remove" do + it "removes the desired association" do + to_remove = books.create(title: "The Life of a Stoic") + books.add_category(to_remove, category) + + categories.remove_book(category, to_remove.id) + found = categories.find_with_books(category.id) + + expect(found.books).to_not include(to_remove) + end + end + + context "collection methods" do + it "returns an array of books" do + ontologies.create(book_id: book.id, category_id: category.id) + + actual = categories.books_for(category).to_a + expect(actual).to eq([book]) + end + + it "iterates through the categories" do + ontologies.create(book_id: book.id, category_id: category.id) + actual = [] + + categories.books_for(category).each do |book| + expect(book).to be_an_instance_of(Book) + actual << book + end + + expect(actual).to eq([book]) + end + + it "iterates through the books and returns an array" do + ontologies.create(book_id: book.id, category_id: category.id) + + actual = categories.books_for(category).map(&:id) + expect(actual).to eq([book.id]) + end + + it "returns the count of the associated books" do + other_book = books.create(title: "Practical Ontologies for Information Professionals") + ontologies.create(book_id: book.id, category_id: category.id) + ontologies.create(book_id: other_book.id, category_id: category.id) + + expect(categories.books_count(category)).to eq(2) + end + end + + context "raises a Hanami::Model::Error wrapped exception on" do + it "#add" do + expect do + categories.add_books(category, id: -2) + end.to raise_error Hanami::Model::ForeignKeyConstraintViolationError + end + end +end diff --git a/model-main/spec/integration/hanami/model/associations/relation_alias_spec.rb b/model-main/spec/integration/hanami/model/associations/relation_alias_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1f62034bf83e03f48e772e2c794eb6e81f1564e6 --- /dev/null +++ b/model-main/spec/integration/hanami/model/associations/relation_alias_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +RSpec.describe "Alias (:as) support for associations" do + let(:users) { UserRepository.new } + let(:posts) { PostRepository.new } + let(:comments) { CommentRepository.new } + + it "the attribute is named after the association" do + user = users.create(name: "Jules Verne") + post = posts.create(title: "World Traveling made easy", user_id: user.id) + + post_found = posts.find_with_author(post.id) + expect(post_found.author).to eq(user) + + user_found = users.find_with_threads(user.id) + expect(user_found.threads).to match_array([post]) + end + + it "it works with nested aggregates" do + user = users.create(name: "Jules Verne") + post = posts.create(title: "World Traveling made easy", user_id: user.id) + commenter = users.create(name: "Thomas Reid") + comments.create(user_id: commenter.id, post_id: post.id) + + found = posts.feed_for(post.id) + expect(found.author).to eq(user) + expect(found.comments[0].user).to eq(commenter) + end + + context "#assoc support (calling assoc by the alias)" do + it "for #belongs_to" do + user = users.create(name: "Jules Verne") + post = posts.create(title: "World Traveling made easy", user_id: user.id) + commenter = users.create(name: "Thomas Reid") + comment = comments.create(user_id: commenter.id, post_id: post.id) + + found_author = posts.author_for(post) + expect(found_author).to eq(user) + + found_commenter = comments.commenter_for(comment) + expect(found_commenter).to eq(commenter) + end + + it "for #has_many" do + user = users.create(name: "Jules Verne") + post = posts.create(title: "World Traveling made easy", user_id: user.id) + + found_threads = users.threads_for(user) + expect(found_threads).to match_array [post] + end + + it "for #has_many :through" do + user = users.create(name: "Jules Verne") + post = posts.create(title: "World Traveling made easy", user_id: user.id) + commenter = users.create(name: "Thomas Reid") + comments.create(user_id: commenter.id, post_id: post.id) + + commenters = posts.commenters_for(post) + + expect(commenters).to match_array([commenter]) + end + end +end diff --git a/model-main/spec/integration/hanami/model/migration/mysql.rb b/model-main/spec/integration/hanami/model/migration/mysql.rb new file mode 100644 index 0000000000000000000000000000000000000000..0d2fe0e2b226af473f9729bc2ca9990d0d8aa603 --- /dev/null +++ b/model-main/spec/integration/hanami/model/migration/mysql.rb @@ -0,0 +1,463 @@ +# frozen_string_literal: true + +RSpec.shared_examples "migration_integration_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] + expect(name).to eq(:integer1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[1] + expect(name).to eq(:integer2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] + expect(name).to eq(:integer3) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[3] + expect(name).to eq(:string1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[4] + expect(name).to eq(:string2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(3)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[5] + expect(name).to eq(:string5) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(50)") + expect(options.fetch(:max_length)).to eq(50) + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[6] + expect(name).to eq(:string6) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("char(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[7] + expect(name).to eq(:string7) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("char(64)") + expect(options.fetch(:max_length)).to eq(64) + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[8] + expect(name).to eq(:string8) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[9] + expect(name).to eq(:file1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:blob) + expect(options.fetch(:db_type)).to eq("blob") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[10] + expect(name).to eq(:file2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:blob) + expect(options.fetch(:db_type)).to eq("blob") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[11] + expect(name).to eq(:number1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[12] + expect(name).to eq(:number2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("bigint") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[13] + expect(name).to eq(:number3) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:float) + expect(options.fetch(:db_type)).to eq("double") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[14] + expect(name).to eq(:number4) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("decimal(10,0)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[15] + expect(name).to eq(:number5) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + # expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("decimal(10,0)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[16] + expect(name).to eq(:number6) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("decimal(10,2)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[17] + expect(name).to eq(:number7) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("decimal(10,0)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[18] + expect(name).to eq(:date1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:date) + expect(options.fetch(:db_type)).to eq("date") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[19] + expect(name).to eq(:date2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:datetime) + expect(options.fetch(:db_type)).to eq("datetime") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[20] + expect(name).to eq(:time1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:datetime) + expect(options.fetch(:db_type)).to eq("datetime") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[21] + expect(name).to eq(:time2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:time) + expect(options.fetch(:db_type)).to eq("time") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[22] + expect(name).to eq(:boolean1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("tinyint(1)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[23] + expect(name).to eq(:boolean2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("tinyint(1)") + expect(options.fetch(:primary_key)).to eq(false) + end + + it "defines column defaults" do + table = @connection.schema(:default_values) + + name, options = table[0] + expect(name).to eq(:a) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("23") + expect(options.fetch(:ruby_default)).to eq(23) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[1] + expect(name).to eq(:b) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("Hanami") + expect(options.fetch(:ruby_default)).to eq("Hanami") + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] + expect(name).to eq(:c) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("-1") + expect(options.fetch(:ruby_default)).to eq(-1) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[3] + expect(name).to eq(:d) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("0") + expect(options.fetch(:ruby_default)).to eq(0) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("bigint") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[4] + expect(name).to eq(:e) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("3.14") + expect(options.fetch(:ruby_default)).to eq(3.14) + expect(options.fetch(:type)).to eq(:float) + expect(options.fetch(:db_type)).to eq("double") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[5] + expect(name).to eq(:f) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("1") + expect(options.fetch(:ruby_default)).to eq(1.0) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("decimal(10,0)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[6] + expect(name).to eq(:g) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("943943") + expect(options.fetch(:ruby_default)).to eq(943_943) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("decimal(10,0)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[10] + expect(name).to eq(:k) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("1") + expect(options.fetch(:ruby_default)).to eq(true) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("tinyint(1)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[11] + expect(name).to eq(:l) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("0") + expect(options.fetch(:ruby_default)).to eq(false) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("tinyint(1)") + expect(options.fetch(:primary_key)).to eq(false) + end + + it "defines null constraint" do + table = @connection.schema(:null_constraints) + + name, options = table[0] + expect(name).to eq(:a) + + expect(options.fetch(:allow_null)).to eq(true) + + name, options = table[1] + expect(name).to eq(:b) + + expect(options.fetch(:allow_null)).to eq(false) + + name, options = table[2] + expect(name).to eq(:c) + + expect(options.fetch(:allow_null)).to eq(true) + end + + it "defines column index" do + indexes = @connection.indexes(:column_indexes) + + expect(indexes.fetch(:column_indexes_a_index, nil)).to be_nil + expect(indexes.fetch(:column_indexes_b_index, nil)).to be_nil + + index = indexes.fetch(:column_indexes_c_index) + expect(index[:unique]).to eq(false) + expect(index[:columns]).to eq([:c]) + end + + it "defines index via #index" do + indexes = @connection.indexes(:column_indexes) + + index = indexes.fetch(:column_indexes_d_index) + expect(index[:unique]).to eq(true) + expect(index[:columns]).to eq([:d]) + + index = indexes.fetch(:column_indexes_b_c_index) + expect(index[:unique]).to eq(false) + expect(index[:columns]).to eq(%i[b c]) + + index = indexes.fetch(:column_indexes_coords_index) + expect(index[:unique]).to eq(false) + expect(index[:columns]).to eq(%i[lat lng]) + end + + it "defines primary key (via #primary_key :id)" do + table = @connection.schema(:primary_keys_1) + + name, options = table[0] + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + end + + it "defines composite primary key (via #primary_key [:column1, :column2])" do + table = @connection.schema(:primary_keys_3) + + name, options = table[0] + expect(name).to eq(:group_id) + + expect(options.fetch(:allow_null)).to eq(false) + + expected = Platform.match do + default { nil } + end + + expect(options.fetch(:default)).to eq(expected) + + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(false) + + name, options = table[1] + expect(name).to eq(:position) + + expect(options.fetch(:allow_null)).to eq(false) + + expected = Platform.match do + default { nil } + end + + expect(options.fetch(:default)).to eq(expected) + + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(false) + end + + it "defines primary key (via #column primary_key: true)" do + table = @connection.schema(:primary_keys_2) + + name, options = table[0] + expect(name).to eq(:name) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(false) + end + + it "defines foreign key (via #foreign_key)" do + table = @connection.schema(:albums) + + name, options = table[1] + expect(name).to eq(:artist_id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(false) + + foreign_key = @connection.foreign_key_list(:albums).first + expect(foreign_key.fetch(:columns)).to eq([:artist_id]) + expect(foreign_key.fetch(:table)).to eq(:artists) + expect(foreign_key.fetch(:key)).to eq([:id]) + # expect(foreign_key.fetch(:on_update)).to eq(:no_action) + # expect(foreign_key.fetch(:on_delete)).to eq(:cascade) + end + + it "defines column constraint and check" + # it 'defines column constraint and check' do + # expect(@schema.read).to 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/model-main/spec/integration/hanami/model/migration/postgresql.rb b/model-main/spec/integration/hanami/model/migration/postgresql.rb new file mode 100644 index 0000000000000000000000000000000000000000..3017af7f36d3c1259ff887bd5fc2ae3018e09b2e --- /dev/null +++ b/model-main/spec/integration/hanami/model/migration/postgresql.rb @@ -0,0 +1,623 @@ +# frozen_string_literal: true + +RSpec.shared_examples "migration_integration_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] + expect(name).to eq(:integer1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[1] + expect(name).to eq(:integer2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] + expect(name).to eq(:integer3) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[3] + expect(name).to eq(:string1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[4] + expect(name).to eq(:string2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[5] + expect(name).to eq(:string3) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("character varying(1)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[6] + expect(name).to eq(:string4) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("character varying(2)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[7] + expect(name).to eq(:string5) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("character(3)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[8] + expect(name).to eq(:string6) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("character(4)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[9] + expect(name).to eq(:string7) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("character varying(50)") + expect(options.fetch(:max_length)).to eq(50) + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[10] + expect(name).to eq(:string8) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("character(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[11] + expect(name).to eq(:string9) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("character(64)") + expect(options.fetch(:max_length)).to eq(64) + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[12] + expect(name).to eq(:string10) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[13] + expect(name).to eq(:file1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:blob) + expect(options.fetch(:db_type)).to eq("bytea") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[14] + expect(name).to eq(:file2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:blob) + expect(options.fetch(:db_type)).to eq("bytea") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[15] + expect(name).to eq(:number1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[16] + expect(name).to eq(:number2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("bigint") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[17] + expect(name).to eq(:number3) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:float) + expect(options.fetch(:db_type)).to eq("double precision") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[18] + expect(name).to eq(:number4) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[19] + expect(name).to eq(:number5) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + # expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric(10,0)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[20] + expect(name).to eq(:number6) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric(10,2)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[21] + expect(name).to eq(:number7) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[22] + expect(name).to eq(:date1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:date) + expect(options.fetch(:db_type)).to eq("date") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[23] + expect(name).to eq(:date2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:datetime) + expect(options.fetch(:db_type)).to eq("timestamp without time zone") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[24] + expect(name).to eq(:time1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:datetime) + expect(options.fetch(:db_type)).to eq("timestamp without time zone") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[25] + expect(name).to eq(:time2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:time) + expect(options.fetch(:db_type)).to eq("time without time zone") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[26] + expect(name).to eq(:boolean1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("boolean") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[27] + expect(name).to eq(:boolean2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("boolean") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[28] + expect(name).to eq(:array1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer[]") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[29] + expect(name).to eq(:array2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer[]") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[30] + expect(name).to eq(:array3) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text[]") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[31] + expect(name).to eq(:money1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + # expect(options.fetch(:type)).to eq(:money) + expect(options.fetch(:db_type)).to eq("money") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[32] + expect(name).to eq(:enum1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + # expect(options.fetch(:type)).to eq(:mood) + expect(options.fetch(:db_type)).to eq("mood") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[33] + expect(name).to eq(:geometric1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + # expect(options.fetch(:type)).to eq(:point) + expect(options.fetch(:db_type)).to eq("point") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[34] + expect(name).to eq(:geometric2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + # expect(options.fetch(:type)).to eq(:line) + expect(options.fetch(:db_type)).to eq("line") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[35] + expect(name).to eq(:geometric3) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("'<(15,15),1>'::circle") + # expect(options.fetch(:type)).to eq(:circle) + expect(options.fetch(:db_type)).to eq("circle") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[36] + expect(name).to eq(:net1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("'192.168.0.0/24'::cidr") + # expect(options.fetch(:type)).to eq(:cidr) + expect(options.fetch(:db_type)).to eq("cidr") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[37] + expect(name).to eq(:uuid1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("uuid_generate_v4()") + # expect(options.fetch(:type)).to eq(:uuid) + expect(options.fetch(:db_type)).to eq("uuid") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[38] + expect(name).to eq(:xml1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + # expect(options.fetch(:type)).to eq(:xml) + expect(options.fetch(:db_type)).to eq("xml") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[39] + expect(name).to eq(:json1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + # expect(options.fetch(:type)).to eq(:json) + expect(options.fetch(:db_type)).to eq("json") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[40] + expect(name).to eq(:json2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + # expect(options.fetch(:type)).to eq(:jsonb) + expect(options.fetch(:db_type)).to eq("jsonb") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[41] + expect(name).to eq(:composite1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("ROW('fuzzy dice'::text, 42, 1.99)") + # expect(options.fetch(:type)).to eq(:inventory_item) + expect(options.fetch(:db_type)).to eq("inventory_item") + expect(options.fetch(:primary_key)).to eq(false) + end + + it "defines column defaults" do + table = @connection.schema(:default_values) + + name, options = table[0] + expect(name).to eq(:a) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("23") + expect(options.fetch(:ruby_default)).to eq(23) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[1] + expect(name).to eq(:b) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("'Hanami'::text") + expect(options.fetch(:ruby_default)).to eq("Hanami") + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] + expect(name).to eq(:c) + + expect(options.fetch(:allow_null)).to eq(true) + + expected = Platform.match do + default { "'-1'::integer" } + end + + expect(options.fetch(:default)).to eq(expected) + + # expect(options.fetch(:ruby_default)).to eq(-1) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[3] + expect(name).to eq(:d) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("0") + expect(options.fetch(:ruby_default)).to eq(0) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("bigint") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[4] + expect(name).to eq(:e) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("3.14") + expect(options.fetch(:ruby_default)).to eq(3.14) + expect(options.fetch(:type)).to eq(:float) + expect(options.fetch(:db_type)).to eq("double precision") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[5] + expect(name).to eq(:f) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("1.0") + expect(options.fetch(:ruby_default)).to eq(1.0) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[6] + expect(name).to eq(:g) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("943943") + expect(options.fetch(:ruby_default)).to eq(943_943) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[10] + expect(name).to eq(:k) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("true") + expect(options.fetch(:ruby_default)).to eq(true) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("boolean") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[11] + expect(name).to eq(:l) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("false") + expect(options.fetch(:ruby_default)).to eq(false) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("boolean") + expect(options.fetch(:primary_key)).to eq(false) + end + + it "defines null constraint" do + table = @connection.schema(:null_constraints) + + name, options = table[0] + expect(name).to eq(:a) + + expect(options.fetch(:allow_null)).to eq(true) + + name, options = table[1] + expect(name).to eq(:b) + + expect(options.fetch(:allow_null)).to eq(false) + + name, options = table[2] + expect(name).to eq(:c) + + expect(options.fetch(:allow_null)).to eq(true) + end + + it "defines column index" do + indexes = @connection.indexes(:column_indexes) + + expect(indexes.fetch(:column_indexes_a_index, nil)).to be_nil + expect(indexes.fetch(:column_indexes_b_index, nil)).to be_nil + + index = indexes.fetch(:column_indexes_c_index) + expect(index[:unique]).to eq(false) + expect(index[:columns]).to eq([:c]) + end + + it "defines index via #index" do + indexes = @connection.indexes(:column_indexes) + + index = indexes.fetch(:column_indexes_d_index) + expect(index[:unique]).to eq(true) + expect(index[:columns]).to eq([:d]) + + index = indexes.fetch(:column_indexes_b_c_index) + expect(index[:unique]).to eq(false) + expect(index[:columns]).to eq(%i[b c]) + + index = indexes.fetch(:column_indexes_coords_index) + expect(index[:unique]).to eq(false) + expect(index[:columns]).to eq(%i[lat lng]) + end + + it "defines primary key (via #primary_key :id)" do + table = @connection.schema(:primary_keys_1) + + name, options = table[0] + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq("nextval('primary_keys_1_id_seq'::regclass)") + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + end + + it "defines composite primary key (via #primary_key [:column1, :column2])" do + table = @connection.schema(:primary_keys_3) + + name, options = table[0] + expect(name).to eq(:group_id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(false) + + name, options = table[1] + expect(name).to eq(:position) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(false) + end + + it "defines primary key (via #column primary_key: true)" do + table = @connection.schema(:primary_keys_2) + + name, options = table[0] + expect(name).to eq(:name) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(false) + end + + it "defines foreign key (via #foreign_key)" do + table = @connection.schema(:albums) + + name, options = table[1] + expect(name).to eq(:artist_id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + foreign_key = @connection.foreign_key_list(:albums).first + expect(foreign_key.fetch(:columns)).to eq([:artist_id]) + expect(foreign_key.fetch(:table)).to eq(:artists) + expect(foreign_key.fetch(:key)).to eq([:id]) + expect(foreign_key.fetch(:on_update)).to eq(:no_action) + expect(foreign_key.fetch(:on_delete)).to eq(:cascade) + end + + unless Platform.ci? + it "defines column constraint and check" do + actual = @schema.read + + expect(actual).to include %(CONSTRAINT age_constraint CHECK ((age > 18))) + expect(actual).to include %(CONSTRAINT table_constraints_role_check CHECK ((role = ANY (ARRAY['contributor'::text, 'manager'::text, 'owner'::text])))) + end + end + end +end diff --git a/model-main/spec/integration/hanami/model/migration/sqlite.rb b/model-main/spec/integration/hanami/model/migration/sqlite.rb new file mode 100644 index 0000000000000000000000000000000000000000..9aacdc3a020f38d4fe65497813ab47101ca0d6b3 --- /dev/null +++ b/model-main/spec/integration/hanami/model/migration/sqlite.rb @@ -0,0 +1,468 @@ +# frozen_string_literal: true + +RSpec.shared_examples "migration_integration_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] + expect(name).to eq(:integer1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[1] + expect(name).to eq(:integer2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] + expect(name).to eq(:integer3) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[3] + expect(name).to eq(:string1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[4] + expect(name).to eq(:string2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("string") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[5] + expect(name).to eq(:string3) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("string") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[6] + expect(name).to eq(:string4) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(3)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[7] + expect(name).to eq(:string5) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(50)") + expect(options.fetch(:max_length)).to eq(50) + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[8] + expect(name).to eq(:string6) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("char(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[9] + expect(name).to eq(:string7) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("char(64)") + expect(options.fetch(:max_length)).to eq(64) + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[10] + expect(name).to eq(:string8) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[11] + expect(name).to eq(:file1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:blob) + expect(options.fetch(:db_type)).to eq("blob") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[12] + expect(name).to eq(:file2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:blob) + expect(options.fetch(:db_type)).to eq("blob") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[13] + expect(name).to eq(:number1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[14] + expect(name).to eq(:number2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("bigint") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[15] + expect(name).to eq(:number3) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:float) + expect(options.fetch(:db_type)).to eq("double precision") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[16] + expect(name).to eq(:number4) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[17] + expect(name).to eq(:number5) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + # expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric(10)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[18] + expect(name).to eq(:number6) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric(10, 2)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[19] + expect(name).to eq(:number7) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[20] + expect(name).to eq(:date1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:date) + expect(options.fetch(:db_type)).to eq("date") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[21] + expect(name).to eq(:date2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:datetime) + expect(options.fetch(:db_type)).to eq("timestamp") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[22] + expect(name).to eq(:time1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:datetime) + expect(options.fetch(:db_type)).to eq("timestamp") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[23] + expect(name).to eq(:time2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:time) + expect(options.fetch(:db_type)).to eq("time") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[24] + expect(name).to eq(:boolean1) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("boolean") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[25] + expect(name).to eq(:boolean2) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("boolean") + expect(options.fetch(:primary_key)).to eq(false) + end + + it "defines column defaults" do + table = @connection.schema(:default_values) + + name, options = table[0] + expect(name).to eq(:a) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("23") + expect(options.fetch(:ruby_default)).to eq(23) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[1] + expect(name).to eq(:b) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("'Hanami'") + expect(options.fetch(:ruby_default)).to eq("Hanami") + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] + expect(name).to eq(:c) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("-1") + expect(options.fetch(:ruby_default)).to eq(-1) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[3] + expect(name).to eq(:d) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("0") + expect(options.fetch(:ruby_default)).to eq(0) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("bigint") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[4] + expect(name).to eq(:e) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("3.14") + expect(options.fetch(:ruby_default)).to eq(3.14) + expect(options.fetch(:type)).to eq(:float) + expect(options.fetch(:db_type)).to eq("double precision") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[5] + expect(name).to eq(:f) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("1.0") + expect(options.fetch(:ruby_default)).to eq(1.0) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[6] + expect(name).to eq(:g) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("943943") + expect(options.fetch(:ruby_default)).to eq(943_943) + expect(options.fetch(:type)).to eq(:decimal) + expect(options.fetch(:db_type)).to eq("numeric") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[10] + expect(name).to eq(:k) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("1") + expect(options.fetch(:ruby_default)).to eq(true) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("boolean") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[11] + expect(name).to eq(:l) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("0") + expect(options.fetch(:ruby_default)).to eq(false) + expect(options.fetch(:type)).to eq(:boolean) + expect(options.fetch(:db_type)).to eq("boolean") + expect(options.fetch(:primary_key)).to eq(false) + end + + it "defines null constraint" do + table = @connection.schema(:null_constraints) + + name, options = table[0] + expect(name).to eq(:a) + + expect(options.fetch(:allow_null)).to eq(true) + + name, options = table[1] + expect(name).to eq(:b) + + expect(options.fetch(:allow_null)).to eq(false) + + name, options = table[2] + expect(name).to eq(:c) + + expect(options.fetch(:allow_null)).to eq(true) + end + + it "defines column index" do + indexes = @connection.indexes(:column_indexes) + + expect(indexes.fetch(:column_indexes_a_index, nil)).to be_nil + expect(indexes.fetch(:column_indexes_b_index, nil)).to be_nil + + index = indexes.fetch(:column_indexes_c_index) + expect(index[:unique]).to eq(false) + expect(index[:columns]).to eq([:c]) + end + + it "defines index via #index" do + indexes = @connection.indexes(:column_indexes) + + index = indexes.fetch(:column_indexes_d_index) + expect(index[:unique]).to eq(true) + expect(index[:columns]).to eq([:d]) + + index = indexes.fetch(:column_indexes_b_c_index) + expect(index[:unique]).to eq(false) + expect(index[:columns]).to eq(%i[b c]) + + index = indexes.fetch(:column_indexes_coords_index) + expect(index[:unique]).to eq(false) + expect(index[:columns]).to eq(%i[lat lng]) + end + + it "defines primary key (via #primary_key :id)" do + table = @connection.schema(:primary_keys_1) + + name, options = table[0] + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + end + + it "defines composite primary key (via #primary_key [:column1, :column2])" do + table = @connection.schema(:primary_keys_3) + + name, options = table[0] + expect(name).to eq(:group_id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(false) + + name, options = table[1] + expect(name).to eq(:position) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(false) + end + + it "defines primary key (via #column primary_key: true)" do + table = @connection.schema(:primary_keys_2) + + name, options = table[0] + expect(name).to eq(:name) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(false) + end + + it "defines foreign key (via #foreign_key)" do + table = @connection.schema(:albums) + + name, options = table[1] + expect(name).to eq(:artist_id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq(nil) + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(false) + + foreign_key = @connection.foreign_key_list(:albums).first + expect(foreign_key.fetch(:columns)).to eq([:artist_id]) + expect(foreign_key.fetch(:table)).to eq(:artists) + expect(foreign_key.fetch(:key)).to eq(nil) + expect(foreign_key.fetch(:on_update)).to eq(:no_action) + expect(foreign_key.fetch(:on_delete)).to eq(:cascade) + end + + it "defines column constraint and check" do + expect(@schema.read).to 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/model-main/spec/integration/hanami/model/migration_spec.rb b/model-main/spec/integration/hanami/model/migration_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fbb022646e6bfe99cbdd7128243050891fb2e6d1 --- /dev/null +++ b/model-main/spec/integration/hanami/model/migration_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require_relative "./migration/#{Database.engine}.rb" + +RSpec.describe "Hanami::Model.migration" do + include_examples "migration_integration_#{Database.engine}" +end diff --git a/model-main/spec/integration/hanami/model/repository/base_spec.rb b/model-main/spec/integration/hanami/model/repository/base_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b5668e22d84f0b11f9d580b84b8d92265cc43876 --- /dev/null +++ b/model-main/spec/integration/hanami/model/repository/base_spec.rb @@ -0,0 +1,712 @@ +# frozen_string_literal: true + +require "securerandom" + +RSpec.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) + + expect(found).to eq(user) + end + + it "returns nil when nil is given" do + repository = UserRepository.new + repository.create(name: "L") + found = repository.find(nil) + + expect(found).to be_nil + end + + it "returns nil for missing record" do + repository = UserRepository.new + found = repository.find("9999999") + + expect(found).to be_nil + end + + # See https://github.com/hanami/model/issues/374 + describe "with non-autoincrement primary key" do + before do + repository.clear + end + + let(:repository) { LabelRepository.new } + let(:id) { 1 } + + it "raises error" do + repository.create(id: id) + + expect { repository.find(id) } + .to raise_error(Hanami::Model::MissingPrimaryKeyError, "Missing primary key for :labels") + end + end + + # See https://github.com/hanami/model/issues/399 + describe "with custom relation" do + it "finds record by primary key" do + repository = AccessTokenRepository.new + access_token = repository.create(token: "123") + found = repository.find(access_token.id) + + expect(found).to eq(access_token) + end + end + end + + describe "#all" do + it "returns all the records" do + repository = UserRepository.new + user = repository.create(name: "L") + + expect(repository.all).to be_an_instance_of(Array) + expect(repository.all).to 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") + + expect(repository.first).to eq(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") + + expect(repository.last).to eq(user) + end + end + + # https://github.com/hanami/model/issues/473 + describe "querying" do + it "allows to access relation attributes via square bracket syntax" do + repository = UserRepository.new + repository.clear + + expected = [repository.create(name: "Ella"), + repository.create(name: "Bella")] + repository.create(name: "Jon") + + actual = repository.by_matching_name("%ella%") + expect(actual).to eq(expected) + end + end + + describe "#clear" do + it "clears all the records" do + repository = UserRepository.new + repository.create(name: "L") + + repository.clear + expect(repository.all).to be_empty + end + end + + describe "relation" do + describe "read" do + it "reads records from the database given a raw query string" do + repository = UserRepository.new + repository.create(name: "L") + + users = repository.find_all_by_manual_query + expect(users).to be_a_kind_of(Array) + + user = users.first + expect(user).to be_a_kind_of(User) + end + end + end + + describe "#create" do + it "creates record from data" do + repository = UserRepository.new + user = repository.create(name: "L") + + expect(user).to be_an_instance_of(User) + expect(user.id).to_not be_nil + expect(user.name).to eq("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 + expect(entity.id).to be_nil + + expect(user).to be_an_instance_of(User) + expect(user.id).to_not be_nil + expect(user.name).to eq("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") + + expect(user.created_at).to be_within(2).of(Time.now.utc) + expect(user.updated_at).to be_within(2).of(Time.now.utc) + end + + it "respects given timestamps" do + repository = UserRepository.new + given_time = Time.new(2010, 1, 1, 12, 0, 0, "+00:00") + + user = repository.create(name: "L", created_at: given_time, updated_at: given_time) + + expect(user.created_at).to be_within(2).of(given_time) + expect(user.updated_at).to be_within(2).of(given_time) + end + + it "can update timestamps" do + repository = UserRepository.new + user = repository.create(name: "L") + expect(user.created_at).to be_within(2).of(Time.now.utc) + expect(user.updated_at).to be_within(2).of(Time.now.utc) + + given_time = Time.new(2010, 1, 1, 12, 0, 0, "+00:00") + updated = repository.update( + user.id, + created_at: given_time, + updated_at: given_time + ) + + expect(updated.name).to eq("L") + expect(updated.created_at).to be_within(2).of(given_time) + expect(updated.updated_at).to be_within(2).of(given_time) + end + + # Bug: https://github.com/hanami/model/issues/412 + it "can have only creation timestamp" do + user = UserRepository.new.create(name: "L") + repository = AvatarRepository.new + account = repository.create(url: "http://foo.com", user_id: user.id) + expect(account.created_at).to be_within(2).of(Time.now.utc) + end + end + + # Bug: https://github.com/hanami/model/issues/237 + it "respects database defaults" do + repository = UserRepository.new + user = repository.create(name: "L") + + expect(user.comments_count).to eq(0) + end + + # Bug: https://github.com/hanami/model/issues/272 + it "accepts booleans as attributes" do + user = UserRepository.new.create(name: "L", active: false) + expect(user.active).to eq(false) + end + + it "raises error when generic database error is raised" + # it 'raises error when generic database error is raised' do + # expected_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 + + # expect { UserRepository.new.create(name: 'L', bogus: 23) }.to raise_error do |error| + # expect(error).to be_a(expected_error) + # expect(error.message).to include(message) + # end + # end + + it 'raises error when "not null" database constraint is violated' do + expected_error = Hanami::Model::NotNullConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { "SQLite3::ConstraintException" } + engine(:jruby).db(:sqlite) { "Java::OrgSqlite::SQLiteException: [SQLITE_CONSTRAINT_NOTNULL] A NOT NULL constraint failed (NOT NULL constraint failed: users.active)" } + + engine(:ruby).db(:postgresql) { 'PG::NotNullViolation: ERROR: null value in column "active" of relation "users" 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 + + expect { UserRepository.new.create(name: "L", active: nil) }.to raise_error do |error| + expect(error).to be_a(expected_error) + expect(error.message).to include(message) + end + end + + it 'raises error when "unique constraint" is violated' do + email = "user@#{SecureRandom.uuid}.test" + + expected_error = Hanami::Model::UniqueConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { "SQLite3::ConstraintException" } + engine(:jruby).db(:sqlite) { "Java::OrgSqlite::SQLiteException: [SQLITE_CONSTRAINT_UNIQUE] A UNIQUE constraint failed (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.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) + + expect { repository.create(name: "L", email: email) }.to raise_error do |error| + expect(error).to be_a(expected_error) + expect(error.message).to include(message) + end + end + + it 'raises error when "foreign key" constraint is violated' do + expected_error = Hanami::Model::ForeignKeyConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { "SQLite3::ConstraintException" } + engine(:jruby).db(:sqlite) { "Java::OrgSqlite::SQLiteException: [SQLITE_CONSTRAINT_FOREIGNKEY] A foreign key constraint failed (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 + + expect { AvatarRepository.new.create(user_id: 999_999_999, url: "url") }.to raise_error do |error| + expect(error).to be_a(expected_error) + expect(error.message).to include(message) + end + 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 + expected = Hanami::Model::CheckConstraintViolationError + + message = Platform.match do + engine(:ruby).db(:sqlite) { "SQLite3::ConstraintException: CHECK constraint failed" } + engine(:jruby).db(:sqlite) { "Java::OrgSqlite::SQLiteException: [SQLITE_CONSTRAINT_CHECK] A CHECK constraint failed (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 + + expect { UserRepository.new.create(name: "L", age: 1) }.to raise_error do |error| + expect(error).to be_a(expected) + expect(error.message).to include(message) + end + end + + it "raises error when constraint is violated" do + expected = Hanami::Model::CheckConstraintViolationError + + message = Platform.match do + engine(:ruby).db(:sqlite) { "SQLite3::ConstraintException: CHECK constraint failed" } + engine(:jruby).db(:sqlite) { "Java::OrgSqlite::SQLiteException: [SQLITE_CONSTRAINT_CHECK] A CHECK constraint failed (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 + + expect { UserRepository.new.create(name: "L", comments_count: -1) }.to raise_error do |error| + expect(error).to be_a(expected) + expect(error.message).to include(message) + end + 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") + + expect(updated).to be_an_instance_of(User) + expect(updated.id).to eq(user.id) + expect(updated.name).to eq("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 + expect(entity.id).to be_nil + + expect(updated).to be_an_instance_of(User) + expect(updated.id).to eq(user.id) + expect(updated.name).to eq("Luca") + end + + it "returns nil when record cannot be found" do + repository = UserRepository.new + updated = repository.update("9999999", name: "Luca") + + expect(updated).to 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") + + expect(updated.created_at).to be_within(2).of(user.created_at) + expect(updated.updated_at).to be_within(2).of(Time.now) + end + end + + it "raises error when generic database error is raised" + # it 'raises error when generic database error is raised' do + # expected_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') + + # expect { repository.update(user.id, bogus: 23) }.to raise_error do |error| + # expect(error).to be_a(expected_error) + # expect(error.message).to include(message) + # end + # 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 + expected_error = Hanami::Model::NotNullConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { "SQLite3::ConstraintException" } + engine(:jruby).db(:sqlite) { "Java::OrgSqlite::SQLiteException: [SQLITE_CONSTRAINT_NOTNULL] A NOT NULL constraint failed (NOT NULL constraint failed: users.active)" } + + engine(:ruby).db(:postgresql) { 'PG::NotNullViolation: ERROR: null value in column "active" of relation "users" 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") + + expect { repository.update(user.id, active: nil) }.to raise_error do |error| + expect(error).to be_a(expected_error) + expect(error.message).to include(message) + end + end + end + + it 'raises error when "unique constraint" is violated' do + email = "update@#{SecureRandom.uuid}.test" + + expected_error = Hanami::Model::UniqueConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { "SQLite3::ConstraintException" } + engine(:jruby).db(:sqlite) { "Java::OrgSqlite::SQLiteException: [SQLITE_CONSTRAINT_UNIQUE] A UNIQUE constraint failed (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.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) + + expect { repository.update(user.id, email: email) }.to raise_error do |error| + expect(error).to be_a(expected_error) + expect(error.message).to include(message) + end + end + + it 'raises error when "foreign key" constraint is violated' do + expected_error = Hanami::Model::ForeignKeyConstraintViolationError + message = Platform.match do + engine(:ruby).db(:sqlite) { "SQLite3::ConstraintException" } + engine(:jruby).db(:sqlite) { "Java::OrgSqlite::SQLiteException: [SQLITE_CONSTRAINT_FOREIGNKEY] A foreign key constraint failed (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, url: "a valid url") + + expect { repository.update(avatar.id, user_id: 999_999_999) }.to raise_error do |error| + expect(error).to be_a(expected_error) + expect(error.message).to include(message) + end + 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 + expected = Hanami::Model::CheckConstraintViolationError + + message = Platform.match do + engine(:ruby).db(:sqlite) { "SQLite3::ConstraintException: CHECK constraint failed" } + engine(:jruby).db(:sqlite) { "Java::OrgSqlite::SQLiteException: [SQLITE_CONSTRAINT_CHECK] A CHECK constraint failed (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") + + expect { repository.update(user.id, age: 17) }.to raise_error do |error| + expect(error).to be_a(expected) + expect(error.message).to include(message) + end + end + + it "raises error when constraint is violated" do + expected = Hanami::Model::CheckConstraintViolationError + + message = Platform.match do + engine(:ruby).db(:sqlite) { "SQLite3::ConstraintException: CHECK constraint failed" } + engine(:jruby).db(:sqlite) { "Java::OrgSqlite::SQLiteException: [SQLITE_CONSTRAINT_CHECK] A CHECK constraint failed (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") + + expect { repository.update(user.id, comments_count: -2) }.to raise_error do |error| + expect(error).to be_a(expected) + expect(error.message).to include(message) + end + end + end + end + + describe "#delete" do + it "deletes record" do + repository = UserRepository.new + user = repository.create(name: "L") + deleted = repository.delete(user.id) + + expect(deleted).to be_an_instance_of(User) + expect(deleted.id).to eq(user.id) + expect(deleted.name).to eq("L") + + found = repository.find(user.id) + expect(found).to be_nil + end + + it "returns nil when record cannot be found" do + repository = UserRepository.new + deleted = repository.delete("9999999") + + expect(deleted).to 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") + + expect(found.to_a).to include(user) + end + + it "uses root relation" do + repository = UserRepository.new + user = repository.create(name: "L") + found = repository.by_name_with_root("L") + + expect(found.to_a).to include(user) + end + + it "selects only a single column" do + repository = UserRepository.new + repository.clear + + repository.create([{name: "L", age: 35}, {name: "MG", age: 34}]) + found = repository.ids + + expect(found.size).to be(2) + found.each do |user| + expect(user).to be_a_kind_of(User) + expect(user.id).to_not be(nil) + expect(user.name).to be(nil) + expect(user.age).to be(nil) + end + end + + it "selects multiple columns" do + repository = UserRepository.new + repository.clear + + repository.create([{name: "L", age: 35}, {name: "MG", age: 34}]) + found = repository.select_id_and_name + + expect(found.size).to be(2) + found.each do |user| + expect(user).to be_a_kind_of(User) + expect(user.id).to_not be(nil) + expect(user.name).to_not be(nil) + expect(user.age).to be(nil) + end + 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) + + expect(file.languages).to eq(["ruby"]) + expect(file.metadata).to eq(coverage: 100.0) + + expect(found).to eq(file) + end + + it "returns nil for nil primary key (UUID)" do + repository = SourceFileRepository.new + + found = repository.find(nil) + expect(found).to 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') + # expect(found).to be_nil + # end + + describe "JSON types" do + it "writes hashes" do + hash = {first_name: "John", age: 53, married: true, car: nil} + repository = SourceFileRepository.new + column_type = repository.create(metadata: hash, name: "test", content: "test", json_info: hash) + found = repository.find(column_type.id) + + expect(found.metadata).to eq(hash) + expect(found.json_info).to eq(hash) + end + + it "writes arrays" do + array = ["abc", 1, true, nil] + repository = SourceFileRepository.new + column_type = repository.create(metadata: array, name: "test", content: "test", json_info: array) + found = repository.find(column_type.id) + + expect(found.metadata).to eq(array) + expect(found.json_info).to eq(array) + end + end + + describe "when timestamps aren't enabled" do + it "writes the proper PG types" do + repository = ProductRepository.new + + product = repository.create(name: "NeoVim", categories: ["software"]) + found = repository.find(product.id) + + expect(product.categories).to eq(["software"]) + + expect(found).to eq(product) + end + + it "succeeds even if timestamps is the only plugin" do + repository = ProductRepository.new + + product = repository + .command(:create, repository.root, use: %i[timestamps]) + .call(name: "NeoVim", categories: ["software"]) + + found = repository.find(product.id) + + expect(product.categories).to eq(["software"]) + + expect(found.to_h).to eq(product.to_h) + end + end + end + + describe "enum database type" do + it "allows to write data" do + repository = ColorRepository.new + color = repository.create(name: "red") + + expect(color).to be_a_kind_of(Color) + expect(color.name).to eq("red") + end + + it "raises error if the value is not included in the enum" do + repository = ColorRepository.new + message = Platform.match do + engine(:ruby) { %(PG::InvalidTextRepresentation: ERROR: invalid input value for enum rainbow: "grey") } + engine(:jruby) { %(Java::OrgPostgresqlUtil::PSQLException: ERROR: invalid input value for enum rainbow: "grey") } + end + + expect { repository.create(name: "grey") }.to raise_error do |error| + expect(error).to be_a(Hanami::Model::Error) + expect(error.message).to include(message) + end + end + end + end +end diff --git a/model-main/spec/integration/hanami/model/repository/command_spec.rb b/model-main/spec/integration/hanami/model/repository/command_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a59701070285b2a20f1435f917e5a9d637dd3619 --- /dev/null +++ b/model-main/spec/integration/hanami/model/repository/command_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe "Customized commands" do + subject(:authors) { AuthorRepository.new } + + let(:data) do + [{name: "Arthur C. Clarke"}, {name: "Phillip K. Dick"}] + end + + context "the mapper" do + it "is enabled by default" do + result = authors.create_many(data) + expect(result).to be_an Array + expect(result).to all(be_an(Author)) + end + + it "can be explictly turned off" do + result = authors.create_many(data, opts: {mapper: nil}) + expect(result).to all(be_an(ROM::Struct)) + end + end + + context "timestamps" do + it "are enabled by default" do + result = authors.create_many(data) + expect(result.first.created_at).to be_within(2).of(Time.now.utc) + end + end +end diff --git a/model-main/spec/integration/hanami/model/repository/legacy_spec.rb b/model-main/spec/integration/hanami/model/repository/legacy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bbbb815edd0a79572cf3e30f7e2b24f384ae4007 --- /dev/null +++ b/model-main/spec/integration/hanami/model/repository/legacy_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +RSpec.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) + + expect(operator).to eq(found) + end + + it "returns nil for missing record" do + repository = OperatorRepository.new + found = repository.find("9999999") + + expect(found).to be_nil + end + end + + describe "#all" do + it "returns all the records" do + repository = OperatorRepository.new + operator = repository.create(name: "F") + + expect(repository.all).to be_an_instance_of(Array) + expect(repository.all).to 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") + + expect(repository.first).to eq(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") + + expect(repository.last).to eq(operator) + end + end + + describe "#clear" do + it "clears all the records" do + repository = OperatorRepository.new + repository.create(name: "F") + + repository.clear + expect(repository.all).to 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") + + expect(operator).to be_an_instance_of(Operator) + expect(operator.id).to_not be_nil + expect(operator.name).to eq("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") + + expect(updated).to be_an_instance_of(Operator) + expect(updated.id).to eq(operator.id) + expect(updated.name).to eq("Flo") + end + + it "returns nil when record cannot be found" do + repository = OperatorRepository.new + updated = repository.update("9999999", name: "Flo") + + expect(updated).to be_nil + end + end + + describe "#delete" do + it "deletes record" do + repository = OperatorRepository.new + operator = repository.create(name: "F") + deleted = repository.delete(operator.id) + + expect(deleted).to be_an_instance_of(Operator) + expect(deleted.id).to eq(operator.id) + expect(deleted.name).to eq("F") + + found = repository.find(operator.id) + expect(found).to be_nil + end + + it "returns nil when record cannot be found" do + repository = OperatorRepository.new + deleted = repository.delete("9999999") + + expect(deleted).to be_nil + end + end + + describe "#transaction" do + end +end diff --git a/model-main/spec/spec_helper.rb b/model-main/spec/spec_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..b74a6c735bf26e77f1044c0b80ee54967e16f866 --- /dev/null +++ b/model-main/spec/spec_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift "lib" +require "hanami/devtools/unit" +require "hanami/model" + +require_relative "./support/rspec" +require_relative "./support/test_io" +require_relative "./support/platform" +require_relative "./support/database" +require_relative "./support/fixtures" diff --git a/model-main/spec/support/database.rb b/model-main/spec/support/database.rb new file mode 100644 index 0000000000000000000000000000000000000000..ebff228891eb4f1d596257b601ae40271f70210b --- /dev/null +++ b/model-main/spec/support/database.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Database + class Setup + DEFAULT_ADAPTER = "sqlite" + + 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 + + def self.engine?(name) + engine == name.to_sym + end +end + +Database::Setup.new.run diff --git a/model-main/spec/support/database/strategies/abstract.rb b/model-main/spec/support/database/strategies/abstract.rb new file mode 100644 index 0000000000000000000000000000000000000000..cfc48a9f646ddd89389829ce954bf564c88e5be8 --- /dev/null +++ b/model-main/spec/support/database/strategies/abstract.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +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 NoMethodError + end + + def export_env + ENV["HANAMI_DATABASE_NAME"] = database_name + end + + def create_database + raise NoMethodError + 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" + 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/model-main/spec/support/database/strategies/mysql.rb b/model-main/spec/support/database/strategies/mysql.rb new file mode 100644 index 0000000000000000000000000000000000000000..adee51cc74e94e795dc045dae3ccab08c912ed6a --- /dev/null +++ b/model-main/spec/support/database/strategies/mysql.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +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 TravisCiImplementation + protected + + def export_env + super + ENV["HANAMI_DATABASE_USERNAME"] = "travis" + ENV["HANAMI_DATABASE_URL"] = "mysql2://#{credentials}@#{host}/#{database_name}" + end + + def create_database + super + run_command "GRANT ALL PRIVILEGES ON *.* TO '#{ENV['HANAMI_DATABASE_USERNAME']}'@'#{host}'; FLUSH PRIVILEGES;" + run_command "GRANT ALL PRIVILEGES ON *.* TO '#{ENV['HANAMI_DATABASE_USERNAME']}'@'%'; FLUSH PRIVILEGES;" if jruby? + end + + private + + def run_command(command) + result = system %(mysql -u root -e "#{command}") + raise "Failed command:\n#{command}" unless result + end + end + + module CircleCiImplementation + protected + + def export_env + super + ENV["HANAMI_DATABASE_USERNAME"] ||= "root" + 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}" + end + + private + + def run_command(command) + result = system %(mysql -h #{host} -u #{ENV['HANAMI_DATABASE_USERNAME']} --password=#{ENV['HANAMI_DATABASE_PASSWORD']} -e "#{command}") + raise "Failed command:\n#{command}" unless result + end + end + + def self.eligible?(adapter) + adapter.start_with?("mysql") + end + + def initialize + ci_implementation = Platform.match do + ci(:travis) { TravisCiImplementation } + ci(:circle) { CircleCiImplementation } + default { Module.new } + end + + extend(ci_implementation) + 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/model-main/spec/support/database/strategies/postgresql.rb b/model-main/spec/support/database/strategies/postgresql.rb new file mode 100644 index 0000000000000000000000000000000000000000..95c9ef84adb1ebb265b95bf3f0d73e2c9181f6b4 --- /dev/null +++ b/model-main/spec/support/database/strategies/postgresql.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +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_and_credentials}/#{database_name}" + end + end + + module TravisCiImplementation + protected + + def export_env + super + ENV["HANAMI_DATABASE_USERNAME"] = "postgres" + end + end + + module CircleCiImplementation + protected + + def create_database + try("Failed to drop Postgres database: #{database_name}") do + system "dropdb --host=#{ENV['HANAMI_DATABASE_HOST']} --username=#{ENV['HANAMI_DATABASE_USERNAME']} --if-exists #{database_name}" + end + + try("Failed to create Postgres database: #{database_name}") do + system "createdb --host=#{ENV['HANAMI_DATABASE_HOST']} --username=#{ENV['HANAMI_DATABASE_USERNAME']} #{database_name}" + end + end + end + + module GithubActionsImplementation + protected + + def export_env + super + ENV["HANAMI_DATABASE_HOST"] = "localhost" + ENV["HANAMI_DATABASE_URL"] = "postgres://#{credentials}@#{host}/#{database_name}" + end + + def create_database + try("Failed to drop Postgres database: #{database_name}") do + system "PGPASSWORD=#{ENV['HANAMI_DATABASE_PASSWORD']} dropdb --host=#{ENV['HANAMI_DATABASE_HOST']} --username=#{ENV['HANAMI_DATABASE_USERNAME']} --if-exists #{database_name}" + end + + try("Failed to create Postgres database: #{database_name}") do + system "PGPASSWORD=#{ENV['HANAMI_DATABASE_PASSWORD']} createdb --host=#{ENV['HANAMI_DATABASE_HOST']} --username=#{ENV['HANAMI_DATABASE_USERNAME']} #{database_name}" + end + end + end + + def self.eligible?(adapter) + adapter.start_with?("postgres") + end + + def initialize + ci_implementation = Platform.match do + ci(:travis) { TravisCiImplementation } + ci(:circle) { CircleCiImplementation } + ci(:github) { GithubActionsImplementation } + default { Module.new } + end + + extend(ci_implementation) + extend(JrubyImplementation) if jruby? + end + + protected + + def load_dependencies + require "hanami/model/sql" + require "pg" + end + + def create_database + env = { + "PGHOST" => ENV["HANAMI_DATABASE_HOST"], + "PGUSER" => ENV["HANAMI_DATABASE_USERNAME"], + "PGPASSWORD" => ENV["HANAMI_DATABASE_PASSWORD"] + } + + try("Failed to drop Postgres database: #{database_name}") do + system env, "dropdb --if-exists #{database_name}" + end + + try("Failed to create Postgres database: #{database_name}") do + system env, "createdb #{database_name}" + end + end + + def export_env + super + ENV["HANAMI_DATABASE_TYPE"] = "postgresql" + ENV["HANAMI_DATABASE_USERNAME"] ||= `whoami`.strip.freeze + ENV["HANAMI_DATABASE_URL"] = "postgres://#{credentials}@#{host}/#{database_name}" + end + + private + + def try(message) + yield + rescue + warn message + end + end + end +end diff --git a/model-main/spec/support/database/strategies/sql.rb b/model-main/spec/support/database/strategies/sql.rb new file mode 100644 index 0000000000000000000000000000000000000000..f62a0596fe8852b7e247e8459b5775785751c097 --- /dev/null +++ b/model-main/spec/support/database/strategies/sql.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require_relative "abstract" +require "hanami/utils/blank" +require "pathname" +require "stringio" + +module Database + module Strategies + class Sql < Abstract + def self.eligible?(_adapter) + false + end + + protected + + def before + super + logger.unlink if logger.exist? + logger.dirname.mkpath + end + + def export_env + super + ENV["HANAMI_DATABASE_ADAPTER"] = "sql" + ENV["HANAMI_DATABASE_LOGGER"] = logger.to_s + end + + def configure + Hanami::Model.configure do + adapter ENV["HANAMI_DATABASE_ADAPTER"].to_sym, ENV["HANAMI_DATABASE_URL"] + logger ENV["HANAMI_DATABASE_LOGGER"], level: :debug + migrations Dir.pwd + "/spec/support/fixtures/database_migrations" + schema Dir.pwd + "/tmp/schema.sql" + + migrations_logger ENV["HANAMI_DATABASE_LOGGER"] + + gateway do |g| + g.connection.extension(:pg_enum) if Database.engine?(:postgresql) + end + 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 + TestIO.with_stdout do + require "hanami/model/migrator" + Hanami::Model::Migrator.migrate + end + 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 + + def host_and_credentials + result = [host] + result.unshift(credentials) unless Hanami::Utils::Blank.blank?(credentials) + result.join("@") + end + + def logger + Pathname.new("tmp").join("hanami_model.log") + end + end + end +end diff --git a/model-main/spec/support/database/strategies/sqlite.rb b/model-main/spec/support/database/strategies/sqlite.rb new file mode 100644 index 0000000000000000000000000000000000000000..958c3d00ef399c19618a2c9562b63313ff903fd8 --- /dev/null +++ b/model-main/spec/support/database/strategies/sqlite.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +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/model-main/spec/support/fixtures.rb b/model-main/spec/support/fixtures.rb new file mode 100644 index 0000000000000000000000000000000000000000..1600768fc2d3fdc9e812b4034af29b1fbd9e8562 --- /dev/null +++ b/model-main/spec/support/fixtures.rb @@ -0,0 +1,376 @@ +# frozen_string_literal: true + +require "ostruct" + +class BaseParams < OpenStruct + def to_hash + to_h + end +end + +class User < Hanami::Entity +end + +class Avatar < Hanami::Entity +end + +class Author < Hanami::Entity +end + +class Book < Hanami::Entity +end + +class Category < Hanami::Entity +end + +class BookOntology < Hanami::Entity +end + +class Operator < Hanami::Entity +end + +class AccessToken < Hanami::Entity +end + +class SourceFile < Hanami::Entity +end + +class Post < Hanami::Entity +end + +class Comment < Hanami::Entity +end + +class Warehouse < 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 :owner, Types::Entity(User) + 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 PageVisit < Hanami::Entity + attributes do + attribute :id, Types::Strict::Int + attribute :start, Types::DateTime + attribute :end, Types::DateTime + attribute :visitor, Types::Hash + attribute :page_info, Types::Hash.symbolized( + name: Types::Coercible::String, + scroll_depth: Types::Coercible::Float, + meta: Types::Hash + ) + end +end + +class Person < Hanami::Entity + attributes :strict do + attribute :id, Types::Strict::Int + attribute :name, Types::Strict::String + end +end + +class Product < Hanami::Entity +end + +class Color < Hanami::Entity +end + +class Label < Hanami::Entity +end + +class PostRepository < Hanami::Repository + associations do + belongs_to :user, as: :author + has_many :comments + has_many :users, through: :comments, as: :commenters + end + + def find_with_commenters(id) + aggregate(:commenters).where(id: id).map_to(Post).to_a + end + + def commenters_for(post) + assoc(:commenters, post).to_a + end + + def find_with_author(id) + aggregate(:author).where(id: id).map_to(Post).one + end + + def feed_for(id) + aggregate(:author, comments: :user).where(id: id).map_to(Post).one + end + + def author_for(post) + assoc(:author, post).one + end +end + +class CommentRepository < Hanami::Repository + associations do + belongs_to :post + belongs_to :user + end + + def commenter_for(comment) + assoc(:user, comment).one + end +end + +class AvatarRepository < Hanami::Repository + associations do + belongs_to :user + end + + def by_user(id) + avatars.where(user_id: id).to_a + end +end + +class UserRepository < Hanami::Repository + associations do + has_one :avatar + has_many :posts, as: :threads + has_many :comments + end + + def find_with_threads(id) + aggregate(:threads).where(id: id).map_to(User).one + end + + def threads_for(user) + assoc(:threads, user).to_a + end + + def find_with_avatar(id) + aggregate(:avatar).where(id: id).map_to(User).one + end + + def create_with_avatar(data) + assoc(:avatar).create(data) + end + + def remove_avatar(user) + assoc(:avatar, user).delete + end + + def add_avatar(user, data) + assoc(:avatar, user).add(data) + end + + def update_avatar(user, data) + assoc(:avatar, user).update(data) + end + + def replace_avatar(user, data) + assoc(:avatar, user).replace(data) + end + + def avatar_for(user) + assoc(:avatar, user).one + end + + def by_name(name) + users.where(name: name) + end + + def by_matching_name(name) + users.where(users[:name].ilike(name)).map_to(User).to_a + end + + def by_name_with_root(name) + root.where(name: name).as(:entity) + end + + def find_all_by_manual_query + users.read("select * from users").to_a + end + + def ids + users.select(:id).to_a + end + + def select_id_and_name + users.select(:id, :name).to_a + end +end + +class AvatarRepository < Hanami::Repository +end + +class AuthorRepository < Hanami::Repository + associations do + has_many :books + end + + def create_many(data, opts: {}) + command(create: :authors, result: :many, **opts).call(data) + end + + def create_with_books(data) + assoc(:books).create(data) + end + + def find_with_books(id) + aggregate(:books).by_pk(id).map_to(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 BookOntologyRepository < Hanami::Repository + associations do + belongs_to :books + belongs_to :categories + end +end + +class CategoryRepository < Hanami::Repository + associations do + has_many :books, through: :book_ontologies + end + + def books_for(category) + assoc(:books, category) + end + + def on_sales_books_count(category) + assoc(:books, category).where(on_sale: true).count + end + + def books_count(category) + assoc(:books, category).count + end + + def find_with_books(id) + aggregate(:books).where(id: id).map_to(Category).one + end + + def add_books(category, *books) + assoc(:books, category).add(*books) + end + + def remove_book(category, book_id) + assoc(:books, category).remove(book_id) + end +end + +class BookRepository < Hanami::Repository + associations do + belongs_to :author + has_many :categories, through: :book_ontologies + end + + def add_category(book, category) + assoc(:categories, book).add(category) + end + + def clear_categories(book) + assoc(:categories, book).delete + end + + def categories_for(book) + assoc(:categories, book).to_a + end + + def find_with_categories(id) + aggregate(:categories).where(id: id).map_to(Book).one + end + + def find_with_author(id) + aggregate(:author).where(id: id).map_to(Book).one + end + + def author_for(book) + assoc(:author, book).one + 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 AccessTokenRepository < Hanami::Repository + self.relation = "tokens" +end + +class SourceFileRepository < Hanami::Repository +end + +class WarehouseRepository < Hanami::Repository +end + +class ProductRepository < Hanami::Repository +end + +class ColorRepository < Hanami::Repository + schema do + attribute :id, Hanami::Model::Sql::Types::Int + attribute :name, Hanami::Model::Sql::Types::String + attribute :created_at, Hanami::Model::Sql::Types::DateTime + attribute :updated_at, Hanami::Model::Sql::Types::DateTime + end +end + +class LabelRepository < Hanami::Repository +end + +Hanami::Model.load! diff --git a/model-main/spec/support/fixtures/database_migrations/20150612081248_column_types.rb b/model-main/spec/support/fixtures/database_migrations/20150612081248_column_types.rb new file mode 100644 index 0000000000000000000000000000000000000000..bbce4f33e27797d95f0fddfe2020abf724cf119d --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20150612081248_column_types.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + 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/model-main/spec/support/fixtures/database_migrations/20150612084656_default_values.rb b/model-main/spec/support/fixtures/database_migrations/20150612084656_default_values.rb new file mode 100644 index 0000000000000000000000000000000000000000..df040851d516c7cf54e2c437404f28e434d3c60a --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20150612084656_default_values.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + 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 FIXME: see https://github.com/hanami/model/pull/474 + column :j, Time, default: Time.now + column :k, TrueClass, default: true + column :l, FalseClass, default: false + end + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20150612093458_null_constraints.rb b/model-main/spec/support/fixtures/database_migrations/20150612093458_null_constraints.rb new file mode 100644 index 0000000000000000000000000000000000000000..9e6a7cf79fe727006979648184d9af08ccfd8dc1 --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20150612093458_null_constraints.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + create_table :null_constraints do + column :a, Integer + column :b, Integer, null: false + column :c, Integer, null: true + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20150612093810_column_indexes.rb b/model-main/spec/support/fixtures/database_migrations/20150612093810_column_indexes.rb new file mode 100644 index 0000000000000000000000000000000000000000..781754ae1354f27f90e9a83b66840f6614f2e914 --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20150612093810_column_indexes.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + create_table :column_indexes do + column :a, Integer + column :b, Integer, index: false + column :c, Integer, index: true + column :d, Integer + + column :lat, Float + column :lng, Float + + index :d, unique: true + index %i[b c] + index %i[lat lng], name: :column_indexes_coords_index + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20150612094740_primary_keys.rb b/model-main/spec/support/fixtures/database_migrations/20150612094740_primary_keys.rb new file mode 100644 index 0000000000000000000000000000000000000000..32a4802488db6cfe9d7158812307b8e3ea8c6ce1 --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20150612094740_primary_keys.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + create_table :primary_keys_1 do + primary_key :id + end + + create_table :primary_keys_2 do + column :name, String, primary_key: true + end + + create_table :primary_keys_3 do + column :group_id, Integer + column :position, Integer + + primary_key %i[group_id position], name: :primary_keys_3_pk + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20150612115204_foreign_keys.rb b/model-main/spec/support/fixtures/database_migrations/20150612115204_foreign_keys.rb new file mode 100644 index 0000000000000000000000000000000000000000..4b011c2f438914fabf106faaa280daad8778a91a --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20150612115204_foreign_keys.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + create_table :artists do + primary_key :id + end + + create_table :albums do + primary_key :id + foreign_key :artist_id, :artists, on_delete: :cascade, null: false, type: :integer + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20150612122233_table_constraints.rb b/model-main/spec/support/fixtures/database_migrations/20150612122233_table_constraints.rb new file mode 100644 index 0000000000000000000000000000000000000000..1571ca556d33c5b248ff8e512348fba8e6951ead --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20150612122233_table_constraints.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + 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")) + 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/model-main/spec/support/fixtures/database_migrations/20150612124205_table_alterations.rb b/model-main/spec/support/fixtures/database_migrations/20150612124205_table_alterations.rb new file mode 100644 index 0000000000000000000000000000000000000000..c19cb9af61ef6e1e6c5eef188b570845487a349d --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20150612124205_table_alterations.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + 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 + + 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 + + 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 + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20160830094800_create_users.rb b/model-main/spec/support/fixtures/database_migrations/20160830094800_create_users.rb new file mode 100644 index 0000000000000000000000000000000000000000..9eaccb321e60c69608821fe69c87941c6cbc32b3 --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20160830094800_create_users.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +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/model-main/spec/support/fixtures/database_migrations/20160830094851_create_authors.rb b/model-main/spec/support/fixtures/database_migrations/20160830094851_create_authors.rb new file mode 100644 index 0000000000000000000000000000000000000000..7be34224c851ad87737cb4e2877e4f15cd9798f3 --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20160830094851_create_authors.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +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/model-main/spec/support/fixtures/database_migrations/20160830094941_create_books.rb b/model-main/spec/support/fixtures/database_migrations/20160830094941_create_books.rb new file mode 100644 index 0000000000000000000000000000000000000000..3ce90faa5a9c2ce9f6c0e3e577e4fabea8a0fde6 --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20160830094941_create_books.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +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/model-main/spec/support/fixtures/database_migrations/20160830095033_create_t_operator.rb b/model-main/spec/support/fixtures/database_migrations/20160830095033_create_t_operator.rb new file mode 100644 index 0000000000000000000000000000000000000000..c6ee29ff6ff0c20848ec312c2690fbe29e4b54dc --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20160830095033_create_t_operator.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +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/model-main/spec/support/fixtures/database_migrations/20160905125728_create_source_files.rb b/model-main/spec/support/fixtures/database_migrations/20160905125728_create_source_files.rb new file mode 100644 index 0000000000000000000000000000000000000000..93627441a9b314aedb6a9c2411028e4cb9152d94 --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20160905125728_create_source_files.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +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 :json_info, "json" + 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/model-main/spec/support/fixtures/database_migrations/20160909150704_create_avatars.rb b/model-main/spec/support/fixtures/database_migrations/20160909150704_create_avatars.rb new file mode 100644 index 0000000000000000000000000000000000000000..2e854266b483e485b7c96cf84b76f69bedca7036 --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20160909150704_create_avatars.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +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, unique: true + + column :url, String, null: false + column :created_at, DateTime + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20161104143844_create_warehouses.rb b/model-main/spec/support/fixtures/database_migrations/20161104143844_create_warehouses.rb new file mode 100644 index 0000000000000000000000000000000000000000..bcea269d69a2d6a86227361c1304f7510b5b87f9 --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20161104143844_create_warehouses.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + drop_table? :warehouses + create_table? :warehouses 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/model-main/spec/support/fixtures/database_migrations/20161114094644_create_products.rb b/model-main/spec/support/fixtures/database_migrations/20161114094644_create_products.rb new file mode 100644 index 0000000000000000000000000000000000000000..05e988d70e6490390d3582ca8cb42354dac0c7dd --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20161114094644_create_products.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + case Database.engine + when :postgresql + create_table :products do + primary_key :id + column :name, String + column :categories, "text[]" + end + else + create_table :products do + primary_key :id + end + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20170103142428_create_colors.rb b/model-main/spec/support/fixtures/database_migrations/20170103142428_create_colors.rb new file mode 100644 index 0000000000000000000000000000000000000000..2ecc04efc90da7edc8022caff0139508056d5f1b --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20170103142428_create_colors.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + case Database.engine + when :postgresql + extension :pg_enum + create_enum :rainbow, %w[red orange yellow green blue indigo violet] + + create_table :colors do + primary_key :id + + column :name, :rainbow, null: false + + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + end + else + create_table :colors do + primary_key :id + end + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20170124081339_create_labels.rb b/model-main/spec/support/fixtures/database_migrations/20170124081339_create_labels.rb new file mode 100644 index 0000000000000000000000000000000000000000..bb47388c39d29ba6c80de4d9fd25614df3995abd --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20170124081339_create_labels.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + create_table :labels do + column :id, Integer + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20170517115243_create_tokens.rb b/model-main/spec/support/fixtures/database_migrations/20170517115243_create_tokens.rb new file mode 100644 index 0000000000000000000000000000000000000000..aa0b92490952454432aefc7c0a9dc0ff4f8e04aa --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20170517115243_create_tokens.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + drop_table? :tokens + create_table? :tokens do + primary_key :id + column :token, String + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20170519172332_create_categories.rb b/model-main/spec/support/fixtures/database_migrations/20170519172332_create_categories.rb new file mode 100644 index 0000000000000000000000000000000000000000..69f4ab53d2409d782de3660a360685bdff283a9b --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20170519172332_create_categories.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + drop_table? :categories + create_table? :categories do + primary_key :id + column :name, String + end + drop_table? :book_ontologies + create_table? :book_ontologies do + primary_key :id + + foreign_key :book_id, :books, on_delete: :cascade, null: false + foreign_key :category_id, :categories, on_delete: :cascade, null: false + end + end +end diff --git a/model-main/spec/support/fixtures/database_migrations/20171002201227_create_posts_and_comments.rb b/model-main/spec/support/fixtures/database_migrations/20171002201227_create_posts_and_comments.rb new file mode 100644 index 0000000000000000000000000000000000000000..96864f2a310424985bccd8a4213b8f46c2e18957 --- /dev/null +++ b/model-main/spec/support/fixtures/database_migrations/20171002201227_create_posts_and_comments.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + change do + drop_table? :posts + create_table? :posts do + primary_key :id + column :title, String + foreign_key :user_id, :users, on_delete: :cascade, null: false + end + drop_table? :comments + create_table? :comments do + primary_key :id + + foreign_key :user_id, :users, on_delete: :cascade, null: false + foreign_key :post_id, :posts, on_delete: :cascade, null: false + end + end +end diff --git a/model-main/spec/support/fixtures/empty_migrations/.gitkeep b/model-main/spec/support/fixtures/empty_migrations/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/model-main/spec/support/fixtures/migrations/20160831073534_create_reviews.rb b/model-main/spec/support/fixtures/migrations/20160831073534_create_reviews.rb new file mode 100644 index 0000000000000000000000000000000000000000..164350f15cc6ad53cbb740f7a9ea87080051f283 --- /dev/null +++ b/model-main/spec/support/fixtures/migrations/20160831073534_create_reviews.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +Hanami::Model.migration do + up do + create_table :reviews do + primary_key :id + column :title, String, null: false + end + end + + down do + drop_table :reviews + end +end diff --git a/model-main/spec/support/fixtures/migrations/20160831090612_add_rating_to_reviews.rb b/model-main/spec/support/fixtures/migrations/20160831090612_add_rating_to_reviews.rb new file mode 100644 index 0000000000000000000000000000000000000000..e6029fc54ea582f0fe18b4f21208818fdd58b2e0 --- /dev/null +++ b/model-main/spec/support/fixtures/migrations/20160831090612_add_rating_to_reviews.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +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/model-main/spec/support/platform.rb b/model-main/spec/support/platform.rb new file mode 100644 index 0000000000000000000000000000000000000000..b0b29ffaea13065cab00c9432d798e1bbe89b8aa --- /dev/null +++ b/model-main/spec/support/platform.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Platform + require_relative "platform/os" + require_relative "platform/ci" + require_relative "platform/engine" + require_relative "platform/db" + require_relative "platform/matcher" + + def self.ci? + !Ci.current.nil? + 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/model-main/spec/support/platform/ci.rb b/model-main/spec/support/platform/ci.rb new file mode 100644 index 0000000000000000000000000000000000000000..d1c649a824661f45fbde0d05251aa4cd94cdb9ee --- /dev/null +++ b/model-main/spec/support/platform/ci.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Platform + module Ci + def self.ci?(name) + current == name + end + + def self.current + if travis? then :travis + elsif circle? then :circle + elsif drone? then :drone + elsif github? then :github + end + end + + class << self + private + + def travis? + ENV["TRAVIS"] == "true" + end + + def circle? + ENV["CIRCLECI"] == "true" + end + + def drone? + ENV["DRONE"] == "true" + end + + def github? + ENV["GITHUB_ACTIONS"] == "true" + end + end + end +end diff --git a/model-main/spec/support/platform/db.rb b/model-main/spec/support/platform/db.rb new file mode 100644 index 0000000000000000000000000000000000000000..0e6bc2006726b27010c9a4c542f269b0611cffa5 --- /dev/null +++ b/model-main/spec/support/platform/db.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Platform + module Db + def self.db?(name) + current == name + end + + def self.current + Database.engine + end + end +end diff --git a/model-main/spec/support/platform/engine.rb b/model-main/spec/support/platform/engine.rb new file mode 100644 index 0000000000000000000000000000000000000000..1afa86a8b1baed9e2a8db80b98d9ddcbde5a83f9 --- /dev/null +++ b/model-main/spec/support/platform/engine.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +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/model-main/spec/support/platform/matcher.rb b/model-main/spec/support/platform/matcher.rb new file mode 100644 index 0000000000000000000000000000000000000000..01e543385dfd77a838872c91b5ed0ba4f5a34212 --- /dev/null +++ b/model-main/spec/support/platform/matcher.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "hanami/utils/basic_object" + +module Platform + class Matcher + class Nope < Hanami::Utils::BasicObject + def or(other, &blk) + blk.nil? ? other : blk.call + end + + # rubocop:disable Style/MethodMissingSuper + # rubocop:disable Style/MissingRespondToMissing + def method_missing(*) + self.class.new + end + # rubocop:enable Style/MissingRespondToMissing + # rubocop:enable Style/MethodMissingSuper + end + + def self.match(&blk) + catch :match do + new.__send__(:match, &blk) + end + end + + def self.match?(os: Os.current, ci: Ci.current, engine: Engine.current, db: Db.current) + catch :match do + new.os(os).ci(ci).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 ci(name, &blk) + return nope unless ci?(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 ci?(name) + Ci.ci?(name) + end + + def engine?(name) + Engine.engine?(name) + end + + def db?(name) + Db.db?(name) + end + end +end diff --git a/model-main/spec/support/platform/os.rb b/model-main/spec/support/platform/os.rb new file mode 100644 index 0000000000000000000000000000000000000000..014aff62ada9f26333cf089c52b8262b7b558bff --- /dev/null +++ b/model-main/spec/support/platform/os.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +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/model-main/spec/support/rspec.rb b/model-main/spec/support/rspec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bcdd2b0d39e8e124a6cb34091e077293b67ae548 --- /dev/null +++ b/model-main/spec/support/rspec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + + config.filter_run_when_matching :focus + config.disable_monkey_patching! + + config.warnings = true + + config.default_formatter = "doc" if config.files_to_run.one? + + config.profile_examples = 10 + + config.order = :random + Kernel.srand config.seed +end diff --git a/model-main/spec/support/test_io.rb b/model-main/spec/support/test_io.rb new file mode 100644 index 0000000000000000000000000000000000000000..a711f7d15433212ba6223ea6e848c73a3edfd76f --- /dev/null +++ b/model-main/spec/support/test_io.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module TestIO + def self.with_stdout + stdout = $stdout + $stdout = stream + yield + ensure + $stdout.close + $stdout = stdout + end + + def self.stream + File.new(ENV["HANAMI_DATABASE_LOGGER"], "a+") + end +end diff --git a/model-main/spec/unit/hanami/entity/automatic_schema_spec.rb b/model-main/spec/unit/hanami/entity/automatic_schema_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4116a0e865b1e0566e37390b1d2f08faa008e0ca --- /dev/null +++ b/model-main/spec/unit/hanami/entity/automatic_schema_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +RSpec.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 + + expect(entity).to be_a_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) + + expect(entity.id).to eq(1) + expect(entity.name).to eq("Luca") + expect(entity.books).to eq(books) + expect(entity.created_at).to be_within(2).of(now) + end + + it "accepts object that implements #to_hash" do + entity = described_class.new(input) + + expect(entity.id).to eq(1) + end + + it "freezes the instance" do + entity = described_class.new + + expect(entity).to be_frozen + end + + it "coerces values" do + now = Time.now + entity = described_class.new(created_at: now.to_s) + + expect(entity.created_at).to be_within(2).of(now) + 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] + + expect(b).to be_a_kind_of(Book) + expect(b.title).to eq(book.fetch(:title)) + end + end + + it "raises error if initialized with wrong array object" do + object = Object.new + expect { described_class.new(books: [object]) }.to raise_error do |error| + expect(error).to be_a(TypeError) + expect(error.message).to include("[#] (Array) has invalid type for :books") + end + end + end + + describe "#id" do + it "returns the value" do + entity = described_class.new(id: 1) + + expect(entity.id).to eq(1) + end + + it "returns nil if not present in attributes" do + entity = described_class.new + + expect(entity.id).to be_nil + end + end + + describe "accessors" do + it "exposes accessors from schema" do + entity = described_class.new(name: "Luca") + + expect(entity.name).to eq("Luca") + end + + it "raises error for unknown methods" do + entity = described_class.new + + expect { entity.foo } + .to raise_error(NoMethodError, /undefined method `foo'/) + end + + it "raises error when #attributes is invoked" do + entity = described_class.new + + expect { entity.attributes } + .to raise_error(NoMethodError, /private method `attributes' called for # "w3m/0.5.3", "language" => {"en" => 0.9} + }, + page_info: { + "name" => "landing page", + scroll_depth: 0.7, + "meta" => {"version" => "0.8.3", updated_at: 1_492_769_467_000} + } + ) + + expect(entity.visitor).to eq( + user_agent: "w3m/0.5.3", language: {en: 0.9} + ) + expect(entity.page_info).to eq( + name: "landing page", + scroll_depth: 0.7, + meta: {version: "0.8.3", updated_at: 1_492_769_467_000} + ) + end + end + + describe "#id" do + it "returns the value" do + entity = described_class.new(id: 1) + + expect(entity.id).to eq(1) + end + + it "returns nil if not present in attributes" do + entity = described_class.new + + expect(entity.id).to be_nil + end + end + + describe "accessors" do + it "exposes accessors from schema" do + entity = described_class.new(name: "Acme Inc.") + + expect(entity.name).to eq("Acme Inc.") + end + + it "raises error for unknown methods" do + entity = described_class.new + + expect { entity.foo } + .to raise_error(NoMethodError, /undefined method `foo'/) + end + + it "raises error when #attributes is invoked" do + entity = described_class.new + + expect { entity.attributes } + .to raise_error(NoMethodError, /private method `attributes' called for # "bar") + + expect(result).to eq(foo: "bar") + end + end + + describe "#attribute?" do + it "always returns true" do + expect(subject.attribute?(:foo)).to eq 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") + + expect(result).to eq(id: 1) + end + + it "ignores unknown attributes" do + result = subject.call(foo: "bar") + + expect(result).to eq({}) + end + end + + describe "#attribute?" do + it "returns true for known attributes" do + expect(subject.attribute?(:id)).to eq true + end + + it "returns false for unknown attributes" do + expect(subject.attribute?(:foo)).to eq false + end + end + end +end diff --git a/model-main/spec/unit/hanami/entity/schemaless_spec.rb b/model-main/spec/unit/hanami/entity/schemaless_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fa8f93e925d798fc053c684b1faca1f4d65efaf6 --- /dev/null +++ b/model-main/spec/unit/hanami/entity/schemaless_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +RSpec.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 + + expect(entity).to be_a_kind_of(described_class) + end + + it "accepts a hash" do + entity = described_class.new(foo: 1, "bar" => 2) + + expect(entity.foo).to eq(1) + expect(entity.bar).to eq(2) + end + + it "accepts object that implements #to_hash" do + entity = described_class.new(input) + + expect(entity.a).to eq(1) + end + + it "freezes the instance" do + entity = described_class.new + + expect(entity).to be_frozen + end + end + + describe "#id" do + it "returns the value" do + entity = described_class.new(id: 1) + + expect(entity.id).to eq(1) + end + + it "returns nil if not present in attributes" do + entity = described_class.new + + expect(entity.id).to be_nil + end + end + + describe "accessors" do + it "exposes accessors for given keys" do + entity = described_class.new(name: "Luca") + + expect(entity.name).to eq("Luca") + end + + it "returns nil for unknown methods" do + entity = described_class.new + + expect(entity.foo).to be_nil + end + + it "returns nil for #attributes" do + entity = described_class.new + + expect(entity.attributes).to be_nil + end + end + + describe "#to_h" do + it "serializes attributes into hash" do + entity = described_class.new(foo: 1, "bar" => {"baz" => 2}) + + expect(entity.to_h).to eq(Hash[foo: 1, bar: {baz: 2}]) + end + + it "must be an instance of ::Hash" do + entity = described_class.new + + expect(entity.to_h).to be_an_instance_of(::Hash) + end + + it "prevents information escape" do + entity = described_class.new(a: [1, 2, 3]) + + entity.to_h[:a].reverse! + expect(entity.a).to eq([1, 2, 3]) + end + + it "is aliased as #to_hash" do + entity = described_class.new(foo: "bar") + + expect(entity.to_h).to eq(entity.to_hash) + end + end + + describe "#respond_to?" do + it "returns ture for id" do + entity = described_class.new + + expect(entity).to respond_to(:id) + end + + it "returns true for present keys" do + entity = described_class.new(foo: 1, "bar" => 2) + + expect(entity).to respond_to(:foo) + expect(entity).to respond_to(:bar) + end + + it "returns false for missing keys" do + entity = described_class.new + + expect(entity).to respond_to(:baz) + end + end + end +end diff --git a/model-main/spec/unit/hanami/entity_spec.rb b/model-main/spec/unit/hanami/entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8b448b9f64eba7b59f800f288778f507d4f4df0c --- /dev/null +++ b/model-main/spec/unit/hanami/entity_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "ostruct" + +RSpec.describe Hanami::Entity do + let(:described_class) do + Class.new(Hanami::Entity) + end + + 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) + + expect(entity1).to eq(entity2), "Expected #{entity1.inspect} to equal #{entity2.inspect}" + end + + it "returns false if same class but different id" do + entity1 = described_class.new(id: 1) + entity2 = described_class.new(id: 1000) + + expect(entity1).to_not eq(entity2), "Expected #{entity1.inspect} to NOT equal #{entity2.inspect}" + end + + it "returns false if different class but same id" do + entity1 = described_class.new(id: 1) + entity2 = OpenStruct.new(id: 1) + + expect(entity1).to_not eq(entity2), "Expected #{entity1.inspect} to NOT equal #{entity2.inspect}" + end + + it "returns false if different class and different id" do + entity1 = described_class.new(id: 1) + entity2 = OpenStruct.new(id: 1000) + + expect(entity1).to_not eq(entity2), "Expected #{entity1.inspect} to NOT equal #{entity2.inspect}" + end + + it "returns true when both the ids are nil" do + entity1 = described_class.new + entity2 = described_class.new + + expect(entity1).to eq(entity2), "Expected #{entity1.inspect} to equal #{entity2.inspect}" + end + end + + describe "#hash" do + it "returns predictable object hashing" do + entity1 = described_class.new(id: 1) + entity2 = described_class.new(id: 1) + + expect(entity1.hash).to eq(entity2.hash), "Expected #{entity1.hash} to equal #{entity2.hash}" + 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) + + expect(entity1.hash).to_not eq(entity2.hash), "Expected #{entity1.hash} to NOT equal #{entity2.hash}" + end + + 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) + + expect(entity1.hash).to_not eq(entity2.hash), "Expected #{entity1.hash} to NOT equal #{entity2.hash}" + 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) + + expect(entity1.hash).to_not eq(entity2.hash), "Expected #{entity1.hash} to NOT equal #{entity2.hash}" + end + end +end diff --git a/model-main/spec/unit/hanami/model/check_constraint_validation_error_spec.rb b/model-main/spec/unit/hanami/model/check_constraint_validation_error_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5593e9c58823ae2d32872a7cf4a01407746b5004 --- /dev/null +++ b/model-main/spec/unit/hanami/model/check_constraint_validation_error_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::CheckConstraintViolationError do + it "inherits from Hanami::Model::ConstraintViolationError" do + expect(described_class.ancestors).to include(Hanami::Model::ConstraintViolationError) + end + + it "has a default error message" do + expect { raise described_class }.to raise_error(described_class, "Check constraint has been violated") + end + + it "allows custom error message" do + expect { raise described_class.new("Ouch") }.to raise_error(described_class, "Ouch") + end +end diff --git a/model-main/spec/unit/hanami/model/configuration_spec.rb b/model-main/spec/unit/hanami/model/configuration_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e1b2f7fcf9d9a1521cbff85e74866c042bcc192f --- /dev/null +++ b/model-main/spec/unit/hanami/model/configuration_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +RSpec.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 + expect(subject.url).to eq(url) + end + end + + describe "#connection" do + it "returns a raw connection aganist the database" do + connection = subject.connection + + expect(connection).to be_a_kind_of(Sequel::Database) + expect(connection.url).to eq(url) + end + + context "with blank url" do + let(:url) { nil } + + it "raises error" do + expect { subject.connection }.to raise_error(Hanami::Model::UnknownDatabaseAdapterError, "Unknown database adapter for URL: #{url.inspect}. Please check your database configuration (hint: ENV['DATABASE_URL']).") + end + end + end + + describe "#gateway" do + it "returns default ROM gateway" do + gateway = subject.gateway + + expect(gateway).to be_a_kind_of(ROM::Gateway) + expect(gateway.connection).to eq(subject.connection) + end + + context "with blank url" do + let(:url) { nil } + + it "raises error" do + expect { subject.connection }.to raise_error(Hanami::Model::UnknownDatabaseAdapterError, "Unknown database adapter for URL: #{url.inspect}. Please check your database configuration (hint: ENV['DATABASE_URL']).") + end + end + end + + describe "#root" do + it "returns current directory" do + expect(subject.root).to eq(Pathname.pwd) + end + end + + describe "#migrations" do + it "returns path to migrations" do + expected = subject.root.join("tmp", "db", "migrations") + + expect(subject.migrations).to eq(expected) + end + end + + describe "#schema" do + it "returns path to database schema" do + expected = subject.root.join("tmp", "db", "schema.sql") + + expect(subject.schema).to eq(expected) + end + end +end diff --git a/model-main/spec/unit/hanami/model/constraint_violation_error_spec.rb b/model-main/spec/unit/hanami/model/constraint_violation_error_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a3d4f46f1ee3906ba5c148a0c3ad2df2d5052142 --- /dev/null +++ b/model-main/spec/unit/hanami/model/constraint_violation_error_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::ConstraintViolationError do + it "inherits from Hanami::Model::Error" do + expect(described_class.ancestors).to include(Hanami::Model::Error) + end + + it "has a default error message" do + expect { raise described_class }.to raise_error(described_class, "Constraint has been violated") + end + + it "allows custom error message" do + expect { raise described_class.new("Ouch") }.to raise_error(described_class, "Ouch") + end +end diff --git a/model-main/spec/unit/hanami/model/disconnect_spec.rb b/model-main/spec/unit/hanami/model/disconnect_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..81b4f0fd48413f1449b7f7f7ca1e6effb3f1359e --- /dev/null +++ b/model-main/spec/unit/hanami/model/disconnect_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# This test is tightly coupled to Sequel +# +# We should improve connection management via ROM +RSpec.describe "Hanami::Model.disconnect" do + before do + # warm up + connection[:users].to_a + end + + let(:connection) { Hanami::Model.configuration.connection } + + it "disconnects from database" do + # Sequel returns a collection of SQLite3::Database instances that were + # active and has been disconnected from the database + connections = Hanami::Model.disconnect + expect(connections.size).to eq(1) + + # If we don't hit the database, the next disconnection returns an empty set + # of SQLite3::Database + connections = Hanami::Model.disconnect + expect(connections.size).to eq(0) + + # If we try to use the database again, it's able to transparently reconnect + expect(connection[:users].to_a).to be_a_kind_of(Array) + + # Now that we hit the database again, on this time the collection of + # disconnected SQLite3::Database instances has size of 1 + connections = Hanami::Model.disconnect + expect(connections.size).to eq(1) + end + + it "doesn't disconnect from the database when not connected yet" do + expect(connection).to receive(:disconnect) + + Hanami::Model.disconnect + end +end diff --git a/model-main/spec/unit/hanami/model/error_spec.rb b/model-main/spec/unit/hanami/model/error_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8bb5b813027ec3693a1856ae7eec913f0935843a --- /dev/null +++ b/model-main/spec/unit/hanami/model/error_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::Error do + it "inherits from StandardError" do + expect(described_class.ancestors).to include(StandardError) + end +end diff --git a/model-main/spec/unit/hanami/model/foreign_key_constraint_violation_error_spec.rb b/model-main/spec/unit/hanami/model/foreign_key_constraint_violation_error_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4d76215cea6993caaae56d9d7792ed1eaadd05b9 --- /dev/null +++ b/model-main/spec/unit/hanami/model/foreign_key_constraint_violation_error_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::ForeignKeyConstraintViolationError do + it "inherits from Hanami::Model::ConstraintViolationError" do + expect(described_class.ancestors).to include(Hanami::Model::ConstraintViolationError) + end + + it "has a default error message" do + expect { raise described_class }.to raise_error(described_class, "Foreign key constraint has been violated") + end + + it "allows custom error message" do + expect { raise described_class.new("Ouch") }.to raise_error(described_class, "Ouch") + end +end diff --git a/model-main/spec/unit/hanami/model/load_spec.rb b/model-main/spec/unit/hanami/model/load_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e5d5f686dcd84614766613bf3d8af042e2ba83fd --- /dev/null +++ b/model-main/spec/unit/hanami/model/load_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.describe "Hanami::Model.load!" do + let(:message) { "Cannot find corresponding type for form" } + + before do + allow(ROM).to receive(:container) { raise ROM::SQL::UnknownDBTypeError, message } + end + + it "raises unknown database error when repository automapping spots an unknown type" do + expect { Hanami::Model.load! }.to raise_error(Hanami::Model::UnknownDatabaseTypeError, message) + end +end diff --git a/model-main/spec/unit/hanami/model/mapped_relation_spec.rb b/model-main/spec/unit/hanami/model/mapped_relation_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..858dba7d1eb2af5aa8a5c2332b5e9000799e7f67 --- /dev/null +++ b/model-main/spec/unit/hanami/model/mapped_relation_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::MappedRelation do + subject { described_class.new(relation) } + let(:relation) { UserRepository.new.users } + + describe "#[]" do + it "returns attribute" do + expect(subject[:name]).to be_a_kind_of(ROM::SQL::Attribute) + end + + it "raises error in case of unknown attribute" do + expect { subject[:foo] }.to raise_error(Hanami::Model::UnknownAttributeError, ":foo attribute doesn't exist in users schema") + end + end +end diff --git a/model-main/spec/unit/hanami/model/migrator/adapter_spec.rb b/model-main/spec/unit/hanami/model/migrator/adapter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..13de55ca7f831aaa61b69397c720cc1c6a1c5f27 --- /dev/null +++ b/model-main/spec/unit/hanami/model/migrator/adapter_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::Migrator::Adapter do + extend PlatformHelpers + + subject { described_class.new(connection) } + + let(:connection) { instance_double("Hanami::Model::Migrator::Connection", database_type: database_type) } + let(:database_type) { "unknown" } + + describe ".for" do + before do + expect(configuration).to receive(:url).at_least(:once).and_return(url) + end + + let(:configuration) { instance_double("Hanami::Model::Configuration") } + let(:url) { ENV["HANAMI_DATABASE_URL"] } + + with_platform(db: :sqlite) do + context "when sqlite" do + it "returns sqlite adapter" do + expect(described_class.for(configuration)).to be_kind_of(Hanami::Model::Migrator::SQLiteAdapter) + end + end + end + + with_platform(db: :postgresql) do + context "when postgresql" do + it "returns postgresql adapter" do + expect(described_class.for(configuration)).to be_kind_of(Hanami::Model::Migrator::PostgresAdapter) + end + end + end + + with_platform(db: :mysql) do + context "when mysql" do + it "returns mysql adapter" do + expect(described_class.for(configuration)).to be_kind_of(Hanami::Model::Migrator::MySQLAdapter) + end + end + end + + context "when unknown" do + let(:url) { "unknown" } + + it "returns generic adapter" do + expect(described_class.for(configuration)).to be_kind_of(described_class) + end + end + end + + describe "#create" do + it "raises migration error" do + expect { subject.create }.to raise_error(Hanami::Model::MigrationError, "Current adapter (#{database_type}) doesn't support create.") + end + end + + describe "#drop" do + it "raises migration error" do + expect { subject.drop }.to raise_error(Hanami::Model::MigrationError, "Current adapter (#{database_type}) doesn't support drop.") + end + end + + describe "#load" do + it "raises migration error" do + expect { subject.load }.to raise_error(Hanami::Model::MigrationError, "Current adapter (#{database_type}) doesn't support load.") + end + end + + describe "migrate" do + it "raises migration error in case of error" do + expect(connection).to receive(:raw) + expect(Sequel::Migrator).to receive(:run).and_raise(Sequel::Migrator::Error.new("ouch")) + + expect { subject.migrate([], "-1") }.to raise_error(Hanami::Model::MigrationError, "ouch") + end + end +end diff --git a/model-main/spec/unit/hanami/model/migrator/connection_spec.rb b/model-main/spec/unit/hanami/model/migrator/connection_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7fe76f7b889244e9e5f50405892552b803fa37a6 --- /dev/null +++ b/model-main/spec/unit/hanami/model/migrator/connection_spec.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::Migrator::Connection do + extend PlatformHelpers + + let(:connection) { Hanami::Model::Migrator::Connection.new(hanami_model_configuration) } + + describe "when not a jdbc connection" do + let(:hanami_model_configuration) { OpenStruct.new(url: url) } + let(:url) { "postgresql://postgres:s3cr3T@127.0.0.1:5432/database" } + + describe "#jdbc?" do + it "returns false" do + expect(connection.jdbc?).to eq(false) + end + end + + describe "#global_uri" do + it "returns connection URI without database" do + expect(connection.global_uri.scan("database").empty?).to eq(true) + end + end + + describe "#parsed_uri?" do + it "returns an URI instance" do + expect(connection.parsed_uri).to be_a_kind_of(URI) + end + end + + describe "#host" do + describe "when the host is only specified in the URI" do + let(:url) { "postgresql://127.0.0.1/database" } + + it "returns configured host" do + expect(connection.host).to eq("127.0.0.1") + end + end + + describe "when the host is only specified in the query" do + let(:url) { "postgresql:///database?host=0.0.0.0" } + + it "returns the host specified in the query param" do + expect(connection.host).to eql("0.0.0.0") + end + end + + describe "when the host is specified as a socket" do + let(:url) { "postgresql:///database?host=/path/to/my/sock" } + + it "returns the path to the socket specified in the query param" do + expect(connection.host).to eql("/path/to/my/sock") + end + end + + describe "when the host is specified in both the URI and query" do + let(:url) { "postgresql://127.0.0.1/database?host=0.0.0.0" } + + it "prefers the host from the URI" do + expect(connection.host).to eql("127.0.0.1") + end + end + end + + describe "#port" do + it "returns configured port" do + expect(connection.port).to eq(5432) + end + end + + describe "#database" do + it "returns configured database" do + expect(connection.database).to eq("database") + end + end + + describe "#user" do + it "returns configured user" do + expect(connection.user).to eq("postgres") + end + + describe "when there is no user option" do + let(:hanami_model_configuration) do + OpenStruct.new(url: "postgresql://127.0.0.1:5432/database") + end + + it "returns nil" do + expect(connection.user).to be_nil + end + end + end + + describe "#password" do + it "returns configured password" do + expect(connection.password).to eq("s3cr3T") + end + + describe "when there is no password option" do + let(:hanami_model_configuration) do + OpenStruct.new(url: "postgresql://127.0.0.1/database") + end + + it "returns nil" do + expect(connection.password).to be_nil + end + end + end + + describe "#raw" do + let(:url) { ENV["HANAMI_DATABASE_URL"] } + + with_platform(db: :sqlite) do + context "when sqlite" do + it "returns raw sequel connection" do + expected = Platform.match do + engine(:ruby) { Sequel::SQLite::Database } + engine(:jruby) { Sequel::JDBC::Database } + end + + expect(connection.raw).to be_kind_of(expected) + end + end + end + + with_platform(db: :postgresql) do + context "when postgres" do + it "returns raw sequel connection" do + expected = Platform.match do + engine(:ruby) { Sequel::Postgres::Database } + engine(:jruby) { Sequel::JDBC::Database } + end + + expect(connection.raw).to be_kind_of(expected) + end + end + end + + with_platform(db: :mysql) do + context "when mysql" do + it "returns raw sequel connection" do + expected = Platform.match do + engine(:ruby) { Sequel::Mysql2::Database } + engine(:jruby) { Sequel::JDBC::Database } + end + + expect(connection.raw).to be_kind_of(expected) + end + end + end + end + + # See https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING + describe "when connection components in uri params" do + let(:hanami_model_configuration) do + OpenStruct.new( + url: "postgresql:///mydb?host=localhost&port=6433&user=postgres&password=testpasswd" + ) + end + + it "returns configured database" do + expect(connection.database).to eq("mydb") + end + + it "returns configured user" do + expect(connection.user).to eq("postgres") + end + + it "returns configured password" do + expect(connection.password).to eq("testpasswd") + end + + it "returns configured host" do + expect(connection.host).to eq("localhost") + end + + it "returns configured port" do + expect(connection.port).to eq(6433) + end + + describe "with blank port" do + let(:hanami_model_configuration) do + OpenStruct.new( + url: "postgresql:///mydb?host=localhost&port=&user=postgres&password=testpasswd" + ) + end + + it "raises an error" do + expect(connection.port).to be_nil + end + end + end + end + + describe "when jdbc connection" do + let(:hanami_model_configuration) do + OpenStruct.new( + url: "jdbc:postgresql://127.0.0.1:5432/database?user=postgres&password=s3cr3T" + ) + end + + describe "#jdbc?" do + it "returns true" do + expect(connection.jdbc?).to eq(true) + end + end + + describe "#host" do + it "returns configured host" do + expect(connection.host).to eq("127.0.0.1") + end + end + + describe "#port" do + it "returns configured port" do + expect(connection.port).to eq(5432) + end + end + + describe "#user" do + it "returns configured user" do + expect(connection.user).to eq("postgres") + end + + describe "when there is no user option" do + let(:hanami_model_configuration) do + OpenStruct.new(url: "jdbc:postgresql://127.0.0.1/database") + end + + it "returns nil" do + expect(connection.user).to be_nil + end + end + end + + describe "#password" do + it "returns configured password" do + expect(connection.password).to eq("s3cr3T") + end + + describe "when there is no password option" do + let(:hanami_model_configuration) do + OpenStruct.new(url: "jdbc:postgresql://127.0.0.1/database") + end + + it "returns nil" do + expect(connection.password).to be_nil + end + end + end + + describe "#database" do + it "returns configured database" do + expect(connection.database).to eq("database") + end + end + end +end diff --git a/model-main/spec/unit/hanami/model/migrator/mysql.rb b/model-main/spec/unit/hanami/model/migrator/mysql.rb new file mode 100644 index 0000000000000000000000000000000000000000..065a753e275ddbe049fbf7dd4fe90c8f9284b7c4 --- /dev/null +++ b/model-main/spec/unit/hanami/model/migrator/mysql.rb @@ -0,0 +1,466 @@ +# frozen_string_literal: true + +require "ostruct" +require "securerandom" + +RSpec.shared_examples "migrator_mysql" do + let(:migrator) do + Hanami::Model::Migrator.new(configuration: configuration) + end + + let(:random) { SecureRandom.hex(4) } + + # General variables + let(:migrations) { Pathname.new(__dir__ + "/../../../../support/fixtures/migrations") } + let(:schema) { nil } + let(:config) { OpenStruct.new(backend: :sql, url: url, _migrations: migrations, _schema: schema, migrations_logger: Hanami::Model::Migrator::Logger.new(ENV["HANAMI_DATABASE_LOGGER"])) } + 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__}/../../../../support/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) { "mysql_#{random}" } + + let(:url) do + db = database + credentials = [ + ENV["HANAMI_DATABASE_USERNAME"], + ENV["HANAMI_DATABASE_PASSWORD"] + ].compact.join(":") + + Platform.match do + engine(:ruby) { "mysql2://#{credentials}@#{ENV['HANAMI_DATABASE_HOST']}/#{db}?user=#{ENV['HANAMI_DATABASE_USERNAME']}" } + engine(:jruby) { "jdbc:mysql://localhost/#{db}?user=#{ENV['HANAMI_DATABASE_USERNAME']}&useSSL=false" } + end + end + + describe "create" do + it "creates the database" do + migrator.create + + connection = Sequel.connect(url) + expect(connection.tables).to be_empty + end + + it "raises error when can't connect to database" do + expect(Sequel).to receive(:connect).at_least(:once).and_raise(Sequel::DatabaseError.new("ouch")) + + expect { migrator.create }.to raise_error do |error| + expect(error).to be_a(Hanami::Model::MigrationError) + expect(error.message).to eq("ouch") + end + end + + it "raises error if database is busy" do + migrator.create + Sequel.connect(url).tables + + expect { migrator.create }.to raise_error do |error| + expect(error).to be_a(Hanami::Model::MigrationError) + expect(error.message).to include("Database creation failed. If the database exists,") + expect(error.message).to include("then its console may be open. See this issue for more details:") + expect(error.message).to include("https://github.com/hanami/model/issues/250") + end + end + + # See https://github.com/hanami/model/issues/381 + describe "when database name contains a dash" do + let(:database) { "db-name-create_#{random}" } + + it "creates the database" do + migrator.create + + connection = Sequel.connect(url) + expect(connection.tables).to be_empty + end + end + end + + describe "drop" do + before do + migrator.create + end + + it "drops the database" do + migrator.drop + expect { Sequel.connect(url).tables }.to raise_error(Sequel::DatabaseConnectionError) + end + + it "raises error if database doesn't exist" do + migrator.drop # remove the first time + + expect { migrator.drop } + .to raise_error(Hanami::Model::MigrationError, "Cannot find database: #{database}") + end + + it "raises error when can't connect to database" do + expect(Sequel).to receive(:connect).at_least(:once).and_raise(Sequel::DatabaseError.new("ouch")) + + expect { migrator.drop }.to raise_error do |error| + expect(error).to be_a(Hanami::Model::MigrationError) + expect(error.message).to eq("ouch") + end + end + + # See https://github.com/hanami/model/issues/381 + describe "when database name contains a dash" do + let(:database) { "db-name-drop_#{random}" } + + it "drops the database" do + migrator.drop + + expect { Sequel.connect(url).tables }.to raise_error(Sequel::DatabaseConnectionError) + end + end + end + + describe "migrate" do + before do + migrator.create + end + + describe "when no migrations" do + let(:migrations) { Pathname.new(__dir__ + "/../../../../support/fixtures/empty_migrations") } + + it "it doesn't alter database" do + migrator.migrate + + connection = Sequel.connect(url) + expect(connection.tables).to be_empty + end + end + + describe "when migrations are present" do + it "migrates the database" do + migrator.migrate + + connection = Sequel.connect(url) + expect(connection.tables).to_not be_empty + + table = connection.schema(:reviews) + + name, options = table[0] # id + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + + name, options = table[1] # title + expect(name).to eq(:title) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] # rating (second migration) + expect(name).to eq(:rating) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("0") + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(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) + expect(connection.tables).to_not be_empty + expect(connection.tables).to eq(%i[reviews schema_migrations]) + end + end + + describe "migrate down" do + before do + migrator.migrate + end + + it "migrates the database" do + migrator.migrate(version: "20160831073534") # see spec/support/fixtures/migrations + + connection = Sequel.connect(url) + expect(connection.tables).to_not be_empty + + table = connection.schema(:reviews) + + name, options = table[0] # id + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + + name, options = table[1] # title + expect(name).to eq(:title) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] # rating (rolled back second migration) + expect(name).to be_nil + expect(options).to be_nil + end + end + end + + describe "rollback" do + before do + migrator.create + end + + describe "when no migrations" do + let(:migrations) { Pathname.new(__dir__ + "/../../../../support/fixtures/empty_migrations") } + + it "it doesn't alter database" do + migrator.rollback + + connection = Sequel.connect(url) + expect(connection.tables).to be_empty + end + end + + describe "when migrations are present" do + it "rollbacks one migration (default)" do + migrator.migrate + migrator.rollback + + connection = Sequel.connect(url) + expect(connection.tables).to include(:reviews) + + table = connection.schema(:reviews) + + name, options = table[0] # id + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("int") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + + name, options = table[1] # title + expect(name).to eq(:title) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] # rating (second migration) + expect(name).to eq(nil) + expect(options).to eq(nil) + end + + it "rollbacks several migrations" do + migrator.migrate + migrator.rollback(steps: 2) + + connection = Sequel.connect(url) + expect(connection.tables).to eq([:schema_migrations]) + end + end + end + + describe "apply" do + let(:migrations) { target_migrations } + let(:schema) { root.join("schema-#{random}.sql") } + + before do + prepare_migrations_directory + migrator.create + end + + after do + clean_migrations + end + + it "migrates to latest version" do + migrator.apply + connection = Sequel.connect(url) + migration = connection[:schema_migrations].to_a.last + + expect(migration.fetch(:filename)).to include("20160831090612") # see spec/support/fixtures/migrations + end + + it "dumps database schema.sql" do + migrator.apply + actual = schema.read + + expect(actual).to include %(DROP TABLE IF EXISTS `reviews`;) + + expect(actual).to include %(CREATE TABLE `reviews`) + + expect(actual).to include %(`id` int NOT NULL AUTO_INCREMENT,) + + expect(actual).to include %(`title` varchar(255)) + + expect(actual).to include %(`rating` int DEFAULT '0',) + expect(actual).to include %(PRIMARY KEY \(`id`\)) + + expect(actual).to include %(DROP TABLE IF EXISTS `schema_migrations`;) + + expect(actual).to include %(CREATE TABLE `schema_migrations` \() + + expect(actual).to include %(`filename` varchar(255)) + expect(actual).to include %(PRIMARY KEY (`filename`)) + + expect(actual).to include %(LOCK TABLES `schema_migrations` WRITE;) + + # expect(actual).to include %(INSERT INTO `schema_migrations` VALUES \('20150610133853_create_books.rb'\),\('20150610141017_add_price_to_books.rb'\);) + + expect(actual).to include %(UNLOCK TABLES;) + end + + it "deletes all the migrations" do + migrator.apply + expect(target_migrations.children).to be_empty + end + + context "when a system call fails" do + before do + expect(migrator).to receive(:adapter).at_least(:once).and_return(adapter) + end + + let(:adapter) { Hanami::Model::Migrator::Adapter.for(configuration) } + + it "raises error when fails to dump database structure" do + expect(adapter).to receive(:dump_structure).and_raise(Hanami::Model::MigrationError, message = "there was a problem") + expect { migrator.apply }.to raise_error(Hanami::Model::MigrationError, message) + + expect(target_migrations.children).to_not be_empty + end + + it "raises error when fails to dump migrations data" do + expect(adapter).to receive(:dump_migrations_data).and_raise(Hanami::Model::MigrationError, message = "there was another problem") + expect { migrator.apply }.to raise_error(Hanami::Model::MigrationError, message) + + expect(target_migrations.children).to_not be_empty + end + 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 + expect(tables).to include(:schema_migrations) + expect(tables).to include(:reviews) + expect(tables).to 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) + expect(connection.tables).to include(:schema_migrations) + expect(connection.tables).to include(:reviews) + end + + it "drops the database and recreates it" do + migrator.prepare + + connection = Sequel.connect(url) + expect(connection.tables).to include(:schema_migrations) + expect(connection.tables).to include(:reviews) + end + end + + describe "version" do + before do + migrator.create + end + + describe "when no migrations were ran" do + it "returns nil" do + expect(migrator.version).to be_nil + end + end + + describe "with migrations" do + before do + migrator.migrate + end + + it "returns current database version" do + expect(migrator.version).to eq("20160831090612") # see spec/support/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/model-main/spec/unit/hanami/model/migrator/postgresql.rb b/model-main/spec/unit/hanami/model/migrator/postgresql.rb new file mode 100644 index 0000000000000000000000000000000000000000..1db667cc121d0d8ee92363ee285b50d74f66bd00 --- /dev/null +++ b/model-main/spec/unit/hanami/model/migrator/postgresql.rb @@ -0,0 +1,544 @@ +# frozen_string_literal: true + +require "ostruct" +require "securerandom" + +RSpec.shared_examples "migrator_postgresql" do + let(:migrator) do + Hanami::Model::Migrator.new(configuration: configuration) + end + + let(:random) { SecureRandom.hex(4) } + + # General variables + let(:migrations) { Pathname.new(__dir__ + "/../../../../support/fixtures/migrations") } + let(:schema) { nil } + let(:config) { OpenStruct.new(backend: :sql, url: url, _migrations: migrations, _schema: schema, migrations_logger: Hanami::Model::Migrator::Logger.new(ENV["HANAMI_DATABASE_LOGGER"])) } + 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__}/../../../../support/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) { random } + let(:url) do + db = database + uri = format("%s/%s?user=%s&password=%s", + host: ENV.fetch("HANAMI_DATABASE_HOST", "127.0.0.1"), + db: db, + user: ENV["HANAMI_DATABASE_USERNAME"], + password: ENV["HANAMI_DATABASE_PASSWORD"]) + + Platform.match do + engine(:ruby) { "postgresql://#{uri}" } + engine(:jruby) { "jdbc:postgresql://#{uri}" } + end + end + + describe "create" do + before do + migrator.create + end + + it "creates the database" do + connection = Sequel.connect(url) + expect(connection.tables).to be_empty + end + + it "raises error if database is busy" do + Sequel.connect(url).tables + expect { migrator.create }.to raise_error do |error| + expect(error).to be_a(Hanami::Model::MigrationError) + + expect(error.message).to include("createdb: database creation failed. If the database exists,") + expect(error.message).to include("then its console may be open. See this issue for more details:") + expect(error.message).to include("https://github.com/hanami/model/issues/250") + end + end + end + + describe "drop" do + before do + migrator.create + end + + it "drops the database" do + migrator.drop + + expect { Sequel.connect(url).tables }.to raise_error(Sequel::DatabaseConnectionError) + end + + it "raises error if database doesn't exist" do + migrator.drop # remove the first time + + expect { migrator.drop } + .to raise_error(Hanami::Model::MigrationError, "Cannot find database: #{database}") + end + end + + describe "when executables are not 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 missing `createdb`" do + message = Platform.match do + os(:macos).engine(:jruby) { "createdb" } + default { "Could not find executable in your PATH: `createdb`" } + end + + expect { migrator.create }.to raise_error do |exception| + expect(exception).to be_kind_of(Hanami::Model::MigrationError) + expect(exception.message).to include(message) + end + end + + it "raises MigrationError on missing `dropdb`" do + message = Platform.match do + os(:macos).engine(:jruby) { "dropdb" } + default { "Could not find executable in your PATH: `dropdb`" } + end + + expect { migrator.drop }.to raise_error do |exception| + expect(exception).to be_kind_of(Hanami::Model::MigrationError) + expect(exception.message).to include(message) + end + end + end + + describe "migrate" do + before do + migrator.create + end + + describe "when no migrations" do + let(:migrations) { Pathname.new(__dir__ + "/../../../../support/fixtures/empty_migrations") } + + it "it doesn't alter database" do + migrator.migrate + + connection = Sequel.connect(url) + expect(connection.tables).to be_empty + end + end + + describe "when migrations are present" do + it "migrates the database" do + migrator.migrate + + connection = Sequel.connect(url) + expect(connection.tables).to_not be_empty + + table = connection.schema(:reviews) + + name, options = table[0] # id + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq("nextval('reviews_id_seq'::regclass)") + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + + name, options = table[1] # title + expect(name).to eq(:title) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] # rating (second migration) + expect(name).to eq(:rating) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("0") + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(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) + expect(connection.tables).to_not be_empty + expect(connection.tables).to include(:schema_migrations) + expect(connection.tables).to include(:reviews) + end + end + + describe "migrate down" do + before do + migrator.migrate + end + + it "migrates the database" do + migrator.migrate(version: "20160831073534") # see spec/support/fixtures/migrations + + connection = Sequel.connect(url) + expect(connection.tables).to_not be_empty + + table = connection.schema(:reviews) + + name, options = table[0] # id + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq("nextval('reviews_id_seq'::regclass)") + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + + name, options = table[1] # title + expect(name).to eq(:title) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] # rating (rolled back second migration) + expect(name).to be_nil + expect(options).to be_nil + end + end + end + + describe "rollback" do + before do + migrator.create + end + + describe "when no migrations" do + let(:migrations) { Pathname.new(__dir__ + "/../../../../support/fixtures/empty_migrations") } + + it "it doesn't alter database" do + migrator.rollback + + connection = Sequel.connect(url) + expect(connection.tables).to be_empty + end + end + + describe "when migrations are present" do + it "rollbacks one migration (default)" do + migrator.migrate + migrator.rollback + + connection = Sequel.connect(url) + expect(connection.tables).to include(:reviews) + + table = connection.schema(:reviews) + + name, options = table[0] # id + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to eq("nextval('reviews_id_seq'::regclass)") + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + + name, options = table[1] # title + expect(name).to eq(:title) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("text") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] # rating (second migration) + expect(name).to eq(nil) + expect(options).to eq(nil) + end + + it "rollbacks several migrations" do + migrator.migrate + migrator.rollback(steps: 2) + + connection = Sequel.connect(url) + expect(connection.tables).to eq([:schema_migrations]) + 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 + end + + after do + clean_migrations + end + + it "migrates to latest version" do + migrator.apply + connection = Sequel.connect(url) + migration = connection[:schema_migrations].to_a.last + + expect(migration.fetch(:filename)).to include("20160831090612") # see spec/support/fixtures/migrations + end + + it "dumps database schema.sql" do + migrator.apply + actual = schema.read + + if actual =~ /public\.reviews/ + # + # POSTGRESQL 10 + # + expect(actual).to include <<~SQL + CREATE TABLE public.reviews ( + id integer NOT NULL, + title text NOT NULL, + rating integer DEFAULT 0 + ); + SQL + + expect(actual).to include <<~SQL + CREATE SEQUENCE public.reviews_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + SQL + + expect(actual).to include <<~SQL + ALTER SEQUENCE public.reviews_id_seq OWNED BY public.reviews.id; + SQL + + expect(actual).to include <<~SQL + ALTER TABLE ONLY public.reviews ALTER COLUMN id SET DEFAULT nextval('public.reviews_id_seq'::regclass); + SQL + + expect(actual).to include <<~SQL + ALTER TABLE ONLY public.reviews + ADD CONSTRAINT reviews_pkey PRIMARY KEY (id); + SQL + + expect(actual).to include <<~SQL + CREATE TABLE public.schema_migrations ( + filename text NOT NULL + ); + SQL + + expect(actual).to include <<~SQL + COPY public.schema_migrations (filename) FROM stdin; + 20160831073534_create_reviews.rb + 20160831090612_add_rating_to_reviews.rb + SQL + + expect(actual).to include <<~SQL + ALTER TABLE ONLY public.schema_migrations + ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (filename); + SQL + else + # + # POSTGRESQL 9 + # + expect(actual).to include <<~SQL + CREATE TABLE reviews ( + id integer NOT NULL, + title text NOT NULL, + rating integer DEFAULT 0 + ); + SQL + + expect(actual).to include <<~SQL + CREATE SEQUENCE reviews_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + SQL + + expect(actual).to include <<~SQL + ALTER SEQUENCE reviews_id_seq OWNED BY reviews.id; + SQL + + expect(actual).to include <<~SQL + ALTER TABLE ONLY reviews ALTER COLUMN id SET DEFAULT nextval('reviews_id_seq'::regclass); + SQL + + expect(actual).to include <<~SQL + ALTER TABLE ONLY reviews + ADD CONSTRAINT reviews_pkey PRIMARY KEY (id); + SQL + + expect(actual).to include <<~SQL + CREATE TABLE schema_migrations ( + filename text NOT NULL + ); + SQL + + expect(actual).to include <<~SQL + COPY schema_migrations (filename) FROM stdin; + 20160831073534_create_reviews.rb + 20160831090612_add_rating_to_reviews.rb + SQL + + expect(actual).to include <<~SQL + ALTER TABLE ONLY schema_migrations + ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (filename); + SQL + end + end + + it "deletes all the migrations" do + migrator.apply + expect(target_migrations.children).to be_empty + end + + context "when a system call fails" do + before do + expect(migrator).to receive(:adapter).at_least(:once).and_return(adapter) + end + + let(:adapter) { Hanami::Model::Migrator::Adapter.for(configuration) } + + it "raises error when fails to dump database structure" do + expect(adapter).to receive(:dump_structure).and_raise(Hanami::Model::MigrationError, message = "there was a problem") + expect { migrator.apply }.to raise_error(Hanami::Model::MigrationError, message) + + expect(target_migrations.children).to_not be_empty + end + + it "raises error when fails to dump migrations data" do + expect(adapter).to receive(:dump_migrations_data).and_raise(Hanami::Model::MigrationError, message = "there was another problem") + expect { migrator.apply }.to raise_error(Hanami::Model::MigrationError, message) + + expect(target_migrations.children).to_not be_empty + end + 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 + 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 = Sequel.connect(url) + tables = connection.tables + expect(tables).to include(:schema_migrations) + expect(tables).to include(:reviews) + expect(tables).to 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) + expect(connection.tables).to include(:schema_migrations) + expect(connection.tables).to include(:reviews) + end + + it "drops the database and recreates it" do + migrator.prepare + + connection = Sequel.connect(url) + expect(connection.tables).to include(:schema_migrations) + expect(connection.tables).to include(:reviews) + end + end + + describe "version" do + before do + migrator.create + end + + describe "when no migrations were ran" do + it "returns nil" do + expect(migrator.version).to be_nil + end + end + + describe "with migrations" do + before do + migrator.migrate + end + + it "returns current database version" do + expect(migrator.version).to eq("20160831090612") # see spec/support/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/model-main/spec/unit/hanami/model/migrator/sqlite.rb b/model-main/spec/unit/hanami/model/migrator/sqlite.rb new file mode 100644 index 0000000000000000000000000000000000000000..1093c0131c2435c4f30533130f764fd075a8481b --- /dev/null +++ b/model-main/spec/unit/hanami/model/migrator/sqlite.rb @@ -0,0 +1,418 @@ +# frozen_string_literal: true + +require "ostruct" +require "securerandom" + +RSpec.shared_examples "migrator_sqlite" do + let(:migrator) do + Hanami::Model::Migrator.new(configuration: configuration) + end + + let(:random) { SecureRandom.hex } + + # General variables + let(:migrations) { Pathname.new(__dir__ + "/../../../../support/fixtures/migrations") } + let(:schema) { nil } + let(:config) { OpenStruct.new(backend: :sql, url: url, _migrations: migrations, _schema: schema, migrations_logger: Hanami::Model::Migrator::Logger.new(ENV["HANAMI_DATABASE_LOGGER"])) } + 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__}/../../../../support/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 + expect(File.exist?(database)).to be_truthy, "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 + skip if Platform::Ci.ci?(:circle) + + 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 + + expect { migrator.create }.to raise_error(error, message) + end + end + + describe "when the path is relative" do + let(:database) { "create.sqlite3" } + + it "creates the database" do + migrator.create + expect(File.exist?(database)).to be_truthy, "Expected database #{database} to exist" + end + end + end + + describe "drop" do + before do + migrator.create + end + + it "drops the database" do + migrator.drop + expect(File.exist?(database)).to be_falsey, "Expected database #{database} to NOT exist" + end + + it "raises error if database doesn't exist" do + migrator.drop # remove the first time + + expect { migrator.drop } + .to raise_error(Hanami::Model::MigrationError, "Cannot find database: #{database}") + end + end + + describe "migrate" do + before do + migrator.create + end + + describe "when no migrations" do + let(:migrations) { Pathname.new(__dir__ + "/../../../../support/fixtures/empty_migrations") } + + it "it doesn't alter database" do + migrator.migrate + + connection = Sequel.connect(url) + expect(connection.tables).to be_empty + end + end + + describe "when migrations are present" do + it "migrates the database" do + migrator.migrate + + connection = Sequel.connect(url) + expect(connection.tables).to_not be_empty + + table = connection.schema(:reviews) + + name, options = table[0] # id + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + + name, options = table[1] # title + expect(name).to eq(:title) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] # rating (second migration) + expect(name).to eq(:rating) + + expect(options.fetch(:allow_null)).to eq(true) + expect(options.fetch(:default)).to eq("0") + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(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) + expect(connection.tables).to_not be_empty + expect(connection.tables).to eq(%i[schema_migrations reviews]) + end + end + + describe "migrate down" do + before do + migrator.migrate + end + + it "migrates the database" do + migrator.migrate(version: "20160831073534") # see spec/support/fixtures/migrations + + connection = Sequel.connect(url) + expect(connection.tables).to_not be_empty + + table = connection.schema(:reviews) + + name, options = table[0] # id + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + + name, options = table[1] # title + expect(name).to eq(:title) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] # rating (rolled back second migration) + expect(name).to be_nil + expect(options).to be_nil + end + end + end + + describe "rollback" do + before do + migrator.create + end + + describe "when no migrations" do + let(:migrations) { Pathname.new(__dir__ + "/../../../../support/fixtures/empty_migrations") } + + it "it doesn't alter database" do + migrator.rollback + + connection = Sequel.connect(url) + expect(connection.tables).to be_empty + end + end + + describe "when migrations are present" do + it "rollbacks one migration (default)" do + migrator.migrate + migrator.rollback + + connection = Sequel.connect(url) + expect(connection.tables).to eq(%i[schema_migrations reviews]) + + table = connection.schema(:reviews) + + name, options = table[0] # id + expect(name).to eq(:id) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:integer) + expect(options.fetch(:db_type)).to eq("integer") + expect(options.fetch(:primary_key)).to eq(true) + expect(options.fetch(:auto_increment)).to eq(true) + + name, options = table[1] # title + expect(name).to eq(:title) + + expect(options.fetch(:allow_null)).to eq(false) + expect(options.fetch(:default)).to be_nil + expect(options.fetch(:type)).to eq(:string) + expect(options.fetch(:db_type)).to eq("varchar(255)") + expect(options.fetch(:primary_key)).to eq(false) + + name, options = table[2] # rating (second migration) + expect(name).to eq(nil) + expect(options).to eq(nil) + end + + it "rollbacks several migrations" do + migrator.migrate + migrator.rollback(steps: 2) + + connection = Sequel.connect(url) + expect(connection.tables).to eq([:schema_migrations]) + end + end + end + + describe "apply" 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 "migrates to latest version" do + migrator.apply + connection = Sequel.connect(url) + migration = connection[:schema_migrations].to_a.last + + expect(migration.fetch(:filename)).to include("20160831090612") # see spec/support/fixtures/migrations + end + + it "dumps database schema.sql" do + migrator.apply + actual = schema.read + + expect(actual).to include %(CREATE TABLE `schema_migrations` (`filename` varchar(255) NOT NULL PRIMARY KEY);) + expect(actual).to include %(CREATE TABLE `reviews` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `title` varchar(255) NOT NULL, `rating` integer DEFAULT (0));) + expect(actual).to match(/INSERT INTO "?schema_migrations"? VALUES\('20160831073534_create_reviews.rb'\);/) + expect(actual).to match(/INSERT INTO "?schema_migrations"? VALUES\('20160831090612_add_rating_to_reviews.rb'\);/) + end + + it "deletes all the migrations" do + migrator.apply + expect(target_migrations.children).to be_empty + end + + context "when a system call fails" do + before do + expect(migrator).to receive(:adapter).at_least(:once).and_return(adapter) + end + + let(:adapter) { Hanami::Model::Migrator::Adapter.for(configuration) } + + it "raises error when fails to dump database structure" do + expect(adapter).to receive(:dump_structure).and_raise(Hanami::Model::MigrationError, message = "there was a problem") + expect { migrator.apply }.to raise_error(Hanami::Model::MigrationError, message) + + expect(target_migrations.children).to_not be_empty + end + + it "raises error when fails to dump migrations data" do + expect(adapter).to receive(:dump_migrations_data).and_raise(Hanami::Model::MigrationError, message = "there was another problem") + expect { migrator.apply }.to raise_error(Hanami::Model::MigrationError, message) + + expect(target_migrations.children).to_not be_empty + end + + it "raises error when fails to write migrations data" do + expect(File).to receive(:open).and_raise(StandardError, message = "a standard error") + expect { migrator.apply }.to raise_error(Hanami::Model::MigrationError, message) + + expect(target_migrations.children).to_not be_empty + end + 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 + + expect(connection.tables).to eq(%i[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) + expect(connection.tables).to eq(%i[schema_migrations reviews]) + end + + it "drops the database and recreate it" do + migrator.create + migrator.prepare + + connection = Sequel.connect(url) + expect(connection.tables).to include(:schema_migrations) + expect(connection.tables).to include(:reviews) + end + end + + describe "version" do + before do + migrator.create + end + + describe "when no migrations were ran" do + it "returns nil" do + expect(migrator.version).to be_nil + end + end + + describe "with migrations" do + before do + migrator.migrate + end + + it "returns current database version" do + expect(migrator.version).to eq("20160831090612") # see spec/support/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/model-main/spec/unit/hanami/model/migrator_spec.rb b/model-main/spec/unit/hanami/model/migrator_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c425d87dbdcf9fe61bddcb5a198bf65156e1c149 --- /dev/null +++ b/model-main/spec/unit/hanami/model/migrator_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "hanami/model/migrator" +require_relative "./migrator/#{Database.engine}" + +RSpec.describe Hanami::Model::Migrator do + include_examples "migrator_#{Database.engine}" +end diff --git a/model-main/spec/unit/hanami/model/not_null_constraint_violation_error_spec.rb b/model-main/spec/unit/hanami/model/not_null_constraint_violation_error_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4871645c1cf055ea62bcd86e5e8d3d85c6782188 --- /dev/null +++ b/model-main/spec/unit/hanami/model/not_null_constraint_violation_error_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::NotNullConstraintViolationError do + it "inherits from Hanami::Model::ConstraintViolationError" do + expect(described_class.ancestors).to include(Hanami::Model::ConstraintViolationError) + end + + it "has a default error message" do + expect { raise described_class }.to raise_error(described_class, "NOT NULL constraint has been violated") + end + + it "allows custom error message" do + expect { raise described_class.new("Ouch") }.to raise_error(described_class, "Ouch") + end +end diff --git a/model-main/spec/unit/hanami/model/sql/console/mysql.rb b/model-main/spec/unit/hanami/model/sql/console/mysql.rb new file mode 100644 index 0000000000000000000000000000000000000000..e0977c35cccd9c469f25527974b9bc08f40c7bd9 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/console/mysql.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "hanami/model/sql/consoles/mysql" + +RSpec.shared_examples "sql_console_mysql" do + let(:sql_console) { Hanami::Model::Sql::Consoles::Mysql.new(uri) } + + describe "#connection_string" do + let(:uri) { URI.parse("mysql://username:password@localhost:1234/foo_development") } + + it "returns a connection string" do + expect(sql_console.connection_string).to eq("mysql -h localhost -D foo_development -P 1234 -u username -p password") + end + end +end diff --git a/model-main/spec/unit/hanami/model/sql/console/postgresql.rb b/model-main/spec/unit/hanami/model/sql/console/postgresql.rb new file mode 100644 index 0000000000000000000000000000000000000000..165dbaf15c363fe8935a15220e84e9d745d9cf40 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/console/postgresql.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "hanami/model/sql/consoles/postgresql" + +RSpec.shared_examples "sql_console_postgresql" do + let(:sql_console) { Hanami::Model::Sql::Consoles::Postgresql.new(uri) } + + around(:each) do |example| + original_pgpassword = ENV["PGPASSWORD"] + example.run + ENV["PGPASSWORD"] = original_pgpassword + end + + describe "#connection_string" do + let(:uri) { URI.parse("postgres://username:password@localhost:1234/foo_development") } + + it "returns a connection string" do + expect(sql_console.connection_string).to eq("psql -h localhost -d foo_development -p 1234 -U username") + end + + it "sets the PGPASSWORD environment variable" do + sql_console.connection_string + expect(ENV["PGPASSWORD"]).to eq("password") + ENV.delete("PGPASSWORD") + end + + context "when the password contains percent encoded characters" do + let(:uri) { URI.parse("postgres://username:p%40ss@localhost:1234/foo_development") } + + it "sets the PGPASSWORD environment variable decoding special characters" do + sql_console.connection_string + expect(ENV["PGPASSWORD"]).to eq("p@ss") + ENV.delete("PGPASSWORD") + end + end + + context "when components of the hierarchical part of the URI can also be given as parameters" do + let(:uri) { URI.parse("postgres:///foo_development?user=username&password=password&host=localhost&port=1234") } + + it "returns a connection string" do + expect(sql_console.connection_string).to eq("psql -h localhost -d foo_development -p 1234 -U username") + end + + it "sets the PGPASSWORD environment variable" do + sql_console.connection_string + expect(ENV["PGPASSWORD"]).to eq("password") + ENV.delete("PGPASSWORD") + end + end + end +end diff --git a/model-main/spec/unit/hanami/model/sql/console/sqlite.rb b/model-main/spec/unit/hanami/model/sql/console/sqlite.rb new file mode 100644 index 0000000000000000000000000000000000000000..26ca08e3bc2b80343c451e649a81ae0f04e28dfb --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/console/sqlite.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "hanami/model/sql/consoles/sqlite" + +RSpec.shared_examples "sql_console_sqlite" do + let(:sql_console) { Hanami::Model::Sql::Consoles::Sqlite.new(uri) } + + describe "#connection_string" do + describe "with shell ok database uri" do + let(:uri) { URI.parse("sqlite://foo/bar.db") } + it "returns a connection string for Sqlite3" do + expect(sql_console.connection_string).to eq("sqlite3 foo/bar.db") + end + end + + describe "with non shell ok database uri" do + let(:uri) { URI.parse("sqlite://foo/%20bar.db") } + it "returns an escaped connection string for Sqlite3" do + expect(sql_console.connection_string).to eq('sqlite3 foo/\\%20bar.db') + end + end + end +end diff --git a/model-main/spec/unit/hanami/model/sql/console_spec.rb b/model-main/spec/unit/hanami/model/sql/console_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bdd93be9a6f51ddc44fd7245b77d07d727987166 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/console_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "hanami/model/sql/console" + +RSpec.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) + expect(console).to be_a_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) + expect(console).to be_a_kind_of(Hanami::Model::Sql::Consoles::Postgresql) + end + + it "postgresql:// uri returns an instance of Console::Postgresql" do + console = Hanami::Model::Sql::Console.new("postgresql://#{uri}").send(:console) + expect(console).to be_a_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) + expect(console).to be_a_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) + expect(console).to be_a_kind_of(Hanami::Model::Sql::Consoles::Mysql) + end + end + end + + describe Database.engine.to_s do + require_relative "./console/#{Database.engine}" + include_examples "sql_console_#{Database.engine}" + end +end diff --git a/model-main/spec/unit/hanami/model/sql/entity/schema/automatic_spec.rb b/model-main/spec/unit/hanami/model/sql/entity/schema/automatic_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..423d8b044ab7cea7713be69075a63d82444509e4 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/entity/schema/automatic_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::Sql::Entity::Schema do + describe "automatic" do + subject { Author.schema } + + describe "#initialize" do + it "returns frozen instance" do + expect(subject).to be_frozen + end + end + + describe "#call" do + it "returns empty hash when nil is given" do + result = subject.call(nil) + + expect(result).to eq({}) + end + + it "processes attributes" do + now = Time.now + result = subject.call(id: 1, created_at: now.to_s) + + expect(result.fetch(:id)).to eq(1) + expect(result.fetch(:created_at)).to be_within(2).of(now) + end + + it "ignores unknown attributes" do + result = subject.call(foo: "bar") + + expect(result).to eq({}) + end + end + + describe "#attribute?" do + it "returns true for known attributes" do + expect(subject.attribute?(:id)).to eq(true) + end + + it "returns false for unknown attributes" do + expect(subject.attribute?(:foo)).to eq(false) + end + end + end +end diff --git a/model-main/spec/unit/hanami/model/sql/entity/schema/mapping_spec.rb b/model-main/spec/unit/hanami/model/sql/entity/schema/mapping_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c9ba9ccf038e30149e307f6b58ccf50e8e7f2311 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/entity/schema/mapping_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::Sql::Entity::Schema do + describe "mapping" do + subject { Operator.schema } + + describe "#initialize" do + it "returns frozen instance" do + expect(subject).to be_frozen + end + end + + describe "#call" do + it "returns empty hash when nil is given" do + result = subject.call(nil) + + expect(result).to eq({}) + end + + it "processes attributes" do + result = subject.call(id: 1, name: :foo) + + expect(result).to eq(id: 1, name: "foo") + end + + it "ignores unknown attributes" do + result = subject.call(foo: "bar") + + expect(result).to eq({}) + end + end + + describe "#attribute?" do + it "returns true for known attributes" do + expect(subject.attribute?(:id)).to eq(true) + end + + it "returns false for unknown attributes" do + expect(subject.attribute?(:foo)).to eq(false) + end + end + end +end diff --git a/model-main/spec/unit/hanami/model/sql/schema/array_spec.rb b/model-main/spec/unit/hanami/model/sql/schema/array_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bffd00da36900f333ec7799520075c856cc2ad33 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/schema/array_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +RSpec.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 + expect(described_class[input]).to eq(input) + end + + it "coerces object that respond to #to_ary" do + expect(described_class[input]).to eq(input.to_ary) + end + + it "coerces string" do + input = "foo" + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Array(): #{input.inspect}") + end + + it "raises error for symbol" do + input = :foo + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Array(): #{input.inspect}") + end + + it "raises error for integer" do + input = 11 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Array(): #{input.inspect}") + end + + it "raises error for float" do + input = 3.14 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Array(): #{input.inspect}") + end + + it "raises error for bigdecimal" do + input = BigDecimal(3.14, 10) + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Array(): #{input.inspect}") + end + + it "raises error for date" do + input = Date.today + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Array(): #{input.inspect}") + end + + it "raises error for datetime" do + input = DateTime.new + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Array(): #{input.inspect}") + end + + it "raises error for time" do + input = Time.now + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Array(): #{input.inspect}") + end + + it "coerces array" do + input = [] + expect(described_class[input]).to eq(input) + end + + it "raises error for hash" do + input = {} + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Array(): #{input.inspect}") + end +end diff --git a/model-main/spec/unit/hanami/model/sql/schema/bool_spec.rb b/model-main/spec/unit/hanami/model/sql/schema/bool_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1469579337f0a1ce378db693e82dae09adc851e4 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/schema/bool_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +RSpec.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 + expect(described_class[input]).to eq(input) + end + + it "returns true for true" do + input = true + expect(described_class[input]).to eq(input) + end + + it "returns false for false" do + input = true + expect(described_class[input]).to eq(input) + end + + it "raises error for string" do + input = "foo" + expect { described_class[input] } + .to raise_error(TypeError, "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)") + end + + it "raises error for symbol" do + input = :foo + expect { described_class[input] } + .to raise_error(TypeError, "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)") + end + + it "raises error for integer" do + input = 11 + expect { described_class[input] } + .to raise_error(TypeError, "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)") + end + + it "raises error for float" do + input = 3.14 + expect { described_class[input] } + .to raise_error(TypeError, "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)") + end + + it "raises error for bigdecimal" do + input = BigDecimal(3.14, 10) + expect { described_class[input] } + .to raise_error(TypeError, "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)") + end + + it "raises error for date" do + input = Date.today + expect { described_class[input] } + .to raise_error(TypeError, "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)") + end + + it "raises error for datetime" do + input = DateTime.new + expect { described_class[input] } + .to raise_error(TypeError, "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)") + end + + it "raises error for time" do + input = Time.now + expect { described_class[input] } + .to raise_error(TypeError, "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)") + end + + it "raises error for array" do + input = [] + expect { described_class[input] } + .to raise_error(TypeError, "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)") + end + + it "raises error for hash" do + input = {} + expect { described_class[input] } + .to raise_error(TypeError, "#{input.inspect} violates constraints (type?(FalseClass, #{input.inspect}) failed)") + end +end diff --git a/model-main/spec/unit/hanami/model/sql/schema/date_spec.rb b/model-main/spec/unit/hanami/model/sql/schema/date_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f8c3f2c8c75135ec29e4f2ef4c3d5009beca148b --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/schema/date_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +RSpec.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 + expect(described_class[input]).to eq(input) + end + + it "coerces object that respond to #to_date" do + expect(described_class[input]).to eq(input.to_date) + end + + it "coerces string" do + date = Date.today + input = date.to_s + + expect(described_class[input]).to eq(date) + end + + it "coerces Hanami string" do + input = Hanami::Utils::String.new(Date.today) + expect(described_class[input]).to eq(Date.parse(input)) + end + + it "raises error for meaningless string" do + input = "foo" + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid date") + end + + it "raises error for symbol" do + input = :foo + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Date(): #{input.inspect}") + end + + it "raises error for integer" do + input = 11 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Date(): #{input.inspect}") + end + + it "raises error for float" do + input = 3.14 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Date(): #{input.inspect}") + end + + it "raises error for bigdecimal" do + input = BigDecimal(3.14, 10) + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Date(): #{input.inspect}") + end + + it "coerces date" do + input = Date.today + date = input + + expect(described_class[input]).to eq(date) + end + + it "coerces datetime" do + input = DateTime.new + date = input.to_date + + expect(described_class[input]).to eq(date) + end + + it "coerces time" do + input = Time.now + date = input.to_date + + expect(described_class[input]).to eq(date) + end + + it "raises error for array" do + input = [] + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Date(): #{input.inspect}") + end + + it "raises error for hash" do + input = {} + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Date(): #{input.inspect}") + end +end diff --git a/model-main/spec/unit/hanami/model/sql/schema/date_time_spec.rb b/model-main/spec/unit/hanami/model/sql/schema/date_time_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3033026552379bd9f8c17e63504c811454d12119 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/schema/date_time_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +RSpec.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 + expect(described_class[input]).to eq(input) + end + + it "coerces object that respond to #to_datetime" do + expect(described_class[input]).to eq(input.to_datetime) + end + + it "coerces string" do + date = DateTime.new + input = date.to_s + + expect(described_class[input]).to eq(date) + end + + it "coerces Hanami string" do + input = Hanami::Utils::String.new(DateTime.new) + expect(described_class[input]).to eq(DateTime.parse(input)) + end + + it "raises error for meaningless string" do + input = "foo" + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid date") + end + + it "raises error for symbol" do + input = :foo + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for DateTime(): #{input.inspect}") + end + + it "raises error for integer" do + input = 11 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for DateTime(): #{input.inspect}") + end + + it "raises error for float" do + input = 3.14 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for DateTime(): #{input.inspect}") + end + + it "raises error for bigdecimal" do + input = BigDecimal(3.14, 10) + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for DateTime(): #{input.inspect}") + end + + it "coerces date" do + input = Date.today + date_time = input.to_datetime + + expect(described_class[input]).to eq(date_time) + end + + it "coerces datetime" do + input = DateTime.new + date_time = input + + expect(described_class[input]).to eq(date_time) + end + + it "coerces time" do + input = Time.now + date_time = input.to_datetime + + expect(described_class[input]).to be_within(2).of(date_time) + end + + it "raises error for array" do + input = [] + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for DateTime(): #{input.inspect}") + end + + it "raises error for hash" do + input = {} + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for DateTime(): #{input.inspect}") + end +end diff --git a/model-main/spec/unit/hanami/model/sql/schema/decimal_spec.rb b/model-main/spec/unit/hanami/model/sql/schema/decimal_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ca74189b040598ee72768899bbcab2f5e42236af --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/schema/decimal_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +RSpec.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(10) + end + end.new + end + + it "returns nil for nil" do + input = nil + expect(described_class[input]).to eq(input) + end + + it "coerces object that respond to #to_d" do + expect(described_class[input]).to eq(input.to_d) + end + + it "coerces string representing int" do + input = "1" + expect(described_class[input]).to eq(input.to_d) + end + + it "coerces Hanami string representing int" do + input = Hanami::Utils::String.new("1") + expect(described_class[input]).to eq(input.to_d) + end + + it "coerces string representing float" do + input = "3.14" + expect(described_class[input]).to eq(input.to_d) + end + + it "coerces Hanami string representing float" do + input = Hanami::Utils::String.new("3.14") + expect(described_class[input]).to eq(input.to_d) + end + + it "raises error for symbol" do + input = :house_11 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for BigDecimal(): #{input.inspect}") + end + + it "coerces integer" do + input = 23 + expect(described_class[input]).to eq(input.to_d) + end + + it "coerces float" do + input = 3.14 + expect(described_class[input]).to eq(input.to_d) + end + + it "coerces bigdecimal" do + input = BigDecimal(3.14, 10) + expect(described_class[input]).to eq(input.to_d) + end + + it "raises error for date" do + input = Date.today + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for BigDecimal(): #{input.inspect}") + end + + it "raises error for datetime" do + input = DateTime.new + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for BigDecimal(): #{input.inspect}") + end + + it "raises error for time" do + input = Time.now + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for BigDecimal(): #{input.inspect}") + end + + it "raises error for array" do + input = [] + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for BigDecimal(): #{input.inspect}") + end + + it "raises error for hash" do + input = {} + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for BigDecimal(): #{input.inspect}") + end +end diff --git a/model-main/spec/unit/hanami/model/sql/schema/float_spec.rb b/model-main/spec/unit/hanami/model/sql/schema/float_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7e4c0bdc403e543fdb465c357c7b0e53b6ea82e7 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/schema/float_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +RSpec.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 + expect(described_class[input]).to eq(input) + end + + it "coerces object that respond to #to_f" do + expect(described_class[input]).to eq(input.to_f) + end + + it "coerces string representing int" do + input = "1" + expect(described_class[input]).to eq(input.to_f) + end + + it "coerces Hanami string representing int" do + input = Hanami::Utils::String.new("1") + expect(described_class[input]).to eq(input.to_f) + end + + it "coerces string representing float" do + input = "3.14" + expect(described_class[input]).to eq(input.to_f) + end + + it "coerces Hanami string representing float" do + input = Hanami::Utils::String.new("3.14") + expect(described_class[input]).to eq(input.to_f) + end + + it "raises error for meaningless string" do + input = "foo" + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Float(): #{input.inspect}") + end + + it "raises error for symbol" do + input = :house_11 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Float(): #{input.inspect}") + end + + it "coerces integer" do + input = 23 + expect(described_class[input]).to eq(input.to_f) + end + + it "coerces float" do + input = 3.14 + expect(described_class[input]).to eq(input.to_f) + end + + it "coerces bigdecimal" do + input = BigDecimal(3.14, 10) + expect(described_class[input]).to eq(input.to_f) + end + + it "raises error for date" do + input = Date.today + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Float(): #{input.inspect}") + end + + it "raises error for datetime" do + input = DateTime.new + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Float(): #{input.inspect}") + end + + it "raises error for time" do + input = Time.now + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Float(): #{input.inspect}") + end + + it "raises error for array" do + input = [] + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Float(): #{input.inspect}") + end + + it "raises error for hash" do + input = {} + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Float(): #{input.inspect}") + end +end diff --git a/model-main/spec/unit/hanami/model/sql/schema/hash_spec.rb b/model-main/spec/unit/hanami/model/sql/schema/hash_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ee0a4b0487bbb556b371e2b5cb7e6d37eb70ea57 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/schema/hash_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +RSpec.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 + expect(described_class[input]).to eq(input) + end + + it "coerces object that respond to #to_hash" do + expect(described_class[input]).to eq(input.to_hash) + end + + it "coerces string" do + input = "foo" + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Hash(): #{input.inspect}") + end + + it "raises error for symbol" do + input = :foo + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Hash(): #{input.inspect}") + end + + it "raises error for integer" do + input = 11 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Hash(): #{input.inspect}") + end + + it "raises error for float" do + input = 3.14 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Hash(): #{input.inspect}") + end + + it "raises error for bigdecimal" do + input = BigDecimal(3.14, 10) + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Hash(): #{input.inspect}") + end + + it "raises error for date" do + input = Date.today + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Hash(): #{input.inspect}") + end + + it "raises error for datetime" do + input = DateTime.new + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Hash(): #{input.inspect}") + end + + it "raises error for time" do + input = Time.now + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Hash(): #{input.inspect}") + end + + it "raises error for array" do + input = [] + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Hash(): #{input.inspect}") + end + + it "coerces hash" do + input = {} + expect(described_class[input]).to eq(input) + end +end diff --git a/model-main/spec/unit/hanami/model/sql/schema/int_spec.rb b/model-main/spec/unit/hanami/model/sql/schema/int_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..366adc75589d9e17bbaa65d185009049cc58bb69 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/schema/int_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +RSpec.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 + expect(described_class[input]).to eq(input) + end + + it "coerces object that respond to #to_int" do + expect(described_class[input]).to eq(input.to_int) + end + + it "coerces string representing int" do + input = "1" + expect(described_class[input]).to eq(input.to_i) + end + + it "coerces Hanami string representing int" do + input = Hanami::Utils::String.new("1") + expect(described_class[input]).to eq(input.to_i) + end + + it "raises error for meaningless string" do + input = "foo" + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Integer(): #{input.inspect}") + end + + it "raises error for symbol" do + input = :house_11 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Integer(): #{input.inspect}") + end + + it "coerces integer" do + input = 23 + expect(described_class[input]).to eq(input) + end + + it "coerces float" do + input = 3.14 + expect(described_class[input]).to eq(input.to_i) + end + + it "coerces bigdecimal" do + input = BigDecimal(3.14, 10) + expect(described_class[input]).to eq(input.to_i) + end + + it "raises error for date" do + input = Date.today + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Integer(): #{input.inspect}") + end + + it "raises error for datetime" do + input = DateTime.new + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Integer(): #{input.inspect}") + end + + it "raises error for time" do + input = Time.now + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Integer(): #{input.inspect}") + end + + it "raises error for array" do + input = [] + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Integer(): #{input.inspect}") + end + + it "raises error for hash" do + input = {} + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Integer(): #{input.inspect}") + end +end diff --git a/model-main/spec/unit/hanami/model/sql/schema/string_spec.rb b/model-main/spec/unit/hanami/model/sql/schema/string_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6fc060633352d6d8c94a1cea1554c777e6bf8534 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/schema/string_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +RSpec.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 + expect(described_class[input]).to eq(input) + end + + it "coerces string" do + input = "foo" + expect(described_class[input]).to eq(input.to_s) + end + + it "coerces symbol" do + input = :foo + expect(described_class[input]).to eq(input.to_s) + end + + it "coerces integer" do + input = 23 + expect(described_class[input]).to eq(input.to_s) + end + + it "coerces float" do + input = 3.14 + expect(described_class[input]).to eq(input.to_s) + end + + it "coerces bigdecimal" do + input = BigDecimal(3.14, 10) + expect(described_class[input]).to eq(input.to_s) + end + + it "coerces date" do + input = Date.today + expect(described_class[input]).to eq(input.to_s) + end + + it "coerces datetime" do + input = DateTime.new + expect(described_class[input]).to eq(input.to_s) + end + + it "coerces time" do + input = Time.now + expect(described_class[input]).to eq(input.to_s) + end + + it "coerces array" do + input = [] + expect(described_class[input]).to eq(input.to_s) + end + + it "coerces hash" do + input = {} + expect(described_class[input]).to eq(input.to_s) + end +end diff --git a/model-main/spec/unit/hanami/model/sql/schema/time_spec.rb b/model-main/spec/unit/hanami/model/sql/schema/time_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f5c2e662cd3ed995422db0a94a0d74dd598c033e --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql/schema/time_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +RSpec.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 + expect(described_class[input]).to eq(input) + end + + it "coerces object that respond to #to_time" do + expect(described_class[input]).to be_within(2).of(input.to_time) + end + + it "coerces string" do + time = Time.now + input = time.to_s + + expect(described_class[input]).to be_within(2).of(time) + end + + it "coerces Hanami string" do + input = Hanami::Utils::String.new(Time.now) + expect(described_class[input]).to be_within(2).of(Time.parse(input)) + end + + it "raises error for meaningless string" do + input = "foo" + expect { described_class[input] } + .to raise_error(ArgumentError, "no time information in #{input.inspect}") + end + + it "raises error for symbol" do + input = :foo + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Time(): #{input.inspect}") + end + + it "coerces integer" do + input = 11 + time = Time.at(input) + + expect(described_class[input]).to be_within(2).of(time) + end + + it "raises error for float" do + input = 3.14 + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Time(): #{input.inspect}") + end + + it "raises error for bigdecimal" do + input = BigDecimal(3.14, 10) + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Time(): #{input.inspect}") + end + + it "coerces date" do + input = Date.today + time = input.to_time + + expect(described_class[input]).to be_within(2).of(time) + end + + it "coerces datetime" do + input = DateTime.new + time = input.to_time + + expect(described_class[input]).to be_within(2).of(time) + end + + it "coerces time" do + input = Time.now + time = input + + expect(described_class[input]).to be_within(2).of(time) + end + + it "raises error for array" do + input = [] + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Time(): #{input.inspect}") + end + + it "raises error for hash" do + input = {} + expect { described_class[input] } + .to raise_error(ArgumentError, "invalid value for Time(): #{input.inspect}") + end +end diff --git a/model-main/spec/unit/hanami/model/sql_spec.rb b/model-main/spec/unit/hanami/model/sql_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3ee7416fd8540dcc5e66343f9c3bdd9fcd81dea0 --- /dev/null +++ b/model-main/spec/unit/hanami/model/sql_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::Sql do + describe ".migration" do + it "returns a new migration" do + migration = Hanami::Model.migration {} + + expect(migration).to be_kind_of(Hanami::Model::Migration) + end + end + + describe ".function" do + it "returns a database function" do + function = described_class.function(:uuid_generate_v4) + + expect(function).to be_kind_of(Sequel::SQL::Function) + end + end + + describe ".literal" do + it "returns a database literal" do + literal = described_class.literal(input = "ROW('fuzzy dice', 42, 1.99)") + + expect(literal).to be_kind_of(Sequel::LiteralString) + expect(literal).to eq(input) + end + end + + describe ".asc" do + it "returns an asceding order clause" do + clause = described_class.asc(input = :created_at) + + expect(clause).to be_kind_of(Sequel::SQL::OrderedExpression) + expect(clause.expression).to eq(input) + expect(clause.descending).to be(false) + end + end + + describe ".desc" do + it "returns an descending order clause" do + clause = described_class.desc(input = :created_at) + + expect(clause).to be_kind_of(Sequel::SQL::OrderedExpression) + expect(clause.expression).to eq(input) + expect(clause.descending).to be(true) + end + end +end diff --git a/model-main/spec/unit/hanami/model/unique_constraint_violation_error_spec.rb b/model-main/spec/unit/hanami/model/unique_constraint_violation_error_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..08aa43f0611e7a9fb79a1fad1225b4a58668bfde --- /dev/null +++ b/model-main/spec/unit/hanami/model/unique_constraint_violation_error_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::Model::UniqueConstraintViolationError do + it "inherits from Hanami::Model::ConstraintViolationError" do + expect(described_class.ancestors).to include(Hanami::Model::ConstraintViolationError) + end + + it "has a default error message" do + expect { raise described_class }.to raise_error(described_class, "Unique constraint has been violated") + end + + it "allows custom error message" do + expect { raise described_class.new("Ouch") }.to raise_error(described_class, "Ouch") + end +end diff --git a/model-main/spec/unit/hanami/model/version_spec.rb b/model-main/spec/unit/hanami/model/version_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8b6ca79953b57079318aa6b86fa36181358ceb37 --- /dev/null +++ b/model-main/spec/unit/hanami/model/version_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe "Hanami::Model::VERSION" do + it "exposes version" do + expect(Hanami::Model::VERSION).to eq("1.3.3") + end +end