# frozen_string_literal: true # Generates CSV when given a collection and a mapping. # # Example: # # columns = { # 'Title' => 'title', # 'Comment' => 'comment', # 'Author' => -> (post) { post.author.full_name } # 'Created At (UTC)' => -> (post) { post.created_at&.strftime('%Y-%m-%d %H:%M:%S') } # } # # CsvBuilder.new(@posts, columns).render # class CsvBuilder DEFAULT_ORDER_BY = 'id' DEFAULT_BATCH_SIZE = 1000 PREFIX_REGEX = /\A[=\+\-@;]/.freeze attr_reader :rows_written # # * +collection+ - The data collection to be used # * +header_to_hash_value+ - A hash of 'Column Heading' => 'value_method'. # * +associations_to_preload+ - An array of records to preload with a batch of records. # # The value method will be called once for each object in the collection, to # determine the value for that row. It can either be the name of a method on # the object, or a lamda to call passing in the object. def initialize(collection, header_to_value_hash, associations_to_preload = []) @header_to_value_hash = header_to_value_hash @collection = collection @truncated = false @rows_written = 0 @associations_to_preload = associations_to_preload end # Renders the csv to a string def render(truncate_after_bytes = nil) Tempfile.open(['csv']) do |tempfile| csv = CSV.new(tempfile) write_csv csv, until_condition: -> do truncate_after_bytes && tempfile.size > truncate_after_bytes end if block_given? yield tempfile else tempfile.rewind tempfile.read end end end def truncated? @truncated end def rows_expected if truncated? || rows_written == 0 @collection.count else rows_written end end def status { truncated: truncated?, rows_written: rows_written, rows_expected: rows_expected } end protected def each(&block) if @associations_to_preload.present? && @collection.respond_to?(:each_batch) @collection.each_batch(order_hint: :created_at) do |relation| relation.preload(@associations_to_preload).order(:id).each(&block) # rubocop:disable CodeReuse/ActiveRecord end else @collection.find_each(&block) # rubocop: disable CodeReuse/ActiveRecord end end private def headers @headers ||= @header_to_value_hash.keys end def attributes @attributes ||= @header_to_value_hash.values end def row(object) attributes.map do |attribute| if attribute.respond_to?(:call) excel_sanitize(attribute.call(object)) else excel_sanitize(object.public_send(attribute)) # rubocop:disable GitlabSecurity/PublicSend end end end def write_csv(csv, until_condition:) csv << headers each do |object| csv << row(object) @rows_written += 1 if until_condition.call @truncated = true break end end end def excel_sanitize(line) return if line.nil? return line unless line.is_a?(String) && line.match?(PREFIX_REGEX) ["'", line].join end end