├── test
├── dummy
│ ├── log
│ │ └── .keep
│ ├── lib
│ │ └── assets
│ │ │ └── .keep
│ ├── public
│ │ ├── favicon.ico
│ │ ├── apple-touch-icon.png
│ │ ├── apple-touch-icon-precomposed.png
│ │ ├── 500.html
│ │ ├── 422.html
│ │ └── 404.html
│ ├── app
│ │ ├── assets
│ │ │ ├── images
│ │ │ │ └── .keep
│ │ │ ├── config
│ │ │ │ └── manifest.js
│ │ │ ├── javascripts
│ │ │ │ └── application.js
│ │ │ └── stylesheets
│ │ │ │ └── application.css
│ │ ├── models
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ ├── user.rb
│ │ │ └── application_record.rb
│ │ ├── controllers
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ └── application_controller.rb
│ │ ├── helpers
│ │ │ └── application_helper.rb
│ │ ├── jobs
│ │ │ └── application_job.rb
│ │ └── views
│ │ │ └── layouts
│ │ │ └── application.html.erb
│ ├── package.json
│ ├── bin
│ │ ├── rake
│ │ ├── bundle
│ │ ├── rails
│ │ ├── yarn
│ │ ├── setup
│ │ └── update
│ ├── config
│ │ ├── spring.rb
│ │ ├── routes.rb
│ │ ├── environment.rb
│ │ ├── initializers
│ │ │ ├── mime_types.rb
│ │ │ ├── filter_parameter_logging.rb
│ │ │ ├── cookies_serializer.rb
│ │ │ ├── application_controller_renderer.rb
│ │ │ ├── wrap_parameters.rb
│ │ │ ├── backtrace_silencers.rb
│ │ │ ├── assets.rb
│ │ │ ├── inflections.rb
│ │ │ └── content_security_policy.rb
│ │ ├── boot.rb
│ │ ├── database.yml
│ │ ├── locales
│ │ │ └── en.yml
│ │ ├── application.rb
│ │ ├── puma.rb
│ │ └── environments
│ │ │ ├── test.rb
│ │ │ ├── development.rb
│ │ │ └── production.rb
│ ├── config.ru
│ ├── db
│ │ ├── migrate
│ │ │ └── 20190525203630_create_users.rb
│ │ └── schema.rb
│ └── Rakefile
├── active_entity_test.rb
└── test_helper.rb
├── .gitattributes
├── bin
└── test
├── lib
├── active_entity
│ ├── type
│ │ ├── date.rb
│ │ ├── date_time.rb
│ │ ├── text.rb
│ │ ├── unsigned_integer.rb
│ │ ├── internal
│ │ │ └── timezone.rb
│ │ ├── time.rb
│ │ ├── modifiers
│ │ │ ├── array_without_blank.rb
│ │ │ └── array.rb
│ │ ├── json.rb
│ │ ├── serialized.rb
│ │ └── registry.rb
│ ├── version.rb
│ ├── coders
│ │ ├── json.rb
│ │ └── yaml_column.rb
│ ├── persistence.rb
│ ├── associations
│ │ └── embeds
│ │ │ ├── builder
│ │ │ ├── embeds_many.rb
│ │ │ ├── embeds_one.rb
│ │ │ ├── singular_association.rb
│ │ │ ├── embedded_in.rb
│ │ │ ├── collection_association.rb
│ │ │ └── association.rb
│ │ │ ├── embeds_many_association.rb
│ │ │ ├── embeds_one_association.rb
│ │ │ ├── embedded_in_association.rb
│ │ │ ├── singular_association.rb
│ │ │ ├── association.rb
│ │ │ └── collection_association.rb
│ ├── gem_version.rb
│ ├── callbacks.rb
│ ├── translation.rb
│ ├── serialization.rb
│ ├── validations
│ │ ├── absence.rb
│ │ ├── length.rb
│ │ ├── associated.rb
│ │ ├── subset.rb
│ │ ├── uniqueness_in_embeds.rb
│ │ └── presence.rb
│ ├── attribute_methods
│ │ ├── query.rb
│ │ ├── read.rb
│ │ ├── write.rb
│ │ ├── primary_key.rb
│ │ ├── before_type_cast.rb
│ │ ├── time_zone_conversion.rb
│ │ ├── serialization.rb
│ │ └── dirty.rb
│ ├── readonly_attributes.rb
│ ├── locale
│ │ └── en.yml
│ ├── validations.rb
│ ├── railtie.rb
│ ├── errors.rb
│ ├── integration.rb
│ ├── type.rb
│ ├── attribute_decorators.rb
│ ├── attribute_assignment.rb
│ ├── model_schema.rb
│ ├── inheritance.rb
│ ├── associations.rb
│ ├── attributes.rb
│ └── enum.rb
├── core_ext
│ └── array_without_blank.rb
└── active_entity.rb
├── .editorconfig
├── Rakefile
├── .gitignore
├── activeentity.gemspec
├── Gemfile
├── MIT-LICENSE
├── Gemfile.lock
├── comparison_with_activemodel_and_activetype.md
├── .rubocop.yml
└── README.md
/test/dummy/log/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/lib/assets/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/app/assets/images/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/app/models/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/public/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.rb diff=ruby
2 | *.gemspec diff=ruby
3 |
4 |
--------------------------------------------------------------------------------
/test/dummy/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dummy",
3 | "private": true,
4 | "dependencies": {}
5 | }
6 |
--------------------------------------------------------------------------------
/test/dummy/app/models/user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class User < ApplicationRecord
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ApplicationHelper
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/app/jobs/application_job.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ApplicationJob < ActiveJob::Base
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/app/assets/config/manifest.js:
--------------------------------------------------------------------------------
1 | //= link_tree ../images
2 | //= link_directory ../javascripts .js
3 | //= link_directory ../stylesheets .css
4 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ApplicationController < ActionController::Base
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require_relative "../config/boot"
5 | require "rake"
6 | Rake.application.run
7 |
--------------------------------------------------------------------------------
/test/dummy/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ApplicationRecord < ActiveRecord::Base
4 | self.abstract_class = true
5 | end
6 |
--------------------------------------------------------------------------------
/bin/test:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | $: << File.expand_path("../test", __dir__)
5 |
6 | require "bundler/setup"
7 | require "rails/plugin/test"
8 |
--------------------------------------------------------------------------------
/test/dummy/bin/bundle:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
5 | load Gem.bin_path("bundler", "bundle")
6 |
--------------------------------------------------------------------------------
/test/dummy/config/spring.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | %w[
4 | .ruby-version
5 | .rbenv-vars
6 | tmp/restart.txt
7 | tmp/caching-dev.txt
8 | ].each { |path| Spring.watch(path) }
9 |
--------------------------------------------------------------------------------
/test/dummy/config.ru:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file is used by Rack-based servers to start the application.
4 |
5 | require_relative "config/environment"
6 |
7 | run Rails.application
8 |
--------------------------------------------------------------------------------
/test/dummy/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | APP_PATH = File.expand_path("../config/application", __dir__)
5 | require_relative "../config/boot"
6 | require "rails/commands"
7 |
--------------------------------------------------------------------------------
/test/dummy/config/routes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Rails.application.routes.draw do
4 | # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
5 | end
6 |
--------------------------------------------------------------------------------
/test/dummy/config/environment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Load the Rails application.
4 | require_relative "application"
5 |
6 | # Initialize the Rails application.
7 | Rails.application.initialize!
8 |
--------------------------------------------------------------------------------
/lib/active_entity/type/date.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Type
5 | class Date < ActiveModel::Type::Date
6 | include Internal::Timezone
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/active_entity/type/date_time.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Type
5 | class DateTime < ActiveModel::Type::DateTime
6 | include Internal::Timezone
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/active_entity_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class ActiveEntity::Test < ActiveSupport::TestCase
6 | test "truth" do
7 | assert_kind_of Module, ActiveEntity
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Add new mime types for use in respond_to blocks:
6 | # Mime::Type.register "text/richtext", :rtf
7 |
--------------------------------------------------------------------------------
/lib/active_entity/type/text.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Type
5 | class Text < ActiveModel::Type::String # :nodoc:
6 | def type
7 | :text
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/test/dummy/db/migrate/20190525203630_create_users.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateUsers < ActiveRecord::Migration[6.0]
4 | def change
5 | create_table :users do |t|
6 | t.string :name
7 |
8 | t.timestamps
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/active_entity/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "gem_version"
4 |
5 | module ActiveEntity
6 | # Returns the version of the currently loaded ActiveEntity as a Gem::Version
7 | def self.version
8 | gem_version
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Configure sensitive parameters which will be filtered from the log file.
6 | Rails.application.config.filter_parameters += [:password]
7 |
--------------------------------------------------------------------------------
/test/dummy/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Add your own tasks in files placed in lib/tasks ending in .rake,
4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
5 |
6 | require_relative "config/application"
7 |
8 | Rails.application.load_tasks
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # This file is for unifying the coding style for different editors and IDEs
2 | # editorconfig.org
3 |
4 | root = true
5 |
6 | [*]
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 | indent_style = space
11 | indent_size = 2
12 | end_of_line = lf
13 |
--------------------------------------------------------------------------------
/test/dummy/config/boot.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Set up gems listed in the Gemfile.
4 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__)
5 |
6 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
7 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__)
8 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/cookies_serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Specify a serializer for the signed and encrypted cookie jars.
6 | # Valid options are :json, :marshal, and :hybrid.
7 | Rails.application.config.action_dispatch.cookies_serializer = :json
8 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/application_controller_renderer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # ActiveSupport::Reloader.to_prepare do
6 | # ApplicationController.renderer.defaults.merge!(
7 | # http_host: 'example.org',
8 | # https: false
9 | # )
10 | # end
11 |
--------------------------------------------------------------------------------
/test/dummy/bin/yarn:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | APP_ROOT = File.expand_path("..", __dir__)
5 | Dir.chdir(APP_ROOT) do
6 | exec "yarnpkg", *ARGV
7 | rescue Errno::ENOENT
8 | $stderr.puts "Yarn executable was not detected in the system."
9 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install"
10 | exit 1
11 | end
12 |
--------------------------------------------------------------------------------
/lib/active_entity/type/unsigned_integer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Type
5 | class UnsignedInteger < ActiveModel::Type::Integer # :nodoc:
6 | private
7 |
8 | def max_value
9 | super * 2
10 | end
11 |
12 | def min_value
13 | 0
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/dummy/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Dummy
5 | <%= csrf_meta_tags %>
6 | <%= csp_meta_tag %>
7 |
8 | <%= stylesheet_link_tag 'application', media: 'all' %>
9 | <%= javascript_include_tag 'application' %>
10 |
11 |
12 |
13 | <%= yield %>
14 |
15 |
16 |
--------------------------------------------------------------------------------
/lib/active_entity/coders/json.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Coders # :nodoc:
5 | class JSON # :nodoc:
6 | def self.dump(obj)
7 | ActiveSupport::JSON.encode(obj)
8 | end
9 |
10 | def self.load(json)
11 | ActiveSupport::JSON.decode(json) unless json.blank?
12 | end
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/active_entity/persistence.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | # = Active Entity \Persistence
5 | module Persistence
6 | extend ActiveSupport::Concern
7 |
8 | def new_record?
9 | true
10 | end
11 |
12 | def destroyed?
13 | false
14 | end
15 |
16 | def persisted?
17 | false
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/builder/embeds_many.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity::Associations::Embeds::Builder # :nodoc:
4 | class EmbedsMany < CollectionAssociation #:nodoc:
5 | def self.macro
6 | :embeds_many
7 | end
8 |
9 | def self.valid_options(options)
10 | super + [:inverse_of, :index_errors]
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/active_entity/type/internal/timezone.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Type
5 | module Internal
6 | module Timezone
7 | def is_utc?
8 | ActiveEntity::Base.default_timezone == :utc
9 | end
10 |
11 | def default_timezone
12 | ActiveEntity::Base.default_timezone
13 | end
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/active_entity/gem_version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | # Returns the version of the currently loaded Active Entity as a Gem::Version
5 | def self.gem_version
6 | Gem::Version.new VERSION::STRING
7 | end
8 |
9 | module VERSION
10 | MAJOR = 6
11 | MINOR = 3
12 | TINY = 0
13 | PRE = nil
14 |
15 | STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # This file contains settings for ActionController::ParamsWrapper which
6 | # is enabled by default.
7 |
8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
9 | ActiveSupport.on_load(:action_controller) do
10 | wrap_parameters format: [:json]
11 | end
12 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Configure Rails Environment
4 | ENV["RAILS_ENV"] = "test"
5 |
6 | require_relative "../test/dummy/config/environment"
7 | require "rails/test_help"
8 |
9 | # Filter out Minitest backtrace while allowing backtrace from other libraries
10 | # to be shown.
11 | Minitest.backtrace_filter = Minitest::BacktraceFilter.new
12 |
13 | require "rails/test_unit/reporter"
14 | Rails::TestUnitReporter.executable = "bin/test"
15 |
--------------------------------------------------------------------------------
/lib/active_entity/type/time.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Type
5 | class Time < ActiveModel::Type::Time
6 | include Internal::Timezone
7 |
8 | class Value < DelegateClass(::Time) # :nodoc:
9 | end
10 |
11 | def serialize(value)
12 | case value = super
13 | when ::Time
14 | Value.new(value)
15 | else
16 | value
17 | end
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/builder/embeds_one.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity::Associations::Embeds::Builder # :nodoc:
4 | class EmbedsOne < SingularAssociation #:nodoc:
5 | def self.macro
6 | :embeds_one
7 | end
8 |
9 | def self.define_validations(model, reflection)
10 | super
11 | if reflection.options[:required]
12 | model.validates_presence_of reflection.name, message: :required
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
6 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
7 |
8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
9 | # Rails.backtrace_cleaner.remove_silencers!
10 |
--------------------------------------------------------------------------------
/lib/active_entity/callbacks.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Callbacks
5 | extend ActiveSupport::Concern
6 |
7 | CALLBACKS = [
8 | :after_initialize, :before_validation, :after_validation
9 | ]
10 |
11 | module ClassMethods # :nodoc:
12 | include ActiveModel::Callbacks
13 | end
14 |
15 | included do
16 | include ActiveModel::Validations::Callbacks
17 |
18 | define_model_callbacks :initialize, only: :after
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/embeds_many_association.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Associations
5 | module Embeds
6 | # = Active Entity Has Many Association
7 | # This is the proxy that handles a has many association.
8 | #
9 | # If the association has a :through option further specialization
10 | # is provided by its child HasManyThroughAssociation.
11 | class EmbedsManyAssociation < CollectionAssociation #:nodoc:
12 | end
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/active_entity/type/modifiers/array_without_blank.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Type
5 | module Modifiers
6 | class ArrayWithoutBlank < Array # :nodoc:
7 | private
8 |
9 | def type_cast_array(value, method)
10 | if value.is_a?(::Array)
11 | ::ArrayWithoutBlank.new value.map { |item| type_cast_array(item, method) }
12 | else
13 | @subtype.public_send(method, value)
14 | end
15 | end
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/active_entity/translation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Translation
5 | include ActiveModel::Translation
6 |
7 | # Set the lookup ancestors for ActiveModel.
8 | def lookup_ancestors #:nodoc:
9 | klass = self
10 | classes = [klass]
11 | return classes if klass == ActiveEntity::Base
12 |
13 | while !klass.base_class?
14 | classes << klass = klass.superclass
15 | end
16 | classes
17 | end
18 |
19 | # Set the i18n scope to overwrite ActiveModel.
20 | def i18n_scope #:nodoc:
21 | :active_entity
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/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: <%= ENV.fetch("RAILS_MAX_THREADS") { 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 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler/setup"
4 | require "rdoc/task"
5 |
6 | RDoc::Task.new(:rdoc) do |rdoc|
7 | rdoc.rdoc_dir = "rdoc"
8 | rdoc.title = "ActiveEntity"
9 | rdoc.options << "--line-numbers"
10 | rdoc.rdoc_files.include("README.md")
11 | rdoc.rdoc_files.include("lib/**/*.rb")
12 | end
13 |
14 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
15 | load "rails/tasks/engine.rake"
16 |
17 | load "rails/tasks/statistics.rake"
18 |
19 | require "bundler/gem_tasks"
20 |
21 | require "rake/testtask"
22 |
23 | Rake::TestTask.new(:test) do |t|
24 | t.libs << "test"
25 | t.pattern = "test/**/*_test.rb"
26 | t.verbose = false
27 | end
28 |
29 | task default: :test
30 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/assets.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Version of your assets, change this if you want to expire all your assets.
6 | Rails.application.config.assets.version = "1.0"
7 |
8 | # Add additional assets to the asset load path.
9 | # Rails.application.config.assets.paths << Emoji.images_path
10 | # Add Yarn node_modules folder to the asset load path.
11 | Rails.application.config.assets.paths << Rails.root.join("node_modules")
12 |
13 | # Precompile additional assets.
14 | # application.js, application.css, and all non-JS/CSS in the app/assets
15 | # folder are already added.
16 | # Rails.application.config.assets.precompile += %w( admin.js admin.css )
17 |
--------------------------------------------------------------------------------
/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 rails-ujs
14 | //= require_tree .
15 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Add new inflection rules using the following format. Inflections
6 | # are locale specific, and you may define rules for as many different
7 | # locales as you wish. All of these examples are active by default:
8 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
9 | # inflect.plural /^(ox)$/i, '\1en'
10 | # inflect.singular /^(ox)en/i, '\1'
11 | # inflect.irregular 'person', 'people'
12 | # inflect.uncountable %w( fish sheep )
13 | # end
14 |
15 | # These inflection rules are supported but not enabled by default:
16 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
17 | # inflect.acronym 'RESTful'
18 | # end
19 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/embeds_one_association.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Associations
5 | module Embeds
6 | # = Active Entity Has One Association
7 | class EmbedsOneAssociation < SingularAssociation #:nodoc:
8 | private
9 |
10 | def replace(record)
11 | self.target =
12 | if record.is_a? reflection.klass
13 | record
14 | elsif record.nil?
15 | nil
16 | elsif record.respond_to?(:to_h)
17 | build_record(record.to_h)
18 | end
19 | rescue => ex
20 | raise_on_type_mismatch!(record)
21 | raise ex
22 | end
23 | end
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/active_entity/type/json.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Type
5 | class Json < ActiveModel::Type::Value
6 | include ActiveModel::Type::Helpers::Mutable
7 |
8 | def type
9 | :json
10 | end
11 |
12 | def deserialize(value)
13 | return value unless value.is_a?(::String)
14 | ActiveSupport::JSON.decode(value) rescue nil
15 | end
16 |
17 | def serialize(value)
18 | ActiveSupport::JSON.encode(value) unless value.nil?
19 | end
20 |
21 | def changed_in_place?(raw_old_value, new_value)
22 | deserialize(raw_old_value) != new_value
23 | end
24 |
25 | def accessor
26 | ActiveEntity::Store::StringKeyedHashAccessor
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/active_entity/serialization.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity #:nodoc:
4 | # = Active Entity \Serialization
5 | module Serialization
6 | extend ActiveSupport::Concern
7 | include ActiveModel::Serializers::JSON
8 |
9 | included do
10 | self.include_root_in_json = false
11 | end
12 |
13 | def serializable_hash(options = nil)
14 | options = options ? options.dup : {}
15 |
16 | include_embeds = options.delete :include_embeds
17 | if include_embeds
18 | includes = Array.wrap(options[:include]).concat(self.class.embeds_association_names)
19 | options[:include] ||= []
20 | options[:include].concat includes
21 | end
22 |
23 | options[:except] = Array(options[:except]).map(&:to_s)
24 |
25 | super(options)
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/test/dummy/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "fileutils"
5 | include FileUtils
6 |
7 | # path to your application root.
8 | APP_ROOT = File.expand_path("..", __dir__)
9 |
10 | def system!(*args)
11 | system(*args) || abort("\n== Command #{args} failed ==")
12 | end
13 |
14 | chdir APP_ROOT do
15 | # This script is a starting point to setup your application.
16 | # Add necessary setup steps to this file.
17 |
18 | puts "== Installing dependencies =="
19 | system! "gem install bundler --conservative"
20 | system("bundle check") || system!("bundle install")
21 |
22 | # Install JavaScript dependencies if using Yarn
23 | # system('bin/yarn')
24 |
25 | puts "\n== Removing old logs and tempfiles =="
26 | system! "bin/rails log:clear tmp:clear"
27 |
28 | puts "\n== Restarting application server =="
29 | system! "bin/rails restart"
30 | end
31 |
--------------------------------------------------------------------------------
/test/dummy/bin/update:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "fileutils"
5 | include FileUtils
6 |
7 | # path to your application root.
8 | APP_ROOT = File.expand_path("..", __dir__)
9 |
10 | def system!(*args)
11 | system(*args) || abort("\n== Command #{args} failed ==")
12 | end
13 |
14 | chdir APP_ROOT do
15 | # This script is a way to update your development environment automatically.
16 | # Add necessary update steps to this file.
17 |
18 | puts "== Installing dependencies =="
19 | system! "gem install bundler --conservative"
20 | system("bundle check") || system!("bundle install")
21 |
22 | # Install JavaScript dependencies if using Yarn
23 | # system('bin/yarn')
24 |
25 | puts "\n== Removing old logs and tempfiles =="
26 | system! "bin/rails log:clear tmp:clear"
27 |
28 | puts "\n== Restarting application server =="
29 | system! "bin/rails restart"
30 | end
31 |
--------------------------------------------------------------------------------
/lib/core_ext/array_without_blank.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ArrayWithoutBlank < Array
4 | def self.new(*several_variants)
5 | arr = super
6 | arr.reject!(&:blank?)
7 | arr
8 | end
9 |
10 | def initialize_copy(other_ary)
11 | super other_ary.reject(&:blank?)
12 | end
13 |
14 | def replace(other_ary)
15 | super other_ary.reject(&:blank?)
16 | end
17 |
18 | def push(obj, *smth)
19 | return self if obj.blank?
20 | super
21 | end
22 |
23 | def insert(*args)
24 | super(*args.reject(&:blank?))
25 | end
26 |
27 | def []=(index, obj)
28 | return self[index] if obj.blank?
29 | super
30 | end
31 |
32 | def concat(other_ary)
33 | super other_ary.reject(&:blank?)
34 | end
35 |
36 | def +(other_ary)
37 | super other_ary.reject(&:blank?)
38 | end
39 |
40 | def <<(obj)
41 | return self if obj.blank?
42 | super
43 | end
44 |
45 | def to_ary
46 | Array.new(self)
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/embedded_in_association.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Associations
5 | module Embeds
6 | # = Active Entity Belongs To Association
7 | class EmbeddedInAssociation < SingularAssociation #:nodoc:
8 | def default(&block)
9 | writer(owner.instance_exec(&block)) if reader.nil?
10 | end
11 |
12 | private
13 |
14 | def replace(record)
15 | if record
16 | raise_on_type_mismatch!(record)
17 | set_inverse_instance(record)
18 | end
19 |
20 | self.target = record
21 | end
22 | # NOTE - for now, we're only supporting inverse setting from belongs_to back onto
23 | # has_one associations.
24 | def invertible_for?(record)
25 | inverse = inverse_reflection_for(record)
26 | inverse&.embeds_one?
27 | end
28 | end
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/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 | # The following keys must be escaped otherwise they will not be retrieved by
20 | # the default I18n backend:
21 | #
22 | # true, false, on, off, yes, no
23 | #
24 | # Instead, surround them with single quotes.
25 | #
26 | # en:
27 | # 'true': 'foo'
28 | #
29 | # To learn more, please read the Rails Internationalization guide
30 | # available at http://guides.rubyonrails.org/i18n.html.
31 |
32 | en:
33 | hello: "Hello world"
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files.
2 | #
3 | # If you find yourself ignoring temporary files generated by your text editor
4 | # or operating system, you probably want to add a global ignore instead:
5 | # git config --global core.excludesfile '~/.gitignore_global'
6 |
7 | # Ignore bundler config.
8 | .bundle/
9 |
10 | # Ignore the default SQLite database.
11 | test/dummy/db/*.sqlite3
12 | test/dummy/db/*.sqlite3-journal
13 |
14 | # Ignore all logfiles and tempfiles.
15 | log/*.log
16 | !test/dummy/log/.keep
17 | !test/dummy/tmp/.keep
18 | test/dummy/log/*.log
19 | test/dummy/tmp/*
20 |
21 | # Ignore uploaded files in development
22 | test/dummy/storage/*
23 | !test/dummy/storage/.keep
24 |
25 | pkg/
26 | .byebug_history
27 |
28 | node_modules/
29 | test/dummy/public/packs
30 | test/dummy/node_modules/
31 | yarn-error.log
32 |
33 | *.gem
34 |
35 | .env
36 | .rakeTasks
37 | /sqlnet.log
38 | /test/config.yml
39 | /test/fixtures/*.sqlite*
40 |
41 | .ruby-version
42 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/builder/singular_association.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This class is inherited by the has_one and belongs_to association classes
4 |
5 | module ActiveEntity::Associations::Embeds::Builder # :nodoc:
6 | class SingularAssociation < Association #:nodoc:
7 | def self.valid_options(options)
8 | super + [:inverse_of, :required]
9 | end
10 |
11 | def self.define_accessors(model, reflection)
12 | super
13 | mixin = model.generated_association_methods
14 | name = reflection.name
15 |
16 | define_constructors(mixin, name) if reflection.constructable?
17 | end
18 |
19 | # Defines the (build|create)_association methods for belongs_to or has_one association
20 | def self.define_constructors(mixin, name)
21 | mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
22 | def build_#{name}(*args, &block)
23 | association(:#{name}).build(*args, &block)
24 | end
25 | CODE
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/activeentity.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | $:.push File.expand_path("lib", __dir__)
4 |
5 | require "active_entity/version"
6 |
7 | Gem::Specification.new do |s|
8 | s.platform = Gem::Platform::RUBY
9 | s.name = "activeentity"
10 | s.version = ActiveEntity::VERSION::STRING
11 | s.authors = ["jasl"]
12 | s.email = ["jasl9187@hotmail.com"]
13 | s.homepage = "https://github.com/jasl/activeentity"
14 | s.summary = "Rails virtual model solution based on ActiveModel."
15 | s.description = "Rails virtual model solution based on ActiveModel design for Rails 6+."
16 | s.license = "MIT"
17 |
18 | s.required_ruby_version = ">= 2.5.0"
19 |
20 | s.files = Dir["lib/**/*", "MIT-LICENSE", "Rakefile", "README.md"]
21 | s.require_path = "lib"
22 |
23 | # s.extra_rdoc_files = %w(README.rdoc)
24 | # s.rdoc_options.concat %w[--main README.rdoc]
25 |
26 | s.add_dependency "activesupport", ">= 6.0", "< 8"
27 | s.add_dependency "activemodel", ">= 6.0", "< 8"
28 | end
29 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/singular_association.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Associations
5 | module Embeds
6 | class SingularAssociation < Association #:nodoc:
7 | # Implements the reader method, e.g. foo.bar for Foo.has_one :bar
8 | def reader
9 | target
10 | end
11 |
12 | # Implements the writer method, e.g. foo.bar= for Foo.belongs_to :bar
13 | def writer(record)
14 | replace(record)
15 | end
16 |
17 | def build(attributes = {}, &block)
18 | record = build_record(attributes, &block)
19 | set_new_record(record)
20 | record
21 | end
22 |
23 | private
24 |
25 | def replace(_record)
26 | raise NotImplementedError, "Subclasses must implement a replace(record) method"
27 | end
28 |
29 | def set_new_record(record)
30 | replace(record)
31 | end
32 | end
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/active_entity/validations/absence.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Validations
5 | class AbsenceValidator < ActiveModel::Validations::AbsenceValidator # :nodoc:
6 | def validate_each(record, attribute, association_or_value)
7 | if record.class._reflect_on_association(attribute)
8 | association_or_value = Array.wrap(association_or_value).reject(&:marked_for_destruction?)
9 | end
10 | super
11 | end
12 | end
13 |
14 | module ClassMethods
15 | # Validates that the specified attributes are not present (as defined by
16 | # Object#present?). If the attribute is an association, the associated object
17 | # is considered absent if it was marked for destruction.
18 | #
19 | # See ActiveModel::Validations::HelperMethods.validates_absence_of for more information.
20 | def validates_absence_of(*attr_names)
21 | validates_with AbsenceValidator, _merge_attributes(attr_names)
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/test/dummy/db/schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file is auto-generated from the current state of the database. Instead
4 | # of editing this file, please use the migrations feature of Active Record to
5 | # incrementally modify your database, and then regenerate this schema definition.
6 | #
7 | # This file is the source Rails uses to define your schema when running `rails
8 | # db:schema:load`. When creating a new database, `rails db:schema:load` tends to
9 | # be faster and is potentially less error prone than running all of your
10 | # migrations from scratch. Old migrations may fail to apply correctly if those
11 | # migrations use external dependencies or application code.
12 | #
13 | # It's strongly recommended that you check this file into your version control system.
14 |
15 | ActiveRecord::Schema.define(version: 2019_05_25_203630) do
16 | create_table "users", force: :cascade do |t|
17 | t.string "name"
18 | t.datetime "created_at", precision: 6, null: false
19 | t.datetime "updated_at", precision: 6, null: false
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/active_entity/attribute_methods/query.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module AttributeMethods
5 | module Query
6 | extend ActiveSupport::Concern
7 |
8 | included do
9 | attribute_method_suffix "?"
10 | end
11 |
12 | def query_attribute(attr_name)
13 | value = self[attr_name]
14 |
15 | case value
16 | when true then true
17 | when false, nil then false
18 | else
19 | if !type_for_attribute(attr_name) { false }
20 | if Numeric === value || !value.match?(/[^0-9]/)
21 | !value.to_i.zero?
22 | else
23 | return false if ActiveModel::Type::Boolean::FALSE_VALUES.include?(value)
24 | !value.blank?
25 | end
26 | elsif value.respond_to?(:zero?)
27 | !value.zero?
28 | else
29 | !value.blank?
30 | end
31 | end
32 | end
33 |
34 | alias :attribute? :query_attribute
35 | private :attribute?
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/active_entity/validations/length.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Validations
5 | class LengthValidator < ActiveModel::Validations::LengthValidator # :nodoc:
6 | def validate_each(record, attribute, association_or_value)
7 | if association_or_value.respond_to?(:loaded?) && association_or_value.loaded?
8 | association_or_value = association_or_value.target.reject(&:marked_for_destruction?)
9 | end
10 | super
11 | end
12 | end
13 |
14 | module ClassMethods
15 | # Validates that the specified attributes match the length restrictions supplied.
16 | # If the attribute is an association, records that are marked for destruction are not counted.
17 | #
18 | # See ActiveModel::Validations::HelperMethods.validates_length_of for more information.
19 | def validates_length_of(*attr_names)
20 | validates_with LengthValidator, _merge_attributes(attr_names)
21 | end
22 |
23 | alias_method :validates_size_of, :validates_length_of
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/test/dummy/config/application.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "boot"
4 |
5 | require "rails"
6 | # Pick the frameworks you want:
7 | require "active_model/railtie"
8 | require "active_job/railtie"
9 | require "active_record/railtie"
10 | # require "active_storage/engine"
11 | require "action_controller/railtie"
12 | # require "action_mailer/railtie"
13 | require "action_view/railtie"
14 | # require "action_cable/engine"
15 | require "sprockets/railtie"
16 | require "rails/test_unit/railtie"
17 |
18 | require "active_entity/railtie"
19 |
20 | Bundler.require(*Rails.groups)
21 |
22 | module Dummy
23 | class Application < Rails::Application
24 | # Initialize configuration defaults for originally generated Rails version.
25 | config.load_defaults 6.0
26 |
27 | # Settings in config/environments/* take precedence over those specified here.
28 | # Application configuration can go into files in config/initializers
29 | # -- all .rb files in that directory are automatically loaded after loading
30 | # the framework and any gems in your application.
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/builder/embedded_in.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity::Associations::Embeds::Builder # :nodoc:
4 | class EmbeddedIn < SingularAssociation #:nodoc:
5 | def self.macro
6 | :embedded_in
7 | end
8 |
9 | def self.valid_options(options)
10 | super + [:default]
11 | end
12 |
13 | def self.define_callbacks(model, reflection)
14 | super
15 | add_default_callbacks(model, reflection) if reflection.options[:default]
16 | end
17 |
18 | def self.add_default_callbacks(model, reflection)
19 | model.before_validation lambda { |o|
20 | o.association(reflection.name).default(&reflection.options[:default])
21 | }
22 | end
23 |
24 | def self.define_validations(model, reflection)
25 | if reflection.options.key?(:required)
26 | reflection.options[:optional] = !reflection.options.delete(:required)
27 | end
28 |
29 | required = !reflection.options[:optional]
30 |
31 | super
32 |
33 | if required
34 | model.validates_presence_of reflection.name, message: :required
35 | end
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/content_security_policy.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # Define an application-wide content security policy
6 | # For further information see the following documentation
7 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
8 |
9 | # Rails.application.config.content_security_policy do |policy|
10 | # policy.default_src :self, :https
11 | # policy.font_src :self, :https, :data
12 | # policy.img_src :self, :https, :data
13 | # policy.object_src :none
14 | # policy.script_src :self, :https
15 | # policy.style_src :self, :https
16 |
17 | # # Specify URI for violation reports
18 | # # policy.report_uri "/csp-violation-report-endpoint"
19 | # end
20 |
21 | # If you are using UJS then enable automatic nonce generation
22 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
23 |
24 | # Report CSP violations to a specified URI
25 | # For further information see the following documentation:
26 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
27 | # Rails.application.config.content_security_policy_report_only = true
28 |
--------------------------------------------------------------------------------
/lib/active_entity/readonly_attributes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module ReadonlyAttributes
5 | extend ActiveSupport::Concern
6 |
7 | included do
8 | class_attribute :_attr_readonly, instance_accessor: false, default: []
9 | end
10 |
11 | def disable_attr_readonly!
12 | @_attr_readonly_enabled = false
13 | end
14 |
15 | def enable_attr_readonly!
16 | @_attr_readonly_enabled = true
17 | end
18 |
19 | def without_attr_readonly
20 | return unless block_given?
21 |
22 | disable_attr_readonly!
23 | yield self
24 | enable_attr_readonly!
25 |
26 | self
27 | end
28 |
29 | def _attr_readonly_enabled
30 | @_attr_readonly_enabled
31 | end
32 | alias attr_readonly_enabled? _attr_readonly_enabled
33 |
34 | def readonly_attribute?(name)
35 | self.class.readonly_attribute?(name)
36 | end
37 |
38 | module ClassMethods
39 | # Attributes listed as readonly will be used to create a new record but update operations will
40 | # ignore these fields.
41 | def attr_readonly(*attributes)
42 | self._attr_readonly = Set.new(attributes.map(&:to_s)) + (_attr_readonly || [])
43 | end
44 |
45 | # Returns an array of all the attributes that have been specified as readonly.
46 | def readonly_attributes
47 | _attr_readonly
48 | end
49 |
50 | def readonly_attribute?(name) # :nodoc:
51 | _attr_readonly.include?(name)
52 | end
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/active_entity/coders/yaml_column.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "yaml"
4 |
5 | module ActiveEntity
6 | module Coders # :nodoc:
7 | class YAMLColumn # :nodoc:
8 | attr_accessor :object_class
9 |
10 | def initialize(attr_name, object_class = Object)
11 | @attr_name = attr_name
12 | @object_class = object_class
13 | check_arity_of_constructor
14 | end
15 |
16 | def dump(obj)
17 | return if obj.nil?
18 |
19 | assert_valid_value(obj, action: "dump")
20 | YAML.dump obj
21 | end
22 |
23 | def load(yaml)
24 | return object_class.new if object_class != Object && yaml.nil?
25 | return yaml unless yaml.is_a?(String) && /^---/.match?(yaml)
26 | obj = YAML.load(yaml)
27 |
28 | assert_valid_value(obj, action: "load")
29 | obj ||= object_class.new if object_class != Object
30 |
31 | obj
32 | end
33 |
34 | def assert_valid_value(obj, action:)
35 | unless obj.nil? || obj.is_a?(object_class)
36 | raise SerializationTypeMismatch,
37 | "can't #{action} `#{@attr_name}`: was supposed to be a #{object_class}, but was a #{obj.class}. -- #{obj.inspect}"
38 | end
39 | end
40 |
41 | private
42 |
43 | def check_arity_of_constructor
44 | load(nil)
45 | rescue ArgumentError
46 | raise ArgumentError, "Cannot serialize #{object_class}. Classes passed to `serialize` must have a 0 argument constructor."
47 | end
48 | end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/test/dummy/config/puma.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Puma can serve each request in a thread from an internal thread pool.
4 | # The `threads` method setting takes two numbers: a minimum and maximum.
5 | # Any libraries that use thread pools should be configured to match
6 | # the maximum value specified for Puma. Default is set to 5 threads for minimum
7 | # and maximum; this matches the default thread size of Active Record.
8 | #
9 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
10 | threads threads_count, threads_count
11 |
12 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
13 | #
14 | port ENV.fetch("PORT") { 3000 }
15 |
16 | # Specifies the `environment` that Puma will run in.
17 | #
18 | environment ENV.fetch("RAILS_ENV") { "development" }
19 |
20 | # Specifies the number of `workers` to boot in clustered mode.
21 | # Workers are forked webserver processes. If using threads and workers together
22 | # the concurrency of the application would be max `threads` * `workers`.
23 | # Workers do not work on JRuby or Windows (both of which do not support
24 | # processes).
25 | #
26 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 }
27 |
28 | # Use the `preload_app!` method when specifying a `workers` number.
29 | # This directive tells Puma to first boot the application and load code
30 | # before forking the application. This takes advantage of Copy On Write
31 | # process behavior so workers use less memory.
32 | #
33 | # preload_app!
34 |
35 | # Allow puma to be restarted by `rails restart` command.
36 | plugin :tmp_restart
37 |
--------------------------------------------------------------------------------
/lib/active_entity/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 | non_subset: "is not subset of the given list"
11 | duplicated: "duplicated"
12 |
13 | # Active Entity models configuration
14 | active_entity:
15 | errors:
16 | messages:
17 | record_invalid: "Validation failed: %{errors}"
18 | # Append your own errors here or at the model/attributes scope.
19 |
20 | # You can define own errors for models or model attributes.
21 | # The values :model, :attribute and :value are always available for interpolation.
22 | #
23 | # For example,
24 | # models:
25 | # user:
26 | # blank: "This is a custom blank message for %{model}: %{attribute}"
27 | # attributes:
28 | # login:
29 | # blank: "This is a custom blank message for User login"
30 | # Will define custom blank validation message for User model and
31 | # custom blank validation message for login attribute of User model.
32 | #models:
33 |
34 | # Translate model names. Used in Model.human_name().
35 | #models:
36 | # For example,
37 | # user: "Dude"
38 | # will translate User model name to "Dude"
39 |
40 | # Translate model attribute names. Used in Model.human_attribute_name(attribute).
41 | #attributes:
42 | # For example,
43 | # user:
44 | # login: "Handle"
45 | # will translate User attribute "login" as "Handle"
46 |
--------------------------------------------------------------------------------
/lib/active_entity/attribute_methods/read.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module AttributeMethods
5 | module Read
6 | extend ActiveSupport::Concern
7 |
8 | module ClassMethods # :nodoc:
9 | private
10 |
11 | def define_method_attribute(name, owner:)
12 | ActiveEntity::AMAttributeMethods::AttrNames.define_attribute_accessor_method(
13 | owner, name
14 | ) do |temp_method_name, attr_name_expr|
15 | owner <<
16 | "def #{temp_method_name}" <<
17 | " _read_attribute(#{attr_name_expr}) { |n| missing_attribute(n, caller) }" <<
18 | "end"
19 | end
20 | end
21 | end
22 |
23 | # Returns the value of the attribute identified by attr_name after
24 | # it has been typecast (for example, "2004-12-12" in a date column is cast
25 | # to a date object, like Date.new(2004, 12, 12)).
26 | def read_attribute(attr_name, &block)
27 | name = attr_name.to_s
28 | name = self.class.attribute_aliases[name] || name
29 |
30 | name = @primary_key if name == "id" && @primary_key
31 | @attributes.fetch_value(name, &block)
32 | end
33 |
34 | # This method exists to avoid the expensive primary_key check internally, without
35 | # breaking compatibility with the read_attribute API
36 | def _read_attribute(attr_name, &block) # :nodoc
37 | @attributes.fetch_value(attr_name, &block)
38 | end
39 |
40 | alias :attribute :_read_attribute
41 | private :attribute
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/test/dummy/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # The test environment is used exclusively to run your application's
7 | # test suite. You never need to work with it otherwise. Remember that
8 | # your test database is "scratch space" for the test suite and is wiped
9 | # and recreated between test runs. Don't rely on the data there!
10 | config.cache_classes = true
11 |
12 | # Do not eager load code on boot. This avoids loading your whole application
13 | # just for the purpose of running a single test. If you are using a tool that
14 | # preloads Rails for running tests, you may have to set it to true.
15 | config.eager_load = false
16 |
17 | # Configure public file server for tests with Cache-Control for performance.
18 | config.public_file_server.enabled = true
19 | config.public_file_server.headers = {
20 | "Cache-Control" => "public, max-age=#{1.hour.to_i}"
21 | }
22 |
23 | # Show full error reports and disable caching.
24 | config.consider_all_requests_local = true
25 | config.action_controller.perform_caching = false
26 |
27 | # Raise exceptions instead of rendering exception templates.
28 | config.action_dispatch.show_exceptions = false
29 |
30 | # Disable request forgery protection in test environment.
31 | config.action_controller.allow_forgery_protection = false
32 |
33 | # Print deprecation notices to the stderr.
34 | config.active_support.deprecation = :stderr
35 |
36 | # Raises error for missing translations
37 | # config.action_view.raise_on_missing_translations = true
38 | end
39 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" }
5 |
6 | # Declare your gem"s dependencies in virtual_record.gemspec.
7 | # Bundler will treat runtime dependencies like base dependencies, and
8 | # development dependencies will be added by default to the :development group.
9 | gemspec
10 |
11 | # Declare any dependencies that are still in development here instead of in
12 | # your gemspec. These might include edge Rails or gems from your path or
13 | # Git. Remember to move these dependencies to your gemspec before releasing
14 | # your gem to rubygems.org.
15 |
16 | # Your gem is dependent on dev or edge Rails. Once you can lock this
17 | # dependency down to a specific version, move it to your gemspec.
18 | gem "rails", "~> 6.1"
19 | # gem "rails", github: "rails/rails"
20 |
21 | gem "sqlite3"
22 |
23 | # Use Puma as the app server
24 | gem "puma"
25 | # Use SCSS for stylesheets
26 | # gem "sassc-rails"
27 | # Use Uglifier as compressor for JavaScript assets
28 | # gem "uglifier", ">= 1.3.0"
29 | # See https://github.com/rails/execjs#readme for more supported runtimes
30 | # gem "mini_racer", platforms: :ruby
31 |
32 | # Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
33 | # gem "turbolinks", "~> 5"
34 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
35 | # gem "jbuilder", "~> 2.5"
36 |
37 | # Access an IRB console on exception pages or by using <%= console %> anywhere in the code.
38 | gem "web-console"
39 | # Call "byebug" anywhere in the code to stop execution and get a debugger console
40 | gem "byebug", group: [:development, :test]
41 |
42 | gem "rubocop"
43 | gem "rubocop-performance"
44 | gem "rubocop-rails"
45 |
--------------------------------------------------------------------------------
/lib/active_entity/attribute_methods/write.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module AttributeMethods
5 | module Write
6 | extend ActiveSupport::Concern
7 |
8 | included do
9 | attribute_method_suffix "="
10 | end
11 |
12 | module ClassMethods # :nodoc:
13 | private
14 |
15 | def define_method_attribute=(name, owner:)
16 | ActiveEntity::AMAttributeMethods::AttrNames.define_attribute_accessor_method(
17 | owner, name, writer: true,
18 | ) do |temp_method_name, attr_name_expr|
19 | owner <<
20 | "def #{temp_method_name}(value)" <<
21 | " _write_attribute(#{attr_name_expr}, value)" <<
22 | "end"
23 | end
24 | end
25 | end
26 |
27 | # Updates the attribute identified by attr_name with the
28 | # specified +value+. Empty strings for Integer and Float columns are
29 | # turned into +nil+.
30 | def write_attribute(attr_name, value)
31 | name = attr_name.to_s
32 | name = self.class.attribute_aliases[name] || name
33 |
34 | name = @primary_key if name == "id" && @primary_key
35 | @attributes.write_from_user(name, value)
36 | end
37 |
38 | # This method exists to avoid the expensive primary_key check internally, without
39 | # breaking compatibility with the write_attribute API
40 | def _write_attribute(attr_name, value) # :nodoc:
41 | @attributes.write_from_user(attr_name, value)
42 | end
43 |
44 | alias :attribute= :_write_attribute
45 | private :attribute=
46 |
47 | private
48 |
49 | def write_attribute_without_type_cast(attr_name, value)
50 | @attributes.write_cast_value(attr_name, value)
51 | end
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/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/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # In the development environment your application's code is reloaded on
7 | # every request. This slows down response time but is perfect for development
8 | # since you don't have to restart the web server when you make code changes.
9 | config.cache_classes = false
10 |
11 | # Do not eager load code on boot.
12 | config.eager_load = false
13 |
14 | # Show full error reports.
15 | config.consider_all_requests_local = true
16 |
17 | # Enable/disable caching. By default caching is disabled.
18 | # Run rails dev:cache to toggle caching.
19 | if Rails.root.join("tmp", "caching-dev.txt").exist?
20 | config.action_controller.perform_caching = true
21 |
22 | config.cache_store = :memory_store
23 | config.public_file_server.headers = {
24 | "Cache-Control" => "public, max-age=#{2.days.to_i}"
25 | }
26 | else
27 | config.action_controller.perform_caching = false
28 |
29 | config.cache_store = :null_store
30 | end
31 |
32 | # Print deprecation notices to the Rails logger.
33 | config.active_support.deprecation = :log
34 |
35 | # Debug mode disables concatenation and preprocessing of assets.
36 | # This option may cause significant delays in view rendering with a large
37 | # number of complex assets.
38 | config.assets.debug = true
39 |
40 | # Suppress logger output for asset requests.
41 | config.assets.quiet = true
42 |
43 | # Raises error for missing translations
44 | # config.action_view.raise_on_missing_translations = true
45 |
46 | # Use an evented file watcher to asynchronously detect changes in source code,
47 | # routes, locales, etc. This feature depends on the listen gem.
48 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker
49 | end
50 |
--------------------------------------------------------------------------------
/lib/active_entity/validations.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | # = Active Entity \Validations
5 | #
6 | # Active Entity includes the majority of its validations from ActiveModel::Validations
7 | # all of which accept the :on argument to define the context where the
8 | # validations are active. Active Entity will always supply either the context of
9 | # :create or :update dependent on whether the model is a
10 | # {new_record?}[rdoc-ref:Persistence#new_record?].
11 | module Validations
12 | extend ActiveSupport::Concern
13 | include ActiveModel::Validations
14 |
15 | # Runs all the validations within the specified context. Returns +true+ if
16 | # no errors are found, +false+ otherwise.
17 | #
18 | # Aliased as #validate.
19 | #
20 | # If the argument is +false+ (default is +nil+), the context is set to :create if
21 | # {new_record?}[rdoc-ref:Persistence#new_record?] is +true+, and to :update if it is not.
22 | #
23 | # \Validations with no :on option will run no matter the context. \Validations with
24 | # some :on option will only run in the specified context.
25 | def valid?(context = nil)
26 | context ||= :default
27 | output = super(context)
28 | errors.empty? && output
29 | end
30 |
31 | alias_method :validate, :valid?
32 |
33 | private
34 |
35 | def perform_validations(options = {})
36 | options[:validate] == false || valid?(options[:context])
37 | end
38 | end
39 | end
40 |
41 | require "active_entity/validations/associated"
42 | require "active_entity/validations/presence"
43 | require "active_entity/validations/absence"
44 | require "active_entity/validations/length"
45 | require "active_entity/validations/subset"
46 | require "active_entity/validations/uniqueness_in_embeds"
47 | require "active_entity/validations/uniqueness_on_active_record"
48 |
--------------------------------------------------------------------------------
/lib/active_entity/type/serialized.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Type
5 | class Serialized < DelegateClass(ActiveModel::Type::Value) # :nodoc:
6 | undef to_yaml if method_defined?(:to_yaml)
7 |
8 | include ActiveModel::Type::Helpers::Mutable
9 |
10 | attr_reader :subtype, :coder
11 |
12 | def initialize(subtype, coder)
13 | @subtype = subtype
14 | @coder = coder
15 | super(subtype)
16 | end
17 |
18 | def deserialize(value)
19 | if default_value?(value)
20 | value
21 | else
22 | coder.load(super)
23 | end
24 | end
25 |
26 | def serialize(value)
27 | return if value.nil?
28 | unless default_value?(value)
29 | super coder.dump(value)
30 | end
31 | end
32 |
33 | def inspect
34 | Kernel.instance_method(:inspect).bind(self).call
35 | end
36 |
37 | def changed_in_place?(raw_old_value, value)
38 | return false if value.nil?
39 | raw_new_value = encoded(value)
40 | raw_old_value.nil? != raw_new_value.nil? ||
41 | subtype.changed_in_place?(raw_old_value, raw_new_value)
42 | end
43 |
44 | def accessor
45 | ActiveEntity::Store::IndifferentHashAccessor
46 | end
47 |
48 | def assert_valid_value(value)
49 | if coder.respond_to?(:assert_valid_value)
50 | coder.assert_valid_value(value, action: "serialize")
51 | end
52 | end
53 |
54 | def force_equality?(value)
55 | coder.respond_to?(:object_class) && value.is_a?(coder.object_class)
56 | end
57 |
58 | private
59 |
60 | def default_value?(value)
61 | value == coder.load(nil)
62 | end
63 |
64 | def encoded(value)
65 | unless default_value?(value)
66 | coder.dump(value)
67 | end
68 | end
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/lib/active_entity/type/modifiers/array.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Type
5 | module Modifiers
6 | class Array < ActiveModel::Type::Value # :nodoc:
7 | include ActiveModel::Type::Helpers::Mutable
8 |
9 | attr_reader :subtype, :delimiter
10 | delegate :type, :user_input_in_time_zone, :limit, :precision, :scale, to: :subtype
11 |
12 | def initialize(subtype, delimiter = ",")
13 | @subtype = subtype
14 | @delimiter = delimiter
15 | end
16 |
17 | def deserialize(value)
18 | case value
19 | when ::String
20 | type_cast_array(value.split(@delimiter), :deserialize)
21 | else
22 | super
23 | end
24 | end
25 |
26 | def cast(value)
27 | if value.is_a?(::String)
28 | value = value.split(@delimiter)
29 | end
30 | type_cast_array(value, :cast)
31 | end
32 |
33 | def serialize(value)
34 | if value.is_a?(::Array)
35 | casted_values = type_cast_array(value, :serialize)
36 | casted_values.join(@delimiter)
37 | else
38 | super
39 | end
40 | end
41 |
42 | def ==(other)
43 | other.is_a?(Array) &&
44 | subtype == other.subtype &&
45 | delimiter == other.delimiter
46 | end
47 |
48 | def map(value, &block)
49 | value.map(&block)
50 | end
51 |
52 | def changed_in_place?(raw_old_value, new_value)
53 | deserialize(raw_old_value) != new_value
54 | end
55 |
56 | def force_equality?(value)
57 | value.is_a?(::Array)
58 | end
59 |
60 | private
61 |
62 | def type_cast_array(value, method)
63 | if value.is_a?(::Array)
64 | value.map { |item| type_cast_array(item, method) }
65 | else
66 | @subtype.public_send(method, value)
67 | end
68 | end
69 | end
70 | end
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020 jasl
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 |
23 | Copyright (c) 2005-2019 David Heinemeier Hansson
24 |
25 | Permission is hereby granted, free of charge, to any person obtaining
26 | a copy of this software and associated documentation files (the
27 | "Software"), to deal in the Software without restriction, including
28 | without limitation the rights to use, copy, modify, merge, publish,
29 | distribute, sublicense, and/or sell copies of the Software, and to
30 | permit persons to whom the Software is furnished to do so, subject to
31 | the following conditions:
32 |
33 | The above copyright notice and this permission notice shall be
34 | included in all copies or substantial portions of the Software.
35 |
36 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
37 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
38 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
39 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
40 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
41 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
42 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
43 |
--------------------------------------------------------------------------------
/lib/active_entity/railtie.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_entity"
4 | require "rails"
5 | require "active_support/core_ext/object/try"
6 | require "active_model/railtie"
7 |
8 | # For now, action_controller must always be present with
9 | # Rails, so let's make sure that it gets required before
10 | # here. This is needed for correctly setting up the middleware.
11 | # In the future, this might become an optional require.
12 | require "action_controller/railtie"
13 |
14 | module ActiveEntity
15 | # = Active Entity Railtie
16 | class Railtie < Rails::Railtie # :nodoc:
17 | config.active_entity = ActiveSupport::OrderedOptions.new
18 |
19 | config.eager_load_namespaces << ActiveEntity
20 |
21 | # When loading console, force ActiveEntity::Base to be loaded
22 | # to avoid cross references when loading a constant for the
23 | # first time. Also, make it output to STDERR.
24 | console do |_app|
25 | require "active_entity/base"
26 | unless ActiveSupport::Logger.logger_outputs_to?(Rails.logger, STDERR, STDOUT)
27 | console = ActiveSupport::Logger.new(STDERR)
28 | Rails.logger.extend ActiveSupport::Logger.broadcast console
29 | end
30 | end
31 |
32 | runner do
33 | require "active_entity/base"
34 | end
35 |
36 | initializer "active_entity.initialize_timezone" do
37 | ActiveSupport.on_load(:active_entity) do
38 | self.time_zone_aware_attributes = true
39 | self.default_timezone = :utc
40 | end
41 | end
42 |
43 | initializer "active_entity.logger" do
44 | ActiveSupport.on_load(:active_entity) { self.logger ||= ::Rails.logger }
45 | end
46 |
47 |
48 | initializer "active_entity.define_attribute_methods" do |app|
49 | config.after_initialize do
50 | ActiveSupport.on_load(:active_entity) do
51 | if app.config.eager_load
52 | descendants.each do |model|
53 | model.define_attribute_methods
54 | end
55 | end
56 | end
57 | end
58 | end
59 |
60 | initializer "active_entity.set_configs" do |app|
61 | ActiveSupport.on_load(:active_entity) do
62 | configs = app.config.active_entity
63 |
64 | configs.each do |k, v|
65 | send "#{k}=", v
66 | end
67 | end
68 | end
69 |
70 | initializer "active_entity.set_filter_attributes" do
71 | ActiveSupport.on_load(:active_entity) do
72 | self.filter_attributes += Rails.application.config.filter_parameters
73 | end
74 | end
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/builder/collection_association.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_entity/associations"
4 |
5 | module ActiveEntity::Associations::Embeds::Builder # :nodoc:
6 | class CollectionAssociation < Association #:nodoc:
7 | CALLBACKS = [:before_add, :after_add, :before_remove, :after_remove]
8 |
9 | def self.valid_options(options)
10 | super + [:before_add, :after_add, :before_remove, :after_remove, :extend]
11 | end
12 |
13 | def self.define_callbacks(model, reflection)
14 | super
15 | name = reflection.name
16 | options = reflection.options
17 | CALLBACKS.each { |callback_name|
18 | define_callback(model, callback_name, name, options)
19 | }
20 | end
21 |
22 | def self.define_extensions(model, name, &block)
23 | if block_given?
24 | extension_module_name = "#{name.to_s.camelize}AssociationExtension"
25 | extension = Module.new(&block)
26 | model.const_set(extension_module_name, extension)
27 | end
28 | end
29 |
30 | def self.define_callback(model, callback_name, name, options)
31 | full_callback_name = "#{callback_name}_for_#{name}"
32 |
33 | # TODO : why do i need method_defined? I think its because of the inheritance chain
34 | model.class_attribute full_callback_name unless model.method_defined?(full_callback_name)
35 | callbacks = Array(options[callback_name.to_sym]).map do |callback|
36 | case callback
37 | when Symbol
38 | ->(_method, owner, record) { owner.send(callback, record) }
39 | when Proc
40 | ->(_method, owner, record) { callback.call(owner, record) }
41 | else
42 | ->(method, owner, record) { callback.send(method, owner, record) }
43 | end
44 | end
45 | model.send "#{full_callback_name}=", callbacks
46 | end
47 |
48 | # Defines the setter and getter methods for the collection_singular_ids.
49 | def self.define_readers(mixin, name)
50 | super
51 |
52 | mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
53 | def #{name.to_s.singularize}_ids
54 | association(:#{name}).ids_reader
55 | end
56 | CODE
57 | end
58 |
59 | def self.define_writers(mixin, name)
60 | super
61 |
62 | mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
63 | def #{name.to_s.singularize}_ids=(ids)
64 | association(:#{name}).ids_writer(ids)
65 | end
66 | CODE
67 | end
68 |
69 | private_class_method :valid_options, :define_callback, :define_extensions, :define_readers, :define_writers
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/active_entity/errors.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | # = Active Entity Errors
5 | #
6 | # Generic Active Entity exception class.
7 | class ActiveEntityError < StandardError
8 | end
9 |
10 | # Raised when an object assigned to an association has an incorrect type.
11 | #
12 | # class Ticket < ActiveEntity::Base
13 | # has_many :patches
14 | # end
15 | #
16 | # class Patch < ActiveEntity::Base
17 | # belongs_to :ticket
18 | # end
19 | #
20 | # # Comments are not patches, this assignment raises AssociationTypeMismatch.
21 | # @ticket.patches << Comment.new(content: "Please attach tests to your patch.")
22 | class AssociationTypeMismatch < ActiveEntityError
23 | end
24 |
25 | # Raised when unserialized object's type mismatches one specified for serializable field.
26 | class SerializationTypeMismatch < ActiveEntityError
27 | end
28 |
29 | # Raised when association is being configured improperly or user tries to use
30 | # offset and limit together with
31 | # {ActiveEntity::Base.has_many}[rdoc-ref:Associations::ClassMethods#has_many] or
32 | # {ActiveEntity::Base.has_and_belongs_to_many}[rdoc-ref:Associations::ClassMethods#has_and_belongs_to_many]
33 | # associations.
34 | class ConfigurationError < ActiveEntityError
35 | end
36 |
37 | # Raised when attribute has a name reserved by Active Entity (when attribute
38 | # has name of one of Active Entity instance methods).
39 | class DangerousAttributeError < ActiveEntityError
40 | end
41 |
42 | # Raised when unknown attributes are supplied via mass assignment.
43 | UnknownAttributeError = ActiveModel::UnknownAttributeError
44 |
45 | # Raised when an error occurred while doing a mass assignment to an attribute through the
46 | # {ActiveEntity::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] method.
47 | # The exception has an +attribute+ property that is the name of the offending attribute.
48 | class AttributeAssignmentError < ActiveEntityError
49 | attr_reader :exception, :attribute
50 |
51 | def initialize(message = nil, exception = nil, attribute = nil)
52 | super(message)
53 | @exception = exception
54 | @attribute = attribute
55 | end
56 | end
57 |
58 | # Raised when there are multiple errors while doing a mass assignment through the
59 | # {ActiveEntity::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=]
60 | # method. The exception has an +errors+ property that contains an array of AttributeAssignmentError
61 | # objects, each corresponding to the error while assigning to an attribute.
62 | class MultiparameterAssignmentErrors < ActiveEntityError
63 | attr_reader :errors
64 |
65 | def initialize(errors = nil)
66 | @errors = errors
67 | end
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/lib/active_entity/validations/associated.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Validations
5 | class AssociatedValidator < ActiveModel::EachValidator #:nodoc:
6 | def validate_each(record, attribute, value)
7 | if Array(value).reject { |r| valid_object?(r) }.any?
8 | record.errors.add(attribute, :invalid, **options.merge(value: value))
9 | end
10 | end
11 |
12 | private
13 |
14 | def valid_object?(record)
15 | (record.respond_to?(:marked_for_destruction?) && record.marked_for_destruction?) || record.valid?
16 | end
17 | end
18 |
19 | module ClassMethods
20 | # Validates whether the associated object or objects are all valid.
21 | # Works with any kind of association.
22 | #
23 | # class Book < ActiveEntity::Base
24 | # has_many :pages
25 | # belongs_to :library
26 | #
27 | # validates_associated :pages, :library
28 | # end
29 | #
30 | # WARNING: This validation must not be used on both ends of an association.
31 | # Doing so will lead to a circular dependency and cause infinite recursion.
32 | #
33 | # NOTE: This validation will not fail if the association hasn't been
34 | # assigned. If you want to ensure that the association is both present and
35 | # guaranteed to be valid, you also need to use
36 | # {validates_presence_of}[rdoc-ref:Validations::ClassMethods#validates_presence_of].
37 | #
38 | # Configuration options:
39 | #
40 | # * :message - A custom error message (default is: "is invalid").
41 | # * :on - Specifies the contexts where this validation is active.
42 | # Runs in all validation contexts by default +nil+. You can pass a symbol
43 | # or an array of symbols. (e.g. on: :create or
44 | # on: :custom_validation_context or
45 | # on: [:create, :custom_validation_context])
46 | # * :if - Specifies a method, proc or string to call to determine
47 | # if the validation should occur (e.g. if: :allow_validation,
48 | # or if: Proc.new { |user| user.signup_step > 2 }). The method,
49 | # proc or string should return or evaluate to a +true+ or +false+ value.
50 | # * :unless - Specifies a method, proc or string to call to
51 | # determine if the validation should not occur (e.g. unless: :skip_validation,
52 | # or unless: Proc.new { |user| user.signup_step <= 2 }). The
53 | # method, proc or string should return or evaluate to a +true+ or +false+
54 | # value.
55 | def validates_associated(*attr_names)
56 | validates_with AssociatedValidator, _merge_attributes(attr_names)
57 | end
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/active_entity/attribute_methods/primary_key.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "set"
4 |
5 | module ActiveEntity
6 | module AttributeMethods
7 | module PrimaryKey
8 | extend ActiveSupport::Concern
9 |
10 | # Returns this record's primary key value wrapped in an array if one is
11 | # available.
12 | def to_key
13 | key = id
14 | [key] if key
15 | end
16 |
17 | # Returns the primary key column's value.
18 | def id
19 | _read_attribute(@primary_key)
20 | end
21 |
22 | # Sets the primary key column's value.
23 | def id=(value)
24 | _write_attribute(@primary_key, value)
25 | end
26 |
27 | # Queries the primary key column's value.
28 | def id?
29 | query_attribute(@primary_key)
30 | end
31 |
32 | # Returns the primary key column's value before type cast.
33 | def id_before_type_cast
34 | read_attribute_before_type_cast(@primary_key)
35 | end
36 |
37 | # Returns the primary key column's previous value.
38 | def id_was
39 | attribute_was(@primary_key)
40 | end
41 |
42 | private
43 |
44 | def attribute_method?(attr_name)
45 | attr_name == "id" || super
46 | end
47 |
48 | module ClassMethods
49 | ID_ATTRIBUTE_METHODS = %w(id id= id? id_before_type_cast id_was).to_set
50 |
51 | def instance_method_already_implemented?(method_name)
52 | super || primary_key && ID_ATTRIBUTE_METHODS.include?(method_name)
53 | end
54 |
55 | def dangerous_attribute_method?(method_name)
56 | super && !ID_ATTRIBUTE_METHODS.include?(method_name)
57 | end
58 |
59 | # Defines the primary key field -- can be overridden in subclasses.
60 | # Overwriting will negate any effect of the +primary_key_prefix_type+
61 | # setting, though.
62 | def primary_key
63 | unless defined? @primary_key
64 | @primary_key =
65 | if has_attribute?("id")
66 | "id"
67 | else
68 | nil
69 | end
70 | end
71 |
72 | @primary_key
73 | end
74 |
75 | # Sets the name of the primary key column.
76 | #
77 | # class Project < ActiveEntity::Base
78 | # self.primary_key = 'sysid'
79 | # end
80 | #
81 | # You can also define the #primary_key method yourself:
82 | #
83 | # class Project < ActiveEntity::Base
84 | # def self.primary_key
85 | # 'foo_' + super
86 | # end
87 | # end
88 | #
89 | # Project.primary_key # => "foo_id"
90 | def primary_key=(value)
91 | @primary_key = value && -value.to_s
92 | end
93 | end
94 | end
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/lib/active_entity/integration.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/core_ext/string/filters"
4 |
5 | module ActiveEntity
6 | module Integration
7 | extend ActiveSupport::Concern
8 |
9 | # Returns a +String+, which Action Pack uses for constructing a URL to this
10 | # object. The default implementation returns this record's id as a +String+,
11 | # or +nil+ if this record's unsaved.
12 | #
13 | # For example, suppose that you have a User model, and that you have a
14 | # resources :users route. Normally, +user_path+ will
15 | # construct a path with the user object's 'id' in it:
16 | #
17 | # user = User.find_by(name: 'Phusion')
18 | # user_path(user) # => "/users/1"
19 | #
20 | # You can override +to_param+ in your model to make +user_path+ construct
21 | # a path using the user's name instead of the user's id:
22 | #
23 | # class User < ActiveEntity::Base
24 | # def to_param # overridden
25 | # name
26 | # end
27 | # end
28 | #
29 | # user = User.find_by(name: 'Phusion')
30 | # user_path(user) # => "/users/Phusion"
31 | def to_param
32 | # We can't use alias_method here, because method 'id' optimizes itself on the fly.
33 | id&.to_s # Be sure to stringify the id for routes
34 | end
35 |
36 | module ClassMethods
37 | # Defines your model's +to_param+ method to generate "pretty" URLs
38 | # using +method_name+, which can be any attribute or method that
39 | # responds to +to_s+.
40 | #
41 | # class User < ActiveEntity::Base
42 | # to_param :name
43 | # end
44 | #
45 | # user = User.find_by(name: 'Fancy Pants')
46 | # user.id # => 123
47 | # user_path(user) # => "/users/123-fancy-pants"
48 | #
49 | # Values longer than 20 characters will be truncated. The value
50 | # is truncated word by word.
51 | #
52 | # user = User.find_by(name: 'David Heinemeier Hansson')
53 | # user.id # => 125
54 | # user_path(user) # => "/users/125-david-heinemeier"
55 | #
56 | # Because the generated param begins with the record's +id+, it is
57 | # suitable for passing to +find+. In a controller, for example:
58 | #
59 | # params[:id] # => "123-fancy-pants"
60 | # User.find(params[:id]).id # => 123
61 | def to_param(method_name = nil)
62 | if method_name.nil?
63 | super()
64 | else
65 | define_method :to_param do
66 | if (default = super()) &&
67 | (result = send(method_name).to_s).present? &&
68 | (param = result.squish.parameterize.truncate(20, separator: /-/, omission: "")).present?
69 | "#{default}-#{param}"
70 | else
71 | default
72 | end
73 | end
74 | end
75 | end
76 | end
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/lib/active_entity/type.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_model/type"
4 |
5 | require "active_entity/type/internal/timezone"
6 |
7 | require "active_entity/type/date"
8 | require "active_entity/type/date_time"
9 | require "active_entity/type/json"
10 | require "active_entity/type/time"
11 | require "active_entity/type/text"
12 | require "active_entity/type/unsigned_integer"
13 |
14 | require "active_entity/type/modifiers/array"
15 | require "active_entity/type/modifiers/array_without_blank"
16 |
17 | require "active_entity/type/serialized"
18 | require "active_entity/type/registry"
19 |
20 | module ActiveEntity
21 | module Type
22 | @registry = Registry.new
23 |
24 | class << self
25 | attr_accessor :registry # :nodoc:
26 | delegate :add_modifier, to: :registry
27 |
28 | # Add a new type to the registry, allowing it to be referenced as a
29 | # symbol by {ActiveEntity::Base.attribute}[rdoc-ref:Attributes::ClassMethods#attribute].
30 | # If your type is only meant to be used with a specific database adapter, you can
31 | # do so by passing adapter: :postgresql. If your type has the same
32 | # name as a native type for the current adapter, an exception will be
33 | # raised unless you specify an +:override+ option. override: true will
34 | # cause your type to be used instead of the native type. override:
35 | # false will cause the native type to be used over yours if one exists.
36 | def register(type_name, klass = nil, **options, &block)
37 | registry.register(type_name, klass, **options, &block)
38 | end
39 |
40 | def lookup(*args, **kwargs) # :nodoc:
41 | registry.lookup(*args, **kwargs)
42 | end
43 |
44 | def default_value # :nodoc:
45 | @default_value ||= Value.new
46 | end
47 | end
48 |
49 | BigInteger = ActiveModel::Type::BigInteger
50 | Binary = ActiveModel::Type::Binary
51 | Boolean = ActiveModel::Type::Boolean
52 | Decimal = ActiveModel::Type::Decimal
53 | Float = ActiveModel::Type::Float
54 | Integer = ActiveModel::Type::Integer
55 | String = ActiveModel::Type::String
56 | Value = ActiveModel::Type::Value
57 |
58 | add_modifier({ array: true }, Modifiers::Array)
59 | add_modifier({ array_without_blank: true }, Modifiers::ArrayWithoutBlank)
60 |
61 | register(:big_integer, Type::BigInteger, override: false)
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(:unsigned_integer, Type::UnsignedInteger, override: false)
70 | register(:json, Type::Json, override: false)
71 | register(:string, Type::String, override: false)
72 | register(:text, Type::Text, override: false)
73 | register(:time, Type::Time, override: false)
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/lib/active_entity/type/registry.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | # :stopdoc:
5 | module Type
6 | class Registry # :nodoc:
7 | def initialize
8 | @registrations = []
9 | end
10 |
11 | def initialize_copy(_other)
12 | @registrations = @registrations.dup
13 | end
14 |
15 | def add_modifier(options, klass, **_args)
16 | registrations << DecorationRegistration.new(options, klass)
17 | end
18 |
19 | def register(type_name, klass = nil, **options, &block)
20 | unless block_given?
21 | block = proc { |_, *args| klass.new(*args) }
22 | block.ruby2_keywords if block.respond_to?(:ruby2_keywords)
23 | end
24 | registrations << Registration.new(type_name, block, **options)
25 | end
26 |
27 | def lookup(symbol, *args, **kwargs)
28 | registration = find_registration(symbol, *args, **kwargs)
29 |
30 | if registration
31 | registration.call(self, symbol, *args, **kwargs)
32 | else
33 | raise ArgumentError, "Unknown type #{symbol.inspect}"
34 | end
35 | end
36 |
37 | private
38 |
39 | attr_reader :registrations
40 |
41 | def find_registration(symbol, *args, **kwargs)
42 | registrations
43 | .select { |registration| registration.matches?(symbol, *args, **kwargs) }
44 | .max
45 | end
46 | end
47 |
48 | class Registration # :nodoc:
49 | def initialize(name, block, override: nil)
50 | @name = name
51 | @block = block
52 | @override = override
53 | end
54 |
55 | def call(_registry, *args, **kwargs)
56 | if kwargs.any? # https://bugs.ruby-lang.org/issues/10856
57 | block.call(*args, **kwargs)
58 | else
59 | block.call(*args)
60 | end
61 | end
62 |
63 | def matches?(type_name, *args, **kwargs)
64 | type_name == name
65 | end
66 |
67 | def <=>(other)
68 | priority <=> other.priority
69 | end
70 |
71 | protected
72 |
73 | attr_reader :name, :block, :override
74 |
75 | def priority
76 | override ? 1 : 0
77 | end
78 | end
79 |
80 | class DecorationRegistration < Registration # :nodoc:
81 | def initialize(options, klass, **)
82 | @options = options
83 | @klass = klass
84 | end
85 |
86 | def call(registry, *args, **kwargs)
87 | subtype = registry.lookup(*args, **kwargs.except(*options.keys))
88 | klass.new(subtype)
89 | end
90 |
91 | def matches?(*args, **kwargs)
92 | matches_options?(**kwargs)
93 | end
94 |
95 | def priority
96 | super | 4
97 | end
98 |
99 | private
100 |
101 | attr_reader :options, :klass
102 |
103 | def matches_options?(**kwargs)
104 | options.all? do |key, value|
105 | kwargs[key] == value
106 | end
107 | end
108 | end
109 | end
110 |
111 | # :startdoc:
112 | end
113 |
--------------------------------------------------------------------------------
/lib/active_entity/validations/subset.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
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 | if value && !value.respond_to?(:to_a)
17 | raise ArgumentError, "#{record} can't respond `to_a`."
18 | end
19 |
20 | unless subset?(record, value)
21 | record.errors.add(attribute, :non_subset, **options.except(:in, :within).merge(value: value))
22 | end
23 | end
24 |
25 | private
26 |
27 | def delimiter
28 | @delimiter ||= options[:in] || options[:within]
29 | end
30 |
31 | def subset?(record, value)
32 | enumerable = value.to_a
33 | members =
34 | if delimiter.respond_to?(:call)
35 | delimiter.call(record)
36 | elsif delimiter.respond_to?(:to_sym)
37 | record.send(delimiter)
38 | else
39 | delimiter
40 | end
41 |
42 | (members & enumerable).size == enumerable.size
43 | end
44 | end
45 |
46 | module ClassMethods
47 | # Validates whether the value of the specified attribute is available in a
48 | # particular enumerable object.
49 | #
50 | # class Person < ActiveRecord::Base
51 | # validates :tags, subset: %w( m f )
52 | # validates_subset_of :tags, in: %w( m f )
53 | # validates_inclusion_of :tags, in: %w( m f ), message: "tag %{value} is not included in the list"
54 | # validates_inclusion_of :tags, in: ->(record) { record.available_tags }
55 | # validates_inclusion_of :tags, in: :available_tags
56 | # end
57 | #
58 | # Configuration options:
59 | # * :in - An enumerable object of available items. This can be
60 | # supplied as a proc, lambda or symbol which returns an enumerable. If the
61 | # enumerable is a numerical, time or datetime range the test is performed
62 | # with Range#cover?, otherwise with include?. When using
63 | # a proc or lambda the instance under validation is passed as an argument.
64 | # * :within - A synonym(or alias) for :in
65 | # * :message - Specifies a custom error message (default is: "is
66 | # not included in the list").
67 | #
68 | # There is also a list of default options supported by every validator:
69 | # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
70 | # See ActiveModel::Validations#validates for more information
71 | def validates_subset_of(*attr_names)
72 | validates_with SubsetValidator, _merge_attributes(attr_names)
73 | end
74 | end
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/active_entity/attribute_methods/before_type_cast.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module AttributeMethods
5 | # = Active Entity Attribute Methods Before Type Cast
6 | #
7 | # ActiveEntity::AttributeMethods::BeforeTypeCast provides a way to
8 | # read the value of the attributes before typecasting and deserialization.
9 | #
10 | # class Task < ActiveEntity::Base
11 | # end
12 | #
13 | # task = Task.new(id: '1', completed_on: '2012-10-21')
14 | # task.id # => 1
15 | # task.completed_on # => Sun, 21 Oct 2012
16 | #
17 | # task.attributes_before_type_cast
18 | # # => {"id"=>"1", "completed_on"=>"2012-10-21", ... }
19 | # task.read_attribute_before_type_cast('id') # => "1"
20 | # task.read_attribute_before_type_cast('completed_on') # => "2012-10-21"
21 | #
22 | # In addition to #read_attribute_before_type_cast and #attributes_before_type_cast,
23 | # it declares a method for all attributes with the *_before_type_cast
24 | # suffix.
25 | #
26 | # task.id_before_type_cast # => "1"
27 | # task.completed_on_before_type_cast # => "2012-10-21"
28 | module BeforeTypeCast
29 | extend ActiveSupport::Concern
30 |
31 | included do
32 | attribute_method_suffix "_before_type_cast"
33 | attribute_method_suffix "_came_from_user?"
34 | end
35 |
36 | # Returns the value of the attribute identified by +attr_name+ before
37 | # typecasting and deserialization.
38 | #
39 | # class Task < ActiveEntity::Base
40 | # end
41 | #
42 | # task = Task.new(id: '1', completed_on: '2012-10-21')
43 | # task.read_attribute('id') # => 1
44 | # task.read_attribute_before_type_cast('id') # => '1'
45 | # task.read_attribute('completed_on') # => Sun, 21 Oct 2012
46 | # task.read_attribute_before_type_cast('completed_on') # => "2012-10-21"
47 | # task.read_attribute_before_type_cast(:completed_on) # => "2012-10-21"
48 | def read_attribute_before_type_cast(attr_name)
49 | attribute_before_type_cast(attr_name.to_s)
50 | end
51 |
52 | # Returns a hash of attributes before typecasting and deserialization.
53 | #
54 | # class Task < ActiveEntity::Base
55 | # end
56 | #
57 | # task = Task.new(title: nil, is_done: true, completed_on: '2012-10-21')
58 | # task.attributes
59 | # # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>Sun, 21 Oct 2012, "created_at"=>nil, "updated_at"=>nil}
60 | # task.attributes_before_type_cast
61 | # # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>"2012-10-21", "created_at"=>nil, "updated_at"=>nil}
62 | def attributes_before_type_cast
63 | @attributes.values_before_type_cast
64 | end
65 |
66 | private
67 |
68 | # Dispatch target for *_before_type_cast attribute methods.
69 | def attribute_before_type_cast(attr_name)
70 | @attributes[attr_name].value_before_type_cast
71 | end
72 |
73 | def attribute_came_from_user?(attr_name)
74 | @attributes[attr_name].came_from_user?
75 | end
76 | end
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/lib/active_entity.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | #--
4 | # Copyright (c) 2004-2020 David Heinemeier Hansson
5 | #
6 | # Permission is hereby granted, free of charge, to any person obtaining
7 | # a copy of this software and associated documentation files (the
8 | # "Software"), to deal in the Software without restriction, including
9 | # without limitation the rights to use, copy, modify, merge, publish,
10 | # distribute, sublicense, and/or sell copies of the Software, and to
11 | # permit persons to whom the Software is furnished to do so, subject to
12 | # the following conditions:
13 | #
14 | # The above copyright notice and this permission notice shall be
15 | # included in all copies or substantial portions of the Software.
16 | #
17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
21 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
22 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
23 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24 | #++
25 |
26 | require "active_support"
27 | require "active_support/rails"
28 | require "active_model"
29 | require "yaml"
30 |
31 | require "core_ext/array_without_blank"
32 |
33 | require "active_entity/version"
34 | require "active_model/attribute_set"
35 | require "active_entity/errors"
36 |
37 | module ActiveEntity
38 | extend ActiveSupport::Autoload
39 |
40 | autoload :Base
41 | autoload :Callbacks
42 | autoload :Core
43 | autoload :Enum
44 | autoload :Inheritance
45 | autoload :Integration
46 | autoload :ModelSchema
47 | autoload :NestedAttributes
48 | autoload :Persistence
49 | autoload :ReadonlyAttributes
50 | autoload :Reflection
51 | autoload :Serialization
52 | autoload :Store
53 | autoload :Translation
54 | autoload :Validations
55 |
56 | eager_autoload do
57 | autoload :Aggregations
58 | autoload :Associations
59 | autoload :AttributeAssignment
60 | autoload :AttributeMethods
61 | autoload :ValidateEmbedsAssociation
62 |
63 | autoload :Type
64 | end
65 |
66 | module Coders
67 | autoload :YAMLColumn, "active_entity/coders/yaml_column"
68 | autoload :JSON, "active_entity/coders/json"
69 | end
70 |
71 | module AttributeMethods
72 | extend ActiveSupport::Autoload
73 |
74 | eager_autoload do
75 | autoload :BeforeTypeCast
76 | autoload :Dirty
77 | autoload :PrimaryKey
78 | autoload :Query
79 | autoload :Read
80 | autoload :TimeZoneConversion
81 | autoload :Write
82 | autoload :Serialization
83 | end
84 | end
85 |
86 | def self.eager_load!
87 | super
88 |
89 | ActiveEntity::Associations.eager_load!
90 | ActiveEntity::AttributeMethods.eager_load!
91 | end
92 | end
93 |
94 | ActiveSupport.on_load(:i18n) do
95 | I18n.load_path << File.expand_path("active_entity/locale/en.yml", __dir__)
96 | end
97 |
98 | YAML.load_tags["!ruby/object:ActiveEntity::AttributeSet"] = "ActiveModel::AttributeSet"
99 | YAML.load_tags["!ruby/object:ActiveEntity::LazyAttributeHash"] = "ActiveModel::LazyAttributeHash"
100 |
--------------------------------------------------------------------------------
/lib/active_entity/validations/uniqueness_in_embeds.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Validations
5 | class UniquenessInEmbedsValidator < ActiveModel::EachValidator # :nodoc:
6 | ERROR_MESSAGE = "`key` option of the configuration hash must be symbol or array of symbols."
7 |
8 | def check_validity!
9 | return if key.is_a?(Symbol) || key.is_a?(Array)
10 |
11 | raise ArgumentError, ERROR_MESSAGE
12 | end
13 |
14 | def validate_each(record, attribute, association_or_value)
15 | reflection = record.class._reflect_on_association(attribute)
16 | if reflection
17 | return unless reflection.is_a?(ActiveEntity::Reflection::EmbeddedAssociationReflection)
18 | return unless reflection.collection?
19 | end
20 |
21 | indexed_attribute =
22 | if reflection
23 | reflection.options[:index_errors] || ActiveEntity::Base.index_nested_attribute_errors
24 | else
25 | options[:index_errors] || true
26 | end
27 |
28 | association_or_value =
29 | if reflection
30 | Array.wrap(association_or_value).reject(&:marked_for_destruction?)
31 | else
32 | Array.wrap(association_or_value)
33 | end
34 |
35 | return if association_or_value.size <= 1
36 |
37 | duplicate_records =
38 | if key.is_a? Symbol
39 | association_or_value.group_by(&key)
40 | elsif key.is_a? Array
41 | association_or_value.group_by { |r| key.map { |attr| r.send(attr) } }
42 | end
43 | .values
44 | .select { |v| v.size > 1 }
45 | .flatten
46 |
47 | return if duplicate_records.empty?
48 |
49 | duplicate_records.each do |r|
50 | if key.is_a? Symbol
51 | r.errors.add(key, :duplicated, **options)
52 |
53 | # Hack the record
54 | normalized_attribute = normalize_attribute(attribute, indexed_attribute, association_or_value.index(r), key)
55 | record.errors.import r.errors.where(key).first, attribute: normalized_attribute
56 | elsif key.is_a? Array
57 | key.each do |attr|
58 | r.errors.add(attr, :duplicated, **options)
59 |
60 | # Hack the record
61 | normalized_attribute = normalize_attribute(attribute, indexed_attribute, association_or_value.index(r), attr)
62 | record.errors.import r.errors.where(key).first, attribute: normalized_attribute
63 | end
64 | end
65 | end
66 | end
67 |
68 | private
69 |
70 | def key
71 | @key ||= options[:key]
72 | end
73 |
74 | def normalize_attribute(attribute, indexed_attribute, index, nested_attribute)
75 | if indexed_attribute
76 | "#{attribute}[#{index}].#{nested_attribute}"
77 | else
78 | "#{attribute}.#{nested_attribute}"
79 | end
80 | end
81 | end
82 |
83 | module ClassMethods
84 | # Validates whether the value of the specified attributes are unique
85 | # in the embedded association.
86 | def validates_uniqueness_in_embedding_of(*attr_names)
87 | validates_with UniquenessInEmbeddingValidator, _merge_attributes(attr_names)
88 | end
89 | end
90 | end
91 | end
92 |
--------------------------------------------------------------------------------
/lib/active_entity/attribute_methods/time_zone_conversion.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/core_ext/object/try"
4 |
5 | module ActiveEntity
6 | module AttributeMethods
7 | module TimeZoneConversion
8 | class TimeZoneConverter < DelegateClass(Type::Value) # :nodoc:
9 | def deserialize(value)
10 | convert_time_to_time_zone(super)
11 | end
12 |
13 | def cast(value)
14 | return if value.nil?
15 |
16 | if value.is_a?(Hash)
17 | set_time_zone_without_conversion(super)
18 | elsif value.respond_to?(:in_time_zone)
19 | begin
20 | super(user_input_in_time_zone(value)) || super
21 | rescue ArgumentError
22 | nil
23 | end
24 | else
25 | map_avoiding_infinite_recursion(super) { |v| cast(v) }
26 | end
27 | end
28 |
29 | private
30 |
31 | def convert_time_to_time_zone(value)
32 | return if value.nil?
33 |
34 | if value.acts_like?(:time)
35 | value.in_time_zone
36 | elsif value.is_a?(::Float)
37 | value
38 | else
39 | map_avoiding_infinite_recursion(value) { |v| convert_time_to_time_zone(v) }
40 | end
41 | end
42 |
43 | def set_time_zone_without_conversion(value)
44 | ::Time.zone.local_to_utc(value).try(:in_time_zone) if value
45 | end
46 |
47 | def map_avoiding_infinite_recursion(value)
48 | map(value) do |v|
49 | if value.equal?(v)
50 | nil
51 | else
52 | yield(v)
53 | end
54 | end
55 | end
56 | end
57 |
58 | extend ActiveSupport::Concern
59 |
60 | included do
61 | mattr_accessor :time_zone_aware_attributes, instance_writer: false, default: false
62 |
63 | class_attribute :skip_time_zone_conversion_for_attributes, instance_writer: false, default: []
64 | class_attribute :time_zone_aware_types, instance_writer: false, default: [ :datetime, :time ]
65 | end
66 |
67 | module ClassMethods # :nodoc:
68 | private
69 |
70 | def inherited(subclass)
71 | super
72 | # We need to apply this decorator here, rather than on module inclusion. The closure
73 | # created by the matcher would otherwise evaluate for `ActiveEntity::Base`, not the
74 | # sub class being decorated. As such, changes to `time_zone_aware_attributes`, or
75 | # `skip_time_zone_conversion_for_attributes` would not be picked up.
76 | subclass.class_eval do
77 | matcher = ->(name, type) { create_time_zone_conversion_attribute?(name, type) }
78 | decorate_matching_attribute_types(matcher, "_time_zone_conversion") do |type|
79 | TimeZoneConverter.new(type)
80 | end
81 | end
82 | end
83 |
84 | def create_time_zone_conversion_attribute?(name, cast_type)
85 | enabled_for_column = time_zone_aware_attributes &&
86 | !skip_time_zone_conversion_for_attributes.include?(name.to_sym)
87 |
88 | enabled_for_column && time_zone_aware_types.include?(cast_type.type)
89 | end
90 | end
91 | end
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/lib/active_entity/attribute_decorators.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module AttributeDecorators # :nodoc:
5 | extend ActiveSupport::Concern
6 |
7 | included do
8 | class_attribute :attribute_type_decorations, instance_accessor: false, default: TypeDecorator.new # :internal:
9 | end
10 |
11 | module ClassMethods # :nodoc:
12 | # This method is an internal API used to create class macros such as
13 | # +serialize+, and features like time zone aware attributes.
14 | #
15 | # Used to wrap the type of an attribute in a new type.
16 | # When the schema for a model is loaded, attributes with the same name as
17 | # +column_name+ will have their type yielded to the given block. The
18 | # return value of that block will be used instead.
19 | #
20 | # Subsequent calls where +column_name+ and +decorator_name+ are the same
21 | # will override the previous decorator, not decorate twice. This can be
22 | # used to create idempotent class macros like +serialize+
23 | def decorate_attribute_type(column_name, decorator_name, &block)
24 | matcher = ->(name, _) { name == column_name.to_s }
25 | key = "_#{column_name}_#{decorator_name}"
26 | decorate_matching_attribute_types(matcher, key, &block)
27 | end
28 |
29 | # This method is an internal API used to create higher level features like
30 | # time zone aware attributes.
31 | #
32 | # When the schema for a model is loaded, +matcher+ will be called for each
33 | # attribute with its name and type. If the matcher returns a truthy value,
34 | # the type will then be yielded to the given block, and the return value
35 | # of that block will replace the type.
36 | #
37 | # Subsequent calls to this method with the same value for +decorator_name+
38 | # will replace the previous decorator, not decorate twice. This can be
39 | # used to ensure that class macros are idempotent.
40 | def decorate_matching_attribute_types(matcher, decorator_name, &block)
41 | reload_schema_from_cache
42 | decorator_name = decorator_name.to_s
43 |
44 | # Create new hashes so we don't modify parent classes
45 | self.attribute_type_decorations = attribute_type_decorations.merge(decorator_name => [matcher, block])
46 | end
47 |
48 | private
49 |
50 | def load_schema!
51 | super
52 | attribute_types.each do |name, type|
53 | decorated_type = attribute_type_decorations.apply(name, type)
54 | define_attribute(name, decorated_type)
55 | end
56 | end
57 | end
58 |
59 | class TypeDecorator # :nodoc:
60 | delegate :clear, to: :@decorations
61 |
62 | def initialize(decorations = {})
63 | @decorations = decorations
64 | end
65 |
66 | def merge(*args)
67 | TypeDecorator.new(@decorations.merge(*args))
68 | end
69 |
70 | def apply(name, type)
71 | decorations = decorators_for(name, type)
72 | decorations.inject(type) do |new_type, block|
73 | block.call(new_type)
74 | end
75 | end
76 |
77 | private
78 |
79 | def decorators_for(name, type)
80 | matching(name, type).map(&:last)
81 | end
82 |
83 | def matching(name, type)
84 | @decorations.values.select do |(matcher, _)|
85 | matcher.call(name, type)
86 | end
87 | end
88 | end
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/test/dummy/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # Code is not reloaded between requests.
7 | config.cache_classes = true
8 |
9 | # Eager load code on boot. This eager loads most of Rails and
10 | # your application in memory, allowing both threaded web servers
11 | # and those relying on copy on write to perform better.
12 | # Rake tasks automatically ignore this option for performance.
13 | config.eager_load = true
14 |
15 | # Full error reports are disabled and caching is turned on.
16 | config.consider_all_requests_local = false
17 | config.action_controller.perform_caching = true
18 |
19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
21 | # config.require_master_key = true
22 |
23 | # Disable serving static files from the `/public` folder by default since
24 | # Apache or NGINX already handles this.
25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?
26 |
27 | # Compress JavaScripts and CSS.
28 | config.assets.js_compressor = :uglifier
29 | # config.assets.css_compressor = :sass
30 |
31 | # Do not fallback to assets pipeline if a precompiled asset is missed.
32 | config.assets.compile = false
33 |
34 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb
35 |
36 | # Enable serving of images, stylesheets, and JavaScripts from an asset server.
37 | # config.action_controller.asset_host = 'http://assets.example.com'
38 |
39 | # Specifies the header that your server uses for sending files.
40 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
41 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
42 |
43 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
44 | # config.force_ssl = true
45 |
46 | # Use the lowest log level to ensure availability of diagnostic information
47 | # when problems arise.
48 | config.log_level = :debug
49 |
50 | # Prepend all log lines with the following tags.
51 | config.log_tags = [ :request_id ]
52 |
53 | # Use a different cache store in production.
54 | # config.cache_store = :mem_cache_store
55 |
56 | # Use a real queuing backend for Active Job (and separate queues per environment)
57 | # config.active_job.queue_adapter = :resque
58 | # config.active_job.queue_name_prefix = "dummy_#{Rails.env}"
59 |
60 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
61 | # the I18n.default_locale when a translation cannot be found).
62 | config.i18n.fallbacks = true
63 |
64 | # Send deprecation notices to registered listeners.
65 | config.active_support.deprecation = :notify
66 |
67 | # Use default logging formatter so that PID and timestamp are not suppressed.
68 | config.log_formatter = ::Logger::Formatter.new
69 |
70 | # Use a different logger for distributed setups.
71 | # require 'syslog/logger'
72 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')
73 |
74 | if ENV["RAILS_LOG_TO_STDOUT"].present?
75 | logger = ActiveSupport::Logger.new(STDOUT)
76 | logger.formatter = config.log_formatter
77 | config.logger = ActiveSupport::TaggedLogging.new(logger)
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/lib/active_entity/validations/presence.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Validations
5 | class PresenceValidator < ActiveModel::Validations::PresenceValidator # :nodoc:
6 | def validate_each(record, attribute, association_or_value)
7 | if record.class._reflect_on_association(attribute)
8 | association_or_value = Array.wrap(association_or_value).reject(&:marked_for_destruction?)
9 | end
10 | super
11 | end
12 | end
13 |
14 | module ClassMethods
15 | # Validates that the specified attributes are not blank (as defined by
16 | # Object#blank?), and, if the attribute is an association, that the
17 | # associated object is not marked for destruction. Happens by default
18 | # on save.
19 | #
20 | # class Person < ActiveEntity::Base
21 | # has_one :face
22 | # validates_presence_of :face
23 | # end
24 | #
25 | # The face attribute must be in the object and it cannot be blank or marked
26 | # for destruction.
27 | #
28 | # If you want to validate the presence of a boolean field (where the real values
29 | # are true and false), you will want to use
30 | # validates_inclusion_of :field_name, in: [true, false].
31 | #
32 | # This is due to the way Object#blank? handles boolean values:
33 | # false.blank? # => true.
34 | #
35 | # This validator defers to the Active Model validation for presence, adding the
36 | # check to see that an associated object is not marked for destruction. This
37 | # prevents the parent object from validating successfully and saving, which then
38 | # deletes the associated object, thus putting the parent object into an invalid
39 | # state.
40 | #
41 | # NOTE: This validation will not fail while using it with an association
42 | # if the latter was assigned but not valid. If you want to ensure that
43 | # it is both present and valid, you also need to use
44 | # {validates_associated}[rdoc-ref:Validations::ClassMethods#validates_associated].
45 | #
46 | # Configuration options:
47 | # * :message - A custom error message (default is: "can't be blank").
48 | # * :on - Specifies the contexts where this validation is active.
49 | # Runs in all validation contexts by default +nil+. You can pass a symbol
50 | # or an array of symbols. (e.g. on: :create or
51 | # on: :custom_validation_context or
52 | # on: [:create, :custom_validation_context])
53 | # * :if - Specifies a method, proc or string to call to determine if
54 | # the validation should occur (e.g. if: :allow_validation, or
55 | # if: Proc.new { |user| user.signup_step > 2 }). The method, proc
56 | # or string should return or evaluate to a +true+ or +false+ value.
57 | # * :unless - Specifies a method, proc or string to call to determine
58 | # if the validation should not occur (e.g. unless: :skip_validation,
59 | # or unless: Proc.new { |user| user.signup_step <= 2 }). The method,
60 | # proc or string should return or evaluate to a +true+ or +false+ value.
61 | # * :strict - Specifies whether validation should be strict.
62 | # See ActiveModel::Validations#validates! for more information.
63 | def validates_presence_of(*attr_names)
64 | validates_with PresenceValidator, _merge_attributes(attr_names)
65 | end
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/builder/association.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This is the parent Association class which defines the variables
4 | # used by all associations.
5 | #
6 | # The hierarchy is defined as follows:
7 | # Association
8 | # - SingularAssociation
9 | # - BelongsToAssociation
10 | # - HasOneAssociation
11 | # - CollectionAssociation
12 | # - HasManyAssociation
13 |
14 | module ActiveEntity::Associations::Embeds::Builder # :nodoc:
15 | class Association #:nodoc:
16 | class << self
17 | attr_accessor :extensions
18 | end
19 | self.extensions = []
20 |
21 | VALID_OPTIONS = [:class_name, :anonymous_class, :validate] # :nodoc:
22 |
23 | def self.build(model, name, options)
24 | if model.dangerous_attribute_method?(name)
25 | raise ArgumentError, "You tried to define an association named #{name} on the model #{model.name}, but " \
26 | "this will conflict with a method #{name} already defined by Active Entity. " \
27 | "Please choose a different association name."
28 | end
29 |
30 | reflection = create_reflection model, name, options
31 | define_accessors model, reflection
32 | define_callbacks model, reflection
33 | define_validations model, reflection
34 | reflection
35 | end
36 |
37 | def self.create_reflection(model, name, options, &block)
38 | raise ArgumentError, "association names must be a Symbol" unless name.kind_of?(Symbol)
39 |
40 | validate_options(options)
41 |
42 | extension = define_extensions(model, name, &block)
43 | options[:extend] = [*options[:extend], extension] if extension
44 |
45 | ActiveEntity::Reflection.create(macro, name, nil, options, model)
46 | end
47 |
48 | def self.macro
49 | raise NotImplementedError
50 | end
51 |
52 | def self.valid_options(options)
53 | VALID_OPTIONS + Association.extensions.flat_map(&:valid_options)
54 | end
55 |
56 | def self.validate_options(options)
57 | options.assert_valid_keys(valid_options(options))
58 | end
59 |
60 | def self.define_extensions(model, name)
61 | end
62 |
63 | def self.define_callbacks(model, reflection)
64 | Association.extensions.each do |extension|
65 | extension.build model, reflection
66 | end
67 | end
68 |
69 | # Defines the setter and getter methods for the association
70 | # class Post < ActiveEntity::Base
71 | # has_many :comments
72 | # end
73 | #
74 | # Post.first.comments and Post.first.comments= methods are defined by this method...
75 | def self.define_accessors(model, reflection)
76 | mixin = model.generated_association_methods
77 | name = reflection.name
78 | define_readers(mixin, name)
79 | define_writers(mixin, name)
80 | end
81 |
82 | def self.define_readers(mixin, name)
83 | mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
84 | def #{name}
85 | association(:#{name}).reader
86 | end
87 | CODE
88 | end
89 |
90 | def self.define_writers(mixin, name)
91 | mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
92 | def #{name}=(value)
93 | association(:#{name}).writer(value)
94 | end
95 | CODE
96 | end
97 |
98 | def self.define_validations(_model, _reflection)
99 | # noop
100 | end
101 |
102 | private_class_method :macro, :valid_options, :validate_options, :define_extensions,
103 | :define_callbacks, :define_accessors, :define_readers, :define_writers, :define_validations
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/lib/active_entity/attribute_assignment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_model/forbidden_attributes_protection"
4 |
5 | module ActiveEntity
6 | module AttributeAssignment
7 | include ActiveModel::AttributeAssignment
8 |
9 | private
10 |
11 | def _assign_attributes(attributes)
12 | multi_parameter_attributes = nested_parameter_attributes = nil
13 |
14 | attributes.each do |k, v|
15 | key = k.to_s
16 |
17 | if key.include?("(")
18 | (multi_parameter_attributes ||= {})[key] = v
19 | elsif v.is_a?(Hash)
20 | (nested_parameter_attributes ||= {})[key] = v
21 | else
22 | _assign_attribute(key, v)
23 | end
24 | end
25 |
26 | assign_nested_parameter_attributes(nested_parameter_attributes) if nested_parameter_attributes
27 | assign_multiparameter_attributes(multi_parameter_attributes) if multi_parameter_attributes
28 | end
29 |
30 | # Assign any deferred nested attributes after the base attributes have been set.
31 | def assign_nested_parameter_attributes(pairs)
32 | pairs.each { |k, v| _assign_attribute(k, v) }
33 | end
34 |
35 | # Instantiates objects for all attribute classes that needs more than one constructor parameter. This is done
36 | # by calling new on the column type or aggregation type (through composed_of) object with these parameters.
37 | # So having the pairs written_on(1) = "2004", written_on(2) = "6", written_on(3) = "24", will instantiate
38 | # written_on (a date type) with Date.new("2004", "6", "24"). You can also specify a typecast character in the
39 | # parentheses to have the parameters typecasted before they're used in the constructor. Use i for Integer and
40 | # f for Float. If all the values for a given attribute are empty, the attribute will be set to +nil+.
41 | def assign_multiparameter_attributes(pairs)
42 | execute_callstack_for_multiparameter_attributes(
43 | extract_callstack_for_multiparameter_attributes(pairs)
44 | )
45 | end
46 |
47 | def execute_callstack_for_multiparameter_attributes(callstack)
48 | errors = []
49 | callstack.each do |name, values_with_empty_parameters|
50 | if values_with_empty_parameters.each_value.all?(&:nil?)
51 | values = nil
52 | else
53 | values = values_with_empty_parameters
54 | end
55 | send("#{name}=", values)
56 | rescue => ex
57 | errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name)
58 | end
59 | unless errors.empty?
60 | error_descriptions = errors.map(&:message).join(",")
61 | raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes [#{error_descriptions}]"
62 | end
63 | end
64 |
65 | def extract_callstack_for_multiparameter_attributes(pairs)
66 | attributes = {}
67 |
68 | pairs.each do |(multiparameter_name, value)|
69 | attribute_name = multiparameter_name.split("(").first
70 | attributes[attribute_name] ||= {}
71 |
72 | parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value)
73 | attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= parameter_value
74 | end
75 |
76 | attributes
77 | end
78 |
79 | def type_cast_attribute_value(multiparameter_name, value)
80 | multiparameter_name =~ /\([0-9]*([if])\)/ ? value.send("to_" + $1) : value
81 | end
82 |
83 | def find_parameter_position(multiparameter_name)
84 | multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i
85 | end
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/lib/active_entity/attribute_methods/serialization.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module AttributeMethods
5 | module Serialization
6 | extend ActiveSupport::Concern
7 |
8 | class ColumnNotSerializableError < StandardError
9 | def initialize(name, type)
10 | super <<~EOS
11 | Column `#{name}` of type #{type.class} does not support `serialize` feature.
12 | Usually it means that you are trying to use `serialize`
13 | on a column that already implements serialization natively.
14 | EOS
15 | end
16 | end
17 |
18 | module ClassMethods
19 | # If you have an attribute that needs to be saved to the database as an
20 | # object, and retrieved as the same object, then specify the name of that
21 | # attribute using this method and it will be handled automatically. The
22 | # serialization is done through YAML. If +class_name+ is specified, the
23 | # serialized object must be of that class on assignment and retrieval.
24 | # Otherwise SerializationTypeMismatch will be raised.
25 | #
26 | # Empty objects as {}, in the case of +Hash+, or [], in the case of
27 | # +Array+, will always be persisted as null.
28 | #
29 | # Keep in mind that database adapters handle certain serialization tasks
30 | # for you. For instance: +json+ and +jsonb+ types in PostgreSQL will be
31 | # converted between JSON object/array syntax and Ruby +Hash+ or +Array+
32 | # objects transparently. There is no need to use #serialize in this
33 | # case.
34 | #
35 | # For more complex cases, such as conversion to or from your application
36 | # domain objects, consider using the ActiveEntity::Attributes API.
37 | #
38 | # ==== Parameters
39 | #
40 | # * +attr_name+ - The field name that should be serialized.
41 | # * +class_name_or_coder+ - Optional, a coder object, which responds to +.load+ and +.dump+
42 | # or a class name that the object type should be equal to.
43 | #
44 | # ==== Example
45 | #
46 | # # Serialize a preferences attribute.
47 | # class User < ActiveEntity::Base
48 | # serialize :preferences
49 | # end
50 | #
51 | # # Serialize preferences using JSON as coder.
52 | # class User < ActiveEntity::Base
53 | # serialize :preferences, JSON
54 | # end
55 | #
56 | # # Serialize preferences as Hash using YAML coder.
57 | # class User < ActiveEntity::Base
58 | # serialize :preferences, Hash
59 | # end
60 | def serialize(attr_name, class_name_or_coder = Object)
61 | # When ::JSON is used, force it to go through the Active Support JSON encoder
62 | # to ensure special objects (e.g. Active Entity models) are dumped correctly
63 | # using the #as_json hook.
64 | coder = if class_name_or_coder == ::JSON
65 | Coders::JSON
66 | elsif [:load, :dump].all? { |x| class_name_or_coder.respond_to?(x) }
67 | class_name_or_coder
68 | else
69 | Coders::YAMLColumn.new(attr_name, class_name_or_coder)
70 | end
71 |
72 | decorate_attribute_type(attr_name, :serialize) do |type|
73 | if type_incompatible_with_serialize?(type, class_name_or_coder)
74 | raise ColumnNotSerializableError.new(attr_name, type)
75 | end
76 |
77 | Type::Serialized.new(type, coder)
78 | end
79 | end
80 |
81 | private
82 |
83 | def type_incompatible_with_serialize?(type, class_name)
84 | type.is_a?(ActiveEntity::Type::Json) && class_name == ::JSON ||
85 | type.respond_to?(:type_cast_array, true) && class_name == ::Array
86 | end
87 | end
88 | end
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/lib/active_entity/model_schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "monitor"
4 |
5 | module ActiveEntity
6 | module ModelSchema
7 | extend ActiveSupport::Concern
8 |
9 | included do
10 | delegate :type_for_attribute, to: :class
11 |
12 | initialize_load_schema_monitor
13 | end
14 |
15 | module ClassMethods
16 | def attributes_builder # :nodoc:
17 | unless defined?(@attributes_builder) && @attributes_builder
18 | defaults = _default_attributes
19 | @attributes_builder = ActiveModel::AttributeSet::Builder.new(attribute_types, defaults)
20 | end
21 | @attributes_builder
22 | end
23 |
24 | def attribute_types # :nodoc:
25 | load_schema
26 | @attribute_types ||= Hash.new(Type.default_value)
27 | end
28 |
29 | def yaml_encoder # :nodoc:
30 | @yaml_encoder ||= ActiveModel::AttributeSet::YAMLEncoder.new(attribute_types)
31 | end
32 |
33 | # Returns the type of the attribute with the given name, after applying
34 | # all modifiers. This method is the only valid source of information for
35 | # anything related to the types of a model's attributes. This method will
36 | # access the database and load the model's schema if it is required.
37 | #
38 | # The return value of this method will implement the interface described
39 | # by ActiveModel::Type::Value (though the object itself may not subclass
40 | # it).
41 | #
42 | # +attr_name+ The name of the attribute to retrieve the type for. Must be
43 | # a string or a symbol.
44 | def type_for_attribute(attr_name, &block)
45 | attr_name = attr_name.to_s
46 | if block
47 | attribute_types.fetch(attr_name, &block)
48 | else
49 | attribute_types[attr_name]
50 | end
51 | end
52 |
53 | def _default_attributes # :nodoc:
54 | load_schema
55 | @default_attributes ||= ActiveModel::AttributeSet.new({})
56 | end
57 |
58 | protected
59 |
60 | def initialize_load_schema_monitor
61 | @load_schema_monitor = Monitor.new
62 | end
63 |
64 | private
65 |
66 | def inherited(child_class)
67 | super
68 | child_class.initialize_load_schema_monitor
69 | end
70 |
71 | def schema_loaded?
72 | defined?(@schema_loaded) && @schema_loaded
73 | end
74 |
75 | def load_schema
76 | return if schema_loaded?
77 | @load_schema_monitor.synchronize do
78 | return if defined?(@load_schema_invoked) && @load_schema_invoked
79 |
80 | load_schema!
81 | @schema_loaded = true
82 | end
83 | end
84 |
85 | def load_schema!
86 | @load_schema_invoked = true
87 | end
88 |
89 | if ActiveSupport::VERSION::MAJOR >= 7
90 | def reload_schema_from_cache
91 | @attribute_types = nil
92 | @default_attributes = nil
93 | @attributes_builder = nil
94 | @schema_loaded = false
95 | @load_schema_invoked = false
96 | @attribute_names = nil
97 | @yaml_encoder = nil
98 | subclasses.each do |descendant|
99 | descendant.send(:reload_schema_from_cache)
100 | end
101 | end
102 | else
103 | def reload_schema_from_cache
104 | @attribute_types = nil
105 | @default_attributes = nil
106 | @attributes_builder = nil
107 | @schema_loaded = false
108 | @load_schema_invoked = false
109 | @attribute_names = nil
110 | @yaml_encoder = nil
111 | direct_descendants.each do |descendant|
112 | descendant.send(:reload_schema_from_cache)
113 | end
114 | end
115 | end
116 | end
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/association.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/core_ext/array/wrap"
4 |
5 | module ActiveEntity
6 | module Associations
7 | module Embeds
8 | # = Active Entity Associations
9 | #
10 | # This is the root class of all associations ('+ Foo' signifies an included module Foo):
11 | #
12 | # Association
13 | # SingularAssociation
14 | # HasOneAssociation + ForeignAssociation
15 | # HasOneThroughAssociation + ThroughAssociation
16 | # BelongsToAssociation
17 | # BelongsToPolymorphicAssociation
18 | # CollectionAssociation
19 | # HasManyAssociation + ForeignAssociation
20 | # HasManyThroughAssociation + ThroughAssociation
21 | class Association #:nodoc:
22 | attr_reader :owner, :target, :reflection
23 |
24 | delegate :options, to: :reflection
25 |
26 | def initialize(owner, reflection)
27 | reflection.check_validity!
28 |
29 | @owner, @reflection = owner, reflection
30 |
31 | @target = nil
32 | @inversed = false
33 | end
34 |
35 | # Has the \target been already \loaded?
36 | def loaded?
37 | true
38 | end
39 |
40 | # Sets the target of this association to \target, and the \loaded flag to +true+.
41 | attr_writer :target
42 |
43 | # Set the inverse association, if possible
44 | def set_inverse_instance(record)
45 | if inverse = inverse_association_for(record)
46 | inverse.inversed_from(owner)
47 | end
48 | record
49 | end
50 |
51 | # Remove the inverse association, if possible
52 | def remove_inverse_instance(record)
53 | if inverse = inverse_association_for(record)
54 | inverse.inversed_from(nil)
55 | end
56 | end
57 |
58 | def inversed_from(record)
59 | self.target = record
60 | @inversed = !!record
61 | end
62 |
63 | # Returns the class of the target. belongs_to polymorphic overrides this to look at the
64 | # polymorphic_type field on the owner.
65 | def klass
66 | reflection.klass
67 | end
68 |
69 | def extensions
70 | reflection.extensions
71 | end
72 |
73 | # We can't dump @reflection and @through_reflection since it contains the scope proc
74 | def marshal_dump
75 | ivars = (instance_variables - [:@reflection, :@through_reflection]).map { |name| [name, instance_variable_get(name)] }
76 | [@reflection.name, ivars]
77 | end
78 |
79 | def marshal_load(data)
80 | reflection_name, ivars = data
81 | ivars.each { |name, val| instance_variable_set(name, val) }
82 | @reflection = @owner.class._reflect_on_association(reflection_name)
83 | end
84 |
85 | def initialize_attributes(record, attributes = {}) #:nodoc:
86 | record.assign_attributes attributes if attributes.any?
87 | set_inverse_instance(record)
88 | end
89 |
90 | private
91 |
92 | # Raises ActiveEntity::AssociationTypeMismatch unless +record+ is of
93 | # the kind of the class of the associated objects. Meant to be used as
94 | # a sanity check when you are about to assign an associated record.
95 | def raise_on_type_mismatch!(record)
96 | unless record.is_a?(reflection.klass)
97 | fresh_class = reflection.class_name.safe_constantize
98 | unless fresh_class && record.is_a?(fresh_class)
99 | message = "#{reflection.class_name}(##{reflection.klass.object_id}) expected, "\
100 | "got #{record.inspect} which is an instance of #{record.class}(##{record.class.object_id})"
101 | raise ActiveEntity::AssociationTypeMismatch, message
102 | end
103 | end
104 | end
105 |
106 | def inverse_association_for(record)
107 | if invertible_for?(record)
108 | record.association(inverse_reflection_for(record).name)
109 | end
110 | end
111 |
112 | # Can be redefined by subclasses, notably polymorphic belongs_to
113 | # The record parameter is necessary to support polymorphic inverses as we must check for
114 | # the association in the specific class of the record.
115 | def inverse_reflection_for(record)
116 | reflection.inverse_of
117 | end
118 |
119 | # Returns true if inverse association on the given record needs to be set.
120 | # This method is redefined by subclasses.
121 | def invertible_for?(record)
122 | inverse_reflection_for(record)
123 | end
124 |
125 | def build_record(attributes)
126 | reflection.build_association(attributes) do |record|
127 | initialize_attributes(record, attributes)
128 | yield(record) if block_given?
129 | end
130 | end
131 | end
132 | end
133 | end
134 | end
135 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | activeentity (6.3.0)
5 | activemodel (>= 6.0, < 8)
6 | activesupport (>= 6.0, < 8)
7 |
8 | GEM
9 | remote: https://rubygems.org/
10 | specs:
11 | actioncable (6.1.6.1)
12 | actionpack (= 6.1.6.1)
13 | activesupport (= 6.1.6.1)
14 | nio4r (~> 2.0)
15 | websocket-driver (>= 0.6.1)
16 | actionmailbox (6.1.6.1)
17 | actionpack (= 6.1.6.1)
18 | activejob (= 6.1.6.1)
19 | activerecord (= 6.1.6.1)
20 | activestorage (= 6.1.6.1)
21 | activesupport (= 6.1.6.1)
22 | mail (>= 2.7.1)
23 | actionmailer (6.1.6.1)
24 | actionpack (= 6.1.6.1)
25 | actionview (= 6.1.6.1)
26 | activejob (= 6.1.6.1)
27 | activesupport (= 6.1.6.1)
28 | mail (~> 2.5, >= 2.5.4)
29 | rails-dom-testing (~> 2.0)
30 | actionpack (6.1.6.1)
31 | actionview (= 6.1.6.1)
32 | activesupport (= 6.1.6.1)
33 | rack (~> 2.0, >= 2.0.9)
34 | rack-test (>= 0.6.3)
35 | rails-dom-testing (~> 2.0)
36 | rails-html-sanitizer (~> 1.0, >= 1.2.0)
37 | actiontext (6.1.6.1)
38 | actionpack (= 6.1.6.1)
39 | activerecord (= 6.1.6.1)
40 | activestorage (= 6.1.6.1)
41 | activesupport (= 6.1.6.1)
42 | nokogiri (>= 1.8.5)
43 | actionview (6.1.6.1)
44 | activesupport (= 6.1.6.1)
45 | builder (~> 3.1)
46 | erubi (~> 1.4)
47 | rails-dom-testing (~> 2.0)
48 | rails-html-sanitizer (~> 1.1, >= 1.2.0)
49 | activejob (6.1.6.1)
50 | activesupport (= 6.1.6.1)
51 | globalid (>= 0.3.6)
52 | activemodel (6.1.6.1)
53 | activesupport (= 6.1.6.1)
54 | activerecord (6.1.6.1)
55 | activemodel (= 6.1.6.1)
56 | activesupport (= 6.1.6.1)
57 | activestorage (6.1.6.1)
58 | actionpack (= 6.1.6.1)
59 | activejob (= 6.1.6.1)
60 | activerecord (= 6.1.6.1)
61 | activesupport (= 6.1.6.1)
62 | marcel (~> 1.0)
63 | mini_mime (>= 1.1.0)
64 | activesupport (6.1.6.1)
65 | concurrent-ruby (~> 1.0, >= 1.0.2)
66 | i18n (>= 1.6, < 2)
67 | minitest (>= 5.1)
68 | tzinfo (~> 2.0)
69 | zeitwerk (~> 2.3)
70 | ast (2.4.2)
71 | bindex (0.8.1)
72 | builder (3.2.4)
73 | byebug (11.1.3)
74 | concurrent-ruby (1.1.10)
75 | crass (1.0.6)
76 | erubi (1.11.0)
77 | globalid (1.0.0)
78 | activesupport (>= 5.0)
79 | i18n (1.12.0)
80 | concurrent-ruby (~> 1.0)
81 | json (2.6.2)
82 | loofah (2.18.0)
83 | crass (~> 1.0.2)
84 | nokogiri (>= 1.5.9)
85 | mail (2.7.1)
86 | mini_mime (>= 0.1.1)
87 | marcel (1.0.2)
88 | method_source (1.0.0)
89 | mini_mime (1.1.2)
90 | mini_portile2 (2.8.0)
91 | minitest (5.16.2)
92 | nio4r (2.5.8)
93 | nokogiri (1.13.8)
94 | mini_portile2 (~> 2.8.0)
95 | racc (~> 1.4)
96 | parallel (1.22.1)
97 | parser (3.1.2.1)
98 | ast (~> 2.4.1)
99 | puma (5.6.4)
100 | nio4r (~> 2.0)
101 | racc (1.6.0)
102 | rack (2.2.4)
103 | rack-test (2.0.2)
104 | rack (>= 1.3)
105 | rails (6.1.6.1)
106 | actioncable (= 6.1.6.1)
107 | actionmailbox (= 6.1.6.1)
108 | actionmailer (= 6.1.6.1)
109 | actionpack (= 6.1.6.1)
110 | actiontext (= 6.1.6.1)
111 | actionview (= 6.1.6.1)
112 | activejob (= 6.1.6.1)
113 | activemodel (= 6.1.6.1)
114 | activerecord (= 6.1.6.1)
115 | activestorage (= 6.1.6.1)
116 | activesupport (= 6.1.6.1)
117 | bundler (>= 1.15.0)
118 | railties (= 6.1.6.1)
119 | sprockets-rails (>= 2.0.0)
120 | rails-dom-testing (2.0.3)
121 | activesupport (>= 4.2.0)
122 | nokogiri (>= 1.6)
123 | rails-html-sanitizer (1.4.3)
124 | loofah (~> 2.3)
125 | railties (6.1.6.1)
126 | actionpack (= 6.1.6.1)
127 | activesupport (= 6.1.6.1)
128 | method_source
129 | rake (>= 12.2)
130 | thor (~> 1.0)
131 | rainbow (3.1.1)
132 | rake (13.0.6)
133 | regexp_parser (2.5.0)
134 | rexml (3.2.5)
135 | rubocop (1.35.0)
136 | json (~> 2.3)
137 | parallel (~> 1.10)
138 | parser (>= 3.1.2.1)
139 | rainbow (>= 2.2.2, < 4.0)
140 | regexp_parser (>= 1.8, < 3.0)
141 | rexml (>= 3.2.5, < 4.0)
142 | rubocop-ast (>= 1.20.1, < 2.0)
143 | ruby-progressbar (~> 1.7)
144 | unicode-display_width (>= 1.4.0, < 3.0)
145 | rubocop-ast (1.21.0)
146 | parser (>= 3.1.1.0)
147 | rubocop-performance (1.14.3)
148 | rubocop (>= 1.7.0, < 2.0)
149 | rubocop-ast (>= 0.4.0)
150 | rubocop-rails (2.15.2)
151 | activesupport (>= 4.2.0)
152 | rack (>= 1.1)
153 | rubocop (>= 1.7.0, < 2.0)
154 | ruby-progressbar (1.11.0)
155 | sprockets (4.1.1)
156 | concurrent-ruby (~> 1.0)
157 | rack (> 1, < 3)
158 | sprockets-rails (3.4.2)
159 | actionpack (>= 5.2)
160 | activesupport (>= 5.2)
161 | sprockets (>= 3.0.0)
162 | sqlite3 (1.4.4)
163 | thor (1.2.1)
164 | tzinfo (2.0.5)
165 | concurrent-ruby (~> 1.0)
166 | unicode-display_width (2.2.0)
167 | web-console (4.2.0)
168 | actionview (>= 6.0.0)
169 | activemodel (>= 6.0.0)
170 | bindex (>= 0.4.0)
171 | railties (>= 6.0.0)
172 | websocket-driver (0.7.5)
173 | websocket-extensions (>= 0.1.0)
174 | websocket-extensions (0.1.5)
175 | zeitwerk (2.6.0)
176 |
177 | PLATFORMS
178 | ruby
179 |
180 | DEPENDENCIES
181 | activeentity!
182 | byebug
183 | puma
184 | rails (~> 6.1)
185 | rubocop
186 | rubocop-performance
187 | rubocop-rails
188 | sqlite3
189 | web-console
190 |
191 | BUNDLED WITH
192 | 2.2.32
193 |
--------------------------------------------------------------------------------
/comparison_with_activemodel_and_activetype.md:
--------------------------------------------------------------------------------
1 | # Comparison with ActiveModel and ActiveType
2 |
3 | ## In most simple cases use ActiveModel is enough
4 |
5 | You can make your "BaseFormObject":
6 |
7 | ```ruby
8 | class BaseFormObject
9 | # Acts as Active Model
10 | # includes Rails integration and Validation
11 | include ActiveModel::Model
12 |
13 | # Enable Attribute API
14 | #
15 | #
16 | include ActiveModel::Attributes
17 |
18 | # (Optional) Enable dirty tracking
19 | #
20 | include ActiveModel::Dirty
21 |
22 | # (Optional) Enable serialization
23 | # e.g. dump attributes to hash or JSON
24 | include ActiveModel::Serialization
25 | end
26 | ```
27 |
28 | Usage:
29 |
30 | ```ruby
31 | class Book < BaseFormObject
32 | attribute :title, :string
33 | attribute :score, :integer
34 |
35 | validates :title, presence: true
36 | validates :score, numericality: { only_integer: true }
37 | end
38 | ```
39 |
40 | It basically acts as Active Record model.
41 |
42 | In addition, some Active Record model plugins (e.g [adzap/validates_timeliness](https://github.com/adzap/validates_timeliness)) which not save or update database will compatible with the `BaseFormObject`.
43 |
44 | ## ActiveType
45 |
46 | It has extra features:
47 |
48 | - Derivable from an exists Active Record model
49 | - Nested attributes
50 |
51 | It:
52 |
53 | - Built upon `ActiveRecord::Model` with patches to made it table-less
54 | - Doesn't reuse Active Model Attribute API, Dirty and other components
55 | - Active maintain
56 |
57 | ## ActiveEntity
58 |
59 | It has extra features:
60 |
61 | - Nested attributes (I call it embedded models)
62 | - Port PG-only feature typed array attribute.
63 | - Extra useful validations
64 |
65 | It:
66 |
67 | - Forked from Active Record and removing all database relates codes
68 | - Initially for the author personal usage (such as [Flow Core](https://github.com/rails-engine/flow_core), [Form Core](https://github.com/rails-engine/form_core), [Script Core](https://github.com/rails-engine/script_core)) Less reliable because tests are not ported
69 | - Reuse Active Model features, only few part such as "Nested attributes" are written by the author
70 | - More similar to Active Record
71 | - Less active maintain, but issue will be reply quickly
72 |
73 | ## Extra read: What is Active Model
74 |
75 | Not all business objects are backed with database, and modeling them is an essential thing for complex applications.
76 |
77 | ### Plain Ordinary Ruby Object
78 |
79 | you can just define a `class` to do that.
80 |
81 | ```ruby
82 | class Post
83 | attr_reader :title, :body
84 |
85 | # Initializer, attribute writers, methods, etc.
86 | end
87 | ```
88 |
89 | But we're on Rails, compared to Active Record model, it's lacking:
90 |
91 | - Typed attributes
92 | - Validation
93 | - Integrate with Rails, the most part is integrate with Strong parameter, form helpers and I18n
94 |
95 | ### ActiveModel::Model
96 |
97 | Since Rails 4, Active Record has extracted the Rails integration layer called Active Model, it defined essential interfaces that how controller and view helpers can interact with the model.
98 |
99 | Many people don't know Active Model because lacking guide, actually it has a WIP one, and it's not easy to be found: .
100 |
101 | I recommend read it first, here I want to explain what features contains in `ActiveModel::Model`
102 |
103 | ```ruby
104 | module Model
105 | # It's a Mixin
106 | extend ActiveSupport::Concern
107 |
108 | # Provides `write_attribute`, `read_attribute` and `assign_attributes` methods as unify attribute accessor,
109 | # other extensions will depend on this
110 | include ActiveModel::AttributeAssignment
111 | # Validation DSL (e.g `validates :title, presence: true`), same as ActiveRecord validations
112 | include ActiveModel::Validations
113 | # Interfaces of how to interact with URL helpers and rendering helpers
114 | include ActiveModel::Conversion
115 |
116 | included do
117 | # Reflection of the model name
118 | extend ActiveModel::Naming
119 | # Interfaces of how to interact with I18n
120 | extend ActiveModel::Translation
121 | end
122 |
123 | # Initializes a new model with the given +params+.
124 | #
125 | # class Person
126 | # include ActiveModel::Model
127 | # attr_accessor :name, :age
128 | # end
129 | #
130 | # person = Person.new(name: 'bob', age: '18')
131 | # person.name # => "bob"
132 | # person.age # => "18"
133 | def initialize(attributes = {})
134 | assign_attributes(attributes) if attributes
135 |
136 | super()
137 | end
138 |
139 | # Indicates if the model is persisted. Default is +false+.
140 | #
141 | # class Person
142 | # include ActiveModel::Model
143 | # attr_accessor :id, :name
144 | # end
145 | #
146 | # person = Person.new(id: 1, name: 'bob')
147 | # person.persisted? # => false
148 | def persisted?
149 | false
150 | end
151 | end
152 | end
153 | ```
154 |
155 | Including `ActiveModel::Model` to a class it will get:
156 |
157 | - Validation
158 | - Integrate with Rails, the most part is integrate with Strong parameter, form helpers and I18n
159 |
160 | Still lacking
161 |
162 | - Typed attributes
163 |
164 | #### Attribute API, the hidden jewel in Active Model
165 |
166 | Since Rails 5, attribute API was made public and moved to Active Model,
167 | you can use it to define attributes with type.
168 |
169 | See and for detail.
170 |
171 | To enable it, just `include ActiveModel::Attributes`.
172 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | require:
2 | - rubocop-performance
3 | - rubocop-rails
4 |
5 | AllCops:
6 | TargetRubyVersion: 2.5
7 | # RuboCop has a bunch of cops enabled by default. This setting tells RuboCop
8 | # to ignore them, so only the ones explicitly set in this file are enabled.
9 | DisabledByDefault: true
10 | Exclude:
11 | - '**/templates/**/*'
12 | - '**/vendor/**/*'
13 | - 'node_modules/**/*'
14 | - 'test/dummy/db/schema.rb'
15 |
16 | #Metrics/AbcSize:
17 | # Max: 30
18 |
19 | # Prefer assert_not over assert !
20 | Rails/AssertNot:
21 | Include:
22 | - 'test/**/*'
23 |
24 | # Prefer assert_not_x over refute_x
25 | Rails/RefuteMethods:
26 | Include:
27 | - 'test/**/*'
28 |
29 | Rails/IndexBy:
30 | Enabled: true
31 |
32 | Rails/IndexWith:
33 | Enabled: true
34 |
35 | # Prefer &&/|| over and/or.
36 | Style/AndOr:
37 | Enabled: true
38 |
39 | # Align `when` with `case`.
40 | Layout/CaseIndentation:
41 | Enabled: true
42 |
43 | Layout/ClosingHeredocIndentation:
44 | Enabled: true
45 |
46 | # Align comments with method definitions.
47 | Layout/CommentIndentation:
48 | Enabled: true
49 |
50 | Layout/ElseAlignment:
51 | Enabled: true
52 |
53 | # Align `end` with the matching keyword or starting expression except for
54 | # assignments, where it should be aligned with the LHS.
55 | Layout/EndAlignment:
56 | Enabled: true
57 | EnforcedStyleAlignWith: variable
58 | AutoCorrect: true
59 |
60 | Layout/EmptyLineAfterMagicComment:
61 | Enabled: true
62 |
63 | Layout/EmptyLinesAroundAccessModifier:
64 | Enabled: true
65 |
66 | Layout/EmptyLinesAroundBlockBody:
67 | Enabled: true
68 |
69 | # In a regular class definition, no empty lines around the body.
70 | Layout/EmptyLinesAroundClassBody:
71 | Enabled: true
72 |
73 | # In a regular method definition, no empty lines around the body.
74 | Layout/EmptyLinesAroundMethodBody:
75 | Enabled: true
76 |
77 | # In a regular module definition, no empty lines around the body.
78 | Layout/EmptyLinesAroundModuleBody:
79 | Enabled: true
80 |
81 | # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }.
82 | Style/HashSyntax:
83 | Enabled: true
84 |
85 | Layout/FirstArgumentIndentation:
86 | Enabled: true
87 |
88 | # Method definitions after `private` or `protected` isolated calls need one
89 | # extra level of indentation.
90 | Layout/IndentationConsistency:
91 | Enabled: true
92 | EnforcedStyle: indented_internal_methods
93 |
94 | # Two spaces, no tabs (for indentation).
95 | Layout/IndentationWidth:
96 | Enabled: true
97 |
98 | Layout/LeadingCommentSpace:
99 | Enabled: true
100 |
101 | Layout/SpaceAfterColon:
102 | Enabled: true
103 |
104 | Layout/SpaceAfterComma:
105 | Enabled: true
106 |
107 | Layout/SpaceAfterSemicolon:
108 | Enabled: true
109 |
110 | Layout/SpaceAroundEqualsInParameterDefault:
111 | Enabled: true
112 |
113 | Layout/SpaceAroundKeyword:
114 | Enabled: true
115 |
116 | Layout/SpaceBeforeComma:
117 | Enabled: true
118 |
119 | Layout/SpaceBeforeComment:
120 | Enabled: true
121 |
122 | Layout/SpaceBeforeFirstArg:
123 | Enabled: true
124 |
125 | Style/DefWithParentheses:
126 | Enabled: true
127 |
128 | # Defining a method with parameters needs parentheses.
129 | Style/MethodDefParentheses:
130 | Enabled: true
131 |
132 | Style/FrozenStringLiteralComment:
133 | Enabled: true
134 | EnforcedStyle: always
135 |
136 | Style/RedundantFreeze:
137 | Enabled: true
138 |
139 | # Use `foo {}` not `foo{}`.
140 | Layout/SpaceBeforeBlockBraces:
141 | Enabled: true
142 |
143 | # Use `foo { bar }` not `foo {bar}`.
144 | Layout/SpaceInsideBlockBraces:
145 | Enabled: true
146 | EnforcedStyleForEmptyBraces: space
147 |
148 | # Use `{ a: 1 }` not `{a:1}`.
149 | Layout/SpaceInsideHashLiteralBraces:
150 | Enabled: true
151 |
152 | Layout/SpaceInsideParens:
153 | Enabled: true
154 |
155 | # Check quotes usage according to lint rule below.
156 | Style/StringLiterals:
157 | Enabled: true
158 | EnforcedStyle: double_quotes
159 |
160 | # Detect hard tabs, no hard tabs.
161 | Layout/IndentationStyle:
162 | Enabled: true
163 |
164 | # Empty lines should not have any spaces.
165 | Layout/TrailingEmptyLines:
166 | Enabled: true
167 |
168 | # No trailing whitespace.
169 | Layout/TrailingWhitespace:
170 | Enabled: true
171 |
172 | # Use quotes for string literals when they are enough.
173 | Style/RedundantPercentQ:
174 | Enabled: true
175 |
176 | Lint/AmbiguousOperator:
177 | Enabled: true
178 |
179 | Lint/AmbiguousRegexpLiteral:
180 | Enabled: true
181 |
182 | Lint/ErbNewArguments:
183 | Enabled: true
184 |
185 | # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg.
186 | Lint/RequireParentheses:
187 | Enabled: true
188 |
189 | Lint/ShadowingOuterLocalVariable:
190 | Enabled: true
191 |
192 | Lint/RedundantStringCoercion:
193 | Enabled: true
194 |
195 | Lint/UriEscapeUnescape:
196 | Enabled: true
197 |
198 | Lint/UselessAssignment:
199 | Enabled: true
200 |
201 | Lint/DeprecatedClassMethods:
202 | Enabled: true
203 |
204 | Lint/DeprecatedOpenSSLConstant:
205 | Enabled: true
206 |
207 | Style/ParenthesesAroundCondition:
208 | Enabled: true
209 |
210 | Style/HashTransformKeys:
211 | Enabled: true
212 |
213 | Style/HashTransformValues:
214 | Enabled: true
215 |
216 | Style/RedundantBegin:
217 | Enabled: true
218 |
219 | Style/RedundantReturn:
220 | Enabled: true
221 | AllowMultipleReturnValues: true
222 |
223 | Style/Semicolon:
224 | Enabled: true
225 | AllowAsExpressionSeparator: true
226 |
227 | # Prefer Foo.method over Foo::method
228 | Style/ColonMethodCall:
229 | Enabled: true
230 |
231 | Style/TrivialAccessors:
232 | Enabled: true
233 |
234 | Style/SlicingWithRange:
235 | Enabled: true
236 |
237 | Style/RedundantRegexpEscape:
238 | Enabled: true
239 |
240 | Performance/FlatMap:
241 | Enabled: true
242 |
243 | Performance/RedundantMerge:
244 | Enabled: true
245 |
246 | Performance/StartWith:
247 | Enabled: true
248 |
249 | Performance/EndWith:
250 | Enabled: true
251 |
252 | Performance/RegexpMatch:
253 | Enabled: true
254 |
255 | Performance/ReverseEach:
256 | Enabled: true
257 |
258 | Performance/UnfreezeString:
259 | Enabled: true
260 |
261 | Performance/DeletePrefix:
262 | Enabled: true
263 |
264 | Performance/DeleteSuffix:
265 | Enabled: true
266 |
--------------------------------------------------------------------------------
/lib/active_entity/attribute_methods/dirty.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/core_ext/module/attribute_accessors"
4 |
5 | module ActiveEntity
6 | module AttributeMethods
7 | module Dirty
8 | extend ActiveSupport::Concern
9 | include ActiveEntity::AMAttributeMethods
10 |
11 | included do
12 | attribute_method_suffix "_changed?", "_change", "_will_change!", "_was"
13 | attribute_method_suffix "_previously_changed?", "_previous_change", "_previously_was"
14 | attribute_method_affix prefix: "restore_", suffix: "!"
15 | attribute_method_affix prefix: "clear_", suffix: "_change"
16 | end
17 |
18 | def initialize_dup(other) # :nodoc:
19 | super
20 | if self.class.respond_to?(:_default_attributes)
21 | @attributes = self.class._default_attributes.map do |attr|
22 | attr.with_value_from_user(@attributes.fetch_value(attr.name))
23 | end
24 | end
25 | @mutations_from_database = nil
26 | end
27 |
28 | def as_json(options = {}) # :nodoc:
29 | options[:except] = [*options[:except], "mutations_from_database", "mutations_before_last_save"]
30 | super(options)
31 | end
32 |
33 | # Clears dirty data and moves +changes+ to +previous_changes+ and
34 | # +mutations_from_database+ to +mutations_before_last_save+ respectively.
35 | def changes_applied
36 | unless defined?(@attributes)
37 | mutations_from_database.finalize_changes
38 | end
39 | @mutations_before_last_save = mutations_from_database
40 | forget_attribute_assignments
41 | @mutations_from_database = nil
42 | end
43 |
44 | # Returns +true+ if any of the attributes has unsaved changes, +false+ otherwise.
45 | #
46 | # person.changed? # => false
47 | # person.name = 'bob'
48 | # person.changed? # => true
49 | def changed?
50 | mutations_from_database.any_changes?
51 | end
52 |
53 | # Returns an array with the name of the attributes with unsaved changes.
54 | #
55 | # person.changed # => []
56 | # person.name = 'bob'
57 | # person.changed # => ["name"]
58 | def changed
59 | mutations_from_database.changed_attribute_names
60 | end
61 |
62 | # Dispatch target for *_changed? attribute methods.
63 | def attribute_changed?(attr_name, **options) # :nodoc:
64 | mutations_from_database.changed?(attr_name.to_s, **options)
65 | end
66 |
67 | # Dispatch target for *_was attribute methods.
68 | def attribute_was(attr_name) # :nodoc:
69 | mutations_from_database.original_value(attr_name.to_s)
70 | end
71 |
72 | # Dispatch target for *_previously_changed? attribute methods.
73 | def attribute_previously_changed?(attr_name, **options) # :nodoc:
74 | mutations_before_last_save.changed?(attr_name.to_s, **options)
75 | end
76 |
77 | # Dispatch target for *_previously_was attribute methods.
78 | def attribute_previously_was(attr_name) # :nodoc:
79 | mutations_before_last_save.original_value(attr_name.to_s)
80 | end
81 |
82 | # Restore all previous data of the provided attributes.
83 | def restore_attributes(attr_names = changed)
84 | attr_names.each { |attr_name| restore_attribute!(attr_name) }
85 | end
86 |
87 | # Clears all dirty data: current changes and previous changes.
88 | def clear_changes_information
89 | @mutations_before_last_save = nil
90 | forget_attribute_assignments
91 | @mutations_from_database = nil
92 | end
93 |
94 | def clear_attribute_changes(attr_names)
95 | attr_names.each do |attr_name|
96 | clear_attribute_change(attr_name)
97 | end
98 | end
99 |
100 | # Returns a hash of the attributes with unsaved changes indicating their original
101 | # values like attr => original value.
102 | #
103 | # person.name # => "bob"
104 | # person.name = 'robert'
105 | # person.changed_attributes # => {"name" => "bob"}
106 | def changed_attributes
107 | mutations_from_database.changed_values
108 | end
109 |
110 | # Returns a hash of changed attributes indicating their original
111 | # and new values like attr => [original value, new value].
112 | #
113 | # person.changes # => {}
114 | # person.name = 'bob'
115 | # person.changes # => { "name" => ["bill", "bob"] }
116 | def changes
117 | mutations_from_database.changes
118 | end
119 |
120 | # Returns a hash of attributes that were changed before the model was saved.
121 | #
122 | # person.name # => "bob"
123 | # person.name = 'robert'
124 | # person.save
125 | # person.previous_changes # => {"name" => ["bob", "robert"]}
126 | def previous_changes
127 | mutations_before_last_save.changes
128 | end
129 |
130 | def attribute_changed_in_place?(attr_name) # :nodoc:
131 | mutations_from_database.changed_in_place?(attr_name.to_s)
132 | end
133 |
134 | private
135 | def clear_attribute_change(attr_name)
136 | mutations_from_database.forget_change(attr_name.to_s)
137 | end
138 |
139 | def mutations_from_database
140 | @mutations_from_database ||= if defined?(@attributes)
141 | ActiveModel::AttributeMutationTracker.new(@attributes)
142 | else
143 | ActiveModel::ForcedMutationTracker.new(self)
144 | end
145 | end
146 |
147 | def forget_attribute_assignments
148 | @attributes = @attributes.map(&:forgetting_assignment) if defined?(@attributes)
149 | end
150 |
151 | def mutations_before_last_save
152 | @mutations_before_last_save ||= ActiveModel::NullMutationTracker.instance
153 | end
154 |
155 | # Dispatch target for *_change attribute methods.
156 | def attribute_change(attr_name)
157 | mutations_from_database.change_to_attribute(attr_name.to_s)
158 | end
159 |
160 | # Dispatch target for *_previous_change attribute methods.
161 | def attribute_previous_change(attr_name)
162 | mutations_before_last_save.change_to_attribute(attr_name.to_s)
163 | end
164 |
165 | # Dispatch target for *_will_change! attribute methods.
166 | def attribute_will_change!(attr_name)
167 | mutations_from_database.force_change(attr_name.to_s)
168 | end
169 |
170 | # Dispatch target for restore_*! attribute methods.
171 | def restore_attribute!(attr_name)
172 | attr_name = attr_name.to_s
173 | if attribute_changed?(attr_name)
174 | __send__("#{attr_name}=", attribute_was(attr_name))
175 | clear_attribute_change(attr_name)
176 | end
177 | end
178 | end
179 | end
180 | end
181 |
--------------------------------------------------------------------------------
/lib/active_entity/inheritance.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/core_ext/hash/indifferent_access"
4 |
5 | module ActiveEntity
6 | # == Single table inheritance
7 | #
8 | # Active Entity allows inheritance by storing the name of the class in a column that by
9 | # default is named "type" (can be changed by overwriting Base.inheritance_column).
10 | # This means that an inheritance looking like this:
11 | #
12 | # class Company < ActiveEntity::Base; end
13 | # class Firm < Company; end
14 | # class Client < Company; end
15 | # class PriorityClient < Client; end
16 | #
17 | # When you do Firm.create(name: "37signals"), this record will be saved in
18 | # the companies table with type = "Firm". You can then fetch this row again using
19 | # Company.where(name: '37signals').first and it will return a Firm object.
20 | #
21 | # Be aware that because the type column is an attribute on the record every new
22 | # subclass will instantly be marked as dirty and the type column will be included
23 | # in the list of changed attributes on the record. This is different from non
24 | # Single Table Inheritance(STI) classes:
25 | #
26 | # Company.new.changed? # => false
27 | # Firm.new.changed? # => true
28 | # Firm.new.changes # => {"type"=>["","Firm"]}
29 | #
30 | # If you don't have a type column defined in your table, single-table inheritance won't
31 | # be triggered. In that case, it'll work just like normal subclasses with no special magic
32 | # for differentiating between them or reloading the right type with find.
33 | #
34 | # Note, all the attributes for all the cases are kept in the same table. Read more:
35 | # https://www.martinfowler.com/eaaCatalog/singleTableInheritance.html
36 | #
37 | module Inheritance
38 | extend ActiveSupport::Concern
39 |
40 | module ClassMethods
41 | # Determines if one of the attributes passed in is the inheritance column,
42 | # and if the inheritance column is attr accessible, it initializes an
43 | # instance of the given subclass instead of the base class.
44 | def new(attributes = nil, &block)
45 | if abstract_class? || self == Base
46 | raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated."
47 | end
48 |
49 | super
50 | end
51 |
52 | # Returns +true+ if this does not need STI type condition. Returns
53 | # +false+ if STI type condition needs to be applied.
54 | def descends_from_active_entity?
55 | if self == Base
56 | false
57 | elsif superclass.abstract_class?
58 | superclass.descends_from_active_entity?
59 | else
60 | superclass == Base
61 | end
62 | end
63 |
64 | # Returns the class descending directly from ActiveEntity::Base, or
65 | # an abstract class, if any, in the inheritance hierarchy.
66 | #
67 | # If A extends ActiveEntity::Base, A.base_class will return A. If B descends from A
68 | # through some arbitrarily deep hierarchy, B.base_class will return A.
69 | #
70 | # If B < A and C < B and if A is an abstract_class then both B.base_class
71 | # and C.base_class would return B as the answer since A is an abstract_class.
72 | def base_class
73 | unless self < Base
74 | raise ActiveEntityError, "#{name} doesn't belong in a hierarchy descending from ActiveEntity"
75 | end
76 |
77 | if superclass == Base || superclass.abstract_class?
78 | self
79 | else
80 | superclass.base_class
81 | end
82 | end
83 |
84 | # Returns whether the class is a base class.
85 | # See #base_class for more information.
86 | def base_class?
87 | base_class == self
88 | end
89 |
90 | # Set this to +true+ if this is an abstract class (see
91 | # abstract_class?).
92 | # If you are using inheritance with Active Entity and don't want a class
93 | # to be considered as part of the STI hierarchy, you must set this to
94 | # true.
95 | # +ApplicationRecord+, for example, is generated as an abstract class.
96 | #
97 | # Consider the following default behaviour:
98 | #
99 | # Shape = Class.new(ActiveEntity::Base)
100 | # Polygon = Class.new(Shape)
101 | # Square = Class.new(Polygon)
102 | #
103 | # Shape.table_name # => "shapes"
104 | # Polygon.table_name # => "shapes"
105 | # Square.table_name # => "shapes"
106 | # Shape.create! # => #
107 | # Polygon.create! # => #
108 | # Square.create! # => #
109 | #
110 | # However, when using abstract_class, +Shape+ is omitted from
111 | # the hierarchy:
112 | #
113 | # class Shape < ActiveEntity::Base
114 | # self.abstract_class = true
115 | # end
116 | # Polygon = Class.new(Shape)
117 | # Square = Class.new(Polygon)
118 | #
119 | # Shape.table_name # => nil
120 | # Polygon.table_name # => "polygons"
121 | # Square.table_name # => "polygons"
122 | # Shape.create! # => NotImplementedError: Shape is an abstract class and cannot be instantiated.
123 | # Polygon.create! # => #
124 | # Square.create! # => #
125 | #
126 | # Note that in the above example, to disallow the creation of a plain
127 | # +Polygon+, you should use validates :type, presence: true,
128 | # instead of setting it as an abstract class. This way, +Polygon+ will
129 | # stay in the hierarchy, and Active Entity will continue to correctly
130 | # derive the table name.
131 | attr_accessor :abstract_class
132 |
133 | # Returns whether this class is an abstract class or not.
134 | def abstract_class?
135 | defined?(@abstract_class) && @abstract_class == true
136 | end
137 |
138 | def inherited(subclass)
139 | subclass.instance_variable_set(:@_type_candidates_cache, Concurrent::Map.new)
140 | super
141 | end
142 |
143 | protected
144 |
145 | # Returns the class type of the record using the current module as a prefix. So descendants of
146 | # MyApp::Business::Account would appear as MyApp::Business::AccountSubclass.
147 | def compute_type(type_name)
148 | if type_name.start_with?("::")
149 | # If the type is prefixed with a scope operator then we assume that
150 | # the type_name is an absolute reference.
151 | ActiveSupport::Dependencies.constantize(type_name)
152 | else
153 | type_candidate = @_type_candidates_cache[type_name]
154 | if type_candidate && type_constant = type_candidate.safe_constantize
155 | return type_constant
156 | end
157 |
158 | # Build a list of candidates to search for
159 | candidates = []
160 | name.scan(/::|$/) { candidates.unshift "#{$`}::#{type_name}" }
161 | candidates << type_name
162 |
163 | candidates.each do |candidate|
164 | constant = candidate.safe_constantize
165 | if candidate == constant.to_s
166 | @_type_candidates_cache[type_name] = candidate
167 | return constant
168 | end
169 | end
170 |
171 | raise NameError.new("uninitialized constant #{candidates.first}", candidates.first)
172 | end
173 | end
174 | end
175 | end
176 | end
177 |
--------------------------------------------------------------------------------
/lib/active_entity/associations.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/core_ext/enumerable"
4 | require "active_support/core_ext/string/conversions"
5 |
6 | module ActiveEntity
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 | class InverseOfAssociationNotFoundError < ActiveEntityError #:nodoc:
18 | def initialize(reflection = nil, associated_class = nil)
19 | if reflection
20 | super("Could not find the inverse association for #{reflection.name} (#{reflection.options[:inverse_of].inspect} in #{associated_class.nil? ? reflection.class_name : associated_class.name})")
21 | else
22 | super("Could not find the inverse association.")
23 | end
24 | end
25 | end
26 |
27 | # See ActiveEntity::Associations::ClassMethods for documentation.
28 | module Associations # :nodoc:
29 | extend ActiveSupport::Autoload
30 | extend ActiveSupport::Concern
31 | # These classes will be loaded when associations are created.
32 | # So there is no need to eager load them.
33 | module Embeds
34 | extend ActiveSupport::Autoload
35 |
36 | autoload :Association, "active_entity/associations/embeds/association"
37 | autoload :SingularAssociation, "active_entity/associations/embeds/singular_association"
38 | autoload :CollectionAssociation, "active_entity/associations/embeds/collection_association"
39 | autoload :CollectionProxy, "active_entity/associations/embeds/collection_proxy"
40 |
41 | module Builder #:nodoc:
42 | autoload :Association, "active_entity/associations/embeds/builder/association"
43 | autoload :SingularAssociation, "active_entity/associations/embeds/builder/singular_association"
44 | autoload :CollectionAssociation, "active_entity/associations/embeds/builder/collection_association"
45 |
46 | autoload :EmbeddedIn, "active_entity/associations/embeds/builder/embedded_in"
47 | autoload :EmbedsOne, "active_entity/associations/embeds/builder/embeds_one"
48 | autoload :EmbedsMany, "active_entity/associations/embeds/builder/embeds_many"
49 | end
50 |
51 | eager_autoload do
52 | autoload :EmbeddedInAssociation
53 | autoload :EmbedsOneAssociation
54 | autoload :EmbedsManyAssociation
55 | end
56 | end
57 |
58 | def self.eager_load!
59 | super
60 | Embeds.eager_load!
61 | end
62 | # Returns the association instance for the given name, instantiating it if it doesn't already exist
63 | def association(name) #:nodoc:
64 | association = association_instance_get(name)
65 |
66 | if association.nil?
67 | unless reflection = self.class._reflect_on_association(name)
68 | raise AssociationNotFoundError.new(self, name)
69 | end
70 | association = reflection.association_class.new(self, reflection)
71 | association_instance_set(name, association)
72 | end
73 |
74 | association
75 | end
76 |
77 | def association_cached?(name) # :nodoc:
78 | @association_cache.key?(name)
79 | end
80 |
81 | def initialize_dup(*) # :nodoc:
82 | @association_cache = {}
83 | super
84 | end
85 |
86 | private
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 | # \Associations are a set of macro-like class methods for tying objects together through
104 | # foreign keys. They express relationships like "Project has one Project Manager"
105 | # or "Project belongs to a Portfolio". Each macro adds a number of methods to the
106 | # class which are specialized according to the collection or association symbol and the
107 | # options hash. It works much the same way as Ruby's own attr*
108 | # methods.
109 | #
110 | # class Project < ActiveEntity::Base
111 | # belongs_to :portfolio
112 | # has_one :project_manager
113 | # has_many :milestones
114 | # has_and_belongs_to_many :categories
115 | # end
116 | #
117 | # The project class now has the following methods (and more) to ease the traversal and
118 | # manipulation of its relationships:
119 | # * Project#portfolio, Project#portfolio=(portfolio), Project#reload_portfolio
120 | # * Project#project_manager, Project#project_manager=(project_manager), Project#reload_project_manager
121 | # * Project#milestones.empty?, Project#milestones.size, Project#milestones, Project#milestones<<(milestone),
122 | # Project#milestones.delete(milestone), Project#milestones.destroy(milestone), Project#milestones.find(milestone_id),
123 | # Project#milestones.build, Project#milestones.create
124 | # * Project#categories.empty?, Project#categories.size, Project#categories, Project#categories<<(category1),
125 | # Project#categories.delete(category1), Project#categories.destroy(category1)
126 | #
127 | # === A word of warning
128 | #
129 | # Don't create associations that have the same name as {instance methods}[rdoc-ref:ActiveEntity::Core] of
130 | # ActiveEntity::Base. Since the association adds a method with that name to
131 | # its model, using an association with the same name as one provided by ActiveEntity::Base will override the method inherited through ActiveEntity::Base and will break things.
132 | # For instance, +attributes+ and +connection+ would be bad choices for association names, because those names already exist in the list of ActiveEntity::Base instance methods.
133 | module ClassMethods
134 | def embedded_in(name, **options)
135 | reflection = Embeds::Builder::EmbeddedIn.build(self, name, options)
136 | Reflection.add_reflection self, name, reflection
137 | end
138 |
139 | def embeds_one(name, **options)
140 | reflection = Embeds::Builder::EmbedsOne.build(self, name, options)
141 | Reflection.add_reflection self, name, reflection
142 | end
143 |
144 | def embeds_many(name, **options)
145 | reflection = Embeds::Builder::EmbedsMany.build(self, name, options)
146 | Reflection.add_reflection self, name, reflection
147 | end
148 |
149 | def association_names
150 | @association_names ||=
151 | if !abstract_class?
152 | reflections.keys.map(&:to_sym)
153 | else
154 | []
155 | end
156 | end
157 |
158 | def embeds_association_names
159 | @association_names ||=
160 | if !abstract_class?
161 | reflections.select { |_, r| r.embedded? }.keys.map(&:to_sym)
162 | else
163 | []
164 | end
165 | end
166 | end
167 | end
168 | end
169 |
--------------------------------------------------------------------------------
/lib/active_entity/associations/embeds/collection_association.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveEntity
4 | module Associations
5 | module Embeds
6 | # = Active Entity Association Collection
7 | #
8 | # CollectionAssociation is an abstract class that provides common stuff to
9 | # ease the implementation of association proxies that represent
10 | # collections. See the class hierarchy in Association.
11 | #
12 | # CollectionAssociation:
13 | # HasManyAssociation => has_many
14 | # HasManyThroughAssociation + ThroughAssociation => has_many :through
15 | #
16 | # The CollectionAssociation class provides common methods to the collections
17 | # defined by +has_and_belongs_to_many+, +has_many+ or +has_many+ with
18 | # the +:through association+ option.
19 | #
20 | # You need to be careful with assumptions regarding the target: The proxy
21 | # does not fetch records from the database until it needs them, but new
22 | # ones created with +build+ are added to the target. So, the target may be
23 | # non-empty and still lack children waiting to be read from the database.
24 | # If you look directly to the database you cannot assume that's the entire
25 | # collection because new records may have been added to the target, etc.
26 | #
27 | # If you need to work on all current children, new and existing records,
28 | # +load_target+ and the +loaded+ flag are your friends.
29 | class CollectionAssociation < Association #:nodoc:
30 | def initialize(owner, reflection)
31 | super
32 |
33 | @target = []
34 | end
35 |
36 | # Implements the reader method, e.g. foo.items for Foo.has_many :items
37 | def reader
38 | @proxy ||= CollectionProxy.new(klass, self)
39 | end
40 |
41 | # Implements the writer method, e.g. foo.items= for Foo.has_many :items
42 | def writer(records)
43 | replace(records)
44 | end
45 |
46 | def find(&block)
47 | target.find(&block)
48 | end
49 |
50 | def build(attributes = {}, &block)
51 | if attributes.is_a?(Array)
52 | attributes.collect { |attr| build(attr, &block) }
53 | else
54 | add_to_target(build_record(attributes, &block))
55 | end
56 | end
57 |
58 | # Add +records+ to this association. Returns +self+ so method calls may
59 | # be chained. Since << flattens its argument list and inserts each record,
60 | # +push+ and +concat+ behave identically.
61 | def concat(*records)
62 | records = records.flatten
63 | concat_records(records)
64 | end
65 |
66 | # Removes all records from the association without calling callbacks
67 | # on the associated records. It honors the +:dependent+ option. However
68 | # if the +:dependent+ value is +:destroy+ then in that case the +:delete_all+
69 | # deletion strategy for the association is applied.
70 | #
71 | # You can force a particular deletion strategy by passing a parameter.
72 | #
73 | # Example:
74 | #
75 | # @author.books.delete_all(:nullify)
76 | # @author.books.delete_all(:delete_all)
77 | #
78 | # See delete for more info.
79 | def delete_all
80 | target.clear
81 | end
82 | alias destroy_all delete_all
83 |
84 | # Removes +records+ from this association calling +before_remove+ and
85 | # +after_remove+ callbacks.
86 | #
87 | # This method is abstract in the sense that +delete_records+ has to be
88 | # provided by descendants. Note this method does not imply the records
89 | # are actually removed from the database, that depends precisely on
90 | # +delete_records+. They are in any case removed from the collection.
91 | def delete(*records)
92 | records.each do |record|
93 | target.delete(record)
94 | end
95 | end
96 | alias destroy delete
97 |
98 | # Returns the size of the collection by executing a SELECT COUNT(*)
99 | # query if the collection hasn't been loaded, and calling
100 | # collection.size if it has.
101 | #
102 | # If the collection has been already loaded +size+ and +length+ are
103 | # equivalent. If not and you are going to need the records anyway
104 | # +length+ will take one less query. Otherwise +size+ is more efficient.
105 | #
106 | # This method is abstract in the sense that it relies on
107 | # +count_records+, which is a method descendants have to provide.
108 | def size
109 | target.size
110 | end
111 |
112 | # Returns true if the collection is empty.
113 | #
114 | # If the collection has been loaded
115 | # it is equivalent to collection.size.zero?. If the
116 | # collection has not been loaded, it is equivalent to
117 | # collection.exists?. If the collection has not already been
118 | # loaded and you are going to fetch the records anyway it is better to
119 | # check collection.length.zero?.
120 | def empty?
121 | size.zero?
122 | end
123 |
124 | # Replace this collection with +other_array+. This will perform a diff
125 | # and delete/add only records that have changed.
126 | def replace(other_array)
127 | records = other_array.map do |val|
128 | if val.is_a? reflection.klass
129 | val
130 | elsif val.nil?
131 | next
132 | elsif val.respond_to?(:to_h)
133 | build_record(val.to_h)
134 | end
135 | rescue => ex
136 | raise_on_type_mismatch!(val)
137 | raise ex
138 | end
139 |
140 | target.replace records
141 | end
142 |
143 | def include?(record)
144 | if record.is_a?(reflection.klass)
145 | target.include?(record)
146 | else
147 | false
148 | end
149 | end
150 |
151 | def add_to_target(record, skip_callbacks = false, &block)
152 | # index = @target.index(record)
153 | # replace_on_target(record, index, skip_callbacks, &block)
154 | replace_on_target(record, nil, skip_callbacks, &block)
155 | end
156 |
157 | private
158 |
159 | def concat_records(records)
160 | records.each do |record|
161 | r =
162 | if record.is_a? reflection.klass
163 | record
164 | elsif record.nil?
165 | nil
166 | elsif record.respond_to?(:to_h)
167 | build_record(record.to_h)
168 | end
169 | add_to_target(r)
170 | rescue => ex
171 | raise_on_type_mismatch!(record)
172 | raise ex
173 | end
174 |
175 | records
176 | end
177 |
178 | def replace_on_target(record, index, skip_callbacks)
179 | callback(:before_add, record) unless skip_callbacks
180 |
181 | set_inverse_instance(record)
182 |
183 | yield(record) if block_given?
184 |
185 | if index
186 | target[index] = record
187 | else
188 | target << record
189 | end
190 |
191 | callback(:after_add, record) unless skip_callbacks
192 |
193 | record
194 | end
195 |
196 | def callback(method, record)
197 | callbacks_for(method).each do |callback|
198 | callback.call(method, owner, record)
199 | end
200 | end
201 |
202 | def callbacks_for(callback_name)
203 | full_callback_name = "#{callback_name}_for_#{reflection.name}"
204 | owner.class.send(full_callback_name)
205 | end
206 | end
207 | end
208 | end
209 | end
210 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Active Entity
2 | ====
3 |
4 | Active Entity is a Rails virtual model solution based on ActiveModel and it's design for Rails 6+.
5 |
6 | Active Entity is forked from Active Record by removing all database relates codes, so it nearly no need to learn how to use.
7 |
8 | ## About Virtual Model
9 |
10 | Virtual Model is the model not backed by a database table, usually used as "form model" or "presenter", because it's implement interfaces of Active Model, so you can use it like a normal Active Record model in your Rails app.
11 |
12 | ## Features
13 |
14 | ### Attribute declaration
15 |
16 | ```ruby
17 | class Book < ActiveEntity::Base
18 | attribute :title, :string
19 | attribute :tags, :string, array: true, default: []
20 | end
21 | ```
22 |
23 | Same usage with Active Record, [Learn more](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-attribute).
24 |
25 | One enhancement is `array: true` that transform the attribute to an array that can accept multiple values.
26 |
27 | ### Nested attributes
28 |
29 | Active Entity supports its own variant of nested attributes via the `embeds_one` / `embeds_many` macros. The intention is to be mostly compatible with ActiveRecord's `accepts_nested_attributes_for` functionality.
30 |
31 | ```ruby
32 | class Holiday < ActiveEntity::Base
33 | attribute :date, :date
34 | validates :date, presence: true
35 | end
36 |
37 | class HolidaysForm < ActiveEntity::Base
38 | embeds_many :holidays
39 | accepts_nested_attributes_for :holidays, reject_if: :all_blank
40 | end
41 | ```
42 |
43 | ### Validations
44 |
45 | ```ruby
46 | class Book < ActiveEntity::Base
47 | attribute :title, :string
48 | validates :title, presence: true
49 | end
50 | ```
51 |
52 | Supported Active Record validations:
53 |
54 | - [acceptance](https://guides.rubyonrails.org/active_record_validations.html#acceptance)
55 | - [confirmation](https://guides.rubyonrails.org/active_record_validations.html#confirmation)
56 | - [exclusion](https://guides.rubyonrails.org/active_record_validations.html#exclusion)
57 | - [format](https://guides.rubyonrails.org/active_record_validations.html#format)
58 | - [inclusion](https://guides.rubyonrails.org/active_record_validations.html#inclusion)
59 | - [length](https://guides.rubyonrails.org/active_record_validations.html#length)
60 | - [numericality](https://guides.rubyonrails.org/active_record_validations.html#numericality)
61 | - [presence](https://guides.rubyonrails.org/active_record_validations.html#presence)
62 | - [absence](https://guides.rubyonrails.org/active_record_validations.html#absence)
63 |
64 | [Common validation options](https://guides.rubyonrails.org/active_record_validations.html#common-validation-options) supported too.
65 |
66 | #### `subset` validation
67 |
68 | Because Active Entity supports array attribute, for some reason, you may want to test values of an array attribute are all included in a given set.
69 |
70 | Active Entity provides `subset` validation to achieve that, it usage similar to `inclusion` or `exclusion`
71 |
72 | ```ruby
73 | class Steak < ActiveEntity::Base
74 | attribute :side_dishes, :string, array: true, default: []
75 | validates :side_dishes, subset: { in: %w(chips mashed_potato salad) }
76 | end
77 | ```
78 |
79 | #### `uniqueness_in_embeds` validation
80 |
81 | Active Entity provides `uniqueness_in_embeds` validation to test duplicate nesting virtual record.
82 |
83 | Argument `key` is attribute name of nested model, it also supports multiple attributes by given an array.
84 |
85 | ```ruby
86 | class Category < ActiveEntity::Base
87 | attribute :name, :string
88 | end
89 |
90 | class Reviewer < ActiveEntity::Base
91 | attribute :first_name, :string
92 | attribute :last_name, :string
93 | end
94 |
95 | class Book < ActiveEntity::Base
96 | embeds_many :categories, index_errors: true
97 | validates :categories, uniqueness_in_embeds: {key: :name}
98 |
99 | embeds_many :reviewers
100 | validates :reviewers, uniqueness_in_embeds: {key: [:first_name, :last_name]}
101 | end
102 | ```
103 |
104 | #### `uniqueness_in_active_record` validation
105 |
106 | Active Entity provides `uniqueness_in_active_record` validation to test given `scope` doesn't present in ActiveRecord model.
107 |
108 | The usage same as [uniqueness](https://guides.rubyonrails.org/active_record_validations.html#uniqueness) in addition you must give a AR model `class_name`
109 |
110 | ```ruby
111 | class Candidate < ActiveEntity::Base
112 | attribute :name, :string
113 |
114 | validates :name,
115 | uniqueness_on_active_record: {
116 | class_name: "Staff"
117 | }
118 | end
119 | ```
120 |
121 | ### Others
122 |
123 | These Active Record feature also available in Active Entity
124 |
125 | - [`composed_of`](https://api.rubyonrails.org/classes/ActiveRecord/Aggregations/ClassMethods.html)
126 | - [`serializable_hash`](https://api.rubyonrails.org/classes/ActiveModel/Serialization.html#method-i-serializable_hash)
127 | - [`serialize`](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html#method-i-serialize)
128 | - [`store`](https://api.rubyonrails.org/classes/ActiveRecord/Store.html)
129 |
130 | #### I18n
131 |
132 | Same to [Active Record I18n](https://guides.rubyonrails.org/i18n.html#translations-for-active-record-models), the only different is the root of locale YAML is `active_entity` instead of `activerecord`
133 |
134 | #### Enum
135 |
136 | You can use the `enum` class method to define a set of possible values for an attribute. It is similar to the `enum` functionality in Active Model, but has significant enough quirks that you should think of them as distinct.
137 |
138 | ```rb
139 | class Example < ActiveEntity::Base
140 | attribute :steve, :integer
141 | enum steve: [:martin, :carell, :buscemi]
142 | end
143 |
144 | example = Example.new
145 | example.attributes # => {"steve"=>nil}
146 | example.steve = :carell
147 | example.carell? # => true
148 | example.attributes # => {"steve"=>"carell"}
149 | example.steve = 2
150 | example.attributes # => {"steve"=>"buscemi"}
151 |
152 | # IMPORTANT: the next line will only work if you implement an update! method
153 | example.martin! # => {"steve"=>"martin"}
154 |
155 | example.steve = :bannon # ArgumentError ('bannon' is not a valid steve)
156 | ```
157 |
158 | The first thing you'll notice about the `:steve` attribute is that it is an "Integer", even though it might seem logical to define it as a String... TL;DR: don't do this. Internally enum tracks the possible values based on their index position in the array.
159 |
160 | It's also possible to provide a Hash of possible values:
161 |
162 | ```rb
163 | class Example < ActiveEntity::Base
164 | attribute :steve, :integer, default: 9
165 | enum steve: {martin: 5, carell: 12, buscemi: 9}
166 | end
167 |
168 | example = Example.new
169 | example.attributes # => {"steve"=>"buscemi"}
170 | ```
171 |
172 | The other quirk of this implementation is that you must create your attribute before you call enum.
173 | enum does not create the search scopes that might be familar to Active Model users, since there is no search or where concept in Active Entity. You can, however, access the mapping directly to obtain the index number for a given value:
174 |
175 | ```rb
176 | Example.steves[:buscemi] # => 9
177 | ```
178 |
179 | You can define prefixes and suffixes for your `enum` attributes. Note the underscores:
180 |
181 | ```rb
182 | class Conversation < ActiveEntity::Base
183 | attribute :status, :integer
184 | attribute :comments_status, :integer
185 | enum status: [ :active, :archived ], _suffix: true
186 | enum comments_status: [ :active, :inactive ], _prefix: :comments
187 | end
188 |
189 | conversation = Conversation.new
190 | conversation.active_status! # only if you have an update! method
191 | conversation.archived_status? # => false
192 |
193 | conversation.comments_inactive! # only if you have an update! method
194 | conversation.comments_active? # => false
195 | ```
196 |
197 | #### Read-only attributes
198 |
199 | You can use `attr_readonly :title, :author` to prevent assign value to attribute after initialized.
200 |
201 | You can use `enable_readonly!` and `disable_readonly!` to control the behavior.
202 |
203 | **Important: It's no effect with embeds or array attributes !!!**
204 |
205 | ## Extending
206 |
207 | Most of Active Model plugins are compatible with Active Entity.
208 |
209 | You need to include them manually.
210 |
211 | Tested extensions:
212 |
213 | - [adzap/validates_timeliness](https://github.com/adzap/validates_timeliness)
214 |
215 | ## Installation
216 |
217 | Add this line to your application's Gemfile:
218 |
219 | ```ruby
220 | gem 'activeentity', require: "active_entity/railtie"
221 | ```
222 |
223 | And then execute:
224 | ```bash
225 | $ bundle
226 | ```
227 |
228 | Or install it yourself as:
229 | ```bash
230 | $ gem install activeentity
231 | ```
232 |
233 | ## Other awesome gems
234 |
235 | - [makandra/active_type](https://github.com/makandra/active_type)
236 |
237 | ## Contributing
238 |
239 | - Fork the project.
240 | - Make your feature addition or bug fix.
241 | - Add tests for it. This is important so I don't break it in a future version unintentionally.
242 | - 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)
243 | - Send me a pull request. Bonus points for topic branches.
244 |
245 | ## License
246 |
247 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
248 |
--------------------------------------------------------------------------------
/lib/active_entity/attributes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_model/attribute/user_provided_default"
4 |
5 | module ActiveEntity
6 | # See ActiveEntity::Attributes::ClassMethods for documentation
7 | module Attributes
8 | extend ActiveSupport::Concern
9 |
10 | included do
11 | class_attribute :attributes_to_define_after_schema_loads, instance_accessor: false, default: {} # :internal:
12 | end
13 |
14 | module ClassMethods
15 | # Defines an attribute with a type on this model. It will override the
16 | # type of existing attributes if needed. This allows control over how
17 | # values are converted to and from SQL when assigned to a model. It also
18 | # changes the behavior of values passed to
19 | # {ActiveEntity::Base.where}[rdoc-ref:QueryMethods#where]. This will let you use
20 | # your domain objects across much of Active Entity, without having to
21 | # rely on implementation details or monkey patching.
22 | #
23 | # +name+ The name of the methods to define attribute methods for, and the
24 | # column which this will persist to.
25 | #
26 | # +cast_type+ A symbol such as +:string+ or +:integer+, or a type object
27 | # to be used for this attribute. See the examples below for more
28 | # information about providing custom type objects.
29 | #
30 | # ==== Options
31 | #
32 | # The following options are accepted:
33 | #
34 | # +default+ The default value to use when no value is provided. If this option
35 | # is not passed, the previous default value (if any) will be used.
36 | # Otherwise, the default will be +nil+.
37 | #
38 | # +array+ (PostgreSQL only) specifies that the type should be an array (see the
39 | # examples below).
40 | #
41 | # +range+ (PostgreSQL only) specifies that the type should be a range (see the
42 | # examples below).
43 | #
44 | # When using a symbol for +cast_type+, extra options are forwarded to the
45 | # constructor of the type object.
46 | #
47 | # ==== Examples
48 | #
49 | # The type detected by Active Entity can be overridden.
50 | #
51 | # # db/schema.rb
52 | # create_table :store_listings, force: true do |t|
53 | # t.decimal :price_in_cents
54 | # end
55 | #
56 | # # app/models/store_listing.rb
57 | # class StoreListing < ActiveEntity::Base
58 | # end
59 | #
60 | # store_listing = StoreListing.new(price_in_cents: '10.1')
61 | #
62 | # # before
63 | # store_listing.price_in_cents # => BigDecimal(10.1)
64 | #
65 | # class StoreListing < ActiveEntity::Base
66 | # attribute :price_in_cents, :integer
67 | # end
68 | #
69 | # # after
70 | # store_listing.price_in_cents # => 10
71 | #
72 | # A default can also be provided.
73 | #
74 | # # db/schema.rb
75 | # create_table :store_listings, force: true do |t|
76 | # t.string :my_string, default: "original default"
77 | # end
78 | #
79 | # StoreListing.new.my_string # => "original default"
80 | #
81 | # # app/models/store_listing.rb
82 | # class StoreListing < ActiveEntity::Base
83 | # attribute :my_string, :string, default: "new default"
84 | # end
85 | #
86 | # StoreListing.new.my_string # => "new default"
87 | #
88 | # class Product < ActiveEntity::Base
89 | # attribute :my_default_proc, :datetime, default: -> { Time.now }
90 | # end
91 | #
92 | # Product.new.my_default_proc # => 2015-05-30 11:04:48 -0600
93 | # sleep 1
94 | # Product.new.my_default_proc # => 2015-05-30 11:04:49 -0600
95 | #
96 | # \Attributes do not need to be backed by a database column.
97 | #
98 | # # app/models/my_model.rb
99 | # class MyModel < ActiveEntity::Base
100 | # attribute :my_string, :string
101 | # attribute :my_int_array, :integer, array: true
102 | # attribute :my_float_range, :float, range: true
103 | # end
104 | #
105 | # model = MyModel.new(
106 | # my_string: "string",
107 | # my_int_array: ["1", "2", "3"],
108 | # my_float_range: "[1,3.5]",
109 | # )
110 | # model.attributes
111 | # # =>
112 | # {
113 | # my_string: "string",
114 | # my_int_array: [1, 2, 3],
115 | # my_float_range: 1.0..3.5
116 | # }
117 | #
118 | # Passing options to the type constructor
119 | #
120 | # # app/models/my_model.rb
121 | # class MyModel < ActiveEntity::Base
122 | # attribute :small_int, :integer, limit: 2
123 | # end
124 | #
125 | # MyModel.create(small_int: 65537)
126 | # # => Error: 65537 is out of range for the limit of two bytes
127 | #
128 | # ==== Creating Custom Types
129 | #
130 | # Users may also define their own custom types, as long as they respond
131 | # to the methods defined on the value type. The method +deserialize+ or
132 | # +cast+ will be called on your type object, with raw input from the
133 | # database or from your controllers. See ActiveModel::Type::Value for the
134 | # expected API. It is recommended that your type objects inherit from an
135 | # existing type, or from ActiveEntity::Type::Value
136 | #
137 | # class MoneyType < ActiveEntity::Type::Integer
138 | # def cast(value)
139 | # if !value.kind_of?(Numeric) && value.include?('$')
140 | # price_in_dollars = value.gsub(/\$/, '').to_f
141 | # super(price_in_dollars * 100)
142 | # else
143 | # super
144 | # end
145 | # end
146 | # end
147 | #
148 | # # config/initializers/types.rb
149 | # ActiveEntity::Type.register(:money, MoneyType)
150 | #
151 | # # app/models/store_listing.rb
152 | # class StoreListing < ActiveEntity::Base
153 | # attribute :price_in_cents, :money
154 | # end
155 | #
156 | # store_listing = StoreListing.new(price_in_cents: '$10.00')
157 | # store_listing.price_in_cents # => 1000
158 | #
159 | # For more details on creating custom types, see the documentation for
160 | # ActiveModel::Type::Value. For more details on registering your types
161 | # to be referenced by a symbol, see ActiveEntity::Type.register. You can
162 | # also pass a type object directly, in place of a symbol.
163 | #
164 | # ==== Dirty Tracking
165 | #
166 | # The type of an attribute is given the opportunity to change how dirty
167 | # tracking is performed. The methods +changed?+ and +changed_in_place?+
168 | # will be called from ActiveModel::Dirty. See the documentation for those
169 | # methods in ActiveModel::Type::Value for more details.
170 | def attribute(name, cast_type = Type::Value.new, **options)
171 | name = name.to_s
172 | reload_schema_from_cache
173 |
174 | self.attributes_to_define_after_schema_loads =
175 | attributes_to_define_after_schema_loads.merge(
176 | name => [cast_type, options]
177 | )
178 | end
179 |
180 | # This is the low level API which sits beneath +attribute+. It only
181 | # accepts type objects, and will do its work immediately instead of
182 | # waiting for the schema to load. Automatic schema detection and
183 | # ClassMethods#attribute both call this under the hood. While this method
184 | # is provided so it can be used by plugin authors, application code
185 | # should probably use ClassMethods#attribute.
186 | #
187 | # +name+ The name of the attribute being defined. Expected to be a +String+.
188 | #
189 | # +cast_type+ The type object to use for this attribute.
190 | #
191 | # +default+ The default value to use when no value is provided. If this option
192 | # is not passed, the previous default value (if any) will be used.
193 | # Otherwise, the default will be +nil+. A proc can also be passed, and
194 | # will be called once each time a new value is needed.
195 | #
196 | # +cast+ or +deserialize+.
197 | def define_attribute(
198 | name,
199 | cast_type,
200 | default: NO_DEFAULT_PROVIDED
201 | )
202 | attribute_types[name] = cast_type
203 | define_default_attribute(name, default, cast_type)
204 | define_attribute_method(name)
205 | end
206 |
207 | def load_schema! # :nodoc:
208 | super
209 | attributes_to_define_after_schema_loads.each do |name, (type, options)|
210 | if type.is_a?(Symbol)
211 | type = ActiveEntity::Type.lookup(type, **options.except(:default))
212 | end
213 |
214 | define_attribute(name, type, **options.slice(:default))
215 | end
216 | end
217 |
218 | private
219 |
220 | NO_DEFAULT_PROVIDED = Object.new # :nodoc:
221 | private_constant :NO_DEFAULT_PROVIDED
222 |
223 | def define_default_attribute(name, value, type)
224 | default_attribute =
225 | if value == NO_DEFAULT_PROVIDED
226 | _default_attributes[name].with_type(type)
227 | else
228 | ActiveModel::Attribute::UserProvidedDefault.new(
229 | name,
230 | value,
231 | type,
232 | _default_attributes.fetch(name.to_s) { nil },
233 | )
234 | end
235 |
236 | _default_attributes[name] = default_attribute
237 | end
238 | end
239 | end
240 | end
241 |
--------------------------------------------------------------------------------
/lib/active_entity/enum.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/core_ext/object/deep_dup"
4 |
5 | module ActiveEntity
6 | # Declare an enum attribute where the values map to integers in the database,
7 | # but can be queried by name. Example:
8 | #
9 | # class Conversation < ActiveEntity::Base
10 | # enum status: [ :active, :archived ]
11 | # end
12 | #
13 | # # conversation.update! status: 0
14 | # conversation.active!
15 | # conversation.active? # => true
16 | # conversation.status # => "active"
17 | #
18 | # # conversation.update! status: 1
19 | # conversation.archived!
20 | # conversation.archived? # => true
21 | # conversation.status # => "archived"
22 | #
23 | # # conversation.status = 1
24 | # conversation.status = "archived"
25 | #
26 | # conversation.status = nil
27 | # conversation.status.nil? # => true
28 | # conversation.status # => nil
29 | #
30 | # Scopes based on the allowed values of the enum field will be provided
31 | # as well. With the above example:
32 | #
33 | # Conversation.active
34 | # Conversation.not_active
35 | # Conversation.archived
36 | # Conversation.not_archived
37 | #
38 | # Of course, you can also query them directly if the scopes don't fit your
39 | # needs:
40 | #
41 | # Conversation.where(status: [:active, :archived])
42 | # Conversation.where.not(status: :active)
43 | #
44 | # Defining scopes can be disabled by setting +:_scopes+ to +false+.
45 | #
46 | # class Conversation < ActiveEntity::Base
47 | # enum status: [ :active, :archived ], _scopes: false
48 | # end
49 | #
50 | # You can set the default value from the database declaration, like:
51 | #
52 | # create_table :conversations do |t|
53 | # t.column :status, :integer, default: 0
54 | # end
55 | #
56 | # Good practice is to let the first declared status be the default.
57 | #
58 | # Finally, it's also possible to explicitly map the relation between attribute and
59 | # database integer with a hash:
60 | #
61 | # class Conversation < ActiveEntity::Base
62 | # enum status: { active: 0, archived: 1 }
63 | # end
64 | #
65 | # Note that when an array is used, the implicit mapping from the values to database
66 | # integers is derived from the order the values appear in the array. In the example,
67 | # :active is mapped to +0+ as it's the first element, and :archived
68 | # is mapped to +1+. In general, the +i+-th element is mapped to i-1 in the
69 | # database.
70 | #
71 | # Therefore, once a value is added to the enum array, its position in the array must
72 | # be maintained, and new values should only be added to the end of the array. To
73 | # remove unused values, the explicit hash syntax should be used.
74 | #
75 | # In rare circumstances you might need to access the mapping directly.
76 | # The mappings are exposed through a class method with the pluralized attribute
77 | # name, which return the mapping in a +HashWithIndifferentAccess+:
78 | #
79 | # Conversation.statuses[:active] # => 0
80 | # Conversation.statuses["archived"] # => 1
81 | #
82 | # Use that class method when you need to know the ordinal value of an enum.
83 | # For example, you can use that when manually building SQL strings:
84 | #
85 | # Conversation.where("status <> ?", Conversation.statuses[:archived])
86 | #
87 | # You can use the +:_prefix+ or +:_suffix+ options when you need to define
88 | # multiple enums with same values. If the passed value is +true+, the methods
89 | # are prefixed/suffixed with the name of the enum. It is also possible to
90 | # supply a custom value:
91 | #
92 | # class Conversation < ActiveEntity::Base
93 | # enum status: [:active, :archived], _suffix: true
94 | # enum comments_status: [:active, :inactive], _prefix: :comments
95 | # end
96 | #
97 | # With the above example, the bang and predicate methods along with the
98 | # associated scopes are now prefixed and/or suffixed accordingly:
99 | #
100 | # conversation.active_status!
101 | # conversation.archived_status? # => false
102 | #
103 | # conversation.comments_inactive!
104 | # conversation.comments_active? # => false
105 |
106 | module Enum
107 | def self.extended(base) # :nodoc:
108 | base.class_attribute(:defined_enums, instance_writer: false, default: {})
109 | end
110 |
111 | def inherited(base) # :nodoc:
112 | base.defined_enums = defined_enums.deep_dup
113 | super
114 | end
115 |
116 | class EnumType < Type::Value # :nodoc:
117 | delegate :type, to: :subtype
118 |
119 | def initialize(name, mapping, subtype)
120 | @name = name
121 | @mapping = mapping
122 | @subtype = subtype
123 | end
124 |
125 | def cast(value)
126 | if mapping.has_key?(value)
127 | value.to_s
128 | elsif mapping.has_value?(value)
129 | mapping.key(value)
130 | elsif value.blank?
131 | nil
132 | else
133 | assert_valid_value(value)
134 | end
135 | end
136 |
137 | def deserialize(value)
138 | return if value.nil?
139 | mapping.key(subtype.deserialize(value))
140 | end
141 |
142 | def serialize(value)
143 | mapping.fetch(value, value)
144 | end
145 |
146 | def assert_valid_value(value)
147 | unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value)
148 | raise ArgumentError, "'#{value}' is not a valid #{name}"
149 | end
150 | end
151 |
152 | private
153 |
154 | attr_reader :name, :mapping, :subtype
155 | end
156 |
157 | def enum(definitions)
158 | klass = self
159 | enum_prefix = definitions.delete(:_prefix)
160 | enum_suffix = definitions.delete(:_suffix)
161 | definitions.each do |name, values|
162 | assert_valid_enum_definition_values(values)
163 | # statuses = { }
164 | enum_values = ActiveSupport::HashWithIndifferentAccess.new
165 | name = name.to_s
166 |
167 | # def self.statuses() statuses end
168 | detect_enum_conflict!(name, name.pluralize, true)
169 | singleton_class.define_method(name.pluralize) { enum_values }
170 | defined_enums[name] = enum_values
171 |
172 | detect_enum_conflict!(name, name)
173 | detect_enum_conflict!(name, "#{name}=")
174 |
175 | attr = attribute_alias?(name) ? attribute_alias(name) : name
176 | decorate_attribute_type(attr, :enum) do |subtype|
177 | EnumType.new(attr, enum_values, subtype)
178 | end
179 |
180 | _enum_methods_module.module_eval do
181 | pairs = values.respond_to?(:each_pair) ? values.each_pair : values.each_with_index
182 | pairs.each do |label, value|
183 | if enum_prefix == true
184 | prefix = "#{name}_"
185 | elsif enum_prefix
186 | prefix = "#{enum_prefix}_"
187 | end
188 | if enum_suffix == true
189 | suffix = "_#{name}"
190 | elsif enum_suffix
191 | suffix = "_#{enum_suffix}"
192 | end
193 |
194 | value_method_name = "#{prefix}#{label}#{suffix}"
195 | enum_values[label] = value
196 | label = label.to_s
197 |
198 | # def active?() status == "active" end
199 | klass.send(:detect_enum_conflict!, name, "#{value_method_name}?")
200 | define_method("#{value_method_name}?") { self[attr] == label }
201 |
202 | # def active!() update!(status: 0) end
203 | klass.send(:detect_enum_conflict!, name, "#{value_method_name}!")
204 | define_method("#{value_method_name}!") { update!(attr => value) }
205 | end
206 | end
207 | enum_values.freeze
208 | end
209 | end
210 |
211 | private
212 |
213 | def _enum_methods_module
214 | @_enum_methods_module ||= begin
215 | mod = Module.new
216 | include mod
217 | mod
218 | end
219 | end
220 |
221 | def assert_valid_enum_definition_values(values)
222 | unless values.is_a?(Hash) || values.all? { |v| v.is_a?(Symbol) } || values.all? { |v| v.is_a?(String) }
223 | error_message = <<~MSG
224 | Enum values #{values} must be either a hash, an array of symbols, or an array of strings.
225 | MSG
226 | raise ArgumentError, error_message
227 | end
228 |
229 | if values.is_a?(Hash) && values.keys.any?(&:blank?) || values.is_a?(Array) && values.any?(&:blank?)
230 | raise ArgumentError, "Enum label name must not be blank."
231 | end
232 | end
233 |
234 | ENUM_CONFLICT_MESSAGE = \
235 | "You tried to define an enum named \"%{enum}\" on the model \"%{klass}\", but " \
236 | "this will generate a %{type} method \"%{method}\", which is already defined " \
237 | "by %{source}."
238 | private_constant :ENUM_CONFLICT_MESSAGE
239 |
240 | def detect_enum_conflict!(enum_name, method_name, klass_method = false)
241 | if klass_method && dangerous_class_method?(method_name)
242 | raise_conflict_error(enum_name, method_name, type: "class")
243 | elsif !klass_method && dangerous_attribute_method?(method_name)
244 | raise_conflict_error(enum_name, method_name)
245 | elsif !klass_method && method_defined_within?(method_name, _enum_methods_module, Module)
246 | raise_conflict_error(enum_name, method_name, source: "another enum")
247 | end
248 | end
249 |
250 | def raise_conflict_error(enum_name, method_name, type: "instance", source: "Active Entity")
251 | raise ArgumentError, ENUM_CONFLICT_MESSAGE % {
252 | enum: enum_name,
253 | klass: name,
254 | type: type,
255 | method: method_name,
256 | source: source
257 | }
258 | end
259 | end
260 | end
261 |
--------------------------------------------------------------------------------