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>
149 lines
4.6 KiB
PHP
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,
|
|
};
|
|
}
|
|
}
|