diff --git a/PERFORMANCE_PLAN.md b/PERFORMANCE_PLAN.md new file mode 100644 index 000000000000..a0d0aaf527a4 --- /dev/null +++ b/PERFORMANCE_PLAN.md @@ -0,0 +1,207 @@ +# Performance: Test "Add from pool" — Fragenbrowser + +## Problem + +`ilObjTestGUI::showQuestions` → Button "Add from pool" → +`ilTestQuestionBrowserTableGUI::browseQuestionsCmd` baut eine +`ilAssQuestionList` und ruft `load()` mit gesetzter Range/Order auf. + +`ilAssQuestionList::buildQuery()` (`class.ilAssQuestionList.php:577`) +setzt über `getSelectFieldsExpression()` (`:444`) **korrelierte +EXISTS-Subqueries** als SELECT-Felder: + +- `generateFeedbackSubquery()` (`:476`) — 4 abhängige Subqueries + (qpl_fb_generic, qpl_fb_specific, jeweils + page_object) +- `generateHintSubquery()` (`:500`) — qpl_hints (EXPLAIN: `ALL`-Scan) +- `generateTaxonomySubquery()` (`:506`) — tax_node_assignment + +Diese werden für **jede Kandidatenzeile** berechnet, **bevor** +`ORDER BY`/`LIMIT` greifen (EXPLAIN: `Using temporary; Using filesort` +über ~199k Zeilen). Auf großen Instanzen → 30–40 min Laufzeit, +Blockieren anderer Queries. Lokal (1 Pool, 2 Fragen) nicht reproduzierbar. + +Bug-Report schlägt vor: erst paginierte question_id-Menge bestimmen, +dann Flags nur für diese IDs berechnen. + +## Lösungsansatz: Zweiphasige Abfrage + +**Aktiv nur wenn `$this->range !== null`** (Pagination aktiv — der +Langsamm-Pfad). Alle anderen Caller setzen keine Range → unverändert. + +### Phase A — ID-Auswahl (`buildPaginatedIdsQuery()`) + +``` +SELECT DISTINCT qpl_questions.question_id +FROM qpl_questions + {getTableJoinExpression()} -- inkl. Filter-Joins (handleFeedbackJoin/handleHintJoin) +WHERE qpl_questions.tstamp > 0 + {getConditionalFilterExpression()} -- alle WHERE-Filter + {buildOrderQueryExpression()} -- Spalten qualifiziert! + {buildLimitQueryExpression()} -- Pagination hier +``` + +- **kein** `getSelectFieldsExpression()` (keine EXISTS-Subqueries) +- **kein** `getHavingFilterExpression()` +- liefert geordnete Liste von `question_id`s + +### Phase B — Anreicherung (`buildEnrichmentQuery(array $ids)`) + +``` +{getSelectFieldsExpression()} -- inkl. feedback/hints/taxonomies (nur f. wenige IDs!) +FROM qpl_questions + {getBaseTableJoinExpression()} -- qpl_qst_type, object_data, ggf. tst_test_result + -- OHNE handleFeedbackJoin/handleHintJoin +WHERE qpl_questions.tstamp > 0 + AND qpl_questions.question_id IN () +``` + +- Ergebnis in PHP nach Phase-A-Reihenfolge ordnen. + +### Fallback auf alte Einzelabfrage + +Wenn **eine** Bedingung zutrifft: + +1. `getHavingFilterExpression() !== ''` + (Filter `feedback=false`/`hints=false` nutzt HAVING) +2. Order-Feld ∈ `{feedback, hints, taxonomies}` + (Sortierung nach berechneter Spalte → Phase A kann nicht danach sortieren) + +→ alte `buildQuery()` (unverändert). + +## Detailänderungen in `class.ilAssQuestionList.php` + +### 1. `load()` (`:588`) + +```php +public function load(): void +{ + $this->checkFilters(); + + if ($this->canUseTwoPhaseQuery()) { + $this->loadTwoPhase(); + return; + } + + $this->loadSinglePhase(); // bisherige Implementierung +} +``` + +### 2. Neu: `canUseTwoPhaseQuery(): bool` + +```php +private function canUseTwoPhaseQuery(): bool +{ + if ($this->range === null) { + return false; + } + if ($this->getHavingFilterExpression() !== '') { + return false; + } + if ($this->order !== null + && $this->isOrderByComputedField() + ) { + return false; + } + return true; +} +``` + +### 3. Neu: `buildPaginatedIdsQuery(): string` + +SELECT DISTINCT question_id + Joins + Filter + Order(qualifiziert) + Limit. + +### 4. Neu: `buildEnrichmentQuery(array $ids): string` + +getSelectFieldsExpression() + getBaseTableJoinExpression() + WHERE IN. + +### 5. Neu: `getBaseTableJoinExpression(): string` + +Wie `getTableJoinExpression()`, aber **ohne** `handleFeedbackJoin`/ +`handleHintJoin` (die waren nur Filter-Joins, in Phase A bereits +angewendet; in Phase B stören sie nur). + +### 6. `buildOrderQueryExpression()` (`:544`) erweitern + +Neuer Parameter `bool $qualify = false`. Bei `$qualify=true`: +Spalten qualifizieren, da `qpl_questions.*` in Phase A fehlt → sonst +"Column 'title' is ambiguous": + +| Order-Feld | qualifiziert zu | +|---------------|---------------------------| +| title | qpl_questions.title | +| description | qpl_questions.description | +| author | qpl_questions.author | +| lifecycle | qpl_questions.lifecycle | +| points | qpl_questions.points | +| max_points | qpl_questions.points | +| created | qpl_questions.created | +| tstamp | qpl_questions.tstamp | +| type_tag | qpl_qst_type.type_tag | +| parent_title | object_data.title | +| feedback/hints/taxonomies | (Fallback — kommt hier nie an) | + +### 7. Neu: `isOrderByComputedField(): bool` + +Prüft ob Order-Feld ∈ {feedback, hints, taxonomies}. + +### 8. `getTotalRowCount()` (`:622`) + +Unverändert (bereits ohne EXISTS-Subqueries → schnell). + +**Vorhandener Bug (nur dokumentieren):** wendet `getHavingFilterExpression()` +nicht an → Count bei `feedback=false`-Filter falsch. Nicht Teil dieses +Tickets. + +## DB-Index — `class.ilTestQuestionPool10DBUpdateSteps.php` + +Neue `step_3()`: Composite-Index `(obj_fi, original_id, title)` auf +`qpl_questions` als `i6` (ILIAS' `checkIndexName` limitiert Indexnamen +auf 3 Zeichen; die bestehenden `i1_idx`…`i5_idx` predaten diese Regel). + +- Deckt Phase A für beide Hauptpfade: + - Pool-intern: `obj_fi = X` + `original_id IS NULL` + - Browse-from-pools: `obj_fi IN (...)` + `original_id IS NULL` +- `title` als dritte Spalte unterstützt Default-Sortierung. +- `addIndex()` ist idempotent (vorhandene Single-Column-Indexes + `i3_idx`(obj_fi), `i2_idx`(original_id), `i4_idx`(title) bleiben). +- Trade-off: höhere Schreibkosten auf `qpl_questions` bei INSERT/UPDATE + (große Tabelle) — akzeptiert zugunsten der Lese-Performance. + +## Kompatibilität + +- `QuestionTable extends ilAssQuestionList`: überschreibt `load()` nicht; + `getData()` setzt **kein** `setRange` auf der Liste (paginiert in PHP) + → `canUseTwoPhaseQuery()` liefert `false` → unverändert. +- `getTotalRowCount()` dort ruft `load()`+`count()` auf (lädt alles — + separates Problem, nicht dieses Ticket). +- Alle anderen Caller (`ilTestResultsGUI`, `ilTestSkillAdministrationGUI`, + `ilLMTracker`, `ilAsqFactory`, `ilCopySelfAssQuestionTableGUI`, + `ilObjQuestionPoolTaxonomyEditingCommandForwarder`, + `ilQuestionPoolSkillAdministrationGUI`, `ilTestPlayerAbstractGUI`) + setzen keine Range → unverändert. + +## Validierung + +1. `ilAssQuestionListTest` ergänzen: + - Phase-A/B-Pfad mit Mock-DB + - Fallback bei Sortierung nach `feedback` + - Fallback bei Filter `feedback=false` (HAVING) + - Fallback bei fehlender Range +2. Docker: Setup-Step ausführen, Index prüfen + (`SHOW INDEX FROM qpl_questions`). +3. Docker: Query-Log vor/nachher (ILIAS-Debug-Toolbar) — + 2 Queries statt 1, dafür ohne korrelierte Subqueries über die + Kandidatenmenge. +4. Edge Cases manuell: + - Sortierung nach `feedback`/`hints`/`taxonomies` → Fallback + - Filter `feedback=false` → Fallback + - Filter `feedback=true` → Fallback (INNER JOIN + HAVING) + - Sortierung nach `title`/`parent_title`/`type_tag` → Phase A +5. Auf großer Instanz: EXPLAIN der Phase-A-Query (sollte nur noch + `qpl_questions` + billige JOINs, keine `DEPENDENT SUBQUERY`), + Laufzeit messen. + +## Out of scope + +- `QuestionTable` (PHP-Pagination, lädt alle) — separates Ticket. +- `getTotalRowCount` HAVING-Korrektur — vorhandener Bug, nur dokumentieren. diff --git a/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPool10DBUpdateSteps.php b/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPool10DBUpdateSteps.php index 7b9b56281927..3d3827969f7e 100755 --- a/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPool10DBUpdateSteps.php +++ b/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPool10DBUpdateSteps.php @@ -47,4 +47,23 @@ public function step_2(): void $this->db->dropTableColumn('qpl_questionpool', 'show_taxonomies'); } } + + /** + * Composite index supporting the paginated question browser query + * (ilAssQuestionList two-phase loading, phase A) which filters by + * obj_fi (eq / IN) and original_id IS NULL and commonly orders by + * title. Covers both the pool-internal view (obj_fi = X) and the + * "add from pool" browser (obj_fi IN (...)) of ilObjTestGUI. + * + * Note: ILIAS' ilDBPdoFieldDefinition::checkIndexName limits index + * names to 3 characters, hence the short name "i6" (the existing + * i1_idx..i5_idx on this table predate that constraint). + */ + public function step_3(): void + { + $fields = ['obj_fi', 'original_id', 'title']; + if (!$this->db->indexExistsByFields('qpl_questions', $fields)) { + $this->db->addIndex('qpl_questions', $fields, 'i6'); + } + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilAssQuestionList.php b/components/ILIAS/TestQuestionPool/classes/class.ilAssQuestionList.php index d7046c9e6df0..d629b47af94a 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilAssQuestionList.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilAssQuestionList.php @@ -445,7 +445,7 @@ private function getConditionalFilterExpression(): string return $conditions !== '' ? "AND $conditions" : ''; } - private function getSelectFieldsExpression(): string + private function getSelectFieldsExpression(bool $with_computed = true): string { $select_fields = [ 'qpl_questions.*', @@ -469,12 +469,26 @@ private function getSelectFieldsExpression(): string "; } - $select_fields[] = $this->generateFeedbackSubquery(); - $select_fields[] = $this->generateHintSubquery(); - $select_fields[] = $this->generateTaxonomySubquery(); + if ($with_computed) { + $select_fields[] = $this->generateFeedbackSubquery(); + $select_fields[] = $this->generateHintSubquery(); + $select_fields[] = $this->generateTaxonomySubquery(); + } $select_fields = implode(', ', $select_fields); - return "SELECT DISTINCT $select_fields"; + return "SELECT DISTINCT {$select_fields}"; + } + + private function getComputedFieldsExpression(): string + { + $fields = [ + 'qpl_questions.question_id', + $this->generateFeedbackSubquery(), + $this->generateHintSubquery(), + $this->generateTaxonomySubquery(), + ]; + $fields = implode(', ', $fields); + return "SELECT DISTINCT {$fields}"; } private function generateFeedbackSubquery(): string @@ -514,9 +528,11 @@ private function generateTaxonomySubquery(): string return "CASE WHEN EXISTS ($tax_subquery) THEN TRUE ELSE FALSE END AS taxonomies"; } - private function buildBasicQuery(): string + private function buildBasicQuery(bool $with_computed = true): string { - return "{$this->getSelectFieldsExpression()} FROM qpl_questions {$this->getTableJoinExpression()} WHERE qpl_questions.tstamp > 0"; + $select = $this->getSelectFieldsExpression($with_computed); + $joins = $this->getTableJoinExpression(); + return "{$select} FROM qpl_questions {$joins} WHERE qpl_questions.tstamp > 0"; } private function getHavingFilterExpression(): string @@ -562,7 +578,32 @@ private function buildOrderQueryExpression(): string $order_direction = Order::ASC; } - return " ORDER BY `$order_field` $order_direction"; + $order_field = $this->qualifyField($order_field); + $order_field = $this->backtickField($order_field); + + return " ORDER BY {$order_field} {$order_direction}"; + } + + private function backtickField(string $field): string + { + $segments = explode('.', $field); + $quoted = array_map( + static fn(string $segment): string => "`{$segment}`", + $segments + ); + return implode('.', $quoted); + } + + private function qualifyField(string $field): string + { + return match ($field) { + 'title', 'description', 'author', 'lifecycle', 'points', + 'created', 'tstamp', 'complete', 'question_id', 'original_id' => "qpl_questions.{$field}", + 'max_points' => 'qpl_questions.points', + 'type_tag' => 'qpl_qst_type.type_tag', + 'parent_title' => 'object_data.title', + default => $field, + }; } private function buildLimitQueryExpression(): string @@ -575,13 +616,14 @@ private function buildLimitQueryExpression(): string $limit = max($range->getLength(), 0); $offset = max($range->getStart(), 0); - return " LIMIT $limit OFFSET $offset"; + return " LIMIT {$limit} OFFSET {$offset}"; } private function buildQuery(): string { + $with_computed = $this->computedColumnsRequired(); return implode(PHP_EOL, array_filter([ - $this->buildBasicQuery(), + $this->buildBasicQuery($with_computed), $this->getConditionalFilterExpression(), $this->getHavingFilterExpression(), $this->buildOrderQueryExpression(), @@ -589,14 +631,95 @@ private function buildQuery(): string ])); } + private function computedColumnsRequired(): bool + { + return $this->getHavingFilterExpression() !== '' + || $this->isOrderByComputedField(); + } + + private function isOrderByComputedField(): bool + { + if ($this->order === null) { + return false; + } + [$order_field] = $this->order->join( + '', + static fn(string $index, string $key, string $value): array => [$key, $value] + ); + return in_array($order_field, ['feedback', 'hints', 'taxonomies'], true); + } + + private function buildEnrichmentQuery(array $question_ids): string + { + $in = $this->db->in('qpl_questions.question_id', $question_ids, false, ilDBConstants::T_INTEGER); + $select_fields = $this->getComputedFieldsExpression(); + $joins = $this->getBaseTableJoinExpression(); + return "{$select_fields} FROM qpl_questions {$joins} WHERE qpl_questions.tstamp > 0 AND {$in}"; + } + + private function getBaseTableJoinExpression(): string + { + $table_join = ' + INNER JOIN qpl_qst_type + ON qpl_qst_type.question_type_id = qpl_questions.question_type_fi + '; + + if ($this->join_obj_data) { + $table_join .= ' + INNER JOIN object_data + ON object_data.obj_id = qpl_questions.obj_fi + '; + } + + if ( + $this->parentObjType === 'tst' + && $this->questionInstanceTypeFilter === self::QUESTION_INSTANCE_TYPE_ALL + ) { + $table_join .= 'INNER JOIN tst_test_question tstquest ON tstquest.question_fi = qpl_questions.question_id'; + } + + if ($this->answerStatusActiveId) { + $quoted = $this->db->quote($this->answerStatusActiveId, ilDBConstants::T_INTEGER); + $table_join .= " + LEFT JOIN tst_test_result + ON tst_test_result.question_fi = qpl_questions.question_id + AND tst_test_result.active_fi = {$quoted} + "; + } + + return $table_join; + } + public function load(): void { $this->checkFilters(); $tags_trafo = $this->refinery->encode()->htmlSpecialCharsAsEntities(); + $with_computed = $this->computedColumnsRequired(); $res = $this->db->query($this->buildQuery()); + $rows_by_id = []; + $ordered_ids = []; while ($row = $this->db->fetchAssoc($res)) { + $qid = (int) $row['question_id']; + $rows_by_id[$qid] = $row; + $ordered_ids[] = $qid; + } + + if (!$with_computed && $ordered_ids !== []) { + $enrichment_res = $this->db->query($this->buildEnrichmentQuery($ordered_ids)); + while ($enrichment_row = $this->db->fetchAssoc($enrichment_res)) { + $eid = (int) $enrichment_row['question_id']; + if (isset($rows_by_id[$eid])) { + $rows_by_id[$eid]['feedback'] = $enrichment_row['feedback']; + $rows_by_id[$eid]['hints'] = $enrichment_row['hints']; + $rows_by_id[$eid]['taxonomies'] = $enrichment_row['taxonomies']; + } + } + } + + foreach ($ordered_ids as $question_id) { + $row = $rows_by_id[$question_id]; $row = ilAssQuestionType::completeMissingPluginName($row); if (!$this->isActiveQuestionType($row)) { @@ -628,9 +751,13 @@ public function getTotalRowCount(?array $filter_data, ?array $additional_paramet $this->checkFilters(); $count = 'COUNT(*)'; - $query = "SELECT $count FROM qpl_questions {$this->getTableJoinExpression()} WHERE qpl_questions.tstamp > 0 {$this->getConditionalFilterExpression()}"; + $joins = $this->getTableJoinExpression(); + $filters = $this->getConditionalFilterExpression(); + $query = "SELECT {$count} FROM qpl_questions {$joins} WHERE qpl_questions.tstamp > 0 {$filters}"; - return (int) ($this->db->query($query)->fetch()[$count] ?? 0); + $result = $this->db->query($query); + $fetch = $this->db->fetchAssoc($result); + return (int) ($fetch[$count] ?? 0); } protected function getNumberOfCommentsForQuestion(int $question_id): int diff --git a/components/ILIAS/TestQuestionPool/tests/ilAssQuestionListTwoPhaseTest.php b/components/ILIAS/TestQuestionPool/tests/ilAssQuestionListTwoPhaseTest.php new file mode 100644 index 000000000000..f4b9c18631a5 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/tests/ilAssQuestionListTwoPhaseTest.php @@ -0,0 +1,219 @@ +createMock(ilDBInterface::class); + $db->method('like')->willReturn('1'); + $db->method('in')->willReturnCallback(function (string $field, array $values, bool $negate = false): string { + if ($values === []) { + return $negate ? ' 1=1 ' : ' 1=2 '; + } + $list = implode(',', array_map('intval', $values)); + return ($negate ? 'NOT ' : '') . "{$field} IN ({$list})"; + }); + $db->method('quote')->willReturnCallback(fn($v) => is_numeric($v) ? (string) $v : "'{$v}'"); + + $lng = $this->createMock(ilLanguage::class); + $refinery = $this->createMock(ILIAS\Refinery\Factory::class); + $component_repository = $this->createMock(ilComponentRepository::class); + $notes_service = $this->createMock(ILIAS\Notes\Service::class); + + $this->object = new ilAssQuestionList($db, $lng, $refinery, $component_repository, $notes_service); + } + + private function setPrivateProperty(string $name, mixed $value): void + { + $prop = new ReflectionProperty(ilAssQuestionList::class, $name); + $prop->setAccessible(true); + $prop->setValue($this->object, $value); + } + + private function invokePrivate(string $name, array $args = []): mixed + { + $method = new ReflectionMethod(ilAssQuestionList::class, $name); + $method->setAccessible(true); + return $method->invokeArgs($this->object, $args); + } + + public function testComputedColumnsRequiredReturnsFalseWithoutHavingOrComputedOrder(): void + { + $this->setPrivateProperty('order', new Order('title', Order::ASC)); + $this->assertFalse($this->invokePrivate('computedColumnsRequired')); + } + + public function testComputedColumnsRequiredReturnsTrueForOrderByFeedback(): void + { + $this->setPrivateProperty('order', new Order('feedback', Order::ASC)); + $this->assertTrue($this->invokePrivate('computedColumnsRequired')); + } + + public function testComputedColumnsRequiredReturnsTrueForOrderByHints(): void + { + $this->setPrivateProperty('order', new Order('hints', Order::ASC)); + $this->assertTrue($this->invokePrivate('computedColumnsRequired')); + } + + public function testComputedColumnsRequiredReturnsTrueForOrderByTaxonomies(): void + { + $this->setPrivateProperty('order', new Order('taxonomies', Order::ASC)); + $this->assertTrue($this->invokePrivate('computedColumnsRequired')); + } + + public function testComputedColumnsRequiredReturnsTrueWithFeedbackFalseFilter(): void + { + $this->setPrivateProperty('order', new Order('title', Order::ASC)); + $this->setPrivateProperty('fieldFilters', ['feedback' => 'false']); + $this->assertTrue($this->invokePrivate('computedColumnsRequired')); + } + + public function testComputedColumnsRequiredReturnsTrueWithFeedbackTrueFilter(): void + { + $this->setPrivateProperty('order', new Order('title', Order::ASC)); + $this->setPrivateProperty('fieldFilters', ['feedback' => 'true']); + $this->assertTrue($this->invokePrivate('computedColumnsRequired')); + } + + public function testIsOrderByComputedFieldReturnsFalseWithoutOrder(): void + { + $this->setPrivateProperty('order', null); + $this->assertFalse($this->invokePrivate('isOrderByComputedField')); + } + + public function testIsOrderByComputedFieldReturnsFalseForRegularField(): void + { + $this->setPrivateProperty('order', new Order('title', Order::ASC)); + $this->assertFalse($this->invokePrivate('isOrderByComputedField')); + } + + public function testQualifyFieldMapsKnownFields(): void + { + $this->assertSame('qpl_questions.title', $this->invokePrivate('qualifyField', ['title'])); + $this->assertSame('qpl_questions.description', $this->invokePrivate('qualifyField', ['description'])); + $this->assertSame('qpl_questions.author', $this->invokePrivate('qualifyField', ['author'])); + $this->assertSame('qpl_questions.points', $this->invokePrivate('qualifyField', ['points'])); + $this->assertSame('qpl_questions.points', $this->invokePrivate('qualifyField', ['max_points'])); + $this->assertSame('qpl_questions.created', $this->invokePrivate('qualifyField', ['created'])); + $this->assertSame('qpl_questions.tstamp', $this->invokePrivate('qualifyField', ['tstamp'])); + $this->assertSame('qpl_qst_type.type_tag', $this->invokePrivate('qualifyField', ['type_tag'])); + $this->assertSame('object_data.title', $this->invokePrivate('qualifyField', ['parent_title'])); + } + + public function testQualifyFieldLeavesComputedFieldsUnchanged(): void + { + $this->assertSame('feedback', $this->invokePrivate('qualifyField', ['feedback'])); + $this->assertSame('hints', $this->invokePrivate('qualifyField', ['hints'])); + $this->assertSame('taxonomies', $this->invokePrivate('qualifyField', ['taxonomies'])); + } + + public function testBacktickFieldWrapsSimpleField(): void + { + $this->assertSame('`title`', $this->invokePrivate('backtickField', ['title'])); + } + + public function testBacktickFieldWrapsEachSegmentOfQualifiedName(): void + { + $this->assertSame('`qpl_questions`.`title`', $this->invokePrivate('backtickField', ['qpl_questions.title'])); + } + + public function testBuildOrderQueryExpressionQualifiesAndBackticksField(): void + { + $this->setPrivateProperty('order', new Order('title', Order::ASC)); + $sql = $this->invokePrivate('buildOrderQueryExpression'); + $this->assertStringContainsString('`qpl_questions`.`title`', $sql); + $this->assertStringContainsString('ASC', $sql); + } + + public function testBuildOrderQueryExpressionReturnsEmptyWithoutOrder(): void + { + $this->setPrivateProperty('order', null); + $this->assertSame('', $this->invokePrivate('buildOrderQueryExpression')); + } + + public function testBuildBasicQueryWithoutComputedHasNoExistsSubqueries(): void + { + $this->setPrivateProperty('join_obj_data', true); + $sql = $this->invokePrivate('buildBasicQuery', [false]); + $this->assertStringContainsString('qpl_questions.*', $sql); + $this->assertStringNotContainsString('EXISTS', $sql); + $this->assertStringNotContainsString('qpl_fb_generic', $sql); + $this->assertStringNotContainsString('qpl_hints', $sql); + $this->assertStringNotContainsString('tax_node_assignment', $sql); + } + + public function testBuildBasicQueryWithComputedContainsExistsSubqueries(): void + { + $this->setPrivateProperty('join_obj_data', true); + $sql = $this->invokePrivate('buildBasicQuery', [true]); + $this->assertStringContainsString('EXISTS', $sql); + $this->assertStringContainsString('qpl_fb_generic', $sql); + $this->assertStringContainsString('qpl_hints', $sql); + $this->assertStringContainsString('tax_node_assignment', $sql); + } + + public function testBuildEnrichmentQueryContainsExistsSubqueriesAndInClause(): void + { + $this->setPrivateProperty('join_obj_data', true); + $sql = $this->invokePrivate('buildEnrichmentQuery', [[10, 20, 30]]); + $this->assertStringContainsString('EXISTS', $sql); + $this->assertStringContainsString('qpl_fb_generic', $sql); + $this->assertStringContainsString('qpl_hints', $sql); + $this->assertStringContainsString('tax_node_assignment', $sql); + $this->assertStringContainsString('qpl_questions.question_id IN (10,20,30)', $sql); + $this->assertStringNotContainsString('LIMIT', $sql); + } + + public function testBuildEnrichmentQueryHandlesEmptyIdList(): void + { + $this->setPrivateProperty('join_obj_data', true); + $sql = $this->invokePrivate('buildEnrichmentQuery', [[]]); + $this->assertStringContainsString('1=2', $sql); + } + + public function testBuildQueryWithoutComputedAppliesRangeAndOrder(): void + { + $this->setPrivateProperty('range', new Range(0, 800)); + $this->setPrivateProperty('order', new Order('title', Order::ASC)); + $this->setPrivateProperty('join_obj_data', true); + $sql = $this->invokePrivate('buildQuery'); + $this->assertStringNotContainsString('EXISTS', $sql); + $this->assertStringContainsString('`qpl_questions`.`title`', $sql); + $this->assertStringContainsString('LIMIT 800 OFFSET 0', $sql); + } + + public function testBuildQueryWithComputedIncludesExistsSubqueries(): void + { + $this->setPrivateProperty('range', new Range(0, 800)); + $this->setPrivateProperty('order', new Order('feedback', Order::ASC)); + $this->setPrivateProperty('join_obj_data', true); + $sql = $this->invokePrivate('buildQuery'); + $this->assertStringContainsString('EXISTS', $sql); + $this->assertStringContainsString('LIMIT 800 OFFSET 0', $sql); + } +}