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
+
+[](https://badge.fury.io/rb/hanami-model)
+[](https://github.com/hanami/model/actions?query=workflow%3Aci+branch%3Amain)
+[](https://codecov.io/gh/hanami/model)
+[](https://depfu.com/github/hanami/model?project=Bundler)
+[](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