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