diff --git a/.github/workflows/moodle-plugin-ci.yml b/.github/workflows/moodle-plugin-ci.yml deleted file mode 100644 index 77276fadd..000000000 --- a/.github/workflows/moodle-plugin-ci.yml +++ /dev/null @@ -1,161 +0,0 @@ -name: Moodle Plugin CI - -on: [push, pull_request] - -jobs: - moodle500to501: - uses: Wunderbyte-GmbH/catalyst-moodle-workflows/.github/workflows/ci.yml@main - with: - # Change these based on your plugin's requirements - disable_release: true # Use true if using the tag-based release workflow - moodle_branches: "MOODLE_500_STABLE MOODLE_501_STABLE" # Optional: Only test specific Moodle versions - min_php: '8.2' # Optional: Set minimum PHP version - - # Command to install more dependencies - extra_plugin_runners: | - moodle-plugin-ci add-plugin --branch MOODLE_405_DEV Wunderbyte-GmbH/moodle-local_wunderbyte_table - moodle-plugin-ci add-plugin --branch MOODLE_405_DEV Wunderbyte-GmbH/moodle-local_shopping_cart - moodle-plugin-ci add-plugin --branch main Wunderbyte-GmbH/moodle-local_entities - moodle-plugin-ci add-plugin --branch main Wunderbyte-GmbH/moodle-customfield_dynamicformat - moodle-plugin-ci add-plugin --branch main Wunderbyte-GmbH/moodle-tool_mocktesttime - moodle-plugin-ci add-plugin --branch master branchup/moodle-filter_shortcodes - moodle-plugin-ci add-plugin --branch MOODLE_500_STABLE moodleworkplace/moodle-tool_certificate - - # If you need to ignore specific paths (third-party libraries are ignored by default) - ignore_paths: 'vue3,moodle/tests/fixtures,moodle/Sniffs,moodle/vue3' - - # Specify paths to ignore for mustache lint - mustache_ignore_names: 'bookingoption_dates_custom_list_items.mustache,form_booking_options_selector_suggestion.mustache,table_list_container.mustache,optiondatesteacherstable_list.mustache,optiondatesteacherstable_list_row.mustache,optiondatesteacherstable_list_container.mustache,option_collapsible_close.mustache,option_collapsible_open.mustache,static.mustache,advcheckbox.mustache,select.mustache,shorttext.mustache,mobile_details_button.mustache,mobile_mybookings_list.mustache,mobile_booking_option_details.mustache,mobile_view_page.mustache,table_cards_container.mustache' - - # Specify paths to ignore for code checker - # codechecker_ignore_paths: 'OpenTBS, TinyButStrong' - - # Specify paths to ignore for PHPDoc checker - # phpdocchecker_ignore_paths: 'OpenTBS, TinyButStrong' - - # If you need to disable specific tests - # disable_phpcpd: true - # disable_mustache: true - # disable_phpunit: true - # disable_grunt: true - # disable_phpdoc: true - # disable_phpcs: true - # disable_phplint: true - # disable_ci_validate: true - - # If you need to enable PHPMD - enable_phpmd: true - - # For strict code quality checks - codechecker_max_warnings: 0 - - # Override to exclude stale AMD file check (similar to your current workaround) - workarounds: | - # Set additional environment variables - # echo "SOME_PATHS=OpenTBS, TinyButStrong" >> $GITHUB_ENV - - # WORKAROUND 17/04/2025: The following code is a workaround for the "File is stale and needs to be rebuilt" error - # This occurs when AMD modules import Moodle core dependencies - # See issue: https://github.com/moodlehq/moodle-plugin-ci/issues/350 - # This workaround should be removed once the issue is fixed upstream - # Load NVM and use the version from .nvmrc - export NVM_DIR="$HOME/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - - # Go to moodle directory - cd moodle - - # Use NVM to set Node version and ensure grunt-cli is installed - nvm use - npm install - npm install -g grunt-cli - - # Go back to plugin directory - cd ../plugin - - # Pre-build AMD files to avoid stale file warnings - echo "=== Building AMD files before CI check ===" - grunt --gruntfile ../moodle/Gruntfile.js amd - echo "AMD files built successfully" - - # Go Back to main directory - cd .. - # END OF WORKAROUND - moodle405: - uses: Wunderbyte-GmbH/catalyst-moodle-workflows/.github/workflows/ci.yml@main - with: - # Change these based on your plugin's requirements - disable_release: true # Use true if using the tag-based release workflow - moodle_branches: "MOODLE_405_STABLE" # Optional: Only test specific Moodle versions - min_php: '8.1' # Optional: Set minimum PHP version - - # Command to install more dependencies - extra_plugin_runners: | - moodle-plugin-ci add-plugin --branch MOODLE_405_DEV Wunderbyte-GmbH/moodle-local_wunderbyte_table - moodle-plugin-ci add-plugin --branch MOODLE_405_DEV Wunderbyte-GmbH/moodle-local_shopping_cart - moodle-plugin-ci add-plugin --branch main Wunderbyte-GmbH/moodle-local_entities - moodle-plugin-ci add-plugin --branch main Wunderbyte-GmbH/moodle-customfield_dynamicformat - moodle-plugin-ci add-plugin --branch main Wunderbyte-GmbH/moodle-tool_mocktesttime - moodle-plugin-ci add-plugin --branch master branchup/moodle-filter_shortcodes - moodle-plugin-ci add-plugin --branch MOODLE_400_STABLE moodleworkplace/moodle-tool_certificate - - # If you need to ignore specific paths (third-party libraries are ignored by default) - ignore_paths: 'vue3,moodle/tests/fixtures,moodle/Sniffs,moodle/vue3' - - # Specify paths to ignore for mustache lint - mustache_ignore_names: 'bookingoption_dates_custom_list_items.mustache,form_booking_options_selector_suggestion.mustache,table_list_container.mustache,optiondatesteacherstable_list.mustache,optiondatesteacherstable_list_row.mustache,optiondatesteacherstable_list_container.mustache,option_collapsible_close.mustache,option_collapsible_open.mustache,static.mustache,advcheckbox.mustache,select.mustache,shorttext.mustache,mobile_details_button.mustache,mobile_mybookings_list.mustache,mobile_booking_option_details.mustache,mobile_view_page.mustache,table_cards_container.mustache' - - # Specify paths to ignore for code checker - # codechecker_ignore_paths: 'OpenTBS, TinyButStrong' - - # Specify paths to ignore for PHPDoc checker - # phpdocchecker_ignore_paths: 'OpenTBS, TinyButStrong' - - # If you need to disable specific tests - # disable_phpcpd: true - # disable_mustache: true - # disable_phpunit: true - # disable_grunt: true - # disable_phpdoc: true - # disable_phpcs: true - # disable_phplint: true - # disable_ci_validate: true - - # If you need to enable PHPMD - enable_phpmd: true - - # For strict code quality checks - codechecker_max_warnings: 0 - - # Override to exclude stale AMD file check (similar to your current workaround) - workarounds: | - # Set additional environment variables - # echo "SOME_PATHS=OpenTBS, TinyButStrong" >> $GITHUB_ENV - - # WORKAROUND 17/04/2025: The following code is a workaround for the "File is stale and needs to be rebuilt" error - # This occurs when AMD modules import Moodle core dependencies - # See issue: https://github.com/moodlehq/moodle-plugin-ci/issues/350 - # This workaround should be removed once the issue is fixed upstream - # Load NVM and use the version from .nvmrc - export NVM_DIR="$HOME/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - - # Go to moodle directory - cd moodle - - # Use NVM to set Node version and ensure grunt-cli is installed - nvm use - npm install - npm install -g grunt-cli - - # Go back to plugin directory - cd ../plugin - - # Pre-build AMD files to avoid stale file warnings - echo "=== Building AMD files before CI check ===" - grunt --gruntfile ../moodle/Gruntfile.js amd - echo "AMD files built successfully" - - # Go Back to main directory - cd .. - # END OF WORKAROUND diff --git a/MSSQL_JSON_SUPPORT.md b/MSSQL_JSON_SUPPORT.md new file mode 100644 index 000000000..f7f88bdb5 --- /dev/null +++ b/MSSQL_JSON_SUPPORT.md @@ -0,0 +1,68 @@ +MSSQL JSON support examples for mod_booking + +This document contains example SQL snippets for SQL Server (MSSQL / sqlsrv) that correspond to changes added in the codebase. + +1) Extract scalar from JSON object (JSON_VALUE) + +Example: extract `price` from a JSON object stored in column `json`: + +SELECT JSON_VALUE(json, '$.price') AS price + +Cast to numeric: +SELECT CAST(JSON_VALUE(json, '$.price') AS FLOAT) AS price + +2) Extract key from JSON array element at index (JSON_VALUE) + +Example: extract `key` from first element of JSON array in column `json`: + +SELECT JSON_VALUE(json, '$[0].key') AS first_key + +3) Expand payments array and extract fields (OPENJSON + JSON_VALUE) + +Assume `sch.json` contains {"installments": {"payments": [ {...}, {...} ] }} + +SELECT + bo.id AS optionid, + sch.userid, + CAST(JSON_VALUE(pay.[value], '$.timestamp') AS INT) AS datefield, + CAST(JSON_VALUE(pay.[value], '$.paid') AS FLOAT) AS paid, + CAST(JSON_VALUE(pay.[value], '$.price') AS FLOAT) AS price, + CAST(JSON_VALUE(pay.[value], '$.id') AS INT) AS payment_id +FROM local_shopping_cart_history sch +CROSS APPLY OPENJSON(sch.json, '$.installments.payments') AS pay +WHERE CAST(JSON_VALUE(pay.[value], '$.paid') AS FLOAT) = 0 + +4) Check membership in JSON array (OPENJSON) + +Assume `json` contains { "sharedplaceswithoptions": [ 1, 2, 3 ] } + +SELECT id FROM booking_options +WHERE EXISTS ( + SELECT 1 FROM OPENJSON(json, '$.sharedplaceswithoptions') AS sp + WHERE sp.[value] = '42' -- checks membership of option id 42 +) + +5) Aggregate JSON objects per user+option (FOR JSON PATH) + +Create an aggregated JSON array of certificate objects per user + optionid: + +SELECT t.userid, + CAST(JSON_VALUE(t.data, '$.bookingoptionid') AS INT) AS optionid, + ( + SELECT t2.id, t2.code, t2.expires, t2.data, t2.timecreated + FROM tool_certificate_issues t2 + WHERE t2.userid = t.userid + AND CAST(JSON_VALUE(t2.data, '$.bookingoptionid') AS INT) = CAST(JSON_VALUE(t.data, '$.bookingoptionid') AS INT) + FOR JSON PATH + ) AS certificate +FROM tool_certificate_issues t +GROUP BY t.userid, CAST(JSON_VALUE(t.data, '$.bookingoptionid') AS INT) + +Notes +- SQL Server functions used: JSON_VALUE, JSON_QUERY, OPENJSON, FOR JSON PATH. +- OPENJSON returns rows with columns `key`, `value`, `type`. For arrays of scalars, `value` contains the scalar value. +- JSON_VALUE returns NVARCHAR; cast when numeric types are required. + +Testing tips +- Use `SELECT JSON_QUERY('[{"id":1,"price":10}]', '$[0]')` to inspect JSON fragments. +- Validate that the stored JSON is valid nvarchar (not stored as binary) and accessible by JSON functions. diff --git a/classes/bo_availability/bo_info.php b/classes/bo_availability/bo_info.php index 63c935689..794a288a1 100644 --- a/classes/bo_availability/bo_info.php +++ b/classes/bo_availability/bo_info.php @@ -1383,6 +1383,11 @@ public static function check_for_sqljson_key_in_array(string $dbcolumn, string $ case 'mysql': // MySQL: Extract key from JSON array element at specified index. return "JSON_UNQUOTE(JSON_EXTRACT($dbcolumn, '$[$index]." . addslashes($jsonkey) . "'))"; + case 'mssql': + case 'sqlsrv': + // MSSQL / SQL Server: Use JSON_VALUE with array index path. + // Example: JSON_VALUE(column, '$[0].key') + return "JSON_VALUE($dbcolumn, '$[$index]." . addslashes($jsonkey) . "')"; default: throw new \moodle_exception('Unsupported database type for JSON key extraction.'); } @@ -1417,7 +1422,19 @@ public static function check_for_sqljson_key_in_object(string $dbcolumn, string } else { return "CAST(JSON_EXTRACT($dbcolumn, '$." . addslashes($jsonkey) . "') AS CHAR)"; } - + case 'mssql': + case 'sqlsrv': + // MSSQL / SQL Server: Use JSON_VALUE for scalar extraction. + // Map common types to SQL Server types. + $lt = strtolower($type); + if (in_array($lt, ['int', 'integer', 'bigint'], true)) { + return "CAST(JSON_VALUE($dbcolumn, '$." . addslashes($jsonkey) . "') AS INT)"; + } elseif (in_array($lt, ['float', 'double', 'decimal'], true)) { + return "CAST(JSON_VALUE($dbcolumn, '$." . addslashes($jsonkey) . "') AS FLOAT)"; + } else { + // Default: return text value. JSON_VALUE already returns NVARCHAR. + return "JSON_VALUE($dbcolumn, '$." . addslashes($jsonkey) . "')"; + } default: throw new moodle_exception('Unsupported database type for JSON key extraction.'); } diff --git a/classes/bo_availability/conditions/enrolledincohorts.php b/classes/bo_availability/conditions/enrolledincohorts.php index 4dd09ab94..36dcebaba 100644 --- a/classes/bo_availability/conditions/enrolledincohorts.php +++ b/classes/bo_availability/conditions/enrolledincohorts.php @@ -327,6 +327,49 @@ public function return_sql(int $userid = 0, &$params = []): array { ) )"; return ['', '', '', $params, $where]; + } else if ($databasetype == 'mssql' || $databasetype == 'sqlsrv') { + // SQL Server: Use OPENJSON and JSON_VALUE to evaluate array elements. + $cohorts = []; + foreach ($usercohorts as $cohort) { + $cohorts[] = "'$cohort->id'"; + } + $appendwhere2 = implode(', ', $cohorts); + + $where = " + availability IS NOT NULL + AND (( + (NOT EXISTS ( + SELECT 1 + FROM OPENJSON(availability) WITH (sqlfilter nvarchar(10) '$.sqlfilter') jt + WHERE jt.sqlfilter = '1' + )) + ) + OR ( + id IN ( + SELECT id + FROM ( + SELECT id, + JSON_VALUE(availability, '$[0].cohortidsoperator') AS operator, + ( + SELECT COUNT(1) + FROM OPENJSON(availability) AS obj + CROSS APPLY OPENJSON(obj.[value], '$.cohortids') AS cid + ) AS length, + ( + SELECT SUM(CASE WHEN cid2.[value] IN ($appendwhere2) THEN 1 ELSE 0 END) + FROM OPENJSON(availability) AS obj2 + CROSS APPLY OPENJSON(obj2.[value], '$.cohortids') AS cid2 + ) AS true_conditions_count + FROM {booking_options} + WHERE availability IS NOT NULL + ) s1 + WHERE ( + CASE WHEN operator LIKE 'AND' THEN length = true_conditions_count ELSE true_conditions_count > 0 END + ) + ) + ) + )"; + return ['', '', '', $params, $where]; } else { return ['', '', '', $params, '']; } diff --git a/classes/booking.php b/classes/booking.php index 82b99e1be..200f84b3a 100755 --- a/classes/booking.php +++ b/classes/booking.php @@ -1715,6 +1715,31 @@ public static function get_sql_for_fieldofstudy(string $dbname, array $courses) ) bos1 WHERE bos1.boavailid = '" . MOD_BOOKING_BO_COND_JSON_ENROLLEDINCOURSE . "'" . $where . " ) bo"; + case 'sqlsrv_native_moodle_database': + case 'mssql_native_moodle_database': + // SQL Server: use OPENJSON to expand availability array and extract id/courseids. + $where = ''; + $wherearray = []; + foreach ($courses as $courseid) { + $wherearray[] = " EXISTS ( + SELECT 1 FROM OPENJSON(bos1.boscourseids) AS cid WHERE cid.[value] = '" . $courseid . "') "; + } + if (count($courses) > 0) { + $where = " AND ( ( " . implode(" ) OR ( ", $wherearray) . " ) ) "; + } + + return " + FROM ( + SELECT bos1.* + FROM ( + SELECT bo.*, elem.[value] AS elemvalue, + JSON_VALUE(elem.[value], '$.id') AS boavailid, + JSON_QUERY(elem.[value], '$.courseids') AS boscourseids + FROM {booking_options} bo + CROSS APPLY OPENJSON(bo.availability) AS elem + ) bos1 + WHERE bos1.boavailid = '" . MOD_BOOKING_BO_COND_JSON_ENROLLEDINCOURSE . "'" + . $where . " ) bo"; } } diff --git a/classes/booking_rules/conditions/select_user_shopping_cart.php b/classes/booking_rules/conditions/select_user_shopping_cart.php index 4f9aafde7..46f72b04c 100644 --- a/classes/booking_rules/conditions/select_user_shopping_cart.php +++ b/classes/booking_rules/conditions/select_user_shopping_cart.php @@ -301,6 +301,49 @@ public function execute( >= ( :nowparam + (86400 * :numberofdays ))"; } + break; + case 'mssql': + case 'sqlsrv': + // SQL Server: expand JSON payments array using OPENJSON and extract fields via JSON_VALUE. + // Use $DB->sql_concat to build unique id consistently. + $concatparts = []; + $concatparts[] = "bo.id"; + if (strpos($sql->select, 'optiondateid') !== false) { + $concatparts[] = "bod.id"; + } + $concatparts[] = "JSON_VALUE(payments_info.[value], '$.id')"; + $concatparts[] = "JSON_VALUE(payments_info.[value], '$.timestamp')"; + $concat = $DB->sql_concat(...array_map(function($p){ return $p; }, $concatparts)); + + $sql->select = "$concat AS uniquid, + bo.id optionid, + cm.id cmid, + sch.userid, + CAST(JSON_VALUE(payments_info.[value], '$.timestamp') AS INT) AS datefield, + CAST(JSON_VALUE(payments_info.[value], '$.paid') AS FLOAT) AS paid, + CAST(JSON_VALUE(payments_info.[value], '$.price') AS FLOAT) AS price, + CAST(JSON_VALUE(payments_info.[value], '$.id') AS INT) AS payment_id + "; + + $sql->from .= " RIGHT JOIN {local_shopping_cart_history} sch + ON sch.itemid = bo.id AND sch.componentname = :componentname AND sch.area = :area + CROSS APPLY OPENJSON(sch.json, '$.installments.payments') AS payments_info"; + + $sql->where = "CAST(JSON_VALUE(payments_info.[value], '$.paid') AS FLOAT) = 0 + AND sch.installments > 0 + AND sch.paymentstatus = :paymentstatus + AND sch.json IS NOT NULL + AND sch.json <> ''"; + + if ($testmode) { + $sql->where .= " AND sch.userid = :userid "; + $nextruntime = $nextruntime + $params['numberofdays'] * 86400; + $sql->where .= " AND CAST(JSON_VALUE(payments_info.[value], '$.timestamp') AS INT) = :nextruntime "; + $params['nextruntime'] = $nextruntime; + } else { + $sql->where .= " AND CAST(JSON_VALUE(payments_info.[value], '$.timestamp') AS INT) + >= ( :nowparam + (86400 * :numberofdays ))"; + } break; } } diff --git a/classes/option/fields/sharedplaces.php b/classes/option/fields/sharedplaces.php index e98753494..dd26aa494 100644 --- a/classes/option/fields/sharedplaces.php +++ b/classes/option/fields/sharedplaces.php @@ -377,6 +377,15 @@ public static function get_sharedplaces_options(int $optionid, bool $onlypriorit $additionalwhere "; break; + case 'mssql': + case 'sqlsrv': + $additionalwhere = $onlypriority ? " AND JSON_VALUE(json, '$.sharedplacespriority') = '1' " : ''; + // OPENJSON on the array returns rows with column [value] containing the element. + $where = "EXISTS ( + SELECT 1 FROM OPENJSON(json, '$.sharedplaceswithoptions') AS sp + WHERE sp.[value] = '$optionid' + ) $additionalwhere"; + break; default: throw new moodle_exception('Unsupported database type for JSON key extraction.'); } diff --git a/classes/shortcodes.php b/classes/shortcodes.php index 6bf404d5b..71ced1f72 100644 --- a/classes/shortcodes.php +++ b/classes/shortcodes.php @@ -1013,6 +1013,8 @@ public static function fieldofstudycohortoptions($shortcode, $args, $content, $e $supporteddbs = [ 'pgsql_native_moodle_database', 'mariadb_native_moodle_database', + 'sqlsrv_native_moodle_database', + 'mssql_native_moodle_database', ]; if (!in_array(get_class($DB), $supporteddbs)) { diff --git a/report.php b/report.php index 592069212..0587411d4 100755 --- a/report.php +++ b/report.php @@ -837,6 +837,26 @@ ) cert ON cert.optionid = ba.optionid AND cert.userid = ba.userid "; break; + case 'mssql': + case 'sqlsrv': + // SQL Server: aggregate per user and optionid using FOR JSON PATH in a correlated subquery. + $certificatefrom = " + LEFT JOIN ( + SELECT + t.userid, + CAST(JSON_VALUE(t.data, '$.bookingoptionid') AS INT) AS optionid, + ( + SELECT t2.id, t2.code, t2.expires, t2.data, t2.timecreated + FROM {tool_certificate_issues} t2 + WHERE t2.userid = t.userid + AND CAST(JSON_VALUE(t2.data, '$.bookingoptionid') AS INT) = CAST(JSON_VALUE(t.data, '$.bookingoptionid') AS INT) + FOR JSON PATH + ) AS certificate + FROM {tool_certificate_issues} t + GROUP BY t.userid, CAST(JSON_VALUE(t.data, '$.bookingoptionid') AS INT) + ) cert ON cert.optionid = ba.optionid AND cert.userid = ba.userid + "; + break; default: throw new \moodle_exception('Unsupported database type for JSON key extraction.'); } diff --git a/version.php b/version.php index c110cc3fb..cccdc97ee 100755 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026031300; +$plugin->version = 2026031301; $plugin->requires = 2024100700; // Requires this Moodle version. Current: Moodle 4.5. $plugin->release = '9.1.5'; $plugin->maturity = MATURITY_STABLE;