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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ require_relative "../sheet"
6
+
7
+ module Sheetah
8
+ module Backends
9
+ class Csv
10
+ include Sheet
11
+
12
+ class InvalidCSVError < Error
13
+ end
14
+
15
+ DEFAULTS = {
16
+ row_sep: :auto,
17
+ col_sep: ",",
18
+ quote_char: '"',
19
+ }.freeze
20
+
21
+ private_constant :DEFAULTS
22
+
23
+ def self.defaults
24
+ DEFAULTS
25
+ end
26
+
27
+ def initialize(
28
+ io,
29
+ row_sep: self.class.defaults[:row_sep],
30
+ col_sep: self.class.defaults[:col_sep],
31
+ quote_char: self.class.defaults[:quote_char]
32
+ )
33
+ @csv = CSV.new(
34
+ io,
35
+ row_sep: row_sep,
36
+ col_sep: col_sep,
37
+ quote_char: quote_char
38
+ )
39
+
40
+ @headers = detect_headers(@csv)
41
+ @cols_count = @headers.size
42
+ end
43
+
44
+ def each_header
45
+ return to_enum(:each_header) { @cols_count } unless block_given?
46
+
47
+ @headers.each_with_index do |header, col_idx|
48
+ col = Sheet.int2col(col_idx + 1)
49
+
50
+ yield Header.new(col: col, value: header)
51
+ end
52
+
53
+ self
54
+ end
55
+
56
+ def each_row
57
+ return to_enum(:each_row) unless block_given?
58
+
59
+ handle_malformed_csv do
60
+ @csv.each.with_index(1) do |raw, row|
61
+ value = Array.new(@cols_count) do |col_idx|
62
+ col = Sheet.int2col(col_idx + 1)
63
+
64
+ Cell.new(row: row, col: col, value: raw[col_idx])
65
+ end
66
+
67
+ yield Row.new(row: row, value: value)
68
+ end
69
+ end
70
+
71
+ self
72
+ end
73
+
74
+ def close
75
+ # Do nothing: this backend isn't responsible for opening the IO, and therefore it is not
76
+ # responsible for closing it either.
77
+ end
78
+
79
+ private
80
+
81
+ def handle_malformed_csv
82
+ yield
83
+ rescue CSV::MalformedCSVError
84
+ raise InvalidCSVError
85
+ end
86
+
87
+ def detect_headers(csv)
88
+ handle_malformed_csv { csv.shift } || []
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sheet"
4
+
5
+ module Sheetah
6
+ module Backends
7
+ class Wrapper
8
+ include Sheet
9
+
10
+ def initialize(table)
11
+ raise Error if table.nil?
12
+
13
+ @table = table
14
+
15
+ if (table_size = @table.size).positive?
16
+ @headers = @table[0]
17
+ @rows_count = table_size - 1
18
+ @cols_count = @headers.size
19
+ else
20
+ @headers = []
21
+ @rows_count = 0
22
+ @cols_count = 0
23
+ end
24
+ end
25
+
26
+ def each_header
27
+ return to_enum(:each_header) { @cols_count } unless block_given?
28
+
29
+ 1.upto(@cols_count) do |col|
30
+ yield Header.new(col: Sheet.int2col(col), value: @headers[col - 1])
31
+ end
32
+
33
+ self
34
+ end
35
+
36
+ def each_row
37
+ return to_enum(:each_row) unless block_given?
38
+
39
+ 1.upto(@rows_count) do |row|
40
+ raw = @table[row]
41
+
42
+ value = Array.new(@cols_count) do |col_idx|
43
+ Cell.new(row: row, col: Sheet.int2col(col_idx + 1), value: raw[col_idx])
44
+ end
45
+
46
+ yield Row.new(row: row, value: value)
47
+ end
48
+
49
+ self
50
+ end
51
+
52
+ def close
53
+ # nothing to do here
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # NOTE: As reference:
4
+ # - {Roo::Excelx::Cell#cell_value} => the "raw" value before Excel's typecasts
5
+ # - {Roo::Excelx::Cell#value} => the "user" value, after Excel's typecasts
6
+ require "roo"
7
+
8
+ require_relative "../sheet"
9
+
10
+ module Sheetah
11
+ module Backends
12
+ class Xlsx
13
+ include Sheet
14
+
15
+ def initialize(path)
16
+ raise Error if path.nil?
17
+
18
+ @roo = Roo::Excelx.new(path)
19
+ @is_empty = worksheet.first_row.nil?
20
+ @headers = detect_headers
21
+ @cols_count = @headers.size
22
+ end
23
+
24
+ def each_header
25
+ return to_enum(:each_header) { @cols_count } unless block_given?
26
+
27
+ @headers.each_with_index do |header, col_idx|
28
+ col = Sheet.int2col(col_idx + 1)
29
+
30
+ yield Header.new(col: col, value: header)
31
+ end
32
+
33
+ self
34
+ end
35
+
36
+ def each_row
37
+ return to_enum(:each_row) unless block_given?
38
+
39
+ return if @is_empty
40
+
41
+ first_row = 2
42
+ last_row = worksheet.last_row
43
+ row = 0
44
+
45
+ first_row.upto(last_row) do |cursor|
46
+ raw = worksheet.row(cursor)
47
+ row += 1
48
+
49
+ value = Array.new(@cols_count) do |col_idx|
50
+ col = Sheet.int2col(col_idx + 1)
51
+
52
+ Cell.new(row: row, col: col, value: raw[col_idx])
53
+ end
54
+
55
+ yield Row.new(row: row, value: value)
56
+ end
57
+
58
+ self
59
+ end
60
+
61
+ def close
62
+ @roo.close
63
+
64
+ nil
65
+ end
66
+
67
+ private
68
+
69
+ def worksheet
70
+ @worksheet ||= @roo.sheet_for(@roo.default_sheet)
71
+ end
72
+
73
+ def detect_headers
74
+ return [] if @is_empty
75
+
76
+ worksheet.row(1) || []
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sheetah
4
+ module Backends
5
+ class << self
6
+ def open(*args, backend:, **opts, &block)
7
+ backend.open(*args, **opts, &block)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sheetah
4
+ class Column
5
+ def initialize(
6
+ key:,
7
+ type:,
8
+ index:,
9
+ header:,
10
+ header_pattern:,
11
+ required:
12
+ )
13
+ @key = key
14
+ @type = type
15
+ @index = index
16
+ @header = header
17
+ @header_pattern = header_pattern
18
+ @required = required
19
+ end
20
+
21
+ attr_reader :key,
22
+ :type,
23
+ :index,
24
+ :header,
25
+ :header_pattern
26
+
27
+ def required?
28
+ @required
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sheetah
4
+ module Errors
5
+ class Error < StandardError
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Sheetah
6
+ module Errors
7
+ class SpecError < Error
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Sheetah
6
+ module Errors
7
+ class TypeError < Error
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nocov: #
4
+
5
+ require "sheetah"
6
+
7
+ Sheetah::Types::Type.all(&:freeze)
8
+
9
+ # :nocov: #
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "messaging/messages/invalid_header"
5
+ require_relative "messaging/messages/duplicated_header"
6
+ require_relative "messaging/messages/missing_column"
7
+
8
+ module Sheetah
9
+ class Headers
10
+ include Utils::MonadicResult
11
+
12
+ class Header
13
+ def initialize(sheet_header, spec_column)
14
+ @header = sheet_header
15
+ @column = spec_column
16
+ end
17
+
18
+ attr_reader :header, :column
19
+
20
+ def ==(other)
21
+ other.is_a?(self.class) &&
22
+ header == other.header &&
23
+ column == other.column
24
+ end
25
+
26
+ def row_value_index
27
+ header.row_value_index
28
+ end
29
+ end
30
+
31
+ def initialize(specification:, messenger:)
32
+ @specification = specification
33
+ @messenger = messenger
34
+ @headers = []
35
+ @columns = Set.new
36
+ @failure = false
37
+ end
38
+
39
+ def add(header)
40
+ @messenger.scope_col!(header.col) do
41
+ column = @specification.get(header.value)
42
+
43
+ return unless add_ensure_column_is_specified(header, column)
44
+ return unless add_ensure_column_is_unique(header, column)
45
+
46
+ @headers << Header.new(header, column)
47
+ end
48
+ end
49
+
50
+ def result
51
+ missing_columns = @specification.required_columns - @columns.to_a
52
+
53
+ unless missing_columns.empty?
54
+ @failure = true
55
+
56
+ missing_columns.each do |column|
57
+ @messenger.error(
58
+ Messaging::Messages::MissingColumn.new(code_data: { value: column.header })
59
+ )
60
+ end
61
+ end
62
+
63
+ if @failure
64
+ Failure()
65
+ else
66
+ Success(@headers)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def add_ensure_column_is_specified(header, column)
73
+ return true unless column.nil?
74
+
75
+ unless @specification.ignore_unspecified_columns?
76
+ @failure = true
77
+ @messenger.error(
78
+ Messaging::Messages::InvalidHeader.new(code_data: { value: header.value })
79
+ )
80
+ end
81
+
82
+ false
83
+ end
84
+
85
+ def add_ensure_column_is_unique(header, column)
86
+ return true if @columns.add?(column)
87
+
88
+ @failure = true
89
+ @messenger.error(
90
+ Messaging::Messages::DuplicatedHeader.new(code_data: { value: header.value })
91
+ )
92
+
93
+ false
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sheetah
4
+ module Messaging
5
+ class Config
6
+ def initialize(validate_messages: default_validate_messages)
7
+ @validate_messages = validate_messages
8
+ end
9
+
10
+ attr_accessor :validate_messages
11
+
12
+ private
13
+
14
+ def default_validate_messages
15
+ ENV["SHEETAH_MESSAGING_VALIDATE_MESSAGES"] != "false"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sheetah
4
+ module Messaging
5
+ module SCOPES
6
+ SHEET = "SHEET"
7
+ ROW = "ROW"
8
+ COL = "COL"
9
+ CELL = "CELL"
10
+ end
11
+
12
+ module SEVERITIES
13
+ WARN = "WARN"
14
+ ERROR = "ERROR"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+ require_relative "validations"
5
+
6
+ module Sheetah
7
+ module Messaging
8
+ class Message
9
+ include Validations
10
+
11
+ def initialize(
12
+ code:,
13
+ code_data: nil,
14
+ scope: SCOPES::SHEET,
15
+ scope_data: nil,
16
+ severity: SEVERITIES::WARN
17
+ )
18
+ @code = code
19
+ @code_data = code_data
20
+ @scope = scope
21
+ @scope_data = scope_data
22
+ @severity = severity
23
+ end
24
+
25
+ attr_accessor(
26
+ :code,
27
+ :code_data,
28
+ :scope,
29
+ :scope_data,
30
+ :severity
31
+ )
32
+
33
+ def ==(other)
34
+ other.is_a?(self.class) &&
35
+ code == other.code &&
36
+ code_data == other.code_data &&
37
+ scope == other.scope &&
38
+ scope_data == other.scope_data &&
39
+ severity == other.severity
40
+ end
41
+
42
+ def to_s
43
+ parts = [scoping_to_s, "#{severity}: #{code}", code_data]
44
+ parts.compact!
45
+ parts.join(" ")
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ code: code,
51
+ code_data: code_data,
52
+ scope: scope,
53
+ scope_data: scope_data,
54
+ severity: severity,
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def scoping_to_s
61
+ case scope
62
+ when SCOPES::SHEET then "[#{scope}]"
63
+ when SCOPES::ROW then "[#{scope}: #{scope_data[:row]}]"
64
+ when SCOPES::COL then "[#{scope}: #{scope_data[:col]}]"
65
+ when SCOPES::CELL then "[#{scope}: #{scope_data[:col]}#{scope_data[:row]}]"
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "message"
4
+
5
+ module Sheetah
6
+ module Messaging
7
+ # While a {Message} represents any kind of message, {MessageVariant} represents any subset of
8
+ # messages that share the same code.
9
+ #
10
+ # The code of a message variant can and should be defined at the class level, given that it
11
+ # won't differ among the instances (and a validation is defined to enforce that invariant).
12
+ # {MessageVariant} should be considered an abstract class, and its subclasses should define
13
+ # their own `CODE` constant, which will be read by {.code}.
14
+ #
15
+ # As far as the other methods are concerned, {.code} should be considered the only source of
16
+ # truth when it comes to reading the code assigned to a message variant. The fact that {.code}
17
+ # is actually implemented using a dynamic resolution of the class' `CODE` constant is an
18
+ # implementation detail stemming from the fact that documentation tools such as YARD will
19
+ # highlight constants, as opposed to instance variables of a class for example. Using a constant
20
+ # is therefore meant to provide better documentation, and it should not be relied upon
21
+ # otherwise.
22
+ #
23
+ # @abstract
24
+ class MessageVariant < Message
25
+ # Reads the code assigned to the class (and its instances)
26
+ # @return [String]
27
+ def self.code
28
+ self::CODE
29
+ end
30
+
31
+ # Simplifies the initialization of a variant
32
+ #
33
+ # Contrary to the requirements of {Message#initialize}, {MessageVariant.new} doesn't require
34
+ # the caller to pass the `:code` keyword argument, as it is capable of prodividing it
35
+ # automatically (from reading {.code}).
36
+ def self.new(**opts)
37
+ super(code: code, **opts)
38
+ end
39
+
40
+ def_validator do
41
+ def validate_code(message)
42
+ message.code == message.class.code
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../message_variant"
4
+
5
+ module Sheetah
6
+ module Messaging
7
+ module Messages
8
+ class CleanedString < MessageVariant
9
+ CODE = "cleaned_string"
10
+
11
+ def_validator do
12
+ cell
13
+ nil_code_data
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../message_variant"
4
+
5
+ module Sheetah
6
+ module Messaging
7
+ module Messages
8
+ class DuplicatedHeader < MessageVariant
9
+ CODE = "duplicated_header"
10
+
11
+ def_validator do
12
+ col
13
+
14
+ def validate_code_data(message)
15
+ message.code_data in { value: String }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../message_variant"
4
+
5
+ module Sheetah
6
+ module Messaging
7
+ module Messages
8
+ class InvalidHeader < MessageVariant
9
+ CODE = "invalid_header"
10
+
11
+ def_validator do
12
+ col
13
+
14
+ def validate_code_data(message)
15
+ message.code_data in { value: String }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../message_variant"
4
+
5
+ module Sheetah
6
+ module Messaging
7
+ module Messages
8
+ class MissingColumn < MessageVariant
9
+ CODE = "missing_column"
10
+
11
+ def_validator do
12
+ sheet
13
+
14
+ def validate_code_data(message)
15
+ message.code_data in { value: String }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../message_variant"
4
+
5
+ module Sheetah
6
+ module Messaging
7
+ module Messages
8
+ class MustBeArray < MessageVariant
9
+ CODE = "must_be_array"
10
+
11
+ def_validator do
12
+ cell
13
+ nil_code_data
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../message_variant"
4
+
5
+ module Sheetah
6
+ module Messaging
7
+ module Messages
8
+ class MustBeBoolsy < MessageVariant
9
+ CODE = "must_be_boolsy"
10
+
11
+ def_validator do
12
+ cell
13
+
14
+ def validate_code_data(message)
15
+ message.code_data in { value: String }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end