From 2a93a5313a591a118e436a9f359272dfbf3a2ec4 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Wed, 30 Sep 2026 18:15:59 +0200 Subject: [PATCH] add(core): getTemplateVarValues for reading TVs from multiple docs --- core/src/Core.php | 14 +++ core/src/Models/SiteContent.php | 82 ++++++++------ .../Unit/Models/SiteContentTvValuesTest.php | 102 ++++++++++++++++++ 3 files changed, 167 insertions(+), 31 deletions(-) create mode 100644 core/tests/Unit/Models/SiteContentTvValuesTest.php diff --git a/core/src/Core.php b/core/src/Core.php index d7c43f0b07..e360f00c3f 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -5457,6 +5457,20 @@ public function getTemplateVarOutput($idnames = [], $docid = '', $published = 1, return $output; } + /** + * Reads the named TVs of many documents in one query, for listings that show a few + * TVs per item (cards, prices, ratings) where getTemplateVarOutput() per item is N+1. + * + * @param array $docIds Document ids, e.g. array_column(getDocumentChildren(...), 'id') + * @param array $tvNames TV names (or TV ids when every element is numeric) + * @param bool $withDefaults Fall back to the TV default_text when a value is empty or missing + * @return array [docid => [tvname => raw value]], [] when no TV matches + */ + public function getTemplateVarValues(array $docIds, array $tvNames, bool $withDefaults = true): array + { + return SiteContent::getTvValues($docIds, $tvNames, $withDefaults); + } + /** * Returns the full table name based on db settings * diff --git a/core/src/Models/SiteContent.php b/core/src/Models/SiteContent.php index 4a2c05bd63..1968d0258e 100644 --- a/core/src/Models/SiteContent.php +++ b/core/src/Models/SiteContent.php @@ -2239,42 +2239,62 @@ public function scopeGetRootTree($query, $depth = 0) //return tvs array [$docid => tvs array()] public static function getTvList($docs, $tvList = []) { - $docsTV = []; - if (empty($docs)) { + if (empty($docs) || empty($tvList)) { return []; - } else if (empty($tvList)) { + } + + return static::getTvValues($docs->pluck('id')->toArray(), $tvList); + } + + /** + * Reads the given TVs of many documents in one query: [docid => [tvname => value]]. + * + * Only the named TVs are read, through the (tmplvarid, contentid) unique index, so a + * listing of cards, prices or any other per-document fields costs one query however + * many documents it shows. Every requested document gets every TV that exists; a TV + * without a stored (or with an empty) value falls back to its default_text unless + * $withDefaults is false, then it is ''. Values are raw: no widget rendering and no + * template assignment check, as with getTvList(). Returns [] when no TV matches. + * + * @param array $docIds document ids + * @param array $tvNames TV names, or TV ids when every element is numeric + */ + public static function getTvValues(array $docIds, array $tvNames, bool $withDefaults = true): array + { + $docIds = array_values(array_unique(array_map('intval', $docIds))); + $tvNames = array_values(array_unique(array_filter(array_map('trim', array_map('strval', $tvNames)), 'strlen'))); + if ($docIds === [] || $tvNames === []) { return []; - } else { - $ids = $docs->pluck('id')->toArray(); - $tvs = SiteTmplvar::whereIn('name', $tvList)->get(); - $tvNames = $tvs->pluck('default_text', 'name')->toArray(); - $tvIds = $tvs->pluck('name', 'id')->toArray(); - $tvValues = SiteTmplvarContentvalue::whereIn('contentid', $ids)->whereIn('tmplvarid', array_keys($tvIds))->get()->toArray(); - foreach ($tvValues as $tv) { - if (empty($tv['value']) && !empty($tvNames[$tvIds [$tv['tmplvarid']]])) { - $tv['value'] = $tvNames[$tvIds[$tv['tmplvarid']]]; - } - unset($tv['id']); - $docsTV[$tv['contentid']][$tv['tmplvarid']] = $tv; - } - foreach ($ids as $docid) { - foreach ($tvIds as $tvid => $tvname) { - if (empty($docsTV[$docid][$tvid])) { - $docsTV[$docid][$tvid] = ['tmplvarid' => $tvid, 'contentid' => $docid, 'value' => $tvNames[$tvIds [$tvid]]]; - } - } - } } - if (!empty($docsTV)) { - $tmp = []; - foreach ($docsTV as $docid => $tvs) { - foreach ($tvs as $tvid => $tv) { - $tmp[$docid][$tvIds[$tvid]] = $tv['value']; - } + $byId = count(array_filter($tvNames, 'is_numeric')) === count($tvNames); + + $rows = (new SiteTmplvar())->getConnection() + ->table('site_tmplvars as tv') + ->leftJoin('site_tmplvar_contentvalues as tvc', function ($join) use ($docIds) { + $join->on('tvc.tmplvarid', '=', 'tv.id')->whereIntegerInRaw('tvc.contentid', $docIds); + }) + ->whereIn($byId ? 'tv.id' : 'tv.name', $byId ? array_map('intval', $tvNames) : $tvNames) + ->orderBy('tv.id') + ->get(['tv.name', 'tv.default_text', 'tvc.contentid', 'tvc.value']); + if ($rows->isEmpty()) { + return []; + } + + $defaults = []; + $values = []; + foreach ($rows as $row) { + $defaults[$row->name] = $withDefaults ? (string) $row->default_text : ''; + if ($row->contentid !== null && (string) $row->value !== '') { + $values[(int) $row->contentid][$row->name] = (string) $row->value; } - $docsTV = $tmp; } - return $docsTV; + + $result = []; + foreach ($docIds as $docId) { + $result[$docId] = array_replace($defaults, $values[$docId] ?? []); + } + + return $result; } //return docs array with tvs diff --git a/core/tests/Unit/Models/SiteContentTvValuesTest.php b/core/tests/Unit/Models/SiteContentTvValuesTest.php new file mode 100644 index 0000000000..74abcef7b6 --- /dev/null +++ b/core/tests/Unit/Models/SiteContentTvValuesTest.php @@ -0,0 +1,102 @@ + Facade::clearResolvedInstances()); + +/** + * TVs: 1 price (default "0"), 2 sku (no default), 3 unused (default "u"). + * Documents 10 and 11 carry values, 12 has none, 11 stores an empty price. + * A prefixed connection, as on a real install, so the table aliases are exercised. + */ +function bootTvValuesDatabase(): Capsule +{ + $capsule = new Capsule(); + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'evo_']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + Model::setConnectionResolver($capsule->getDatabaseManager()); + + $schema = $capsule->getConnection()->getSchemaBuilder(); + $schema->create('site_tmplvars', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->default(''); + $table->text('default_text')->nullable(); + }); + $schema->create('site_tmplvar_contentvalues', function (Blueprint $table) { + $table->increments('id'); + $table->integer('tmplvarid'); + $table->integer('contentid'); + $table->text('value')->nullable(); + $table->unique(['tmplvarid', 'contentid']); + }); + + Capsule::table('site_tmplvars')->insert([ + ['id' => 1, 'name' => 'price', 'default_text' => '0'], + ['id' => 2, 'name' => 'sku', 'default_text' => ''], + ['id' => 3, 'name' => 'unused', 'default_text' => 'u'], + ]); + Capsule::table('site_tmplvar_contentvalues')->insert([ + ['tmplvarid' => 1, 'contentid' => 10, 'value' => '19.90'], + ['tmplvarid' => 2, 'contentid' => 10, 'value' => 'A-10'], + ['tmplvarid' => 3, 'contentid' => 10, 'value' => 'not asked'], + ['tmplvarid' => 1, 'contentid' => 11, 'value' => ''], + ['tmplvarid' => 2, 'contentid' => 11, 'value' => 'A-11'], + ['tmplvarid' => 1, 'contentid' => 99, 'value' => 'other document'], + ]); + + return $capsule; +} + +test('reads only the named TVs of every document in one query', function () { + $capsule = bootTvValuesDatabase(); + $capsule->getConnection()->enableQueryLog(); + + $values = SiteContent::getTvValues([10, 11, 12], ['price', 'sku']); + + expect($values)->toBe([ + 10 => ['price' => '19.90', 'sku' => 'A-10'], + 11 => ['price' => '0', 'sku' => 'A-11'], + 12 => ['price' => '0', 'sku' => ''], + ])->and($capsule->getConnection()->getQueryLog())->toHaveCount(1); +}); + +test('without defaults a missing or empty value is an empty string', function () { + bootTvValuesDatabase(); + + expect(SiteContent::getTvValues([11, 12], ['price'], false))->toBe([ + 11 => ['price' => ''], + 12 => ['price' => ''], + ]); +}); + +test('accepts TV ids and string document ids', function () { + bootTvValuesDatabase(); + + expect(SiteContent::getTvValues(['10', 10], [1, '2']))->toBe([ + 10 => ['price' => '19.90', 'sku' => 'A-10'], + ]); +}); + +test('returns nothing without documents, without names or when no TV matches', function () { + bootTvValuesDatabase(); + + expect(SiteContent::getTvValues([], ['price']))->toBe([]) + ->and(SiteContent::getTvValues([10], ['', ' ']))->toBe([]) + ->and(SiteContent::getTvValues([10], ['missing']))->toBe([]); +}); + +test('getTvList keeps its result shape on top of the bulk read', function () { + bootTvValuesDatabase(); + $docs = new Collection([['id' => 10], ['id' => 12]]); + + expect(SiteContent::getTvList($docs, ['price']))->toBe([ + 10 => ['price' => '19.90'], + 12 => ['price' => '0'], + ])->and(SiteContent::getTvList($docs, []))->toBe([]); +});