├── 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 | --------------------------------------------------------------------------------