├── PGUnit.sql ├── PGUnitDrop.sql └── README.md /PGUnit.sql: -------------------------------------------------------------------------------- 1 |  2 | create type test_results as ( 3 | test_name varchar, 4 | successful boolean, 5 | failed boolean, 6 | erroneous boolean, 7 | error_message varchar, 8 | duration interval); 9 | 10 | -- 11 | -- Use select * from test_run_all() to execute all test cases 12 | -- 13 | create or replace function test_run_all() returns setof test_results as $$ 14 | begin 15 | return query select * from test_run_suite(NULL); 16 | end; 17 | $$ language plpgsql set search_path from current; 18 | 19 | -- 20 | -- Executes all test cases part of a suite and returns the test results. 21 | -- 22 | -- Each test case will have a setup procedure run first, then a precondition, 23 | -- then the test itself, followed by a postcondition and a tear down. 24 | -- 25 | -- The test case stored procedure name has to match 'test_case_%' patern. 26 | -- It is assumed the setup and precondition procedures are in the same schema as 27 | -- the test stored procedure. 28 | -- 29 | -- select * from test_run_suite('my_test'); will run all tests that will have 30 | -- 'test_case_my_test' prefix. 31 | create or replace function test_run_suite(p_suite TEXT) returns setof test_results as $$ 32 | declare 33 | l_proc RECORD; 34 | l_sid INTEGER; 35 | l_row test_results%rowtype; 36 | l_start_ts timestamp; 37 | l_cmd text; 38 | l_condition text; 39 | l_precondition_cmd text; 40 | l_postcondition_cmd text; 41 | begin 42 | l_sid := pg_backend_pid(); 43 | for l_proc in select p.proname, n.nspname 44 | from pg_catalog.pg_proc p join pg_catalog.pg_namespace n 45 | on p.pronamespace = n.oid 46 | where p.proname like 'test/_case/_' || COALESCE(p_suite, '') || '%' escape '/' 47 | order by p.proname loop 48 | -- check for setup 49 | l_condition := test_get_procname(l_proc.proname, 2, 'test_setup'); 50 | if l_condition is not null then 51 | l_cmd := 'DO $body$ begin perform ' || quote_ident(l_proc.nspname) || '.' || quote_ident(l_condition) 52 | || '(); end; $body$'; 53 | perform test_autonomous(l_cmd); 54 | end if; 55 | l_row.test_name := quote_ident(l_proc.proname); 56 | -- check for precondition 57 | l_condition := test_get_procname(l_proc.proname, 2, 'test_precondition'); 58 | if l_condition is not null then 59 | l_precondition_cmd := 'perform test_run_condition(''' || quote_ident(l_proc.nspname) || '.' || quote_ident(l_condition) 60 | || '''); '; 61 | else 62 | l_precondition_cmd := ''; 63 | end if; 64 | -- check for postcondition 65 | l_condition := test_get_procname(l_proc.proname, 2, 'test_postcondition'); 66 | if l_condition is not null then 67 | l_postcondition_cmd := 'perform test_run_condition(''' || quote_ident(l_proc.nspname) || '.' || quote_ident(l_condition) 68 | || '''); '; 69 | else 70 | l_postcondition_cmd := ''; 71 | end if; 72 | -- execute the test 73 | l_start_ts := clock_timestamp(); 74 | begin 75 | l_cmd := 'DO $body$ begin ' || l_precondition_cmd || 'perform ' || quote_ident(l_proc.nspname) || '.' || quote_ident(l_proc.proname) 76 | || '(); ' || l_postcondition_cmd || ' end; $body$'; 77 | perform test_autonomous(l_cmd); 78 | l_row.successful := true; 79 | l_row.failed := false; 80 | l_row.erroneous := false; 81 | l_row.error_message := 'OK'; 82 | exception 83 | when triggered_action_exception then 84 | l_row.successful := false; 85 | l_row.failed := true; 86 | l_row.erroneous := false; 87 | l_row.error_message := SQLERRM; 88 | when others then 89 | l_row.successful := false; 90 | l_row.failed := false; 91 | l_row.erroneous := true; 92 | l_row.error_message := SQLERRM; 93 | end; 94 | l_row.duration = clock_timestamp() - l_start_ts; 95 | return next l_row; 96 | -- check for teardown 97 | l_condition := test_get_procname(l_proc.proname, 2, 'test_teardown'); 98 | if l_condition is not null then 99 | l_cmd := 'DO $body$ begin perform ' || quote_ident(l_proc.nspname) || '.' || quote_ident(l_condition) 100 | || '(); end; $body$'; 101 | perform test_autonomous(l_cmd); 102 | end if; 103 | end loop; 104 | end; 105 | $$ language plpgsql set search_path from current; 106 | 107 | -- 108 | -- recreates a _ separated string from parts array 109 | -- 110 | create or replace function test_build_procname(parts text[], p_from integer default 1, p_to integer default null) returns text as $$ 111 | declare 112 | name TEXT := ''; 113 | idx integer; 114 | begin 115 | if p_to is null then 116 | p_to := array_length(parts, 1); 117 | end if; 118 | name := parts[p_from]; 119 | for idx in (p_from + 1) .. p_to loop 120 | name := name || '_' || parts[idx]; 121 | end loop; 122 | 123 | return name; 124 | end; 125 | $$ language plpgsql set search_path from current immutable; 126 | 127 | -- 128 | -- Returns the procedure name matching the pattern below 129 | -- _ 130 | -- Ex: result_prefix = test_setup and test_case_name = company_finance_invoice then it searches for: 131 | -- test_setup_company_finance_invoice() 132 | -- test_setup_company_finance() 133 | -- test_setup_company() 134 | -- 135 | -- It returns the name of the first stored procedure present in the database 136 | -- 137 | create or replace function test_get_procname(test_case_name text, expected_name_count integer, result_prefix text) returns text as $$ 138 | declare 139 | array_name text[]; 140 | array_proc text[]; 141 | idx integer; 142 | len integer; 143 | proc_name text; 144 | is_valid integer; 145 | begin 146 | array_name := string_to_array(test_case_name, '_'); 147 | len := array_length(array_name, 1); 148 | for idx in expected_name_count + 1 .. len loop 149 | array_proc := array_proc || array_name[idx]; 150 | end loop; 151 | 152 | len := array_length(array_proc, 1); 153 | for idx in reverse len .. 1 loop 154 | proc_name := result_prefix || '_' 155 | || test_build_procname(array_proc, 1, idx); 156 | select 1 into is_valid from pg_catalog.pg_proc where proname = proc_name; 157 | if is_valid = 1 then 158 | return proc_name; 159 | end if; 160 | end loop; 161 | 162 | return null; 163 | end; 164 | $$ language plpgsql set search_path from current; 165 | 166 | -- 167 | -- executes a condition boolean function 168 | -- 169 | create or replace function test_run_condition(proc_name text) returns void as $$ 170 | declare 171 | status boolean; 172 | begin 173 | execute 'select ' || proc_name || '()' into status; 174 | if status then 175 | return; 176 | end if; 177 | raise exception 'condition failure: %()', proc_name using errcode = 'triggered_action_exception'; 178 | end; 179 | $$ language plpgsql set search_path from current; 180 | 181 | -- 182 | -- Use: select test_terminate('db name'); to terminate all locked processes 183 | -- 184 | create or replace function test_terminate(db VARCHAR) returns setof record as $$ 185 | SELECT pg_terminate_backend(pid), query 186 | FROM pg_stat_activity 187 | WHERE pid != pg_backend_pid() AND datname = db AND state = 'active'; 188 | $$ language sql; 189 | 190 | -- 191 | -- Use: perform test_autonomous('UPDATE|INSERT|DELETE|SELECT sp() ...'); to 192 | -- change data in a separate transaction. 193 | -- 194 | create or replace function test_autonomous(p_statement VARCHAR) returns void as $$ 195 | declare 196 | l_error_text character varying; 197 | l_error_detail character varying; 198 | l_dblink_conn_extra character varying; 199 | begin 200 | begin 201 | select current_setting('pgunit.dblink_conn_extra') into l_dblink_conn_extra; 202 | exception 203 | when undefined_object then 204 | select '' into l_dblink_conn_extra; 205 | end; 206 | perform test_dblink_connect('test_auto', 'dbname=' || current_catalog || ' ' || l_dblink_conn_extra); 207 | begin 208 | perform test_dblink_exec('test_auto', 'BEGIN WORK;'); 209 | perform test_dblink_exec('test_auto', p_statement); 210 | perform test_dblink_exec('test_auto', 'COMMIT;'); 211 | perform test_dblink_disconnect('test_auto'); 212 | exception 213 | when others then 214 | get stacked diagnostics l_error_text = message_text, 215 | l_error_detail = pg_exception_detail; 216 | perform test_dblink_exec('test_auto', 'ROLLBACK;'); 217 | perform test_dblink_disconnect('test_auto'); 218 | raise exception '%: Error on executing: % % %', sqlstate, p_statement, l_error_text, l_error_detail 219 | using errcode = sqlstate; 220 | end; 221 | end; 222 | $$ language plpgsql set search_path from current; 223 | 224 | -- 225 | -- Calls dblink_connect taking into account the autodetected DBLINK schema 226 | -- 227 | create or replace function test_dblink_connect(text, text) returns text as $$ 228 | declare 229 | dblink_schema text := test_detect_dblink_schema(); 230 | dblink_result text; 231 | begin 232 | execute format('select %s.dblink_connect_u($1, $2)', quote_ident(dblink_schema)) 233 | using $1, $2 234 | into dblink_result; 235 | 236 | return dblink_result; 237 | end 238 | $$ language plpgsql set search_path from current security definer; 239 | 240 | -- 241 | -- Calls dblink_disconnect taking into account the autodetected DBLINK schema 242 | -- 243 | create or replace function test_dblink_disconnect(text) returns text as $$ 244 | declare 245 | dblink_schema text := test_detect_dblink_schema(); 246 | dblink_result text; 247 | begin 248 | execute format('select %s.dblink_disconnect($1)', quote_ident(dblink_schema)) 249 | using $1 250 | into dblink_result; 251 | 252 | return dblink_result; 253 | end 254 | $$ language plpgsql set search_path from current; 255 | 256 | -- 257 | -- Calls dblink_exec taking into account the autodetected DBLINK schema 258 | -- 259 | create or replace function test_dblink_exec(text, text) returns text as $$ 260 | declare 261 | dblink_schema text := test_detect_dblink_schema(); 262 | dblink_result text; 263 | begin 264 | execute format('select %s.dblink_exec($1, $2)', quote_ident(dblink_schema)) 265 | using $1, $2 266 | into dblink_result; 267 | 268 | return dblink_result; 269 | end 270 | $$ language plpgsql set search_path from current; 271 | 272 | -- 273 | -- Detects the schema where DBLINK extension is installed 274 | -- Caches the result in the pgunit.dblink_schema setting per session 275 | -- 276 | create or replace function test_detect_dblink_schema() returns text as $$ 277 | declare 278 | schema_name text; 279 | begin 280 | begin 281 | select current_setting('pgunit.dblink_schema') into schema_name; 282 | if schema_name is null or schema_name = '' then 283 | raise exception undefined_object; 284 | end if; 285 | exception 286 | when undefined_object then 287 | select nspname 288 | into schema_name 289 | from pg_extension px 290 | join pg_namespace pn on px.extnamespace = pn.oid 291 | where extname = 'dblink' 292 | limit 1; 293 | perform set_config('pgunit.dblink_schema', schema_name, true); 294 | end; 295 | return schema_name; 296 | end; 297 | $$ language plpgsql set search_path from current; 298 | 299 | create or replace function test_assertTrue(message VARCHAR, condition BOOLEAN) returns void as $$ 300 | begin 301 | if condition then 302 | null; 303 | else 304 | raise exception 'assertTrue failure: %', message using errcode = 'triggered_action_exception'; 305 | end if; 306 | end; 307 | $$ language plpgsql set search_path from current immutable; 308 | 309 | create or replace function test_assertTrue(condition BOOLEAN) returns void as $$ 310 | begin 311 | if condition then 312 | null; 313 | else 314 | raise exception 'assertTrue failure' using errcode = 'triggered_action_exception'; 315 | end if; 316 | end; 317 | $$ language plpgsql set search_path from current immutable; 318 | 319 | create or replace function test_assertNotNull(VARCHAR, ANYELEMENT) returns void as $$ 320 | begin 321 | if $2 IS NULL then 322 | raise exception 'assertNotNull failure: %', $1 using errcode = 'triggered_action_exception'; 323 | end if; 324 | end; 325 | $$ language plpgsql set search_path from current immutable; 326 | 327 | create or replace function test_assertNull(VARCHAR, ANYELEMENT) returns void as $$ 328 | begin 329 | if $2 IS NOT NULL then 330 | raise exception 'assertNull failure: %', $1 using errcode = 'triggered_action_exception'; 331 | end if; 332 | end; 333 | $$ language plpgsql set search_path from current immutable; 334 | 335 | create or replace function test_fail(VARCHAR) returns void as $$ 336 | begin 337 | raise exception 'test failure: %', $1 using errcode = 'triggered_action_exception'; 338 | end; 339 | $$ language plpgsql set search_path from current immutable; 340 | -------------------------------------------------------------------------------- /PGUnitDrop.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- Clears the PG Unit functions 3 | -- 4 | -- 5 | drop function test_run_suite(TEXT); 6 | drop function test_run_all(); 7 | drop function test_run_condition(proc_name text); 8 | drop function test_build_procname(parts text[], p_from integer, p_to integer); 9 | drop function test_get_procname(test_case_name text, expected_name_count integer, result_prefix text); 10 | drop function test_terminate(db VARCHAR); 11 | drop function test_autonomous(p_statement VARCHAR); 12 | drop function test_dblink_connect(text, text); 13 | drop function test_dblink_disconnect(text); 14 | drop function test_dblink_exec(text, text); 15 | drop function test_detect_dblink_schema(); 16 | drop function test_assertTrue(message VARCHAR, condition BOOLEAN); 17 | drop function test_assertTrue(condition BOOLEAN); 18 | drop function test_assertNotNull(VARCHAR, ANYELEMENT); 19 | drop function test_assertNull(VARCHAR, ANYELEMENT); 20 | drop function test_fail(VARCHAR); 21 | drop type test_results cascade; 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PGUnit - unit test framework for Postgresql 2 | 3 | The purpose of this suite of stored procedures is to allow a user to run unit tests as stored procedures. 4 | 5 | The testing is based on a specific naming convention that allows automatic grouping of tests, setup, tear-downs, pre- and post- conditions. 6 | 7 | Each unit test procedure name should have "test_case_" prefix in order to be identified as an unit test. Here is the comprehensive list of prefixes for all types: 8 | - "test_case_": identifies an unit test procedure 9 | - "test_precondition_": identifies a test precondition function 10 | - "test_postcondition_": identifies a test postcondition function 11 | - "test_setup_": identifies a test setup procedure 12 | - "test_teardown_": identifies a test tear down procedure. 13 | 14 | For each test case the following 3 transactions are executed: 15 | 1. setup transaction: the setup procedure is searched based on the test name. If one is found it is executed in an autonomous transaction 16 | 2. unit test transaction: the pre and post condition functions are searched based on the test name; if they are found the autonomous transaction will be: if the precondition is true (default if one is not found) the unit test is ran, then the postcondition function is evaluated (true if one is not found). If any condition returns false the test is failed 17 | 3. tear down transaction: if a tear down procedure is found it is executed in an autonomous transaction indepedent of the unit test result. 18 | 19 | A unit test execution can have 3 results: successful if the condition functions are true and the unit test procedure doesn't throw an exception, failed if there is an action exception triggered by a condition function or an assertion, and finally erroneous if any other exeception is triggered by any of the code above. 20 | 21 | ## Tests logical grouping 22 | 23 | The tests can be grouped based on their name structure. The underscore character is used to separate the test name into a hierarchical structure so that you can share the setup, tear down, or the preconditions across several unit tests. For instance the tests 'test_case_finance_audit_x' and 'test_case_finance_accounting_y' can share the setup procedure called 'test_setup_finance', the teardown procedure 'test_teardown_finance' as well as the precondition 'test_precondition_finance' function as they share a common prefix. 24 | 25 | If, let's say, the audit tests have a specific precondition function, you can define a new 'test_precondition_finance_audit' function and that will be shared accross all unit test procedures with prefix 'test_case_finance_audit_' and override the common 'test_precondition_finance' function. 26 | 27 | Using the built-in grouping mechanism you can re-use supporting code such as the data setup and tear down across unit tests . There is little if any gain if the save data setup is shared across multiple tests and they will have to be present in test-specific setup function while having the exact same content. 28 | 29 | ## Running one or more tests 30 | 31 | To run the entire test suite the 'test_run_all' stored procedure needs to be used: 32 | ```sql 33 | select * from test_run_all(); 34 | ``` 35 | You can pick one or an entire group of tests based on their prefix using the 'test_run_suite' stored procedure: 36 | ```sql 37 | select * from test_run_suite('finance'); 38 | ``` 39 | 40 | The statement above will pick up all unit tests starting with the 'test_case_finance' prefix together with the associated support functions and procedures. 41 | 42 | The select statement returns the type 'test_results', which is: 43 | ```sql 44 | create type test_results as ( 45 | test_name varchar, 46 | successful boolean, 47 | failed boolean, 48 | erroneous boolean, 49 | error_message varchar, 50 | duration interval); 51 | ``` 52 | 53 | ## Setting up PGUnit 54 | The plpgsql code depends on the dblink extension being present in the database you run the tests on, so you need to ensure the statement below has been run before loading the test code: 55 | ```sql 56 | CREATE EXTENSION DBLINK; 57 | ``` 58 | If you want to set up PGUnit in a dedicated schema like 'pgunit', run these two lines of SQL: 59 | ```sql 60 | CREATE SCHEMA pgunit; 61 | CREATE EXTENSION DBLINK SCHEMA pgunit; 62 | ``` 63 | 64 | You should run the `PGUnit.sql` code using either the `psql` command line tool or a tool like PGAdmin 4's query tool and deploy it in the public schema of the selected database or a dedicated schema, such as `pgunit`. The code should be deployed as superuser, but can be used by ordinary users. 65 | 66 | A convenient way to install the PGUnit suite in a dedicated schema is to temporarily change the search path like this: 67 | ```sql 68 | ALTER DATABASE my_db SET search_path TO pgunit; 69 | ``` 70 | run `PGUnit.sql`, and then reset the search_path. There is a good tip for this on Stack Exchange: 71 | https://dba.stackexchange.com/questions/145280/reset-search-path-to-the-global-cluster-default 72 | ```sql 73 | ALTER DATABASE my_db RESET search_path; 74 | ``` 75 | 76 | ## Removal 77 | The `PGUnitDrop.sql` has the code you can use to remove all `PGUnit` code from the database. 78 | 79 | ## Assertion procedures 80 | | Procedure | Description | 81 | | --- | --- | 82 | |`test_assertTrue(message VARCHAR, condition BOOLEAN) returns void`|If condition is false it throws an exception with the given message| 83 | | `test_assertTrue(condition BOOLEAN) returns void` |Similar to `assertTrue` above but with no user message| 84 | |`test_assertNotNull(message VARCHAR, data ANYELEMENT) returns void`|If the data is null an exception is thrown with the message provided| 85 | |`test_assertNull(message VARCHAR, data ANYELEMENT) returns void`|If the data is not null an exception is thrown with the message provided| 86 | |`test_fail(message VARCHAR) returns void`|If reached, the test fails with the message provided| 87 | 88 | ## Examples 89 | 90 | Test case that checks if an application user is created by a stored procedure 91 | - the user id is returned if user id 1 is a parent 92 | - the id is larger than a thresold 93 | ```sql 94 | create or replace function test_case_user_create_1() returns void as $$ 95 | declare 96 | id BIGINT; 97 | begin 98 | SELECT customer.createUser(1, 100) INTO id; 99 | perform test_assertNotNull('user not created', id); 100 | perform test_assertTrue('user id range improper', id >= 10000); 101 | end; 102 | $$ language plpgsql; 103 | ``` 104 | A precondition function for this test may be one checking for user id 1 being present in the database 105 | ```sql 106 | create or replace function test_precondition_user() returns boolean as $$ 107 | declare 108 | id BIGINT; 109 | begin 110 | SELECT user_id INTO id FROM customer.user WHERE user_id=1; 111 | RETURN id IS NOT NULL AND (id = 1); 112 | end; 113 | $$ language plpgsql; 114 | ``` 115 | The precondition above will be shared on all 'user' tests unless one with a more specific name is created. 116 | 117 | ## Troubleshooting 118 | 119 | ### Lock issues 120 | Although the unit tests are run in autonomous transactions it is possible to run into lock issues and have the select statements above hanging. In this case have a new connection on the same database and issue the statement below to stop all locking sessions: 121 | select * from test_terminate('my_db_name'); 122 | 123 | In order to find out which test is at issue you should run the suite one test at the time. The procedure above is not specific to PGUnit and can be used in general as well; it will terminate all locking sessions. 124 | 125 | ### Install the code in public schema and switching to a different schema 126 | 127 | You can add the 'public' schema to the search path using the statement below: 128 | ```sql 129 | SELECT set_config( 130 | 'search_path', 131 | current_setting('search_path') || ',public', 132 | false 133 | ) WHERE current_setting('search_path') !~ '(^|,)public(,|$)'; 134 | ``` 135 | 136 | ### Installing the code in dedicated schema 137 | 138 | The framework can be installed in a dedicated schema, even if it is not present in the search_path, for example `pgunit`. In that case all calls to pgunit functions should be qualified with its installation schema name: 139 | ```sql 140 | create or replace function test_case_user_create_1() returns void as $$ 141 | declare 142 | id BIGINT; 143 | begin 144 | SELECT customer.createUser(1, 100) INTO id; 145 | perform pgunit.test_assertNotNull('user not created', id); 146 | perform pgunit.test_assertTrue('user id range improper', id >= 10000); 147 | end; 148 | $$ language plpgsql; 149 | ``` 150 | ## Dealing with 'could not establish connection' errors 151 | 152 | On a local server running in Windows 10, the only way I could find of removing these errors was to pass the database 153 | owner's name as user to the connection string used to establish a connection through db_link. The commit entitled 'Added pgunit.dblink_conn_extra setting for extra connection settings' checks a current_setting called 'pgunit.dblink_conn_extra'. If it exists, it adds the string in that setting to the connection. So, just before running test_run_all(), you need to set a configuration setting. In my environment, I only needed to specify the owner of the database. The password is supplied from the pgpass.conf file. I passed `false` as the 3rd parameter to add the setting to the current session. If you pass `true`, you get 'could not establish connection' errors again. 154 | 155 | ```sql 156 | select set_config('pgunit.dblink_conn_extra', 'user=myuser", false) 157 | ``` 158 | --- 159 | We can generalise from this by checking the PostgreSQL documentation on connection strings: https://www.postgresql.org/docs/16/libpq-connect.html#LIBPQ-CONNSTRING 160 | 161 | I got the 'could not establish connection' errors on a shared server running Ubuntu. I don't have super user privileges; I only have full privileges on my local instance of PostgreSQL running on a non-standard port. So, I need to specify 162 | the port in the connection string. I also couldn't find a way to get the password supplied from the pgpass.conf file so I had to add to the 'pgunit.dblink_conn_extra' setting like this: 163 | 164 | select set_config('pgunit.dblink_conn_extra', 'host=localhost port=non_standard_number password=my_password', false); 165 | 166 | # Copyright and License 167 | 168 | Copyright (c) 2016 Adrian Andrei. Some rights reserved. 169 | 170 | Permission to use, copy, modify, and distribute this software and its documentation for any purpose, without fee, and without a written agreement is hereby granted, provided that the above copyright notice and this paragraph and the following two paragraphs appear in all copies. 171 | 172 | IN NO EVENT SHALL ADRIAN ANDREI BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF ADRIAN ANDREI HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 173 | 174 | ADRIAN ANDREI SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND ADRIAN ANDREI HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. 175 | --------------------------------------------------------------------------------