Przejdź do głównej treści
Grafika przedstawia ukryty obrazek

Obsługa plików CSV za pomocą zapytań SQL – nowoczesne podejście

Obsługa plików CSV za pomocą zapytań SQL  – nowoczesne podejście

Pliki CSV są prostym i uniwersalnym formatem przechowywania danych tabelarycznych. Mimo swojej prostoty, wciąż są szeroko stosowane w importach, eksportach, prostych bazach danych i integracjach między systemami. Jednak ich ręczna obsługa w PHP — z wykorzystaniem funkcji typu fgetcsv() — szybko staje się uciążliwa, szczególnie gdy trzeba filtrować, sortować, modyfikować lub aktualizować dane.

SQL jako interfejs do CSV

Ciekawym i bardzo wygodnym rozwiązaniem jest stworzenie warstwy pośredniej, która pozwala operować na plikach CSV tak, jakby były prostą bazą danych — za pomocą znanych wszystkim zapytań SQL:

  • SELECT: pobieranie danych z filtrowaniem, sortowaniem i limitem
  • INSERT: dodawanie nowych rekordów
  • UPDATE: edytowanie istniejących wierszy
  • DELETE: usuwanie wierszy spełniających warunek

Taka abstrakcja pozwala na znacznie szybsze i bardziej czytelne operacje, a także na ponowne wykorzystanie wiedzy z klasycznych relacyjnych baz danych.

Przykładowe możliwości

Przy odpowiednim parserze SQL, możliwe staje się wykonanie zapytań takich jak:

SELECT imie, nazwisko FROM dane WHERE kraj = 'Polska' AND wiek > 30 ORDER BY wiek DESC LIMIT 10

lub:

UPDATE dane SET kraj = 'Niemcy' WHERE nazwisko LIKE 'Ko%'

Tego typu zapytania działają na pliku CSV tak, jakby był on małą, plikową bazą danych.

Dla kogo to rozwiązanie?

To podejście szczególnie przypadnie do gustu:

  • programistom pracującym z danymi z Excela lub importami z ERP,
  • twórcom prostych aplikacji bez konieczności utrzymywania bazy danych,
  • osobom automatyzującym raportowanie i przetwarzanie danych z plików,
  • hobbystom budującym własne lekkie systemy analizy danych.

Korzyści

  • Brak potrzeby instalowania baz danych (np. SQLite, MySQL)
  • Zachowanie prostoty plików CSV, przy mocy składni SQL
  • Łatwość integracji z istniejącymi narzędziami i skryptami PHP
  • Zrozumiała logika działania — nawet dla mniej zaawansowanych użytkowników

Jak to wdrożyć?

Klasa w PHP

class CSVDatabase 
{
    private string $filename;
    private array $headers = [];
    private array $rows = [];

    public function __construct(string $filename) 
    {
        $this->filename = $filename;
        if (!file_exists($filename)) 
        {
            throw new Exception("Plik nie istnieje: $filename");
        }
        $this->load();
    }

    private function load(): void 
    {
        $this->rows = [];
        $fp = fopen($this->filename, 'r');
        $this->headers = fgetcsv($fp);
        while (($row = fgetcsv($fp)) !== false) 
        {
            if (count($row) === 1 && trim($row[0]) === '') continue;
            $assoc = array_combine($this->headers, $row);
            if ($assoc !== false) 
            {
                $this->rows[] = $assoc;
            }
        }
        fclose($fp);
    }

    private function save(): void 
    {
        $fp = fopen($this->filename, 'w');
        fputcsv($fp, $this->headers);
        foreach ($this->rows as $row) 
        {
            $ordered = array_map(fn($h) => $row[$h] ?? '', $this->headers);
            fputcsv($fp, $ordered);
        }
        fclose($fp);
    }

    public function query(string $sql): array|bool 
    {
        $sql = trim($sql);
        if (stripos($sql, 'SELECT') === 0) 
        {
            return $this->select($sql);
        } elseif (stripos($sql, 'INSERT') === 0) 
        {
            $this->insert($sql);
            return true;
        } elseif (stripos($sql, 'UPDATE') === 0) 
        {
            $this->update($sql);
            return true;
        } elseif (stripos($sql, 'DELETE') === 0) 
        {
            $this->delete($sql);
            return true;
        } else 
        {
            throw new Exception("Nieznane zapytanie SQL: $sql");
        }
    }

    private function select(string $query): array 
    {
        $pattern = '/^SELECT\s+(.*?)\s+FROM\s+\w+(?:\s+WHERE\s+(.*?))?(?:\s+ORDER\s+BY\s+(\w+)(?:\s+(ASC|DESC))?)?(?:\s+LIMIT\s+(\d+))?\s*$/i';
        if (!preg_match($pattern, $query, $m)) 
        {
            throw new Exception("Nieprawidłowe zapytanie SELECT");
        }

        $columns = trim($m[1]) === '*' ? $this->headers : array_map('trim', explode(',', $m[1]));
        $whereClause = $m[2] ?? null;
        $orderBy = $m[3] ?? null;
        $orderDir = strtoupper($m[4] ?? 'ASC');
        $limit = isset($m[5]) ? (int)$m[5] : null;

        $result = $this->rows;

        if ($whereClause) 
        {
            $result = array_filter($result, fn($r) => $this->matchConditions($r, $whereClause));
        }

        if ($orderBy) 
        {
            usort($result, function ($a, $b) use ($orderBy, $orderDir) 
            {
                $va = $a[$orderBy] ?? '';
                $vb = $b[$orderBy] ?? '';
                if (is_numeric($va) && is_numeric($vb)) 
                {
                    $va = +$va;
                    $vb = +$vb;
                }
                if ($va == $vb) return 0;
                return ($va < $vb xor $orderDir === 'DESC') ? -1 : 1;
            });
        }

        if ($limit !== null) 
        {
            $result = array_slice($result, 0, $limit);
        }

        return array_map(fn($r) => array_intersect_key($r, array_flip($columns)), $result);
    }

    private function insert(string $query): void 
    {
        if (!preg_match('/^INSERT\s+INTO\s+\w+\s*\(([^)]+)\)\s+VALUES\s*\(([^)]+)\)$/i', $query, $m)) 
        {
            throw new Exception("Nieprawidłowe zapytanie INSERT");
        }

        $columns = array_map('trim', explode(',', $m[1]));
        $values = array_map(fn($v) => trim($v, " '\t\n\r\""), explode(',', $m[2]));

        if (count($columns) !== count($values)) 
        {
            throw new Exception("Liczba kolumn ≠ liczba wartości");
        }

        $newRow = array_fill_keys($this->headers, '');
        foreach ($columns as $i => $col) 
        {
            $newRow[$col] = $values[$i] ?? '';
        }

        $this->rows[] = $newRow;
        $this->save();
    }

    private function update(string $query): void 
    {
        if (!preg_match('/^UPDATE\s+\w+\s+SET\s+(.+?)\s+WHERE\s+(.+)$/i', $query, $m)) 
        {
            throw new Exception("Nieprawidłowe zapytanie UPDATE");
        }

        parse_str(str_replace(",", "&", strtr($m[1], [" = " => "=", " " => "%20"])), $setParts);
        $setParts = array_map(fn($v) => urldecode($v), $setParts);
        $whereClause = $m[2];

        for ($i = 0, $c = count($this->rows); $i < $c; $i++)
        {
            if ($this->matchConditions($this->rows[$i], $whereClause)) 
            {
                foreach ($setParts as $col => $val) 
                {
                    $this->rows[$i][$col] = trim($val, "'");
                }
            }
        }

        $this->save();
    }

    private function delete(string $query): void 
    {
        if (!preg_match('/^DELETE\s+FROM\s+\w+\s+WHERE\s+(.+)$/i', $query, $m)) 
        {
            throw new Exception("Nieprawidłowe zapytanie DELETE");
        }

        $this->rows = array_filter($this->rows, fn($r) => !$this->matchConditions($r, $m[1]));
        $this->save();
    }

    private function matchConditions(array $row, string $clause): bool 
    {
        $tokens = preg_split('/\s+(AND|OR)\s+/i', $clause, -1, PREG_SPLIT_DELIM_CAPTURE);
        $result = null;
        $op = null;

        foreach ($tokens as $token) 
        {
            $token = trim($token);
            if (strtoupper($token) === 'AND' || strtoupper($token) === 'OR') 
            {
                $op = strtoupper($token);
                continue;
            }

            $eval = $this->evaluateCondition($row, $token);
            if ($result === null) 
            {
                $result = $eval;
            } elseif ($op === 'AND') 
            {
                $result = $result && $eval;
            } elseif ($op === 'OR') 
            {
                $result = $result || $eval;
            }
        }

        return $result ?? true;
    }

    private function evaluateCondition(array $row, string $condition): bool 
    {
        $pattern = '/(\w+)\s*(=|!=|>=|<=|>|<|LIKE)\s*[\'"]?([^\'"]+)[\'"]?/i';
        if (!preg_match($pattern, $condition, $m)) 
        {
            throw new Exception("Nieprawidłowy warunek: $condition");
        }

        [$_, $col, $op, $val] = $m;
        $cell = $row[$col] ?? '';

        if (strtoupper($op) === 'LIKE') 
        {
            $regex = '/^' . str_replace('%', '.*', preg_quote($val, '/')) . '$/i';
            return preg_match($regex, $cell);
        }

        if (is_numeric($cell) && is_numeric($val)) 
        {
            $cell = +$cell;
            $val = +$val;
        }

        return match ($op) 
        {
            '='  => $cell == $val,
            '!=' => $cell != $val,
            '>'  => $cell > $val,
            '<'  => $cell < $val,
            '>=' => $cell >= $val,
            '<=' => $cell <= $val,
            default => false,
        };
    }
}

Testowy plik dane.csv

imie,nazwisko,miasto,kraj,wiek
Jan,Kowalski,Warszawa,Polska,35
Anna,Nowak,Kraków,Polska,28
Piotr,Zieliński,Poznań,Polska,40
Maria,Wiśniewska,Wrocław,Polska,33
Tomasz,Wójcik,Gdańsk,Polska,45
John,Smith,London,UK,38
Sara,Müller,Berlin,Niemcy,29

Przykład użycia

$db = new CSVDatabase('dane.csv');

// SELECT z warunkami
$results = $db->query("SELECT imie, nazwisko FROM dane WHERE kraj = 'Polska' AND wiek > 30 ORDER BY wiek DESC LIMIT 5");
print_r($results);

// INSERT
$db->query("INSERT INTO dane (imie, nazwisko, wiek, kraj) VALUES ('Anna', 'Nowak', 28, 'Polska')");

// UPDATE
$db->query("UPDATE dane SET kraj = 'Niemcy' WHERE nazwisko LIKE 'Ko%'");

// DELETE
$db->query("DELETE FROM dane WHERE wiek < 18 OR kraj = 'USA'");
Ważne
  • przed użyciem klasy plik musi istnieć w wybranym katalogu
  • plik nie może być pusty
  • pierwsza linia w pliku musi zawierać nazwy pól

Przykład w HTML i JavaScript

<!DOCTYPE html>
<html lang="pl">
<head>
  <meta charset="UTF-8">
  <title>CSV SQL Tester</title>
  <style>
    textarea { width: 100%; height: 150px; margin-bottom: 10px; }
    button { padding: 10px 20px; margin: 10px 0; }
    pre { background: #f4f4f4; padding: 10px; white-space: pre-wrap; }
  </style>
</head>
<body>

<h3>Dane CSV</h3>
<textarea id="csvInput">
id,imie,nazwisko,wiek,miasto,kraj
1,Jan,Kowalski,30,Warszawa,Polska
2,Ewa,Nowak,25,Chicago,USA
3,Piotr,Zieliński,17,Berlin,Niemcy
</textarea>

<h3>Zapytanie SQL</h3>
<textarea id="sqlInput">SELECT * FROM dane WHERE wiek >= 18 ORDER BY wiek DESC LIMIT 2</textarea>

<br>
<button onclick="runQuery()">Wykonaj</button>

<h3>Wynik:</h3>
<pre id="output"></pre>

<script>
class CSVDatabase {
  constructor(csvText) {
    this.headers = [];
    this.rows = [];
    this.load(csvText);
  }

  load(csvText) {
    const lines = csvText.trim().split(/\r?\n/).filter(line => line.trim());
    this.headers = lines[0].split(',');
    this.rows = lines.slice(1).map(line => {
      const values = line.split(',');
      return Object.fromEntries(this.headers.map((h, i) => [h, values[i] || '']));
    });
  }

  toCSV() {
    const lines = [this.headers.join(',')];
    this.rows.forEach(row => {
      lines.push(this.headers.map(h => row[h] ?? '').join(','));
    });
    return lines.join('\n');
  }

  query(sql) {
    sql = sql.trim();
    if (sql.toUpperCase().startsWith('SELECT')) {
      return this.select(sql);
    } else if (sql.toUpperCase().startsWith('INSERT')) {
      this.insert(sql);
      return 'Wiersz dodany.';
    } else if (sql.toUpperCase().startsWith('UPDATE')) {
      this.update(sql);
      return 'Wiersze zaktualizowane.';
    } else if (sql.toUpperCase().startsWith('DELETE')) {
      this.delete(sql);
      return 'Wiersze usunięte.';
    } else {
      return 'Nieznane zapytanie.';
    }
  }

  select(query) {
    const pattern = /^SELECT\s+(.*?)\s+FROM\s+\w+(?:\s+WHERE\s+(.*?))?(?:\s+ORDER\s+BY\s+(\w+)(?:\s+(ASC|DESC))?)?(?:\s+LIMIT\s+(\d+))?$/i;
    const m = query.match(pattern);
    if (!m) return 'Nieprawidłowe SELECT';

    const columns = m[1].trim() === '*' ? this.headers : m[1].split(',').map(s => s.trim());
    const whereClause = m[2];
    const orderBy = m[3];
    const orderDir = (m[4] || 'ASC').toUpperCase();
    const limit = m[5] ? parseInt(m[5]) : null;

    let result = [...this.rows];

    if (whereClause) {
      result = result.filter(r => this.matchConditions(r, whereClause));
    }

    if (orderBy) {
      result.sort((a, b) => {
        const va = a[orderBy];
        const vb = b[orderBy];
        const cmp = (va == vb) ? 0 : (va < vb ? -1 : 1);
        return orderDir === 'DESC' ? -cmp : cmp;
      });
    }

    if (limit !== null) {
      result = result.slice(0, limit);
    }

    return result.map(r => {
      const obj = {};
      columns.forEach(c => obj[c] = r[c]);
      return obj;
    });
  }

  insert(query) {
    const m = query.match(/^INSERT\s+INTO\s+\w+\s*\(([^)]+)\)\s+VALUES\s*\(([^)]+)\)$/i);
    if (!m) throw new Error("INSERT błąd składni");

    const columns = m[1].split(',').map(s => s.trim());
    const values = m[2].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, ''));

    if (columns.length !== values.length) throw new Error("Niezgodność kolumn i wartości");

    const newRow = Object.fromEntries(this.headers.map(h => [h, '']));
    columns.forEach((col, i) => newRow[col] = values[i]);
    this.rows.push(newRow);
  }

  update(query) {
    const m = query.match(/^UPDATE\s+\w+\s+SET\s+(.+?)\s+WHERE\s+(.+)$/i);
    if (!m) throw new Error("UPDATE błąd składni");

    const setParts = Object.fromEntries(
      m[1].split(',').map(pair => {
        const [k, v] = pair.split('=').map(s => s.trim().replace(/^['"]|['"]$/g, ''));
        return [k, v];
      })
    );
    const whereClause = m[2];

    this.rows.forEach(row => {
      if (this.matchConditions(row, whereClause)) {
        for (const [k, v] of Object.entries(setParts)) {
          row[k] = v;
        }
      }
    });
  }

  delete(query) {
    const m = query.match(/^DELETE\s+FROM\s+\w+\s+WHERE\s+(.+)$/i);
    if (!m) throw new Error("DELETE błąd składni");

    this.rows = this.rows.filter(r => !this.matchConditions(r, m[1]));
  }

  matchConditions(row, clause) {
    const tokens = clause.split(/\s+(AND|OR)\s+/i);
    let result = null, op = null;

    for (const token of tokens) {
      const upper = token.toUpperCase();
      if (upper === 'AND' || upper === 'OR') {
        op = upper;
        continue;
      }

      const evalResult = this.evaluateCondition(row, token);
      if (result === null) {
        result = evalResult;
      } else if (op === 'AND') {
        result = result && evalResult;
      } else if (op === 'OR') {
        result = result || evalResult;
      }
    }

    return result ?? true;
  }

  evaluateCondition(row, condition) {
    const m = condition.match(/(\w+)\s*(=|!=|>=|<=|>|<|LIKE)\s*['"]?([^'"]+)['"]?/i);
    if (!m) return false;

    const [_, col, op, val] = m;
    const cell = row[col] ?? '';

    if (op.toUpperCase() === 'LIKE') {
      const regex = new RegExp('^' + val.replace(/%/g, '.*') + '$', 'i');
      return regex.test(cell);
    }

    const a = isNaN(cell) ? cell : parseFloat(cell);
    const b = isNaN(val) ? val : parseFloat(val);

    switch (op) {
      case '=': return a == b;
      case '!=': return a != b;
      case '>': return a > b;
      case '<': return a < b;
      case '>=': return a >= b;
      case '<=': return a <= b;
      default: return false;
    }
  }
}

function runQuery() {
  const csvText = document.getElementById('csvInput').value;
  const sql = document.getElementById('sqlInput').value;
  const db = new CSVDatabase(csvText);

  try {
    const result = db.query(sql);
    if (Array.isArray(result)) {
      document.getElementById('output').textContent = JSON.stringify(result, null, 2);
    } else {
      // pokaż CSV po INSERT/UPDATE/DELETE
      document.getElementById('csvInput').value = db.toCSV();
      document.getElementById('output').textContent = result;
    }
  } catch (e) {
    document.getElementById('output').textContent = 'Błąd: ' + e.message;
  }
}
</script>

</body>
</html>

Przetestuj kod w HTML i JavaScript!

Dane i zapytanie:

Wynik:

Przykładowe zapytania, które możesz wkleić:

SELECT imie, miasto FROM dane WHERE kraj = 'Polska' ORDER BY wiek DESC

INSERT INTO dane (id, imie, nazwisko, wiek, miasto, kraj) VALUES (4, 'Anna', 'Nowak', 21, 'Lublin', 'Polska')

UPDATE dane SET kraj = 'RPA' WHERE id = 2

DELETE FROM dane WHERE wiek < 18 OR kraj = 'USA'

Ograniczenia i wyzwania

Choć traktowanie plików CSV jak lekkiej bazy danych z interfejsem SQL jest pomysłowe i praktyczne, warto mieć świadomość jego ograniczeń. Poniżej zestawiamy najważniejsze z nich:

1. Brak indeksów i optymalizacji

W przeciwieństwie do prawdziwych silników baz danych, przeszukiwanie CSV odbywa się liniowo – wiersz po wierszu. Przy większych plikach (setki tysięcy rekordów) może to prowadzić do spadku wydajności.

2. Brak typów danych i walidacji

CSV nie przechowuje informacji o typach danych – wszystko jest traktowane jako tekst. Oznacza to, że:

  • operacje porównawcze mogą nie działać tak, jak się spodziewasz (np. porównania liczbowe na stringach),
  • trzeba ręcznie konwertować wartości (np. "42" na int),
  • nie ma żadnego sprawdzania poprawności typu podczas INSERT czy UPDATE.

3. Brak obsługi relacji i złożonych zapytań

Nie obsłużysz JOIN, GROUP BY, HAVING ani zagnieżdżonych zapytań (subqueries). To podejście nadaje się tylko do najprostszych operacji na jednej "tabeli".

4. Ryzyko błędów przy znakach specjalnych

CSV może zawierać przecinki, cudzysłowy i inne znaki w danych. Jeśli parser nie uwzględnia ich odpowiednio (np. fgetcsv() nie radzi sobie ze złożonymi polami), mogą wystąpić błędy lub błędna interpretacja danych.

5. Brak kontroli nad współbieżnością

Jeśli wiele procesów jednocześnie odczytuje i zapisuje ten sam plik CSV, może dojść do konfliktów lub utraty danych — nie ma mechanizmu transakcji ani blokad jak w normalnych bazach danych.

6. Brak trwałości typowej dla baz danych

CSV to po prostu plik tekstowy. Nie ma logów transakcyjnych, kopii zapasowych, rollbacków ani systemu praw dostępu.

7. Potencjalne luki bezpieczeństwa

Jeśli użytkownik końcowy może wykonywać zapytania (np. poprzez interfejs webowy), parser SQL musi być bardzo ostrożny, by uniknąć nieoczekiwanych zachowań (np. nieuprawnionych modyfikacji pliku).

Kiedy warto, a kiedy nie?

Warto, gdy:

  • plik CSV jest mały i lokalny (np. do 1–2 tys. wierszy),
  • potrzebujesz szybkiego przeszukiwania, filtrowania lub raportu,
  • tworzysz narzędzie dla siebie lub małego zespołu.

Nie warto, gdy:

  • dane są duże, krytyczne lub współdzielone,
  • potrzebujesz relacyjnych operacji,
  • wymagana jest wysoka niezawodność i trwałość.

Podsumowanie

Łączenie świata plików CSV z interfejsem SQL to świetny sposób na uproszczenie operacji na danych, bez rezygnowania z czytelności i elastyczności. Choć nie zastąpi to prawdziwej bazy danych w dużych projektach, może znacząco przyspieszyć pracę z danymi w wielu codziennych scenariuszach.

Jeśli jesteś programistą PHP i często pracujesz z CSV, warto rozważyć to podejście jako poręczne narzędzie do ręki — lekkie, czytelne i bez zbędnych zależności.

18 czerwca 2025 38

Kategorie

programowanie

Tagi

csv php regexp sql

Dziękujemy!
()

Powiązane wpisy


Informacja o cookies

Moja strona internetowa wykorzystuje wyłącznie niezbędne pliki cookies, które są wymagane do jej prawidłowego działania. Nie używam ciasteczek w celach marketingowych ani analitycznych. Korzystając z mojej strony, wyrażasz zgodę na stosowanie tych plików. Możesz dowiedzieć się więcej w mojej polityce prywatności.