jsonb_accessor-1.4/0000755000004100000410000000000014531041247014404 5ustar www-datawww-datajsonb_accessor-1.4/UPGRADE_GUIDE.md0000644000004100000410000000377314531041247016744 0ustar www-datawww-data# 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). jsonb_accessor-1.4/Makefile0000644000004100000410000000071514531041247016047 0ustar www-datawww-databuild-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 jsonb_accessor-1.4/.rspec0000644000004100000410000000003214531041247015514 0ustar www-datawww-data--format progress --color jsonb_accessor-1.4/README.md0000644000004100000410000002656314531041247015677 0ustar www-datawww-data# 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. ## 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" } ``` ## 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 - Ruby > 3. Lower versions are not tested. - ActiveRecord >= 6.1 - Postgres >= 9.4 (in order to use the [jsonb column type](http://www.postgresql.org/docs/9.4/static/datatype-json.html)). ## Upgrading See the [upgrade guide](UPGRADE_GUIDE.md). ## Development ### On your local machine 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. ### With Docker ``` # setup docker-compose build docker-compose run ruby rake db:migrate # run test suite docker-compose run ruby rake spec ``` ## 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 🤩 jsonb_accessor-1.4/bin/0000755000004100000410000000000014531041247015154 5ustar www-datawww-datajsonb_accessor-1.4/bin/console0000755000004100000410000000067314531041247016552 0ustar www-datawww-data#!/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 jsonb_accessor-1.4/bin/setup0000755000004100000410000000012214531041247016235 0ustar www-datawww-data#!/bin/bash set -euo pipefail IFS=$'\n\t' bundle rake db:create rake db:migrate jsonb_accessor-1.4/gemfiles/0000755000004100000410000000000014531041247016177 5ustar www-datawww-datajsonb_accessor-1.4/gemfiles/activerecord_6.1.gemfile0000644000004100000410000000017114531041247022566 0ustar www-datawww-data# This file was generated by Appraisal source "https://rubygems.org" gem "activerecord", "~> 6.1" gemspec path: "../" jsonb_accessor-1.4/gemfiles/activerecord_7.0.gemfile0000644000004100000410000000017314531041247022570 0ustar www-datawww-data# This file was generated by Appraisal source "https://rubygems.org" gem "activerecord", "~> 7.0.8" gemspec path: "../" jsonb_accessor-1.4/gemfiles/activerecord_7.1.gemfile0000644000004100000410000000017114531041247022567 0ustar www-datawww-data# This file was generated by Appraisal source "https://rubygems.org" gem "activerecord", "~> 7.1" gemspec path: "../" jsonb_accessor-1.4/CHANGELOG.md0000644000004100000410000000363614531041247016225 0ustar www-datawww-data# Changelog ## [Unreleased] ## [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 jsonb_accessor-1.4/.rubocop.yml0000644000004100000410000000246614531041247016666 0ustar www-datawww-dataAllCops: NewCops: enable TargetRubyVersion: 2.7.2 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 jsonb_accessor-1.4/Appraisals0000644000004100000410000000035114531041247016425 0ustar www-datawww-data# frozen_string_literal: true appraise "activerecord-6.1" do gem "activerecord", "~> 6.1" end appraise "activerecord-7.0" do gem "activerecord", "~> 7.0.8" end appraise "activerecord-7.1" do gem "activerecord", "~> 7.1" end jsonb_accessor-1.4/.gitignore0000644000004100000410000000020014531041247016364 0ustar www-datawww-data/.bundle/ /.yardoc /Gemfile.lock /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ gemfiles/.bundle gemfiles/*.gemfile.lock jsonb_accessor-1.4/CODE_OF_CONDUCT.md0000644000004100000410000000261514531041247017207 0ustar www-datawww-data# 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/) jsonb_accessor-1.4/db/0000755000004100000410000000000014531041247014771 5ustar www-datawww-datajsonb_accessor-1.4/db/config.yml0000644000004100000410000000032014531041247016754 0ustar www-datawww-datadefault: &default adapter: postgresql database: jsonb_accessor host: <%= ENV.fetch("DATABASE_HOST") { "127.0.0.1" } %> username: <%= ENV.fetch("DATABASE_USER") { "postgres" } %> test: <<: *default jsonb_accessor-1.4/db/schema.rb0000644000004100000410000000252414531041247016561 0ustar www-datawww-data# 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 jsonb_accessor-1.4/db/migrate/0000755000004100000410000000000014531041247016421 5ustar www-datawww-datajsonb_accessor-1.4/db/migrate/20150407031737_set_up_testing_db.rb0000644000004100000410000000102114531041247024110 0ustar www-datawww-data# 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 jsonb_accessor-1.4/docker-compose.yml0000644000004100000410000000134414531041247020043 0ustar www-datawww-dataversion: '3' services: ruby: environment: - DATABASE_HOST=postgres build: args: - RUBY_VERSION=${RUBY_VERSION:-3.2.2} - RUBY_PLATFORM=${RUBY_PLATFORM:-ruby} context: . volumes: - '.:/usr/src/app' depends_on: - postgres postgres: image: postgres environment: - POSTGRES_HOST_AUTH_METHOD=trust - POSTGRES_DB=jsonb_accessor - PGDATA=/var/lib/postgresql/data/pgdata volumes: - pg_data:/var/lib/postgresql/data/pgdata ports: - 5432:5432 volumes: pg_data: jsonb_accessor-1.4/Rakefile0000644000004100000410000000164514531041247016057 0ustar www-datawww-data# 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]) jsonb_accessor-1.4/lib/0000755000004100000410000000000014531041247015152 5ustar www-datawww-datajsonb_accessor-1.4/lib/jsonb_accessor/0000755000004100000410000000000014531041247020147 5ustar www-datawww-datajsonb_accessor-1.4/lib/jsonb_accessor/attribute_query_methods.rb0000644000004100000410000000312514531041247025450 0ustar www-datawww-data# 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.each_with_object({}) { |attribute, config| config[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 jsonb_accessor-1.4/lib/jsonb_accessor/query_helper.rb0000644000004100000410000000735714531041247023214 0ustar www-datawww-data# frozen_string_literal: true module JsonbAccessor module QueryHelper # Errors InvalidColumnName = Class.new(StandardError) InvalidFieldName = Class.new(StandardError) InvalidDirection = Class.new(StandardError) NotSupported = Class.new(StandardError) # 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 jsonb_accessor-1.4/lib/jsonb_accessor/version.rb0000644000004100000410000000035714531041247022166 0ustar www-datawww-data# frozen_string_literal: true module JsonbAccessor VERSION = "1.4" 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 jsonb_accessor-1.4/lib/jsonb_accessor/macro.rb0000644000004100000410000001362514531041247021604 0ustar www-datawww-data# frozen_string_literal: true module JsonbAccessor module Macro module ClassMethods def jsonb_accessor(jsonb_attribute, field_types) names_and_store_keys = field_types.each_with_object({}) do |(name, type), mapping| _type, options = Array(type) mapping[name.to_s] = (options.try(:delete, :store_key) || name).to_s end # Defines virtual attributes for each jsonb field. field_types.each do |name, type| next attribute name, type unless type.is_a?(Array) next attribute name, *type unless type.last.is_a?(Hash) *args, keyword_args = type attribute name, *args, **keyword_args end store_key_mapping_method_name = "jsonb_store_key_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 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 field names to default values mapping names_and_defaults = field_types.each_with_object({}) do |(name, type), mapping| _type, options = Array(type) field_default = options.try(:delete, :default) mapping[name.to_s] = field_default unless field_default.nil? end # 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| define_method("#{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[name].present? ? defined_enums[name][value] : public_send(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) # 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) write_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) next unless name write_attribute( name, JsonbAccessor::Helpers.deserialize_value(value, self.class.type_for_attribute(name).type) ) clear_attribute_change(name) if persisted? end end JsonbAccessor::AttributeQueryMethods.new(self).define(store_key_mapping_method_name, jsonb_attribute) end end end end jsonb_accessor-1.4/lib/jsonb_accessor/helpers.rb0000644000004100000410000000245014531041247022137 0ustar www-datawww-data# 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 end end jsonb_accessor-1.4/lib/jsonb_accessor/query_builder.rb0000644000004100000410000001014714531041247023352 0ustar www-datawww-data# 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 jsonb_accessor-1.4/lib/jsonb_accessor.rb0000644000004100000410000000106314531041247020474 0ustar www-datawww-data# 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 jsonb_accessor-1.4/jsonb_accessor.gemspec0000644000004100000410000000363514531041247020755 0ustar www-datawww-data# frozen_string_literal: true lib = File.expand_path("lib", __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "jsonb_accessor/version" is_java = RUBY_PLATFORM == "java" Gem::Specification.new do |spec| spec.name = "jsonb_accessor" spec.version = JsonbAccessor::VERSION spec.authors = ["Michael Crismali", "Joe Hirn", "Jason Haruska"] spec.email = ["michael@crismali.com", "joe@devmynd.com", "jason@haruska.com"] spec.platform = "java" if is_java spec.summary = "Adds typed jsonb backed fields to your ActiveRecord models." spec.description = "Adds typed jsonb backed fields to your ActiveRecord models." spec.homepage = "https://github.com/devmynd/jsonb_accessor" spec.license = "MIT" spec.required_ruby_version = ">= 3" spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) || f.match(/png\z/) } spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] spec.add_dependency "activerecord", ">= 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 "appraisal", "~> 2.5" spec.add_development_dependency "awesome_print" spec.add_development_dependency "database_cleaner-active_record", "~> 2.1" 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" end jsonb_accessor-1.4/Gemfile0000644000004100000410000000020214531041247015671 0ustar www-datawww-data# frozen_string_literal: true source "https://rubygems.org" # Specify your gem's dependencies in jsonb_accessor.gemspec gemspec jsonb_accessor-1.4/.ruby-version0000644000004100000410000000000614531041247017045 0ustar www-datawww-data3.2.2 jsonb_accessor-1.4/.github/0000755000004100000410000000000014531041247015744 5ustar www-datawww-datajsonb_accessor-1.4/.github/workflows/0000755000004100000410000000000014531041247020001 5ustar www-datawww-datajsonb_accessor-1.4/.github/workflows/ci.yml0000644000004100000410000000354714531041247021130 0ustar www-datawww-dataname: CI on: push: branches: [master] pull_request: branches: ['**'] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: bundler-cache: true - name: Rubocop run: bundle exec rubocop tests: needs: lint services: db: image: postgres 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 runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - gemfile: activerecord_6.1 ruby: '3.0' - gemfile: activerecord_6.1 ruby: '3.1' - gemfile: activerecord_6.1 ruby: '3.2' - gemfile: activerecord_7.0 ruby: '3.0' - gemfile: activerecord_7.0 ruby: '3.1' - gemfile: activerecord_7.0 ruby: '3.2' - gemfile: activerecord_7.1 ruby: '3.0' - gemfile: activerecord_7.1 ruby: '3.1' - gemfile: activerecord_7.1 ruby: '3.2' - gemfile: activerecord_7.0 ruby: 'jruby-9.4' name: ${{ matrix.gemfile }}, ruby ${{ matrix.ruby }} env: BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile steps: - uses: actions/checkout@v3 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Bundle install run: | bundle install - name: Setup DB run: | bundle exec rake db:schema:load - name: Run tests run: | bundle exec rake spec jsonb_accessor-1.4/LICENSE.txt0000644000004100000410000000207314531041247016231 0ustar www-datawww-dataThe 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. jsonb_accessor-1.4/Dockerfile0000644000004100000410000000045514531041247016402 0ustar www-datawww-dataARG RUBY_VERSION ARG RUBY_PLATFORM FROM ${RUBY_PLATFORM}:${RUBY_VERSION} RUN apt-get update && apt-get install -y --no-install-recommends git WORKDIR /usr/src/app COPY lib/jsonb_accessor/version.rb ./lib/jsonb_accessor/version.rb COPY jsonb_accessor.gemspec Gemfile ./ RUN bundle install COPY . ./