You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

data_table.rb 19 kB

2 years ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. # frozen_string_literal: true
  2. require 'forwardable'
  3. require 'cucumber/gherkin/data_table_parser'
  4. require 'cucumber/gherkin/formatter/escaping'
  5. require 'cucumber/multiline_argument/data_table/diff_matrices'
  6. module Cucumber
  7. module MultilineArgument
  8. # Step Definitions that match a plain text Step with a multiline argument table
  9. # will receive it as an instance of Table. A Table object holds the data of a
  10. # table parsed from a feature file and lets you access and manipulate the data
  11. # in different ways.
  12. #
  13. # For example:
  14. #
  15. # Given I have:
  16. # | a | b |
  17. # | c | d |
  18. #
  19. # And a matching StepDefinition:
  20. #
  21. # Given /I have:/ do |table|
  22. # data = table.raw
  23. # end
  24. #
  25. # This will store <tt>[['a', 'b'], ['c', 'd']]</tt> in the <tt>data</tt> variable.
  26. #
  27. class DataTable
  28. def self.default_arg_name # :nodoc:
  29. 'table'
  30. end
  31. def describe_to(visitor, *args)
  32. visitor.legacy_table(self, *args)
  33. end
  34. class << self
  35. def from(data)
  36. case data
  37. when Array
  38. from_array(data)
  39. when String
  40. parse(data)
  41. else
  42. raise ArgumentError, 'expected data to be a String or an Array.'
  43. end
  44. end
  45. private
  46. def parse(text)
  47. builder = Builder.new
  48. parser = Cucumber::Gherkin::DataTableParser.new(builder)
  49. parser.parse(text)
  50. from_array(builder.rows)
  51. end
  52. def from_array(data)
  53. new Core::Test::DataTable.new(data)
  54. end
  55. end
  56. class Builder
  57. attr_reader :rows
  58. def initialize
  59. @rows = []
  60. end
  61. def row(row)
  62. @rows << row
  63. end
  64. def eof; end
  65. end
  66. NULL_CONVERSIONS = Hash.new(strict: false, proc: ->(cell_value) { cell_value }).freeze
  67. # @param data [Core::Test::DataTable] the data for the table
  68. # @param conversion_procs [Hash] see map_column
  69. # @param header_mappings [Hash] see map_headers
  70. # @param header_conversion_proc [Proc] see map_headers
  71. def initialize(data, conversion_procs = NULL_CONVERSIONS.dup, header_mappings = {}, header_conversion_proc = nil)
  72. raise ArgumentError, 'data must be a Core::Test::DataTable' unless data.is_a? Core::Test::DataTable
  73. ast_table = data
  74. # Verify that it's square
  75. ast_table.transpose
  76. @cell_matrix = create_cell_matrix(ast_table)
  77. @conversion_procs = conversion_procs
  78. @header_mappings = header_mappings
  79. @header_conversion_proc = header_conversion_proc
  80. @ast_table = ast_table
  81. end
  82. def append_to(array)
  83. array << self
  84. end
  85. def to_step_definition_arg
  86. dup
  87. end
  88. attr_accessor :file
  89. def location
  90. @ast_table.location
  91. end
  92. # Returns a new, transposed table. Example:
  93. #
  94. # | a | 7 | 4 |
  95. # | b | 9 | 2 |
  96. #
  97. # Gets converted into the following:
  98. #
  99. # | a | b |
  100. # | 7 | 9 |
  101. # | 4 | 2 |
  102. #
  103. def transpose
  104. self.class.new(Core::Test::DataTable.new(raw.transpose), @conversion_procs.dup, @header_mappings.dup, @header_conversion_proc)
  105. end
  106. # Converts this table into an Array of Hash where the keys of each
  107. # Hash are the headers in the table. For example, a Table built from
  108. # the following plain text:
  109. #
  110. # | a | b | sum |
  111. # | 2 | 3 | 5 |
  112. # | 7 | 9 | 16 |
  113. #
  114. # Gets converted into the following:
  115. #
  116. # [{'a' => '2', 'b' => '3', 'sum' => '5'}, {'a' => '7', 'b' => '9', 'sum' => '16'}]
  117. #
  118. # Use #map_column to specify how values in a column are converted.
  119. #
  120. def hashes
  121. @hashes ||= build_hashes
  122. end
  123. # Converts this table into an Array of Hashes where the keys are symbols.
  124. # For example, a Table built from the following plain text:
  125. #
  126. # | foo | Bar | Foo Bar |
  127. # | 2 | 3 | 5 |
  128. # | 7 | 9 | 16 |
  129. #
  130. # Gets converted into the following:
  131. #
  132. # [{:foo => '2', :bar => '3', :foo_bar => '5'}, {:foo => '7', :bar => '9', :foo_bar => '16'}]
  133. #
  134. def symbolic_hashes
  135. @symbolic_hashes ||=
  136. hashes.map do |string_hash|
  137. string_hash.transform_keys { |a| symbolize_key(a) }
  138. end
  139. end
  140. # Converts this table into a Hash where the first column is
  141. # used as keys and the second column is used as values
  142. #
  143. # | a | 2 |
  144. # | b | 3 |
  145. #
  146. # Gets converted into the following:
  147. #
  148. # {'a' => '2', 'b' => '3'}
  149. #
  150. # The table must be exactly two columns wide
  151. #
  152. def rows_hash
  153. return @rows_hash if @rows_hash
  154. verify_table_width(2)
  155. @rows_hash = transpose.hashes[0]
  156. end
  157. # Gets the raw data of this table. For example, a Table built from
  158. # the following plain text:
  159. #
  160. # | a | b |
  161. # | c | d |
  162. #
  163. # gets converted into the following:
  164. #
  165. # [['a', 'b'], ['c', 'd']]
  166. #
  167. def raw
  168. cell_matrix.map do |row|
  169. row.map(&:value)
  170. end
  171. end
  172. def column_names # :nodoc:
  173. @column_names ||= cell_matrix[0].map(&:value)
  174. end
  175. def rows
  176. hashes.map do |hash|
  177. hash.values_at *headers
  178. end
  179. end
  180. def each_cells_row(&proc) # :nodoc:
  181. cells_rows.each(&proc)
  182. end
  183. # Matches +pattern+ against the header row of the table.
  184. # This is used especially for argument transforms.
  185. #
  186. # Example:
  187. # | column_1_name | column_2_name |
  188. # | x | y |
  189. #
  190. # table.match(/table:column_1_name,column_2_name/) #=> non-nil
  191. #
  192. # Note: must use 'table:' prefix on match
  193. def match(pattern)
  194. header_to_match = "table:#{headers.join(',')}"
  195. pattern.match(header_to_match)
  196. end
  197. # Returns a new Table where the headers are redefined.
  198. # This makes it possible to use
  199. # prettier and more flexible header names in the features. The
  200. # keys of +mappings+ are Strings or regular expressions
  201. # (anything that responds to #=== will work) that may match
  202. # column headings in the table. The values of +mappings+ are
  203. # desired names for the columns.
  204. #
  205. # Example:
  206. #
  207. # | Phone Number | Address |
  208. # | 123456 | xyz |
  209. # | 345678 | abc |
  210. #
  211. # A StepDefinition receiving this table can then map the columns
  212. # with both Regexp and String:
  213. #
  214. # table.map_headers(/phone( number)?/i => :phone, 'Address' => :address)
  215. # table.hashes
  216. # # => [{:phone => '123456', :address => 'xyz'}, {:phone => '345678', :address => 'abc'}]
  217. #
  218. # You may also pass in a block if you wish to convert all of the headers:
  219. #
  220. # table.map_headers { |header| header.downcase }
  221. # table.hashes.keys
  222. # # => ['phone number', 'address']
  223. #
  224. # When a block is passed in along with a hash then the mappings in the hash take precendence:
  225. #
  226. # table.map_headers('Address' => 'ADDRESS') { |header| header.downcase }
  227. # table.hashes.keys
  228. # # => ['phone number', 'ADDRESS']
  229. #
  230. def map_headers(mappings = {}, &block)
  231. self.class.new(Core::Test::DataTable.new(raw), @conversion_procs.dup, mappings, block)
  232. end
  233. # Returns a new Table with an additional column mapping.
  234. #
  235. # Change how #hashes converts column values. The +column_name+ argument identifies the column
  236. # and +conversion_proc+ performs the conversion for each cell in that column. If +strict+ is
  237. # true, an error will be raised if the column named +column_name+ is not found. If +strict+
  238. # is false, no error will be raised. Example:
  239. #
  240. # Given /^an expense report for (.*) with the following posts:$/ do |table|
  241. # posts_table = posts_table.map_column('amount') { |a| a.to_i }
  242. # posts_table.hashes.each do |post|
  243. # # post['amount'] is a Fixnum, rather than a String
  244. # end
  245. # end
  246. #
  247. def map_column(column_name, strict: true, &conversion_proc)
  248. conversion_procs = @conversion_procs.dup
  249. conversion_procs[column_name.to_s] = { strict: strict, proc: conversion_proc }
  250. self.class.new(Core::Test::DataTable.new(raw), conversion_procs, @header_mappings.dup, @header_conversion_proc)
  251. end
  252. # Compares +other_table+ to self. If +other_table+ contains columns
  253. # and/or rows that are not in self, new columns/rows are added at the
  254. # relevant positions, marking the cells in those rows/columns as
  255. # <tt>surplus</tt>. Likewise, if +other_table+ lacks columns and/or
  256. # rows that are present in self, these are marked as <tt>missing</tt>.
  257. #
  258. # <tt>surplus</tt> and <tt>missing</tt> cells are recognised by formatters
  259. # and displayed so that it's easy to read the differences.
  260. #
  261. # Cells that are different, but <em>look</em> identical (for example the
  262. # boolean true and the string "true") are converted to their Object#inspect
  263. # representation and preceded with (i) - to make it easier to identify
  264. # where the difference actually is.
  265. #
  266. # Since all tables that are passed to StepDefinitions always have String
  267. # objects in their cells, you may want to use #map_column before calling
  268. # #diff!. You can use #map_column on either of the tables.
  269. #
  270. # A Different error is raised if there are missing rows or columns, or
  271. # surplus rows. An error is <em>not</em> raised for surplus columns. An
  272. # error is <em>not</em> raised for misplaced (out of sequence) columns.
  273. # Whether to raise or not raise can be changed by setting values in
  274. # +options+ to true or false:
  275. #
  276. # * <tt>missing_row</tt> : Raise on missing rows (defaults to true)
  277. # * <tt>surplus_row</tt> : Raise on surplus rows (defaults to true)
  278. # * <tt>missing_col</tt> : Raise on missing columns (defaults to true)
  279. # * <tt>surplus_col</tt> : Raise on surplus columns (defaults to false)
  280. # * <tt>misplaced_col</tt> : Raise on misplaced columns (defaults to false)
  281. #
  282. # The +other_table+ argument can be another Table, an Array of Array or
  283. # an Array of Hash (similar to the structure returned by #hashes).
  284. #
  285. # Calling this method is particularly useful in <tt>Then</tt> steps that take
  286. # a Table argument, if you want to compare that table to some actual values.
  287. #
  288. def diff!(other_table, options = {})
  289. other_table = ensure_table(other_table)
  290. other_table.convert_headers!
  291. other_table.convert_columns!
  292. convert_headers!
  293. convert_columns!
  294. DiffMatrices.new(cell_matrix, other_table.cell_matrix, options).call
  295. end
  296. class Different < StandardError
  297. attr_reader :table
  298. def initialize(table)
  299. @table = table
  300. super("Tables were not identical:\n#{table}")
  301. end
  302. end
  303. def to_hash
  304. cells_rows.map { |cells| cells.map(&:value) }
  305. end
  306. def cells_to_hash(cells) # :nodoc:
  307. hash = Hash.new do |hash_inner, key|
  308. hash_inner[key.to_s] if key.is_a?(Symbol)
  309. end
  310. column_names.each_with_index do |column_name, column_index|
  311. hash[column_name] = cells.value(column_index)
  312. end
  313. hash
  314. end
  315. def index(cells) # :nodoc:
  316. cells_rows.index(cells)
  317. end
  318. def verify_column(column_name) # :nodoc:
  319. raise %(The column named "#{column_name}" does not exist) unless raw[0].include?(column_name)
  320. end
  321. def verify_table_width(width) # :nodoc:
  322. raise %(The table must have exactly #{width} columns) unless raw[0].size == width
  323. end
  324. # TODO: remove the below function if it's not actually being used.
  325. # Nothing else in this repo calls it.
  326. def text?(text) # :nodoc:
  327. raw.flatten.compact.detect { |cell_value| cell_value.index(text) }
  328. end
  329. def cells_rows # :nodoc:
  330. @rows ||= cell_matrix.map do |cell_row| # rubocop:disable Naming/MemoizedInstanceVariableName
  331. Cells.new(self, cell_row)
  332. end
  333. end
  334. def headers # :nodoc:
  335. raw.first
  336. end
  337. def header_cell(col) # :nodoc:
  338. cells_rows[0][col]
  339. end
  340. attr_reader :cell_matrix
  341. def col_width(col) # :nodoc:
  342. columns[col].__send__(:width)
  343. end
  344. def to_s(options = {}) # :nodoc:
  345. indentation = options.key?(:indent) ? options[:indent] : 2
  346. prefixes = options.key?(:prefixes) ? options[:prefixes] : TO_S_PREFIXES
  347. DataTablePrinter.new(self, indentation, prefixes).to_s
  348. end
  349. class DataTablePrinter # :nodoc:
  350. include Cucumber::Gherkin::Formatter::Escaping
  351. attr_reader :data_table, :indentation, :prefixes
  352. private :data_table, :indentation, :prefixes
  353. def initialize(data_table, indentation, prefixes)
  354. @data_table = data_table
  355. @indentation = indentation
  356. @prefixes = prefixes
  357. end
  358. def to_s
  359. leading_row = "\n"
  360. end_indentation = indentation - 2
  361. trailing_row = "\n#{' ' * end_indentation}"
  362. table_rows = data_table.cell_matrix.map { |row| format_row(row) }
  363. leading_row + table_rows.join("\n") + trailing_row
  364. end
  365. private
  366. def format_row(row)
  367. row_start = "#{' ' * indentation}| "
  368. row_end = '|'
  369. cells = row.map.with_index do |cell, i|
  370. format_cell(cell, data_table.col_width(i))
  371. end
  372. row_start + cells.join('| ') + row_end
  373. end
  374. def format_cell(cell, col_width)
  375. cell_text = escape_cell(cell.value.to_s)
  376. cell_text_width = cell_text.unpack('U*').length
  377. padded_text = cell_text + (' ' * (col_width - cell_text_width))
  378. prefix = prefixes[cell.status]
  379. "#{prefix}#{padded_text} "
  380. end
  381. end
  382. def columns # :nodoc:
  383. @columns ||= cell_matrix.transpose.map do |cell_row|
  384. Cells.new(self, cell_row)
  385. end
  386. end
  387. def to_json(*args)
  388. raw.to_json(*args)
  389. end
  390. TO_S_PREFIXES = Hash.new(' ')
  391. TO_S_PREFIXES[:comment] = '(+) '
  392. TO_S_PREFIXES[:undefined] = '(-) '
  393. private_constant :TO_S_PREFIXES
  394. protected
  395. def build_hashes
  396. convert_headers!
  397. convert_columns!
  398. cells_rows[1..].map(&:to_hash)
  399. end
  400. def create_cell_matrix(ast_table) # :nodoc:
  401. ast_table.raw.map do |raw_row|
  402. line = begin
  403. raw_row.line
  404. rescue StandardError
  405. -1
  406. end
  407. raw_row.map do |raw_cell|
  408. Cell.new(raw_cell, self, line)
  409. end
  410. end
  411. end
  412. def convert_columns! # :nodoc:
  413. @conversion_procs.each do |column_name, conversion_proc|
  414. verify_column(column_name) if conversion_proc[:strict]
  415. end
  416. cell_matrix.transpose.each do |col|
  417. column_name = col[0].value
  418. conversion_proc = @conversion_procs[column_name][:proc]
  419. col[1..].each do |cell|
  420. cell.value = conversion_proc.call(cell.value)
  421. end
  422. end
  423. end
  424. def convert_headers! # :nodoc:
  425. header_cells = cell_matrix[0]
  426. if @header_conversion_proc
  427. header_values = header_cells.map(&:value) - @header_mappings.keys
  428. @header_mappings = @header_mappings.merge(Hash[*header_values.zip(header_values.map(&@header_conversion_proc)).flatten])
  429. end
  430. @header_mappings.each_pair do |pre, post|
  431. mapped_cells = header_cells.select { |cell| pre.is_a?(Regexp) ? cell.value.match?(pre) : cell.value == pre }
  432. raise "No headers matched #{pre.inspect}" if mapped_cells.empty?
  433. raise "#{mapped_cells.length} headers matched #{pre.inspect}: #{mapped_cells.map(&:value).inspect}" if mapped_cells.length > 1
  434. mapped_cells[0].value = post
  435. @conversion_procs[post] = @conversion_procs.delete(pre) if @conversion_procs.key?(pre)
  436. end
  437. end
  438. def clear_cache! # :nodoc:
  439. @hashes = @rows_hash = @column_names = @rows = @columns = nil
  440. end
  441. def ensure_table(table_or_array) # :nodoc:
  442. return table_or_array if DataTable == table_or_array.class
  443. DataTable.from(table_or_array)
  444. end
  445. def symbolize_key(key)
  446. key.downcase.tr(' ', '_').to_sym
  447. end
  448. # Represents a row of cells or columns of cells
  449. class Cells # :nodoc:
  450. include Enumerable
  451. include Cucumber::Gherkin::Formatter::Escaping
  452. attr_reader :exception
  453. def initialize(table, cells)
  454. @table = table
  455. @cells = cells
  456. end
  457. def accept(visitor)
  458. return if Cucumber.wants_to_quit
  459. each do |cell|
  460. visitor.visit_table_cell(cell)
  461. end
  462. nil
  463. end
  464. # For testing only
  465. def to_sexp # :nodoc:
  466. [:row, line, *@cells.map(&:to_sexp)]
  467. end
  468. def to_hash # :nodoc:
  469. @to_hash ||= @table.cells_to_hash(self)
  470. end
  471. def value(n) # :nodoc:
  472. self[n].value
  473. end
  474. def [](n)
  475. @cells[n]
  476. end
  477. def line
  478. @cells[0].line
  479. end
  480. def dom_id
  481. "row_#{line}"
  482. end
  483. def each(&proc)
  484. @cells.each(&proc)
  485. end
  486. private
  487. def index
  488. @table.index(self)
  489. end
  490. def width
  491. map { |cell| cell.value ? escape_cell(cell.value.to_s).unpack('U*').length : 0 }.max
  492. end
  493. end
  494. class Cell # :nodoc:
  495. attr_reader :line, :table
  496. attr_accessor :status, :value
  497. def initialize(value, table, line)
  498. @value = value
  499. @table = table
  500. @line = line
  501. end
  502. def inspect!
  503. @value = "(i) #{value.inspect}"
  504. end
  505. def ==(other)
  506. SurplusCell == other.class || value == other.value
  507. end
  508. def eql?(other)
  509. self == other
  510. end
  511. def hash
  512. 0
  513. end
  514. # For testing only
  515. def to_sexp # :nodoc:
  516. [:cell, @value]
  517. end
  518. end
  519. class SurplusCell < Cell # :nodoc:
  520. def status
  521. :comment
  522. end
  523. def ==(_other)
  524. true
  525. end
  526. def hash
  527. 0
  528. end
  529. end
  530. end
  531. end
  532. end

No Description

Contributors (1)