NamedCsvRecord.java

package de.siegmar.fastcsv.reader;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.StringJoiner;

/// Represents an immutable CSV record with named (and indexed) fields.
///
/// The field values are never `null`. Empty fields are represented as empty strings.
///
/// @see CsvReader
@SuppressWarnings("PMD.ArrayIsStoredDirectly")
public final class NamedCsvRecord extends CsvRecord {

    private final String[] header;

    @SuppressWarnings("PMD.UseVarargs")
    NamedCsvRecord(final long startingLineNumber, final String[] fields, final boolean comment,
                   final String[] header) {
        super(startingLineNumber, fields, comment);
        this.header = header;
    }

    /// Retrieves the header names of this record.
    ///
    /// The header names are returned in the order they appear in the CSV file.
    ///
    /// Note that the header names are not necessarily unique.
    /// If you need to collect all fields with the same name (duplicate header), use [#getFieldsAsMapList()].
    ///
    /// Note that records for commented lines ([#isComment()]) do not have an empty header.
    /// To retrieve the comment value, user [#getField(int)] with index 0.
    ///
    /// @return the header names, never `null`
    public List<String> getHeader() {
        // Not using List.of() here for performance reasons, as it copies the array.
        return Collections.unmodifiableList(Arrays.asList(header));
    }

    /// Retrieves the value of a field by its case-sensitive name, considering the first occurrence in case of
    /// duplicates.
    ///
    /// This method is equivalent to `findField(name).orElseThrow(NoSuchElementException::new)` although a
    /// more explanatory exception message is provided.
    ///
    /// @param name case-sensitive name of the field to be retrieved
    /// @return field value, never `null`
    /// @throws NoSuchElementException if this record has no such field
    /// @throws NullPointerException   if name is `null`
    /// @see #findField(String)
    /// @see #findFields(String)
    public String getField(final String name) {
        final int fieldIdx = findHeaderIndex(name);

        // Check if the field index is valid
        if (fieldIdx == -1) {
            throw new NoSuchElementException("Header does not contain a field '%s'. Valid names are: %s"
                .formatted(name, Arrays.toString(header)));
        }
        if (fieldIdx >= fields.length) {
            throw new NoSuchElementException("Field '%s' is on index %d, but current record only contains %d fields"
                .formatted(name, fieldIdx, fields.length));
        }

        // Return the value of the field
        return fields[fieldIdx];
    }

    // Finds the index for the first occurrence of the given header name (case-sensitive); returns -1 if not found
    private int findHeaderIndex(final String name) {
        for (int i = 0; i < header.length; i++) {
            if (name.equals(header[i])) {
                return i;
            }
        }
        return -1;
    }

    /// Retrieves the value of a field by its case-sensitive name, considering the first occurrence in case of
    /// duplicates.
    ///
    /// This method is equivalent to `findFields(name).stream().findFirst()` but more performant.
    ///
    /// @param name case-sensitive name of the field to be retrieved
    /// @return An [Optional] containing the value of the field if found,
    ///     or an [Optional#EMPTY] if the field is not present. Never returns `null`.
    /// @throws NullPointerException if name is `null`
    /// @see #findFields(String)
    public Optional<String> findField(final String name) {
        final int fieldIdx = findHeaderIndex(name);

        // Check if the field index is valid
        if (fieldIdx == -1 || fieldIdx >= fields.length) {
            return Optional.empty();
        }

        // Return the value of the field wrapped in an Optional
        return Optional.of(fields[fieldIdx]);
    }

    /// Collects all field values with the given name (case-sensitive) in the order they appear in the header.
    ///
    /// @param name case-sensitive name of the field to collect values for
    /// @return the field values (empty list if record doesn't contain that field), never `null`
    /// @throws NullPointerException if name is `null`
    public List<String> findFields(final String name) {
        final int bound = header.length;
        final List<String> ret = new ArrayList<>(bound);
        for (int i = 0; i < bound; i++) {
            if (name.equals(header[i])) {
                ret.add(fields[i]);
            }
        }
        return ret;
    }

    /// Constructs an ordered map, associating header names with corresponding field values of this record,
    /// considering the first occurrence in case of duplicates.
    ///
    /// The constructed map will only contain entries for fields that have a key and a value. No map entry will have a
    /// `null` key or value.
    ///
    /// If you need to collect all fields with the same name (duplicate header), use [#getFieldsAsMapList()].
    ///
    /// @return an ordered map of header names and field values of this record, never `null`
    /// @see #getFieldsAsMapList()
    @SuppressWarnings("PMD.UseConcurrentHashMap")
    public Map<String, String> getFieldsAsMap() {
        final int bound = commonSize();
        final Map<String, String> map = new LinkedHashMap<>(bound);
        for (int i = 0; i < bound; i++) {
            map.putIfAbsent(header[i], fields[i]);
        }
        return map;
    }

    /// Constructs an unordered map, associating header names with an ordered list of corresponding field values in
    /// this record.
    ///
    /// The constructed map will only contain entries for fields that have a key and a value. No map entry will have a
    /// `null` key or value.
    ///
    /// If you don't have to handle duplicate headers, you may simply use [#getFieldsAsMap()].
    ///
    /// @return an unordered map of header names and field values of this record, never `null`
    /// @see #getFieldsAsMap()
    @SuppressWarnings({"PMD.AvoidInstantiatingObjectsInLoops", "PMD.UseConcurrentHashMap"})
    public Map<String, List<String>> getFieldsAsMapList() {
        final int bound = commonSize();
        final Map<String, List<String>> map = new HashMap<>(bound);
        for (int i = 0; i < bound; i++) {
            final String key = header[i];
            List<String> val = map.get(key);
            if (val == null) {
                val = new ArrayList<>();
                map.put(key, val);
            }
            val.add(fields[i]);
        }
        return map;
    }

    // Mappings will only be created for fields that have a key and a value ��� return the minimum of both sizes
    private int commonSize() {
        return Math.min(header.length, fields.length);
    }

    @Override
    public String toString() {
        return new StringJoiner(", ", NamedCsvRecord.class.getSimpleName() + "[", "]")
            .add("startingLineNumber=" + startingLineNumber)
            .add("fields=" + Arrays.toString(fields))
            .add("comment=" + comment)
            .add("header=" + Arrays.toString(header))
            .toString();
    }

}