From 4a652240a39100cd3d18909742583a8f750536f4 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 22:13:41 +0200 Subject: [PATCH] feat: Pods field source for schema detection and writes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Pods/PodsFieldSource.php | 169 ++++++++++++++++++ tests/Integration/pods-fixture.php | 65 +++++++ .../Integrations/Pods/PodsFieldSourceTest.php | 144 +++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 src/Integrations/Pods/PodsFieldSource.php create mode 100644 tests/Integration/pods-fixture.php create mode 100644 tests/Unit/Integrations/Pods/PodsFieldSourceTest.php diff --git a/src/Integrations/Pods/PodsFieldSource.php b/src/Integrations/Pods/PodsFieldSource.php new file mode 100644 index 0000000..2cc0b1c --- /dev/null +++ b/src/Integrations/Pods/PodsFieldSource.php @@ -0,0 +1,169 @@ +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 */ + 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; + } +} diff --git a/tests/Integration/pods-fixture.php b/tests/Integration/pods-fixture.php new file mode 100644 index 0000000..b5a9b5c --- /dev/null +++ b/tests/Integration/pods-fixture.php @@ -0,0 +1,65 @@ +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"; diff --git a/tests/Unit/Integrations/Pods/PodsFieldSourceTest.php b/tests/Unit/Integrations/Pods/PodsFieldSourceTest.php new file mode 100644 index 0000000..fe6d779 --- /dev/null +++ b/tests/Unit/Integrations/Pods/PodsFieldSourceTest.php @@ -0,0 +1,144 @@ + $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> */ + 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'], []); + } +}