WordPress
WordPress meta_query: de stille FLOAT-cast die GPS opat
Acht dagen in een WordPress-naar-Astro migratie landde elk perceel op de nieuwe site binnen negentig meter van het kantoor van de coöperatie. De bug was ouder dan de migratie.

De dev op piket ververste de staging-kaart om 23:14 op een dinsdag en zag 6.400 percelen opgestapeld in een blob van een kilometer breed boven het parkeerterrein van de coöperatie. Mechelen, half juni, dag acht van een WordPress-migratie die er vijf had moeten duren.
De coöperatie is een organisatie van 22 mensen buiten Mechelen. Ze bundelen suikerbiet, cichorei en pootaardappel over ongeveer 9.000 hectare voor 340 leden. De website is niet de business, maar runt wel de business: elk perceel van een lid heeft een publieke pagina (een perceel-pagina) met teelthistorie, grondsoort, datum van het laatste monster, en een polygoon getekend op een Leaflet-kaart. Aan de hand van die polygonen vindt de veldagronoom 's ochtends überhaupt iets terug.
De migratiebriefing was onromantisch. WordPress 6.5, PHP 8.2, MariaDB 10.6, een toren van plugins, drie jaar aan ACF Pro field churn, en een edit window zo krap dat één schemawijziging een lid een halve hectare kon kosten. Verplaats het naar Astro voor de publieke site, Payload voor het editor-oppervlak, behoud de URLs, behoud de kaarten, laat de ochtendroutine van de agronoom intact.
De eerste zeven dagen waren het saaie deel. URL-stabiele rewrites, image pipeline, zoekindex, het gebruikelijke.
Toen klapten de polygonen in elkaar.
De pagina die brak
De parcel-importer van de migratie deed een paginated WP_Query op het perceel post type, trok de ACF Pro repeater met grenspunten op, en duwde elke row naar Payload als een array van {lat, lng} objecten. De staging-review door de agronoom was de eerste keer dat iemand alle 6.400 percelen tegelijk op één kaart had geladen.
Ze lagen allemaal boven op elkaar. Niet "in het verkeerde land" fout. Erger: in hetzelfde grindveld bij het kantoor, negentig meter doorsnee, elk perceel klikte vast op elk ander perceel.
De eerste reactie van het team was het migratiescript. Dat werd tussen 23:14 en 02:00 twee keer herschreven. Elke herschrijving leverde dezelfde blob op. Ze controleerden de GeoJSON-output. Valide. Ze controleerden de Leaflet-config. Prima. Ze schreven een script dat de coördinaten van vijf voorbeeldpercelen diff'de tegen de legacy site live in productie. Identiek. Dezelfde vijf floats, byte voor byte gekopieerd, rendeerden correct op de ene site en fout op de andere.
Dat was het moment waarop het geen migratie-bug meer was.
Wat ACF Pro werkelijk opsloeg
ACF Pro repeaters worden opgeslagen als platte rows in wp_postmeta. Een repeater genaamd boundary met sub-fields lat en lng schrijft één row per coördinaat per perceel. Voor een polygoon met zestig vertices is dat 120 rows per perceel. Voor 6.400 percelen rond de 768.000 rows. Acceptabel, indexeerbaar, de manier waarop de plugin bedoeld is.
Dat was niet wat er in de database stond.
Wat er in de database stond was één enkel repeater-veld genaamd polygon_csv, met één row per perceel, met daarin een string die er zo uitzag:
51.0231,4.4598,51.0234,4.4612,51.0245,4.4621,51.0249,4.4604,51.0231,4.4598Een voorganger-bureau had drie jaar geleden besloten dat een polygoon van 60 vertices "te veel rows" was, en verving de losse lat/lng-velden van de repeater door één textarea. Ze hielden de veldnaam en de ACF field key, dus de editor-UI rendeerde nog steeds als een repeater, maar elke row hield een CSV-blob met gepaarde floats. Een point-of-failure JavaScript-hook op de admin-save parste de CSV terug naar een Leaflet-polygoon voor de editor-preview, en een bijpassende template helper parste het er weer uit op de publieke site. Twee functies, negentig regels code, één ongedocumenteerd contract.
Dat ging prima. Min of meer.
Wat WP_Query deed
Het migratiescript bevatte een volkomen redelijke regel. Het team wilde percelen in geografische batches importeren om de Payload bulk upserts behapbaar te houden, dus filterden ze op bounding box.
$query = new WP_Query([
'post_type' => 'perceel',
'posts_per_page' => 200,
'meta_query' => [
'relation' => 'AND',
[
'key' => 'polygon_csv',
'value' => [$bbox_min_lat, $bbox_max_lat],
'compare' => 'BETWEEN',
'type' => 'DECIMAL(10,3)',
],
],
]);Dit is de regel die de week opat. Lees de waarde van type. BETWEEN op een DECIMAL(10,3) cast.
MySQL's CAST(... AS DECIMAL(10,3)) geeft geen NULL terug als je een string aanlevert die met een getal begint en daarna troep bevat. Het geeft het voorste numerieke deel terug, stil, afgerond op de precisie die je vroeg. Dus CAST('51.0231,4.4598,51.0234,...' AS DECIMAL(10,3)) geeft 51.023. Niet 51.0231. De vierde decimaal, en alles daarna, inclusief de rest van de zestig vertices van de polygoon, verdween in de cast.
Op drie decimale graden van de breedtegraad heb je een resolutie van ongeveer 110 meter. Elk perceel dat binnen één tegel van 110 meter viel kreeg dezelfde coördinaat. De bounding-box batches draaiden nog steeds. Het script draaide netjes af. De data importeerde nog steeds. De kaarten rendeerden nog steeds.
Ze rendeerden alleen allemaal boven op één grindvlek.
Waarom dit moeilijk te zien was
De fix is één parameter, en daar komen we zo op. De reden dat het acht dagen kostte: er ging niets stuk.
WP_Query gaf posts terug. De importer schreef rows. Payload accepteerde de inserts. De staging-kaart tekende valide GeoJSON voor elk perceel. De CSV-parsende JS-hook op de legacy admin werkte al drie jaar prima, omdat die nooit in de buurt kwam van het database-cast-pad. De legacy publieke site gebruikte op dit veld nooit meta_query, alleen get_post_meta(), dat de ruwe string teruggeeft. De enige test die dit zou hebben gevangen was "open de kaart met alle 6.400 percelen tegelijk zichtbaar", en zo'n test schrijft niemand.
Het ontdekkingsmoment was geometrisch, niet logisch. De agronoom opende staging op zijn iPad om naar een lid te scrollen dat hij wilde bellen, zoomde uit, en zei waar is alles.
Waarom MySQL cast zoals het cast
Dit is gedocumenteerd gedrag, en dat is het al decennia. De MySQL-handleiding over type conversion zegt, op zijn rustige manier, dat als een string met een getal begint, de geconverteerde waarde het voorste numerieke deel is, en als hij niet met een getal begint, de geconverteerde waarde 0 is. Zie MySQL type conversion in expression evaluation. Geen waarschuwing. Geen NULL. Een numeriek prefix is genoeg.
Combineer dat met de WordPress meta_query-laag, een dunne wrapper die de cast rechtstreeks in de gegenereerde SQL gooit, en je krijgt een query die draait, redelijk uitziende row counts oplevert, en stil liegt over elke coördinaat na de eerste komma. De WP_Query meta_query reference beschrijft de type-parameter als een hint aan MySQL. Die hint wordt letterlijk genomen.
Er schuilt een bredere les in. Een HN-thread van diezelfde week beweerde dat de enige schaalbare delete in Postgres DROP TABLE is: zodra een tabel groot genoeg is, schalen alleen de operaties die de row contents negeren. WordPress's wp_postmeta is de spiegelvorm van dat probleem. Zodra je honderdduizenden ondoorzichtige string-blobs hebt onder een meta_key, werken alleen de operaties die de structuur negeren: index lookups op de key, returns van de ruwe waarde, parsen in applicatiecode. Op het moment dat je MySQL vraagt te redeneren over de structuur van een string-waarde via meta_query-type hints, ben je overgeleverd aan welke cast dan ook in de SQL belandde.
Als je ooit meta_query hebt gebruikt met type op DECIMAL, NUMERIC, SIGNED of UNSIGNED bij een custom field, controleer dan wat er werkelijk in die kolom staat. MySQL cast "51.02,4.45" naar 51.02 en zegt er niets over.
De fix van negentig minuten
De fix had twee delen. Het eerste was een beslissing van één regel in de importer: omzeil meta_query volledig en doe de bounding-box-filter in PHP, nadat je de geparste coördinaten hebt opgehaald.
$query = new WP_Query([
'post_type' => 'perceel',
'posts_per_page' => -1,
'fields' => 'ids',
]);
foreach ($query->posts as $post_id) {
$csv = get_post_meta($post_id, 'polygon_csv', true);
$coords = parse_polygon_csv($csv);
if ($coords === null) continue;
if (! polygon_intersects_bbox($coords, $bbox)) continue;
enqueue_for_payload($post_id, $coords);
}Trager, uiteraard. Voor 6.400 percelen draaide de volledige import in elf minuten. De agronoom keek de volgende ochtend om 03:40 naar de kaart en zag 6.381 percelen in de juiste vorm, met 19 kapotte CSV's gemarkeerd voor handmatige review.
Het tweede deel van de fix was het deel dat telde voor het nieuwe systeem. In Payload bewaart het perceel-schema boundary nu als een typed GeoJSON polygon-veld, gevalideerd op write, geïndexeerd op read.
{
name: 'boundary',
type: 'json',
validate: (value) =>
isValidGeoJSONPolygon(value)
|| 'Polygon must be a closed ring of [lng, lat] pairs.',
admin: {
components: { Field: PolygonMapEditor },
},
}Geen CSV. Geen meta_query. Geen stille casts. Een perceel heeft óf een valide polygoon, óf de editor weigert te bewaren. De agronoom krijgt dezelfde sleep-de-vertices-kaart als voorheen. De database krijgt een structuur waar hij daadwerkelijk over kan redeneren.
Een kolom die polygon_csv heet is geen polygoon. Het is een string die de applicatie toevallig weet te parsen. SQL weet dat niet, en zal er niet naar vragen.
De audit van vijf minuten die je vandaag kunt doen
Als je een WordPress-site draait met custom field-vergelijkingen in queries, kost deze audit vijf minuten en vertelt hij je of je op dezelfde bug zit.
Open een database-client. Kies één custom field waar je tegen aan queryt met meta_query. Draai dit:
SELECT
meta_value,
CAST(meta_value AS DECIMAL(20,6)) AS cast_value
FROM wp_postmeta
WHERE meta_key = 'your_field_key'
ORDER BY RAND()
LIMIT 50;Leg de twee kolommen naast elkaar. Als cast_value afwijkt van de voorste digits van meta_value op manieren die je niet had verwacht, of als meta_value iets anders bevat dan digits, een decimaal punt en een teken, dan liegt je meta_query met een numerieke type hint tegen je. Stil, elke request, elke cron, elke export.
De fix in de legacy site is bijna nooit "verander de data". De data is het enige waar de editors op vertrouwen. De fix is: stop met SQL vragen om te redeneren over string-structuur. Trek de rows op, parse in PHP, filter in PHP. Het is trager. Het is ook correct.
Wat we hieruit hebben meegenomen
Toen we de Astro- en Payload-rebuild voor de coöperatie bouwden, liepen we ertegenaan dat de repeater-vorm van ACF stil kan afdrijven van typed structure naar ondoorzichtige string, zonder dat downstream-queries je waarschuwen. We hebben uiteindelijk een importer geschreven die elk perceel rondloopt door een typed validator voor het in de nieuwe database belandt, en een Payload-schema dat weigert een polygoon te bewaren die niet te parsen is. Als je een WordPress-site hebt die ouder is dan zijn huidige team, begint ons werk aan legacy migratie altijd met een lees-pass over de werkelijk opgeslagen waarden, niet over de veldlabels.
Het kleinste dat de moeite waard is om vandaag te doen: kies één meta_query die je meer dan een jaar geleden schreef. Open de database. Kijk wat er werkelijk in de kolom staat. Het label en de inhoud zijn niet hetzelfde.
Kern
WordPress meta_query met een numerieke type hint cast string-blobs stilletjes naar hun voorste digit en zegt er niets over.
FAQ
Waarom cast WordPress meta_query stilletjes string-waarden naar getallen?
Omdat de type-parameter rechtstreeks als CAST aan MySQL wordt doorgegeven, en MySQL elke string die met digits begint zonder waarschuwing naar zijn voorste getal cast. De database neemt de hint letterlijk.
Kan ik ACF Pro repeaters blijven gebruiken en dit soort bugs vermijden?
Ja, als elk repeater-veld in zijn aangegeven vorm blijft. De bug hier was een voorganger-team dat typed sub-fields verving door een CSV-blob binnen dezelfde repeater. Valideer op write, anders kun je de data op read niet vertrouwen.
Waarom überhaupt van WordPress naar Astro en Payload?
Payload geeft strikte schemas die stille drift in de editor stoppen. Astro geeft voorspelbare static output voor offline-werkende kaarten. WordPress had ofwel het ene ofwel het andere kunnen leveren, niet beide goed voor dit team.
Schaalt filteren in PHP in plaats van meta_query wel?
Voor batch imports en eenmalige jobs, ja. Voor per-request page queries op honderdduizenden rows, nee. Het echte antwoord op die schaal is stoppen met structuur opslaan als strings in postmeta.