object_patch 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+