Files
landing-api/app/Support/Batch/Importer.php
Josep Piguillem 0119004f2c Run batches and import JSON or CSV through the API
Each operation is dispatched as a request of its own, so a batch is held to
the same validation and permissions as the calls it contains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:58:36 +02:00

149 lines
4.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Batch;
use App\Support\AdminContent;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Spatie\Translatable\HasTranslations;
/**
* Turns rows — from a JSON array or a CSV file — into the create and update
* calls the API already has, and hands them to the batch runner.
*
* A row that names an existing record, by `id` or by the field given as
* `match`, updates it; any other row creates one. In a CSV, a column called
* `title.es` fills the Spanish title, a list is written `1|2|3`, `true` and
* `false` are booleans, and an empty cell is left out rather than erasing
* what is there.
*/
final class Importer
{
public const MAX_ROWS = BatchRunner::MAX_OPERATIONS;
/**
* Fields a CSV writes as `a|b|c`.
*/
protected const LISTS = ['technology_ids', 'service_ids', 'project_ids'];
/**
* @return array<int, array<string, mixed>>
*/
public function rowsFromCsv(UploadedFile $file): array
{
$handle = fopen($file->getRealPath(), 'r');
$header = null;
$rows = [];
while (($line = fgetcsv($handle, escape: '\\')) !== false) {
if ($line === [null]) {
continue;
}
if ($header === null) {
// A byte-order mark is what a spreadsheet saving "CSV UTF-8"
// puts before the first column's name.
$header = array_map(fn ($name) => trim((string) preg_replace('/^\xEF\xBB\xBF/', '', (string) $name)), $line);
continue;
}
$row = [];
foreach ($header as $i => $column) {
$value = $line[$i] ?? '';
if ($column === '' || $value === '') {
continue;
}
Arr::set($row, $column, $this->cell($column, $value));
}
$rows[] = $row;
}
fclose($handle);
return $rows;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array{method: string, path: string, body: array<string, mixed>}>
*/
public function operations(string $resource, array $rows, ?string $match = null): array
{
if (count($rows) > self::MAX_ROWS) {
throw ValidationException::withMessages(['rows' => [__('rules.batch.too_many', ['max' => self::MAX_ROWS])]]);
}
return array_map(function (array $row) use ($resource, $match): array {
$id = $row['id'] ?? $this->existing($resource, $row, $match);
unset($row['id']);
return $id === null
? ['method' => 'POST', 'path' => "/v1/{$resource}", 'body' => $row]
: ['method' => 'PATCH', 'path' => "/v1/{$resource}/{$id}", 'body' => $row];
}, $rows);
}
/**
* The record a row names through its `match` field, if any: a slug is
* looked up in the default language when it is translated.
*
* @param array<string, mixed> $row
*/
protected function existing(string $resource, array $row, ?string $match): mixed
{
if ($match === null || ! array_key_exists($match, $row)) {
return null;
}
$model = AdminContent::model($resource);
$instance = new $model;
$value = $row[$match];
$translated = in_array(HasTranslations::class, class_uses_recursive($model), true)
&& in_array($match, $instance->translatable, true);
if ($translated) {
$locale = (string) config('locales.default');
$value = is_array($value) ? ($value[$locale] ?? null) : $value;
$column = "{$match}->{$locale}";
} else {
$column = $match;
}
if ($value === null || is_array($value)) {
return null;
}
$query = $model::query();
if (method_exists($instance, 'bootSoftDeletes')) {
$query->withTrashed();
}
return $query->where($column, $value)->value($instance->getKeyName());
}
protected function cell(string $column, string $value): mixed
{
$base = explode('.', $column)[0];
return match (true) {
in_array($base, self::LISTS, true) => array_map(
fn ($item) => is_numeric($item) ? (int) $item : $item,
array_values(array_filter(array_map('trim', explode('|', $value)), fn ($item) => $item !== '')),
),
strtolower($value) === 'true' => true,
strtolower($value) === 'false' => false,
strtolower($value) === 'null' => null,
default => $value,
};
}
}