monolens 0.3.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 4a6edbb2568985503bf9228ded96c6d179acc196
4
- data.tar.gz: f37432cfc01970a18c57884547f5520ac31fb374
3
+ metadata.gz: 9f60bac69139624ed4d81f0ae72e710b384ca42f
4
+ data.tar.gz: f69b94c2440bd260a61d7619f9883501fa5237d7
5
5
  SHA512:
6
- metadata.gz: e83f9436b81460041e6ed9edd73c1fd95ca8b095f2cd0d27d063649b49c8b125363735258c5d03293c1fb74afaa1d042e4a4663b47c2a45276c0b8a730d8ee77
7
- data.tar.gz: 5c704058d773ab5bddc4d58541d043a63ef176ad68f9a5f7ba670335b167da7df50c7935691ecb8951e98b354bd39d79ad5d36fb8cafa210372d30d44376051f
6
+ metadata.gz: 5e9cebb9122bac25f9787f8061242b69315bac0bfa5c27eb9cea4c22802060b8b3c7d28e82c536d25011b23756f14663f00030f75d1a8852fea688b5ac6a3a55
7
+ data.tar.gz: de6e9e3fedf61c3ef624d11a958ff2de6a5e3d8babd6b8d6f65175179e015e8eff77088bcf7c93e1d571aeeb97ea67b30b94a3b90ff65922c1acd2058b1bbe96
data/README.md CHANGED
@@ -76,6 +76,7 @@ result = lens.call(input)
76
76
  ## Available lenses
77
77
 
78
78
  ```
79
+ core.dig - Extract from the input value (object or array) using a path.
79
80
  core.chain - Applies a chain of lenses to an input value
80
81
  core.mapping - Converts the input value via a key:value mapping
81
82
 
@@ -86,6 +87,7 @@ str.upcase - Converts the input string to uppercase
86
87
 
87
88
  skip.null - Aborts the current lens transformation if nil
88
89
 
90
+ object.extend - Adds key/value(s) to the input object
89
91
  object.rename - Rename some keys of the input object
90
92
  object.transform - Applies specific lenses to specific values of the input object
91
93
  object.keys - Applies a lens to all keys of the input object
@@ -94,6 +96,8 @@ object.select - Builds an object by selecting key/values from the input obje
94
96
 
95
97
  coerce.date - Coerces the input value to a date
96
98
  coerce.datetime - Coerces the input value to a datetime
99
+ coerce.string - Coerces the input value to a string (aka to_s)
100
+ coerce.integer - Coerces the input value to an integer
97
101
 
98
102
  array.compact - Removes null from the input array
99
103
  array.join - Joins values of the input array as a string
@@ -114,6 +118,8 @@ Anyway, the public interface will cover at least the following:
114
118
 
115
119
  * Exception classes: `Monolens::Error`, `Monolens::LensError`
116
120
 
121
+ * bin/monolens, its args, options and general behavior
122
+
117
123
  Everything else is condidered private and may change any time
118
124
  (i.e. even on patch releases).
119
125
 
data/bin/monolens ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ retried = false
3
+ begin
4
+ require 'monolens'
5
+ require 'monolens/command'
6
+ rescue LoadError
7
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
8
+ to_retry, retried = !retried, true
9
+ to_retry ? retry : raise
10
+ end
11
+ Monolens::Command.call(ARGV)
@@ -10,6 +10,8 @@ module Monolens
10
10
  ]
11
11
 
12
12
  def call(arg, world = {})
13
+ return arg if arg.is_a?(::Date)
14
+
13
15
  is_string!(arg, world)
14
16
 
15
17
  date = nil
@@ -10,6 +10,8 @@ module Monolens
10
10
  ]
11
11
 
12
12
  def call(arg, world = {})
13
+ return arg if arg.is_a?(::DateTime)
14
+
13
15
  is_string!(arg, world)
14
16
 
15
17
  date = nil
@@ -28,12 +30,20 @@ module Monolens
28
30
  fail!(first_error.message, world)
29
31
  end
30
32
 
33
+ private
34
+
31
35
  def strptime(arg, format = nil)
32
- if format.nil?
33
- ::DateTime.strptime(arg)
36
+ parsed = if format.nil?
37
+ parser.parse(arg)
34
38
  else
35
- ::DateTime.strptime(arg, format)
39
+ parser.strptime(arg, format)
36
40
  end
41
+ parsed = parsed.to_datetime if parsed.respond_to?(:to_datetime)
42
+ parsed
43
+ end
44
+
45
+ def parser
46
+ option(:parser, ::DateTime)
37
47
  end
38
48
  end
39
49
  end
@@ -0,0 +1,15 @@
1
+ require 'date'
2
+
3
+ module Monolens
4
+ module Coerce
5
+ class Integer
6
+ include Lens
7
+
8
+ def call(arg, world = {})
9
+ Integer(arg)
10
+ rescue => ex
11
+ fail!(ex.message, world)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -5,6 +5,11 @@ module Monolens
5
5
  end
6
6
  module_function :date
7
7
 
8
+ def integer(options = {})
9
+ Integer.new(options)
10
+ end
11
+ module_function :integer
12
+
8
13
  def datetime(options = {})
9
14
  DateTime.new(options)
10
15
  end
@@ -20,4 +25,5 @@ module Monolens
20
25
  end
21
26
  require_relative 'coerce/date'
22
27
  require_relative 'coerce/date_time'
28
+ require_relative 'coerce/integer'
23
29
  require_relative 'coerce/string'
@@ -0,0 +1,96 @@
1
+ require 'optparse'
2
+ require 'date'
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ module Monolens
7
+ class Command
8
+ def initialize(argv, stdin, stdout, stderr)
9
+ @argv = argv
10
+ @stdin = stdin
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ @pretty = false
14
+ end
15
+ attr_reader :argv, :stdin, :stdout, :stderr
16
+ attr_reader :pretty
17
+
18
+ def self.call(argv, stdin = $stdin, stdout = $stdout, stderr = $stderr)
19
+ new(argv, stdin, stdout, stderr).call
20
+ end
21
+
22
+ def call
23
+ lens, input = options.parse!(argv)
24
+ show_help_and_exit if lens.nil? || input.nil?
25
+
26
+ lens, input = read_file(lens), read_file(input)
27
+ error_handler = ErrorHandler.new
28
+ lens = Monolens.lens(lens)
29
+ result = lens.call(input, error_handler: error_handler)
30
+
31
+ unless error_handler.empty?
32
+ stderr.puts(error_handler.report)
33
+ end
34
+
35
+ if result
36
+ output = if pretty
37
+ JSON.pretty_generate(result)
38
+ else
39
+ result.to_json
40
+ end
41
+
42
+ stdout.puts output
43
+ end
44
+ rescue Monolens::LensError => ex
45
+ stderr.puts("[#{ex.location.join('/')}] #{ex.message}")
46
+ do_exit(-2)
47
+ end
48
+
49
+ def read_file(file)
50
+ case ::File.extname(file)
51
+ when /json$/
52
+ content = ::File.read(file)
53
+ JSON.parse(content)
54
+ when /ya?ml$/
55
+ content = ::File.read(file)
56
+ YAML.safe_load(content)
57
+ when /csv$/
58
+ require 'bmg'
59
+ Bmg.csv(file).to_a
60
+ when /xlsx?$/
61
+ require 'bmg'
62
+ Bmg.excel(file).to_a
63
+ else
64
+ fail!("Unable to use #{file}")
65
+ end
66
+ end
67
+
68
+ def fail!(msg)
69
+ stderr.puts(msg)
70
+ do_exit(1)
71
+ end
72
+
73
+ def do_exit(status)
74
+ exit(status)
75
+ end
76
+
77
+ def show_help_and_exit
78
+ stdout.puts options
79
+ do_exit(0)
80
+ end
81
+
82
+ def options
83
+ @options ||= OptionParser.new do |opts|
84
+ opts.banner = 'Usage: monolens [options] LENS INPUT'
85
+
86
+ opts.on('--version', 'Show version and exit') do
87
+ stdout.puts "Monolens v#{VERSION} - (c) Enspirit #{Date.today.year}"
88
+ do_exit(0)
89
+ end
90
+ opts.on('-p', '--[no-]pretty', 'Show version and exit') do |pretty|
91
+ @pretty = pretty
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,52 @@
1
+ module Monolens
2
+ module Core
3
+ class Dig
4
+ include Lens
5
+
6
+ def call(arg, world = {})
7
+ option(:defn, []).inject(arg) do |memo, part|
8
+ dig_on(part, memo, world)
9
+ end
10
+ end
11
+
12
+ private
13
+
14
+ def path
15
+ option(:defn, []).join('.')
16
+ end
17
+
18
+ def dig_on(attr, arg, world)
19
+ if arg.is_a?(::Array)
20
+ index = attr.to_i
21
+ on_missing(world) if index >= arg.size
22
+ arg[index]
23
+ elsif arg.is_a?(::Hash)
24
+ actual, value = fetch_on(attr, arg)
25
+ on_missing(world) unless actual
26
+ value
27
+ elsif arg
28
+ if attr.is_a?(::Integer)
29
+ is_array!(arg, world)
30
+ else
31
+ is_hash!(arg, world)
32
+ end
33
+ else
34
+ on_missing(world)
35
+ end
36
+ end
37
+
38
+ def on_missing(world)
39
+ strategy = option(:on_missing, :fail)
40
+ case strategy.to_sym
41
+ when :fail
42
+ fail!("Unable to find #{path}", world)
43
+ when :null
44
+ nil
45
+ else
46
+ raise Monolens::Error, "Unexpected missing strategy `#{strategy}`"
47
+ end
48
+ end
49
+ private :on_missing
50
+ end
51
+ end
52
+ end
@@ -5,11 +5,31 @@ module Monolens
5
5
 
6
6
  def call(arg, world = {})
7
7
  option(:values, {}).fetch(arg) do
8
- fail!("Unrecognized value `#{arg}`", world) if option(:fail_if_missing)
8
+ on_missing(arg, world)
9
+ end
10
+ end
11
+
12
+ private
9
13
 
10
- option(:default)
14
+ def on_missing(arg, world)
15
+ strategy = option(:on_missing, :fail)
16
+ case strategy.to_sym
17
+ when :fail
18
+ fail!("Unrecognized value `#{arg}`", world)
19
+ when :default
20
+ option(:default, nil)
21
+ when :null
22
+ nil
23
+ when :fallback
24
+ missing_fallback = ->(arg, world) do
25
+ raise Monolens::Error, "Unexpected missing fallback handler"
26
+ end
27
+ option(:fallback, missing_fallback).call(self, arg, world)
28
+ else
29
+ raise Monolens::Error, "Unexpected missing strategy `#{strategy}`"
11
30
  end
12
31
  end
32
+ private :on_missing
13
33
  end
14
34
  end
15
35
  end
data/lib/monolens/core.rb CHANGED
@@ -5,6 +5,11 @@ module Monolens
5
5
  end
6
6
  module_function :chain
7
7
 
8
+ def dig(options)
9
+ Dig.new(options)
10
+ end
11
+ module_function :dig
12
+
8
13
  def mapping(options)
9
14
  Mapping.new(options)
10
15
  end
@@ -14,4 +19,5 @@ module Monolens
14
19
  end
15
20
  end
16
21
  require_relative 'core/chain'
22
+ require_relative 'core/dig'
17
23
  require_relative 'core/mapping'
@@ -0,0 +1,31 @@
1
+ module Monolens
2
+ class ErrorHandler
3
+ include Enumerable
4
+
5
+ def initialize
6
+ @errors = []
7
+ end
8
+
9
+ def call(error)
10
+ @errors << error
11
+ end
12
+
13
+ def each(&bl)
14
+ @errors.each(&bl)
15
+ end
16
+
17
+ def size
18
+ @errors.size
19
+ end
20
+
21
+ def empty?
22
+ @errors.empty?
23
+ end
24
+
25
+ def report
26
+ @errors
27
+ .map{|err| "[#{err.location.join('/')}] #{err.message}" }
28
+ .join("\n")
29
+ end
30
+ end
31
+ end
@@ -1,7 +1,6 @@
1
1
  module Monolens
2
2
  module Lens
3
3
  module FetchSupport
4
-
5
4
  def fetch_on(attr, arg, default = nil)
6
5
  if arg.key?(attr)
7
6
  [ attr, arg[attr] ]
@@ -15,7 +14,6 @@ module Monolens
15
14
  nil
16
15
  end
17
16
  end
18
-
19
17
  end
20
18
  end
21
19
  end
data/lib/monolens/lens.rb CHANGED
@@ -11,6 +11,11 @@ module Monolens
11
11
  end
12
12
  attr_reader :options
13
13
 
14
+ def fail!(msg, world)
15
+ location = world[:location]&.to_a || []
16
+ raise Monolens::LensError.new(msg, location)
17
+ end
18
+
14
19
  protected
15
20
 
16
21
  def option(name, default = nil)
@@ -58,10 +63,5 @@ module Monolens
58
63
 
59
64
  fail!("Array expected, got #{arg.class}", world)
60
65
  end
61
-
62
- def fail!(msg, world)
63
- location = world[:location]&.to_a || []
64
- raise Monolens::LensError.new(msg, location)
65
- end
66
66
  end
67
67
  end
@@ -0,0 +1,53 @@
1
+ module Monolens
2
+ module Object
3
+ class Extend
4
+ include Lens
5
+
6
+ def initialize(options)
7
+ super(options)
8
+ ts = option(:defn, {})
9
+ ts.each_pair do |k,v|
10
+ ts[k] = Monolens.lens(v)
11
+ end
12
+ end
13
+
14
+ def call(arg, world = {})
15
+ is_hash!(arg, world)
16
+
17
+ result = arg.dup
18
+ is_symbol = arg.keys.any?{|k| k.is_a?(Symbol) }
19
+ option(:defn, {}).each_pair do |attr, lens|
20
+ deeper(world, attr) do |w|
21
+ actual = is_symbol ? attr.to_sym : attr.to_s
22
+ begin
23
+ result[actual] = lens.call(arg, w)
24
+ rescue Monolens::LensError => ex
25
+ strategy = option(:on_error, :fail)
26
+ handle_error(strategy, ex, result, actual, world)
27
+ end
28
+ end
29
+ end
30
+ result
31
+ end
32
+
33
+ def handle_error(strategy, ex, result, attr, world)
34
+ strategy = strategy.to_sym unless strategy.is_a?(::Array)
35
+ case strategy
36
+ when ::Array
37
+ strategy.each{|s| handle_error(s, ex, result, attr, world) }
38
+ when :handler
39
+ error_handler!(world).call(ex)
40
+ when :fail
41
+ raise
42
+ when :null
43
+ result[attr] = nil
44
+ when :skip
45
+ nil
46
+ else
47
+ raise Monolens::Error, "Unexpected error strategy `#{strategy}`"
48
+ end
49
+ end
50
+ private :handle_error
51
+ end
52
+ end
53
+ end
@@ -8,27 +8,65 @@ module Monolens
8
8
 
9
9
  result = {}
10
10
  is_symbol = arg.keys.any?{|k| k.is_a?(Symbol) }
11
- option(:defn, {}).each_pair do |new_attr, selector|
11
+ defn.each_pair do |new_attr, selector|
12
+ new_attr = is_symbol ? new_attr.to_sym : new_attr.to_s
13
+
12
14
  deeper(world, new_attr) do |w|
13
- is_array = selector.is_a?(::Array)
14
- values = []
15
- Array(selector).each do |old_attr|
16
- actual, fetched = fetch_on(old_attr, arg)
17
- if actual.nil?
18
- on_missing(old_attr, values, w)
19
- else
20
- values << fetched
21
- end
22
- end
23
- new_attr = is_symbol ? new_attr.to_sym : new_attr.to_s
24
- unless values.empty?
25
- result[new_attr] = is_array ? values : values.first
15
+ catch (:skip) do
16
+ value = do_select(arg, selector, w)
17
+ result[new_attr] = value
26
18
  end
27
19
  end
28
20
  end
29
21
  result
30
22
  end
31
23
 
24
+ private
25
+
26
+ def do_select(arg, selector, world)
27
+ if selector.is_a?(::Array)
28
+ do_array_select(arg, selector, world)
29
+ else
30
+ do_single_select(arg, selector, world)
31
+ end
32
+ end
33
+
34
+ def do_array_select(arg, selector, world)
35
+ case option(:strategy, :all).to_sym
36
+ when :all
37
+ selector.each_with_object([]) do |old_attr, values|
38
+ catch (:skip) do
39
+ values << do_single_select(arg, old_attr, world)
40
+ end
41
+ end
42
+ when :first
43
+ selector.each do |old_attr|
44
+ actual, fetched = fetch_on(old_attr, arg)
45
+ return fetched if actual
46
+ end
47
+ on_missing(selector.first, [], world).first
48
+ else
49
+ raise Monolens::Error, "Unexpected strategy `#{strategy}`"
50
+ end
51
+ end
52
+
53
+ def do_single_select(arg, selector, world)
54
+ actual, fetched = fetch_on(selector, arg)
55
+ if actual.nil?
56
+ on_missing(selector, [], world).first
57
+ else
58
+ fetched
59
+ end
60
+ end
61
+
62
+ def defn
63
+ defn = option(:defn, {})
64
+ defn = defn.each_with_object({}) do |attr, memo|
65
+ memo[attr] = attr
66
+ end if defn.is_a?(::Array)
67
+ defn
68
+ end
69
+
32
70
  def on_missing(attr, values, world)
33
71
  strategy = option(:on_missing, :fail)
34
72
  case strategy.to_sym
@@ -37,9 +75,9 @@ module Monolens
37
75
  when :null
38
76
  values << nil
39
77
  when :skip
40
- nil
78
+ throw :skip
41
79
  else
42
- raise Monolens::Error, "Unexpected missing strategy `#{strategy}`"
80
+ raise Monolens::Error, "Unexpected on_missing strategy `#{strategy}`"
43
81
  end
44
82
  end
45
83
  private :on_missing
@@ -30,7 +30,7 @@ module Monolens
30
30
 
31
31
  def on_missing(result, attr, world)
32
32
  strategy = option(:on_missing, :fail)
33
- case strategy.to_sym
33
+ case strategy&.to_sym
34
34
  when :fail
35
35
  fail!("Expected `#{attr}` to be defined", world)
36
36
  when :null
@@ -1,5 +1,10 @@
1
1
  module Monolens
2
2
  module Object
3
+ def extend(options)
4
+ Extend.new(options)
5
+ end
6
+ module_function :extend
7
+
3
8
  def rename(parts)
4
9
  Rename.new(parts)
5
10
  end
@@ -33,3 +38,4 @@ require_relative 'object/transform'
33
38
  require_relative 'object/keys'
34
39
  require_relative 'object/values'
35
40
  require_relative 'object/select'
41
+ require_relative 'object/extend'
@@ -1,8 +1,8 @@
1
1
  module Monolens
2
2
  module Version
3
3
  MAJOR = 0
4
- MINOR = 3
5
- TINY = 0
4
+ MINOR = 5
5
+ TINY = 1
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
8
8
  end
data/lib/monolens.rb CHANGED
@@ -5,6 +5,7 @@ module Monolens
5
5
  class << self
6
6
  require_relative 'monolens/version'
7
7
  require_relative 'monolens/error'
8
+ require_relative 'monolens/error_handler'
8
9
  require_relative 'monolens/lens'
9
10
 
10
11
  def define_namespace(name, impl_module)
@@ -43,6 +44,10 @@ module Monolens
43
44
  end
44
45
  private :chain
45
46
 
47
+ def file_lens(arg)
48
+ File.new(arg)
49
+ end
50
+
46
51
  def leaf_lens(arg)
47
52
  namespace_name, lens_name = arg.to_s.split('.')
48
53
  factor_lens(namespace_name, lens_name, {})
@@ -50,6 +55,7 @@ module Monolens
50
55
  private :leaf_lens
51
56
 
52
57
  def hash_lens(arg)
58
+ return file_lens(arg) if arg['version'] || arg[:version]
53
59
  raise "Invalid lens #{arg}" unless arg.size == 1
54
60
 
55
61
  name, options = arg.to_a.first
@@ -5,6 +5,11 @@ describe Monolens, 'coerce.date' do
5
5
  Monolens.lens('coerce.date' => { formats: ['%d/%m/%Y'] })
6
6
  end
7
7
 
8
+ it 'returns Date objects unchanged (idempotency)' do
9
+ input = Date.today
10
+ expect(subject.call(input)).to be(input)
11
+ end
12
+
8
13
  it 'coerces valid dates' do
9
14
  expect(subject.call('11/12/2022')).to eql(Date.parse('2022-12-11'))
10
15
  end
@@ -2,11 +2,49 @@ require 'spec_helper'
2
2
 
3
3
  describe Monolens, 'coerce.datetime' do
4
4
  subject do
5
- Monolens.lens('coerce.datetime' => { formats: ['%d/%m/%Y %H:%M'] })
5
+ Monolens.lens('coerce.datetime' => {
6
+ }.merge(options))
6
7
  end
7
8
 
8
- it 'coerces valid date times' do
9
- expect(subject.call('11/12/2022 17:38')).to eql(DateTime.parse('2022-12-11 17:38'))
9
+ let(:options) do
10
+ {}
11
+ end
12
+
13
+ it 'returns DateTime objects unchanged (idempotency)' do
14
+ input = DateTime.now
15
+ expect(subject.call(input)).to be(input)
16
+ end
17
+
18
+ describe 'support for formats' do
19
+ let(:options) do
20
+ { formats: ['%d/%m/%Y %H:%M'] }
21
+ end
22
+
23
+ it 'coerces valid date times' do
24
+ expect(subject.call('11/12/2022 17:38')).to eql(DateTime.parse('2022-12-11 17:38'))
25
+ end
26
+ end
27
+
28
+ describe 'support for a timezone' do
29
+ let(:options) do
30
+ { parser: timezone }
31
+ end
32
+
33
+ let(:now) do
34
+ ::DateTime.now
35
+ end
36
+
37
+ let(:timezone) do
38
+ Object.new
39
+ end
40
+
41
+ before do
42
+ expect(timezone).to receive(:parse).and_return(now)
43
+ end
44
+
45
+ it 'uses it to parse' do
46
+ expect(subject.call('2022-01-01')).to be(now)
47
+ end
10
48
  end
11
49
 
12
50
  describe 'error handling' do