├── .github
└── workflows
│ └── ci.yaml
├── .gitignore
├── .rspec
├── .rubocop.yml
├── Gemfile
├── LICENSE.md
├── README.md
├── Rakefile
├── activerecord_json_validator.gemspec
├── docker-compose.yml
├── lib
├── active_record
│ └── json_validator
│ │ ├── validator.rb
│ │ └── version.rb
└── activerecord_json_validator.rb
└── spec
├── json_validator_spec.rb
├── spec_helper.rb
└── support
└── macros
├── database
├── database_adapter.rb
├── mysql_adapter.rb
└── postgresql_adapter.rb
├── database_macros.rb
└── model_macros.rb
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 | branches:
9 | - '**'
10 |
11 | jobs:
12 | ci:
13 | runs-on: ubuntu-latest
14 |
15 | services:
16 | db:
17 | image: postgres:10.19
18 | env:
19 | POSTGRES_DB: activerecord_json_validator_test
20 | POSTGRES_USER: postgres
21 | POSTGRES_PASSWORD: postgres
22 | ports: ['5432:5432']
23 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
24 |
25 | env:
26 | CANONICAL_HOST: localhost
27 | DATABASE_URL: postgres://postgres:postgres@localhost/activerecord_json_validator_test
28 | DB_ADAPTER: postgresql
29 |
30 | steps:
31 | - uses: actions/checkout@v2
32 | - uses: ruby/setup-ruby@v1
33 | with:
34 | ruby-version: '2.7.5'
35 | bundler-cache: true
36 | - run: bundle exec rubocop
37 | - run: bundle exec rake spec
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.gem
2 | *.rbc
3 | .bundle
4 | .config
5 | .yardoc
6 | Gemfile.lock
7 | InstalledFiles
8 | _yardoc
9 | coverage
10 | doc/
11 | lib/bundler/man
12 | pkg
13 | rdoc
14 | spec/reports
15 | test/tmp
16 | test/version_tmp
17 | tmp
18 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --colour
2 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | AllCops:
2 | Include:
3 | - Rakefile
4 | - Gemfile
5 | - '**/*.rb'
6 | Exclude:
7 | - activerecord_json_validator.gemspec
8 | - vendor/**/*
9 |
10 | Documentation:
11 | Enabled: false
12 |
13 | Encoding:
14 | Enabled: false
15 |
16 | LineLength:
17 | Max: 200
18 |
19 | AccessModifierIndentation:
20 | EnforcedStyle: outdent
21 |
22 | IfUnlessModifier:
23 | Enabled: false
24 |
25 | CaseIndentation:
26 | EnforcedStyle: case
27 | IndentOneStep: true
28 |
29 | MethodLength:
30 | CountComments: false
31 | Max: 20
32 |
33 | SignalException:
34 | Enabled: false
35 |
36 | ColonMethodCall:
37 | Enabled: false
38 |
39 | AsciiComments:
40 | Enabled: false
41 |
42 | RegexpLiteral:
43 | Enabled: false
44 |
45 | AssignmentInCondition:
46 | Enabled: false
47 |
48 | ParameterLists:
49 | CountKeywordArgs: false
50 |
51 | SingleLineBlockParams:
52 | Methods:
53 | - reduce:
54 | - memo
55 | - item
56 |
57 | Metrics/AbcSize:
58 | Enabled: false
59 |
60 | Style/CollectionMethods:
61 | Enabled: true
62 |
63 | Style/SymbolArray:
64 | Enabled: true
65 |
66 | Style/ExtraSpacing:
67 | Enabled: true
68 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 | gemspec
5 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2013-2025, Mirego
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | - Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 | - Redistributions in binary form must reproduce the above copyright notice,
10 | this list of conditions and the following disclaimer in the documentation
11 | and/or other materials provided with the distribution.
12 | - Neither the name of the Mirego nor the names of its contributors may
13 | be used to endorse or promote products derived from this software without
14 | specific prior written permission.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
20 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26 | POSSIBILITY OF SUCH DAMAGE.
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | ActiveRecord::JSONValidator
makes it easy to validate
JSON attributes against a JSON schema.
7 |
8 |
9 |
10 |
11 |
12 | ---
13 |
14 | ## Installation
15 |
16 | Add this line to your application's Gemfile:
17 |
18 | ```ruby
19 | gem 'activerecord_json_validator', '~> 3.1.0'
20 | ```
21 |
22 | ## Usage
23 |
24 | ### JSON Schema
25 |
26 | Schemas should be a JSON file
27 |
28 | ```json
29 | {
30 | "type": "object",
31 | "$schema": "http://json-schema.org/draft-04/schema#",
32 | "properties": {
33 | "city": { "type": "string" },
34 | "country": { "type": "string" }
35 | },
36 | "required": ["country"]
37 | }
38 | ```
39 |
40 | ### Ruby
41 |
42 | ```ruby
43 | create_table "users" do |t|
44 | t.string "name"
45 | t.json "profile" # First-class JSON with PostgreSQL, yo.
46 | end
47 |
48 | class User < ActiveRecord::Base
49 | # Constants
50 | PROFILE_JSON_SCHEMA = Rails.root.join('config', 'schemas', 'profile.json')
51 |
52 | # Validations
53 | validates :name, presence: true
54 | validates :profile, presence: true, json: { schema: PROFILE_JSON_SCHEMA }
55 | end
56 |
57 | user = User.new(name: 'Samuel Garneau', profile: { city: 'Quebec City' })
58 | user.valid? # => false
59 |
60 | user = User.new(name: 'Samuel Garneau', profile: { city: 'Quebec City', country: 'Canada' })
61 | user.valid? # => true
62 |
63 | user = User.new(name: 'Samuel Garneau', profile: '{invalid JSON":}')
64 | user.valid? # => false
65 | user.profile_invalid_json # => '{invalid JSON":}'
66 | ```
67 |
68 | #### Options
69 |
70 | | Option | Description |
71 | | ---------- | ------------------------------------------------------------------------------------------------------------------------------ |
72 | | `:schema` | The JSON schema to validate the data against (see **Schema** section) |
73 | | `:value` | The actual value to use when validating (see **Value** section) |
74 | | `:message` | The ActiveRecord message added to the record errors (see **Message** section) |
75 | | `:options` | A `Hash` of [`json_schemer`](https://github.com/davishmcclurg/json_schemer#options)-supported options to pass to the validator |
76 |
77 | ##### Schema
78 |
79 | `ActiveRecord::JSONValidator` uses the [json_schemer](https://github.com/davishmcclurg/json_schemer) gem to validate the JSON
80 | data against a JSON schema.
81 |
82 | Additionally, you can use a `Symbol` or a `Proc`. Both will be executed in the
83 | context of the validated record (`Symbol` will be sent as a method and the
84 | `Proc` will be `instance_exec`ed)
85 |
86 | ```ruby
87 | class User < ActiveRecord::Base
88 | # Constants
89 | PROFILE_REGULAR_JSON_SCHEMA = Rails.root.join('config', 'schemas', 'profile.json_schema')
90 | PROFILE_ADMIN_JSON_SCHEMA = Rails.root.join('config', 'schemas', 'profile_admin.json_schema')
91 |
92 | # Validations
93 | validates :profile, presence: true, json: { schema: lambda { dynamic_profile_schema } } # `schema: :dynamic_profile_schema` would also work
94 |
95 | def dynamic_profile_schema
96 | admin? ? PROFILE_ADMIN_JSON_SCHEMA : PROFILE_REGULAR_JSON_SCHEMA
97 | end
98 | end
99 | ```
100 |
101 | The schema is passed to the `JSONSchemer.schema` function, so it can be anything supported by it:
102 |
103 | ```ruby
104 | class User < ActiveRecord::Base
105 | # Constants
106 | JSON_SCHEMA = Rails.root.join('config', 'schemas', 'profile.json_schema')
107 | # JSON_SCHEMA = { 'type' => 'object', 'properties' => { 'foo' => { 'type' => 'integer', 'minimum' => 3 } } }
108 | # JSON_SCHEMA = '{"type":"object","properties":{"foo":{"type":"integer","minimum":3}}}'
109 |
110 | # Validations
111 | validates :profile, presence: true, json: { schema: JSON_SCHEMA }
112 | end
113 | ```
114 |
115 | ##### Value
116 |
117 | By default, the validator will use the “getter” method to the fetch attribute
118 | value and validate the schema against it.
119 |
120 | ```ruby
121 | # Will validate `self.foo`
122 | validates :foo, json: { schema: SCHEMA }
123 | ```
124 |
125 | But you can change this behavior if the getter method doesn’t return raw JSON data (a `Hash`):
126 |
127 | ```ruby
128 | # Will validate `self[:foo]`
129 | validates :foo, json: { schema: SCHEMA, value: ->(record, _, _) { record[:foo] } }
130 | ```
131 |
132 | You could also implement a “raw getter” if you want to avoid the `value` option:
133 |
134 | ```ruby
135 | # Will validate `self[:foo]`
136 | validates :raw_foo, json: { schema: SCHEMA }
137 |
138 | def raw_foo
139 | self[:foo]
140 | end
141 | ```
142 |
143 | ##### Message
144 |
145 | Like any other ActiveModel validation, you can specify either a `Symbol` or
146 | `String` value for the `:message` option. The default value is `:invalid_json`.
147 |
148 | However, you can also specify a `Proc` that returns an array of errors. The
149 | `Proc` will be called with a single argument — an array of errors returned by
150 | the JSON schema validator. So, if you’d like to add each of these errors as
151 | a first-level error for the record, you can do this:
152 |
153 | ```ruby
154 | class User < ActiveRecord::Base
155 | # Validations
156 | validates :profile, presence: true, json: { message: ->(errors) { errors }, schema: 'foo.json_schema' }
157 | end
158 |
159 | user = User.new.tap(&:valid?)
160 | user.errors.full_messages
161 | # => [
162 | # 'The property '#/email' of type Fixnum did not match the following type: string in schema 2d44293f-cd9d-5dca-8a6a-fb9db1de722b#',
163 | # 'The property '#/full_name' of type Fixnum did not match the following type: string in schema 2d44293f-cd9d-5dca-8a6a-fb9db1de722b#',
164 | # ]
165 | ```
166 |
167 | ## Development
168 |
169 | The tests require a database. We've provided a simple `docker-compose.yml` that will make
170 | it trivial to run the tests against PostgreSQL. Simply run `docker compose up -d`
171 | followed by `rake spec`. When you're done, run `docker compose down` to stop the database.
172 |
173 | In order to use another database, simply define the `DATABASE_URL` environment variable
174 | appropriately.
175 |
176 | ## License
177 |
178 | `ActiveRecord::JSONValidator` is © 2013-2025 [Mirego](https://www.mirego.com) and may be freely distributed under the [New BSD license](https://opensource.org/licenses/BSD-3-Clause). See the [`LICENSE.md`](https://github.com/mirego/activerecord_json_validator/blob/master/LICENSE.md) file.
179 |
180 | The tree logo is based on [this lovely icon](https://thenounproject.com/term/tree/51004/) by [Sara Quintana](https://thenounproject.com/sara.quintana.75), from The Noun Project. Used under a [Creative Commons BY 3.0](https://creativecommons.org/licenses/by/3.0/) license.
181 |
182 | ## About Mirego
183 |
184 | [Mirego](https://www.mirego.com) is a team of passionate people who believe that work is a place where you can innovate and have fun. We're a team of [talented people](https://life.mirego.com) who imagine and build beautiful Web and mobile applications. We come together to share ideas and [change the world](https://www.mirego.org).
185 |
186 | We also [love open-source software](https://open.mirego.com) and we try to give back to the community as much as we can.
187 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'bundler'
4 | require 'rake'
5 | require 'bundler/gem_tasks'
6 | require 'rspec/core/rake_task'
7 |
8 | task default: :spec
9 |
10 | desc 'Run all specs'
11 | RSpec::Core::RakeTask.new(:spec) do |task|
12 | task.pattern = 'spec/**/*_spec.rb'
13 | end
14 |
15 | desc 'Start an IRB session with the gem'
16 | task :console do
17 | $LOAD_PATH.unshift File.expand_path(__dir__)
18 | require 'activerecord_json_validator'
19 | require 'irb'
20 |
21 | ARGV.clear
22 | IRB.start
23 | end
24 |
--------------------------------------------------------------------------------
/activerecord_json_validator.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'active_record/json_validator/version'
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = 'activerecord_json_validator'
8 | spec.version = ActiveRecord::JSONValidator::VERSION
9 | spec.authors = ['Rémi Prévost']
10 | spec.email = ['rprevost@mirego.com']
11 | spec.description = 'ActiveRecord::JSONValidator makes it easy to validate JSON attributes with a JSON schema.'
12 | spec.summary = spec.description
13 | spec.homepage = 'https://github.com/mirego/activerecord_json_validator'
14 | spec.license = 'BSD 3-Clause'
15 |
16 | spec.files = `git ls-files`.split($/)
17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19 | spec.require_paths = ['lib']
20 |
21 | spec.add_development_dependency 'bundler', '>= 1.12'
22 | spec.add_development_dependency 'rake'
23 | spec.add_development_dependency 'rspec', '~> 3.5'
24 | spec.add_development_dependency 'pg'
25 | spec.add_development_dependency 'activesupport', '>= 4.2.0', '< 9'
26 | spec.add_development_dependency 'rubocop', '~> 0.28'
27 | spec.add_development_dependency 'rubocop-rspec', '~> 1.44'
28 | spec.add_development_dependency 'rubocop-standard', '~> 6.0'
29 |
30 | spec.add_dependency 'json_schemer', '~> 2.2'
31 | spec.add_dependency 'activerecord', '>= 4.2.0', '< 9'
32 | end
33 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.2'
2 |
3 | services:
4 | postgres:
5 | image: postgres:10
6 | ports:
7 | - 5432:5432
8 | restart: on-failure
9 | environment:
10 | POSTGRES_DB: activerecord_json_validator_test
11 | POSTGRES_HOST_AUTH_METHOD: trust # don't require password
12 |
--------------------------------------------------------------------------------
/lib/active_record/json_validator/validator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class JsonValidator < ActiveModel::EachValidator
4 | def initialize(options)
5 | options.reverse_merge!(message: :invalid_json)
6 | options.reverse_merge!(schema: nil)
7 | options.reverse_merge!(options: {})
8 | options.reverse_merge!(value: ->(_record, _attribute, value) { value })
9 | @attributes = options[:attributes]
10 |
11 | super
12 |
13 | inject_setter_method(options[:class], @attributes)
14 | end
15 |
16 | # Validate the JSON value with a JSON schema path or String
17 | def validate_each(record, attribute, value)
18 | # Get the _actual_ attribute value, not the getter method value
19 | value = options.fetch(:value).call(record, attribute, value)
20 |
21 | # Validate value with JSON Schemer
22 | errors = JSONSchemer.schema(schema(record), **options.fetch(:options)).validate(value).to_a
23 |
24 | # Everything is good if we don’t have any errors and we got valid JSON value
25 | return if errors.empty? && record.send(:"#{attribute}_invalid_json").blank?
26 |
27 | # Add error message to the attribute
28 | details = errors.map { |e| e.fetch('error') }
29 | message(errors).each do |error|
30 | error = error.fetch('error') if error.is_a?(Hash)
31 | record.errors.add(attribute, error, errors: details, value: value)
32 | end
33 | end
34 |
35 | protected
36 |
37 | # Redefine the setter method for the attributes, since we want to
38 | # catch JSON parsing errors.
39 | def inject_setter_method(klass, attributes)
40 | return if klass.nil?
41 |
42 | attributes.each do |attribute|
43 | klass.prepend(Module.new do
44 | attr_reader :"#{attribute}_invalid_json"
45 |
46 | define_method "#{attribute}=" do |args|
47 | begin
48 | instance_variable_set("@#{attribute}_invalid_json", nil)
49 | args = ::ActiveSupport::JSON.decode(args) if args.is_a?(::String)
50 | super(args)
51 | rescue ActiveSupport::JSON.parse_error
52 | instance_variable_set("@#{attribute}_invalid_json", args)
53 | super({})
54 | end
55 | end
56 | end)
57 | end
58 | end
59 |
60 | # Return a valid schema, recursively calling
61 | # itself until it gets a non-Proc/non-Symbol value.
62 | def schema(record, schema = nil)
63 | schema ||= options.fetch(:schema)
64 |
65 | case schema
66 | when Proc then schema(record, record.instance_exec(&schema))
67 | when Symbol then schema(record, record.send(schema))
68 | else schema
69 | end
70 | end
71 |
72 | def message(errors)
73 | message = options.fetch(:message)
74 | message = message.call(errors) if message.is_a?(Proc)
75 | [message].flatten
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/lib/active_record/json_validator/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveRecord
4 | module JSONValidator
5 | VERSION = '3.1.0'
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/activerecord_json_validator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'active_record'
4 | require 'json_schemer'
5 |
6 | require 'active_record/json_validator/version'
7 | require 'active_record/json_validator/validator'
8 |
9 | # NOTE: In case `"JSON"` is treated as an acronym by `ActiveSupport::Inflector`,
10 | # make `JSONValidator` available too.
11 | JSONValidator = JsonValidator
12 |
--------------------------------------------------------------------------------
/spec/json_validator_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # rubocop:disable Metrics/BlockLength
4 | require 'spec_helper'
5 |
6 | module CountryDefaulter
7 | extend ActiveSupport::Concern
8 |
9 | class_methods do
10 | def default_country_attribute(name, country:)
11 | define_method("#{name}=") do |value|
12 | self[name] = { country: country }.merge(value)
13 | end
14 | end
15 | end
16 | end
17 |
18 | describe JsonValidator do
19 | describe :validate_each do
20 | before do
21 | run_migration do
22 | create_table(:users, force: true) do |t|
23 | t.text :data
24 | t.json :smart_data
25 | end
26 | end
27 |
28 | spawn_model 'User' do
29 | include CountryDefaulter
30 |
31 | schema = '
32 | {
33 | "type": "object",
34 | "properties": {
35 | "city": { "type": "string" },
36 | "country": { "type": "string" }
37 | },
38 | "required": ["country"]
39 | }
40 | '
41 |
42 | default_country_attribute :smart_data, country: 'Canada'
43 |
44 | serialize :data, coder: JSON
45 | serialize :other_data, coder: JSON
46 | validates :data, json: { schema: schema, message: ->(errors) { errors } }
47 | validates :other_data, json: { schema: schema, message: ->(errors) { errors.map { |error| error['details'].to_a.flatten.join(' ') } } }
48 | validates :smart_data, json: { value: ->(record, _, _) { record[:smart_data] }, schema: schema, message: ->(errors) { errors } }
49 |
50 | def smart_data
51 | OpenStruct.new(self[:smart_data])
52 | end
53 | end
54 | end
55 |
56 | context 'with valid JSON data but schema errors' do
57 | let(:user) do
58 | User.new(
59 | data: '{"city":"Quebec City"}',
60 | other_data: '{"city":"Quebec City"}',
61 | smart_data: { country: 'Ireland', city: 'Dublin' }
62 | )
63 | end
64 |
65 | specify do
66 | expect(user).not_to be_valid
67 | expect(user.errors.full_messages).to eql(['Data object at root is missing required properties: country', 'Other data missing_keys country'])
68 | expect(user.errors.group_by_attribute[:data].first).to have_attributes(
69 | options: include(errors: ['object at root is missing required properties: country'])
70 | )
71 | expect(user.errors.group_by_attribute[:other_data].first).to have_attributes(
72 | options: include(errors: ['object at root is missing required properties: country'])
73 | )
74 | expect(user.data).to eql({ 'city' => 'Quebec City' })
75 | expect(user.data_invalid_json).to be_nil
76 | expect(user.smart_data.city).to eql('Dublin')
77 | expect(user.smart_data.country).to eql('Ireland')
78 | end
79 | end
80 |
81 | context 'with invalid JSON data' do
82 | let(:data) { 'What? This is not JSON at all.' }
83 | let(:user) { User.new(data: data, smart_data: data) }
84 |
85 | specify do
86 | expect(user.data_invalid_json).to eql(data)
87 | expect(user.data).to eql({})
88 |
89 | # Ensure that both setters ran
90 | expect(user.smart_data_invalid_json).to eql(data)
91 | expect(user.smart_data).to eql(OpenStruct.new({ country: 'Canada' }))
92 | end
93 | end
94 |
95 | context 'with missing country in smart data' do
96 | let(:user) do
97 | User.new(
98 | data: '{"city":"Quebec City","country":"Canada"}',
99 | other_data: '{"city":"Quebec City","country":"Canada"}',
100 | smart_data: { city: 'Quebec City' }
101 | )
102 | end
103 |
104 | specify do
105 | expect(user).to be_valid
106 | expect(user.smart_data.city).to eql('Quebec City')
107 | expect(user.smart_data.country).to eql('Canada') # Due to CountryDefaulter
108 | end
109 | end
110 | end
111 |
112 | describe :schema do
113 | let(:validator) { JsonValidator.new(options) }
114 | let(:options) { { attributes: [:foo], schema: schema_option } }
115 | let(:schema) { validator.send(:schema, record) }
116 |
117 | context 'with String schema' do
118 | let(:schema_option) { double(:schema) }
119 | let(:record) { double(:record) }
120 |
121 | it { expect(schema).to eql(schema_option) }
122 | end
123 |
124 | context 'with Proc schema returning a Proc returning a Proc' do
125 | let(:schema_option) { -> { dynamic_schema } }
126 | let(:record) { record_class.new }
127 | let(:record_class) do
128 | Class.new do
129 | def dynamic_schema
130 | -> { another_dynamic_schema }
131 | end
132 |
133 | def another_dynamic_schema
134 | -> { what_another_dynamic_schema }
135 | end
136 |
137 | def what_another_dynamic_schema
138 | 'yay'
139 | end
140 | end
141 | end
142 |
143 | it { expect(schema).to eql('yay') }
144 | end
145 |
146 | context 'with Symbol schema' do
147 | let(:schema_option) { :dynamic_schema }
148 | let(:record) { record_class.new }
149 | let(:record_class) do
150 | Class.new do
151 | def dynamic_schema
152 | 'foo'
153 | end
154 | end
155 | end
156 |
157 | it { expect(schema).to eql('foo') }
158 | end
159 | end
160 |
161 | describe :message do
162 | let(:validator) { JsonValidator.new(options) }
163 | let(:options) { { attributes: [:foo], message: message_option } }
164 | let(:message) { validator.send(:message, errors) }
165 | let(:errors) { %i[first_error second_error] }
166 |
167 | context 'with Symbol message' do
168 | let(:message_option) { :invalid_json }
169 | it { expect(message).to eql([:invalid_json]) }
170 | end
171 |
172 | context 'with String value' do
173 | let(:message_option) { ->(errors) { errors } }
174 | it { expect(message).to eql(%i[first_error second_error]) }
175 | end
176 | end
177 | end
178 | # rubocop:enable Metrics/BlockLength
179 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | $LOAD_PATH.unshift File.expand_path('lib', __dir__)
4 |
5 | require 'active_support/all'
6 | require 'rspec'
7 | require 'pg'
8 |
9 | require 'activerecord_json_validator'
10 |
11 | # Require our macros and extensions
12 | Dir[File.expand_path('../spec/support/macros/**/*.rb', __dir__)].map(&method(:require))
13 |
14 | RSpec.configure do |config|
15 | # Include our macros
16 | config.include DatabaseMacros
17 | config.include ModelMacros
18 |
19 | config.before :each do
20 | adapter = ENV['DB_ADAPTER'] || 'postgresql'
21 | setup_database(adapter: adapter, database: 'activerecord_json_validator_test')
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/spec/support/macros/database/database_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class DatabaseAdapter
4 | def initialize(opts = {})
5 | @database = opts[:database]
6 | end
7 |
8 | def establish_connection!
9 | ActiveRecord::Base.establish_connection(ENV.fetch('DATABASE_URL', 'postgres://postgres@localhost/activerecord_json_validator_test'))
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/spec/support/macros/database/mysql_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative 'database_adapter'
4 |
5 | class Mysql2Adapter < DatabaseAdapter
6 | def reset_database!
7 | ActiveRecord::Base.connection.execute("SELECT concat('DROP TABLE IF EXISTS ', table_name, ';') FROM information_schema.tables WHERE table_schema = '#{@database}';")
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/spec/support/macros/database/postgresql_adapter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative 'database_adapter'
4 |
5 | class PostgresqlAdapter < DatabaseAdapter
6 | def reset_database!
7 | ActiveRecord::Base.connection.execute('drop schema public cascade;')
8 | ActiveRecord::Base.connection.execute('create schema public;')
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/spec/support/macros/database_macros.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module DatabaseMacros
4 | # Run migrations in the test database
5 | def run_migration(&block)
6 | migration_class = if ActiveRecord::Migration.respond_to?(:[])
7 | ActiveRecord::Migration[4.2]
8 | else
9 | ActiveRecord::Migration
10 | end
11 |
12 | # Create a new migration class
13 | klass = Class.new(migration_class)
14 |
15 | # Create a new `up` that executes the argument
16 | klass.send(:define_method, :up) { instance_exec(&block) }
17 |
18 | # Create a new instance of it and execute its `up` method
19 | klass.new.up
20 | end
21 |
22 | def setup_database(opts = {})
23 | adapter = "#{opts[:adapter].capitalize}Adapter".constantize.new(database: opts[:database])
24 | adapter.establish_connection!
25 | adapter.reset_database!
26 |
27 | # Silence everything
28 | ActiveRecord::Base.logger = ActiveRecord::Migration.verbose = nil
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/spec/support/macros/model_macros.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ModelMacros
4 | # Create a new model class
5 | def spawn_model(klass_name, parent_klass = ActiveRecord::Base, &block)
6 | Object.instance_eval { remove_const klass_name } if Object.const_defined?(klass_name)
7 | Object.const_set(klass_name, Class.new(parent_klass))
8 | Object.const_get(klass_name).class_eval(&block) if block_given?
9 | end
10 | end
11 |
--------------------------------------------------------------------------------