diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e8b782a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + ruby: ["3.1", "3.2", "3.3"] + gemfile: + - gemfiles/Gemfile.activemodel-6.1 + - gemfiles/Gemfile.activemodel-7.0 + - gemfiles/Gemfile.activemodel-7.1 + - gemfiles/Gemfile.activemodel-7.2 + - gemfiles/Gemfile.activemodel-8.0 + exclude: + - ruby: "3.1" + gemfile: gemfiles/Gemfile.activemodel-8.0 + + env: + BUNDLE_GEMFILE: ${{ matrix.gemfile }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run specs + run: bundle exec rake spec diff --git a/.gitignore b/.gitignore index f72c9df9..baf083d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /Gemfile.lock +gemfiles/*.lock /pkg /tmp /coverage +*.code-workspace diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml new file mode 100644 index 00000000..17e43a98 --- /dev/null +++ b/.qlty/qlty.toml @@ -0,0 +1,57 @@ +# This file configures Qlty code quality and coverage tools +# For configuration guide: https://qlty.sh/d/config +# For full reference: https://qlty.sh/d/qlty-toml + +config_version = "0" + +exclude_patterns = [ + "*_min.*", + "*-min.*", + "*.min.*", + "**/*.d.ts", + "**/node_modules/**", + "**/vendor/**", + "**/coverage/**", + "**/tmp/**", + "**/pkg/**", + "**/gemfiles/**", + "**/.bundle/**", +] + +test_patterns = [ + "**/test/**", + "**/spec/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.*", + "**/*_spec.*", +] + +[smells] +mode = "comment" + +[[source]] +name = "default" +default = true + +[[plugin]] +name = "actionlint" + +[[plugin]] +name = "markdownlint" +drivers = ["lint"] +mode = "comment" + +[[plugin]] +name = "ripgrep" +mode = "comment" + +[[plugin]] +name = "trivy" +drivers = ["config"] + +[[plugin]] +name = "trufflehog" + +[[plugin]] +name = "yamllint" diff --git a/.rspec b/.rspec index f78983b8..74167f07 100644 --- a/.rspec +++ b/.rspec @@ -1 +1 @@ ---colour --format=documentation +--color --format=documentation diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index 675d73b2..00000000 --- a/.rubocop.yml +++ /dev/null @@ -1,31 +0,0 @@ -inherit_from: .rubocop_todo.yml - -Layout/CaseIndentation: - EnforcedStyle: end - -Layout/EmptyLinesAroundClassBody: - EnforcedStyle: beginning_only - -Lint/AmbiguousBlockAssociation: - Enabled: false - -Lint/AmbiguousOperator: - Enabled: false - -Lint/AssignmentInCondition: - Enabled: false - -Naming/UncommunicativeMethodParamName: - AllowedNames: io, id, to, by - -Style/AsciiComments: - Enabled: false - -Style/GuardClause: - Enabled: false - -Style/RescueModifier: - Enabled: false - -Style/SymbolArray: - Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml deleted file mode 100644 index a54fa0a6..00000000 --- a/.rubocop_todo.yml +++ /dev/null @@ -1,232 +0,0 @@ -# This configuration was generated by -# `rubocop --auto-gen-config` -# on 2018-05-16 14:33:11 -0300 using RuboCop version 0.54.0. -# The point is for the user to remove these configuration records -# one by one as the offenses are removed from the code base. -# Note that changes in the inspected code, or installation of new -# versions of RuboCop, may require this file to be generated again. - -# Offense count: 2 -# Configuration parameters: Include. -# Include: **/*.gemfile, **/Gemfile, **/gems.rb -Bundler/DuplicatedGem: - Exclude: - - 'Gemfile' - -# Offense count: 1 -Lint/EmptyWhen: - Exclude: - - 'lib/her/model/parse.rb' - -# Offense count: 1 -Lint/IneffectiveAccessModifier: - Exclude: - - 'lib/her/api.rb' - -# Offense count: 6 -# Cop supports --auto-correct. -# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods. -Lint/UnusedMethodArgument: - Exclude: - - 'lib/her/api.rb' - - 'lib/her/model/associations/association.rb' - - 'lib/her/model/attributes.rb' - - 'lib/her/model/orm.rb' - - 'spec/model/attributes_spec.rb' - -# Offense count: 1 -# Configuration parameters: ContextCreatingMethods, MethodCreatingMethods. -Lint/UselessAccessModifier: - Exclude: - - 'lib/her/api.rb' - -# Offense count: 10 -Metrics/AbcSize: - Max: 38 - -# Offense count: 91 -# Configuration parameters: CountComments, ExcludedMethods. -Metrics/BlockLength: - Max: 694 - -# Offense count: 4 -Metrics/CyclomaticComplexity: - Max: 12 - -# Offense count: 10 -# Configuration parameters: CountComments. -Metrics/MethodLength: - Max: 28 - -# Offense count: 1 -# Configuration parameters: CountComments. -Metrics/ModuleLength: - Max: 106 - -# Offense count: 4 -Metrics/PerceivedComplexity: - Max: 12 - -# Offense count: 4 -Naming/MemoizedInstanceVariableName: - Exclude: - - 'lib/her/model/associations.rb' - - 'lib/her/model/attributes.rb' - - 'lib/her/model/relation.rb' - -# Offense count: 7 -# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist, MethodDefinitionMacros. -# NamePrefix: is_, has_, have_ -# NamePrefixBlacklist: is_, has_, have_ -# NameWhitelist: is_a? -# MethodDefinitionMacros: define_method, define_singleton_method -Naming/PredicateName: - Exclude: - - 'spec/**/*' - - 'lib/her/model/associations.rb' - - 'lib/her/model/attributes.rb' - - 'lib/her/model/base.rb' - - 'lib/her/model/deprecated_methods.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -Performance/RegexpMatch: - Exclude: - - 'spec/support/macros/model_macros.rb' - -# Offense count: 23 -Style/Documentation: - Enabled: false - -# Offense count: 21 -# Cop supports --auto-correct. -Style/Encoding: - Enabled: false - -# Offense count: 2 -# Cop supports --auto-correct. -Style/ExpandPathArguments: - Exclude: - - 'her.gemspec' - - 'spec/spec_helper.rb' - -# Offense count: 59 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: when_needed, always, never -Style/FrozenStringLiteralComment: - Enabled: false - -# Offense count: 89 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols. -# SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys -Style/HashSyntax: - Enabled: false - -# Offense count: 6 -# Cop supports --auto-correct. -Style/IfUnlessModifier: - Exclude: - - 'lib/her/json_api/model.rb' - - 'lib/her/model/associations/association_proxy.rb' - - 'lib/her/model/nested_attributes.rb' - - 'lib/her/model/orm.rb' - - 'lib/her/model/parse.rb' - -# Offense count: 2 -Style/MethodMissing: - Exclude: - - 'lib/her/model/associations/association_proxy.rb' - - 'lib/her/model/relation.rb' - -# Offense count: 2 -# Cop supports --auto-correct. -Style/MutableConstant: - Exclude: - - 'lib/her/model/http.rb' - - 'lib/her/version.rb' - -# Offense count: 3 -# Cop supports --auto-correct. -Style/PerlBackrefs: - Exclude: - - 'lib/her/model/paths.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, AllowInnerSlashes. -# SupportedStyles: slashes, percent_r, mixed -Style/RegexpLiteral: - Exclude: - - 'lib/her/model/paths.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: ConvertCodeThatCanStartToReturnNil, Whitelist. -# Whitelist: present?, blank?, presence, try -Style/SafeNavigation: - Exclude: - - 'spec/model/orm_spec.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: use_perl_names, use_english_names -Style/SpecialGlobalVars: - Exclude: - - 'her.gemspec' - -# Offense count: 1613 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. -# SupportedStyles: single_quotes, double_quotes -Style/StringLiterals: - Enabled: false - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: single_quotes, double_quotes -Style/StringLiteralsInInterpolation: - Exclude: - - 'lib/her/model/introspection.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: IgnoredMethods. -# IgnoredMethods: respond_to, define_method -Style/SymbolProc: - Exclude: - - 'lib/her/model/parse.rb' - -# Offense count: 2 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, AllowSafeAssignment. -# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex -Style/TernaryParentheses: - Exclude: - - 'lib/her/model/http.rb' - - 'lib/her/model/orm.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyleForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma -Style/TrailingCommaInHashLiteral: - Exclude: - - 'lib/her/middleware/json_api_parser.rb' - -# Offense count: 6 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, MinSize, WordRegex. -# SupportedStyles: percent, brackets -Style/WordArray: - Exclude: - - 'spec/model/orm_spec.rb' - -# Offense count: 409 -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. -# URISchemes: http, https -Metrics/LineLength: - Max: 377 diff --git a/.ruby-version b/.ruby-version index 530cdd91..5f6fc5ed 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.2.4 +3.3.10 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b693a557..00000000 --- a/.travis.yml +++ /dev/null @@ -1,55 +0,0 @@ -language: ruby - -sudo: false - -rvm: - - 1.9.3 - - 2.0.0 - - 2.1.6 - - 2.2.10 - - 2.3.7 - - 2.4.3 - - 2.5.1 - - 2.6.6 - -gemfile: - - gemfiles/Gemfile.activemodel-4.2 - - gemfiles/Gemfile.activemodel-5.0 - - gemfiles/Gemfile.activemodel-5.1 - - gemfiles/Gemfile.activemodel-5.2 - - gemfiles/Gemfile.faraday-1.0 - -matrix: - exclude: - - gemfile: gemfiles/Gemfile.activemodel-5.0 - rvm: 1.9.3 - - gemfile: gemfiles/Gemfile.activemodel-5.0 - rvm: 2.0.0 - - gemfile: gemfiles/Gemfile.activemodel-5.0 - rvm: 2.1.6 - - gemfile: gemfiles/Gemfile.activemodel-5.1 - rvm: 1.9.3 - - gemfile: gemfiles/Gemfile.activemodel-5.1 - rvm: 2.0.0 - - gemfile: gemfiles/Gemfile.activemodel-5.1 - rvm: 2.1.6 - - gemfile: gemfiles/Gemfile.activemodel-5.2 - rvm: 1.9.3 - - gemfile: gemfiles/Gemfile.activemodel-5.2 - rvm: 2.0.0 - - gemfile: gemfiles/Gemfile.activemodel-5.2 - rvm: 2.1.6 - - gemfile: gemfiles/Gemfile.faraday-1.0 - rvm: 1.9.3 - - gemfile: gemfiles/Gemfile.faraday-1.0 - rvm: 2.0.0 - - gemfile: gemfiles/Gemfile.faraday-1.0 - rvm: 2.1.6 - - gemfile: gemfiles/Gemfile.faraday-1.0 - rvm: 2.2.10 - -before_install: - - gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true - - gem install bundler -v '< 2' - -script: "echo 'COME ON!' && bundle exec rake spec" diff --git a/Gemfile b/Gemfile index 6892d730..3be9c3cd 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,2 @@ source "https://rubygems.org" gemspec - -if RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] && RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] >= '1.9.3' - gem 'activemodel', '>= 3.2.0' - gem 'activesupport', '>= 3.2.0' -else - gem 'activemodel', '~> 3.2.0' - gem 'activesupport', '~> 3.2.0' -end diff --git a/LICENSE b/LICENSE index 84c3209c..bdc8be0e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2012-2015 Rémi Prévost +Copyright (c) 2012-2026 Rémi Prévost 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: diff --git a/README.md b/README.md index 798a912c..21d60601 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,10 @@ -

- - Her - -
- Her is an ORM (Object Relational Mapper) that maps REST resources to Ruby objects.
It is designed to build applications that are powered by a RESTful API instead of a database. -

- - - - - Gitter chat -

+# Her + +Her is an ORM (Object Relational Mapper) that maps REST resources to Ruby objects. +It is designed to build applications that are powered by a RESTful API instead of a database. + +**Requirements:** Ruby >= 3.1, ActiveModel >= 6.1, Faraday >= 2.0 --- @@ -27,8 +20,6 @@ That’s it! ## Usage -_For a complete reference of all the methods you can use, check out [the documentation](http://rdoc.info/github/remiprev/her)._ - First, you have to define which API your models will be bound to. For example, with Rails, you would create a new `config/initializers/her.rb` file with these lines: ```ruby @@ -41,7 +32,7 @@ Her::API.setup url: "https://api.example.com" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -91,10 +82,6 @@ user.fullname = "Lindsay Fünke" # OR user.assign_attributes(fullname: "Lindsay user.save # returns false if it fails, errors in user.response_errors array # PUT "/users/1" with `fullname=Lindsay+Fünke` -user.update_attributes(fullname: "Maeby Fünke") -# PUT "/users/1" with `fullname=Maeby+Fünke` - -# => PUT /users/1 { "id": 1, "name": "new new name" } # Update a resource without fetching it User.save_existing(1, fullname: "Lindsay Fünke") # PUT "/users/1" with `fullname=Lindsay+Fünke` @@ -124,8 +111,6 @@ user.save! # raises Her::Errors::ResourceInvalid if it fails # POST "/users" with `fullname=Maeby+Fünke` ``` -You can look into the [`her-example`](https://github.com/remiprev/her-example) repository for a sample application using Her. - ## Middleware Since Her relies on [Faraday](https://github.com/lostisland/faraday) to send HTTP requests, you can choose the middleware used to handle requests and responses. Using the block in the `setup` call, you have access to Faraday’s `connection` object and are able to customize the middleware stack used on each request and response. @@ -139,7 +124,7 @@ For example, to add a token header to your API requests in a Rails application, ```ruby # app/controllers/application_controller.rb class ApplicationController < ActionController::Base - before_filter :set_user_api_token + before_action :set_user_api_token protected def set_user_api_token @@ -167,27 +152,28 @@ Her::API.setup url: "https://api.example.com" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` Now, each HTTP request made by Her will have the `X-API-Token` header. -### Basic Http Authentication -Her can use basic http auth by adding a line to your initializer +### Basic HTTP Authentication + +Her can use basic HTTP auth by adding a line to your initializer: ```ruby # config/initializers/her.rb Her::API.setup url: "https://api.example.com" do |c| # Request - c.use Faraday::Request::BasicAuthentication, 'myusername', 'mypassword' + c.request :authorization, :basic, 'myusername', 'mypassword' c.use Faraday::Request::UrlEncoded # Response c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -222,7 +208,7 @@ Her::API.setup url: "https://api.twitter.com/1/" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end class Tweet @@ -258,9 +244,9 @@ Also, you can define your own parsing method using a response middleware. The mi # "errors": [] # } # -class MyCustomParser < Faraday::Response::Middleware +class MyCustomParser < Faraday::Middleware def on_complete(env) - json = MultiJson.load(env[:body], symbolize_keys: true) + json = JSON.parse(env[:body], symbolize_names: true) env[:body] = { data: json[:result], errors: json[:errors], @@ -274,7 +260,7 @@ Her::API.setup url: "https://api.example.com" do |c| c.use MyCustomParser # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -301,7 +287,7 @@ Her::API.setup url: "https://api.example.com" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end class User @@ -662,7 +648,7 @@ Her::API.setup url: 'https://my_awesome_json_api_service' do |c| c.use Her::Middleware::JsonApiParser # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -869,7 +855,7 @@ MY_API.setup url: "https://my-api.example.com" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end OTHER_API = Her::API.new @@ -878,7 +864,7 @@ OTHER_API.setup url: "https://other-api.example.com" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -913,7 +899,7 @@ Her::API.setup url: "https://api.example.com", ssl: ssl_options do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -1009,57 +995,12 @@ end See the [UPGRADE.md](https://github.com/remiprev/her/blob/master/UPGRADE.md) for backward compatibility issues. -## Her IRL - -Most projects I know that use Her are internal or private projects but here’s a list of public ones: - -* [tumbz](https://github.com/remiprev/tumbz) -* [zoho-ruby](https://github.com/errorstudio/zoho-ruby) -* [crowdher](https://github.com/simonprev/crowdher) -* [vodka](https://github.com/magnolia-fan/vodka) -* [webistrano_cli](https://github.com/chytreg/webistrano_cli) -* [ASMALLWORLD](https://www.asmallworld.com) - -## History - -I told myself a few months ago that it would be great to build a gem to replace Rails’ [ActiveResource](http://api.rubyonrails.org/classes/ActiveResource/Base.html) since it was barely maintained (and now removed from Rails 4.0), lacking features and hard to extend/customize. I had built a few of these REST-powered ORMs for client projects before but I decided I wanted to write one for myself that I could release as an open-source project. - -Most of Her’s core concepts were written on a Saturday morning of April 2012 ([first commit](https://github.com/remiprev/her/commit/689d8e88916dc2ad258e69a2a91a283f061cbef2) at 7am!). - -## Maintainers -The gem is currently maintained by [@zacharywelch](https://github.com/zacharywelch) and [@edtjones](https://github.com/edtjones). - ## Contribute -Yes please! Feel free to contribute and submit issues/pull requests [on GitHub](https://github.com/remiprev/her/issues). There’s no such thing as a bad pull request — even if it’s for a typo, a small improvement to the code or the documentation! +Feel free to contribute and submit issues/pull requests [on GitHub](https://github.com/remiprev/her/issues). See [CONTRIBUTING.md](https://github.com/remiprev/her/blob/master/CONTRIBUTING.md) for best practices. -### Contributors - -These [fine folks](https://github.com/remiprev/her/contributors) helped with Her: - -* [@jfcixmedia](https://github.com/jfcixmedia) -* [@EtienneLem](https://github.com/EtienneLem) -* [@rafaelss](https://github.com/rafaelss) -* [@tysontate](https://github.com/tysontate) -* [@nfo](https://github.com/nfo) -* [@simonprevost](https://github.com/simonprevost) -* [@jmlacroix](https://github.com/jmlacroix) -* [@thomsbg](https://github.com/thomsbg) -* [@calmyournerves](https://github.com/calmyournerves) -* [@luflux](https://github.com/luxflux) -* [@simonc](https://github.com/simonc) -* [@pencil](https://github.com/pencil) -* [@joanniclaborde](https://github.com/joanniclaborde) -* [@seanreads](https://github.com/seanreads) -* [@jonkarna](https://github.com/jonkarna) -* [@aclevy](https://github.com/aclevy) -* [@stevschmid](https://github.com/stevschmid) -* [@prognostikos](https://github.com/prognostikos) -* [@dturnerTS](https://github.com/dturnerTS) -* [@kritik](https://github.com/kritik) - ## License -Her is © 2012-2013 [Rémi Prévost](http://exomel.com) and may be freely distributed under the [MIT license](https://github.com/remiprev/her/blob/master/LICENSE). See the `LICENSE` file. +Her is © 2012-2026 [Rémi Prévost](http://exomel.com) and may be freely distributed under the [MIT license](https://github.com/remiprev/her/blob/master/LICENSE). See the `LICENSE` file. diff --git a/gemfiles/Gemfile.activemodel-4.2 b/gemfiles/Gemfile.activemodel-4.2 deleted file mode 100644 index bf7286a3..00000000 --- a/gemfiles/Gemfile.activemodel-4.2 +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -gem 'activemodel', '~> 4.2.0' -gem 'faraday', '~> 0.8.9' diff --git a/gemfiles/Gemfile.activemodel-5.0 b/gemfiles/Gemfile.activemodel-5.0 deleted file mode 100644 index 2b646676..00000000 --- a/gemfiles/Gemfile.activemodel-5.0 +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -gem 'activemodel', '~> 5.0.0' -gem 'faraday', '~> 0.8.9' diff --git a/gemfiles/Gemfile.activemodel-5.1 b/gemfiles/Gemfile.activemodel-5.1 deleted file mode 100644 index 5b1b26ae..00000000 --- a/gemfiles/Gemfile.activemodel-5.1 +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -gem 'activemodel', '~> 5.1.0' -gem 'faraday', '~> 0.8.9' diff --git a/gemfiles/Gemfile.activemodel-5.2 b/gemfiles/Gemfile.activemodel-5.2 deleted file mode 100644 index f8d18985..00000000 --- a/gemfiles/Gemfile.activemodel-5.2 +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -gem 'activemodel', '~> 5.2.0' -gem 'faraday', '~> 0.8.9' diff --git a/gemfiles/Gemfile.activemodel-6.1 b/gemfiles/Gemfile.activemodel-6.1 new file mode 100644 index 00000000..7dbef569 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-6.1 @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec path: "../" + +gem "activemodel", "~> 6.1.0" +gem "activesupport", "~> 6.1.0" diff --git a/gemfiles/Gemfile.activemodel-7.0 b/gemfiles/Gemfile.activemodel-7.0 new file mode 100644 index 00000000..021d8df4 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-7.0 @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec path: "../" + +gem "activemodel", "~> 7.0.0" +gem "activesupport", "~> 7.0.0" diff --git a/gemfiles/Gemfile.activemodel-7.1 b/gemfiles/Gemfile.activemodel-7.1 new file mode 100644 index 00000000..522400aa --- /dev/null +++ b/gemfiles/Gemfile.activemodel-7.1 @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec path: "../" + +gem "activemodel", "~> 7.1.0" +gem "activesupport", "~> 7.1.0" diff --git a/gemfiles/Gemfile.activemodel-7.2 b/gemfiles/Gemfile.activemodel-7.2 new file mode 100644 index 00000000..11cfbaed --- /dev/null +++ b/gemfiles/Gemfile.activemodel-7.2 @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec path: "../" + +gem "activemodel", "~> 7.2.0" +gem "activesupport", "~> 7.2.0" diff --git a/gemfiles/Gemfile.activemodel-8.0 b/gemfiles/Gemfile.activemodel-8.0 new file mode 100644 index 00000000..b48a1551 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-8.0 @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec path: "../" + +gem "activemodel", "~> 8.0.0" +gem "activesupport", "~> 8.0.0" diff --git a/gemfiles/Gemfile.faraday-1.0 b/gemfiles/Gemfile.faraday-1.0 deleted file mode 100644 index 89edfe1c..00000000 --- a/gemfiles/Gemfile.faraday-1.0 +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -gem 'activemodel', '~> 4.2.1' -gem 'faraday', '~> 1.0' diff --git a/her.gemspec b/her.gemspec index 17976122..a935337f 100644 --- a/her.gemspec +++ b/her.gemspec @@ -1,4 +1,4 @@ -# -*- encoding: utf-8 -*- +# frozen_string_literal: true $:.push File.expand_path("../lib", __FILE__) require "her/version" @@ -8,21 +8,21 @@ Gem::Specification.new do |s| s.version = Her::VERSION s.authors = ["Rémi Prévost"] s.email = ["remi@exomel.com"] - s.homepage = "http://her-rb.org" + s.homepage = "https://github.com/TwilightCoders/him" s.license = "MIT" s.summary = "A simple Representational State Transfer-based Hypertext Transfer Protocol-powered Object Relational Mapper. Her?" s.description = "Her is an ORM that maps REST resources and collections to Ruby objects" + s.required_ruby_version = ">= 3.1" + s.files = `git ls-files`.split("\n") s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } s.require_paths = ["lib"] - s.add_development_dependency "json", "~> 1.8" - s.add_development_dependency "rake", "~> 10.0" + s.add_development_dependency "rake", ">= 13.0" s.add_development_dependency "rspec", "~> 3.5" - s.add_runtime_dependency "activemodel", ">= 4.2.1" - s.add_runtime_dependency "faraday", ">= 0.8" - s.add_runtime_dependency "multi_json", "~> 1.7" + s.add_runtime_dependency "activemodel", ">= 6.1" + s.add_runtime_dependency "faraday", ">= 2.0" end diff --git a/lib/her.rb b/lib/her.rb index a34ac837..17df42b5 100644 --- a/lib/her.rb +++ b/lib/her.rb @@ -1,6 +1,6 @@ require "her/version" -require "multi_json" +require "json" require "faraday" require "active_support" require "active_support/inflector" diff --git a/lib/her/collection.rb b/lib/her/collection.rb index a5d5cf9f..a33fe704 100644 --- a/lib/her/collection.rb +++ b/lib/her/collection.rb @@ -9,5 +9,13 @@ def initialize(items = [], metadata = {}, errors = {}) @metadata = metadata @errors = errors end + + %i[select reject collect map compact flatten uniq reverse + sort sort_by sample shuffle slice drop take first last].each do |method| + define_method(method) do |*args, &block| + result = super(*args, &block) + result.is_a?(Array) ? self.class.new(result, @metadata, @errors) : result + end + end end end diff --git a/lib/her/middleware/json_api_parser.rb b/lib/her/middleware/json_api_parser.rb index 51e7baab..9b091165 100644 --- a/lib/her/middleware/json_api_parser.rb +++ b/lib/her/middleware/json_api_parser.rb @@ -12,13 +12,41 @@ class JsonApiParser < ParseJSON def parse(body) json = parse_json(body) + included = json.fetch(:included, []) + primary_data = json.fetch(:data, {}) + Array(primary_data).each do |resource| + next unless resource.is_a?(Hash) + relationships = resource.delete(:relationships) { {} } + resource[:attributes].merge!(resolve_relationships(relationships, included)) + end + { - :data => json[:data] || {}, - :errors => json[:errors] || [], - :metadata => json[:meta] || {}, + data: primary_data || {}, + errors: json[:errors] || [], + metadata: json[:meta] || {} } end + private + + def resolve_relationships(relationships, included) + return {} if included.empty? || relationships.nil? + relationships.each_with_object({}) do |(rel_name, linkage), built| + linkage_data = linkage.fetch(:data, {}) + built[rel_name] = if linkage_data.is_a?(Array) + linkage_data.map { |l| find_included(l, included) }.compact + else + find_included(linkage_data, included) + end + end + end + + def find_included(linkage, included) + included.detect { |i| i.values_at(:id, :type) == linkage.values_at(:id, :type) } + end + + public + # This method is triggered when the response has been received. It modifies # the value of `env[:body]`. # diff --git a/lib/her/middleware/parse_json.rb b/lib/her/middleware/parse_json.rb index 7bae5950..b15021f2 100644 --- a/lib/her/middleware/parse_json.rb +++ b/lib/her/middleware/parse_json.rb @@ -1,6 +1,6 @@ module Her module Middleware - class ParseJSON < Faraday::Response::Middleware + class ParseJSON < Faraday::Middleware # @private def parse_json(body = nil) @@ -8,8 +8,8 @@ def parse_json(body = nil) message = "Response from the API must behave like a Hash or an Array (last JSON response was #{body.inspect})" json = begin - MultiJson.load(body, :symbolize_keys => true) - rescue MultiJson::LoadError + JSON.parse(body, symbolize_names: true) + rescue JSON::ParserError raise Her::Errors::ParseError, message end diff --git a/lib/her/model.rb b/lib/her/model.rb index 43929e5e..cbb62d65 100644 --- a/lib/her/model.rb +++ b/lib/her/model.rb @@ -1,5 +1,4 @@ require "her/model/base" -require "her/model/deprecated_methods" require "her/model/http" require "her/model/attributes" require "her/model/relation" @@ -27,7 +26,6 @@ module Model # Her modules include Her::Model::Base - include Her::Model::DeprecatedMethods include Her::Model::Attributes include Her::Model::ORM include Her::Model::HTTP @@ -44,6 +42,12 @@ module Model include ActiveModel::Conversion include ActiveModel::Dirty + # Override ActiveModel::Dirty's attribute_changed_in_place? to use + # Her's change tracking. This allows validates_numericality_of to work. + def attribute_changed_in_place?(attribute_name) + !changes[attribute_name.to_s].nil? + end + # Class methods included do # Assign the default API diff --git a/lib/her/model/associations/association.rb b/lib/her/model/associations/association.rb index 3548dc80..cf8f8b10 100644 --- a/lib/her/model/associations/association.rb +++ b/lib/her/model/associations/association.rb @@ -4,7 +4,7 @@ module Associations class Association # @private - attr_accessor :params + attr_accessor :params, :cached_result # @private def initialize(parent, opts = {}) @@ -16,6 +16,12 @@ def initialize(parent, opts = {}) @name = @opts[:name] end + # Returns the foreign key to use when building child records, + # respecting the :foreign_key option or falling back to convention. + def foreign_key + @opts[:foreign_key]&.to_sym || :"#{@parent.singularized_resource_name}_id" + end + # @private def self.proxy(parent, opts = {}) AssociationProxy.new new(parent, opts) @@ -48,9 +54,22 @@ def fetch(opts = {}) return @parent.attributes[@name] unless @params.any? || @parent.attributes[@name].blank? return @opts[:default].try(:dup) if @parent.new? - path = build_association_path -> { "#{@parent.request_path(@params)}#{@opts[:path]}" } - @klass.get(path, @params).tap do |result| - @cached_result = result unless @params.any? + if @params.values.include?([]) && !is_a?(HasOneAssociation) + Her::Collection.new + else + path = build_association_path -> { "#{@parent.request_path(@params)}#{@opts[:path]}" } + request_association(path, @params).tap do |result| + @cached_result = result unless @params.any? + end + end + end + + # @private + def request_association(path, params) + if @opts[:default].is_a?(Array) + @klass.get_collection(path, params) + else + @klass.get(path, params) end end diff --git a/lib/her/model/associations/association_proxy.rb b/lib/her/model/associations/association_proxy.rb index 36bd2243..682334af 100644 --- a/lib/her/model/associations/association_proxy.rb +++ b/lib/her/model/associations/association_proxy.rb @@ -1,7 +1,7 @@ module Her module Model module Associations - class AssociationProxy < (ActiveSupport.const_defined?('ProxyObject') ? ActiveSupport::ProxyObject : ActiveSupport::BasicObject) + class AssociationProxy < BasicObject # @private def self.install_proxy_methods(target_name, *names) @@ -17,6 +17,9 @@ def #{name}(*args, &block) install_proxy_methods :association, :build, :create, :where, :find, :all, :assign_nested_attributes, :reload + install_proxy_methods 'association.fetch', + :class, :inspect, :==, :kind_of?, :is_a?, :respond_to?, :nil?, :hash, :eql? + # @private def initialize(association) @_her_association = association diff --git a/lib/her/model/associations/has_many_association.rb b/lib/her/model/associations/has_many_association.rb index 23ed797a..fe4698af 100644 --- a/lib/her/model/associations/has_many_association.rb +++ b/lib/her/model/associations/has_many_association.rb @@ -49,10 +49,15 @@ def self.parse(association, klass, data) # user = User.find(1) # new_comment = user.comments.build(:body => "Hello!") # new_comment # => # - # TODO: This only merges the id of the parents, handle the case - # where this is more deeply nested def build(attributes = {}) - @klass.build(attributes.merge(:"#{@parent.singularized_resource_name}_id" => @parent.id)) + resource = @klass.build(attributes.merge(foreign_key => @parent.id)) + + collection = @cached_result || @parent.attributes[@name] || Her::Collection.new + collection << resource + @cached_result = collection + @parent.attributes[@name] = collection + + resource end # Create a new object, save it and add it to the associated collection @@ -73,9 +78,10 @@ def build(attributes = {}) def create(attributes = {}) resource = build(attributes) - if resource.save - @parent.attributes[@name] ||= Her::Collection.new - @parent.attributes[@name] << resource + unless resource.save + # Remove the built resource from the collection if save failed + @cached_result&.delete(resource) + @parent.attributes[@name]&.delete(resource) end resource @@ -85,7 +91,14 @@ def create(attributes = {}) def fetch super.tap do |o| inverse_of = @opts[:inverse_of] || @parent.singularized_resource_name - o.each { |entry| entry.attributes[inverse_of] = @parent } + o.each do |entry| + inverse_association = entry.send(inverse_of).association rescue nil + if inverse_association.present? + inverse_association.cached_result = @parent + else + entry.attributes[inverse_of] = @parent + end + end end end diff --git a/lib/her/model/associations/has_one_association.rb b/lib/her/model/associations/has_one_association.rb index 80cb3cd3..f62884f0 100644 --- a/lib/her/model/associations/has_one_association.rb +++ b/lib/her/model/associations/has_one_association.rb @@ -45,7 +45,7 @@ def self.parse(*args) # new_role = user.role.build(:title => "moderator") # new_role # => # def build(attributes = {}) - @klass.build(attributes.merge(:"#{@parent.singularized_resource_name}_id" => @parent.id)) + @klass.build(attributes.merge(foreign_key => @parent.id)) end # Create a new object, save it and associate it to the parent diff --git a/lib/her/model/attributes.rb b/lib/her/model/attributes.rb index 38759312..42eeaffc 100644 --- a/lib/her/model/attributes.rb +++ b/lib/her/model/attributes.rb @@ -28,6 +28,7 @@ def initialize(attributes = {}) @metadata = attributes.delete(:_metadata) || {} @response_errors = attributes.delete(:_errors) || {} @destroyed = attributes.delete(:_destroyed) || false + @_her_new_record = attributes.delete(:_new_record) { true } attributes = self.class.default_scope.apply_to(attributes) assign_attributes(attributes) @@ -35,6 +36,16 @@ def initialize(attributes = {}) run_callbacks :initialize end + # Ensure dup/clone creates a deep copy of mutable state + # + # @private + def initialize_copy(other) + super + @_her_attributes = other.attributes.dup + @metadata = other.metadata.dup if other.metadata + @response_errors = other.response_errors.dup if other.response_errors + end + # Handles missing methods # # @private @@ -55,9 +66,19 @@ def method_missing(method, *args, &blk) # @private def respond_to_missing?(method, include_private = false) + return false if Thread.current[:her_respond_to_missing] method.to_s =~ /[?=]$/ || @_her_attributes.include?(method) || super end + # Check if the model responds to a method without considering + # method_missing-based dynamic attributes. + def respond_to_without_missing?(method, include_private = false) + Thread.current[:her_respond_to_missing] = true + respond_to?(method, include_private) + ensure + Thread.current[:her_respond_to_missing] = false + end + # Assign new attributes to a resource # # @example @@ -167,7 +188,8 @@ def instantiate_record(klass, parsed_data) record else attributes = klass.parse(record).merge(_metadata: parsed_data[:metadata], - _errors: parsed_data[:errors]) + _errors: parsed_data[:errors], + _new_record: false) klass.new(attributes).tap do |record_instance| record_instance.send :clear_changes_information record_instance.run_callbacks :find @@ -179,7 +201,9 @@ def instantiate_record(klass, parsed_data) # # @private def instantiate_collection(klass, parsed_data = {}) - records = klass.extract_array(parsed_data).map do |record| + raw_data = klass.extract_array(parsed_data) + raw_data = [] if raw_data.blank? + records = raw_data.map do |record| instantiate_record(klass, data: record) end Her::Collection.new(records, parsed_data[:metadata], parsed_data[:errors]) @@ -206,13 +230,13 @@ def new_from_parsed_data(parsed_data) # # @private def use_setter_methods(model, params = {}) - reserved = [:id, model.class.primary_key, *model.class.association_keys] - model.class.attributes *params.keys.reject { |k| reserved.include?(k) } + reserved = [:id, :class, :attributes, model.class.primary_key, *model.class.association_keys] + settable_params = params.reject { |k, _| reserved.include?(k.to_sym) } + model.class.attributes *settable_params.keys - setter_method_names = model.class.setter_method_names - params.each_with_object({}) do |(key, value), memo| + settable_params.each_with_object(params.slice(*reserved)) do |(key, value), memo| setter_method = "#{key}=" - if setter_method_names.include?(setter_method) + if model.respond_to_without_missing?(setter_method) model.send setter_method, value else memo[key.to_sym] = value @@ -286,15 +310,6 @@ def store_metadata(value = nil) store_her_data(:metadata, value) end - # @private - def setter_method_names - @_her_setter_method_names ||= begin - instance_methods.each_with_object(Set.new) do |method, memo| - memo << method.to_s if method.to_s.end_with?('=') - end - end - end - private # @private diff --git a/lib/her/model/base.rb b/lib/her/model/base.rb index fe08f839..d44c3f7c 100644 --- a/lib/her/model/base.rb +++ b/lib/her/model/base.rb @@ -1,6 +1,6 @@ module Her module Model - # This module includes basic functionnality to Her::Model + # This module includes basic functionality to Her::Model module Base extend ActiveSupport::Concern @@ -16,7 +16,7 @@ def has_key?(attribute_name) # Returns # * the value of the attribute_name attribute if it's in orm data - # * the resource/collection corrsponding to attribute_name if it's an association + # * the resource/collection corresponding to attribute_name if it's an association # # @private def [](attribute_name) diff --git a/lib/her/model/deprecated_methods.rb b/lib/her/model/deprecated_methods.rb deleted file mode 100644 index 61b500c5..00000000 --- a/lib/her/model/deprecated_methods.rb +++ /dev/null @@ -1,61 +0,0 @@ -module Her - module Model - # @private - module DeprecatedMethods - extend ActiveSupport::Concern - - def self.deprecate!(old, new, object, *args) - line = begin - raise StandardError - rescue StandardError => e - e.backtrace[2] - end - - warn "#{line} - The `#{old}` method is deprecated and may be removed soon. Please update your code with `#{new}` instead." - object.send(new, *args) - end - - def data(*args) - Her::Model::DeprecatedMethods.deprecate! :data, :attributes, self, *args - end - - def data=(*args) - Her::Model::DeprecatedMethods.deprecate! :data=, :attributes=, self, *args - end - - def update_attributes(*args) - Her::Model::DeprecatedMethods.deprecate! :update_attributes, :assign_attributes, self, *args - end - - def assign_data(*args) - Her::Model::DeprecatedMethods.deprecate! :assign_data, :assign_attributes, self, *args - end - - def has_data?(*args) - Her::Model::DeprecatedMethods.deprecate! :has_data?, :has_attribute?, self, *args - end - - def get_data(*args) - Her::Model::DeprecatedMethods.deprecate! :get_data, :get_attribute, self, *args - end - - module ClassMethods - def has_relationship?(*args) - Her::Model::DeprecatedMethods.deprecate! :has_relationship?, :has_association?, self, *args - end - - def get_relationship(*args) - Her::Model::DeprecatedMethods.deprecate! :get_relationship, :get_association, self, *args - end - - def relationships(*args) - Her::Model::DeprecatedMethods.deprecate! :relationships, :associations, self, *args - end - - def her_api(*args) - Her::Model::DeprecatedMethods.deprecate! :her_api, :use_api, self, *args - end - end - end - end -end diff --git a/lib/her/model/http.rb b/lib/her/model/http.rb index 71c4f344..319e5479 100644 --- a/lib/her/model/http.rb +++ b/lib/her/model/http.rb @@ -54,11 +54,13 @@ def use_api(value = nil) # @private def request(params = {}) request = her_api.request(params) + parsed_data = request[:parsed_data] + parsed_data = parsed_data.symbolize_keys if parsed_data.respond_to?(:symbolize_keys) if block_given? - yield request[:parsed_data], request[:response] + yield parsed_data, request[:response] else - { :parsed_data => request[:parsed_data], :response => request[:response] } + { parsed_data: parsed_data, response: request[:response] } end end @@ -68,7 +70,7 @@ def #{method}(path, params={}) path = build_request_path_from_string_or_symbol(path, params) params = to_params(params) unless #{method.to_sym.inspect} == :get send(:'#{method}_raw', path, params) do |parsed_data, response| - if parsed_data[:data].is_a?(Array) || active_model_serializers_format? || json_api_format? + if parsed_data[:data].is_a?(Array) || json_api_format? || (active_model_serializers_format? && parsed_data[:data].is_a?(Hash) && parsed_data[:data].key?(pluralized_parsed_root_element)) new_collection(parsed_data) else new_from_parsed_data(parsed_data) @@ -96,17 +98,9 @@ def #{method}_resource(path, params={}) end def custom_#{method}(*paths) - metaclass = (class << self; self; end) - - # TODO: Remove this check after January 2020 - if paths.last.is_a?(Hash) - warn("[DEPRECATION] options for custom request methods are deprecated and will be removed on or after January 2020.") - paths.pop - end - paths.each do |path| - metaclass.send(:define_method, path) do |*params| - params = params.first || Hash.new + singleton_class.send(:define_method, path) do |*params| + params = params.first || {} send(#{method.to_sym.inspect}, path, params) end end diff --git a/lib/her/model/introspection.rb b/lib/her/model/introspection.rb index 34f15926..fe282a17 100644 --- a/lib/her/model/introspection.rb +++ b/lib/her/model/introspection.rb @@ -12,13 +12,23 @@ module Introspection # @user = User.find(1) # p @user # => # def inspect + first = Thread.current[:her_inspect_objects].nil? + Thread.current[:her_inspect_objects] = [] if first + resource_path = begin request_path rescue Her::Errors::PathError => e "" end - "#<#{self.class}(#{resource_path}) #{attributes.keys.map { |k| "#{k}=#{attribute_for_inspect(send(k))}" }.join(" ")}>" + if Thread.current[:her_inspect_objects].include?(object_id) + "#<#{self.class}(#{resource_path}) ...>" + else + Thread.current[:her_inspect_objects] << object_id + "#<#{self.class}(#{resource_path}) #{attributes.keys.map { |k| "#{k}=#{attribute_for_inspect(send(k))}" }.join(" ")}>" + end + ensure + Thread.current[:her_inspect_objects] = nil if first end private diff --git a/lib/her/model/orm.rb b/lib/her/model/orm.rb index e15fc666..b588037e 100644 --- a/lib/her/model/orm.rb +++ b/lib/her/model/orm.rb @@ -8,7 +8,7 @@ module ORM # Return `true` if a resource was not saved yet def new? - id.nil? + @_her_new_record end alias new_record? new? @@ -39,12 +39,18 @@ def destroyed? def save callback = new? ? :create : :update method = self.class.method_for(callback) + path = if new? + self.class.build_request_path(self.class.collection_path, attributes.except(self.class.primary_key)) + else + request_path + end run_callbacks :save do run_callbacks callback do - self.class.request(to_params.merge(:_method => method, :_path => request_path)) do |parsed_data, response| + self.class.request(to_params.merge(_method: method, _path: path)) do |parsed_data, response| load_from_parsed_data(parsed_data) return false if !response.success? || @response_errors.any? + @_her_new_record = false changes_applied end end @@ -105,7 +111,7 @@ def increment!(attribute, by = 1) self end - # Initializes +attribute+ to zero if +nil+ and substracts the value passed as + # Initializes +attribute+ to zero if +nil+ and subtracts the value passed as # +by+ (default is 1). The decrement is performed directly on the # underlying attribute, no setter is invoked. Only makes sense for # number-based attributes. Returns +self+. @@ -182,7 +188,7 @@ module ClassMethods # User.page(2).all # Called via GET "/users?page=2" def scope(name, code) # Add the scope method to the class - (class << self; self end).send(:define_method, name) do |*args| + singleton_class.send(:define_method, name) do |*args| instance_exec(*args, &code) end @@ -235,7 +241,7 @@ def save_existing(id, params) # Similar to .save_existing but raises ResourceInvalid if save fails def save_existing!(id, params) - resource = new(params.merge(primary_key => id)) + resource = new(params.merge(primary_key => id, _new_record: false)) resource.save! resource end @@ -280,9 +286,10 @@ def build(attributes = {}) method = method_for(:new) resource = nil - request(params.merge(:_method => method, :_path => path)) do |parsed_data, response| + request(params.merge(_method: method, _path: path)) do |parsed_data, response| if response.success? resource = new_from_parsed_data(parsed_data) + resource.instance_variable_set(:@_her_new_record, true) end end resource diff --git a/lib/her/model/parse.rb b/lib/her/model/parse.rb index 51b4a31a..d60bb0d4 100644 --- a/lib/her/model/parse.rb +++ b/lib/her/model/parse.rb @@ -42,9 +42,9 @@ def to_params(attributes, changes = {}) memo[key.to_sym] = value end - filtered_attributes.merge!(embeded_params(attributes)) + filtered_attributes.merge!(embedded_params(attributes)) - if her_api.options[:send_only_modified_attributes] + if her_api.options[:send_only_modified_attributes] && changes.any? filtered_attributes.slice! *changes.keys.map(&:to_sym) end @@ -60,7 +60,7 @@ def to_params(attributes, changes = {}) end # @private - def embeded_params(attributes) + def embedded_params(attributes) associations.values.flatten.each_with_object({}) do |definition, hash| value = case association = attributes[definition[:name]] when Her::Collection, Array @@ -173,10 +173,11 @@ def included_root_element # # @private def extract_array(request_data) - if request_data[:data].is_a?(Hash) && (active_model_serializers_format? || json_api_format?) - request_data[:data][pluralized_parsed_root_element] + data = request_data[:data] + if data.is_a?(Hash) && (parse_root_in_json? || active_model_serializers_format? || json_api_format?) + data[pluralized_parsed_root_element] else - request_data[:data] + data end end diff --git a/lib/her/model/relation.rb b/lib/her/model/relation.rb index df476392..2be23978 100644 --- a/lib/her/model/relation.rb +++ b/lib/her/model/relation.rb @@ -67,10 +67,14 @@ def kind_of?(thing) # @private def fetch @_fetch ||= begin - path = @parent.build_request_path(@parent.collection_path, @params) - method = @parent.method_for(:find) - @parent.request(@params.merge(:_method => method, :_path => path)) do |parsed_data, _| - @parent.new_collection(parsed_data) + if @params.values.include?([]) + Her::Collection.new + else + path = @parent.build_request_path(@parent.collection_path, @params) + method = @parent.method_for(:find) + @parent.request(@params.merge(:_method => method, :_path => path)) do |parsed_data, _| + @parent.new_collection(parsed_data) + end end end end @@ -98,7 +102,6 @@ def find(*ids) @parent.request(request_params) do |parsed_data, response| if response.success? resource = @parent.new_from_parsed_data(parsed_data) - resource.run_callbacks :find else return nil end diff --git a/spec/api_spec.rb b/spec/api_spec.rb index 6bfd4093..5348859a 100644 --- a/spec/api_spec.rb +++ b/spec/api_spec.rb @@ -32,7 +32,7 @@ class Bar; end describe "#request" do before do - class SimpleParser < Faraday::Response::Middleware + class SimpleParser < Faraday::Middleware def on_complete(env) env[:body] = { data: env[:body] } @@ -71,7 +71,7 @@ def on_complete(env) subject.setup url: "https://api.example.com" do |builder| builder.use Her::Middleware::FirstLevelParseJSON builder.adapter :test do |stub| - stub.get("/users/1") { [200, {}, MultiJson.dump(id: 1, name: "George Michael Bluth", errors: ["This is a single error"], metadata: { page: 1, per_page: 10 })] } + stub.get("/users/1") { [200, {}, JSON.generate(id: 1, name: "George Michael Bluth", errors: ["This is a single error"], metadata: { page: 1, per_page: 10 })] } end end end @@ -86,10 +86,10 @@ def on_complete(env) context "parsing a request with a custom parser" do let(:parsed_data) { subject.request(_method: :get, _path: "users/1")[:parsed_data] } before do - class CustomParser < Faraday::Response::Middleware + class CustomParser < Faraday::Middleware def on_complete(env) - json = MultiJson.load(env[:body], symbolize_keys: true) + json = JSON.parse(env[:body], symbolize_names: true) errors = json.delete(:errors) || [] metadata = json.delete(:metadata) || {} env[:body] = { @@ -104,7 +104,7 @@ def on_complete(env) builder.use CustomParser builder.use Faraday::Request::UrlEncoded builder.adapter :test do |stub| - stub.get("/users/1") { [200, {}, MultiJson.dump(id: 1, name: "George Michael Bluth")] } + stub.get("/users/1") { [200, {}, JSON.generate(id: 1, name: "George Michael Bluth")] } end end end diff --git a/spec/collection_spec.rb b/spec/collection_spec.rb index ecfe0748..05d5a666 100644 --- a/spec/collection_spec.rb +++ b/spec/collection_spec.rb @@ -38,4 +38,33 @@ end end end + + describe "Array methods preserve Collection type" do + subject { Her::Collection.new(items, metadata, errors) } + + it "returns a Collection from #select" do + result = subject.select(&:odd?) + expect(result).to be_a(Her::Collection) + expect(result).to eq([1, 3]) + expect(result.metadata).to eq(name: "Testname") + end + + it "returns a Collection from #reject" do + result = subject.reject(&:odd?) + expect(result).to be_a(Her::Collection) + expect(result).to eq([2, 4]) + end + + it "returns a Collection from #map" do + result = subject.map { |x| x * 2 } + expect(result).to be_a(Her::Collection) + expect(result).to eq([2, 4, 6, 8]) + end + + it "returns a Collection from #first with count" do + result = subject.first(2) + expect(result).to be_a(Her::Collection) + expect(result).to eq([1, 2]) + end + end end diff --git a/spec/json_api/model_spec.rb b/spec/json_api/model_spec.rb index 019e34ec..e9c26b28 100644 --- a/spec/json_api/model_spec.rb +++ b/spec/json_api/model_spec.rb @@ -166,4 +166,95 @@ end end end + + context "compound document" do + before do + Her::API.setup url: "https://api.example.com" do |connection| + connection.use Her::Middleware::JsonApiParser + connection.adapter :test do |stub| + stub.get("/players") do + [ + 200, + {}, + { + data: [ + { + id: 1, + type: "players", + attributes: { name: "Roger Federer" }, + relationships: { + sponsors: { + data: [ + { type: "sponsors", id: 1 }, + { type: "sponsors", id: 2 } + ] + }, + racquet: { + data: { type: "racquets", id: 1 } + } + } + }, + { + id: 2, + type: "players", + attributes: { name: "Kei Nishikori" }, + relationships: { + sponsors: { + data: [ + { type: "sponsors", id: 2 }, + { type: "sponsors", id: 3 } + ] + }, + racquet: { + data: { type: "racquets", id: 2 } + } + } + }, + { + id: 3, + type: "players", + attributes: { name: "Hubert Huang", racquet_id: nil }, + relationships: {} + } + ], + included: [ + { type: "sponsors", id: 1, attributes: { company: "Nike" } }, + { type: "sponsors", id: 2, attributes: { company: "Wilson" } }, + { type: "sponsors", id: 3, attributes: { company: "Uniqlo" } }, + { type: "racquets", id: 1, attributes: { name: "Wilson Pro Staff" } }, + { type: "racquets", id: 2, attributes: { name: "Wilson Steam" } } + ] + }.to_json + ] + end + + stub.get("/players/3/sponsors") do + [200, {}, { data: [] }.to_json] + end + end + end + + spawn_model("Foo::Sponsor", type: Her::JsonApi::Model) + spawn_model("Foo::Racquet", type: Her::JsonApi::Model) + spawn_model("Foo::Player", type: Her::JsonApi::Model) do + has_many :sponsors + belongs_to :racquet + end + end + + it "parses included resources into associations from compound documents" do + players = Foo::Player.all.to_a + fed = players.detect { |p| p.name == "Roger Federer" } + expect(fed.sponsors.map(&:company)).to match_array ["Nike", "Wilson"] + expect(fed.racquet.name).to eq "Wilson Pro Staff" + + kei = players.detect { |p| p.name == "Kei Nishikori" } + expect(kei.sponsors.map(&:company)).to match_array ["Uniqlo", "Wilson"] + expect(kei.racquet.name).to eq "Wilson Steam" + + hubert = players.detect { |p| p.name == "Hubert Huang" } + expect(hubert.sponsors).to eq [] + expect(hubert.racquet).to be_nil + end + end end diff --git a/spec/model/associations_spec.rb b/spec/model/associations_spec.rb index e44eb712..8018c6a4 100644 --- a/spec/model/associations_spec.rb +++ b/spec/model/associations_spec.rb @@ -279,6 +279,7 @@ before do spawn_model "Foo::User" do has_many :comments, class_name: "Foo::Comment" + has_many :feeds, class_name: "Foo::Feed" has_one :role, class_name: "Foo::Role" belongs_to :organization, class_name: "Foo::Organization" has_many :posts, inverse_of: :admin @@ -289,6 +290,10 @@ parse_root_in_json true end + spawn_model "Foo::Feed" do + belongs_to :user + end + spawn_model "Foo::Post" do belongs_to :admin, class_name: "Foo::User" end @@ -308,6 +313,7 @@ builder.adapter :test do |stub| stub.get("/users/1") { [200, {}, { id: 1, name: "Tobias Fünke", comments: [{ comment: { id: 2, body: "Tobias, you blow hard!", user_id: 1 } }, { comment: { id: 3, body: "I wouldn't mind kissing that man between the cheeks, so to speak", user_id: 1 } }], role: { id: 1, body: "Admin" }, organization: { id: 1, name: "Bluth Company" }, organization_id: 1 }.to_json] } stub.get("/users/1/comments") { [200, {}, [{ comment: { id: 4, body: "They're having a FIRESALE?" } }].to_json] } + stub.get("/users/1/feeds") { [204, {}, ""] } stub.get("/users/1/role") { [200, {}, { id: 3, body: "User" }.to_json] } stub.get("/users/1/posts") { [200, {}, [{ id: 1, body: "blogging stuff", admin_id: 1 }].to_json] } stub.get("/organizations/1") { [200, {}, { organization: { id: 1, name: "Bluth Company Foo" } }.to_json] } @@ -325,6 +331,12 @@ expect(user.comments.first.body).to eq("Tobias, you blow hard!") end + it "handles a 204 empty response on has_many" do + expect(user.feeds).to eq([]) + expect(user.feeds.first).to eq(nil) + expect(user.feeds.length).to eq(0) + end + it "does not refetch the parents models data if they have been fetched before" do expect(user.comments.first.user.object_id).to eq(user.object_id) end @@ -333,6 +345,11 @@ expect(user.posts.first.admin.object_id).to eq(user.object_id) end + it "does not set the inverse of a has_many as an attribute" do + first_comment = user.comments.where(foo_id: 1).first + expect(first_comment.attributes.keys).not_to include("user") + end + it "fetches has_many data even if it was included, only if called with parameters" do expect(user.comments.where(foo_id: 1).length).to eq(1) end @@ -485,6 +502,11 @@ expect(new_user.role).to be_nil expect(new_user.organization).to be_nil end + + it "reports nil associations as blank" do + expect(new_user.role.blank?).to be true + expect(new_user.organization.blank?).to be true + end end context "when foreign_key is nil" do @@ -643,6 +665,9 @@ builder.adapter :test do |stub| stub.get("/users/1") { [200, {}, { user: { id: 1, name: "Tobias Fünke", comments: [{ id: 2, body: "Tobias, you blow hard!", user_id: 1 }, { id: 3, body: "I wouldn't mind kissing that man between the cheeks, so to speak", user_id: 1 }], role: { id: 1, body: "Admin" }, organization: { id: 1, name: "Bluth Company" }, organization_id: 1 } }.to_json] } stub.get("/users/1/comments") { [200, {}, { comments: [{ id: 4, body: "They're having a FIRESALE?" }] }.to_json] } + stub.get("/users/1/role") { [200, {}, { role: { id: 3, body: "User" } }.to_json] } + stub.get("/users/2") { [200, {}, { user: { id: 2, name: "Lindsay Fünke", organization_id: 1 } }.to_json] } + stub.get("/users/2/role") { [200, {}, { role: { id: 4, body: "Viewer" } }.to_json] } stub.get("/organizations/1") { [200, {}, { organization: { id: 1, name: "Bluth Company Foo" } }.to_json] } end end @@ -676,6 +701,19 @@ expect(user.organization.where(foo_id: 1).name).to eq("Bluth Company Foo") end + it "maps included data through has_one with AMS format" do + expect(user.role).to be_a(Foo::Role) + expect(user.role.id).to eq(1) + expect(user.role.body).to eq("Admin") + end + + it "fetches has_one data not included with AMS format" do + user2 = Foo::User.find(2) + expect(user2.role).to be_a(Foo::Role) + expect(user2.role.id).to eq(4) + expect(user2.role.body).to eq("Viewer") + end + it "includes has_many relationships in params by default" do expect(user_params[:comments]).to be_kind_of(Array) expect(user_params[:comments].length).to eq(2) @@ -862,6 +900,20 @@ def present? end end + context "with #build using custom foreign_key" do + before do + spawn_model "Foo::Article" + spawn_model "Foo::User" do + has_many :articles, foreign_key: :creator_id + end + end + + it "uses the declared foreign_key instead of convention" do + article = Foo::User.new(id: 5).articles.build(title: "Hello") + expect(article.creator_id).to eq(5) + end + end + context "with #create" do let(:user) { Foo::User.find(10) } let(:comment) { user.comments.create(body: "Hello!") } @@ -888,6 +940,53 @@ def present? end end + context "with #build appending to existing collection" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Her::Middleware::FirstLevelParseJSON + builder.use Faraday::Request::UrlEncoded + builder.adapter :test do |stub| + stub.get("/users/1") { [200, {}, { id: 1 }.to_json] } + stub.get("/users/1/comments") { [200, {}, [{ id: 1, body: "Existing", user_id: 1 }].to_json] } + end + end + + Foo::User.use_api Her::API.default_api + Foo::Comment.use_api Her::API.default_api + end + + it "appends built resource to the existing collection" do + user = Foo::User.find(1) + expect(user.comments.length).to eq(1) + user.comments.build(body: "New!") + expect(user.comments.length).to eq(2) + end + end + + context "with #create when collection already has data" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Her::Middleware::FirstLevelParseJSON + builder.use Faraday::Request::UrlEncoded + builder.adapter :test do |stub| + stub.get("/users/10") { [200, {}, { id: 10 }.to_json] } + stub.get("/users/10/comments") { [200, {}, [{ id: 1, body: "Existing", user_id: 10 }].to_json] } + stub.post("/comments") { |env| [200, {}, { id: 2, body: Faraday::Utils.parse_query(env[:body])["body"], user_id: 10 }.to_json] } + end + end + + Foo::User.use_api Her::API.default_api + Foo::Comment.use_api Her::API.default_api + end + + it "appends to the existing collection" do + user = Foo::User.find(10) + expect(user.comments.length).to eq(1) + user.comments.create(body: "New!") + expect(user.comments.length).to eq(2) + end + end + context "with #new" do let(:user) { Foo::User.new(name: "vic", comments: [comment]) } diff --git a/spec/model/attributes_spec.rb b/spec/model/attributes_spec.rb index 64f9d9ff..a57dafed 100644 --- a/spec/model/attributes_spec.rb +++ b/spec/model/attributes_spec.rb @@ -74,6 +74,12 @@ expect(@new_user.get_attribute(:unknown_method_for_a_user)).to be_nil expect(@new_user.get_attribute(:'life-span')).to eq("3 years") end + + it "handles attribute_changed_in_place?" do + @new_user = Foo::User.new + @new_user.fullname = "Schmoo" + expect(@new_user.attribute_changed_in_place?(:fullname)).to be_truthy + end end context "assigning new resource data" do @@ -246,6 +252,18 @@ def document=(document) @user = Foo::User.find(1) expect(@user.document).to eq("http://example.com") end + + it "exposes the method to respond_to? and respond_to_without_missing?" do + @user = Foo::User.find(1) + expect(@user.respond_to?(:document=)).to be_truthy + expect(@user.respond_to_without_missing?(:document=)).to be_truthy + end + + it "exposes a non-existent method to respond_to? but not respond_to_without_missing?" do + @user = Foo::User.find(1) + expect(@user.respond_to?(:nonexistent=)).to be_truthy + expect(@user.respond_to_without_missing?(:nonexistent=)).to be_falsey + end end context "for predicate method" do @@ -312,6 +330,18 @@ def document? expect(user.fullname?).to be_truthy end + it "does not define a getter for attributes matching reserved words" do + user = Foo::User.new + user.assign_attributes(class: "klass") + expect(user.class.name).to eq("Foo::User") + end + + it "does not crash when data includes an 'attributes' key" do + user = Foo::User.new(attributes: { foo: "bar" }) + expect(user.attributes).to be_a(Hash) + expect(user.attributes[:attributes]).to eq("foo" => "bar") + end + context "when attribute methods are already defined" do before do class AbstractUser diff --git a/spec/model/callbacks_spec.rb b/spec/model/callbacks_spec.rb index f0f30ac3..29f1efb9 100644 --- a/spec/model/callbacks_spec.rb +++ b/spec/model/callbacks_spec.rb @@ -127,6 +127,24 @@ def alter_name end end + context "callback invocation count" do + before do + spawn_model "User" do + attr_accessor :find_count + after_find :track_find + def track_find + self.find_count ||= 0 + self.find_count += 1 + end + end + end + + it "calls after_find exactly once" do + user = User.find(1) + expect(user.find_count).to eq(1) + end + end + context "when using a block callback" do before do spawn_model "User" do diff --git a/spec/model/http_spec.rb b/spec/model/http_spec.rb index e88da45b..cb709d0a 100644 --- a/spec/model/http_spec.rb +++ b/spec/model/http_spec.rb @@ -183,19 +183,5 @@ end end - context "with options" do - before do - allow(Foo::User).to receive(:warn) - Foo::User.custom_get :popular, foo: "bar" - end - - it "issues DEPRECATION warning" do - expect(Foo::User).to have_received(:warn).with("[DEPRECATION] options for custom request methods are deprecated and will be removed on or after January 2020.") - end - - it "makes HTTP request" do - expect(Foo::User.popular.length).to be 2 - end - end end end diff --git a/spec/model/introspection_spec.rb b/spec/model/introspection_spec.rb index a2037197..12c53a06 100644 --- a/spec/model/introspection_spec.rb +++ b/spec/model/introspection_spec.rb @@ -52,6 +52,35 @@ def password end end + describe "#inspect with cyclic associations" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Her::Middleware::FirstLevelParseJSON + builder.adapter :test do |stub| + stub.get("/users/1") { [200, {}, { id: 1, name: "Tobias", comments: [{ id: 2, body: "Hey!", user_id: 1 }] }.to_json] } + end + end + + spawn_model "Foo::User" do + has_many :comments, class_name: "Foo::Comment" + end + spawn_model "Foo::Comment" do + belongs_to :user, class_name: "Foo::User" + end + end + + it "does not infinitely recurse" do + user = Foo::User.find(1) + expect { Timeout.timeout(5) { user.inspect } }.not_to raise_error + end + + it "truncates cyclic references with ..." do + user = Foo::User.find(1) + output = user.inspect + expect(output).to include("Foo::User") + end + end + describe "#inspect with errors in resource path" do it "prints the resource path as “unknown”" do @comment = Foo::Comment.where(project_id: 1).first diff --git a/spec/model/orm_spec.rb b/spec/model/orm_spec.rb index 7bd5a3fa..dd12b236 100644 --- a/spec/model/orm_spec.rb +++ b/spec/model/orm_spec.rb @@ -58,6 +58,12 @@ expect(@existing_user.new_record?).to be_falsey end + it "treats new resource with pre-assigned id as new" do + @user = Foo::User.new(id: 5, fullname: "Tobias Fünke") + expect(@user.new?).to be_truthy + expect(@user.persisted?).to be_falsey + end + it "handles new resource with custom primary key" do @new_user = Foo::AdminUser.new(fullname: "Lindsay Fünke", id: -1) expect(@new_user).to be_new @@ -65,6 +71,7 @@ @existing_user = Foo::AdminUser.find(1) expect(@existing_user).not_to be_new end + end context "mapping data, metadata and error data to Ruby objects" do @@ -392,6 +399,12 @@ def friends expect(@company.save).to be_falsey expect(@company.name).to eq("Company Inc.") end + + it "POSTs to collection path even with a pre-assigned id" do + @user = Foo::User.create(id: 1234, fullname: "Tobias Fünke") + expect(@user.fullname).to eq("Tobias Fünke") + expect(@user).to be_persisted + end end context "updating resources" do diff --git a/spec/model/parse_spec.rb b/spec/model/parse_spec.rb index 25b617a8..8d81555d 100644 --- a/spec/model/parse_spec.rb +++ b/spec/model/parse_spec.rb @@ -358,6 +358,99 @@ def to_params end end + context "when parse_root_in_json is true without AMS format" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Her::Middleware::FirstLevelParseJSON + builder.adapter :test do |stub| + stub.get("/users") { [200, {}, { users: [{ id: 1, name: "Tobias" }] }.to_json] } + stub.get("/users/1") { [200, {}, { user: { id: 1, name: "Tobias" } }.to_json] } + end + end + + spawn_model "Foo::User" do + parse_root_in_json true + end + end + + it "unwraps collection root key" do + users = Foo::User.all + expect(users.length).to eq(1) + expect(users.first.name).to eq("Tobias") + end + + it "unwraps single resource root key" do + user = Foo::User.find(1) + expect(user.name).to eq("Tobias") + end + end + + context "when associations reference each other" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Her::Middleware::FirstLevelParseJSON + builder.use Faraday::Request::UrlEncoded + end + + Her::API.default_api.connection.adapter :test do |stub| + stub.get("/users/1") { |env| [200, {}, { id: 1, name: "Tobias", comments: [{ id: 2, body: "Hey!", user_id: 1 }] }.to_json] } + end + + spawn_model "Foo::User" do + has_many :comments, class_name: "Foo::Comment" + end + + spawn_model "Foo::Comment" do + belongs_to :user, class_name: "Foo::User" + end + end + + it "does not infinitely loop when calling to_params with cyclic associations" do + user = Foo::User.find(1) + expect { Timeout.timeout(5) { user.to_params } }.not_to raise_error + end + + it "does not include belongs_to parent data in nested to_params" do + user = Foo::User.find(1) + params = user.to_params + comment_params = params[:comments].first + expect(comment_params).not_to have_key(:user) + end + end + + context "when parsed_data has string keys" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Class.new(Faraday::Middleware) { + def on_complete(env) + env[:body] = { + "data" => JSON.parse(env[:body]), + "metadata" => {}, + "errors" => {} + } + end + } + builder.adapter :test do |stub| + stub.get("/users") { [200, {}, [{ "id" => 1, "name" => "Tobias" }].to_json] } + stub.get("/users/1") { [200, {}, { "id" => 1, "name" => "Tobias" }.to_json] } + end + end + + spawn_model "Foo::User" + end + + it "handles collections with string-keyed parsed data" do + users = Foo::User.all + expect(users.length).to eq(1) + expect(users.first.name).to eq("Tobias") + end + + it "handles single resources with string-keyed parsed data" do + user = Foo::User.find(1) + expect(user.name).to eq("Tobias") + end + end + context "when parse_root_in_json set json_api to true" do before do Her::API.setup url: "https://api.example.com" do |builder| @@ -468,6 +561,12 @@ def to_params user.first_name = "Someone" expect(user.to_params).to eql(user: { first_name: "Someone" }) end + + it "sends all attributes when changes is empty (custom actions)" do + user = Foo::User.find 1 + params = Foo::User.to_params(user.attributes) + expect(params).to eql(user: { id: 1, first_name: "Gooby", last_name: "Pls" }) + end end context 'when passed a non-Her ActiveModel instance' do diff --git a/spec/model/relation_spec.rb b/spec/model/relation_spec.rb index b8bd658c..617bba38 100644 --- a/spec/model/relation_spec.rb +++ b/spec/model/relation_spec.rb @@ -31,7 +31,7 @@ spawn_model "Foo::User" end - it "doesn't fetch the data immediatly" do + it "doesn't fetch the data immediately" do expect(Foo::User).to receive(:request).never @users = Foo::User.where(admin: 1) end @@ -60,6 +60,13 @@ expect(Foo::User.create(fullname: "George Michael Bluth").id).to eq(3) expect(Foo::User.all.size).to eql 3 end + + it "returns an empty collection without fetching when [] is given" do + expect(Foo::User).not_to receive(:request) + users = Foo::User.where(fullname: []) + expect(users).to respond_to(:length) + expect(users.size).to eql 0 + end end context "for parent class" do @@ -172,6 +179,7 @@ class User < Foo::Model; end expect(Foo::User.scoped.baz.params[:where]).to eq(6) expect(Bar::User.scoped.baz.params[:where]).to eq(7) end + end describe :default_scope do diff --git a/spec/model/validations_spec.rb b/spec/model/validations_spec.rb index 511e5fb5..cd7663cb 100644 --- a/spec/model/validations_spec.rb +++ b/spec/model/validations_spec.rb @@ -6,9 +6,10 @@ context "validating attributes" do before do spawn_model "Foo::User" do - attributes :fullname, :email + attributes :fullname, :email, :age validates_presence_of :fullname validates_presence_of :email + validates_numericality_of :age end end @@ -19,6 +20,7 @@ expect(user.errors.full_messages).to include("Email can't be blank") user.fullname = "Tobias Fünke" user.email = "tobias@bluthcompany.com" + user.age = 35 expect(user).to be_valid end end diff --git a/spec/model_spec.rb b/spec/model_spec.rb index 4031950e..74727b8b 100644 --- a/spec/model_spec.rb +++ b/spec/model_spec.rb @@ -42,4 +42,14 @@ specify { expect(subject[:name]).to eq("Tobias Fünke") } specify { expect(subject[:comments].first.body).to eq("They're having a FIRESALE?") } end + + describe :dup do + it "does not share mutable state between original and copy" do + original = Foo::User.find(1) + copy = original.dup + copy.name = "Lindsay Fünke" + expect(original.name).to eq("Tobias Fünke") + expect(copy.name).to eq("Lindsay Fünke") + end + end end diff --git a/spec/support/extensions/array.rb b/spec/support/extensions/array.rb index 9c938e33..f1f48e98 100644 --- a/spec/support/extensions/array.rb +++ b/spec/support/extensions/array.rb @@ -1,6 +1,6 @@ class Array def to_json - MultiJson.dump(self) + JSON.generate(self) end end diff --git a/spec/support/extensions/hash.rb b/spec/support/extensions/hash.rb index 45c552ab..96879698 100644 --- a/spec/support/extensions/hash.rb +++ b/spec/support/extensions/hash.rb @@ -1,6 +1,6 @@ class Hash def to_json - MultiJson.dump(self) + JSON.generate(self) end end