PluginProbe ʕ •ᴥ•ʔ
Secure Custom Fields / 6.9.1
Secure Custom Fields v6.9.1
6.9.1 6.9.0 6.8.9 6.8.7 6.8.8 6.8.6 6.8.4 6.8.5 trunk 6.4.0-beta1 6.4.0-beta2 6.4.1 6.4.1-beta3 6.4.1-beta4 6.4.1-beta5 6.4.1-beta6 6.4.1-beta7 6.4.2 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.6.0 6.7.0 6.7.1 6.8.0 6.8.1 6.8.2 6.8.3
secure-custom-fields / includes / class-scf-schema-builder.php
secure-custom-fields / includes Last commit date
Blocks 1 week ago Datastore 1 month ago Meta 1 year ago abilities 1 week ago admin 1 week ago ajax 1 month ago api 2 days ago fields 2 days ago forms 2 days ago legacy 1 year ago locations 1 year ago post-types 2 months ago rest-api 1 week ago walkers 1 year ago acf-bidirectional-functions.php 1 year ago acf-field-functions.php 2 months ago acf-field-group-functions.php 7 months ago acf-form-functions.php 1 year ago acf-helper-functions.php 1 year ago acf-hook-functions.php 1 year ago acf-input-functions.php 7 months ago acf-internal-post-type-functions.php 7 months ago acf-meta-functions.php 3 weeks ago acf-post-functions.php 1 year ago acf-post-type-functions.php 1 year ago acf-taxonomy-functions.php 1 year ago acf-user-functions.php 1 week ago acf-utility-functions.php 1 year ago acf-value-functions.php 1 year ago acf-wp-functions.php 2 days ago assets.php 1 week ago blocks-auto-inline-editing.php 2 months ago blocks.php 3 weeks ago class-acf-data.php 10 months ago class-acf-internal-post-type.php 1 week ago class-acf-options-page.php 1 year ago class-acf-site-health.php 3 months ago class-scf-json-schema-validator.php 6 months ago class-scf-schema-builder.php 2 months ago compatibility.php 1 year ago datastore.php 1 month ago deprecated.php 1 year ago fields.php 10 months ago index.php 1 year ago l10n.php 1 year ago local-fields.php 1 year ago local-json.php 1 month ago local-meta.php 1 year ago locations.php 1 year ago loop.php 10 months ago media.php 1 year ago rest-api.php 10 months ago revisions.php 1 month ago scf-ui-options-page-functions.php 1 year ago third-party.php 7 months ago upgrades.php 3 weeks ago validation.php 10 months ago wpml.php 1 year ago
class-scf-schema-builder.php
308 lines
1 <?php
2 /**
3 * Schema Builder for SCF
4 *
5 * Handles JSON Schema operations like $ref resolution and schema composition.
6 *
7 * @package SCF
8 */
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12 }
13
14 if ( ! class_exists( 'SCF_Schema_Builder' ) ) :
15
16 /**
17 * SCF Schema Builder
18 *
19 * Builds composed field schemas and resolves $ref for WordPress.
20 *
21 * Why $ref resolution:
22 * - WordPress internal validation doesn't understand JSON Schema $ref
23 * - We inline referenced definitions before passing schemas to WP
24 *
25 * Why oneOf composition:
26 * - Field validation requires type-specific rules (text has maxlength, number has min/max)
27 * - Base properties (key, label, name, type, parent) are shared across all types
28 * - oneOf validates "valid text field OR valid number field OR ..."
29 * - Each variant merges base + type-specific properties with additionalProperties: false
30 * - Fallback variant allows unknown types until all 35 field types have schemas
31 *
32 * Schema structure:
33 * - schemas/field-fragments/field-base.schema.json: Base properties shared by all types
34 * - schemas/field-fragments/{category}/{type}.schema.json: Type-specific properties
35 *
36 * @since 6.8.0
37 */
38 class SCF_Schema_Builder {
39
40 /**
41 * Cached composed field schema.
42 *
43 * @var array|null
44 */
45 private ?array $composed_field_schema = null;
46
47 /**
48 * Cached base field schema.
49 *
50 * @var array|null
51 */
52 private ?array $base_schema = null;
53
54 /**
55 * Max recursion depth for $ref resolution. Guards against circular refs.
56 */
57 private const MAX_REF_DEPTH = 32;
58
59 /**
60 * Recursively resolves $ref references in a JSON schema.
61 *
62 * WordPress internal validation doesn't understand JSON Schema $ref,
63 * so we inline referenced definitions before passing schemas to WP.
64 *
65 * Supports two ref formats:
66 * - Internal refs: #/definitions/foo (resolved from root_schema)
67 * - Relative file refs: file.schema.json#/definitions/foo (loaded from base_path)
68 *
69 * Cycle detection uses two guards:
70 * - $visited tracks external-file ref strings on the current resolution chain
71 * so cycles (A -> B -> A) abort cleanly without exhausting the stack.
72 * - $depth is a belt-and-suspenders upper bound, only incremented when
73 * recursing into a resolved external-file $ref (not on plain structural
74 * descent into properties, items, etc.), so deeply nested schemas don't
75 * trip the cycle guard prematurely.
76 *
77 * @since 6.8.0
78 *
79 * @param array $schema The schema to resolve.
80 * @param array|null $root_schema The root schema containing definitions. If null, uses $schema.
81 * @param string|null $base_path Base path for loading external schema files. Defaults to schemas/.
82 * @param int $depth Internal external-ref recursion depth counter.
83 * @param array $visited External-file ref strings seen on the current resolution chain.
84 * @return array The resolved schema.
85 */
86 public function resolve_refs( array $schema, ?array $root_schema = null, ?string $base_path = null, int $depth = 0, array $visited = array() ): array {
87 if ( $depth >= self::MAX_REF_DEPTH ) {
88 return $schema;
89 }
90
91 $root_schema = $root_schema ?? $schema;
92 $definitions = $root_schema['definitions'] ?? array();
93 $base_path = $base_path ?? acf_get_path( 'schemas/' );
94 $base_path = rtrim( $base_path, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;
95
96 if ( isset( $schema['$ref'] ) ) {
97 $ref = $schema['$ref'];
98 $resolved = null;
99 $ref_signature = null;
100
101 // Internal ref: #/definitions/path/to/def
102 if ( preg_match( '~^#/definitions/(.+)$~', $ref, $matches ) ) {
103 $resolved = $definitions;
104 foreach ( explode( '/', $matches[1] ) as $part ) {
105 $resolved = $resolved[ $part ] ?? null;
106 }
107 } elseif ( preg_match( '~^([^#]+)#/definitions/(.+)$~', $ref, $matches ) && false === strpos( $matches[1], "\0" ) ) {
108 // Relative file ref: file.schema.json#/definitions/path/to/def.
109 // Contain the resolved path to $base_path to block traversal via ../ or absolute paths.
110 $candidate = $base_path . $matches[1];
111 $real_base = realpath( $base_path );
112 $real_file = realpath( $candidate );
113 $def_path = $matches[2];
114
115 $within_base = false !== $real_base
116 && false !== $real_file
117 && 0 === strpos( $real_file, rtrim( $real_base, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR );
118
119 if ( $within_base && is_file( $real_file ) ) {
120 // Signature uses the resolved real file path so equivalent refs collapse to the same key.
121 $ref_signature = $real_file . '#/definitions/' . $def_path;
122
123 // Abort unresolved if this external ref is already on the current resolution chain.
124 if ( in_array( $ref_signature, $visited, true ) ) {
125 return $schema;
126 }
127
128 $external_content = file_get_contents( $real_file );
129 $external_schema = json_decode( $external_content, true );
130
131 if ( is_array( $external_schema ) ) {
132 $resolved = $external_schema['definitions'] ?? array();
133 foreach ( explode( '/', $def_path ) as $part ) {
134 $resolved = $resolved[ $part ] ?? null;
135 }
136 }
137 }
138 }
139
140 if ( is_array( $resolved ) ) {
141 unset( $schema['$ref'] );
142
143 // Only bump depth and extend visited on external-file refs — internal refs
144 // resolve against already-loaded definitions and don't open new files.
145 if ( null !== $ref_signature ) {
146 $next_visited = $visited;
147 $next_visited[] = $ref_signature;
148 return array_merge( $this->resolve_refs( $resolved, $root_schema, $base_path, $depth + 1, $next_visited ), $schema );
149 }
150
151 return array_merge( $this->resolve_refs( $resolved, $root_schema, $base_path, $depth, $visited ), $schema );
152 }
153 }
154
155 foreach ( $schema as $key => $value ) {
156 if ( is_array( $value ) ) {
157 // Structural descent: don't bump $depth, just thread the visited set through.
158 $schema[ $key ] = $this->resolve_refs( $value, $root_schema, $base_path, $depth, $visited );
159 }
160 }
161
162 return $schema;
163 }
164
165 /**
166 * Merges base and type-specific schema properties.
167 *
168 * Uses array_merge for nested arrays: fragment values overwrite base values,
169 * but base-only keys are preserved (e.g., base's "type": "string" is kept
170 * when fragment only provides "enum").
171 *
172 * @since 6.8.0
173 *
174 * @param array $base_props Base properties from field-base.schema.json.
175 * @param array $type_props Type-specific properties from fragment files.
176 * @return array Merged properties.
177 */
178 private function merge_schema_properties( array $base_props, array $type_props ): array {
179 $merged = $base_props;
180
181 foreach ( $type_props as $prop_name => $prop_value ) {
182 if ( isset( $merged[ $prop_name ] ) && is_array( $merged[ $prop_name ] ) && is_array( $prop_value ) ) {
183 // Merge arrays: fragment values overwrite base, but base-only keys preserved.
184 $merged[ $prop_name ] = array_merge( $merged[ $prop_name ], $prop_value );
185 } else {
186 $merged[ $prop_name ] = $prop_value;
187 }
188 }
189
190 return $merged;
191 }
192
193 /**
194 * Composes a field schema with oneOf containing all type variants.
195 *
196 * Each variant merges base field properties with type-specific properties,
197 * enabling complete validation without schema duplication in source files.
198 *
199 * @since 6.8.0
200 *
201 * @return array The composed schema with oneOf variants.
202 */
203 public function compose_field_schema(): array {
204 if ( null !== $this->composed_field_schema ) {
205 return $this->composed_field_schema;
206 }
207
208 // Load and resolve base field schema.
209 $base_schema = $this->load_base_field_schema();
210 $base_def = $base_schema['definitions']['field'] ?? array();
211 $base_props = $base_def['properties'] ?? array();
212
213 // Build oneOf variants for each field type with a schema.
214 $variants = array();
215 $type_schemas = $this->load_type_schemas();
216
217 foreach ( $type_schemas as $type_schema ) {
218 $type_props = $type_schema['properties'] ?? array();
219
220 $variants[] = array(
221 'type' => 'object',
222 'required' => array( 'key', 'label', 'name', 'type', 'parent' ),
223 'properties' => $this->merge_schema_properties( $base_props, $type_props ),
224 'additionalProperties' => $type_schema['additionalProperties'] ?? false,
225 );
226 }
227
228 $this->composed_field_schema = array(
229 'oneOf' => $variants,
230 );
231
232 return $this->composed_field_schema;
233 }
234
235 /**
236 * Loads the base field schema without resolving refs.
237 *
238 * Refs are kept intact so the generated field.schema.json stays compact.
239 * Consumers (like Field Abilities) resolve refs at runtime when needed.
240 *
241 * @since 6.8.0
242 *
243 * @return array The base field schema with refs intact.
244 */
245 private function load_base_field_schema(): array {
246 if ( null === $this->base_schema ) {
247 $schema_path = ACF_PATH . 'schemas/field-fragments/field-base.schema.json';
248 $this->base_schema = json_decode( file_get_contents( $schema_path ), true );
249 }
250
251 return $this->base_schema;
252 }
253
254 /**
255 * Loads all type-specific field schemas from category directories.
256 *
257 * Scans schemas/field-fragments/{category}/ directories for type schema files.
258 *
259 * @since 6.8.0
260 *
261 * @return array Associative array of type => schema data.
262 */
263 private function load_type_schemas(): array {
264 $schemas = array();
265 $fields_path = ACF_PATH . 'schemas/field-fragments/';
266 $category_dirs = glob( $fields_path . '*', GLOB_ONLYDIR );
267
268 if ( ! is_array( $category_dirs ) ) {
269 return $schemas;
270 }
271
272 foreach ( $category_dirs as $category_path ) {
273 // Scan schema files in this category.
274 $files = glob( $category_path . '/*.schema.json' );
275 if ( ! is_array( $files ) ) {
276 continue;
277 }
278
279 foreach ( $files as $file ) {
280 $content = file_get_contents( $file );
281 if ( false === $content ) {
282 continue;
283 }
284
285 $schema = json_decode( $content, true );
286 if ( ! $schema ) {
287 continue;
288 }
289
290 // Extract field type from schema or filename.
291 $type = $schema['properties']['type']['enum'][0]
292 ?? basename( $file, '.schema.json' );
293
294 $schemas[ $type ] = $schema;
295 }
296 }
297
298 return $schemas;
299 }
300 }
301
302 // Initialize only in WordPress context, not in the CLI.
303 if ( function_exists( 'acf_new_instance' ) ) {
304 acf_new_instance( 'SCF_Schema_Builder' );
305 }
306
307 endif;
308