Search code examples
c#winformscsvstreamreader

How To Go Back To Previous Line In .csv?


I'm trying to figure out how to either Record which line I'm in, for example, line = 32, allowing me to just add line-- in the previous record button event or find a better alternative.

I currently have my form setup and working where if I click on "Next Record" button, the file increments to the next line and displays the cells correctly within their associated textboxes, but how do I create a button that goes to the previous line in the .csv file?

StreamReader csvFile;

public GP_Appointment_Manager()
{
    InitializeComponent();
}

private void buttonOpenFile_Click(object sender, EventArgs e)
{
    try
    {
        csvFile = new StreamReader("patients_100.csv");
        // Read First line and do nothing
        string line;
        if (ReadPatientLineFromCSV(out line))
        {
            // Read second line, first patient line and populate form
            ReadPatientLineFromCSV(out line);
            PopulateForm(line);
        }
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

private bool ReadPatientLineFromCSV(out string line)
{
    bool result = false;
    line = "";
    if ((csvFile != null) && (!csvFile.EndOfStream))
    {
        line = csvFile.ReadLine();
        result = true;
    }
    else
    {
        MessageBox.Show("File has not been opened. Please open file before reading.");
    }
    return result;
}

private void PopulateForm(string patientDetails)
{
    string[] patient = patientDetails.Split(',');
    //Populates ID
    textBoxID.Text = patient[0];
    //Populates Personal 
    comboBoxSex.SelectedIndex = (patient[1] == "M") ? 0 : 1;
    dateTimePickerDOB.Value = DateTime.Parse(patient[2]);
    textBoxFirstName.Text = patient[3];
    textBoxLastName.Text = patient[4];
    //Populates Address 
    textboxAddress.Text = patient[5];
    textboxCity.Text = patient[6];
    textboxCounty.Text = patient[7];
    textboxTelephone.Text = patient[8];
    //Populates Kin
    textboxNextOfKin.Text = patient[9];
    textboxKinTelephone.Text = patient[10];
}

Here's the code for the "Next Record" Button

private void buttonNextRecord_Click(object sender, EventArgs e)
{
    string patientInfo;
    if (ReadPatientLineFromCSV(out patientInfo))
    {
        PopulateForm(patientInfo);
    }
}

Solution

  • Now, this is some sort of exercise. This class uses the standard StreamReader with a couple of modification, to implement simple move-forward/step-back functionalities.

    It also allows to associate an array/list of Controls with the data read from a CSV-like file format. Note that this is not a general-purpose CSV reader; it just splits a string in parts, using a separator that can be specified calling its AssociateControls() method.

    The class has 3 constructors:

    (1) public LineReader(string filePath)
    (2) public LineReader(string filePath, bool hasHeader)
    (3) public LineReader(string filePath, bool hasHeader, Encoding encoding)
    
    1. The source file has no Header in the first line and the text Encoding should be auto-detected
    2. Same, but the first line of the file contain the Header if hasHeader = true
    3. Used to specify an Encoding, if the automatic discovery cannot identify it correctly.

    The positions of the lines of text are stored in a Dictionary<long, long>, where the Key is the line number and Value is the starting position of the line.

    This has some advantages: no strings are stored anywhere, the file is indexed while reading it but you could use a background task to complete the indexing (this feature is not implemented here, maybe later...).
    The disadvantage is that the Dictionary takes space in memory. If the file is very large (just the number of lines counts, though), it may become a problem. To test.

    A note about the Encoding:
    The text encoding auto-detection is reliable enough only if the Encoding is not set to the default one (UTF-8). The code here, if you don't specify an Encoding, sets it to Encoding.ASCII. When the first line is read, the automatic feature tries to determine the actual encoding. It usually gets it right.
    In the default StreamReader implementation, if we specify Encoding.UTF8 (or none, which is the same) and the text encoding is ASCII, the encoder will use the default (Encoding.UTF8) encoding, since UTF-8 maps to ASCII gracefully.
    However, when this is the case, [Encoding].GetPreamble() will return the UTF-8 BOM (3 bytes), compromising the calculation of the current position in the underlying stream.


    To associate controls with the data read, you just need to pass a collection of controls to the LineReader.AssociateControls() method.
    This will map each control to the data field in the same position.
    To skip a data field, specify null instead of a control reference.

    The visual example is built using a CSV file with this structure:
    (Note: this data is generated using an automated on-line tool)

    seq;firstname;lastname;age;street;city;state;zip;deposit;color;date
    ---------------------------------------------------------------------------
    1;Harriett;Gibbs;62;Segmi Center;Ebanavi;ID;57854;$4444.78;WHITE;05/15/1914
    2;Oscar;McDaniel;49;Kulak Drive;Jetagoz;IL;57631;$5813.94;RED;02/11/1918
    3;Winifred;Olson;29;Wahab Mill;Ucocivo;NC;46073;$2002.70;RED;08/11/2008
    

    I skipped the seq and color fields, passing this array of Controls:

    LineReader lineReader = null;
    
    private void btnOpenFile_Click(object sender, EventArgs e)
    {
        string filePath = Path.Combine(Application.StartupPath, @"sample.csv");
        lineReader = new LineReader(filePath, true);
        string header = lineReader.HeaderLine;
        Control[] controls = new[] { 
            null, textBox1, textBox2, textBox3, textBox4, textBox5, 
            textBox6, textBox9, textBox7, null, textBox8 };
        lineReader.AssociateControls(controls, ";");
    }
    

    The null entries correspond to the data fields that are not considered.

    Visual sample of the functionality:

    StreamReader MovePrevious MoveNext


    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Text;
    using System.Windows.Forms;
    
    class LineReader : IDisposable
    {
        private StreamReader reader = null;
        private Dictionary<long, long> positions;
        private string m_filePath = string.Empty;
        private Encoding m_encoding = null;
        private IEnumerable<Control> m_controls = null;
        private string m_separator = string.Empty;
        private bool m_associate = false;
        private long m_currentPosition = 0;
        private bool m_hasHeader = false;
    
        public LineReader(string filePath) : this(filePath, false) { }
        public LineReader(string filePath, bool hasHeader) : this(filePath, hasHeader, Encoding.ASCII) { }
        public LineReader(string filePath, bool hasHeader, Encoding encoding)
        {
            if (!File.Exists(filePath)) {
                throw new FileNotFoundException($"The file specified: {filePath} was not found");
            }
            this.m_filePath = filePath;
            m_hasHeader = hasHeader;
            CurrentLineNumber = 0;
            reader = new StreamReader(this.m_filePath, encoding, true);
            CurrentLine = reader.ReadLine();
    
            m_encoding = reader.CurrentEncoding;
            m_currentPosition = m_encoding.GetPreamble().Length;
            positions = new Dictionary<long, long>() { [0]= m_currentPosition };
            if (hasHeader) { this.HeaderLine = CurrentLine = this.MoveNext(); }
        }
    
        public string HeaderLine { get; private set; }
        public string CurrentLine { get; private set; }
        public long CurrentLineNumber { get; private set; }
    
        public string MoveNext()
        {
            string read = reader.ReadLine();
            if (string.IsNullOrEmpty(read)) return this.CurrentLine;
            CurrentLineNumber += 1;
    
            if ((positions.Count - 1) < CurrentLineNumber) {
                AdjustPositionToLineFeed();
                positions.Add(CurrentLineNumber, m_currentPosition);
            }
            else {
                m_currentPosition = positions[CurrentLineNumber];
            }
            this.CurrentLine = read;
            if (m_associate) this.Associate();
            return read;
        }
    
        public string MovePrevious()
        {
            if (CurrentLineNumber == 0 || (CurrentLineNumber == 1 && m_hasHeader)) return this.CurrentLine;
            CurrentLineNumber -= 1;
            m_currentPosition = positions[CurrentLineNumber];
            reader.BaseStream.Position = m_currentPosition;
            reader.DiscardBufferedData();
    
            this.CurrentLine = reader.ReadLine();
            if (m_associate) this.Associate();
            return this.CurrentLine;
        }
    
        private void AdjustPositionToLineFeed()
        {
            long linePos = m_currentPosition + m_encoding.GetByteCount(this.CurrentLine);
            long prevPos = reader.BaseStream.Position;
            reader.BaseStream.Position = linePos;
    
            byte[] buffer = new byte[4];
            reader.BaseStream.Read(buffer, 0, buffer.Length);
            char[] chars = m_encoding.GetChars(buffer).Where(c => c.Equals((char)10) || c.Equals((char)13)).ToArray();
            m_currentPosition = linePos + m_encoding.GetByteCount(chars);
            reader.BaseStream.Position = prevPos;
        }
    
        public void AssociateControls(IEnumerable<Control> controls, string separator)
        {
            m_controls = controls;
            m_separator = separator;
            m_associate = true;
            if (!string.IsNullOrEmpty(this.CurrentLine)) Associate();
        }
    
        private void Associate()
        {
            string[] values = this.CurrentLine.Split(new[] { m_separator }, StringSplitOptions.None);
            int associate = 0;
            m_controls.ToList().ForEach(c => {
                if (c != null) c.Text = values[associate];
                associate += 1;
            });
        }
    
        public override string ToString() =>
            $"File Path: {m_filePath} Encoding: {m_encoding.BodyName} CodePage: {m_encoding.CodePage}";
    
        public void Dispose()
        {
            this.Dispose(true);
            GC.SuppressFinalize(this);
        }
        protected virtual void Dispose(bool disposing)
        {
            if (disposing) { reader?.Dispose(); }
        }
    }