Repository: hanami/model Branch: main Commit: f07823d79c9a Files: 150 Total size: 414.9 KB Directory structure: gitextract_p2apyb1u/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .jrubyrc ├── .rspec ├── .rubocop.yml ├── .yardopts ├── CHANGELOG.md ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── hanami-model.gemspec ├── lib/ │ ├── hanami/ │ │ ├── entity/ │ │ │ └── schema.rb │ │ ├── entity.rb │ │ ├── model/ │ │ │ ├── association.rb │ │ │ ├── associations/ │ │ │ │ ├── belongs_to.rb │ │ │ │ ├── dsl.rb │ │ │ │ ├── has_many.rb │ │ │ │ ├── has_one.rb │ │ │ │ └── many_to_many.rb │ │ │ ├── configuration.rb │ │ │ ├── configurator.rb │ │ │ ├── entity_name.rb │ │ │ ├── error.rb │ │ │ ├── mapped_relation.rb │ │ │ ├── mapping.rb │ │ │ ├── migration.rb │ │ │ ├── migrator/ │ │ │ │ ├── adapter.rb │ │ │ │ ├── connection.rb │ │ │ │ ├── logger.rb │ │ │ │ ├── mysql_adapter.rb │ │ │ │ ├── postgres_adapter.rb │ │ │ │ └── sqlite_adapter.rb │ │ │ ├── migrator.rb │ │ │ ├── plugins/ │ │ │ │ ├── mapping.rb │ │ │ │ ├── schema.rb │ │ │ │ └── timestamps.rb │ │ │ ├── plugins.rb │ │ │ ├── relation_name.rb │ │ │ ├── sql/ │ │ │ │ ├── console.rb │ │ │ │ ├── consoles/ │ │ │ │ │ ├── abstract.rb │ │ │ │ │ ├── mysql.rb │ │ │ │ │ ├── postgresql.rb │ │ │ │ │ └── sqlite.rb │ │ │ │ ├── entity/ │ │ │ │ │ └── schema.rb │ │ │ │ ├── types/ │ │ │ │ │ └── schema/ │ │ │ │ │ └── coercions.rb │ │ │ │ └── types.rb │ │ │ ├── sql.rb │ │ │ ├── types.rb │ │ │ └── version.rb │ │ ├── model.rb │ │ └── repository.rb │ └── hanami-model.rb ├── script/ │ └── ci └── spec/ ├── integration/ │ └── hanami/ │ └── model/ │ ├── associations/ │ │ ├── belongs_to_spec.rb │ │ ├── has_many_spec.rb │ │ ├── has_one_spec.rb │ │ ├── many_to_many_spec.rb │ │ └── relation_alias_spec.rb │ ├── migration/ │ │ ├── mysql.rb │ │ ├── postgresql.rb │ │ └── sqlite.rb │ ├── migration_spec.rb │ └── repository/ │ ├── base_spec.rb │ ├── command_spec.rb │ └── legacy_spec.rb ├── spec_helper.rb ├── support/ │ ├── database/ │ │ └── strategies/ │ │ ├── abstract.rb │ │ ├── mysql.rb │ │ ├── postgresql.rb │ │ ├── sql.rb │ │ └── sqlite.rb │ ├── database.rb │ ├── fixtures/ │ │ ├── database_migrations/ │ │ │ ├── 20150612081248_column_types.rb │ │ │ ├── 20150612084656_default_values.rb │ │ │ ├── 20150612093458_null_constraints.rb │ │ │ ├── 20150612093810_column_indexes.rb │ │ │ ├── 20150612094740_primary_keys.rb │ │ │ ├── 20150612115204_foreign_keys.rb │ │ │ ├── 20150612122233_table_constraints.rb │ │ │ ├── 20150612124205_table_alterations.rb │ │ │ ├── 20160830094800_create_users.rb │ │ │ ├── 20160830094851_create_authors.rb │ │ │ ├── 20160830094941_create_books.rb │ │ │ ├── 20160830095033_create_t_operator.rb │ │ │ ├── 20160905125728_create_source_files.rb │ │ │ ├── 20160909150704_create_avatars.rb │ │ │ ├── 20161104143844_create_warehouses.rb │ │ │ ├── 20161114094644_create_products.rb │ │ │ ├── 20170103142428_create_colors.rb │ │ │ ├── 20170124081339_create_labels.rb │ │ │ ├── 20170517115243_create_tokens.rb │ │ │ ├── 20170519172332_create_categories.rb │ │ │ └── 20171002201227_create_posts_and_comments.rb │ │ ├── empty_migrations/ │ │ │ └── .gitkeep │ │ └── migrations/ │ │ ├── 20160831073534_create_reviews.rb │ │ └── 20160831090612_add_rating_to_reviews.rb │ ├── fixtures.rb │ ├── platform/ │ │ ├── ci.rb │ │ ├── db.rb │ │ ├── engine.rb │ │ ├── matcher.rb │ │ └── os.rb │ ├── platform.rb │ ├── rspec.rb │ └── test_io.rb └── unit/ └── hanami/ ├── entity/ │ ├── automatic_schema_spec.rb │ ├── manual_schema/ │ │ ├── base_spec.rb │ │ ├── strict_spec.rb │ │ └── types_spec.rb │ ├── schema/ │ │ ├── definition_spec.rb │ │ └── schemaless_spec.rb │ ├── schema_spec.rb │ └── schemaless_spec.rb ├── entity_spec.rb └── model/ ├── check_constraint_validation_error_spec.rb ├── configuration_spec.rb ├── constraint_violation_error_spec.rb ├── disconnect_spec.rb ├── error_spec.rb ├── foreign_key_constraint_violation_error_spec.rb ├── load_spec.rb ├── mapped_relation_spec.rb ├── migrator/ │ ├── adapter_spec.rb │ ├── connection_spec.rb │ ├── mysql.rb │ ├── postgresql.rb │ └── sqlite.rb ├── migrator_spec.rb ├── not_null_constraint_violation_error_spec.rb ├── sql/ │ ├── console/ │ │ ├── mysql.rb │ │ ├── postgresql.rb │ │ └── sqlite.rb │ ├── console_spec.rb │ ├── entity/ │ │ └── schema/ │ │ ├── automatic_spec.rb │ │ └── mapping_spec.rb │ └── schema/ │ ├── array_spec.rb │ ├── bool_spec.rb │ ├── date_spec.rb │ ├── date_time_spec.rb │ ├── decimal_spec.rb │ ├── float_spec.rb │ ├── hash_spec.rb │ ├── int_spec.rb │ ├── string_spec.rb │ └── time_spec.rb ├── sql_spec.rb ├── unique_constraint_violation_error_spec.rb └── version_spec.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: hanami ================================================ FILE: .github/workflows/ci.yml ================================================ 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 ================================================ FILE: .gitignore ================================================ *.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 ================================================ FILE: .jrubyrc ================================================ debug.fullTrace=true ================================================ FILE: .rspec ================================================ --color --require spec_helper ================================================ FILE: .rubocop.yml ================================================ # 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 ================================================ FILE: .yardopts ================================================ --protected --private - LICENSE.md lib/**/*.rb ================================================ FILE: CHANGELOG.md ================================================ # 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` ================================================ FILE: Gemfile ================================================ # 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" ================================================ FILE: LICENSE.md ================================================ 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. ================================================ FILE: README.md ================================================ # Hanami::Model (deprecated) ## _Important notice_ **NOTE**: Hanami::Model was the persistence layer for Hanami 1.x. This library will not receive any updates. For the persistence layer for Hanami 2.x, please see [`hanami/db`](https://github.com/hanami/db) ## Contact * Home page: http://hanamirb.org * Mailing List: http://hanamirb.org/mailing-list * API Doc: http://rubydoc.info/gems/hanami-model * Chat: https://chat.hanamirb.org ## Rubies `Hanami::Model` **supports Hanami 1.x only**, and Ruby (MRI) 2.6 and 2.7. ## 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`). ================================================ FILE: Rakefile ================================================ # 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" ================================================ FILE: hanami-model.gemspec ================================================ # 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 ================================================ FILE: lib/hanami/entity/schema.rb ================================================ # 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 ================================================ FILE: lib/hanami/entity.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/association.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/associations/belongs_to.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/associations/dsl.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/associations/has_many.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/associations/has_one.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/associations/many_to_many.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/configuration.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/configurator.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/entity_name.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/error.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/mapped_relation.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/mapping.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/migration.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/migrator/adapter.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/migrator/connection.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/migrator/logger.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/migrator/mysql_adapter.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/migrator/postgres_adapter.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/migrator/sqlite_adapter.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/migrator.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/plugins/mapping.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/plugins/schema.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/plugins/timestamps.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/plugins.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/relation_name.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/sql/console.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/sql/consoles/abstract.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/sql/consoles/mysql.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/sql/consoles/postgresql.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/sql/consoles/sqlite.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/sql/entity/schema.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/sql/types/schema/coercions.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/sql/types.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/sql.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/types.rb ================================================ # 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 ================================================ FILE: lib/hanami/model/version.rb ================================================ # frozen_string_literal: true module Hanami module Model # Defines the version # # @since 0.1.0 VERSION = "1.3.3" end end ================================================ FILE: lib/hanami/model.rb ================================================ # 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 ================================================ FILE: lib/hanami/repository.rb ================================================ # 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 ================================================ FILE: lib/hanami-model.rb ================================================ # frozen_string_literal: true require "hanami/model" ================================================ FILE: script/ci ================================================ #!/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 ================================================ FILE: spec/integration/hanami/model/associations/belongs_to_spec.rb ================================================ # 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 ================================================ FILE: spec/integration/hanami/model/associations/has_many_spec.rb ================================================ # 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 ================================================ FILE: spec/integration/hanami/model/associations/has_one_spec.rb ================================================ # 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 ================================================ FILE: spec/integration/hanami/model/associations/many_to_many_spec.rb ================================================ # 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 ================================================ FILE: spec/integration/hanami/model/associations/relation_alias_spec.rb ================================================ # 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 ================================================ FILE: spec/integration/hanami/model/migration/mysql.rb ================================================ # 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 ================================================ FILE: spec/integration/hanami/model/migration/postgresql.rb ================================================ # 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 ================================================ FILE: spec/integration/hanami/model/migration/sqlite.rb ================================================ # 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 ================================================ FILE: spec/integration/hanami/model/migration_spec.rb ================================================ # frozen_string_literal: true require_relative "./migration/#{Database.engine}.rb" RSpec.describe "Hanami::Model.migration" do include_examples "migration_integration_#{Database.engine}" end ================================================ FILE: spec/integration/hanami/model/repository/base_spec.rb ================================================ # 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 ================================================ FILE: spec/integration/hanami/model/repository/command_spec.rb ================================================ # 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 ================================================ FILE: spec/integration/hanami/model/repository/legacy_spec.rb ================================================ # 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 ================================================ FILE: spec/spec_helper.rb ================================================ # 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" ================================================ FILE: spec/support/database/strategies/abstract.rb ================================================ # 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 ================================================ FILE: spec/support/database/strategies/mysql.rb ================================================ # 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 ================================================ FILE: spec/support/database/strategies/postgresql.rb ================================================ # 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 ================================================ FILE: spec/support/database/strategies/sql.rb ================================================ # 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 ================================================ FILE: spec/support/database/strategies/sqlite.rb ================================================ # 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 ================================================ FILE: spec/support/database.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20150612081248_column_types.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20150612084656_default_values.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20150612093458_null_constraints.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20150612093810_column_indexes.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20150612094740_primary_keys.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20150612115204_foreign_keys.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20150612122233_table_constraints.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20150612124205_table_alterations.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20160830094800_create_users.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20160830094851_create_authors.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20160830094941_create_books.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20160830095033_create_t_operator.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20160905125728_create_source_files.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20160909150704_create_avatars.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20161104143844_create_warehouses.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20161114094644_create_products.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20170103142428_create_colors.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20170124081339_create_labels.rb ================================================ # frozen_string_literal: true Hanami::Model.migration do change do create_table :labels do column :id, Integer end end end ================================================ FILE: spec/support/fixtures/database_migrations/20170517115243_create_tokens.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20170519172332_create_categories.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/database_migrations/20171002201227_create_posts_and_comments.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/empty_migrations/.gitkeep ================================================ ================================================ FILE: spec/support/fixtures/migrations/20160831073534_create_reviews.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures/migrations/20160831090612_add_rating_to_reviews.rb ================================================ # 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 ================================================ FILE: spec/support/fixtures.rb ================================================ # 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! ================================================ FILE: spec/support/platform/ci.rb ================================================ # 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 ================================================ FILE: spec/support/platform/db.rb ================================================ # frozen_string_literal: true module Platform module Db def self.db?(name) current == name end def self.current Database.engine end end end ================================================ FILE: spec/support/platform/engine.rb ================================================ # 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 ================================================ FILE: spec/support/platform/matcher.rb ================================================ # 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 ================================================ FILE: spec/support/platform/os.rb ================================================ # 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 ================================================ FILE: spec/support/platform.rb ================================================ # 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 ================================================ FILE: spec/support/rspec.rb ================================================ # 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 ================================================ FILE: spec/support/test_io.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/entity/automatic_schema_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/entity/schemaless_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/entity_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/check_constraint_validation_error_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/configuration_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/constraint_violation_error_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/disconnect_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/error_spec.rb ================================================ # frozen_string_literal: true RSpec.describe Hanami::Model::Error do it "inherits from StandardError" do expect(described_class.ancestors).to include(StandardError) end end ================================================ FILE: spec/unit/hanami/model/foreign_key_constraint_violation_error_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/load_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/mapped_relation_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/migrator/adapter_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/migrator/connection_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/migrator/mysql.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/migrator/postgresql.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/migrator/sqlite.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/migrator_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/not_null_constraint_violation_error_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/console/mysql.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/console/postgresql.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/console/sqlite.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/console_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/entity/schema/automatic_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/entity/schema/mapping_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/schema/array_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/schema/bool_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/schema/date_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/schema/date_time_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/schema/decimal_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/schema/float_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/schema/hash_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/schema/int_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/schema/string_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql/schema/time_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/sql_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/unique_constraint_violation_error_spec.rb ================================================ # 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 ================================================ FILE: spec/unit/hanami/model/version_spec.rb ================================================ # 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