pax_global_header00006660000000000000000000000064151572135470014524gustar00rootroot0000000000000052 comment=cef7a810a48ce9c3b8a79015591fddfb1a963e5a madeintandem-jsonb_accessor-7626103/000077500000000000000000000000001515721354700173375ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/.github/000077500000000000000000000000001515721354700206775ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/.github/dependabot.yml000066400000000000000000000001661515721354700235320ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: 'github-actions' directory: '/' schedule: interval: 'weekly' madeintandem-jsonb_accessor-7626103/.github/workflows/000077500000000000000000000000001515721354700227345ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/.github/workflows/ci.yml000066400000000000000000000054371515721354700240630ustar00rootroot00000000000000name: CI on: push: branches: [master] pull_request: branches: [master] jobs: lint: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: "3.4" bundler-cache: true - run: bundle exec rubocop tests: needs: lint runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: ruby: ["ruby-3.2", "ruby-3.3", "ruby-3.4", "ruby-4.0", "jruby-9.4", "jruby-10.0"] activerecord: ["6.1", "7.0", "7.1", "7.2", "8.0", "8.1"] postgresql: ["13", "14", "15", "16", "17", "18"] exclude: # fails due to "ArgumentError: when initializing an Active Record adapter with a config hash, that should be the only argument" in db:schema:load - ruby: "jruby-9.4" activerecord: "7.2" - ruby: "jruby-9.4" activerecord: "8.0" # fails due to "Because activerecord >= 8.0.0.beta1 depends on Ruby >= 3.2.0 and Gemfile depends on activerecord ~> 8.1, Ruby >= 3.2.0 is required. So, because current Ruby version is = 3.1.7, version solving has failed." - ruby: "jruby-9.4" activerecord: "8.1" # https://github.com/jruby/activerecord-jdbc-adapter/issues/1173 - ruby: "jruby-10.0" activerecord: "8.0" # https://github.com/jruby/activerecord-jdbc-adapter/issues/1184 - ruby: "jruby-10.0" activerecord: "8.1" name: "Active Record ${{ matrix.activerecord }} with PostgreSQL ${{ matrix.postgresql }} on ${{ matrix.ruby }}" services: db: image: postgres:${{ matrix.postgresql }} env: POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_DB: jsonb_accessor ports: ['5432:5432'] options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - run: echo "gem 'activerecord', '~> ${{ matrix.activerecord }}.0'" > Gemfile.local - if: matrix.activerecord == '6.1' # see https://github.com/rails/rails/pull/54264#issuecomment-2596149819 and https://www.ruby-lang.org/en/news/2024/12/25/ruby-3-4-0-released/#standard-library-updates and https://www.ruby-lang.org/en/news/2025/12/25/ruby-4-0-0-released/#stdlib-updates run: printf "gem 'concurrent-ruby', '< 1.3.5'\ngem 'mutex_m'\ngem 'base64'\ngem 'bigdecimal'\ngem 'logger'\n" >> Gemfile.local - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - run: bundle exec rake db:schema:load - run: bundle exec rake spec madeintandem-jsonb_accessor-7626103/.github/workflows/push_gem.yml000066400000000000000000000021421515721354700252650ustar00rootroot00000000000000name: Publish gem to rubygems.org on: push: tags: - 'v*' permissions: contents: read jobs: push: if: github.repository == 'madeintandem/jsonb_accessor' runs-on: ubuntu-24.04 permissions: contents: write id-token: write strategy: matrix: ruby: ["ruby-3.4", "jruby-9.4"] steps: - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 with: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - if: matrix.ruby == 'jruby-9.4' # cribbed from https://github.com/ruby/psych/blob/v5.2.4/.github/workflows/push_gem.yml run: | sudo apt install default-jdk maven gem update --system gem install ruby-maven rake-compiler --no-document rake compile - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: bundler-cache: true ruby-version: ${{ matrix.ruby }} - uses: rubygems/release-gem@e9a6361a0b14562539327c2a02373edc56dd3169 # v1.1.4 madeintandem-jsonb_accessor-7626103/.gitignore000066400000000000000000000002001515721354700213170ustar00rootroot00000000000000/.bundle/ /.yardoc /Gemfile.lock /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ gemfiles/.bundle gemfiles/*.gemfile.lock madeintandem-jsonb_accessor-7626103/.rspec000066400000000000000000000000321515721354700204470ustar00rootroot00000000000000--format progress --color madeintandem-jsonb_accessor-7626103/.rubocop.yml000066400000000000000000000024661515721354700216210ustar00rootroot00000000000000AllCops: NewCops: enable TargetRubyVersion: 3.2.8 SuggestExtensions: false Exclude: - "db/**/*" - "gemfiles/**/*" - "vendor/**/*" Layout/SpaceBeforeFirstArg: Enabled: false Layout/LineLength: Enabled: false Layout/SpaceAroundEqualsInParameterDefault: Enabled: false Lint/UnusedBlockArgument: Enabled: false Lint/UnusedMethodArgument: Enabled: false Metrics/AbcSize: Enabled: false Metrics/ClassLength: Enabled: false Metrics/CyclomaticComplexity: Enabled: false Metrics/MethodLength: Enabled: false Metrics/ModuleLength: Enabled: false Metrics/PerceivedComplexity: Enabled: false Metrics/BlockLength: Enabled: false Style/ClassAndModuleChildren: Enabled: false Style/ClassVars: Enabled: false Style/Documentation: Enabled: false Style/DoubleNegation: Enabled: false Naming/FileName: Enabled: false Style/GuardClause: Enabled: false Style/NilComparison: Enabled: false Style/RescueModifier: Enabled: false Style/SignalException: Enabled: false Style/SingleLineMethods: Enabled: false Style/StringLiterals: EnforcedStyle: double_quotes Naming/BinaryOperatorParameterName: Enabled: false Naming/VariableNumber: Enabled: false Gemspec/RequiredRubyVersion: Enabled: false Gemspec/RequireMFA: Enabled: false Gemspec/DevelopmentDependencies: EnforcedStyle: gemspec madeintandem-jsonb_accessor-7626103/.ruby-version000066400000000000000000000000061515721354700220000ustar00rootroot000000000000003.4.3 madeintandem-jsonb_accessor-7626103/CHANGELOG.md000066400000000000000000000053121515721354700211510ustar00rootroot00000000000000# Changelog ## [Unreleased] ## [1.4.2] - 2026-03-20 ### Fixed - Bug fix: Persisted records with sparse JSONB data returned `nil` instead of declared defaults for fields absent from the column. This regression was introduced in 1.4.1 during the `prefix`/`suffix` refactor, where `options.delete(:default)` mutated the options hash before passing it to `attribute`, stripping the default from the virtual attribute definition. ### Changed - Documented the backward-compatible positional hash syntax in `jsonb_accessor`, where definitions are passed as a plain hash (jsonb_accessor :col, { foo: :string }) ## [1.4.1] - 2026-01-22 ### Added - Support for `prefix` and `suffix` options to customize attribute accessor names while preserving original keys in the JSONB column. [#173](https://github.com/madeintandem/jsonb_accessor/issues/173) ## [1.4] - 2023-10-15 ### Breaking change - `jsonb_accessor` dropped support for Ruby 2 and Rails versions lower than 6.1. Support for ActiveRecord::Enum was also dropped because ActiveRecord 7.1 now requires each enum field to be backed by a database column. Enums will still work when using AR versions lower than 7.1. This is a limitation of Rails, not of this gem. ### Fixed - Bug fix: An array of datetimes previously caused an error. https://github.com/madeintandem/jsonb_accessor/pull/169. Thanks @bekicot. - Rails 7.1 is officially supported and tested against. ## [1.3.10] - 2023-05-30 ### No changes A new release was necessary to fix the corrupted 1.3.9 Java release on RubyGems. ## [1.3.9] - 2023-05-30 ### No changes A new release was necessary to fix the corrupted 1.3.8 Java release on RubyGems. ## [1.3.8] - 2023-05-29 ### Fixes - Support for ActiveRecord::Enum. [#163](https://github.com/madeintandem/jsonb_accessor/pull/163) ## [1.3.7] - 2022-12-29 - jruby support. jsonb_accessor now depends on `activerecord-jdbcpostgresql-adapter` instead of `pg` when the RUBY_PLATFORM is java. [#157](https://github.com/madeintandem/jsonb_accessor/pull/157) ## [1.3.6] - 2022-09-23 ### Fixed - Bug fix: Datetime values were not properly deserialized [#155](https://github.com/madeintandem/jsonb_accessor/pull/155) ## [1.3.5] - 2022-07-23 ### Fixed - Bug fix: Attributes defined outside of jsonb_accessor are not written [#149](https://github.com/madeintandem/jsonb_accessor/pull/149) ## [1.3.4] - 2022-02-02 ### Fixed - Bug fix: Raised ActiveModel::MissingAttributeError when model was initialized without the jsonb_accessor field [#145](https://github.com/madeintandem/jsonb_accessor/issues/145) ## [1.3.3] - 2022-01-29 ### Fixed - Bug fix: DateTime objects are now correctly written without timezone information [#137](https://github.com/madeintandem/jsonb_accessor/pull/137). Thanks @caiohsramos madeintandem-jsonb_accessor-7626103/CODE_OF_CONDUCT.md000066400000000000000000000026151515721354700221420ustar00rootroot00000000000000# Contributor Code of Conduct As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) madeintandem-jsonb_accessor-7626103/Gemfile000066400000000000000000000003671515721354700206400ustar00rootroot00000000000000# frozen_string_literal: true source "https://rubygems.org" # Specify your gem's dependencies in jsonb_accessor.gemspec gemspec local_gemfile = File.expand_path("Gemfile.local", __dir__) eval_gemfile local_gemfile if File.exist?(local_gemfile) madeintandem-jsonb_accessor-7626103/LICENSE.txt000066400000000000000000000020731515721354700211640ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2015 Michael Crismali Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. madeintandem-jsonb_accessor-7626103/Makefile000066400000000000000000000007151515721354700210020ustar00rootroot00000000000000build-gem: @docker build --build-arg RUBY_PLATFORM=ruby --build-arg RUBY_VERSION=3.2.2 -t jsonb_accessor-ruby:3.2.2 . @docker run --rm -v $(PWD):/usr/src/app -w /usr/src/app jsonb_accessor-ruby:3.2.2 gem build build-gem-java: @docker build --build-arg RUBY_PLATFORM=jruby --build-arg RUBY_VERSION=9.4.2-jdk -t jsonb_accessor-jruby:9.4.2-jdk . @docker run --rm -v $(PWD):/usr/src/app -w /usr/src/app jsonb_accessor-jruby:9.4.2-jdk gem build --platform java madeintandem-jsonb_accessor-7626103/README.md000066400000000000000000000320321515721354700206160ustar00rootroot00000000000000# JSONb Accessor Created by     [Tandem Logo](https://www.madeintandem.com/) [![Gem Version](https://badge.fury.io/rb/jsonb_accessor.svg)](http://badge.fury.io/rb/jsonb_accessor)    ![CI](https://github.com/madeintandem/jsonb_accessor/actions/workflows/ci.yml/badge.svg) JSONb Accessor Logo Adds typed `jsonb` backed fields as first class citizens to your `ActiveRecord` models. This gem is similar in spirit to [HstoreAccessor](https://github.com/madeintandem/hstore_accessor), but the `jsonb` column in PostgreSQL has a few distinct advantages, mostly around nested documents and support for collections. It also adds generic scopes for querying `jsonb` columns. ## ⚠️ Status This gem is in maintenance mode and no active development of new features is planned. The major focus is to keep it working with new Ruby/Rails versions and fix any bugs reported. Any PRs for feature requests or enhancements will be reviewed and merged -- so contributions are encouraged! ## Table of Contents - [Installation](#installation) - [Usage](#usage) - [Scopes](#scopes) - [Single-Table Inheritance](#single-table-inheritance) - [Dependencies](#dependencies) - [Validations](#validations) - [Upgrading](#upgrading) - [Development](#development) - [Contributing](#contributing) ## Installation Add this line to your application's `Gemfile`: ```ruby gem "jsonb_accessor" ``` And then execute: $ bundle install ## Usage First we must create a model which has a `jsonb` column available to store data into it: ```ruby class CreateProducts < ActiveRecord::Migration def change create_table :products do |t| t.jsonb :data end end end ``` We can then declare the `jsonb` fields we wish to expose via the accessor: ```ruby class Product < ActiveRecord::Base jsonb_accessor :data, title: :string, external_id: :integer, reviewed_at: :datetime end ``` Any type the [`attribute` API](http://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-attribute) supports. You can also implement your own type by following the example in the `attribute` documentation. To pass through options like `default` and `array` to the `attribute` API, just put them in an array. ```ruby class Product < ActiveRecord::Base jsonb_accessor :data, title: [:string, default: "Untitled"], previous_titles: [:string, array: true, default: []] end ``` The `default` option works pretty much as you would expect in practice; if no values are set for the attributes, a hash of the specified default values is saved to the jsonb column. You can also pass in a `store_key` option. ```ruby class Product < ActiveRecord::Base jsonb_accessor :data, title: [:string, store_key: :t] end ``` This allows you to use `title` for your getters and setters, but use `t` as the key in the `jsonb` column. ```ruby product = Product.new(title: "Foo") product.title #=> "Foo" product.data #=> { "t" => "Foo" } ``` You can also pass in a `prefix` or `suffix` option. ```ruby class Product < ActiveRecord::Base jsonb_accessor :data, title: [:string, prefix: :data], external_id: [:integer, suffix: :attr] end ``` This allows you to use `data_title` and `external_id_attr` for your getters and setters, but use `title` and `external_id` as the key in the `jsonb`. Also, you can pass `true` as a value for `prefix` or `suffix` to use the json_accessor name. ```ruby product = Product.new(data_title: "Foo", external_id_attr: 12314122) product.data_title #=> "Foo" product.external_id_attr #=> 12314122 product.data #=> { "title" => "Foo", "external_id" => 12314122 } ``` ### Global Options You can apply options to all fields by passing an options hash as the second parameter: ```ruby class Product < ActiveRecord::Base jsonb_accessor :data, { prefix: true }, title: :string, external_id: :integer, price: :decimal end ``` This applies the `prefix` to all fields. You can still override it for individual fields: ```ruby class Product < ActiveRecord::Base jsonb_accessor :data, { prefix: :product }, title: :string, external_id: [:integer, prefix: :custom], price: :decimal end product = Product.new(product_title: "Widget", custom_external_id: 123, product_price: 19.99) product.product_title #=> "Widget" product.custom_external_id #=> 123 product.product_price #=> 19.99 product.data #=> { "title" => "Widget", "external_id" => 123, "price" => 19.99 } ``` Global options currently support `:prefix` and `:suffix`. ## Scopes Jsonb Accessor provides several scopes to make it easier to query `jsonb` columns. `jsonb_contains`, `jsonb_number_where`, `jsonb_time_where`, and `jsonb_where` are available on all `ActiveRecord::Base` subclasses and don't require that you make use of the `jsonb_accessor` declaration. If a class does have a `jsonb_accessor` declaration, then we define one custom scope. So, let's say we have a class that looks like this: ```ruby class Product < ActiveRecord::Base jsonb_accessor :data, name: :string, price: [:integer, store_key: :p], price_in_cents: :integer, reviewed_at: :datetime end ``` Jsonb Accessor will add a `scope` to `Product` called like the json column with `_where` suffix, in our case `data_where`. ```ruby Product.all.data_where(name: "Granite Towel", price: 17) ``` Similarly, it will also add a `data_where_not` `scope` to `Product`. ```ruby Product.all.data_where_not(name: "Plasma Fork") ``` For number fields you can query using `<` or `>`or use plain english if that's what you prefer. ```ruby Product.all.data_where(price: { <: 15 }) Product.all.data_where(price: { <=: 15 }) Product.all.data_where(price: { less_than: 15 }) Product.all.data_where(price: { less_than_or_equal_to: 15 }) Product.all.data_where(price: { >: 15 }) Product.all.data_where(price: { >=: 15 }) Product.all.data_where(price: { greater_than: 15 }) Product.all.data_where(price: { greater_than_or_equal_to: 15 }) Product.all.data_where(price: { greater_than: 15, less_than: 30 }) ``` For time related fields you can query using `before` and `after`. ```ruby Product.all.data_where(reviewed_at: { before: Time.current.beginning_of_week, after: 4.weeks.ago }) ``` If you want to search for records within a certain time, date, or number range, just pass in the range (Note: this is just shorthand for the above mentioned `before`/`after`/`less_than`/`less_than_or_equal_to`/`greater_than_or_equal_to`/etc options). ```ruby Product.all.data_where(price: 10..20) Product.all.data_where(price: 10...20) Product.all.data_where(reviewed_at: Time.current..3.days.from_now) ``` This scope is a convenient wrapper around the `jsonb_where` `scope` that saves you from having to convert the given keys to the store keys and from specifying the column. ### `jsonb_where` Works just like the [`scope` above](#scopes) except that it does not convert the given keys to store keys and you must specify the column name. For example: ```ruby Product.all.jsonb_where(:data, reviewed_at: { before: Time.current }, p: { greater_than: 5 }) # instead of Product.all.data_where(reviewed_at: { before: Time.current }, price: { greater_than: 5 }) ``` This scope makes use of the `jsonb_contains`, `jsonb_number_where`, and `jsonb_time_where` `scope`s. ### `jsonb_where_not` Just the opposite of `jsonb_where`. Note that this will automatically exclude all records that contain `null` in their jsonb column (the `data` column, in the example below). ```ruby Product.all.jsonb_where_not(:data, reviewed_at: { before: Time.current }, p: { greater_than: 5 }) ``` ### `_order` Orders your query according to values in the Jsonb Accessor fields similar to ActiveRecord's `order`. ```ruby Product.all.data_order(:price) Product.all.data_order(:price, :reviewed_at) Product.all.data_order(:price, reviewed_at: :desc) ``` It will convert your given keys into store keys if necessary. ### `jsonb_order` Allows you to order by a Jsonb Accessor field. ```ruby Product.all.jsonb_order(:data, :price, :asc) Product.all.jsonb_order(:data, :price, :desc) ``` ### `jsonb_contains` Returns all records that contain the given JSON paths. ```ruby Product.all.jsonb_contains(:data, title: "foo") Product.all.jsonb_contains(:data, reviewed_at: 10.minutes.ago, p: 12) # Using the store key ``` **Note:** Under the hood, `jsonb_contains` uses the [`@>` operator in Postgres](https://www.postgresql.org/docs/9.5/static/functions-json.html) so when you include an array query, the stored array and the array used for the query do not need to match exactly. For example, when queried with `[1, 2]`, records that have arrays of `[2, 1, 3]` will be returned. ### `jsonb_excludes` Returns all records that exclude the given JSON paths. Pretty much the opposite of `jsonb_contains`. Note that this will automatically exclude all records that contain `null` in their jsonb column (the `data` column, in the example below). ```ruby Product.all.jsonb_excludes(:data, title: "foo") Product.all.jsonb_excludes(:data, reviewed_at: 10.minutes.ago, p: 12) # Using the store key ``` ### `jsonb_number_where` Returns all records that match the given criteria. ```ruby Product.all.jsonb_number_where(:data, :price_in_cents, :greater_than, 300) ``` It supports: - `>` - `>=` - `greater_than` - `greater_than_or_equal_to` - `<` - `<=` - `less_than` - `less_than_or_equal_to` and it is indifferent to strings/symbols. ### `jsonb_number_where_not` Returns all records that do not match the given criteria. It's the opposite of `jsonb_number_where`. Note that this will automatically exclude all records that contain `null` in their jsonb column (the `data` column, in the example below). ```ruby Product.all.jsonb_number_where_not(:data, :price_in_cents, :greater_than, 300) ``` ### `jsonb_time_where` Returns all records that match the given criteria. ```ruby Product.all.jsonb_time_where(:data, :reviewed_at, :before, 2.days.ago) ``` It supports `before` and `after` and is indifferent to strings/symbols. ### `jsonb_time_where_not` Returns all records that match the given criteria. The opposite of `jsonb_time_where`. Note that this will automatically exclude all records that contain `null` in their jsonb column (the `data` column, in the example below). ```ruby Product.all.jsonb_time_where_not(:data, :reviewed_at, :before, 2.days.ago) ``` ## Single-Table Inheritance One of the big issues with `ActiveRecord` single-table inheritance (STI) is sparse columns. Essentially, as sub-types of the original table diverge further from their parent more columns are left empty in a given table. Postgres' `jsonb` type provides part of the solution in that the values in an `jsonb` column does not impose a structure - different rows can have different values. We set up our table with an `jsonb` field: ```ruby # db/migration/_create_players.rb class CreateVehicles < ActiveRecord::Migration def change create_table :vehicles do |t| t.string :make t.string :model t.integer :model_year t.string :type t.jsonb :data end end end ``` And for our models: ```ruby # app/models/vehicle.rb class Vehicle < ActiveRecord::Base end # app/models/vehicles/automobile.rb class Automobile < Vehicle jsonb_accessor :data, axle_count: :integer, weight: :float end # app/models/vehicles/airplane.rb class Airplane < Vehicle jsonb_accessor :data, engine_type: :string, safety_rating: :integer end ``` From here any attributes specific to any sub-class can be stored in the `jsonb` column avoiding sparse data. Indices can also be created on individual fields in an `jsonb` column. This approach was originally conceived by Joe Hirn in [this blog post](https://madeintandem.com/blog/2013-3-single-table-inheritance-hstore-lovely-combination/). ## Validations Because this gem promotes attributes nested into the JSON column to first level attributes, most validations should just work. Please leave us feedback if they're not working as expected. ## Dependencies We actively test the following in CI, older versions may work but are **not** supported: - Ruby >= 3.2 or JRuby >= 9.4 - Rails >= 6.1 - PostgreSQL >= 13 JRuby isn't fully supported due to errors, see the CI matrix -- contributions to improve support are welcome! ## Development After checking out the repo, run `bin/setup` to install dependencies (make sure postgres is running first). Run `bin/console` for an interactive prompt that will allow you to experiment. `rake` will run Rubocop and the specs. ## Contributing 1. [Fork it](https://github.com/madeintandem/jsonb_accessor/fork) 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Add tests and changes (run the tests with `rake`) 4. Commit your changes (`git commit -am 'Add some feature'`) 5. Push to the branch (`git push origin my-new-feature`) 6. Create a new Pull Request ## Alternatives - https://github.com/DmitryTsepelev/store_model 💪 - https://github.com/palkan/store_attribute ❤️ - https://github.com/jrochkind/attr_json 🤩 madeintandem-jsonb_accessor-7626103/Rakefile000066400000000000000000000016451515721354700210120ustar00rootroot00000000000000# frozen_string_literal: true require "rubygems" require "bundler/setup" require "bundler/gem_tasks" require "rspec/core/rake_task" require "rubocop/rake_task" require "active_record" require "erb" RSpec::Core::RakeTask.new RuboCop::RakeTask.new # rubocop:disable Style/MixinUsage include ActiveRecord::Tasks # rubocop:enable Style/MixinUsage root = File.expand_path __dir__ db_dir = File.join(root, "db") DatabaseTasks.root = root DatabaseTasks.db_dir = db_dir DatabaseTasks.database_configuration = YAML.safe_load(ERB.new(File.read(File.join(db_dir, "config.yml"))).result, aliases: true) DatabaseTasks.migrations_paths = [File.join(db_dir, "migrate")] DatabaseTasks.env = "test" task :environment do ActiveRecord::Base.configurations = DatabaseTasks.database_configuration ActiveRecord::Base.establish_connection DatabaseTasks.env.to_sym end load "active_record/railties/databases.rake" task(default: %i[rubocop spec]) madeintandem-jsonb_accessor-7626103/UPGRADE_GUIDE.md000066400000000000000000000037731515721354700216770ustar00rootroot00000000000000# Upgrading from 0.X.X to 1.0.0 ## Jsonb Accessor declaration In 0.X.X you would write: ```ruby class Product < ActiveRecord::Base jsonb_accessor :data, :count, # doesn't specify a type title: :string, external_id: :integer, reviewed_at: :date_time, # snake cased previous_rankings: :integer_array, # `:type_array` key external_rankings: :array # plain array end ``` In 1.0.0 you would write: ```ruby class Product < ActiveRecord::Base jsonb_accessor :data, count: :value, # all fields must specify a type title: :string, external_id: :integer, reviewed_at: :datetime, # `:date_time` is now `:datetime` previous_rankings: [:integer, array: true], # now just the type followed by `array: true` external_rankings: [:value, array: true] # now the value type is specified as well as `array: true` end ``` There are several important differences. All fields must now specify a type, `:date_time` is now `:datetime`, and arrays are specified using a type and `array: true` instead of `type_array`. Also, in order to use the `value` type you need to register it: ```ruby # in an initializer ActiveRecord::Type.register(:value, ActiveRecord::Type::Value) ``` ### Deeply nested objects In 0.X.X you could write: ```ruby class Product < ActiveRecord::Base jsonb_accessor :data, ranking_info: { original_rank: :integer, current_rank: :integer, metadata: { ranked_on: :date } } end ``` Which would allow you to use getter and setter methods at any point in the structure. ```ruby Product.new(ranking_info: { original_rank: 3, current_rank: 5, metadata: { ranked_on: Date.today } }) product.ranking_info.original_rank # 3 product.ranking_info.metadata.ranked_on # Date.today ``` 1.0.0 does not support this syntax. If you need these sort of methods, you can create your own type `class` and register it with `ActiveRecord::Type`. [Here's an example](http://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-attribute). madeintandem-jsonb_accessor-7626103/bin/000077500000000000000000000000001515721354700201075ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/bin/console000077500000000000000000000006731515721354700215050ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true require "bundler/setup" require "jsonb_accessor" require "rspec" require File.expand_path("../spec/spec_helper.rb", __dir__) dbconfig = YAML.safe_load(ERB.new(File.read(File.join("db", "config.yml"))).result, aliases: true) ActiveRecord::Base.establish_connection(dbconfig["test"]) # rubocop:disable Lint/UselessAssignment x = Product.new # rubocop:enable Lint/UselessAssignment Pry.start madeintandem-jsonb_accessor-7626103/bin/setup000077500000000000000000000001221515721354700211700ustar00rootroot00000000000000#!/bin/bash set -euo pipefail IFS=$'\n\t' bundle rake db:create rake db:migrate madeintandem-jsonb_accessor-7626103/db/000077500000000000000000000000001515721354700177245ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/db/config.yml000066400000000000000000000003201515721354700217070ustar00rootroot00000000000000default: &default adapter: postgresql database: jsonb_accessor host: <%= ENV.fetch("DATABASE_HOST") { "127.0.0.1" } %> username: <%= ENV.fetch("DATABASE_USER") { "postgres" } %> test: <<: *default madeintandem-jsonb_accessor-7626103/db/migrate/000077500000000000000000000000001515721354700213545ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/db/migrate/20150407031737_set_up_testing_db.rb000066400000000000000000000010211515721354700270430ustar00rootroot00000000000000# frozen_string_literal: true class SetUpTestingDb < ActiveRecord::Migration[5.0] def change create_table :products do |t| t.jsonb :options t.jsonb :data t.string :string_type t.integer :integer_type t.integer :product_category_id t.boolean :boolean_type t.float :float_type t.time :time_type t.date :date_type t.datetime :datetime_type t.decimal :decimal_type end create_table :product_categories do |t| t.jsonb :options end end end madeintandem-jsonb_accessor-7626103/db/schema.rb000066400000000000000000000025241515721354700215140ustar00rootroot00000000000000# This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # # This file is the source Rails uses to define your schema when running `bin/rails # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to # be faster and is potentially less error prone than running all of your # migrations from scratch. Old migrations may fail to apply correctly if those # migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema.define(version: 2015_04_07_031737) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" create_table "product_categories", id: :serial, force: :cascade do |t| t.jsonb "options" end create_table "products", id: :serial, force: :cascade do |t| t.jsonb "options" t.jsonb "data" t.string "string_type" t.integer "integer_type" t.integer "product_category_id" t.boolean "boolean_type" t.float "float_type" t.time "time_type" t.date "date_type" t.datetime "datetime_type", precision: nil t.decimal "decimal_type" end end madeintandem-jsonb_accessor-7626103/json-bee.png000066400000000000000000001320011515721354700215440ustar00rootroot00000000000000PNG  IHDR6tEXtSoftwareAdobe ImageReadyqe<IDATx |I p' L<@ Ulz֊Gi/EJjZQA@N9r GH8BI6vwvvvw6{>$D"H$D"H$D"H$D"H$D"H$D"H$D"H$D"H$D"H$D"H$D"H$D"H$D"H$D"H$DH!Nx&IR]iRQNQ"H^]r}9.UTQ$H՛t4f̘jժ┢fӧӫǗhb7kԨQYJJ:uqܹsרK&$tq#)''gR׮]p8])~[%H  8G7jjz$ӑ&H  ThaX D"58y8 cu6eoȓH" X xۢEygΜIo޼2~Zz\uߒH$ yռyR{ャkX @٨.e>"$p޴5SNZpH@Һu뼳gϦVVVpQu~D}``z8ݻv9!ə\TTF@-DyjrRVV֔7N 9q~d2!H$H B->55%1

2~1Tע:7* v=믺ꪲ~- 絘:^+V,\lz2X?D]2W#Hu +0;v/Wg jB"B1ƺ7}}ZZS7:77,|7Z үG"O* Z!}W\,׹daZ)))Kk&Y#$R Mk=Эuȑ>'&&=ϭM?D"ԟZ9 $ر#9s} ;Y#$:jCVòB>rdwF(SD"@ l\Y_}X8Nk kk׮y:LH$)qAhbuTo/)*:t_#|w 裏?W]nb'Hdԩ%P_׶mpX~{;ԒFoB+D"ԡЕR<$8HqqqT6iDF_AuRw&$RwV J}aOb0 aXF,D" DK٧O%N.-D#`ŃnIyM6E}uti[|"H:: ko֭ǎKϏP~95#TxH" u te w), $yփˇ9VD"@j `T7mk;.-)K I$$ I ~HadiqDH$V!n,VF}e@Tx8 |q4I$ R"KKK{\g_#՗"$RqM 7`}T<"!HR-A17@z Cx_-՗t1stjW1t=<}; -.VjZ#q]}U.PW_,RV#n 0>EN>=YH$VL<+ׄHtG+T/C$~mBe;z^z)+֋ഹ\p!2~mB=T_uUakbH s)N"]Lc,I闐u8S`]QQq{,}''2(N"B%n-ky}}Ճ8gxvp*)PG_B ?~p_cx,|۷S!ri뙙s1N}I$ 7o|c,|/swxb庾\ϦD"|u 1+n.]SRKKK+׹E"@HEkLX!}ʖz됡C?9tሲWiXЍhdz2/CD"B@ F=50tѱ۟t)))&ehHRT4+~7&47WwF9r="$HBDtD֮]o}wHF$fB- Df̘ҌJJje_hh| "$RcGK{'{w7|3ٮ]8ut^"_+џxR!־+~q[6$q?裧v.ɥ?B ]i.N+W6mzG8A]szav|S Č*|Yoo. uعsz={k'OڨZ!BGvvv cl˯zCL#Hu+Ė꼋-yݡCiz[n}},8\l,Xp8H$)tWQy-F0*\ĉQF~V4yEH$ɯ#MZҭh!…v2 IBrqfX#t`/r pWbC̺8N>ݾDKGi1*ϥZ6s*8$ $Nu2_=FRTWtouc߿{7DcŲrk_}h,8u)Ei#cPU ? F8%7w鰂}ws^z/F =UPPp#Q@H*DY+mSFbxw[~0ւw'$+K6T' "$]`{?A?0-?ӵm?|}sh`4D :@tӂ9c",5ˏB! ~2yQ7 kK.[yy1AD0EYX/xUeBL/nٳg|n;8 \ؿ\ɗD'Q/W<.~ rJ&$ܺ2-[l\|ESNJo3TLOO/tR\pI" A JugnXعsR7|#Gyyy*PJ\R ԄD"\J nhiСGE9}vH]veٳ\v(C:HVUtTմi&O\\_ [lr;:;н AD""WZm`]NN:nr!ۗ-;Bc#_|eƘI4 ~DHƆ! :byp|ѩScb֯=`'9U4!Tˢ4 ܲQۊu!*0Yw$ts̫ D"kʁګPT+[~-ױWuDH$Ŧ[, :9־\'r(j'a% Uo;G[cjB! -޶Nx!7'H+J_agj=W^]zwՃyf H| ?;; ^bBj#iʄ:511pP4{gqs8X"ukD"rs.01з_mܥ oA{ilfy$X={ha%{$,t$֩#DU@q4u+H^_f7@dIbb"N ;w0bĈF=& +"BAD v4S]%$$X۟75k;WV%%]Ijc݄ s=kmۖwΘ_2jN+]WL?zhtikiӦ֊;D"Ăx'W<^DZeOh:UZZ3p‡ONV!{;6lVLZယ;wm6hР8LhDH$H}#oW8=WV_WySr'N}7mݺ`b"AD"4DaEk}]|Djxx^=]k@Fq\vk &(oߞuDKM*jM@r"B=zͨU~*έ3g4/++KHOEj B"@B؎}$5F'%%%#i}B! тGf4w:r׮];?Db::dUN)Z#VjZ8y֍,ZxzkYS駟~}Ej,۷ϡp3RRRW\ 4ea#F|W_.-"VTQQђ B"EW +xxEk؞.#ع++W-̂zy;ԩSYoЄ]wӧOUL BühYfD>F 絧ONݼyKjk׮6mz{ʔ)9#^}YYYS0F 7n=AD!GTc8C"ϯݯov5Q^U*$v>޸q6m̮62D羰;~9Der(hݺu5k~SЭC,?񏏬\rСC`z,xs$ "$I3 cٲe9}-ߴOFUἰ"'A֊+>‰6l؄XHs@D"\_k< F!n޼[m|&?|ŘPL~Ҋ6DTP~AdF$ŧh,04-kiZ׮a`V[ ]Z|N^Ϟ1cFzCH~~8L" HƎ0M7wFἮV}FN=;c8AD"4d=-xFn(kL?~D||*86R}.\D")))qFr #F| 0Խ{X>O=v>"iiiOG\ "룏>]͍j)I$Hljl$/W^y7 c^zY nĸH,@$bë/|<ra+]k)H$HG,ьѨ\k<1 dIp!7c:H CA[1=LZ[uuub/lҤIi,fna3F>C۟TR] 7* \Xɺˀ_ҤmfI߾}}ŗ&=i̺ 5|1C..|njWZ}ƌg~o  1&D`$2_e'@W\rIy,9s>HQшPӉw G -"|"?ؖEEE0,4$ 1N:@ƹm0}X-,*.N9/$i?ʕ+Ǐ?P4:t_WXg?!@O:݉:thwKg\)kCYM4ϾvzI~g)RkBK F~nnX۶mo{?qٴ`ǂqCDS |R.[?c*++[Vĩ3_[67-MݽmU֙cV=~, :?ӑ7_{|;KuPDYotXsοiヵrPLD\a|NV'OJ!@BW̎&+WNo&>}oVǏ󫮺b&ٳ[]Cdڴ__Sy:S۷B[D48BDauL([mOo 8$KV>`-ܔP~P=h^[}Ĥʔν\nÆ ʕ+"|chia@D-SfD{Lx$_Kd ΚP 'JJJۦGOջ]pIan'UV%WۯHnmJiooԲ.!q}8qaAHd}p`q?02Wof ɧaD ꭟP}gd6>įbmXر J8p`n+8UZvɈ`WvJ+nZUAO_Rz`aZ4lֈ2\Zx+1@q:-sxD:xI Y$ ^;L~onz+.|yhv֭צuĉwpA\y/vصöR2(;S[|{`ؤّ7F L(.;XM3֣V\XA@ \ [nK"/H@~Zw疗7ݷo__J{wersh5?ddguyh[#VHCtь}qgOVL`᱃ô@<Uxpn,SOH&C%LxYYc &*8H^mFdJ&]lkMp 駟^|Q֭V "'NM,(ز ed., DL5C!bHk=0 /ϊغukC=t:9B=1 Z0 ]]:=IuVֈ ۿxq~z$)s% Bc~?TUey<<8 r.{`b Ln aY!5ʼnQlt!$Sw]EEݺu[;k֬y0":r䈙>S.F9믿n `[pֳopen'.:0pNҙX]-1\Z&0|׸ɥtlHx_$JMׯҳgmğ5޻?xMƺ-o'?~O*a@ dl*tR˙Y w8\Vʰ: 7iu0l8#It+ 1a´|I@fBmj(Bm˩C74Ì8Ǡ-˳ n@r8Z0%΀aq 1"<8dC>X"H3C ʴH hHn,>ӊ!t!$[>"&u֗g[nPUUe&`]w5SZ86okS׌}w'Zv؏F/~)4p8< 8>+NV!vn,]Y5^"ULԐW!zXΔ-)`i(.Y O zkk7dܮ90{0lPw-v=2<X!<0 DZ⻫3HLKkY"|<ĂQDH}"{`5kh߾yj֬!Cm߿WEG'$793kb#[kPW&IG:\UIrrunM;Z!2شo ^ZHse}K}J)$0d Rbs" !2zpٺ1ȼqTϗlYDg IVH8PO~1g]={۳4&\0{d֟A4_.܅` 8岊S,8GF<2H4p0 {ZxZgߟ? u& \,8vL*Vt_DhҊm Q bV?[ȗ*D!Ų kR+{|W/OlיSǒV 떾ʹT뾼ykjroӻ¬~mw ,X U#pHGJ\M^-?ǹ]QzޅƸq+aEp?REK$HVo>]tb iV%p:YN=0by+9w {AeyKCP8Q_e+6:Ecƌy;'' U_Mw}|{ 鲀x+, \[ͯ{7]Wq|sDXgL5lp/X~8y6,t6ɉJLes f_y!EWWYobA!pEJLrrggZZ!t] :^ɤEl.Ou1hvK5;ؑ=l U}$գ4PhȾkFS} ;nVc [EvYZ`a qA_i lޭH[i$[QDHQ"gow*alYD8~xE/L- cUޚSG3&\eP,kѼ \=|j:_k7¸kAY VX-+- ?Q˯ k{Bf|]hQs)2 $N0ew„ /_A=@"TXPUT; 2$1s]yqm:%+BɩJ(:^#_ 1fͫ|"2483o}p>1.8(.[WLob#lE).AsL٥V%޼V2q?1n"% ڶAߔq|8+#eMEuBaq)[{풠gV@:X5>W %C\zcR|7b}p6[!d@/V%W|pSO0M RxpW~0ux~}E ]|b/9yۏU\< (|֕07=sX$ C`9(,KK T)#ᬐ4 yTpS]%SvWX(?}.XыR8AϤ0/;@]JK[tANe!m=ŭ]8Xi `1Ws{nN0(AbTRѪ |Mw|0j>r}u_xX ȉb NV WŖQn.}8%Č}x>-73Z/&lVSA|jU_< ԭ7'b@_;VMT:xbǾ}jO?СCԩSkl;J;4@T΍o!: gi`3ǝ{1U gH\.y8LUkE(պ6=~> 9͓B$Y@/˱@Q#&c~~W[_yҍn8m >aƭ&?;#r4 J<W=7nשSZ5 ޿cLf@3"(L+b9giXu؇S\>ᢓ0eSǻۆwSa2Sv ¸/mصA[{$280C'|pۛI8mEEyeIu,*RڷRfIZ+s=.,NPa~`j萱=WRGS<@L˷ԧҞ|<07+Y|5E`pyW& 8XY&@8+bĀ(.~'cXNAOmBj14"[WZ:֮]cǮ]{]2KU p4дk&V ~|' XR0Q`oֽ(␃L|XגKIeu_?)[ Ԃ ) m;خ tR|fHV<)zum4U7x\!bBzsgxXBDBڶ꺗~;'< X?*_̄@b\1| 6@ fʩ uhg;!c D= G<63_g+|`wfIMT2owmط6 $FlH1}嘾`t?}ھ i70aqc05v;4 B,[d}'+l X5`Z >W_>Noi^R@'@-@+Z\؅8샇< Wh$o8xA)Ч;$%%BоroZ2tY'q lW-1MOC 6 Z#|Ch33QPde  "Jbu5KgTzu/]*7H|6j14MHև˜N u-X~M߽9&;0^}+  J͒ٽ |\Z)ڶ?'k $4~8G&.atY9E-+kp6+̡|+`G'+Bd+cfd1n,l,=n Q?Nn D@u^"g)>tn `z= ྲ~++[+ WO\XqV`F@Ks]u幩}U(uyG\YNV7?bX!^ DPfwr|Oc['kq"@x}1_7W mM_(@׎?>!e] 3dz#Wٕl-.2PT0P|#@Zi&_A e0x@}i*tJimWVꮯvJa=+ FW P=$ҶGt$ flɨQ:u궆+ٴ -m EIX,'bJ Pb[DLetf#IثBlh A"f݄}pe 9ZY'I܃}䲷 "@HB|I:X!**tD7;2сRcebH7=@ I.&U@d8ԝ`dРAknj!q<[+0:!YsLDWp a\+k;V'EGKԥPQ-k)bJxE&}.MѺ $j aETS̫;5$XaӇnn,8HtU㒉eD}[;MwW{C|`ܟr `&ӐN lpIff;skȶ J+Z (al09-c q_H/$=*TU c0jd )&PLrL% % Ig jx}A>`z8Pҝ2Z[!= KsW.ҍM`EC:$X%W.#[ە Nv5m;ΔϻP͏Bpg0 $hW:@[{5ٖ(_^-klznSt`W"L;h@P_WiV <X}#  ni=ЦqhWytS!B} `,Ӵܡ%mz訵ء L[b*.3#H #NֈK˘fb3jǭ/#EZLİ@xaQ0nk v"WR ="@<%P1BVN@F.sH29k%- 0*HR;VajByR6`"' An8eiqE D, vLwrYݚ$;OkxmC` K[+= TCESu*@ѰFW3=,}JD9Uo1iPizp4Z0V` HZ>q/Yy2)_na]νGT`uQF!}Շn/W0tU z[;"pҪy/fIL^HTAF^oՊ@M~/ˠX]6,UTC (5 P6o`B@X0@RŪcX "z1#XfaeN\BKGj$ SS>1k/Ǧ~ݶR'c} Ff;wk:[T{ʠE *@rh$#<ЅJU`" NB M;|x[V^{^[^4/\:閍T` J0 $ "Cp}o8{Vo6լ]&JJ]>5h`|+#.^k^ɂc~R붗ZzumC_+w}hB)<+A 2ޫh@^|WˊhoP*@ n{ԙ-p[[+:`n0R+CCvw@"60s gG,{\]Z]ZCjedetBB4̂X-ż羍e,#Te]>a} rh<9ui!. q58 ZP V4@ 8%$Jp`6Kӷ):r.>{oAOXMK|H)YJP]{}D& Сm4}o")¤jtn ɍ%[!+˂HQg;8 xևWNxZn1WT7 UQJR+ӭ(R@pSF8 %.O `0, B*p&tjCz 62Dm ڵN&k4p㱍3 VWճx+XB ̡w!Cqt]Yʲ>t_yJyt&ΜkI+5$$F,D:a]V3=*&  { mDV䑲& Jm"!@_R頿'T 1 i#Z$A234U 'y7K+#qA>{@3Q_,,- @nf([b_kicc W?"6Zbf7(x 8*ʦ$4kTInԫdV38ܭ o,ȈքRL\/#x]f%4rep9țWȄ0m.U{5db؊ULXc*\ 3aჄ>_Q)*y P"`W0#ߗouбag&<8H 0uqGb*7֬GId yTOv[pw JFvB (`gP_q;z Xj[.ߤ l<*/X9;.8; Ҋ-^B\_=(uKp.#ZwL>o&u0j0 ϓL[D:lU@eã -ϝ„YB^l"D_8'(R#0R9ؔW6wq2+`Bzp43Zء)@VE0X-Y&eXsƒ}; {ZT@S׳+X~Ąy@ȅUk+y9Dgp9;|> )T~CR0fف3vXW'_oC%p&!' ?֎CF.~K8X88x9Yk\ZY:;Ln+;%ra=w+>}nec '+wп b&!BN!͙V;>_ B0E<FegGV iiiq%Ar=ШҀE1 ռ{Ʃиps xbsKu0WtRVE+|@=`n7[cw%d9EcSi $B ^nz]avp`C@(f:c#Ab| EXe>A9-n`j<6<wv ]'OB^^v;n8s[6c^A'Ra}HDBՁk)A,pW.Gk07 We~qxO̯`H %bĪI?S 3zN8*~8$czɥ.' 0n0gQcAL~%4k5!ƩNu dԊ#[r*b@@ၚ9s#HE_g*.:PUx`#wQl»kp:& 9Vj\@lHޥu@S&Mrpe!V&庒g˭h(b={"A s{5ޚR vU>䓀"(IBԚ~@ ѓ:sBt[1YF,u( а( }`ۗ8}.͊$xRpi bOiDz`,Z`ʹ7,H6cv2H̩&J8z޺Ƨ L>0JGղdcq(f7M4.*$t7oܝ ?4ağˊ靜 kzεIbۃbi͚hAog^(0 &փ1, #,:}iŀYᎯ' u&#^_:*FkDg4@wUhأc!WLx91$P#\~)WA7f7X>à904j(>} y 8e_9`jB8q9cծ^!&pVk_L;a{/F'0r݆ d&RRUAB̌eȘLP/ͽF`%D|YN>0߭Gf(<` b78L70)CYM₿GV0nD+d`6~̫(("c7k3mv>[c t_p% 'NDQu TI|F_)9ԖKŐα9׏T?DBqoЭ1nH1¸@Ϟvkz|1 }s-PĬ*i X{?/3,jh9S}h}UimE4xps`YW%%짮T n+ןPvME70ڬ8PZD \{wEҎEtm;+*FAӥE/avKz?1\@,HkD96k}#"`U,W-7b5<ΰ>D bnJ&eeq%1~XRwFqNҁB$"}i#$Dk|lLI?/mU4E"8h0 "\㶐2l@1ڲ2s\ECܜH.`/j$TTWт5s3zfW.է5,"; +AD%&>ZM ֆ6:< Qf֩y=ˠ7I{Yf 5*08ܠ!J_ݹ]جO|> р&sXp'5">e9`b?a݊X0t^&0Rjg Q8 DhZ#aX$ [ >wPur\1X}Jm>Ph#Dl}$c'?Ƴ2a 2:ir2hUd iɛZc20ܫkG͂]x7L]*D%;Pi8# :3!;PZK˜ri ޒ1c'V<$5j'5(L<q 0? 6^(aM.-A Wh5b1 pQ`wp?Wra mX+v{EnURcJ&TEppj} jxone|T` 0xJߢp[Cj-QCRHh Sڇ*O4[#q!"~D3dX,?*j̬0j݄``6-x4OaQ@1LrB%e`k>IQ;Q8Uև*ǣ*<.:]W R?r̼aK;^|c_\[A kbMA_*PJc']qh>Kv߁nST DBrэu!55W>kFѶ5EV݈m{x7En?_X[4 ɣ*kt+ U/OHuAe}:@j '^9Ɨq.;o)x(Ly~MAaqҪ$T $R+ʲ0 "KKDwٙ%<0fI^S1 i*4'>waAsh.*ŗx-ä||xK: ,"SZ;Ao ~Bl}̠a Sv#Zk[͖$޺t *Էc [¿r `p߶»>=BzQCR &0 Ҳf/W`hE!+++v&f1JzSs%=x|>a3fzA(bb5kɥ@~) $|3Ԩ"LщZ?/l] QVuλ xh'rQYPNKR}0S/^A7K3{7q8{1h}AeX# ߅"{ņdӋ5WOz7hV*=}EHT 2REHPF%_}v-]9nzK-ҠnU!58붕@=vU2{X{n-9Ѡco.jgƥۇf0p%9̳R@W.I2*HrԯMϧo]u ,Zasyx,.~Ǻ%Lx\›+OV?-Fjr+r_g _OCϴV~ Μ=wIsM4m;nCKwZ AXqk L#!}[; Lr^)|EH!`&6kDDqh9d[`,*?-j7}bȕMKɜ\ˏ5BLV cՏ7ISF19WH&bB.sҴqWh!8XW6{ fbWS/-wYFVVέ!I4k/x$H`Wax]`Kwl(To`JmY5%srBS{!+A' 1^d\TLRggB?o0au>(Sh[¤]dv)*-**Lwo}~& GyU*ڑr1}YQXqjWTiw?֣ >$)S}4ևSY.CAAZp.PY gat'TkWYȳwq"r3Q6ۏQ!n=0=Yv^ q2/۫]( 43o  NRA"SUb6~>ؕY;lPn,QpdOs,=XX1>CHҽ9Gul0n E[ۦ&6}в ':ja9. gX]T\S'>.?=t`cZ_+ߧ} 0}"D,gxtJIw8 X߮;BRS+s O?HG`"B,y^nח 2+*L`d~u\ ?,{ЂFc g~V¼MCQ96OLh 'GSw ODw )g.qbra= S%i+-:fU3WqQJ3b̃.zX͊0TJހV>-`Tco3)7fe֜`sYYT=6d(m0O_7t<-'rM\V}p(A%Mh i23an4πv h~4i2t} -58wi'F緇-_(A 6@p]4hedN:?: aUӢeĿ!Bt{ ѓ[/.{! |]8QG}eOݕD^|* AaH}{*a^Vzx 1{? ,s3_PmRG{PP7gLAw~P:.n=j / \Q|gyhn㊨~;7>&h}RYˋz12cJpW+})ۘJlwҶ.Gx87p͐vB*LŵCk$g㎉48 `uh)]I0B3+Nh8?x2/z'Wo) N€?xHTWN;SȨ-a~gbݥ.oKGra5 uvW7Y _||6#h@YxJkY#l~:WHpkNyM?]BpϘ6c6oWf>978֊,ZZ~R!ЊiҚiض1+>9 !KKش4=3 RB+%H4ddQ=?9 [LcvaD5xv]PIK ~jÝX˟%wҺ]XN+Řc;D؁7vxZFֲy7 ֕^gf4dp .cnP߅ cdi q8||_GP OZp) 6#H/A#smzjDWQ1c6CM 6OJ[A L5;O{* SÓu쯶oq!|5Z<6k=(kF"@f{Ǵ-*(,v9N3y 0ם'FBGgoSʙN mq7Z+)Z^0jB$d}n7 'ˌ ^(N#ezVnԳh8$K I7ުs`šò%+A!`;(v1P]TR4QfLㇵIi <+Juו$;"`NaT0{Y[ N5I ep`x\"j<ˋ{&_BT3<9J>Xs,Sb.I}WX t(]@<ZZlۋWΙҝ666YqJ k=fdԇ[ʃS\_Ձ[$7ޯdSK*#FpO-_s'RBE3GU0L~umf ${PR Ӕ(>TRm&QQ3BPCiΤ`D}UEF͞\TOd[5$kVؤ[¸IcHF!`ߧ ov(iw(> T8W 7S/xn`e]8ibT3ݪ.C=Wv礗G)TptW/ظ )9hgyG,$+6آDT 7oRb> W(O^Ԩ>ny7 ܻ[xZ U 놃go'e-X·'ٟz>\3'b8'֕U(76g}h"&2^Q) W 澲NpWMڲ*YX_3He`C}|!7X;N0mmw2-P>X}4N'<n[=C샵2a j-MLZ#cMy,3go' ^ķK&Ifd٥k`Xt-r@ n\^ k)] 7ѡvu\kPXU9']Z:^sdֱQߏ@-4#t(YZUf\WGz } )cඌRׁq"_n~ vA {ݪ0wrgBs&'*[g{Ijc0itkO'znHR8H#;XS-=;c[7NLTSϥbf 20Z&*..+.J 팓Fy},'T[Ԉ&qCE06c-l=nb~XP8pe&[(>fT0u\{On!L֮A cYVXl<[e 4YTV+jMvLTB|nGUZf)[dNQ*]Z@*2$ g ٩G-qq "X% -O`<`=ZE撄#]ƨ .#54Muc8I҄qqq.ZAIn1U|1/qCؼPܬCq|[ey}c ЕLB&e:_lNĀzIy;(nřh&VaƄ8%јWfβF\Z>A E>\ K T$ޖ辒$=X:F񒡅ɳ7h V XVA&SRH8z9z 'd<$깈7;(?v R|Ws+MhOH!,yTRhޑ$3`6P+Hd+ }Ok$oŗ`UU@Ep:-ESB?1 &+{񐐵.z$Ν/}bZx YX%bAv}mS&#"ڹhxGgoC?Vط/4>Q2J 5ސ$R]!tς›Fwq(_[pap-^\'03wJ]cW6| boUZ3"$DBb@=Ү,]Sl:>pd(~ R\ZHoV}N$ӿW'D_ў\x_c0VI *R;Hz/6QMSTJׄz9xoOkaŐ݇1mnْ> Zba!yxݤ'B"^enu/+T. Ai `FqfQQ6$,/"xH ¬2AaIC]*odE l YZmCWP@& Fxk vWK} ;yAT,؜-Upa쭤O8n*"8'!~dS"A(icw03Ow,tkV1}Ee} XDp(8^QyłLX,p?/} +ԩ5=}$.c?a g9if DyE| |mJ`fSH$⌇$]f%úL%N-P"2cRy"[tSlCl`UHlWtmtOÿ}ԎVʰ3ps0vH{vQ]mWo;O0׌}7d`wk),tzNzys4n 0.b.JUA,RZ<;Ni0RqxjT!,jQ3SydB)dj$FH˪}>CI7tr-V'Nx;K7r[8A$K^6o\ Vvs;q-m&/Ah$5Ce=g{ G{3K)2 Q3jl%D$ ֊^b;wQٽR'oM/>rn^R. ) a6#S@"AOپ/h$NV\%!-ݷ(ܡǪ{xgf+.Hbιf|DϫͶ SueXKs˚LI3x%j=5\IcMb=9&C$Nc"&aXf4>>Kkw+m'  df%RtX*DԬ3 :]}&̤ Q7ˠ:MwpWK3bs|R3f}f\53B,>z?]pKU%kC=\@Vν5-JBy(r e at6-b]m#&7mGRs|{XsC1UěF՟qȼ Ic,29XpǵxaQUۆjH-2I/L \۳%!qSjoY6?V5@`2ö~~w}\~e+sdS|X"Q"^Y>4[msa47VS!ua8<< 7oNP0:+K i` vTL$8B)W8rp+d!cW@6'VXPx 1z,k~3Eջ+F\Vg`E:>Dܢ*arكOk?Klq.h fdL蒒eg-Zb?yDܒH<:!Ju LmJiRsn3IZ]ւc3G :YLwnSo-XQUD/"Y2Xc"t{pBAnR.YsŢZM`لk&p-~9GpWPҗm>%@:>ּ)<(lۓQ'=3.W" $#m,_W3BDPa׎0=weΰAS%k2<>ET#nWUx̔qdsGgmV-{t=;yXTHVe^qh!̼BJS@SK26tEN'C0҉w 퓹3@kΫ!w\ec[3ϖv_e=e&oqNĩ Ao]z׏GuDԆ\4Lɰ߸a5wTEU {{ǁSVb[*wI"a?"/FH"Y;*x"xe MHqQD:i>q"$ߍyUJ+9:9N!]fZWtkJڽ#n}Kcgm>q)oT"]o\;H] "3*yŃWJC::nSʗ{ ƚA5 08Ed\ߙ`>-J$hܣJtZar,pn:ŋ-q[]X2#n,vfj=5 @Z 0* xf"mmH18]V7UxU/QoT@k)V {a m֍V%C|{:$. .mCHɨfA"!2aG{OT2T@ԬQ9!ǞREV5"XڔT<@iD3^]tŸ*.Q׶Ctz7=#C=f-=e YwA0.ZGaDIYi?k|.l>>\2J?f!>υ{ܠRTTسW> Ǒ?= Ŋ>(2@0άl6m]I5^6C" &Í6;v5QF^ۖɯg>/K"&cB6(SQ}D!&"{I{j2G F"qDb/Śܣr娦<;GDW*(ZRhiL*2K ^5Vv 4zuח:Vvط=鏅O'"lߗw>^\_H&Z\H, _JbFwu@ "vD :^\VHdɞv6k2WnC/F}0iD#A"7/!IH,"Xª&FS2I9Fu^TT @"ߎTqH{[W\L7-bY< ywM?H Kv;U0$@O(W$jO<uY,-BJVZգGSH#!*%2eɰZAa RI6J+hO[/%U$nteSF(Oo-W{_!!L}U, {@WB A0#Q$Ɏ)$>>Y*H#pcؚ$"[p?43(aVT+W#gsJ.6N9ȥʣž/%"ʲ +' S!fUHWYvq{M K 02@l>æF o; q$F$ՆR!pVD]QA[lx{By$;Gɂ<.mρ㎣OXMu v.1+'7$#J}c;l1kL6efߢ5 +^s\dM+!/.+K=yIe$"$~ W:Oլr  qU>O\{ q{88-+f{zUP6rP"ev xVseq.)C+nVH]Y ,9aǻkߚ2Ӛi] v2Q&*zNs#6bYK6k ?nWT@ d]Z!1X:in!aj㮍HD'B'D}}WTE;hq8{ΔiĸcAcG4*?;oHߛ\=Є("E"mn]($1_EN!mCS]`FOZJt;Wq./cfZ3p!6pZQpN:k -ʐHߴ$u#տ pU&r@?fte%CxKҠp)65(.,\_Ԯ,|&(8:DV8z\fH&PHWcx!eUI%bUNYS dGxcU~>ʉ9bȁР:O}6l;J}>O(ڐGg!w1s]'ַAI Di"J;jhToW/.Juwщ`*=p,YSQF88uCI^[`V0eZGժz{Q2ytM+ =?φ s! P^\X2#ꖺ{kTBl([/q~:# ۏk/-\ 7\AF tIOwyЋrz%Xo(*$!ʝAČOa#^.-N:Jin+m5u8V`ihg9w9謓̫U*fNaibDWDOl>:uH 2Fy>F&@) - ^% PQʆAKc' r=(ֆUZ:/XS{;4C;kdĬ+JRk*Y9ׇ߯7m muL7A2T`My0`FRzs9+: 1qQk0WHI/M 4cы؊/Rv!T/BfXV`A Q"f>r/CnMH`$e̮"U ]`,%H_P Jv}¬qchvȓH<h1 ҧ>T뎗ORTz`]=~~TwjP! t#ȄLّ ?Β(4Wl`zja!{6yu<wpuΜ1UM w[9H6a z$0NB늖@Ԋph+=_+V JU$Q&;֨Nƒ~ g}]}.o͑+pv&͗Den7l~·b`D6$U:{ R8ً! [?y N |+6+ :~7Q-\X߮?@7j@Yߵ䇘0Цy\щ#Jf1نDS#s3 ϴZ9H/vJ8! EH"(1=" P(A[&W2 /^\D(M601&РN ue~ p;k'4{z+MUmƷ,?`Z]}]lMSڵ2SJoǏp D~m AdP ۗ: V*\Y`N4@B0/2DrvSzRhdqo (KKUaQpmjKvImР%WD=jGgtV=%wJ{/]82Tʣ2Y{O_cIkg>XmG,`qb'fBByDW^mzCn/!F%V! Doj8^<~ ).ĢŽmBjUԕ[IKY|?KHkMV? k#\MKo"V3 F^[FTM-Ȧq"7QI{nM<x2p;v U/B|%up]?sA VBm(_Rs% LGsdyz*b׋\~Q*HZ[HbCT6Ft39I҆vmGIw\׳%]#qWtmۮ#d2W+IB(*QaOSpe#YUE pmԋC&ظD]A UAL~ NvهL{wղϷ*=FL"ĴICE2L)CknuC/3URޝ1v?El'jp'1k3\G3A^/˸*,I vJ]t8[dVD$5#ZK@|Bh/<N] 2(Ac?"~K7i$n(V,c]%uF$n5HNJ/3LL3<]l܈xI/k{ty&-]ʽȷ#rg*`=논첾A`Ə455u_" #03C+֩ Q "XH%z4!/H殌iV:U2 WkI2ݰGf$خޮ KKIHZ{_TQUAX v}'^ç^#u[T_)sJoD PYMܺb 2FD1мIP6Ia쇕1H =Z̼=!NgOO q-qZC q{>ЦEe& ,KqTr;y`ۮ8Po\fI7T淋"cOUByp(Byqa4Ђ}B QTǭץh-Mx#B^)Z6 ګGEQv)5h*N۪ymB e}YdohE[kIHNXGEAH;]{J-n{]'B ïQg8Y3#BClrU. o)~@K-klHvDg$ח>vρS0wuq>ZzIga`_\$suDԧn.κZ2NQZ 5Օs#+ }:*6 I{d%y%3U1jQf1|l^nIW@)P`-yiUr%iX-k~>`Lދ6b"O ~pMZ /Q0{qu/P46C}`Vd?cE]48C?7S(Ly6ot&'tŨEa1 7;H",A ]Xj}haK"H!K{aTPm >{Lff~B4<)ׄD.p*sCȉ|&5=ZeĂC_FR}ڔ( e?|lj'i礀#I N8|FQ{Hd,.|`츋a:s- P ʋAryU)J*vIi}Bu(je%vd݇ q10F*YR`U dx a'/8k6agmG`}NTǯ rb[ G!>X簏Ye`i ij %x(vI*\P`̙T @b$P5(7S?I7aom] hLkECگv6\ה 65Q dP ?𗒏x$*~"C@@H1Z3P7+ l>틮k,pk{^;f6SoX&܊5'=J$[B= '50$bT{^Xj&}H"4Q@@HN1r$bΒ}d@,Wrpi9'"YkK.Kj m njsI.HnG]fa" Q{\Y6ȃ!. D|.? \V@ `$eC"4WT-tF7;tR#2UktPӃxݴqQoL۽KshPB&zu-EWBrT!eqR@Ec>%`#^ qM"Ju"Xc\_O F4^qA$8S7n;BL@2\F!,nc8"ȃ1${e 9!c-@(#m߉D!'Y2/k>?8K7<)`!s}jw%e[nNo0mFEFcyWy

oex̞ziyw̬3'{θ ]@Z6#dsk(#.YvNOPS<C}-@Hv`!Deтg| Mjãw!$Z3yn7 6Q#Bbc b)րl26)ڬ)N ِHEj"\0I}@@V;ڱ1пw'./[B]A BY݄<Vl[FKЭSsزSϠJhPu.i {SPv !撚pD>Q$+bBA櫍 1գTtZ$u@G?a*NA unv}Cq@ CGN;%LS#) L2! Ė]Gatl fҮKȁ8f^횚`3B7T&Lb|dV ½ u"RZNUg<$$|`?(hX+>Ȇ-a֛k3I_'~FT#XGЦ9vBTP;-ݬ@n=v‘ F]oZPLQ{/"|QJP;C 0#k޻ߒ?#߈ޣ[Yي'zoՐ!2?Ň;PZ4*ѕɈv!OQ_|(F bI )SUK3e A$OΉ-QE su* PD*i}8$+ Фa\֥Ǡ<Hnؕ(tw] UV;*Z*0OgCgkdcCIᨡ(eIZ_ܑh(uzSC]] \/N 'aH*۩O;Lk:27@7iy}H24ykD2!S^ޝm5C Fda$ M USEMR4#/Ca@Nб]#b*}e.X5VpOaO -2zM"4kR[# Ff// ~:+$nѕȾ4l(ԇ7@ K@%%}MP¥[ڜRR'cc1v}_I³}ݏEj@aQ0 ԬO} bnjf `z pƛv!i` I( 2x@s( 죅n cI#dze *uz LSH`].nQA Z, 4{sP^ZvⴔeM!=zO?6ʣIc+H4&+Mm8[7ʍ$Q-]t!\@4;̠aW!?|w| XF:t}ʝen!~lHx$AkڷshI,IU.uT-NˆJSvnjOd]Rg# REx/M!S Md7B4[AA2{oX.;$ӄ/~'xU'tugRh'-En&UصnGRn)P v*+7f8LPة>VB1j`ᖰ]Q!.Kؔ(?$ȸ㡷yQ<X l yF.f.6[ qsO}y-J_B# {c@m' Ă,;@{ ,`Tb&^rBTb!P%U-4! *›E>~q>`O9]; 5յs /i۲ zMD^7ٻ೯ = 6.1" spec.add_dependency "activesupport", ">= 6.1" if is_java spec.add_dependency "activerecord-jdbcpostgresql-adapter", ">= 50.0" else spec.add_dependency "pg", ">= 0.18.1" end spec.add_development_dependency "awesome_print" spec.add_development_dependency "benchmark" # for Ruby 4.0 spec.add_development_dependency "database_cleaner-active_record", ">= 2.1" spec.add_development_dependency "ostruct" # for Ruby 4.0 spec.add_development_dependency "pry" spec.add_development_dependency "pry-doc" spec.add_development_dependency "pry-nav" spec.add_development_dependency "psych", ">= 3" spec.add_development_dependency "rake", ">= 12.3.3" spec.add_development_dependency "rspec", ">= 3.6.0" spec.add_development_dependency "rubocop", ">= 1.0" end madeintandem-jsonb_accessor-7626103/lib/000077500000000000000000000000001515721354700201055ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/lib/jsonb_accessor.rb000066400000000000000000000010631515721354700234270ustar00rootroot00000000000000# frozen_string_literal: true require "active_record" require "active_record/connection_adapters/postgresql_adapter" require "jsonb_accessor/version" require "jsonb_accessor/helpers" require "jsonb_accessor/macro" require "jsonb_accessor/query_helper" require "jsonb_accessor/query_builder" require "jsonb_accessor/attribute_query_methods" module JsonbAccessor extend ActiveSupport::Concern include Macro end ActiveSupport.on_load(:active_record) do ActiveRecord::Base.include JsonbAccessor ActiveRecord::Base.include JsonbAccessor::QueryBuilder end madeintandem-jsonb_accessor-7626103/lib/jsonb_accessor/000077500000000000000000000000001515721354700231025ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/lib/jsonb_accessor/attribute_query_methods.rb000066400000000000000000000030661515721354700304070ustar00rootroot00000000000000# frozen_string_literal: true module JsonbAccessor class AttributeQueryMethods def initialize(klass) @klass = klass end def define(store_key_mapping_method_name, jsonb_attribute) return if klass.superclass.respond_to? store_key_mapping_method_name # _where scope klass.define_singleton_method "#{jsonb_attribute}_where" do |attributes| store_key_attributes = JsonbAccessor::Helpers.convert_keys_to_store_keys(attributes, all.model.public_send(store_key_mapping_method_name)) jsonb_where(jsonb_attribute, store_key_attributes) end # _where_not scope klass.define_singleton_method "#{jsonb_attribute}_where_not" do |attributes| store_key_attributes = JsonbAccessor::Helpers.convert_keys_to_store_keys(attributes, all.model.public_send(store_key_mapping_method_name)) jsonb_where_not(jsonb_attribute, store_key_attributes) end # _order scope klass.define_singleton_method "#{jsonb_attribute}_order" do |*args| ordering_options = args.extract_options! order_by_defaults = args.to_h { |attribute| [attribute, :asc] } store_key_mapping = all.model.public_send(store_key_mapping_method_name) order_by_defaults.merge(ordering_options).reduce(all) do |query, (name, direction)| key = store_key_mapping[name.to_s] order_query = jsonb_order(jsonb_attribute, key, direction) query.merge(order_query) end end end private attr_reader :klass end end madeintandem-jsonb_accessor-7626103/lib/jsonb_accessor/helpers.rb000066400000000000000000000034401515721354700250720ustar00rootroot00000000000000# frozen_string_literal: true module JsonbAccessor module Helpers module_function def active_record_default_timezone ActiveRecord.try(:default_timezone) || ActiveRecord::Base.default_timezone end # Replaces all keys in `attributes` that have a defined store_key with the store_key def convert_keys_to_store_keys(attributes, store_key_mapping) attributes.stringify_keys.transform_keys do |key| store_key_mapping[key] || key end end # Replaces all keys in `attributes` that have a defined store_key with the named key (alias) def convert_store_keys_to_keys(attributes, store_key_mapping) convert_keys_to_store_keys(attributes, store_key_mapping.invert) end def deserialize_value(value, attribute_type) return value if value.blank? if attribute_type == :datetime value = if value.is_a?(Array) value.map { |v| parse_date(v) } else parse_date(value) end end value end # Parse datetime based on the configured default_timezone def parse_date(datetime) if active_record_default_timezone == :utc Time.find_zone("UTC").parse(datetime).in_time_zone else Time.zone.parse(datetime) end end def define_attribute_name(json_attribute, name, prefix, suffix) accessor_prefix = case prefix when String, Symbol "#{prefix}_" when TrueClass "#{json_attribute}_" else "" end accessor_suffix = case suffix when String, Symbol "_#{suffix}" when TrueClass "_#{json_attribute}" else "" end "#{accessor_prefix}#{name}#{accessor_suffix}" end end end madeintandem-jsonb_accessor-7626103/lib/jsonb_accessor/macro.rb000066400000000000000000000157711515721354700245430ustar00rootroot00000000000000# frozen_string_literal: true module JsonbAccessor module Macro module ClassMethods def jsonb_accessor(jsonb_attribute, global_options = {}, **definitions) # Backward compatibility: support the old positional hash syntax where definitions # are passed as a plain hash (jsonb_accessor :col, { foo: :string }) definitions = global_options if global_options.present? && definitions.blank? names_and_store_keys = {} names_and_defaults = {} names_and_attribute_names = {} definitions.each do |name, value| args = Array(value) options = args.last.is_a?(Hash) ? args.pop : {} # Determine store keys and default values for each field names_and_store_keys[name.to_s] = (options.delete(:store_key) || name).to_s names_and_defaults[name.to_s] = options[:default] unless options[:default].nil? prefix = options.delete(:prefix) || global_options[:prefix] suffix = options.delete(:suffix) || global_options[:suffix] attribute_name = JsonbAccessor::Helpers.define_attribute_name(jsonb_attribute, name, prefix, suffix) # Define virtual attributes for each field names_and_attribute_names[name.to_s] = attribute_name attribute attribute_name, *args, **options end store_key_mapping_method_name = "jsonb_store_key_mapping_for_#{jsonb_attribute}" attribute_name_mapping_method_name = "jsonb_attribute_name_mapping_for_#{jsonb_attribute}" # Defines methods on the model class class_methods = Module.new do # Allows us to get a mapping of field names to store keys scoped to the column define_method(store_key_mapping_method_name) do superclass_mapping = superclass.try(store_key_mapping_method_name) || {} superclass_mapping.merge(names_and_store_keys) end # Allows us to get a mapping of field names to attribute names scoped to the column define_method(attribute_name_mapping_method_name) do superclass_mapping = superclass.try(attribute_name_mapping_method_name) || {} superclass_mapping.merge(names_and_attribute_names) end end # We extend with class methods here so we can use the results of methods it defines to define more useful methods later extend class_methods # Get store keys to default values mapping store_keys_and_defaults = JsonbAccessor::Helpers.convert_keys_to_store_keys(names_and_defaults, public_send(store_key_mapping_method_name)) # Define jsonb_defaults_mapping_for_ defaults_mapping_method_name = "jsonb_defaults_mapping_for_#{jsonb_attribute}" class_methods.instance_eval do define_method(defaults_mapping_method_name) do superclass_mapping = superclass.try(defaults_mapping_method_name) || {} superclass_mapping.merge(store_keys_and_defaults) end end all_defaults_mapping = public_send(defaults_mapping_method_name) # Fields may have procs as default value. This means `all_defaults_mapping` may contain procs as values. To make this work # with the attributes API, we need to wrap `all_defaults_mapping` with a proc itself, making sure it returns a plain hash # each time it is evaluated. all_defaults_mapping_proc = if all_defaults_mapping.present? -> { all_defaults_mapping.transform_values { |value| value.respond_to?(:call) ? value.call : value }.to_h.compact } end attribute jsonb_attribute, :jsonb, default: all_defaults_mapping_proc if all_defaults_mapping_proc.present? # Setters are in a module to allow users to override them and still be able to use `super`. setters = Module.new do # Overrides the setter created by `attribute` above to make sure the jsonb attribute is kept in sync. names_and_store_keys.each do |name, store_key| attribute_name = names_and_attribute_names[name] define_method("#{attribute_name}=") do |value| super(value) # If enum was defined, take the value from the enum and not what comes out directly from the getter attribute_value = defined_enums[attribute_name].present? ? defined_enums[attribute_name][value] : public_send(attribute_name) # Rails always saves time based on `default_timezone`. Since #as_json considers timezone, manual conversion is needed if attribute_value.acts_like?(:time) attribute_value = (JsonbAccessor::Helpers.active_record_default_timezone == :utc ? attribute_value.utc : attribute_value.in_time_zone).strftime("%F %R:%S.%L") end new_values = (public_send(jsonb_attribute) || {}).merge(store_key => attribute_value) write_attribute(jsonb_attribute, new_values) end end # Overrides the jsonb attribute setter to make sure the jsonb fields are kept in sync. define_method("#{jsonb_attribute}=") do |value| value ||= {} names_to_store_keys = self.class.public_send(store_key_mapping_method_name) names_to_attribute_names = self.class.public_send(attribute_name_mapping_method_name) # this is the raw hash we want to save in the jsonb_attribute value_with_store_keys = JsonbAccessor::Helpers.convert_keys_to_store_keys(value, names_to_store_keys) write_attribute(jsonb_attribute, value_with_store_keys) # this maps attributes to values value_with_named_keys = JsonbAccessor::Helpers.convert_store_keys_to_keys(value, names_to_store_keys) empty_named_attributes = names_to_store_keys.transform_values { nil } empty_named_attributes.merge(value_with_named_keys).each do |name, attribute_value| # Only proceed if this attribute has been defined using `jsonb_accessor`. next unless names_to_store_keys.key?(name) attribute_name = names_to_attribute_names[name] write_attribute(attribute_name, attribute_value) end end end include setters # Makes sure new objects have the appropriate values in their jsonb fields. after_initialize do next unless has_attribute? jsonb_attribute jsonb_values = public_send(jsonb_attribute) || {} jsonb_values.each do |store_key, value| name = names_and_store_keys.key(store_key) attribute_name = names_and_attribute_names[name] next unless attribute_name write_attribute( attribute_name, JsonbAccessor::Helpers.deserialize_value(value, self.class.type_for_attribute(attribute_name).type) ) clear_attribute_change(attribute_name) if persisted? end end JsonbAccessor::AttributeQueryMethods.new(self).define(store_key_mapping_method_name, jsonb_attribute) end end end end madeintandem-jsonb_accessor-7626103/lib/jsonb_accessor/query_builder.rb000066400000000000000000000101471515721354700263050ustar00rootroot00000000000000# frozen_string_literal: true module JsonbAccessor module QueryBuilder extend ActiveSupport::Concern included do scope(:jsonb_contains, lambda do |column_name, attributes| JsonbAccessor::QueryHelper.validate_column_name!(all, column_name) where("#{table_name}.#{column_name} @> (?)::jsonb", attributes.to_json) end) scope(:jsonb_excludes, lambda do |column_name, attributes| JsonbAccessor::QueryHelper.validate_column_name!(all, column_name) where.not("#{table_name}.#{column_name} @> (?)::jsonb", attributes.to_json) end) scope(:jsonb_number_where, lambda do |column_name, field_name, given_operator, value| JsonbAccessor::QueryHelper.validate_column_name!(all, column_name) operator = JsonbAccessor::QueryHelper::NUMBER_OPERATORS_MAP.fetch(given_operator.to_s) where("(#{table_name}.#{column_name} ->> ?)::float #{operator} ?", field_name, value) end) scope(:jsonb_number_where_not, lambda do |column_name, field_name, given_operator, value| JsonbAccessor::QueryHelper.validate_column_name!(all, column_name) operator = JsonbAccessor::QueryHelper::NUMBER_OPERATORS_MAP.fetch(given_operator.to_s) where.not("(#{table_name}.#{column_name} ->> ?)::float #{operator} ?", field_name, value) end) scope(:jsonb_time_where, lambda do |column_name, field_name, given_operator, value| JsonbAccessor::QueryHelper.validate_column_name!(all, column_name) operator = JsonbAccessor::QueryHelper::TIME_OPERATORS_MAP.fetch(given_operator.to_s) where("(#{table_name}.#{column_name} ->> ?)::timestamp #{operator} ?", field_name, value) end) scope(:jsonb_time_where_not, lambda do |column_name, field_name, given_operator, value| JsonbAccessor::QueryHelper.validate_column_name!(all, column_name) operator = JsonbAccessor::QueryHelper::TIME_OPERATORS_MAP.fetch(given_operator.to_s) where.not("(#{table_name}.#{column_name} ->> ?)::timestamp #{operator} ?", field_name, value) end) scope(:jsonb_where, lambda do |column_name, attributes| query = all contains_attributes = {} JsonbAccessor::QueryHelper.convert_ranges(attributes).each do |name, value| if JsonbAccessor::QueryHelper.number_query_arguments?(value) value.each { |operator, query_value| query = query.jsonb_number_where(column_name, name, operator, query_value) } elsif JsonbAccessor::QueryHelper.time_query_arguments?(value) value.each { |operator, query_value| query = query.jsonb_time_where(column_name, name, operator, query_value) } else contains_attributes[name] = value end end query.jsonb_contains(column_name, contains_attributes) end) scope(:jsonb_where_not, lambda do |column_name, attributes| query = all excludes_attributes = {} attributes.each do |name, value| raise JsonbAccessor::QueryHelper::NotSupported, "`jsonb_where_not` scope does not accept ranges as arguments. Given `#{value}` for `#{name}` field" if value.is_a?(Range) if JsonbAccessor::QueryHelper.number_query_arguments?(value) value.each { |operator, query_value| query = query.jsonb_number_where_not(column_name, name, operator, query_value) } elsif JsonbAccessor::QueryHelper.time_query_arguments?(value) value.each { |operator, query_value| query = query.jsonb_time_where_not(column_name, name, operator, query_value) } else excludes_attributes[name] = value end end excludes_attributes.empty? ? query : query.jsonb_excludes(column_name, excludes_attributes) end) scope(:jsonb_order, lambda do |column_name, field_name, direction| JsonbAccessor::QueryHelper.validate_column_name!(all, column_name) JsonbAccessor::QueryHelper.validate_field_name!(all, column_name, field_name) JsonbAccessor::QueryHelper.validate_direction!(direction) order(Arel.sql("(#{table_name}.#{column_name} -> '#{field_name}') #{direction}")) end) end end end madeintandem-jsonb_accessor-7626103/lib/jsonb_accessor/query_helper.rb000066400000000000000000000073571515721354700261470ustar00rootroot00000000000000# frozen_string_literal: true module JsonbAccessor module QueryHelper # Errors class InvalidColumnName < StandardError; end class InvalidFieldName < StandardError; end class InvalidDirection < StandardError; end class NotSupported < StandardError; end # Constants GREATER_THAN = ">" GREATER_THAN_OR_EQUAL_TO = ">=" LESS_THAN = "<" LESS_THAN_OR_EQUAL_TO = "<=" NUMBER_OPERATORS_MAP = { GREATER_THAN => GREATER_THAN, "greater_than" => GREATER_THAN, "gt" => GREATER_THAN, GREATER_THAN_OR_EQUAL_TO => GREATER_THAN_OR_EQUAL_TO, "greater_than_or_equal_to" => GREATER_THAN_OR_EQUAL_TO, "gte" => GREATER_THAN_OR_EQUAL_TO, LESS_THAN => LESS_THAN, "less_than" => LESS_THAN, "lt" => LESS_THAN, LESS_THAN_OR_EQUAL_TO => LESS_THAN_OR_EQUAL_TO, "less_than_or_equal_to" => LESS_THAN_OR_EQUAL_TO, "lte" => LESS_THAN_OR_EQUAL_TO }.freeze NUMBER_OPERATORS = NUMBER_OPERATORS_MAP.keys.freeze TIME_OPERATORS_MAP = { "after" => GREATER_THAN, "before" => LESS_THAN }.freeze TIME_OPERATORS = TIME_OPERATORS_MAP.keys.freeze ORDER_DIRECTIONS = [:asc, :desc, "asc", "desc"].freeze class << self def validate_column_name!(query, column_name) raise InvalidColumnName, "a column named `#{column_name}` does not exist on the `#{query.model.table_name}` table" if query.model.columns.none? { |column| column.name == column_name.to_s } end def validate_field_name!(query, column_name, field_name) store_keys = query.model.public_send("jsonb_store_key_mapping_for_#{column_name}").values if store_keys.exclude?(field_name.to_s) valid_field_names = store_keys.map { |key| "`#{key}`" }.join(", ") raise InvalidFieldName, "`#{field_name}` is not a valid field name, valid field names include: #{valid_field_names}" end end def validate_direction!(option) raise InvalidDirection, "`#{option}` is not a valid direction for ordering, only `asc` and `desc` are accepted" if ORDER_DIRECTIONS.exclude?(option) end def number_query_arguments?(arg) arg.is_a?(Hash) && arg.keys.map(&:to_s).all? { |key| NUMBER_OPERATORS.include?(key) } end def time_query_arguments?(arg) arg.is_a?(Hash) && arg.keys.map(&:to_s).all? { |key| TIME_OPERATORS.include?(key) } end def convert_number_ranges(attributes) attributes.each_with_object({}) do |(name, value), new_attributes| is_range = value.is_a?(Range) new_attributes[name] = if is_range && value.first.is_a?(Numeric) && value.exclude_end? { greater_than_or_equal_to: value.first, less_than: value.end } elsif is_range && value.first.is_a?(Numeric) { greater_than_or_equal_to: value.first, less_than_or_equal_to: value.end } else value end end end def convert_time_ranges(attributes) attributes.each_with_object({}) do |(name, value), new_attributes| is_range = value.is_a?(Range) if is_range && (value.first.is_a?(Time) || value.first.is_a?(Date)) start_time = value.first end_time = value.end new_attributes[name] = { before: end_time, after: start_time } else new_attributes[name] = value end end end def convert_ranges(attributes) %i[convert_number_ranges convert_time_ranges].reduce(attributes) do |new_attributes, converter_method| public_send(converter_method, new_attributes) end end end end end madeintandem-jsonb_accessor-7626103/lib/jsonb_accessor/version.rb000066400000000000000000000003611515721354700251140ustar00rootroot00000000000000# frozen_string_literal: true module JsonbAccessor VERSION = "1.4.2" def self.enum_support? # From AR 7.1 on, enums require a database column. Gem::Version.new(ActiveRecord::VERSION::STRING) < Gem::Version.new("7.1") end end madeintandem-jsonb_accessor-7626103/spec/000077500000000000000000000000001515721354700202715ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/spec/jsonb_accessor_performance_spec.rb000066400000000000000000000015101515721354700272030ustar00rootroot00000000000000# frozen_string_literal: true require "benchmark" require "spec_helper" RSpec.describe "Jsonb Accessor Performace" do context "initializing objects from the database" do before do 1000.times do Product.create( string_type: "static string", integer_type: (1..60_000).to_a.sample, boolean_type: [true, false].sample, float_type: rand, time_type: Time.now, date_type: Date.today ) end end it "is of reasonable performance against non-jsonb records" do static_time = Benchmark.realtime { StaticProduct.all.to_a } jsonb_time = Benchmark.realtime { Product.all.to_a } # it shouldn't even be 0.1 seconds slower to fetch and instantiate 1000 records expect((static_time - jsonb_time).abs).to be < 0.1 end end end madeintandem-jsonb_accessor-7626103/spec/jsonb_accessor_spec.rb000066400000000000000000000721351515721354700246350ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe JsonbAccessor do def build_class(jsonb_accessor_config, &block) Class.new(ActiveRecord::Base) do self.table_name = "products" jsonb_accessor :options, **jsonb_accessor_config instance_eval(&block) if block attribute :bang, :string end end let(:klass) do build_class( foo: :string, bar: :integer, ban: :integer, baz: [:integer, { array: true }], bazzle: [:integer, { default: 5 }], dates: [:datetime, { array: true }] ) do enum ban: { foo: 1, bar: 2 } if JsonbAccessor.enum_support? end end let(:instance) { klass.new } it "has a version number" do expect(JsonbAccessor::VERSION).to_not be nil end it "is mixed into ActiveRecord::Base" do expect(ActiveRecord::Base.ancestors).to include(subject) end it "defines jsonb_accessor" do expect(ActiveRecord::Base).to respond_to(:jsonb_accessor) end describe "#jsonb_accessor" do it "defines getters and setters for the given methods" do expect(instance).to attr_accessorize(:foo) expect(instance).to attr_accessorize(:bar) expect(instance).to attr_accessorize(:baz) end it "supports types" do instance.foo = 12 expect(instance.foo).to eq("12") instance.bar = "12" expect(instance.bar).to eq(12) end it "supports arrays" do instance.baz = %w[1 2 3] expect(instance.baz).to eq([1, 2, 3]) end it "supports array of date" do # Write instance.dates = [Date.new(2017, 1, 1), Date.new(2017, 1, 2)] expect { instance.save! }.to_not raise_error # Read instance.reload expect(instance.dates).to be_kind_of Array expect(instance.dates.first).to be_kind_of Time end it "supports defaults" do expect(instance.bazzle).to eq(5) end if JsonbAccessor.enum_support? it "supports enums" do instance.ban = :foo expect(instance.ban).to eq("foo") expect(instance.options["ban"]).to eq(1) end end it "initializes without the jsonb_accessor field selected" do instance.save! expect do Product.select(:id).first end.not_to raise_error end context "with positional hash syntax" do let(:legacy_klass) do Class.new(ActiveRecord::Base) do self.table_name = "products" # Old-style positional hash syntax jsonb_accessor :options, { foo: :string, bar: [:integer, { default: 42 }] } end end it "defines getters and setters" do instance = legacy_klass.new expect(instance).to respond_to(:foo) expect(instance).to respond_to(:foo=) expect(instance).to respond_to(:bar) expect(instance).to respond_to(:bar=) end it "respects defaults" do instance = legacy_klass.new expect(instance.bar).to eq(42) end it "syncs values to the jsonb column" do instance = legacy_klass.new instance.foo = "hello" expect(instance.options["foo"]).to eq("hello") end end end context "getters" do let(:klass) do build_class(foo: :string) do define_method(:foo) { super().upcase } end end it "is overridable" do instance.foo = "foo" expect(instance.foo).to eq("FOO") expect(instance.options).to eq("foo" => "FOO") end end context "setters" do let(:klass) do build_class(foo: :string, bar: :integer) do define_method(:foo=) { |value| super(value.downcase) } end end it "updates the jsonb column" do foo = "foo" instance.foo = foo expect(instance.options).to eq("foo" => foo) bar = 17 instance.bar = bar expect(instance.options).to eq("foo" => foo, "bar" => bar) end it "is overridable" do instance.foo = "FOO" expect(instance.foo).to eq("foo") expect(instance.options).to eq("foo" => "foo") end end context "defaults" do let(:klass) do counter = 0 build_class(foo: [:string, { default: "bar" }], baz: [:integer, { default: -> { counter += 1 } }]) end it "allows defaults (literal and as proc)" do expect(instance.foo).to eq("bar") expect(instance.baz).to eq(1) expect(instance.options).to eq("foo" => "bar", "baz" => 1) # Make sure the default proc is evaluated each time an instance is created expect(klass.new.baz).to eq(2) end context "persisted records with sparse jsonb data" do let(:klass) do build_class( title: :string, score: [:integer, { default: 0 }], tags: [:string, { array: true, default: %w[a b] }] ) end it "returns defaults for fields not present in the jsonb column" do # Save a record with only 'title' set — score and tags are not in the jsonb hash record = klass.create! klass.connection.execute( "UPDATE products SET options = '{\"title\": \"hello\"}' WHERE id = #{record.id}" ) loaded = klass.find(record.id) expect(loaded.title).to eq("hello") expect(loaded.score).to eq(0) expect(loaded.tags).to eq(%w[a b]) end end context "false as a default" do let(:klass) do build_class(foo: [:boolean, { default: false }]) end it "allows false" do expect(instance.foo).to eq(false) expect(instance.options).to eq("foo" => false) end end context "inheritance" do let(:subklass) do counter = 100 Class.new(klass) do jsonb_accessor :options, bazbaz: [:integer, { default: -> { counter += 1 } }] end end it "allows procs as default values in both superclasses and subclasses" do instance = subklass.new expect(instance.baz).to eq(1) expect(instance.bazbaz).to eq(101) instance = subklass.new expect(instance.baz).to eq(2) expect(instance.bazbaz).to eq(102) end end context "store keys" do let(:klass) do build_class(foo: [:string, { default: "bar", store_key: :f }]) end it "puts the default value in the jsonb hash at the given store key" do expect(instance.foo).to eq("bar") expect(instance.options).to eq("f" => "bar") end context "inheritance" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, bar: [:integer, { default: 2, store_key: :o }] end end let(:subklass_instance) { subklass.new } it "includes default values from the parent in the jsonb hash with the correct store keys" do expect(subklass_instance.foo).to eq("bar") expect(subklass_instance.bar).to eq(2) expect(subklass_instance.options).to eq("f" => "bar", "o" => 2) end end end context "dirty tracking" do let(:default_class) do Class.new(ActiveRecord::Base) do self.table_name = "products" attribute :options, :jsonb, default: {} end end let(:default_instance) { default_class.new } it "is dirty the same way that overriding the default for a column via `attribute` dirties the model" do expect(instance).to be_options_changed expect(default_instance).to be_options_changed instance.save! default_instance.save! expect(instance).to_not be_options_changed expect(default_instance).to_not be_options_changed expect(instance.class.find(instance.id)).to_not be_options_changed expect(default_instance.class.find(default_instance.id)).to_not be_options_changed end end end context "setting the jsonb field directly" do let(:klass) do build_class(foo: :string, bar: :integer, baz: [:string, { store_key: :b }]) end let(:subklass) do Class.new(klass) do jsonb_accessor :options, sub: [:integer, { store_key: :s }] end end let(:subklass_instance) { subklass.new } before do instance.foo = "foo" instance.bar = 12 subklass_instance.foo = "foo" subklass_instance.bar = 12 subklass_instance.sub = 4 end it "sets the jsonb field" do new_value = { "foo" => "bar" } instance.options = new_value subklass_instance.options = new_value expect(instance.options).to eq("foo" => "bar") expect(subklass_instance.options).to eq("foo" => "bar") end it "clears the fields that are not set" do new_value = { foo: "new foo" } instance.options = new_value subklass_instance.options = new_value expect(instance.bar).to be_nil expect(subklass_instance.bar).to be_nil end it "sets the fields given in object" do new_value = { foo: "new foo" } instance.options = new_value subklass_instance.options = new_value expect(instance.foo).to eq("new foo") expect(subklass_instance.foo).to eq("new foo") expect(instance.options).to eq new_value.stringify_keys expect(subklass_instance.options).to eq new_value.stringify_keys end it "stores the data using store keys" do new_value = { baz: "baz" } instance.options = new_value subklass_instance.options = new_value expect(instance.options).to eq({ "b" => "baz" }) expect(subklass_instance.options).to eq({ "b" => "baz" }) end it "it allows store keys to be used" do new_value = { "b" => "b" } instance.options = new_value subklass_instance.options = new_value.merge(s: 22) expect(instance.baz).to eq "b" expect(subklass_instance.baz).to eq "b" expect(subklass_instance.sub).to eq 22 expect(instance.options).to eq new_value expect(subklass_instance.options).to eq new_value.merge("s" => 22) end context "when nil" do it "clears all fields" do instance.options = nil subklass_instance.options = nil expect(instance.foo).to be_nil expect(instance.bar).to be_nil expect(subklass_instance.foo).to be_nil expect(subklass_instance.bar).to be_nil expect(subklass_instance.sub).to be_nil end end it "does not write a normal Ruby attribute" do expect(instance.bang).to be_nil instance.options = { bang: "bang" } expect(instance.bang).to be_nil end end context "dirty tracking for already persisted models" do let(:klass) do build_class(foo: :string, bar: [:string, { store_key: :b }]) end it "is not dirty by default" do instance.foo = "foo" instance.bar = "bar" instance.save! persisted_instance = klass.find(instance.id) expect(persisted_instance.foo).to eq("foo") expect(persisted_instance.bar).to eq("bar") expect(persisted_instance).to_not be_foo_changed expect(persisted_instance).to_not be_bar_changed expect(persisted_instance).to_not be_options_changed expect(persisted_instance.changes).to be_empty persisted_instance = klass.find(klass.create!(foo: "foo", bar: "bar").id) expect(persisted_instance.foo).to eq("foo") expect(persisted_instance.bar).to eq("bar") expect(persisted_instance).to_not be_foo_changed expect(persisted_instance).to_not be_bar_changed expect(persisted_instance).to_not be_options_changed end end context "dirty tracking for new records" do let(:klass) do build_class(foo: :string, bar: [:string, { store_key: :b }]) end it "is not dirty by default" do expect(instance).to_not be_options_changed expect(instance).to_not be_foo_changed expect(instance).to_not be_bar_changed expect(klass.new(options: {})).to_not be_foo_changed end end context "prefixes" do let(:klass) do build_class(foo: [:string, { default: "bar", prefix: :a }]) end it "creates accessor attribute with the given prefix" do expect(instance.a_foo).to eq("bar") expect(instance.options).to eq("foo" => "bar") end context "when prefix is true" do let(:klass) do build_class(foo: [:string, { default: "bar", prefix: true }]) end it "creates accessor attribute with the json_attribute name" do expect(instance.options_foo).to eq("bar") expect(instance.options).to eq("foo" => "bar") end end context "inheritance" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, bar: [:integer, { default: 2 }] end end let(:subklass_instance) { subklass.new } it "includes default values from the parent in the jsonb hash" do expect(subklass_instance.a_foo).to eq("bar") expect(subklass_instance.bar).to eq(2) expect(subklass_instance.options).to eq("foo" => "bar", "bar" => 2) end end context "inheritance with prefix" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, bar: [:integer, { default: 2, prefix: :b }] end end let(:subklass_instance) { subklass.new } it "includes default values from the parent in the jsonb hash" do expect(subklass_instance.a_foo).to eq("bar") expect(subklass_instance.b_bar).to eq(2) expect(subklass_instance.options).to eq("foo" => "bar", "bar" => 2) end end context "with store keys" do let(:klass) do build_class(foo: [:string, { default: "bar", store_key: :g, prefix: :a }]) end it "creates accessor attribute with the given prefix and with the given store key" do expect(instance.a_foo).to eq("bar") expect(instance.options).to eq("g" => "bar") end context "inheritance" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, bar: [:integer, { default: 2, store_key: :h }] end end let(:subklass_instance) { subklass.new } it "includes default values from the parent in the jsonb hash with the correct store keys" do expect(subklass_instance.a_foo).to eq("bar") expect(subklass_instance.bar).to eq(2) expect(subklass_instance.options).to eq("g" => "bar", "h" => 2) end end context "inheritance with prefix" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, bar: [:integer, { default: 2, store_key: :i, prefix: :b }] end end let(:subklass_instance) { subklass.new } it "includes default values from the parent in the jsonb hash with the correct store keys" do expect(subklass_instance.a_foo).to eq("bar") expect(subklass_instance.b_bar).to eq(2) expect(subklass_instance.options).to eq("g" => "bar", "i" => 2) end end end context "with global prefix option" do let(:klass) do Class.new(ActiveRecord::Base) do self.table_name = "products" jsonb_accessor :options, { prefix: :opt }, foo: [:string, { default: "bar" }], baz: :integer end end it "applies prefix to all fields" do expect(instance.opt_foo).to eq("bar") expect(instance).to respond_to(:opt_baz) expect(instance).to respond_to(:opt_baz=) expect(instance.options).to eq("foo" => "bar") end it "allows individual fields to override the global prefix" do klass_with_override = Class.new(ActiveRecord::Base) do self.table_name = "products" jsonb_accessor :options, { prefix: :opt }, foo: [:string, { default: "bar" }], bar: [:integer, { prefix: :custom }] end instance_override = klass_with_override.new expect(instance_override.opt_foo).to eq("bar") expect(instance_override).to respond_to(:custom_bar) expect(instance_override).to respond_to(:custom_bar=) end end end context "suffixes" do let(:klass) do build_class(foo: [:string, { default: "bar", suffix: :a }]) end it "creates accessor attribute with the given suffix" do expect(instance.foo_a).to eq("bar") expect(instance.options).to eq("foo" => "bar") end context "when suffix is true" do let(:klass) do build_class(foo: [:string, { default: "bar", suffix: true }]) end it "creates accessor attribute with the json_attribute name" do expect(instance.foo_options).to eq("bar") expect(instance.options).to eq("foo" => "bar") end end context "inheritance" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, bar: [:integer, { default: 2 }] end end let(:subklass_instance) { subklass.new } it "includes default values from the parent in the jsonb hash" do expect(subklass_instance.foo_a).to eq("bar") expect(subklass_instance.bar).to eq(2) expect(subklass_instance.options).to eq("foo" => "bar", "bar" => 2) end end context "inheritance with suffix" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, bar: [:integer, { default: 2, suffix: :b }] end end let(:subklass_instance) { subklass.new } it "includes default values from the parent in the jsonb hash" do expect(subklass_instance.foo_a).to eq("bar") expect(subklass_instance.bar_b).to eq(2) expect(subklass_instance.options).to eq("foo" => "bar", "bar" => 2) end end context "with store keys" do let(:klass) do build_class(foo: [:string, { default: "bar", store_key: :g, suffix: :a }]) end it "creates accessor attribute with the given suffix and with the given store key" do expect(instance.foo_a).to eq("bar") expect(instance.options).to eq("g" => "bar") end context "inheritance" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, bar: [:integer, { default: 2, store_key: :h }] end end let(:subklass_instance) { subklass.new } it "includes default values from the parent in the jsonb hash with the correct store keys" do expect(subklass_instance.foo_a).to eq("bar") expect(subklass_instance.bar).to eq(2) expect(subklass_instance.options).to eq("g" => "bar", "h" => 2) end end context "inheritance with suffix" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, bar: [:integer, { default: 2, store_key: :i, suffix: :b }] end end let(:subklass_instance) { subklass.new } it "includes default values from the parent in the jsonb hash with the correct store keys" do expect(subklass_instance.foo_a).to eq("bar") expect(subklass_instance.bar_b).to eq(2) expect(subklass_instance.options).to eq("g" => "bar", "i" => 2) end end end end describe "#_where" do let(:klass) do build_class( title: [:string, { store_key: :t }], rank: [:integer, { store_key: :r }], made_at: [:datetime, { store_key: :ma }] ) end let(:title) { "title" } let!(:matching_record) { klass.create!(title: title, rank: 4, made_at: Time.current) } let!(:ignored_record) { klass.create!(title: "ignored", rank: 3, made_at: 3.years.ago) } let!(:blank_record) { klass.create! } subject { klass.all } it "is records matching the criteria" do query = subject.options_where( title: title, rank: { greater_than: 3, less_than: 7 }, made_at: { before: 2.days.from_now, after: 2.days.ago } ) expect(query).to exist expect(query).to eq([matching_record]) end context "inheritance" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, other_title: [:string, { store_key: :ot }] end end subject { subklass.all } it "is records matching the criteria on a subclass" do query = subject.options_where( title: title, rank: { greater_than: 3, less_than: 7 }, made_at: { before: 2.days.from_now, after: 2.days.ago } ) expect(query).to exist expect(query.pluck(:id)).to eq([matching_record.id]) end end end describe "#_where_not" do let(:klass) do build_class( title: [:string, { store_key: :t }], rank: [:integer, { store_key: :r }], made_at: [:datetime, { store_key: :ma }] ) end let(:title) { "title" } let!(:matching_record) { klass.create!(title: "foo", rank: 4, made_at: Time.current) } let!(:ignored_record) { klass.create!(title: title, rank: 3, made_at: 3.years.ago) } let!(:blank_record) { klass.create! } subject { klass.all } it "excludes records matching the criteria" do query = subject.options_where_not( title: title, rank: { greater_than: 5 }, made_at: { before: 1.year.ago } ) expect(query).to exist expect(query).to eq([matching_record]) end context "inheritance" do let(:subklass) do Class.new(klass) do self.table_name = "products" jsonb_accessor :options, other_title: [:string, { store_key: :ot }] end end subject { subklass.all } it "excludes records matching the criteria on a subclass" do query = subject.options_where_not( title: title, rank: { greater_than: 5 }, made_at: { before: 1.year.ago } ) expect(query).to exist expect(query.pluck(:id)).to eq([matching_record.id]) end end end describe "#" do context "field name only" do let(:klass) { build_class(title: :string) } let!(:instance_1) { klass.create!(title: "B") } let!(:instance_2) { klass.create!(title: "C") } let!(:instance_3) { klass.create!(title: "A") } let(:ordered_intances) { [instance_3, instance_1, instance_2] } it "orders the values" do expect(klass.all.options_order(:title)).to eq(ordered_intances) end end context "hash argument" do let(:klass) { build_class(title: :string) } let!(:instance_1) { klass.create!(title: "B") } let!(:instance_2) { klass.create!(title: "C") } let!(:instance_3) { klass.create!(title: "A") } let(:ordered_intances) { [instance_2, instance_1, instance_3] } it "orders the values" do expect(klass.all.options_order(title: :desc)).to eq(ordered_intances) end end context "field names and a hash argument" do let(:klass) { build_class(title: :string, rank: :integer, name: :string) } let!(:instance_1) { klass.create!(title: "B", rank: 99, name: "A") } let!(:instance_2) { klass.create!(title: "A", rank: 100, name: "A") } let!(:instance_3) { klass.create!(title: "B", rank: 99, name: "B") } let!(:instance_4) { klass.create!(title: "B", rank: 100, name: "A") } let(:ordered_intances) { [instance_2, instance_4, instance_1, instance_3] } let(:filtered_and_ordered_intances) { [instance_2, instance_4, instance_1] } it "orders the values" do expect(klass.all.options_order(:title, rank: :desc, name: :asc)).to eq(ordered_intances) expect(klass.all.options_where(name: "A").options_order(:title, rank: :desc, name: :asc)).to eq(filtered_and_ordered_intances) end end context "store keys" do let(:klass) { build_class(title: [:string, { store_key: :t }]) } let!(:instance_1) { klass.create!(title: "B") } let!(:instance_2) { klass.create!(title: "C") } let!(:instance_3) { klass.create!(title: "A") } let(:ordered_intances) { [instance_3, instance_1, instance_2] } it "orders the values while accounting for store keys" do expect(klass.all.options_order(:title)).to eq(ordered_intances) end end end describe "store keys" do let(:klass) { build_class(foo: [:string, { store_key: :f }]) } it "stores the value at the given key in the jsonb attribute" do instance.foo = "foo" expect(instance.options).to eq("f" => "foo") end end describe "having non jsonb accessor declared fields" do let!(:static_product) { StaticProduct.create!(options: { "foo" => 5 }) } let(:product) { Product.find(static_product.id) } it "does not raise an error" do expect { product }.to_not raise_error expect(product.options).to eq(static_product.options) end end describe "when excluding the jsonb attribute field from a call to `select`" do it "does not raise an error" do expect { Product.select(:string_type).where(nil).to_a }.to_not raise_error end end describe ".jsonb_store_key_mapping_for_" do let(:klass) { build_class(foo: :string, bar: [:integer, { store_key: :b }]) } it "is a mapping of fields to store keys" do expect(klass.jsonb_store_key_mapping_for_options).to eq("foo" => "foo", "bar" => "b") end context "inheritance" do let(:subklass) do Class.new(klass) do jsonb_accessor :options, baz: [:integer, { store_key: :bz }] end end it "includes its parent's and its own jsonb attributes" do expect(subklass.jsonb_store_key_mapping_for_options).to eq("foo" => "foo", "bar" => "b", "baz" => "bz") end end end describe ".jsonb_defaults_mapping_for_" do let(:klass) { build_class(bar: [:integer, { store_key: :b, default: 2 }]) } it "is a mapping of store keys to defaults" do expect(klass.jsonb_defaults_mapping_for_options).to eq("b" => 2) end context "inheritance" do let(:subklass) do Class.new(klass) do self.table_name = "products" jsonb_accessor :options, baz: [:string, { store_key: :z, default: 3 }] end end it "is a mapping of store keys to defaults that includes its parent's mapping" do expect(subklass.jsonb_defaults_mapping_for_options).to eq("b" => 2, "z" => 3) end end end describe "inheritance" do let(:parent_class) do build_class(title: :string, rank: [:integer, { store_key: :r }]) end let(:child_class) do Class.new(parent_class) do jsonb_accessor :options, other_title: :string, year: [:integer, { store_key: :y }] end end context "initialization" do let(:title) { "some title" } let(:parent) { parent_class.new(title: title, rank: 3) } let(:child) { child_class.new(title: title, other_title: title, rank: 4, year: 1996) } it "sets the object with the proper values" do expect(parent.title).to eq(title) expect(parent.rank).to eq(3) expect(child.title).to eq(title) expect(child.other_title).to eq(title) expect(child.rank).to eq(4) expect(child.year).to eq(1996) parent.save! child.save! db_parent = parent_class.find(parent.id) db_child = child_class.find(child.id) expect(db_parent.title).to eq(title) expect(db_parent.rank).to eq(3) expect(db_child.title).to eq(title) expect(db_child.other_title).to eq(title) expect(db_child.rank).to eq(4) expect(db_child.year).to eq(1996) expect(db_parent.title).to eq(title) expect(db_parent.rank).to eq(3) expect(db_child.title).to eq(title) expect(db_child.other_title).to eq(title) expect(db_child.rank).to eq(4) expect(db_child.year).to eq(1996) end end end context "datetime field" do let(:klass) { build_class(foo: :datetime) } let(:time_with_zone) { Time.new(2022, 1, 1, 12, 5, 0, "-03:00") } let(:now) { Time.zone.parse("2022-09-18 09:44:00") } it "saves in UTC" do instance.foo = time_with_zone expect(instance.options).to eq({ "foo" => "2022-01-01 15:05:00.000" }) end it "deserializes to time with zone", tz: "America/Los_Angeles" do travel_to now do # we are -7 hours from UTC instance = klass.new({ options: { "foo" => "2022-09-18 16:44:00" } }) expect(instance.foo).to eq Time.new(2022, 9, 18, 9, 44, 0, "-07:00") end end context "when default_timezone is local", ar_default_tz: :local do it "saves in local time" do instance.foo = time_with_zone expect(instance.options).to eq({ "foo" => "2022-01-01 12:05:00.000" }) end it "deserializes to time with zone", tz: "Europe/Berlin" do travel_to now do # we are +2 hours from UTC instance = klass.new({ options: { "foo" => "2022-09-18 16:44:00" } }) expect(instance.foo).to eq Time.new(2022, 9, 18, 16, 44, 0, "+02:00") end end end end describe "arbitrary data" do let(:field) { "external" } let(:some_value) { ["any", "value", { "really" => "actually" }] } it "is possible to set arbitrary data" do options = instance.options.merge(field => some_value) instance.update!(options: options) expect(instance.options[field]).to eq some_value # make sure it doesn't get lost after normal use instance.foo = "fooos" instance.save! instance.reload expect(instance.foo).to eq "fooos" expect(instance.options[field]).to eq some_value end end end madeintandem-jsonb_accessor-7626103/spec/lib/000077500000000000000000000000001515721354700210375ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/spec/lib/jsonb_accessor/000077500000000000000000000000001515721354700240345ustar00rootroot00000000000000madeintandem-jsonb_accessor-7626103/spec/lib/jsonb_accessor/helpers_spec.rb000066400000000000000000000037311515721354700270410ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe JsonbAccessor::Helpers do describe ".convert_keys_to_store_keys" do let(:attributes) { { foo: "bar", bar: "baz" } } let(:store_key_mapping) { { "foo" => "foo", "bar" => "b" } } let(:expected) { { "foo" => "bar", "b" => "baz" } } it "converts the keys of a given hash into store keys based on the given store key mapping" do expect(subject.convert_keys_to_store_keys(attributes, store_key_mapping)).to eq(expected) end end describe ".convert_store_keys_to_keys" do let(:attributes) { { foo: "bar", b: "baz" } } let(:store_key_mapping) { { "foo" => "foo", "bar" => "b" } } let(:expected) { { "foo" => "bar", "bar" => "baz" } } it "converts the keys of a given hash into named keys based on the given store key mapping" do expect(subject.convert_store_keys_to_keys(attributes, store_key_mapping)).to eq(expected) end end describe ".define_attribute_name" do let(:json_attribute) { :options } let(:name) { :foo } let(:prefix) { :pref } let(:suffix) { :suff } let(:expected) { "#{prefix}_#{name}_#{suffix}" } it "returns attribute name with prefix and suffix" do expect(subject.define_attribute_name(json_attribute, name, prefix, suffix)).to eq(expected) end context "when affixes is true class" do let(:prefix) { true } let(:suffix) { true } let(:expected) { "#{json_attribute}_#{name}_#{json_attribute}" } it "returns attribute name with json_attribute prefix and suffix" do expect(subject.define_attribute_name(json_attribute, name, prefix, suffix)).to eq(expected) end end context "when affixes is nil" do let(:prefix) { nil } let(:suffix) { nil } let(:expected) { name.to_s } it "returns attribute name without prefix and suffix" do expect(subject.define_attribute_name(json_attribute, name, prefix, suffix)).to eq(expected) end end end end madeintandem-jsonb_accessor-7626103/spec/lib/jsonb_accessor/query_builder_spec.rb000066400000000000000000000350401515721354700302500ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe JsonbAccessor::QueryBuilder do describe "#jsonb_contains" do let(:title) { "title" } let!(:matching_record) { Product.create!(title: title) } let!(:other_matching_record) { Product.create!(title: title) } let!(:ignored_record) { Product.create!(title: "ignored") } subject { Product.all } it "is a collection of records that match the query" do query = subject.jsonb_contains(:options, title: title) expect(query).to exist expect(query).to match_array([matching_record, other_matching_record]) end it "escapes sql" do expect do subject.jsonb_contains(:options, title: "foo\"};delete from products where id = #{matching_record.id}").to_a end.to_not raise_error expect(subject.count).to eq(3) end context "given an invalid column name" do it "raises an error" do expect do subject.jsonb_contains(:nope, title: "foo") end.to raise_error(JsonbAccessor::QueryHelper::InvalidColumnName) end end context "table names" do let!(:product_category) { ProductCategory.create!(title: "category") } before do product_category.products << matching_record product_category.products << other_matching_record end it "is not ambigious which table is being referenced" do expect do subject.joins(:product_category).merge(ProductCategory.jsonb_contains(:options, title: "category")).to_a end.to_not raise_error end end end describe "#jsonb_excludes" do let(:title) { "title" } let!(:matching_record) { Product.create!(title: title) } let!(:other_matching_record) { Product.create!(title: title) } let!(:ignored_record) { Product.create!(title: "ignored") } subject { Product.all } it "is a collection of records that don't match the query" do query = subject.jsonb_excludes(:options, title: ignored_record.title) expect(query).to exist expect(query).to match_array([matching_record, other_matching_record]) end it "escapes sql" do expect do subject.jsonb_excludes(:options, title: "foo\"};delete from products where id = #{matching_record.id}").to_a end.to_not raise_error expect(subject.count).to eq(3) end context "given an invalid column name" do it "raises an error" do expect do subject.jsonb_excludes(:nope, title: "foo") end.to raise_error(JsonbAccessor::QueryHelper::InvalidColumnName) end end context "table names" do let!(:product_category) { ProductCategory.create!(title: "category") } before do product_category.products << matching_record product_category.products << other_matching_record end it "is not ambigious which table is being referenced" do expect do subject.joins(:product_category).merge(ProductCategory.jsonb_excludes(:options, title: "category")).to_a end.to_not raise_error end end end describe "#jsonb_number_where" do let!(:high_rank_record) { Product.create!(rank: 5) } let!(:middle_rank_record) { Product.create!(rank: 4) } let!(:low_rank_record) { Product.create!(rank: 0) } subject { Product.all } context "given an invalid column name" do it "raises an error" do expect do subject.jsonb_number_where(:nope, :rank, ">", middle_rank_record.rank) end.to raise_error(JsonbAccessor::QueryHelper::InvalidColumnName) end end context "greater than" do it "is matching records" do [:>, :greater_than, :gt, ">", "greater_than", "gt"].each do |operator| query = subject.jsonb_number_where(:options, :rank, operator, middle_rank_record.rank) expect(query).to exist expect(query).to eq([high_rank_record]) end end end context "less than" do it "is matching records" do [:<, :less_than, :lt, "<", "less_than", "lt"].each do |operator| query = subject.jsonb_number_where(:options, :rank, operator, middle_rank_record.rank) expect(query).to exist expect(query).to eq([low_rank_record]) end end end context "less than or equal to" do it "is matching records" do [:<=, :less_than_or_equal_to, :lte, "<=", "less_than_or_equal_to", "lte"].each do |operator| query = subject.jsonb_number_where(:options, :rank, operator, middle_rank_record.rank) expect(query).to exist expect(query).to match_array([low_rank_record, middle_rank_record]) end end end context "greater than or equal to" do it "is matching records" do [:>=, :greater_than_or_equal_to, :gte, ">=", "greater_than_or_equal_to", "gte"].each do |operator| query = subject.jsonb_number_where(:options, :rank, operator, middle_rank_record.rank) expect(query).to exist expect(query).to match_array([high_rank_record, middle_rank_record]) end end end end describe "#jsonb_number_where_not" do let!(:high_rank_record) { Product.create!(rank: 5) } let!(:middle_rank_record) { Product.create!(rank: 4) } let!(:low_rank_record) { Product.create!(rank: 0) } subject { Product.all } context "given an invalid column name" do it "raises an error" do expect do subject.jsonb_number_where_not(:nope, :rank, ">", middle_rank_record.rank) end.to raise_error(JsonbAccessor::QueryHelper::InvalidColumnName) end end context "greater than" do it "excludes matching records" do [:>, :greater_than, :gt, ">", "greater_than", "gt"].each do |operator| query = subject.jsonb_number_where_not(:options, :rank, operator, middle_rank_record.rank) expect(query).to exist expect(query).to match_array([low_rank_record, middle_rank_record]) end end end context "less than" do it "excludes matching records" do [:<, :less_than, :lt, "<", "less_than", "lt"].each do |operator| query = subject.jsonb_number_where_not(:options, :rank, operator, middle_rank_record.rank) expect(query).to exist expect(query).to match_array([high_rank_record, middle_rank_record]) end end end context "less than or equal to" do it "excludes matching records" do [:<=, :less_than_or_equal_to, :lte, "<=", "less_than_or_equal_to", "lte"].each do |operator| query = subject.jsonb_number_where_not(:options, :rank, operator, middle_rank_record.rank) expect(query).to exist expect(query).to match_array([high_rank_record]) end end end context "greater than or equal to" do it "excludes matching records" do [:>=, :greater_than_or_equal_to, :gte, ">=", "greater_than_or_equal_to", "gte"].each do |operator| query = subject.jsonb_number_where_not(:options, :rank, operator, middle_rank_record.rank) expect(query).to exist expect(query).to match_array([low_rank_record]) end end end end describe "#jsonb_time_where" do let!(:early_record) { Product.create!(made_at: 10.days.ago) } let!(:late_record) { Product.create!(made_at: 2.days.from_now) } subject { Product.all } context "given an invalid column name" do it "raises an error" do expect do subject.jsonb_time_where(:nope, :made_at, "before", Time.current) end.to raise_error(JsonbAccessor::QueryHelper::InvalidColumnName) end end context "before" do it "is matching records" do [:before, "before"].each do |operator| query = subject.jsonb_time_where(:options, :made_at, operator, Time.current) expect(query).to exist expect(query).to eq([early_record]) end end end context "after" do it "is matching records" do [:after, "after"].each do |operator| query = subject.jsonb_time_where(:options, :made_at, operator, Time.current) expect(query).to exist expect(query).to eq([late_record]) end end end end describe "#jsonb_time_where_not" do let!(:early_record) { Product.create!(made_at: 10.days.ago) } let!(:late_record) { Product.create!(made_at: 2.days.from_now) } subject { Product.all } context "given an invalid column name" do it "raises an error" do expect do subject.jsonb_time_where_not(:nope, :made_at, "before", Time.current) end.to raise_error(JsonbAccessor::QueryHelper::InvalidColumnName) end end context "before" do it "excludes matching records" do [:before, "before"].each do |operator| query = subject.jsonb_time_where_not(:options, :made_at, operator, Time.current) expect(query).to exist expect(query).to eq([late_record]) end end end context "after" do it "excludes matching records" do [:after, "after"].each do |operator| query = subject.jsonb_time_where_not(:options, :made_at, operator, Time.current) expect(query).to exist expect(query).to eq([early_record]) end end end end describe "#jsonb_where" do let(:title) { "title" } let!(:matching_record) { Product.create!(title: title, rank: 4, made_at: Time.current) } let!(:ignored_record) { Product.create!(title: "ignored", rank: 8, made_at: 3.years.ago) } let!(:blank_record) { Product.create! } subject { Product.all } context "contains" do it "is matching records" do query = subject.jsonb_where(:options, title: title) expect(query).to exist expect(query).to eq([matching_record]) end end context "number queries" do it "is records matching the criteria" do query = subject.jsonb_where(:options, rank: { greater_than: 3, less_than: 7 }) expect(query).to exist expect(query).to eq([matching_record]) end end context "time queries" do it "is records matching the criteria" do query = subject.jsonb_where(:options, made_at: { before: 2.days.from_now, after: 2.days.ago }) expect(query).to exist expect(query).to eq([matching_record]) end end context "number ranges" do it "is records within the range" do query = subject.jsonb_where(:options, rank: 3...6) expect(query).to exist expect(query).to eq([matching_record]) end context "excluding the end" do it "is the records within the range" do query = subject.jsonb_where(:options, rank: 3...8) expect(query).to exist expect(query).to eq([matching_record]) end end context "including the end" do it "is the records within the range" do query = subject.jsonb_where(:options, rank: 1..4) expect(query).to exist expect(query).to eq([matching_record]) end end end context "time ranges" do it "is records within the range" do query = subject.jsonb_where(:options, made_at: 2.days.ago..2.days.from_now) expect(query).to exist expect(query).to eq([matching_record]) end end context "smoke test" do it "is records matching the criteria" do query = subject.jsonb_where( :options, title: title, rank: { greater_than: 3, less_than: 7 }, made_at: { before: 2.days.from_now, after: 2.days.ago } ) expect(query).to exist expect(query).to eq([matching_record]) end end end describe "#jsonb_where_not" do let(:title) { "title" } let!(:matching_record) { Product.create!(title: "not excluded", rank: 3, made_at: 3.years.ago) } let!(:ignored_record) { Product.create!(title: title, rank: 5, made_at: Time.current) } let!(:blank_record) { Product.create! } subject { Product.all } context "contains" do it "excludes matching records" do query = subject.jsonb_where_not(:options, title: ignored_record.title) expect(query).to exist expect(query).to eq([matching_record]) end end context "number queries" do it "excludes records matching the criteria" do query = subject.jsonb_where_not(:options, rank: { greater_than: 3 }) expect(query).to exist expect(query).to eq([matching_record]) end end context "time queries" do it "excludes records matching the criteria" do query = subject.jsonb_where_not(:options, made_at: { after: 2.days.ago }) expect(query).to exist expect(query).to eq([matching_record]) end end context "ranges" do it "raises an error when any value is a range" do expect { subject.jsonb_where_not(:options, rank: 4...6) }.to raise_error(JsonbAccessor::QueryHelper::NotSupported, "`jsonb_where_not` scope does not accept ranges as arguments. Given `4...6` for `rank` field") end end context "smoke test" do it "excludes records matching the criteria" do query = subject.jsonb_where_not( :options, title: title, rank: { greater_than: 3 }, made_at: { after: 2.days.ago } ) expect(query).to exist expect(query).to eq([matching_record]) end end end describe "#jsonb_order" do let!(:second_product) { Product.create!(title: "B") } let!(:last_product) { Product.create!(title: "C") } let!(:first_product) { Product.create!(title: "A") } let(:ordered_records) { [first_product, second_product, last_product] } subject { Product.all } it "orders by the given attribute and direction" do expect(subject.jsonb_order(:options, :title, :asc)).to eq(ordered_records) expect(subject.jsonb_order(:options, :title, :desc)).to eq(ordered_records.reverse) end context "given an invalid column name" do it "raises an error" do expect do subject.jsonb_order(:nah, :title, :asc) end.to raise_error(JsonbAccessor::QueryHelper::InvalidColumnName) end end context "given an invalid field" do it "raises an error" do expect do subject.jsonb_order(:options, :nah, :asc) end.to raise_error(JsonbAccessor::QueryHelper::InvalidFieldName) end end context "given an invalid direction" do it "raises an error" do expect do subject.jsonb_order(:options, :title, :nah) end.to raise_error(JsonbAccessor::QueryHelper::InvalidDirection) end end end end madeintandem-jsonb_accessor-7626103/spec/lib/jsonb_accessor/query_helper_spec.rb000066400000000000000000000153561515721354700301110ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe JsonbAccessor::QueryHelper do describe ".validate_column_name!" do context "when the column exists for the relation" do it "is true" do expect do subject.validate_column_name!(Product.all, :options) end.to_not raise_error expect do subject.validate_column_name!(Product.all, "options") end.to_not raise_error end end context "when the column does not exist for the relation" do it "is false" do error_message = "a column named `nope` does not exist on the `products` table" expect do subject.validate_column_name!(Product.all, :nope) end.to raise_error(JsonbAccessor::QueryHelper::InvalidColumnName, error_message) expect do subject.validate_column_name!(Product.all, "nope") end.to raise_error(JsonbAccessor::QueryHelper::InvalidColumnName, error_message) end end end describe ".validate_field_name!" do let(:klass) do Class.new(ActiveRecord::Base) do self.table_name = "products" jsonb_accessor :options, title: :string, description: [:string, { store_key: :d }] end end context "given a valid field name" do it "does not raise an error" do expect do subject.validate_field_name!(klass.all, :options, :title) subject.validate_field_name!(klass.all, :options, "title") subject.validate_field_name!(klass.all, :options, "d") end.to_not raise_error end end context "given an invalid field name" do it "raises an error" do expect do subject.validate_field_name!(klass.all, :options, "foo") end.to raise_error( JsonbAccessor::QueryHelper::InvalidFieldName, "`foo` is not a valid field name, valid field names include: `title`, `d`" ) end end end describe ".validate_direction!" do context "given a valid direction" do it "does not raise an error" do expect do [:asc, :desc, "asc", "desc"].each do |option| subject.validate_direction!(option) end end.to_not raise_error end end context "given an invalid direction" do it "raises an error" do expect do subject.validate_direction!(:foo) end.to raise_error(JsonbAccessor::QueryHelper::InvalidDirection, "`foo` is not a valid direction for ordering, only `asc` and `desc` are accepted") end end end describe ".number_query_arguments?" do context "not a hash" do it "is false" do expect(subject.number_query_arguments?(nil)).to eq(false) expect(subject.number_query_arguments?("foo")).to eq(false) end end context "hash that is not for a number query" do it "is false" do expect(subject.number_query_arguments?("before" => 12)).to eq(false) expect(subject.number_query_arguments?("title" => "foo")).to eq(false) end end context "hash that is for a number query" do it "is true" do expect(subject.number_query_arguments?(greater_than: 5, "less_than" => 10)).to eq(true) end end end describe ".time_query_arguments?" do context "not a hash" do it "is false" do expect(subject.time_query_arguments?(nil)).to eq(false) expect(subject.time_query_arguments?("foo")).to eq(false) end end context "hash that is not for a number query" do it "is false" do expect(subject.time_query_arguments?("greater_than" => 12)).to eq(false) expect(subject.time_query_arguments?("title" => "foo")).to eq(false) end end context "hash that is for a number query" do it "is true" do expect(subject.time_query_arguments?(before: 10, "after" => 5)).to eq(true) end end end describe ".convert_time_ranges" do let(:start_time) { 3.days.ago } let(:end_time) { 3.days.from_now } let(:start_date) { start_time.to_date } let(:end_date) { end_time.to_date } context "times" do it "converts time ranges into `before` and `after` hashes" do expect(subject.convert_time_ranges(foo: start_time..end_time)).to eq(foo: { after: start_time, before: end_time }) end end context "dates" do it "converts time ranges into `before` and `after` hashes" do expect(subject.convert_time_ranges(foo: start_date..end_date)).to eq(foo: { after: start_date, before: end_date }) end end context "non ranges" do it "preserves them" do expect(subject.convert_time_ranges(foo: start_time)).to eq(foo: start_time) expect(subject.convert_time_ranges(bar: 9)).to eq(bar: 9) end end context "number ranges" do it "preserves them" do expect(subject.convert_time_ranges(foo: 1..3)).to eq(foo: 1..3) end end end describe ".convert_number_ranges" do context "inclusive" do it "is greater than or equal to the start value and less than or equal to the end value" do expect(subject.convert_number_ranges(foo: 1..3)).to eq(foo: { greater_than_or_equal_to: 1, less_than_or_equal_to: 3 }) expect(subject.convert_number_ranges(foo: 1.1..3.3)).to eq(foo: { greater_than_or_equal_to: 1.1, less_than_or_equal_to: 3.3 }) end end context "exclusive" do it "is greater than or equal to the start value and less than the end value" do expect(subject.convert_number_ranges(foo: 1...3)).to eq(foo: { greater_than_or_equal_to: 1, less_than: 3 }) expect(subject.convert_number_ranges(foo: 1.1...3.3)).to eq(foo: { greater_than_or_equal_to: 1.1, less_than: 3.3 }) end end context "non ranges" do it "preserves them" do expect(subject.convert_number_ranges(foo: "A")).to eq(foo: "A") expect(subject.convert_number_ranges(bar: 9)).to eq(bar: 9) end end context "date/time ranges" do let(:start_time) { 3.days.ago } let(:end_time) { 3.days.from_now } let(:start_date) { start_time.to_date } let(:end_date) { end_time.to_date } it "preserves them" do expect(subject.convert_number_ranges(foo: start_time..end_time)).to eq(foo: start_time..end_time) expect(subject.convert_number_ranges(foo: start_date..end_date)).to eq(foo: start_date..end_date) end end end describe ".convert_ranges" do let(:start_time) { 3.days.ago } let(:end_time) { 3.days.from_now } it "converts number and time ranges" do expected = { foo: { greater_than_or_equal_to: 1, less_than_or_equal_to: 3 }, bar: { before: end_time, after: start_time } } expect(subject.convert_ranges(foo: 1..3, bar: start_time..end_time)).to eq(expected) end end end madeintandem-jsonb_accessor-7626103/spec/spec_helper.rb000066400000000000000000000046271515721354700231200ustar00rootroot00000000000000# frozen_string_literal: true $LOAD_PATH.unshift File.expand_path("../lib", __dir__) require "jsonb_accessor" require "pry" require "pry-nav" require "pry-doc" require "awesome_print" require "database_cleaner-active_record" require "yaml" require "active_support/testing/time_helpers" dbconfig = YAML.safe_load(ERB.new(File.read(File.join("db", "config.yml"))).result, aliases: true) ActiveRecord::Base.establish_connection(dbconfig["test"]) ActiveRecord::Base.logger = Logger.new($stdout, level: :warn) # rubocop:disable Style/OneClassPerFile class StaticProduct < ActiveRecord::Base self.table_name = "products" belongs_to :product_category end class Product < StaticProduct jsonb_accessor :options, title: :string, rank: :integer, made_at: :datetime end class ProductCategory < ActiveRecord::Base jsonb_accessor :options, title: :string has_many :products end # rubocop:enable Style/OneClassPerFile RSpec::Matchers.define :attr_accessorize do |attribute_name| match do |actual| actual.respond_to?(attribute_name) && actual.respond_to?("#{attribute_name}=") end end RSpec.configure do |config| config.include ActiveSupport::Testing::TimeHelpers config.expect_with :rspec do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true end config.mock_with :rspec do |mocks| mocks.verify_partial_doubles = true end config.around :example, :tz do |example| Time.use_zone(example.metadata[:tz]) { example.run } end config.around :example, :ar_default_tz do |example| active_record_base = if ActiveRecord.respond_to? :default_timezone ActiveRecord else ActiveRecord::Base end old_default = active_record_base.default_timezone active_record_base.default_timezone = example.metadata[:ar_default_tz] example.run active_record_base.default_timezone = old_default end config.filter_run :focus config.run_all_when_everything_filtered = true config.disable_monkey_patching! config.default_formatter = "doc" if config.files_to_run.one? config.profile_examples = 0 config.order = :random Kernel.srand config.seed config.before do DatabaseCleaner.clean_with(:truncation) # treat warnings as error for example when Rails warns that some method is being overridden. expect_any_instance_of(Logger).to_not receive(:warn) end end madeintandem-jsonb_accessor-7626103/tandem-logo.png000066400000000000000000000143101515721354700222520ustar00rootroot00000000000000PNG  IHDR3 GDsRGBIDATx] xŕ~3#Y>4#K1lX΅K,n> ,&dINp,a g dˬ,ٱ,i뚙_}TOe$z{^:$7F2DYh(x!+r QCid%,+|Q c\Ltb!˼*IvkN{IғPQ4D!W.S ZE M$ľZw 8s$юcU[XS1N z R8b}5 TB ! K8>9DB 2lUQ |Rip9 T$Go TDCJR9 T$Go _Y~+M>6Blo#{4J‡64Fdd! kid[MȢzC@ִ?{Hn$^E:F@h=2CUMЇo13vGɈN#e"ҶwJj8EВaM~BnDBޞjM8M`g28-6t- q5 s4g8 +ڋ,&J4%TWF, Ep,DѶ xGPYedQePdaxdg:Ru3J)};xү-]<} eU%/y$-%y!h}lR Hz _s?y 酾Fx=o=4>St$(u=I4@G؋ J朼r Xv5=8Է~ <ɚ⋒z|en{PIf7KFbgOk(>0ƪN$#Zd!-|嵴7͢u\&o72ڟ<\GT> %[mŢp.Gᗉy \BwQrDo(͋uMٴ3B}yhˁzdod+}U_oz;`}V%K7' {^#?=a7%Z y͌m̈́@& 0 ys?O F;O6EOp!ŌPzd7SL޸!A*`lQ ]A|%:Q]xzk"qw](1ogV"$QlLjekͦȽˣ]$9M۽[DHf)M# %?< j 7kO[]0w?O`()m΂*&Tn͞gP0D.I+">Q[`-!zhGJn(kE/*1RF\ K 8COw͡x f.kD;S1\0 wCb0ْ6<&:SChŃiAP4V>«Te.}!E]L&3Hy8lC&8 (g_+_œ(a7Q)D[=ph% (b`Kj9}2V'n&91Gw 7嵑Ή P>}z3N3 ]x'OPP~u1PߩkS `g 1HdDz9X]U8GꥴkZsx4~_[._Յf=a'm^I IĠZ8O`ZƄ< f=ҷuiCڵHs.: ! `#ϥJH`tJ]ɾUX. ]GWXyd~H׀wk0շňtWi)z 9lnLMxRONm{\Nkl[#"zޞ=0nD?ԣ+ }:O`z}[jPU0۴pv7Pl `&egY:sE3gR6|)*%~ub`T),T>owz,b25"gGD'EmǙXpCbn8 CL( EB# 3! t ՇS>\1 g'yˈ.8QS\x|njȴ'HVz%0@ + A)~a qA_udi&Wi#by%F] J&KT52 }dp 4 u ϕW_>$ZUTgRZlAҟ81ы.=M ^O&z l0 8Yt2yΌģLZɮ] ],iڲ(R0x^6K  aنT$47owVI`#,g"'PLR 4v@ۣ%ԥfw1P.=kUB<[WPQDBcXթ&%tf!х u& YXZ)rҘKVz֮0kYL:&G7B%_ 0:&(Xǐb^e;Di-L|.u6GhjqcVN0a0JSF`VH&Yo[Q;&G,v%xwClwWdʋJi1k6̧qnb?y3`Gchhs=Ŋ3 l6q3 1 L!x:ylJ'@A[X6.$o֪z` ?(#\"È);MQv.k1Vm&@Ps1Mtg9id(<"M:JR4+;cI81('9#Fb'ާbΌ<ݜ;@+A+wRW|ȑ"N ք(_X ̑PvMPʑfvɛ"|'@(0`\!f_lG,GE?XBx `d>̆iF]-0Ɍzݕ|׺Buf # nPĤ\gH12? ΔG((gQy} LʋxvB5JT(0|*UQyDg+~U( xS]7 hlp7eFހjT}z<>6=[V͸xQWw4` VFh6%Ǜ bB"t*X4%ꔰé'ƿP37 :LWΠ8MkZP?Q|AF*J.|^<_lZY}^wYQgxb gEa= 1i=j E= z @@pE%#fwV8B]^`F/@a|L#| >pfeRL}FU#FaLKu<?1rEHrxª}+ik?nX)C6y! @+U 䗹~tαի2SI7:޲zFhoxz\_d\V$B\QVy:^_řG{BoW_ bC{0dzїDW \ǀcTxP&~!m\4 ]L_ӨBC W|,ssk JSnR6Z{~ԡN):lQ~Jk2ih<9.L8%x ^%#\ hXw ۆ\Wy+5d@.o[5gZA[`}襉üg8T0bI st_kkM85:ۊ"BXoM;@Jgf\n LҰ?ݷ, &;N\@Bz#M\3cW[tv2~30q! l3nꤏ|!K >NCAϠ( avȅ* +qCm%4a$;+< D^aqn1*=&xӴJCv2ݮlL-ynnwTFFJ=&GdbA4a\sV7(S1!oPzk]b?B̢ `[qRӎhǛ> 5x11Ԅ?r d|b?L-e9ijB>nEK<3oFIENDB`