Tag Archives: PDO

webmastering

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ą najbardziej :-)

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.

webmastering

PDO poprzez Dependency Injection Container [cz. 2/2]

Dzisiaj kończę temat wstrzy­ki­wa­nia zależ­no­ści. Punk­tem wyjścia będzie poprzedni kod. Konte­ner posłuży do skon­fi­gu­ro­wa­nia jesz­cze dwóch obiek­tów. Napi­sa­łem sobie 2 proste klasy: SelectQuery, który odpyta bazę danych na podane zapy­ta­nie oraz SelectQueryCache, który zachowa wynik zapy­ta­nia do pliku. Wynik będzie ważny tylko przez podany czas.

Przy okazji zmie­ni­łem trochę forma­to­wa­nie składni na bardziej „Zend-Frameworkowe”. Mam nadzieję, że bardziej czytelne. Kolejną zmianą jest używa­nie w nazwach klas/zmiennych wyłą­cze­nie języka angiel­skiego. Wam to nie będzie prze­szka­dzać, a mnie oszczę­dzi masę czasu gdybym chciał coś kiedyś rzucić na szero­kie wody.

W meto­dzie loadDefaults() dorzu­camy zmienne konfi­gu­ru­jące obiekt cachu­jący i dwa domknię­cia potra­fiące wypro­du­ko­wać gotowe obiekty:

//zmienne konfigurujące pdo z poprzedniego wpisu
 
$this->cache_filename = 'queries.cache';
$this->cache_interval = 30;
$this->cache_class = 'SelectQueryCache';
 
$this->selectQueryCache = function (DBContainer $k)
{
  return new $k->cache_class($k->cache_filename , k->cache_interval);
};
 
$this->selectQuery = function (DBContainer $k)
{
  $q = new SelectQuery($k->pdo_getPDO);
  // wstrzykujemy zaleznosc
  $q->setCache($k->selectQueryCache);
  return $q;
};

Właści­wie to wpis można by teraz zamknąć. Widać jak cache tworzony jest na podsta­wie zmien­nych oraz widać wstrzyk­nię­cie zależ­no­ści w postaci metody setCache(). Innym sposo­bem wstrzy­ki­wa­nia jest konstruk­tor. Nie zrobi­łem tak z uwagi na to, że SelectQuery może sobie radzić bez obiektu cachu­ją­cego, więc po co wymuszać.

Poni­żej załą­czam kod dwóch wspo­mnia­nych wyżej klas.

class SelectQuery
{
  /**
   * @var PDO
   */
  protected $pdo;
 
  /**
   * @var SelectQueryCache 
   */
  protected $cache = null;
 
  public function __construct(PDO $pdo)
  {
    $this->pdo = $pdo;
  }
 
  /**
   * @param SelectQueryCache $sqc dependency injection
   */
  public function setCache(SelectQueryCache $sqc)
  {
    $this->cache = $sqc;
  }
 
  /**
   * @param string $query
   * @return array
   */
  public function getResults($query)
  {
    if ($this->cache)
    {
      $cachedResults = $this->cache->getCachedResults($query);
      if ($cachedResults !== null)
      {
        return $cachedResults;
      }
    }
    $q = $this->pdo->prepare($query);
    $q->execute();
    $rows = array();
    while ($row = $q->fetch(PDO::FETCH_ASSOC))
    {
      $rows[] = $row;
    }
    if ($this->cache)
    {
      $this->cache->cacheQuery($query , $rows);
    }
    return $rows;
  }
}
 
class SelectQueryCache
{
  /**
   * @var array contains all cached queries
   */
  protected $cachedQueries = array();
 
  /**
   * @var SPLFileInfo
   */
  protected $cacheFile;
 
  /**
   * @var int time interval in secs to check if cache is still valid
   */
  protected $validTime;
 
  public function __construct($filename , $validtime = 60)
  {
    $this->cacheFile = new SplFileInfo(__DIR__ . '/' . $filename);
    $this->validTime = (int) $validtime;
    $this->loadCache();
  }
 
  protected function loadCache()
  {
    if (!file_exists($this->cacheFile))
    {
      touch($this->cacheFile);
    }
    $file = $this->cacheFile->openFile('r');
    if ($file->flock(LOCK_SH))
    {
      $this->cachedQueries = json_decode(file_get_contents($file) , true);
      $file->flock(LOCK_UN);
    }
    else
    {
      throw new Exception('Cannot acquire file lock to read file');
    }
  }
 
  public function cacheQuery($query , $result)
  {
    $this->cachedQueries[$query] = array(
      'time' => time() ,
      'result' => $result);
  }
 
  public function getCachedResults($query)
  {
    if ($this->isValidCache($query))
    {
      return $this->cachedQueries[$query]['result'];
    }
    else
    {
      return null;
    }
  }
 
  protected function isValidCache($query)
  {
    return ($this->cachedQueries !== null
      && array_key_exists($query , $this->cachedQueries)
      && $this->isValidTime($this->cachedQueries[$query]['time']));
  }
 
  protected function isValidTime($timestamp)
  {
    return ((time() - $this->validTime) < $timestamp);
  }
 
  protected function cleanUpOldCache()
  {
    foreach ($this->cachedQueries as $key => $cq)
    {
      if (!$this->isValidTime($cq['time']))
      {
        unset($this->cachedQueries[$key]);
      }
    }
  }
 
  protected function save()
  {
    $serializedData = json_encode($this->cachedQueries);
    $file = $this->cacheFile->openFile('w');
    if ($file->flock(LOCK_EX))
    {
      $file->fwrite($serializedData);
      $file->flock(LOCK_UN);
    }
    else
    {
      throw new Exception('Cannot acquire exclusive file lock to save file');
    }
  }
 
  public function __destruct()
  {
    $this->cleanUpOldCache();
    $this->save();
  }
}

Myślę, że w SelectQuery nie ma nic specjal­nego, no może poza getResults(), które najpierw spraw­dza czy jest cache i jakiś wynik, a jak nie to odpy­tuje bazę danych i zapi­suje wynik do cache.

Za to w SelectQueryCache znaj­dzie się trochę mięska:

  1. Do seria­li­za­cji danych używam json_encode, które jest ponoć trochę szyb­sze od zwykłej seria­li­za­cji, a zapi­sane dane mają formę straw­niej­szą dla humanoidów.
  2. Dane w pliku trzy­mam w postaci tablicy asocja­cyj­nej, której kluczem jest zapy­ta­nie, a warto­ściami wynik zapy­ta­nia i czas utworzenia.
  3. Do spraw­dza­nia czy cache nie jest prze­ter­mi­no­wany odej­muję liczbę sekund od obec­nego czasu i spraw­dzam czy jest wcze­śniej­sza od czasu utwo­rze­nia danego klucza.
  4. Oczysz­czam cache spraw­dza­jąc czy poszcze­gólne klucze nie są już prze­ter­mi­no­wane i w razie czego wywa­lam cały klucz.
  5. Do zapisu do pliku używam destruk­tora. Mam pewność, że obiekt zaraz przed zakoń­cze­niem żywota zapi­sze dane do pliku.

Na koniec przy­kład użycia całości:

$c = new DBContainer();
$query = $c->selectQuery;
$results = $query->getResults('SELECT nazwa_pola FROM jakas_tabela');

Za pierw­szym razem dane zostaną pobrane z bazy, a następ­nie przez 30 sekund z pliku cache.

webmastering

PDO poprzez Dependency Injection Container [cz. 1/2]

Ostat­nio mnie ostro zjecha­li­ście. Dzięki za komen­ta­rze, szcze­gól­nie te nega­tywne (yyy wszyst­kie?). Wszyst­kie staran­nie prze­czy­ta­łem. Poczy­ta­łem co nieco i zdecy­do­wa­łem się uderzyć z tema­tem jesz­cze raz. Tym razem uwzględ­nia­jąc zada­nia takie jak „a co jak będę miał kilka serwe­rów: testowy, produk­cyjny, itp.”, „a co jak chcę połą­czyć się z dwiema bazami na raz”?

Punk­tem wyjścia stał się Twit­tee, czyli konte­ner stwo­rzony w 2009 r. przez Fabiena Poten­ciera zajmu­jący 140 znaków (tyle żeby całość dała się prze­słać w postaci poje­dyn­czej wiado­mo­ści w serwi­sie Twit­ter). Podstawą konte­nera jest magia __set()__get(), czyli to co Zyx lubi najbar­dziej :-) Całość została przeze mnie mocno zmody­fi­ko­wana. Doda­łem np. rzuca­nie wyjąt­kami jeżeli wyma­gana wartość nie została usta­wiona plus obsługę domknięć w przy­padku gdy usta­wiona wartość jest funk­cją anonimową.

Parę lini­jek doty­czą­cych usta­wie­nia PDO wcisną­łem do funk­cji anoni­mo­wej plus doda­łem możli­wość trzy­ma­nia poje­dyn­czej instan­cji PDO w razie potrzeby (zwrócę potem uwagę na static w domknię­ciu). Obiekt PDO „nie wie”, że jest w konte­ne­rze i dobrze. Istotą DI jest to żeby klas nie trzeba było specjal­nie dosto­so­wy­wać do współ­pracy z kontenerem.

Obsługę wyjąt­ków w cało­ści zrzu­cam na klien­tów nie miesza­jąc kompe­ten­cji konte­nera, który ma ustawiać/zwracać zmienne/fabrykować obiekty.

class DBContainer {
 
  protected $values = array();
 
  public function __construct() {
        $this->loadDefaults();
  }
 
  protected function loadDefaults() {
    $this->pdo_driver = 'mysql';
    $this->pdo_host = 'localhost';
    $this->pdo_dbname = 'nazwabazy';
    $this->pdo_user = 'user';
    $this->pdo_pass = 'haslo';
    $this->pdo_charset = 'SET NAMES utf8';
    $this->pdo_persist = false;
 
    $this->pdo_getpdo = function(DBContainer $cont) {
 
      // static w kontekscie funkcji anonimowej
      static $persistentPDO;
 
      $pdoCreator = function() use ($cont) {
        if (!extension_loaded('PDO')) throw new Exception('Brak modulu PDO');
        $pdo = new PDO(
                    $cont->pdo_driver . ':host=' . $cont->pdo_host . ';dbname=' . $cont->pdo_dbname,
                    $cont->pdo_user,
                    $cont->pdo_pass,
                    array(PDO::MYSQL_ATTR_INIT_COMMAND => $cont->pdo_charset));
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
        return $pdo;
      };
 
      if ($cont->pdo_persist && $persistentPDO instanceof PDO) {
        return $persistentPDO;
      }
      elseif ($cont->pdo_persist) {
        $persistentPDO = $pdoCreator();
        return $persistentPDO;
      }
      else {
        return $pdoCreator();
      }
    };
 
    public function __set($key,$val) {
        $this->values[$key] = $val;
    }
 
    public function __get($key) {
        if (!isset($this->values[$key])) {
            throw new Exception("Wartosc {$key} nie istnieje");
        }
        if ($this->values[$key] instanceof Closure) {
            return $this->values[$key]($this);
        }
        else {
            return $this->values[$key];
        }
    }
}

Przy­kłady użycia:

$c = new DBContainer();
 
// PDO na domyslnych ustawieniach
$pdo = $c->pdo_getpdo;
 
// przestawiam baze danych
$c->pdo_dbname = 'testowa baza';
$nowePDOdlaBazyTestowa = $c->pdo_getpdo;
 
// znowu przestawiam baze danych, przestawiam na zapis PDO na stale
$c->pdo_dbname = 'baza produkcyjna';
$c->pdo_persist = true;
 
// sprawdzam czy na pewno obiekty PDO sa tej samej instancji
echo spl_object_hash($c->pdo_getpdo) . '<br>' . spl_object_hash($c->pdo_getpdo);
// zwroci taki sam hash

Na raz następny pokażę jak można fajnie korzy­stać z tego dla obiek­tów korzy­sta­ją­cych z pdo wewnętrznie.

Osoby nielu­biące magii upra­sza się o powstrzy­ma­nie od wyle­wa­nia żalu. Po to zrobili __get(), __set() i dyna­miczne typy zmien­nych żeby z nich korzy­stać. Dobra doku­men­ta­cja wg mnie zała­twia sprawę.