tabulard 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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