diff --git a/_test/AccessTableDataSQLTest.php b/_test/AccessTableDataSQLTest.php index 5cc02360..ff5ee096 100644 --- a/_test/AccessTableDataSQLTest.php +++ b/_test/AccessTableDataSQLTest.php @@ -51,12 +51,12 @@ public static function buildGetDataSQL_testdata() "SELECT DATA.pid AS PID, DATA.col1 AS out1, DATA.col2 AS out2, - GROUP_CONCAT_DISTINCT(M3.value,'" . Search::CONCAT_SEPARATOR . "') AS out3 + GROUP_CONCAT_DISTINCT(M1.value,'" . Search::CONCAT_SEPARATOR . "') AS out3 FROM data_testtable AS DATA - LEFT OUTER JOIN multi_testtable AS M3 - ON DATA.pid = M3.pid - AND DATA.rev = M3.rev - AND M3.colref = 3 + LEFT OUTER JOIN multi_testtable AS M1 + ON DATA.pid = M1.pid + AND DATA.rev = M1.rev + AND M1.colref = 3 WHERE (DATA.pid = ? AND DATA.rev = ?) GROUP BY DATA.pid,out1,out2", diff --git a/_test/ConfigParserTest.php b/_test/ConfigParserTest.php index d1096ad8..55768fbe 100644 --- a/_test/ConfigParserTest.php +++ b/_test/ConfigParserTest.php @@ -17,7 +17,7 @@ class ConfigParserTest extends StructTest public function test_simple() { $lines = [ - "schema : testtable, another, foo bar", + "schema : testtable, another ON field = %pageid%, foo bar", "cols : %pageid%, count", "sort : ^count", "sort : %pageid%, ^bam", @@ -49,16 +49,19 @@ public function test_simple() [ 0 => 'testtable', 1 => '', + 2 => [], ], 1 => [ 0 => 'another', 1 => '', + 2 => ['field', '=', '%pageid%'], ], 2 => [ 0 => 'foo', 1 => 'bar', + 2 => [], ], ], 'cols' => diff --git a/_test/InlineConfigParserTest.php b/_test/InlineConfigParserTest.php index a8883903..9bd0d8cb 100644 --- a/_test/InlineConfigParserTest.php +++ b/_test/InlineConfigParserTest.php @@ -17,7 +17,7 @@ class InlineConfigParserTest extends StructTest public function test_simple() { // Same initial setup as ConfigParser.test - $inline = '"testtable, another, foo bar"."%pageid%, count" '; + $inline = '"testtable, another ON another.a = testtable.b, foo bar ON bar.a = testtable.a"."%pageid%, count" '; $inline .= '?sort: ^count sort: "%pageid%, ^bam" align: "r,l,center,foo"'; // Add InlineConfigParser-specific tests: $inline .= ' & "%pageid% != start" | "count = 1"'; @@ -38,9 +38,9 @@ public function test_simple() 'limit' => 0, 'rownumbers' => false, 'schemas' => [ - ['testtable', ''], - ['another', ''], - ['foo', 'bar'], + ['testtable', '', []], + ['another', '', ['another.a', '=', 'testtable.b']], + ['foo', 'bar', ['bar.a', '=', 'testtable.a']], ], 'sepbyheaders' => false, 'sort' => [ diff --git a/_test/SearchConfigParameterTest.php b/_test/SearchConfigParameterTest.php index 345dfdd2..1c70e961 100644 --- a/_test/SearchConfigParameterTest.php +++ b/_test/SearchConfigParameterTest.php @@ -77,8 +77,8 @@ public function test_constructor() $data = [ 'schemas' => [ - ['schema1', 'alias1'], - ['schema2', 'alias2'], + ['schema1', 'alias1', []], + ['schema2', 'alias2', []], ], 'cols' => [ '%pageid%', @@ -142,8 +142,8 @@ public function test_filter() { $data = [ 'schemas' => [ - ['schema1', 'alias1'], - ['schema2', 'alias2'], + ['schema1', 'alias1', []], + ['schema2', 'alias2', []], ], 'cols' => [ '%pageid%', @@ -194,8 +194,8 @@ public function test_sort() { $data = [ 'schemas' => [ - ['schema1', 'alias1'], - ['schema2', 'alias2'], + ['schema1', 'alias1', []], + ['schema2', 'alias2', []], ], 'cols' => [ '%pageid%', @@ -228,7 +228,7 @@ public function test_pagination() $data = [ 'schemas' => [ - ['schema2', 'alias2'], + ['schema2', 'alias2', []], ], 'cols' => [ 'afirst' diff --git a/_test/SearchConfigTest.php b/_test/SearchConfigTest.php index c5732c77..39de0ee8 100644 --- a/_test/SearchConfigTest.php +++ b/_test/SearchConfigTest.php @@ -49,7 +49,8 @@ public function test_filtervars_struct() ] ); - $searchConfig = new SearchConfig(['schemas' => [['schema1', 'alias']]]); + $searchConfig = new SearchConfig(['schemas' => [['schema1', 'alias', []]]]); + $this->assertEquals('test', $searchConfig->applyFilterVars('$STRUCT.first$')); $this->assertEquals('test', $searchConfig->applyFilterVars('$STRUCT.alias.first$')); $this->assertEquals('test', $searchConfig->applyFilterVars('$STRUCT.schema1.first$')); @@ -94,7 +95,7 @@ public function test_filtervars_struct_other() ] ); - $searchConfig = new SearchConfig(['schemas' => [['schema3', 'alias']]]); + $searchConfig = new SearchConfig(['schemas' => [['schema3', 'alias', []]]]); $this->assertEquals('', $searchConfig->applyFilterVars('$STRUCT.afirst$')); $this->assertEquals('test', $searchConfig->applyFilterVars('$STRUCT.schema2.afirst$')); diff --git a/_test/SearchTest.php b/_test/SearchTest.php index 0cb2f755..c381a9b5 100644 --- a/_test/SearchTest.php +++ b/_test/SearchTest.php @@ -20,12 +20,14 @@ public function setUp(): void $this->loadSchemaJSON('schema1'); $this->loadSchemaJSON('schema2'); + $this->loadSchemaJSON('pageschema'); $_SERVER['REMOTE_USER'] = 'testuser'; $as = mock\Assignments::getInstance(); $page = 'page01'; $as->assignPageSchema($page, 'schema1'); $as->assignPageSchema($page, 'schema2'); + $as->assignPageSchema($page, 'pageschema'); saveWikiText($page, "===== TestTitle =====\nabc", "Summary"); p_get_metadata($page); $now = time(); @@ -51,16 +53,28 @@ public function setUp(): void ], $now ); + $this->saveData( + $page, + 'pageschema', + [ + 'singlepage' => 'page12', + 'multipage' => ['test:document', $page, 'page16'], + 'singletitle' => 'page10', + 'multititle' => ['page12', 'page10'], + ], + $now, + ); $as->assignPageSchema('test:document', 'schema1'); $as->assignPageSchema('test:document', 'schema2'); + $as->assignPageSchema('test:document', 'pageschema'); $this->saveData( 'test:document', 'schema1', [ 'first' => 'document first data', 'second' => ['second', 'more'], - 'third' => '', + 'third' => 'Summary', 'fourth' => 'fourth data' ], $now @@ -76,6 +90,43 @@ public function setUp(): void ], $now ); + $this->saveData( + 'test:document', + 'pageschema', + [ + 'singlepage' => $page, + 'multipage' => ['test:document', $page], + 'singletitle' => 'page10', + 'multititle' => ['page11', 'page16'], + ], + $now, + ); + + $as->assignPageSchema('test:document2', 'schema2'); + $this->saveData( + 'test:document2', + 'schema2', + [ + 'afirst' => 'TestTitle', + 'asecond' => ['test:document', 'fourth data'], + 'athird' => '', + 'afourth' => '' + ], + $now + ); + + $as->assignPageSchema('test:document3', 'schema2'); + $this->saveData( + 'test:document3', + 'schema2', + [ + 'afirst' => 'test:document', + 'asecond' => [], + 'athird' => '1234', + 'afourth' => 'abcd' + ], + $now + ); for ($i = 10; $i <= 20; $i++) { $this->saveData( @@ -230,6 +281,14 @@ public function test_search() $search->addSchema('schema2', 'foo'); $this->assertCount(2, $search->schemas); + $this->assertEquals(1, count($search->joins)); + $joincols = $search->joins['schema2']; + $this->assertEquals(2, count($joincols)); + $this->assertInstanceOf(meta\PageColumn::class, $joincols[0]); + $this->assertInstanceOf(meta\PageColumn::class, $joincols[1]); + $this->assertEquals('schema1', $joincols[0]->getTable()); + $this->assertEquals('schema2', $joincols[1]->getTable()); + $search->addColumn('first'); $this->assertEquals('schema1', $search->columns[0]->getTable()); $this->assertEquals(1, $search->columns[0]->getColref()); @@ -440,4 +499,292 @@ public function test_filterValueList() $this->callInaccessibleMethod($search, 'parseFilterValueList', array('(18.7, 10e5, -100)'))); } + + public function test_join() + { + $search = new mock\Search(); + + $search->addSchema('schema2', 'foo'); + $search->addSchema('schema1', '', array('foo.athird', '=', 'third')); + $this->assertEquals(2, count($search->schemas)); + + $this->assertEquals(1, count($search->joins)); + $joincols = $search->joins['schema1']; + $this->assertEquals(2, count($joincols)); + $this->assertEquals('schema2', $joincols[0]->getTable()); + $this->assertEquals('athird', $joincols[0]->getLabel()); + $this->assertEquals('schema1', $joincols[1]->getTable()); + $this->assertEquals('third', $joincols[1]->getLabel()); + + $search->addColumn('%pageid%'); + $search->addColumn('first'); + $search->addFilter('afourth', 'fourth data', '='); + + $result = $search->execute(); + $count = $search->getCount(); + + // check result dimensions + $this->assertEquals(2, $count, 'result count'); // full result set + $this->assertEquals(2, count($result), 'result rows'); // wanted result set + + // check the values + $this->assertEquals('page01', $result[0][0]->getValue()); + $this->assertEquals('test:document', $result[1][0]->getValue()); + $this->assertEquals('first data', $result[0][1]->getValue()); + $this->assertEquals('first data', $result[1][1]->getValue()); + } + + public function test_join_pageid() + { + $search = new mock\Search(); + + $search->addSchema('schema2', 'foo'); + $search->addSchema('pageschema', '', array('pageschema.singlepage', '=', 'foo.%pageid%')); + $this->assertEquals(2, count($search->schemas)); + + $this->assertEquals(1, count($search->joins)); + $joincols = $search->joins['pageschema']; + $this->assertEquals(2, count($joincols)); + $this->assertEquals('schema2', $joincols[0]->getTable()); + $this->assertEquals('%pageid%', $joincols[0]->getLabel()); + $this->assertEquals('pageschema', $joincols[1]->getTable()); + $this->assertEquals('singlepage', $joincols[1]->getLabel()); + + $search->addColumn('foo.%pageid%'); + $search->addColumn('pageschema.%pageid%'); + $search->addColumn('afourth'); + $search->addColumn('singletitle'); + + $result = $search->execute(); + $count = $search->getCount(); + + // check result dimensions + $this->assertEquals(2, $count, 'result count'); // full result set + $this->assertEquals(2, count($result), 'result rows'); // wanted result set + + // check the values + $this->assertEquals('page01', $result[0][0]->getValue()); + $this->assertEquals('test:document', $result[0][1]->getValue()); + $this->assertEquals('fourth data', $result[0][2]->getValue()); + $this->assertEquals('["page10",null]', $result[0][3]->getValue()); + $this->assertEquals('page12', $result[1][0]->getValue()); + $this->assertEquals('page01', $result[1][1]->getValue()); + $this->assertEquals('page12 fourth data', $result[1][2]->getValue()); + $this->assertEquals('["page10",null]', $result[1][3]->getValue()); + } + + // Test joining against Autosummary? + + public function test_join_pagetitle_against_string() + { + $search = new mock\Search(); + + $search->addSchema('schema2', 'foo'); + $search->addSchema('pageschema', '', array('pageschema.%title%', '=', 'afirst')); + $this->assertEquals(2, count($search->schemas)); + + $this->assertEquals(1, count($search->joins)); + $joincols = $search->joins['pageschema']; + $this->assertEquals(2, count($joincols)); + $this->assertEquals('schema2', $joincols[0]->getTable()); + $this->assertEquals('afirst', $joincols[0]->getLabel()); + $this->assertEquals('pageschema', $joincols[1]->getTable()); + $this->assertEquals('%title%', $joincols[1]->getLabel()); + + $search->addColumn('foo.%pageid%'); + $search->addColumn('pageschema.%pageid%'); + $search->addColumn('afourth'); + $search->addColumn('singlepage'); + + $result = $search->execute(); + $count = $search->getCount(); + + // check result dimensions + $this->assertEquals(2, $count, 'result count'); // full result set + $this->assertEquals(2, count($result), 'result rows'); // wanted result set + + // check the values + $this->assertEquals('test:document2', $result[0][0]->getValue()); + $this->assertEquals('page01', $result[0][1]->getValue()); + $this->assertEquals('', $result[0][2]->getValue()); + $this->assertEquals('page12', $result[0][3]->getValue()); + $this->assertEquals('test:document3', $result[1][0]->getValue()); + $this->assertEquals('test:document', $result[1][1]->getValue()); + $this->assertEquals('abcd', $result[1][2]->getValue()); + $this->assertEquals('page01', $result[1][3]->getValue()); + } + + public function test_join_string_against_pagetitle() + { + $search = new mock\Search(); + + $search->addSchema('pageschema', ''); + $search->addSchema('schema2', 'foo', array('pageschema.%title%', '=', 'afirst')); + $search->addColumn('foo.%pageid%'); + $search->addColumn('pageschema.%pageid%'); + $search->addColumn('afourth'); + $search->addColumn('singlepage'); + + $result = $search->execute(); + $count = $search->getCount(); + + // check result dimensions + $this->assertEquals(2, $count, 'result count'); // full result set + $this->assertEquals(2, count($result), 'result rows'); // wanted result set + + // check the values + $this->assertEquals('test:document2', $result[0][0]->getValue()); + $this->assertEquals('page01', $result[0][1]->getValue()); + $this->assertEquals('', $result[0][2]->getValue()); + $this->assertEquals('page12', $result[0][3]->getValue()); + $this->assertEquals('test:document3', $result[1][0]->getValue()); + $this->assertEquals('test:document', $result[1][1]->getValue()); + $this->assertEquals('abcd', $result[1][2]->getValue()); + $this->assertEquals('page01', $result[1][3]->getValue()); + + } + + private function setup_pagetitle_join_test_page($letter, $title, $singletitle) { + $as = mock\Assignments::getInstance(); + $letter_up = strtoupper($letter); + $now = time(); + $name = "page_$letter"; + $as->assignPageSchema($name, 'pageschema'); + saveWikiText($name, "===== $title =====\nabc", "Summary"); + p_get_metadata($name); + $this->saveData( + $name, + 'pageschema', + ['singlepage' => $letter_up, 'multipage' => [], 'singletitle' => $singletitle, 'multititle' => []], + $now, + ); + $this->saveData( + $name, + 'schema1', + ['first' => "first $letter_up", 'second' => [], 'third' => '', 'fourth' => ''], + $now + ); + + } + + public function test_join_pagetitle_against_pagetitle() { + $this->setup_pagetitle_join_test_page('a', 'page_b', 'page_b'); + $this->setup_pagetitle_join_test_page('b', 'B', 'page_c'); + $this->setup_pagetitle_join_test_page('c', 'Title', 'Title'); + $this->setup_pagetitle_join_test_page('d', 'Title', 'page_b'); + + $search = new mock\Search(); + $search->addSchema('pageschema', ''); + $search->addSchema('schema1', 'foo', array('pageschema.singletitle', '=', 'foo.%title%')); + $search->addColumn('pageschema.%pageid%'); + $search->addColumn('foo.%pageid%'); + + $result = $search->execute(); + $count = $search->getCount(); + + $this->assertEquals(8, $count, 'result count'); // full result set + $this->assertEquals(8, count($result), 'result rows'); // wanted result set + + // check the values + $this->assertEquals('page_a', $result[0][0]->getValue()); + $this->assertEquals('page_a', $result[0][1]->getValue()); + + $this->assertEquals('page_a', $result[1][0]->getValue()); + $this->assertEquals('page_b', $result[1][1]->getValue()); + + $this->assertEquals('page_b', $result[2][0]->getValue()); + $this->assertEquals('page_c', $result[2][1]->getValue()); + + $this->assertEquals('page_b', $result[3][0]->getValue()); + $this->assertEquals('page_d', $result[3][1]->getValue()); + + $this->assertEquals('page_c', $result[4][0]->getValue()); + $this->assertEquals('page_c', $result[4][1]->getValue()); + + $this->assertEquals('page_c', $result[5][0]->getValue()); + $this->assertEquals('page_d', $result[5][1]->getValue()); + + $this->assertEquals('page_d', $result[6][0]->getValue()); + $this->assertEquals('page_a', $result[6][1]->getValue()); + + $this->assertEquals('page_d', $result[7][0]->getValue()); + $this->assertEquals('page_b', $result[7][1]->getValue()); + } + + public function test_join_summary() + { + $search = new mock\Search(); + + $search->addSchema('schema1', 'foo'); + $search->addSchema('schema2', '', array('schema1.third', '=', 'schema2.%lastsummary%')); + + $search->addColumn('foo.%pageid%'); + $search->addColumn('schema2.%pageid%'); + $search->addColumn('afourth'); + $search->addColumn('first'); + + $result = $search->execute(); + $count = $search->getCount(); + + // check result dimensions + $this->assertEquals(1, $count, 'result count'); // full result set + $this->assertEquals(1, count($result), 'result rows'); // wanted result set + + // check the values + $this->assertEquals('test:document', $result[0][0]->getValue()); + $this->assertEquals('page01', $result[0][1]->getValue()); + $this->assertEquals('fourth data', $result[0][2]->getValue()); + $this->assertEquals('document first data', $result[0][3]->getValue()); + } + + public function test_join_summary2() + { + $search = new mock\Search(); + + $search->addSchema('schema2', ''); + $search->addSchema('schema1', 'foo', array('schema1.third', '=', 'schema2.%lastsummary%')); + + $search->addColumn('foo.%pageid%'); + $search->addColumn('schema2.%pageid%'); + $search->addColumn('afourth'); + $search->addColumn('first'); + + $result = $search->execute(); + $count = $search->getCount(); + + // check result dimensions + $this->assertEquals(1, $count, 'result count'); // full result set + $this->assertEquals(1, count($result), 'result rows'); // wanted result set + + // check the values + $this->assertEquals('test:document', $result[0][0]->getValue()); + $this->assertEquals('page01', $result[0][1]->getValue()); + $this->assertEquals('fourth data', $result[0][2]->getValue()); + $this->assertEquals('document first data', $result[0][3]->getValue()); + } + + public function invalidJoins_testdata() { + return array( + array('first', '<>', 'afirst'), + array('first', '=', 'third'), + array('foo.athird', '=', 'afirst'), + array('notaschema.first', '=', 'schema1.first'), + array('notacolumn', '=', 'schema1.first'), + array('foo.athird', '=', 'schema1.notacolumn'), + array('schema1.second', '=', 'schema2.afirst'), + array('schema1.first', '=', 'scheam2.asecond'), + ); + } + + /** + * @dataProvider invalidJoins_testdata + * + */ + public function test_invalid_joins($lhs, $comp, $rhs) + { + $search = new mock\Search(); + $search->addSchema('schema1'); + $this->setExpectedException(meta\StructException::class); + $search->addSchema('schema2', 'foo', array($lhs, $comp, $rhs)); + } } diff --git a/_test/action/LookupAjaxTest.php b/_test/action/LookupAjaxTest.php index 5be00722..b0d7ba3c 100644 --- a/_test/action/LookupAjaxTest.php +++ b/_test/action/LookupAjaxTest.php @@ -36,10 +36,10 @@ public function testSaveGlobalDataEvent() { $testLabel = 'testcontent'; global $INPUT; - $INPUT->post->set('schema', 'wikilookup'); + $INPUT->post->set('schema', 'wikilookup', ); $INPUT->post->set('entry', ['FirstFieldText' => $testLabel]); $INPUT->post->set('searchconf', json_encode([ - 'schemas' => [['wikilookup', '']], + 'schemas' => [['wikilookup', '', []]], 'cols' => ['*'] ])); $call = 'plugin_struct_aggregationeditor_save'; diff --git a/_test/mock/AccessTableDataNoDB.php b/_test/mock/AccessTableDataNoDB.php index 052493c3..64676a1d 100644 --- a/_test/mock/AccessTableDataNoDB.php +++ b/_test/mock/AccessTableDataNoDB.php @@ -34,11 +34,15 @@ public function setColumns($singles, $multis) $sort = 0; foreach ($singles as $single) { $sort += 1; - $this->schema->columns[] = new Column($sort, new $single(), $sort); + $col = new Column($sort, new $single(), $sort); + $col->getType()->setContext($col); + $this->schema->columns[] = $col; } foreach ($multis as $multi) { $sort += 1; - $this->schema->columns[] = new Column($sort, new $multi(null, null, true), $sort); + $col = new Column($sort, new $multi(null, null, true), $sort); + $col->getType()->setContext($col); + $this->schema->columns[] = $col; } } diff --git a/_test/mock/Search.php b/_test/mock/Search.php index 49a52d94..4c8dc34e 100644 --- a/_test/mock/Search.php +++ b/_test/mock/Search.php @@ -16,6 +16,8 @@ class Search extends meta\Search public $dynamicFilter = array(); + public $joins = array(); + /** * Register a dummy function that always returns false */ diff --git a/_test/types/TextTest.php b/_test/types/TextTest.php index 9b482adf..fe64aca3 100644 --- a/_test/types/TextTest.php +++ b/_test/types/TextTest.php @@ -125,7 +125,7 @@ public function data() 'NOT LIKE', // comp ['%val1%', '%val2%'], // multiple values '((T.col != \'\' AND (? || T.col || ? NOT LIKE ? OR ? || T.col || ? NOT LIKE ?)))', // expect sql - ['before', 'after', '%val1%', 'before', 'after', '%val2%',], // expect opts + ['before', 'after', '%val1%', '%val2%',], // expect opts ], ]; diff --git a/helper/config.php b/helper/config.php index 642330e7..7c62b13c 100644 --- a/helper/config.php +++ b/helper/config.php @@ -50,7 +50,7 @@ public function parseFilterLine($logic, $val) * @return array ($col, $comp, $value) * @throws StructException */ - protected function parseFilter($val) + public function parseFilter($val) { $comps = Search::$COMPARATORS; @@ -60,7 +60,7 @@ protected function parseFilter($val) $comps = implode('|', $comps); if (!preg_match('/^(.*?)(' . $comps . ')(.*)$/', $val, $match)) { - throw new StructException('Invalid search filter %s', hsc($val)); + throw new StructException('Invalid conditional expression %s', hsc($val)); } array_shift($match); // we don't need the zeroth match $match[0] = trim($match[0]); diff --git a/meta/AccessTable.php b/meta/AccessTable.php index 353147a4..346128c5 100644 --- a/meta/AccessTable.php +++ b/meta/AccessTable.php @@ -505,25 +505,14 @@ protected function buildGetDataSQL($idColumn = 'pid') $QB->addGroupByStatement("DATA.$idColumn"); foreach ($this->schema->getColumns(false) as $col) { - $colref = $col->getColref(); - $colname = 'col' . $colref; - $outname = 'out' . $colref; - - if ($col->getType()->isMulti()) { - $tn = 'M' . $colref; - $QB->addLeftJoin( - 'DATA', - $mtable, - $tn, - "DATA.$idColumn = $tn.$idColumn AND DATA.rev = $tn.rev AND $tn.colref = $colref" - ); - $col->getType()->select($QB, $tn, 'value', $outname); - $sel = $QB->getSelectStatement($outname); - $QB->addSelectStatement("GROUP_CONCAT_DISTINCT($sel, '$sep')", $outname); - } else { - $col->getType()->select($QB, 'DATA', $colname, $outname); - $QB->addGroupByStatement($outname); - } + $col->getType()->select( + $QB, + 'DATA', + $mtable, + 'out' . $col->getColref(), + false, + $sep + ); } $pl = $QB->addValue($this->{$idColumn}); diff --git a/meta/ConfigParser.php b/meta/ConfigParser.php index e91caf20..b68fc105 100644 --- a/meta/ConfigParser.php +++ b/meta/ConfigParser.php @@ -43,7 +43,7 @@ class ConfigParser public function __construct($lines) { /** @var \helper_plugin_struct_config $helper */ - $helper = plugin_load('helper', 'struct_config'); + $this->helper = plugin_load('helper', 'struct_config'); // parse info foreach ($lines as $line) { [$key, $val] = $this->splitLine($line); @@ -88,7 +88,7 @@ public function __construct($lines) case 'order': case 'sort': $sorts = $this->parseValues($val); - $sorts = array_map([$helper, 'parseSort'], $sorts); + $sorts = array_map([$this->helper, 'parseSort'], $sorts); $this->config['sort'] = array_merge($this->config['sort'], $sorts); break; case 'where': @@ -99,7 +99,7 @@ public function __construct($lines) $logic = 'AND'; case 'filteror': case 'or': - $flt = $helper->parseFilterLine($logic, $val); + $flt = $this->helper->parseFilterLine($logic, $val); if ($flt) { $this->config['filter'][] = $flt; } @@ -196,13 +196,19 @@ protected function parseSchema($val) { $schemas = []; $parts = explode(',', $val); + $firsttable = null; foreach ($parts as $part) { - [$table, $alias] = sexplode(' ', trim($part), 2, ''); + $segments = explode('ON', trim($part)); + [$table, $alias] = array_pad(explode(' ', trim($segments[0])), 2, ''); $table = trim($table); $alias = trim($alias); if (!$table) continue; - - $schemas[] = [$table, $alias]; + if (count($segments) > 1) { + $condition = $this->helper->parseFilter($segments[1]); + } else { + $condition = []; + } + $schemas[] = [$table, $alias, $condition]; } return $schemas; } diff --git a/meta/QueryBuilder.php b/meta/QueryBuilder.php index 1eb42fe2..743e7538 100644 --- a/meta/QueryBuilder.php +++ b/meta/QueryBuilder.php @@ -23,6 +23,8 @@ class QueryBuilder /** @var string[] */ protected $groupby = []; + protected $aliasCount = 0; + /** * QueryBuilder constructor. */ @@ -116,7 +118,20 @@ public function addLeftJoin($leftalias, $righttable, $rightalias, $onclause) throw new StructException('Table Alias already exists'); } - $pos = array_search($leftalias, array_keys($this->from)); + if (count($this->from) > 0) { + $pos = 0; + $matches = []; + preg_match_all('/\w+(?=\.\w+)/', $onclause, $matches); + $matches[0][] = $leftalias; + $matches = array_unique($matches[0]); + $existing = array_keys($this->from); + foreach ($matches as $match) { + $p = array_search($match, $existing); + if ($p > $pos) $pos = $p; + } + } else { + $pos = false; + } $statement = "LEFT OUTER JOIN $righttable AS $rightalias ON $onclause"; $this->from = $this->arrayInsert($this->from, [$rightalias => $statement], $pos + 1); $this->type[$rightalias] = 'join'; @@ -196,9 +211,8 @@ public function addValue($value) */ public function generateTableAlias($prefix = 'T') { - static $count = 0; - $count++; - return $prefix . $count; + $this->aliasCount++; + return $prefix . $this->aliasCount; } /** diff --git a/meta/Search.php b/meta/Search.php index fe679061..fd509aa8 100755 --- a/meta/Search.php +++ b/meta/Search.php @@ -28,6 +28,9 @@ class Search /** @var \helper_plugin_sqlite */ protected $sqlite; + /** @var string first schema added to the search */ + protected $firstschema; + /** @var Schema[] list of schemas to query */ protected $schemas = []; @@ -46,6 +49,9 @@ class Search /** @var array list of aliases tables can be referenced by */ protected $aliases = []; + /** @var array list of conditionals to be used when joining tables */ + protected $joins = array(); + /** @var int begin results from here */ protected $range_begin = 0; @@ -86,8 +92,9 @@ public function getDb() * * @param string $table * @param string $alias + * @param string $joinon */ - public function addSchema($table, $alias = '') + public function addSchema($table, $alias = '', $joinon = array()) { $schema = new Schema($table); if (!$schema->getId()) { @@ -96,6 +103,64 @@ public function addSchema($table, $alias = '') $this->schemas[$schema->getTable()] = $schema; if ($alias) $this->aliases[$alias] = $schema->getTable(); + if (is_null($this->firstschema)) { + $this->firstschema = $schema->getTable(); + if (count($joinon) > 0) { + throw new StructException('JOIN condition not supported for first schema'); + } + } else { + if (count($joinon) == 0) { + $joinon = array( + "{$schema->getTable()}.%pageid%", '=', "{$this->firstschema}.%pageid%" + ); + } + if ($joinon[1] != '=') { + throw new StructException('Only equality comparison is supported for JOIN conditions'); + } + $this->joins[$schema->getTable()] = $this->getJoinColumns($schema, $joinon[0], $joinon[2]); + } + } + + /** + * Returns the columns being matched against for a JOIN ... ON + * expression. The result will be ordered such that the first + * column is the one from a previously-joined schema. + * + * @param Schema $schema The schema being JOINed to the query + * @param string $left The LHS of the JOIN ON comparison + * @param string $right the RHS of the JOIN ON comparison + * @return array The first element is the LHS column object and second is the RHS + */ + protected function getJoinColumns($schema, $left, $right) + { + $lcol = $this->findColumn($left); + if ($lcol === false) { + throw new StructException('Unrecognoside field ' . $left); + } + if ($lcol->getType()->isMulti()) { + throw new StructException( + "Column $left is multi-valued, but JOINs are not supported on multi-valued columns" + ); + } + $rcol = $this->findColumn($right); + if ($rcol === false) { + throw new StructException('Unrecognoside field ' . $right); + } + if ($rcol->getType()->isMulti()) { + throw new StructException( + "Column $right is multi-valued, but JOINs are not supported on multi-valued columns" + ); + } + $table = $schema->getTable(); + $left_is_old_table = $lcol->getTable() != $table; + if ($left_is_old_table == ($rcol->getTable() != $table)) { + throw new StructException("Exactly one side of ON condition $left = $right must be a column of $table"); + } + if ($left_is_old_table) { + return array($lcol, $rcol); + } else { + return array($rcol, $lcol); + } } /** @@ -527,7 +592,7 @@ protected function runSQLBuilder() { $sqlBuilder = new SearchSQLBuilder(); $sqlBuilder->setSelectLatest($this->selectLatest); - $sqlBuilder->addSchemas($this->schemas); + $sqlBuilder->addSchemas($this->schemas, $this->joins); $sqlBuilder->addColumns($this->columns); $sqlBuilder->addFilters($this->filter); $sqlBuilder->addFilters($this->dynamicFilter); @@ -619,31 +684,46 @@ public function findColumn($colname, $strict = false) if (!$this->schemas) throw new StructException('noschemas'); $schema_list = array_keys($this->schemas); + [$colname, $table] = $this->resolveColumn($colname); + $table_or_first = $table !== null ? $table : $schema_list[0]; + // add "fake" column for special col if ($colname == '%pageid%') { - return new PageColumn(0, new Page(), $schema_list[0]); + $col = new PageColumn(0, new Page(), $table_or_first); + $col->getType()->setContext($col); + return $col; } if ($colname == '%title%') { - return new PageColumn(0, new Page(['usetitles' => true]), $schema_list[0]); + $col = new PageColumn(0, new Page(['usetitles' => true]), $table_or_first); + $col->getType()->setContext($col); + return $col; } if ($colname == '%lastupdate%') { - return new RevisionColumn(0, new DateTime(), $schema_list[0]); + $col = new RevisionColumn(0, new DateTime(), $table_or_first); + $col->getType()->setContext($col); + return $col; } if ($colname == '%lasteditor%') { - return new UserColumn(0, new User(), $schema_list[0]); + $col = new UserColumn(0, new User(), $table_or_first); + $col->getType()->setContext($col); + return $col; } if ($colname == '%lastsummary%') { - return new SummaryColumn(0, new AutoSummary(), $schema_list[0]); + $col = new SummaryColumn(0, new AutoSummary(), $table_or_first); + $col->getType()->setContext($col); + return $col; } if ($colname == '%rowid%') { - return new RowColumn(0, new Decimal(), $schema_list[0]); + $col = new RowColumn(0, new Decimal(), $table_or_first); + $col->getType()->setContext($col); + return $col; } if ($colname == '%published%') { - return new PublishedColumn(0, new Decimal(), $schema_list[0]); + $col = new PublishedColumn(0, new Decimal(), $schema_list[0]); + $col->getType()->setContext($col); + return $col; } - [$colname, $table] = $this->resolveColumn($colname); - /* * If table name is given search only that, otherwise if no strict behavior * is requested by the caller, try all assigned schemas for matching the diff --git a/meta/SearchCloud.php b/meta/SearchCloud.php index 8a53a0e5..85e85c25 100644 --- a/meta/SearchCloud.php +++ b/meta/SearchCloud.php @@ -56,29 +56,16 @@ public function getSQL() $QB->filters()->where('AND', 'tag IS NOT \'\''); $col = $this->columns[0]; + $col->getType()->select($QB, 'data_' . $datatable, 'multi_' . $col->getTable(), 'tag', true); + + $QB->addSelectStatement('COUNT(tag)', 'count'); + $QB->addSelectColumn('schema_assignments', 'assigned', 'ASSIGNED'); if ($col->isMulti()) { - $multitable = "multi_{$col->getTable()}"; - $MN = $QB->generateTableAlias('M'); - - $QB->addLeftJoin( - $datatable, - $multitable, - $MN, - "$datatable.pid = $MN.pid AND - $datatable.rid = $MN.rid AND - $datatable.rev = $MN.rev AND - $MN.colref = {$col->getColref()}" - ); - - $col->getType()->select($QB, $MN, 'value', 'tag'); - $colname = $MN . '.value'; - } else { - $col->getType()->select($QB, $datatable, $col->getColName(), 'tag'); - $colname = $datatable . '.' . $col->getColName(); + // This GROUP BY was added with the SELECT statement for a + // single-valued column, just need to make sure it's added + // in the case of multi-valued as well. + $QB->addGroupByStatement('tag'); } - $QB->addSelectStatement("COUNT($colname)", 'count'); - $QB->addSelectColumn('schema_assignments', 'assigned', 'ASSIGNED'); - $QB->addGroupByStatement('tag'); $QB->addOrderBy('count DESC'); [$sql, $opts] = $QB->getSQL(); diff --git a/meta/SearchConfig.php b/meta/SearchConfig.php index 07f8c79f..8acad472 100644 --- a/meta/SearchConfig.php +++ b/meta/SearchConfig.php @@ -44,7 +44,7 @@ public function __construct($config, $dynamic = true) // setup schemas and columns if (!empty($config['schemas'])) foreach ($config['schemas'] as $schema) { - $this->addSchema($schema[0], $schema[1]); + $this->addSchema($schema[0], $schema[1], $schema[2]); } if (!empty($config['cols'])) foreach ($config['cols'] as $col) { $this->addColumn($col); diff --git a/meta/SearchSQLBuilder.php b/meta/SearchSQLBuilder.php index 977cdcfc..876ea780 100644 --- a/meta/SearchSQLBuilder.php +++ b/meta/SearchSQLBuilder.php @@ -27,29 +27,40 @@ public function __construct() * Add the schemas to the query * * @param Schema[] $schemas Schema names to query + * @param array $joins Conditionals to be used when joining tables */ - public function addSchemas($schemas) + public function addSchemas($schemas, $joins) { // basic tables $first_table = ''; + $added_schemas = []; foreach ($schemas as $schema) { $datatable = 'data_' . $schema->getTable(); + $new_pid = false; if ($first_table) { // follow up tables - $this->qb->addLeftJoin($first_table, $datatable, $datatable, "$first_table.pid = $datatable.pid"); + [$lcol, $rcol] = $joins[$schema->getTable()]; + if ($lcol->getLabel() == '%pageid%' and $rcol->getLabel() == '%pageid%') { + // Simple (default) case where we join on page IDs + $this->qb->addLeftJoin($first_table, $datatable, $datatable, "$first_table.pid = $datatable.pid"); + } else { + // Custom join on some other columns + $lefttable = 'data_' . $lcol->getTable(); + $righttable = 'data_' . $rcol->getTable(); + $on = $lcol->getType()->joinCondition( + $this->qb, + $lefttable, + $lcol->getColName(), + $righttable, + $rcol->getColName(), + $rcol->getType() + ); + $this->qb->addLeftJoin($lefttable, $righttable, $righttable, $on); + } } else { // first table $this->qb->addTable($datatable); - // add conditional page clauses if pid has a value - $subAnd = $this->qb->filters()->whereSubAnd(); - $subAnd->whereAnd("$datatable.pid = ''"); - $subOr = $subAnd->whereSubOr(); - $subOr->whereAnd("GETACCESSLEVEL($datatable.pid) > 0"); - $subOr->whereAnd("PAGEEXISTS($datatable.pid) = 1"); - // make sure to check assignment for page data only - $subOr->whereAnd("($datatable.rid != 0 OR (ASSIGNED = 1 OR ASSIGNED IS NULL))"); - // add conditional schema assignment check $this->qb->addLeftJoin( $datatable, @@ -60,6 +71,15 @@ public function addSchemas($schemas) AND schema_assignments.tbl = '{$schema->getTable()}'" ); + // add conditional page clauses if pid has a value + $subAnd = $this->qb->filters()->whereSubAnd(); + $subAnd->whereAnd("$datatable.pid = ''"); + $subOr = $subAnd->whereSubOr(); + $subOr->whereAnd("GETACCESSLEVEL($datatable.pid) > 0"); + $subOr->whereAnd("PAGEEXISTS($datatable.pid) = 1"); + // make sure to check assignment for page data only + $subOr->whereAnd("($datatable.rid != 0 OR (ASSIGNED = 1 OR ASSIGNED IS NULL))"); + $this->qb->addSelectColumn($datatable, 'rid'); $this->qb->addSelectColumn($datatable, 'pid', 'PID'); $this->qb->addSelectColumn($datatable, 'rev'); @@ -83,29 +103,14 @@ public function addColumns($columns) $sep = Search::CONCAT_SEPARATOR; $n = 0; foreach ($columns as $col) { - $CN = 'C' . $n++; - - if ($col->isMulti()) { - $datatable = "data_{$col->getTable()}"; - $multitable = "multi_{$col->getTable()}"; - $MN = $this->qb->generateTableAlias('M'); - - $this->qb->addLeftJoin( - $datatable, - $multitable, - $MN, - "$datatable.pid = $MN.pid AND $datatable.rid = $MN.rid AND - $datatable.rev = $MN.rev AND - $MN.colref = {$col->getColref()}" - ); - - $col->getType()->select($this->qb, $MN, 'value', $CN); - $sel = $this->qb->getSelectStatement($CN); - $this->qb->addSelectStatement("GROUP_CONCAT_DISTINCT($sel, '$sep')", $CN); - } else { - $col->getType()->select($this->qb, 'data_' . $col->getTable(), $col->getColName(), $CN); - $this->qb->addGroupByStatement($CN); - } + $col->getType()->select( + $this->qb, + 'data_' . $col->getTable(), + 'multi_' . $col->getTable(), + 'C' . $n++, + true, + $sep + ); } } diff --git a/types/AbstractBaseType.php b/types/AbstractBaseType.php index 973599da..05225773 100644 --- a/types/AbstractBaseType.php +++ b/types/AbstractBaseType.php @@ -361,6 +361,31 @@ public function renderTagCloudLink($value, \Doku_Renderer $R, $mode, $page, $fil $R->internallink("$page?$filter", $value); } + /** + * Creates a JOIN to handle multi-valued columns. + * + * @param QueryBuilder $QB + * @param string $datatable The table for the schema in question + * @param string $multitable The table containing multi-valued data for this schema + * @param int $colref The ID of the multi-valued column + * @param bool $test_rid Whether to require RIDs to be equal in the JOIN condition + * @return string Alias for the multi-table + */ + public function joinMulti(QueryBuilder $QB, $datatable, $multitable, $colref, $test_rid = true) + { + $MN = $QB->generateTableAlias('M'); + $condition = "$datatable.pid = $MN.pid "; + if ($test_rid) $condition .= "AND $datatable.rid = $MN.rid "; + $condition .= "AND $datatable.rev = $MN.rev AND $MN.colref = $colref"; + $QB->addLeftJoin( + $datatable, + $multitable, + $MN, + $condition + ); + return $MN; + } + /** * This function is used to modify an aggregation query to add a filter * for the given column matching the given value. A type should add at @@ -379,19 +404,274 @@ public function renderTagCloudLink($value, \Doku_Renderer $R, $mode, $page, $fil */ public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op) { + $additional_join = $this->getAdditionalJoinForComparison($add, $tablealias, $colname); + if (!is_null($additional_join)) { + $add->getQB()->addLeftJoin( + $additional_join[0], + $additional_join[1], + $additional_join[2], + $additional_join[3] + ); + $oldalias = $tablealias; + $tablealias = $additional_join[2]; + } else { + $oldalias = null; + } + $compareVal = $this->getSqlCompareValue($add, $tablealias, $oldalias, $colname, $op); /** @var QueryBuilderWhere $add Where additionional queries are added to */ if (is_array($value)) { $add = $add->where($op); // sub where group $op = 'OR'; } foreach ((array)$value as $item) { - $pl = $add->getQB()->addValue($item); - $add->where($op, "$tablealias.$colname $comp $pl"); + if (is_array($compareVal)) { + $sub = $add->where($op); + $op = 'OR'; // safe to do, as if the previous line is + // executed again it means $value is an + // array and $op was already 'OR' anyway + } else { + $sub = $add; + } + $pl = $this->wrapValue($add->getQB()->addValue($item)); + foreach ((array)$compareVal as $lhs) { + $sub->where($op, "$lhs $comp $pl"); + } + } + } + + /** + * This function provides the SQL expression for this column which is used to + * compare against in a filter expression or a JOIN condition. In simple cases + * that is all it will need to do. However, for some columnt types, it may + * need to add additional logic to the conditional expression or make + * additional JOINs. + * + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string|null $oldalias A previous alias used for this table (only used by Page) + * @param string $colname The column name on the above table + * @param string &$op the logical operator this filter should use + * @return string|array The SQL expression to be used on one side of the comparison operator + */ + protected function getSqlCompareValue(QueryBuilderWhere &$add, $tablealias, $oldalias, $colname, &$op) + { + return "$tablealias.$colname"; + } + + /** + * This function provides arguments for an additional JOIN operation needed + * to perform a comparison (e.g., for a JOIN or FILTER), or null if no + * additional JOIN is needed. + * + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string $colname The column name on the above table + * @return null|array [$leftalias, $righttable, $rightalias, $onclause] + */ + protected function getAdditionalJoinForComparison(QueryBuilderWhere &$add, $tablealias, $colname) + { + return null; + } + + /** + * Handle the value(s) that a column is being compared against. If $value is an array, each element will be wrapped. + * + * @param string|array $value The value(s) a column is being compared to + * @return string|array SQL expression(s) processing the value in some way. + */ + protected function wrapValues($value) + { + if (is_array($value)) { + return array_map([$this, 'wrapValue'], $value); + } + return $this->wrapValue($value); + } + + /** + * Handle the value that a column is being compared against. In + * most cases this method will just return the value unchanged, + * but for some types it may be necessary to preform some sort of + * transformation (e.g., casting it to a decimal). + * + * @param string $value The value a column is being compared to + * @return string A SQL expression processing the value in some way. + */ + protected function wrapValue($value) + { + return $value; + } + + /** + * Returns a SQL expression making an equality comparison between + * two values. If one of the values is an array, then the + * conditional expression will evaluate to true if the other value + * is equal to any element in the array. If both values are arrays + * then they must be the same length and the expression will + * evaluate to true if a pair of items with the same indices in + * the two arrays are equal. + * + * @param string|array $lhs The first value to be compared + * @param string|array $rhs The second value to be compared + * @return string SQL expression for equality comparison + */ + protected function equalityComparison($lhs, $rhs) + { + $lhs_array = is_array($lhs); + $rhs_array = is_array($rhs); + $nlhs = $lhs_array ? count($lhs) : 1; + $nrhs = $rhs_array ? count($rhs) : 1; + if ($lhs_array and $rhs_array and count($lhs) != count($rhs)) { + throw new StructException("Arrays not of equal length."); + } + if (!$lhs_array) $lhs = array_fill(0, $nrhs, $lhs); + if (!$rhs_array) $rhs = array_fill(0, $nlhs, $rhs); + $comparisons = array_map( + function ($l, $r) { + return "($l = $r)"; + }, + $lhs, + $rhs + ); + return implode(' OR ', $comparisons); + } + + /** + * Returns a SQL expression on which to join two tables, when the + * column of the right table being joined on is of this data + * type. This should only be called if joining on this data type + * requires introducing an additional join (i.e., if + * getAdditionalJoinForComparison returns an array). + * + * @param QueryBuilder $QB + * @param string $lhs Left hand side of the ON clause (for left table) + * @param string $rhs Right hand side of the ON clause (for right table) + * @param string $additional_join_condition The ON clause of the additional join + * @return string SQL expression to be returned by joinCondition + */ + protected function joinConditionIfAdditionalJoin($lhs, &$rhs, $additional_join_condition) + { + return $additional_join_condition; + } + + /** + * Returns a SQL expression ON which to JOIN $left_table and + * $right_table. Semantically, this provides an + * equality comparison between two columns in the two + * schemas. However, in practice it may require more complex + * logic, including additional JOINs to pull in other data or + * handle multi-valued columns. + * + * @param QueryBuilder $QB + * @param string $left_table The name of the left table being JOINed + * @param string $left_colname The name of the column in the left table being compared against for the JOIN + * @param string $right_table The name of the right table being JOINed + * @param string $right_colname The name of hte column in the right table being compared against for hte JOIN + * @param AbstractBaseType $right_coltype The type of $right_colname + * @return string SQL expression on which to join schemas + */ + public function joinCondition($QB, &$left_table, $left_colname, $right_table, $right_colname, $right_coltype) + { + $add = new QueryBuilderWhere($QB); + $op = 'AND'; + $additional_join = $this->getAdditionalJoinForComparison($add, $left_table, $left_colname); + if (!is_null($additional_join)) { + $add->getQB()->addLeftJoin( + $additional_join[0], + $additional_join[1], + $additional_join[2], + $additional_join[3] + ); + $lhs = $this->getSqlCompareValue($add, $additional_join[2], $left_table, $left_colname, $op); + } else { + $lhs = $this->getSqlCompareValue($add, $left_table, null, $left_colname, $op); + } + $additional_join = $right_coltype->getAdditionalJoinForComparison($add, $right_table, $right_colname); + if (!is_null($additional_join)) { + $rhs = $this->wrapValues( + $right_coltype->getSqlCompareValue($add, $additional_join[2], $right_table, $right_colname, $op) + ); + $result = $right_coltype->joinConditionIfAdditionalJoin($lhs, $rhs, $additional_join[3]); + $add->getQB()->addLeftJoin( + $left_table, + $additional_join[1], + $additional_join[2], + $this->equalityComparison($lhs, $rhs) + ); + $left_table = $additional_join[2]; + } else { + $rhs = $this->wrapValues( + $right_coltype->getSqlCompareValue($add, $right_table, null, $right_colname, $op) + ); + $result = $this->equalityComparison($lhs, $rhs); + } + // FIXME: Can I do this somehow without needing to execute the subquery twice? + $AN = $add->getQB()->generateTableAlias('A'); + $subquery = "(SELECT assigned + FROM schema_assignments AS $AN + WHERE $right_table.pid != '' AND + $right_table.pid = $AN.pid AND + $AN.tbl = '{$right_coltype->getContext()->getTable()}')"; + $subAnd = $add->whereSubAnd(); + $subAnd->whereOr("$right_table.pid = ''"); + $subOr = $subAnd->whereSubOr(); + $subOr->whereAnd("GETACCESSLEVEL($right_table.pid) > 0"); + $subOr->whereAnd("PAGEEXISTS($right_table.pid) = 1"); + $subOr->whereAnd("($right_table.rid != 0 OR ($subquery = 1 OR $subquery IS NULL))"); + return $result; + } + + /** + * Returns an expression for one side of the equality-comparison + * used when JOINing schemas in aggregations. It may add + * additional conditions to the $add expression or JOIN other + * tables, as needed. + * + * @param QueryBuilderWhere $add The condition ON which to JOIN the tables. May not be used. + * @param string $table Name of the table being JOINed + * @param string $colname Name of the column being JOINed ON + * @return string One side of the equality comparion being used for the JOIN + */ + protected function joinArgument(QueryBuilderWhere $add, $table, $colname) + { + return "$table.$colname"; + } + + /** + * Add the proper selection for this type to the current Query. Handles the + * possibility of multi-valued columns. + * + * @param QueryBuilder $QB + * @param string $singletable The name of the table the saved value(s) are stored in, if the column is single-valued + * @param string $multitable The name of the table the values are stored in if the column is multi-valued + * @param string $alias The added selection *has* to use this column alias + * @param bool $test_rid Whether to require RIDs to be equal if JOINing multi-table + * @param string|null $concat_sep Seperator to concatenate mutli-values together. Don't concatenate if null. + */ + public function select(QueryBuilder $QB, $singletable, $multitable, $alias, $test_rid = true, $concat_sep = null) + { + if ($this->isMulti()) { + $colref = $this->getContext()->getColref(); + $datatable = $this->joinMulti($QB, $singletable, $multitable, $colref, $test_rid); + $colname = 'value'; + } else { + $datatable = $singletable; + $colname = $this->getContext()->getColName(); + } + $this->selectCol($QB, $datatable, $colname, $alias); + if ($this->isMulti()) { + if (!is_null($concat_sep)) { + $sel = $QB->getSelectStatement($alias); + $QB->addSelectStatement("GROUP_CONCAT_DISTINCT($sel, '$concat_sep')", $alias); + } + } else { + $QB->addGroupByStatement($alias); } } /** - * Add the proper selection for this type to the current Query + * Internal function to add the proper selection for a column of this type to the + * current Query. It is called from the `select` method, after any joins needed + * for multi-valued tables are handled. * * The default implementation here should be good for nearly all types, it simply * passes the given parameters to the query builder. But type may do more fancy @@ -410,7 +690,7 @@ public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $va * @param string $colname The column name on above table * @param string $alias The added selection *has* to use this column alias */ - public function select(QueryBuilder $QB, $tablealias, $colname, $alias) + protected function selectCol(QueryBuilder $QB, $tablealias, $colname, $alias) { $QB->addSelectColumn($tablealias, $colname, $alias); } @@ -425,7 +705,7 @@ public function select(QueryBuilder $QB, $tablealias, $colname, $alias) * @param string $tablealias The table the currently saved value is stored in * @param string $colname The column name on above table (always single column!) * @param string $order either ASC or DESC - * @see select() you probably want to implement this, + * @see selectCol() you probably want to implement this, * too. * */ diff --git a/types/AutoSummary.php b/types/AutoSummary.php index 902bc2bc..219c8c8e 100644 --- a/types/AutoSummary.php +++ b/types/AutoSummary.php @@ -16,7 +16,7 @@ class AutoSummary extends AbstractBaseType * @param string $colname * @param string $alias */ - public function select(QueryBuilder $QB, $tablealias, $colname, $alias) + public function selectCol(QueryBuilder $QB, $tablealias, $colname, $alias) { $rightalias = $QB->generateTableAlias(); $QB->addLeftJoin($tablealias, 'titles', $rightalias, "$tablealias.pid = $rightalias.pid"); @@ -41,22 +41,32 @@ public function sort(QueryBuilder $QB, $tablealias, $colname, $order) /** * When using `%lastsummary%`, we need to compare against the `title` table. * - * @param QueryBuilderWhere $add - * @param string $tablealias - * @param string $colname - * @param string $comp - * @param string|\string[] $value - * @param string $op + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string|null $oldalias A previous alias used for this table (only used by Page) + * @param string $colname The column name on the above table + * @param string &$op the logical operator this filter should use + * @return string The SQL expression to be used on one side of the comparison operator + */ + protected function getSqlCompareValue(QueryBuilderWhere &$add, $tablealias, $oldalias, $colname, &$op) + { + return "$tablealias.lastsummary"; + } + + /** + * This function provides arguments for an additional JOIN operation needed + * to perform a comparison (e.g., for a JOIN or FILTER), or null if no + * additional JOIN is needed. + * + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string $colname The column name on the above table + * @return null|array [$leftalias, $righttable, $rightalias, $onclause] */ - public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op) + protected function getAdditionalJoinForComparison(QueryBuilderWhere &$add, $tablealias, $colname) { $QB = $add->getQB(); $rightalias = $QB->generateTableAlias(); - $QB->addLeftJoin($tablealias, 'titles', $rightalias, "$tablealias.pid = $rightalias.pid"); - - // compare against page and title - $sub = $add->where($op); - $pl = $QB->addValue($value); - $sub->whereOr("$rightalias.lastsummary $comp $pl"); + return [$tablealias, 'titles', $rightalias, "$tablealias.pid = $rightalias.pid"]; } } diff --git a/types/DateTime.php b/types/DateTime.php index 3c8678b2..bd094690 100644 --- a/types/DateTime.php +++ b/types/DateTime.php @@ -102,7 +102,7 @@ public function validate($rawvalue) * @param string $colname * @param string $alias */ - public function select(QueryBuilder $QB, $tablealias, $colname, $alias) + public function selectCol(QueryBuilder $QB, $tablealias, $colname, $alias) { $col = "$tablealias.$colname"; @@ -116,35 +116,47 @@ public function select(QueryBuilder $QB, $tablealias, $colname, $alias) $QB->addSelectStatement($col, $alias); } + /** - * @param QueryBuilderWhere $add - * @param string $tablealias - * @param string $colname - * @param string $comp - * @param string|\string[] $value - * @param string $op + * Handle case of a revision column, where you need to convert from a Unix + * timestamp. + * + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string $colname The column name on the above table + * @param string|null $oldalias A previous alias used for this table (only used by Page) + * @param string &$op the logical operator this filter should use + * @return string|array The SQL expression to be used on one side of the comparison operator */ - public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op) + protected function getSqlCompareValue(QueryBuilderWhere &$add, $tablealias, $oldalias, $colname, &$op) { $col = "$tablealias.$colname"; - $QB = $add->getQB(); // when accessing the revision column we need to convert from Unix timestamp if (is_a($this->context, 'dokuwiki\plugin\struct\meta\RevisionColumn')) { - $rightalias = $QB->generateTableAlias(); - $col = "DATETIME($rightalias.lastrev, 'unixepoch', 'localtime')"; - $QB->addLeftJoin($tablealias, 'titles', $rightalias, "$tablealias.pid = $rightalias.pid"); + $col = "DATETIME($tablealias.lastrev, 'unixepoch', 'localtime')"; } - /** @var QueryBuilderWhere $add Where additional queries are added to */ - if (is_array($value)) { - $add = $add->where($op); // sub where group - $op = 'OR'; - } - foreach ((array)$value as $item) { - $pl = $QB->addValue($item); - $add->where($op, "$col $comp $pl"); + return $col; + } + + /** + * This function provides arguments for an additional JOIN operation needed + * to perform a comparison (e.g., for a JOIN or FILTER), or null if no + * additional JOIN is needed. + * + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string $colname The column name on the above table + * @return null|array [$leftalias, $righttable, $rightalias, $onclause] + */ + protected function getAdditionalJoinForComparison(QueryBuilderWhere &$add, $tablealias, $colname) + { + if (is_a($this->context, 'dokuwiki\plugin\struct\meta\RevisionColumn')) { + $rightalias = $QB->generateTableAlias(); + return [$tablealias, 'titles', $rightalias, "$tablealias.pid = $rightalias.pid"]; } + return parent::getAdditionalJoinForComparison($add, $tablealias, $colname); } /** diff --git a/types/Decimal.php b/types/Decimal.php index 6aa68f58..53b02354 100644 --- a/types/Decimal.php +++ b/types/Decimal.php @@ -155,30 +155,29 @@ public function sort(QueryBuilder $QB, $tablealias, $colname, $order) /** * Decimals need to be casted to proper type for comparison * - * @param QueryBuilderWhere $add - * @param string $tablealias - * @param string $colname - * @param string $comp - * @param string|\string[] $value - * @param string $op + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string|null $oldalias A previous alias used for this table (only used by Page) + * @param string $colname The column name on the above table + * @param string &$op the logical operator this filter should use + * @return string The SQL expression to be used on one side of the comparison operator */ - public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op) + protected function getSqlCompareValue(QueryBuilderWhere &$add, $tablealias, $oldalias, $colname, &$op) { $add = $add->where($op); // open a subgroup $add->where('AND', "$tablealias.$colname != ''"); // make sure the field isn't empty $op = 'AND'; + return "CAST($tablealias.$colname AS DECIMAL)"; + } - /** @var QueryBuilderWhere $add Where additionional queries are added to */ - if (is_array($value)) { - $add = $add->where($op); // sub where group - $op = 'OR'; - } - - foreach ((array)$value as $item) { - $pl = $add->getQB()->addValue($item); - $add->where($op, "CAST($tablealias.$colname AS DECIMAL) $comp CAST($pl AS DECIMAL)"); - } + /** + * @param string $value The value a column is being compared to + * @return string SQL expression casting $value to a decimal + */ + protected function wrapValue($value) + { + return "CAST($value AS DECIMAL)"; } /** diff --git a/types/Lookup.php b/types/Lookup.php index edd9d76f..44772753 100644 --- a/types/Lookup.php +++ b/types/Lookup.php @@ -230,12 +230,12 @@ public function compareValue($value) * @param string $colname * @param string $alias */ - public function select(QueryBuilder $QB, $tablealias, $colname, $alias) + public function selectCol(QueryBuilder $QB, $tablealias, $colname, $alias) { $schema = 'data_' . $this->config['schema']; $column = $this->getLookupColumn(); if (!$column) { - parent::select($QB, $tablealias, $colname, $alias); + parent::selectCol($QB, $tablealias, $colname, $alias); return; } @@ -248,7 +248,7 @@ public function select(QueryBuilder $QB, $tablealias, $colname, $alias) "$tablealias.$colname = STRUCT_JSON($rightalias.pid, CAST($rightalias.rid AS DECIMAL)) " . "AND $rightalias.latest = 1" ); - $column->getType()->select($QB, $rightalias, $field, $alias); + $column->getType()->selectCol($QB, $rightalias, $field, $alias); $sql = $QB->getSelectStatement($alias); $QB->addSelectStatement("STRUCT_JSON($tablealias.$colname, $sql)", $alias); } @@ -256,34 +256,62 @@ public function select(QueryBuilder $QB, $tablealias, $colname, $alias) /** * Compare against lookup table * - * @param QueryBuilderWhere $add - * @param string $tablealias - * @param string $colname - * @param string $comp - * @param string|\string[] $value - * @param string $op + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string|null $oldalias A previous alias used for this table (only used by Page) + * @param string $colname The column name on the above table + * @param string &$op the logical operator this filter should use + * @return string|array The SQL expression to be used on one side of the comparison operator */ - public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op) + protected function getSqlCompareValue(QueryBuilderWhere &$add, $tablealias, $oldalias, $colname, &$op) { - $schema = 'data_' . $this->config['schema']; $column = $this->getLookupColumn(); if (!$column) { - parent::filter($add, $tablealias, $colname, $comp, $value, $op); - return; + return parent::getSqlCompareValue($add, $tablealias, $oldalias, $colname, $op); } $field = $column->getColName(); + return $column->getType()->getSqlCompareValue($add, $tablealias, $oldalias, $field, $op); + } - // compare against lookup field + /** + * This function provides arguments for an additional JOIN operation needed + * to perform a comparison (e.g., for a JOIN or FILTER), or null if no + * additional JOIN is needed. + * + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string $colname The column name on the above table + * @return null|array [$leftalias, $righttable, $rightalias, $onclause] + */ + protected function getAdditionalJoinForComparison(QueryBuilderWhere &$add, $tablealias, $colname) + { + if (!$this->getLookupColumn()) return null; + $schema = 'data_' . $this->config['schema']; $QB = $add->getQB(); $rightalias = $QB->generateTableAlias(); - $QB->addLeftJoin( + return [ $tablealias, $schema, $rightalias, "$tablealias.$colname = STRUCT_JSON($rightalias.pid, CAST($rightalias.rid AS DECIMAL)) AND " . "$rightalias.latest = 1" - ); - $column->getType()->filter($add, $rightalias, $field, $comp, $value, $op); + ]; + } + + /** + * Handle the value that a column is being compared against. + * + * @param string $value The value a column is being compared to + * @return string A SQL expression processing the value in some way. + */ + protected function wrapValue($value) + { + $schema = 'data_' . $this->config['schema']; + $column = $this->getLookupColumn(); + if (!$column) { + return parent::wrapValue($value); + } + return $column->getType()->wrapValue($value); } /** diff --git a/types/Page.php b/types/Page.php index 0d2d1270..2501d243 100644 --- a/types/Page.php +++ b/types/Page.php @@ -121,10 +121,10 @@ public function handleAjax() * @param string $colname * @param string $alias */ - public function select(QueryBuilder $QB, $tablealias, $colname, $alias) + public function selectCol(QueryBuilder $QB, $tablealias, $colname, $alias) { if (!$this->config['usetitles']) { - parent::select($QB, $tablealias, $colname, $alias); + parent::selectCol($QB, $tablealias, $colname, $alias); return; } $rightalias = $QB->generateTableAlias(); @@ -185,32 +185,63 @@ public function displayValue($value) } /** - * When using titles, we need to compare against the title table, too + * When using titles, we need to compare against the title table, too. * - * @param QueryBuilderWhere $add - * @param string $tablealias - * @param string $colname - * @param string $comp - * @param string $value - * @param string $op + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string|null $oldalias A previous alias used for this table (only used by Page) + * @param string $colname The column name on the above table + * @param string &$op the logical operator this filter should use + * @return array The SQL expression to be used on one side of the comparison operator */ - public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op) + protected function getSqlCompareValue(QueryBuilderWhere &$add, $tablealias, $oldalias, $colname, &$op) { if (!$this->config['usetitles']) { - parent::filter($add, $tablealias, $colname, $comp, $value, $op); - return; + return parent::getSqlCompareValue($add, $tablealias, $oldalias, $colname, $op); + } + if (is_null($oldalias)) { + throw new StructException('Table name for Page column not specified.'); } + return ["$oldalias.$colname", "$tablealias.title"]; + } + /** + * This function provides arguments for an additional JOIN operation needed + * to perform a comparison (e.g., for a JOIN or FILTER), or null if no + * additional JOIN is needed. + * + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string $colname The column name on the above table + * @return null|array [$leftalias, $righttable, $rightalias, $onclause] + */ + protected function getAdditionalJoinForComparison(QueryBuilderWhere &$add, $tablealias, $colname) + { + if (!$this->config['usetitles']) { + return parent::getAdditionalJoinForComparison($add, $tablealias, $colname); + } $QB = $add->getQB(); $rightalias = $QB->generateTableAlias(); - $QB->addLeftJoin($tablealias, 'titles', $rightalias, "$tablealias.$colname = $rightalias.pid"); + return [$tablealias, 'titles', $rightalias, "$tablealias.$colname = $rightalias.pid"]; + } - // compare against page and title - $sub = $add->where($op); - $pl = $QB->addValue($value); - $sub->whereOr("$tablealias.$colname $comp $pl"); - $pl = $QB->addValue($value); - $sub->whereOr("$rightalias.title $comp $pl"); + /** + * Returns a SQL expression on which to join two tables, when the + * column of the right table being joined on is of this data + * type. This should only be called if joining on this data type + * requires introducing an additional join (i.e., if + * getAdditionalJoinForComparison returns an array). + * + * @param QueryBuilder $QB + * @param string $lhs Left hand side of the ON clause (for left table) + * @param string $rhs Right hand side of the ON clause (for right table) + * @param string $additional_join_condition The ON clause of the additional join + * @return string SQL expression to be returned by joinCondition + */ + protected function joinConditionIfAdditionalJoin($lhs, &$rhs, $additional_join_condition) + { + [$rhs_id, $rhs] = $rhs; + return $additional_join_condition . ' OR ' . $this->equalityComparison($lhs, $rhs_id); } /** diff --git a/types/Tag.php b/types/Tag.php index f9bfff8b..087046d1 100644 --- a/types/Tag.php +++ b/types/Tag.php @@ -115,23 +115,26 @@ protected function buildSQLFromContext(Column $context) /** * Normalize tags before comparing * - * @param QueryBuilder $QB - * @param string $tablealias - * @param string $colname - * @param string $comp - * @param string|string[] $value - * @param string $op + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string|null $oldalias A previous alias used for this table (only used by Page) + * @param string $colname The column name on the above table + * @param string &$op the logical operator this filter should use + * @return string The SQL expression to be used on one side of the comparison operator */ - public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op) + protected function getSqlCompareValue(QueryBuilderWhere &$add, $tablealias, $oldalias, $colname, &$op) { - /** @var QueryBuilderWhere $add Where additionional queries are added to */ - if (is_array($value)) { - $add = $add->where($op); // sub where group - $op = 'OR'; - } - foreach ((array)$value as $item) { - $pl = $add->getQB()->addValue($item); - $add->where($op, "LOWER(REPLACE($tablealias.$colname, ' ', '')) $comp LOWER(REPLACE($pl, ' ', ''))"); - } + return "LOWER(REPLACE($tablealias.$colname, ' ', ''))"; + } + + /** + * Normalize before comparing. + * + * @param string $value The value a column is being compared to + * @return string A SQL expression processing the value in some way. + */ + protected function wrapValue($value) + { + return "LOWER(REPLACE($value, ' ', ''))"; } } diff --git a/types/TraitFilterPrefix.php b/types/TraitFilterPrefix.php index ddf8a16f..a2140645 100644 --- a/types/TraitFilterPrefix.php +++ b/types/TraitFilterPrefix.php @@ -17,40 +17,30 @@ trait TraitFilterPrefix /** * Comparisons are done against the full string (including prefix/postfix) * - * @param QueryBuilderWhere $add - * @param string $tablealias - * @param string $colname - * @param string $comp - * @param string|string[] $value - * @param string $op + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string|null $oldalias A previous alias used for this table (only used by Page) + * @param string $colname The column name on the above table + * @param string &$op the logical operator this filter should use + * @return string|array The SQL expression to be used on one side of the comparison operator */ - public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op) + protected function getSqlCompareValue(QueryBuilderWhere &$add, $tablealias, $oldalias, $colname, &$op) { + $column = parent::getSqlCompareValue($add, $tablealias, $oldalias, $colname, $op); + $add = $add->where($op); // open a subgroup - $add->where('AND', "$tablealias.$colname != ''"); - // make sure the field isn't empty + $add->where('AND', "$column != ''"); // make sure the field isn't empty $op = 'AND'; - /** @var QueryBuilderWhere $add Where additionional queries are added to */ - if (is_array($value)) { - $add = $add->where($op); // sub where group - $op = 'OR'; - } $QB = $add->getQB(); - foreach ((array)$value as $item) { - $column = "$tablealias.$colname"; - - if ($this->config['prefix']) { - $pl = $QB->addValue($this->config['prefix']); - $column = "$pl || $column"; - } - if ($this->config['postfix']) { - $pl = $QB->addValue($this->config['postfix']); - $column = "$column || $pl"; - } - - $pl = $QB->addValue($item); - $add->where($op, "$column $comp $pl"); + if ($this->config['prefix']) { + $pl = $QB->addValue($this->config['prefix']); + $column = "$pl || $column"; + } + if ($this->config['postfix']) { + $pl = $QB->addValue($this->config['postfix']); + $column = "$column || $pl"; } + return $column; } } diff --git a/types/User.php b/types/User.php index 93aa2c08..0c1b7459 100644 --- a/types/User.php +++ b/types/User.php @@ -106,7 +106,7 @@ public function handleAjax() * @param string $colname * @param string $alias */ - public function select(QueryBuilder $QB, $tablealias, $colname, $alias) + public function selectCol(QueryBuilder $QB, $tablealias, $colname, $alias) { if (is_a($this->context, 'dokuwiki\plugin\struct\meta\UserColumn')) { $rightalias = $QB->generateTableAlias(); @@ -115,7 +115,7 @@ public function select(QueryBuilder $QB, $tablealias, $colname, $alias) return; } - parent::select($QB, $tablealias, $colname, $alias); + parent::selectCol($QB, $tablealias, $colname, $alias); } /** @@ -139,29 +139,39 @@ public function sort(QueryBuilder $QB, $tablealias, $colname, $order) } /** - * When using `%lasteditor%`, we need to compare against the `title` table. + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string|null $oldalias A previous alias used for this table (only used by Page) + * @param string $colname The column name on the above table + * @param string &$op the logical operator this filter should use + * @return string The SQL expression to be used on one side of the comparison operator + */ + protected function getSqlCompareValue(QueryBuilderWhere &$add, $tablealias, $oldalias, $colname, &$op) + { + if (is_a($this->context, 'dokuwiki\plugin\struct\meta\UserColumn')) { + return "$tablealias.lasteditor"; + } + + return parent::getSqlCompareValue($add, $tablealias, $oldalias, $colname, $comp, $value, $op); + } + + /** + * This function provides arguments for an additional JOIN operation needed + * to perform a comparison (e.g., for a JOIN or FILTER), or null if no + * additional JOIN is needed. * - * @param QueryBuilderWhere $add - * @param string $tablealias - * @param string $colname - * @param string $comp - * @param string|string[] $value - * @param string $op + * @param QueryBuilderWhere &$add The WHERE or ON clause to contain the conditional this comparator will be used in + * @param string $tablealias The table the values are stored in + * @param string $colname The column name on the above table + * @return null|array [$leftalias, $righttable, $rightalias, $onclause] */ - public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op) + protected function getAdditionalJoinForComparison(QueryBuilderWhere &$add, $tablealias, $colname) { if (is_a($this->context, 'dokuwiki\plugin\struct\meta\UserColumn')) { $QB = $add->getQB(); $rightalias = $QB->generateTableAlias(); - $QB->addLeftJoin($tablealias, 'titles', $rightalias, "$tablealias.pid = $rightalias.pid"); - - // compare against page and title - $sub = $add->where($op); - $pl = $QB->addValue($value); - $sub->whereOr("$rightalias.lasteditor $comp $pl"); - return; + return [$tablealias, 'titles', $rightalias, "$tablealias.pid = $rightalias.pid"]; } - - parent::filter($add, $tablealias, $colname, $comp, $value, $op); + return parent::getAdditionalJoinForComparison($add, $tablealias, $colname); } }