Skip to content

Commit 74eddbe

Browse files
committed
Added an array type of field to store nested values.
1 parent 4d9b327 commit 74eddbe

6 files changed

Lines changed: 207 additions & 0 deletions

File tree

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(tree:*)"
5+
]
6+
}
7+
}

src/DataObject/DataSet.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class DataSet {
1515
public const TYPE_STRING = 'string';
1616
public const TYPE_INTEGER = 'int';
1717
public const TYPE_BOOLEAN = 'bool';
18+
public const TYPE_ARRAY = 'array';
1819

1920
/**
2021
* @var array
@@ -76,6 +77,21 @@ public function add_string( string $slug ) {
7677
return $this;
7778
}
7879

80+
/**
81+
* Add an array based field to the dataset.
82+
*
83+
* Array fields hold structured (nested) values such as associative maps
84+
* and are stored as-is. Their values pass through coercion untouched.
85+
*
86+
* @param String $slug the field name.
87+
*/
88+
public function add_array( string $slug ) {
89+
$this->fields[ $slug ] = [
90+
'type' => self::TYPE_ARRAY,
91+
];
92+
return $this;
93+
}
94+
7995
/**
8096
* Coerce a value to the correct type based on field definition.
8197
*
@@ -94,6 +110,7 @@ public function coerce( string $slug, mixed $value ): mixed {
94110
self::TYPE_STRING => (string) $value,
95111
self::TYPE_INTEGER => (int) $value,
96112
self::TYPE_BOOLEAN => $this->coerce_boolean( $value ),
113+
self::TYPE_ARRAY => is_array( $value ) ? $value : [],
97114
default => $value,
98115
};
99116
}

src/DataView/DataView.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ protected function build_dataset(): void {
106106
DataSet::TYPE_STRING => $this->dataset->add_string( $name ),
107107
DataSet::TYPE_INTEGER => $this->dataset->add_integer( $name ),
108108
DataSet::TYPE_BOOLEAN => $this->dataset->add_boolean( $name ),
109+
DataSet::TYPE_ARRAY => $this->dataset->add_array( $name ),
109110
};
110111
}
111112
}

src/DataView/FieldTypeRegistry.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ protected function register_default_types(): void {
7979
'schema' => [ 'type' => 'longtext' ],
8080
'input' => 'repeater',
8181
],
82+
'map' => [
83+
'dataset' => DataSet::TYPE_ARRAY,
84+
'sanitizer' => [ $this, 'sanitize_map' ],
85+
'schema' => [ 'type' => 'longtext' ],
86+
'input' => 'hidden',
87+
],
88+
'boolean_map' => [
89+
'dataset' => DataSet::TYPE_ARRAY,
90+
'sanitizer' => [ $this, 'sanitize_boolean_map' ],
91+
'schema' => [ 'type' => 'longtext' ],
92+
'input' => 'hidden',
93+
],
8294
];
8395
}
8496

@@ -252,4 +264,60 @@ protected function sanitize_repeater_rows( array $rows ): array {
252264
return $sanitized;
253265
}, $rows ) );
254266
}
267+
268+
/**
269+
* Sanitize an associative map value from POST data.
270+
*
271+
* Keeps the nested array shape (string keys mapped to scalar values),
272+
* recursing into nested maps. Values are limited to JSON-compatible
273+
* primitives; strings are run through sanitize_text_field.
274+
*
275+
* @param mixed $value Value to sanitize (associative array).
276+
* @return array Sanitized associative array.
277+
*/
278+
public function sanitize_map( mixed $value ): array {
279+
if ( ! is_array( $value ) ) {
280+
return [];
281+
}
282+
283+
$sanitized = [];
284+
foreach ( $value as $key => $item ) {
285+
$key = is_string( $key ) ? sanitize_key( $key ) : $key;
286+
287+
if ( is_array( $item ) ) {
288+
$sanitized[ $key ] = $this->sanitize_map( $item );
289+
} elseif ( is_string( $item ) ) {
290+
$sanitized[ $key ] = sanitize_text_field( $item );
291+
} elseif ( is_int( $item ) || is_float( $item ) || is_bool( $item ) || is_null( $item ) ) {
292+
$sanitized[ $key ] = $item;
293+
}
294+
// Skip any other types for security.
295+
}
296+
297+
return $sanitized;
298+
}
299+
300+
/**
301+
* Sanitize a map of boolean flags from POST data.
302+
*
303+
* Each value is coerced to a real boolean, so values submitted as the
304+
* strings "1"/"0" (e.g. from hidden form inputs) are stored as true/false
305+
* rather than truthy strings.
306+
*
307+
* @param mixed $value Value to sanitize (associative array of flags).
308+
* @return array<string, bool> Sanitized map of booleans.
309+
*/
310+
public function sanitize_boolean_map( mixed $value ): array {
311+
if ( ! is_array( $value ) ) {
312+
return [];
313+
}
314+
315+
$sanitized = [];
316+
foreach ( $value as $key => $item ) {
317+
$key = is_string( $key ) ? sanitize_key( $key ) : $key;
318+
$sanitized[ $key ] = $this->sanitize_boolean( $item );
319+
}
320+
321+
return $sanitized;
322+
}
255323
}

tests/phpunit/data-object.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,38 @@ public function test_dataset_can_add_boolean_field(): void {
8484
$this->assertEquals(DataSet::TYPE_BOOLEAN, $fields['is_active']['type']);
8585
}
8686

87+
public function test_dataset_can_add_array_field(): void {
88+
$dataset = new DataSet();
89+
$dataset->add_array('post_types');
90+
91+
$fields = $dataset->get_fields();
92+
$this->assertArrayHasKey('post_types', $fields);
93+
$this->assertEquals(DataSet::TYPE_ARRAY, $fields['post_types']['type']);
94+
}
95+
96+
public function test_dataset_coerces_array_field_as_passthrough(): void {
97+
$dataset = new DataSet();
98+
$dataset->add_array('post_types');
99+
100+
$value = ['posts' => true, 'pages' => false];
101+
$this->assertSame($value, $dataset->coerce('post_types', $value));
102+
103+
// Non-array values coerce to an empty array.
104+
$this->assertSame([], $dataset->coerce('post_types', 'nonsense'));
105+
}
106+
107+
public function test_singular_object_can_save_and_retrieve_array_value(): void {
108+
$dataset = new DataSet();
109+
$dataset->add_array('post_types');
110+
111+
$object = new SingularObject('test_settings');
112+
$object->set_dataset($dataset);
113+
114+
$value = ['posts' => true, 'pages' => false];
115+
$object->set('post_types', $value);
116+
$this->assertSame($value, $object->get('post_types'));
117+
}
118+
87119
public function test_dataset_can_add_multiple_fields(): void {
88120
$dataset = new DataSet();
89121
$dataset->add_string('title');

tests/phpunit/data-view.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,88 @@ public function test_boolean_sanitizer_works_correctly(): void {
211211
$this->assertFalse( $sanitizer( 'no' ) );
212212
}
213213

214+
public function test_field_type_registry_has_map_types(): void {
215+
$registry = new FieldTypeRegistry();
216+
217+
$this->assertTrue( $registry->has_type( 'map' ) );
218+
$this->assertTrue( $registry->has_type( 'boolean_map' ) );
219+
220+
$this->assertEquals( DataSet::TYPE_ARRAY, $registry->get_dataset_type( 'map' ) );
221+
$this->assertEquals( DataSet::TYPE_ARRAY, $registry->get_dataset_type( 'boolean_map' ) );
222+
}
223+
224+
public function test_map_sanitizer_keeps_nested_shape(): void {
225+
$registry = new FieldTypeRegistry();
226+
$sanitizer = $registry->get_sanitizer( 'map' );
227+
228+
$result = $sanitizer( [
229+
'name' => '<b>Hello</b>',
230+
'count' => 3,
231+
'nested' => [ 'flag' => true, 'label' => 'ok' ],
232+
] );
233+
234+
$this->assertSame( 'Hello', $result['name'] );
235+
$this->assertSame( 3, $result['count'] );
236+
$this->assertSame( true, $result['nested']['flag'] );
237+
$this->assertSame( 'ok', $result['nested']['label'] );
238+
}
239+
240+
public function test_map_sanitizer_returns_empty_array_for_non_array(): void {
241+
$registry = new FieldTypeRegistry();
242+
$sanitizer = $registry->get_sanitizer( 'map' );
243+
244+
$this->assertSame( [], $sanitizer( 'not-an-array' ) );
245+
$this->assertSame( [], $sanitizer( null ) );
246+
}
247+
248+
public function test_boolean_map_sanitizer_coerces_values_to_real_booleans(): void {
249+
$registry = new FieldTypeRegistry();
250+
$sanitizer = $registry->get_sanitizer( 'boolean_map' );
251+
252+
// Values arrive as "1"/"0" strings from hidden form inputs.
253+
$result = $sanitizer( [
254+
'posts' => '1',
255+
'pages' => '0',
256+
'media' => 'on',
257+
'groups' => '',
258+
] );
259+
260+
$this->assertSame( true, $result['posts'] );
261+
$this->assertSame( false, $result['pages'], 'String "0" must become real false, not a truthy string' );
262+
$this->assertSame( true, $result['media'] );
263+
$this->assertSame( false, $result['groups'] );
264+
}
265+
266+
public function test_array_field_round_trips_through_dataview_and_option_storage(): void {
267+
// Proves the TYPE_ARRAY primitive: a nested array value passes through
268+
// coercion untouched and is persisted as a nested array by OptionStorage.
269+
// (Boolean coercion is the sanitizer's job, exercised in extract_post_data;
270+
// here the handler receives already-sanitized values.)
271+
$config = [
272+
'slug' => 'dv_test_map_roundtrip',
273+
'label' => 'Settings',
274+
'mode' => 'singular',
275+
'storage' => 'option',
276+
'fields' => [
277+
'post_types' => 'boolean_map',
278+
],
279+
];
280+
281+
$view = new DataView( $config );
282+
$handler = $view->get_handler();
283+
284+
$result = $handler->update( [
285+
'post_types' => [ 'posts' => true, 'pages' => false ],
286+
] );
287+
288+
$this->assertTrue( $result->is_success() );
289+
290+
$stored = get_option( 'dv_test_map_roundtrip' );
291+
$this->assertIsArray( $stored['post_types'] );
292+
$this->assertSame( true, $stored['post_types']['posts'] );
293+
$this->assertSame( false, $stored['post_types']['pages'] );
294+
}
295+
214296
public function test_field_type_registry_has_repeater_type(): void {
215297
$registry = new FieldTypeRegistry();
216298

0 commit comments

Comments
 (0)