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 limitemINSERT
: dodawanie nowych rekordówUPDATE
: edytowanie istniejących wierszyDELETE
: 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'");
- 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"
naint
), - nie ma żadnego sprawdzania poprawności typu podczas
INSERT
czyUPDATE
.
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.