golden_fleece 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 69b0eddc5da9185615c28fa06cb491850643d5ce
4
+ data.tar.gz: a6f855832e94fe8d72d71f9f837dea0004fc370b
5
+ SHA512:
6
+ metadata.gz: 02edc695368a00db6beadb66b7ca9ee9ac06384581bf09119cdef5e1e904d384b418ccb58d649b28c587e2d02c4c4b08cb57ee0ef1c6a5fbc6b93d302f72135a
7
+ data.tar.gz: 8c67be8b70be4d7b37502fa8b52984708e4c95f8972b2c7801d342d15155d03d200766eed9f83091b53cf430ad790fcf1fe81e8e5a00524dcc2b095c2b5b89f6
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.3
5
+ before_install: gem install bundler -v 1.14.3
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at ersin.akinci@instacart.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in golden_fleece.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Ersin Akinci
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # Golden Fleece 🐑
2
+
3
+ Easy schemas for JSON columns in your Ruby data models. Currently supports ActiveRecord/ActiveModel >= 3.0 (i.e., Rails 3, 4 and 5).
4
+
5
+ Golden Fleece lets you define a schema for your Ruby data models, which can be used to do fun things:
6
+
7
+ - Validate JSON data types and formats
8
+ - Normalize JSON data
9
+ - Provide default values within nested JSON
10
+
11
+ It's like [JSON Schema](http://json-schema.org/) but more opinionated and, in our opinion, more straightforward to use.
12
+
13
+ 🍊 Battle-tested at Instacart.
14
+
15
+ ## Quick start
16
+
17
+ Add this line to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem 'golden_fleece'
21
+ ```
22
+
23
+ Then `include GoldenFleece::Model` and define schemas in your data models:
24
+
25
+ ```ruby
26
+ class Person < ActiveRecord::Base
27
+ include GoldenFleece::Model
28
+
29
+ fleece do
30
+ define_schemas :profile, {
31
+ first_name: { type: :string },
32
+ last_name: { types: [:string, :null] },
33
+ zip_code: { type: :string, default: '90210' }
34
+ }
35
+ end
36
+ end
37
+
38
+ person.profile['first_name'] = 'Jane'
39
+ person.profile['last_name'] = nil
40
+ person.valid? # true
41
+ person.export_fleece # { profile: { first_name: 'Jane', last_name: nil, zip_code: '90210' } }
42
+
43
+ person.profile.delete 'first_name'
44
+ person.valid? # false
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ### Schemas
50
+
51
+ Golden Fleece's core concept is the schema. A schema is a structure that defines what your JSON columns should look like and are defined within the `fleece` block on a model using `define_schemas`:
52
+
53
+ ```ruby
54
+ class Person < ActiveRecord::Base
55
+ include GoldenFleece::Model
56
+
57
+ fleece do
58
+ define_schemas :profile, {
59
+ first_name: { type: :string },
60
+ last_name: { types: [:string, :null] },
61
+ zip_code: { type: :string, default: '90210' }
62
+ }
63
+ end
64
+ end
65
+ ```
66
+
67
+ The above example defines a schema on the `Person` model's `profile` column and introduces certain restraints on the `first_name`, `last_name` and `zip_code` fields within the `profile` column's JSON object. Note that Golden Fleece assumes that all columns with a schema are valid JSON objects.
68
+
69
+ Note that any keys added to a JSON object that aren't listed in the schema are invalid:
70
+
71
+ ```ruby
72
+ define_schemas :profile, {
73
+ first_name: ...,
74
+ last_name: ...,
75
+ zip_code: ...,
76
+ }
77
+
78
+ person.profile['address'] = '123 Nottingham Way'
79
+ person.valid? # false
80
+ ```
81
+
82
+ ### Types
83
+
84
+ Type checks are introduced with the `type` or `types` option (both are interchangeable):
85
+
86
+ ```ruby
87
+ define_schemas :profile, {
88
+ zip_code: { type: :string }
89
+ }
90
+
91
+ person.profile['zip_code'] = 90210
92
+ person.valid? # false
93
+ ```
94
+
95
+ Note that passing `:null` to `type`/`types` allows the field to be nullable.
96
+
97
+ ### Defaults
98
+
99
+ Defaults defined on schemas will fill in `nil` values in your JSON columns when validating and exporting. Defaults are safe and will _never_ backfill your model's columns:
100
+
101
+ ```ruby
102
+ define_schemas :profile, {
103
+ zip_code: { type: :string, default: '90210' }
104
+ }
105
+
106
+ person.profile['zip_code'] = nil
107
+ person.valid? # true
108
+ person.export_fleece # { profile: { zip_code: '90210' } }
109
+ person.profile['zip_code'] # nil
110
+ ```
111
+
112
+ In addition to static values, you can use Proc's to dynamically generate defaults at runtime:
113
+
114
+ ```ruby
115
+ define_schemas :profile, {
116
+ zip_code: { type: :string, default: -> record { record.closest_location.zip_code } }
117
+ }
118
+
119
+ person.export_fleece # { profile: { zip_code: '94131' } }
120
+ ```
121
+
122
+ ### Getters
123
+
124
+ Top-level keys in your JSON columns can automatically be mapped as getters on your data model's instances using `define_getters`. Getters are safe and will _never_ override any preexisting instance methods:
125
+
126
+ ```ruby
127
+ define_schemas :profile, {
128
+ zip_code: ...,
129
+ class: ...
130
+ }
131
+ define_getters :profile
132
+
133
+ person.zip_code # '90210'
134
+ person.profile['zip_code'] # nil
135
+
136
+ person.profile['class'] = 'Freshman'
137
+ person.class # Person
138
+ ```
139
+
140
+ Note that getters will return the exported value of your JSON key rather than the raw value.
141
+
142
+ ### Normalizers
143
+
144
+ Normalizers are Procs that normalize your data before validating, exporting or saving:
145
+
146
+ ```ruby
147
+ define_normalizers({
148
+ cast_string: -> record, value { value.to_s }
149
+ })
150
+
151
+ define_schemas :profile, {
152
+ zip_code: { type: :string, normalizer: :cast_string }
153
+ }
154
+
155
+ person.profile['zip_code'] = 90210
156
+ person.profile['zip_code'] # 90210
157
+ person.zip_code # '90210'
158
+ person.valid? # true
159
+
160
+ person.save
161
+ person.profile['zip_code'] # '90210'
162
+ person.zip_code # '90210'
163
+ ```
164
+
165
+ Note that multiple normalizers can be chained with `normalizers`:
166
+
167
+ ```ruby
168
+ define_normalizers({
169
+ cast_string: ...,
170
+ sha1: -> record, value { sha1(value) }
171
+ })
172
+
173
+ define_schemas :profile, {
174
+ zip_code: { type: :string, normalizers: [:cast_string, :sha1] }
175
+ }
176
+
177
+ person.profile['zip_code'] = 90210
178
+ person.zip_code # '2b02dbc1030b278245b2b9cb11667eebf7275a52'
179
+ ```
180
+
181
+ ### Formats
182
+
183
+ Formats are Procs that can be used to enforce complex validations:
184
+
185
+ ```ruby
186
+ define_formats({
187
+ zip_code: -> record, value { raise ArgumentError.new("must be a valid ZIP code") unless value =~ /^[0-9]{5}(?:-[0-9]{4})?$/ }
188
+ })
189
+
190
+ define_schemas :profile, {
191
+ zip_code: { type: :string, format: :zip_code }
192
+ }
193
+
194
+ person.profile['zip_code'] = '90210' # person.valid? == true
195
+ person.profile['zip_code'] = '90210-1234' # person.valid? == true
196
+ person.profile['zip_code'] = '90210-12' # person.valid? == false
197
+ person.errors.messages # "Invalid format at '/zip_code' on column 'profile': must be a valid ZIP code"
198
+ ```
199
+
200
+ Note that unlike types and normalizers, you can only use one format at a time for each schema.
201
+
202
+ ### Nested JSON
203
+
204
+ Schemas can be nested with `subschemas`:
205
+
206
+ ```ruby
207
+ define_schemas :profile, {
208
+ address: { type: :object, subschemas: {
209
+ number: { type: :number },
210
+ street: { type: :string },
211
+ zip_code: { type: :string, default: '90210' }
212
+ }
213
+ }
214
+ }
215
+
216
+ person.profile['address'] # nil
217
+ person.address # { number: nil, street: nil, zip_code: '90210' }
218
+ person.valid? # false
219
+ ```
220
+
221
+ ### Exporting
222
+
223
+ TODO
224
+
225
+ ## Development
226
+
227
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
228
+
229
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
230
+
231
+ ### Using Golden Fleece with other ORM's
232
+
233
+ TODO
234
+
235
+ ## Contributing
236
+
237
+ Bug reports and pull requests are welcome on GitHub at https://github.com/earksiinni/golden_fleece. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
238
+
239
+
240
+ ## License
241
+
242
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "golden_fleece"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'golden_fleece/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "golden_fleece"
8
+ spec.version = GoldenFleece::VERSION
9
+ spec.authors = ["Ersin Akinci"]
10
+ spec.email = ["ersin.akinci@gmail.com"]
11
+
12
+ spec.summary = %q{Easy schemas for your JSON columns.}
13
+ spec.description = %q{Golden Fleece lets you validate, normalize, set up defaults for and provide getters for JSON data in your Ruby data models through easy to use schemas. More opinionated and easier to use than JSON Schema.}
14
+ spec.homepage = "https://github.com/earksiinni/golden_fleece"
15
+ spec.license = "MIT"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = "exe"
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.add_development_dependency "bundler", "~> 1.14"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "rspec", "~> 3.0"
27
+ spec.add_development_dependency "activemodel", "~> 3.0"
28
+ spec.add_development_dependency "activesupport", "~> 3.0"
29
+ spec.add_development_dependency "pry", "~> 0.9"
30
+ spec.add_dependency "hana", "~> 1.3"
31
+ end
@@ -0,0 +1,14 @@
1
+ require "golden_fleece/context"
2
+ require "golden_fleece/definitions"
3
+ require "golden_fleece/format"
4
+ require "golden_fleece/model"
5
+ require "golden_fleece/normalizer"
6
+ require "golden_fleece/schema"
7
+ require "golden_fleece/type"
8
+ require "golden_fleece/utility"
9
+ require "golden_fleece/value"
10
+ require "golden_fleece/version"
11
+
12
+ module GoldenFleece
13
+
14
+ end
@@ -0,0 +1,37 @@
1
+ require "golden_fleece/context/export"
2
+ require "golden_fleece/context/formats"
3
+ require "golden_fleece/context/getters"
4
+ require "golden_fleece/context/normalizers"
5
+ require "golden_fleece/context/schemas"
6
+ require 'golden_fleece/schema'
7
+
8
+ module GoldenFleece
9
+ class Context
10
+ include ::GoldenFleece::Context::Export
11
+ include ::GoldenFleece::Context::Formats
12
+ include ::GoldenFleece::Context::Getters
13
+ include ::GoldenFleece::Context::Normalizers
14
+ include ::GoldenFleece::Context::Schemas
15
+
16
+ attr_accessor :rules
17
+ attr_reader :model_class, :normalizers, :formats, :attributes, :schemas, :setup_callbacks, :has_run_setup
18
+
19
+ def initialize(model_class)
20
+ @model_class = model_class
21
+ @normalizers = {}
22
+ @formats = {}
23
+ @attributes = []
24
+ @schemas = Schema.new(self, '/', {})
25
+ @setup_callbacks = []
26
+ @has_run_setup = false
27
+ end
28
+
29
+ def run_setup_callbacks
30
+ @setup_callbacks.each do |cb|
31
+ cb.call self
32
+ end
33
+
34
+ @has_run_setup = true
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,19 @@
1
+ module GoldenFleece
2
+ class Context
3
+ module Export
4
+ def export(record, export_attributes)
5
+ export_attributes = Array.wrap export_attributes
6
+
7
+ schemas.reduce({}) { |memo, (attribute, schema)|
8
+ if export_attributes.include? attribute
9
+ memo[attribute] = schema.reduce({}) { |memo, (schema_name, schema)|
10
+ memo[schema_name] = schema.value.compute(record)
11
+ memo
12
+ }
13
+ end
14
+ memo
15
+ }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ require 'golden_fleece/format'
2
+
3
+ module GoldenFleece
4
+ class Context
5
+ module Formats
6
+ def define_formats(lambdas = {})
7
+ lambdas.each do |name, fn|
8
+ name = name.to_sym
9
+
10
+ formats[name] = Format.new(name, fn)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ module GoldenFleece
2
+ class Context
3
+ module Getters
4
+ def define_getters(*attributes)
5
+ # For each attribute...
6
+ attributes.each do |attribute|
7
+ # ...and each top-level schema of each attribute...
8
+ schemas[attribute.to_sym].each do |schema_name, schema|
9
+ # ...if there isn't already an instance method named after the schema...
10
+ if !model_class.new.respond_to?(schema_name)
11
+ # ...define a getter for that schema's value!
12
+ model_class.class_eval do
13
+ define_method schema_name do
14
+ self.class.fleece_context.schemas[attribute.to_sym][schema_name].value.compute(self)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ require 'golden_fleece/normalizer'
2
+
3
+ module GoldenFleece
4
+ class Context
5
+ module Normalizers
6
+ def define_normalizers(lambdas = {})
7
+ lambdas.each do |name, fn|
8
+ name = name.to_sym
9
+
10
+ normalizers[name] = Normalizer.new(name, fn)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ module GoldenFleece
2
+ class Context
3
+ module Schemas
4
+ def define_schemas(attribute, schema_definitions = {})
5
+ schemas[attribute.to_sym] ||= schema_definitions
6
+ attributes << attribute
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ require 'golden_fleece/type'
2
+
3
+ module GoldenFleece
4
+ module Definitions
5
+ TYPES = {
6
+ array: Type.new(:array, Array),
7
+ boolean: Type.new(:boolean, FalseClass, TrueClass),
8
+ null: Type.new(:null, NilClass),
9
+ number: Type.new(:number, Numeric),
10
+ object: Type.new(:object, Hash),
11
+ string: Type.new(:string, String)
12
+ }.freeze
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ module GoldenFleece
2
+ class Format
3
+ attr_reader :name
4
+
5
+ def initialize(name, fn)
6
+ @name = name
7
+ @fn = fn
8
+ end
9
+
10
+ def validate(record, value)
11
+ fn.call record, value
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :fn
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ require "golden_fleece/model/context"
2
+ require "golden_fleece/model/export"
3
+ require "golden_fleece/model/normalization"
4
+ require "golden_fleece/model/active_model/normalization"
5
+ require "golden_fleece/model/active_model/validation"
6
+
7
+ module GoldenFleece
8
+ module Model
9
+ def self.included(base)
10
+ # Include ORM-specific modules depending on what ORM we're using
11
+ orm = if defined? ::ActiveModel
12
+ "ActiveModel"
13
+ end
14
+ orm_module = "GoldenFleece::Model::#{orm}".constantize
15
+
16
+ base.class_eval do
17
+ include GoldenFleece::Model::Context
18
+ include GoldenFleece::Model::Export
19
+ include GoldenFleece::Model::Normalization
20
+ include orm_module::Normalization
21
+ include orm_module::Validation
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ require 'active_model'
2
+
3
+ module GoldenFleece
4
+ module Model
5
+ module ActiveModel
6
+ module Normalization
7
+ def self.included(base)
8
+ base.class_eval do
9
+ before_save do
10
+ normalize_fleece
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ require 'active_model'
2
+ require 'golden_fleece/validations/active_model/fleece_schema_conformance_validator'
3
+
4
+ module GoldenFleece
5
+ module Model
6
+ module ActiveModel
7
+ module Validation
8
+ def self.included(base)
9
+ base.class_eval do
10
+ validate_attributes = -> fleece_context {
11
+ fleece_context.model_class.class_eval do
12
+ validates *fleece_context.attributes, 'GoldenFleece::Validations::ActiveModel::FleeceSchemaConformance' => true
13
+ end
14
+ }
15
+
16
+ fleece_context.setup_callbacks << validate_attributes
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ require 'golden_fleece/context'
2
+
3
+ module GoldenFleece
4
+ module Model
5
+ module Context
6
+ def self.included(base)
7
+ base.extend ClassMethods
8
+
9
+ base.instance_eval do
10
+ @fleece_context = GoldenFleece::Context.new(self)
11
+ end
12
+ end
13
+
14
+ module ClassMethods
15
+ attr_reader :fleece_context
16
+
17
+ def fleece(&block)
18
+ fleece_context.instance_eval(&block)
19
+ fleece_context.run_setup_callbacks unless fleece_context.has_run_setup
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,13 @@
1
+ module GoldenFleece
2
+ module Model
3
+ module Export
4
+ def self.included(base)
5
+ base.class_eval do
6
+ def export_fleece(attribs = self.class.fleece_context.attributes)
7
+ self.class.fleece_context.export self, attribs
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ module GoldenFleece
2
+ module Model
3
+ module Normalization
4
+ include Utility
5
+
6
+ def normalize_fleece
7
+ self.class.fleece_context.schemas.each do |attribute, schema|
8
+ persisted_json = read_attribute attribute
9
+
10
+ schema.each do |schema_name, schema|
11
+ schema_name = schema_name.to_s
12
+ computed_json = { schema_name => schema.value.compute(self) }
13
+ deep_stringify_keys computed_json if computed_json.is_a? Hash
14
+
15
+ if !persisted_json[schema_name].nil? && persisted_json[schema_name] != computed_json[schema_name]
16
+ write_attribute attribute, computed_json
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ module GoldenFleece
2
+ class Normalizer
3
+ attr_reader :name
4
+
5
+ def initialize(name, fn)
6
+ @name = name
7
+ @fn = fn
8
+ end
9
+
10
+ def normalize(record, value)
11
+ fn.call record, value
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :fn
17
+ end
18
+ end
@@ -0,0 +1,117 @@
1
+ require 'golden_fleece/definitions'
2
+ require 'golden_fleece/schema'
3
+ require 'golden_fleece/utility'
4
+
5
+ module GoldenFleece
6
+ class Schema
7
+ include Utility
8
+
9
+ attr_reader :attribute, :name, :path, :json_path, :types, :normalizers, :format, :value, :default
10
+
11
+ def initialize(context, path, definitions)
12
+ @context = context
13
+ @path = path
14
+ @name = path.split("/").last
15
+ @attribute = path.split("/")[1]
16
+ @json_path = path.split("/")[2..-1]
17
+ @json_path = @json_path.join("/") if @json_path
18
+ @subschemas = {}
19
+
20
+ # .count == 1 means we're at the root
21
+ # .count == 2 means we're at the attribute
22
+ # .count >= 3 means we're cookin'
23
+ if path.split("/").count <= 2
24
+ @types = [Definitions::TYPES[:object]]
25
+ map_subschemas(definitions)
26
+ else
27
+ map_value
28
+ map_types(definitions[:type], definitions[:types])
29
+ map_normalizers(definitions[:normalizer], definitions[:normalizers])
30
+ map_format(definitions[:format])
31
+ map_default(definitions[:default])
32
+ map_subschemas(definitions[:subschemas])
33
+ end
34
+ end
35
+
36
+ def [](subschema_name)
37
+ subschemas[subschema_name]
38
+ end
39
+
40
+ def []=(subschema_name, subschema_definition)
41
+ subschemas[subschema_name] = Schema.new(context, build_json_path(path, subschema_name), subschema_definition)
42
+ end
43
+
44
+ def each(&block)
45
+ subschemas.each(&block)
46
+ end
47
+
48
+ def reduce(memo, &block)
49
+ subschemas.reduce(memo, &block)
50
+ end
51
+
52
+ def parent?
53
+ subschemas.count > 0
54
+ end
55
+
56
+ def keys
57
+ subschemas.keys
58
+ end
59
+
60
+ def values
61
+ subschemas.values
62
+ end
63
+
64
+ private
65
+
66
+ attr_reader :context, :subschemas
67
+
68
+ def map_value
69
+ @value = Value.new self
70
+ end
71
+
72
+ def map_types(*args)
73
+ @types = args.flatten.compact.map { |type|
74
+ type = type.to_sym
75
+
76
+ raise ArgumentError.new("Invalid type '#{type}' specified for #{error_suffix(attribute, json_path)}}") unless Definitions::TYPES.include? type
77
+
78
+ Definitions::TYPES[type]
79
+ }.uniq
80
+ end
81
+
82
+ def map_normalizers(*args)
83
+ @normalizers = args.flatten.compact.map { |normalizer|
84
+ normalizer = normalizer.to_sym
85
+
86
+ raise ArgumentError.new("Invalid normalizer(s) '#{normalizer}' specified for #{error_suffix(attribute, json_path)}") unless context.normalizers.include?(normalizer)
87
+
88
+ context.normalizers[normalizer]
89
+ }.uniq
90
+ end
91
+
92
+ def map_format(fmt)
93
+ unless fmt.nil?
94
+ fmt = fmt.to_sym
95
+
96
+ raise ArgumentError.new("Invalid format '#{fmt}' specified for #{error_suffix(attribute, json_path)}") unless context.formats.include?(fmt)
97
+
98
+ @format = context.formats[fmt]
99
+ end
100
+ end
101
+
102
+ def map_default(default)
103
+ @default = default
104
+ end
105
+
106
+ def map_subschemas(subschema_definitions)
107
+ @subschemas = subschema_definitions.reduce({}) { |memo, (subschema_name, subschema_definition)|
108
+ raise ArgumentError.new("'subschemas' option can only be set for 'object' type schemas, attempted to provide subschemas for #{error_suffix(attribute, json_path)}") unless types.include? Definitions::TYPES[:object]
109
+ raise ArgumentError.new("The 'subschemas' option must be passed a hash, please check #{error_suffix(attribute, json_path)}") unless subschema_definition.is_a?(Hash)
110
+
111
+ subschema_path = build_json_path(path, subschema_name)
112
+ memo[subschema_name] = Schema.new(context, subschema_path, subschema_definition)
113
+ memo
114
+ } if subschema_definitions.present?
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,22 @@
1
+ module GoldenFleece
2
+ class Type
3
+ attr_reader :name, :classes
4
+
5
+ def initialize(name, *classes)
6
+ @name = name.to_sym
7
+ @classes = classes
8
+ end
9
+
10
+ def matches?(value)
11
+ classes.any? { |klass| value.is_a? klass }
12
+ end
13
+
14
+ def to_s
15
+ ":#{name}"
16
+ end
17
+
18
+ def inspect
19
+ to_s
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ module GoldenFleece
2
+ module Utility
3
+ FALSE_VALUES = [false, 0, "0", "f", "F", "false", "FALSE", "off", "OFF"].to_set
4
+
5
+ def build_json_path(parent_path, key_name)
6
+ "#{parent_path}#{'/' unless parent_path =~ /\/$/}#{key_name}"
7
+ end
8
+
9
+ def error_suffix(attribute, path)
10
+ "'#{path}' on column '#{attribute}'"
11
+ end
12
+
13
+ # Copied from ActiveModel::Type::Boolean
14
+ # https://github.com/rails/rails/blob/master/activemodel/lib/active_model/type/boolean.rb
15
+ def cast_boolean(value)
16
+ if value == ""
17
+ nil
18
+ else
19
+ !FALSE_VALUES.include?(value)
20
+ end
21
+ end
22
+
23
+ def deep_stringify_keys(hash)
24
+ hash.reduce({}) { |memo, (key, value)|
25
+ memo[key.to_s] = value.is_a?(Hash) ? deep_stringify_keys(value) : value
26
+ memo
27
+ }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,18 @@
1
+ require 'active_model'
2
+ require 'golden_fleece/validations/validator_context'
3
+
4
+ module GoldenFleece
5
+ module Validations
6
+ module ActiveModel
7
+ class FleeceSchemaConformanceValidator < ::ActiveModel::EachValidator
8
+ def validate_each(record, attribute, persisted_json)
9
+ context = record.class.fleece_context
10
+ errors = ValidatorContext.new(record, attribute, persisted_json, context.schemas[attribute], '/').validate
11
+
12
+ errors.each { |e| record.errors.add attribute, e }
13
+ errors.empty?
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,74 @@
1
+ require 'golden_fleece/utility'
2
+
3
+ # An ORM-independent recursive validator that goes down into every level of
4
+ # your nested JSON and applies various validation logic. ORM-specific validation
5
+ # classes should use this class for their core processing. Returns an array of
6
+ # error messages.
7
+
8
+ module GoldenFleece
9
+ class ValidatorContext
10
+ include Utility
11
+
12
+ def initialize(record, attribute, persisted_json, schemas, parent_path)
13
+ @persisted_json = persisted_json
14
+ @schemas = schemas
15
+ @parent_path = parent_path
16
+ persisted_keys = if persisted_json && persisted_json.keys
17
+ persisted_json.keys.map(&:to_sym)
18
+ else
19
+ []
20
+ end
21
+ schemas_keys = schemas ? schemas.keys : []
22
+ @validatable_keys = (persisted_keys + schemas_keys).uniq
23
+ @record = record
24
+ @attribute = attribute
25
+ @errors = []
26
+ end
27
+
28
+ def validate
29
+ validatable_keys.each do |key|
30
+ path = build_json_path(parent_path, key)
31
+
32
+ validate_key key, path
33
+
34
+ # If all keys on our current level are valid, proceed
35
+ if errors.empty?
36
+ schema = schemas[key]
37
+ value = schema.value.compute(record)
38
+
39
+ validate_type(value, schema.types, path)
40
+ validate_format(value, schema.format, path)
41
+
42
+ # If the key's value is a nested JSON object, recurse down
43
+ errors << ValidatorContext.new(record, attribute, (persisted_json ? persisted_json[key.to_s] : nil), schemas[key], path).validate if value.is_a?(Hash)
44
+ end
45
+ end
46
+
47
+ errors.flatten
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :persisted_json, :schemas, :parent_path, :validatable_keys, :record, :attribute, :errors
53
+
54
+ def validate_key(key, path)
55
+ errors << "Invalid key #{error_suffix(attribute, path)}" unless schemas.keys.include? key
56
+ end
57
+
58
+ def validate_type(value, valid_types, path)
59
+ unless valid_types.any? { |valid_type| valid_type.matches? value }
60
+ errors << "Invalid type at #{error_suffix(attribute, path)}, must be one of #{valid_types}"
61
+ end
62
+ end
63
+
64
+ def validate_format(value, valid_format, path)
65
+ if valid_format
66
+ begin
67
+ valid_format.validate record, value
68
+ rescue Exception => e
69
+ errors << "Invalid format at #{error_suffix(attribute, path)}: #{e.message}"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,69 @@
1
+ require 'golden_fleece/definitions'
2
+ require 'hana'
3
+
4
+ module GoldenFleece
5
+ class Value
6
+ include Utility
7
+
8
+ def initialize(schema)
9
+ @schema = schema
10
+ self.value_initialized = false
11
+ end
12
+
13
+ def compute(record)
14
+ @record = record
15
+
16
+ if dirty?
17
+ @value = Hana::Pointer.new(schema.json_path).eval(record.read_attribute(schema.attribute))
18
+
19
+ cast_booleans
20
+ apply_normalizers
21
+ apply_default
22
+
23
+ self.value_initialized = true
24
+ end
25
+
26
+ value
27
+ end
28
+
29
+ private
30
+
31
+ attr_accessor :value_initialized
32
+ attr_reader :schema, :record, :value
33
+
34
+ def dirty?
35
+ record.send("#{schema.attribute}_changed?") || !value_initialized
36
+ end
37
+
38
+ # Cast boolean values the way that Rails normally does on boolean columns
39
+ def cast_booleans
40
+ if schema.types.include? Definitions::TYPES[:boolean]
41
+ @value = cast_boolean(value)
42
+ end
43
+ end
44
+
45
+ def apply_normalizers
46
+ @value = schema.normalizers.reduce(value) { |memo, normalizer| normalizer.normalize record, memo }
47
+ end
48
+
49
+ # If there's a persisted value, use that
50
+ # If not, use the default value; if the default is a lambda, call it
51
+ def apply_default
52
+ @value = if value.nil?
53
+ if schema.parent?
54
+ d = schema.reduce({}) { |memo, (subschema_name, subschema)|
55
+ memo[subschema_name] = subschema.value.compute(record)
56
+ memo
57
+ }
58
+ d.values.compact.empty? ? nil : d
59
+ elsif schema.default.respond_to?(:call)
60
+ schema.default.call(record)
61
+ else
62
+ schema.default
63
+ end
64
+ else
65
+ value
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,3 @@
1
+ module GoldenFleece
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,178 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: golden_fleece
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ersin Akinci
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-05-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.14'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.14'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: activemodel
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: activesupport
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.9'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.9'
97
+ - !ruby/object:Gem::Dependency
98
+ name: hana
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.3'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.3'
111
+ description: Golden Fleece lets you validate, normalize, set up defaults for and provide
112
+ getters for JSON data in your Ruby data models through easy to use schemas. More
113
+ opinionated and easier to use than JSON Schema.
114
+ email:
115
+ - ersin.akinci@gmail.com
116
+ executables: []
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - ".gitignore"
121
+ - ".rspec"
122
+ - ".travis.yml"
123
+ - CODE_OF_CONDUCT.md
124
+ - Gemfile
125
+ - LICENSE.txt
126
+ - README.md
127
+ - Rakefile
128
+ - bin/console
129
+ - bin/setup
130
+ - golden_fleece.gemspec
131
+ - lib/golden_fleece.rb
132
+ - lib/golden_fleece/context.rb
133
+ - lib/golden_fleece/context/export.rb
134
+ - lib/golden_fleece/context/formats.rb
135
+ - lib/golden_fleece/context/getters.rb
136
+ - lib/golden_fleece/context/normalizers.rb
137
+ - lib/golden_fleece/context/schemas.rb
138
+ - lib/golden_fleece/definitions.rb
139
+ - lib/golden_fleece/format.rb
140
+ - lib/golden_fleece/model.rb
141
+ - lib/golden_fleece/model/active_model/normalization.rb
142
+ - lib/golden_fleece/model/active_model/validation.rb
143
+ - lib/golden_fleece/model/context.rb
144
+ - lib/golden_fleece/model/export.rb
145
+ - lib/golden_fleece/model/normalization.rb
146
+ - lib/golden_fleece/normalizer.rb
147
+ - lib/golden_fleece/schema.rb
148
+ - lib/golden_fleece/type.rb
149
+ - lib/golden_fleece/utility.rb
150
+ - lib/golden_fleece/validations/active_model/fleece_schema_conformance_validator.rb
151
+ - lib/golden_fleece/validations/validator_context.rb
152
+ - lib/golden_fleece/value.rb
153
+ - lib/golden_fleece/version.rb
154
+ homepage: https://github.com/earksiinni/golden_fleece
155
+ licenses:
156
+ - MIT
157
+ metadata: {}
158
+ post_install_message:
159
+ rdoc_options: []
160
+ require_paths:
161
+ - lib
162
+ required_ruby_version: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ required_rubygems_version: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - ">="
170
+ - !ruby/object:Gem::Version
171
+ version: '0'
172
+ requirements: []
173
+ rubyforge_project:
174
+ rubygems_version: 2.6.11
175
+ signing_key:
176
+ specification_version: 4
177
+ summary: Easy schemas for your JSON columns.
178
+ test_files: []