Install
$ agentstack add skill-lonsdale201-wp-agent-skills-bd-source-adapter ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
better-data: Adding a source adapter
For library maintainers integrating a new WordPress data store with better-data — comments, attachments, transients, custom tables, taxonomy hierarchies. Sources hydrate stored data into typed DataObjects via AttributeDrivenHydrator; the work is wiring up the bridge between WP's storage idioms and the hydrator's closure contract.
Misconception this skill corrects
> "I'll call get_post_meta($id, $key, true) directly — empty string means 'not set'."
Wrong. WordPress get_meta($key, true) returns '' (empty string) for both "key does not exist" AND "key exists with empty-string value". The AttributeDrivenHydrator distinguishes these because the default-value fallback at [src/Internal/AttributeDrivenHydrator.php:90-95](AttributeDrivenHydrator.php) needs to know "missing → use the parameter's default" vs "present-but-empty → coerce as empty string". Without the distinction, every nullable meta-backed field with a non-null default falls back incorrectly.
The contract: the closure you pass to the hydrator returns:
null→ key does not exist (hydrator falls back to parameter default / parameter nullable)- the stored value (including
'') → key exists, hydrator coerces
Verified pattern from [src/Source/PostSource.php:91-93](PostSource.php):
if (\function_exists('metadata_exists')
&& !\metadata_exists('post', $postId, $key)) {
return null; // key does not exist
}
return \get_post_meta($postId, $key, true); // exists; could be ''
Other AI-prone misconceptions:
- "I'll instantiate
WP_Userdirectly withnew WP_User($id)in a hot loop." Wrong — bypasses object cache. Useget_user_by('id', $id)so the per-request cache participates. - "Bulk hydration is just
array_map($id => hydrate($id))." Wrong — without prewarming, that's N+1 queries. Always callupdate_meta_cache(...)(and_prime_post_cachesfor posts) first. - "I'll write the WP-touching code in
src/Internal/." Wrong —src/Internal/is the WP-free engine zone (so it's unit-testable without a WP runtime). Source adapters live insrc/Source/and CAN call WP functions.
When to use this skill
Trigger when ANY of the following is true:
- Creating a new file under
src/Source/. - The diff adds a
hydrate(...)/hydrateMany(...)method that reads from a WP table. - Adding a
::fromComment($id)/::fromAttachment($id)etc. shortcut toHasWpSources. - Reviewing a PR that calls
get_*_meta($key, true)without ametadata_existsguard inside a source.
Workflow
1. File location
Public source adapter: src/Source/Source.php. The pure, WP-free engine that does the attribute-driven work already lives in src/Internal/AttributeDrivenHydrator.php — DON'T duplicate it. Your job is to feed the hydrator a fetcher closure.
2. Method shape — match the existing sources
Every source has the same two static methods:
public static function hydrate(int|\WP_Comment $record, string $dtoClass): DataObject;
/**
* @template T of DataObject
* @param list $ids
* @param class-string $dtoClass
* @return list
*/
public static function hydrateMany(array $ids, string $dtoClass): array;
Why static: the source has no per-instance state. Why this exact shape: HasWpSources wires Dto::fromComment($id) to CommentSource::hydrate($id, static::class); deviating breaks the trait contract.
3. The fetcher closure contract
Every source ends up calling (or fakes a call to) AttributeDrivenHydrator::hydrate($dtoClass, $primary, $metaFetcher). The third arg is a closure:
$metaFetcher = static function (string $key) use ($commentId): mixed {
if (\function_exists('metadata_exists')
&& !\metadata_exists('comment', $commentId, $key)) {
return null; // CRUCIAL — distinguishes missing from empty
}
return \get_comment_meta($commentId, $key, true);
};
Without the metadata_exists guard, the closure returns '' for both missing and stored-empty, and the hydrator can't apply default-fallback logic correctly.
4. Bulk path: prewarm caches first
public static function hydrateMany(array $commentIds, string $dtoClass): array
{
if ($commentIds === []) {
return [];
}
// Prewarm WP's per-request comment cache.
if (\function_exists('_prime_comment_caches')) {
\_prime_comment_caches($commentIds);
}
// Prewarm meta cache (single SELECT for all comments × all keys).
if (\function_exists('update_meta_cache')) {
\update_meta_cache('comment', $commentIds);
}
return \array_map(
static fn (int $id): DataObject => self::hydrate($id, $dtoClass),
$commentIds,
);
}
Result: N comments cost 1 prime + 1 meta + N hydrate calls (cache hits) — instead of N comments × (1 SELECT comment + N SELECT meta) = O(N²).
5. Add a HasWpSources shortcut
If the source maps to a natural shortcut, add a method on the trait at [src/Source/HasWpSources.php:28](HasWpSources.php):
// Inside trait HasWpSources:
public static function fromComment(int|\WP_Comment $comment): static
{
/** @var static */
return CommentSource::hydrate($comment, static::class);
}
DTO authors then write:
final readonly class CommentDto extends DataObject
{
use HasWpSources;
// ...
}
$dto = CommentDto::fromComment($commentId);
Don't add a shortcut for niche sources — RowSource doesn't have one because raw row hydration is intentionally call-site-explicit.
6. Exception strategy
Throw a typed exception when the primary record doesn't exist. Mirror [src/Exception/PostNotFoundException.php](PostNotFoundException.php):
namespace BetterData\Exception;
final class CommentNotFoundException extends \RuntimeException
{
public static function forId(string $dtoClass, int $id): self
{
return new self(\sprintf('No comment found for %s with ID %d', $dtoClass, $id));
}
}
7. Testing
Two test layers:
tests/Unit/CommentSourceTest.php ← pure engine via fake fetcher closure
tests/Fixtures/CommentDtoFixture.php ← realistic DTO shape
companion plugin: src/Stress/CommentScenario.php ← live-WP behavior
Unit tests don't require WP — pass a hand-crafted closure to AttributeDrivenHydrator::hydrate directly. Stress tests against a live WP install verify cache primings and metadata_exists behavior in the real environment.
vendor/bin/phpunit --filter CommentSource
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/php-cs-fixer fix
wp better-data stress # in companion plugin context
Critical rules
- Lives in
src/Source/, notsrc/Internal/.Internalstays WP-free. hydrate(int|, string $dtoClass): DataObject+hydrateMany(list, string $dtoClass): list— exact two-method shape every source uses.- Meta fetcher closure: return
nullfor missing, stored value otherwise. Usemetadata_exists(or equivalent) before fetching. Empty string is NOT missing. - Bulk path prewarms caches.
_prime_*_cachesfor the primary objects,update_meta_cache(...)for meta. Without it, you've shipped O(N²). - Use
get_user_by('id', $id), notnew WP_User($id)in user-related sources — object cache participation. - Wrap WP function calls in
function_existsif the source is unit-tested without WP. Allows tests to importSourcewithout bootstrapping WP. - Throw a typed exception when the primary record is missing (
PostNotFoundException-style). Don't return null fromhydrate— the caller can't tell hydration-of-missing from hydration-of-empty. - Add the
HasWpSourcesshortcut if the source has a natural ergonomic call site (most do;RowSourceis the exception).
Common mistakes
// WRONG — get_post_meta with empty-string ambiguity
$metaFetcher = static fn (string $key): mixed => \get_post_meta($postId, $key, true);
// Returns '' for missing AND stored-empty — defaults break.
// RIGHT
$metaFetcher = static function (string $key) use ($postId): mixed {
if (\function_exists('metadata_exists')
&& !\metadata_exists('post', $postId, $key)) {
return null;
}
return \get_post_meta($postId, $key, true);
};
// WRONG — N+1 in hydrateMany
public static function hydrateMany(array $ids, string $dtoClass): array
{
return \array_map(
static fn (int $id) => self::hydrate($id, $dtoClass),
$ids,
);
}
// Each hydrate call hits cold caches.
// RIGHT — prewarm first
\_prime_comment_caches($ids);
\update_meta_cache('comment', $ids);
return \array_map(...);
// WRONG — new WP_User in a loop
foreach ($userIds as $id) {
$user = new \WP_User($id); // bypasses object cache
// ...
}
// RIGHT
\update_meta_cache('user', $userIds);
foreach ($userIds as $id) {
$user = \get_user_by('id', $id);
// ...
}
// WRONG — silent on missing record
public static function hydrate(int $id, string $dtoClass): DataObject
{
$comment = \get_comment($id);
if ($comment === null) {
return $dtoClass::fromArray([]); // 🔴 hydrates an empty DTO; caller can't tell
}
// ...
}
// RIGHT — typed exception
if ($comment === null) {
throw CommentNotFoundException::forId($dtoClass, $id);
}
// WRONG — engine call inside src/Internal/
// File: src/Internal/CommentEngine.php
\update_meta_cache('comment', $ids); // 🔴 src/Internal/ must be WP-free for unit testability
// RIGHT — keep WP calls in src/Source/
// (the WP-free attribute-driven hydrator already lives in src/Internal/)
Cross-references
- Run
bd-sinkwhen also writing back to the same store — sources and sinks usually ship as a pair. - Run
bd-attributeif the new source needs a NEW attribute (e.g.#[CommentField]) — the attribute and source must be wired together. - Run
bd-data-objectwhen adding the DTO that the source hydrates — DTO + source design typically co-evolve.
What this skill does NOT cover
- Custom hydration logic beyond the attribute-driven path. If your source needs deep custom branching (e.g. building a tree from term hierarchy), that goes inside the source as helper logic, but the entry shape stays
hydrate / hydrateMany. - Replacing
AttributeDrivenHydrator. The hydrator is intentionally one engine; new behaviors are attributes, not parallel hydrators. - Performance optimization beyond cache prewarming (sharding queries, lazy hydration). Most sources don't need it.
- Caching the hydrated DTO itself — that's a consumer concern. Sources hydrate fresh each call.
- Sourcing from non-WP systems (REST API, GraphQL, external DB). Possible architecturally but outside this skill's WP-store scope.
References
- Reference source (full pattern): [libraries/better-data/src/Source/PostSource.php](PostSource.php) — hydrate at line 64, hydrateMany with
_prime_post_caches+update_meta_cacheat line 117-124,metadata_existsguard at line 91-93. - Trait shortcut: [libraries/better-data/src/Source/HasWpSources.php:28](HasWpSources.php) —
::fromPost,::fromUser,::fromTerm,::fromOption,::fromRow. - Hydrator engine: [libraries/better-data/src/Internal/AttributeDrivenHydrator.php](AttributeDrivenHydrator.php) — pure, WP-free; takes a fetcher closure and produces a hydrated DTO.
- Default-fallback predicate: [libraries/better-data/src/Internal/AttributeDrivenHydrator.php:90-95](AttributeDrivenHydrator.php) — the
null-vs-value distinction in the fetcher matters here. - Other sources: [libraries/better-data/src/Source/UserSource.php](UserSource.php), [libraries/better-data/src/Source/TermSource.php](TermSource.php), [libraries/better-data/src/Source/OptionSource.php](OptionSource.php), [libraries/better-data/src/Source/RowSource.php](RowSource.php), [libraries/better-data/src/Source/RequestSource.php](RequestSource.php).
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Lonsdale201
- Source: Lonsdale201/wp-agent-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.