├── .gitattributes ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── bin └── test ├── duck_record.gemspec ├── lib ├── core_ext │ └── array_without_blank.rb ├── duck_record.rb ├── duck_record │ ├── associations.rb │ ├── associations │ │ ├── association.rb │ │ ├── belongs_to_association.rb │ │ ├── builder │ │ │ ├── association.rb │ │ │ ├── belongs_to.rb │ │ │ ├── collection_association.rb │ │ │ ├── embeds_many.rb │ │ │ ├── embeds_one.rb │ │ │ ├── has_many.rb │ │ │ ├── has_one.rb │ │ │ └── singular_association.rb │ │ ├── collection_association.rb │ │ ├── collection_proxy.rb │ │ ├── embeds_association.rb │ │ ├── embeds_many_association.rb │ │ ├── embeds_many_proxy.rb │ │ ├── embeds_one_association.rb │ │ ├── foreign_association.rb │ │ ├── has_many_association.rb │ │ ├── has_one_association.rb │ │ └── singular_association.rb │ ├── attribute.rb │ ├── attribute │ │ └── user_provided_default.rb │ ├── attribute_assignment.rb │ ├── attribute_decorators.rb │ ├── attribute_methods.rb │ ├── attribute_methods │ │ ├── before_type_cast.rb │ │ ├── dirty.rb │ │ ├── read.rb │ │ ├── serialization.rb │ │ └── write.rb │ ├── attribute_mutation_tracker.rb │ ├── attribute_set.rb │ ├── attribute_set │ │ └── yaml_encoder.rb │ ├── attributes.rb │ ├── base.rb │ ├── callbacks.rb │ ├── coders │ │ ├── json.rb │ │ └── yaml_column.rb │ ├── core.rb │ ├── define_callbacks.rb │ ├── enum.rb │ ├── errors.rb │ ├── inheritance.rb │ ├── locale │ │ └── en.yml │ ├── model_schema.rb │ ├── nested_attributes.rb │ ├── nested_validate_association.rb │ ├── persistence.rb │ ├── readonly_attributes.rb │ ├── reflection.rb │ ├── serialization.rb │ ├── translation.rb │ ├── type.rb │ ├── type │ │ ├── array.rb │ │ ├── array_without_blank.rb │ │ ├── date.rb │ │ ├── date_time.rb │ │ ├── decimal_without_scale.rb │ │ ├── internal │ │ │ ├── abstract_json.rb │ │ │ └── timezone.rb │ │ ├── json.rb │ │ ├── registry.rb │ │ ├── serialized.rb │ │ ├── text.rb │ │ ├── time.rb │ │ └── unsigned_integer.rb │ ├── validations.rb │ ├── validations │ │ ├── subset.rb │ │ └── uniqueness_on_real_record.rb │ └── version.rb └── tasks │ └── acts_as_record_tasks.rake └── test ├── acts_as_record_test.rb ├── dummy ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── images │ │ │ └── .keep │ │ ├── javascripts │ │ │ └── application.js │ │ └── stylesheets │ │ │ └── application.css │ ├── channels │ │ └── application_cable │ │ │ ├── channel.rb │ │ │ └── connection.rb │ ├── controllers │ │ ├── application_controller.rb │ │ └── concerns │ │ │ └── .keep │ ├── helpers │ │ └── application_helper.rb │ ├── jobs │ │ └── application_job.rb │ ├── models │ │ ├── application_record.rb │ │ ├── concerns │ │ │ └── .keep │ │ ├── contact.rb │ │ ├── post.rb │ │ ├── profile.rb │ │ └── user.rb │ └── views │ │ └── layouts │ │ └── application.html.erb ├── bin │ ├── bundle │ ├── rails │ ├── rake │ ├── setup │ └── update ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── cable.yml │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── application_controller_renderer.rb │ │ ├── backtrace_silencers.rb │ │ ├── cookies_serializer.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── new_framework_defaults.rb │ │ ├── session_store.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── routes.rb │ ├── secrets.yml │ └── spring.rb ├── db │ ├── migrate │ │ ├── 20170325065439_create_contacts.rb │ │ ├── 20180106214409_create_users.rb │ │ ├── 20180106214441_create_profiles.rb │ │ └── 20180106214458_create_posts.rb │ └── schema.rb ├── lib │ └── assets │ │ └── .keep ├── log │ └── .keep ├── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ ├── apple-touch-icon-precomposed.png │ ├── apple-touch-icon.png │ └── favicon.ico └── test │ ├── fixtures │ └── contacts.yml │ └── models │ └── contact_test.rb └── test_helper.rb /.gitattributes: -------------------------------------------------------------------------------- 1 | *.rb diff=ruby 2 | *.gemspec diff=ruby 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | test/dummy/db/*.sqlite3 5 | test/dummy/db/*.sqlite3-journal 6 | test/dummy/log/*.log 7 | test/dummy/tmp/ 8 | *.gem 9 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.3 3 | # RuboCop has a bunch of cops enabled by default. This setting tells RuboCop 4 | # to ignore them, so only the ones explicitly set in this file are enabled. 5 | DisabledByDefault: true 6 | Exclude: 7 | - '**/templates/**/*' 8 | - '**/vendor/**/*' 9 | - 'actionpack/lib/action_dispatch/journey/parser.rb' 10 | 11 | # Prefer &&/|| over and/or. 12 | Style/AndOr: 13 | Enabled: true 14 | 15 | # Do not use braces for hash literals when they are the last argument of a 16 | # method call. 17 | Style/BracesAroundHashParameters: 18 | Enabled: true 19 | 20 | # Align `when` with `case`. 21 | Style/CaseIndentation: 22 | Enabled: true 23 | 24 | # Align comments with method definitions. 25 | Style/CommentIndentation: 26 | Enabled: true 27 | 28 | # No extra empty lines. 29 | Style/EmptyLines: 30 | Enabled: true 31 | 32 | # In a regular class definition, no empty lines around the body. 33 | Style/EmptyLinesAroundClassBody: 34 | Enabled: true 35 | 36 | # In a regular method definition, no empty lines around the body. 37 | Style/EmptyLinesAroundMethodBody: 38 | Enabled: true 39 | 40 | # In a regular module definition, no empty lines around the body. 41 | Style/EmptyLinesAroundModuleBody: 42 | Enabled: true 43 | 44 | # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. 45 | Style/HashSyntax: 46 | Enabled: true 47 | 48 | # Method definitions after `private` or `protected` isolated calls need one 49 | # extra level of indentation. 50 | Style/IndentationConsistency: 51 | Enabled: true 52 | EnforcedStyle: rails 53 | 54 | # Two spaces, no tabs (for indentation). 55 | Style/IndentationWidth: 56 | Enabled: true 57 | 58 | Style/SpaceAfterColon: 59 | Enabled: true 60 | 61 | Style/SpaceAfterComma: 62 | Enabled: true 63 | 64 | Style/SpaceAroundEqualsInParameterDefault: 65 | Enabled: true 66 | 67 | Style/SpaceAroundKeyword: 68 | Enabled: true 69 | 70 | Style/SpaceAroundOperators: 71 | Enabled: true 72 | 73 | Style/SpaceBeforeFirstArg: 74 | Enabled: true 75 | 76 | # Defining a method with parameters needs parentheses. 77 | Style/MethodDefParentheses: 78 | Enabled: true 79 | 80 | # Use `foo {}` not `foo{}`. 81 | Style/SpaceBeforeBlockBraces: 82 | Enabled: true 83 | 84 | # Use `foo { bar }` not `foo {bar}`. 85 | Style/SpaceInsideBlockBraces: 86 | Enabled: true 87 | 88 | # Use `{ a: 1 }` not `{a:1}`. 89 | Style/SpaceInsideHashLiteralBraces: 90 | Enabled: true 91 | 92 | Style/SpaceInsideParens: 93 | Enabled: true 94 | 95 | # Check quotes usage according to lint rule below. 96 | Style/StringLiterals: 97 | Enabled: true 98 | EnforcedStyle: double_quotes 99 | 100 | # Detect hard tabs, no hard tabs. 101 | Style/Tab: 102 | Enabled: true 103 | 104 | # Blank lines should not have any spaces. 105 | Style/TrailingBlankLines: 106 | Enabled: true 107 | 108 | # No trailing whitespace. 109 | Style/TrailingWhitespace: 110 | Enabled: true 111 | 112 | # Use quotes for string literals when they are enough. 113 | Style/UnneededPercentQ: 114 | Enabled: true 115 | 116 | # Align `end` with the matching keyword or starting expression except for 117 | # assignments, where it should be aligned with the LHS. 118 | Lint/EndAlignment: 119 | Enabled: true 120 | EnforcedStyleAlignWith: variable 121 | 122 | # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. 123 | Lint/RequireParentheses: 124 | Enabled: true 125 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.5.3 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Declare your gem's dependencies in duck_record.gemspec. 4 | # Bundler will treat runtime dependencies like base dependencies, and 5 | # development dependencies will be added by default to the :development group. 6 | gemspec 7 | 8 | # Declare any dependencies that are still in development here instead of in 9 | # your gemspec. These might include edge Rails or gems from your path or 10 | # Git. Remember to move these dependencies to your gemspec before releasing 11 | # your gem to rubygems.org. 12 | 13 | # To use a debugger 14 | # gem 'byebug', group: [:development, :test] 15 | 16 | gem "pry-rails" 17 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | duck_record (0.0.27) 5 | activemodel (~> 5.0) 6 | activesupport (~> 5.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actioncable (5.2.1) 12 | actionpack (= 5.2.1) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | actionmailer (5.2.1) 16 | actionpack (= 5.2.1) 17 | actionview (= 5.2.1) 18 | activejob (= 5.2.1) 19 | mail (~> 2.5, >= 2.5.4) 20 | rails-dom-testing (~> 2.0) 21 | actionpack (5.2.1) 22 | actionview (= 5.2.1) 23 | activesupport (= 5.2.1) 24 | rack (~> 2.0) 25 | rack-test (>= 0.6.3) 26 | rails-dom-testing (~> 2.0) 27 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 28 | actionview (5.2.1) 29 | activesupport (= 5.2.1) 30 | builder (~> 3.1) 31 | erubi (~> 1.4) 32 | rails-dom-testing (~> 2.0) 33 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 34 | activejob (5.2.1) 35 | activesupport (= 5.2.1) 36 | globalid (>= 0.3.6) 37 | activemodel (5.2.1) 38 | activesupport (= 5.2.1) 39 | activerecord (5.2.1) 40 | activemodel (= 5.2.1) 41 | activesupport (= 5.2.1) 42 | arel (>= 9.0) 43 | activestorage (5.2.1) 44 | actionpack (= 5.2.1) 45 | activerecord (= 5.2.1) 46 | marcel (~> 0.3.1) 47 | activesupport (5.2.1) 48 | concurrent-ruby (~> 1.0, >= 1.0.2) 49 | i18n (>= 0.7, < 2) 50 | minitest (~> 5.1) 51 | tzinfo (~> 1.1) 52 | arel (9.0.0) 53 | builder (3.2.3) 54 | coderay (1.1.2) 55 | concurrent-ruby (1.1.3) 56 | crass (1.0.4) 57 | erubi (1.7.1) 58 | globalid (0.4.1) 59 | activesupport (>= 4.2.0) 60 | i18n (1.1.1) 61 | concurrent-ruby (~> 1.0) 62 | loofah (2.2.3) 63 | crass (~> 1.0.2) 64 | nokogiri (>= 1.5.9) 65 | mail (2.7.1) 66 | mini_mime (>= 0.1.1) 67 | marcel (0.3.3) 68 | mimemagic (~> 0.3.2) 69 | method_source (0.9.2) 70 | mimemagic (0.3.2) 71 | mini_mime (1.0.1) 72 | mini_portile2 (2.3.0) 73 | minitest (5.11.3) 74 | nio4r (2.3.1) 75 | nokogiri (1.8.5) 76 | mini_portile2 (~> 2.3.0) 77 | pry (0.12.2) 78 | coderay (~> 1.1.0) 79 | method_source (~> 0.9.0) 80 | pry-rails (0.3.7) 81 | pry (>= 0.10.4) 82 | rack (2.0.6) 83 | rack-test (1.1.0) 84 | rack (>= 1.0, < 3) 85 | rails (5.2.1) 86 | actioncable (= 5.2.1) 87 | actionmailer (= 5.2.1) 88 | actionpack (= 5.2.1) 89 | actionview (= 5.2.1) 90 | activejob (= 5.2.1) 91 | activemodel (= 5.2.1) 92 | activerecord (= 5.2.1) 93 | activestorage (= 5.2.1) 94 | activesupport (= 5.2.1) 95 | bundler (>= 1.3.0) 96 | railties (= 5.2.1) 97 | sprockets-rails (>= 2.0.0) 98 | rails-dom-testing (2.0.3) 99 | activesupport (>= 4.2.0) 100 | nokogiri (>= 1.6) 101 | rails-html-sanitizer (1.0.4) 102 | loofah (~> 2.2, >= 2.2.2) 103 | railties (5.2.1) 104 | actionpack (= 5.2.1) 105 | activesupport (= 5.2.1) 106 | method_source 107 | rake (>= 0.8.7) 108 | thor (>= 0.19.0, < 2.0) 109 | rake (12.3.1) 110 | sprockets (3.7.2) 111 | concurrent-ruby (~> 1.0) 112 | rack (> 1, < 3) 113 | sprockets-rails (3.2.1) 114 | actionpack (>= 4.0) 115 | activesupport (>= 4.0) 116 | sprockets (>= 3.0.0) 117 | sqlite3 (1.3.13) 118 | thor (0.20.3) 119 | thread_safe (0.3.6) 120 | tzinfo (1.2.5) 121 | thread_safe (~> 0.1) 122 | websocket-driver (0.7.0) 123 | websocket-extensions (>= 0.1.0) 124 | websocket-extensions (0.1.3) 125 | 126 | PLATFORMS 127 | ruby 128 | 129 | DEPENDENCIES 130 | duck_record! 131 | pry-rails 132 | rails (~> 5.0) 133 | sqlite3 134 | 135 | BUNDLED WITH 136 | 1.17.1 137 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Jun Jiang 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | Copyright (c) 2004-2017 David Heinemeier Hansson 23 | 24 | Permission is hereby granted, free of charge, to any person obtaining 25 | a copy of this software and associated documentation files (the 26 | "Software"), to deal in the Software without restriction, including 27 | without limitation the rights to use, copy, modify, merge, publish, 28 | distribute, sublicense, and/or sell copies of the Software, and to 29 | permit persons to whom the Software is furnished to do so, subject to 30 | the following conditions: 31 | 32 | The above copyright notice and this permission notice shall be 33 | included in all copies or substantial portions of the Software. 34 | 35 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 36 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 37 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 38 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 39 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 40 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 41 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Duck Record 2 | ==== 3 | 4 | It looks like Active Record and quacks like Active Record, it's Duck Record! 5 | Actually it's extract from Active Record. 6 | 7 | ## Usage 8 | 9 | ```ruby 10 | class Person < DuckRecord::Base 11 | attribute :name, :string 12 | attribute :age, :integer 13 | 14 | validates :name, presence: true 15 | end 16 | 17 | class Comment < DuckRecord::Base 18 | attribute :content, :string 19 | 20 | validates :content, presence: true 21 | end 22 | 23 | class Book < DuckRecord::Base 24 | embeds_one :author, class_name: 'Person', validate: true 25 | accepts_nested_attributes_for :author 26 | 27 | embeds_many :comments, validate: true 28 | accepts_nested_attributes_for :comments 29 | 30 | attribute :title, :string 31 | attribute :tags, :string, array: true 32 | attribute :price, :decimal, default: 0 33 | attribute :meta, :json, default: {} 34 | attribute :bought_at, :datetime, default: -> { Time.new } 35 | 36 | validates :title, presence: true 37 | end 38 | ``` 39 | 40 | then use these models like a Active Record model, 41 | but remember that can't be persisting! 42 | 43 | ## Installation 44 | 45 | Since Duck Record is under early development, 46 | I suggest you fetch the gem through GitHub. 47 | 48 | Add this line to your application's Gemfile: 49 | 50 | ```ruby 51 | gem 'duck_record', github: 'jasl/duck_record' 52 | ``` 53 | 54 | And then execute: 55 | ```bash 56 | $ bundle 57 | ``` 58 | 59 | Or install it yourself as: 60 | ```bash 61 | $ gem install duck_record 62 | ``` 63 | 64 | ## TODO 65 | 66 | - refactor that original design for database 67 | - update docs 68 | - add useful methods 69 | - add tests 70 | - let me know.. 71 | 72 | ## Contributing 73 | 74 | - Fork the project. 75 | - Make your feature addition or bug fix. 76 | - Add tests for it. This is important so I don't break it in a future version unintentionally. 77 | - Commit, do not mess with Rakefile or version (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) 78 | - Send me a pull request. Bonus points for topic branches. 79 | 80 | ## License 81 | 82 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 83 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require "bundler/setup" 3 | rescue LoadError 4 | puts "You must `gem install bundler` and `bundle install` to run rake tasks" 5 | end 6 | 7 | require "rdoc/task" 8 | 9 | RDoc::Task.new(:rdoc) do |rdoc| 10 | rdoc.rdoc_dir = "rdoc" 11 | rdoc.title = "DuckRecord" 12 | rdoc.options << "--line-numbers" 13 | rdoc.rdoc_files.include("README.md") 14 | rdoc.rdoc_files.include("lib/**/*.rb") 15 | end 16 | 17 | require "bundler/gem_tasks" 18 | 19 | require "rake/testtask" 20 | 21 | Rake::TestTask.new(:test) do |t| 22 | t.libs << "lib" 23 | t.libs << "test" 24 | t.pattern = "test/**/*_test.rb" 25 | t.verbose = false 26 | end 27 | 28 | task default: :test 29 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path(File.expand_path("../../test", __FILE__)) 3 | 4 | require "bundler/setup" 5 | require "rails/test_unit/minitest_plugin" 6 | 7 | Rails::TestUnitReporter.executable = "bin/test" 8 | 9 | Minitest.run_via = :rails 10 | 11 | require "active_support/testing/autorun" 12 | -------------------------------------------------------------------------------- /duck_record.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | 3 | # Maintain your gem's version: 4 | require "duck_record/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |s| 8 | s.name = "duck_record" 9 | s.version = DuckRecord::VERSION 10 | s.authors = ["jasl"] 11 | s.email = ["jasl9187@hotmail.com"] 12 | s.homepage = "https://github.com/jasl-lab/duck_record" 13 | s.summary = "Used for creating virtual models like ActiveType or ModelAttribute does" 14 | s.description = <<-DESC.strip 15 | It looks like Active Record and quacks like Active Record, but it can't do persistence or querying, 16 | it's Duck Record! 17 | Actually it's extract from Active Record. 18 | Used for creating virtual models like ActiveType or ModelAttribute does. 19 | DESC 20 | s.license = "MIT" 21 | 22 | s.platform = Gem::Platform::RUBY 23 | s.required_ruby_version = ">= 2.2.2" 24 | 25 | s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 26 | 27 | s.add_dependency "activesupport", "~> 5.0" 28 | s.add_dependency "activemodel", "~> 5.0" 29 | 30 | s.add_development_dependency "rails", "~> 5.0" 31 | s.add_development_dependency "sqlite3" 32 | end 33 | -------------------------------------------------------------------------------- /lib/core_ext/array_without_blank.rb: -------------------------------------------------------------------------------- 1 | class ArrayWithoutBlank < Array 2 | def self.new(*several_variants) 3 | arr = super 4 | arr.reject!(&:blank?) 5 | arr 6 | end 7 | 8 | def initialize_copy(other_ary) 9 | super other_ary.reject(&:blank?) 10 | end 11 | 12 | def replace(other_ary) 13 | super other_ary.reject(&:blank?) 14 | end 15 | 16 | def push(obj, *smth) 17 | return self if obj.blank? 18 | super 19 | end 20 | 21 | def insert(*args) 22 | super *args.reject(&:blank?) 23 | end 24 | 25 | def []=(index, obj) 26 | return self[index] if obj.blank? 27 | super 28 | end 29 | 30 | def concat(other_ary) 31 | super other_ary.reject(&:blank?) 32 | end 33 | 34 | def +(other_ary) 35 | super other_ary.reject(&:blank?) 36 | end 37 | 38 | def <<(obj) 39 | return self if obj.blank? 40 | super 41 | end 42 | 43 | def to_ary 44 | Array.new(self) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/duck_record.rb: -------------------------------------------------------------------------------- 1 | require "active_support" 2 | require "active_support/rails" 3 | require "active_model" 4 | 5 | require "core_ext/array_without_blank" 6 | 7 | require "duck_record/type" 8 | require "duck_record/attribute_set" 9 | 10 | module DuckRecord 11 | extend ActiveSupport::Autoload 12 | 13 | autoload :Attribute 14 | autoload :AttributeDecorators 15 | autoload :Base 16 | autoload :Callbacks 17 | autoload :Core 18 | autoload :Enum 19 | autoload :Inheritance 20 | autoload :Persistence 21 | autoload :ModelSchema 22 | autoload :NestedAttributes 23 | autoload :ReadonlyAttributes 24 | autoload :Reflection 25 | autoload :Serialization 26 | autoload :Translation 27 | autoload :Validations 28 | 29 | eager_autoload do 30 | autoload :DuckRecordError, "duck_record/errors" 31 | 32 | autoload :Associations 33 | autoload :AttributeAssignment 34 | autoload :AttributeMethods 35 | autoload :NestedValidateAssociation 36 | end 37 | 38 | module Coders 39 | autoload :YAMLColumn, "duck_record/coders/yaml_column" 40 | autoload :JSON, "duck_record/coders/json" 41 | end 42 | 43 | module AttributeMethods 44 | extend ActiveSupport::Autoload 45 | 46 | eager_autoload do 47 | autoload :BeforeTypeCast 48 | autoload :Dirty 49 | autoload :Read 50 | autoload :Serialization 51 | autoload :Write 52 | end 53 | end 54 | 55 | def self.eager_load! 56 | super 57 | 58 | DuckRecord::Associations.eager_load! 59 | DuckRecord::AttributeMethods.eager_load! 60 | end 61 | end 62 | 63 | ActiveSupport.on_load(:i18n) do 64 | I18n.load_path << File.dirname(__FILE__) + "/duck_record/locale/en.yml" 65 | end 66 | -------------------------------------------------------------------------------- /lib/duck_record/associations.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/enumerable" 2 | require "active_support/core_ext/string/conversions" 3 | require "active_support/core_ext/module/remove_method" 4 | require "duck_record/errors" 5 | 6 | module DuckRecord 7 | class AssociationNotFoundError < ConfigurationError #:nodoc: 8 | def initialize(record = nil, association_name = nil) 9 | if record && association_name 10 | super("Association named '#{association_name}' was not found on #{record.class.name}; perhaps you misspelled it?") 11 | else 12 | super("Association was not found.") 13 | end 14 | end 15 | end 16 | 17 | # See ActiveRecord::Associations::ClassMethods for documentation. 18 | module Associations # :nodoc: 19 | extend ActiveSupport::Autoload 20 | extend ActiveSupport::Concern 21 | 22 | # These classes will be loaded when associations are created. 23 | # So there is no need to eager load them. 24 | autoload :EmbedsAssociation 25 | autoload :EmbedsManyProxy 26 | 27 | autoload :Association 28 | autoload :SingularAssociation 29 | autoload :CollectionAssociation 30 | autoload :ForeignAssociation 31 | autoload :CollectionProxy 32 | autoload :ThroughAssociation 33 | 34 | module Builder #:nodoc: 35 | autoload :Association, "duck_record/associations/builder/association" 36 | autoload :SingularAssociation, "duck_record/associations/builder/singular_association" 37 | autoload :CollectionAssociation, "duck_record/associations/builder/collection_association" 38 | 39 | autoload :EmbedsOne, "duck_record/associations/builder/embeds_one" 40 | autoload :EmbedsMany, "duck_record/associations/builder/embeds_many" 41 | 42 | autoload :BelongsTo, "duck_record/associations/builder/belongs_to" 43 | autoload :HasOne, "duck_record/associations/builder/has_one" 44 | autoload :HasMany, "duck_record/associations/builder/has_many" 45 | end 46 | 47 | eager_autoload do 48 | autoload :EmbedsManyAssociation 49 | autoload :EmbedsOneAssociation 50 | 51 | autoload :BelongsToAssociation 52 | autoload :HasOneAssociation 53 | autoload :HasOneThroughAssociation 54 | autoload :HasManyAssociation 55 | autoload :HasManyThroughAssociation 56 | end 57 | 58 | # Returns the association instance for the given name, instantiating it if it doesn't already exist 59 | def association(name) #:nodoc: 60 | association = association_instance_get(name) 61 | 62 | if association.nil? 63 | unless reflection = self.class._reflect_on_association(name) 64 | raise AssociationNotFoundError.new(self, name) 65 | end 66 | association = reflection.association_class.new(self, reflection) 67 | association_instance_set(name, association) 68 | end 69 | 70 | association 71 | end 72 | 73 | def association_cached?(name) # :nodoc 74 | @association_cache.key?(name) 75 | end 76 | 77 | def initialize_dup(*) # :nodoc: 78 | @association_cache = {} 79 | super 80 | end 81 | 82 | private 83 | # Clears out the association cache. 84 | def clear_association_cache 85 | @association_cache.clear if persisted? 86 | end 87 | 88 | def init_internals 89 | @association_cache = {} 90 | super 91 | end 92 | 93 | # Returns the specified association instance if it exists, +nil+ otherwise. 94 | def association_instance_get(name) 95 | @association_cache[name] 96 | end 97 | 98 | # Set the specified association instance. 99 | def association_instance_set(name, association) 100 | @association_cache[name] = association 101 | end 102 | 103 | module ClassMethods 104 | def embeds_many(name, options = {}, &extension) 105 | reflection = Builder::EmbedsMany.build(self, name, nil, options, &extension) 106 | Reflection.add_reflection self, name, reflection 107 | end 108 | 109 | def embeds_one(name, options = {}) 110 | reflection = Builder::EmbedsOne.build(self, name, nil, options) 111 | Reflection.add_reflection self, name, reflection 112 | end 113 | 114 | def belongs_to(name, scope = nil, options = {}) 115 | reflection = Builder::BelongsTo.build(self, name, scope, options) 116 | Reflection.add_reflection self, name, reflection 117 | end 118 | 119 | def has_one(name, scope = nil, options = {}) 120 | reflection = Builder::HasOne.build(self, name, scope, options) 121 | Reflection.add_reflection self, name, reflection 122 | end 123 | 124 | def has_many(name, scope = nil, options = {}, &extension) 125 | reflection = Builder::HasMany.build(self, name, scope, options, &extension) 126 | Reflection.add_reflection self, name, reflection 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/duck_record/associations/association.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/array/wrap" 2 | 3 | module DuckRecord 4 | module Associations 5 | # = Active Record Associations 6 | # 7 | # This is the root class of all associations ('+ Foo' signifies an included module Foo): 8 | # 9 | # Association 10 | # SingularAssociation 11 | # HasOneAssociation + ForeignAssociation 12 | # HasOneThroughAssociation + ThroughAssociation 13 | # BelongsToAssociation 14 | # BelongsToPolymorphicAssociation 15 | # CollectionAssociation 16 | # HasManyAssociation + ForeignAssociation 17 | # HasManyThroughAssociation + ThroughAssociation 18 | class Association #:nodoc: 19 | attr_reader :owner, :target, :reflection 20 | 21 | delegate :options, to: :reflection 22 | 23 | def initialize(owner, reflection) 24 | reflection.check_validity! 25 | 26 | @owner, @reflection = owner, reflection 27 | 28 | reset 29 | reset_scope 30 | end 31 | 32 | # Returns the name of the table of the associated class: 33 | # 34 | # post.comments.aliased_table_name # => "comments" 35 | # 36 | def aliased_table_name 37 | klass.table_name 38 | end 39 | 40 | # Resets the \loaded flag to +false+ and sets the \target to +nil+. 41 | def reset 42 | @loaded = false 43 | @target = nil 44 | @stale_state = nil 45 | end 46 | 47 | # Reloads the \target and returns +self+ on success. 48 | def reload 49 | reset 50 | reset_scope 51 | load_target 52 | self unless target.nil? 53 | end 54 | 55 | # Has the \target been already \loaded? 56 | def loaded? 57 | @loaded 58 | end 59 | 60 | # Asserts the \target has been loaded setting the \loaded flag to +true+. 61 | def loaded! 62 | @loaded = true 63 | @stale_state = stale_state 64 | end 65 | 66 | # The target is stale if the target no longer points to the record(s) that the 67 | # relevant foreign_key(s) refers to. If stale, the association accessor method 68 | # on the owner will reload the target. It's up to subclasses to implement the 69 | # stale_state method if relevant. 70 | # 71 | # Note that if the target has not been loaded, it is not considered stale. 72 | def stale_target? 73 | loaded? && @stale_state != stale_state 74 | end 75 | 76 | # Sets the target of this association to \target, and the \loaded flag to +true+. 77 | def target=(target) 78 | @target = target 79 | loaded! 80 | end 81 | 82 | def scope 83 | target_scope.merge!(association_scope) 84 | end 85 | 86 | # The scope for this association. 87 | # 88 | # Note that the association_scope is merged into the target_scope only when the 89 | # scope method is called. This is because at that point the call may be surrounded 90 | # by scope.scoping { ... } or with_scope { ... } etc, which affects the scope which 91 | # actually gets built. 92 | def association_scope 93 | return unless klass 94 | 95 | @association_scope ||= ActiveRecord::Associations::AssociationScope.scope(self) 96 | rescue ArgumentError 97 | @association_scope ||= ActiveRecord::Associations::AssociationScope.scope(self, klass.connection) 98 | end 99 | 100 | def reset_scope 101 | @association_scope = nil 102 | end 103 | 104 | # Set the inverse association, if possible 105 | def set_inverse_instance(record) 106 | record 107 | end 108 | 109 | # Remove the inverse association, if possible 110 | def remove_inverse_instance(_record); end 111 | 112 | # Returns the class of the target. belongs_to polymorphic overrides this to look at the 113 | # polymorphic_type field on the owner. 114 | def klass 115 | reflection.klass 116 | end 117 | 118 | # Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the 119 | # through association's scope) 120 | def target_scope 121 | ActiveRecord::AssociationRelation.create(klass, self).merge!(klass.all) 122 | rescue ArgumentError 123 | ActiveRecord::AssociationRelation.create(klass, klass.arel_table, klass.predicate_builder, self).merge!(klass.all) 124 | end 125 | 126 | def extensions 127 | extensions = klass.default_extensions | reflection.extensions 128 | 129 | if scope = reflection.scope 130 | extensions |= klass.unscoped.instance_exec(owner, &scope).extensions 131 | end 132 | 133 | extensions 134 | end 135 | 136 | # Loads the \target if needed and returns it. 137 | # 138 | # This method is abstract in the sense that it relies on +find_target+, 139 | # which is expected to be provided by descendants. 140 | # 141 | # If the \target is already \loaded it is just returned. Thus, you can call 142 | # +load_target+ unconditionally to get the \target. 143 | # 144 | # ActiveRecord::RecordNotFound is rescued within the method, and it is 145 | # not reraised. The proxy is \reset and +nil+ is the return value. 146 | def load_target 147 | @target = find_target if (@stale_state && stale_target?) || find_target? 148 | 149 | loaded! unless loaded? 150 | target 151 | rescue ActiveRecord::RecordNotFound 152 | reset 153 | end 154 | 155 | def interpolate(sql, record = nil) 156 | if sql.respond_to?(:to_proc) 157 | owner.instance_exec(record, &sql) 158 | else 159 | sql 160 | end 161 | end 162 | 163 | # We can't dump @reflection since it contains the scope proc 164 | def marshal_dump 165 | ivars = (instance_variables - [:@reflection]).map { |name| [name, instance_variable_get(name)] } 166 | [@reflection.name, ivars] 167 | end 168 | 169 | def marshal_load(data) 170 | reflection_name, ivars = data 171 | ivars.each { |name, val| instance_variable_set(name, val) } 172 | @reflection = @owner.class._reflect_on_association(reflection_name) 173 | end 174 | 175 | def initialize_attributes(record, except_from_scope_attributes = nil) #:nodoc: 176 | except_from_scope_attributes ||= {} 177 | skip_assign = [reflection.foreign_key, reflection.type].compact 178 | assigned_keys = record.changed_attribute_names_to_save 179 | assigned_keys += except_from_scope_attributes.keys.map(&:to_s) 180 | attributes = create_scope.except(*(assigned_keys - skip_assign)) 181 | record.assign_attributes(attributes) 182 | end 183 | 184 | def create(attributes = {}, &block) 185 | _create_record(attributes, &block) 186 | end 187 | 188 | def create!(attributes = {}, &block) 189 | _create_record(attributes, true, &block) 190 | end 191 | 192 | private 193 | 194 | def find_target? 195 | !loaded? && foreign_key_present? && klass 196 | end 197 | 198 | def creation_attributes 199 | attributes = {} 200 | 201 | if (reflection.has_one? || reflection.collection?) && !options[:through] 202 | attributes[reflection.foreign_key] = owner[reflection.active_record_primary_key] 203 | 204 | if reflection.options[:as] 205 | attributes[reflection.type] = owner.class.base_class.name 206 | end 207 | end 208 | 209 | attributes 210 | end 211 | 212 | # Sets the owner attributes on the given record 213 | def set_owner_attributes(record) 214 | creation_attributes.each { |key, value| record[key] = value } 215 | end 216 | 217 | # Returns true if there is a foreign key present on the owner which 218 | # references the target. This is used to determine whether we can load 219 | # the target if the owner is currently a new record (and therefore 220 | # without a key). If the owner is a new record then foreign_key must 221 | # be present in order to load target. 222 | # 223 | # Currently implemented by belongs_to (vanilla and polymorphic) and 224 | # has_one/has_many :through associations which go through a belongs_to. 225 | def foreign_key_present? 226 | false 227 | end 228 | 229 | # Raises ActiveRecord::AssociationTypeMismatch unless +record+ is of 230 | # the kind of the class of the associated objects. Meant to be used as 231 | # a sanity check when you are about to assign an associated record. 232 | def raise_on_type_mismatch!(record) 233 | unless record.is_a?(reflection.klass) 234 | fresh_class = reflection.class_name.safe_constantize 235 | unless fresh_class && record.is_a?(fresh_class) 236 | message = "#{reflection.class_name}(##{reflection.klass.object_id}) expected, "\ 237 | "got #{record.inspect} which is an instance of #{record.class}(##{record.class.object_id})" 238 | raise DuckRecord::AssociationTypeMismatch, message 239 | end 240 | end 241 | end 242 | 243 | # Returns true if record contains the foreign_key 244 | def foreign_key_for?(record) 245 | record.has_attribute?(reflection.foreign_key) 246 | end 247 | 248 | # This should be implemented to return the values of the relevant key(s) on the owner, 249 | # so that when stale_state is different from the value stored on the last find_target, 250 | # the target is stale. 251 | # 252 | # This is only relevant to certain associations, which is why it returns +nil+ by default. 253 | def stale_state 254 | end 255 | 256 | def build_record(attributes) 257 | reflection.build_association(attributes) do |record| 258 | initialize_attributes(record, attributes) 259 | end 260 | end 261 | 262 | # Returns true if statement cache should be skipped on the association reader. 263 | def skip_statement_cache? 264 | reflection.has_scope? || 265 | scope.eager_loading? || 266 | klass.scope_attributes? || 267 | reflection.source_reflection.active_record.try(:default_scopes)&.any? 268 | end 269 | end 270 | end 271 | end 272 | -------------------------------------------------------------------------------- /lib/duck_record/associations/belongs_to_association.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | # = Active Record Belongs To Association 3 | module Associations 4 | class BelongsToAssociation < SingularAssociation #:nodoc: 5 | def replace(record) 6 | if owner.class.readonly_attributes.include?(reflection.foreign_key.to_s) 7 | return 8 | end 9 | 10 | if record 11 | raise_on_type_mismatch!(record) 12 | replace_keys(record) 13 | @updated = true 14 | else 15 | remove_keys 16 | end 17 | 18 | self.target = record 19 | end 20 | 21 | def default(&block) 22 | writer(owner.instance_exec(&block)) if reader.nil? 23 | end 24 | 25 | def reset 26 | super 27 | @updated = false 28 | end 29 | 30 | def updated? 31 | @updated 32 | end 33 | 34 | private 35 | 36 | def find_target? 37 | !loaded? && foreign_key_present? && klass 38 | end 39 | 40 | # Checks whether record is different to the current target, without loading it 41 | def different_target?(record) 42 | record.id != owner._read_attribute(reflection.foreign_key) 43 | end 44 | 45 | def replace_keys(record) 46 | owner[reflection.foreign_key] = record._read_attribute(reflection.association_primary_key(record.class)) 47 | end 48 | 49 | def remove_keys 50 | owner[reflection.foreign_key] = nil 51 | end 52 | 53 | def foreign_key_present? 54 | owner._read_attribute(reflection.foreign_key) 55 | end 56 | 57 | def target_id 58 | if options[:primary_key] 59 | owner.send(reflection.name).try(:id) 60 | else 61 | owner._read_attribute(reflection.foreign_key) 62 | end 63 | end 64 | 65 | def stale_state 66 | result = owner._read_attribute(reflection.foreign_key) { |n| owner.send(:missing_attribute, n, caller) } 67 | result&.to_s 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/duck_record/associations/builder/association.rb: -------------------------------------------------------------------------------- 1 | # This is the parent Association class which defines the variables 2 | # used by all associations. 3 | # 4 | # The hierarchy is defined as follows: 5 | # Association 6 | # - SingularAssociation 7 | # - BelongsToAssociation 8 | # - HasOneAssociation 9 | # - CollectionAssociation 10 | # - HasManyAssociation 11 | 12 | module DuckRecord::Associations::Builder # :nodoc: 13 | class Association #:nodoc: 14 | class << self 15 | attr_accessor :extensions 16 | end 17 | self.extensions = [] 18 | 19 | VALID_OPTIONS = [:class_name, :anonymous_class, :foreign_key, :validate] # :nodoc: 20 | 21 | def self.build(model, name, scope, options, &block) 22 | if model.dangerous_attribute_method?(name) 23 | raise ArgumentError, "You tried to define an association named #{name} on the model #{model.name}, but " \ 24 | "this will conflict with a method #{name} already defined by Active Record. " \ 25 | "Please choose a different association name." 26 | end 27 | 28 | extension = define_extensions model, name, &block 29 | reflection = create_reflection model, name, scope, options, extension 30 | define_accessors model, reflection 31 | define_callbacks model, reflection 32 | define_validations model, reflection 33 | reflection 34 | end 35 | 36 | def self.create_reflection(model, name, scope, options, extension = nil) 37 | raise ArgumentError, "association names must be a Symbol" unless name.kind_of?(Symbol) 38 | 39 | if scope.is_a?(Hash) 40 | options = scope 41 | scope = nil 42 | end 43 | 44 | validate_options(options) 45 | 46 | scope = build_scope(scope, extension) 47 | 48 | DuckRecord::Reflection.create(macro, name, scope, options, model) 49 | end 50 | 51 | def self.build_scope(scope, extension) 52 | new_scope = scope 53 | 54 | if scope && scope.arity == 0 55 | new_scope = proc { instance_exec(&scope) } 56 | end 57 | 58 | if extension 59 | new_scope = wrap_scope new_scope, extension 60 | end 61 | 62 | new_scope 63 | end 64 | 65 | def self.wrap_scope(scope, _extension) 66 | scope 67 | end 68 | 69 | def self.macro 70 | raise NotImplementedError 71 | end 72 | 73 | def self.valid_options(options) 74 | VALID_OPTIONS + Association.extensions.flat_map(&:valid_options) 75 | end 76 | 77 | def self.validate_options(options) 78 | options.assert_valid_keys(valid_options(options)) 79 | end 80 | 81 | def self.define_extensions(model, name) 82 | end 83 | 84 | def self.define_callbacks(model, reflection) 85 | Association.extensions.each do |extension| 86 | extension.build model, reflection 87 | end 88 | end 89 | 90 | # Defines the setter and getter methods for the association 91 | # class Post < ActiveRecord::Base 92 | # has_many :comments 93 | # end 94 | # 95 | # Post.first.comments and Post.first.comments= methods are defined by this method... 96 | def self.define_accessors(model, reflection) 97 | mixin = model.generated_association_methods 98 | name = reflection.name 99 | define_readers(mixin, name) 100 | define_writers(mixin, name) 101 | end 102 | 103 | def self.define_readers(mixin, name) 104 | mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 105 | def #{name}(*args) 106 | association(:#{name}).reader(*args) 107 | end 108 | CODE 109 | end 110 | 111 | def self.define_writers(mixin, name) 112 | mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 113 | def #{name}=(value) 114 | if self.class.readonly_attributes.include?("#{name}") && attr_readonly_enabled? 115 | return 116 | end 117 | 118 | association(:#{name}).writer(value) 119 | end 120 | CODE 121 | end 122 | 123 | def self.define_validations(model, reflection) 124 | # noop 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/duck_record/associations/builder/belongs_to.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord::Associations::Builder # :nodoc: 2 | class BelongsTo < SingularAssociation #:nodoc: 3 | def self.macro 4 | :belongs_to 5 | end 6 | 7 | def self.valid_options(_options) 8 | super + [:optional, :default] 9 | end 10 | 11 | def self.define_callbacks(model, reflection) 12 | super 13 | add_default_callbacks(model, reflection) if reflection.options[:default] 14 | end 15 | 16 | def self.define_accessors(mixin, reflection) 17 | super 18 | end 19 | 20 | def self.add_default_callbacks(model, reflection) 21 | model.before_validation lambda { |o| 22 | o.association(reflection.name).default(&reflection.options[:default]) 23 | } 24 | end 25 | 26 | def self.define_validations(model, reflection) 27 | if reflection.options.key?(:required) 28 | reflection.options[:optional] = !reflection.options.delete(:required) 29 | end 30 | 31 | if reflection.options[:optional].nil? 32 | required = true 33 | else 34 | required = !reflection.options[:optional] 35 | end 36 | 37 | super 38 | 39 | if required 40 | model.validates_presence_of reflection.name, message: :required 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/duck_record/associations/builder/collection_association.rb: -------------------------------------------------------------------------------- 1 | # This class is inherited by the has_many and has_many_and_belongs_to_many association classes 2 | module DuckRecord::Associations::Builder # :nodoc: 3 | class CollectionAssociation < Association #:nodoc: 4 | CALLBACKS = [:before_add, :after_add, :before_remove, :after_remove] 5 | 6 | def self.valid_options(_options) 7 | super + [:index_errors] + CALLBACKS 8 | end 9 | 10 | def self.define_callbacks(model, reflection) 11 | super 12 | name = reflection.name 13 | options = reflection.options 14 | CALLBACKS.each { |callback_name| 15 | define_callback(model, callback_name, name, options) 16 | } 17 | end 18 | 19 | def self.define_extensions(model, name) 20 | if block_given? 21 | extension_module_name = "#{model.name.demodulize}#{name.to_s.camelize}AssociationExtension" 22 | extension = Module.new(&Proc.new) 23 | model.parent.const_set(extension_module_name, extension) 24 | end 25 | end 26 | 27 | def self.define_callback(model, callback_name, name, options) 28 | full_callback_name = "#{callback_name}_for_#{name}" 29 | 30 | # TODO : why do i need method_defined? I think its because of the inheritance chain 31 | model.class_attribute full_callback_name unless model.method_defined?(full_callback_name) 32 | callbacks = Array(options[callback_name.to_sym]).map do |callback| 33 | case callback 34 | when Symbol 35 | ->(_method, owner, record) { owner.send(callback, record) } 36 | when Proc 37 | ->(_method, owner, record) { callback.call(owner, record) } 38 | else 39 | ->(method, owner, record) { callback.send(method, owner, record) } 40 | end 41 | end 42 | model.send "#{full_callback_name}=", callbacks 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/duck_record/associations/builder/embeds_many.rb: -------------------------------------------------------------------------------- 1 | # This class is inherited by the has_many and has_many_and_belongs_to_many association classes 2 | 3 | module DuckRecord::Associations::Builder # :nodoc: 4 | class EmbedsMany < CollectionAssociation #:nodoc: 5 | def self.macro 6 | :embeds_many 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/duck_record/associations/builder/embeds_one.rb: -------------------------------------------------------------------------------- 1 | # This class is inherited by the has_one and belongs_to association classes 2 | 3 | module DuckRecord::Associations::Builder # :nodoc: 4 | class EmbedsOne < SingularAssociation #:nodoc: 5 | def self.macro 6 | :embeds_one 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/duck_record/associations/builder/has_many.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord::Associations::Builder # :nodoc: 2 | class HasMany < CollectionAssociation #:nodoc: 3 | def self.macro 4 | :has_many 5 | end 6 | 7 | def self.valid_options(_options) 8 | super + [:primary_key, :through, :source, :source_type, :join_table, :foreign_type, :index_errors] 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/duck_record/associations/builder/has_one.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord::Associations::Builder # :nodoc: 2 | class HasOne < SingularAssociation #:nodoc: 3 | def self.macro 4 | :has_one 5 | end 6 | 7 | def self.valid_options(options) 8 | valid = super 9 | valid += [:through, :source, :source_type] if options[:through] 10 | valid 11 | end 12 | 13 | def self.define_validations(model, reflection) 14 | super 15 | if reflection.options[:required] 16 | model.validates_presence_of reflection.name, message: :required 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/duck_record/associations/builder/singular_association.rb: -------------------------------------------------------------------------------- 1 | # This class is inherited by the has_one and belongs_to association classes 2 | module DuckRecord::Associations::Builder # :nodoc: 3 | class SingularAssociation < Association #:nodoc: 4 | def self.valid_options(options) 5 | super + [:primary_key, :required] 6 | end 7 | 8 | def self.define_validations(model, reflection) 9 | super 10 | 11 | if reflection.options[:required] 12 | model.validates_presence_of reflection.name, message: :required 13 | end 14 | end 15 | 16 | def self.define_accessors(model, reflection) 17 | super 18 | mixin = model.generated_association_methods 19 | name = reflection.name 20 | 21 | define_constructors(mixin, name) if reflection.constructable? 22 | end 23 | 24 | # Defines the (build|create)_association methods for belongs_to or has_one association 25 | def self.define_constructors(mixin, name) 26 | mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 27 | def build_#{name}(*args, &block) 28 | association(:#{name}).build(*args, &block) 29 | end 30 | CODE 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/duck_record/associations/embeds_association.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/array/wrap" 2 | 3 | module DuckRecord 4 | module Associations 5 | # = Active Record Associations 6 | # 7 | # This is the root class of all associations ('+ Foo' signifies an included module Foo): 8 | # 9 | # Association 10 | # SingularAssociation 11 | # HasOneAssociation + ForeignAssociation 12 | # HasOneThroughAssociation + ThroughAssociation 13 | # BelongsToAssociation 14 | # BelongsToPolymorphicAssociation 15 | # CollectionAssociation 16 | # HasManyAssociation + ForeignAssociation 17 | # HasManyThroughAssociation + ThroughAssociation 18 | class EmbedsAssociation #:nodoc: 19 | attr_reader :owner, :target, :reflection 20 | 21 | delegate :options, to: :reflection 22 | 23 | def initialize(owner, reflection) 24 | reflection.check_validity! 25 | 26 | @owner, @reflection = owner, reflection 27 | 28 | reset 29 | end 30 | 31 | # Resets the \loaded flag to +false+ and sets the \target to +nil+. 32 | def reset 33 | @target = nil 34 | end 35 | 36 | # Has the \target been already \loaded? 37 | def loaded? 38 | !!@target 39 | end 40 | 41 | # Sets the target of this association to \target, and the \loaded flag to +true+. 42 | def target=(target) 43 | @target = target 44 | end 45 | 46 | # Returns the class of the target. belongs_to polymorphic overrides this to look at the 47 | # polymorphic_type field on the owner. 48 | def klass 49 | reflection.klass 50 | end 51 | 52 | # We can't dump @reflection since it contains the scope proc 53 | def marshal_dump 54 | ivars = (instance_variables - [:@reflection]).map { |name| [name, instance_variable_get(name)] } 55 | [@reflection.name, ivars] 56 | end 57 | 58 | def marshal_load(data) 59 | reflection_name, ivars = data 60 | ivars.each { |name, val| instance_variable_set(name, val) } 61 | @reflection = @owner.class._reflect_on_association(reflection_name) 62 | end 63 | 64 | def initialize_attributes(record, attributes = nil) #:nodoc: 65 | attributes ||= {} 66 | record.assign_attributes(attributes) 67 | end 68 | 69 | private 70 | 71 | # Raises ActiveRecord::AssociationTypeMismatch unless +record+ is of 72 | # the kind of the class of the associated objects. Meant to be used as 73 | # a sanity check when you are about to assign an associated record. 74 | def raise_on_type_mismatch!(record) 75 | unless record.is_a?(reflection.klass) 76 | fresh_class = reflection.class_name.safe_constantize 77 | unless fresh_class && record.is_a?(fresh_class) 78 | message = "#{reflection.class_name}(##{reflection.klass.object_id}) expected, "\ 79 | "got #{record.inspect} which is an instance of #{record.class}(##{record.class.object_id})" 80 | raise DuckRecord::AssociationTypeMismatch, message 81 | end 82 | end 83 | end 84 | 85 | def build_record(attributes) 86 | reflection.build_association(attributes) do |record| 87 | initialize_attributes(record, attributes) 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/duck_record/associations/embeds_many_association.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Associations 3 | # = Active Record Association Collection 4 | # 5 | # CollectionAssociation is an abstract class that provides common stuff to 6 | # ease the implementation of association proxies that represent 7 | # collections. See the class hierarchy in Association. 8 | # 9 | # CollectionAssociation: 10 | # HasManyAssociation => has_many 11 | # HasManyThroughAssociation + ThroughAssociation => has_many :through 12 | # 13 | # The CollectionAssociation class provides common methods to the collections 14 | # defined by +has_and_belongs_to_many+, +has_many+ or +has_many+ with 15 | # the +:through association+ option. 16 | # 17 | # You need to be careful with assumptions regarding the target: The proxy 18 | # does not fetch records from the database until it needs them, but new 19 | # ones created with +build+ are added to the target. So, the target may be 20 | # non-empty and still lack children waiting to be read from the database. 21 | # If you look directly to the database you cannot assume that's the entire 22 | # collection because new records may have been added to the target, etc. 23 | # 24 | # If you need to work on all current children, new and existing records, 25 | # +load_target+ and the +loaded+ flag are your friends. 26 | class EmbedsManyAssociation < EmbedsAssociation #:nodoc: 27 | # Implements the reader method, e.g. foo.items for Foo.has_many :items 28 | def reader 29 | @_reader ||= EmbedsManyProxy.new(klass, self) 30 | end 31 | 32 | # Implements the writer method, e.g. foo.items= for Foo.has_many :items 33 | def writer(records) 34 | replace(records) 35 | end 36 | 37 | def reset 38 | super 39 | @target = [] 40 | end 41 | 42 | def build(attributes = {}, &block) 43 | if attributes.is_a?(klass) 44 | add_to_target(attributes) do |record| 45 | yield(record) if block_given? 46 | end 47 | elsif attributes.is_a?(Array) 48 | attributes.collect { |attr| build(attr, &block) } 49 | else 50 | add_to_target(build_record(attributes)) do |record| 51 | yield(record) if block_given? 52 | end 53 | end 54 | end 55 | 56 | # Add +records+ to this association. Returns +self+ so method calls may 57 | # be chained. Since << flattens its argument list and inserts each record, 58 | # +push+ and +concat+ behave identically. 59 | def concat(*records) 60 | records.flatten.each do |r| 61 | begin 62 | build(r) 63 | rescue 64 | raise_on_type_mismatch!(r) 65 | end 66 | end 67 | end 68 | 69 | # Removes all records from the association without calling callbacks 70 | # on the associated records. It honors the +:dependent+ option. However 71 | # if the +:dependent+ value is +:destroy+ then in that case the +:delete_all+ 72 | # deletion strategy for the association is applied. 73 | # 74 | # You can force a particular deletion strategy by passing a parameter. 75 | # 76 | # Example: 77 | # 78 | # @author.books.delete_all(:nullify) 79 | # @author.books.delete_all(:delete_all) 80 | # 81 | # See delete for more info. 82 | def delete_all 83 | @target.clear 84 | end 85 | 86 | # Removes +records+ from this association calling +before_remove+ and 87 | # +after_remove+ callbacks. 88 | # 89 | # This method is abstract in the sense that +delete_records+ has to be 90 | # provided by descendants. Note this method does not imply the records 91 | # are actually removed from the database, that depends precisely on 92 | # +delete_records+. They are in any case removed from the collection. 93 | def delete(*records) 94 | return if records.empty? 95 | @target = @target - records 96 | end 97 | 98 | # Deletes the +records+ and removes them from this association calling 99 | # +before_remove+ , +after_remove+ , +before_destroy+ and +after_destroy+ callbacks. 100 | # 101 | # Note that this method removes records from the database ignoring the 102 | # +:dependent+ option. 103 | def destroy(*records) 104 | return if records.empty? 105 | records = find(records) if records.any? { |record| record.kind_of?(Integer) || record.kind_of?(String) } 106 | delete_or_destroy(records, :destroy) 107 | end 108 | 109 | # Returns the size of the collection by executing a SELECT COUNT(*) 110 | # query if the collection hasn't been loaded, and calling 111 | # collection.size if it has. 112 | # 113 | # If the collection has been already loaded +size+ and +length+ are 114 | # equivalent. If not and you are going to need the records anyway 115 | # +length+ will take one less query. Otherwise +size+ is more efficient. 116 | # 117 | # This method is abstract in the sense that it relies on 118 | # +count_records+, which is a method descendants have to provide. 119 | def size 120 | @target.size 121 | end 122 | 123 | def uniq 124 | @target.uniq! 125 | end 126 | 127 | # Returns true if the collection is empty. 128 | # 129 | # If the collection has been loaded 130 | # it is equivalent to collection.size.zero?. If the 131 | # collection has not been loaded, it is equivalent to 132 | # collection.exists?. If the collection has not already been 133 | # loaded and you are going to fetch the records anyway it is better to 134 | # check collection.length.zero?. 135 | def empty? 136 | @target.blank? 137 | end 138 | 139 | # Replace this collection with +other_array+. This will perform a diff 140 | # and delete/add only records that have changed. 141 | def replace(other_array) 142 | delete_all 143 | other_array.each do |item| 144 | begin 145 | build(item) 146 | rescue 147 | raise_on_type_mismatch!(item) 148 | end 149 | end 150 | end 151 | 152 | def include?(record) 153 | @target.include?(record) 154 | end 155 | 156 | def add_to_target(record, skip_callbacks = false, &block) 157 | index = @target.index(record) 158 | 159 | replace_on_target(record, index, skip_callbacks, &block) 160 | end 161 | 162 | def replace_on_target(record, index, skip_callbacks) 163 | callback(:before_add, record) unless skip_callbacks 164 | 165 | begin 166 | if index 167 | record_was = target[index] 168 | target[index] = record 169 | else 170 | target << record 171 | end 172 | 173 | yield(record) if block_given? 174 | rescue 175 | if index 176 | target[index] = record_was 177 | else 178 | target.delete(record) 179 | end 180 | 181 | raise 182 | end 183 | 184 | callback(:after_add, record) unless skip_callbacks 185 | 186 | record 187 | end 188 | 189 | private 190 | 191 | def callback(method, record) 192 | callbacks_for(method).each do |callback| 193 | callback.call(method, owner, record) 194 | end 195 | end 196 | 197 | def callbacks_for(callback_name) 198 | full_callback_name = "#{callback_name}_for_#{reflection.name}" 199 | owner.class.send(full_callback_name) 200 | end 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/duck_record/associations/embeds_one_association.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Associations 3 | class EmbedsOneAssociation < EmbedsAssociation #:nodoc: 4 | # Implements the reader method, e.g. foo.bar for Foo.has_one :bar 5 | def reader 6 | target 7 | end 8 | 9 | # Implements the writer method, e.g. foo.bar= for Foo.belongs_to :bar 10 | def writer(record) 11 | replace(record) 12 | end 13 | 14 | def build(attributes = {}) 15 | record = build_record(attributes) 16 | yield(record) if block_given? 17 | set_new_record(record) 18 | record 19 | end 20 | 21 | # Implements the reload reader method, e.g. foo.reload_bar for 22 | # Foo.has_one :bar 23 | def force_reload_reader 24 | klass.uncached { reload } 25 | target 26 | end 27 | 28 | private 29 | 30 | def replace(record) 31 | self.target = 32 | if record.is_a? klass 33 | record 34 | elsif record.nil? 35 | nil 36 | elsif record.respond_to?(:to_h) 37 | build_record(record.to_h) 38 | end 39 | rescue 40 | raise_on_type_mismatch!(record) 41 | end 42 | 43 | def set_new_record(record) 44 | replace(record) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/duck_record/associations/foreign_association.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord::Associations 2 | module ForeignAssociation # :nodoc: 3 | def foreign_key_present? 4 | if reflection.klass.primary_key 5 | owner.attribute_present?(reflection.active_record_primary_key) 6 | else 7 | false 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/duck_record/associations/has_many_association.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | # = Active Record Has Many Association 3 | module Associations 4 | # This is the proxy that handles a has many association. 5 | # 6 | # If the association has a :through option further specialization 7 | # is provided by its child HasManyThroughAssociation. 8 | class HasManyAssociation < CollectionAssociation #:nodoc: 9 | include ForeignAssociation 10 | 11 | def insert_record(record, validate = true, raise = false) 12 | set_owner_attributes(record) 13 | super 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/duck_record/associations/has_one_association.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | # = Active Record Has One Association 3 | module Associations 4 | class HasOneAssociation < SingularAssociation #:nodoc: 5 | include ForeignAssociation 6 | 7 | def replace(record) 8 | if owner.class.readonly_attributes.include?(reflection.foreign_key.to_s) 9 | return 10 | end 11 | 12 | raise_on_type_mismatch!(record) if record 13 | load_target 14 | 15 | return target unless target || record 16 | 17 | self.target = record 18 | end 19 | 20 | private 21 | 22 | def foreign_key_present? 23 | true 24 | end 25 | 26 | # The reason that the save param for replace is false, if for create (not just build), 27 | # is because the setting of the foreign keys is actually handled by the scoping when 28 | # the record is instantiated, and so they are set straight away and do not need to be 29 | # updated within replace. 30 | def set_new_record(record) 31 | replace(record) 32 | end 33 | 34 | def nullify_owner_attributes(record) 35 | record[reflection.foreign_key] = nil 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/duck_record/associations/singular_association.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Associations 3 | class SingularAssociation < Association #:nodoc: 4 | # Implements the reader method, e.g. foo.bar for Foo.has_one :bar 5 | def reader 6 | if !loaded? || stale_target? 7 | reload 8 | end 9 | 10 | target 11 | end 12 | 13 | # Implements the writer method, e.g. foo.bar= for Foo.belongs_to :bar 14 | def writer(record) 15 | replace(record) 16 | end 17 | 18 | def build(attributes = {}) 19 | record = build_record(attributes) 20 | yield(record) if block_given? 21 | set_new_record(record) 22 | record 23 | end 24 | 25 | # Implements the reload reader method, e.g. foo.reload_bar for 26 | # Foo.has_one :bar 27 | def force_reload_reader 28 | klass.uncached { reload } 29 | target 30 | end 31 | 32 | private 33 | 34 | def create_scope 35 | scope.scope_for_create.stringify_keys.except(klass.primary_key) 36 | end 37 | 38 | def find_target 39 | return scope.take if skip_statement_cache? 40 | 41 | conn = klass.connection 42 | sc = reflection.association_scope_cache(conn, owner) do 43 | ActiveRecord::StatementCache.create(conn) { |params| 44 | as = ActiveRecord::Associations::AssociationScope.create { params.bind } 45 | target_scope.merge(as.scope(self, conn)).limit(1) 46 | } 47 | end 48 | 49 | binds = ActiveRecord::Associations::AssociationScope.get_bind_values(owner, reflection.chain) 50 | sc.execute(binds, klass, conn).first 51 | rescue ::RangeError 52 | nil 53 | end 54 | 55 | def replace(record) 56 | raise NotImplementedError, "Subclasses must implement a replace(record) method" 57 | end 58 | 59 | def set_new_record(record) 60 | replace(record) 61 | end 62 | 63 | def _create_record(attributes, raise_error = false) 64 | record = build_record(attributes) 65 | yield(record) if block_given? 66 | saved = record.save 67 | set_new_record(record) 68 | raise ActiveRecord::RecordInvalid.new(record) if !saved && raise_error 69 | record 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/duck_record/attribute.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | class Attribute # :nodoc: 3 | class << self 4 | def from_database(name, value, type) 5 | FromDatabase.new(name, value, type) 6 | end 7 | 8 | def from_user(name, value, type, original_attribute = nil) 9 | FromUser.new(name, value, type, original_attribute) 10 | end 11 | 12 | def with_cast_value(name, value, type) 13 | WithCastValue.new(name, value, type) 14 | end 15 | 16 | def null(name) 17 | Null.new(name) 18 | end 19 | 20 | def uninitialized(name, type) 21 | Uninitialized.new(name, type) 22 | end 23 | end 24 | 25 | attr_reader :name, :value_before_type_cast, :type 26 | 27 | # This method should not be called directly. 28 | # Use #from_database or #from_user 29 | def initialize(name, value_before_type_cast, type, original_attribute = nil) 30 | @name = name 31 | @value_before_type_cast = value_before_type_cast 32 | @type = type 33 | @original_attribute = original_attribute 34 | end 35 | 36 | def value 37 | # `defined?` is cheaper than `||=` when we get back falsy values 38 | @value = type_cast(value_before_type_cast) unless defined?(@value) 39 | @value 40 | end 41 | 42 | def original_value 43 | if assigned? 44 | original_attribute.original_value 45 | else 46 | type_cast(value_before_type_cast) 47 | end 48 | end 49 | 50 | def value_for_database 51 | type.serialize(value) 52 | end 53 | 54 | def changed? 55 | changed_from_assignment? || changed_in_place? 56 | end 57 | 58 | def changed_in_place? 59 | has_been_read? && type.changed_in_place?(original_value_for_database, value) 60 | end 61 | 62 | def forgetting_assignment 63 | with_value_from_database(value_for_database) 64 | end 65 | 66 | def with_value_from_user(value) 67 | type.assert_valid_value(value) 68 | self.class.from_user(name, value, type, original_attribute || self) 69 | end 70 | 71 | def with_value_from_database(value) 72 | self.class.from_database(name, value, type) 73 | end 74 | 75 | def with_cast_value(value) 76 | self.class.with_cast_value(name, value, type) 77 | end 78 | 79 | def with_type(type) 80 | self.class.new(name, value_before_type_cast, type, original_attribute) 81 | end 82 | 83 | def type_cast(*) 84 | raise NotImplementedError 85 | end 86 | 87 | def initialized? 88 | true 89 | end 90 | 91 | def came_from_user? 92 | false 93 | end 94 | 95 | def has_been_read? 96 | defined?(@value) 97 | end 98 | 99 | def ==(other) 100 | self.class == other.class && 101 | name == other.name && 102 | value_before_type_cast == other.value_before_type_cast && 103 | type == other.type 104 | end 105 | alias eql? == 106 | 107 | def hash 108 | [self.class, name, value_before_type_cast, type].hash 109 | end 110 | 111 | protected 112 | 113 | attr_reader :original_attribute 114 | alias_method :assigned?, :original_attribute 115 | 116 | def initialize_dup(other) 117 | if defined?(@value) && @value.duplicable? 118 | @value = @value.dup 119 | end 120 | end 121 | 122 | def changed_from_assignment? 123 | assigned? && type.changed?(original_value, value, value_before_type_cast) 124 | end 125 | 126 | def original_value_for_database 127 | if assigned? 128 | original_attribute.original_value_for_database 129 | else 130 | _original_value_for_database 131 | end 132 | end 133 | 134 | def _original_value_for_database 135 | type.serialize(original_value) 136 | end 137 | 138 | class FromDatabase < Attribute # :nodoc: 139 | def type_cast(value) 140 | type.deserialize(value) 141 | end 142 | 143 | def _original_value_for_database 144 | value_before_type_cast 145 | end 146 | end 147 | 148 | class FromUser < Attribute # :nodoc: 149 | def type_cast(value) 150 | type.cast(value) 151 | end 152 | 153 | def came_from_user? 154 | true 155 | end 156 | end 157 | 158 | class WithCastValue < Attribute # :nodoc: 159 | def type_cast(value) 160 | value 161 | end 162 | 163 | def changed_in_place? 164 | false 165 | end 166 | end 167 | 168 | class Null < Attribute # :nodoc: 169 | def initialize(name) 170 | super(name, nil, Type::Value.new) 171 | end 172 | 173 | def type_cast(*) 174 | nil 175 | end 176 | 177 | def with_type(type) 178 | self.class.with_cast_value(name, nil, type) 179 | end 180 | 181 | def with_value_from_database(value) 182 | raise ActiveModel::MissingAttributeError, "can't write unknown attribute `#{name}`" 183 | end 184 | alias_method :with_value_from_user, :with_value_from_database 185 | end 186 | 187 | class Uninitialized < Attribute # :nodoc: 188 | UNINITIALIZED_ORIGINAL_VALUE = Object.new 189 | 190 | def initialize(name, type) 191 | super(name, nil, type) 192 | end 193 | 194 | def value 195 | if block_given? 196 | yield name 197 | end 198 | end 199 | 200 | def original_value 201 | UNINITIALIZED_ORIGINAL_VALUE 202 | end 203 | 204 | def value_for_database 205 | end 206 | 207 | def initialized? 208 | false 209 | end 210 | end 211 | private_constant :FromDatabase, :FromUser, :Null, :Uninitialized, :WithCastValue 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /lib/duck_record/attribute/user_provided_default.rb: -------------------------------------------------------------------------------- 1 | require "duck_record/attribute" 2 | 3 | module DuckRecord 4 | class Attribute # :nodoc: 5 | class UserProvidedDefault < FromUser # :nodoc: 6 | def initialize(name, value, type, default) 7 | @user_provided_value = value 8 | super(name, value, type, default) 9 | end 10 | 11 | def value_before_type_cast 12 | if user_provided_value.is_a?(Proc) 13 | @memoized_value_before_type_cast ||= user_provided_value.call 14 | else 15 | @user_provided_value 16 | end 17 | end 18 | 19 | def with_type(type) 20 | self.class.new(name, user_provided_value, type, original_attribute) 21 | end 22 | 23 | # TODO Change this to private once we've dropped Ruby 2.2 support. 24 | # Workaround for Ruby 2.2 "private attribute?" warning. 25 | protected 26 | 27 | attr_reader :user_provided_value 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/duck_record/attribute_assignment.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/hash/keys" 2 | require "active_model/forbidden_attributes_protection" 3 | 4 | module DuckRecord 5 | module AttributeAssignment 6 | extend ActiveSupport::Concern 7 | include ActiveModel::ForbiddenAttributesProtection 8 | 9 | # Alias for ActiveModel::AttributeAssignment#assign_attributes. See ActiveModel::AttributeAssignment. 10 | def attributes=(attributes) 11 | assign_attributes(attributes) 12 | end 13 | 14 | def assign_attributes(new_attributes) 15 | unless new_attributes.respond_to?(:stringify_keys) 16 | raise ArgumentError, "When assigning attributes, you must pass a hash as an argument." 17 | end 18 | return if new_attributes.nil? || new_attributes.empty? 19 | 20 | attributes = new_attributes.stringify_keys 21 | _assign_attributes(sanitize_for_mass_assignment(attributes)) 22 | end 23 | 24 | private 25 | 26 | def _assign_attributes(attributes) 27 | multi_parameter_attributes = {} 28 | nested_parameter_attributes = {} 29 | 30 | attributes.each do |k, v| 31 | if k.include?("(") 32 | multi_parameter_attributes[k] = attributes.delete(k) 33 | elsif v.is_a?(Hash) 34 | nested_parameter_attributes[k] = attributes.delete(k) 35 | end 36 | end 37 | 38 | attributes.each do |k, v| 39 | _assign_attribute(k, v) 40 | end 41 | 42 | unless nested_parameter_attributes.empty? 43 | assign_nested_parameter_attributes(nested_parameter_attributes) 44 | end 45 | 46 | unless multi_parameter_attributes.empty? 47 | assign_multiparameter_attributes(multi_parameter_attributes) 48 | end 49 | end 50 | 51 | # Assign any deferred nested attributes after the base attributes have been set. 52 | def assign_nested_parameter_attributes(pairs) 53 | pairs.each { |k, v| _assign_attribute(k, v) } 54 | end 55 | 56 | # Instantiates objects for all attribute classes that needs more than one constructor parameter. This is done 57 | # by calling new on the column type or aggregation type (through composed_of) object with these parameters. 58 | # So having the pairs written_on(1) = "2004", written_on(2) = "6", written_on(3) = "24", will instantiate 59 | # written_on (a date type) with Date.new("2004", "6", "24"). You can also specify a typecast character in the 60 | # parentheses to have the parameters typecasted before they're used in the constructor. Use i for Integer and 61 | # f for Float. If all the values for a given attribute are empty, the attribute will be set to +nil+. 62 | def assign_multiparameter_attributes(pairs) 63 | execute_callstack_for_multiparameter_attributes( 64 | extract_callstack_for_multiparameter_attributes(pairs) 65 | ) 66 | end 67 | 68 | def execute_callstack_for_multiparameter_attributes(callstack) 69 | errors = [] 70 | callstack.each do |name, values_with_empty_parameters| 71 | begin 72 | if values_with_empty_parameters.each_value.all?(&:nil?) 73 | values = nil 74 | else 75 | values = values_with_empty_parameters 76 | end 77 | send("#{name}=", values) 78 | rescue => ex 79 | errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name) 80 | end 81 | end 82 | unless errors.empty? 83 | error_descriptions = errors.map(&:message).join(",") 84 | raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes [#{error_descriptions}]" 85 | end 86 | end 87 | 88 | def extract_callstack_for_multiparameter_attributes(pairs) 89 | attributes = {} 90 | 91 | pairs.each do |(multiparameter_name, value)| 92 | attribute_name = multiparameter_name.split("(").first 93 | attributes[attribute_name] ||= {} 94 | 95 | parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value) 96 | attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= parameter_value 97 | end 98 | 99 | attributes 100 | end 101 | 102 | def type_cast_attribute_value(multiparameter_name, value) 103 | multiparameter_name =~ /\([0-9]*([if])\)/ ? value.send("to_" + $1) : value 104 | end 105 | 106 | def find_parameter_position(multiparameter_name) 107 | multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i 108 | end 109 | 110 | def _assign_attribute(k, v) 111 | if respond_to?("#{k}=") 112 | public_send("#{k}=", v) 113 | else 114 | raise UnknownAttributeError.new(self, k) 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/duck_record/attribute_decorators.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module AttributeDecorators # :nodoc: 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | class_attribute :attribute_type_decorations, instance_accessor: false # :internal: 7 | self.attribute_type_decorations = TypeDecorator.new 8 | end 9 | 10 | module ClassMethods # :nodoc: 11 | # This method is an internal API used to create class macros such as 12 | # +serialize+, and features like time zone aware attributes. 13 | # 14 | # Used to wrap the type of an attribute in a new type. 15 | # When the schema for a model is loaded, attributes with the same name as 16 | # +column_name+ will have their type yielded to the given block. The 17 | # return value of that block will be used instead. 18 | # 19 | # Subsequent calls where +column_name+ and +decorator_name+ are the same 20 | # will override the previous decorator, not decorate twice. This can be 21 | # used to create idempotent class macros like +serialize+ 22 | def decorate_attribute_type(column_name, decorator_name, &block) 23 | matcher = ->(name, _) { name == column_name.to_s } 24 | key = "_#{column_name}_#{decorator_name}" 25 | decorate_matching_attribute_types(matcher, key, &block) 26 | end 27 | 28 | # This method is an internal API used to create higher level features like 29 | # time zone aware attributes. 30 | # 31 | # When the schema for a model is loaded, +matcher+ will be called for each 32 | # attribute with its name and type. If the matcher returns a truthy value, 33 | # the type will then be yielded to the given block, and the return value 34 | # of that block will replace the type. 35 | # 36 | # Subsequent calls to this method with the same value for +decorator_name+ 37 | # will replace the previous decorator, not decorate twice. This can be 38 | # used to ensure that class macros are idempotent. 39 | def decorate_matching_attribute_types(matcher, decorator_name, &block) 40 | reload_schema_from_cache 41 | decorator_name = decorator_name.to_s 42 | 43 | # Create new hashes so we don't modify parent classes 44 | self.attribute_type_decorations = attribute_type_decorations.merge(decorator_name => [matcher, block]) 45 | end 46 | 47 | private 48 | 49 | def load_schema! 50 | super 51 | attribute_types.each do |name, type| 52 | decorated_type = attribute_type_decorations.apply(name, type) 53 | define_attribute(name, decorated_type) 54 | end 55 | end 56 | end 57 | 58 | class TypeDecorator # :nodoc: 59 | delegate :clear, to: :@decorations 60 | 61 | def initialize(decorations = {}) 62 | @decorations = decorations 63 | end 64 | 65 | def merge(*args) 66 | TypeDecorator.new(@decorations.merge(*args)) 67 | end 68 | 69 | def apply(name, type) 70 | decorations = decorators_for(name, type) 71 | decorations.inject(type) do |new_type, block| 72 | block.call(new_type) 73 | end 74 | end 75 | 76 | private 77 | 78 | def decorators_for(name, type) 79 | matching(name, type).map(&:last) 80 | end 81 | 82 | def matching(name, type) 83 | @decorations.values.select do |(matcher, _)| 84 | matcher.call(name, type) 85 | end 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/duck_record/attribute_methods/before_type_cast.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module AttributeMethods 3 | # = Active Record Attribute Methods Before Type Cast 4 | # 5 | # DuckRecord::AttributeMethods::BeforeTypeCast provides a way to 6 | # read the value of the attributes before typecasting and deserialization. 7 | # 8 | # class Task < DuckRecord::Base 9 | # end 10 | # 11 | # task = Task.new(id: '1', completed_on: '2012-10-21') 12 | # task.id # => 1 13 | # task.completed_on # => Sun, 21 Oct 2012 14 | # 15 | # task.attributes_before_type_cast 16 | # # => {"id"=>"1", "completed_on"=>"2012-10-21", ... } 17 | # task.read_attribute_before_type_cast('id') # => "1" 18 | # task.read_attribute_before_type_cast('completed_on') # => "2012-10-21" 19 | # 20 | # In addition to #read_attribute_before_type_cast and #attributes_before_type_cast, 21 | # it declares a method for all attributes with the *_before_type_cast 22 | # suffix. 23 | # 24 | # task.id_before_type_cast # => "1" 25 | # task.completed_on_before_type_cast # => "2012-10-21" 26 | module BeforeTypeCast 27 | extend ActiveSupport::Concern 28 | 29 | included do 30 | attribute_method_suffix "_before_type_cast" 31 | attribute_method_suffix "_came_from_user?" 32 | end 33 | 34 | # Returns the value of the attribute identified by +attr_name+ before 35 | # typecasting and deserialization. 36 | # 37 | # class Task < DuckRecord::Base 38 | # end 39 | # 40 | # task = Task.new(id: '1', completed_on: '2012-10-21') 41 | # task.read_attribute('id') # => 1 42 | # task.read_attribute_before_type_cast('id') # => '1' 43 | # task.read_attribute('completed_on') # => Sun, 21 Oct 2012 44 | # task.read_attribute_before_type_cast('completed_on') # => "2012-10-21" 45 | # task.read_attribute_before_type_cast(:completed_on) # => "2012-10-21" 46 | def read_attribute_before_type_cast(attr_name) 47 | @attributes[attr_name.to_s].value_before_type_cast 48 | end 49 | 50 | # Returns a hash of attributes before typecasting and deserialization. 51 | # 52 | # class Task < DuckRecord::Base 53 | # end 54 | # 55 | # task = Task.new(title: nil, is_done: true, completed_on: '2012-10-21') 56 | # task.attributes 57 | # # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>Sun, 21 Oct 2012, "created_at"=>nil, "updated_at"=>nil} 58 | # task.attributes_before_type_cast 59 | # # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>"2012-10-21", "created_at"=>nil, "updated_at"=>nil} 60 | def attributes_before_type_cast 61 | @attributes.values_before_type_cast 62 | end 63 | 64 | private 65 | 66 | # Handle *_before_type_cast for method_missing. 67 | def attribute_before_type_cast(attribute_name) 68 | read_attribute_before_type_cast(attribute_name) 69 | end 70 | 71 | def attribute_came_from_user?(attribute_name) 72 | @attributes[attribute_name].came_from_user? 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/duck_record/attribute_methods/dirty.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "active_support/core_ext/module/attribute_accessors" 3 | require "duck_record/attribute_mutation_tracker" 4 | 5 | module DuckRecord 6 | module AttributeMethods 7 | module Dirty # :nodoc: 8 | extend ActiveSupport::Concern 9 | 10 | include ActiveModel::Dirty 11 | 12 | def initialize_dup(other) # :nodoc: 13 | super 14 | @attributes = self.class._default_attributes.map do |attr| 15 | attr.with_value_from_user(@attributes.fetch_value(attr.name)) 16 | end 17 | @mutation_tracker = nil 18 | end 19 | 20 | def changes_applied 21 | @previous_mutation_tracker = mutation_tracker 22 | @changed_attributes = HashWithIndifferentAccess.new 23 | store_original_attributes 24 | end 25 | 26 | def clear_changes_information 27 | @previous_mutation_tracker = nil 28 | @changed_attributes = HashWithIndifferentAccess.new 29 | store_original_attributes 30 | end 31 | 32 | def raw_write_attribute(attr_name, *) 33 | result = super 34 | clear_attribute_change(attr_name) 35 | result 36 | end 37 | 38 | def clear_attribute_changes(attr_names) 39 | super 40 | attr_names.each do |attr_name| 41 | clear_attribute_change(attr_name) 42 | end 43 | end 44 | 45 | def changed_attributes 46 | # This should only be set by methods which will call changed_attributes 47 | # multiple times when it is known that the computed value cannot change. 48 | if defined?(@cached_changed_attributes) 49 | @cached_changed_attributes 50 | else 51 | super.reverse_merge(mutation_tracker.changed_values).freeze 52 | end 53 | end 54 | 55 | def changes 56 | cache_changed_attributes do 57 | super 58 | end 59 | end 60 | 61 | def previous_changes 62 | previous_mutation_tracker.changes 63 | end 64 | 65 | def attribute_changed_in_place?(attr_name) 66 | mutation_tracker.changed_in_place?(attr_name) 67 | end 68 | 69 | private 70 | 71 | def mutation_tracker 72 | unless defined?(@mutation_tracker) 73 | @mutation_tracker = nil 74 | end 75 | @mutation_tracker ||= AttributeMutationTracker.new(@attributes) 76 | end 77 | 78 | def changes_include?(attr_name) 79 | super || mutation_tracker.changed?(attr_name) 80 | end 81 | 82 | def clear_attribute_change(attr_name) 83 | mutation_tracker.forget_change(attr_name) 84 | end 85 | 86 | def store_original_attributes 87 | @attributes = @attributes.map(&:forgetting_assignment) 88 | @mutation_tracker = nil 89 | end 90 | 91 | def previous_mutation_tracker 92 | @previous_mutation_tracker ||= NullMutationTracker.instance 93 | end 94 | 95 | def cache_changed_attributes 96 | @cached_changed_attributes = changed_attributes 97 | yield 98 | ensure 99 | clear_changed_attributes_cache 100 | end 101 | 102 | def clear_changed_attributes_cache 103 | remove_instance_variable(:@cached_changed_attributes) if defined?(@cached_changed_attributes) 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/duck_record/attribute_methods/read.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module AttributeMethods 3 | module Read 4 | extend ActiveSupport::Concern 5 | 6 | module ClassMethods 7 | private 8 | 9 | # We want to generate the methods via module_eval rather than 10 | # define_method, because define_method is slower on dispatch. 11 | # Evaluating many similar methods may use more memory as the instruction 12 | # sequences are duplicated and cached (in MRI). define_method may 13 | # be slower on dispatch, but if you're careful about the closure 14 | # created, then define_method will consume much less memory. 15 | # 16 | # But sometimes the database might return columns with 17 | # characters that are not allowed in normal method names (like 18 | # 'my_column(omg)'. So to work around this we first define with 19 | # the __temp__ identifier, and then use alias method to rename 20 | # it to what we want. 21 | # 22 | # We are also defining a constant to hold the frozen string of 23 | # the attribute name. Using a constant means that we do not have 24 | # to allocate an object on each call to the attribute method. 25 | # Making it frozen means that it doesn't get duped when used to 26 | # key the @attributes in read_attribute. 27 | def define_method_attribute(name) 28 | safe_name = name.unpack("h*".freeze).first 29 | temp_method = "__temp__#{safe_name}" 30 | 31 | DuckRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name 32 | 33 | generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 34 | def #{temp_method} 35 | name = ::DuckRecord::AttributeMethods::AttrNames::ATTR_#{safe_name} 36 | _read_attribute(name) { |n| missing_attribute(n, caller) } 37 | end 38 | STR 39 | 40 | generated_attribute_methods.module_eval do 41 | alias_method name, temp_method 42 | undef_method temp_method 43 | end 44 | end 45 | end 46 | 47 | # Returns the value of the attribute identified by attr_name after 48 | # it has been typecast (for example, "2004-12-12" in a date column is cast 49 | # to a date object, like Date.new(2004, 12, 12)). 50 | def read_attribute(attr_name, &block) 51 | name = if self.class.attribute_alias?(attr_name) 52 | self.class.attribute_alias(attr_name).to_s 53 | else 54 | attr_name.to_s 55 | end 56 | 57 | _read_attribute(name, &block) 58 | end 59 | 60 | # This method exists to avoid the expensive primary_key check internally, without 61 | # breaking compatibility with the read_attribute API 62 | if defined?(JRUBY_VERSION) 63 | # This form is significantly faster on JRuby, and this is one of our biggest hotspots. 64 | # https://github.com/jruby/jruby/pull/2562 65 | def _read_attribute(attr_name, &block) # :nodoc 66 | @attributes.fetch_value(attr_name.to_s, &block) 67 | end 68 | else 69 | def _read_attribute(attr_name) # :nodoc: 70 | @attributes.fetch_value(attr_name.to_s) { |n| yield n if block_given? } 71 | end 72 | end 73 | 74 | alias :attribute :_read_attribute 75 | private :attribute 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/duck_record/attribute_methods/serialization.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module AttributeMethods 3 | module Serialization 4 | extend ActiveSupport::Concern 5 | 6 | module ClassMethods 7 | # If you have an attribute that needs to be saved to the database as an 8 | # object, and retrieved as the same object, then specify the name of that 9 | # attribute using this method and it will be handled automatically. The 10 | # serialization is done through YAML. If +class_name+ is specified, the 11 | # serialized object must be of that class on assignment and retrieval. 12 | # Otherwise SerializationTypeMismatch will be raised. 13 | # 14 | # Empty objects as {}, in the case of +Hash+, or [], in the case of 15 | # +Array+, will always be persisted as null. 16 | # 17 | # Keep in mind that database adapters handle certain serialization tasks 18 | # for you. For instance: +json+ and +jsonb+ types in PostgreSQL will be 19 | # converted between JSON object/array syntax and Ruby +Hash+ or +Array+ 20 | # objects transparently. There is no need to use #serialize in this 21 | # case. 22 | # 23 | # For more complex cases, such as conversion to or from your application 24 | # domain objects, consider using the ActiveRecord::Attributes API. 25 | # 26 | # ==== Parameters 27 | # 28 | # * +attr_name+ - The field name that should be serialized. 29 | # * +class_name_or_coder+ - Optional, a coder object, which responds to +.load+ and +.dump+ 30 | # or a class name that the object type should be equal to. 31 | # 32 | # ==== Example 33 | # 34 | # # Serialize a preferences attribute. 35 | # class User < ActiveRecord::Base 36 | # serialize :preferences 37 | # end 38 | # 39 | # # Serialize preferences using JSON as coder. 40 | # class User < ActiveRecord::Base 41 | # serialize :preferences, JSON 42 | # end 43 | # 44 | # # Serialize preferences as Hash using YAML coder. 45 | # class User < ActiveRecord::Base 46 | # serialize :preferences, Hash 47 | # end 48 | def serialize(attr_name, class_name_or_coder = Object) 49 | # When ::JSON is used, force it to go through the Active Support JSON encoder 50 | # to ensure special objects (e.g. Active Record models) are dumped correctly 51 | # using the #as_json hook. 52 | coder = if class_name_or_coder == ::JSON 53 | Coders::JSON 54 | elsif [:load, :dump].all? { |x| class_name_or_coder.respond_to?(x) } 55 | class_name_or_coder 56 | else 57 | Coders::YAMLColumn.new(attr_name, class_name_or_coder) 58 | end 59 | decorate_attribute_type(attr_name, :serialize) do |type| 60 | Type::Serialized.new(type, coder) 61 | end 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/duck_record/attribute_methods/write.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module AttributeMethods 3 | module Write 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | attribute_method_suffix "=" 8 | end 9 | 10 | module ClassMethods 11 | private 12 | 13 | def define_method_attribute=(name) 14 | safe_name = name.unpack("h*".freeze).first 15 | DuckRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name 16 | 17 | generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 18 | def __temp__#{safe_name}=(value) 19 | name = ::DuckRecord::AttributeMethods::AttrNames::ATTR_#{safe_name} 20 | write_attribute(name, value) 21 | end 22 | alias_method #{(name + '=').inspect}, :__temp__#{safe_name}= 23 | undef_method :__temp__#{safe_name}= 24 | STR 25 | end 26 | end 27 | 28 | # Updates the attribute identified by attr_name with the 29 | # specified +value+. Empty strings for Integer and Float columns are 30 | # turned into +nil+. 31 | def write_attribute(attr_name, value) 32 | name = 33 | if self.class.attribute_alias?(attr_name) 34 | self.class.attribute_alias(attr_name).to_s 35 | else 36 | attr_name.to_s 37 | end 38 | 39 | if self.class.readonly_attributes.include?(name) && attr_readonly_enabled? 40 | return 41 | end 42 | 43 | write_attribute_with_type_cast(name, value, true) 44 | end 45 | 46 | def raw_write_attribute(attr_name, value) # :nodoc: 47 | write_attribute_with_type_cast(attr_name, value, false) 48 | end 49 | 50 | private 51 | 52 | # Handle *= for method_missing. 53 | def attribute=(attribute_name, value) 54 | write_attribute(attribute_name, value) 55 | end 56 | 57 | def write_attribute_with_type_cast(attr_name, value, should_type_cast) 58 | attr_name = attr_name.to_s 59 | 60 | if should_type_cast 61 | @attributes.write_from_user(attr_name, value) 62 | else 63 | @attributes.write_cast_value(attr_name, value) 64 | end 65 | 66 | value 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/duck_record/attribute_mutation_tracker.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | class AttributeMutationTracker # :nodoc: 3 | OPTION_NOT_GIVEN = Object.new 4 | 5 | def initialize(attributes) 6 | @attributes = attributes 7 | @forced_changes = Set.new 8 | end 9 | 10 | def changed_values 11 | attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result| 12 | if changed?(attr_name) 13 | result[attr_name] = attributes[attr_name].original_value 14 | end 15 | end 16 | end 17 | 18 | def changes 19 | attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result| 20 | change = change_to_attribute(attr_name) 21 | if change 22 | result[attr_name] = change 23 | end 24 | end 25 | end 26 | 27 | def change_to_attribute(attr_name) 28 | if changed?(attr_name) 29 | [attributes[attr_name].original_value, attributes.fetch_value(attr_name)] 30 | end 31 | end 32 | 33 | def any_changes? 34 | attr_names.any? { |attr| changed?(attr) } 35 | end 36 | 37 | def changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN) 38 | attr_name = attr_name.to_s 39 | forced_changes.include?(attr_name) || 40 | attributes[attr_name].changed? && 41 | (OPTION_NOT_GIVEN == from || attributes[attr_name].original_value == from) && 42 | (OPTION_NOT_GIVEN == to || attributes[attr_name].value == to) 43 | end 44 | 45 | def changed_in_place?(attr_name) 46 | attributes[attr_name].changed_in_place? 47 | end 48 | 49 | def forget_change(attr_name) 50 | attr_name = attr_name.to_s 51 | attributes[attr_name] = attributes[attr_name].forgetting_assignment 52 | forced_changes.delete(attr_name) 53 | end 54 | 55 | def original_value(attr_name) 56 | attributes[attr_name].original_value 57 | end 58 | 59 | def force_change(attr_name) 60 | forced_changes << attr_name.to_s 61 | end 62 | 63 | # TODO Change this to private once we've dropped Ruby 2.2 support. 64 | # Workaround for Ruby 2.2 "private attribute?" warning. 65 | protected 66 | 67 | attr_reader :attributes, :forced_changes 68 | 69 | private 70 | 71 | def attr_names 72 | attributes.keys 73 | end 74 | end 75 | 76 | class NullMutationTracker # :nodoc: 77 | include Singleton 78 | 79 | def changed_values(*) 80 | {} 81 | end 82 | 83 | def changes(*) 84 | {} 85 | end 86 | 87 | def change_to_attribute(_) 88 | end 89 | 90 | def any_changes?(*) 91 | false 92 | end 93 | 94 | def changed?(*) 95 | false 96 | end 97 | 98 | def changed_in_place?(*) 99 | false 100 | end 101 | 102 | def forget_change(*) 103 | end 104 | 105 | def original_value(*) 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/duck_record/attribute_set.rb: -------------------------------------------------------------------------------- 1 | require "duck_record/attribute_set/yaml_encoder" 2 | 3 | module DuckRecord 4 | class AttributeSet # :nodoc: 5 | delegate :each_value, :fetch, to: :attributes 6 | 7 | def initialize(attributes) 8 | @attributes = attributes 9 | end 10 | 11 | def [](name) 12 | attributes[name] || Attribute.null(name) 13 | end 14 | 15 | def []=(name, value) 16 | attributes[name] = value 17 | end 18 | 19 | def values_before_type_cast 20 | attributes.transform_values(&:value_before_type_cast) 21 | end 22 | 23 | def to_hash 24 | initialized_attributes.transform_values(&:value) 25 | end 26 | alias_method :to_h, :to_hash 27 | 28 | def key?(name) 29 | attributes.key?(name) && self[name].initialized? 30 | end 31 | 32 | def keys 33 | attributes.each_key.select { |name| self[name].initialized? } 34 | end 35 | 36 | if defined?(JRUBY_VERSION) 37 | # This form is significantly faster on JRuby, and this is one of our biggest hotspots. 38 | # https://github.com/jruby/jruby/pull/2562 39 | def fetch_value(name, &block) 40 | self[name].value(&block) 41 | end 42 | else 43 | def fetch_value(name) 44 | self[name].value { |n| yield n if block_given? } 45 | end 46 | end 47 | 48 | def write_from_user(name, value) 49 | attributes[name] = self[name].with_value_from_user(value) 50 | end 51 | 52 | def write_cast_value(name, value) 53 | attributes[name] = self[name].with_cast_value(value) 54 | end 55 | 56 | def freeze 57 | @attributes.freeze 58 | super 59 | end 60 | 61 | def deep_dup 62 | dup.tap do |copy| 63 | copy.instance_variable_set(:@attributes, attributes.deep_dup) 64 | end 65 | end 66 | 67 | def initialize_dup(_) 68 | @attributes = attributes.dup 69 | super 70 | end 71 | 72 | def initialize_clone(_) 73 | @attributes = attributes.clone 74 | super 75 | end 76 | 77 | def map(&block) 78 | new_attributes = attributes.transform_values(&block) 79 | AttributeSet.new(new_attributes) 80 | end 81 | 82 | def ==(other) 83 | attributes == other.attributes 84 | end 85 | 86 | # TODO Change this to private once we've dropped Ruby 2.2 support. 87 | # Workaround for Ruby 2.2 "private attribute?" warning. 88 | protected 89 | 90 | attr_reader :attributes 91 | 92 | private 93 | 94 | def initialized_attributes 95 | attributes.select { |_, attr| attr.initialized? } 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/duck_record/attribute_set/yaml_encoder.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | class AttributeSet 3 | # Attempts to do more intelligent YAML dumping of an 4 | # DuckRecord::AttributeSet to reduce the size of the resulting string 5 | class YAMLEncoder # :nodoc: 6 | def initialize(default_types) 7 | @default_types = default_types 8 | end 9 | 10 | def encode(attribute_set, coder) 11 | coder["concise_attributes"] = attribute_set.each_value.map do |attr| 12 | if attr.type.equal?(default_types[attr.name]) 13 | attr.with_type(nil) 14 | else 15 | attr 16 | end 17 | end 18 | end 19 | 20 | def decode(coder) 21 | if coder["attributes"] 22 | coder["attributes"] 23 | else 24 | attributes_hash = Hash[coder["concise_attributes"].map do |attr| 25 | if attr.type.nil? 26 | attr = attr.with_type(default_types[attr.name]) 27 | end 28 | [attr.name, attr] 29 | end] 30 | AttributeSet.new(attributes_hash) 31 | end 32 | end 33 | 34 | # TODO Change this to private once we've dropped Ruby 2.2 support. 35 | # Workaround for Ruby 2.2 'private attribute?' warning. 36 | protected 37 | 38 | attr_reader :default_types 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/duck_record/attributes.rb: -------------------------------------------------------------------------------- 1 | require "duck_record/attribute/user_provided_default" 2 | 3 | module DuckRecord 4 | # See DuckRecord::Attributes::ClassMethods for documentation 5 | module Attributes 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | class_attribute :attributes_to_define_after_schema_loads, instance_accessor: false # :internal: 10 | self.attributes_to_define_after_schema_loads = {} 11 | end 12 | 13 | module ClassMethods 14 | # Defines an attribute with a type on this model. It will override the 15 | # type of existing attributes if needed. This allows control over how 16 | # values are converted to and from SQL when assigned to a model. It also 17 | # changes the behavior of values passed to 18 | # {DuckRecord::Base.where}[rdoc-ref:QueryMethods#where]. This will let you use 19 | # your domain objects across much of Active Record, without having to 20 | # rely on implementation details or monkey patching. 21 | # 22 | # +name+ The name of the methods to define attribute methods for, and the 23 | # column which this will persist to. 24 | # 25 | # +cast_type+ A symbol such as +:string+ or +:integer+, or a type object 26 | # to be used for this attribute. See the examples below for more 27 | # information about providing custom type objects. 28 | # 29 | # ==== Options 30 | # 31 | # The following options are accepted: 32 | # 33 | # +default+ The default value to use when no value is provided. If this option 34 | # is not passed, the previous default value (if any) will be used. 35 | # Otherwise, the default will be +nil+. 36 | # 37 | # +array+ (PostgreSQL only) specifies that the type should be an array (see the 38 | # examples below). 39 | # 40 | # +range+ (PostgreSQL only) specifies that the type should be a range (see the 41 | # examples below). 42 | # 43 | # ==== Examples 44 | # 45 | # The type detected by Active Record can be overridden. 46 | # 47 | # # db/schema.rb 48 | # create_table :store_listings, force: true do |t| 49 | # t.decimal :price_in_cents 50 | # end 51 | # 52 | # # app/models/store_listing.rb 53 | # class StoreListing < DuckRecord::Base 54 | # end 55 | # 56 | # store_listing = StoreListing.new(price_in_cents: '10.1') 57 | # 58 | # # before 59 | # store_listing.price_in_cents # => BigDecimal.new(10.1) 60 | # 61 | # class StoreListing < DuckRecord::Base 62 | # attribute :price_in_cents, :integer 63 | # end 64 | # 65 | # # after 66 | # store_listing.price_in_cents # => 10 67 | # 68 | # A default can also be provided. 69 | # 70 | # # db/schema.rb 71 | # create_table :store_listings, force: true do |t| 72 | # t.string :my_string, default: "original default" 73 | # end 74 | # 75 | # StoreListing.new.my_string # => "original default" 76 | # 77 | # # app/models/store_listing.rb 78 | # class StoreListing < DuckRecord::Base 79 | # attribute :my_string, :string, default: "new default" 80 | # end 81 | # 82 | # StoreListing.new.my_string # => "new default" 83 | # 84 | # class Product < DuckRecord::Base 85 | # attribute :my_default_proc, :datetime, default: -> { Time.now } 86 | # end 87 | # 88 | # Product.new.my_default_proc # => 2015-05-30 11:04:48 -0600 89 | # sleep 1 90 | # Product.new.my_default_proc # => 2015-05-30 11:04:49 -0600 91 | # 92 | # \Attributes do not need to be backed by a database column. 93 | # 94 | # # app/models/my_model.rb 95 | # class MyModel < DuckRecord::Base 96 | # attribute :my_string, :string 97 | # attribute :my_int_array, :integer, array: true 98 | # attribute :my_float_range, :float, range: true 99 | # end 100 | # 101 | # model = MyModel.new( 102 | # my_string: "string", 103 | # my_int_array: ["1", "2", "3"], 104 | # my_float_range: "[1,3.5]", 105 | # ) 106 | # model.attributes 107 | # # => 108 | # { 109 | # my_string: "string", 110 | # my_int_array: [1, 2, 3], 111 | # my_float_range: 1.0..3.5 112 | # } 113 | # 114 | # ==== Creating Custom Types 115 | # 116 | # Users may also define their own custom types, as long as they respond 117 | # to the methods defined on the value type. The method +deserialize+ or 118 | # +cast+ will be called on your type object, with raw input from the 119 | # database or from your controllers. See ActiveModel::Type::Value for the 120 | # expected API. It is recommended that your type objects inherit from an 121 | # existing type, or from DuckRecord::Type::Value 122 | # 123 | # class MoneyType < DuckRecord::Type::Integer 124 | # def cast(value) 125 | # if !value.kind_of?(Numeric) && value.include?('$') 126 | # price_in_dollars = value.gsub(/\$/, '').to_f 127 | # super(price_in_dollars * 100) 128 | # else 129 | # super 130 | # end 131 | # end 132 | # end 133 | # 134 | # # config/initializers/types.rb 135 | # DuckRecord::Type.register(:money, MoneyType) 136 | # 137 | # # app/models/store_listing.rb 138 | # class StoreListing < DuckRecord::Base 139 | # attribute :price_in_cents, :money 140 | # end 141 | # 142 | # store_listing = StoreListing.new(price_in_cents: '$10.00') 143 | # store_listing.price_in_cents # => 1000 144 | # 145 | # For more details on creating custom types, see the documentation for 146 | # ActiveModel::Type::Value. For more details on registering your types 147 | # to be referenced by a symbol, see DuckRecord::Type.register. You can 148 | # also pass a type object directly, in place of a symbol. 149 | # 150 | # ==== \Querying 151 | # 152 | # When {DuckRecord::Base.where}[rdoc-ref:QueryMethods#where] is called, it will 153 | # use the type defined by the model class to convert the value to SQL, 154 | # calling +serialize+ on your type object. For example: 155 | # 156 | # class Money < Struct.new(:amount, :currency) 157 | # end 158 | # 159 | # class MoneyType < Type::Value 160 | # def initialize(currency_converter:) 161 | # @currency_converter = currency_converter 162 | # end 163 | # 164 | # # value will be the result of +deserialize+ or 165 | # # +cast+. Assumed to be an instance of +Money+ in 166 | # # this case. 167 | # def serialize(value) 168 | # value_in_bitcoins = @currency_converter.convert_to_bitcoins(value) 169 | # value_in_bitcoins.amount 170 | # end 171 | # end 172 | # 173 | # # config/initializers/types.rb 174 | # DuckRecord::Type.register(:money, MoneyType) 175 | # 176 | # # app/models/product.rb 177 | # class Product < DuckRecord::Base 178 | # currency_converter = ConversionRatesFromTheInternet.new 179 | # attribute :price_in_bitcoins, :money, currency_converter: currency_converter 180 | # end 181 | # 182 | # Product.where(price_in_bitcoins: Money.new(5, "USD")) 183 | # # => SELECT * FROM products WHERE price_in_bitcoins = 0.02230 184 | # 185 | # Product.where(price_in_bitcoins: Money.new(5, "GBP")) 186 | # # => SELECT * FROM products WHERE price_in_bitcoins = 0.03412 187 | # 188 | # ==== Dirty Tracking 189 | # 190 | # The type of an attribute is given the opportunity to change how dirty 191 | # tracking is performed. The methods +changed?+ and +changed_in_place?+ 192 | # will be called from ActiveModel::Dirty. See the documentation for those 193 | # methods in ActiveModel::Type::Value for more details. 194 | def attribute(name, cast_type = Type::Value.new, **options) 195 | name = name.to_s 196 | reload_schema_from_cache 197 | 198 | self.attributes_to_define_after_schema_loads = 199 | attributes_to_define_after_schema_loads.merge( 200 | name => [cast_type, options] 201 | ) 202 | end 203 | 204 | # This is the low level API which sits beneath +attribute+. It only 205 | # accepts type objects, and will do its work immediately instead of 206 | # waiting for the schema to load. Automatic schema detection and 207 | # ClassMethods#attribute both call this under the hood. While this method 208 | # is provided so it can be used by plugin authors, application code 209 | # should probably use ClassMethods#attribute. 210 | # 211 | # +name+ The name of the attribute being defined. Expected to be a +String+. 212 | # 213 | # +cast_type+ The type object to use for this attribute. 214 | # 215 | # +default+ The default value to use when no value is provided. If this option 216 | # is not passed, the previous default value (if any) will be used. 217 | # Otherwise, the default will be +nil+. A proc can also be passed, and 218 | # will be called once each time a new value is needed. 219 | # 220 | # +user_provided_default+ Whether the default value should be cast using 221 | # +cast+ or +deserialize+. 222 | def define_attribute( 223 | name, 224 | cast_type, 225 | default: NO_DEFAULT_PROVIDED 226 | ) 227 | attribute_types[name] = cast_type 228 | define_default_attribute(name, default, cast_type) 229 | end 230 | 231 | def load_schema! # :nodoc: 232 | super 233 | attributes_to_define_after_schema_loads.each do |name, (type, options)| 234 | if type.is_a?(Symbol) 235 | type = DuckRecord::Type.lookup(type, **options.except(:default)) 236 | end 237 | 238 | define_attribute(name, type, **options.slice(:default)) 239 | end 240 | end 241 | 242 | private 243 | 244 | NO_DEFAULT_PROVIDED = Object.new # :nodoc: 245 | private_constant :NO_DEFAULT_PROVIDED 246 | 247 | def define_default_attribute(name, value, type) 248 | if value == NO_DEFAULT_PROVIDED 249 | default_attribute = _default_attributes[name].with_type(type) 250 | else 251 | default_attribute = Attribute::UserProvidedDefault.new( 252 | name, 253 | value, 254 | type, 255 | _default_attributes.fetch(name.to_s) { nil }, 256 | ) 257 | end 258 | _default_attributes[name] = default_attribute 259 | end 260 | end 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /lib/duck_record/coders/json.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Coders # :nodoc: 3 | class JSON # :nodoc: 4 | def self.dump(obj) 5 | ActiveSupport::JSON.encode(obj) 6 | end 7 | 8 | def self.load(json) 9 | ActiveSupport::JSON.decode(json) unless json.blank? 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/duck_record/coders/yaml_column.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | module DuckRecord 4 | module Coders # :nodoc: 5 | class YAMLColumn # :nodoc: 6 | attr_accessor :object_class 7 | 8 | def initialize(attr_name, object_class = Object) 9 | @attr_name = attr_name 10 | @object_class = object_class 11 | check_arity_of_constructor 12 | end 13 | 14 | def dump(obj) 15 | return if obj.nil? 16 | 17 | assert_valid_value(obj, action: "dump") 18 | YAML.dump obj 19 | end 20 | 21 | def load(yaml) 22 | return object_class.new if object_class != Object && yaml.nil? 23 | return yaml unless yaml.is_a?(String) && /^---/.match?(yaml) 24 | obj = YAML.load(yaml) 25 | 26 | assert_valid_value(obj, action: "load") 27 | obj ||= object_class.new if object_class != Object 28 | 29 | obj 30 | end 31 | 32 | def assert_valid_value(obj, action:) 33 | unless obj.nil? || obj.is_a?(object_class) 34 | raise SerializationTypeMismatch, 35 | "can't #{action} `#{@attr_name}`: was supposed to be a #{object_class}, but was a #{obj.class}. -- #{obj.inspect}" 36 | end 37 | end 38 | 39 | private 40 | 41 | def check_arity_of_constructor 42 | load(nil) 43 | rescue ArgumentError 44 | raise ArgumentError, "Cannot serialize #{object_class}. Classes passed to `serialize` must have a 0 argument constructor." 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/duck_record/core.rb: -------------------------------------------------------------------------------- 1 | require "thread" 2 | require "active_support/core_ext/hash/indifferent_access" 3 | require "active_support/core_ext/object/duplicable" 4 | require "active_support/core_ext/string/filters" 5 | 6 | module DuckRecord 7 | module Core 8 | extend ActiveSupport::Concern 9 | 10 | included do 11 | ## 12 | # :singleton-method: 13 | # Determines whether to use Time.utc (using :utc) or Time.local (using :local) when pulling 14 | # dates and times from the database. This is set to :utc by default. 15 | mattr_accessor :default_timezone, instance_writer: false 16 | self.default_timezone = :utc 17 | end 18 | 19 | module ClassMethods 20 | def allocate 21 | define_attribute_methods 22 | super 23 | end 24 | 25 | def inherited(child_class) # :nodoc: 26 | super 27 | end 28 | 29 | def initialize_generated_modules # :nodoc: 30 | generated_association_methods 31 | end 32 | 33 | def generated_association_methods 34 | @generated_association_methods ||= begin 35 | mod = const_set(:GeneratedAssociationMethods, Module.new) 36 | private_constant :GeneratedAssociationMethods 37 | include mod 38 | 39 | mod 40 | end 41 | end 42 | 43 | # Returns a string like 'Post(id:integer, title:string, body:text)' 44 | def inspect 45 | if abstract_class? 46 | "#{super}(abstract)" 47 | else 48 | attr_list = attribute_types.map { |name, type| "#{name}: #{type.type}" } * ", " 49 | "#{super}(#{attr_list})" 50 | end 51 | end 52 | end 53 | 54 | # New objects can be instantiated as either empty (pass no construction parameter) or pre-set with 55 | # attributes but not yet saved (pass a hash with key names matching the associated table column names). 56 | # In both instances, valid attribute keys are determined by the column names of the associated table -- 57 | # hence you can't have attributes that aren't part of the table columns. 58 | # 59 | # ==== Example: 60 | # # Instantiates a single new object 61 | # User.new(first_name: 'Jamie') 62 | def initialize(attributes = nil) 63 | self.class.define_attribute_methods 64 | @attributes = self.class._default_attributes.deep_dup 65 | 66 | disable_attr_readonly! 67 | 68 | init_internals 69 | initialize_internals_callback 70 | 71 | if attributes 72 | assign_attributes(attributes) 73 | clear_changes_information 74 | end 75 | 76 | yield self if block_given? 77 | _run_initialize_callbacks 78 | 79 | enable_attr_readonly! 80 | end 81 | 82 | # Initialize an empty model object from +coder+. +coder+ should be 83 | # the result of previously encoding an Active Record model, using 84 | # #encode_with. 85 | # 86 | # class Post < DuckRecord::Base 87 | # end 88 | # 89 | # old_post = Post.new(title: "hello world") 90 | # coder = {} 91 | # old_post.encode_with(coder) 92 | # 93 | # post = Post.allocate 94 | # post.init_with(coder) 95 | # post.title # => 'hello world' 96 | def init_with(coder) 97 | @attributes = self.class.yaml_encoder.decode(coder) 98 | 99 | init_internals 100 | 101 | self.class.define_attribute_methods 102 | 103 | yield self if block_given? 104 | 105 | _run_initialize_callbacks 106 | 107 | self 108 | end 109 | 110 | ## 111 | # :method: clone 112 | # Identical to Ruby's clone method. This is a "shallow" copy. Be warned that your attributes are not copied. 113 | # That means that modifying attributes of the clone will modify the original, since they will both point to the 114 | # same attributes hash. If you need a copy of your attributes hash, please use the #dup method. 115 | # 116 | # user = User.first 117 | # new_user = user.clone 118 | # user.name # => "Bob" 119 | # new_user.name = "Joe" 120 | # user.name # => "Joe" 121 | # 122 | # user.object_id == new_user.object_id # => false 123 | # user.name.object_id == new_user.name.object_id # => true 124 | # 125 | # user.name.object_id == user.dup.name.object_id # => false 126 | 127 | ## 128 | # :method: dup 129 | # Duped objects have no id assigned and are treated as new records. Note 130 | # that this is a "shallow" copy as it copies the object's attributes 131 | # only, not its associations. The extent of a "deep" copy is application 132 | # specific and is therefore left to the application to implement according 133 | # to its need. 134 | # The dup method does not preserve the timestamps (created|updated)_(at|on). 135 | 136 | ## 137 | def initialize_dup(other) # :nodoc: 138 | @attributes = @attributes.deep_dup 139 | 140 | _run_initialize_callbacks 141 | 142 | super 143 | end 144 | 145 | # Populate +coder+ with attributes about this record that should be 146 | # serialized. The structure of +coder+ defined in this method is 147 | # guaranteed to match the structure of +coder+ passed to the #init_with 148 | # method. 149 | # 150 | # Example: 151 | # 152 | # class Post < DuckRecord::Base 153 | # end 154 | # coder = {} 155 | # Post.new.encode_with(coder) 156 | # coder # => {"attributes" => {"id" => nil, ... }} 157 | def encode_with(coder) 158 | self.class.yaml_encoder.encode(@attributes, coder) 159 | coder["duck_record_yaml_version"] = 2 160 | end 161 | 162 | # Clone and freeze the attributes hash such that associations are still 163 | # accessible, even on destroyed records, but cloned models will not be 164 | # frozen. 165 | def freeze 166 | @attributes = @attributes.clone.freeze 167 | self 168 | end 169 | 170 | # Returns +true+ if the attributes hash has been frozen. 171 | def frozen? 172 | @attributes.frozen? 173 | end 174 | 175 | # Returns +true+ if the record is read only. Records loaded through joins with piggy-back 176 | # attributes will be marked as read only since they cannot be saved. 177 | def readonly? 178 | @readonly 179 | end 180 | 181 | # Marks this record as read only. 182 | def readonly! 183 | @readonly = true 184 | end 185 | 186 | # Returns the contents of the record as a nicely formatted string. 187 | def inspect 188 | # We check defined?(@attributes) not to issue warnings if the object is 189 | # allocated but not initialized. 190 | inspection = if defined?(@attributes) && @attributes 191 | self.class.attribute_names.collect do |name| 192 | if has_attribute?(name) 193 | "#{name}: #{attribute_for_inspect(name)}" 194 | end 195 | end.compact.join(", ") 196 | else 197 | "not initialized" 198 | end 199 | 200 | "#<#{self.class} #{inspection}>" 201 | end 202 | 203 | # Takes a PP and prettily prints this record to it, allowing you to get a nice result from pp record 204 | # when pp is required. 205 | def pretty_print(pp) 206 | return super if custom_inspect_method_defined? 207 | pp.object_address_group(self) do 208 | if defined?(@attributes) && @attributes 209 | pp.seplist(self.class.attribute_names, proc { pp.text "," }) do |attribute_name| 210 | attribute_value = read_attribute(attribute_name) 211 | pp.breakable " " 212 | pp.group(1) do 213 | pp.text attribute_name 214 | pp.text ":" 215 | pp.breakable 216 | pp.pp attribute_value 217 | end 218 | end 219 | else 220 | pp.breakable " " 221 | pp.text "not initialized" 222 | end 223 | end 224 | end 225 | 226 | # Returns a hash of the given methods with their names as keys and returned values as values. 227 | def slice(*methods) 228 | Hash[methods.flatten.map! { |method| [method, public_send(method)] }].with_indifferent_access 229 | end 230 | 231 | private 232 | 233 | # +Array#flatten+ will call +#to_ary+ (recursively) on each of the elements of 234 | # the array, and then rescues from the possible +NoMethodError+. If those elements are 235 | # +DuckRecord::Base+'s, then this triggers the various +method_missing+'s that we have, 236 | # which significantly impacts upon performance. 237 | # 238 | # So we can avoid the +method_missing+ hit by explicitly defining +#to_ary+ as +nil+ here. 239 | # 240 | # See also http://tenderlovemaking.com/2011/06/28/til-its-ok-to-return-nil-from-to_ary.html 241 | def to_ary 242 | nil 243 | end 244 | 245 | def init_internals 246 | @readonly = false 247 | end 248 | 249 | def initialize_internals_callback 250 | end 251 | 252 | def thaw 253 | if frozen? 254 | @attributes = @attributes.dup 255 | end 256 | end 257 | 258 | def custom_inspect_method_defined? 259 | self.class.instance_method(:inspect).owner != DuckRecord::Base.instance_method(:inspect).owner 260 | end 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /lib/duck_record/define_callbacks.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | # This module exists because `DuckRecord::AttributeMethods::Dirty` needs to 3 | # define callbacks, but continue to have its version of `save` be the super 4 | # method of `DuckRecord::Callbacks`. This will be removed when the removal 5 | # of deprecated code removes this need. 6 | module DefineCallbacks 7 | extend ActiveSupport::Concern 8 | 9 | CALLBACKS = [ 10 | :after_initialize, :before_validation, :after_validation, 11 | ] 12 | 13 | module ClassMethods # :nodoc: 14 | include ActiveModel::Callbacks 15 | end 16 | 17 | included do 18 | include ActiveModel::Validations::Callbacks 19 | 20 | define_model_callbacks :initialize, only: :after 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/duck_record/enum.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/object/deep_dup" 4 | 5 | module DuckRecord 6 | module Enum 7 | def self.extended(base) # :nodoc: 8 | base.class_attribute(:defined_enums, instance_writer: false) 9 | base.defined_enums = {} 10 | end 11 | 12 | def inherited(base) # :nodoc: 13 | base.defined_enums = defined_enums.deep_dup 14 | super 15 | end 16 | 17 | class EnumType < ActiveModel::Type::Value # :nodoc: 18 | delegate :type, to: :subtype 19 | 20 | def initialize(name, mapping, subtype) 21 | @name = name 22 | @mapping = mapping 23 | @subtype = subtype 24 | end 25 | 26 | def cast(value) 27 | return if value.blank? 28 | 29 | if mapping.has_key?(value) 30 | value.to_s 31 | elsif mapping.has_value?(value) 32 | mapping.key(value) 33 | else 34 | assert_valid_value(value) 35 | end 36 | end 37 | 38 | def deserialize(value) 39 | return if value.nil? 40 | mapping.key(subtype.deserialize(value)) 41 | end 42 | 43 | def serialize(value) 44 | mapping.fetch(value, value) 45 | end 46 | 47 | def assert_valid_value(value) 48 | unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value) 49 | raise ArgumentError, "'#{value}' is not a valid #{name}" 50 | end 51 | end 52 | 53 | private 54 | 55 | attr_reader :name, :mapping, :subtype 56 | end 57 | 58 | def enum(definitions) 59 | klass = self 60 | enum_prefix = definitions.delete(:_prefix) 61 | enum_suffix = definitions.delete(:_suffix) 62 | definitions.each do |name, values| 63 | # statuses = { } 64 | enum_values = ActiveSupport::HashWithIndifferentAccess.new 65 | name = name.to_sym 66 | 67 | # def self.statuses() statuses end 68 | detect_enum_conflict!(name, name.to_s.pluralize, true) 69 | klass.singleton_class.send(:define_method, name.to_s.pluralize) { enum_values } 70 | 71 | detect_enum_conflict!(name, name) 72 | detect_enum_conflict!(name, "#{name}=") 73 | 74 | attr = attribute_alias?(name) ? attribute_alias(name) : name 75 | decorate_attribute_type(attr, :enum) do |subtype| 76 | EnumType.new(attr, enum_values, subtype) 77 | end 78 | 79 | _enum_methods_module.module_eval do 80 | pairs = values.respond_to?(:each_pair) ? values.each_pair : values.each_with_index 81 | pairs.each do |value, i| 82 | if enum_prefix == true 83 | prefix = "#{name}_" 84 | elsif enum_prefix 85 | prefix = "#{enum_prefix}_" 86 | end 87 | if enum_suffix == true 88 | suffix = "_#{name}" 89 | elsif enum_suffix 90 | suffix = "_#{enum_suffix}" 91 | end 92 | 93 | value_method_name = "#{prefix}#{value}#{suffix}" 94 | enum_values[value] = i 95 | 96 | # def active?() status == 0 end 97 | klass.send(:detect_enum_conflict!, name, "#{value_method_name}?") 98 | define_method("#{value_method_name}?") { self[attr] == value.to_s } 99 | end 100 | end 101 | defined_enums[name.to_s] = enum_values 102 | end 103 | end 104 | 105 | private 106 | def _enum_methods_module 107 | @_enum_methods_module ||= begin 108 | mod = Module.new 109 | include mod 110 | mod 111 | end 112 | end 113 | 114 | ENUM_CONFLICT_MESSAGE = \ 115 | "You tried to define an enum named \"%{enum}\" on the model \"%{klass}\", but " \ 116 | "this will generate a %{type} method \"%{method}\", which is already defined " \ 117 | "by %{source}." 118 | 119 | def detect_enum_conflict!(enum_name, method_name, klass_method = false) 120 | if klass_method && dangerous_class_method?(method_name) 121 | raise_conflict_error(enum_name, method_name, type: "class") 122 | elsif !klass_method && dangerous_attribute_method?(method_name) 123 | raise_conflict_error(enum_name, method_name) 124 | elsif !klass_method && method_defined_within?(method_name, _enum_methods_module, Module) 125 | raise_conflict_error(enum_name, method_name, source: "another enum") 126 | end 127 | end 128 | 129 | def raise_conflict_error(enum_name, method_name, type: "instance", source: "Active Record") 130 | raise ArgumentError, ENUM_CONFLICT_MESSAGE % { 131 | enum: enum_name, 132 | klass: name, 133 | type: type, 134 | method: method_name, 135 | source: source 136 | } 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/duck_record/errors.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | # = Active Record Errors 3 | # 4 | # Generic Active Record exception class. 5 | class DuckRecordError < StandardError 6 | end 7 | 8 | # Raised on attempt to update record that is instantiated as read only. 9 | class ReadOnlyRecord < DuckRecordError 10 | end 11 | 12 | # Raised when attribute has a name reserved by Active Record (when attribute 13 | # has name of one of Active Record instance methods). 14 | class DangerousAttributeError < DuckRecordError 15 | end 16 | 17 | # Raised when association is being configured improperly or user tries to use 18 | # offset and limit together with 19 | # {ActiveRecord::Base.has_many}[rdoc-ref:Associations::ClassMethods#has_many] or 20 | # {ActiveRecord::Base.has_and_belongs_to_many}[rdoc-ref:Associations::ClassMethods#has_and_belongs_to_many] 21 | # associations. 22 | class ConfigurationError < DuckRecordError 23 | end 24 | 25 | # Raised when an object assigned to an association has an incorrect type. 26 | # 27 | # class Ticket < ActiveRecord::Base 28 | # has_many :patches 29 | # end 30 | # 31 | # class Patch < ActiveRecord::Base 32 | # belongs_to :ticket 33 | # end 34 | # 35 | # # Comments are not patches, this assignment raises AssociationTypeMismatch. 36 | # @ticket.patches << Comment.new(content: "Please attach tests to your patch.") 37 | class AssociationTypeMismatch < DuckRecordError 38 | end 39 | 40 | # Raised when unknown attributes are supplied via mass assignment. 41 | UnknownAttributeError = ActiveModel::UnknownAttributeError 42 | 43 | # Raised when an error occurred while doing a mass assignment to an attribute through the 44 | # {DuckRecord::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] method. 45 | # The exception has an +attribute+ property that is the name of the offending attribute. 46 | class AttributeAssignmentError < DuckRecordError 47 | attr_reader :exception, :attribute 48 | 49 | def initialize(message = nil, exception = nil, attribute = nil) 50 | super(message) 51 | @exception = exception 52 | @attribute = attribute 53 | end 54 | end 55 | 56 | # Raised when unserialized object's type mismatches one specified for serializable field. 57 | class SerializationTypeMismatch < DuckRecordError 58 | end 59 | 60 | # Raised when there are multiple errors while doing a mass assignment through the 61 | # {DuckRecord::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] 62 | # method. The exception has an +errors+ property that contains an array of AttributeAssignmentError 63 | # objects, each corresponding to the error while assigning to an attribute. 64 | class MultiparameterAssignmentErrors < DuckRecordError 65 | attr_reader :errors 66 | 67 | def initialize(errors = nil) 68 | @errors = errors 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/duck_record/inheritance.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/hash/indifferent_access" 2 | 3 | module DuckRecord 4 | # == Single table inheritance 5 | # 6 | # Active Record allows inheritance by storing the name of the class in a column that by 7 | # default is named "type" (can be changed by overwriting Base.inheritance_column). 8 | # This means that an inheritance looking like this: 9 | # 10 | # class Company < DuckRecord::Base; end 11 | # class Firm < Company; end 12 | # class Client < Company; end 13 | # class PriorityClient < Client; end 14 | # 15 | # When you do Firm.create(name: "37signals"), this record will be saved in 16 | # the companies table with type = "Firm". You can then fetch this row again using 17 | # Company.where(name: '37signals').first and it will return a Firm object. 18 | # 19 | # Be aware that because the type column is an attribute on the record every new 20 | # subclass will instantly be marked as dirty and the type column will be included 21 | # in the list of changed attributes on the record. This is different from non 22 | # Single Table Inheritance(STI) classes: 23 | # 24 | # Company.new.changed? # => false 25 | # Firm.new.changed? # => true 26 | # Firm.new.changes # => {"type"=>["","Firm"]} 27 | # 28 | # If you don't have a type column defined in your table, single-table inheritance won't 29 | # be triggered. In that case, it'll work just like normal subclasses with no special magic 30 | # for differentiating between them or reloading the right type with find. 31 | # 32 | # Note, all the attributes for all the cases are kept in the same table. Read more: 33 | # http://www.martinfowler.com/eaaCatalog/singleTableInheritance.html 34 | # 35 | module Inheritance 36 | extend ActiveSupport::Concern 37 | 38 | module ClassMethods 39 | # Determines if one of the attributes passed in is the inheritance column, 40 | # and if the inheritance column is attr accessible, it initializes an 41 | # instance of the given subclass instead of the base class. 42 | def new(*args, &block) 43 | if abstract_class? || self == Base 44 | raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated." 45 | end 46 | 47 | super 48 | end 49 | 50 | # Returns the class descending directly from DuckRecord::Base, or 51 | # an abstract class, if any, in the inheritance hierarchy. 52 | # 53 | # If A extends DuckRecord::Base, A.base_class will return A. If B descends from A 54 | # through some arbitrarily deep hierarchy, B.base_class will return A. 55 | # 56 | # If B < A and C < B and if A is an abstract_class then both B.base_class 57 | # and C.base_class would return B as the answer since A is an abstract_class. 58 | def base_class 59 | unless self < Base 60 | raise DuckRecordError, "#{name} doesn't belong in a hierarchy descending from DuckRecord" 61 | end 62 | 63 | if superclass == Base || superclass.abstract_class? 64 | self 65 | else 66 | superclass.base_class 67 | end 68 | end 69 | 70 | # Set this to true if this is an abstract class (see abstract_class?). 71 | # If you are using inheritance with DuckRecord and don't want child classes 72 | # to utilize the implied STI table name of the parent class, this will need to be true. 73 | # For example, given the following: 74 | # 75 | # class SuperClass < DuckRecord::Base 76 | # self.abstract_class = true 77 | # end 78 | # class Child < SuperClass 79 | # self.table_name = 'the_table_i_really_want' 80 | # end 81 | # 82 | # 83 | # self.abstract_class = true is required to make Child<.find,.create, or any Arel method> use the_table_i_really_want instead of a table called super_classes 84 | # 85 | attr_accessor :abstract_class 86 | 87 | # Returns whether this class is an abstract class or not. 88 | def abstract_class? 89 | defined?(@abstract_class) && @abstract_class == true 90 | end 91 | 92 | def inherited(subclass) 93 | subclass.instance_variable_set(:@_type_candidates_cache, Concurrent::Map.new) 94 | super 95 | end 96 | 97 | protected 98 | 99 | # Returns the class type of the record using the current module as a prefix. So descendants of 100 | # MyApp::Business::Account would appear as MyApp::Business::AccountSubclass. 101 | def compute_type(type_name) 102 | if type_name.start_with?("::".freeze) 103 | # If the type is prefixed with a scope operator then we assume that 104 | # the type_name is an absolute reference. 105 | ActiveSupport::Dependencies.constantize(type_name) 106 | else 107 | type_candidate = @_type_candidates_cache[type_name] 108 | if type_candidate && type_constant = ActiveSupport::Dependencies.safe_constantize(type_candidate) 109 | return type_constant 110 | end 111 | 112 | # Build a list of candidates to search for 113 | candidates = [] 114 | type_name.scan(/::|$/) { candidates.unshift "#{$`}::#{type_name}" } 115 | candidates << type_name 116 | 117 | candidates.each do |candidate| 118 | constant = ActiveSupport::Dependencies.safe_constantize(candidate) 119 | if candidate == constant.to_s 120 | @_type_candidates_cache[type_name] = candidate 121 | return constant 122 | end 123 | end 124 | 125 | raise NameError.new("uninitialized constant #{candidates.first}", candidates.first) 126 | end 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/duck_record/locale/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | # Attributes names common to most models 3 | #attributes: 4 | #created_at: "Created at" 5 | #updated_at: "Updated at" 6 | 7 | # Default error messages 8 | errors: 9 | messages: 10 | required: "must exist" 11 | taken: "has already been taken" 12 | subset: "is not included in the list" 13 | 14 | # Active Record models configuration 15 | duck_record: 16 | errors: 17 | messages: 18 | record_invalid: "Validation failed: %{errors}" 19 | # Append your own errors here or at the model/attributes scope. 20 | 21 | # You can define own errors for models or model attributes. 22 | # The values :model, :attribute and :value are always available for interpolation. 23 | # 24 | # For example, 25 | # models: 26 | # user: 27 | # blank: "This is a custom blank message for %{model}: %{attribute}" 28 | # attributes: 29 | # login: 30 | # blank: "This is a custom blank message for User login" 31 | # Will define custom blank validation message for User model and 32 | # custom blank validation message for login attribute of User model. 33 | #models: 34 | 35 | # Translate model names. Used in Model.human_name(). 36 | #models: 37 | # For example, 38 | # user: "Dude" 39 | # will translate User model name to "Dude" 40 | 41 | # Translate model attribute names. Used in Model.human_attribute_name(attribute). 42 | #attributes: 43 | # For example, 44 | # user: 45 | # login: "Handle" 46 | # will translate User attribute "login" as "Handle" 47 | -------------------------------------------------------------------------------- /lib/duck_record/model_schema.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module ModelSchema 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | delegate :type_for_attribute, to: :class 7 | end 8 | 9 | module ClassMethods 10 | def attribute_types # :nodoc: 11 | load_schema 12 | @attribute_types ||= Hash.new(Type.default_value) 13 | end 14 | 15 | def yaml_encoder # :nodoc: 16 | @yaml_encoder ||= AttributeSet::YAMLEncoder.new(attribute_types) 17 | end 18 | 19 | # Returns the type of the attribute with the given name, after applying 20 | # all modifiers. This method is the only valid source of information for 21 | # anything related to the types of a model's attributes. This method will 22 | # access the database and load the model's schema if it is required. 23 | # 24 | # The return value of this method will implement the interface described 25 | # by ActiveModel::Type::Value (though the object itself may not subclass 26 | # it). 27 | # 28 | # +attr_name+ The name of the attribute to retrieve the type for. Must be 29 | # a string 30 | def type_for_attribute(attr_name, &block) 31 | if block 32 | attribute_types.fetch(attr_name, &block) 33 | else 34 | attribute_types[attr_name] 35 | end 36 | end 37 | 38 | def _default_attributes # :nodoc: 39 | @default_attributes ||= AttributeSet.new({}) 40 | end 41 | 42 | private 43 | 44 | def schema_loaded? 45 | defined?(@schema_loaded) && @schema_loaded 46 | end 47 | 48 | def load_schema 49 | unless schema_loaded? 50 | load_schema! 51 | end 52 | end 53 | 54 | def load_schema! 55 | @schema_loaded = true 56 | end 57 | 58 | def reload_schema_from_cache 59 | @attribute_types = nil 60 | @default_attributes = nil 61 | @attributes_builder = nil 62 | @schema_loaded = false 63 | @attribute_names = nil 64 | @yaml_encoder = nil 65 | direct_descendants.each do |descendant| 66 | descendant.send(:reload_schema_from_cache) 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/duck_record/nested_validate_association.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | # = Active Record Autosave Association 3 | # 4 | # AutosaveAssociation is a module that takes care of automatically saving 5 | # associated records when their parent is saved. In addition to saving, it 6 | # also destroys any associated records that were marked for destruction. 7 | # (See #mark_for_destruction and #marked_for_destruction?). 8 | # 9 | # Saving of the parent, its associations, and the destruction of marked 10 | # associations, all happen inside a transaction. This should never leave the 11 | # database in an inconsistent state. 12 | # 13 | # If validations for any of the associations fail, their error messages will 14 | # be applied to the parent. 15 | # 16 | # Note that it also means that associations marked for destruction won't 17 | # be destroyed directly. They will however still be marked for destruction. 18 | # 19 | # Note that autosave: false is not same as not declaring :autosave. 20 | # When the :autosave option is not present then new association records are 21 | # saved but the updated association records are not saved. 22 | # 23 | # == Validation 24 | # 25 | # Child records are validated unless :validate is +false+. 26 | # 27 | # == Callbacks 28 | # 29 | # Association with autosave option defines several callbacks on your 30 | # model (before_save, after_create, after_update). Please note that 31 | # callbacks are executed in the order they were defined in 32 | # model. You should avoid modifying the association content, before 33 | # autosave callbacks are executed. Placing your callbacks after 34 | # associations is usually a good practice. 35 | # 36 | # === One-to-one Example 37 | # 38 | # class Post < ActiveRecord::Base 39 | # has_one :author, autosave: true 40 | # end 41 | # 42 | # Saving changes to the parent and its associated model can now be performed 43 | # automatically _and_ atomically: 44 | # 45 | # post = Post.find(1) 46 | # post.title # => "The current global position of migrating ducks" 47 | # post.author.name # => "alloy" 48 | # 49 | # post.title = "On the migration of ducks" 50 | # post.author.name = "Eloy Duran" 51 | # 52 | # post.save 53 | # post.reload 54 | # post.title # => "On the migration of ducks" 55 | # post.author.name # => "Eloy Duran" 56 | # 57 | # Destroying an associated model, as part of the parent's save action, is as 58 | # simple as marking it for destruction: 59 | # 60 | # post.author.mark_for_destruction 61 | # post.author.marked_for_destruction? # => true 62 | # 63 | # Note that the model is _not_ yet removed from the database: 64 | # 65 | # id = post.author.id 66 | # Author.find_by(id: id).nil? # => false 67 | # 68 | # post.save 69 | # post.reload.author # => nil 70 | # 71 | # Now it _is_ removed from the database: 72 | # 73 | # Author.find_by(id: id).nil? # => true 74 | # 75 | # === One-to-many Example 76 | # 77 | # When :autosave is not declared new children are saved when their parent is saved: 78 | # 79 | # class Post < ActiveRecord::Base 80 | # has_many :comments # :autosave option is not declared 81 | # end 82 | # 83 | # post = Post.new(title: 'ruby rocks') 84 | # post.comments.build(body: 'hello world') 85 | # post.save # => saves both post and comment 86 | # 87 | # post = Post.create(title: 'ruby rocks') 88 | # post.comments.build(body: 'hello world') 89 | # post.save # => saves both post and comment 90 | # 91 | # post = Post.create(title: 'ruby rocks') 92 | # post.comments.create(body: 'hello world') 93 | # post.save # => saves both post and comment 94 | # 95 | # When :autosave is true all children are saved, no matter whether they 96 | # are new records or not: 97 | # 98 | # class Post < ActiveRecord::Base 99 | # has_many :comments, autosave: true 100 | # end 101 | # 102 | # post = Post.create(title: 'ruby rocks') 103 | # post.comments.create(body: 'hello world') 104 | # post.comments[0].body = 'hi everyone' 105 | # post.comments.build(body: "good morning.") 106 | # post.title += "!" 107 | # post.save # => saves both post and comments. 108 | # 109 | # Destroying one of the associated models as part of the parent's save action 110 | # is as simple as marking it for destruction: 111 | # 112 | # post.comments # => [#, # 113 | # post.comments[1].mark_for_destruction 114 | # post.comments[1].marked_for_destruction? # => true 115 | # post.comments.length # => 2 116 | # 117 | # Note that the model is _not_ yet removed from the database: 118 | # 119 | # id = post.comments.last.id 120 | # Comment.find_by(id: id).nil? # => false 121 | # 122 | # post.save 123 | # post.reload.comments.length # => 1 124 | # 125 | # Now it _is_ removed from the database: 126 | # 127 | # Comment.find_by(id: id).nil? # => true 128 | module NestedValidateAssociation 129 | extend ActiveSupport::Concern 130 | 131 | module AssociationBuilderExtension #:nodoc: 132 | def self.build(model, reflection) 133 | model.send(:add_nested_validate_association_callbacks, reflection) 134 | end 135 | 136 | def self.valid_options 137 | [] 138 | end 139 | end 140 | 141 | included do 142 | Associations::Builder::Association.extensions << AssociationBuilderExtension 143 | mattr_accessor :index_nested_attribute_errors, instance_writer: false 144 | self.index_nested_attribute_errors = false 145 | end 146 | 147 | module ClassMethods # :nodoc: 148 | private 149 | 150 | def define_non_cyclic_method(name, &block) 151 | return if method_defined?(name) 152 | define_method(name) do |*args| 153 | result = true; @_already_called ||= {} 154 | # Loop prevention for validation of associations 155 | unless @_already_called[name] 156 | begin 157 | @_already_called[name] = true 158 | result = instance_eval(&block) 159 | ensure 160 | @_already_called[name] = false 161 | end 162 | end 163 | 164 | result 165 | end 166 | end 167 | 168 | # Adds validation and save callbacks for the association as specified by 169 | # the +reflection+. 170 | # 171 | # For performance reasons, we don't check whether to validate at runtime. 172 | # However the validation and callback methods are lazy and those methods 173 | # get created when they are invoked for the very first time. However, 174 | # this can change, for instance, when using nested attributes, which is 175 | # called _after_ the association has been defined. Since we don't want 176 | # the callbacks to get defined multiple times, there are guards that 177 | # check if the save or validation methods have already been defined 178 | # before actually defining them. 179 | def add_nested_validate_association_callbacks(reflection) 180 | validation_method = :"validate_associated_records_for_#{reflection.name}" 181 | if reflection.validate? && !method_defined?(validation_method) 182 | if reflection.collection? 183 | method = :validate_collection_association 184 | else 185 | method = :validate_single_association 186 | end 187 | 188 | define_non_cyclic_method(validation_method) do 189 | send(method, reflection) 190 | # TODO: remove the following line as soon as the return value of 191 | # callbacks is ignored, that is, returning `false` does not 192 | # display a deprecation warning or halts the callback chain. 193 | true 194 | end 195 | validate validation_method 196 | after_validation :_ensure_no_duplicate_errors 197 | end 198 | end 199 | end 200 | 201 | private 202 | 203 | # Validate the association if :validate or :autosave is 204 | # turned on for the association. 205 | def validate_single_association(reflection) 206 | association = association_instance_get(reflection.name) 207 | record = association&.reader 208 | association_valid?(reflection, record) if record 209 | end 210 | 211 | # Validate the associated records if :validate or 212 | # :autosave is turned on for the association specified by 213 | # +reflection+. 214 | def validate_collection_association(reflection) 215 | if association = association_instance_get(reflection.name) 216 | if records = association.target 217 | records.each_with_index { |record, index| association_valid?(reflection, record, index) } 218 | end 219 | end 220 | end 221 | 222 | # Returns whether or not the association is valid and applies any errors to 223 | # the parent, self, if it wasn't. Skips any :autosave 224 | # enabled records if they're marked_for_destruction? or destroyed. 225 | def association_valid?(reflection, record, index = nil) 226 | unless valid = record.valid? 227 | indexed_attribute = !index.nil? && (reflection.options[:index_errors] || DuckRecord::Base.index_nested_attribute_errors) 228 | 229 | record.errors.each do |attribute, message| 230 | attribute = normalize_reflection_attribute(indexed_attribute, reflection, index, attribute) 231 | errors[attribute] << message 232 | errors[attribute].uniq! 233 | end 234 | 235 | record.errors.details.each_key do |attribute| 236 | reflection_attribute = 237 | normalize_reflection_attribute(indexed_attribute, reflection, index, attribute).to_sym 238 | 239 | record.errors.details[attribute].each do |error| 240 | errors.details[reflection_attribute] << error 241 | errors.details[reflection_attribute].uniq! 242 | end 243 | end 244 | end 245 | valid 246 | end 247 | 248 | def normalize_reflection_attribute(indexed_attribute, reflection, index, attribute) 249 | if indexed_attribute 250 | "#{reflection.name}[#{index}].#{attribute}" 251 | else 252 | "#{reflection.name}.#{attribute}" 253 | end 254 | end 255 | 256 | def _ensure_no_duplicate_errors 257 | errors.messages.each_key do |attribute| 258 | errors[attribute].uniq! 259 | end 260 | end 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /lib/duck_record/persistence.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | # = DuckRecord \Persistence 3 | module Persistence 4 | extend ActiveSupport::Concern 5 | 6 | def persisted? 7 | false 8 | end 9 | 10 | def destroyed? 11 | false 12 | end 13 | 14 | def new_record? 15 | true 16 | end 17 | 18 | # Returns an instance of the specified +klass+ with the attributes of the 19 | # current record. This is mostly useful in relation to single-table 20 | # inheritance structures where you want a subclass to appear as the 21 | # superclass. This can be used along with record identification in 22 | # Action Pack to allow, say, Client < Company to do something 23 | # like render partial: @client.becomes(Company) to render that 24 | # instance using the companies/company partial instead of clients/client. 25 | # 26 | # Note: The new instance will share a link to the same attributes as the original class. 27 | # Therefore the sti column value will still be the same. 28 | # Any change to the attributes on either instance will affect both instances. 29 | # If you want to change the sti column as well, use #becomes! instead. 30 | def becomes(klass) 31 | became = klass.new 32 | became.instance_variable_set("@attributes", @attributes) 33 | became.instance_variable_set("@mutation_tracker", @mutation_tracker) if defined?(@mutation_tracker) 34 | became.instance_variable_set("@changed_attributes", attributes_changed_by_setter) 35 | became.errors.copy!(errors) 36 | became 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/duck_record/readonly_attributes.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module ReadonlyAttributes 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | attr_accessor :_attr_readonly_enabled 7 | class_attribute :_attr_readonly, instance_accessor: false 8 | self._attr_readonly = [] 9 | end 10 | 11 | def attr_readonly_enabled? 12 | _attr_readonly_enabled 13 | end 14 | 15 | def enable_attr_readonly! 16 | self._attr_readonly_enabled = true 17 | end 18 | 19 | def disable_attr_readonly! 20 | self._attr_readonly_enabled = false 21 | end 22 | 23 | module ClassMethods 24 | # Attributes listed as readonly will be used to create a new record but update operations will 25 | # ignore these fields. 26 | def attr_readonly(*attributes) 27 | self._attr_readonly = Set.new(attributes.map(&:to_s)) + (_attr_readonly || []) 28 | end 29 | 30 | # Returns an array of all the attributes that have been specified as readonly. 31 | def readonly_attributes 32 | _attr_readonly 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/duck_record/serialization.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord #:nodoc: 2 | # = Active Record \Serialization 3 | module Serialization 4 | extend ActiveSupport::Concern 5 | include ActiveModel::Serializers::JSON 6 | 7 | included do 8 | self.include_root_in_json = false 9 | end 10 | 11 | private 12 | 13 | def read_attribute_for_serialization(key) 14 | v = send(key) 15 | if v.respond_to?(:serializable_hash) 16 | v.serializable_hash 17 | elsif v.respond_to?(:to_ary) 18 | v.to_ary 19 | elsif v.respond_to?(:to_hash) 20 | v.to_hash 21 | else 22 | v 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/duck_record/translation.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Translation 3 | include ActiveModel::Translation 4 | 5 | # Set the lookup ancestors for ActiveModel. 6 | def lookup_ancestors #:nodoc: 7 | klass = self 8 | classes = [klass] 9 | return classes if klass == DuckRecord::Base 10 | 11 | while klass != klass.base_class 12 | classes << klass = klass.superclass 13 | end 14 | classes 15 | end 16 | 17 | # Set the i18n scope to overwrite ActiveModel. 18 | def i18n_scope #:nodoc: 19 | :duck_record 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/duck_record/type.rb: -------------------------------------------------------------------------------- 1 | require "active_model/type" 2 | 3 | require "duck_record/type/internal/abstract_json" 4 | require "duck_record/type/internal/timezone" 5 | 6 | require "duck_record/type/date" 7 | require "duck_record/type/date_time" 8 | require "duck_record/type/time" 9 | require "duck_record/type/json" 10 | 11 | require "duck_record/type/array" 12 | require "duck_record/type/array_without_blank" 13 | 14 | require "duck_record/type/unsigned_integer" 15 | require "duck_record/type/decimal_without_scale" 16 | require "duck_record/type/text" 17 | 18 | require "duck_record/type/serialized" 19 | require "duck_record/type/registry" 20 | 21 | module DuckRecord 22 | module Type 23 | @registry = Registry.new 24 | 25 | class << self 26 | attr_accessor :registry # :nodoc: 27 | delegate :add_modifier, to: :registry 28 | 29 | # Add a new type to the registry, allowing it to be referenced as a 30 | # symbol by {ActiveRecord::Base.attribute}[rdoc-ref:Attributes::ClassMethods#attribute]. 31 | # If your type is only meant to be used with a specific database adapter, you can 32 | # do so by passing adapter: :postgresql. If your type has the same 33 | # name as a native type for the current adapter, an exception will be 34 | # raised unless you specify an +:override+ option. override: true will 35 | # cause your type to be used instead of the native type. override: 36 | # false will cause the native type to be used over yours if one exists. 37 | def register(type_name, klass = nil, **options, &block) 38 | registry.register(type_name, klass, **options, &block) 39 | end 40 | 41 | def lookup(*args, **kwargs) # :nodoc: 42 | registry.lookup(*args, **kwargs) 43 | end 44 | 45 | def default_value # :nodoc: 46 | @default_value ||= Value.new 47 | end 48 | end 49 | 50 | Helpers = ActiveModel::Type::Helpers 51 | BigInteger = ActiveModel::Type::BigInteger 52 | Binary = ActiveModel::Type::Binary 53 | Boolean = ActiveModel::Type::Boolean 54 | Decimal = ActiveModel::Type::Decimal 55 | Float = ActiveModel::Type::Float 56 | Integer = ActiveModel::Type::Integer 57 | String = ActiveModel::Type::String 58 | Value = ActiveModel::Type::Value 59 | 60 | register(:big_integer, Type::BigInteger, override: false) 61 | register(:decimal_without_scale) 62 | register(:binary, Type::Binary, override: false) 63 | register(:boolean, Type::Boolean, override: false) 64 | register(:date, Type::Date, override: false) 65 | register(:datetime, Type::DateTime, override: false) 66 | register(:decimal, Type::Decimal, override: false) 67 | register(:float, Type::Float, override: false) 68 | register(:integer, Type::Integer, override: false) 69 | register(:string, Type::String, override: false) 70 | register(:text, Type::Text, override: false) 71 | register(:time, Type::Time, override: false) 72 | register(:json, Type::JSON, override: false) 73 | 74 | add_modifier({ array: true }, Type::Array) 75 | add_modifier({ array_without_blank: true }, Type::ArrayWithoutBlank) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/duck_record/type/array.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type # :nodoc: 3 | class Array < ActiveModel::Type::Value # :nodoc: 4 | include ActiveModel::Type::Helpers::Mutable 5 | 6 | attr_reader :subtype 7 | delegate :type, :user_input_in_time_zone, :limit, to: :subtype 8 | 9 | def initialize(subtype) 10 | @subtype = subtype 11 | end 12 | 13 | def cast(value) 14 | type_cast_array(value, :cast) 15 | end 16 | 17 | def ==(other) 18 | other.is_a?(Array) && subtype == other.subtype 19 | end 20 | 21 | def map(value, &block) 22 | value.map(&block) 23 | end 24 | 25 | private 26 | 27 | def type_cast_array(value, method) 28 | if value.is_a?(::Array) 29 | value.map { |item| type_cast_array(item, method) } 30 | else 31 | @subtype.public_send(method, value) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/duck_record/type/array_without_blank.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type # :nodoc: 3 | class ArrayWithoutBlank < ActiveModel::Type::Value # :nodoc: 4 | include ActiveModel::Type::Helpers::Mutable 5 | 6 | attr_reader :subtype 7 | delegate :type, :user_input_in_time_zone, :limit, to: :subtype 8 | 9 | def initialize(subtype) 10 | @subtype = subtype 11 | end 12 | 13 | def cast(value) 14 | type_cast_array(value, :cast) 15 | end 16 | 17 | def ==(other) 18 | other.is_a?(Array) && subtype == other.subtype 19 | end 20 | 21 | def map(value, &block) 22 | value.map(&block) 23 | end 24 | 25 | private 26 | 27 | def type_cast_array(value, method) 28 | if value.is_a?(::Array) 29 | ::ArrayWithoutBlank.new value.map { |item| type_cast_array(item, method) } 30 | else 31 | @subtype.public_send(method, value) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/duck_record/type/date.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type 3 | class Date < ActiveModel::Type::Date 4 | include Internal::Timezone 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/duck_record/type/date_time.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type 3 | class DateTime < ActiveModel::Type::DateTime 4 | include Internal::Timezone 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/duck_record/type/decimal_without_scale.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type 3 | class DecimalWithoutScale < ActiveModel::Type::BigInteger # :nodoc: 4 | def type 5 | :decimal 6 | end 7 | 8 | def type_cast_for_schema(value) 9 | value.to_s.inspect 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/duck_record/type/internal/abstract_json.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type 3 | module Internal # :nodoc: 4 | class AbstractJson < ActiveModel::Type::Value # :nodoc: 5 | include ActiveModel::Type::Helpers::Mutable 6 | 7 | def type 8 | :json 9 | end 10 | 11 | def deserialize(value) 12 | if value.is_a?(::String) 13 | ::ActiveSupport::JSON.decode(value) rescue nil 14 | else 15 | value 16 | end 17 | end 18 | 19 | def serialize(value) 20 | if value.nil? 21 | nil 22 | else 23 | ::ActiveSupport::JSON.encode(value) 24 | end 25 | end 26 | 27 | def accessor 28 | DuckRecord::Store::StringKeyedHashAccessor 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/duck_record/type/internal/timezone.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type 3 | module Internal 4 | module Timezone 5 | def is_utc? 6 | DuckRecord::Base.default_timezone == :utc 7 | end 8 | 9 | def default_timezone 10 | DuckRecord::Base.default_timezone 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/duck_record/type/json.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type 3 | class JSON < Internal::AbstractJson 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/duck_record/type/registry.rb: -------------------------------------------------------------------------------- 1 | require "active_model/type/registry" 2 | 3 | module DuckRecord 4 | # :stopdoc: 5 | module Type 6 | class Registry < ActiveModel::Type::Registry 7 | def add_modifier(options, klass) 8 | registrations << DecorationRegistration.new(options, klass) 9 | end 10 | 11 | private 12 | 13 | def registration_klass 14 | Registration 15 | end 16 | 17 | def find_registration(symbol, *args) 18 | registrations 19 | .select { |registration| registration.matches?(symbol, *args) } 20 | .max 21 | end 22 | end 23 | 24 | class Registration 25 | def initialize(name, block, override: nil) 26 | @name = name 27 | @block = block 28 | @override = override 29 | end 30 | 31 | def call(_registry, *args, **kwargs) 32 | if kwargs.any? # https://bugs.ruby-lang.org/issues/10856 33 | block.call(*args, **kwargs) 34 | else 35 | block.call(*args) 36 | end 37 | end 38 | 39 | def matches?(type_name, *args, **kwargs) 40 | type_name == name 41 | end 42 | 43 | def <=>(other) 44 | priority <=> other.priority 45 | end 46 | 47 | # TODO Change this to private once we've dropped Ruby 2.2 support. 48 | # Workaround for Ruby 2.2 "private attribute?" warning. 49 | protected 50 | 51 | attr_reader :name, :block, :override 52 | 53 | def priority 54 | result = 0 55 | if override 56 | result |= 1 57 | end 58 | result 59 | end 60 | end 61 | 62 | class DecorationRegistration < Registration 63 | def initialize(options, klass) 64 | @options = options 65 | @klass = klass 66 | end 67 | 68 | def call(registry, *args, **kwargs) 69 | subtype = registry.lookup(*args, **kwargs.except(*options.keys)) 70 | klass.new(subtype) 71 | end 72 | 73 | def matches?(*args, **kwargs) 74 | matches_options?(**kwargs) 75 | end 76 | 77 | def priority 78 | super | 4 79 | end 80 | 81 | # TODO Change this to private once we've dropped Ruby 2.2 support. 82 | # Workaround for Ruby 2.2 "private attribute?" warning. 83 | protected 84 | 85 | attr_reader :options, :klass 86 | 87 | private 88 | 89 | def matches_options?(**kwargs) 90 | options.all? do |key, value| 91 | kwargs[key] == value 92 | end 93 | end 94 | end 95 | end 96 | # :startdoc: 97 | end 98 | -------------------------------------------------------------------------------- /lib/duck_record/type/serialized.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type 3 | class Serialized < DelegateClass(ActiveModel::Type::Value) # :nodoc: 4 | include ActiveModel::Type::Helpers::Mutable 5 | 6 | attr_reader :subtype, :coder 7 | 8 | def initialize(subtype, coder) 9 | @subtype = subtype 10 | @coder = coder 11 | super(subtype) 12 | end 13 | 14 | def deserialize(value) 15 | if default_value?(value) 16 | value 17 | else 18 | coder.load(super) 19 | end 20 | end 21 | 22 | def serialize(value) 23 | return if value.nil? 24 | unless default_value?(value) 25 | super coder.dump(value) 26 | end 27 | end 28 | 29 | def inspect 30 | Kernel.instance_method(:inspect).bind(self).call 31 | end 32 | 33 | def changed_in_place?(raw_old_value, value) 34 | return false if value.nil? 35 | raw_new_value = encoded(value) 36 | raw_old_value.nil? != raw_new_value.nil? || 37 | subtype.changed_in_place?(raw_old_value, raw_new_value) 38 | end 39 | 40 | def accessor 41 | DuckRecord::Store::IndifferentHashAccessor 42 | end 43 | 44 | def assert_valid_value(value) 45 | if coder.respond_to?(:assert_valid_value) 46 | coder.assert_valid_value(value, action: "serialize") 47 | end 48 | end 49 | 50 | private 51 | 52 | def default_value?(value) 53 | value == coder.load(nil) 54 | end 55 | 56 | def encoded(value) 57 | unless default_value?(value) 58 | coder.dump(value) 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/duck_record/type/text.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type 3 | class Text < ActiveModel::Type::String # :nodoc: 4 | def type 5 | :text 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/duck_record/type/time.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type 3 | class Time < ActiveModel::Type::Time 4 | include Internal::Timezone 5 | 6 | class Value < DelegateClass(::Time) # :nodoc: 7 | end 8 | 9 | def serialize(value) 10 | case value = super 11 | when ::Time 12 | Value.new(value) 13 | else 14 | value 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/duck_record/type/unsigned_integer.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | module Type 3 | class UnsignedInteger < ActiveModel::Type::Integer # :nodoc: 4 | private 5 | 6 | def max_value 7 | super * 2 8 | end 9 | 10 | def min_value 11 | 0 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/duck_record/validations.rb: -------------------------------------------------------------------------------- 1 | require "duck_record/validations/uniqueness_on_real_record" 2 | require "duck_record/validations/subset" 3 | 4 | module DuckRecord 5 | class RecordInvalid < DuckRecordError 6 | attr_reader :record 7 | 8 | def initialize(record = nil) 9 | if record 10 | @record = record 11 | errors = @record.errors.full_messages.join(", ") 12 | message = I18n.t(:"#{@record.class.i18n_scope}.errors.messages.record_invalid", errors: errors, default: :"errors.messages.record_invalid") 13 | else 14 | message = "Record invalid" 15 | end 16 | 17 | super(message) 18 | end 19 | end 20 | 21 | # = Active Record \Validations 22 | # 23 | # Active Record includes the majority of its validations from ActiveModel::Validations 24 | # all of which accept the :on argument to define the context where the 25 | # validations are active. Active Record will always supply either the context of 26 | # :create or :update dependent on whether the model is a 27 | # {new_record?}[rdoc-ref:Persistence#new_record?]. 28 | module Validations 29 | extend ActiveSupport::Concern 30 | include ActiveModel::Validations 31 | 32 | # Runs all the validations within the specified context. Returns +true+ if 33 | # no errors are found, +false+ otherwise. 34 | # 35 | # Aliased as #validate. 36 | # 37 | # If the argument is +false+ (default is +nil+), the context is set to :default. 38 | # 39 | # \Validations with no :on option will run no matter the context. \Validations with 40 | # some :on option will only run in the specified context. 41 | def valid?(context = nil) 42 | context ||= default_validation_context 43 | output = super(context) 44 | errors.empty? && output 45 | end 46 | 47 | def valid!(context = nil) 48 | if valid?(context) 49 | true 50 | else 51 | raise RecordInvalid.new(self) 52 | end 53 | end 54 | 55 | alias_method :validate, :valid? 56 | 57 | private 58 | 59 | def default_validation_context 60 | :default 61 | end 62 | 63 | def perform_validations(options = {}) 64 | options[:validate] == false || valid?(options[:context]) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/duck_record/validations/subset.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DuckRecord 4 | module Validations 5 | class SubsetValidator < ActiveModel::EachValidator # :nodoc: 6 | ERROR_MESSAGE = "An object with the method #include? or a proc, lambda or symbol is required, " \ 7 | "and must be supplied as the :in (or :within) option of the configuration hash" 8 | 9 | def check_validity! 10 | unless delimiter.respond_to?(:include?) || delimiter.respond_to?(:call) || delimiter.respond_to?(:to_sym) 11 | raise ArgumentError, ERROR_MESSAGE 12 | end 13 | end 14 | 15 | def validate_each(record, attribute, value) 16 | unless subset?(record, value) 17 | record.errors.add(attribute, :subset, options.except(:in, :within).merge!(value: value)) 18 | end 19 | end 20 | 21 | private 22 | 23 | def delimiter 24 | @delimiter ||= options[:in] || options[:within] 25 | end 26 | 27 | def subset?(record, value) 28 | return false unless value.respond_to?(:to_a) 29 | 30 | enumerable = value.to_a 31 | members = 32 | if delimiter.respond_to?(:call) 33 | delimiter.call(record) 34 | elsif delimiter.respond_to?(:to_sym) 35 | record.send(delimiter) 36 | else 37 | delimiter 38 | end 39 | 40 | (members & enumerable).size == enumerable.size 41 | end 42 | end 43 | 44 | module HelperMethods 45 | # Validates whether the value of the specified attribute is available in a 46 | # particular enumerable object. 47 | # 48 | # class Person < ActiveRecord::Base 49 | # validates_inclusion_of :gender, in: %w( m f ) 50 | # validates_inclusion_of :age, in: 0..99 51 | # validates_inclusion_of :format, in: %w( jpg gif png ), message: "extension %{value} is not included in the list" 52 | # validates_inclusion_of :states, in: ->(person) { STATES[person.country] } 53 | # validates_inclusion_of :karma, in: :available_karmas 54 | # end 55 | # 56 | # Configuration options: 57 | # * :in - An enumerable object of available items. This can be 58 | # supplied as a proc, lambda or symbol which returns an enumerable. If the 59 | # enumerable is a numerical, time or datetime range the test is performed 60 | # with Range#cover?, otherwise with include?. When using 61 | # a proc or lambda the instance under validation is passed as an argument. 62 | # * :within - A synonym(or alias) for :in 63 | # * :message - Specifies a custom error message (default is: "is 64 | # not included in the list"). 65 | # 66 | # There is also a list of default options supported by every validator: 67 | # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. 68 | # See ActiveModel::Validations#validates for more information 69 | def validates_subset_of(*attr_names) 70 | validates_with SubsetValidator, _merge_attributes(attr_names) 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/duck_record/version.rb: -------------------------------------------------------------------------------- 1 | module DuckRecord 2 | VERSION = "0.0.27" 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/acts_as_record_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :duck_record do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /test/acts_as_record_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class DuckRecord::Test < ActiveSupport::TestCase 4 | test "truth" do 5 | assert_kind_of Module, DuckRecord 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | 2 | //= link_tree ../images 3 | //= link_directory ../stylesheets .css 4 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasl-lab/duck_record/93d2b89c55dff82ebda0103b282184b5f6959f58/test/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery with: :exception 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasl-lab/duck_record/93d2b89c55dff82ebda0103b282184b5f6959f58/test/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasl-lab/duck_record/93d2b89c55dff82ebda0103b282184b5f6959f58/test/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/models/contact.rb: -------------------------------------------------------------------------------- 1 | class Contact < ApplicationRecord 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ApplicationRecord 2 | belongs_to :user 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/profile.rb: -------------------------------------------------------------------------------- 1 | class Profile < ApplicationRecord 2 | belongs_to :user 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | has_one :profile 3 | 4 | has_many :posts 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= csrf_meta_tags %> 6 | 7 | <%= stylesheet_link_tag 'application', media: 'all' %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __FILE__) 3 | load Gem.bin_path("bundler", "bundle") 4 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "pathname" 3 | require "fileutils" 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path("../../", __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a starting point to setup your application. 15 | # Add necessary setup steps to this file. 16 | 17 | puts "== Installing dependencies ==" 18 | system! "gem install bundler --conservative" 19 | system("bundle check") || system!("bundle install") 20 | 21 | # puts "\n== Copying sample files ==" 22 | # unless File.exist?('config/database.yml') 23 | # cp 'config/database.yml.sample', 'config/database.yml' 24 | # end 25 | 26 | puts "\n== Preparing database ==" 27 | system! "bin/rails db:setup" 28 | 29 | puts "\n== Removing old logs and tempfiles ==" 30 | system! "bin/rails log:clear tmp:clear" 31 | 32 | puts "\n== Restarting application server ==" 33 | system! "bin/rails restart" 34 | end 35 | -------------------------------------------------------------------------------- /test/dummy/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "pathname" 3 | require "fileutils" 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path("../../", __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts "== Installing dependencies ==" 18 | system! "gem install bundler --conservative" 19 | system("bundle check") || system!("bundle install") 20 | 21 | puts "\n== Updating database ==" 22 | system! "bin/rails db:migrate" 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! "bin/rails log:clear tmp:clear" 26 | 27 | puts "\n== Restarting application server ==" 28 | system! "bin/rails restart" 29 | end 30 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | # Pick the frameworks you want: 4 | require "active_record/railtie" 5 | require "action_controller/railtie" 6 | require "action_view/railtie" 7 | # require "action_mailer/railtie" 8 | # require "active_job/railtie" 9 | # require "action_cable/engine" 10 | require "rails/test_unit/railtie" 11 | # require "sprockets/railtie" 12 | 13 | Bundler.require(*Rails.groups) 14 | require "duck_record" 15 | 16 | module Dummy 17 | class Application < Rails::Application 18 | # Settings in config/environments/* take precedence over those specified here. 19 | # Application configuration should go into files in config/initializers 20 | # -- all .rb files in that directory are automatically loaded. 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: redis://localhost:6379/1 10 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: 5 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | if Rails.root.join("tmp/caching-dev.txt").exist? 17 | config.action_controller.perform_caching = true 18 | 19 | config.cache_store = :memory_store 20 | config.public_file_server.headers = { 21 | "Cache-Control" => "public, max-age=172800" 22 | } 23 | else 24 | config.action_controller.perform_caching = false 25 | 26 | config.cache_store = :null_store 27 | end 28 | 29 | # Print deprecation notices to the Rails logger. 30 | config.active_support.deprecation = :log 31 | 32 | # Raise an error on page load if there are pending migrations. 33 | # config.active_record.migration_error = :page_load 34 | 35 | # Raises error for missing translations 36 | # config.action_view.raise_on_missing_translations = true 37 | 38 | # Use an evented file watcher to asynchronously detect changes in source code, 39 | # routes, locales, etc. This feature depends on the listen gem. 40 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 41 | end 42 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Disable serving static files from the `/public` folder by default since 18 | # Apache or NGINX already handles this. 19 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 20 | 21 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 22 | # config.action_controller.asset_host = 'http://assets.example.com' 23 | 24 | # Specifies the header that your server uses for sending files. 25 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 26 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 27 | 28 | # Mount Action Cable outside main process or domain 29 | # config.action_cable.mount_path = nil 30 | # config.action_cable.url = 'wss://example.com/cable' 31 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 32 | 33 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 34 | # config.force_ssl = true 35 | 36 | # Use the lowest log level to ensure availability of diagnostic information 37 | # when problems arise. 38 | config.log_level = :debug 39 | 40 | # Prepend all log lines with the following tags. 41 | config.log_tags = [ :request_id ] 42 | 43 | # Use a different cache store in production. 44 | # config.cache_store = :mem_cache_store 45 | 46 | # Use a real queuing backend for Active Job (and separate queues per environment) 47 | # config.active_job.queue_adapter = :resque 48 | # config.active_job.queue_name_prefix = "dummy_#{Rails.env}" 49 | 50 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 51 | # the I18n.default_locale when a translation cannot be found). 52 | config.i18n.fallbacks = true 53 | 54 | # Send deprecation notices to registered listeners. 55 | config.active_support.deprecation = :notify 56 | 57 | # Use default logging formatter so that PID and timestamp are not suppressed. 58 | config.log_formatter = ::Logger::Formatter.new 59 | 60 | # Use a different logger for distributed setups. 61 | # require 'syslog/logger' 62 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 63 | 64 | if ENV["RAILS_LOG_TO_STDOUT"].present? 65 | logger = ActiveSupport::Logger.new(STDOUT) 66 | logger.formatter = config.log_formatter 67 | config.logger = ActiveSupport::TaggedLogging.new(logger) 68 | end 69 | 70 | # Do not dump schema after migrations. 71 | config.active_record.dump_schema_after_migration = false 72 | end 73 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | config.public_file_server.enabled = true 17 | config.public_file_server.headers = { 18 | "Cache-Control" => "public, max-age=3600" 19 | } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Print deprecation notices to the stderr. 32 | config.active_support.deprecation = :stderr 33 | 34 | # Raises error for missing translations 35 | # config.action_view.raise_on_missing_translations = true 36 | end 37 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ApplicationController.renderer.defaults.merge!( 4 | # http_host: 'example.org', 5 | # https: false 6 | # ) 7 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/new_framework_defaults.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file contains migration options to ease your Rails 5.0 upgrade. 4 | # 5 | # Read the Guide for Upgrading Ruby on Rails for more info on each option. 6 | 7 | # Enable per-form CSRF tokens. Previous versions had false. 8 | Rails.application.config.action_controller.per_form_csrf_tokens = true 9 | 10 | # Enable origin-checking CSRF mitigation. Previous versions had false. 11 | Rails.application.config.action_controller.forgery_protection_origin_check = true 12 | 13 | # Make Ruby 2.4 preserve the timezone of the receiver when calling `to_time`. 14 | # Previous versions had false. 15 | ActiveSupport.to_time_preserves_timezone = true 16 | 17 | # Require `belongs_to` associations by default. Previous versions had false. 18 | # Rails.application.config.active_record.belongs_to_required_by_default = true 19 | 20 | # Configure SSL options to enable HSTS with subdomains. Previous versions had false. 21 | Rails.application.config.ssl_options = { hsts: { subdomains: true } } 22 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: "_dummy_session" 4 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:duck_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum, this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests, default is 3000. 11 | # 12 | port ENV.fetch("PORT") { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch("RAILS_ENV") { "development" } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. If you use this option 30 | # you need to make sure to reconnect any threads in the `on_worker_boot` 31 | # block. 32 | # 33 | # preload_app! 34 | 35 | # The code in the `on_worker_boot` will be called if you are using 36 | # clustered mode by specifying a number of `workers`. After each worker 37 | # process is booted this block will be run, if you are using `preload_app!` 38 | # option you will want to use this block to reconnect to any threads 39 | # or connections that may have been created at application boot, Ruby 40 | # cannot share connections between processes. 41 | # 42 | # on_worker_boot do 43 | # ActiveRecord::Base.establish_connection if defined?(ActiveRecord) 44 | # end 45 | 46 | # Allow puma to be restarted by `rails restart` command. 47 | plugin :tmp_restart 48 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rails secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 574447e32eccdf5c10dff354846712c8b72a7f275dce4562c9ec1dedf293b1d057ed00eb8a158079d63683f8c4d04f7b3d9c2b8da9b2ea45d5880f7e4a053fd1 15 | 16 | test: 17 | secret_key_base: 7f13feb99c6234b2ba911ba6028068eb50c723a8642af49db36506031a993433f39937257568628f2660efa158ae4d81fb733dec184506c90568914499a2332a 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /test/dummy/config/spring.rb: -------------------------------------------------------------------------------- 1 | %w( 2 | .ruby-version 3 | .rbenv-vars 4 | tmp/restart.txt 5 | tmp/caching-dev.txt 6 | ).each { |path| Spring.watch(path) } 7 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20170325065439_create_contacts.rb: -------------------------------------------------------------------------------- 1 | class CreateContacts < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :contacts do |t| 4 | t.string :email 5 | t.string :name 6 | t.string :phone 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20180106214409_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :users do |t| 4 | t.string :name 5 | 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20180106214441_create_profiles.rb: -------------------------------------------------------------------------------- 1 | class CreateProfiles < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :profiles do |t| 4 | t.references :user, foreign_key: true 5 | t.text :description 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20180106214458_create_posts.rb: -------------------------------------------------------------------------------- 1 | class CreatePosts < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :posts do |t| 4 | t.references :user, foreign_key: true 5 | 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 20180106214458) do 14 | 15 | create_table "contacts", force: :cascade do |t| 16 | t.string "email" 17 | t.string "name" 18 | t.string "phone" 19 | t.datetime "created_at", null: false 20 | t.datetime "updated_at", null: false 21 | end 22 | 23 | create_table "posts", force: :cascade do |t| 24 | t.integer "user_id" 25 | t.datetime "created_at", null: false 26 | t.datetime "updated_at", null: false 27 | t.index ["user_id"], name: "index_posts_on_user_id" 28 | end 29 | 30 | create_table "profiles", force: :cascade do |t| 31 | t.integer "user_id" 32 | t.text "description" 33 | t.datetime "created_at", null: false 34 | t.datetime "updated_at", null: false 35 | t.index ["user_id"], name: "index_profiles_on_user_id" 36 | end 37 | 38 | create_table "users", force: :cascade do |t| 39 | t.string "name" 40 | t.datetime "created_at", null: false 41 | t.datetime "updated_at", null: false 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasl-lab/duck_record/93d2b89c55dff82ebda0103b282184b5f6959f58/test/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasl-lab/duck_record/93d2b89c55dff82ebda0103b282184b5f6959f58/test/dummy/log/.keep -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasl-lab/duck_record/93d2b89c55dff82ebda0103b282184b5f6959f58/test/dummy/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasl-lab/duck_record/93d2b89c55dff82ebda0103b282184b5f6959f58/test/dummy/public/apple-touch-icon.png -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasl-lab/duck_record/93d2b89c55dff82ebda0103b282184b5f6959f58/test/dummy/public/favicon.ico -------------------------------------------------------------------------------- /test/dummy/test/fixtures/contacts.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | email: MyString 5 | name: MyString 6 | phone: MyString 7 | 8 | two: 9 | email: MyString 10 | name: MyString 11 | phone: MyString 12 | -------------------------------------------------------------------------------- /test/dummy/test/models/contact_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ContactTest < ActiveSupport::TestCase 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | 4 | require File.expand_path("../../test/dummy/config/environment.rb", __FILE__) 5 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../../test/dummy/db/migrate", __FILE__)] 6 | require "rails/test_help" 7 | 8 | # Filter out Minitest backtrace while allowing backtrace from other libraries 9 | # to be shown. 10 | Minitest.backtrace_filter = Minitest::BacktraceFilter.new 11 | 12 | Rails::TestUnitReporter.executable = "bin/test" 13 | 14 | # Load fixtures from the engine 15 | if ActiveSupport::TestCase.respond_to?(:fixture_path=) 16 | ActiveSupport::TestCase.fixture_path = File.expand_path("../fixtures", __FILE__) 17 | ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path 18 | ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_path + "/files" 19 | ActiveSupport::TestCase.fixtures :all 20 | end 21 | --------------------------------------------------------------------------------