object_patch 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 1d001241ee49b3b29f9a8a04bd8aa4ea32720175
4
+ data.tar.gz: 4dd6636e8c29378d7038cacf6972697ef1d69f71
5
+ SHA512:
6
+ metadata.gz: 69fecfe300ec106c18761625fa0e0b059dc69b1f2e62a2d5e9b1b0fe3620e6b57146bb6d153d5448b0c013c06af895a178f13f02ea70d000b87f85b1187cc2dd
7
+ data.tar.gz: a8759ce823f1e00a6b43bf15aab0714d76a2f33c364bb3a94f921cc5c02904c3c50fa01792ce348730fa736b249a4b1a6db0f4522e977edac039a1720e8c29e2
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,4 @@
1
+ [submodule "spec/fixtures/json-patch-tests"]
2
+ path = spec/fixtures/json-patch-tests
3
+ url = https://github.com/json-patch/json-patch-tests.git
4
+ branch = master
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format progress
2
+ --color
@@ -0,0 +1,5 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+ - 2.0.0
5
+ - 2.1.0
@@ -0,0 +1,3 @@
1
+ --private
2
+ --protected
3
+ lib/**/*.rb - README.md LICENSE.txt
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Sam Stelfox
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,77 @@
1
+ # ObjectPatch
2
+
3
+ Please note this project isn't complete in either the generation or application
4
+ of patches.
5
+
6
+ ObjectPatch is a pure ruby implementation of [RFC6902
7
+ (JSON::Patch)](http://tools.ietf.org/rfc/rfc6902.txt) for standard hashes,
8
+ arrays, and scalar types. This will both generate patches as well as apply
9
+ them.
10
+
11
+ Rather than restricting end-users to the native JSON library, ObjectPatch only
12
+ operates on pure Ruby objects. These objects can be converted to a proper JSON
13
+ encoding using the standard JSON library or any other JSON compliant encoder.
14
+
15
+ The application of patches is based on
16
+ [Hana](http://github.com/tenderlove/hana). I greatly respect tenderlove but
17
+ disagreed with pieces of his implementation. I could have chosen to make pull
18
+ requests but since I was going to be extending the scope of the project I
19
+ decided to create my own project. At the very least it seemed like a fun
20
+ project to attempt.
21
+
22
+ The generation of patches attempts to do so in a way to minimize the size of
23
+ the patch, this is particularily difficult in arrays where the deletion of a
24
+ single element at the beginning may be hard to distinguish from the changing of
25
+ multiple values and the removal of the last.
26
+
27
+ ## Installation
28
+
29
+ Add this line to your application's Gemfile:
30
+
31
+ gem 'object_patch'
32
+
33
+ And then execute:
34
+
35
+ $ bundle
36
+
37
+ Or install it yourself as:
38
+
39
+ $ gem install object_patch
40
+
41
+ ## Usage
42
+
43
+ TODO: Write usage instructions here
44
+
45
+ ## RFC6901
46
+
47
+ There is one thing that I implemented within this RFC that is either an
48
+ extension or a violation of the RFC depending on your view of it. The violation
49
+ specifically is I have allowed negative array addressing (ie -1 is the last
50
+ element, -2 the second to last, &c). The absolute value of the index can't be
51
+ greater than or equal to the the length of the array.
52
+
53
+ ## RFC6902
54
+
55
+ One thing to note is that while referencing [RFC6902
56
+ (JSON::Patch)](http://tools.ietf.org/rfc/rfc6902.txt) it came to my attention
57
+ that the published RFC was missing a section that was part of the accepted
58
+ revision (specifically the appendix). The revision of the document that was
59
+ accepted by the IETF can be [found
60
+ here](http://tools.ietf.org/id/draft-ietf-appsawg-json-patch-10.txt). This was
61
+ gleaned from the public history of review of the RFC which is [available
62
+ here](https://datatracker.ietf.org/doc/rfc6902/history/).
63
+
64
+ I referenced the draft version 10 for any information available in the appendix
65
+ and the published version when the information was available there.
66
+
67
+ ## Contributing
68
+
69
+ 1. Fork it
70
+ 2. Create your feature branch off of the current `develop` head (`git checkout
71
+ -b my-new-feature origin/develop`)
72
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
73
+ 4. Push to the branch (`git push origin my-new-feature`)
74
+ 5. Create new Pull Request
75
+
76
+ I'll respond to all pull requests within two weeks, hopefully in under one.
77
+
@@ -0,0 +1,18 @@
1
+
2
+ require "bundler/gem_tasks"
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task :default => :spec
8
+
9
+ task :environment do
10
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'lib'))
11
+ require 'object_patch'
12
+ end
13
+
14
+ desc "Run a pry session with the gem code loaded"
15
+ task :console => :environment do
16
+ require 'pry'
17
+ pry
18
+ end
data/TODO.md ADDED
@@ -0,0 +1,7 @@
1
+
2
+ * Apparently string reuse is better done through constants to prevent them
3
+ from being recreated all the time such as accessing common hash keys, think
4
+ 'path', 'from', 'value', 'op'.
5
+ * The big one: Generation of patches based on the difference of two provided
6
+ hashes.
7
+
@@ -0,0 +1,46 @@
1
+
2
+
3
+ require "object_patch/exceptions"
4
+ require "object_patch/generator"
5
+ require "object_patch/operation_factory"
6
+ require "object_patch/operations"
7
+ require "object_patch/pointer"
8
+ require "object_patch/version"
9
+
10
+ module ObjectPatch # :nodoc:
11
+
12
+ # Applies a series of patches to a source document. It is important to note
13
+ # that this will return the changed document but will also modify the original
14
+ # document that got passed in, using dup also isn't enough to prevent this
15
+ # modification as any subvalues within a hash or an array will not by
16
+ # duplicated. A deep duplication by marshalling the object, encoding and
17
+ # decoding as JSON, or using a recursive function to duplicate all of an
18
+ # object's contents would prevent your source from being modified.
19
+ #
20
+ # The JSON Patch RFC specifies that the original document shouldn't be
21
+ # modified unless the whole series of patches is able to be applied
22
+ # successfully.
23
+ #
24
+ # @param [Object] source The source document that will be modified.
25
+ # @param [Array<Hash<String => String>>] patches An array of JSON patch
26
+ # operations that should be applied to the source document.
27
+ # @return [Object] The modified source document.
28
+ def apply(source, patches)
29
+ patches.inject(source) do |src, patch|
30
+ OperationFactory.build(patch).apply(src)
31
+ end
32
+ end
33
+
34
+ # Proxies the generation of a new set of patches to the Generator.
35
+ #
36
+ # @see [Generator#generate]
37
+ # @param [Object] source The source document
38
+ # @param [Object] target The target document we'll generate the
39
+ # differences for.
40
+ # @return [Array<Hash>]
41
+ def generate(source, target)
42
+ Generator.generate(source, target)
43
+ end
44
+
45
+ module_function :apply, :generate
46
+ end
@@ -0,0 +1,51 @@
1
+
2
+ module ObjectPatch
3
+ # This is a parent exception for anything that is going to get raised by this
4
+ # gem, allowing users of this code to catch all the subclasses.
5
+ BaseException = Class.new(StandardError)
6
+
7
+ # Raised when the index value that was attempted to be accessed isn't a
8
+ # numeric identifier or '-' (the special index value defined in JSON Pointer).
9
+ InvalidIndexError = Class.new(BaseException)
10
+
11
+ # An exception that gets raised when a patch contains an invalid operation.
12
+ InvalidOperation = Class.new(BaseException)
13
+
14
+ # An exception that gets raised when an operation takes place on a missing
15
+ # hash key, or a missing hash key is somewhere along the path.
16
+ MissingTargetException = Class.new(BaseException)
17
+
18
+ # Raised when a non-integer value is attempted to be used to access some part
19
+ # of an array.
20
+ ObjectOperationOnArrayException = Class.new(BaseException)
21
+
22
+ # When an integer value outside the available range of an array is used to
23
+ # access an array this will get raised.
24
+ OutOfBoundsException = Class.new(BaseException)
25
+
26
+ # When the path provided attempts to cross a scalar value, this exception will
27
+ # be raised.
28
+ TraverseScalarException = Class.new(BaseException)
29
+
30
+ # An exception that will get raised when a test operation fails after being
31
+ # applied to a document.
32
+ #
33
+ # @attr [String] path A JSON pointer string representing the location to be
34
+ # checked.
35
+ # @attr [Object] value The value that failed the comparison check.
36
+ class FailedTestException < BaseException
37
+ attr_accessor :path, :value
38
+
39
+ # Formats the exception message with the relevant information before
40
+ # actually raising the error.
41
+ #
42
+ # @param [String] path
43
+ # @param [Object] value
44
+ # @return [void]
45
+ def initialize(path, value)
46
+ super("Expected #{value} at #{path}")
47
+ @path, @value = path, value
48
+ end
49
+ end
50
+ end
51
+
@@ -0,0 +1,114 @@
1
+
2
+ module ObjectPatch
3
+
4
+ # This module handles the generation of patches between two objects.
5
+ module Generator
6
+
7
+ # Generate a series of patch operations that describe the changes from the
8
+ # source object to the target object.
9
+ #
10
+ # @todo This doesn't do anything yet...
11
+ # @param [Object] source The source document
12
+ # @param [Object] target The target document we'll generate the
13
+ # differences for.
14
+ # @return [Array<Hash>]
15
+ def generate(source, target, current_path = [])
16
+ if source.class != target.class
17
+ # The simplest of cases is that we have incompatible types, in which
18
+ # case we'll full replace the root. This of course could be optimized
19
+ # for situations where the root as moved to a child element but that is
20
+ # harder to detect...
21
+ return [Operations::Replace.new("path" => Pointer.encode(current_path), "value" => target)]
22
+ end
23
+
24
+ case source.class.to_s
25
+ when "Hash"
26
+ return hash_compare(source, target, current_path)
27
+ when "Array"
28
+ return array_compare(source, target, current_path)
29
+ else
30
+ # A scaler value
31
+ return [] if source == target
32
+ return [Operations::Replace.new("path" => Pointer.encode(current_path), "value" => target)]
33
+ end
34
+ end
35
+
36
+ def hash_compare(src_hash, tgt_hash, current_path)
37
+ operations = []
38
+
39
+ # Keys to remove
40
+ (src_hash.keys - tgt_hash.keys).each do |k|
41
+ path = Pointer.encode(current_path + Array(k))
42
+ operations.push(
43
+ Operations::Test.new("path" => path, "value" => src_hash[k]),
44
+ Operations::Remove.new("path" => path)
45
+ )
46
+ end
47
+
48
+ # Missing keys to add
49
+ (tgt_hash.keys - src_hash.keys).each do |k|
50
+ path = Pointer.encode(current_path + Array(k))
51
+ operations.push(
52
+ Operations::Add.new("path" => path, "value" => tgt_hash[k])
53
+ )
54
+ end
55
+
56
+ # When both hashes share the same key we need to go deeper...
57
+ (src_hash.keys & tgt_hash.keys).each do |k|
58
+ operations += generate(src_hash[k], tgt_hash[k], current_path + Array(k))
59
+ end
60
+
61
+ operations
62
+ end
63
+
64
+ def array_compare(src_ary, tgt_ary, current_path)
65
+ operations = []
66
+
67
+ if src_ary.size > tgt_ary.size
68
+ # We'll need to remove some elements
69
+ base_size = tgt_ary.size
70
+ src_ary[base_size..-1].each_with_index do |itm, idx|
71
+ path = Pointer.encode(current_path + Array(base_size + idx))
72
+ operations.push(
73
+ Operations::Test.new("path" => path, "value" => src_ary[base_size + idx]),
74
+ Operations::Remove.new("path" => path)
75
+ )
76
+ end
77
+ elsif src_ary.size < tgt_ary.size
78
+ # We'll need to add some elements
79
+ base_size = tgt_ary.size
80
+ src_ary[base_size..-1].each_with_index do |itm, idx|
81
+ path = Pointer.encode(current_path + Array(base_size + idx))
82
+ operations.push(
83
+ Operations::Add.new("path" => path, "value" => tgt_ary[base_size + idx]),
84
+ )
85
+ end
86
+ end
87
+
88
+ # Compare the existing values in the array
89
+ smallest_length = (src_ary.size > tgt_ary.size) ? tgt_ary.size : src_ary.size
90
+ smallest_length.times do |n|
91
+ # Handle arrays and hashes in a special way as their values are
92
+ # potentially not going to be able to hold up to a comparison.
93
+ if src_ary[n].is_a?(Array) || src_ary[n].is_a?(Hash) || tgt_ary[n].is_a?(Array) || tgt_ary[n].is_a?(Hash)
94
+ operations.push(*generate(src_ary[n], tgt_ary[n], current_path + Array(n)))
95
+ next
96
+ end
97
+
98
+ # We've gotten the complicated cases out, this is a simple test and
99
+ # replace.
100
+ unless src_ary[n] == tgt_ary[n]
101
+ path = Pointer.encode(current_path + Array(n))
102
+ operations.push(
103
+ Operations::Test.new("path" => path, "value" => src_ary[n]),
104
+ Operations::Replace.new("path" => path, "value" => tgt_ary[n]),
105
+ )
106
+ end
107
+ end
108
+
109
+ operations
110
+ end
111
+
112
+ module_function :array_compare, :generate, :hash_compare
113
+ end
114
+ end
@@ -0,0 +1,25 @@
1
+
2
+ module ObjectPatch
3
+
4
+ # A factory module used to build the appropriate operation objects.
5
+ module OperationFactory
6
+
7
+ # Build an operation object that matches the operation type and makes the
8
+ # appropriate information available to them.
9
+ #
10
+ # @param [Hash] patch
11
+ # @return [Object] One of the operations classes.
12
+ def build(patch)
13
+ operations = ObjectPatch::Operations.constants.map { |c| c.to_s.downcase }
14
+
15
+ unless operations.include?(patch['op'])
16
+ raise InvalidOperation, "Invalid operation: `#{patch['op']}`"
17
+ end
18
+
19
+ Operations.const_get(patch['op'].capitalize.to_sym).new(patch)
20
+ end
21
+
22
+ module_function :build
23
+ end
24
+ end
25
+
@@ -0,0 +1,79 @@
1
+
2
+ require "object_patch/operations/add"
3
+ require "object_patch/operations/copy"
4
+ require "object_patch/operations/move"
5
+ require "object_patch/operations/remove"
6
+ require "object_patch/operations/replace"
7
+ require "object_patch/operations/test"
8
+
9
+ module ObjectPatch
10
+
11
+ # These operations take advantage of the fact that Pointer#eval returns the
12
+ # same object (obj.object_id match) and thus any changes made to the
13
+ # extracted object will be reflected in the original deeply nested object.
14
+ module Operations
15
+
16
+ # Add a value at the provided key within the provided object. This will
17
+ # behave differently depending on whether we're processing a hash or an
18
+ # array as the target destination.
19
+ #
20
+ # It is important to note that this behaves by adjusting the state of the
21
+ # provided object. It does not return the new object itself!
22
+ #
23
+ # @param [Array, Hash] target_obj The object that will have the value added.
24
+ # @param [Fixnum,String] key The index / key where the new value will be
25
+ # inserted.
26
+ # @param [Object] new_value The value to insert at the specified location.
27
+ # @return [Object] The value that was added.
28
+ def add_op(target_obj, key, new_value)
29
+ if target_obj.is_a?(Array)
30
+ target_obj.insert(check_array_index(key, target_obj.size), new_value)
31
+ else
32
+ target_obj[key] = new_value
33
+ end
34
+ end
35
+
36
+ # Validates that the array index provided falls within the acceptable range
37
+ # or in the event we have received the special '-' index defined in the
38
+ # JSON Pointer RFC we treat it as the last element.
39
+ #
40
+ # @param [String,Fixnum] index The index value to validate
41
+ # @param [Fixnum] array_size The size of the array this index will be used
42
+ # within (Used for bounds checking).
43
+ # @return [Fixnum] Valid index
44
+ def check_array_index(index, array_size)
45
+ return -1 if index == "-"
46
+ raise ObjectOperationOnArrayException unless index =~ /\A-?\d+\Z/
47
+
48
+ index = index.to_i
49
+
50
+ # There is a bug in the IETF tests that require us to allow patches to
51
+ # set a value at the end of the array. The final '<=' should actually be
52
+ # a '<'.
53
+ raise OutOfBoundsException unless (0 <= index && index <= array_size)
54
+
55
+ index
56
+ end
57
+
58
+ # Remove a hash key or index from the provided object.
59
+ #
60
+ # It is important to note that this behaves by adjusting the state of the
61
+ # provided object. It does not return the new object itself!
62
+ #
63
+ # @param [Array, Hash] target_obj The object that will have the value
64
+ # removed.
65
+ # @return [Object] The deleted object.
66
+ def rm_op(target_obj, key)
67
+ if target_obj.is_a?(Array)
68
+ raise InvalidIndexError unless key =~ /\A\d+\Z/
69
+ target_obj.delete_at(check_array_index(key, target_obj.size))
70
+ else
71
+ raise(MissingTargetException, key) unless target_obj.has_key?(key)
72
+ target_obj.delete(key)
73
+ end
74
+ end
75
+
76
+ module_function :add_op, :check_array_index, :rm_op
77
+ end
78
+ end
79
+