5

I need to write a script that will search through a CSV file, and perform certain search functions on it;

  1. find duplicate entries in a column
  2. find matches to a list of banned entries in another column
  3. find entries through regular expression matching on a column specified

Now, I have no problem at all coding this procedurally, but as I am now moving on to Object Orientated Programming, I would like to use classes and instances of objects instead.

However, thinking in OOP doesn't come naturally to me yet, so I'm not entirely sure which way to go. I'm not looking for specific code, but rather suggestions on how I could design the script.

My current thinking is this;

  1. Create a file class. This will handle import/export of data
  2. Create a search class. A child class of file. This will contain the various search methods

How it would function in index.php:

  1. get an array from the csv in the file object in index.php
  2. create a loop to iterate through the values of the array
  3. call the methods in the loop from a search object and echo them out

The problem I see with this approach is this;

  • I will want to point at different elements in my array to look at particular "columns". I could just put my loop in a function and pass this as a parameter, but this kind of defeats the point of OOP, I feel
  • My search methods will work in different ways. To search for duplicate entries is fairly straight forward with nested loops, but I do not need a nested loop to do a simple word or regular expression searchs.

Should I instead go like this?

  1. Create a file class. This will handle import/export of data
  2. Create a loop class A child of class of file. This will contain methods that deals with iterating through the array
  3. Create a search class. A child class of loop. This will contain the various search methods

My main issue with this is that it appears that I may need multiple search objects and iterate through this within my loop class.

Any help would be much appreciated. I'm very new to OOP, and while I understand the individual parts, I'm not yet able to see the bigger picture. I may be overcomplicating what it is I'm trying to do, or there may be a much simpler way that I can't see yet.

deceze
  • 510,633
  • 85
  • 743
  • 889
Martyn Shutt
  • 1,671
  • 18
  • 25

3 Answers3

14

PHP already offers a way to read a CSV file in an OO manner with SplFileObject:

$file = new SplFileObject("data.csv");

// tell object that it is reading a CSV file
$file->setFlags(SplFileObject::READ_CSV);
$file->setCsvControl(',', '"', '\\');

// iterate over the data
foreach ($file as $row) {
    list ($fruit, $quantity) = $row;
    // Do something with values
}

Since SplFileObject streams over the CSV data, the memory consumption is quite low and you can efficiently handle large CSV files, but since it is file i/o, it is not the fastest. However, an SplFileObject implements the Iterator interface, so you can wrap that $file instance into other iterators to modify the iteration. For instance, to limit file i/o, you could wrap it into a CachingIterator:

$cachedFile = new CachingIterator($file, CachingIterator::FULL_CACHE);

To fill the cache, you iterate over the $cachedFile. This will fill the cache

foreach ($cachedFile as $row) {

To iterate over the cache then, you do

foreach ($cachedFile->getCache() as $row) {

The tradeoff is increased memory obviously.

Now, to do your queries, you could wrap that CachingIterator or the SplFileObject into a FilterIterator which would limit the output when iterating over the csv data

class BannedEntriesFilter extends FilterIterator
{
    private $bannedEntries = array();

    public function setBannedEntries(array $bannedEntries)
    {
        $this->bannedEntries = $bannedEntries;
    }

    public function accept()
    {
        foreach ($this->current() as $key => $val) {
            return !$this->isBannedEntryInColumn($val, $key);
        }
    }

    public function $isBannedEntryInColumn($entry, $column)
    {
        return isset($this->bannedEntries[$column])
            && in_array($this->bannedEntries[$column], $entry);
    }
}

A FilterIterator will omit all entries from the inner Iterator which does not satisfy the test in the FilterIterator's accept method. Above, we check the current row from the csv file against an array of banned entries and if it matches, the data is not included in the iteration. You use it like this:

$filteredCachedFile = new BannedEntriesFilter(
    new ArrayIterator($cachedFile->getCache())
)

Since the cached results are always an Array, we need to wrap that Array into an ArrayIterator before we can wrap it into our FilterIterator. Note that to use the cache, you also need to iterate the CachingIterator at least once. We just assume you already did that above. The next step is to configure the banned entries

$filteredCachedFile->setBannedEntries(
    array(
        // banned entries for column 0
        array('foo', 'bar'),
        // banned entries for column 1
        array( …
    )
);

I guess that's rather straightforward. You have a multidimensional array with one entry for each column in the CSV data holding the banned entries. You then simply iterate over the instance and it will give you only the rows not having banned entries

foreach ($filteredCachedFile as $row) {
    // do something with filtered rows
}

or, if you just want to get the results into an array:

$results = iterator_to_array($filteredCachedFile);

You can stack multiple FilterIterators to further limit the results. If you dont feel like writing a class for each filtering, have a look at the CallbackFilterIterator, which allows passing of the accept logic at runtime:

$filteredCachedFile = new CallbackFilterIterator(
    new ArrayIterator($cachedFile->getCache()),
    function(array $row) {
        static $bannedEntries = array(
            array('foo', 'bar'),
            …
        );
        foreach ($row as $key => $val) {
            // logic from above returning boolean if match is found
        }
    }
);
Community
  • 1
  • 1
Gordon
  • 312,688
  • 75
  • 539
  • 559
4

I 'm going to illustrate a reasonable approach to designing OOP code that serves your stated needs. While I firmly believe that the ideas presented below are sound, please be aware that:

  • the design can be improved -- the aim here is to show the approach, not the final product
  • the implementation is only meant as an example -- if it (barely) works, it's good enough

How to go about doing this

A highly engineered solution would start by trying to define the interface to the data. That is, think about what would be a representation of the data that allows you to perform all your query operations. Here's one that would work:

  • A dataset is a finite collection of rows. Each row can be accessed given its zero-based index.
  • A row is a finite collection of values. Each value is a string and can be accessed given its zero-based index (i.e. column index). All rows in a dataset have exactly the same number of values.

This definition is enough to implement all three types of queries you mention by looping over the rows and performing some type of test on the values of a particular column.

The next move is to define an interface that describes the above in code. A not particularly nice but still adequate approach would be:

interface IDataSet {
    public function getRowCount();
    public function getValueAt($row, $column);
}

Now that this part is done, you can go and define a concrete class that implements this interface and can be used in your situation:

class InMemoryDataSet implements IDataSet {
    private $_data = array();

    public function __construct(array $data) {
        $this->_data = $data;
    }

    public function getRowCount() {
        return count($this->_data);
    }

    public function getValueAt($row, $column) {
        if ($row >= $this->getRowCount()) {
            throw new OutOfRangeException();
        }

        return isset($this->_data[$row][$column])
            ? $this->_data[$row][$column]
            : null;
    }
}

The next step is to go and write some code that converts your input data to some kind of IDataSet:

function CSVToDataSet($file) {
    return new InMemoryDataSet(array_map('str_getcsv', file($file)));
}

Now you can trivially create an IDataSet from a CSV file, and you know that you can perform your queries on it because IDataSet was explicitly designed for that purpose. You 're almost there.

The only thing missing is creating a reusable class that can perform your queries on an IDataSet. Here is one of them:

class DataQuery {
    private $_dataSet;

    public function __construct(IDataSet $dataSet) {
        $this->_dataSet = $dataSet;
    }

    public static function getRowsWithDuplicates($columnIndex) {
        $values = array();
        for ($i = 0; $i < $this->_dataSet->getRowCount(); ++$i) {
            $values[$this->_dataSet->->getValueAt($i, $columnIndex)][] = $i;
        }

        return array_filter($values, function($row) { return count($row) > 1; });
    }
}

This code will return an array where the keys are values in your CSV data and the values are arrays with the zero-based indexes of the rows where each value appears. Since only duplicate values are returned, each array will have at least two elements.

So at this point you are ready to go:

$dataSet = CSVToDataSet("data.csv");
$query = new DataQuery($dataSet);
$dupes = $query->getRowsWithDuplicates(0);

What you gain by doing this

Clean, maintainable code that supports being modified in the future without requiring edits all over your application.

If you want to add more query operations, add them to DataQuery and you can instantly use them on all concrete types of data sets. The data set and any other external code will not need any modifications.

If you want to change the internal representation of the data, modify InMemoryDataSet accordingly or create another class that implements IDataSet and use that one instead from CSVToDataSet. The query class and any other external code will not need any modifications.

If you need to change the definition of the data set (perhaps to allow more types of queries to be performed efficiently) then you have to modify IDataSet, which also brings all the concrete data set classes into the picture and probably DataQuery as well. While this won't be the end of the world, it's exactly the kind of thing you would want to avoid.

And this is precisely the reason why I suggested to start from this: If you come up with a good definition for the data set, everything else will just fall into place.

Jon
  • 428,835
  • 81
  • 738
  • 806
  • I think this is exactly the kind of answer I was looking for. I'm at work now so I don't have too much time to try it out, but I will certainly be trying this approach this evening. If I can get my head around this, I think it'll be a great starting point. – Martyn Shutt Nov 06 '12 at 10:52
  • @MartynShutt: Just finished writing, hope it helps. :) – Jon Nov 06 '12 at 11:01
  • This is a great answer. There's a bit in there I'll need to get my head around (never used array_map() before, for example), but logically it makes a lot of sense. Really appreciate the time you've put into this. I look forward to working on it! – Martyn Shutt Nov 06 '12 at 11:06
  • I'm now working on utilising the code you provided, but I'm having a bit of difficulty understanding some of it. I've posted it as a seperate question here: http://stackoverflow.com/questions/13575246/php-working-with-array-filter I'd be really grateful if you could offer some of your expertise. Thanks again. – Martyn Shutt Nov 28 '12 at 15:25
3

You have actually chosen a bad example for learning OOP. Because, the functionality you are looking for "importing" and "searching" a file, can be best implemented in procedural way, rather than object-oriented way. Remember that not everything in the world is an "object". Besides objects, we have "procedures", "actions" etc. You can still implement this functionality with classes, which is recommended way, in fact. But, just putting a functionality in a class does not turn it into real OOP automatically.

The point that I am trying to make is that, one of the reasons that you might be struggling to comprehend this functionality in terms of OOP is, that it is not really of object-oriented nature. If you are familiar with Java Math class (PHP may have a similar thing), it has b bunch of methods/functions such as abs, log, etc. This, although is a class, is not really a class in the object-oriented sense. It is just a bunch of functions.

What really a class in object-oriented sense is? Well this is a huge topic, but at least one general criteria is that it has both state (attributes/fields) and behavior (methods), in such a way that there is an intrinsic bond between the behavior and state. If so, for instance, a call to a method accesses state (because they are so tied together). Here is a simple OOP class:

Class person {

  // State
  name;
  age;
  income;

  // Behavior
  getName();
  setName()
  . 
  .
  .
  getMonthlyIncome() {
    return income / 12;
  }


}

And here is a class, that despite its appearance (as a class) in reality is procedureal:

class Math {

  multiply(double x, double y) {
    return x * y;
  }

  divide(double x, double y) {
    return x / y;
   }

  exponentiate(double x, double y) {
     return x^y;
  }
Nazar Merza
  • 3,365
  • 1
  • 19
  • 19
  • I think I understand what you're saying. I think with practice and time I'll learn to differentiate between when it's best to use functions and when best to use classes. I still think in procedural terms, and I do admit that classes that look very relational are far more readable. Trying to mentally read through the process of how multiple classes interact with one another leaves my brain a little numb. Perhaps I'm missing the point of encapsulation in that sense. – Martyn Shutt Nov 06 '12 at 22:15