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,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