Metadata API Usage
This page documents every metadata key the generator reads, where it reads it from, and what it produces out of it.
1. Metadata Entities
To get the list of all the metadata entities the generator calls {{BASE_URL}}/{{API_VERSION}}/metadata/entities and reads data.entities, which is the array of FQCNs.
This entity path is the folder key for everything:
config/versioned/json/{entityPath}/{version}/
config/versioned/tsx/{entityPath}/{version}/This API is only called by the …All commands (generateJson all, generateTsx all, validateAll). For a single entity we pass --entity=AccountingBundle/Account and the FQCN is built locally by encodeEntityName(), so there is no need to call the entities list.
See also: Commands & CLI Flags.
2. Metadata Entity
For one entity we are not calling /metadata/entity/{FQCN} once but 4 times with different query strings, because each call returns a different set of fields.
1st call — no query string
From this one we take: fields, unfiltered_fields, virtual_properties, displayable_properties, resourceName, class_object_data.tableName, class_object_data.implemented_interfaces, availableSerializationGroups, writable_properties, updatable_properties, writable_required_properties / required_properties, apiFilters and operations.
2nd call — Show groups
?groups[]=… — from this we merge fields + virtual_properties and it becomes our showFields.
3rd call — Listing groups
?groups[]=… — same thing, fields + virtual_properties merged, and it becomes our listingFields.
4th call — ?requiredOnly=true
From this we take required_properties and fields to build the requiredFields.
The 1st and 4th calls are running in parallel, and after that the 2nd and 3rd are running in parallel, because they need availableSerializationGroups which comes from the 1st call.
Whatever comes from these calls is saved as it is in data.json. After that
the TSX layer is only reading data.json — the API is never called again
during generateTsx.
3. Serialization Groups
First we fetch availableSerializationGroups and then createEntitySerialization() in utils/functions.js decides which groups to send.
The convention is {bundle}{Entity}{Suffix}, with the first letter in lowercase and the bundle without its Bundle suffix … so AccountingBundle/Account + Show becomes accountingAccountShow.
If this group is not present in availableSerializationGroups then it falls back in this order:
{entity}{Suffix} → {bundle}{Suffix} → listing → showAfter that a few more groups are appended if they exist:
- Show:
show,standardShow - Listing:
standardListing,tagTaggingList,id,listing,{entity}tagging{Suffix},{entity}taggingList,{group}tagging{Suffix}
The final query string is built in the array style:
?groups[]=accountingAccountShow&groups[]=show&groups[]=standardShowThe old ?group=a,b style is still there in the code but commented, for the CRUDs which are not migrated yet.
The matching is case-insensitive, so tagtagginglist written in the code is
still matching tagTaggingList coming from the API.
4. Resource Name
resourceName is the main source of truth for the generator as well. It is printed in the tsx as const resource: string = 'accounts'.
There are a few actions which are switched directly on the resourceName:
| resourceName | Condition | Action |
|---|---|---|
users | — | crud-link |
users | accountCrud | send-invite |
dashboard_reports | — | report |
dashboard_widgets | — | widget |
dataanalysis_datacollectors | — | data-collector |
dataanalysis_dataprocessors | — | data-processor |
templates | — | template |
tickets | ticketCrud | all the ticket actions |
users is also the only resource where we keep the slug field in the quick forms.
5. Class Objects
class_object_data has many keys but the generator is only reading two of them.
tableName— used by the frontend for the customProperties (/${apiVersion}/custom_properties?tableName=${tableName}). We just take it from the API and print it in the config asconst tableName: string = '…'. See Custom Properties.implemented_interfaces— the biggest deciding factor in the whole generation. They are mapped inImplementedInterfaceMapping(src/templates/template-generator.js) and also printed in the tsx as theimplementedInterfacesobject.
Interfaces and their impact
| Interface | Impact in the generated config |
|---|---|
Taggable | The tag filter is added in filterFields.all and a SELECT_ASYNC tag filter is generated in the tsx |
Disableable | The enabled filter is added (default true) and the disable action as well, but only if disabledAt is also present in updatable_properties |
SoftDeleteable | delete action, but this one is used only when operations is not answering it already |
Prioritizable | priority-up / priority-down actions and the Priority section on the show page |
NestedEntityInterface (treeview) | tree-up / tree-down, the Position section and the tree fields handling |
Translatable | The Translatable section on the show page with title and description |
Printable | print-template action |
Emailable | email-template action |
Sequenceable | Not read from the entity itself but from the target entity — more about it in point 12 |
Sluggable, Defaultable, Timestampable, Stringable | Just passed to the tsx implementedInterfaces object, no behaviour is attached to them for now |
For the backend side of these interfaces see Entity Interfaces & Behaviors, and for the template side see Entity Interfaces & Template.
used_traits, role_prefix, parentRole, shortname and
defaultDataSkeletonUri are not read by the generator at all.
6. Operations
operations is the main deciding factor, exactly as mentioned in the backend doc. We store it as it is in data.json and then it is interpreted in createVersionedChangesTemplate().
The numbered objects like "0": {"method": …, "url": …, "groups": {…}} are the ones we read. The generator loops on all of them and checks the method: POST → add, PUT/PATCH → update, DELETE → delete. The old boolean style (operations.add === false) is also still supported.
- From this we build
isNotCreatablewhich is true when there is no POST, or whenwritable_propertiesis explicitly empty. In that caseformFields.sectionsis generated empty and theduplicate,quick-duplicateanddefault-valueactions are removed. isNotEditableis true when there is no PUT/PATCH, or when the updatable properties are explicitly empty. In that caseedit,quick-editandinline-editare removed and the edit modal fields stay empty.hasDeleteableis taken from the DELETE method, and only ifoperationsis not there at all we fall back on theSoftDeleteableinterface.- If
operationsis null or missing we assume everything is allowed, otherwise an older backend would silently lose all its forms. - The
urland thegroups(normalization/denormalization) inside each operation are stored but we are not using them, because the groups are already decided fromavailableSerializationGroupsas explained in point 3.
7. Required Properties
required_properties is coming from the ?requiredOnly=true call and we use it to build the requiredFields … we take each name and look for it in fields first, and if it is not found then in virtual_properties, just to get the complete metadata of that field.
writable_required_properties (with required_properties as fallback) is the one which fills formFields.required, so it decides which inputs are marked as required: true.
There is one deviation here. The boolean fields are removed from
formFields.required even if the API says they are required. It is a
temporary workaround for the frontend issue
phpreaction-frontend-crud-react-v2#838 .
8. Displayable Properties
displayable_properties is used for only one decision today, to check if uniqueId exists or not.
If it exists then uniqueId is placed in listingFields.selected and id is moved to hideSelected, otherwise we keep showing id.
9. Writable Properties
writable_properties is filtering the add form and the add modal fields.
One important thing: an empty array is not the same as a missing key. If the API returns it as empty we treat it as “this entity can’t be created”.
A virtual property can come inside a form only if it is also present in writable_properties, and after that the required/optional split is done with required_properties.
10. Updatable Properties
updatable_properties is filtering the edit modal fields.
- If the key is not present at all we fall back on
writable_properties. - Same as above, an empty array means “this entity can’t be edited”.
- It is also checked for the disable action … the
Disableableinterface is not enough,disabledAtmust also be present inupdatable_properties.
11. Unfiltered Fields
unfiltered_fields is majorly used to generate the sideboxes, which is still matching the original intention even if the API is returning relational and non-relational fields together.
We only pick the entries where the type is MANY_TO_MANY or ONE_TO_MANY and they become sideboxFields.all as {fieldName, targetResource, targetEntity}. For the name we prefer inversedBy, then mappedBy, and at the end fieldName.
The same keys are then excluded from the show and form sections, otherwise the same relation was getting rendered twice (generator issue frontend-components-crud-react-generator#258 — sidebox fields excluded from showFields, referenced in src/core/version-manager.js:876).
12. Virtual Properties
virtual_properties is merged inside the show and listing field pools on every serialization call.
toString is always available … even if the API doesn’t return it we force it as {fieldName: 'toString', type: 'string'}, and on the show page it is placed first in the Identification section as the identifier of the record.
As said above these can’t be created/edited directly, so a virtual field can only reach a form through writable_properties / updatable_properties.
showFields.sequencable is the only place where the generator is reading
another entity’s metadata. For each MANY_TO_ONE field it takes the
targetEntity, converts it to the entity path, opens the latest data.json
of that entity and checks if it implements Sequenceable. If that entity is
not generated yet then the flag is simply skipped, so generating the full
bundle twice gives a better result than generating it once.
13. Writable Required Properties
writable_required_properties is taken as it is from the API and it is the primary source for the required form fields. The generator is not calculating the intersection by itself.
required_properties is only used as a fallback when this key is missing.
14. API Filters
apiFilters is used for the filters on the listing page and for the sorting. extractFromApiFilters() is looping on every filter class and on the properties of every filter entry.
- Sorting — we use
PHPReaction\Api\Filter\OrderFilter→gobal.properties(the API’s spelling) and it becomessortableFieldsindata.jsonand thenlistingFields.sortable. - The dotted paths are splitted, so
translations.titleis displayed astitleand we keepsortableKeys: { title: 'translations.title' }so that the frontend always sends the correct sort key. FordefaultOrderwe take the first available one betweenpriority: asc,title: ascandid: desc. - Search — we use
ApiPlatform\Doctrine\Orm\Filter\SearchFilter→ it fillsfilterFields.searchMethodsand the strategies are ordered aspartial,start,end,exact. When thepropertiesis an array (relation filters) then no search method is stored for it. - Ranges — we use
RangeFilterandDateFilterand their properties go tofilterFields.range. - For
TranslationSearchFilter, and also for any property starting withtranslations., we keep the base field as translatable and we skip it from the normal fields list. - The
textfields are removed from the filters list, because a textarea is not really a filter input. - By default these filters are enabled:
id,type,tag,enabled, plus whatever the legacy scraper is giving us as listing filters for that entity.
15. Fields
fields is used to generate the fields metadata in the generator, however from each field object we are reading only a few keys.
| Key | Usage |
|---|---|
fieldName | The key in the tsx, and also the source of the label (capitalize() → “Account Name”) |
type | It decides the ColumnTypeEnum through FieldTypeGeneration() and also the relation detection |
format | It refines the type, money → CURRENCY and percent → PERCENT |
nullable | Printed on the boolean inputs |
targetEntity | The FQCN of the relation target, used for the sequencable lookup and the sidebox |
targetEntityResourceName | targetResourceAsync on the async selects, and the sidebox target |
targetEntityResourceIri | prefixOnSubmit on the async selects |
inversedBy / mappedBy | The preferred name of the sidebox field |
constraints | Only the presence is checked, as a tiebreaker when we have to choose a fallback sortable column |
A few things about the type mapping: the 4 relation types → SELECT_ASYNC, text → TEXTAREA (and directives → SQL_QUERY), date/datetime (with _immutable also) → DATE/DATETIME, integer/smallint → NUMBER, decimal/float → DECIMAL/FLOAT, json/json_document → JSON. On the strings we also check the name, like icon, color, *Phone*, *Email*, *url*, *password*. And there is a hardcoded list of money fields (total, amount, price …) which forces CURRENCY.
16. What the generator is producing at the end
/metadata/entities and the 4 /metadata/entity/{FQCN} calls are producing config/versioned/json/{Entity}/vN/data.json which is the raw snapshot of the API.
The keys of data.json, in order:
entity
resourceName
tableName
implementedInterfaces
fields
showFields
listingFields
unfilteredFields
virtualProperties
displayableProperties
requiredFields
requiredProperties
writableProperties
updatableProperties
operations
availableSerializationGroups
apiFilters
sortableFields
_generatedAt
_generationModeAfter that changes.json is generated with all the defaults calculated from the keys mentioned above, and then it is customized by hand — see the changes.json Reference.
index.tsx is generated only from data.json + changes.json, without touching the API.
On the regeneration, the API driven arrays (sortable, all, required,
fields, range) are merged with the previous version values, but
selected, hide and order are considered as the user’s choice and they
are kept as they are. More in Workflow &
Versioning.
bundleCrud is not coming from the API, we pass it ourselves with --bundleCrud, but it ends up in the generated config as the FQCN Bundle (fqcn_bui.Bundle) and the entity simple name goes as the Unit (fqcn_bui.Unit). From these two we derive fqcn_bui_listing, fqcn_bui_create, fqcn_bui_edit and fqcn_bui_show. It is also used in a couple of places to switch the actions, like accountCrud + users → send-invite and ticketCrud + tickets → the ticket actions.
17 Not used by the generator
- From the entity metadata:
used_traits,role_prefix,parentRole,shortname,defaultDataSkeletonUri,database_required_properties,hidden_from_serialization_properties,hidden_oneToMany_relations_properties, the top levelconstraintsandsuccess. - From
operations: theurland thegroupsof each nested object. - From
fields: everything listed in 15.12.
There are many other things that have been skipped for now, which are either internal to the generator or not coming from the API at all.