diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2634621..d73dd5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: services: postgres: - image: postgres:10 + image: postgres:12 env: POSTGRES_USER: 'postgres' POSTGRES_HOST_AUTH_METHOD: 'trust' diff --git a/db/install.xml b/db/install.xml old mode 100644 new mode 100755 index de5c328..909c24a --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -43,4 +43,4 @@ - + \ No newline at end of file diff --git a/edit_form.php b/edit_form.php index c380fef..889fdc2 100644 --- a/edit_form.php +++ b/edit_form.php @@ -150,6 +150,7 @@ public function definition() { $mform->disabledIf('singlerow', 'runable', 'eq', 'manual'); $mform->disabledIf('at', 'runable', 'ne', 'daily'); $mform->disabledIf('emailto', 'runable', 'eq', 'manual'); + $mform->disabledIf('customdir', 'runable', 'eq', 'manual'); $mform->disabledIf('emailwhat', 'runable', 'eq', 'manual'); $this->add_action_buttons(); @@ -301,6 +302,16 @@ public function validation($data, $files) { } } + // Check that the custom directory is writable and a directory, if provided. + if (isset($data['customdir']) && !empty($data['customdir'])) { + if (!is_dir($data['customdir'])) { + $errors['customdir'] = get_string('notadirectory', 'report_customsql'); + } + else if (!is_writable($data['customdir'])) { + $errors['customdir'] = get_string('directorynotwritable', 'report_customsql'); + } + } + return $errors; } } diff --git a/lang/en/report_customsql.php b/lang/en/report_customsql.php index e56d997..1e9d7b7 100644 --- a/lang/en/report_customsql.php +++ b/lang/en/report_customsql.php @@ -61,6 +61,7 @@ $string['deletecategoryyesno'] = '

Are you really sure you want to delete this category?

'; $string['deletereportx'] = 'Delete query \'{$a}\''; $string['description'] = 'Description'; +$string['directorynotwritable'] = 'The directory you provided is not writable.'; $string['displayname'] = 'Query name'; $string['displaynamex'] = 'Query name: {$a}'; $string['displaynamerequired'] = 'You must enter a query name'; diff --git a/lib.php b/lib.php index 21013e6..8294dbf 100644 --- a/lib.php +++ b/lib.php @@ -89,7 +89,7 @@ function report_customsql_pluginfile($course, $cm, $context, $filearea, $args, $ if ($report->runable !== 'manual') { $runtime = $report->lastrun; } - $csvtimestamp = \report_customsql_generate_csv($report, $runtime); + $csvtimestamp = \report_customsql_generate_csv($report, $runtime, true); } list($csvfilename) = report_customsql_csv_filename($report, $csvtimestamp); diff --git a/locallib.php b/locallib.php index cd9fec4..423718b 100644 --- a/locallib.php +++ b/locallib.php @@ -96,7 +96,14 @@ function report_customsql_get_element_type($name) { return 'text'; } -function report_customsql_generate_csv($report, $timenow) { +/** + * Generate customsql csv file. + * + * @param stdclass $report report record from customsql table. + * @param int $timetimenow unix timestamp - usually "now()" + * @param bool $returnheaderwhenempty if true, a CSV file with headers will always be generated, even if there are no results. + */ +function report_customsql_generate_csv($report, $timenow, $returnheaderwhenempty = false) { global $DB; $starttime = microtime(true); @@ -104,6 +111,14 @@ function report_customsql_generate_csv($report, $timenow) { $queryparams = !empty($report->queryparams) ? unserialize($report->queryparams) : array(); $querylimit = $report->querylimit ?? get_config('report_customsql', 'querylimitdefault'); + if ($returnheaderwhenempty) { + // We want the export to always generate a CSV file so we modify the query slightly + // to generate an extra "null" values row, so we can get the column names, + // then we ignore rows that contain null records in every row when generating the csv. + $sql = "SELECT subq.* + FROM (SELECT 1) as ignoreme + LEFT JOIN ($sql) as subq on true"; + } // Query one extra row, so we can tell if we hit the limit. $rs = report_customsql_execute_query($sql, $queryparams, $querylimit + 1); @@ -124,6 +139,11 @@ function report_customsql_generate_csv($report, $timenow) { } $data = get_object_vars($row); + + if ($returnheaderwhenempty && array_unique(array_values($data)) === [null]) { + // This is a row with all null values - ignore it. + continue; + } foreach ($data as $name => $value) { if (report_customsql_get_element_type($name) == 'date_time_selector' && report_customsql_is_integer($value) && $value > 0) { @@ -146,6 +166,7 @@ function report_customsql_generate_csv($report, $timenow) { fclose($handle); } + // Update the execution time in the DB. $updaterecord = new stdClass(); $updaterecord->id = $report->id; diff --git a/tests/behat/behat_report_customsql.php b/tests/behat/behat_report_customsql.php index 70a09cc..e02c23e 100644 --- a/tests/behat/behat_report_customsql.php +++ b/tests/behat/behat_report_customsql.php @@ -238,6 +238,28 @@ public function adhoc_database_queries_thinks_the_time_is($time) { set_config('behat_fixed_time', $value, 'report_customsql'); } + /** + * Simulates downloading an empty report to ensure it shows table headers. + * + * For example: + * When downloading the empty custom sql report "Frog" it contains the headers "frogname,freddy" + * + * @Then /^downloading custom sql report "(?P[^"]*)" returns a file with headers "([^"]*)"$/ + * @param string $reportname the name of the report to go to. + * @param string $headers the headers that shuold be returned. + */ + public function downloading_custom_sql_report_x_returns_a_file_with_headers(string $reportname, string $headers) { + $report = $this->get_report_by_name($reportname); + $url = new \moodle_url('/pluginfile.php/1/'.'report_customsql'. '/'.'download'. '/'. $report->id, ['dataformat' => 'csv']); + + $session = $this->getSession()->getCookie('MoodleSession'); + $filecontent = trim(download_file_content($url, array('Cookie' => 'MoodleSession=' . $session))); + $filecontent = core_text::trim_utf8_bom($filecontent); + if ($filecontent != $headers) { + throw new \Behat\Mink\Exception\ExpectationException("File headers: $filecontent did not match expected: $headers", $this->getSession()); + } + } + /** * Find a report by name and get all the details. * diff --git a/tests/behat/report_customsql.feature b/tests/behat/report_customsql.feature index a831bd1..6c3e893 100644 --- a/tests/behat/report_customsql.feature +++ b/tests/behat/report_customsql.feature @@ -76,6 +76,13 @@ Feature: Ad-hoc database queries report And I view the "Test query" custom sql report Then I should see "This query did not return any data." + Scenario: Download an Ad-hoc database query that returns no data but includes headers + Given the following custom sql report exists: + | name | Test query | + | querysql | SELECT * FROM {config} WHERE name = '-1' | + When I log in as "admin" + Then downloading custom sql report "Test query" returns a file with headers "id,name,value" + Scenario: Create an Ad-hoc database queries category When I log in as "admin" And I navigate to "Reports > Ad-hoc database queries" in site administration diff --git a/tests/privacy_test.php b/tests/privacy_test.php index 4f2cb6e..9e50e35 100644 --- a/tests/privacy_test.php +++ b/tests/privacy_test.php @@ -81,7 +81,7 @@ public function test_export_user_data(): void { $subcontext = [ get_string('privacy:metadata:reportcustomsqlqueries', 'report_customsql') ]; - $data = $writer->get_data($subcontext); + $data = (array)$writer->get_data($subcontext); $this->assertEquals('Report of user 1', reset($data)['displayname']); }