MySQL, PDO i procedury składowane

Trochę nie pisa­łem. Mam nadzieję, że dzisiej­szy wpis wszyst­kim wyna­gro­dzi moją nieobec­ność. Ostat­nio staną­łem przed wyzwa­niem zrobie­nia gale­rii zdjęć, których kolej­ność dałoby się dowol­nie mody­fi­ko­wać za pomocą prze­cią­ga­nia i upusz­cza­nia. Dzisiaj opiszę opera­cje bazo­da­nowe, a na następny raz jQuery. Będę maksy­mal­nie uprasz­czał aby nie zaciem­niać meritum.

1. Przy­go­to­wa­nia

Powiedzmy, że mamy 2 tabele rela­cyjne odpo­wie­dzialne za prze­cho­wy­wa­nie gale­rii i obra­zów. Np. takie:

CREATE TABLE `galleries` (
  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated` TIMESTAMP NOT NULL DEFAULT '0000-00-00 00:00:00',
  `title` VARCHAR(250) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 ;
 
CREATE TABLE IF NOT EXISTS `images` (
  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `gallery_id` INT(10) UNSIGNED NOT NULL,
  `filename` VARCHAR(50) NOT NULL,
  `ordr` INT(10) UNSIGNED NOT NULL DEFAULT '1',
  `created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `image_to_gallery` (`gallery_id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 ;

Od razu widać dwa dziwac­twa: dlaczego pole nazywa się ordr a nie order? Z czystego leni­stwa. Order jest słowem zare­zer­wo­wa­nym w SQL (ORDER BY coś tam). Każdo­ra­zowo nazwa pola musia­łaby być w nawia­sach. Dlaczego pole upda­ted ma domyślną wartość 0000–00-00 00:00:00? Ano dlatego, że CURRENT_TIMESTAMP można użyć tylko raz w tabeli. Wobec tego stwo­rzymy od razu wyzwa­lacz (trig­ger), który przed każdym zapy­ta­niem typu UPDATE poprawi wartość na taką jak trzeba.

DELIMITER $$
CREATE TRIGGER `updated_current_timestamp` BEFORE UPDATE ON `galleries`
   FOR EACH ROW BEGIN
      SET NEW.updated = NOW();
END$$

Na koniec trzeba stwo­rzyć rela­cję 1 gale­ria do wielu zdjęć.

ALTER TABLE `images`
   ADD CONSTRAINT `image_to_gallery` FOREIGN KEY (`gallery_id`) REFERENCES `galleries` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;

Ozna­cza to, że kasu­jąc gale­rię od razu pozbę­dziemy się również wszyst­kich powią­za­nych z nią zdjęć. Pliki z dysku oczy­wi­ście nie znikną. Można napi­sać funk­cję, która przed usunię­ciem gale­rii z bazy najpierw wyrzuca wszyst­kie powią­zane z nią pliki, a dopiero potem wyko­nuje pole­ce­nie DELETE.

2. Decy­zje

Teraz nadszedł czas na poważne decy­zje. Chodzi o sposób mani­pu­la­cji wier­szami doty­czą­cymi zdjęć. Można to zrobić za pomocą PHP. Jest to rozwią­za­nie prost­sze. Powo­duje jednak spory narzut komu­ni­ka­cji PHP<—>SQL. W przy­padku zwale­nia wszyst­kiego na bazę danych, pchamy logikę wyżej i bliżej mody­fi­ko­wa­nych danych. Minu­sem jest cholerna skład­nia SQLowa i później­sze proble­ma­tyczne utrzy­ma­nie kodu.
Problem oczy­wi­ście nie istnieje gdy robimy gale­ryjkę na 10 obraz­ków i prze­sta­wimy sobie kolej­ność ostat­niego na przed­ostatni. Ja raczej podcho­dzę do rzeczy poważ­nie i wolę od początku zrobić to tak jak powinno być. Poza tym wyzwa­la­cze i proce­dury skła­do­wane to jest to, co bazo­da­nowe tygry­ski lubią najbar­dziej :-)

3. Wyko­na­nie

Każdy nowy rekord tabeli images musi mieć nadany odpo­wiedni iden­ty­fi­ka­tor pozy­cji ordr o 1 więk­szy od ostat­niego w danej gale­rii. Mamy trzy rozwią­za­nia: czysty PHP, wyzwa­lacz wywo­ły­wany przed INSERTem lub proce­dura skła­do­wana. Zapy­ta­nie w PHP może wyglą­dać tak:

$q = $pdo->prepare('INSERT INTO images (filename,gallery_id,ordr) (SELECT ?,?,MAX(ordr)+1 FROM images WHERE gallery_id=? LIMIT 1)');
$q->bindValue(1, 'obrazek.jpg', PDO::PARAM_STR);
$q->bindParam(2, $galleryId, PDO::PARAM_INT);
$q->bindParam(3, $galleryId, PDO::PARAM_INT);
$q->execute();

Wspo­mi­nam o tym rozwią­za­niu dlatego, że ma ciekawą konstruk­cję INSERT SELECT. Zapewne więk­szość z was po kilku­na­sto­krot­nej próbie wywo­ła­nia pole­ce­nia INSERT INTO VALUES i gdzieś tam SELECT dosta­nie cholery i rozbije zapy­ta­nie na 2: pierw­sze spraw­dza ostatni ordr, a następne doda 1 i umie­ści INSERTem pozo­stałe dane.
Bazo­da­nowe tygry­ski wybiorą jednak co innego. Proce­dury składowane!

DELIMITER $$
CREATE PROCEDURE `insert_image`(IN image_filename VARCHAR(50), IN image_gallery_id INT, OUT last_inserted_id INT)
   MODIFIES SQL DATA
   COMMENT 'Inserts new image at the end of given gallery.'
   BEGIN
      DECLARE max_order INT;
 
      # Zamiast SET zmienna= uzywam SELECT INTO just FOR fun
      SELECT MAX(ordr) INTO max_order FROM images WHERE gallery_id=image_gallery_id LIMIT 1;
 
      # Gdy obrazek jest pierwszy w galerii
      IF max_order IS NULL THEN 
         SET max_order = 1;
      ELSE
         SET max_order = max_order + 1;
      END IF;
      INSERT INTO images (filename, gallery_id, ordr) VALUES (image_filename, image_gallery_id, max_order);
      SELECT LAST_INSERT_ID() INTO last_inserted_id;
END$$

Próba wywo­ła­nia $pdo->lastInsertId() zakoń­czy się niepo­wo­dze­niem (a raczej zerem :-) ). Dlatego potrze­bu­jemy para­me­tru wyjścio­wego. Poni­żej poka­zuję jak całość wywo­łać w PDO:

$q = $pdo->prepare('CALL insert_image(?,?,@lastInsertId)');
$q->bindValue(1, 'obrazek.jpg', PDO::PARAM_STR);
$q->bindParam(2, $galleryId, PDO::PARAM_INT);
$q->execute();
$outputArray = $pdo->query('select @lastInsertId')->fetch(PDO::FETCH_ASSOC);
$lastInsertId = $outputArray['@lastInsertId'];

Ktoś może się zapy­tać po co te numery ze zmienną wyjściową. PDO i sterow­nik MySQL w PHP ma szpetny błąd doty­czący obsługi para­me­trów wyjścio­wych z proce­dur skła­do­wa­nych. Podobno w nowszych wersjach jest OK. Trik podany wyżej u mnie działa i oszczę­dza trochę nerwów.

Proce­dura kasu­jąca obrazki również jest raczej prosta. Rzuć­cie okiem:

DELIMITER $$
CREATE PROCEDURE `delete_image`(IN image_id INT)
   MODIFIES SQL DATA
   COMMENT 'Deletes and reorders if there is a gap.'
   BEGIN
      DECLARE image_gallery_id, image_order, max_order INT;
      SELECT gallery_id, ordr INTO image_gallery_id, image_order FROM images WHERE id=image_id LIMIT 1;
      DELETE FROM images WHERE id=image_id LIMIT 1;
      SELECT MAX(ordr) INTO max_order FROM images WHERE gallery_id=image_gallery_id LIMIT 1;
 
      # Sprawdzamy czy istnieja jakies obrazki za skasowanym
      IF max_order IS NOT NULL AND max_order > image_order THEN
         WHILE image_order < max_order DO
 
            # Cofamy w petli wypelniajac luke po skasowanym obrazku
            UPDATE images SET ordr=image_order WHERE ordr=image_order+1 AND gallery_id=image_gallery_id;
            SET image_order = image_order + 1;
       END WHILE;
   END IF;
END$$

Wywo­ła­nie jest super proste. Wpisu­jemy i zapominamy:

$q = $pdo->prepare('CALL delete_image(?)');
$q->bindParam(1, $imageId, PDO::PARAM_INT);
$q->execute();

Najlep­sze zosta­wi­łem na koniec. Proce­dura prze­sta­wia­jąca pozy­cję obrazka. Spotka­łem się w necie z rozwią­za­niem typu „bierzesz sobie wszyst­kie id obraz­ków w kolej­no­ści, prze­jeż­dżasz fore­achem, który nadaje kolej­ność i na każdym obrazku wyko­nu­jesz update”. No dobra, ale co jeżeli prze­sta­wiam kolej­ność tylko ostat­niego i przed­ostat­niego, a w gale­rii mam 1000 zdjęć? Powyż­sze rozwią­za­nie orze całą gale­rię, a userzy czekają 5 sek. na zała­do­wa­nie się strony. A co jeżeli kilka osób na raz coś prze­sta­wia w swoich gale­riach? Wtedy na serwe­rze włącza się na kilka minut turbo hard­core i poja­wia się „Pan Gąbka” :-D
Moje rozwią­za­nie polega na wyła­pa­niu wyłącz­nie tego co wymaga zmian. Dałem kilka komen­ta­rzy dla jasności.

DELIMITER $$
CREATE PROCEDURE `reorder_image`(IN image_id INT, IN new_image_order INT)
   MODIFIES SQL DATA
   COMMENT 'Reorders images. Does nothing if given order is out of scope.'
   BEGIN
      DECLARE current_image_order, image_gallery_id, is_destination_order_exists INT;
 
      # Lapie obecne polozenie obrazka i przy okazji id galerii
      SELECT ordr, gallery_id INTO current_image_order, image_gallery_id FROM images WHERE id=image_id LIMIT 1;
 
      # Sprawdzam czy punkt docelowy w ogole istnieje
      SELECT ordr INTO is_destination_order_exists FROM images WHERE gallery_id=image_gallery_id AND ordr=new_image_order LIMIT 1;
 
      # Jezeli punkt docelowy istnieje i jest inny od obecnego TO rozpoczynam dzialanie
      IF is_destination_order_exists IS NOT NULL AND current_image_order <> new_image_order THEN
 
         # Jezeli przestawiam obrazek do gory
         IF current_image_order >  new_image_order THEN 
            WHILE current_image_order >=  new_image_order DO
               UPDATE images SET ordr=current_image_order+1 WHERE gallery_id=image_gallery_id AND ordr= current_image_order LIMIT 1;
               SET current_image_order = current_image_order - 1;
            END WHILE;
 
         # Jezeli przestawiam obrazek w dol
         ELSEIF  current_image_order <  new_image_order THEN 
            WHILE current_image_order <=  new_image_order DO
               UPDATE images SET ordr=current_image_order-1 WHERE gallery_id=image_gallery_id AND ordr= current_image_order LIMIT 1;
               SET current_image_order = current_image_order + 1;
            END WHILE;
         END IF;
 
         # Mam juz miejsce, wrzucam obrazek tam gdzie ma byc
         UPDATE images SET ordr=new_image_order WHERE id=image_id LIMIT 1;
      END IF;
END$$

Użycie prze­sta­wia­nia kolej­no­ści jest również proste:

$q = $pdo->prepare('CALL reorder_image(?,?)');
$q->bindParam(1, $imageId, PDO::PARAM_INT);
$q->bindParam(2, $wantedImageOrder, PDO::PARAM_INT);
$q->execute();

Jeśli ktoś dobrnął aż tutaj to proszę „lajk­nąć” i „plusnąć”. Niedzielne pozdro4all.

Śpiechu liczy zawartość alkoholu w drinkach

Zasta­na­wiało Was kiedyś o ile dokład­nie wzro­śnie całko­wita zawar­tość alko­holu gdy do piwa dodamy „u-boota” (50tkę wódki)? A o ile spad­nie gdy to piwo zbesz­cze­ścimy doda­niem soku mali­no­wego? Chodzi mi o dokładne warto­ści, a nie jakieś tam „no pewnie z 2% jak nic”. Dzisiaj się dowie­cie :-)

Całość napi­sa­łem w Pytho­nie żeby się trochę wdro­żyć. Dla Windowsa jest bardzo fajna rzecz: Porta­ble Python. Insta­lu­jesz na pendraka i nieza­leż­nie od maszyny odpa­lasz inter­pre­ter. Do cało­ści dołą­czony jest całkiem zgrabny edytor PyScripter.

Dobra, do rzeczy. Potrzebna będzie wiedza przed­gim­na­zjalna — mnoże­nie, dzie­le­nie i trochę procen­tów. Wyni­kowy program obsłu­guje się tak:

#!/usr/bin/env python
 
from shaker import Shaker, ShakerDisplayer
 
shaker = Shaker()
shaker.addIngredient('piwo', 500, 5.7)
shaker.addIngredient('wodka', 50, 40)
shaker.addIngredient('sok malinowy', 25, 0)
 
ShakerDisplayer.displayShakerDetails(shaker, 89, True)

Otrzy­mamy poniż­szy tekst:

Sklad­niki drinka:
25.00ml sok mali­nowy
30.00ml wodka
48.50ml alco­hol
471.50ml piwo
Calko­wita zawar­tosc alko­holu w drinku: 8.43%
Calko­wita zawar­tosc alko­holu w drinku (wyra­zona w gramach): 38.32g
Praw­do­po­dobna liczba promili dla faceta o wadze 89kg wynosi 0.62‰

W pliku (module!) shaker.py stwo­rzymy sobie 2 klasy: ShakerShaker­Di­splayer. Jak linijki nie miesz­czą się w okienku to można prze­su­wać strzał­kami na klawia­tu­rze ;-)

#!/usr/bin/env python
 
import unicodedata
from numbers import Number
 
class Shaker:
 
    def __init__(self, shakerCapacity = 1000000):
 
        # Zakladamy, ze pojemnosc shakera jest nieograniczona ;-)
        self._shakerCapacity = shakerCapacity
 
        # Inicjalizuje dictionary dla przechowywania klucz => wartosc
        # Klucz dla alkoholu jest w skladnikach zawsze
        self._drinkIngredients = {'alcohol' : 0}
 
    def addIngredient(self, name, amount, alcoholPercentage = 0):
 
        # Gdyby komus zachcialo sie wprowadzac inne wartosci niz numerki i wartosci dodatnie
        if not isinstance(amount, Number) or not isinstance(alcoholPercentage, Number) or alcoholPercentage < 0 or amount < 0:
 
            # Formatowanie za pomoca {} zadziala od Pythona 3.1+    
            raise ValueError('Podano {} i {} dla {}'.format(amount, alcoholPercentage, name))
 
        # Sprawdzam czy jest wystarczajaco duzo miejsca w shakerze
        spaceInShakerLeft = self._shakerCapacity - self.totalLiquidAmount()
        if spaceInShakerLeft >= amount:
            self._storeNewIngredient(name, amount, alcoholPercentage)
        else:
 
            # Jak za malo to dolewam tylko tyle ile sie zmiesci
            self._storeNewIngredient(name, spaceInShakerLeft, alcoholPercentage)
 
    def _storeNewIngredient(self, name, amount, alcoholPercentage):
 
        # Licze objetosc czystego alkoholu w plynie
        liquidAlcohol = 0
        if alcoholPercentage > 0:
            liquidAlcohol = (amount * alcoholPercentage) / 100
            self._drinkIngredients['alcohol'] += liquidAlcohol
 
        # Sprawdzam czy w drinku jest juz dany skladnik
        if self._drinkIngredients.get(name) == None:
            self._drinkIngredients[name] = amount - liquidAlcohol
        else:
            self._drinkIngredients[name] += amount - liquidAlcohol
 
    def getAllDrinkIngredients(self):
 
        # Wypluwam czysty dictionary ze skladnikami
        return self._drinkIngredients
 
    def totalLiquidAmount(self):
 
        # Zliczam objetosc wszystkich skladnikow w shakerze
        total = 0
        for value in self._drinkIngredients.values():
            total += value
        return total
 
    def getTotalLiquidAlcohol(self):
        return self._drinkIngredients['alcohol']
 
    def getTotalAlcoholPercentage(self):
 
        # Licze calkowity procent alkoholu w drinku
        return (self.getTotalLiquidAlcohol() / self.totalLiquidAmount()) * 100
 
    def getTotalAlcoholInGrams(self):
 
        # Przeliczam ml alkoholu na gramy
        return 0.79 * self.getTotalLiquidAlcohol()
 
    def computeAlcoholPromillesPerWeight(self, weight, man = True):
 
        # PRZYBLIZONA zawartosc procentowa plynow ustrojowych w zaleznosci od plci
        humanFluidsPercentage = 0.7 if man == True else 0.6
        return self.getTotalAlcoholInGrams() / (humanFluidsPercentage * weight)
 
    def pourOutDrink(self, amount):
        """
        Metoda wlasciwie niepotrzebna. Mozna wylac (wypic!) czesc zawartosci
        shakera i sobie potem dolewac kolejne skladniki.
        """
        if amount >= self.totalLiquidAmount():
            self._drinkIngredients = {'alcohol' : 0}
        else:
            percentage = amount / self.totalLiquidAmount()
            for key, value in self._drinkIngredients.items():
                self._drinkIngredients[key] -= self._drinkIngredients[key] * percentage

Klasa powy­żej poli­czy to co trzeba. Wyli­cze­nia doty­czące promili w orga­ni­zmie należy trak­to­wać jako mocno przy­bli­żone. Całej dawki alko­holu nie przyj­mu­jemy w końcu w 1 sekun­dzie, orga­nizm na bieżąco spala truci­znę. Poza tym zależy czy i co się jadło itp. itd., nie wiem, nie znam się.
Teraz jesz­cze klasa, która zajmie się wyświe­tla­niem (a raczej metoda statyczna).

class ShakerDisplayer:
 
    def displayShakerDetails(shaker, weight, man = True):
        if not isinstance(shaker, Shaker):
            raise ValueError('Podano argument klasy {}'.format(shaker.__class__))
 
        print('Skladniki drinka:')
        for key,value in shaker.getAllDrinkIngredients().items():
            # Zaokraglamy i wymuszamy 2 miejsca po przecinku
            print('{:.2f}ml {}'.format(value,key))
 
        print('Calkowita zawartosc alkoholu w drinku: {:.2f}%'.format(shaker.getTotalAlcoholPercentage()))
        print('Calkowita zawartosc alkoholu w drinku (wyrazona w gramach): {:.2f}g'.format(shaker.getTotalAlcoholInGrams()))
 
        # Nie znam lepszej metody na wyswietlenie znaku promila niz unicodedata.lookup('PER MILLE SIGN')
        # Ma ktos lepszy pomysl?
        print('Prawdopodobna liczba promili dla {} o wadze {}kg wynosi {:.2f}{}'.format('faceta' if man == True else 'kobiety', weight, shaker.computeAlcoholPromillesPerWeight(weight,man), unicodedata.lookup('PER MILLE SIGN')))

Gdyby potrak­to­wać nasz żołądek jak taki shaker, to po wpisa­niu pół litra wódki + 2 piwa + litrowy sprite da nam:

Sklad­niki drinka:
300.00ml wodka
257.00ml alco­hol
1000.00ml sprite
943.00ml piwo
Calko­wita zawar­tosc alko­holu w drinku: 10.28%
Calko­wita zawar­tosc alko­holu w drinku (wyra­zona w gramach): 203.03g
Praw­do­po­dobna liczba promili dla faceta o wadze 89kg wynosi 3.26‰

No nic, na zdro­wie i pozdro dla wszyst­kich Pytho­now­ców! Leniu­chom podaję link do goto­wych plików.

Wygrzebane z GitHuba (4) : PHP User Agent

Dziś w progra­mie lżej­szy zamien­nik dla funk­cji get_browser()PHP User Agent. Za pomocą skryptu jeste­śmy w stanie okre­ślić jakiej prze­glą­darki i systemu opera­cyj­nego użyt­kow­nik używa. Orygi­nalna funk­cja potrze­buje pliku browscap.ini, który obec­nie zajmuje 405KB, co prze­kłada się na szyb­kość dzia­ła­nia. Poza tym istnieje niebez­pie­czeń­stwo, że na serwe­rze produk­cyj­nym nie dadzą nam dostępu do w/w funk­cji. (A przy­naj­mniej tak piszą ;-) )

Całość składa się z dwóch klas. Skrypt nie stosuje prze­strzeni nazw, za to jest całkiem nieźle udoku­men­to­wany. Ponadto jest trochę testów jednostkowych.

Używa­nie jest bardzo proste. Wystar­czy stwo­rzyć obiekt phpUserAgent i można szaleć.

$ua = new phpUserAgent();
echo $ua->getBrowserName();     // firefox
echo $ua->getBrowserVersion();   // 3.6
echo $ua->getOperatingSystem(); // linux
echo $ua->getEngine();            // gecko

Gdy nie podamy para­me­trów, obiekt korzy­sta z bieżą­cej zmien­nej $_SERVER['HTTP_USER_AGENT']. Można samemu wymu­sić inny ciąg do rozpo­zna­nia poda­jąc w konstruktorze.

Dla typo­wych konfi­gu­ra­cji użyt­kow­nika skrypt działa całkiem nieźle. Rozpo­znaje również aliasy nazw prze­glą­da­rek i syste­mów opera­cyj­nych. Z testów jednost­ko­wych widzę, że próbuje również rozpo­zna­wać boty wyszukiwarek.

Patrząc na skrypt przy­szło­ściowo już widzę rozra­sta­jącą się listę nazw i alia­sów oraz autora powoli prze­sta­ją­cego pano­wać nad tym wszyst­kim. Na razie działa, ale co będzie później? Wg mnie archi­tek­tura cało­ści jest trochę niedo­pra­co­wana. Aż prosi się o użycie wzorca projek­to­wego Łańcuch zobo­wią­zań, którego ogni­wami będą poszcze­gólne prze­glą­darki i to w ich gestii będzie rozpo­znać siebie w poda­nym im ciągu. Żeby nie uderzyć tak bardzo w wydaj­ność skryptu, łańcuch powinny rozpo­czy­nać najczę­ściej używane prze­glą­darki aż do typu Unknown.