Małe a cieszy: sp_executesql

Aplikacja, nad którą obecnie pracuję, dostaje od użytkowników zapytania SQL, które wykonuje na wielu bazach danych MS SQL. Zapytania mogą być najróżniejszego typu, od prostych SELECTów po złożone skrypty w Transact SQL-u. Wykonywanie takich złożonych zapytań przez PDO i odczytywanie wyników mogłoby być kłoptliwe, analiza zapytań i rozbijanie ich na pojedyncze kwerendy jeszcze bardziej. Bólu głowy mozna sobie oszczędzić w bardzo prosty sposób – z pomoca przychodzi sp_executesql. Nasze złożone zapytania możemy wykonać poprzez tę funkcję przerzucając rozwiązanie wspomnianych problemów na bazę danych.

$statement = $dbh->prepare("EXEC sp_executesql N" . $dbh->quote($query));
$statement->execute($params);
$rows = $statement->fetchAll();

Wywoływanie funkcji z nieznaną liczbą parametrów

Niektóre funkcje PHP mogą akceptować zmienną liczbę parametrów. To wygodne rozwiązanie, ale co zrobić jeśli nie mamy z góry ustalonej liczby parametrów, a funkcja nie ma swojego odpowiednika przyjmującego parametry w tablicy?

Załóżmy, że chcemy wyciągnąć wspólne wartości z kilku tablic. Można to zrobić używając funkcji array_intersect(). Jeżeli liczba tablic na wejściu nie jest znana możemy przetwarzanie je w pętli. Dużo prościej będzie jednak posłużyć się funkcją call_user_func_array().

$result = call_user_func_array('array_intersect', $arrayOfArrays);

W zmiennej $arrayOfArrays oczywiście znajdują się tablice, z których chcemy wyciągnąć wspólne wartości.

Wadą call_user_func_array() jest narzut czasowy, wywoływanie funkcji w ten sposób jest wyraźnie wolniejsze niż bezpośrednio. W krytycznej pętli chyba warto by się pokusić o switcha, który wywołuje funkcję z odpowiednią ilością argumentów.

PDO dblib a parametr charset

Dzisiaj zmagałem się z kodowaniem znaków w bazach danych Microsoft SQL. Sytuacja wyglądała następująco: PHP na Linuksie, aplikacja w UTF-8, na innym serwerze kilkadziesiąt baz danych Microsoft SQL, a żeby było zabawniej bazy używały kilku kodowań znaków.

Do baz łączyłem się przez PDO ze sterownikiem dblib. Pomysł z konwertowaniem kodowania w aplikacji szybko odrzuciłem, zamiast tego zainteresowałem się parametrem charset. W dokumentacji nie jest jasno opisany, ale łatwo można się domyślić, że informuje sterownik o oczekiwanym kodowaniu.

$dbh = new \PDO('dblib:charset=UTF-8;host=10.12.34.56;dbname=nazwa_bazy', $dbUser, $dbPassword);

Niestety to nie działało. Rozwiązaniem okazała się zmiana konfiguracji FreeTDS w pliku /etc/freetds/freetds.conf.

tds version = 7.0

Ta zmiana wymusza połaczenia z użyciem wersji 7.0 protokołu. Wedle dokumentacji dopiero w tej wersji działa parametr client charset.

Serwer na Debianie: nginx, PHP i bazy danych

Przedstawiam mój przepis szybkie postawienie serwera, na którym działa nginx, PHP i bazy danych. Ja z powodzeniem używam takiej konfiguracji na maszynie deweloperskiej, ale można jej też użyć jako punktu wyjścia do konfiguracji serwera produkcyjnego, jednak w tym wypadku radzę uważnie przeczytać zastrzeżenia na końcu.

W przepisie używam Debiana Squeeze, ale po dodaniu sudo przed większością poleceń będzie go można zastosować do Ubuntu. ;-)

Opisywana konfiguracja spełnia następujące założenia:

  • Na serwerze mają działać nginx, PHP, MySQL, PostgreSQL, MongoDB i memcached.
  • Oprogramowanie powinno być w możliwie aktualnych stabilnych wersjach.
  • Skrypty PHP mają działać z prawami użytkownika.
  • Serwer powinien mieć możliwość uruchamiania skryptów PHP zabezpieczonych ionCube’em.

Czytaj dalej „Serwer na Debianie: nginx, PHP i bazy danych”

Błąd w ADOdb CacheFlush()

Przy okazji przeprowadzki anime.com.pl na nowy serwer zaktualizowałem wykorzystywane biblioteki, w tym ADOdb. W trakcie testów odkryłem, że coś jest nie tak z obsługą cache, a konkretnie czyszczenie zapamiętanych wyników przez CacheFlush() nie działało. Po analizie kodu ADOdb odkryłem, że pierwszą rzeczą, którą robi CacheFlush() jest sprawdzenie czy $ADODB_CACHE jest ustawione:

global $ADODB_CACHE_DIR, $ADODB_CACHE;	
if (empty($ADODB_CACHE)) return false;

Problem polega na tym, że $ADODB_CACHE jest inicjalizowane tylko w metodzie CacheExecute() czyli innymi słowy, żeby skorzystać z CacheFlush() trzeba najpierw wywołać CacheExecute(). Moim zdaniem to błąd (wcześniej chyba tak nie było), zgłosiłem go i mam nadzieję, że zostanie poprawiony. W międzyczasie pozostaje poprawienie ADOdb na własną rękę lub wywoływanie CacheExecute() przed CacheFlush().

Połączenia HTTP z PHP za serwerem proxy

Dzisiaj zmierzyłem się z problemem wywoływania usługi SOAP z poziomu PHP działającego na serwerze schowanym za proxy. Ostatecznie okazało się to prostsze niż myślałem, wystarczy przekazać dodatkowe parametry do konstruktora obiektu SoapClient.

$soap = new SoapClient('http://domena.pl/usluga?wsdl', array(
  'proxy_host' => '10.107.10.69',
  'proxy_port' => 8080,
  'proxy_login' => 'nazwa.uzytkownika',
  'proxy_password' => 'haslo'
  ));

Zabrało mi to jednak trochę czasu bo zacząłem od szukania rozwiązania na niższym poziomie czyli modyfikacji parametrów używanych przez PHP do wykonywania połączeń HTTP. Metodą guglania, prób i błędów dotarłem do takiego rozwiązania, które również działa.

$default = stream_context_get_default(array(
  'http' => array(
    'proxy' => 'tcp://10.107.10.69:8080',
    'request_fulluri' => true,
    'header' => 'Proxy-Authorization: Basic ' . base64_encode('nazwa.uzytkownika:haslo'),
    )
  ));

Dzięki tej metodzie z proxy zaczynają współpracować wszystkie funkcje wykorzystujące protokół HTTP, np. readfile() z plikiem na zdalnym serwerze.

readfile('http://www.google.com/');

Wyłączanie akcji w generatorze Symfony

Generator modułów administracyjnych w Symfony nie umożliwia wybrania akcji, które dany moduł ma wykonywać, zawsze dostajemy pełen zestaw CRUD. Czasami zachodzi potrzeba zablokowania niektórych akcji, np. w wypadku logów bezpieczeństwa sens ma jedynie index (przeglądanie listy), a jakakolwiek modyfikacja powinna być zabroniona.

Odnośniki widoczne na liście można łatwo ukryć edytując plik generator.yml, wystarczy domyślne akcje zastąpić pustymi listami.

generator:
  param:
    config:
      list:
        title:             Permission changes log
        actions:           {}
        object_actions:    {}
        batch_actions:     {}
      filter:
        display:           [agent_user_name, subject_user_name, permission_name, created_at]

Akcje można jednak dalej wykonywać ręcznie wpisując URL, dlatego należy je zablokować w kontrolerze.

class permission_logActions extends autoPermission_logActions
{
  public function preExecute()
  {
    $this->forward404If($this->getActionName() !== 'index', 'Action disabled');
    parent::preExecute();
  }
}

Zapisywanie osadzonych formularzy w Symfony

Zapisywanie osadzonych formularzy w Symfony działa inaczej niż intuicyjnie zakładałem. Co ciekawsze, już raz się z tym kiedyś zetknąłem, ale było to na tyle dawno, że zapomniałem rozwiązania.

Załóżmy, że w zwykłym formularzu dziedziczącym z klasy sfForm osadzam formularz oparty na modelu z bazy danych czyli dziedziczący z sfFormDoctrine.

class OnePageCheckoutForm extends sfForm
{
  public function configure()
  {
    $this->widgetSchema['same_address'] = new sfWidgetFormInputCheckbox();
    $this->validatorSchema['same_address'] = new sfValidatorBoolean();

    $this->embedForm('customer', new CustomerForm());
    $this->embedForm('shipment_address', new AddressForm());
    $this->getWidgetSchema()->setNameFormat('checkout_form[%s]');
  }
}

W kontrolerze chcę zapisać dane z osadzonych formularzy, ale oczywiście klasa OnePageCheckoutForm nie ma metody save(). W pierwszym odruchu napisałem mniej więcej coś takiego:

public function executeCheckout(sfWebRequest $request)
{
  if (!$this->getUser()->hasCart())
  {
    return $this->redirect('@cart');
  }
  $this->cart = $this->getUser()->getCart();

  $this->form = new OnePageCheckoutForm();
  if ($request->getMethod() == 'POST')
  {
    $this->form->bind($request->getParameter($this->form->getName()), $request->getFiles($this->form->getName()));
    if ($this->form->isValid())
    {
      $values = $this->form->getValues();
      $this->form->getEmbeddedForm('customer')->save();
      $this->form->getEmbeddedForm('shipment_address')->save();

      $this->cart->setCustomerId($this->form->getEmbeddedForm('customer')->getObject()->getId());
      $this->cart->save();
      $this->redirect('@cart_payment_process');
    }
  }
}

Ku memu zaskoczeniu zobaczyłem wyjątek, z którego wynikało, że zapis formularza customer się nie powiódł, bo formularz nie zawiera danych. Dla pewności wrzuciłem w kod kilka var_dumpów:

// wynik: dane dla całego formularza łącznie z osadzonymi
var_dump($this->form->getValues());
// wynik: false
var_dump($this->form->getEmbeddedForm('customer')->isBound());
// wynik: pusta tablica
var_dump($this->form->getEmbeddedForm('customer')->getObject()->toArray());

Jak się okazało, formularz klasy sfForm nie aktualizuje obiektów w osadzonych formularzach, trzeba to wykonać samemu:

$this->form->getEmbeddedForm('customer')
  ->updateObject($this->form->getValue('customer'));

Można to zrobić w kontrolerze, ja ze względów konwencjonalno-estetycznych dopisałem do klasy OnePageCheckoutForm metody updateEmbeddedForms() i saveEmbeddedForms(), które są z grubsza kopią tak samo nazwanych metod z klasy sfFormObject.

Sortowanie wyników z użyciem funkcji w Solarium

To będzie zwięzły wpis dokumentujący małe zwycięstwo w pojedynku z Solarium. Zadanie polegało na dodaniu nowego sposobu sortowania produktów: w pierwszej kolejności według dostępności produktu (bez względu na ilość, tylko binarne tak/nie), w drugiej według ceny. W bazie Solr mamy tylko ilość sztuk danego produktu, należało ją więc jakoś zrzutować na wartość binarną. Na szczęście Solr umożliwia użycie funkcji do porządkowania znalezionych dokumentów, Solarium od drugiej wersji także to obsługuje. Końcowy efekt moich starań jest następujący:

$query->addSort($query->getHelper()->functionCall('min', array('stock', 1)), Solarium_Query_Select::SORT_DESC);

Dla celów dydaktycznych dodam, że najpierw próbowałem zrobić to w ten sposób:

$query->addSort($query->getHelper()->functionCall('if', array('stock', 1, 0)), Solarium_Query_Select::SORT_DESC);

jednak kończyło się to błędem:

Solr HTTP error: sort param could not be parsed as a query, and is not a field that exists in the index: if(stock,1,0) (400)

Przyczyna okazała się prozaiczna, ale odkrycie jej zajęło mi dłuższą chwilę. Otóż funkcja if dostępna jest dopiero od wersji 4.0 Solr, a my używamy 3.6.

Generowanie losowych kodów rabatowych

Wczoraj pisałem moduł obsługujący kody rabatowe w sklepie internetowym. Wedle specyfikacji kody mogą być jedno- lub wielokrotnego użytku. Te pierwsze mogą być hurtowo generowane w panelu administracyjnym. Na chłopski rozum taki jednorazowy kod powinien być:

  • losowy, żeby pomysłowi klienci nie mogli zgadywać kodów
  • w miarę krótki, żeby wpisywanie go nie było kłopotliwe
  • pozbawiony podobnych do siebie znaków takich jak O i 0, ze względów wspomnianych w poprzednim punkcie

Mój pomysł na generowanie takich kodów wygląda następująco:

public static function generateRandom()
{
  return str_pad(strtr(strtoupper(base_convert(rand(1000, 2176782335), 10, 36)), '0O', '1A'), 6, 'X');
}

Powyższa metoda generuje losowe kody złożone z 6 alfanumerycznych znaków (wielkie litery i cyfry) z pominięciem zera i litery O. Przykładowy kod: 3LFPHP. Tytułem wyjaśnienia: 2176782335 to ZZZZZZ w systemie trzydziestoszóstkowym.

Nie robiłem żadnych testów, ale przeczucie mówi mi, że takie rozwiązanie jest szybsze niż losowanie po jednym znaku z tablicy dozwolonych znaków.

Kody powinny być także unikalne, a powyższa metoda tego nie zapewnia. Gdyby tych kodów trzeba było generować tysiące na minutę pewnie pokusiłbym się o jakiś bardziej wyszukany algorytm, np. w jednej transakcji wygenerować tablicę z liczbą kodów ciut większą niż zadana, jednym zapytaniem sprawdzić które z nich już występują w bazie, odfiltrować je z tablicy, a resztę wstawić drugim zapytaniem. W tym konkretnym sklepie nie ma takiej potrzeby, więc wybrałem prostsze rozwiązanie.

public static function generateCodes(Voucher $voucher, $number)
{
  for ($i = 1; $i <= $number; $i++)
  {
    do
    {
      try
      {
        $code = new VoucherCode();
        $code->setVoucher($voucher)
          ->setCode(VoucherCode::generateRandom())
          ->save();
      }
      catch (Doctrine_Connection_Mysql_Exception $e)
      {
      }
    } while ($code->isNew())
  }
}

Kolumna code w tabeli ma założony unikalny indeks (przydaje się do wyszukiwania), więc próba wstawienia takiej samej wartości powoduje wyjątek, który łapię i generuję nowe wartości dopóki wstawianie rekordu do tablicy nie zakończy się powodzeniem.