├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .simplecov ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── MIT-LICENSE ├── README.md ├── Rakefile ├── lib ├── superstore.rb └── superstore │ ├── adapters │ ├── abstract_adapter.rb │ └── jsonb_adapter.rb │ ├── associations.rb │ ├── associations │ ├── association.rb │ ├── association_scope.rb │ ├── belongs_to.rb │ ├── builder │ │ ├── association.rb │ │ ├── belongs_to.rb │ │ ├── has_many.rb │ │ └── has_one.rb │ ├── has_many.rb │ ├── has_one.rb │ └── reflection.rb │ ├── attribute_assignment.rb │ ├── attribute_methods.rb │ ├── attribute_methods │ └── primary_key.rb │ ├── attributes.rb │ ├── base.rb │ ├── core.rb │ ├── identity.rb │ ├── model_schema.rb │ ├── persistence.rb │ ├── railtie.rb │ ├── relation │ └── scrolling.rb │ ├── types.rb │ └── types │ ├── array_type.rb │ ├── base.rb │ ├── boolean_type.rb │ ├── date_range_type.rb │ ├── date_type.rb │ ├── float_type.rb │ ├── geo_point_type.rb │ ├── integer_range_type.rb │ ├── integer_type.rb │ ├── json_type.rb │ ├── range_type.rb │ ├── string_type.rb │ └── time_type.rb ├── superstore.gemspec └── test ├── support ├── jsonb.rb ├── models.rb └── pg.rb ├── test_helper.rb └── unit ├── active_model_test.rb ├── adapters └── adapter_test.rb ├── associations ├── belongs_to_test.rb ├── has_many_test.rb ├── has_one_test.rb └── reflection_test.rb ├── attribute_methods ├── dirty_test.rb └── primary_key_test.rb ├── attribute_methods_test.rb ├── attributes_test.rb ├── base_test.rb ├── caching_test.rb ├── callbacks_test.rb ├── connection_test.rb ├── core_test.rb ├── identity_test.rb ├── persistence_test.rb ├── relation └── scrolling_test.rb ├── serialization_test.rb ├── timestamp_test.rb ├── types ├── array_type_test.rb ├── boolean_type_test.rb ├── date_range_type_test.rb ├── date_type_test.rb ├── float_type_test.rb ├── geo_point_type_test.rb ├── integer_range_type_test.rb ├── integer_type_test.rb ├── json_type_test.rb ├── string_type_test.rb └── time_type_test.rb └── validations_test.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | jobs: 8 | test: 9 | runs-on: ubuntu-20.04 10 | strategy: 11 | matrix: 12 | ruby: ['3.1'] 13 | env: 14 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 15 | services: 16 | postgres: 17 | image: postgres:12.8 18 | options: >- 19 | --health-cmd pg_isready 20 | --health-interval 10s 21 | --health-timeout 5s 22 | --health-retries 5 23 | ports: 24 | - 5432:5432 25 | env: 26 | POSTGRES_USER: runner 27 | POSTGRES_HOST_AUTH_METHOD: trust 28 | steps: 29 | - name: Checkout the repo 30 | uses: actions/checkout@v2 31 | - name: Install Ruby, bundler and the bundle 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby }} 35 | bundler-cache: true 36 | - name: Run tests 37 | run: bundle exec rake 38 | - name: Publish code coverage 39 | if: ${{ github.actor != 'dependabot[bot]' }} 40 | uses: paambaati/codeclimate-action@v3.0.0 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | Gemfile*.lock 3 | *.gem 4 | coverage 5 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | SimpleCov.start 'test_frameworks' do 2 | enable_coverage :branch 3 | end 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | # 2.4.2 4 | 5 | Released 2016-09-26. 6 | 7 | ## Fixed 8 | 9 | * Ensure `changed_attributes` is copied when `.becomes` is invoked (#21) 10 | 11 | # 2.4.1 12 | 13 | Released 2016-08-22. 14 | 15 | ## Fixed 16 | 17 | * Ensure that new_record is not set until before_save callbacks complete (#20) 18 | 19 | # 2.4.0 20 | 21 | Released 2016-08-10. 22 | 23 | ## New Features 24 | 25 | * Rails 5 compatibility (#19) 26 | 27 | # 2.3.0 28 | 29 | Released 2016-06-29. 30 | 31 | ## Changed 32 | 33 | * Superstore requires PostgreSQL 9.5, since it uses the JSONB operators introduced in that version. 34 | 35 | # 2.2.0 36 | 37 | Released 2016-06-02. 38 | 39 | ## New Features 40 | 41 | * Add `geo_point` type (#18) 42 | 43 | # 2.1.2 44 | 45 | Released 2016-05-04. 46 | 47 | ## Fixed 48 | 49 | `Model.find([nil])` no longer returns all records 50 | 51 | # 2.1.1 52 | 53 | Released 2016-04-01. 54 | 55 | ## New Features 56 | 57 | * Added a `to_ids` scoped method. 58 | 59 | # 2.1.0 60 | 61 | Released 2016-02-12. 62 | 63 | ## Changed 64 | 65 | * Associations are now ActiveRecord-based (#17) 66 | 67 | # 2.0.1 68 | 69 | Released 2016-02-12. 70 | 71 | ## Fixed 72 | 73 | * `has_many` was not working correctly. 74 | 75 | # 2.0.0 76 | 77 | Released 2015-12-18. 78 | 79 | ## New Features 80 | 81 | * **Added Postgres JSONB adapter.** (#9) 82 | * Added support for `has_many` association. (#13) 83 | * Added support for `has_one` association. (#14) 84 | 85 | ## Breaking Changes 86 | 87 | * **Removed Cassandra and Postgres hstore adapters.** (#12) 88 | * `find_each` now uses SQL cursors, so it's not constrained by the limitations of ActiveRecord's 89 | implementation. (#11) 90 | * Default values have been re-implemented correctly. (#15) 91 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | gem 'rake' 5 | 6 | group :test do 7 | gem 'pg' 8 | gem 'mocha', require: false 9 | gem 'simplecov' 10 | end 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Koziarski Software Ltd, 2022 Data Axle Inc 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 [Michael Koziarski], 2022 [Data Axle Inc] 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Superstore 2 | [![Ruby](https://github.com/data-axle/superstore/actions/workflows/ruby.yml/badge.svg)](https://github.com/data-axle/superstore/actions/workflows/ruby.yml) 3 | [![Code Climate](https://codeclimate.com/github/data-axle/superstore/badges/gpa.svg)](https://codeclimate.com/github/data-axle/superstore) 4 | [![Gem](https://img.shields.io/gem/v/superstore.svg?maxAge=2592000)](https://rubygems.org/gems/superstore) 5 | 6 | Superstore is a PostgreSQL JSONB document store which uses ActiveModel to mimic much of the behavior 7 | in ActiveRecord. 8 | 9 | ## Requirements 10 | 11 | Superstore requires PostgreSQL 9.5 or above. 12 | 13 | ## Installation 14 | 15 | Add the following to the `Gemfile`: 16 | 17 | ```ruby 18 | gem 'superstore' 19 | ``` 20 | 21 | Superstore will share the existing ActiveRecord database connection. 22 | 23 | ## Defining Models 24 | 25 | ```ruby 26 | class Widget < Superstore::Base 27 | attribute :name, type: :string 28 | attribute :price, type: :integer 29 | attribute :colors, type: :array 30 | 31 | validates :name, presence: :true 32 | 33 | before_create do 34 | self.description = "#{name} is the best product ever" 35 | end 36 | end 37 | ``` 38 | 39 | The table name defaults to the case-sensitive, pluralized name of the model class. To specify a 40 | custom name, set the `table_name` attribute on the class: 41 | 42 | ```ruby 43 | class MyWidget < Superstore::Base 44 | table_name = 'my_widgets' 45 | end 46 | ``` 47 | 48 | ## Creating and updating records 49 | 50 | Superstore has equivalent methods to ActiveRecord: 51 | 52 | ```ruby 53 | widget = Widget.new 54 | widget.valid? 55 | widget = Widget.create(name: 'Acme', price: 100) 56 | widget.update_attribute(:price, 1200) 57 | widget.update(price: 1200, name: 'Acme Corporation') 58 | widget.attributes = {price: 300} 59 | widget.price_was 60 | widget.save 61 | widget.save! 62 | ``` 63 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | task default: :test 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs = %w(lib test) 8 | t.pattern = 'test/unit/**/*_test.rb' 9 | end 10 | -------------------------------------------------------------------------------- /lib/superstore.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/all' 2 | require 'active_model' 3 | require 'active_record' 4 | 5 | module Superstore 6 | extend ActiveSupport::Autoload 7 | 8 | autoload :AttributeMethods 9 | autoload :Base 10 | autoload :Associations 11 | autoload :AttributeAssignment 12 | autoload :Attributes 13 | autoload :Connection 14 | autoload :Core 15 | autoload :Identity 16 | autoload :Inheritance 17 | autoload :ModelSchema 18 | autoload :Persistence 19 | 20 | module AttributeMethods 21 | extend ActiveSupport::Autoload 22 | 23 | eager_autoload do 24 | autoload :PrimaryKey 25 | end 26 | end 27 | 28 | module Adapters 29 | extend ActiveSupport::Autoload 30 | 31 | autoload :AbstractAdapter 32 | autoload :JsonbAdapter 33 | end 34 | 35 | module Associations 36 | extend ActiveSupport::Autoload 37 | 38 | autoload :Association 39 | autoload :AssociationScope 40 | autoload :Reflection 41 | autoload :BelongsTo 42 | autoload :HasMany 43 | autoload :HasOne 44 | 45 | module Builder 46 | extend ActiveSupport::Autoload 47 | 48 | autoload :Association 49 | autoload :BelongsTo 50 | autoload :HasMany 51 | autoload :HasOne 52 | end 53 | end 54 | 55 | module Relation 56 | extend ActiveSupport::Autoload 57 | 58 | autoload :Scrolling 59 | end 60 | 61 | module Types 62 | extend ActiveSupport::Autoload 63 | 64 | autoload :Base 65 | autoload :ArrayType 66 | autoload :BooleanType 67 | autoload :DateType 68 | autoload :DateRangeType 69 | autoload :FloatType 70 | autoload :GeoPointType 71 | autoload :IntegerType 72 | autoload :IntegerRangeType 73 | autoload :JsonType 74 | autoload :RangeType 75 | autoload :StringType 76 | autoload :TimeType 77 | end 78 | end 79 | 80 | require 'superstore/railtie' if defined?(Rails) 81 | -------------------------------------------------------------------------------- /lib/superstore/adapters/abstract_adapter.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Adapters 3 | class AbstractAdapter 4 | def initialize 5 | end 6 | 7 | # Read records from a instance of Superstore::Scope 8 | def select(scope) # abstract 9 | end 10 | 11 | # Insert a new row 12 | def insert(table, id, attributes) # abstract 13 | end 14 | 15 | # Update an existing row 16 | def update(table, id, attributes) # abstract 17 | end 18 | 19 | # Delete rows by an array of ids 20 | def delete(table, ids) # abstract 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/superstore/adapters/jsonb_adapter.rb: -------------------------------------------------------------------------------- 1 | gem 'pg' 2 | require 'pg' 3 | 4 | module Superstore 5 | module Adapters 6 | class JsonbAdapter < AbstractAdapter 7 | Column = Struct.new(:name) 8 | attr_reader :superstore_column 9 | 10 | def initialize(superstore_column:) 11 | @superstore_column = superstore_column 12 | end 13 | 14 | PRIMARY_KEY_COLUMN = 'id'.freeze 15 | def primary_key_column 16 | PRIMARY_KEY_COLUMN 17 | end 18 | 19 | def connection 20 | active_record_klass.connection 21 | end 22 | 23 | def active_record_klass=(klass) 24 | @active_record_klass = klass 25 | end 26 | 27 | def active_record_klass 28 | @active_record_klass ||= ActiveRecord::Base 29 | end 30 | 31 | def execute(statement) 32 | connection.execute statement 33 | end 34 | 35 | def insert(table, id, superstore_attributes, column_attributes) 36 | not_nil_superstore_attributes = superstore_attributes.reject { |key, value| value.nil? } 37 | 38 | statement = Arel::InsertManager.new(Arel::Table.new(table)) 39 | statement.values = Arel::Nodes::ValuesList.new([[ 40 | id.value, 41 | to_jsonb(not_nil_superstore_attributes), 42 | *column_attributes.values.map(&:value) 43 | ]]) 44 | 45 | execute statement.to_sql 46 | end 47 | 48 | def update(table, id, superstore_attributes, column_attributes) 49 | return if superstore_attributes.empty? && column_attributes.empty? 50 | 51 | nil_superstore_properties = superstore_attributes.each_key.select { |k| superstore_attributes[k].nil? } 52 | not_nil_superstore_attributes = superstore_attributes.reject { |key, value| value.nil? } 53 | 54 | statement = Arel::UpdateManager.new 55 | statement.table(Arel::Table.new(table)) 56 | statement.where(Arel::Nodes::SqlLiteral.new(primary_key_column).eq(id)) 57 | 58 | value_update = superstore_column 59 | nil_superstore_properties.each do |property| 60 | value_update = "(#{value_update} - '#{property}')" 61 | end 62 | 63 | if not_nil_superstore_attributes.any? 64 | value_update = "(#{value_update} || #{quote(to_jsonb(not_nil_superstore_attributes))})" 65 | end 66 | 67 | values = column_attributes.merge(superstore_column => value_update) 68 | values.transform_keys! { |k| Column.new(k) } 69 | values.transform_values! { |v| Arel::Nodes::SqlLiteral.new(v) } 70 | statement.set(values) 71 | 72 | execute statement.to_sql 73 | end 74 | 75 | 76 | def quote(value) 77 | connection.quote(value) 78 | end 79 | 80 | def to_jsonb(data) 81 | ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb.new.serialize(data) 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/superstore/associations.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Associations 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | include ActiveRecord::Associations 7 | extend ClassOverrides 8 | end 9 | 10 | module ClassOverrides 11 | # === Options 12 | # [:class_name] 13 | # Use if the class cannot be inferred from the association 14 | # [:polymorphic] 15 | # Specify if the association is polymorphic 16 | # Example: 17 | # class Driver < Superstore::Base 18 | # end 19 | # class Truck < Superstore::Base 20 | # end 21 | def belongs_to(name, **options) 22 | if options.delete(:superstore) 23 | Superstore::Associations::Builder::BelongsTo.build(self, name, options) 24 | else 25 | super 26 | end 27 | end 28 | 29 | def has_many(name, **options) 30 | if options.delete(:superstore) 31 | Superstore::Associations::Builder::HasMany.build(self, name, options) 32 | else 33 | super 34 | end 35 | end 36 | 37 | def has_one(name, **options) 38 | if options.delete(:superstore) 39 | Superstore::Associations::Builder::HasOne.build(self, name, options) 40 | else 41 | super 42 | end 43 | end 44 | 45 | def belongs_to_required_by_default 46 | false 47 | end 48 | end 49 | 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/superstore/associations/association.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Associations 3 | class Association 4 | attr_reader :owner, :reflection 5 | delegate :options, to: :reflection 6 | 7 | def initialize(owner, reflection) 8 | @owner = owner 9 | @reflection = reflection 10 | reset 11 | end 12 | 13 | def association_class 14 | association_class_name.constantize 15 | end 16 | 17 | def association_class_name 18 | reflection.polymorphic? ? owner.send(reflection.polymorphic_column) : reflection.class_name 19 | end 20 | 21 | def target=(target) 22 | @target = target 23 | loaded! 24 | end 25 | 26 | def target 27 | @target 28 | end 29 | 30 | def loaded? 31 | @loaded 32 | end 33 | 34 | def loaded! 35 | @loaded = true 36 | end 37 | 38 | def reset 39 | @loaded = false 40 | @target = nil 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/superstore/associations/association_scope.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Associations 3 | class AssociationScope < ActiveRecord::Relation 4 | def initialize(klass, association) 5 | super(klass) 6 | @association = association 7 | end 8 | 9 | def exec_queries 10 | super.each { |r| @association.set_inverse_instance r } 11 | end 12 | 13 | def <<(*records) 14 | if loaded? 15 | @records = @records + records 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/superstore/associations/belongs_to.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Associations 3 | class BelongsTo < Association 4 | def reader 5 | unless loaded? 6 | self.target = get_record 7 | end 8 | 9 | target 10 | end 11 | 12 | def writer(record) 13 | self.target = record 14 | owner.send("#{reflection.foreign_key}=", record.try(reflection.primary_key)) 15 | if reflection.polymorphic? 16 | owner.send("#{reflection.polymorphic_column}=", record.class.name) 17 | end 18 | end 19 | 20 | def belongs_to?; true; end 21 | 22 | private 23 | 24 | def get_record 25 | record_id = owner.send(reflection.foreign_key).presence 26 | return unless record_id 27 | 28 | if reflection.default_primary_key? 29 | association_class.find_by_id(record_id) 30 | else 31 | association_class.find_by(reflection.primary_key => record_id) 32 | end 33 | end 34 | 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/superstore/associations/builder/association.rb: -------------------------------------------------------------------------------- 1 | module Superstore::Associations::Builder 2 | class Association 3 | def self.build(model, name, options) 4 | new(model, name, options).build 5 | end 6 | 7 | attr_reader :model, :name, :options 8 | def initialize(model, name, options) 9 | @model, @name, @options = model, name, options 10 | end 11 | 12 | def build 13 | define_writer 14 | define_reader 15 | 16 | reflection = Superstore::Associations::Reflection.new(macro, name, model, options) 17 | ActiveRecord::Reflection.add_reflection model, name, reflection 18 | end 19 | 20 | def mixin 21 | model.generated_association_methods 22 | end 23 | 24 | def define_writer 25 | name = self.name 26 | mixin.redefine_method("#{name}=") do |records| 27 | association(name).writer(records) 28 | end 29 | end 30 | 31 | def define_reader 32 | name = self.name 33 | mixin.redefine_method(name) do 34 | association(name).reader 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/superstore/associations/builder/belongs_to.rb: -------------------------------------------------------------------------------- 1 | module Superstore::Associations::Builder 2 | class BelongsTo < Association 3 | def macro 4 | :belongs_to 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /lib/superstore/associations/builder/has_many.rb: -------------------------------------------------------------------------------- 1 | module Superstore::Associations::Builder 2 | class HasMany < Association 3 | def macro 4 | :has_many 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /lib/superstore/associations/builder/has_one.rb: -------------------------------------------------------------------------------- 1 | module Superstore::Associations::Builder 2 | class HasOne < Association 3 | def macro 4 | :has_one 5 | end 6 | end 7 | end -------------------------------------------------------------------------------- /lib/superstore/associations/has_many.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Associations 3 | class HasMany < Association 4 | def reader 5 | unless loaded? 6 | self.target = load_collection 7 | end 8 | 9 | target 10 | end 11 | 12 | def writer(records) 13 | relation = load_collection 14 | 15 | # TODO: Use relation.load_records with Rails 5 16 | relation.instance_variable_set :@records, records 17 | relation.instance_variable_set :@loaded, true 18 | 19 | self.target = relation 20 | end 21 | 22 | def set_inverse_instance(record) 23 | return unless reflection.inverse_name 24 | 25 | inverse = record.association(reflection.inverse_name) 26 | inverse.target = owner 27 | end 28 | 29 | private 30 | 31 | def inverse_of 32 | return unless reflection.inverse_name 33 | 34 | @inverse_of ||= association_class.reflect_on_association reflection.inverse_name 35 | end 36 | 37 | def load_collection 38 | AssociationScope.new(association_class, self).where("#{owner.superstore_column} ->> '#{reflection.foreign_key}' = '#{owner.try(reflection.primary_key)}'") 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/superstore/associations/has_one.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Associations 3 | class HasOne < Association 4 | def reader 5 | unless loaded? 6 | self.target = load_target 7 | end 8 | 9 | target 10 | end 11 | 12 | def writer(record) 13 | self.target = record 14 | end 15 | 16 | private 17 | 18 | def load_target 19 | association_class.where(reflection.foreign_key => owner.try(reflection.primary_key)).first 20 | end 21 | 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /lib/superstore/associations/reflection.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Associations 3 | class Reflection 4 | attr_reader :macro, :name, :model, :options 5 | def initialize(macro, name, model, options) 6 | @macro = macro 7 | @name = name 8 | @model = model 9 | @options = options 10 | end 11 | 12 | def association_class 13 | case macro 14 | when :belongs_to 15 | Superstore::Associations::BelongsTo 16 | when :has_many 17 | Superstore::Associations::HasMany 18 | when :has_one 19 | Superstore::Associations::HasOne 20 | end 21 | 22 | end 23 | 24 | def instance_variable_name 25 | "@#{name}" 26 | end 27 | 28 | def foreign_key 29 | @foreign_key ||= options[:foreign_key] || derive_foreign_key 30 | end 31 | 32 | def primary_key 33 | options[:primary_key] || "id" 34 | end 35 | 36 | def default_primary_key? 37 | primary_key == "id" 38 | end 39 | 40 | def polymorphic_column 41 | "#{name}_type" 42 | end 43 | 44 | def polymorphic? 45 | options[:polymorphic] 46 | end 47 | 48 | def belongs_to?; false; end 49 | 50 | def class_name 51 | @class_name ||= (options[:class_name] || name.to_s.classify) 52 | end 53 | 54 | def inverse_name 55 | options[:inverse_of] 56 | end 57 | 58 | def parent_reflection 59 | end 60 | 61 | private 62 | 63 | def derive_foreign_key 64 | case macro 65 | when :has_many, :has_one 66 | model.name.foreign_key 67 | when :belongs_to 68 | "#{name}_id" 69 | end 70 | end 71 | 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/superstore/attribute_assignment.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module AttributeAssignment 3 | def _assign_attribute(k, v) 4 | public_send("#{k}=", v) if respond_to?("#{k}=") 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/superstore/attribute_methods.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module AttributeMethods 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | include PrimaryKey 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/superstore/attribute_methods/primary_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Superstore 4 | module AttributeMethods 5 | module PrimaryKey 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | attribute :id, type: :string 10 | include AttributeOverrides 11 | end 12 | 13 | module ClassMethods 14 | def primary_key 15 | 'id' 16 | end 17 | end 18 | 19 | module AttributeOverrides 20 | def id 21 | value = super 22 | if value.nil? 23 | value = self.class._generate_key(self) 24 | @attributes.write_from_user(self.class.primary_key, value) 25 | end 26 | value 27 | end 28 | 29 | def attributes 30 | super.update(self.class.primary_key => id) 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/superstore/attributes.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Attributes 3 | extend ActiveSupport::Concern 4 | 5 | module ClassMethods 6 | def attribute(name, options) 7 | type_name = "superstore_#{options.fetch(:type)}".to_sym 8 | 9 | super(name, type_name) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/superstore/base.rb: -------------------------------------------------------------------------------- 1 | require 'superstore/types' 2 | 3 | module Superstore 4 | class Base < ActiveRecord::Base 5 | self.abstract_class = true 6 | include Core 7 | include Persistence 8 | include ModelSchema 9 | include AttributeAssignment 10 | include Attributes 11 | include AttributeMethods 12 | include Associations 13 | 14 | include Identity 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/superstore/core.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Core 3 | extend ActiveSupport::Concern 4 | 5 | def inspect 6 | inspection = ["#{self.class.primary_key}: #{id.inspect}"] 7 | 8 | (self.class.attribute_names - [self.class.primary_key]).each do |name| 9 | value = send(name) 10 | 11 | if value.present? || value === false 12 | inspection << "#{name}: #{attribute_for_inspect(name)}" 13 | end 14 | end 15 | 16 | "#<#{self.class} #{inspection * ', '}>" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/superstore/identity.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | module Superstore 4 | module Identity 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | class_attribute :key_generator 9 | 10 | key do 11 | SecureRandom.uuid.tr('-','') 12 | end 13 | end 14 | 15 | module ClassMethods 16 | # Define a key generator. Default is UUID. 17 | def key(&block) 18 | self.key_generator = block 19 | end 20 | 21 | def _generate_key(object) 22 | object.instance_eval(&key_generator) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/superstore/model_schema.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module ModelSchema 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | class_attribute :superstore_column, default: 'document' 7 | end 8 | 9 | module ClassMethods 10 | def attributes_builder # :nodoc: 11 | @attributes_builder ||= ActiveModel::AttributeSet::Builder.new(attribute_types, _default_attributes) 12 | end 13 | 14 | def load_schema! # :nodoc: 15 | if table_exists? 16 | @ignored_columns = [primary_key, superstore_column].freeze 17 | super 18 | @ignored_columns = [].freeze 19 | else 20 | @columns_hash = {} 21 | end 22 | 23 | attributes_to_define_after_schema_loads.each do |name, (cast_type, default)| 24 | define_attribute(name, cast_type, default: default) 25 | end 26 | end 27 | 28 | def attribute_names 29 | attribute_types.keys 30 | end 31 | 32 | def column_names 33 | attribute_names 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/superstore/persistence.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Persistence 3 | extend ActiveSupport::Concern 4 | 5 | module ClassMethods 6 | def find_by_id(id) 7 | find_by(id: id) 8 | end 9 | 10 | def _insert_record(attributes, _returning = nil) 11 | adapter.insert(*attributes_for_upsert(attributes.fetch(primary_key), attributes)) 12 | end 13 | 14 | def _update_record(attributes, constraints) 15 | adapter.update(*attributes_for_upsert(constraints.fetch(primary_key), attributes)) 16 | end 17 | 18 | def serialize_attributes(attributes) 19 | serialized = {} 20 | attributes.except(primary_key).each do |attr_name, value| 21 | attribute_type = attribute_types[attr_name] 22 | next unless attribute_type.is_a?(Superstore::Types::Base) 23 | 24 | serialized[attr_name] = attribute_type.serialize(value.value) 25 | end 26 | serialized 27 | end 28 | 29 | private 30 | 31 | def attributes_for_upsert(id, attributes) 32 | superstore_attributes = serialize_attributes(attributes) 33 | [table_name, id, superstore_attributes, attributes.except(primary_key, *superstore_attributes.keys)] 34 | end 35 | 36 | def instantiate_instance_of(klass, attributes, column_types = {}, &block) 37 | if attributes[superstore_column].is_a?(String) 38 | attributes.merge!(JSON.parse(attributes.delete(superstore_column))) 39 | end 40 | 41 | if inheritance_column && attribute_types.key?(inheritance_column) 42 | klass = find_sti_class(attributes[inheritance_column]) 43 | end 44 | 45 | attributes.each_key { |k, v| attributes.delete(k) unless klass.attribute_types.key?(k) } 46 | 47 | super(klass, attributes, column_types, &block) 48 | end 49 | 50 | def adapter 51 | @adapter ||= Superstore::Adapters::JsonbAdapter.new(superstore_column: superstore_column) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/superstore/railtie.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | class Railtie < Rails::Railtie 3 | initializer "superstore.config" do |app| 4 | ActiveSupport.on_load :active_record do 5 | ActiveRecord::Relation.class_eval do 6 | include Superstore::Relation::Scrolling 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/superstore/relation/scrolling.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Relation 3 | module Scrolling 4 | def scroll_each(options = {}) 5 | batch_size = options[:batch_size] || 1000 6 | 7 | scroll_results(batch_size) do |attributes| 8 | yield klass.instantiate(attributes) 9 | end 10 | end 11 | 12 | def scroll_in_batches(options = {}) 13 | batch_size = options[:batch_size] || 1000 14 | batch = [] 15 | 16 | scroll_each(options) do |record| 17 | batch << record 18 | 19 | if batch.size == batch_size 20 | yield batch 21 | batch = [] 22 | end 23 | end 24 | 25 | yield(batch) if batch.any? 26 | end 27 | 28 | private 29 | 30 | def scroll_results(batch_size) 31 | statement = to_sql 32 | cursor_name = "cursor_#{SecureRandom.hex(6)}" 33 | fetch_sql = "FETCH FORWARD #{batch_size} FROM #{cursor_name}" 34 | 35 | connection.transaction do 36 | connection.execute "DECLARE #{cursor_name} NO SCROLL CURSOR FOR (#{statement})" 37 | 38 | while (batch = connection.execute(fetch_sql)).any? 39 | batch.each do |result| 40 | yield result 41 | end 42 | end 43 | end 44 | end 45 | 46 | end 47 | end 48 | end -------------------------------------------------------------------------------- /lib/superstore/types.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Type.register(:superstore_array, Superstore::Types::ArrayType) 2 | ActiveRecord::Type.register(:superstore_boolean, Superstore::Types::BooleanType) 3 | ActiveRecord::Type.register(:superstore_date, Superstore::Types::DateType) 4 | ActiveRecord::Type.register(:superstore_date_range, Superstore::Types::DateRangeType) 5 | ActiveRecord::Type.register(:superstore_float, Superstore::Types::FloatType) 6 | ActiveRecord::Type.register(:superstore_geo_point, Superstore::Types::GeoPointType) 7 | ActiveRecord::Type.register(:superstore_integer, Superstore::Types::IntegerType) 8 | ActiveRecord::Type.register(:superstore_integer_range, Superstore::Types::IntegerRangeType) 9 | ActiveRecord::Type.register(:superstore_json, Superstore::Types::JsonType) 10 | ActiveRecord::Type.register(:superstore_time, Superstore::Types::TimeType) 11 | ActiveRecord::Type.register(:superstore_string, Superstore::Types::StringType) 12 | -------------------------------------------------------------------------------- /lib/superstore/types/array_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class ArrayType < Base 4 | def serialize(value) 5 | value if value.present? 6 | end 7 | 8 | def cast_value(value) 9 | Array(value) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/superstore/types/base.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class Base < ActiveModel::Type::Value 4 | def type 5 | self.class.name.demodulize.sub(/Type$/, '').underscore 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/superstore/types/boolean_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class BooleanType < Base 4 | TRUE_VALS = [true, 'true', '1'] 5 | FALSE_VALS = [false, 'false', '0'] 6 | 7 | def cast_value(value) 8 | if TRUE_VALS.include?(value) 9 | true 10 | elsif FALSE_VALS.include?(value) 11 | false 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/superstore/types/date_range_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class DateRangeType < RangeType 4 | self.subtype = DateType.new 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/superstore/types/date_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class DateType < Base 4 | FORMAT = '%Y-%m-%d' 5 | 6 | def serialize(value) 7 | value.strftime(FORMAT) if value 8 | end 9 | 10 | def deserialize(str) 11 | Date.strptime(str, FORMAT) if str 12 | end 13 | 14 | def cast_value(value) 15 | value.to_date rescue nil 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/superstore/types/float_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class FloatType < Base 4 | def cast_value(value) 5 | Float(value) rescue nil 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/superstore/types/geo_point_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Superstore 4 | module Types 5 | class GeoPointType < Base 6 | def deserialize(value) 7 | {lat: value[:lat] || value['lat'], lon: value[:lon] || value['lon']} if value 8 | end 9 | 10 | def cast_value(value) 11 | case value 12 | when String 13 | cast_value value.split(/[,\s]+/) 14 | when Array 15 | to_float_or_nil(lat: value[0], lon: value[1]) 16 | when Hash 17 | to_float_or_nil(lat: value[:lat] || value['lat'], lon: value[:lon] || value['lon']) 18 | end 19 | end 20 | 21 | private 22 | 23 | def to_float_or_nil(coords) 24 | if coords[:lat] && coords[:lon] 25 | coords.transform_values!(&:to_f) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/superstore/types/integer_range_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class IntegerRangeType < RangeType 4 | self.subtype = IntegerType.new 5 | 6 | def serialize_for_open_ended(value) 7 | value&.abs == Float::INFINITY ? nil : super 8 | end 9 | 10 | def convert_min(method, value) 11 | value.nil? ? -Float::INFINITY : super 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/superstore/types/integer_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class IntegerType < Base 4 | def cast_value(value) 5 | if value.is_a?(String) 6 | Integer(value, 10) 7 | else 8 | Integer(value) 9 | end 10 | rescue 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/superstore/types/json_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class JsonType < Base 4 | def serialize(value) 5 | value if value.present? 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/superstore/types/range_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class RangeType < Base 4 | class_attribute :subtype 5 | 6 | def serialize(range) 7 | if range 8 | [ 9 | serialize_for_open_ended(range.begin), 10 | serialize_for_open_ended(range.end) 11 | ] 12 | end 13 | end 14 | 15 | def deserialize(range_tuple) 16 | if range_tuple.is_a? Range 17 | range_tuple 18 | elsif range_tuple.is_a?(Array) 19 | range = convert_min(:deserialize, range_tuple[0]) .. convert_max(:deserialize, range_tuple[1]) 20 | cast_value(range) 21 | end 22 | end 23 | 24 | def cast_value(value) 25 | if is_beginless_date_range?(value) 26 | nil 27 | elsif value.is_a?(Range) && (value.end.nil? || value.begin <= value.end) 28 | value 29 | elsif value.is_a?(Array) && value.size == 2 30 | begin 31 | range = convert_min(:cast_value, value[0])..convert_max(:cast_value, value[1]) 32 | cast_value(range) 33 | rescue ArgumentError 34 | end 35 | end 36 | end 37 | 38 | private 39 | 40 | def serialize_for_open_ended(value) 41 | subtype.serialize(value) 42 | end 43 | 44 | def convert_min(method, value) 45 | subtype.send(method, value) 46 | end 47 | 48 | def convert_max(method, value) 49 | subtype.send(method, value) 50 | end 51 | 52 | def is_beginless_date_range?(value) 53 | (value.is_a?(Range) && value.begin.nil? && value.end.is_a?(Date)) || 54 | (value.is_a?(Array) && value[0].nil? && value[1].is_a?(Date)) 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/superstore/types/string_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class StringType < Base 4 | def serialize(str) 5 | return if str.nil? 6 | 7 | unless str.encoding == Encoding::UTF_8 8 | (str.frozen? ? str.dup : str).force_encoding('UTF-8') 9 | else 10 | str 11 | end 12 | end 13 | 14 | def cast_value(value) 15 | value.to_s 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/superstore/types/time_type.rb: -------------------------------------------------------------------------------- 1 | module Superstore 2 | module Types 3 | class TimeType < Base 4 | def serialize(time) 5 | time.utc.xmlschema(6) if time 6 | end 7 | 8 | def deserialize(str) 9 | Time.rfc3339(str).in_time_zone if str 10 | rescue ArgumentError 11 | Time.parse(str).in_time_zone rescue nil 12 | end 13 | 14 | def cast_value(value) 15 | value.to_time.in_time_zone rescue nil 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /superstore.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'superstore' 3 | s.version = '4.0.0' 4 | s.description = 'ActiveModel-based JSONB document store' 5 | s.summary = 'ActiveModel for JSONB documents' 6 | s.authors = ['Michael Koziarski', 'Data Axle'] 7 | s.email = 'developer@matthewhiggins.com' 8 | s.homepage = 'http://github.com/data-axle/superstore' 9 | s.licenses = %w[ISC MIT] 10 | 11 | s.required_ruby_version = '>= 3.1.0' 12 | s.required_rubygems_version = '>= 3.2.0' 13 | 14 | s.extra_rdoc_files = ['README.md'] 15 | s.files = `git ls-files`.split("\n") 16 | s.test_files = `git ls-files -- {test}/*`.split("\n") 17 | s.require_paths = ['lib'] 18 | 19 | s.add_runtime_dependency('activemodel', '>= 7.0') 20 | s.add_runtime_dependency('activerecord', '>= 7.0') 21 | 22 | s.add_development_dependency('bundler') 23 | s.add_development_dependency('rails', '~> 7.0.0') 24 | end 25 | -------------------------------------------------------------------------------- /test/support/jsonb.rb: -------------------------------------------------------------------------------- 1 | class JsonbInitializer 2 | def self.initialize! 3 | ActiveRecord::Migration.create_table :issues, id: :string do |t| 4 | t.jsonb :document, null: false 5 | t.integer :widget_id 6 | end 7 | end 8 | end 9 | 10 | JsonbInitializer.initialize! 11 | -------------------------------------------------------------------------------- /test/support/models.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | end 3 | 4 | class Label < ActiveRecord::Base 5 | belongs_to :issue 6 | end 7 | 8 | class Issue < Superstore::Base 9 | attribute :description, type: :string 10 | attribute :title, type: :string 11 | attribute :parent_issue_id, type: :string 12 | attribute :comments, type: :json 13 | attribute :tags, type: :array 14 | attribute :created_at, type: :time 15 | attribute :updated_at, type: :time 16 | 17 | before_create { self.description ||= 'funny' } 18 | 19 | has_many :labels, inverse_of: :issue 20 | has_many :children_issues, class_name: 'Issue', foreign_key: :parent_issue_id, inverse_of: :parent_issue, superstore: true 21 | belongs_to :parent_issue, class_name: 'Issue', superstore: true 22 | 23 | def self.for_key key 24 | where_ids(key) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/support/pg.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | class PGInitializer 4 | def self.initialize! 5 | config = { 6 | 'adapter' => 'postgresql', 7 | 'encoding' => 'unicode', 8 | 'database' => 'superstore_test', 9 | 'pool' => 5, 10 | 'host' => 'localhost' 11 | } 12 | 13 | ActiveRecord::Base.configurations = { test: config } 14 | 15 | ActiveRecord::Tasks::DatabaseTasks.drop config 16 | ActiveRecord::Tasks::DatabaseTasks.create config 17 | ActiveRecord::Base.establish_connection config 18 | 19 | create_labels_table 20 | create_users_table 21 | end 22 | 23 | def self.create_labels_table 24 | ActiveRecord::Migration.create_table :labels do |t| 25 | t.string :issue_id, null: false 26 | t.string :name, null: false 27 | end 28 | end 29 | 30 | def self.create_users_table 31 | ActiveRecord::Migration.create_table :users do |t| 32 | t.string :special_id, null: false 33 | t.index :special_id, unique: true 34 | end 35 | end 36 | 37 | def self.table_names 38 | %w(labels users) 39 | end 40 | end 41 | 42 | PGInitializer.initialize! 43 | ActiveRecord::Migration.verbose = false -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] = 'test' 2 | require 'simplecov' 3 | 4 | require 'rails' 5 | 6 | I18n.config.enforce_available_locales = false 7 | ActiveSupport::TestCase.test_order = :random 8 | 9 | require 'active_record' 10 | 11 | class DummyApp < Rails::Application; end 12 | 13 | require 'rails/test_help' 14 | require 'mocha/api' 15 | 16 | require 'superstore' 17 | 18 | require 'support/pg' 19 | require 'support/jsonb' 20 | require 'support/models' 21 | 22 | module Superstore 23 | class TestCase < ActiveSupport::TestCase 24 | def temp_object(&block) 25 | Class.new(Superstore::Base) do 26 | self.table_name = 'issues' 27 | attribute :force_save, type: :string 28 | before_save { self.force_save = 'junk' } 29 | 30 | def self.name 31 | 'Issue' 32 | end 33 | 34 | instance_eval(&block) if block_given? 35 | end 36 | end 37 | end 38 | 39 | module Types 40 | class TestCase < Superstore::TestCase 41 | attr_accessor :type 42 | 43 | setup do 44 | @type = self.class.name.sub(/Test$/, '').constantize.new 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/unit/active_model_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ActiveModelTest < Superstore::TestCase 4 | 5 | include ActiveModel::Lint::Tests 6 | 7 | # overrides ActiveModel::Lint::Tests#test_to_param 8 | def test_to_param 9 | end 10 | 11 | # overrides ActiveModel::Lint::Tests#test_to_key 12 | def test_to_key 13 | end 14 | 15 | def setup 16 | @model = Issue.new 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/unit/adapters/adapter_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Adapters::AdapterTest < Superstore::TestCase 4 | end 5 | -------------------------------------------------------------------------------- /test/unit/associations/belongs_to_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Associations::BelongsTest < Superstore::TestCase 4 | class TestObject < Superstore::Base 5 | self.table_name = 'issues' 6 | attribute :user_id, type: :string 7 | belongs_to :user, primary_key: :special_id 8 | end 9 | 10 | test 'belongs_to' do 11 | user = User.create(special_id: 'abc') 12 | issue = TestObject.create(user: user) 13 | 14 | assert_equal user, issue.user 15 | assert_equal issue.user_id, 'abc' 16 | 17 | issue = TestObject.find(issue.id) 18 | assert_equal user, issue.user 19 | end 20 | 21 | test 'belongs_to clear cache after reload' do 22 | user = User.create(special_id: 'abc') 23 | issue = TestObject.create(user: user) 24 | user.destroy 25 | 26 | assert_not_nil issue.user 27 | assert_nil TestObject.find(issue.id).user 28 | assert_nil issue.reload.user 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/unit/associations/has_many_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Associations::HasManyTest < Superstore::TestCase 4 | class TestObject < Issue 5 | end 6 | 7 | test 'has_many active_record association' do 8 | issue = TestObject.create! 9 | label = Label.create! name: 'important', issue_id: issue.id 10 | 11 | assert_equal [label], issue.labels 12 | end 13 | 14 | test 'create supports preloaded records' do 15 | issue = TestObject.create! 16 | issue.labels = Label.all 17 | 18 | issue.labels.create! name: 'blue' 19 | 20 | assert_equal 1, issue.labels.size 21 | end 22 | 23 | test 'has_many superstore association' do 24 | parent_issue = Issue.create! 25 | child_issue = Issue.create! parent_issue: parent_issue 26 | 27 | assert_equal [child_issue], parent_issue.children_issues 28 | assert_equal parent_issue.object_id, parent_issue.children_issues.first.parent_issue.object_id 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/unit/associations/has_one_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Associations::HasOneTest < Superstore::TestCase 4 | class TestObject < Issue 5 | has_one :favorite_label, class_name: 'Label', foreign_key: 'issue_id' 6 | end 7 | 8 | test 'has_many' do 9 | issue = TestObject.create! 10 | label = Label.create! name: 'important', issue_id: issue.id 11 | 12 | assert_equal label, issue.favorite_label 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/unit/associations/reflection_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Associations::ReflectionTest < Superstore::TestCase 4 | test 'reflect_on_associations' do 5 | assert_equal %i(labels children_issues parent_issue), Issue.reflect_on_all_associations.map(&:name) 6 | end 7 | 8 | test 'reflections' do 9 | assert_instance_of ActiveRecord::Reflection::HasManyReflection, Issue.reflections['labels'] 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/unit/attribute_methods/dirty_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::AttributeMethods::DirtyTest < Superstore::TestCase 4 | test 'save clears dirty' do 5 | record = temp_object do 6 | attribute :name, type: :string 7 | end.new name: 'foo' 8 | 9 | assert record.changed? 10 | 11 | record.save! 12 | 13 | assert_equal [nil, 'foo'], record.previous_changes['name'] 14 | assert !record.changed? 15 | end 16 | 17 | test 'reload clears dirty' do 18 | record = temp_object do 19 | attribute :name, type: :string 20 | end.create! name: 'foo' 21 | 22 | record.name = 'bar' 23 | assert record.changed? 24 | 25 | record.reload 26 | 27 | assert !record.changed? 28 | end 29 | 30 | test 'cast_value float before dirty check' do 31 | record = temp_object do 32 | attribute :price, type: :float 33 | end.create(price: 5.01) 34 | 35 | record.price = '5.01' 36 | assert !record.changed? 37 | 38 | record.price = '7.12' 39 | assert record.changed? 40 | end 41 | 42 | test 'cast_value boolean before dirty check' do 43 | record = temp_object do 44 | attribute :awesome, type: :boolean 45 | end.create(awesome: false) 46 | 47 | record.awesome = false 48 | assert !record.changed? 49 | 50 | record.awesome = true 51 | assert record.changed? 52 | end 53 | 54 | test 'write_attribute' do 55 | object = temp_object do 56 | attribute :name, type: :string 57 | end 58 | 59 | expected = {"name"=>[nil, "foo"]} 60 | 61 | object.new.tap do |record| 62 | record.name = 'foo' 63 | assert_equal expected, record.changes 64 | end 65 | 66 | object.new.tap do |record| 67 | record[:name] = 'foo' 68 | # record.write_attribute(:name, 'foo') 69 | assert_equal expected, record.changes 70 | end 71 | end 72 | 73 | test 'dirty and restore to original value' do 74 | object = temp_object do 75 | attribute :name, type: :string 76 | end 77 | 78 | record = object.create(name: 'foo') 79 | 80 | assert_equal({}, record.changes) 81 | 82 | record.name = 'bar' 83 | assert_equal({'name' => ['foo', 'bar']}, record.changes) 84 | 85 | record.name = 'foo' 86 | assert_equal({}, record.changes) 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/unit/attribute_methods/primary_key_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::AttributeMethods::PrimaryKeyTest < Superstore::TestCase 4 | test 'get id' do 5 | model = temp_object do 6 | key do 7 | "foo" 8 | end 9 | end 10 | record = model.new 11 | 12 | assert_equal 'foo', record.id 13 | end 14 | 15 | test 'set id' do 16 | issue = Issue.new id: 'foo' 17 | 18 | assert_equal 'foo', issue.id 19 | end 20 | 21 | test 'attributes' do 22 | issue = Issue.new(id: 'lol') 23 | 24 | assert_not_nil issue.attributes['id'] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/unit/attribute_methods_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::AttributeMethodsTest < Superstore::TestCase 4 | test 'read and write attributes' do 5 | issue = Issue.new 6 | assert_nil issue.read_attribute(:description) 7 | 8 | issue.write_attribute(:description, nil) 9 | assert_nil issue.read_attribute(:description) 10 | 11 | issue.write_attribute(:description, 'foo') 12 | assert_equal 'foo', issue.read_attribute(:description) 13 | end 14 | 15 | test 'hash accessor aliases' do 16 | issue = Issue.new 17 | 18 | issue[:description] = 'bar' 19 | 20 | assert_equal 'bar', issue[:description] 21 | end 22 | 23 | test 'attributes setter' do 24 | issue = Issue.new 25 | 26 | issue.attributes = { 27 | description: 'foo' 28 | } 29 | 30 | assert_equal 'foo', issue.description 31 | end 32 | 33 | class ModelWithOverride < Superstore::Base 34 | attribute :title, type: :string 35 | 36 | def title=(v) 37 | super "#{v} lol" 38 | end 39 | end 40 | 41 | test 'override' do 42 | issue = ModelWithOverride.new(title: 'hey') 43 | 44 | assert_equal 'hey lol', issue.title 45 | end 46 | 47 | test 'has_attribute?' do 48 | refute Issue.new.has_attribute?(:unknown) 49 | assert Issue.new.has_attribute?(:description) 50 | assert Issue.new(description: nil).has_attribute?(:description) 51 | assert Issue.new(description: 'hey').has_attribute?(:description) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/unit/attributes_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::AttributesTest < Superstore::TestCase 4 | class TestIssue < Superstore::Base 5 | self.table_name = 'issues' 6 | 7 | attribute :enabled, type: :boolean 8 | attribute :rating, type: :float 9 | attribute :price, type: :integer 10 | attribute :orders, type: :json 11 | attribute :name, type: :string 12 | attribute :age_range, type: :integer_range 13 | end 14 | 15 | class TestChildIssue < TestIssue 16 | attribute :description, type: :string 17 | end 18 | 19 | test 'attributes not shared' do 20 | assert_nothing_raised { Issue.new.description } 21 | assert_raise(NoMethodError) { TestIssue.new.description } 22 | assert_nothing_raised { TestChildIssue.new.description } 23 | end 24 | 25 | test 'boolean attribute' do 26 | issue = TestIssue.create! enabled: '1' 27 | assert_equal true, issue.enabled 28 | 29 | issue = TestIssue.find issue.id 30 | assert_equal true, issue.enabled 31 | end 32 | 33 | test 'float attribute' do 34 | issue = TestIssue.create! rating: '4.5' 35 | assert_equal 4.5, issue.rating 36 | 37 | issue = TestIssue.find issue.id 38 | assert_equal(4.5, issue.rating) 39 | end 40 | 41 | test 'integer attribute' do 42 | issue = TestIssue.create! price: '101' 43 | assert_equal 101, issue.price 44 | 45 | issue = TestIssue.find issue.id 46 | assert_equal(101, issue.price) 47 | 48 | issue = TestIssue.new price: '' 49 | assert_nil issue.price 50 | end 51 | 52 | test 'json attribute' do 53 | issue = TestIssue.create! orders: {'a' => 'b'} 54 | assert_equal({'a' => 'b'}, issue.orders) 55 | 56 | issue = TestIssue.find issue.id 57 | assert_equal({'a' => 'b'}, issue.orders) 58 | end 59 | 60 | test 'string attribute' do 61 | issue = TestIssue.create! name: 'hola' 62 | assert_equal('hola', issue.name) 63 | 64 | issue = TestIssue.find issue.id 65 | assert_equal('hola', issue.name) 66 | 67 | issue = TestIssue.create! name: 42 68 | assert_equal '42', issue.name 69 | end 70 | 71 | test 'integer_range' do 72 | issue = TestIssue.create! age_range: ['70', nil] 73 | assert_equal 70.., issue.age_range 74 | 75 | issue = TestIssue.find issue.id 76 | assert_equal 70.., issue.age_range 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/unit/base_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::BaseTest < Superstore::TestCase 4 | class Son < Superstore::Base 5 | end 6 | 7 | class Grandson < Son 8 | end 9 | 10 | test 'base_class' do 11 | assert_equal Superstore::Base, Superstore::Base 12 | assert_equal Son, Son.base_class 13 | assert_equal Son, Grandson.base_class 14 | end 15 | 16 | test 'table_name' do 17 | assert_equal 'sons', Son.table_name 18 | assert_equal 'sons', Grandson.table_name 19 | end 20 | 21 | test 'translations' do 22 | assert_equal :activerecord, Son.i18n_scope 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/unit/caching_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::CachingTest < Superstore::TestCase 4 | class ::OtherClass < Superstore::Base 5 | self.table_name = 'issues' 6 | end 7 | 8 | test 'for a new record' do 9 | issue = Issue.new 10 | other_class = OtherClass.new 11 | assert_equal "issues/new", issue.cache_key 12 | assert_equal "other_classes/new", other_class.cache_key 13 | end 14 | 15 | test 'for a persisted record' do 16 | updated_at = Time.now 17 | issue = Issue.create!(id: 1, updated_at: updated_at) 18 | 19 | assert_equal "issues/1-#{updated_at.utc.to_fs(:usec)}", issue.cache_key 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/unit/callbacks_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::CallbacksTest < Superstore::TestCase 4 | class TestIssue < Superstore::Base 5 | self.table_name = 'issues' 6 | attribute :description, type: :string 7 | 8 | %w( 9 | before_validation 10 | after_validation 11 | before_save 12 | after_save 13 | after_create 14 | after_update 15 | after_destroy 16 | ).each do |method| 17 | send(method) do 18 | callback_history << method 19 | end 20 | end 21 | 22 | def reset_callback_history 23 | @callback_history = [] 24 | end 25 | 26 | def callback_history 27 | @callback_history ||= [] 28 | end 29 | end 30 | 31 | test 'create' do 32 | issue = TestIssue.create 33 | 34 | expected = %w( 35 | before_validation 36 | after_validation 37 | before_save 38 | after_create 39 | after_save 40 | ) 41 | assert_equal expected, issue.callback_history 42 | end 43 | 44 | test 'update' do 45 | issue = TestIssue.create 46 | issue.reset_callback_history 47 | 48 | issue.update_attribute :description, 'foo' 49 | 50 | assert_equal %w(before_save after_update after_save), issue.callback_history 51 | end 52 | 53 | test 'destroy' do 54 | issue = TestIssue.create 55 | issue.reset_callback_history 56 | 57 | issue.destroy 58 | 59 | assert_equal ['after_destroy'], issue.callback_history 60 | end 61 | 62 | test 'new_record during callbacks' do 63 | class NewRecordTestClass < Superstore::Base 64 | self.table_name = 'issues' 65 | attribute :description, type: :string 66 | 67 | before_create :expect_new_record 68 | before_save :expect_new_record 69 | after_create :refute_new_record 70 | after_save :refute_new_record 71 | 72 | def expect_new_record 73 | raise "Expected new_record? to be true!" unless new_record? 74 | end 75 | 76 | def refute_new_record 77 | raise "Expected new_record? to be false!" if new_record? 78 | end 79 | end 80 | 81 | NewRecordTestClass.create 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/unit/connection_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::ConnectionTest < Superstore::TestCase 4 | class TestObject < Superstore::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/unit/core_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::CoreTest < Superstore::TestCase 4 | test 'initialize' do 5 | issue = Issue.new 6 | 7 | assert issue.new_record? 8 | assert !issue.destroyed? 9 | end 10 | 11 | test 'equality of new records' do 12 | assert_not_equal Issue.new, Issue.new 13 | end 14 | 15 | test 'equality' do 16 | first_issue = Issue.create 17 | second_issue = Issue.create 18 | 19 | assert_equal first_issue, first_issue 20 | assert_equal first_issue, Issue.find(first_issue.id) 21 | assert_not_equal first_issue, second_issue 22 | end 23 | 24 | test 'to_param' do 25 | issue = Issue.new 26 | assert_equal issue.id, issue.to_param 27 | end 28 | 29 | test 'hash' do 30 | issue = Issue.create 31 | issue2 = Issue.create 32 | refute_equal issue.hash, issue2.hash 33 | 34 | issue3 = Issue.new(id: issue.id) 35 | assert_equal issue.hash, issue3.hash 36 | 37 | user = User.new(id: issue.id) 38 | refute_equal issue.hash, user.hash 39 | end 40 | 41 | test 'inspect' do 42 | issue = Issue.create 43 | assert issue.inspect =~ /^#$/ 44 | end 45 | 46 | test 'inspect class' do 47 | expected = "Issue(widget_id: integer, id: string, description: string, title: string, parent_issue_id: string, comments: json, tags: array, created_at: time, updated_at: time)" 48 | assert_equal expected, Issue.inspect 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/unit/identity_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::IdentityTest < Superstore::TestCase 4 | test 'primary_key' do 5 | assert_equal 'id', Issue.primary_key 6 | end 7 | 8 | test 'default _generate_key' do 9 | issue = Issue.new 10 | 11 | assert_not_nil Issue._generate_key(issue) 12 | end 13 | 14 | test 'custom key' do 15 | model = temp_object do 16 | key do 17 | "name:#{name}" 18 | end 19 | attr_accessor :name 20 | end 21 | record = model.new 22 | record.name = 'bar' 23 | 24 | assert_equal 'name:bar', model._generate_key(record) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/unit/persistence_test.rb: -------------------------------------------------------------------------------- 1 | #!/bin/env ruby 2 | # encoding: utf-8 3 | 4 | require 'test_helper' 5 | 6 | class Superstore::PersistenceTest < Superstore::TestCase 7 | class Parent < Superstore::Base 8 | self.inheritance_column = 'document_type' 9 | attribute :document_type, type: :string 10 | 11 | def self.find_sti_class(type) 12 | Child 13 | end 14 | end 15 | 16 | class Child < Parent 17 | attribute :eye_color, type: :string 18 | end 19 | 20 | test 'instantiate with unknowns' do 21 | issue = Issue.instantiate('id' => 'theid', 'document' => {'z' => 'nooo', 'title' => 'Krazy'}.to_json) 22 | 23 | refute issue.attributes.key?('z') 24 | assert_equal 'Krazy', issue.attributes['title'] 25 | end 26 | 27 | test 'instantiate with an inheritance column' do 28 | child = Parent.instantiate('id' => 'theid', 'document' => {'document_type' => 'child', 'eye_color' => 'blue'}.to_json) 29 | 30 | assert_kind_of Child, child 31 | assert_equal 'blue', child.eye_color 32 | end 33 | 34 | test 'instantiate when an inheritance column is expected but is nil' do 35 | child = Parent.instantiate('id' => 'theid', 'document' => { }.to_json) 36 | 37 | assert_kind_of Child, child 38 | end 39 | 40 | test 'persistence inquiries' do 41 | issue = Issue.new 42 | assert issue.new_record? 43 | assert !issue.persisted? 44 | 45 | issue.save 46 | assert issue.persisted? 47 | assert !issue.new_record? 48 | end 49 | 50 | test 'create' do 51 | issue = Issue.create { |i| i.description = 'foo' } 52 | assert_equal 'foo', issue.description 53 | assert_equal 'foo', Issue.find(issue.id).description 54 | end 55 | 56 | test 'read and write UTF' do 57 | utf = "\ucba1\ucba2\ucba3 ƒ´∑ƒ©√åµ≈√ˆअनुच्छेद´µøµø¬≤ 汉语漢語'".force_encoding(Encoding::UTF_8) 58 | 59 | issue = Issue.create { |i| i.description = utf } 60 | assert_equal utf, issue.description 61 | reloaded = Issue.find(issue.id).description 62 | assert_equal utf, reloaded 63 | end 64 | 65 | test 'save' do 66 | issue = Issue.new 67 | issue.save 68 | 69 | assert_equal issue, Issue.find(issue.id) 70 | end 71 | 72 | test 'save!' do 73 | klass = temp_object do 74 | attribute :description, type: :string 75 | validates :description, presence: true 76 | end 77 | 78 | record = klass.new(description: 'bad') 79 | record.save! 80 | 81 | assert_raise ActiveRecord::RecordInvalid do 82 | record = klass.new 83 | record.save! 84 | end 85 | end 86 | 87 | test 'destroy' do 88 | issue = Issue.create 89 | issue.destroy 90 | 91 | assert issue.destroyed? 92 | assert !issue.persisted? 93 | assert !issue.new_record? 94 | end 95 | 96 | test 'update_attribute' do 97 | issue = Issue.create 98 | issue.update_attribute(:description, 'lol') 99 | 100 | assert !issue.changed? 101 | assert_equal 'lol', issue.description 102 | end 103 | 104 | test 'update' do 105 | issue = Issue.create 106 | issue.update(description: 'lol') 107 | 108 | assert !issue.changed? 109 | assert_equal 'lol', issue.description 110 | end 111 | 112 | test 'update!' do 113 | begin 114 | Issue.validates(:description, presence: true) 115 | 116 | issue = Issue.new(description: 'bad') 117 | issue.save! 118 | 119 | assert_raise ActiveRecord::RecordInvalid do 120 | issue.update! description: '' 121 | end 122 | ensure 123 | Issue.reset_callbacks(:validate) 124 | end 125 | end 126 | 127 | test 'update nil attributes' do 128 | issue = Issue.create(title: "It's not fair!'", description: 'lololol') 129 | 130 | issue.update title: nil 131 | 132 | issue = Issue.find issue.id 133 | assert_nil issue.title 134 | end 135 | 136 | test 'becomes' do 137 | klass = temp_object 138 | 139 | assert_kind_of klass, Issue.new.becomes(klass) 140 | end 141 | 142 | test 'becomes includes changed_attributes' do 143 | klass = temp_object do 144 | attribute :title, type: :string 145 | end 146 | 147 | issue = Issue.new(title: 'Something is wrong') 148 | other = issue.becomes(klass) 149 | 150 | assert_equal 'Something is wrong', other.title 151 | assert_equal %w(title), other.changed 152 | end 153 | 154 | test 'reload' do 155 | persisted_issue = Issue.create 156 | fresh_issue = Issue.find(persisted_issue.id) 157 | fresh_issue.update_attribute(:description, "It's not fair!") 158 | 159 | reloaded_issue = persisted_issue.reload 160 | assert_equal "It's not fair!", persisted_issue.description 161 | assert_equal persisted_issue, reloaded_issue 162 | end 163 | 164 | test 'delete' do 165 | klass = temp_object do 166 | attribute :name, type: :string 167 | end 168 | 169 | record = klass.new(name: 'cool') 170 | record.save! 171 | 172 | id = record.id 173 | assert_equal id, klass.find(id).id 174 | 175 | klass.delete(id) 176 | 177 | assert_raise ActiveRecord::RecordNotFound do 178 | klass.find(id) 179 | end 180 | end 181 | 182 | test 'delete multiple' do 183 | klass = temp_object do 184 | attribute :name, type: :string 185 | end 186 | 187 | ids = [] 188 | (1..10).each do 189 | record = klass.create!(name: 'cool') 190 | ids << record.id 191 | end 192 | 193 | klass.delete(ids) 194 | 195 | assert_equal [], klass.where(id: ids) 196 | end 197 | 198 | test 'find_by_id' do 199 | Issue.create.tap do |issue| 200 | assert_equal issue, Issue.find_by_id(issue.id) 201 | end 202 | 203 | assert_nil Issue.find_by_id('what') 204 | end 205 | 206 | test 'saves non-superstore column' do 207 | i = Issue.new 208 | i.widget_id = 100 209 | i.description = 'Abraham Lincoln' 210 | i.save! 211 | 212 | saved_record = ActiveRecord::Base.connection.select_one("SELECT * FROM #{Issue.table_name} WHERE #{Issue.primary_key} = '#{i.id}'") 213 | assert_equal i.id, saved_record['id'] 214 | json = JSON.parse(saved_record['document']) 215 | assert_equal %w(created_at description updated_at), json.keys.sort 216 | assert_equal 100, saved_record['widget_id'] 217 | 218 | saved_record = Issue.find(i.id) 219 | assert_equal i.id, saved_record.id 220 | assert_equal 'Abraham Lincoln', saved_record.description 221 | assert_equal 100, saved_record.widget_id 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /test/unit/relation/scrolling_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::ScrollingTest < Superstore::TestCase 4 | ActiveRecord::Relation.class_eval do 5 | include Superstore::Relation::Scrolling 6 | end 7 | 8 | test 'scroll_each' do 9 | Issue.create 10 | Issue.create 11 | 12 | issues = [] 13 | Issue.all.scroll_each do |issue| 14 | issues << issue 15 | end 16 | 17 | assert_equal Issue.all.to_set, issues.to_set 18 | end 19 | 20 | test 'scroll_in_batches' do 21 | Issue.create 22 | Issue.create 23 | Issue.create 24 | 25 | issue_batches = [] 26 | Issue.all.scroll_in_batches(batch_size: 2) do |issues| 27 | issue_batches << issues 28 | end 29 | 30 | assert_equal 2, issue_batches.size 31 | assert issue_batches.any? { |issues| issues.size == 2 } 32 | assert issue_batches.any? { |issues| issues.size == 1 } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/unit/serialization_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::SerializationTest < Superstore::TestCase 4 | test 'as_json' do 5 | issue = Issue.new 6 | expected = { 7 | "widget_id" => nil, 8 | "id" => issue.id, 9 | "created_at" => nil, 10 | "updated_at" => nil, 11 | "description" => nil, 12 | "tags" => nil, 13 | "title" => nil, 14 | "parent_issue_id" => nil, 15 | "comments" => nil 16 | } 17 | 18 | assert_equal expected, issue.as_json 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/unit/timestamp_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::TimestampTest < Superstore::TestCase 4 | test 'timestamps set on create' do 5 | issue = Issue.create#issue.created_at.to_i = #{issue.created_at.to_i} 6 | 7 | assert_in_delta Time.now.to_i, issue.created_at.to_i, 3 8 | assert_in_delta Time.now.to_i, issue.updated_at.to_i, 3 9 | end 10 | 11 | test 'updated_at set nil on change' do 12 | issue = Issue.create 13 | 14 | issue.updated_at = nil 15 | issue.description = 'lol' 16 | issue.save 17 | 18 | assert issue.updated_at.nil? 19 | end 20 | 21 | test 'updated_at can be set on change' do 22 | issue = Issue.create 23 | 24 | issue.update_attribute :updated_at, 30.days.ago 25 | 26 | assert_in_delta 30.days.ago.to_i, issue.updated_at.to_i, 3 27 | end 28 | 29 | test 'created_at sets only if nil' do 30 | time = 5.days.ago 31 | issue = Issue.create created_at: time 32 | 33 | assert_in_delta time.to_i, issue.created_at.to_i, 3 34 | end 35 | 36 | test 'updated_at sets only if nil' do 37 | time = 5.days.ago.utc 38 | issue = Issue.create updated_at: time 39 | 40 | assert_in_delta time.to_i, issue.updated_at.to_i, 3 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/unit/types/array_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::ArrayTypeTest < Superstore::Types::TestCase 4 | test 'cast_value' do 5 | assert_equal ['x', 'y'], type.cast_value(['x', 'y'].to_set) 6 | assert_equal ['x'], type.cast_value('x') 7 | assert_equal [], type.cast_value([]) 8 | assert_equal [], type.cast_value(nil) 9 | end 10 | 11 | test 'serializes empty array to nil' do 12 | tags = %w(foo bar) 13 | issue1 = Issue.new(tags:) 14 | issue2 = Issue.new(tags: []) 15 | 16 | assert_equal tags, issue1.tags 17 | assert_equal [], issue2.tags 18 | 19 | [issue1, issue2].each(&:save!).each(&:reload) 20 | 21 | assert_equal tags, issue1.tags 22 | assert_nil issue2.tags 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/unit/types/boolean_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::BooleanTypeTest < Superstore::Types::TestCase 4 | test 'cast_value' do 5 | assert_equal true, type.cast_value('1') 6 | assert_equal true, type.cast_value('true') 7 | assert_equal true, type.cast_value('true') 8 | assert_equal false, type.cast_value('0') 9 | assert_equal false, type.cast_value(false) 10 | assert_nil type.cast_value('') 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/unit/types/date_range_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::DateRangeTypeTest < Superstore::Types::TestCase 4 | test 'serialize' do 5 | assert_equal ["2004-04-25", "2004-05-15"], type.serialize(Date.new(2004, 4, 25) .. Date.new(2004, 5, 15)) 6 | end 7 | 8 | test 'deserialize' do 9 | assert_equal Date.new(2004, 4, 25)..Date.new(2004, 5, 15), type.deserialize(["2004-04-25", "2004-05-15"]) 10 | assert_nil type.deserialize(["2004-05-15", "2004-04-25"]) 11 | 12 | # decode returns argument if already a Range 13 | range = Date.new(2019, 1, 1)..Date.new(2019, 2, 1) 14 | assert_equal range, type.deserialize(range) 15 | end 16 | 17 | test 'cast_value' do 18 | assert_equal Date.new(2004, 4, 25)..Date.new(2004, 5, 15), type.cast_value(Date.new(2004, 4, 25)..Date.new(2004, 5, 15)) 19 | assert_equal Date.new(2004, 4, 25)..Date.new(2004, 5, 15), type.cast_value([Date.new(2004, 4, 25), Date.new(2004, 5, 15)]) 20 | assert_equal Date.new(2004, 4, 25)..Date.new(2004, 5, 15), type.cast_value(["2004-04-25", "2004-05-15"]) 21 | assert_equal Date.new(2004, 4, 25)..Date.new(2004, 4, 25), type.cast_value(["2004-04-25", "2004-04-25"]) 22 | assert_equal Date.new(2004, 4, 25).., type.cast_value(["2004-04-25", nil]) 23 | 24 | assert_nil type.cast_value( Date.new(2004, 5, 15)..Date.new(2004, 4, 25)) 25 | assert_nil type.cast_value([Date.new(2004, 5, 15), Date.new(2004, 4, 25)]) 26 | assert_nil type.cast_value([nil, "2004-05-15"]) 27 | assert_nil type.cast_value(["xx", "2004-05-15"]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/unit/types/date_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::DateTypeTest < Superstore::Types::TestCase 4 | test 'serialize' do 5 | assert_equal '2004-04-25', type.serialize(Date.new(2004, 4, 25)) 6 | end 7 | 8 | test 'deserialize' do 9 | assert_equal Date.new(2004, 4, 25), type.deserialize('2004-04-25') 10 | end 11 | 12 | test 'cast_value' do 13 | assert_nil type.cast_value(1000) 14 | assert_nil type.cast_value(1000.0) 15 | assert_nil type.cast_value('') 16 | assert_nil type.cast_value('nil') 17 | assert_nil type.cast_value('bad format') 18 | assert_equal Date.new(2004, 4, 25), type.cast_value('2004-04-25') 19 | assert_equal Date.new(2017, 5, 1), type.cast_value('2017-05-01T21:39:06.796897Z') 20 | 21 | my_time = Time.current 22 | assert_equal my_time.to_date, type.cast_value(my_time) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/unit/types/float_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::FloatTypeTest < Superstore::Types::TestCase 4 | test 'cast_value' do 5 | assert_nil type.cast_value('xyz') 6 | assert_equal 1.1, type.cast_value('1.1') 7 | assert_equal 42.0, type.cast_value(42) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/unit/types/geo_point_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::GeoPointTypeTest < Superstore::Types::TestCase 4 | test 'deserialize' do 5 | lat, lon = 47.604, -122.329 6 | seattle = {lat: lat, lon: lon} 7 | 8 | assert_equal seattle, type.deserialize('lat' => lat, 'lon' => lon) 9 | end 10 | 11 | test 'cast_value' do 12 | lat, lon = 47.604, -122.329 13 | seattle = {lat: lat, lon: lon} 14 | 15 | assert_equal seattle, type.cast_value(lat: lat, lon: lon) 16 | assert_equal seattle, type.cast_value({ "lat" => lat, "lon" => lon }) 17 | assert_equal seattle, type.cast_value([lat, lon]) 18 | 19 | assert_equal({lat: 0.0, lon: 0.0}, type.cast_value(lat: "cats", lon: "dogs")) 20 | 21 | assert_nil type.cast_value([]) 22 | assert_nil type.cast_value('invalid') 23 | end 24 | 25 | test 'type' do 26 | assert_equal 'geo_point', type.type 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/unit/types/integer_range_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::IntegerRangeTypeTest < Superstore::Types::TestCase 4 | test 'serialize' do 5 | assert_equal [4, 5], type.serialize(4..5) 6 | assert_equal [4, nil], type.serialize(4..) 7 | assert_equal [nil, 5], type.serialize(-Float::INFINITY..5) 8 | assert_equal [nil, nil], type.serialize(-Float::INFINITY..) 9 | end 10 | 11 | test 'deserialize' do 12 | assert_equal 4..5, type.deserialize([4, 5]) 13 | assert_nil type.deserialize([5, 4]) 14 | assert_equal 4.., type.deserialize([4, nil]) 15 | assert_equal (-Float::INFINITY..5), type.deserialize([nil, 5]) 16 | assert_equal (-Float::INFINITY..), type.deserialize([nil, nil]) 17 | end 18 | 19 | test 'cast_value' do 20 | assert_equal 1..5, type.cast_value(1..5) 21 | assert_nil type.cast_value(5..1) 22 | assert_equal 1..5, type.cast_value([1, 5]) 23 | assert_nil type.cast_value([5, 1]) 24 | assert_equal 1.., type.cast_value([1, nil]) 25 | assert_equal (-Float::INFINITY..2), type.cast_value([nil, 2]) 26 | assert_equal (-Float::INFINITY..), type.cast_value([nil, nil]) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/unit/types/integer_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::IntegerTypeTest < Superstore::Types::TestCase 4 | test 'cast_value' do 5 | assert_nil type.cast_value('') 6 | assert_nil type.cast_value('abc') 7 | assert_equal 3, type.cast_value(3) 8 | assert_equal 3, type.cast_value('3') 9 | assert_equal(-3, type.cast_value('-3')) 10 | assert_equal 27, type.cast_value('027') 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/unit/types/json_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::JsonTypeTest < Superstore::Types::TestCase 4 | test 'serializes empty json to nil' do 5 | comments = { 'foo' => 'bar' } 6 | issue1 = Issue.new(comments:) 7 | issue2 = Issue.new(comments: []) 8 | issue3 = Issue.new(comments: {}) 9 | 10 | assert_equal comments, issue1.comments 11 | assert_equal [], issue2.comments 12 | assert_equal({}, issue3.comments) 13 | 14 | [issue1, issue2, issue3].each(&:save!).each(&:reload) 15 | 16 | assert_equal comments, issue1.comments 17 | assert_nil issue2.comments 18 | assert_nil issue3.comments 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/unit/types/string_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::StringTypeTest < Superstore::Types::TestCase 4 | test 'serialize' do 5 | assert_equal 'abc', type.serialize('abc') 6 | end 7 | 8 | test 'serialize as utf' do 9 | assert_equal( 10 | '123'.force_encoding('UTF-8').encoding, 11 | type.serialize('123'.force_encoding('ASCII-8BIT')).encoding 12 | ) 13 | end 14 | 15 | test 'serialize frozen as utf' do 16 | assert_equal( 17 | '123'.force_encoding('UTF-8').encoding, 18 | type.serialize('123'.force_encoding('ASCII-8BIT').freeze).encoding 19 | ) 20 | end 21 | 22 | test 'cast_value' do 23 | assert_equal '123', type.cast_value(123) 24 | assert_equal '123', type.cast_value('123') 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/unit/types/time_type_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::Types::TimeTypeTest < Superstore::Types::TestCase 4 | test 'serialize' do 5 | assert_equal '2004-12-24T01:02:03.000000Z', type.serialize(Time.utc(2004, 12, 24, 1, 2, 3)) 6 | assert_nil type.serialize(nil) 7 | end 8 | 9 | test 'deserialize' do 10 | assert_nil type.deserialize(nil) 11 | assert_nil type.deserialize('bad format') 12 | assert_equal Time.utc(2004, 12, 24, 1, 2, 3), type.deserialize('2004-12-24T01:02:03.000000Z') 13 | assert_equal Time.utc(2004, 12, 24, 12, 13, 45), type.deserialize('2004-12-24 12:13:45 -0000') 14 | 15 | Time.use_zone 'Central Time (US & Canada)' do 16 | with_zone = type.deserialize('2013-07-18T13:12:46-07:00') 17 | assert_equal Time.utc(2013, 07, 18, 20, 12, 46), with_zone 18 | assert_equal 'CDT', with_zone.zone 19 | end 20 | end 21 | 22 | test 'cast_value' do 23 | assert_nil type.cast_value(1000) 24 | assert_nil type.cast_value(1000.0) 25 | 26 | my_date = Date.new 27 | assert_equal my_date.to_time, type.cast_value(my_date) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/unit/validations_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Superstore::ValidationsTest < Superstore::TestCase 4 | test 'create!' do 5 | begin 6 | Issue.validates(:description, presence: true) 7 | 8 | Issue.create!(description: 'lol') 9 | 10 | assert_raise(ActiveRecord::RecordInvalid) { Issue.create!(description: '') } 11 | ensure 12 | Issue.reset_callbacks(:validate) 13 | end 14 | end 15 | 16 | test 'save!' do 17 | begin 18 | Issue.validates(:description, presence: true) 19 | 20 | Issue.new(description: 'lol').save! 21 | 22 | assert_raise(ActiveRecord::RecordInvalid) { Issue.new(description: '').save! } 23 | ensure 24 | Issue.reset_callbacks(:validate) 25 | end 26 | end 27 | end 28 | --------------------------------------------------------------------------------