<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Test specific features of the Postgres dml support relating to recordsets.
 *
 * @package core
 * @category test
 * @copyright 2017 The Open University
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

defined('MOODLE_INTERNAL') || die();

global $CFG;
require_once($CFG->dirroot.'/lib/dml/pgsql_native_moodle_database.php');

/**
 * Test specific features of the Postgres dml support relating to recordsets.
 *
 * @package core
 * @category test
 * @copyright 2017 The Open University
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class pgsql_native_recordset_test extends basic_testcase {

    /** @var pgsql_native_moodle_database Special database connection */
    protected $specialdb;

    /**
     * Creates a second db connection and a temp table with values in for testing.
     */
    protected function setUp(): void {
        global $DB;

        parent::setUp();

        // Skip tests if not using Postgres.
        if (!($DB instanceof pgsql_native_moodle_database)) {
            $this->markTestSkipped('Postgres-only test');
        }
    }

    /**
     * Initialises database connection with given fetch buffer size
     * @param int $fetchbuffersize Size of fetch buffer
     */
    protected function init_db($fetchbuffersize) {
        global $CFG, $DB;

        // To make testing easier, create a database with the same dboptions as the real one,
        // but a low number for the cursor size.
        $this->specialdb = \moodle_database::get_driver_instance('pgsql', 'native', true);
        $dboptions = $CFG->dboptions;
        $dboptions['fetchbuffersize'] = $fetchbuffersize;
        $this->specialdb->connect($CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname,
                $DB->get_prefix(), $dboptions);

        // Create a temp table.
        $dbman = $this->specialdb->get_manager();
        $table = new xmldb_table('silly_test_table');
        $table->add_field('id', XMLDB_TYPE_INTEGER, 10, null, XMLDB_NOTNULL, XMLDB_SEQUENCE);
        $table->add_field('msg', XMLDB_TYPE_CHAR, 255);
        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
        $dbman->create_temp_table($table);

        // Add some records to the table.
        for ($index = 1; $index <= 7; $index++) {
            $this->specialdb->insert_record('silly_test_table', ['msg' => 'record' . $index]);
        }
    }

    /**
     * Gets rid of the second db connection.
     */
    protected function tearDown(): void {
        if ($this->specialdb) {
            $table = new xmldb_table('silly_test_table');
            $this->specialdb->get_manager()->drop_table($table);
            $this->specialdb->dispose();
            $this->specialdb = null;
        }
        parent::tearDown();
    }

    /**
     * Tests that get_recordset_sql works when using cursors, which it does when no limit is
     * specified.
     */
    public function test_recordset_cursors() {
        $this->init_db(3);

        // Query the table and check the actual queries using debug mode, also check the count.
        $this->specialdb->set_debug(true);
        $before = $this->specialdb->perf_get_queries();
        ob_start();
        $rs = $this->specialdb->get_recordset_sql('SELECT * FROM {silly_test_table} ORDER BY id');
        $index = 0;
        foreach ($rs as $rec) {
            $index++;
            $this->assertEquals('record' . $index, $rec->msg);
        }
        $this->assertEquals(7, $index);
        $rs->close();
        $debugging = ob_get_contents();
        ob_end_clean();

        // Expect 4 fetches - first three, next three, last one (with 2).
        $this->assert_query_regexps([
                '~SELECT \* FROM~',
                '~FETCH 3 FROM crs1~',
                '~FETCH 3 FROM crs1~',
                '~FETCH 3 FROM crs1~',
                '~CLOSE crs1~'], $debugging);

        // There should have been 7 queries tracked for perf log.
        $this->assertEquals(5, $this->specialdb->perf_get_queries() - $before);

        // Try a second time - this time we'll request exactly 3 items so that it has to query
        // twice (as it can't tell if the first batch is the last).
        $before = $this->specialdb->perf_get_queries();
        ob_start();
        $rs = $this->specialdb->get_recordset_sql(
                'SELECT * FROM {silly_test_table} WHERE id <= ? ORDER BY id', [3]);
        $index = 0;
        foreach ($rs as $rec) {
            $index++;
            $this->assertEquals('record' . $index, $rec->msg);
        }
        $this->assertEquals(3, $index);
        $rs->close();
        $debugging = ob_get_contents();
        ob_end_clean();

        $this->specialdb->set_debug(false);

        // Expect 2 fetches - first three, then next one (empty).
        $this->assert_query_regexps([
                '~SELECT \* FROM~',
                '~FETCH 3 FROM crs2~',
                '~FETCH 3 FROM crs2~',
                '~CLOSE crs2~'], $debugging);

        // There should have been 4 queries tracked for perf log.
        $this->assertEquals(4, $this->specialdb->perf_get_queries() - $before);
    }

    /**
     * Tests that get_recordset_sql works when using cursors and when there are two overlapping
     * recordsets being used.
     */
    public function test_recordset_cursors_overlapping() {
        $this->init_db(3);

        $rs1 = $this->specialdb->get_recordset('silly_test_table', null, 'id');
        $rs2 = $this->specialdb->get_recordset('silly_test_table', null, 'id DESC');

        // Read first 3 from first recordset.
        $read = [];
        $read[] = $rs1->current()->id;
        $rs1->next();
        $read[] = $rs1->current()->id;
        $rs1->next();
        $read[] = $rs1->current()->id;
        $rs1->next();
        $this->assertEquals([1, 2, 3], $read);

        // Read 5 from second recordset.
        $read = [];
        $read[] = $rs2->current()->id;
        $rs2->next();
        $read[] = $rs2->current()->id;
        $rs2->next();
        $read[] = $rs2->current()->id;
        $rs2->next();
        $read[] = $rs2->current()->id;
        $rs2->next();
        $read[] = $rs2->current()->id;
        $rs2->next();
        $this->assertEquals([7, 6, 5, 4, 3], $read);

        // Now read remainder of first recordset and close it.
        $read = [];
        $read[] = $rs1->current()->id;
        $rs1->next();
        $read[] = $rs1->current()->id;
        $rs1->next();
        $read[] = $rs1->current()->id;
        $rs1->next();
        $read[] = $rs1->current()->id;
        $rs1->next();
        $this->assertFalse($rs1->valid());
        $this->assertEquals([4, 5, 6, 7], $read);
        $rs1->close();

        // And remainder of second.
        $read = [];
        $read[] = $rs2->current()->id;
        $rs2->next();
        $read[] = $rs2->current()->id;
        $rs2->next();
        $this->assertFalse($rs2->valid());
        $this->assertEquals([2, 1], $read);
        $rs2->close();
    }

    /**
     * Tests that get_recordset_sql works when using cursors and transactions inside.
     */
    public function test_recordset_cursors_transaction_inside() {
        $this->init_db(3);

        // Transaction inside the recordset processing.
        $rs = $this->specialdb->get_recordset('silly_test_table', null, 'id');
        $read = [];
        foreach ($rs as $rec) {
            $read[] = $rec->id;
            $transaction = $this->specialdb->start_delegated_transaction();
            $transaction->allow_commit();
        }
        $this->assertEquals([1, 2, 3, 4, 5, 6, 7], $read);
        $rs->close();
    }

    /**
     * Tests that get_recordset_sql works when using cursors and a transaction outside.
     */
    public function test_recordset_cursors_transaction_outside() {
        $this->init_db(3);

        // Transaction outside the recordset processing.
        $transaction = $this->specialdb->start_delegated_transaction();
        $rs = $this->specialdb->get_recordset('silly_test_table', null, 'id');
        $read = [];
        foreach ($rs as $rec) {
            $read[] = $rec->id;
        }
        $this->assertEquals([1, 2, 3, 4, 5, 6, 7], $read);
        $rs->close();
        $transaction->allow_commit();
    }

    /**
     * Tests that get_recordset_sql works when using cursors and a transaction overlapping.
     */
    public function test_recordset_cursors_transaction_overlapping_before() {
        $this->init_db(3);

        // Transaction outside the recordset processing.
        $transaction = $this->specialdb->start_delegated_transaction();
        $rs = $this->specialdb->get_recordset('silly_test_table', null, 'id');
        $transaction->allow_commit();
        $read = [];
        foreach ($rs as $rec) {
            $read[] = $rec->id;
        }
        $this->assertEquals([1, 2, 3, 4, 5, 6, 7], $read);
        $rs->close();
    }

    /**
     * Tests that get_recordset_sql works when using cursors and a transaction overlapping.
     */
    public function test_recordset_cursors_transaction_overlapping_after() {
        $this->init_db(3);

        // Transaction outside the recordset processing.
        $rs = $this->specialdb->get_recordset('silly_test_table', null, 'id');
        $transaction = $this->specialdb->start_delegated_transaction();
        $read = [];
        foreach ($rs as $rec) {
            $read[] = $rec->id;
        }
        $this->assertEquals([1, 2, 3, 4, 5, 6, 7], $read);
        $rs->close();
        $transaction->allow_commit();
    }

    /**
     * Tests that get_recordset_sql works when using cursors and a transaction that 'fails' and gets
     * rolled back.
     */
    public function test_recordset_cursors_transaction_rollback() {
        $this->init_db(3);

        try {
            $rs = $this->specialdb->get_recordset('silly_test_table', null, 'id');
            $transaction = $this->specialdb->start_delegated_transaction();
            $this->specialdb->delete_records('silly_test_table', ['id' => 5]);
            $transaction->rollback(new dml_transaction_exception('rollback please'));
            $this->fail('should not get here');
        } catch (dml_transaction_exception $e) {
            $this->assertStringContainsString('rollback please', $e->getMessage());
        } finally {

            // Rollback should not kill our recordset.
            $read = [];
            foreach ($rs as $rec) {
                $read[] = $rec->id;
            }
            $this->assertEquals([1, 2, 3, 4, 5, 6, 7], $read);

            // This would happen in real code (that isn't within the same function) anyway because
            // it would go out of scope.
            $rs->close();
        }

        // OK, transaction aborted, now get the recordset again and check nothing was deleted.
        $rs = $this->specialdb->get_recordset('silly_test_table', null, 'id');
        $read = [];
        foreach ($rs as $rec) {
            $read[] = $rec->id;
        }
        $this->assertEquals([1, 2, 3, 4, 5, 6, 7], $read);
        $rs->close();
    }

    /**
     * Tests that get_recordset_sql works when not using cursors, because a limit is specified.
     */
    public function test_recordset_no_cursors_limit() {
        $this->init_db(3);

        $this->specialdb->set_debug(true);
        $before = $this->specialdb->perf_get_queries();
        ob_start();
        $rs = $this->specialdb->get_recordset_sql(
                'SELECT * FROM {silly_test_table} ORDER BY id', [], 0, 100);
        $index = 0;
        foreach ($rs as $rec) {
            $index++;
            $this->assertEquals('record' . $index, $rec->msg);
        }
        $this->assertEquals(7, $index);
        $rs->close();
        $this->specialdb->set_debug(false);
        $debugging = ob_get_contents();
        ob_end_clean();

        // Expect direct request without using cursors.
        $this->assert_query_regexps(['~SELECT \* FROM~'], $debugging);

        // There should have been 1 query tracked for perf log.
        $this->assertEquals(1, $this->specialdb->perf_get_queries() - $before);
    }

    /**
     * Tests that get_recordset_sql works when not using cursors, because the config setting turns
     * them off.
     */
    public function test_recordset_no_cursors_config() {
        $this->init_db(0);

        $this->specialdb->set_debug(true);
        $before = $this->specialdb->perf_get_queries();
        ob_start();
        $rs = $this->specialdb->get_recordset_sql('SELECT * FROM {silly_test_table} ORDER BY id');
        $index = 0;
        foreach ($rs as $rec) {
            $index++;
            $this->assertEquals('record' . $index, $rec->msg);
        }
        $this->assertEquals(7, $index);
        $rs->close();
        $this->specialdb->set_debug(false);
        $debugging = ob_get_contents();
        ob_end_clean();

        // Expect direct request without using cursors.
        $this->assert_query_regexps(['~SELECT \* FROM~'], $debugging);

        // There should have been 1 query tracked for perf log.
        $this->assertEquals(1, $this->specialdb->perf_get_queries() - $before);
    }

    /**
     * Asserts that database debugging output matches the expected list of SQL queries, specified
     * as an array of regular expressions.
     *
     * @param string[] $expected Expected regular expressions
     * @param string $debugging Debugging text from the database
     */
    protected function assert_query_regexps(array $expected, $debugging) {
        $lines = explode("\n", $debugging);
        $index = 0;
        $params = false;
        foreach ($lines as $line) {
            if ($params) {
                if ($line === ')]') {
                    $params = false;
                }
                continue;
            }
            // Skip irrelevant lines.
            if (preg_match('~^---~', $line)) {
                continue;
            }
            if (preg_match('~^Query took~', $line)) {
                continue;
            }
            if (trim($line) === '') {
                continue;
            }
            // Skip param lines.
            if ($line === '[array (') {
                $params = true;
                continue;
            }
            if (!array_key_exists($index, $expected)) {
                $this->fail('More queries than expected');
            }
            $this->assertMatchesRegularExpression($expected[$index++], $line);
        }
        if (array_key_exists($index, $expected)) {
            $this->fail('Fewer queries than expected');
        }
    }

}
