$npx -y skills add Lonsdale201/wp-agent-skills --skill br-resource-tableBuild better-route 1.1 CRUD endpoints over a custom WordPress database table with Resource::make, restNamespace, sourceTable, primary key, fields, filters, sort, filterSchema, writeSchema, policy, pagination, allow, SQL NULL writes, stable ordering, or a custom TableRepositoryInt
| 1 | # better-route: custom-table Resource CRUD |
| 2 | |
| 3 | Custom tables have no WordPress visibility model, so all actions deny by default until an explicit policy is supplied. |
| 4 | |
| 5 | ```php |
| 6 | use BetterRoute\Resource\Resource; |
| 7 | use BetterRoute\Resource\ResourcePolicy; |
| 8 | |
| 9 | add_action('rest_api_init', static function (): void { |
| 10 | global $wpdb; |
| 11 | |
| 12 | Resource::make('audit-events') |
| 13 | ->restNamespace('myapp/v1') |
| 14 | ->sourceTable($wpdb->prefix . 'myapp_audit_events', 'id') |
| 15 | ->allow(['list', 'get']) |
| 16 | ->fields(['id', 'event_type', 'user_id', 'created_at']) |
| 17 | ->filters(['event_type', 'user_id']) |
| 18 | ->filterSchema(['user_id' => 'int']) |
| 19 | ->sort(['created_at', 'id']) |
| 20 | ->policy(ResourcePolicy::adminOnly('manage_options')) |
| 21 | ->register(); |
| 22 | }); |
| 23 | ``` |
| 24 | |
| 25 | Do not pass a Router to `register()`. The optional argument is a dispatcher for tests/custom integration. |
| 26 | |
| 27 | ## Required configuration |
| 28 | |
| 29 | - Set `restNamespace('vendor/version')`. |
| 30 | - Set exactly one of `sourceTable()` or `sourceCpt()`; 1.1 rejects both. |
| 31 | - Set a non-empty `fields()` list for table resources. |
| 32 | - Set a policy. Without it, list/get/create/update/delete all return 403. |
| 33 | |
| 34 | Omitting `allow()` registers full CRUD. `allow([])` registers no routes. Invalid action names throw. `update` creates both PUT and PATCH routes. |
| 35 | |
| 36 | ## Table name and primary key |
| 37 | |
| 38 | `WpdbAdapter` accepts either the current full prefixed table name or an unprefixed suffix. When the supplied name does not start with current `$wpdb->prefix`, the adapter prepends it. Prefer the explicit multisite-safe form: |
| 39 | |
| 40 | ```php |
| 41 | ->sourceTable($wpdb->prefix . 'myapp_events', 'event_id') |
| 42 | ``` |
| 43 | |
| 44 | Do not pass a different database/table qualified name; `.` is rejected. Identifiers must be simple SQL identifiers. |
| 45 | |
| 46 | The second argument is the database primary-key column. The generated WordPress route uses `/(?P<id>\d+)`; its `id` value maps to that column, while OpenAPI renders the parameter as `{id}`. |
| 47 | |
| 48 | ## Sorting and pagination |
| 49 | |
| 50 | `sort()` is an allowlist of field names; it does not set a default direction: |
| 51 | |
| 52 | ```php |
| 53 | ->sort(['created_at', 'id']) |
| 54 | // ?sort=created_at => ASC |
| 55 | // ?sort=-created_at => DESC |
| 56 | ``` |
| 57 | |
| 58 | Do not include `-created_at` in the allowlist. When no sort query is supplied, the adapter orders by the primary key ascending. When sorting by another field, 1.1 adds the primary key in the same direction as a deterministic tie-breaker. |
| 59 | |
| 60 | Configure pagination in any fluent order. At registration the final values must satisfy `defaultPerPage <= maxPerPage`, both positive, and non-negative `maxOffset`. Oversized page/offset input returns `400 validation_failed`, not a silent clamp. |
| 61 | |
| 62 | Strict list parsing accepts WordPress global REST parameters `_locale`, `_fields`, `_embed`, `_envelope`, and `_jsonp`; other unrecognized parameters fail. |
| 63 | |
| 64 | ## Filters and SQL NULL |
| 65 | |
| 66 | Only allowlisted filter columns become SQL predicates. In 1.1 a null filter value becomes `IS NULL` rather than an equality against an empty string. |
| 67 | |
| 68 | On create/update, a nullable field with payload `null` is written as real SQL `NULL`: |
| 69 | |
| 70 | ```php |
| 71 | ->writeSchema([ |
| 72 | 'event_type' => ['type' => 'string', 'required' => true], |
| 73 | 'user_id' => ['type' => 'int', 'nullable' => true], |
| 74 | ]) |
| 75 | ``` |
| 76 | |
| 77 | The adapter validates every table/column identifier, uses prepared bindings for values, rejects structured array/object column values, and allowlists writable fields. |
| 78 | |
| 79 | ## Deletion and custom repositories |
| 80 | |
| 81 | The default table repository performs a physical `DELETE`. Resource `deleteMode('trash')` is meaningful for CPT repositories only and does not create table soft-delete semantics. Implement a custom `TableRepositoryInterface` for `deleted_at`, joins, aggregates, tenant constraints, or storage-level optimistic updates: |
| 82 | |
| 83 | ```php |
| 84 | ->usingTableRepository(new MyTableRepository()) |
| 85 | ``` |
| 86 | |
| 87 | Row-level authorization must be enforced by policy plus repository/query constraints. `fieldPolicy` protects writes to fields; it does not filter list rows. |
| 88 | |
| 89 | ## Response and OpenAPI |
| 90 | |
| 91 | Lists return `{data, meta}`; create/update return `{data}`; a single get is raw unless `uniformEnvelope(true)` is enabled. After registration, use `contracts()` for OpenAPI export and provide the matching response-envelope schemas in strict mode. |
| 92 | |
| 93 | ## Review checklist |
| 94 | |
| 95 | - Use current `$wpdb->prefix` and reject cross-database identifiers. |
| 96 | - Expose only an ex |