feat: Pods field source for schema detection and writes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
+169
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Pods;
|
||||
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Pods field adapter (spec §6.1/§6.2): definitions from pods_api()->load_pod(),
|
||||
* values read and written through the Pods API so relationships and caches stay
|
||||
* consistent. Works with Pods 3.x (Pods\Whatsit\Pod / Pods\Whatsit\Field).
|
||||
*/
|
||||
final class PodsFieldSource implements FieldSource
|
||||
{
|
||||
private const TYPES = [
|
||||
'text' => 'string', 'website' => 'string', 'phone' => 'string', 'email' => 'string',
|
||||
'password' => 'string', 'slug' => 'string', 'color' => 'string', 'oembed' => 'string',
|
||||
'paragraph' => 'text', 'wysiwyg' => 'text', 'code' => 'text',
|
||||
'number' => 'number', 'currency' => 'number',
|
||||
'boolean' => 'boolean',
|
||||
'date' => 'date', 'datetime' => 'datetime', 'time' => 'time',
|
||||
'file' => 'file', 'avatar' => 'file',
|
||||
];
|
||||
private const LAYOUT_TYPES = ['heading', 'html'];
|
||||
private const ID_TYPES = ['relation', 'file'];
|
||||
|
||||
/** @var callable(string): mixed */
|
||||
private $loadPod;
|
||||
|
||||
/** @var callable(string, int): mixed */
|
||||
private $podFor;
|
||||
|
||||
public function __construct(?callable $loadPod = null, ?callable $podFor = null)
|
||||
{
|
||||
$this->loadPod = $loadPod ?? static fn (string $name): mixed => pods_api()->load_pod(['name' => $name]);
|
||||
$this->podFor = $podFor ?? static fn (string $name, int $id): mixed => pods($name, $id);
|
||||
}
|
||||
|
||||
public static function isAvailable(): bool
|
||||
{
|
||||
return function_exists('pods_api') && function_exists('pods');
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return 'pods';
|
||||
}
|
||||
|
||||
public function fieldsFor(string $postType): array
|
||||
{
|
||||
$pod = ($this->loadPod)($postType);
|
||||
if (! is_object($pod) || $pod->get_type() !== 'post_type') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
foreach ($pod->get_fields() as $field) {
|
||||
$type = (string) $field->get_type();
|
||||
if (in_array($type, self::LAYOUT_TYPES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields[] = $this->definition($field, $type);
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
public function read(int $postId, FieldDefinition $field): mixed
|
||||
{
|
||||
$pod = ($this->podFor)((string) get_post_type($postId), $postId);
|
||||
if (! is_object($pod)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($field->type, self::ID_TYPES, true)) {
|
||||
$ids = $pod->field(['name' => $field->key, 'output' => 'ids', 'single' => ! $field->multiple]);
|
||||
if ($field->multiple) {
|
||||
return array_values(array_map('intval', array_filter((array) $ids, 'is_numeric')));
|
||||
}
|
||||
|
||||
return is_numeric($ids) ? (int) $ids : null;
|
||||
}
|
||||
|
||||
return $pod->field(['name' => $field->key, 'single' => true, 'raw' => true]);
|
||||
}
|
||||
|
||||
public function write(int $postId, string $postType, array $values, array $definitions): void
|
||||
{
|
||||
$data = [];
|
||||
foreach ($values as $key => $value) {
|
||||
$multiple = isset($definitions[$key]) && $definitions[$key]->multiple;
|
||||
$data[$key] = $value ?? ($multiple ? [] : '');
|
||||
}
|
||||
|
||||
$pod = ($this->podFor)($postType, $postId);
|
||||
if (! is_object($pod)) {
|
||||
throw new RuntimeException(sprintf('Pods object for %s #%d is not available.', $postType, $postId));
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $pod->save($data, null, null, ['error_mode' => 'exception']);
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException('Pods could not save the fields: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
if ($result === false || is_wp_error($result)) {
|
||||
throw new RuntimeException('Pods could not save the fields.');
|
||||
}
|
||||
}
|
||||
|
||||
private function definition(object $field, string $type): FieldDefinition
|
||||
{
|
||||
$normalized = self::TYPES[$type] ?? 'unknown';
|
||||
$choices = null;
|
||||
$related = null;
|
||||
|
||||
if ($type === 'pick') {
|
||||
if ((string) $field->get_arg('pick_object', '') === 'custom-simple') {
|
||||
$normalized = 'choice';
|
||||
$choices = $this->choices((string) $field->get_arg('pick_custom', ''));
|
||||
} else {
|
||||
$normalized = 'relation';
|
||||
$name = $field->get_related_object_name();
|
||||
$related = [
|
||||
'object_type' => (string) $field->get_related_object_type(),
|
||||
'name' => $name === null ? null : (string) $name,
|
||||
];
|
||||
}
|
||||
} elseif ($normalized === 'file') {
|
||||
$related = ['object_type' => 'media', 'name' => null];
|
||||
}
|
||||
|
||||
return new FieldDefinition(
|
||||
(string) $field->get_name(),
|
||||
(string) $field->get_label(),
|
||||
$normalized,
|
||||
'pods',
|
||||
$normalized === 'unknown' ? FieldDefinition::UNKNOWN : FieldDefinition::WRITABLE,
|
||||
(bool) $field->is_required(),
|
||||
(bool) $field->is_multi_value(),
|
||||
$choices,
|
||||
$related,
|
||||
(string) $field->get_arg('description', '')
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<array{value: string, label: string}> */
|
||||
private function choices(string $raw): array
|
||||
{
|
||||
$choices = [];
|
||||
foreach (preg_split('/\r\n|\r|\n/', trim($raw)) ?: [] as $line) {
|
||||
$line = trim(trim($line), '|');
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode('|', $line, 2);
|
||||
$value = trim($parts[0]);
|
||||
$choices[] = ['value' => $value, 'label' => trim($parts[1] ?? $value)];
|
||||
}
|
||||
|
||||
return $choices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Creates or removes the Pods test content type "lit_person" for integration tests.
|
||||
*
|
||||
* Usage (inside DDEV, Pods must be active):
|
||||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php create
|
||||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php delete
|
||||
*
|
||||
* No strict_types: wp eval-file runs this through eval().
|
||||
*/
|
||||
|
||||
defined('WP_CLI') && WP_CLI || exit;
|
||||
|
||||
if (! function_exists('pods_api')) {
|
||||
fwrite(STDERR, "Pods is not active\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$action = $args[0] ?? 'create';
|
||||
$api = pods_api();
|
||||
|
||||
if ($action === 'delete') {
|
||||
if ($api->load_pod(['name' => 'lit_person'])) {
|
||||
$api->delete_pod(['name' => 'lit_person'], false, true);
|
||||
}
|
||||
echo "deleted\n";
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $api->load_pod(['name' => 'lit_person'])) {
|
||||
$api->save_pod([
|
||||
'name' => 'lit_person',
|
||||
'label' => 'Personen',
|
||||
'label_singular' => 'Person',
|
||||
'type' => 'post_type',
|
||||
'storage' => 'meta',
|
||||
'public' => 1,
|
||||
'show_ui' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$fields = [
|
||||
['name' => 'role', 'label' => 'Funktion', 'type' => 'text', 'required' => 1],
|
||||
[
|
||||
'name' => 'level',
|
||||
'label' => 'Stufe',
|
||||
'type' => 'pick',
|
||||
'pick_object' => 'custom-simple',
|
||||
'pick_custom' => "junior|Junior\nsenior|Senior",
|
||||
],
|
||||
[
|
||||
'name' => 'mentor',
|
||||
'label' => 'Mentor',
|
||||
'type' => 'pick',
|
||||
'pick_object' => 'post_type',
|
||||
'pick_val' => 'lit_person',
|
||||
'pick_format_type' => 'single',
|
||||
],
|
||||
];
|
||||
foreach ($fields as $field) {
|
||||
$api->save_field(array_merge(['pod' => 'lit_person'], $field));
|
||||
}
|
||||
|
||||
echo "created\n";
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Exception;
|
||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use RuntimeException;
|
||||
|
||||
final class PodsFieldSourceTest extends TestCase
|
||||
{
|
||||
/** @param array<string, mixed> $args */
|
||||
private function field(string $name, string $type, array $args = [], array $extra = []): object
|
||||
{
|
||||
$field = Mockery::mock();
|
||||
$field->allows('get_name')->andReturn($name);
|
||||
$field->allows('get_label')->andReturn(ucfirst($name));
|
||||
$field->allows('get_type')->andReturn($type);
|
||||
$field->allows('is_required')->andReturn($extra['required'] ?? false);
|
||||
$field->allows('is_multi_value')->andReturn($extra['multi'] ?? false);
|
||||
$field->allows('get_related_object_type')->andReturn($extra['related_type'] ?? null);
|
||||
$field->allows('get_related_object_name')->andReturn($extra['related_name'] ?? null);
|
||||
$field->allows('get_arg')->andReturnUsing(static fn (string $key, $default = null) => $args[$key] ?? $default);
|
||||
|
||||
return $field;
|
||||
}
|
||||
|
||||
private function source(?object $pod): PodsFieldSource
|
||||
{
|
||||
return new PodsFieldSource(static fn (string $name) => $pod ?? false);
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> */
|
||||
private function describe(): array
|
||||
{
|
||||
$pod = Mockery::mock();
|
||||
$pod->allows('get_type')->andReturn('post_type');
|
||||
$pod->allows('get_fields')->andReturn([
|
||||
'role' => $this->field('role', 'text', ['description' => 'Funktion im Team'], ['required' => true]),
|
||||
'level' => $this->field('level', 'pick', [
|
||||
'pick_object' => 'custom-simple',
|
||||
'pick_custom' => "junior|Junior\nsenior|Senior\nplain",
|
||||
]),
|
||||
'mentor' => $this->field('mentor', 'pick', ['pick_object' => 'post_type'], [
|
||||
'related_type' => 'post_type',
|
||||
'related_name' => 'lit_person',
|
||||
]),
|
||||
'photos' => $this->field('photos', 'file', [], ['multi' => true]),
|
||||
'intro' => $this->field('intro', 'heading'),
|
||||
'mystery' => $this->field('mystery', 'fancy-addon-type'),
|
||||
]);
|
||||
|
||||
$fields = $this->source($pod)->fieldsFor('lit_person');
|
||||
|
||||
return array_column(array_map(static fn (FieldDefinition $f): array => $f->toArray(), $fields), null, 'key');
|
||||
}
|
||||
|
||||
public function testMapsPodsFieldTypes(): void
|
||||
{
|
||||
$fields = $this->describe();
|
||||
|
||||
self::assertSame(['role', 'level', 'mentor', 'photos', 'mystery'], array_keys($fields));
|
||||
self::assertSame('string', $fields['role']['type']);
|
||||
self::assertTrue($fields['role']['required']);
|
||||
self::assertSame('Funktion im Team', $fields['role']['description']);
|
||||
self::assertSame('pods', $fields['role']['source']);
|
||||
self::assertSame(FieldDefinition::WRITABLE, $fields['role']['status']);
|
||||
self::assertSame('relation', $fields['mentor']['type']);
|
||||
self::assertSame(['object_type' => 'post_type', 'name' => 'lit_person'], $fields['mentor']['related']);
|
||||
self::assertSame('file', $fields['photos']['type']);
|
||||
self::assertTrue($fields['photos']['multiple']);
|
||||
self::assertSame(['object_type' => 'media', 'name' => null], $fields['photos']['related']);
|
||||
self::assertSame('unknown', $fields['mystery']['type']);
|
||||
self::assertSame(FieldDefinition::UNKNOWN, $fields['mystery']['status']);
|
||||
}
|
||||
|
||||
public function testParsesCustomSimpleChoices(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[
|
||||
['value' => 'junior', 'label' => 'Junior'],
|
||||
['value' => 'senior', 'label' => 'Senior'],
|
||||
['value' => 'plain', 'label' => 'plain'],
|
||||
],
|
||||
$this->describe()['level']['choices']
|
||||
);
|
||||
}
|
||||
|
||||
public function testIgnoresNonPodsAndNonPostTypePods(): void
|
||||
{
|
||||
$taxonomyPod = Mockery::mock();
|
||||
$taxonomyPod->allows('get_type')->andReturn('taxonomy');
|
||||
|
||||
self::assertSame([], $this->source(null)->fieldsFor('page'));
|
||||
self::assertSame([], $this->source($taxonomyPod)->fieldsFor('genre'));
|
||||
}
|
||||
|
||||
public function testReadsRelationsAsIdsAndOtherFieldsRaw(): void
|
||||
{
|
||||
Functions\when('get_post_type')->justReturn('lit_person');
|
||||
$pod = Mockery::mock();
|
||||
$pod->expects('field')->with(['name' => 'mentor', 'output' => 'ids', 'single' => true])->andReturn('12');
|
||||
$pod->expects('field')->with(['name' => 'photos', 'output' => 'ids', 'single' => false])->andReturn(['3', '4']);
|
||||
$pod->expects('field')->with(['name' => 'role', 'single' => true, 'raw' => true])->andReturn('Leitung');
|
||||
$source = new PodsFieldSource(null, static fn (string $type, int $id) => $pod);
|
||||
|
||||
self::assertSame(12, $source->read(5, new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable')));
|
||||
self::assertSame(
|
||||
[3, 4],
|
||||
$source->read(5, new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true))
|
||||
);
|
||||
self::assertSame('Leitung', $source->read(5, new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable')));
|
||||
}
|
||||
|
||||
public function testWriteSavesThroughPodsAndMapsNullToEmpty(): void
|
||||
{
|
||||
$definitions = [
|
||||
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
|
||||
'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true),
|
||||
];
|
||||
$pod = Mockery::mock();
|
||||
$pod->expects('save')
|
||||
->with(['role' => '', 'photos' => []], null, null, ['error_mode' => 'exception'])
|
||||
->andReturn(5);
|
||||
|
||||
(new PodsFieldSource(null, static fn () => $pod))
|
||||
->write(5, 'lit_person', ['role' => null, 'photos' => null], $definitions);
|
||||
}
|
||||
|
||||
public function testWriteFailureBecomesRuntimeException(): void
|
||||
{
|
||||
$pod = Mockery::mock();
|
||||
$pod->allows('save')->andThrow(new Exception('Pods error'));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
|
||||
(new PodsFieldSource(null, static fn () => $pod))->write(5, 'lit_person', ['role' => 'x'], []);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user