Index: class.ux_t3lib_sqlparser.php =================================================================== --- class.ux_t3lib_sqlparser.php (revision 27051) +++ class.ux_t3lib_sqlparser.php (working copy) @@ -32,6 +32,7 @@ * * @author Kasper Skaarhoj * @author Karsten Dambekalns + * @author Xavier Perseguers */ @@ -178,73 +179,95 @@ $stack[$level] = array(); // Reset stack for this level } else { // If no new level is started, just parse the current level: - // Find "modifyer", eg. "NOT or !" + // Find "modifier", eg. "NOT or !" $stack[$level][$pnt[$level]]['modifier'] = trim($this->nextPart($parseString, '^(!|NOT[[:space:]]+)')); - // Support calculated value only for: - // - "&" (boolean AND) - // - "+" (addition) - // - "-" (substraction) - // - "*" (multiplication) - // - "/" (division) - // - "%" (modulo) - $calcOperators = '&|\+|-|\*|\/|%'; - - // Fieldname: - if ($fieldName = $this->nextPart($parseString, '^([[:alnum:]._]+)([[:space:]]+|' . $calcOperators . '|<=|>=|<|>|=|!=|IS)')) { - - // Parse field name into field and table: - $tableField = explode('.', $fieldName, 2); - if (count($tableField) == 2) { - $stack[$level][$pnt[$level]]['table'] = $tableField[0]; - $stack[$level][$pnt[$level]]['field'] = $tableField[1]; - } else { - $stack[$level][$pnt[$level]]['table'] = ''; - $stack[$level][$pnt[$level]]['field'] = $tableField[0]; + // See if condition is EXISTS with a subquery + if (preg_match('/^EXISTS[[:space:]]*[(]/', $parseString)) { + $stack[$level][$pnt[$level]]['func']['type'] = $this->nextPart($parseString, '^(EXISTS)'); + $this->nextPart($parseString, '^([(])'); + $stack[$level][$pnt[$level]]['func']['subquery'] = $this->parseSELECT($parseString); + // Seek to new position in parseString after parsing of the subquery + $parseString = $stack[$level][$pnt[$level]]['func']['subquery']['parseString']; + unset($stack[$level][$pnt[$level]]['func']['subquery']['parseString']); + if (!$this->nextPart($parseString, '^([)])')) { + return 'No ) parenthesis at end of subquery'; } } else { - return $this->parseError('No field name found as expected in parseWhereClause()', $parseString); - } - - // See if the value is calculated: - $stack[$level][$pnt[$level]]['calc'] = $this->nextPart($parseString, '^(' . $calcOperators . ')'); - if (strlen($stack[$level][$pnt[$level]]['calc'])) { - // Finding value for calculation: - $stack[$level][$pnt[$level]]['calc_value'] = $this->getValue($parseString); - } - - // Find "comparator": - $stack[$level][$pnt[$level]]['comparator'] = $this->nextPart($parseString, '^(<=|>=|<|>|=|!=|NOT[[:space:]]+IN|IN|NOT[[:space:]]+LIKE|LIKE|IS[[:space:]]+NOT|IS)'); - if (strlen($stack[$level][$pnt[$level]]['comparator'])) { - if (preg_match('/^CONCAT[[:space:]]*\(/', $parseString)) { - $this->nextPart($parseString, '^(CONCAT[[:space:]]?[(])'); - $values = array( - 'operator' => 'CONCAT', - 'args' => array(), - ); - $cnt = 0; - while ($fieldName = $this->nextPart($parseString, '^([[:alnum:]._]+)')) { - // Parse field name into field and table: - $tableField = explode('.', $fieldName, 2); - if (count($tableField) == 2) { - $values['args'][$cnt]['table'] = $tableField[0]; - $values['args'][$cnt]['field'] = $tableField[1]; - } else { - $values['args'][$cnt]['table'] = ''; - $values['args'][$cnt]['field'] = $tableField[0]; - } - // Looking for comma: - $this->nextPart($parseString, '^(,)'); - $cnt++; + // Support calculated value only for: + // - "&" (boolean AND) + // - "+" (addition) + // - "-" (substraction) + // - "*" (multiplication) + // - "/" (division) + // - "%" (modulo) + $calcOperators = '&|\+|-|\*|\/|%'; + + // Fieldname: + if ($fieldName = $this->nextPart($parseString, '^([[:alnum:]._]+)([[:space:]]+|' . $calcOperators . '|<=|>=|<|>|=|!=|IS)')) { + + // Parse field name into field and table: + $tableField = explode('.', $fieldName, 2); + if (count($tableField) == 2) { + $stack[$level][$pnt[$level]]['table'] = $tableField[0]; + $stack[$level][$pnt[$level]]['field'] = $tableField[1]; + } else { + $stack[$level][$pnt[$level]]['table'] = ''; + $stack[$level][$pnt[$level]]['field'] = $tableField[0]; } - // Look for ending parenthesis: - $this->nextPart($parseString, '([)])'); - $stack[$level][$pnt[$level]]['value'] = $values; } else { - // Finding value for comparator: - $stack[$level][$pnt[$level]]['value'] = $this->getValue($parseString, $stack[$level][$pnt[$level]]['comparator']); - if ($this->parse_error) { - return $this->parse_error; + return $this->parseError('No field name found as expected in parseWhereClause()', $parseString); + } + + // See if the value is calculated: + $stack[$level][$pnt[$level]]['calc'] = $this->nextPart($parseString, '^(' . $calcOperators . ')'); + if (strlen($stack[$level][$pnt[$level]]['calc'])) { + // Finding value for calculation: + $stack[$level][$pnt[$level]]['calc_value'] = $this->getValue($parseString); + } + + // Find "comparator": + $stack[$level][$pnt[$level]]['comparator'] = $this->nextPart($parseString, '^(<=|>=|<|>|=|!=|NOT[[:space:]]+IN|IN|NOT[[:space:]]+LIKE|LIKE|IS[[:space:]]+NOT|IS)'); + if (strlen($stack[$level][$pnt[$level]]['comparator'])) { + if (preg_match('/^CONCAT[[:space:]]*\(/', $parseString)) { + $this->nextPart($parseString, '^(CONCAT[[:space:]]?[(])'); + $values = array( + 'operator' => 'CONCAT', + 'args' => array(), + ); + $cnt = 0; + while ($fieldName = $this->nextPart($parseString, '^([[:alnum:]._]+)')) { + // Parse field name into field and table: + $tableField = explode('.', $fieldName, 2); + if (count($tableField) == 2) { + $values['args'][$cnt]['table'] = $tableField[0]; + $values['args'][$cnt]['field'] = $tableField[1]; + } else { + $values['args'][$cnt]['table'] = ''; + $values['args'][$cnt]['field'] = $tableField[0]; + } + // Looking for comma: + $this->nextPart($parseString, '^(,)'); + $cnt++; + } + // Look for ending parenthesis: + $this->nextPart($parseString, '([)])'); + $stack[$level][$pnt[$level]]['value'] = $values; + } elseif (in_array($stack[$level][$pnt[$level]]['comparator'], array('IN', 'NOT IN')) && preg_match('/^[(][[:space:]]*SELECT[[:space:]]+/', $parseString)) { + $this->nextPart($parseString, '^([(])'); + $stack[$level][$pnt[$level]]['subquery'] = $this->parseSELECT($parseString); + // Seek to new position in parseString after parsing of the subquery + $parseString = $stack[$level][$pnt[$level]]['subquery']['parseString']; + unset($stack[$level][$pnt[$level]]['subquery']['parseString']); + if (!$this->nextPart($parseString, '^([)])')) { + return 'No ) parenthesis at end of subquery'; + } + } else { + // Finding value for comparator: + $stack[$level][$pnt[$level]]['value'] = $this->getValue($parseString, $stack[$level][$pnt[$level]]['comparator']); + if ($this->parse_error) { + return $this->parse_error; + } } } } @@ -252,6 +275,13 @@ // Finished, increase pointer: $pnt[$level]++; + // Checking if we are back to level 0 and we should still decrease level, + // meaning we were probably parsing a subquery and should return here: + if ($level === 0 && preg_match('/^[)]/', $parseString)) { + // Return the stacks lowest level: + return $stack[0]; + } + // Checking if the current level is ended, in that case do stack management: while ($this->nextPart($parseString,'^([)])')) { $level--; // Decrease level: @@ -362,6 +392,8 @@ // Look for sublevel: if (is_array($v['sub'])) { $output .= ' (' . trim($this->nativeCompileWhereClause($v['sub'])) . ')'; + } elseif (isset($v['func'])) { + $output .= ' ' . trim($v['modifier']) . ' ' . $v['func']['type'] . ' (' . $this->compileSELECT($v['func']['subquery']) . ')'; } else { // Set field/table with modifying prefix if any: @@ -378,12 +410,16 @@ // Detecting value type; list or plain: if (t3lib_div::inList('NOTIN,IN', strtoupper(str_replace(array(' ', "\t", "\r", "\n"), '', $v['comparator'])))) { - $valueBuffer = array(); - foreach ($v['value'] as $realValue) { - $valueBuffer[] = $realValue[1] . $this->compileAddslashes($realValue[0]) . $realValue[1]; + if (isset($v['subquery'])) { + $output .= ' (' . $this->compileSELECT($v['subquery']) . ')'; + } else { + $valueBuffer = array(); + foreach ($v['value'] as $realValue) { + $valueBuffer[] = $realValue[1] . $this->compileAddslashes($realValue[0]) . $realValue[1]; + } + $output .= ' (' . trim(implode(',', $valueBuffer)) . ')'; } - $output .= ' (' . trim(implode(',', $valueBuffer)) . ')'; - } else if (isset($v['value']['operator'])) { + } elseif (isset($v['value']['operator'])) { $values = array(); foreach ($v['value']['args'] as $fieldDef) { $values[] = ($fieldDef['table'] ? $fieldDef['table'] . '.' : '') . $fieldDef['field']; @@ -721,6 +757,8 @@ // Look for sublevel: if (is_array($v['sub'])) { $output .= ' (' . trim($this->compileWhereClause($v['sub'], $functionMapping)) . ')'; + } elseif (isset($v['func'])) { + $output .= ' ' . trim($v['modifier']) . ' ' . $v['func']['type'] . ' (' . $this->compileSELECT($v['func']['subquery']) . ')'; } else { // Set field/table with modifying prefix if any: @@ -770,12 +808,16 @@ // Detecting value type; list or plain: if (t3lib_div::inList('NOTIN,IN', strtoupper(str_replace(array(' ', "\t", "\r", "\n"), '', $v['comparator'])))) { - $valueBuffer = array(); - foreach ($v['value'] as $realValue) { - $valueBuffer[] = $realValue[1] . $this->compileAddslashes($realValue[0]) . $realValue[1]; + if (isset($v['subquery'])) { + $output .= ' (' . $this->compileSELECT($v['subquery']) . ')'; + } else { + $valueBuffer = array(); + foreach ($v['value'] as $realValue) { + $valueBuffer[] = $realValue[1] . $this->compileAddslashes($realValue[0]) . $realValue[1]; + } + $output .= ' (' . trim(implode(',', $valueBuffer)) . ')'; } - $output .= ' (' . trim(implode(',', $valueBuffer)) . ')'; - } else if (isset($v['value']['operator'])) { + } elseif (isset($v['value']['operator'])) { $values = array(); foreach ($v['value']['args'] as $fieldDef) { $values[] = ($fieldDef['table'] ? $fieldDef['table'] . '.' : '') . $fieldDef['field']; Index: class.ux_t3lib_db.php =================================================================== --- class.ux_t3lib_db.php (revision 27051) +++ class.ux_t3lib_db.php (working copy) @@ -939,6 +939,19 @@ **************************************/ /** + * Quotes components of a SELECT subquery. + * + * @param array $components Array of SQL query components + * @return array + */ + protected function quoteSELECTsubquery(array $components) { + $components['SELECT'] = $this->_quoteFieldNames($components['SELECT']); + $components['FROM'] = $this->_quoteFromTables($components['FROM']); + $components['WHERE'] = $this->_quoteWhereClause($components['WHERE']); + return $components; + } + + /** * Quotes field (and table) names with the quote character suitable for the DB being used * Use quoteFieldNames instead! * @@ -961,6 +974,19 @@ if ($this->runningNative()) return $select_fields; $select_fields = $this->SQLparser->parseFieldList($select_fields); + $select_fields = $this->_quoteFieldNames($select_fields); + + return $this->SQLparser->compileFieldList($select_fields); + } + + /** + * Quotes field (and table) names in a SQL SELECT clause acccording to DB rules + * + * @param array $select_fields The parsed fields to quote + * @return array + * @see quoteFieldNames() + */ + protected function _quoteFieldNames(array $select_fields) { foreach ($select_fields as $k => $v) { if ($select_fields[$k]['field'] != '' && $select_fields[$k]['field'] != '*') { $select_fields[$k]['field'] = $this->quoteName($select_fields[$k]['field']); @@ -977,7 +1003,7 @@ } } - return $this->SQLparser->compileFieldList($select_fields); + return $select_fields; } /** @@ -991,6 +1017,18 @@ if ($this->runningNative()) return $from_table; $from_table = $this->SQLparser->parseFromTables($from_table); + $from_table = $this->_quoteFromTables($from_table); + return $this->SQLparser->compileFromTables($from_table); + } + + /** + * Quotes table names in a SQL FROM clause acccording to DB rules + * + * @param array $from_table The parsed FROM clause to quote + * @return array + * @see quoteFromTables() + */ + protected function _quoteFromTables(array $from_table) { foreach ($from_table as $k => $v) { $from_table[$k]['table'] = $this->quoteName($from_table[$k]['table']); if ($from_table[$k]['as'] != '') { @@ -1007,7 +1045,8 @@ } } } - return $this->SQLparser->compileFromTables($from_table); + + return $from_table; } /** @@ -1037,11 +1076,13 @@ * @return array * @see quoteWhereClause() */ - protected function _quoteWhereClause($where_clause) { + protected function _quoteWhereClause(array $where_clause) { foreach ($where_clause as $k => $v) { // Look for sublevel: if (is_array($where_clause[$k]['sub'])) { $where_clause[$k]['sub'] = $this->_quoteWhereClause($where_clause[$k]['sub']); + } else if (isset($v['func'])) { + $where_clause[$k]['func']['subquery'] = $this->quoteSELECTsubquery($v['func']['subquery']); } else { if ($where_clause[$k]['table'] != '') { $where_clause[$k]['table'] = $this->quoteName($where_clause[$k]['table']); @@ -1066,8 +1107,14 @@ } } else { // Detecting value type; list or plain: - if ((!isset($where_clause[$k]['value'][1]) || $where_clause[$k]['value'][1] == '') && is_string($where_clause[$k]['value'][0]) && strstr($where_clause[$k]['value'][0], '.') && !t3lib_div::inList('NOTIN,IN',strtoupper(str_replace(array(" ","\n","\r","\t"),'',$where_clause[$k]['comparator'])))) { - $where_clause[$k]['value'][0] = $this->quoteFieldNames($where_clause[$k]['value'][0]); + if (t3lib_div::inList('NOTIN,IN', strtoupper(str_replace(array(' ',"\n", "\r", "\t"), '', $where_clause[$k]['comparator'])))) { + if (isset($v['subquery'])) { + $where_clause[$k]['subquery'] = $this->quoteSELECTsubquery($v['subquery']); + } + } else { + if ((!isset($where_clause[$k]['value'][1]) || $where_clause[$k]['value'][1] == '') && is_string($where_clause[$k]['value'][0]) && strstr($where_clause[$k]['value'][0], '.')) { + $where_clause[$k]['value'][0] = $this->quoteFieldNames($where_clause[$k]['value'][0]); + } } } } @@ -2480,6 +2527,11 @@ // Look for sublevel (WHERE parts only) if (is_array($sqlPartArray[$k]['sub'])) { $this->map_sqlParts($sqlPartArray[$k]['sub'], $defaultTable); // Call recursively! + } else if (isset($sqlPartArray[$k]['func'])) { + $subqueryDefaultTable = $sqlPartArray[$k]['func']['subquery']['FROM'][0]['table']; + $this->map_sqlParts($sqlPartArray[$k]['func']['subquery']['SELECT'], $subqueryDefaultTable); + $this->map_sqlParts($sqlPartArray[$k]['func']['subquery']['FROM'], $subqueryDefaultTable); + $this->map_sqlParts($sqlPartArray[$k]['func']['subquery']['WHERE'], $subqueryDefaultTable); } else { // For the field, look for table mapping (generic): $t = $sqlPartArray[$k]['table'] ? $sqlPartArray[$k]['table'] : $defaultTable; @@ -2524,6 +2576,14 @@ } } + // Do we have a subquery (WHERE parts only)? + if (isset($sqlPartArray[$k]['subquery'])) { + $subqueryDefaultTable = $sqlPartArray[$k]['subquery']['FROM'][0]['table']; + $this->map_sqlParts($sqlPartArray[$k]['subquery']['SELECT'], $subqueryDefaultTable); + $this->map_sqlParts($sqlPartArray[$k]['subquery']['FROM'], $subqueryDefaultTable); + $this->map_sqlParts($sqlPartArray[$k]['subquery']['WHERE'], $subqueryDefaultTable); + } + // do we have a field name in the value? // this is a very simplistic check, beware if (!is_numeric($sqlPartArray[$k]['value'][0]) && !isset($sqlPartArray[$k]['value'][1])) { Index: tests/fixtures/oci8.config.php =================================================================== --- tests/fixtures/oci8.config.php (revision 27051) +++ tests/fixtures/oci8.config.php (working copy) @@ -47,6 +47,13 @@ 'uid_local' => 'local_uid', ), ), + 'tx_crawler_process' => array( + 'mapTableName' => 'tx_crawler_ps', + 'mapFieldNames' => array( + 'process_id' => 'ps_id', + 'active' => 'is_active', + ), + ), 'tx_dam_file_tracking' => array( 'mapFieldNames' => array( 'file_name' => 'filename', Index: tests/sqlparser_general_testcase.php =================================================================== --- tests/sqlparser_general_testcase.php (revision 27051) +++ tests/sqlparser_general_testcase.php (working copy) @@ -279,5 +279,57 @@ $createTables = $this->fixture->_callRef('parseCREATETABLE', $parseString); $this->assertTrue(is_array($createTables), $createTables); } + + /////////////////////////////////////// + // Tests concerning subqueries + /////////////////////////////////////// + + /** + * @test + * @see http://bugs.typo3.org/view.php?id=12758 + */ + public function inWhereClauseSupportsSubquery() { + $parseString = 'process_id IN (SELECT process_id FROM tx_crawler_process WHERE active=0 AND deleted=0)'; + $whereParts = $this->fixture->parseWhereClause($parseString); + + $this->assertTrue(is_array($whereParts), $whereParts); + $this->assertTrue(empty($parseString), 'parseString is not empty'); + } + + /** + * @test + * @see http://bugs.typo3.org/view.php?id=12758 + */ + public function inWhereClauseWithSubqueryIsProperlyCompiled() { + $sql = 'SELECT * FROM tx_crawler_queue WHERE 1 = 1 AND process_id IN (SELECT process_id FROM tx_crawler_process WHERE active=0 AND deleted=0)'; + $expected = 'SELECT * FROM tx_crawler_queue WHERE 1 = 1 AND process_id IN (SELECT process_id FROM tx_crawler_process WHERE active = 0 AND deleted = 0)'; + $actual = $this->cleanSql($this->fixture->debug_testSQL($sql)); + + $this->assertEquals($expected, $actual); + } + + /** + * @test + * @see http://bugs.typo3.org/view.php?id=12758 + */ + public function whereClauseSupportsExistsKeyword() { + $parseString = 'EXISTS (SELECT * FROM tx_crawler_queue WHERE tx_crawler_queue.process_id = tx_crawler_process.process_id AND tx_crawler_queue.exec_time = 0)'; + $whereParts = $this->fixture->parseWhereClause($parseString); + + $this->assertTrue(is_array($whereParts), $whereParts); + $this->assertTrue(empty($parseString), 'parseString is not empty'); + } + + /** + * @test + * @see http://bugs.typo3.org/view.php?id=12758 + */ + public function existsClauseIsProperlyCompiled() { + $sql = 'SELECT * FROM tx_crawler_process WHERE active = 0 AND NOT EXISTS (SELECT * FROM tx_crawler_queue WHERE tx_crawler_queue.process_id = tx_crawler_process.process_id AND tx_crawler_queue.exec_time = 0)'; + $expected = 'SELECT * FROM tx_crawler_process WHERE active = 0 AND NOT EXISTS (SELECT * FROM tx_crawler_queue WHERE tx_crawler_queue.process_id = tx_crawler_process.process_id AND tx_crawler_queue.exec_time = 0)'; + $actual = $this->cleanSql($this->fixture->debug_testSQL($sql)); + + $this->assertEquals($expected, $actual); + } } ?> \ No newline at end of file Index: tests/db_oracle_testcase.php =================================================================== --- tests/db_oracle_testcase.php (revision 27051) +++ tests/db_oracle_testcase.php (working copy) @@ -401,6 +401,74 @@ ) '); $this->assertEquals($expected, $this->cleanSql($sqlCommands[0])); - } + } + + /////////////////////////////////////// + // Tests concerning subqueries + /////////////////////////////////////// + + /** + * @test + * @see http://bugs.typo3.org/view.php?id=12758 + */ + public function inWhereClauseWithSubqueryIsProperlyQuoted() { + $query = $this->cleanSql($GLOBALS['TYPO3_DB']->SELECTquery( + '*', + 'tx_crawler_queue', + 'process_id IN (SELECT process_id FROM tx_crawler_process WHERE active=0 AND deleted=0)' + )); + $expected = 'SELECT * FROM "tx_crawler_queue" WHERE "process_id" IN (SELECT "process_id" FROM "tx_crawler_process" WHERE "active" = 0 AND "deleted" = 0)'; + $this->assertEquals($expected, $query); + } + + /** + * @test + * @see http://bugs.typo3.org/view.php?id=12758 + */ + public function subqueryIsRemappedForInWhereClause() { + $selectFields = '*'; + $fromTables = 'tx_crawler_queue'; + $whereClause = 'process_id IN (SELECT process_id FROM tx_crawler_process WHERE active=0 AND deleted=0)'; + $groupBy = ''; + $orderBy = ''; + + $GLOBALS['TYPO3_DB']->_callRef('map_remapSELECTQueryParts', $selectFields, $fromTables, $whereClause, $groupBy, $orderBy); + $query = $this->cleanSql($GLOBALS['TYPO3_DB']->SELECTquery($selectFields, $fromTables, $whereClause, $groupBy, $orderBy)); + + $expected = 'SELECT * FROM "tx_crawler_queue" WHERE "process_id" IN (SELECT "ps_id" FROM "tx_crawler_ps" WHERE "is_active" = 0 AND "deleted" = 0)'; + $this->assertEquals($expected, $query); + } + + /** + * @test + * @see http://bugs.typo3.org/view.php?id=12758 + */ + public function existsWhereClauseIsProperlyQuoted() { + $query = $this->cleanSql($GLOBALS['TYPO3_DB']->SELECTquery( + '*', + 'tx_crawler_process', + 'active = 0 AND NOT EXISTS (SELECT * FROM tx_crawler_queue WHERE tx_crawler_queue.process_id = tx_crawler_process.process_id AND tx_crawler_queue.exec_time = 0)' + )); + $expected = 'SELECT * FROM "tx_crawler_process" WHERE "active" = 0 AND NOT EXISTS (SELECT * FROM "tx_crawler_queue" WHERE "tx_crawler_queue"."process_id" = "tx_crawler_process"."process_id" AND "tx_crawler_queue"."exec_time" = 0)'; + $this->assertEquals($expected, $query); + } + + /** + * @test + * @see http://bugs.typo3.org/view.php?id=12758 + */ + public function subqueryIsRemappedForExistsWhereClause() { + $selectFields = '*'; + $fromTables = 'tx_crawler_process'; + $whereClause = 'active = 0 AND NOT EXISTS (SELECT * FROM tx_crawler_queue WHERE tx_crawler_queue.process_id = tx_crawler_process.process_id AND tx_crawler_queue.exec_time = 0)'; + $groupBy = ''; + $orderBy = ''; + + $GLOBALS['TYPO3_DB']->_callRef('map_remapSELECTQueryParts', $selectFields, $fromTables, $whereClause, $groupBy, $orderBy); + $query = $this->cleanSql($GLOBALS['TYPO3_DB']->SELECTquery($selectFields, $fromTables, $whereClause, $groupBy, $orderBy)); + + $expected = 'SELECT * FROM "tx_crawler_ps" WHERE "is_active" = 0 AND NOT EXISTS (SELECT * FROM "tx_crawler_queue" WHERE "tx_crawler_queue"."process_id" = "tx_crawler_ps"."ps_id" AND "tx_crawler_queue"."exec_time" = 0)'; + $this->assertEquals($expected, $query); + } } ?> \ No newline at end of file