Boj s Nette\Database: Subselect a výběr kolonek

Upozornění: Tohle vlákno je hodně staré a informace nemusí být platné pro současné Nette.
Eda
Backer | 220
+
+1
-

Zdarec.

Při práci s Nette\Database jsem narazil na dvě neintuitivní věci, které mi zabraly nějaký čas než jsem je vyřešil. Tento příspěvek nechť slouží pro další generace (a mě, až na to zapomenu :-) ) jako návod, jak tyto situace řešit.

Používám aktuální vývojovou verzi Nette 2.1.


Mám databázi vozů (tabulka car) a dopravců (tabulka operator). Mezi vozy a dopravci je vazba M:N (přes tabulku car_operator), ve které je mimo jiné poznamenáno i pořadí dopravce u vozu (abych věděl, který dopravce měl vůz jako první, druhý atd.). Dump databáze viz konec příspěvku.

Na stránce s výpisem vozů chci filtrovat podle dopravce dejme tomu „ČSAD Brno-město“ (tzn. chci najít i vozy, které byly dříve vlastněny „ČSAD Brno-město“, ale dnes už jsou prodané jinému dopravci) a zároveň řadit podle posledního majitele vozu (tzn. nejdřív vozy, které jsou nyní vlastněné „ČSAD Brno-město“ a až pak vozy, které jsou vlastněné dopravcem abecedně až za ním, například „STUDENT AGENCY“).

Jak to udělat? Ze začátku jsem dost tápal. Zkoušel jsem dát subselect zjišťující aktuálního dopravce do funkce ->select() takto: table('car')->select('(SELECT ...) AS currentOperator') a následně pak v order volat ->order('currentOperator DESC'), ale nešlo to. NDB se snažila celý subselect rozparsovat. Obalil jsem to tedy do SqlLiteralu. Ani to nepomohlo (funkce select s ním vůbec nepočítá? Tohle je možný námět na zlepšení…). Ani syntaxe známá z funkce ->where('?', $argument) nefunguje, byť se otazník vůbec neošetří, a tak výsledná query vyžaduje dosazení argumentu, který tam ale není jak dostat.

// ukázka jednoho z pokusů
$cars = $this->db
	->table('car')
	->select(new SqlLiteral('
		IFNULL((
			SELECT name
			FROM operator AS o
			INNER JOIN car_operator AS co ON co.operator_id = o.id
			WHERE co.car_id = car.id
			ORDER BY co.order DESC
			LIMIT 1), \'\'
		) AS currrentOperator'))
	->where('car_operator:operator.name', 'ČSAD Brno-město')
	->order('currentOperator DESC');
// Skončí chybou `No reference found for $car->co`.
// NDB se snaží subselect rozparsovat, přestože se jedná o SqlLiteral

Jak jsem to nakonec vyřešil? Stačilo všechny identifikátory v subselectu obalit do znaků `. NDB se je tak nesnaží escapovat a vše projde. Je jedno, jestli se to celé obalí do SqlLiteralu, nebo ne.


Při další práci jsem narazil na podivnou věc. Kód:

// chci všechny vozy, seřazené podle dopravce
$cars = $this->db->table('car')
	->select('*')
	->order('car_operator:operator.name');

Položí do databáze dotaz:

SELECT *
FROM `car`
LEFT JOIN `car_operator` ON `car`.`id` = `car_operator`.`car_id`
LEFT JOIN `operator` ON `car_operator`.`operator_id` = `operator`.`id`
ORDER BY `operator`.`name`

Debug panel ukazuje 4 nalezené záznamy.

Ale když pak v kódu uděláte tohle:

foreach ($cars as $car) {
	echo $car->id;
}

Vypíší se jen dva záznamy.
Podle tohoto kódu:

echo $cars->count();

Byly nalezeny opravu jen dva záznamy.
Naopak tohle:

echo $cars->count('*');

Už vypíše číslo 4.

Jak je to možné?
Za vším hledej… kolonky v selectu :-) Tím, že jsme uvedli:

	->select('*')

…a joinujeme k další tabulce, se povedlo to, že ve výsledku, který nám vrátilo MySQL je dvakrát sloupec s názvem id. Jeden z tabulky car, druhý z tabulky operator, kterou joinujeme. Nette/Database používá při ukládání výsledku indexování v poli pomocí primárního klíče. Primární klíč je jen sloupec id a tak každý záznam uloží do pole $selection->data právě pod touto hodnotou. Jenomže vezme až tu druhou = tu, kterou nechceme = tu, z tabulky operators. Tím dojde k tomu, že se dva a dva záznamy navzájem přepíší a celkem je tedy máme jen dva.

Jak se to dá řešit? Stačí místo ->select('*') uvést ->select('car.*'). Pak se tahají opravdu jen sloupce z tabulky car a k žádné kolizi nedojde.

Tento problém by se možná dal označit za bug. Co myslíte?

Tohle by mohl být další impulz k zamyšlení o tomto. Je to vpodstatě stejný problém, akorát trochu z jiného konce.


Přeju vám ostatním, abyste se s takovými WTF Nette Database nikdy nesetkali. Na malý projekt je Nette\Database optimální. Čím větší a složitější struktura databáze, tím je ale použití problematičtější. A zdánlivé drobnosti jako už zmiňované toto mohou hrát velkou roli při odhalování chyb.


Na závěr ještě dump databáze, kdyby si to někdo chtěl vyzkoušet.

-- Adminer 3.6.0 MySQL dump

SET NAMES utf8;
SET foreign_key_checks = 0;
SET time_zone = 'SYSTEM';
SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';

DROP TABLE IF EXISTS `car`;
CREATE TABLE `car` (
  `id` int(4) unsigned NOT NULL AUTO_INCREMENT,
  `numberPlate` varchar(12) COLLATE utf8_czech_ci NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=32199 DEFAULT CHARSET=utf8 COLLATE=utf8_czech_ci;

INSERT INTO `car` (`id`, `numberPlate`) VALUES
(1,	'BSC 00-01'),
(2,	'BSC 00-02'),
(3,	'BSC 00-03'),
(4,	'BSC 00-04');

DROP TABLE IF EXISTS `car_operator`;
CREATE TABLE `car_operator` (
  `car_id` int(4) unsigned NOT NULL,
  `operator_id` int(11) NOT NULL,
  `order` int(4) NOT NULL,
  PRIMARY KEY (`car_id`,`operator_id`,`order`),
  KEY `car_id` (`car_id`),
  KEY `operator_id` (`operator_id`),
  CONSTRAINT `car_operator_ibfk_1` FOREIGN KEY (`car_id`) REFERENCES `car` (`id`),
  CONSTRAINT `car_operator_ibfk_2` FOREIGN KEY (`operator_id`) REFERENCES `operator` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_czech_ci;

INSERT INTO `car_operator` (`car_id`, `operator_id`, `order`) VALUES
(1,	1,	1),
(2,	1,	1),
(3,	2,	1),
(4,	2,	1);

DROP TABLE IF EXISTS `operator`;
CREATE TABLE `operator` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(20) COLLATE utf8_czech_ci NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COLLATE=utf8_czech_ci;

INSERT INTO `operator` (`id`, `name`) VALUES
(1,	'ČSAD Brno-město'),
(2,	'STUDENT AGENCY');

-- 2013-03-21 18:17:16
hrach
Člen | 1838
+
0
-

ad 1) SqlBuilder nepodporuje SqlLiteral v select. Proto dojde k pretypovani pomoci __toString a tomuto nechtenemu chovani. SqlLiteral tu jakoby nehraje zadnou roli.

ad 2) to je vcelku taky logicky. Otazka je, jestli tedy v pripade rucniho selectu nevypnout tuto feature, ktera je dulezita jenom pro dotahovani zaznamu s jinymi sloupci.