Defekt release, magic quotes og PDO null 15. feb 2010

Ja, så fik jeg jo med nød og næppe lavet release 4 af Paginata, og selv om det oprindelig var meningen at det skulle være en færdig beta-release – altså klar til brug for almindelige mennesker – så var jeg glad nok for at den blev lavet, for i det mindste kom jeg så et skridt videre.
Min glæde ophørte brat da jeg ville blogge om hvad jeg havde lavet.

Det viste sig at det ikke var muligt at oprette nye blogindlæg – og det var heller ikke ligetil at redigere i de eksisterende, de blev fyldt med overflødige skråstreger og blev lettere ulæselige. Og det var heller ikke længere muligt at fjerne en tekst eller en dato; når noget først var skrevet, så kunne det blive skrevet over, men ikke fjernet helt. Suk.

Magic quotes

Problemet med skråstreger viste sig nemt at løse. Mit webhotel har magic_quotes_gpc slået til, så så snart php modtager noget i en request, bliver alle ”besværlige tegn” (fortrinsvist anførselstegn og linjeskift) escaped med en backslash. Det vidste jeg egentlig godt, for i de tidligere udgaver af Paginata lavede jeg selv disse escapes hvis magic_quotes_gpc var slået fra. En hurtig rettelse i Line datatypen, og så var det problem løst.

if(get_magic_quotes_gpc()) {
  $value = stripslashes($value);
}

Nye indlæg

Well – jeg var dum. Jeg havde lavet noget fancy kode til at gemme et dataitem, og den kode byggede selv et sql-statement som startede med UPDATE hvis der var tale om en rettelse, og INSERT INTO hvis der var tale om et nyt dataitem. Jeg havde bare lige glemt at tilføje tabelnavnet i tilfælde af INSERT INTO. En fejl der ville være blevet opdaget med det samme i en unittest – eller spottet i et code-review.

Null-values

Den var slem. Førhen blev hele sql-strengen til at opdatere et dataitem opbygget som en lang tekst-streng, med en masse quotes og escape-ting. Det er generelt en dårlig ide, så jeg havde skiftet det hele ud med et PDOStatement, hvor man bygger et generisk sql-statement op, binder nogle værdier til forskellige parametre, og sluttelig udfører det.
Og det virkede fint indtil en af værdierne var null. Det resulterede i masser af sære fejlbeskeder i stil med: Incorrect date value '' for column … og Incorrect integer value '' for column …

Søgninger på Google hjalp ikke meget, masser af andre folk oplevede den samme fejlbesked, men af alle mulige forskellige årsager. Og de semi-hånlige, bedrevidende svar i PHPs bug-reporting-system var bestemt heller ikke til nogen hjælp.

Af en eller anden grund blev mine null-værdier ændret til tomme strenge når de skulle sendes til databasen, og det duer slet ikke i forbindelse med datoer og tal.
Grunden viste sig at være en kombination af PHPs typeløshed og (typeløse) sammenligningsfunktion. Men det tog mig hele søndagen (med tømmermænd) at forstå og få rettet.

Først og fremmest er der et lille problem med PDO bindValue() – her er et kodeeksempel:

$con = new PDO('mysql:host='.DB_HOST.';dbname='.DB_BASE,
        DB_USER,
        DB_PASS,
        array( PDO::ATTR_PERSISTENT => true,
               PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8"));

$sql = "UPDATE tablename SET `name` = :name, `value`=:value WHERE id=".$id;

$stmt = $con->prepare($sql);

$stmt->bindValue(':name', $name );
$stmt->bindValue(':value', $value );

$res = $stmt->execute( );

Det virker ved første øjekast perfekt; værdien af $name bliver skrevet ind i parameteren :name og gemt i kolonnen `name` i tabellen. Og værdien af $value bliver skrevet ind i parameteren :value og gemt i kolonnen `value`. Medmindre altså at en af dem er null.

For først og fremmest forventer bindValue altid at værdier er strenge, medmindre andet er angivet. Så man skal give en ekstra parameter og fortælle at det altså er et integer vi har med at gøre her.

$stmt->bindValue(':value', $value, PDO::PARAM_INT );

Det burde hjælpe – men nej, for $value kan stadig blive opfattet som en streng, hvis det f.eks. er en streng (for eksempel hvis det er en værdi der er modtaget fra en request - som det jo er i dette tilfælde. Så værdien skal typecastes.

$stmt->bindValue(':value', (int)$value, PDO::PARAM_INT );

Jeg tror aldrig har har typecastet noget PHP kode før, jeg troede nærmest ikke engang at man kunne, men det har altså vist sig nødvendigt her.
Det løser dog kun problemet med integers, for hvad nu hvis det var en dato vi havde med at gøre? Den er jo en streng normalt, men en tom dato må ikke være en tom streng, den skal være null.
Efter jeg ved ikke hvor mange forsøg, endte jeg med en kombineret løsning. Først opdagede jeg at selv om $value er null, så er det ikke ligegyldigt hvilken af disse tre linjer man bruger:

$stmt->bindValue(':value', $value, PDO::PARAM_STR );
$stmt->bindValue(':value', $value, PDO::PARAM_NULL );
$stmt->bindValue(':value', null );

Det er på en måde essensen i PHPs typeløshed, der er forskel på om en værdi er null eller på om den kan evalueres til (sammenlignes med) null. Altså man kan risikere at $value == null returnerer true, men $value === null returnerer false. For at undgå at det skulle blive et problem, forsøgte jeg mig først med denne lille stump kode - som ville forekomme absurd i de fleste andre sprog.

if( $value == null ) {
  $value = null;
}
$stmt->bindValue(':value', $value, PDO::PARAM_STR );

Hvis $value kan evalueres til null, så sikrer jeg at den bliver sat til null - for at undgå misforståelser senere i forløbet. Og det virkede perfekt, men løste kun problemet i forbindelse med strenge. Integers skal behandles lidt anderledes.

Da jeg gerne vil ende med noget kode der er så ens og generisk som muligt, endte jeg med en løsning, hvor datatypen selv sikrer at værdier der kan evalueres til null, også bliver sat til null.

  function setValue( $value )
  {
    // Force actual NULL for values that compares to null
  	if( $value == null ) {
  		$this->value = null;
  	} else {
  		$this->value = $value;	
  	}
  }

Dernæst sikrede jeg at disse null-værdier ikke blev fortolket som noget som helst andet af PDOStatement, ved hjælp af denne lille stump:

if( $value === null ) {
   $stmt->bindValue(':value', null );
} else {
   $stmt->bindValue(':value', $value, $PDOtype );
}

Hvor $PDOtype selvfølgelig er sat til enten PDO::PARAM_STR eller PDO::PARAM_INT afhængig af datatypen.

Det så ud til at klare de værste problemer, men manner hvor var det frustrerende at sidde og debugge, prøve at sammenligne værdier, se om de var null, og opdage at tomme strenge og tallet 0 nogle gange også kan være null ...

Åh hvor er jeg altså nogle gange glad for typestærke sprog som Java og C# ...

Tilføj kommentar

www.peterlind.dk

Nyeste blog-indlæg