tabulard 0.2.0

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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/README.md +43 -0
  4. data/VERSION +1 -0
  5. data/lib/sheetah/attribute.rb +60 -0
  6. data/lib/sheetah/attribute_types/composite.rb +57 -0
  7. data/lib/sheetah/attribute_types/scalar.rb +58 -0
  8. data/lib/sheetah/attribute_types/value.rb +62 -0
  9. data/lib/sheetah/attribute_types/value.rb.orig +68 -0
  10. data/lib/sheetah/attribute_types.rb +49 -0
  11. data/lib/sheetah/backends/csv.rb +92 -0
  12. data/lib/sheetah/backends/wrapper.rb +57 -0
  13. data/lib/sheetah/backends/xlsx.rb +80 -0
  14. data/lib/sheetah/backends.rb +11 -0
  15. data/lib/sheetah/column.rb +31 -0
  16. data/lib/sheetah/errors/error.rb +8 -0
  17. data/lib/sheetah/errors/spec_error.rb +10 -0
  18. data/lib/sheetah/errors/type_error.rb +10 -0
  19. data/lib/sheetah/frozen.rb +9 -0
  20. data/lib/sheetah/headers.rb +96 -0
  21. data/lib/sheetah/messaging/config.rb +19 -0
  22. data/lib/sheetah/messaging/constants.rb +17 -0
  23. data/lib/sheetah/messaging/message.rb +70 -0
  24. data/lib/sheetah/messaging/message_variant.rb +47 -0
  25. data/lib/sheetah/messaging/messages/cleaned_string.rb +18 -0
  26. data/lib/sheetah/messaging/messages/duplicated_header.rb +21 -0
  27. data/lib/sheetah/messaging/messages/invalid_header.rb +21 -0
  28. data/lib/sheetah/messaging/messages/missing_column.rb +21 -0
  29. data/lib/sheetah/messaging/messages/must_be_array.rb +18 -0
  30. data/lib/sheetah/messaging/messages/must_be_boolsy.rb +21 -0
  31. data/lib/sheetah/messaging/messages/must_be_date.rb +21 -0
  32. data/lib/sheetah/messaging/messages/must_be_email.rb +21 -0
  33. data/lib/sheetah/messaging/messages/must_be_string.rb +18 -0
  34. data/lib/sheetah/messaging/messages/must_exist.rb +18 -0
  35. data/lib/sheetah/messaging/messages/sheet_error.rb +18 -0
  36. data/lib/sheetah/messaging/messenger.rb +133 -0
  37. data/lib/sheetah/messaging/validations/base_validator.rb +43 -0
  38. data/lib/sheetah/messaging/validations/dsl.rb +31 -0
  39. data/lib/sheetah/messaging/validations/invalid_message.rb +12 -0
  40. data/lib/sheetah/messaging/validations/mixins.rb +57 -0
  41. data/lib/sheetah/messaging/validations.rb +35 -0
  42. data/lib/sheetah/messaging.rb +22 -0
  43. data/lib/sheetah/row_processor.rb +41 -0
  44. data/lib/sheetah/row_processor_result.rb +20 -0
  45. data/lib/sheetah/row_value_builder.rb +53 -0
  46. data/lib/sheetah/sheet/col_converter.rb +62 -0
  47. data/lib/sheetah/sheet.rb +107 -0
  48. data/lib/sheetah/sheet_processor.rb +61 -0
  49. data/lib/sheetah/sheet_processor_result.rb +18 -0
  50. data/lib/sheetah/specification.rb +30 -0
  51. data/lib/sheetah/template.rb +85 -0
  52. data/lib/sheetah/template_config.rb +35 -0
  53. data/lib/sheetah/types/cast.rb +20 -0
  54. data/lib/sheetah/types/cast_chain.rb +49 -0
  55. data/lib/sheetah/types/composites/array.rb +16 -0
  56. data/lib/sheetah/types/composites/array_compact.rb +13 -0
  57. data/lib/sheetah/types/composites/composite.rb +32 -0
  58. data/lib/sheetah/types/container.rb +81 -0
  59. data/lib/sheetah/types/scalars/boolsy.rb +12 -0
  60. data/lib/sheetah/types/scalars/boolsy_cast.rb +35 -0
  61. data/lib/sheetah/types/scalars/date_string.rb +12 -0
  62. data/lib/sheetah/types/scalars/date_string_cast.rb +43 -0
  63. data/lib/sheetah/types/scalars/email.rb +12 -0
  64. data/lib/sheetah/types/scalars/email_cast.rb +28 -0
  65. data/lib/sheetah/types/scalars/scalar.rb +29 -0
  66. data/lib/sheetah/types/scalars/scalar_cast.rb +49 -0
  67. data/lib/sheetah/types/scalars/string.rb +18 -0
  68. data/lib/sheetah/types/type.rb +103 -0
  69. data/lib/sheetah/utils/cell_string_cleaner.rb +29 -0
  70. data/lib/sheetah/utils/monadic_result.rb +174 -0
  71. data/lib/sheetah.rb +31 -0
  72. metadata +118 -0
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sheet/col_converter"
4
+ require_relative "errors/error"
5
+ require_relative "messaging/messages/sheet_error"
6
+ require_relative "utils/monadic_result"
7
+
8
+ module Sheetah
9
+ module Sheet
10
+ def self.included(mod)
11
+ mod.extend(ClassMethods)
12
+ end
13
+
14
+ def self.col2int(...)
15
+ COL_CONVERTER.col2int(...)
16
+ end
17
+
18
+ def self.int2col(...)
19
+ COL_CONVERTER.int2col(...)
20
+ end
21
+
22
+ module ClassMethods
23
+ def open(*args, **opts)
24
+ handle_sheet_error do
25
+ sheet = new(*args, **opts)
26
+ next sheet unless block_given?
27
+
28
+ begin
29
+ yield sheet
30
+ ensure
31
+ sheet.close
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def handle_sheet_error
39
+ Utils::MonadicResult::Success.new(yield)
40
+ rescue Error => e
41
+ Utils::MonadicResult::Failure.new(e)
42
+ end
43
+ end
44
+
45
+ class Error < Errors::Error
46
+ def to_message
47
+ Messaging::Messages::SheetError.new
48
+ end
49
+ end
50
+
51
+ class Header
52
+ def initialize(col:, value:)
53
+ @col = col
54
+ @value = value
55
+ end
56
+
57
+ attr_reader :col, :value
58
+
59
+ def ==(other)
60
+ other.is_a?(self.class) && col == other.col && value == other.value
61
+ end
62
+
63
+ def row_value_index
64
+ Sheet.col2int(col) - 1
65
+ end
66
+ end
67
+
68
+ class Row
69
+ def initialize(row:, value:)
70
+ @row = row
71
+ @value = value
72
+ end
73
+
74
+ attr_reader :row, :value
75
+
76
+ def ==(other)
77
+ other.is_a?(self.class) && row == other.row && value == other.value
78
+ end
79
+ end
80
+
81
+ class Cell
82
+ def initialize(row:, col:, value:)
83
+ @row = row
84
+ @col = col
85
+ @value = value
86
+ end
87
+
88
+ attr_reader :row, :col, :value
89
+
90
+ def ==(other)
91
+ other.is_a?(self.class) && row == other.row && col == other.col && value == other.value
92
+ end
93
+ end
94
+
95
+ def each_header
96
+ raise NoMethodError, "You must implement #{self.class}#each_header => self"
97
+ end
98
+
99
+ def each_row
100
+ raise NoMethodError, "You must implement #{self.class}#each_row => self"
101
+ end
102
+
103
+ def close
104
+ raise NoMethodError, "You must implement #{self.class}#close => nil"
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "backends"
4
+ require_relative "headers"
5
+ require_relative "messaging"
6
+ require_relative "row_processor"
7
+ require_relative "sheet"
8
+ require_relative "sheet_processor_result"
9
+ require_relative "utils/monadic_result"
10
+
11
+ module Sheetah
12
+ class SheetProcessor
13
+ include Utils::MonadicResult
14
+
15
+ def initialize(specification)
16
+ @specification = specification
17
+ end
18
+
19
+ def call(*args, **opts)
20
+ messenger = Messaging::Messenger.new
21
+
22
+ result = Do() do
23
+ Backends.open(*args, **opts) do |sheet|
24
+ row_processor = build_row_processor(sheet, messenger)
25
+
26
+ sheet.each_row do |row|
27
+ yield row_processor.call(row)
28
+ end
29
+ end
30
+ end
31
+
32
+ handle_result(result, messenger)
33
+ end
34
+
35
+ private
36
+
37
+ def parse_headers(sheet, messenger)
38
+ headers = Headers.new(specification: @specification, messenger: messenger)
39
+
40
+ sheet.each_header do |header|
41
+ headers.add(header)
42
+ end
43
+
44
+ headers.result
45
+ end
46
+
47
+ def build_row_processor(sheet, messenger)
48
+ headers = parse_headers(sheet, messenger).unwrap
49
+
50
+ RowProcessor.new(headers: headers, messenger: messenger)
51
+ end
52
+
53
+ def handle_result(result, messenger)
54
+ result.or do |failure|
55
+ messenger.error(failure.to_message) if failure.respond_to?(:to_message)
56
+ end
57
+
58
+ SheetProcessorResult.new(result: result.discard, messages: messenger.messages)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sheetah
4
+ class SheetProcessorResult
5
+ def initialize(result:, messages: [])
6
+ @result = result
7
+ @messages = messages
8
+ end
9
+
10
+ attr_reader :result, :messages
11
+
12
+ def ==(other)
13
+ other.is_a?(self.class) &&
14
+ result == other.result &&
15
+ messages == other.messages
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sheetah
4
+ class Specification
5
+ def initialize(columns:, ignore_unspecified_columns: false)
6
+ @columns = columns
7
+ @ignore_unspecified_columns = ignore_unspecified_columns
8
+ end
9
+
10
+ def get(header)
11
+ return if header.nil?
12
+
13
+ @columns.find do |column|
14
+ column.header_pattern.match?(header)
15
+ end
16
+ end
17
+
18
+ def required_columns
19
+ @columns.select(&:required?)
20
+ end
21
+
22
+ def optional_columns
23
+ @columns.reject(&:required?)
24
+ end
25
+
26
+ def ignore_unspecified_columns?
27
+ @ignore_unspecified_columns
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "attribute"
5
+ require_relative "specification"
6
+ require_relative "errors/spec_error"
7
+
8
+ module Sheetah
9
+ # A {Template} represents the abstract structure of a tabular document.
10
+ #
11
+ # The main component of the structure is the object obtained by processing a
12
+ # row. A template therefore specifies all possible attributes of that object
13
+ # as a list of (key, abstract type) pairs.
14
+ #
15
+ # Each attribute will eventually be compiled into as many concrete columns as
16
+ # necessary with the help of a {TemplateConfig config} to produce a
17
+ # {Specification specification}.
18
+ #
19
+ # In other words, a {Template} specifies the structure of the processing
20
+ # result (its attributes), whereas a {Specification} specifies the columns
21
+ # that may be involved into building the processing result.
22
+ #
23
+ # {Attribute Attributes} may either be _composite_ (their value is a
24
+ # composition of multiple values) or _scalar_ (their value is a single
25
+ # value). Scalar attributes will thus produce a single column in the
26
+ # specification, and composite attributes will produce as many columns as
27
+ # required by the number of scalar values they hold.
28
+ class Template
29
+ def self.build(attributes:, **kwargs)
30
+ attributes = attributes.map { |attribute| Attribute.build(**attribute) }
31
+ attributes.freeze
32
+
33
+ template = new(attributes: attributes, **kwargs)
34
+ template.freeze
35
+ end
36
+
37
+ def initialize(attributes:, ignore_unspecified_columns: false)
38
+ ensure_attributes_unicity(attributes)
39
+
40
+ @attributes = attributes
41
+ @ignore_unspecified_columns = ignore_unspecified_columns
42
+ end
43
+
44
+ def apply(config)
45
+ columns = []
46
+
47
+ attributes.each do |attribute|
48
+ attribute.each_column(config) do |column|
49
+ columns << column.freeze
50
+ end
51
+ end
52
+
53
+ specification = Specification.new(
54
+ columns: columns.freeze,
55
+ ignore_unspecified_columns: ignore_unspecified_columns
56
+ )
57
+
58
+ specification.freeze
59
+ end
60
+
61
+ def ==(other)
62
+ other.is_a?(self.class) &&
63
+ attributes == other.attributes &&
64
+ ignore_unspecified_columns == other.ignore_unspecified_columns
65
+ end
66
+
67
+ protected
68
+
69
+ attr_reader :attributes, :ignore_unspecified_columns
70
+
71
+ private
72
+
73
+ def ensure_attributes_unicity(attributes)
74
+ keys = Set.new
75
+
76
+ duplicate = attributes.find do |attribute|
77
+ !keys.add?(attribute.key)
78
+ end
79
+
80
+ return unless duplicate
81
+
82
+ raise Errors::SpecError, "Duplicated key: #{duplicate.key.inspect}"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "types/container"
4
+
5
+ module Sheetah
6
+ class TemplateConfig
7
+ def initialize(types: Types::Container.new)
8
+ @types = types
9
+ end
10
+
11
+ attr_reader :types
12
+
13
+ # Given an attribute key and a possibily-nil column index, return the header and header pattern
14
+ # for that column.
15
+ #
16
+ # The return value should be an array with two items:
17
+ #
18
+ # 1. The first item is the header, as a String.
19
+ # 2. The second item is the header pattern, and should respond to `#match?` with a boolean
20
+ # value. Instances of Regexp will obviously do, but the requirement is really about the
21
+ # `#match?` method.
22
+ #
23
+ # @param key [Symbol, String]
24
+ # @param index [Integer, nil]
25
+ # @return [Array(String, #match?)]
26
+ def header(key, index)
27
+ header = key.to_s.capitalize
28
+ header = "#{header} #{index + 1}" if index
29
+
30
+ pattern = /^#{Regexp.escape(header)}$/i
31
+
32
+ [header, pattern]
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sheetah
4
+ module Types
5
+ # @private
6
+ module Cast
7
+ def ==(other)
8
+ other.is_a?(self.class) && other.config == config
9
+ end
10
+
11
+ protected
12
+
13
+ def config
14
+ instance_variables.each_with_object({}) do |ivar, acc|
15
+ acc[ivar] = instance_variable_get(ivar)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../utils/monadic_result"
4
+
5
+ module Sheetah
6
+ module Types
7
+ class CastChain
8
+ include Utils::MonadicResult
9
+
10
+ def initialize(casts = [])
11
+ @casts = casts
12
+ end
13
+
14
+ attr_reader :casts
15
+
16
+ def prepend(cast)
17
+ @casts.unshift(cast)
18
+ self
19
+ end
20
+
21
+ def append(cast)
22
+ @casts.push(cast)
23
+ self
24
+ end
25
+
26
+ def freeze
27
+ @casts.each(&:freeze)
28
+ @casts.freeze
29
+ super
30
+ end
31
+
32
+ def call(value, messenger)
33
+ failure = catch(:failure) do
34
+ success = catch(:success) do
35
+ @casts.reduce(value) do |prev_value, cast|
36
+ cast.call(prev_value, messenger)
37
+ end
38
+ end
39
+
40
+ return Success(success)
41
+ end
42
+
43
+ messenger.error(failure) if failure
44
+
45
+ Failure()
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "composite"
4
+ require_relative "../../messaging/messages/must_be_array"
5
+
6
+ module Sheetah
7
+ module Types
8
+ module Composites
9
+ Array = Composite.cast do |value, _messenger|
10
+ throw :failure, Messaging::Messages::MustBeArray.new unless value.is_a?(::Array)
11
+
12
+ value
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "array"
4
+
5
+ module Sheetah
6
+ module Types
7
+ module Composites
8
+ ArrayCompact = Array.cast do |value, _messenger|
9
+ value.compact
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors/type_error"
4
+ require_relative "../type"
5
+
6
+ module Sheetah
7
+ module Types
8
+ module Composites
9
+ class Composite < Type
10
+ def initialize(types, **opts)
11
+ super(**opts)
12
+
13
+ @types = types
14
+ end
15
+
16
+ def composite?
17
+ true
18
+ end
19
+
20
+ def scalar(index, value, messenger)
21
+ if (type = @types[index])
22
+ type.scalar(nil, value, messenger)
23
+ else
24
+ raise Errors::TypeError, "Invalid index: #{index.inspect}"
25
+ end
26
+ end
27
+
28
+ alias composite cast
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors/type_error"
4
+
5
+ require_relative "scalars/scalar"
6
+ require_relative "scalars/string"
7
+ require_relative "scalars/email"
8
+ require_relative "scalars/boolsy"
9
+ require_relative "scalars/date_string"
10
+ require_relative "composites/array"
11
+ require_relative "composites/array_compact"
12
+
13
+ module Sheetah
14
+ module Types
15
+ class Container
16
+ scalar = Scalars::Scalar.new!
17
+ string = Scalars::String.new!
18
+ email = Scalars::Email.new!
19
+ boolsy = Scalars::Boolsy.new!
20
+ date_string = Scalars::DateString.new!
21
+
22
+ DEFAULTS = {
23
+ scalars: {
24
+ scalar: -> { scalar },
25
+ string: -> { string },
26
+ email: -> { email },
27
+ boolsy: -> { boolsy },
28
+ date_string: -> { date_string },
29
+ }.freeze,
30
+ composites: {
31
+ array: ->(types) { Composites::Array.new!(types) },
32
+ array_compact: ->(types) { Composites::ArrayCompact.new!(types) },
33
+ }.freeze,
34
+ }.freeze
35
+
36
+ def initialize(scalars: nil, composites: nil, defaults: DEFAULTS)
37
+ @scalars =
38
+ (scalars ? defaults[:scalars].merge(scalars) : defaults[:scalars]).freeze
39
+
40
+ @composites =
41
+ (composites ? defaults[:composites].merge(composites) : defaults[:composites]).freeze
42
+ end
43
+
44
+ def scalars
45
+ @scalars.keys
46
+ end
47
+
48
+ def composites
49
+ @composites.keys
50
+ end
51
+
52
+ def scalar(scalar_name)
53
+ builder = fetch_scalar_builder(scalar_name)
54
+
55
+ builder.call
56
+ end
57
+
58
+ def composite(composite_name, scalar_names)
59
+ builder = fetch_composite_builder(composite_name)
60
+
61
+ scalars = scalar_names.map { |scalar_name| scalar(scalar_name) }
62
+
63
+ builder.call(scalars)
64
+ end
65
+
66
+ private
67
+
68
+ def fetch_scalar_builder(type)
69
+ @scalars.fetch(type) do
70
+ raise Errors::TypeError, "Invalid scalar type: #{type.inspect}"
71
+ end
72
+ end
73
+
74
+ def fetch_composite_builder(type)
75
+ @composites.fetch(type) do
76
+ raise Errors::TypeError, "Invalid composite type: #{type.inspect}"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scalar"
4
+ require_relative "boolsy_cast"
5
+
6
+ module Sheetah
7
+ module Types
8
+ module Scalars
9
+ Boolsy = Scalar.cast(BoolsyCast)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../messaging/messages/must_be_boolsy"
4
+ require_relative "../cast"
5
+
6
+ module Sheetah
7
+ module Types
8
+ module Scalars
9
+ class BoolsyCast
10
+ include Cast
11
+
12
+ TRUTHY = [].freeze
13
+ FALSY = [].freeze
14
+ private_constant :TRUTHY, :FALSY
15
+
16
+ def initialize(truthy: TRUTHY, falsy: FALSY, **)
17
+ @truthy = truthy
18
+ @falsy = falsy
19
+ end
20
+
21
+ def call(value, _messenger)
22
+ if @truthy.include?(value)
23
+ true
24
+ elsif @falsy.include?(value)
25
+ false
26
+ else
27
+ throw :failure, Messaging::Messages::MustBeBoolsy.new(
28
+ code_data: { value: value.inspect }
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scalar"
4
+ require_relative "date_string_cast"
5
+
6
+ module Sheetah
7
+ module Types
8
+ module Scalars
9
+ DateString = Scalar.cast(DateStringCast)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "../../messaging/messages/must_be_date"
5
+ require_relative "../cast"
6
+
7
+ module Sheetah
8
+ module Types
9
+ module Scalars
10
+ class DateStringCast
11
+ include Cast
12
+
13
+ DATE_FMT = "%Y-%m-%d"
14
+ private_constant :DATE_FMT
15
+
16
+ def initialize(date_fmt: DATE_FMT, accept_date: true, **)
17
+ @date_fmt = date_fmt
18
+ @accept_date = accept_date
19
+ end
20
+
21
+ def call(value, _messenger)
22
+ case value
23
+ when ::Date
24
+ return value if @accept_date
25
+ when ::String
26
+ date = parse_date_string(value)
27
+ return date if date
28
+ end
29
+
30
+ throw :failure, Messaging::Messages::MustBeDate.new(code_data: { format: @date_fmt })
31
+ end
32
+
33
+ private
34
+
35
+ def parse_date_string(value)
36
+ ::Date.strptime(value, @date_fmt)
37
+ rescue ::TypeError, ::Date::Error
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "string"
4
+ require_relative "email_cast"
5
+
6
+ module Sheetah
7
+ module Types
8
+ module Scalars
9
+ Email = String.cast(EmailCast)
10
+ end
11
+ end
12
+ end