diff --git a/.github/workflows/common_tests.yml b/.github/workflows/common_tests.yml new file mode 100644 index 00000000..b6729df8 --- /dev/null +++ b/.github/workflows/common_tests.yml @@ -0,0 +1,177 @@ +name: Common DB Tests + +on: [push, pull_request] + +env: + DB_NAME: db + DB_USER: db_user + DB_PASS: pass + DB_HOST: 127.0.0.1 + # port is set in the job + + # options for pyodbc only + DB_CHARSET: utf8mb4 + DB_DRIVER: "{MySQL ODBC 9.2 ANSI Driver}" + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - job_name: PostgreSQL + module_mode: standard + py_db_module: psycopg2 + pip_install: psycopg2 + db_port: 5432 + - job_name: oracledb + module_mode: standard + py_db_module: oracledb + pip_install: oracledb + db_port: 1521 + - job_name: SQLite + module_mode: custom + py_db_module: sqlite3 + pip_install: none + db_port: 0000 + - job_name: MySQL_pymysql + module_mode: standard + py_db_module: pymysql + pip_install: pymysql[rsa] + db_port: 3306 + - job_name: MySQL_pyodbc + module_mode: standard + py_db_module: pyodbc + pip_install: pyodbc + db_port: 3306 + + services: + postgres: + image: postgres:11 + env: + POSTGRES_DB: ${{ env.DB_NAME }} + POSTGRES_USER: ${{ env.DB_USER }} + POSTGRES_PASSWORD: ${{ env.DB_PASS }} + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + oracle: + image: gvenzl/oracle-free:latest + env: + ORACLE_PASSWORD: ${{ env.DB_PASS }} + ORACLE_DATABASE: ${{ env.DB_NAME }} + APP_USER: ${{ env.DB_USER }} + APP_USER_PASSWORD: ${{ env.DB_PASS }} + ports: + - 1521:1521 + # Provide healthcheck script options for startup + options: --health-cmd healthcheck.sh --health-interval 10s --health-timeout 5s --health-retries 10 + + mysql: + image: mysql + env: + MYSQL_ROOT_PASSWORD: ${{ env.DB_PASS }} + MYSQL_DATABASE: ${{ env.DB_NAME }} + MYSQL_USER: ${{ env.DB_USER }} + MYSQL_PASSWORD: ${{ env.DB_PASS }} + ports: + - 3306:3306 + + steps: + + - name: Install unixodbc + if: matrix.py_db_module == 'pyodbc' + run: sudo apt-get update && sudo apt-get install -y unixodbc + + - name: Install ODBC driver for PostgreSQL + if: matrix.py_db_module == 'pyodbc' + run: | + echo "*** apt-get install the driver" + sudo apt-get install --yes odbc-postgresql + echo '*** ls -l /usr/lib/x86_64-linux-gnu/odbc' + ls -l /usr/lib/x86_64-linux-gnu/odbc || true + echo '*** add full paths to Postgres .so files in /etc/odbcinst.ini' + sudo sed -i 's|Driver=psqlodbca.so|Driver=/usr/lib/x86_64-linux-gnu/odbc/psqlodbca.so|g' /etc/odbcinst.ini + sudo sed -i 's|Driver=psqlodbcw.so|Driver=/usr/lib/x86_64-linux-gnu/odbc/psqlodbcw.so|g' /etc/odbcinst.ini + sudo sed -i 's|Setup=libodbcpsqlS.so|Setup=/usr/lib/x86_64-linux-gnu/odbc/libodbcpsqlS.so|g' /etc/odbcinst.ini + + - name: Install ODBC driver for MySQL + if: matrix.py_db_module == 'pyodbc' + run: | + cd "$RUNNER_TEMP" + echo "*** download driver" + curl --silent --show-error --write-out "$CURL_OUTPUT_FORMAT" -O -L "https://dev.mysql.com/get/Downloads/Connector-ODBC/9.2/${MYSQL_DRIVER}" + ls -l "${MYSQL_DRIVER}" + echo "*** install the driver" + sudo dpkg -i "./${MYSQL_DRIVER}" + sudo apt-get install -f + env: + CURL_OUTPUT_FORMAT: '%{http_code} %{filename_effective} %{size_download} %{time_total}\n' + MYSQL_DRIVER: mysql-connector-odbc_9.2.0-1ubuntu24.04_amd64.deb + + - name: Check ODBC setup + if: matrix.py_db_module == 'pyodbc' + run: | + echo "*** odbcinst -j" + odbcinst -j + echo "*** cat /etc/odbcinst.ini" + cat /etc/odbcinst.ini + echo "*** cat /etc/odbc.ini" + cat /etc/odbc.ini + echo '*** ls -l /opt/microsoft/msodbcsql17/lib64' + ls -l /opt/microsoft/msodbcsql17/lib64 || true + echo '*** ls -l /usr/lib/x86_64-linux-gnu/odbc' + ls -l /usr/lib/x86_64-linux-gnu/odbc || true + + - name: Check out repository code + uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: '3.8.14' + + - name: Setup Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Install Development/Checked out version of DatabaseLibrary + run: | + pip install -e ${{ github.workspace }} + + - name: Setup Python DB module + if: matrix.pip_install != 'none' + + run: | + pip install ${{ matrix.pip_install }} + + - name: Tests for ${{ matrix.job_name }} + working-directory: ./test + run: >- + robot + -d results + --xunit result.xml + --loglevel DEBUG:INFO + --output output_${{ matrix.job_name }}.xml + --log log_${{ matrix.job_name }}.html + --report report_${{ matrix.job_name }}.html + -v DB_MODULE_MODE:${{ matrix.module_mode }} + -v DB_MODULE:${{ matrix.py_db_module }} + -v DB_NAME:${{ env.DB_NAME }} + -v DB_USER:${{ env.DB_USER }} + -v DB_PASS:${{ env.DB_PASS }} + -v DB_HOST:${{ env.DB_HOST }} + -v DB_PORT:${{ matrix.db_port }} + -v DB_CHARSET:${{env.DB_CHARSET}} + -v DB_DRIVER:"${{env.DB_DRIVER}}" + tests/common_tests + + - name: Upload Robot Logs + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: log-files-${{ matrix.job_name }} + path: ./test/results/ diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 00000000..a9d347e8 --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,43 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Deploy static content to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["master"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Pages + uses: actions/configure-pages@v3 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + # Upload entire repository + path: 'doc' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml new file mode 100644 index 00000000..b036e639 --- /dev/null +++ b/.github/workflows/unit_tests.yml @@ -0,0 +1,56 @@ +--- +# This workflow will install Python dependencies +# and run unit tests for given OSes + +name: Unit tests + +on: [push, pull_request] + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: 'ubuntu-latest' + python-version: '3.8' + rf-version: '5.0.1' + - os: 'ubuntu-latest' + python-version: '3.9' + rf-version: '5.0.1' + - os: 'ubuntu-latest' + python-version: '3.10' + rf-version: '6.1.1' + - os: 'ubuntu-latest' + python-version: '3.11' + rf-version: '6.1.1' + - os: 'ubuntu-latest' + python-version: '3.12' + rf-version: '7.0.1' + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install robotframework==${{ matrix.rf-version }} coverage pytest + pip install . + + - name: Run unit tests with coverage + run: + coverage run -m pytest + + - name: Codecov + uses: codecov/codecov-action@v3 + with: + name: ${{ matrix.python-version }}-${{ matrix.os }}-${{ matrix.rf-version }} diff --git a/.gitignore b/.gitignore index be7dada6..b00d1b3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,16 @@ +dist/ build/ +*.egg-info/ *.pyc .idea +.py*/ +**/my_db_test.db +logs +interactive_console_output.xml +log.html +output.xml +report.html +venv +.runNumber +.DS_Store +test/resources/ojdbc17.jar diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..be00c519 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: +- repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + name: isort (python) + +- repo: https://github.com/psf/black + rev: 23.1.0 + hooks: + - id: black diff --git a/.project b/.project deleted file mode 100644 index 2e6cf0a3..00000000 --- a/.project +++ /dev/null @@ -1,17 +0,0 @@ - - - robotframework-database-library - - - - - - org.python.pydev.PyDevBuilder - - - - - - org.python.pydev.pythonNature - - diff --git a/.pydevproject b/.pydevproject deleted file mode 100644 index a090aef5..00000000 --- a/.pydevproject +++ /dev/null @@ -1,10 +0,0 @@ - - - - -/opt/python-virtualenvs/robot/bin/python -python 2.6 - -/robotframework-database-library/src - - diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..eeb930b9 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.pylint", + "robocorp.robotframework-lsp", + "techer.open-in-browser", + "eamodio.gitlens" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..d9400425 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,104 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "robotframework-lsp", + "name": "Launch .robot file for DB", + "request": "launch", + "cwd": "${workspaceFolder}", + "target": "${file}", + "terminal": "integrated", + "args": [ + "--outputdir", + "logs", + "--loglevel", + "DEBUG:INFO", + "-v GLOBAL_DB_SELECTOR:${input:DB}", + ], + }, + { + "type": "robotframework-lsp", + "name": "Run all common tests for DB", + "request": "launch", + "cwd": "${workspaceFolder}", + "target": "${workspaceFolder}/test/tests/common_tests", + "terminal": "integrated", + "args": [ + "--outputdir", + "logs", + "--loglevel", + "DEBUG:INFO", + "-v GLOBAL_DB_SELECTOR:${input:DB}", + ], + }, + { + "type": "robotframework-lsp", + "name": "dryrun all tests", + "request": "launch", + "cwd": "${workspaceFolder}", + "target": "${workspaceFolder}/test", + "terminal": "integrated", + "args": [ + "--outputdir", + "logs", + "--loglevel", + "DEBUG:INFO", + "--dryrun" + ], + }, + { + "type": "robotframework-lsp", + "name": "dryrun .robot file", + "request": "launch", + "cwd": "${workspaceFolder}", + "target": "${file}", + "terminal": "integrated", + "args": [ + "--outputdir", + "logs", + "--loglevel", + "DEBUG:INFO", + "--dryrun" + ], + }, + { + "type": "robotframework-lsp", + "name": "Robot Framework: Launch template", + "request": "launch", + "cwd": "^\"\\${workspaceFolder}\"", + "target": "^\"\\${file}\"", + "terminal": "integrated", + "args": [ + "--outputdir", + "logs", + "--loglevel", + "DEBUG:INFO", + ], + }, + ], + "inputs": [ + { + "type": "pickString", + "id": "DB", + "description": "Database to run the tests for", + "options": [ + "PostgreSQL", + "oracledb", + "cx_Oracle", + "SQLite", + "IBM_DB2", + "Teradata", + "MySQL_pymysql", + "MySQL_pyodbc", + "Oracle_JDBC", + "MSSQL", + "Excel", + "Excel_RW", + ], + "default": "PostgreSQL" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..3bae5617 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "files.exclude": { + "**/.git": true, + "**/__pycache__": true + }, + "robot.lint.robocop.enabled": true, + "robot.lint.unusedKeyword":false, + "robot.interactiveConsole.arguments": [ + "--output", "${workspaceRoot}/logs/interactive_console.xml" + ], + "python.analysis.completeFunctionParens": true, + "cSpell.words": [ + "hana", + "hdbodbc", + "saphana", + "SERVERNODE" + ], +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..476b056c --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,35 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "shell", + "label": "Run Oracle DB in Docker", + "command": "docker run --rm --name oracle -d -p 1521:1521 -e ORACLE_PASSWORD=pass -e ORACLE_DATABASE=db -e APP_USER=db_user -e APP_USER_PASSWORD=pass gvenzl/oracle-free", + "problemMatcher": [] + }, + { + "type": "shell", + "label": "Run PostreSQL DB in Docker", + "command": "docker run --rm --name postgres -e POSTGRES_USER=db_user -e POSTGRES_PASSWORD=pass -e POSTGRES_DB=db -p 5432:5432 -d postgres", + "problemMatcher": [] + }, + { + "type": "shell", + "label": "Run IBM DB2 in Docker", + "command": "docker run --rm -itd --name mydb2 --privileged=true -p 50000:50000 -e LICENSE=accept -e DB2INSTANCE=db_user -e DB2INST1_PASSWORD=pass -e DBNAME=db ibmcom/db2", + "problemMatcher": [] + }, + { + "type": "shell", + "label": "Run MySQL in Docker", + "command": "docker run --rm --name mysql -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_DATABASE=db -e MYSQL_USER=db_user -e MYSQL_PASSWORD=pass -p 3306:3306 -d mysql", + "problemMatcher": [] + }, + { + "type": "shell", + "label": "Run MS SQL in Docker (don't forget the DB init!)", + "command": "docker run --rm --name mssql -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD=MyPass1234! -p 1433:1433 -d mcr.microsoft.com/mssql/server", + "problemMatcher": [] + }, + ] + } \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..1dafc947 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright (c) 2010 Franz Allan Valencia See + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/MANIFEST b/MANIFEST index 5f971fe8..24a5be2d 100644 --- a/MANIFEST +++ b/MANIFEST @@ -3,3 +3,4 @@ src/DatabaseLibrary/__init__.py src/DatabaseLibrary/assertion.py src/DatabaseLibrary/connection_manager.py src/DatabaseLibrary/query.py +src/DatabaseLibrary/version.py diff --git a/README.md b/README.md new file mode 100644 index 00000000..69819108 --- /dev/null +++ b/README.md @@ -0,0 +1,465 @@ +# Robot Framework Database Library + +The Database Library for [Robot Framework](https://robotframework.org) allows you to query a database and verify the results. +It requires an appropriate **Python module to be installed separately** - depending on your database, like e.g. `oracledb` or `pymysql`. + +The library consists of some keywords designed to perform different checks on your database. +Here you can find the [keyword docs](http://marketsquare.github.io/Robotframework-Database-Library/). + +Wath the [talk at Robocon 2024 about the Database Library update](https://youtu.be/A96NTUps8sU). + +[![Talk at Robocon 2024 about the Database Library update](http://img.youtube.com/vi/A96NTUps8sU/0.jpg)](https://youtu.be/A96NTUps8sU) + +# Requirements +- Python +- Robot Framework +- Python database module you're going to use - e.g. `oracledb` +# Installation +``` +pip install robotframework-databaselibrary +``` +# Basic usage examples +```RobotFramework +*** Settings *** +Library DatabaseLibrary +Test Setup Connect To My Oracle DB + +*** Keywords *** +Connect To My Oracle DB + Connect To Database + ... oracledb + ... db_name=db + ... db_user=my_user + ... db_password=my_pass + ... db_host=127.0.0.1 + ... db_port=1521 + +*** Test Cases *** +Get All Names + ${Rows}= Query select FIRST_NAME, LAST_NAME from person + Should Be Equal ${Rows}[0][0] Franz Allan + Should Be Equal ${Rows}[0][1] See + Should Be Equal ${Rows}[1][0] Jerry + Should Be Equal ${Rows}[1][1] Schneider + +Person Table Contains Expected Records + ${sql}= Catenate select LAST_NAME from person + Check Query Result ${sql} contains See + Check Query Result ${sql} equals Schneider row=1 + +Wait Until Table Gets New Record + ${sql}= Catenate select LAST_NAME from person + Check Row Count ${sql} > 2 retry_timeout=5s + +Person Table Contains No Joe + ${sql}= Catenate SELECT id FROM person + ... WHERE FIRST_NAME= 'Joe' + Check Row Count ${sql} == 0 +``` +See more examples in the folder `tests`. + +# Handling multiple database connections +The library can handle multiple connections to different databases using *aliases*. +An alias is set while creating a connection and can be passed to library keywords in a corresponding argument. +## Example +```RobotFramework +*** Settings *** +Library DatabaseLibrary +Test Setup Connect To All Databases +Test Teardown Disconnect From All Databases + +*** Keywords *** +Connect To All Databases + Connect To Database + ... psycopg2 + ... db_name=db + ... db_user=db_user + ... db_password=pass + ... db_host=127.0.0.1 + ... db_port=5432 + ... alias=postgres + Connect To Database + ... pymysql + ... db_name=db + ... db_user=db_user + ... db_password=pass + ... db_host=127.0.0.1 + ... db_port=3306 + ... alias=mysql + +*** Test Cases *** +Using Aliases + ${names}= Query select LAST_NAME from person alias=postgres + Execute Sql String drop table XYZ alias=mysql + +Switching Default Alias + Switch Database postgres + ${names}= Query select LAST_NAME from person + Switch Database mysql + Execute Sql String drop table XYZ +``` + +# Connection examples for different DB modules +
+Oracle (oracle_db) + +```RobotFramework +# Thin mode is used by default +Connect To Database +... oracledb +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=1521 + +# Thick mode with default location of the Oracle Instant Client +Connect To Database +... oracledb +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=1521 +... oracle_driver_mode=thick + +# Thick mode with custom location of the Oracle Instant Client +Connect To Database +... oracledb +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=1521 +... oracle_driver_mode=thick,lib_dir=C:/instant_client_23_5 +``` +
+ +
+ PostgreSQL (psycopg2) + +```RobotFramework +Connect To Database +... psycopg2 +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=5432 +``` +
+ +
+Microsoft SQL Server (pymssql) + +```RobotFramework +# UTF-8 charset is used by default +Connect To Database +... pymssql +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=1433 + +# Specifying a custom charset +Connect To Database +... pymssql +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=1433 +... db_charset=cp1252 +``` +
+ +
+MySQL (pymysql) + +```RobotFramework +# UTF-8 charset is used by default +Connect To Database +... pymysql +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=3306 + +# Specifying a custom charset +Connect To Database +... pymysql +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=3306 +... db_charset=cp1252 +``` +
+ +
+IBM DB2 (ibm_db_dbi) + +```RobotFramework +Connect To Database +... ibm_db_dbi +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=50000 +``` +
+ +
+MySQL via ODBC (pyodbc) + +```RobotFramework +# ODBC driver name is required +# ODBC driver itself has to be installed +Connect To Database +... pyodbc +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=3306 +... odbc_driver={MySQL ODBC 9.2 ANSI Driver} + +# Specifying a custom charset if needed +Connect To Database +... pyodbc +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=3306 +... odbc_driver={MySQL ODBC 9.2 ANSI Driver} +... db_charset=latin1 +``` +
+ +
+Oracle via JDBC (jaydebeapi) + +```RobotFramework +# Username and password must be set as a dictionary +VAR &{CREDENTIALS} user=db_user password=pass + +# JAR file with Oracle JDBC driver is required +# Jaydebeapi is not "natively" supported by the Database Library, +# so using the custom parameters +Connect To Database +... jaydebeapi +... jclassname=oracle.jdbc.driver.OracleDriver +... url=jdbc:oracle:thin:@127.0.0.1:1521/db +... driver_args=${CREDENTIALS} +... jars=C:/ojdbc17.jar + +# Set if getting error 'Could not commit/rollback with auto-commit enabled' +Set Auto Commit False + +# Set for automatically removing trailing ';' (might be helpful for Oracle) +Set Omit Trailing Semicolon True +``` +
+ +
+SQLite (sqlite3) + +```RobotFramework +# Using custom parameters required +Connect To Database +... sqlite3 +... database=./my_database.db +... isolation_level=${None} +``` +
+ +
+Teradata (teradata) + +```RobotFramework +Connect To Database +... teradata +... db_name=db +... db_user=db_user +... db_password=pass +... db_host=127.0.0.1 +... db_port=1025 +``` +
+ +# Using configuration file +The `Connect To Database` keyword allows providing the connection parameters in two ways: +- As keyword arguments +- In a configuration file - a simple list of _key=value_ pairs, set inside an _alias_ section. + +You can use only one way or you can combine them: +- The keyword arguments are taken by default +- If no keyword argument is provided, a parameter value is searched in the config file + +Along with commonly used connection parameters, named exactly as keyword arguments, a config file +can contain any other DB module specific parameters as key/value pairs. +If same custom parameter is provided both as a keyword argument *and* in config file, +the *keyword argument value takes precedence*. + +The path to the config file is set by default to `./resources/db.cfg`. +You can change it using an according parameter in the `Connect To Database` keyword. + +A config file *must* contain at least one section name - +the connection alias, if used (see [Handling multiple database connections](#handling-multiple-database-connections)), or +`[default]` if no aliases are used. + +## Config file examples +### Config file with default alias (equal to using no aliases at all) +``` +[default] +db_module=psycopg2 +db_name=yourdbname +db_user=yourusername +db_password=yourpassword +db_host=yourhost +db_port=yourport +``` +### Config file with a specific alias +``` +[myoracle] +db_module=oracledb +db_name=yourdbname +db_user=yourusername +db_password=yourpassword +db_host=yourhost +db_port=yourport +``` + +### Config file with some params only +``` +[default] +db_password=mysecret +``` +### Config file with some custom DB module specific params +``` +[default] +my_custom_param=value +``` + +# Inline assertions +Keywords, that accept arguments ``assertion_operator`` and ``expected_value``, +perform a check according to the specified condition - using the [Assertion Engine](https://github.com/MarketSquare/AssertionEngine). + +## Examples +```RobotFramework +Check Row Count SELECT id FROM person == 2 +Check Query Result SELECT first_name FROM person contains Allan +``` + +# Retry mechanism +Assertion keywords, that accept arguments ``retry_timeout`` and ``retry_pause``, support waiting for assertion to pass. + +Setting the ``retry_timeout`` argument enables the mechanism - +in this case the SQL request and the assertion are executed in a loop, +until the assertion is passed or the ``retry_timeout`` is reached. +The pause between the loop iterations is set using the ``retry_pause`` argument. + +The argument values are set in [Robot Framework time format](http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#time-format) - e.g. ``5 seconds``. + +The retry mechanism is disabled by default - ``retry_timeout`` is set to ``0``. + +## Examples +```RobotFramework +${sql}= Catenate SELECT first_name FROM person +Check Row Count ${sql} == 2 retry_timeout=10 seconds +Check Query Result ${sql} contains Allan retry_timeout=5s retry_pause=1s +``` + +# Logging query results +Keywords, that fetch results of a SQL query, print the result rows as a table in RF log. +- A log head limit of *50 rows* is applied, other table rows are truncated in the log message. +- The limit and the logging in general can be adjusted any time in your tests using the Keyword `Set Logging Query Results`. + +You can also setup the limit or disable the logging during the library import. +## Examples +```RobotFramework +*** Settings *** +# Default behavior - logging of query results is enabled, log head is 50 rows. +Library DatabaseLibrary + +# Logging of query results is disabled, log head is 50 rows (default). +Library DatabaseLibrary log_query_results=False + +# Logging of query results is enabled (default), log head is 10 rows. +Library DatabaseLibrary log_query_results_head=10 + +# Logging of query results is enabled (default), log head limit is disabled (log all rows). +Library DatabaseLibrary log_query_results_head=0 +``` + +# Commit behavior +While creating a database connection, the library doesn't explicitly set the _autocommit_ behavior - +so the default value of the Python DB module is used. +According to Python DB API specification it should be disabled by default - +which means each SQL transaction (even a simple _SELECT_) must contain a dedicated commit statement, if necessary. + +The library manages it for you - keywords like `Query` or `Execute SQL String` +perform automatically a commit after running the query (or a rollback in case of error). + +You can turn off this automatic commit/rollback behavior using the ``no_transaction`` parameter. +See docs of a particular keyword. + +It's also possible to explicitly set the _autocommit_ behavior on the Python DB module level - +using the `Set Auto Commit` keyword. +This has no impact on the automatic commit/rollback behavior in library keywords (described above). + +# Omitting trailing semicolon behavior +Some databases (e.g. Oracle) throw an exception, if you leave a semicolon (;) at the SQL string end. +However, there are exceptional cases, when you need it even for Oracle - e.g. at the end of a PL/SQL block. + +The library can handle it for you and remove the semicolon at the end of the SQL string. +By default, it's decided based on the current database module in use: +- For `oracle_db` and `cx_Oracle`, the trailing semicolon is removed +- For other modules, the trailing semicolon is left as it is + +You can also set this behavior explicitly: +- Using the `Set Omit Trailing Semicolon` keyword +- Using the `omit_trailing_semicolon` parameter in the `Execute SQL String` keyword. + +# Database modules compatibility +> Looking for [Connection examples for different DB modules](#connection-examples-for-different-db-modules)? + +The library is basically compatible with any [Python Database API Specification 2.0](https://peps.python.org/pep-0249/) module. + +However, the actual implementation in existing Python modules is sometimes quite different, which requires custom handling in the library. +Therefore there are some modules, which are "natively" supported in the library - and others, which may work and may not. + +## Python modules currently "natively" supported +### Oracle +- [oracledb](https://oracle.github.io/python-oracledb/) + - Both thick and thin client modes are supported - you can select one using the `oracle_driver_mode` parameter. + - However, due to current limitations of the oracledb module, **it's not possible to switch between thick and thin modes during a test execution session** - even in different suites. +- [cx_Oracle](https://oracle.github.io/python-cx_Oracle/) +### MySQL +- [pymysql](https://github.com/PyMySQL/PyMySQL) +- [MySQLdb](https://mysqlclient.readthedocs.io/index.html) +### PostgreSQL +- [psycopg2](https://www.psycopg.org/docs/) +### MS SQL Server +- [pymssql](https://github.com/pymssql/pymssql) +### SQLite +- [sqlite3](https://docs.python.org/3/library/sqlite3.html) +### Teradata +- [teradata](https://github.com/teradata/PyTd) +### IBM DB2 +- The Python package to be installed is [ibm_db](https://github.com/ibmdb/python-ibmdb). It includes two modules - `ibm_db` and `ibm_db_dbi`. +- *Using `ibm_db_dbi` is highly recommended* as only this module is Python DB API 2.0 compatible. See [official docs](https://www.ibm.com/docs/en/db2/12.1?topic=applications-python-sqlalchemy-django-framework). +### ODBC +- [pyodbc](https://github.com/mkleehammer/pyodbc) +- [pypyodbc](https://github.com/pypyodbc/pypyodbc) +### Kingbase +- ksycopg2 + +# Further references (partly outdated) +- [List of Python DB interfaces](https://wiki.python.org/moin/DatabaseInterfaces) +- [Python DB programming](https://wiki.python.org/moin/DatabaseProgramming) diff --git a/dist/robotframework-databaselibrary-0.2.linux-x86_64.exe b/dist/robotframework-databaselibrary-0.2.linux-x86_64.exe deleted file mode 100644 index 6b194f7a..00000000 Binary files a/dist/robotframework-databaselibrary-0.2.linux-x86_64.exe and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.2.tar.gz b/dist/robotframework-databaselibrary-0.2.tar.gz deleted file mode 100644 index b6f52096..00000000 Binary files a/dist/robotframework-databaselibrary-0.2.tar.gz and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.3.linux-x86_64.exe b/dist/robotframework-databaselibrary-0.3.linux-x86_64.exe deleted file mode 100644 index a99bb480..00000000 Binary files a/dist/robotframework-databaselibrary-0.3.linux-x86_64.exe and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.3.linux-x86_64.tar.gz b/dist/robotframework-databaselibrary-0.3.linux-x86_64.tar.gz deleted file mode 100644 index 920e52f5..00000000 Binary files a/dist/robotframework-databaselibrary-0.3.linux-x86_64.tar.gz and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.3.tar.gz b/dist/robotframework-databaselibrary-0.3.tar.gz deleted file mode 100644 index 0726aae4..00000000 Binary files a/dist/robotframework-databaselibrary-0.3.tar.gz and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.4.linux-x86_64.tar.gz b/dist/robotframework-databaselibrary-0.4.linux-x86_64.tar.gz deleted file mode 100644 index 587f2600..00000000 Binary files a/dist/robotframework-databaselibrary-0.4.linux-x86_64.tar.gz and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.4.tar.gz b/dist/robotframework-databaselibrary-0.4.tar.gz deleted file mode 100644 index 1a16b1ae..00000000 Binary files a/dist/robotframework-databaselibrary-0.4.tar.gz and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.4.win32.zip b/dist/robotframework-databaselibrary-0.4.win32.zip deleted file mode 100644 index 84302402..00000000 Binary files a/dist/robotframework-databaselibrary-0.4.win32.zip and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.5.linux-x86_64.tar.gz b/dist/robotframework-databaselibrary-0.5.linux-x86_64.tar.gz deleted file mode 100644 index 5e6c4383..00000000 Binary files a/dist/robotframework-databaselibrary-0.5.linux-x86_64.tar.gz and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.5.tar.gz b/dist/robotframework-databaselibrary-0.5.tar.gz deleted file mode 100644 index 5f8e2258..00000000 Binary files a/dist/robotframework-databaselibrary-0.5.tar.gz and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.5.win32.zip b/dist/robotframework-databaselibrary-0.5.win32.zip deleted file mode 100644 index d43485c2..00000000 Binary files a/dist/robotframework-databaselibrary-0.5.win32.zip and /dev/null differ diff --git a/dist/robotframework-databaselibrary-0.6.tar.gz b/dist/robotframework-databaselibrary-0.6.tar.gz deleted file mode 100644 index 9239e8d6..00000000 Binary files a/dist/robotframework-databaselibrary-0.6.tar.gz and /dev/null differ diff --git a/doc/DatabaseLibrary.html b/doc/DatabaseLibrary.html deleted file mode 100644 index 9bf45af5..00000000 --- a/doc/DatabaseLibrary.html +++ /dev/null @@ -1,931 +0,0 @@ - - - -DatabaseLibrary - - - - - -

DatabaseLibrary

-Scope: global
-Named arguments: -supported - -

Introduction

-
Database Library contains utilities meant for Robot Framework's usage. - -This can allow you to query your database after an action has been made to verify the results. - -This is compatible* with any Database API Specification 2.0 module. - -References: - -+ Database API Specification 2.0 - http://www.python.org/dev/peps/pep-0249/ - -+ Lists of DB API 2.0 - http://wiki.python.org/moin/DatabaseInterfaces - -+ Python Database Programming - http://wiki.python.org/moin/DatabaseProgramming/ - -Notes: - -compatible* - or at least theoretically it should be compatible. Currently tested only with postgresql (using psycopg2). - -Example Usage: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Setup
Connect to Database
# Guard assertion (verify that test started in expected state).
Check if not exists in databaseselect id from person where first_name = 'Franz Allan' and last_name = 'See'
# Drive UI to do some action
Go Tohttp://localhost/person/form.html# From selenium library
Input Textname=first_nameFranz Allan# From selenium library
Input Textname=last_nameSee# From selenium library
Click ButtonSave# From selenium library
# Log results
@{queryResults}Queryselect * from person
Log Many@{queryResults}
# Verify if persisted in the database
Check if exists in databaseselect id from person where first_name = 'Franz Allan' and last_name = 'See'
# Teardown
Disconnect from Database
- - -

Shortcuts

-
-Check If Exists In Database - ·  -Check If Not Exists In Database - ·  -Connect To Database - ·  -Connect To Database Using Custom Params - ·  -Delete All Rows From Table - ·  -Description - ·  -Disconnect From Database - ·  -Execute Sql Script - ·  -Execute Sql String - ·  -Query - ·  -Row Count - ·  -Row Count Is 0 - ·  -Row Count Is Equal To X - ·  -Row Count Is Greater Than X - ·  -Row Count Is Less Than X - ·  -Table Must Exist -
- -

Keywords

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KeywordArgumentsDocumentation
Check If Exists In DatabaseselectStatementCheck if any row would be returned by given the input selectStatement. If there are no results, then this will throw an AssertionError. - -For example, given we have a table person with the following data: - - - - - - - - - - - -
idfirst_namelast_name
1Franz AllanSee
-When you have the following assertions in your robot - - - - - - - - - -
Check If Exists In Databaseselect id from person where first_name = 'Franz Allan'
Check If Exists In Databaseselect id from person where first_name = 'John'
-Then you will get the following: - - - - - - - - - - - -
Check If Exists In Databaseselect id from person where first_name = 'Franz Allan'# PASS
Check If Exists In Databaseselect id from person where first_name = 'John'# FAIL
Check If Not Exists In DatabaseselectStatementThis is the negation of check_if_exists_in_database. - -Check if no rows would be returned by given the input selectStatement. If there are any results, then this will throw an AssertionError. - -For example, given we have a table person with the following data: - - - - - - - - - - - -
idfirst_namelast_name
1Franz AllanSee
-When you have the following assertions in your robot - - - - - - - - - -
Check If Not Exists In Databaseselect id from person where first_name = 'John'
Check If Not Exists In Databaseselect id from person where first_name = 'Franz Allan'
-Then you will get the following: - - - - - - - - - - - -
Check If Not Exists In Databaseselect id from person where first_name = 'John'# PASS
Check If Not Exists In Databaseselect id from person where first_name = 'Franz Allan'# FAIL
Connect To DatabasedbapiModuleName=None, dbName=None, dbUsername=None, dbPassword=None, dbHost=localhost, dbPort=5432, dbConfigFile=./resources/db.cfgLoads the DB API 2.0 module given dbapiModuleName then uses it to connect to the database using dbName, dbUsername, and dbPassword. - -Optionally, you can specify a dbConfigFile wherein it will load the default property values for dbapiModuleName, dbName dbUsername and dbPassword (note: specifying dbapiModuleName, dbName dbUsername or dbPassword directly will override the properties of the same key in dbConfigFile). If no dbConfigFile is specified, it defaults to ./resources/db.cfg. - -The dbConfigFile is useful if you don't want to check into your SCM your database credentials. - -Example usage: - - - - - - - - - - - - - - - - - - - -
# explicitly specifies all db property values
Connect To Databasepsycopg2my_dbpostgress3cr3ttiger.foobar.com5432
- - - - - - - - - -
# loads all property values from default.cfg
Connect To DatabasedbConfigFile=default.cfg
- - - - - - - -
# loads all property values from ./resources/db.cfg
Connect To Database
- - - - - - - - - - - - - -
# uses explicit dbapiModuleName and dbName but uses the dbUsername and dbPassword in 'default.cfg'
Connect To Databasepsycopg2my_db_testdbConfigFile=default.cfg
- - - - - - - - - - - -
# uses explicit dbapiModuleName and dbName but uses the dbUsername and dbPassword in './resources/db.cfg'
Connect To Databasepsycopg2my_db_test
Connect To Database Using Custom ParamsdbapiModuleName=None, db_connect_string=Loads the DB API 2.0 module given dbapiModuleName then uses it to connect to the database using the map string db_custom_param_string. - -Example usage: - - - - - - - - - - - -
# for psycopg2
Connect To Database Using Custom Paramspsycopg2database='my_db_test', user='postgres', password='s3cr3t', host='tiger.foobar.com', port=5432
- - - - - - - - - - - -
# for JayDeBeApi
Connect To Database Using Custom ParamsJayDeBeApi'oracle.jdbc.driver.OracleDriver', 'my_db_test', 'system', 's3cr3t'
Delete All Rows From TabletableNameDelete all the rows within a given table. - -For example, given we have a table person in a database - -When you do the following: - - - - - -
Delete All Rows From Tableperson
-If all the rows can be successfully deleted, then you will get: - - - - - - -
Delete All Rows From Tableperson# PASS
If the table doesn't exist or all the data can't be deleted, then you will get: - - - - - - -
Delete All Rows From Tablefirst_name# FAIL
DescriptionselectStatementUses the input selectStatement to query a table in the db which will be used to determine the description. - -For example, given we have a table person with the following data: - - - - - - - - - - - -
idfirst_namelast_name
1Franz AllanSee
-When you do the following: - - - - - - - - - - - -
@{queryResults}Descriptionselect * from person
Log Many@{queryResults}
-You will get the following: [Column(name='id', type_code=1043, display_size=None, internal_size=255, precision=None, scale=None, null_ok=None)] [Column(name='first_name', type_code=1043, display_size=None, internal_size=255, precision=None, scale=None, null_ok=None)] [Column(name='last_name', type_code=1043, display_size=None, internal_size=255, precision=None, scale=None, null_ok=None)]
Disconnect From DatabaseDisconnects from the database. - -For example: - - - - - -
Disconnect From Database# disconnects from current connection to the database
Execute Sql ScriptsqlScriptFileNameExecutes the content of the sqlScriptFileName as SQL commands. Useful for setting the database to a known state before running your tests, or clearing out your test data after running each a test. - -Sample usage : - - - - - - - - - - - - - - - - - - - - - -
Execute Sql Script${EXECDIR}${/}resources${/}DDL-setup.sql
Execute Sql Script${EXECDIR}${/}resources${/}DML-setup.sql
#interesting stuff here
Execute Sql Script${EXECDIR}${/}resources${/}DML-teardown.sql
Execute Sql Script${EXECDIR}${/}resources${/}DDL-teardown.sql
-SQL commands are expected to be delimited by a semi-colon (';'). - -For example: delete from person_employee_table; delete from person_table; delete from employee_table; - -Also, the last SQL command can optionally omit its trailing semi-colon. - -For example: delete from person_employee_table; delete from person_table; delete from employee_table - -Given this, that means you can create spread your SQL commands in several lines. - -For example: delete from person_employee_table; delete from person_table; delete from employee_table - -However, lines that starts with a number sign (#) are treated as a commented line. Thus, none of the contents of that line will be executed. - -For example: # Delete the bridging table first... delete from person_employee_table; # ...and then the bridged tables. delete from person_table; delete from employee_table
Execute Sql StringsqlStringExecutes the sqlString as SQL commands. Useful to pass arguments to your sql. - -SQL commands are expected to be delimited by a semi-colon (';'). - -For example: - - - - - -
Execute Sql Stringdelete from person_employee_table; delete from person_table
-For example with an argument: - - - - - -
Execute Sql Stringselect from person where first_name = ${FIRSTNAME}
QueryselectStatementUses the input selectStatement to query for the values that will be returned as a list of tuples. - -Tip: Unless you want to log all column values of the specified rows, try specifying the column names in your select statements as much as possible to prevent any unnecessary surprises with schema changes and to easily see what your [] indexing is trying to retrieve (i.e. instead of "select * from my_table", try "select id, col_1, col_2 from my_table"). - -For example, given we have a table person with the following data: - - - - - - - - - - - -
idfirst_namelast_name
1Franz AllanSee
-When you do the following: - - - - - - - - - - - -
@{queryResults}Queryselect * from person
Log Many@{queryResults}
-You will get the following: [1, 'Franz Allan', 'See'] - -Also, you can do something like this: - - - - - - - - - - - -
${queryResults}Queryselect first_name, last_name from person
Log${queryResults[0][1]}, ${queryResults[0][0]}
-And get the following See, Franz Allan
Row CountselectStatementUses the input selectStatement to query the database and returns the number of rows from the query. - -For example, given we have a table person with the following data: - - - - - - - - - - - - - - - - -
idfirst_namelast_name
1Franz AllanSee
2JerrySchneider
-When you do the following: - - - - - - - - - - - -
${rowCount}Row Countselect * from person
Log${rowCount}
-You will get the following: 2 - -Also, you can do something like this: - - - - - - - - - - - -
${rowCount}Row Countselect * from person where id = 2
Log${rowCount}
-And get the following 1
Row Count Is 0selectStatementCheck if any rows are returned from the submitted selectStatement. If there are, then this will throw an AssertionError. - -For example, given we have a table person with the following data: - - - - - - - - - - - -
idfirst_namelast_name
1Franz AllanSee
-When you have the following assertions in your robot - - - - - - - - - -
Row Count is 0select id from person where first_name = 'Franz Allan'
Row Count is 0select id from person where first_name = 'John'
-Then you will get the following: - - - - - - - - - - - -
Row Count is 0select id from person where first_name = 'Franz Allan'# FAIL
Row Count is 0select id from person where first_name = 'John'# PASS
Row Count Is Equal To XselectStatement, numRowsCheck if the number of rows returned from selectStatement is equal to the value submitted. If not, then this will throw an AssertionError. - -For example, given we have a table person with the following data: - - - - - - - - - - - - - - - - -
idfirst_namelast_name
1Franz AllanSee
2JerrySchneider
-When you have the following assertions in your robot - - - - - - - - - - - -
Row Count Is Equal To Xselect id from person1
Row Count Is Equal To Xselect id from person where first_name = 'John'0
-Then you will get the following: - - - - - - - - - - - - - -
Row Count Is Equal To Xselect id from person1# FAIL
Row Count Is Equal To Xselect id from person where first_name = 'John'0# PASS
Row Count Is Greater Than XselectStatement, numRowsCheck if the number of rows returned from selectStatement is greater than the value submitted. If not, then this will throw an AssertionError. - -For example, given we have a table person with the following data: - - - - - - - - - - - - - - - - -
idfirst_namelast_name
1Franz AllanSee
2JerrySchneider
-When you have the following assertions in your robot - - - - - - - - - - - -
Row Count Is Greater Than Xselect id from person1
Row Count Is Greater Than Xselect id from person where first_name = 'John'0
-Then you will get the following: - - - - - - - - - - - - - -
Row Count Is Greater Than Xselect id from person1# PASS
Row Count Is Greater Than Xselect id from person where first_name = 'John'0# FAIL
Row Count Is Less Than XselectStatement, numRowsCheck if the number of rows returned from selectStatement is less than the value submitted. If not, then this will throw an AssertionError. - -For example, given we have a table person with the following data: - - - - - - - - - - - - - - - - -
idfirst_namelast_name
1Franz AllanSee
2JerrySchneider
-When you have the following assertions in your robot - - - - - - - - - - - -
Row Count Is Less Than Xselect id from person3
Row Count Is Less Than Xselect id from person where first_name = 'John'1
-Then you will get the following: - - - - - - - - - - - - - -
Row Count Is Less Than Xselect id from person3# PASS
Row Count Is Less Than Xselect id from person where first_name = 'John'1# FAIL
Table Must ExisttableNameCheck if the table given exists in the database. - -For example, given we have a table person in a database - -When you do the following: - - - - - -
Table Must Existperson
-Then you will get the following: - - - - - - - - - - - -
Table Must Existperson# PASS
Table Must Existfirst_name# FAIL
- - - diff --git a/doc/index.html b/doc/index.html new file mode 100644 index 00000000..fb97385f --- /dev/null +++ b/doc/index.html @@ -0,0 +1,410 @@ + + + + + + + + + + + + + + + +
+

Opening library documentation failed

+ +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..ae6a7c9c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = [ + "setuptools>=61.0", + "robotframework>=5.0.1", + "robotframework-assertion-engine", + "sqlparse" + ] +build-backend = "setuptools.build_meta" + +[project] +name = "robotframework-databaselibrary" +authors = [{name="Franz Allan Valencia See", email="franz.see@gmail.com"}, +] +description = "Database Library for Robot Framework" +readme = "README.md" +requires-python = ">=3.8.1" +dependencies = [ + "robotframework>=5.0.1", + "robotframework-assertion-engine", + "sqlparse" +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] +license = {text = "Apache License 2.0"} +dynamic = ["version"] + +[project.urls] +"Homepage" = "https://github.com/MarketSquare/Robotframework-Database-Library" +"Keyword docs" = "http://marketsquare.github.io/Robotframework-Database-Library/" + +[tool.setuptools.dynamic] +version = {attr = "DatabaseLibrary.__version__"} + +[tool.black] +line-length = 120 + +[tool.isort] +profile = "black" +line_length = 120 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..0a7cdb7b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +robotframework +robotframework-excellib +robotframework-assertion-engine +psycopg2-binary +pre-commit +build +twine \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index cf373cc2..00000000 --- a/setup.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python - -# Copyright (c) 2010 Franz Allan Valencia See -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -"""Setup script for Robot's DatabaseLibrary distributions""" - -from distutils.core import setup - -import sys, os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -from DatabaseLibrary import __version__ - -def main(): - setup(name = 'robotframework-databaselibrary', - version = __version__, - description = 'Database utility library for Robot Framework', - author = 'Franz Allan Valencia See', - author_email = 'franz.see@gmail.com', - url = 'https://github.com/franz-see/Robotframework-Database-Library', - package_dir = { '' : 'src'}, - packages = ['DatabaseLibrary'] - ) - - -if __name__ == "__main__": - main() diff --git a/src/DatabaseLibrary/__init__.py b/src/DatabaseLibrary/__init__.py index bc9af90f..8b1ef859 100644 --- a/src/DatabaseLibrary/__init__.py +++ b/src/DatabaseLibrary/__init__.py @@ -12,55 +12,424 @@ # See the License for the specific language governing permissions and # limitations under the License. -from connection_manager import ConnectionManager -from query import Query -from assertion import Assertion +import os + +from DatabaseLibrary.assertion import Assertion +from DatabaseLibrary.connection_manager import ConnectionManager +from DatabaseLibrary.query import Query +from DatabaseLibrary.version import VERSION + +__version__ = VERSION -__version__ = '0.6' class DatabaseLibrary(ConnectionManager, Query, Assertion): """ - Database Library contains utilities meant for Robot Framework's usage. - - This can allow you to query your database after an action has been made to verify the results. - - This is `compatible*` with any Database API Specification 2.0 module. - - - - References: - - + Database API Specification 2.0 - http://www.python.org/dev/peps/pep-0249/ - - + Lists of DB API 2.0 - http://wiki.python.org/moin/DatabaseInterfaces - - + Python Database Programming - http://wiki.python.org/moin/DatabaseProgramming/ - - Notes: - - - - `compatible* - or at least theoretically it should be compatible. Currently tested only with postgresql - (using psycopg2).` - - Example Usage: - | # Setup | - | Connect to Database | - | # Guard assertion (verify that test started in expected state). | - | Check if not exists in database | select id from person where first_name = 'Franz Allan' and last_name = 'See' | - | # Drive UI to do some action | - | Go To | http://localhost/person/form.html | | # From selenium library | - | Input Text | name=first_name | Franz Allan | # From selenium library | - | Input Text | name=last_name | See | # From selenium library | - | Click Button | Save | | # From selenium library | - | # Log results | - | @{queryResults} | Query | select * from person | - | Log Many | @{queryResults} | - | # Verify if persisted in the database | - | Check if exists in database | select id from person where first_name = 'Franz Allan' and last_name = 'See' | - | # Teardown | - | Disconnect from Database | + The Database Library for [https://robotframework.org|Robot Framework] allows you to query a database and verify the results. + It requires an appropriate *Python module to be installed separately* - depending on your database, like e.g. `oracledb` or `pymysql`. + + == Table of contents == + %TOC% + + = Requirements = + - Python + - Robot Framework + - Python database module you're going to use - e.g. `oracledb` + + = Installation = + | pip install robotframework-databaselibrary + Don't forget to install the required Python database module! + + = Basic usage examples = + | *** Settings *** + | Library DatabaseLibrary + | Test Setup Connect To My Oracle DB + | + | *** Keywords *** + | Connect To My Oracle DB + | Connect To Database + | ... oracledb + | ... db_name=db + | ... db_user=my_user + | ... db_password=my_pass + | ... db_host=127.0.0.1 + | ... db_port=1521 + | + | *** Test Cases *** + | Get All Names + | ${Rows}= Query select FIRST_NAME, LAST_NAME from person + | Should Be Equal ${Rows}[0][0] Franz Allan + | Should Be Equal ${Rows}[0][1] See + | Should Be Equal ${Rows}[1][0] Jerry + | Should Be Equal ${Rows}[1][1] Schneider + | + | Person Table Contains Expected Records + | ${sql}= Catenate select LAST_NAME from person + | Check Query Result ${sql} contains See + | Check Query Result ${sql} equals Schneider row=1 + | + | Wait Until Table Gets New Record + | ${sql}= Catenate select LAST_NAME from person + | Check Row Count ${sql} > 2 retry_timeout=5s + | + | Person Table Contains No Joe + | ${sql}= Catenate SELECT id FROM person + | ... WHERE FIRST_NAME= 'Joe' + | Check Row Count ${sql} == 0 + | + + = Handling multiple database connections = + The library can handle multiple connections to different databases using *aliases*. + An alias is set while creating a connection and can be passed to library keywords in a corresponding argument. + == Example == + | *** Settings *** + | Library DatabaseLibrary + | Test Setup Connect To All Databases + | Test Teardown Disconnect From All Databases + | + | *** Keywords *** + | Connect To All Databases + | Connect To Database + | ... psycopg2 + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=5432 + | ... alias=postgres + | Connect To Database + | ... pymysql + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=3306 + | ... alias=mysql + | + | *** Test Cases *** + | Using Aliases + | ${names}= Query select LAST_NAME from person alias=postgres + | Execute Sql String drop table XYZ alias=mysql + | + | Switching Default Alias + | Switch Database postgres + | ${names}= Query select LAST_NAME from person + | Switch Database mysql + | Execute Sql String drop table XYZ + | + + = Connection examples for different DB modules = + == Oracle (oracle_db) == + | # Thin mode is used by default + | Connect To Database + | ... oracledb + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=1521 + | + | # Thick mode with default location of the Oracle Instant Client + | Connect To Database + | ... oracledb + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=1521 + | ... oracle_driver_mode=thick + | + | # Thick mode with custom location of the Oracle Instant Client + | Connect To Database + | ... oracledb + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=1521 + | ... oracle_driver_mode=thick,lib_dir=C:/instant_client_23_5 + == PostgreSQL (psycopg2) == + | Connect To Database + | ... psycopg2 + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=5432 + == Microsoft SQL Server (pymssql) == + | # UTF-8 charset is used by default + | Connect To Database + | ... pymssql + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=1433 + | + | # Specifying a custom charset + | Connect To Database + | ... pymssql + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=1433 + | ... db_charset=cp1252 + == MySQL (pymysql) == + | # UTF-8 charset is used by default + | Connect To Database + | ... pymysql + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=3306 + | + | # Specifying a custom charset + | Connect To Database + | ... pymysql + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=3306 + | ... db_charset=cp1252 + == IBM DB2 (ibm_db) == + | Connect To Database + | ... ibm_db_dbi + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=50000 + == MySQL via ODBC (pyodbc) == + | # ODBC driver name is required + | # ODBC driver itself has to be installed + | Connect To Database + | ... pyodbc + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=3306 + | ... odbc_driver={MySQL ODBC 9.2 ANSI Driver} + | + | # Specifying a custom charset if needed + | Connect To Database + | ... pyodbc + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=3306 + | ... odbc_driver={MySQL ODBC 9.2 ANSI Driver} + | ... db_charset=latin1 + == Oracle via JDBC (jaydebeapi) == + | # Username and password must be set as a dictionary + | VAR &{CREDENTIALS} user=db_user password=pass + | + | # JAR file with Oracle JDBC driver is required + | # Jaydebeapi is not "natively" supported by the Database Library, + | # so using the custom parameters + | Connect To Database + | ... jaydebeapi + | ... jclassname=oracle.jdbc.driver.OracleDriver + | ... url=jdbc:oracle:thin:@127.0.0.1:1521/db + | ... driver_args=${CREDENTIALS} + | ... jars=C:/ojdbc17.jar + | + | # Set if getting error 'Could not commit/rollback with auto-commit enabled' + | Set Auto Commit False + | + | # Set for automatically removing trailing ';' (might be helpful for Oracle) + | Set Omit Trailing Semicolon True + == SQLite (sqlite3) == + | # Using custom parameters required + | Connect To Database + | ... sqlite3 + | ... database=./my_database.db + | ... isolation_level=${None} + == Teradata (teradata) == + | Connect To Database + | ... teradata + | ... db_name=db + | ... db_user=db_user + | ... db_password=pass + | ... db_host=127.0.0.1 + | ... db_port=1025 + + = Using configuration file = + The `Connect To Database` keyword allows providing the connection parameters in two ways: + - As keyword arguments + - In a configuration file - a simple list of _key=value_ pairs, set inside an _alias_ section. + + You can use only one way or you can combine them: + - The keyword arguments are taken by default + - If no keyword argument is provided, a parameter value is searched in the config file + + Along with commonly used connection parameters, named exactly as keyword arguments, a config file + can contain any other DB module specific parameters as key/value pairs. + If same custom parameter is provided both as a keyword argument *and* in config file, + the *keyword argument value takes precedence*. + + The path to the config file is set by default to `./resources/db.cfg`. + You can change it using an according parameter in the `Connect To Database` keyword. + + A config file *must* contain at least one section name - + the connection alias, if used (see `Handling multiple database connections`), or + `[default]` if no aliases are used. + + == Config file examples == + === Config file with default alias (equal to using no aliases at all) === + | [default] + | db_module=psycopg2 + | db_name=yourdbname + | db_user=yourusername + | db_password=yourpassword + | db_host=yourhost + | db_port=yourport + + === Config file with a specific alias === + | [myoracle] + | db_module=oracledb + | db_name=yourdbname + | db_user=yourusername + | db_password=yourpassword + | db_host=yourhost + | db_port=yourport + + === Config file with some params only === + | [default] + | db_password=mysecret + + === Config file with some custom DB module specific params === + | [default] + | my_custom_param=value + + + = Inline assertions = + Keywords, that accept arguments ``assertion_operator`` <`AssertionOperator`> and ``expected_value``, + perform a check according to the specified condition - using the [https://github.com/MarketSquare/AssertionEngine|Assertion Engine]. + + Examples: + | Check Row Count | SELECT id FROM person | *==* | 2 | + | Check Query Result | SELECT first_name FROM person | *contains* | Allan | + + = Retry mechanism = + Assertion keywords, that accept arguments ``retry_timeout`` and ``retry_pause``, support waiting for assertion to pass. + + Setting the ``retry_timeout`` argument enables the mechanism - + in this case the SQL request and the assertion are executed in a loop, + until the assertion is passed or the ``retry_timeout`` is reached. + The pause between the loop iterations is set using the ``retry_pause`` argument. + + The argument values are set in [http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#time-format|Robot Framework time format] - + e.g. ``5 seconds``. + + The retry mechanism is disabled by default - ``retry_timeout`` is set to ``0``. + + Examples: + | Check Row Count | SELECT id FROM person | *==* | 2 | retry_timeout=10 seconds | + | Check Query Result | SELECT first_name FROM person | *contains* | Allan | retry_timeout=5s | retry_pause=1s | + + = Logging query results = + Keywords, that fetch results of a SQL query, print the result rows as a table in RF log. + - A log head limit of *50 rows* is applied, other table rows are truncated in the log message. + - The limit and the logging in general can be adjusted any time in your tests using the Keyword `Set Logging Query Results`. + + You can also setup the limit or disable the logging during the library import. + Examples: + + | *** Settings *** + | # Default behavior - logging of query results is enabled, log head is 50 rows. + | Library DatabaseLibrary + | + | # Logging of query results is disabled, log head is 50 rows (default). + | Library DatabaseLibrary log_query_results=False + | + | # Logging of query results is enabled (default), log head is 10 rows. + | Library DatabaseLibrary log_query_results_head=10 + | + | # Logging of query results is enabled (default), log head limit is disabled (log all rows). + | Library DatabaseLibrary log_query_results_head=0 + + = Commit behavior = + While creating a database connection, the library doesn't explicitly set the _autocommit_ behavior - + so the default value of the Python DB module is used. + According to Python DB API specification it should be disabled by default - + which means each SQL transaction (even a simple _SELECT_) must contain a dedicated commit statement, if necessary. + + The library manages it for you - keywords like `Query` or `Execute SQL String` + perform automatically a commit after running the query (or a rollback in case of error). + + You can turn off this automatic commit/rollback behavior using the ``no_transaction`` parameter. + See docs of a particular keyword. + + It's also possible to explicitly set the _autocommit_ behavior on the Python DB module level - + using the `Set Auto Commit` keyword. + This has no impact on the automatic commit/rollback behavior in library keywords (described above). + + = Omitting trailing semicolon behavior = + Some databases (e.g. Oracle) throw an exception, if you leave a semicolon (;) at the SQL string end. + However, there are exceptional cases, when you need it even for Oracle - e.g. at the end of a PL/SQL block. + + The library can handle it for you and remove the semicolon at the end of the SQL string. + By default, it's decided based on the current database module in use: + - For `oracle_db` and `cx_Oracle`, the trailing semicolon is removed + - For other modules, the trailing semicolon is left as it is + + You can also set this behavior explicitly: + - Using the `Set Omit Trailing Semicolon` keyword + - Using the `omit_trailing_semicolon` parameter in the `Execute SQL String` keyword. + + = Database modules compatibility = + The library is basically compatible with any [https://peps.python.org/pep-0249|Python Database API Specification 2.0] module. + + However, the actual implementation in existing Python modules is sometimes quite different, which requires custom handling in the library. + Therefore, there are some modules, which are "natively" supported in the library - and others, which may work and may not. + + == Python modules currently "natively" supported == + === Oracle === + [https://oracle.github.io/python-oracledb/|oracledb] + - Both thick and thin client modes are supported - you can select one using the `oracle_driver_mode` parameter. + - However, due to current limitations of the oracledb module, *it's not possible to switch between thick and thin modes during a test execution session* - even in different suites. + + [https://oracle.github.io/python-cx_Oracle/|cx_Oracle] + + === MySQL === + - [https://github.com/PyMySQL/PyMySQL|pymysql] + - [https://mysqlclient.readthedocs.io/index.html|MySQLdb] + === PostgreSQL === + - [https://www.psycopg.org/docs/|psycopg2] + === MS SQL Server === + - [https://github.com/pymssql/pymssql|pymssql] + === SQLite === + - [https://docs.python.org/3/library/sqlite3.html|sqlite3] + === Teradata === + - [https://github.com/teradata/PyTd|teradata] + === IBM DB2 === + - The Python package to be installed is [https://github.com/ibmdb/python-ibmdb|ibm_db]. It includes two modules - `ibm_db` and `ibm_db_dbi`. + - Using *`ibm_db_dbi` is highly recommended* as only this module is Python DB API 2.0 compatible. See [https://www.ibm.com/docs/en/db2/12.1?topic=applications-python-sqlalchemy-django-framework|official docs]. + === ODBC === + - [https://github.com/mkleehammer/pyodbc|pyodbc] + - [https://github.com/pypyodbc/pypyodbc|pypyodbc] + === Kingbase === + - ksycopg2 """ - - ROBOT_LIBRARY_SCOPE = 'GLOBAL' + ROBOT_LIBRARY_SCOPE = "GLOBAL" + ROBOT_LIBRARY_VERSION = __version__ + + def __init__(self, log_query_results=True, log_query_results_head=50, warn_on_connection_overwrite=True): + """ + The library can be imported without any arguments: + | *** Settings *** + | Library DatabaseLibrary + + Use optional library import parameters: + - ``log_query_results`` and ``log_query_results_head`` to disable `Logging query results` or setup the log head + - ``warn_on_connection_overwrite`` to disable the warning about overwriting an existing connection + """ + ConnectionManager.__init__(self, warn_on_connection_overwrite=warn_on_connection_overwrite) + if log_query_results_head < 0: + raise ValueError(f"Wrong log head value provided: {log_query_results_head}. The value can't be negative!") + Query.__init__(self, log_query_results=log_query_results, log_query_results_head=log_query_results_head) diff --git a/src/DatabaseLibrary/assertion.py b/src/DatabaseLibrary/assertion.py index c723c359..d8f88164 100644 --- a/src/DatabaseLibrary/assertion.py +++ b/src/DatabaseLibrary/assertion.py @@ -11,166 +11,520 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from typing import Any, Optional, Tuple -class Assertion(object): +from assertionengine import AssertionOperator, verify_assertion +from robot.api import logger +from robot.libraries.BuiltIn import BuiltIn +from robot.utils import timestr_to_secs + +from .params_decorator import renamed_args + + +class Assertion: """ Assertion handles all the assertions of Database Library. """ - def check_if_exists_in_database(self,selectStatement): + def check_if_exists_in_database( + self, + select_statement: str, + *, + no_transaction: bool = False, + msg: Optional[str] = None, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + ): """ - Check if any row would be returned by given the input - `selectStatement`. If there are no results, then this will + *DEPRECATED* Use new `Check Row Count` keyword with assertion engine instead. + The deprecated keyword will be removed in future versions. + + Check if any row would be returned by given the input ``select_statement``. If there are no results, then this will throw an AssertionError. - - For example, given we have a table `person` with the following data: - | id | first_name | last_name | - | 1 | Franz Allan | See | - - When you have the following assertions in your robot - | Check If Exists In Database | select id from person where first_name = 'Franz Allan' | - | Check If Exists In Database | select id from person where first_name = 'John' | - - Then you will get the following: - | Check If Exists In Database | select id from person where first_name = 'Franz Allan' | # PASS | - | Check If Exists In Database | select id from person where first_name = 'John' | # FAIL | - """ - if not self.query(selectStatement): - raise AssertionError("Expected to have have at least one row from '%s' " - "but got 0 rows." % selectStatement) - - def check_if_not_exists_in_database(self,selectStatement): + + Set optional input ``no_transaction`` to _True_ to run command without an explicit transaction + commit or rollback. + + The default error message can be overridden with the ``msg`` argument. + + Use optional ``alias`` parameter to specify what connection should be used for the query if you have more + than one connection open. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Examples: + | Check If Exists In Database | SELECT id FROM person WHERE first_name = 'Franz Allan' | + | Check If Exists In Database | SELECT id FROM person WHERE first_name = 'John' | msg=my error message | + | Check If Exists In Database | SELECT id FROM person WHERE first_name = 'Franz Allan' | alias=my_alias | + | Check If Exists In Database | SELECT id FROM person WHERE first_name = 'John' | no_transaction=True | + | @{parameters} | Create List | John | + | Check If Exists In Database | SELECT id FROM person WHERE first_name = %s | parameters=${parameters} | """ + if not self.query(select_statement, no_transaction, alias=alias, parameters=parameters): + raise AssertionError( + msg or f"Expected to have have at least one row, but got 0 rows from: '{select_statement}'" + ) + + def check_if_not_exists_in_database( + self, + selectStatement: str, + sansTran: bool = False, + msg: Optional[str] = None, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + ): + """ + *DEPRECATED* Use new `Check Row Count` keyword with assertion engine instead. + The deprecated keyword will be removed in future versions. + This is the negation of `check_if_exists_in_database`. - - Check if no rows would be returned by given the input - `selectStatement`. If there are any results, then this will - throw an AssertionError. - - For example, given we have a table `person` with the following data: - | id | first_name | last_name | - | 1 | Franz Allan | See | - - When you have the following assertions in your robot - | Check If Not Exists In Database | select id from person where first_name = 'John' | - | Check If Not Exists In Database | select id from person where first_name = 'Franz Allan' | - - Then you will get the following: - | Check If Not Exists In Database | select id from person where first_name = 'John' | # PASS | - | Check If Not Exists In Database | select id from person where first_name = 'Franz Allan' | # FAIL | - """ - queryResults = self.query(selectStatement) - if queryResults: - raise AssertionError("Expected to have have no rows from '%s' " - "but got some rows : %s." % (selectStatement, queryResults)) - - def row_count_is_0(self,selectStatement): - """ - Check if any rows are returned from the submitted `selectStatement`. - If there are, then this will throw an AssertionError. - - For example, given we have a table `person` with the following data: - | id | first_name | last_name | - | 1 | Franz Allan | See | - - When you have the following assertions in your robot - | Row Count is 0 | select id from person where first_name = 'Franz Allan' | - | Row Count is 0 | select id from person where first_name = 'John' | - - Then you will get the following: - | Row Count is 0 | select id from person where first_name = 'Franz Allan' | # FAIL | - | Row Count is 0 | select id from person where first_name = 'John' | # PASS | - """ - num_rows = self.row_count(selectStatement) - if (num_rows > 0): - raise AssertionError("Expected zero rows to be returned from '%s' " - "but got rows back. Number of rows returned was %s" % (selectStatement, num_rows)) - - def row_count_is_equal_to_x(self,selectStatement,numRows): - """ - Check if the number of rows returned from `selectStatement` is equal to - the value submitted. If not, then this will throw an AssertionError. - - For example, given we have a table `person` with the following data: - | id | first_name | last_name | - | 1 | Franz Allan | See | - | 2 | Jerry | Schneider | - - When you have the following assertions in your robot - | Row Count Is Equal To X | select id from person | 1 | - | Row Count Is Equal To X | select id from person where first_name = 'John' | 0 | - - Then you will get the following: - | Row Count Is Equal To X | select id from person | 1 | # FAIL | - | Row Count Is Equal To X | select id from person where first_name = 'John' | 0 | # PASS | - """ - num_rows = self.row_count(selectStatement) - if (num_rows != int(numRows.encode('ascii'))): - raise AssertionError("Expected same number of rows to be returned from '%s' " - "than the returned rows of %s" % (selectStatement, num_rows)) - - def row_count_is_greater_than_x(self,selectStatement,numRows): - """ - Check if the number of rows returned from `selectStatement` is greater - than the value submitted. If not, then this will throw an AssertionError. - - For example, given we have a table `person` with the following data: - | id | first_name | last_name | - | 1 | Franz Allan | See | - | 2 | Jerry | Schneider | - - When you have the following assertions in your robot - | Row Count Is Greater Than X | select id from person | 1 | - | Row Count Is Greater Than X | select id from person where first_name = 'John' | 0 | - - Then you will get the following: - | Row Count Is Greater Than X | select id from person | 1 | # PASS | - | Row Count Is Greater Than X | select id from person where first_name = 'John' | 0 | # FAIL | - """ - num_rows = self.row_count(selectStatement) - if (num_rows <= int(numRows.encode('ascii'))): - raise AssertionError("Expected more rows to be returned from '%s' " - "than the returned rows of %s" % (selectStatement, num_rows)) - - def row_count_is_less_than_x(self,selectStatement,numRows): - """ - Check if the number of rows returned from `selectStatement` is less - than the value submitted. If not, then this will throw an AssertionError. - - For example, given we have a table `person` with the following data: - | id | first_name | last_name | - | 1 | Franz Allan | See | - | 2 | Jerry | Schneider | - - When you have the following assertions in your robot - | Row Count Is Less Than X | select id from person | 3 | - | Row Count Is Less Than X | select id from person where first_name = 'John' | 1 | - - Then you will get the following: - | Row Count Is Less Than X | select id from person | 3 | # PASS | - | Row Count Is Less Than X | select id from person where first_name = 'John' | 1 | # FAIL | - """ - num_rows = self.row_count(selectStatement) - if (num_rows >= int(numRows.encode('ascii'))): - raise AssertionError("Expected less rows to be returned from '%s' " - "than the returned rows of %s" % (selectStatement, num_rows)) - - def table_must_exist(self,tableName): - """ - Check if the table given exists in the database. - - For example, given we have a table `person` in a database - - When you do the following: - | Table Must Exist | person | - Then you will get the following: - | Table Must Exist | person | # PASS | - | Table Must Exist | first_name | # FAIL | + Check if no rows would be returned by given the input ``selectStatement``. If there are any results, then this + will throw an AssertionError. + + Set optional input ``sansTran`` to _True_ to run command without an explicit transaction commit or rollback. + + The default error message can be overridden with the ``msg`` argument. + + Use optional ``alias`` parameter to specify what connection should be used for the query if you have more + than one connection open. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Examples: + | Check If Not Exists In Database | SELECT id FROM person WHERE first_name = 'John' | + | Check If Not Exists In Database | SELECT id FROM person WHERE first_name = 'Franz Allan' | msg=my error message | + | Check If Not Exists In Database | SELECT id FROM person WHERE first_name = 'Franz Allan' | alias=my_alias | + | Check If Not Exists In Database | SELECT id FROM person WHERE first_name = 'John' | sansTran=True | + | @{parameters} | Create List | John | + | Check If Not Exists In Database | SELECT id FROM person WHERE first_name = %s | parameters=${parameters} | + """ + query_results = self.query(selectStatement, sansTran, alias=alias, parameters=parameters) + if query_results: + raise AssertionError( + msg or f"Expected to have have no rows from '{selectStatement}', but got some rows: {query_results}" + ) + + def row_count_is_0( + self, + selectStatement: str, + sansTran: bool = False, + msg: Optional[str] = None, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + ): + """ + *DEPRECATED* Use new `Check Row Count` keyword with assertion engine instead. + The deprecated keyword will be removed in future versions. + + Check if any rows are returned from the submitted ``selectStatement``. If there are, then this will throw an + AssertionError. + + Set optional input ``sansTran`` to _True_ to run command without an explicit transaction commit or + rollback. + + The default error message can be overridden with the ``msg`` argument. + + Use optional ``alias`` parameter to specify what connection should be used for the query if you have more + than one connection open. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Examples: + | Row Count is 0 | SELECT id FROM person WHERE first_name = 'Franz Allan' | + | Row Count is 0 | SELECT id FROM person WHERE first_name = 'Franz Allan' | msg=my error message | + | Row Count is 0 | SELECT id FROM person WHERE first_name = 'John' | alias=my_alias | + | Row Count is 0 | SELECT id FROM person WHERE first_name = 'John' | sansTran=True | + | @{parameters} | Create List | John | + | Row Count is 0 | SELECT id FROM person WHERE first_name = %s | parameters=${parameters} | + """ + num_rows = self.row_count(selectStatement, sansTran, alias=alias, parameters=parameters) + if num_rows > 0: + raise AssertionError(msg or f"Expected 0 rows, but {num_rows} were returned from: '{selectStatement}'") + + def row_count_is_equal_to_x( + self, + selectStatement: str, + numRows: str, + sansTran: bool = False, + msg: Optional[str] = None, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + ): + """ + *DEPRECATED* Use new `Check Row Count` keyword with assertion engine instead. + The deprecated keyword will be removed in future versions. + + Check if the number of rows returned from ``selectStatement`` is equal to the value submitted. If not, then this + will throw an AssertionError. + + Set optional input ``sansTran`` to _True_ to run command without an explicit transaction commit or rollback. + + The default error message can be overridden with the ``msg`` argument. + + Use optional ``alias`` parameter to specify what connection should be used for the query if you have more + than one connection open. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Examples: + | Row Count Is Equal To X | SELECT id FROM person | 1 | + | Row Count Is Equal To X | SELECT id FROM person | 3 | msg=my error message | + | Row Count Is Equal To X | SELECT id FROM person WHERE first_name = 'John' | 0 | alias=my_alias | + | Row Count Is Equal To X | SELECT id FROM person WHERE first_name = 'John' | 0 | sansTran=True | + | @{parameters} | Create List | John | + | Row Count Is Equal To X | SELECT id FROM person WHERE first_name = %s | 0 | parameters=${parameters} | + """ + num_rows = self.row_count(selectStatement, sansTran, alias=alias, parameters=parameters) + if num_rows != int(numRows.encode("ascii")): + raise AssertionError( + msg or f"Expected {numRows} rows, but {num_rows} were returned from: '{selectStatement}'" + ) + + def row_count_is_greater_than_x( + self, + selectStatement: str, + numRows: str, + sansTran: bool = False, + msg: Optional[str] = None, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + ): + """ + *DEPRECATED* Use new `Check Row Count` keyword with assertion engine instead. + The deprecated keyword will be removed in future versions. + + Check if the number of rows returned from ``selectStatement`` is greater than the value submitted. If not, then + this will throw an AssertionError. + + Set optional input ``sansTran`` to _True_ to run command without an explicit transaction commit or rollback. + + The default error message can be overridden with the ``msg`` argument. + + Use optional ``alias`` parameter to specify what connection should be used for the query if you have more + than one connection open. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Examples: + | Row Count Is Greater Than X | SELECT id FROM person WHERE first_name = 'John' | 0 | + | Row Count Is Greater Than X | SELECT id FROM person WHERE first_name = 'John' | 0 | msg=my error message | + | Row Count Is Greater Than X | SELECT id FROM person WHERE first_name = 'John' | 0 | alias=my_alias | + | Row Count Is Greater Than X | SELECT id FROM person | 1 | sansTran=True | + | @{parameters} | Create List | John | + | Row Count Is Greater Than X | SELECT id FROM person WHERE first_name = %s | 0 | parameters=${parameters} | + """ + num_rows = self.row_count(selectStatement, sansTran, alias=alias, parameters=parameters) + if num_rows <= int(numRows.encode("ascii")): + raise AssertionError( + msg or f"Expected more than {numRows} rows, but {num_rows} were returned from '{selectStatement}'" + ) + + def row_count_is_less_than_x( + self, + selectStatement: str, + numRows: str, + sansTran: bool = False, + msg: Optional[str] = None, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + ): """ - selectStatement = ("select * from information_schema.tables where table_name='%s'" % tableName) - num_rows = self.row_count(selectStatement) - if (num_rows == 0): - raise AssertionError("Table '%s' does not exist in the db" % tableName) + *DEPRECATED* Use new `Check Row Count` keyword with assertion engine instead. + The deprecated keyword will be removed in future versions. + Check if the number of rows returned from ``selectStatement`` is less than the value submitted. If not, then this + will throw an AssertionError. + Set optional input ``sansTran`` to _True_ to run command without an explicit transaction commit or rollback. + + Using optional ``msg`` to override the default error message: + + Use optional ``alias`` parameter to specify what connection should be used for the query if you have more + than one connection open. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Examples: + | Row Count Is Less Than X | SELECT id FROM person WHERE first_name = 'John' | 1 | + | Row Count Is Less Than X | SELECT id FROM person WHERE first_name = 'John' | 2 | msg=my error message | + | Row Count Is Less Than X | SELECT id FROM person WHERE first_name = 'John' | 3 | alias=my_alias | + | Row Count Is Less Than X | SELECT id FROM person WHERE first_name = 'John' | 4 | sansTran=True | + | @{parameters} | Create List | John | + | Row Count Is Less Than X | SELECT id FROM person WHERE first_name = %s | 5 | parameters=${parameters} | + """ + num_rows = self.row_count(selectStatement, sansTran, alias=alias, parameters=parameters) + if num_rows >= int(numRows.encode("ascii")): + raise AssertionError( + msg or f"Expected less than {numRows} rows, but {num_rows} were returned from '{selectStatement}'" + ) + + @renamed_args(mapping={"selectStatement": "select_statement", "sansTran": "no_transaction"}) + def check_row_count( + self, + select_statement: str, + assertion_operator: AssertionOperator, + expected_value: int, + assertion_message: Optional[str] = None, + no_transaction: bool = False, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + retry_timeout="0 seconds", + retry_pause="0.5 seconds", + *, + replace_robot_variables=False, + selectStatement: Optional[str] = None, + sansTran: Optional[bool] = None, + ): + """ + Check the number of rows returned from ``select_statement`` using ``assertion_operator`` + and ``expected_value``. See `Inline assertions` for more details. + + Use ``assertion_message`` to override the default error message. + + Set ``no_transaction`` to _True_ to run command without explicit transaction rollback in case of error. + See `Commit behavior` for details. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Use ``retry_timeout`` and ``retry_pause`` parameters to enable waiting for assertion to pass. + See `Retry mechanism` for more details. + + Set ``replace_robot_variables`` to resolve RF variables like _${MY_VAR}_ before executing the SQL. + + === Some parameters were renamed in version 2.0 === + The old parameters ``selectStatement`` and ``sansTran`` are *deprecated*, + please use new parameters ``select_statement`` and ``no_transaction`` instead. + + *The old parameters will be removed in future versions.* + + === Examples === + | Check Row Count | SELECT id FROM person WHERE first_name = 'John' | *==* | 1 | + | Check Row Count | SELECT id FROM person WHERE first_name = 'John' | *>=* | 2 | assertion_message=my error message | + | Check Row Count | SELECT id FROM person WHERE first_name = 'John' | *inequal* | 3 | alias=my_alias | + | Check Row Count | SELECT id FROM person WHERE first_name = 'John' | *less than* | 4 | no_transaction=True | + | @{parameters} | Create List | John | + | Check Row Count | SELECT id FROM person WHERE first_name = %s | *equals* | 5 | parameters=${parameters} | + """ + check_ok = False + time_counter = 0 + while not check_ok: + try: + num_rows = self.row_count( + select_statement, + no_transaction=no_transaction, + alias=alias, + parameters=parameters, + replace_robot_variables=replace_robot_variables, + ) + verify_assertion(num_rows, assertion_operator, expected_value, "Wrong row count:", assertion_message) + check_ok = True + except AssertionError as e: + if time_counter >= timestr_to_secs(retry_timeout): + logger.info(f"Timeout '{retry_timeout}' reached") + raise e + BuiltIn().sleep(retry_pause) + time_counter += timestr_to_secs(retry_pause) + + @renamed_args(mapping={"selectStatement": "select_statement", "sansTran": "no_transaction"}) + def check_query_result( + self, + select_statement: str, + assertion_operator: AssertionOperator, + expected_value: Any, + row=0, + col=0, + assertion_message: Optional[str] = None, + no_transaction: bool = False, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + retry_timeout="0 seconds", + retry_pause="0.5 seconds", + *, + assert_as_string=False, + replace_robot_variables=False, + selectStatement: Optional[str] = None, + sansTran: Optional[bool] = None, + ): + """ + Check value in query result returned from ``select_statement`` using ``assertion_operator`` and ``expected_value``. + The value position in results can be adjusted using ``row`` and ``col`` parameters (0-based). + See `Inline assertions` for more details. + + === Assertions are type sensitive! === + Normally, the type of ``expected_value`` is taken as provided (string as RF default or e.g. ``${1}`` for numeric values) + and the type of *actual value* is taken as returned by the ``select_statement`` + (depends on the DB table and the Python module). + Set ``assert_as_string`` to _True_ to convert both *actual value* and ``expected_value`` to string before running the assertion. + + Use optional ``assertion_message`` to override the default error message. + + Set ``no_transaction`` to _True_ to run command without explicit transaction rollback in case of error. + See `Commit behavior` for details. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Use ``retry_timeout`` and ``retry_pause`` parameters to enable waiting for assertion to pass. + See `Retry mechanism` for more details. + + Set ``replace_robot_variables`` to resolve RF variables like _${MY_VAR}_ before executing the SQL. + + === Some parameters were renamed in version 2.0 === + The old parameters ``selectStatement`` and ``sansTran`` are *deprecated*, + please use new parameters ``select_statement`` and ``no_transaction`` instead. + + *The old parameters will be removed in future versions.* + + === Examples === + | Check Query Result | SELECT first_name FROM person | *contains* | Allan | + | Check Query Result | SELECT first_name, last_name FROM person | *==* | Schneider | row=1 | col=1 | + | Check Query Result | SELECT id FROM person WHERE first_name = 'John' | *==* | 2 | # Fails, if query returns an integer value | + | Check Query Result | SELECT id FROM person WHERE first_name = 'John' | *==* | ${2} | # Works, if query returns an integer value | + | Check Query Result | SELECT first_name FROM person | *equal* | Franz Allan | assertion_message=my error message | + | Check Query Result | SELECT first_name FROM person | *inequal* | John | alias=my_alias | + | Check Query Result | SELECT first_name FROM person | *contains* | Allan | no_transaction=True | + | @{parameters} | Create List | Smith | + | Check Query Result | SELECT first_name FROM person last_name = %s | *contains* | Allan | parameters=${parameters} | + """ + check_ok = False + time_counter = 0 + + use_string_assertion_hint = "Consider using the 'assert_as_string' parameter." + + def _log_possible_type_mismatch(expected, actual, suggest_string_assertion=True): + if actual is not None: + msg = ( + f"Possible type mismatch between expected value '{expected}' ({type(expected).__name__}) " + f"and actual value returned by the sql statement '{actual}' ({type(actual).__name__})." + ) + if suggest_string_assertion: + msg += f"\n{use_string_assertion_hint}" + if type(expected) != type(actual): + logger.info(msg) + + while not check_ok: + try: + actual_value = None + + query_results = self.query( + select_statement, + no_transaction=no_transaction, + alias=alias, + parameters=parameters, + replace_robot_variables=replace_robot_variables, + ) + + row_count = len(query_results) + assert ( + row < row_count + ), f"Checking row '{row}' is not possible, as query results contain {row_count} rows only!" + col_count = len(query_results[row]) + assert ( + col < col_count + ), f"Checking column '{col}' is not possible, as query results contain {col_count} columns only!" + actual_value = query_results[row][col] + if assert_as_string: + actual_value = str(actual_value) + expected_value = str(expected_value) + + assert_log_msg = ( + f"'{actual_value}' ({type(actual_value).__name__}) " + f"{assertion_operator.name} '{expected_value}' ({type(expected_value).__name__})" + ) + logger.info(f"Run assertion: {assert_log_msg}") + + verify_assertion( + actual_value, + assertion_operator, + expected_value, + "Wrong query result:", + assertion_message, + ) + check_ok = True + except TypeError as e: + _log_possible_type_mismatch(expected_value, actual_value, suggest_string_assertion=False) + msg = f"Invalid assertion: {assert_log_msg}.\n{use_string_assertion_hint}\n" + raise TypeError(f"{msg}Original error: {e}") from e + + except AssertionError as e: + if time_counter >= timestr_to_secs(retry_timeout): + logger.info(f"Timeout '{retry_timeout}' reached") + _log_possible_type_mismatch(expected_value, actual_value) + raise e + BuiltIn().sleep(retry_pause) + time_counter += timestr_to_secs(retry_pause) + + @renamed_args(mapping={"tableName": "table_name", "sansTran": "no_transaction"}) + def table_must_exist( + self, + table_name: str, + no_transaction: bool = False, + msg: Optional[str] = None, + alias: Optional[str] = None, + *, + tableName: Optional[str] = None, + sansTran: Optional[bool] = None, + ): + """ + Check if the table with `table_name` exists in the database. + + Use ``msg`` for custom error message. + + Set ``no_transaction`` to _True_ to run command without explicit transaction rollback in case of error. + See `Commit behavior` for details. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + === Some parameters were renamed in version 2.0 === + The old parameters ``tableName`` and ``sansTran`` are *deprecated*, + please use new parameters ``table_name`` and ``no_transaction`` instead. + + *The old parameters will be removed in future versions.* + + === Examples === + | Table Must Exist | person | + | Table Must Exist | person | msg=my error message | + | Table Must Exist | person | alias=my_alias | + | Table Must Exist | person | no_transaction=True | + """ + db_connection = self.connection_store.get_connection(alias) + if db_connection.module_name in ["cx_Oracle", "oracledb"]: + query = ( + "SELECT * FROM all_objects WHERE object_type IN ('TABLE','VIEW') AND " + f"owner = SYS_CONTEXT('USERENV', 'SESSION_USER') AND object_name = UPPER('{table_name}')" + ) + table_exists = self.row_count(query, no_transaction=no_transaction, alias=alias) > 0 + elif db_connection.module_name in ["sqlite3"]: + query = f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}' COLLATE NOCASE" + table_exists = self.row_count(query, no_transaction=no_transaction, alias=alias) > 0 + elif db_connection.module_name in ["ibm_db", "ibm_db_dbi"]: + query = f"SELECT name FROM SYSIBM.SYSTABLES WHERE type='T' AND name=UPPER('{table_name}')" + table_exists = self.row_count(query, no_transaction=no_transaction, alias=alias) > 0 + elif db_connection.module_name in ["teradata"]: + query = f"SELECT TableName FROM DBC.TablesV WHERE TableKind='T' AND TableName='{table_name}'" + table_exists = self.row_count(query, no_transaction=no_transaction, alias=alias) > 0 + else: + try: + query = f"SELECT * FROM information_schema.tables WHERE table_name='{table_name}'" + table_exists = self.row_count(query, no_transaction=no_transaction, alias=alias) > 0 + except: + logger.info("Database doesn't support information schema, try using a simple SQL request") + try: + query = f"SELECT 1 from {table_name} where 1=0" + self.row_count(query, no_transaction=no_transaction, alias=alias) + table_exists = True + except: + table_exists = False + assert table_exists, msg or f"Table '{table_name}' does not exist in the db" diff --git a/src/DatabaseLibrary/connection_manager.py b/src/DatabaseLibrary/connection_manager.py index 2c733cef..87926ab5 100644 --- a/src/DatabaseLibrary/connection_manager.py +++ b/src/DatabaseLibrary/connection_manager.py @@ -12,99 +12,665 @@ # See the License for the specific language governing permissions and # limitations under the License. -import ConfigParser +import importlib +import os +from configparser import ConfigParser, NoOptionError, NoSectionError +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional + from robot.api import logger -class ConnectionManager(object): +from .params_decorator import renamed_args + + +@dataclass +class Connection: + client: Any + module_name: str + omit_trailing_semicolon: bool + + +class ConnectionStore: + def __init__(self, warn_on_overwrite=True): + self._connections: Dict[str, Connection] = {} + self.default_alias: str = "default" + self.warn_on_overwrite = warn_on_overwrite + + def register_connection(self, client: Any, module_name: str, alias: str, omit_trailing_semicolon=False): + if alias in self._connections and self.warn_on_overwrite: + if alias == self.default_alias: + logger.warn("Overwriting not closed connection.") + else: + logger.warn(f"Overwriting not closed connection for alias = '{alias}'") + self._connections[alias] = Connection(client, module_name, omit_trailing_semicolon) + + def get_connection(self, alias: Optional[str]) -> Connection: + """ + Return connection with given alias. + + If alias is not provided, it will return default connection. + If there is no default connection, it will return last opened connection. + """ + if not self._connections: + raise ValueError(f"No database connection is open.") + if not alias: + if self.default_alias in self._connections: + return self._connections[self.default_alias] + return list(self._connections.values())[-1] + if alias not in self._connections: + raise ValueError(f"Alias '{alias}' not found in existing connections.") + return self._connections[alias] + + def pop_connection(self, alias: Optional[str]) -> Connection: + if not self._connections: + return None + if not alias: + alias = self.default_alias + if alias not in self._connections: + alias = list(self._connections.keys())[-1] + return self._connections.pop(alias, None) + + def clear(self): + self._connections = {} + + def switch(self, alias: str): + if alias not in self._connections: + raise ValueError(f"Alias '{alias}' not found in existing connections.") + self.default_alias = alias + + def __iter__(self): + return iter(self._connections.values()) + + +class ConfigReader: + def __init__(self, config_file: Optional[str], alias: str): + if config_file is None: + config_file = "./resources/db.cfg" + self.alias = alias + self.config = self._load_config(config_file) + + @staticmethod + def _load_config(config_file: str) -> Optional[ConfigParser]: + config_path = Path(config_file) + logger.info(f"Looking for configuration file: '{config_path}'") + if not config_path.exists(): + logger.info("Configuration file doesn't exist") + return None + config = ConfigParser() + config.read([config_path]) + logger.info("Successfully loaded configuration file") + return config + + def pop(self, param: str) -> Optional[str]: + """ + Returns the `param` value read from the config file and deletes it from the list of all params read + """ + if self.config is None: + logger.debug("Configuration file not loaded") + return None + try: + logger.debug(f"Looking for parameter '{param}' in configuration file") + param_value = self.config.get(self.alias, param) + logger.info(f"Found parameter '{param}' in configuration file") + self.config.remove_option(self.alias, param) + return param_value + except NoSectionError: + logger.debug(f"Configuration file does not have [{self.alias}] section.") + except NoOptionError: + logger.debug(f"Parameter '{param}' missing in configuration file.") + return None + + def get_all_available_params(self) -> Dict: + """ + Returns a dictionary of all params read from the config file, which are currently available + (some of them might have been removed using the `pop` function) + """ + if self.config is None: + logger.debug("Configuration file not loaded") + return {} + try: + all_options = dict(self.config.items(self.alias)) + return all_options + except NoSectionError: + logger.debug(f"Configuration file does not have [{self.alias}] section.") + return {} + + +class ConnectionManager: """ Connection Manager handles the connection & disconnection to the database. """ - def __init__(self): - """ - Initializes _dbconnection to None. - """ - self._dbconnection = None - - def connect_to_database(self, dbapiModuleName=None, dbName=None, dbUsername=None, dbPassword=None, dbHost='localhost', dbPort="5432", dbConfigFile="./resources/db.cfg"): - """ - Loads the DB API 2.0 module given `dbapiModuleName` then uses it to - connect to the database using `dbName`, `dbUsername`, and `dbPassword`. - - Optionally, you can specify a `dbConfigFile` wherein it will load the - default property values for `dbapiModuleName`, `dbName` `dbUsername` - and `dbPassword` (note: specifying `dbapiModuleName`, `dbName` - `dbUsername` or `dbPassword` directly will override the properties of - the same key in `dbConfigFile`). If no `dbConfigFile` is specified, it - defaults to `./resources/db.cfg`. - - The `dbConfigFile` is useful if you don't want to check into your SCM - your database credentials. - - Example usage: - | # explicitly specifies all db property values | - | Connect To Database | psycopg2 | my_db | postgres | s3cr3t | tiger.foobar.com | 5432 | - - | # loads all property values from default.cfg | - | Connect To Database | dbConfigFile=default.cfg | - - | # loads all property values from ./resources/db.cfg | - | Connect To Database | - - | # uses explicit `dbapiModuleName` and `dbName` but uses the `dbUsername` and `dbPassword` in 'default.cfg' | - | Connect To Database | psycopg2 | my_db_test | dbConfigFile=default.cfg | - - | # uses explicit `dbapiModuleName` and `dbName` but uses the `dbUsername` and `dbPassword` in './resources/db.cfg' | - | Connect To Database | psycopg2 | my_db_test | - """ - - config = ConfigParser.ConfigParser() - config.read([dbConfigFile]) - - dbapiModuleName = dbapiModuleName or config.get('default', 'dbapiModuleName') - dbName = dbName or config.get('default', 'dbName') - dbUsername = dbUsername or config.get('default', 'dbUsername') - dbPassword = dbPassword or config.get('default', 'dbPassword') - dbHost = dbHost or config.get('default', 'dbHost') or 'localhost' - dbPort = int(dbPort or config.get('default', 'dbPort')) - - db_api_2 = __import__(dbapiModuleName) - if dbapiModuleName in ["MySQLdb", "pymysql"]: - dbPort = dbPort or 3306 - logger.debug ('Connecting using : %s.connect(db=%s, user=%s, passwd=%s, host=%s, port=%s) ' % (dbapiModuleName, dbName, dbUsername, dbPassword, dbHost, dbPort)) - self._dbconnection = db_api_2.connect (db=dbName, user=dbUsername, passwd=dbPassword, host=dbHost, port=dbPort) - elif dbapiModuleName in ["psycopg2"]: - dbPort = dbPort or 5432 - logger.debug ('Connecting using : %s.connect(database=%s, user=%s, password=%s, host=%s, port=%s) ' % (dbapiModuleName, dbName, dbUsername, dbPassword, dbHost, dbPort)) - self._dbconnection = db_api_2.connect (database=dbName, user=dbUsername, password=dbPassword, host=dbHost, port=dbPort) + def __init__(self, warn_on_connection_overwrite=True): + self.connection_store: ConnectionStore = ConnectionStore(warn_on_overwrite=warn_on_connection_overwrite) + self.ibmdb_driver_already_added_to_path: bool = False + + @staticmethod + def _hide_password_values(string_with_pass, params_separator=","): + string_with_hidden_pass = string_with_pass + for pass_param_name in ["pass", "passwd", "password", "pwd", "PWD"]: + pass_param_name += "=" + splitted = string_with_hidden_pass.split(pass_param_name) + if len(splitted) < 2: + continue + splitted = splitted[1].split(params_separator) + value_to_hide = splitted[0] + string_with_hidden_pass = string_with_hidden_pass.replace( + f"{pass_param_name}{value_to_hide}", f"{pass_param_name}***" + ) + return string_with_hidden_pass + + @renamed_args( + mapping={ + "dbapiModuleName": "db_module", + "dbName": "db_name", + "dbUsername": "db_user", + "dbPassword": "db_password", + "dbHost": "db_host", + "dbPort": "db_port", + "dbCharset": "db_charset", + "dbDriver": "odbc_driver", + "dbConfigFile": "config_file", + "driverMode": "oracle_driver_mode", + } + ) + def connect_to_database( + self, + db_module: Optional[str] = None, + db_name: Optional[str] = None, + db_user: Optional[str] = None, + db_password: Optional[str] = None, + db_host: Optional[str] = None, + db_port: Optional[int] = None, + db_charset: Optional[str] = None, + odbc_driver: Optional[str] = None, + config_file: Optional[str] = None, + oracle_driver_mode: Optional[str] = None, + alias: str = "default", + **custom_connection_params, + ): + """ + Creates a database connection using the DB API 2.0 ``db_module`` and the parameters provided. + Along with listed commonly used arguments (`db_name`, `db_host` etc.) + you can set any other DB module specific parameters as key/value pairs. + + Use ``config_file`` to provide a path to configuration file with connection parameters + to be used along with / instead of keyword arguments. + If no specified, it defaults to `./resources/db.cfg`. + See `Using configuration file` for more details. + + All params are optional, although ``db_module`` must be set - either as keyword argument or in config file. + If some of the listed keyword arguments (`db_name`, `db_host` etc.) are not provided (i.e. left on default value `None`), + they are normally not passed to the Python DB module at all, except: + - _db_port_ - commonly used port number for known databases is set as fallback + - _db_charset_ - _UTF8_ is used as fallback for _pymysql_, _pymssql_ and _pyodbc_ + - _oracle_driver_mode_ - _thin_ is used as fallback for _oracledb_ + + Other custom params from keyword arguments and config file are passed to the Python DB module as provided - + normally as arguments for the _connect()_ function. + However, when using *pyodbc* or *ibm_db_dbi*, the connection is established using a *connection string* - + so all the custom params are added into it instead of function arguments. + + Set ``alias`` for `Handling multiple database connections`. + If the same alias is given twice, then previous connection will be overridden. + + The ``oracle_driver_mode`` is used to select the *oracledb* client mode. + Allowed values are: + - _thin_ (default if omitted) + - _thick_ + - _thick,lib_dir=_ + + By default, there is a warning when overwriting an existing connection (i.e. not closing it properly). + This can be disabled by setting the ``warn_on_connection_overwrite`` parameter to *False* in the library import. + + === Some parameters were renamed in version 2.0 === + The old parameters ``dbapiModuleName``, ``dbName``, ``dbUsername``, + ``dbPassword``, ``dbHost``, ``dbPort``, ``dbCharset``, ``dbDriver``, + ``dbConfigFile`` and ``driverMode`` are *deprecated*, + please use new parameters ``db_module``, ``db_name``, ``db_user``, + ``db_password``, ``db_host``, ``db_port``, ``db_charset``, ``odbc_driver``, + ``config_file`` and ``oracle_driver_mode`` instead. + + *The old parameters will be removed in future versions.* + + == Basic examples == + | Connect To Database | psycopg2 | my_db | user | pass | 127.0.0.1 | 5432 | + | Connect To Database | psycopg2 | my_db | user | pass | 127.0.0.1 | 5432 | my_custom_param=value | + | Connect To Database | psycopg2 | my_db | user | pass | 127.0.0.1 | 5432 | alias=my_alias | + | Connect To Database | config_file=my_db_params.cfg | + + See `Connection examples for different DB modules`. + """ + config = ConfigReader(config_file, alias) + + def _build_connection_params(custom_params=True, **basic_params): + con_params = basic_params.copy() + for param_name, param_val in basic_params.items(): + if param_val is None: + con_params.pop(param_name, None) + if custom_params: + con_params.update(custom_connection_params) + con_params.update(other_config_file_params) + + return con_params + + def _log_all_connection_params(*, connection_object=None, connection_string=None, **connection_params): + connection_object = connection_object or db_module + msg = f"Connect to DB using : {connection_object}.connect(" + params_separator = "," + if connection_string: + msg += f'"{connection_string}"' + params_separator = ";" + for param_name, param_value in connection_params.items(): + msg += f", {param_name}=" + if isinstance(param_value, str): + msg += f"'{param_value}'" + else: + msg += f"{param_value}" + if db_password: + msg = msg.replace(f"'{db_password}'", "***") + msg = self._hide_password_values(msg, params_separator) + msg = msg.replace("connect(, ", "connect(") + msg += ")" + logger.info(msg) + + def _arg_or_config(arg_value, param_name, *, old_param_name=None, mandatory=False): + val_from_config = config.pop(param_name) + + # support deprecated old param names + if val_from_config is None and old_param_name is not None: + val_from_config = config.pop(old_param_name) + if val_from_config is not None: + logger.warn(f"Config file: argument '{old_param_name}' is deprecated, use '{param_name}' instead") + + if arg_value is not None: + final_value = arg_value + if val_from_config is not None: + logger.info( + f"Parameter '{param_name}' set both as keyword argument and in config file, " + "but keyword arguments take precedence" + ) + else: + final_value = val_from_config + if final_value is None and mandatory: + raise ValueError( + f"Required parameter '{param_name}' was not provided - " + "neither in keyword arguments nor in config file" + ) + return final_value + + # mandatory parameter + db_module = _arg_or_config(db_module, "db_module", mandatory=True, old_param_name="dbapiModuleName") + # optional named params - named because of custom module specific handling + db_name = _arg_or_config(db_name, "db_name", old_param_name="dbName") + db_user = _arg_or_config(db_user, "db_user", old_param_name="dbUsername") + db_password = _arg_or_config(db_password, "db_password", old_param_name="dbPassword") + db_host = _arg_or_config(db_host, "db_host", old_param_name="dbHost") + db_port = _arg_or_config(db_port, "db_port", old_param_name="dbPort") + if db_port is not None: + db_port = int(db_port) + db_charset = _arg_or_config(db_charset, "db_charset", old_param_name="dbCharset") + odbc_driver = _arg_or_config(odbc_driver, "odbc_driver", old_param_name="dbDriver") + oracle_driver_mode = _arg_or_config(oracle_driver_mode, "oracle_driver_mode", old_param_name="driverMode") + + for param_name, param_value in custom_connection_params.items(): + _arg_or_config(param_value, param_name) + other_config_file_params = config.get_all_available_params() + if other_config_file_params: + logger.info(f"Other params from configuration file: {list(other_config_file_params.keys())}") + + omit_trailing_semicolon = False + + if db_module == "excel" or db_module == "excelrw": + db_api_module_name = "pyodbc" else: - logger.debug ('Connecting using : %s.connect(database=%s, user=%s, password=%s, host=%s, port=%s) ' % (dbapiModuleName, dbName, dbUsername, dbPassword, dbHost, dbPort)) - self._dbconnection = db_api_2.connect (database=dbName, user=dbUsername, password=dbPassword, host=dbHost, port=dbPort) - - def connect_to_database_using_custom_params(self, dbapiModuleName=None, db_connect_string=''): - """ - Loads the DB API 2.0 module given `dbapiModuleName` then uses it to - connect to the database using the map string `db_custom_param_string`. - - Example usage: - | # for psycopg2 | + db_api_module_name = db_module + + if db_api_module_name in ["ibm_db", "ibm_db_dbi"]: + if os.name == "nt": + if not self.ibmdb_driver_already_added_to_path: + spec = importlib.util.find_spec(db_api_module_name) + if spec is not None: + logger.info( + f"Importing DB module '{db_api_module_name}' on Windows requires configuring the DLL directory for CLI driver" + ) + site_packages_path = os.path.dirname(spec.origin) + clidriver_bin_path = os.path.join(site_packages_path, "clidriver", "bin") + if os.path.exists(clidriver_bin_path): + os.add_dll_directory(clidriver_bin_path) + self.ibmdb_driver_already_added_to_path = True + logger.info(f"Added default CLI driver location to DLL search path: '{clidriver_bin_path}'") + else: + logger.info(f"Default CLI driver location folder not found: '{clidriver_bin_path}'") + + db_api_2 = importlib.import_module(db_api_module_name) + + if db_module in ["MySQLdb", "pymysql"]: + db_port = db_port or 3306 + db_charset = db_charset or "utf8mb4" + con_params = _build_connection_params( + db=db_name, user=db_user, passwd=db_password, host=db_host, port=db_port, charset=db_charset + ) + _log_all_connection_params(**con_params) + db_connection = db_api_2.connect(**con_params) + + elif db_module in ["pymssql"]: + db_port = db_port or 1433 + db_charset = db_charset or "UTF-8" + con_params = _build_connection_params( + database=db_name, user=db_user, password=db_password, host=db_host, port=db_port, charset=db_charset + ) + _log_all_connection_params(**con_params) + db_connection = db_api_2.connect(**con_params) + + elif db_module in ["psycopg2"]: + db_port = db_port or 5432 + con_params = _build_connection_params( + database=db_name, user=db_user, password=db_password, host=db_host, port=db_port + ) + _log_all_connection_params(**con_params) + db_connection = db_api_2.connect(**con_params) + + elif db_module in ["pyodbc", "pypyodbc"]: + db_port = db_port or 1433 + db_charset = db_charset or "utf8mb4" + + if odbc_driver: + con_str = f"DRIVER={odbc_driver};" + else: + con_str = "" + logger.info("No ODBC driver specified") + logger.info(f"List of installed ODBC drivers: {db_api_2.drivers()}") + if db_name: + con_str += f"DATABASE={db_name};" + if db_user: + con_str += f"UID={db_user};" + if db_password: + con_str += f"PWD={db_password};" + if db_charset: + con_str += f"charset={db_charset};" + if db_host and db_port: + con_str_server = f"SERVER={db_host},{db_port};" # default for most databases + if odbc_driver: + driver_lower = odbc_driver.lower() + if "mysql" in driver_lower: + con_str_server = f"SERVER={db_host}:{db_port};" + elif "saphana" in driver_lower or "hdbodbc" in driver_lower or "sap hana" in driver_lower: + con_str_server = f"SERVERNODE={db_host}:{db_port};" + con_str += con_str_server + + for param_name, param_value in custom_connection_params.items(): + con_str += f"{param_name}={param_value};" + + for param_name, param_value in other_config_file_params.items(): + con_str += f"{param_name}={param_value};" + + _log_all_connection_params(connection_string=con_str) + db_connection = db_api_2.connect(con_str) + + elif db_module in ["excel", "excelrw"]: + con_str = f"DRIVER={{Microsoft Excel Driver (*.xls, *.xlsx, *.xlsm, *.xlsb)}};DBQ={db_name};" + con_str += "ReadOnly=" + if db_module == "excel": + con_str += "1;" + elif db_module == "excelrw": + con_str += "0;" + con_str += 'Extended Properties="Excel 8.0;HDR=YES";)' + logger.info(f"Connecting using : {db_api_module_name}.connect({con_str}, autocommit=True)") + db_connection = db_api_2.connect(con_str, autocommit=True) + + elif db_module in ["ibm_db", "ibm_db_dbi"]: + db_port = db_port or 50000 + con_str = "" + if db_name: + con_str += f"DATABASE={db_name};" + if db_user: + con_str += f"UID={db_user};" + if db_password: + con_str += f"PWD={db_password};" + if db_host: + con_str += f"HOSTNAME={db_host};" + if db_port: + con_str += f"PORT={db_port};" + + for param_name, param_value in custom_connection_params.items(): + con_str += f"{param_name}={param_value};" + + for param_name, param_value in other_config_file_params.items(): + con_str += f"{param_name}={param_value};" + + con_params = _build_connection_params(custom_params=False, user="", password="") + _log_all_connection_params(connection_string=con_str, **con_params) + db_connection = db_api_2.connect(con_str, **con_params) + + elif db_module in ["cx_Oracle"]: + db_port = db_port or 1521 + oracle_dsn = db_api_2.makedsn(host=db_host, port=db_port, service_name=db_name) + con_params = _build_connection_params(user=db_user, password=db_password, dsn=oracle_dsn) + _log_all_connection_params(**con_params) + db_connection = db_api_2.connect(**con_params) + omit_trailing_semicolon = True + + elif db_module in ["oracledb"]: + db_port = db_port or 1521 + oracle_driver_mode = oracle_driver_mode or "thin" + oracle_connection_params = db_api_2.ConnectParams(host=db_host, port=db_port, service_name=db_name) + if "thick" in oracle_driver_mode.lower(): + logger.info("Using thick Oracle client mode") + mode_param = oracle_driver_mode.lower().split(",lib_dir=") + if len(mode_param) == 2 and mode_param[0].lower() == "thick": + lib_dir = mode_param[1] + logger.info(f"Oracle client lib dir specified: {lib_dir}") + db_api_2.init_oracle_client(lib_dir=lib_dir) + else: + logger.info("No Oracle client lib dir specified, oracledb will search it in usual places") + db_api_2.init_oracle_client() + oracle_thin_mode = False + elif "thin" in oracle_driver_mode.lower(): + oracle_thin_mode = True + logger.info("Using thin Oracle client mode") + else: + raise ValueError(f"Invalid Oracle client mode provided: {oracle_driver_mode}") + con_params = _build_connection_params(user=db_user, password=db_password, params=oracle_connection_params) + _log_all_connection_params(**con_params) + db_connection = db_api_2.connect(**con_params) + assert db_connection.thin == oracle_thin_mode, ( + f"Expected oracledb to run in thin mode: {oracle_thin_mode}, " + f"but the connection has thin mode: {db_connection.thin}" + ) + omit_trailing_semicolon = True + + elif db_module in ["teradata"]: + db_port = db_port or 1025 + teradata_udaExec = db_api_2.UdaExec(appName="RobotFramework", version="1.0", logConsole=False) + con_params = _build_connection_params( + method="odbc", + system=db_host, + database=db_name, + username=db_user, + password=db_password, + host=db_host, + port=db_port, + ) + _log_all_connection_params(connection_object=f"{db_module}.UdaExec", **con_params) + db_connection = teradata_udaExec.connect(**con_params) + + elif db_module in ["ksycopg2"]: + db_port = db_port or 54321 + con_params = _build_connection_params( + database=db_name, user=db_user, password=db_password, host=db_host, port=db_port + ) + _log_all_connection_params(**con_params) + db_connection = db_api_2.connect(**con_params) + + else: + con_params = _build_connection_params( + database=db_name, user=db_user, password=db_password, host=db_host, port=db_port + ) + _log_all_connection_params(**con_params) + db_connection = db_api_2.connect(**con_params) + + self.connection_store.register_connection(db_connection, db_api_module_name, alias, omit_trailing_semicolon) + + @renamed_args(mapping={"dbapiModuleName": "db_module"}) + def connect_to_database_using_custom_params( + self, + db_module: Optional[str] = None, + db_connect_string: str = "", + alias: str = "default", + *, + dbapiModuleName: Optional[str] = None, + ): + """ + *DEPRECATED* Use new `Connect To Database` keyword with custom parameters instead. + The deprecated keyword will be removed in future versions. + + Loads the DB API 2.0 module given ``db_module`` then uses it to + connect to the database using the map string ``db_connect_string`` + (parsed as a list of named arguments). + + Use `connect_to_database_using_custom_connection_string` for passing + all params in a single connection string or URI. + + === Some parameters were renamed in version 2.0 === + The old parameter ``dbapiModuleName`` is *deprecated*, + please use new parameter ``db_module`` instead. + + *The old parameter will be removed in future versions.* + + === Examples === | Connect To Database Using Custom Params | psycopg2 | database='my_db_test', user='postgres', password='s3cr3t', host='tiger.foobar.com', port=5432 | - - | # for JayDeBeApi | - | Connect To Database Using Custom Params | JayDeBeApi | 'oracle.jdbc.driver.OracleDriver', 'my_db_test', 'system', 's3cr3t' | - """ - db_api_2 = __import__(dbapiModuleName) - - db_connect_string = 'db_api_2.connect(%s)' % db_connect_string - - self._dbconnection = eval(db_connect_string) - - def disconnect_from_database(self): + | Connect To Database Using Custom Params | jaydebeapi | 'oracle.jdbc.driver.OracleDriver', 'my_db_test', 'system', 's3cr3t' | + | Connect To Database Using Custom Params | oracledb | user="username", password="pass", dsn="localhost/orclpdb" | + | Connect To Database Using Custom Params | sqlite3 | database="./my_database.db", isolation_level=None | + """ + db_api_2 = importlib.import_module(db_module) + db_api_module_name = db_module + db_connect_string = f"db_api_2.connect({db_connect_string})" + logger.info( + f"Executing : Connect To Database Using Custom Params : {db_module}.connect(" + f"{self._hide_password_values(db_connect_string)})" + ) + + db_connection = eval(db_connect_string) + self.connection_store.register_connection(db_connection, db_api_module_name, alias) + + @renamed_args(mapping={"dbapiModuleName": "db_module"}) + def connect_to_database_using_custom_connection_string( + self, + db_module: Optional[str] = None, + db_connect_string: str = "", + alias: str = "default", + *, + dbapiModuleName: Optional[str] = None, + ): + """ + Loads the DB API 2.0 module given ``db_module`` then uses it to + connect to the database using the ``db_connect_string`` + (parsed as single connection string or URI). + + Use `Connect To Database` for passing custom connection params as named arguments. + + === Some parameters were renamed in version 2.0 === + The old parameter ``dbapiModuleName`` is *deprecated*, + please use new parameter ``db_module`` instead. + + *The old parameter will be removed in future versions.* + + Example usage: + | Connect To Database Using Custom Connection String | psycopg2 | postgresql://postgres:s3cr3t@tiger.foobar.com:5432/my_db_test | + | Connect To Database Using Custom Connection String | oracledb | username/pass@localhost:1521/orclpdb | + """ + db_api_2 = importlib.import_module(db_module) + db_api_module_name = db_module + logger.info( + f"Executing : Connect To Database Using Custom Connection String : {db_module}.connect(" + f"'{db_connect_string}')" + ) + db_connection = db_api_2.connect(db_connect_string) + self.connection_store.register_connection(db_connection, db_api_module_name, alias) + + def disconnect_from_database(self, error_if_no_connection: bool = False, alias: Optional[str] = None): """ Disconnects from the database. - - For example: - | Disconnect From Database | # disconnects from current connection to the database | + + By default, it's not an error if there was no open database connection - + suitable for usage as a teardown. + However, you can enforce it using the ``error_if_no_connection`` parameter. + + Use ``alias`` to specify what connection should be closed if `Handling multiple database connections`. + + === Examples === + | Disconnect From Database | + | Disconnect From Database | alias=postgres | + """ + db_connection = self.connection_store.pop_connection(alias) + if db_connection is None: + log_msg = "No open database connection to close" + if error_if_no_connection: + raise ConnectionError(log_msg) from None + logger.info(log_msg) + else: + db_connection.client.close() + + def disconnect_from_all_databases(self): + """ + Disconnects from all the databases - + useful when testing with multiple database connections (aliases). + """ + for db_connection in self.connection_store: + db_connection.client.close() + self.connection_store.clear() + + @renamed_args(mapping={"autoCommit": "auto_commit"}) + def set_auto_commit( + self, auto_commit: bool = True, alias: Optional[str] = None, *, autoCommit: Optional[bool] = None + ): + """ + Explicitly sets the autocommit behavior of the database connection to ``auto_commit``. + See `Commit behavior` for details. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + === Some parameters were renamed in version 2.0 === + The old parameter ``autoCommit`` is *deprecated*, + please use new parameter ``auto_commit`` instead. + + *The old parameter will be removed in future versions.* + + === Examples === + | Set Auto Commit + | Set Auto Commit | False | + | Set Auto Commit | True | alias=postgres | + """ + db_connection = self.connection_store.get_connection(alias) + if db_connection.module_name == "jaydebeapi": + db_connection.client.jconn.setAutoCommit(auto_commit) + elif db_connection.module_name in ["ibm_db", "ibm_db_dbi"]: + raise ValueError(f"Setting autocommit for {db_connection.module_name} is not supported") + else: + db_connection.client.autocommit = auto_commit + + def switch_database(self, alias: str): + """ + Switch the default database connection to ``alias``. + + Examples: + | Switch Database | my_alias | + | Switch Database | alias=my_alias | + """ + self.connection_store.switch(alias) + + def set_omit_trailing_semicolon(self, omit_trailing_semicolon=True, alias: Optional[str] = None): + """ + Set the ``omit_trailing_semicolon`` to control the `Omitting trailing semicolon behavior` for the connection. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + Examples: + | Set Omit Trailing Semicolon | True | + | Set Omit Trailing Semicolon | False | alias=my_alias | """ - self._dbconnection.close() - + db_connection = self.connection_store.get_connection(alias) + db_connection.omit_trailing_semicolon = omit_trailing_semicolon diff --git a/src/DatabaseLibrary/params_decorator.py b/src/DatabaseLibrary/params_decorator.py new file mode 100644 index 00000000..b122b3f9 --- /dev/null +++ b/src/DatabaseLibrary/params_decorator.py @@ -0,0 +1,33 @@ +""" +These decorators are introduced for the transition from old argument naming / positioning to the new one. +""" +from functools import wraps + +from robot.api import logger + + +def renamed_args(mapping): + """ + Decorator to rename arguments and warn users about deprecated argument names. + + :param mapping: Dictionary mapping old argument names to new argument names. + :return: The decorated function with remapped arguments. + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Check if any old argument names are used + for old_name, new_name in mapping.items(): + if old_name in kwargs: + # Issue a warning to the user + logger.warn(f"Argument '{old_name}' is deprecated, use '{new_name}' instead") + # Move the argument value to the new name + logger.info(f"Replacing '{old_name}' with '{new_name}'") + kwargs[new_name] = kwargs.pop(old_name) + # Call the original function with updated kwargs + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/src/DatabaseLibrary/query.py b/src/DatabaseLibrary/query.py index cfeab27a..a9024dfe 100644 --- a/src/DatabaseLibrary/query.py +++ b/src/DatabaseLibrary/query.py @@ -12,252 +12,892 @@ # See the License for the specific language governing permissions and # limitations under the License. +import importlib +import inspect +import re +import sys +from typing import List, Optional, Tuple + +import sqlparse from robot.api import logger +from robot.libraries.BuiltIn import BuiltIn +from robot.utils.dotdict import DotDict + +from .connection_manager import Connection +from .params_decorator import renamed_args -class Query(object): + +class Query: """ - Query handles all the querying done by the Database Library. + Query handles all the querying done by the Database Library. """ - def query(self, selectStatement): + def __init__(self, log_query_results, log_query_results_head): + self.LOG_QUERY_RESULTS = log_query_results + self.LOG_QUERY_RESULTS_HEAD = log_query_results_head + + @renamed_args( + mapping={"selectStatement": "select_statement", "sansTran": "no_transaction", "returnAsDict": "return_dict"} + ) + def query( + self, + select_statement: str, + no_transaction: bool = False, + return_dict: bool = False, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + *, + replace_robot_variables=False, + selectStatement: Optional[str] = None, + sansTran: Optional[bool] = None, + returnAsDict: Optional[bool] = None, + ): """ - Uses the input `selectStatement` to query for the values that - will be returned as a list of tuples. - - Tip: Unless you want to log all column values of the specified rows, - try specifying the column names in your select statements - as much as possible to prevent any unnecessary surprises with schema - changes and to easily see what your [] indexing is trying to retrieve - (i.e. instead of `"select * from my_table"`, try - `"select id, col_1, col_2 from my_table"`). - - For example, given we have a table `person` with the following data: - | id | first_name | last_name | - | 1 | Franz Allan | See | - - When you do the following: - | @{queryResults} | Query | select * from person | - | Log Many | @{queryResults} | - - You will get the following: - [1, 'Franz Allan', 'See'] - - Also, you can do something like this: - | ${queryResults} | Query | select first_name, last_name from person | - | Log | ${queryResults[0][1]}, ${queryResults[0][0]} | - - And get the following - See, Franz Allan + Runs a query with the ``select_statement`` and returns the result as list of rows. + The type of row values depends on the database module - + usually they are tuples or tuple-like objects. + + Set ``no_transaction`` to _True_ to run command without explicit transaction commit or rollback in case of error. + See `Commit behavior` for details. + + Set ``return_dict`` to _True_ to explicitly convert the return values into list of dictionaries. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Set ``replace_robot_variables`` to resolve RF variables like _${MY_VAR}_ before executing the SQL. + + === Some parameters were renamed in version 2.0 === + The old parameters ``selectStatement``, ``sansTran`` and ``returnAsDict`` are *deprecated*, + please use new parameters ``select_statement``, ``no_transaction`` and ``return_dict`` instead. + + *The old parameters will be removed in future versions.* + + === Examples === + | ${Results}= | Query | select LAST_NAME from person | + | ${Results}= | Query | select LAST_NAME from person | no_transaction=True | + | ${Results}= | Query | select LAST_NAME from person | return_dict=True | + | ${Results}= | Query | select LAST_NAME from person | alias=postgres | + | @{parameters} | Create List | person | + | ${Results}= | Query | SELECT * FROM %s | parameters=${parameters} | """ + db_connection = self.connection_store.get_connection(alias) cur = None try: - cur = self._dbconnection.cursor() - self.__execute_sql(cur, selectStatement) - allRows = cur.fetchall() - return allRows - finally : - if cur : - self._dbconnection.rollback() - - def row_count(self, selectStatement): + cur = db_connection.client.cursor() + self._execute_sql( + cur, + select_statement, + parameters=parameters, + omit_trailing_semicolon=db_connection.omit_trailing_semicolon, + replace_robot_variables=replace_robot_variables, + ) + all_rows = cur.fetchall() + if all_rows is None: + all_rows = [] + self._commit_if_needed(db_connection, no_transaction) + col_names = [c[0] for c in cur.description] + self._log_query_results(col_names, all_rows) + if return_dict: + return [DotDict(zip(col_names, row)) for row in all_rows] + return all_rows + except Exception as e: + self._rollback_and_raise(db_connection, no_transaction, e) + + @renamed_args(mapping={"selectStatement": "select_statement", "sansTran": "no_transaction"}) + def row_count( + self, + select_statement: str, + no_transaction: bool = False, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + *, + replace_robot_variables=False, + selectStatement: Optional[str] = None, + sansTran: Optional[bool] = None, + ): """ - Uses the input `selectStatement` to query the database and returns - the number of rows from the query. - - For example, given we have a table `person` with the following data: - | id | first_name | last_name | - | 1 | Franz Allan | See | - | 2 | Jerry | Schneider | - - When you do the following: - | ${rowCount} | Row Count | select * from person | - | Log | ${rowCount} | - - You will get the following: - 2 - - Also, you can do something like this: - | ${rowCount} | Row Count | select * from person where id = 2 | - | Log | ${rowCount} | - - And get the following - 1 + Runs a query with the ``select_statement`` and returns the number of rows in the result. + + Set ``no_transaction`` to _True_ to run command without explicit transaction commit or rollback in case of error. + See `Commit behavior` for details. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Set ``replace_robot_variables`` to resolve RF variables like _${MY_VAR}_ before executing the SQL. + + === Some parameters were renamed in version 2.0 === + The old parameters ``selectStatement`` and ``sansTran`` are *deprecated*, + please use new parameters ``select_statement`` and ``no_transaction`` instead. + + *The old parameters will be removed in future versions.* + + === Examples === + | ${Rows}= | Row Count | select LAST_NAME from person | + | ${Rows}= | Row Count | select LAST_NAME from person | no_transaction=True | + | ${Rows}= | Row Count | select LAST_NAME from person | alias=postgres | + | @{parameters} | Create List | person | + | ${Rows}= | Row Count | SELECT * FROM %s | parameters=${parameters} | """ + db_connection = self.connection_store.get_connection(alias) cur = None try: - cur = self._dbconnection.cursor() - self.__execute_sql(cur, selectStatement) - cur.fetchall() - rowCount = cur.rowcount - return rowCount - finally : - if cur : - self._dbconnection.rollback() - - def description(self, selectStatement): + cur = db_connection.client.cursor() + self._execute_sql( + cur, + select_statement, + parameters=parameters, + omit_trailing_semicolon=db_connection.omit_trailing_semicolon, + replace_robot_variables=replace_robot_variables, + ) + data = cur.fetchall() + if data is None: + data = [] + self._commit_if_needed(db_connection, no_transaction) + col_names = [c[0] for c in cur.description] + if db_connection.module_name in ["sqlite3", "ibm_db", "ibm_db_dbi", "pyodbc", "jaydebeapi"]: + current_row_count = len(data) + else: + current_row_count = cur.rowcount + logger.info(f"Retrieved {current_row_count} rows") + self._log_query_results(col_names, data) + return current_row_count + except Exception as e: + self._rollback_and_raise(db_connection, no_transaction, e) + + @renamed_args(mapping={"selectStatement": "select_statement", "sansTran": "no_transaction"}) + def description( + self, + select_statement: str, + no_transaction: bool = False, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + *, + replace_robot_variables=False, + selectStatement: Optional[str] = None, + sansTran: Optional[bool] = None, + ): """ - Uses the input `selectStatement` to query a table in the db which - will be used to determine the description. - - For example, given we have a table `person` with the following data: - | id | first_name | last_name | - | 1 | Franz Allan | See | - - When you do the following: - | @{queryResults} | Description | select * from person | - | Log Many | @{queryResults} | - - You will get the following: - [Column(name='id', type_code=1043, display_size=None, internal_size=255, precision=None, scale=None, null_ok=None)] - [Column(name='first_name', type_code=1043, display_size=None, internal_size=255, precision=None, scale=None, null_ok=None)] - [Column(name='last_name', type_code=1043, display_size=None, internal_size=255, precision=None, scale=None, null_ok=None)] + Runs a query with the ``select_statement`` to determine the table description. + + Set ``no_transaction`` to _True_ to run command without explicit transaction commit or rollback in case of error. + See `Commit behavior` for details. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Set ``replace_robot_variables`` to resolve RF variables like _${MY_VAR}_ before executing the SQL. + + === Some parameters were renamed in version 2.0 === + The old parameters ``selectStatement`` and ``sansTran`` are *deprecated*, + please use new parameters ``select_statement`` and ``no_transaction`` instead. + + *The old parameters will be removed in future versions.* + + === Examples === + | ${Person table description}= | Description | select LAST_NAME from person | + | ${Person table description}= | Description | select LAST_NAME from person | no_transaction=True | + | ${Person table description}= | Description | select LAST_NAME from person | alias=postgres | + | @{parameters} | Create List | person | + | ${Person table description}= | Description | SELECT * FROM %s | parameters=${parameters} | """ + db_connection = self.connection_store.get_connection(alias) cur = None try: - cur = self._dbconnection.cursor() - self.__execute_sql(cur, selectStatement) - description = cur.description + cur = db_connection.client.cursor() + self._execute_sql( + cur, + select_statement, + parameters=parameters, + omit_trailing_semicolon=db_connection.omit_trailing_semicolon, + replace_robot_variables=replace_robot_variables, + ) + self._commit_if_needed(db_connection, no_transaction) + description = list(cur.description) + if sys.version_info[0] < 3: + for row in range(0, len(description)): + description[row] = (description[row][0].encode("utf-8"),) + description[row][1:] return description - finally : - if cur : - self._dbconnection.rollback() + except Exception as e: + self._rollback_and_raise(db_connection, no_transaction, e) - def delete_all_rows_from_table(self, tableName): + @renamed_args(mapping={"tableName": "table_name", "sansTran": "no_transaction"}) + def delete_all_rows_from_table( + self, + table_name: str, + no_transaction: bool = False, + alias: Optional[str] = None, + *, + tableName: Optional[str] = None, + sansTran: Optional[bool] = None, + ): """ - Delete all the rows within a given table. - - For example, given we have a table `person` in a database - - When you do the following: - | Delete All Rows From Table | person | + Deletes all rows from table with ``table_name``. + + Set ``no_transaction`` to _True_ to run command without explicit transaction commit + or rollback in case of error. + See `Commit behavior` for details. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + === Some parameters were renamed in version 2.0 === + The old parameters ``tableName`` and ``sansTran`` are *deprecated*, + please use new parameters ``table_name`` and ``no_transaction`` instead. - If all the rows can be successfully deleted, then you will get: - | Delete All Rows From Table | person | # PASS | - If the table doesn't exist or all the data can't be deleted, then you - will get: - | Delete All Rows From Table | first_name | # FAIL | + *The old parameters will be removed in future versions.* + + === Examples === + | Delete All Rows From Table | person | + | Delete All Rows From Table | person | no_transaction=True | + | Delete All Rows From Table | person | alias=my_alias | """ + db_connection = self.connection_store.get_connection(alias) cur = None - selectStatement = ("delete from %s;" % tableName) + query = f"DELETE FROM {table_name}" try: - cur = self._dbconnection.cursor() - result = self.__execute_sql(cur, selectStatement) + cur = db_connection.client.cursor() + result = self._execute_sql(cur, query) + self._commit_if_needed(db_connection, no_transaction) if result is not None: - return result.fetchall() - self._dbconnection.commit() - finally : - if cur : - self._dbconnection.rollback() + return result + except Exception as e: + self._rollback_and_raise(db_connection, no_transaction, e) - def execute_sql_script(self, sqlScriptFileName): + @renamed_args(mapping={"sqlScriptFileName": "script_path", "sansTran": "no_transaction"}) + def execute_sql_script( + self, + script_path: str, + no_transaction: bool = False, + alias: Optional[str] = None, + split: bool = True, + *, + external_parser=False, + replace_robot_variables=False, + sqlScriptFileName: Optional[str] = None, + sansTran: Optional[bool] = None, + ): """ - Executes the content of the `sqlScriptFileName` as SQL commands. - Useful for setting the database to a known state before running - your tests, or clearing out your test data after running each a - test. - - Sample usage : - | Execute Sql Script | ${EXECDIR}${/}resources${/}DDL-setup.sql | - | Execute Sql Script | ${EXECDIR}${/}resources${/}DML-setup.sql | - | #interesting stuff here | - | Execute Sql Script | ${EXECDIR}${/}resources${/}DML-teardown.sql | - | Execute Sql Script | ${EXECDIR}${/}resources${/}DDL-teardown.sql | - - SQL commands are expected to be delimited by a semi-colon (';'). - - For example: - delete from person_employee_table; - delete from person_table; - delete from employee_table; - - Also, the last SQL command can optionally omit its trailing semi-colon. - - For example: - delete from person_employee_table; - delete from person_table; - delete from employee_table - - Given this, that means you can create spread your SQL commands in several - lines. - - For example: - delete - from person_employee_table; - delete - from person_table; - delete - from employee_table - - However, lines that starts with a number sign (`#`) are treated as a - commented line. Thus, none of the contents of that line will be executed. - - For example: - # Delete the bridging table first... - delete - from person_employee_table; - # ...and then the bridged tables. - delete - from person_table; - delete - from employee_table - """ - sqlScriptFile = open(sqlScriptFileName) + Executes the content of the SQL script file loaded from `script_path` as SQL commands. + + SQL commands are expected to be delimited by a semicolon (';') - they will be split and executed separately. + Set ``split`` to _False_ to disable this behavior - in this case the entire script content + will be passed to the database module for execution as a single command. + + Set ``external_parser`` to _True_ to use the external library [https://pypi.org/project/sqlparse/|sqlparse] for splitting the script. + + Set ``no_transaction`` to _True_ to run command without explicit transaction commit + or rollback in case of error. + See `Commit behavior` for details. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + Set ``replace_robot_variables`` to resolve RF variables like _${MY_VAR}_ before executing the SQL. + === Some parameters were renamed in version 2.0 === + The old parameters ``sqlScriptFileName`` and ``sansTran`` are *deprecated*, + please use new parameters ``script_path`` and ``no_transaction`` instead. + + *The old parameters will be removed in future versions.* + + === Examples === + | Execute SQL Script | insert_data_in_person_table.sql | + | Execute SQL Script | insert_data_in_person_table.sql | no_transaction=True | + | Execute SQL Script | insert_data_in_person_table.sql | alias=postgres | + | Execute SQL Script | insert_data_in_person_table.sql | split=False | + """ + db_connection = self.connection_store.get_connection(alias) cur = None try: - cur = self._dbconnection.cursor() - sqlStatement = '' - for line in sqlScriptFile: - line = line.strip() - if line.startswith('#'): - continue - elif line.startswith('--'): - continue - - sqlFragments = line.split(';') - if len(sqlFragments) == 1: - sqlStatement += line + ' ' - else: + cur = db_connection.client.cursor() + if not split: + with open(script_path, encoding="UTF-8") as sql_file: + logger.info("Statements splitting disabled - pass entire script content to the database module") + self._execute_sql( + cur, + sql_file.read(), + omit_trailing_semicolon=db_connection.omit_trailing_semicolon, + replace_robot_variables=replace_robot_variables, + ) + else: + statements_to_execute = self.split_sql_script(script_path, external_parser=external_parser) + for statement in statements_to_execute: + proc_end_pattern = re.compile("end(?!( if;| loop;| case;| while;| repeat;)).*;()?") + line_ends_with_proc_end = re.compile(r"(\s|;)" + proc_end_pattern.pattern + "$") + omit_semicolon = not line_ends_with_proc_end.search(statement.lower()) + self._execute_sql(cur, statement, omit_semicolon, replace_robot_variables=replace_robot_variables) + self._commit_if_needed(db_connection, no_transaction) + except Exception as e: + self._rollback_and_raise(db_connection, no_transaction, e) + + def split_sql_script( + self, + script_path: str, + external_parser=False, + ): + """ + Splits the content of the SQL script file loaded from ``script_path`` into individual SQL commands + and returns them as a list of strings. + SQL commands are expected to be delimited by a semicolon (';'). + + Set ``external_parser`` to _True_ to use the external library [https://pypi.org/project/sqlparse/|sqlparse]. + """ + with open(script_path, encoding="UTF-8") as sql_file: + logger.info("Splitting script file into statements...") + statements_to_execute = [] + if external_parser: + statements_to_execute = sqlparse.split(sql_file.read()) + else: + current_statement = "" + inside_statements_group = False + proc_start_pattern = re.compile("create( or replace)? (procedure|function){1}( )?") + proc_end_pattern = re.compile("end(?!( if;| loop;| case;| while;| repeat;)).*;()?") + for line in sql_file: + line = line.strip() + if line.startswith("#") or line.startswith("--") or line == "/": + continue + + # check if the line matches the creating procedure regexp pattern + if proc_start_pattern.match(line.lower()): + inside_statements_group = True + elif line.lower().startswith("begin"): + inside_statements_group = True + + # semicolons inside the line? use them to separate statements + # ... but not if they are inside a begin/end block (aka. statements group) + sqlFragments = line.split(";") + # no semicolons + if len(sqlFragments) == 1: + current_statement += line + " " + continue + quotes = 0 + # "select * from person;" -> ["select..", ""] for sqlFragment in sqlFragments: - sqlFragment = sqlFragment.strip() - if len(sqlFragment) == 0: + if len(sqlFragment.strip()) == 0: continue - - sqlStatement += sqlFragment + ' ' - - self.__execute_sql(cur, sqlStatement) - sqlStatement = '' - - sqlStatement = sqlStatement.strip() - if len(sqlStatement) != 0: - self.__execute_sql(cur, sqlStatement) - - self._dbconnection.commit() - finally: - if cur : - self._dbconnection.rollback() - - def execute_sql_string(self, sqlString): + + if inside_statements_group: + # if statements inside a begin/end block have semicolns, + # they must persist - even with oracle + sqlFragment += "; " + + if proc_end_pattern.match(sqlFragment.lower()): + inside_statements_group = False + elif proc_start_pattern.match(sqlFragment.lower()): + inside_statements_group = True + elif sqlFragment.lower().startswith("begin"): + inside_statements_group = True + + # check if the semicolon is a part of the value (quoted string) + quotes += sqlFragment.count("'") + quotes -= sqlFragment.count("\\'") + inside_quoted_string = quotes % 2 != 0 + if inside_quoted_string: + sqlFragment += ";" # restore the semicolon + + current_statement += sqlFragment + if not inside_statements_group and not inside_quoted_string: + statements_to_execute.append(current_statement.strip()) + current_statement = "" + quotes = 0 + + current_statement = current_statement.strip() + if len(current_statement) != 0: + statements_to_execute.append(current_statement) + + return statements_to_execute + + @renamed_args( + mapping={ + "sqlString": "sql_string", + "sansTran": "no_transaction", + "omitTrailingSemicolon": "omit_trailing_semicolon", + } + ) + def execute_sql_string( + self, + sql_string: str, + no_transaction: bool = False, + alias: Optional[str] = None, + parameters: Optional[Tuple] = None, + omit_trailing_semicolon: Optional[bool] = None, + *, + replace_robot_variables=False, + sqlString: Optional[str] = None, + sansTran: Optional[bool] = None, + omitTrailingSemicolon: Optional[bool] = None, + ): + """ + Executes the ``sql_string`` as a single SQL command. + + Set ``no_transaction`` to _True_ to run command without explicit transaction commit + or rollback in case of error. + See `Commit behavior` for details. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + Use ``parameters`` for query variable substitution (variable substitution syntax may be different + depending on the database client). + + Set ``omit_trailing_semicolon`` to explicitly control the `Omitting trailing semicolon behavior` for the command. + + Set ``replace_robot_variables`` to resolve RF variables like _${MY_VAR}_ before executing the SQL. + + === Some parameters were renamed in version 2.0 === + The old parameters ``sqlString``, ``sansTran`` and ``omitTrailingSemicolon`` are *deprecated*, + please use new parameters ``sql_string``, ``no_transaction`` and ``omit_trailing_semicolon`` instead. + + *The old parameters will be removed in future versions.* + + === Examples === + | Execute Sql String | DELETE FROM person_employee_table; DELETE FROM person_table | + | Execute Sql String | DELETE FROM person_employee_table; DELETE FROM person_table | no_transaction=True | + | Execute Sql String | DELETE FROM person_employee_table; DELETE FROM person_table | alias=my_alias | + | Execute Sql String | CREATE PROCEDURE proc AS BEGIN DBMS_OUTPUT.PUT_LINE('Hello!'); END; | omit_trailing_semicolon=False | + | @{parameters} | Create List | person_employee_table | + | Execute Sql String | DELETE FROM %s | parameters=${parameters} | + """ + db_connection = self.connection_store.get_connection(alias) + cur = None + try: + cur = db_connection.client.cursor() + if omit_trailing_semicolon is None: + omit_trailing_semicolon = db_connection.omit_trailing_semicolon + self._execute_sql( + cur, + sql_string, + omit_trailing_semicolon=omit_trailing_semicolon, + parameters=parameters, + replace_robot_variables=replace_robot_variables, + ) + self._commit_if_needed(db_connection, no_transaction) + except Exception as e: + self._rollback_and_raise(db_connection, no_transaction, e) + + @renamed_args(mapping={"spName": "procedure_name", "spParams": "procedure_params", "sansTran": "no_transaction"}) + def call_stored_procedure( + self, + procedure_name: str, + procedure_params: Optional[List] = None, + no_transaction: bool = False, + alias: Optional[str] = None, + additional_output_params: Optional[List] = None, + *, + spName: Optional[str] = None, + spParams: Optional[List] = None, + sansTran: Optional[bool] = None, + ): """ - Executes the sqlString as SQL commands. - Useful to pass arguments to your sql. + Calls a stored procedure `procedure_name` with the `procedure_params` - a *list* of parameters the procedure requires. + *Returns two lists* - the _parameter values_ and the _result sets_. + + Use the special *CURSOR* value for OUT params, which should receive result sets - relevant only for some databases (e.g. Oracle or PostgreSQL). + + Set ``no_transaction`` to _True_ to run command without explicit transaction commit + or rollback in case of error. + See `Commit behavior` for details. + + Use ``alias`` to specify what connection should be used if `Handling multiple database connections`. + + Use the ``additional_output_params`` list for OUT params of a procedure in MSSQL. + + === Some parameters were renamed in version 2.0 === + The old parameters ``spName``, ``spParams`` and ``sansTran`` are *deprecated*, please use + new parameters ``procedure_name``, ``procedure_params`` and ``no_transaction`` instead. + + *The old parameters will be removed in future versions.* + + = Handling parameters and result sets = + Handling the input and output parameters and the result sets is very different + depending on the database itself and on the Python database driver - i.e. how it implements the `cursor.callproc()` function. + + == Common case (e.g. MySQL) == + Generally a procedure call requires all parameter values (IN and OUT) put together in a list - `procedure_params`. + + Calling the procedure returns *two lists*: + - *Param values* - the copy of procedure parameters (modified, if the procedure changes the OUT params). The list is empty, if procedures receives no params. + - *Result sets* - the list of lists, each of them containing results of some query, if the procedure returns them. + + == Oracle (oracledb, cx_Oracle) == + Oracle procedures work fine with simple IN and OUT params, but require some special handling of result sets. + + === Simple case with IN and OUT params (no result sets) === + Consider the following procedure: + | CREATE OR REPLACE PROCEDURE + | get_second_name (person_first_name IN VARCHAR, person_second_name OUT VARCHAR) AS + | BEGIN + | SELECT last_name + | INTO person_second_name + | FROM person + | WHERE first_name = person_first_name; + | END; + + Calling the procedure in Robot Framework: + | @{params}= Create List Jerry OUTPUT + | # Second parameter value can be anything, it will be replaced anyway + | + | ${param values} ${result sets}= Call Stored Procedure get_second_name ${params} + | # ${param values} = ['Jerry', 'Schneider'] + | # ${result sets} = [] + + === Oracle procedure returning a result set === + If a procedure in Oracle should return a result set, it must take OUT parameters of a special type - + _SYS_REFCURSOR_. + + Consider the following procedure: + | get_all_second_names (second_names_cursor OUT SYS_REFCURSOR) AS + | BEGIN + | OPEN second_names_cursor for + | SELECT LAST_NAME FROM person; + | END; + + Calling the procedure in Robot Framework requires the special value *CURSOR* for the OUT parameters, + they will be converted to appropriate DB variables before calling the procedure. + | @{params}= Create List CURSOR + | # The parameter must have this special value CURSOR + | + | ${param values} ${result sets}= Call Stored Procedure get_all_second_names ${params} + | # ${param values} = [>] + | # ${result sets} = [[('Franz Allan',), ('Jerry',)], [('See',), ('Schneider',)]] + + === Oracle procedure returning multiple result sets === + If a procedure takes multiple OUT parameters of the _SYS_REFCURSOR_ type, they all must have + the special *CURSOR* value when calling the procedure: + | @{params} = Create List CURSOR CURSOR + | ${param values} ${result sets} = Call Stored Procedure Get_all_first_and_second_names ${params} + | # ${param values} = [>, >] + | # ${result sets} = [[('Franz Allan',), ('Jerry',)], [('See',), ('Schneider',)]] + + == PostgreSQL (psycopg2, psycopg3) == + PostgreSQL doesn't return single values as params, only as result sets. + It also supports special handling of result sets over OUT params of a special type (like Oracle). + + === Simple case with IN and OUT params (no CURSOR parameters) === + Consider the following procedure: + | CREATE FUNCTION + | get_second_name (IN person_first_name VARCHAR(20), + | OUT person_second_name VARCHAR(20)) + | LANGUAGE plpgsql + | AS + | ' + | BEGIN + | SELECT LAST_NAME INTO person_second_name + | FROM person + | WHERE FIRST_NAME = person_first_name; + | END + | '; + + Calling the procedure in Robot Framework: + | @{params}= Create List Jerry + | ${param values} ${result sets}= Call Stored Procedure get_second_name ${params} + | # ${param values} = ['Jerry'] + | # ${result sets} = [[('Schneider',)]] + + === PostgreSQL procedure with CURSOR parameters === + If a procedure in PostgreSQL should return a proper result set, it must take OUT parameters of a special type - + _refcursor_. + + Consider the following procedure: + | CREATE FUNCTION + | get_all_first_and_second_names(result1 refcursor, result2 refcursor) + | RETURNS SETOF refcursor + | LANGUAGE plpgsql + | AS + | ' + | BEGIN + | OPEN result1 FOR SELECT FIRST_NAME FROM person; + | RETURN NEXT result1; + | OPEN result2 FOR SELECT LAST_NAME FROM person; + | RETURN NEXT result2; + | END + | '; + + Calling the procedure in Robot Framework requires the special value *CURSOR* for the OUT parameters, + they will be converted to appropriate DB variables before calling the procedure. + | @{params}= Create List CURSOR CURSOR + | # The parameters must have this special value CURSOR + | + | ${param values} ${result sets}= Call Stored Procedure get_all_first_and_second_names ${params} + | # ${param values} = ['CURSOR_0', 'CURSOR_1'] + | # ${result sets} = [[('Franz Allan',), ('Jerry',)], [('See',), ('Schneider',)] + + == MS SQL Server (pymssql) == + The _pymssql_ driver doesn't natively support getting the OUT parameter values after calling a procedure. + - This requires special handling of OUT parameters using the `additional_output_params` argument. + - Furthermore, it's not possible to fetch the OUT parameter values for a procedure, which returns a result set AND has OUT parameters. + + === Simple case with IN and OUT params (no result sets) === + Consider the following procedure: + | CREATE PROCEDURE + | return_out_param_without_result_sets + | @my_input VARCHAR(20), + | @my_output INT OUTPUT + | AS + | BEGIN + | IF @my_input = 'give me 1' + | BEGIN + | SELECT @my_output = 1; + | END + | ELSE + | BEGIN + | SELECT @my_output = 0; + | END + | END; + + Calling the procedure in Robot Framework requires putting the IN parameters as usual in the `procedure_params` argument, + but the sample values of OUT parameters must be put in the argument `additional_output_params`. - SQL commands are expected to be delimited by a semi-colon (';'). + | @{params}= Create List give me 1 + | @{out_params}= Create List ${9} + | ${param values} ${result sets}= Call Stored Procedure return_out_param_without_result_sets + | ... ${params} additional_output_params=${out_params} + | # ${result sets} = [] + | # ${param values} = ('give me 1', 1) - For example: - | Execute Sql String | delete from person_employee_table; delete from person_table | + The library uses the sample values in the `additional_output_params` list to determine the number and the type + of OUT parameters - so they are type-sensitive, the type must be the same as in the procedure itself. - For example with an argument: - | Execute Sql String | select from person where first_name = ${FIRSTNAME} | + === MS SQL procedure returning a result set (no OUT params) === + If a procedure doesn't have any OUT params and returns only result sets, they are handled in a normal way. + Consider the following procedure: + | CREATE PROCEDURE get_all_first_and_second_names + | AS + | BEGIN + | SELECT FIRST_NAME FROM person; + | SELECT LAST_NAME FROM person; + | RETURN; + | END; + + Calling the procedure in Robot Framework: + | ${param values} ${result sets}= Call Stored Procedure get_all_first_and_second_names + | ${param values} = () + | ${result sets} = [[('Franz Allan',), ('Jerry',)], [('See',), ('Schneider',)]] + + === MS SQL procedure returning result sets AND OUT params === + This case is *not fully supported* by the library - the OUT params won't be fetched. """ + db_connection = self.connection_store.get_connection(alias) + if procedure_params is None: + procedure_params = [] + if additional_output_params is None: + additional_output_params = [] + cur = None try: - cur = self._dbconnection.cursor() - self.__execute_sql(cur, sqlString) - self._dbconnection.commit() - finally: - if cur: - self._dbconnection.rollback() - - def __execute_sql(self, cur, sqlStatement): - logger.debug("Executing : %s" % sqlStatement) - return cur.execute(sqlStatement) + if db_connection.module_name == "pymssql": + cur = db_connection.client.cursor(as_dict=False) + else: + cur = db_connection.client.cursor() + + param_values = [] + result_sets = [] + + if db_connection.module_name == "pymysql": + cur.callproc(procedure_name, procedure_params) + + # first proceed the result sets if available + result_sets_available = True + while result_sets_available: + result_sets.append(list(cur.fetchall())) + result_sets_available = cur.nextset() + # last result set is always empty + # https://pymysql.readthedocs.io/en/latest/modules/cursors.html#pymysql.cursors.Cursor.callproc + result_sets.pop() + + # now go on with single values - modified input params + for i in range(0, len(procedure_params)): + cur.execute(f"select @_{procedure_name}_{i}") + param_values.append(cur.fetchall()[0][0]) + + elif db_connection.module_name in ["oracledb", "cx_Oracle"]: + # check if "CURSOR" params were passed - they will be replaced + # with cursor variables for storing the result sets + params_substituted = procedure_params.copy() + cursor_params = [] + for i in range(0, len(procedure_params)): + if procedure_params[i] == "CURSOR": + cursor_param = db_connection.client.cursor() + params_substituted[i] = cursor_param + cursor_params.append(cursor_param) + param_values = cur.callproc(procedure_name, params_substituted) + for result_set in cursor_params: + result_sets.append(list(result_set)) + + elif db_connection.module_name in ["psycopg2", "psycopg3"]: + # check if "CURSOR" params were passed - they will be replaced + # with cursor variables for storing the result sets + params_substituted = procedure_params.copy() + cursor_params = [] + for i in range(0, len(procedure_params)): + if procedure_params[i] == "CURSOR": + cursor_param = f"CURSOR_{i}" + params_substituted[i] = cursor_param + cursor_params.append(cursor_param) + param_values = cur.callproc(procedure_name, params_substituted) + if cursor_params: + for cursor_param in cursor_params: + cur.execute(f'FETCH ALL IN "{cursor_param}"') + result_set = cur.fetchall() + result_sets.append(list(result_set)) + else: + if db_connection.module_name in ["psycopg3"]: + result_sets_available = True + while result_sets_available: + result_sets.append(list(cur.fetchall())) + result_sets_available = cur.nextset() + else: + result_set = cur.fetchall() + result_sets.append(list(result_set)) + + else: + if db_connection.module_name == "pymssql": + mssql = importlib.import_module("pymssql") + procedure_params = procedure_params.copy() + for param in additional_output_params: + procedure_params.append(mssql.output(type(param), param)) + + else: + logger.info( + f"Calling a stored procedure for '{db_connection.module_name}'. " + "No special handling is known, so trying the common way with return params and result sets." + ) + + param_values = cur.callproc(procedure_name, procedure_params) + logger.info("Reading the procedure result sets..") + result_sets_available = True + while result_sets_available: + result_set = [] + for row in cur: + result_set.append(row) + if result_set: + result_sets.append(list(result_set)) + if hasattr(cur, "nextset") and inspect.isroutine(cur.nextset): + result_sets_available = cur.nextset() + else: + result_sets_available = False + + self._commit_if_needed(db_connection, no_transaction) + return param_values, result_sets + except Exception as e: + self._rollback_and_raise(db_connection, no_transaction, e) + + def set_logging_query_results(self, enabled: Optional[bool] = None, log_head: Optional[int] = None): + """ + Allows to enable/disable logging of query results and to adjust the log head value. + - Overrides the values, which were set during the library import. + - See `Logging query results` for details. + + Examples: + | Set Logging Query Results | enabled=False | + | Set Logging Query Results | enabled=True | log_head=0 | + | Set Logging Query Results | log_head=10 | + """ + if enabled is not None: + self.LOG_QUERY_RESULTS = enabled + if log_head is not None: + if log_head < 0: + raise ValueError(f"Wrong log head value provided: {log_head}. The value can't be negative!") + self.LOG_QUERY_RESULTS_HEAD = log_head + + def _execute_sql( + self, + cur, + sql_statement: str, + omit_trailing_semicolon: Optional[bool] = False, + parameters: Optional[Tuple] = None, + replace_robot_variables=False, + ): + """ + Runs the `sql_statement` using `cur` as Cursor object. + + Use `omit_trailing_semicolon` parameter (bool) for explicit instruction, + if the trailing semicolon (;) should be removed - otherwise the statement + won't be executed by some databases (e.g. Oracle). + Otherwise, it's decided based on the current database module in use. + """ + if omit_trailing_semicolon: + sql_statement = sql_statement.rstrip(";") + if replace_robot_variables: + sql_statement = BuiltIn().replace_variables(sql_statement) + if parameters is None: + logger.info(f'Executing sql:
{sql_statement}', html=True) + return cur.execute(sql_statement) + else: + logger.info( + f'Executing sql:
{sql_statement}
Parameters: {parameters}', + html=True, + ) + return cur.execute(sql_statement, parameters) + + def _commit_if_needed(self, db_connection: Connection, no_transaction): + if no_transaction: + logger.info(f"Perform no commit, because 'no_transaction' set to {no_transaction}") + else: + logger.info("Commit the transaction") + db_connection.client.commit() + + def _rollback_and_raise(self, db_connection: Connection, no_transaction, e): + logger.info(f"Error occurred: {e}") + if no_transaction: + logger.info(f"Perform no rollback, because 'no_transaction' set to {no_transaction}") + else: + logger.info("Rollback the transaction") + db_connection.client.rollback() + raise e + + def _log_query_results(self, col_names, result_rows, log_head: Optional[int] = None): + """ + Logs the `result_rows` of a query in RF log as a HTML table. + The `col_names` are needed for the table header. + Max. `log_head` rows are logged (`0` disables the limit). + """ + if not self.LOG_QUERY_RESULTS: + return + + if log_head is None: + log_head = self.LOG_QUERY_RESULTS_HEAD + + if result_rows is None: + result_rows = [] + + cell_border_and_align = "border: 1px solid rgb(160 160 160);padding: 8px 10px;text-align: center;" + table_border = "2px solid rgb(140 140 140)" + row_index_background_color = "#d6ecd4" + row_index_text_color = "black" + msg = '
' + msg += f'' + msg += f'' + msg += "" + msg += f'' + for col in col_names: + msg += f'' + msg += "" + table_truncated = False + for i, row in enumerate(result_rows): + if log_head and i >= log_head: + table_truncated = True + break + row_style = "" + if i % 2 == 0: + row_style = ' style="background-color: var(--secondary-color, #eee)"' + msg += f"" + msg += f'' + for cell in row: + try: + cell_string = str(cell) + except TypeError as e: + cell_string = f"Unable printing the value: {e}" + msg += f'' + msg += "" + msg += "
Query returned {len(result_rows)} rows
Row{col}
{i}{cell_string}
" + if table_truncated: + msg += ( + f'

Log limit of {log_head} rows was reached, the table was truncated

' + ) + msg += "
" + logger.info(msg, html=True) diff --git a/src/DatabaseLibrary/version.py b/src/DatabaseLibrary/version.py new file mode 100644 index 00000000..8e0257bf --- /dev/null +++ b/src/DatabaseLibrary/version.py @@ -0,0 +1 @@ +VERSION = "2.3.2" diff --git a/test/readme.md b/test/readme.md new file mode 100644 index 00000000..96870c43 --- /dev/null +++ b/test/readme.md @@ -0,0 +1,61 @@ +# Which tests run automatically in the pipeline? +- Tests from the folder `common_tests` run automatically in the pipeline after pushing in the repository +- The tests in the folder `custom_db_tests` are designed to run locally - they have to be triggered manually. I don't run them at all changes. +- There are some unit tests with pytest, but mostly there are acceptance tests with RF +- See the folder `.github/workflows` + +# Which databases / modules are covered? +- The acceptance tests in the pipeline don't cover all possible DB's - here is a lot of room for improvement +- Running tests locally require DB containers running - see below + +# Running tests locally from VS Code / terminal +- Selecting a DB module works via a global variable `GLOBAL_DB_SELECTOR` - set it from VSC or CLI +- Current debug/launch configs are implemented for old LSP plugin - still need to update to Robotcode from Daniel + +# Here are some advices for local testing of the library with different Python DB modules +## Oracle: +- https://github.com/gvenzl/oci-oracle-free +- https://hub.docker.com/r/gvenzl/oracle-free +- docker pull gvenzl/oracle-free +- docker run --rm --name oracle -d -p 1521:1521 -e ORACLE_PASSWORD=pass -e ORACLE_DATABASE=db -e APP_USER=db_user -e APP_USER_PASSWORD=pass gvenzl/oracle-free + +## PostgreSQL +- https://hub.docker.com/_/postgres +- docker pull postgres +- docker run --rm --name postgres -e POSTGRES_USER=db_user -e POSTGRES_PASSWORD=pass -e POSTGRES_DB=db -p 5432:5432 -d postgres + +## Teradata +- use VM image, e.g. in VirtualBox +- https://downloads.teradata.com/download/database/teradata-express/vmware +- use network bridge mode +- create new DB + CREATE DATABASE db + AS PERMANENT = 60e6, -- 60MB + SPOOL = 120e6; -- 120MB +- Install Teradata driver for your OS + https://downloads.teradata.com/download/connectivity/odbc-driver/windows + +- DEPRECATED: https://github.com/teradata/PyTd + -> new: https://github.com/Teradata/python-driver +- docs: https://quickstarts.teradata.com/getting.started.vbox.html + +## IBM Db2 +- https://hub.docker.com/r/ibmcom/db2 +- docker pull ibmcom/db2 +- docker run --rm -itd --name mydb2 --privileged=true -p 50000:50000 -e LICENSE=accept -e DB2INSTANCE=db_user -e DB2INST1_PASSWORD=pass -e DBNAME=db ibmcom/db2 +--> needs some minutes to start the DB !!! + +## MySQL +- https://hub.docker.com/_/mysql +- docker run --rm --name mysql -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_DATABASE=db -e MYSQL_USER=db_user -e MYSQL_PASSWORD=pass -p 3306:3306 -d mysql +- For tests with pyodbc install the ODBC driver https://learn.microsoft.com/en-us/sql/connect/odbc/windows/system-requirements-installation-and-driver-files?view=sql-server-ver16#installing-microsoft-odbc-driver-for-sql-server + +## Microsoft SQL Server +- https://hub.docker.com/_/microsoft-mssql-server +- docker run --rm --name mssql -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD='MyPass1234!' -p 1433:1433 -d mcr.microsoft.com/mssql/server +--> login and create DB: + - docker exec -it mssql bash + - /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P 'MyPass1234!' + - CREATE DATABASE db + - go +- docs: https://learn.microsoft.com/en-us/sql/linux/quickstart-install-connect-docker?view=sql-server-ver16&pivots=cs1-bash \ No newline at end of file diff --git a/test/resources/common.resource b/test/resources/common.resource new file mode 100644 index 00000000..7487618d --- /dev/null +++ b/test/resources/common.resource @@ -0,0 +1,126 @@ +*** Settings *** +Documentation Global variables, which are used in all test common tests +... and which should be set outside at the test execution start (e.g. in CI pipeline) + +Library Collections +Library OperatingSystem +Library DatabaseLibrary +Library DateTime +Resource config_files/connect_config_file.resource + + +*** Variables *** +${DB_MODULE_MODE} standard +${DB_MODULE} psycopg2 +${DB_HOST} 127.0.0.1 +${DB_NAME} db +${DB_PASS} pass +${DB_PORT} 5432 +${DB_USER} db_user +${Script files dir} ${CURDIR}/script_file_tests + +# used for MySQL via PyODBC only +${DB_DRIVER} ODBC Driver 18 for SQL Server + +# Oracle via Jaydebeapi +${DRIVER_PATH} ${CURDIR}/ojdbc17.jar +${DRIVER_CLASSNAME} oracle.jdbc.driver.OracleDriver +&{DRIVER_ARGS} user=${DB_USER} password=${DB_PASS} +${JDBC_URL} jdbc:oracle:thin:@${DB_HOST}:${DB_PORT}/${DB_NAME} + + +*** Keywords *** +Connect To DB + [Documentation] Connects to the database based on the current DB module under test + ... and connection params set in global variables with alias + [Arguments] ${alias}=${None} + ${DB_KWARGS}= Create Dictionary + IF $alias is not None + Set To Dictionary ${DB_KWARGS} alias=${alias} + END + IF "${DB_MODULE_MODE}" == "custom" + IF "${DB_MODULE}" == "sqlite3" + Remove File ${DBName}.db + Connect To Database sqlite3 database=./${DBName}.db isolation_level=${None} + ... &{DB_KWARGS} + ELSE IF "${DB_MODULE}" == "jaydebeapi" + Connect To Database ${DB_MODULE} jclassname=${DRIVER_CLASSNAME} url=${JDBC_URL} + ... driver_args=${DRIVER_ARGS} jars=${DRIVER_PATH} &{DB_KWARGS} + Set Auto Commit False alias=${alias} + Set Omit Trailing Semicolon True alias=${alias} + ELSE + ${Connection String}= Build Connection String + Connect To Database Using Custom Connection String ${DB_MODULE} ${Connection String} &{DB_KWARGS} + END + ELSE IF "${DB_MODULE_MODE}" == "standard" + ${DB_ARGS}= Create List + ... ${DB_MODULE} + ... ${DB_NAME} + ... ${DB_USER} + ... ${DB_PASS} + ... ${DB_HOST} + ... ${DB_PORT} + IF "${DB_MODULE}" == "pyodbc" + Set To Dictionary ${DB_KWARGS} odbc_driver=${DB_DRIVER} + END + Connect To Database @{DB_ARGS} &{DB_KWARGS} + ELSE + Fail Unexpected mode - ${DB_MODULE_MODE} + END + +Build Connection String + [Documentation] Returns the connection string variable depending on the DB module + ... currently under test. + IF "${DB_MODULE}" == "oracledb" + ${Result}= Set Variable + ... ${DB_USER}/${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME} + ELSE IF "${DB_MODULE}" == "psycopg2" + ${Result}= Set Variable + ... postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME} + ELSE + Skip Don't know how to build a connection string for '${DB_MODULE}' + END + RETURN ${Result} + +Create Person Table + ${sql}= Catenate + ... CREATE TABLE person + ... (id integer not null unique, FIRST_NAME varchar(20), LAST_NAME varchar(20)) + ${output}= Execute Sql String ${sql} + RETURN ${output} + +Create Person Table And Insert Data + Create Person Table + Insert Data In Person Table Using SQL Script + +Insert Data In Person Table Using SQL Script + [Arguments] ${alias}=${None} + IF $alias is None + ${output}= Execute SQL Script ${CURDIR}/insert_data_in_person_table.sql + ELSE + ${output}= Execute SQL Script ${CURDIR}/insert_data_in_person_table.sql alias=${alias} + END + RETURN ${output} + +Create Foobar Table + ${sql}= Catenate + ... CREATE TABLE foobar + ... (id integer not null primary key, FIRST_NAME varchar(30) not null unique) + ${output}= Execute Sql String ${sql} + RETURN ${output} + +Create Foobar Table And Insert Data + Create Foobar Table + Execute SQL String INSERT INTO foobar VALUES(1,'Jerry') + +Create Tables Person And Foobar + Create Person Table + Create Foobar Table + +Drop Tables Person And Foobar + Sleep 1s + FOR ${table} IN person foobar + ${exists}= Run Keyword And Return Status + ... Table Must Exist ${table} + IF ${exists} Execute Sql String DROP TABLE ${table} + END diff --git a/test/resources/config_files/connect_config_file.resource b/test/resources/config_files/connect_config_file.resource new file mode 100644 index 00000000..621ecd36 --- /dev/null +++ b/test/resources/config_files/connect_config_file.resource @@ -0,0 +1,11 @@ +*** Settings *** +Resource ../common.resource + + +*** Keywords *** +Connect Using Config File + [Documentation] `File name` is only name without extension, + ... the path is build relative to the resource directory + [Arguments] ${File name}=${None} &{Params} + ${Path}= Set Variable ${CURDIR}/${File name}.cfg + Connect To Database config_file=${Path} &{Params} diff --git a/test/resources/config_files/oracledb/custom_param_password.cfg b/test/resources/config_files/oracledb/custom_param_password.cfg new file mode 100644 index 00000000..d6d93b96 --- /dev/null +++ b/test/resources/config_files/oracledb/custom_param_password.cfg @@ -0,0 +1,6 @@ +[default] +db_module=oracledb +db_name=db +password=pass +db_host=127.0.0.1 +db_port=1521 \ No newline at end of file diff --git a/test/resources/config_files/oracledb/invalid_custom_params.cfg b/test/resources/config_files/oracledb/invalid_custom_params.cfg new file mode 100644 index 00000000..1214b8be --- /dev/null +++ b/test/resources/config_files/oracledb/invalid_custom_params.cfg @@ -0,0 +1,8 @@ +[default] +db_module=oracledb +db_name=db +db_user=db_user +db_password=pass +db_host=127.0.0.1 +db_port=1521 +blah=blah \ No newline at end of file diff --git a/test/resources/config_files/oracledb/old_param_names.cfg b/test/resources/config_files/oracledb/old_param_names.cfg new file mode 100644 index 00000000..2d81cd40 --- /dev/null +++ b/test/resources/config_files/oracledb/old_param_names.cfg @@ -0,0 +1,8 @@ +[default] +dbapiModuleName=oracledb +dbName=db +dbUsername=db_user +dbPassword=pass +dbHost=127.0.0.1 +dbPort=1521 +driverMode=thin \ No newline at end of file diff --git a/test/resources/config_files/oracledb/simple_default_alias.cfg b/test/resources/config_files/oracledb/simple_default_alias.cfg new file mode 100644 index 00000000..fe487e8e --- /dev/null +++ b/test/resources/config_files/oracledb/simple_default_alias.cfg @@ -0,0 +1,8 @@ +[default] +db_module=oracledb +db_name=db +db_user=db_user +db_password=pass +db_host=127.0.0.1 +db_port=1521 +oracle_driver_mode=thin \ No newline at end of file diff --git a/test/resources/config_files/oracledb/some_basic_params_missing.cfg b/test/resources/config_files/oracledb/some_basic_params_missing.cfg new file mode 100644 index 00000000..7e845af3 --- /dev/null +++ b/test/resources/config_files/oracledb/some_basic_params_missing.cfg @@ -0,0 +1,3 @@ +[default] +db_module=oracledb +db_name=db \ No newline at end of file diff --git a/test/resources/config_files/oracledb/thick_mode.cfg b/test/resources/config_files/oracledb/thick_mode.cfg new file mode 100644 index 00000000..bd1a4875 --- /dev/null +++ b/test/resources/config_files/oracledb/thick_mode.cfg @@ -0,0 +1,8 @@ +[default] +db_module=oracledb +db_name=db +db_user=db_user +db_password=pass +db_host=127.0.0.1 +db_port=1521 +oracle_driver_mode=thick \ No newline at end of file diff --git a/test/resources/config_files/oracledb/valid_custom_params.cfg b/test/resources/config_files/oracledb/valid_custom_params.cfg new file mode 100644 index 00000000..df3bffd1 --- /dev/null +++ b/test/resources/config_files/oracledb/valid_custom_params.cfg @@ -0,0 +1,7 @@ +[default] +db_module=oracledb +db_name=db +user=db_user +password=pass +db_host=127.0.0.1 +db_port=1521 \ No newline at end of file diff --git a/test/resources/config_files/oracledb/wrong_password.cfg b/test/resources/config_files/oracledb/wrong_password.cfg new file mode 100644 index 00000000..87691ac7 --- /dev/null +++ b/test/resources/config_files/oracledb/wrong_password.cfg @@ -0,0 +1,7 @@ +[default] +db_module=oracledb +db_name=db +db_user=db_user +db_password=wrong +db_host=127.0.0.1 +db_port=1521 \ No newline at end of file diff --git a/test/resources/config_files/psycopg2/custom_param_password.cfg b/test/resources/config_files/psycopg2/custom_param_password.cfg new file mode 100644 index 00000000..1ae5cb93 --- /dev/null +++ b/test/resources/config_files/psycopg2/custom_param_password.cfg @@ -0,0 +1,6 @@ +[default] +db_module=psycopg2 +db_name=db +password=pass +db_host=127.0.0.1 +db_port=5432 \ No newline at end of file diff --git a/test/resources/config_files/psycopg2/invalid_custom_params.cfg b/test/resources/config_files/psycopg2/invalid_custom_params.cfg new file mode 100644 index 00000000..14969652 --- /dev/null +++ b/test/resources/config_files/psycopg2/invalid_custom_params.cfg @@ -0,0 +1,8 @@ +[default] +db_module=psycopg2 +db_name=db +db_user=db_user +db_password=pass +db_host=127.0.0.1 +db_port=5432 +blah=blah \ No newline at end of file diff --git a/test/resources/config_files/psycopg2/old_param_names.cfg b/test/resources/config_files/psycopg2/old_param_names.cfg new file mode 100644 index 00000000..d9faef72 --- /dev/null +++ b/test/resources/config_files/psycopg2/old_param_names.cfg @@ -0,0 +1,7 @@ +[default] +dbapiModuleName=psycopg2 +dbName=db +dbUsername=db_user +dbPassword=pass +dbHost=127.0.0.1 +dbPort=5432 \ No newline at end of file diff --git a/test/resources/config_files/psycopg2/simple_default_alias.cfg b/test/resources/config_files/psycopg2/simple_default_alias.cfg new file mode 100644 index 00000000..a80ef74b --- /dev/null +++ b/test/resources/config_files/psycopg2/simple_default_alias.cfg @@ -0,0 +1,7 @@ +[default] +db_module=psycopg2 +db_name=db +db_user=db_user +db_password=pass +db_host=127.0.0.1 +db_port=5432 \ No newline at end of file diff --git a/test/resources/config_files/psycopg2/some_basic_params_missing.cfg b/test/resources/config_files/psycopg2/some_basic_params_missing.cfg new file mode 100644 index 00000000..49d1cdee --- /dev/null +++ b/test/resources/config_files/psycopg2/some_basic_params_missing.cfg @@ -0,0 +1,3 @@ +[default] +db_module=psycopg2 +db_name=db \ No newline at end of file diff --git a/test/resources/config_files/psycopg2/valid_custom_params.cfg b/test/resources/config_files/psycopg2/valid_custom_params.cfg new file mode 100644 index 00000000..fb15ffac --- /dev/null +++ b/test/resources/config_files/psycopg2/valid_custom_params.cfg @@ -0,0 +1,7 @@ +[default] +db_module=psycopg2 +db_name=db +user=db_user +password=pass +db_host=127.0.0.1 +db_port=5432 \ No newline at end of file diff --git a/test/resources/config_files/psycopg2/wrong_password.cfg b/test/resources/config_files/psycopg2/wrong_password.cfg new file mode 100644 index 00000000..9e97614c --- /dev/null +++ b/test/resources/config_files/psycopg2/wrong_password.cfg @@ -0,0 +1,7 @@ +[default] +db_module=psycopg2 +db_name=db +db_user=db_user +db_password=wrong +db_host=127.0.0.1 +db_port=5432 \ No newline at end of file diff --git a/test/resources/config_files/pymssql/charset_invalid.cfg b/test/resources/config_files/pymssql/charset_invalid.cfg new file mode 100644 index 00000000..dab69516 --- /dev/null +++ b/test/resources/config_files/pymssql/charset_invalid.cfg @@ -0,0 +1,8 @@ +[default] +db_module=pymssql +db_name=db +user=SA +password=MyPass1234! +db_host=127.0.0.1 +db_port=1433 +db_charset=wrong \ No newline at end of file diff --git a/test/resources/config_files/pymssql/custom_param_password.cfg b/test/resources/config_files/pymssql/custom_param_password.cfg new file mode 100644 index 00000000..ff03d151 --- /dev/null +++ b/test/resources/config_files/pymssql/custom_param_password.cfg @@ -0,0 +1,6 @@ +[default] +db_module=pymssql +db_name=db +password=MyPass1234! +db_host=127.0.0.1 +db_port=1433 \ No newline at end of file diff --git a/test/resources/config_files/pymssql/invalid_custom_params.cfg b/test/resources/config_files/pymssql/invalid_custom_params.cfg new file mode 100644 index 00000000..1c6c801e --- /dev/null +++ b/test/resources/config_files/pymssql/invalid_custom_params.cfg @@ -0,0 +1,8 @@ +[default] +db_module=pymssql +db_name=db +db_user=SA +db_password=MyPass1234! +db_host=127.0.0.1 +db_port=1433 +blah=blah \ No newline at end of file diff --git a/test/resources/config_files/pymssql/old_param_names.cfg b/test/resources/config_files/pymssql/old_param_names.cfg new file mode 100644 index 00000000..00a68add --- /dev/null +++ b/test/resources/config_files/pymssql/old_param_names.cfg @@ -0,0 +1,7 @@ +[default] +dbapiModuleName=pymssql +dbName=db +dbUsername=SA +dbPassword=MyPass1234! +dbHost=127.0.0.1 +dbPort=1433 \ No newline at end of file diff --git a/test/resources/config_files/pymssql/simple_default_alias.cfg b/test/resources/config_files/pymssql/simple_default_alias.cfg new file mode 100644 index 00000000..cd111ab1 --- /dev/null +++ b/test/resources/config_files/pymssql/simple_default_alias.cfg @@ -0,0 +1,7 @@ +[default] +db_module=pymssql +db_name=db +db_user=SA +db_password=MyPass1234! +db_host=127.0.0.1 +db_port=1433 \ No newline at end of file diff --git a/test/resources/config_files/pymssql/some_basic_params_missing.cfg b/test/resources/config_files/pymssql/some_basic_params_missing.cfg new file mode 100644 index 00000000..20e4533b --- /dev/null +++ b/test/resources/config_files/pymssql/some_basic_params_missing.cfg @@ -0,0 +1,3 @@ +[default] +db_module=pymssql +db_name=db \ No newline at end of file diff --git a/test/resources/config_files/pymssql/valid_custom_params.cfg b/test/resources/config_files/pymssql/valid_custom_params.cfg new file mode 100644 index 00000000..47613a21 --- /dev/null +++ b/test/resources/config_files/pymssql/valid_custom_params.cfg @@ -0,0 +1,7 @@ +[default] +db_module=pymssql +db_name=db +user=SA +password=MyPass1234! +db_host=127.0.0.1 +db_port=1433 \ No newline at end of file diff --git a/test/resources/config_files/pymssql/wrong_password.cfg b/test/resources/config_files/pymssql/wrong_password.cfg new file mode 100644 index 00000000..04b37f2e --- /dev/null +++ b/test/resources/config_files/pymssql/wrong_password.cfg @@ -0,0 +1,7 @@ +[default] +db_module=pymssql +db_name=db +db_user=SA +db_password=wrong +db_host=127.0.0.1 +db_port=1433 \ No newline at end of file diff --git a/test/resources/config_files/pymysql/charset_invalid.cfg b/test/resources/config_files/pymysql/charset_invalid.cfg new file mode 100644 index 00000000..9eb9a14f --- /dev/null +++ b/test/resources/config_files/pymysql/charset_invalid.cfg @@ -0,0 +1,8 @@ +[default] +db_module=pymysql +db_name=db +user=db_user +password=pass +db_host=127.0.0.1 +db_port=3306 +db_charset=wrong \ No newline at end of file diff --git a/test/resources/config_files/pymysql/custom_param_password.cfg b/test/resources/config_files/pymysql/custom_param_password.cfg new file mode 100644 index 00000000..52b68e76 --- /dev/null +++ b/test/resources/config_files/pymysql/custom_param_password.cfg @@ -0,0 +1,6 @@ +[default] +db_module=pymysql +db_name=db +password=pass +db_host=127.0.0.1 +db_port=3306 \ No newline at end of file diff --git a/test/resources/config_files/pymysql/invalid_custom_params.cfg b/test/resources/config_files/pymysql/invalid_custom_params.cfg new file mode 100644 index 00000000..46975eff --- /dev/null +++ b/test/resources/config_files/pymysql/invalid_custom_params.cfg @@ -0,0 +1,8 @@ +[default] +db_module=pymysql +db_name=db +db_user=db_user +db_password=pass +db_host=127.0.0.1 +db_port=3306 +blah=blah \ No newline at end of file diff --git a/test/resources/config_files/pymysql/old_param_names.cfg b/test/resources/config_files/pymysql/old_param_names.cfg new file mode 100644 index 00000000..0d73312d --- /dev/null +++ b/test/resources/config_files/pymysql/old_param_names.cfg @@ -0,0 +1,7 @@ +[default] +dbapiModuleName=pymysql +dbName=db +dbUsername=db_user +dbPassword=pass +dbHost=127.0.0.1 +dbPort=3306 \ No newline at end of file diff --git a/test/resources/config_files/pymysql/simple_default_alias.cfg b/test/resources/config_files/pymysql/simple_default_alias.cfg new file mode 100644 index 00000000..d4242af6 --- /dev/null +++ b/test/resources/config_files/pymysql/simple_default_alias.cfg @@ -0,0 +1,7 @@ +[default] +db_module=pymysql +db_name=db +db_user=db_user +db_password=pass +db_host=127.0.0.1 +db_port=3306 \ No newline at end of file diff --git a/test/resources/config_files/pymysql/some_basic_params_missing.cfg b/test/resources/config_files/pymysql/some_basic_params_missing.cfg new file mode 100644 index 00000000..f6c24e4e --- /dev/null +++ b/test/resources/config_files/pymysql/some_basic_params_missing.cfg @@ -0,0 +1,3 @@ +[default] +db_module=pymysql +db_name=db \ No newline at end of file diff --git a/test/resources/config_files/pymysql/valid_custom_params.cfg b/test/resources/config_files/pymysql/valid_custom_params.cfg new file mode 100644 index 00000000..dcd264a0 --- /dev/null +++ b/test/resources/config_files/pymysql/valid_custom_params.cfg @@ -0,0 +1,7 @@ +[default] +db_module=pymysql +db_name=db +user=db_user +password=pass +db_host=127.0.0.1 +db_port=3306 \ No newline at end of file diff --git a/test/resources/config_files/pymysql/wrong_password.cfg b/test/resources/config_files/pymysql/wrong_password.cfg new file mode 100644 index 00000000..5d8921ef --- /dev/null +++ b/test/resources/config_files/pymysql/wrong_password.cfg @@ -0,0 +1,7 @@ +[default] +db_module=pymysql +db_name=db +db_user=db_user +db_password=wrong +db_host=127.0.0.1 +db_port=3306 \ No newline at end of file diff --git a/test/resources/config_files/pyodbc/charset_invalid.cfg b/test/resources/config_files/pyodbc/charset_invalid.cfg new file mode 100644 index 00000000..7020a6fd --- /dev/null +++ b/test/resources/config_files/pyodbc/charset_invalid.cfg @@ -0,0 +1,9 @@ +[default] +db_module=pyodbc +db_name=db +user=db_user +password=pass +db_host=127.0.0.1 +db_port=3306 +odbc_driver={MySQL ODBC 9.2 ANSI Driver} +db_charset=wrong \ No newline at end of file diff --git a/test/resources/config_files/pyodbc/custom_param_password.cfg b/test/resources/config_files/pyodbc/custom_param_password.cfg new file mode 100644 index 00000000..7f64dd2b --- /dev/null +++ b/test/resources/config_files/pyodbc/custom_param_password.cfg @@ -0,0 +1,7 @@ +[default] +db_module=pyodbc +db_name=db +PWD=pass +db_host=127.0.0.1 +db_port=3306 +odbc_driver={MySQL ODBC 9.2 ANSI Driver} \ No newline at end of file diff --git a/test/resources/config_files/pyodbc/invalid_custom_params.cfg b/test/resources/config_files/pyodbc/invalid_custom_params.cfg new file mode 100644 index 00000000..e40c70a7 --- /dev/null +++ b/test/resources/config_files/pyodbc/invalid_custom_params.cfg @@ -0,0 +1,9 @@ +[default] +db_module=pyodbc +db_name=db +db_user=db_user +db_password=pass +db_host=127.0.0.1 +db_port=3306 +odbc_driver={MySQL ODBC 9.2 ANSI Driver} +blah=blah \ No newline at end of file diff --git a/test/resources/config_files/pyodbc/old_param_names.cfg b/test/resources/config_files/pyodbc/old_param_names.cfg new file mode 100644 index 00000000..3698fd26 --- /dev/null +++ b/test/resources/config_files/pyodbc/old_param_names.cfg @@ -0,0 +1,8 @@ +[default] +dbapiModuleName=pyodbc +dbName=db +dbUsername=db_user +dbPassword=pass +dbHost=127.0.0.1 +dbPort=3306 +dbDriver={MySQL ODBC 9.2 ANSI Driver} \ No newline at end of file diff --git a/test/resources/config_files/pyodbc/simple_default_alias.cfg b/test/resources/config_files/pyodbc/simple_default_alias.cfg new file mode 100644 index 00000000..78cbcbfb --- /dev/null +++ b/test/resources/config_files/pyodbc/simple_default_alias.cfg @@ -0,0 +1,8 @@ +[default] +db_module=pyodbc +db_name=db +db_user=db_user +db_password=pass +db_host=127.0.0.1 +db_port=3306 +odbc_driver={MySQL ODBC 9.2 ANSI Driver} \ No newline at end of file diff --git a/test/resources/config_files/pyodbc/some_basic_params_missing.cfg b/test/resources/config_files/pyodbc/some_basic_params_missing.cfg new file mode 100644 index 00000000..16a3448f --- /dev/null +++ b/test/resources/config_files/pyodbc/some_basic_params_missing.cfg @@ -0,0 +1,3 @@ +[default] +db_module=pyodbc +db_name=db \ No newline at end of file diff --git a/test/resources/config_files/pyodbc/valid_custom_params.cfg b/test/resources/config_files/pyodbc/valid_custom_params.cfg new file mode 100644 index 00000000..fd441a7b --- /dev/null +++ b/test/resources/config_files/pyodbc/valid_custom_params.cfg @@ -0,0 +1,8 @@ +[default] +db_module=pyodbc +db_name=db +UID=db_user +PWD=pass +db_host=127.0.0.1 +db_port=3306 +odbc_driver={MySQL ODBC 9.2 ANSI Driver} \ No newline at end of file diff --git a/test/resources/config_files/pyodbc/wrong_password.cfg b/test/resources/config_files/pyodbc/wrong_password.cfg new file mode 100644 index 00000000..9f50b420 --- /dev/null +++ b/test/resources/config_files/pyodbc/wrong_password.cfg @@ -0,0 +1,8 @@ +[default] +db_module=pyodbc +db_name=db +db_user=db_user +db_password=wrong +db_host=127.0.0.1 +db_port=3306 +odbc_driver={MySQL ODBC 9.2 ANSI Driver} \ No newline at end of file diff --git a/test/resources/config_files/sqlite3/old_param_names.cfg b/test/resources/config_files/sqlite3/old_param_names.cfg new file mode 100644 index 00000000..4a8047b0 --- /dev/null +++ b/test/resources/config_files/sqlite3/old_param_names.cfg @@ -0,0 +1,4 @@ +[default] +dbapiModuleName=sqlite3 +database=./${DBName}.db +isolation_level= \ No newline at end of file diff --git a/test/resources/config_files/sqlite3/simple_default_alias.cfg b/test/resources/config_files/sqlite3/simple_default_alias.cfg new file mode 100644 index 00000000..bdc48bd4 --- /dev/null +++ b/test/resources/config_files/sqlite3/simple_default_alias.cfg @@ -0,0 +1,4 @@ +[default] +db_module=sqlite3 +database=./${DBName}.db +isolation_level= \ No newline at end of file diff --git a/test/resources/create_stored_procedures_mssql.sql b/test/resources/create_stored_procedures_mssql.sql new file mode 100644 index 00000000..14c2ae85 --- /dev/null +++ b/test/resources/create_stored_procedures_mssql.sql @@ -0,0 +1,69 @@ +DROP PROCEDURE IF EXISTS no_params; +CREATE PROCEDURE no_params +AS +BEGIN +-- Do nothing +RETURN; +END; + +DROP PROCEDURE IF EXISTS get_second_name; +CREATE PROCEDURE +get_second_name +@person_first_name VARCHAR(20) +AS +BEGIN +SELECT LAST_NAME +FROM person +WHERE FIRST_NAME = @person_first_name; +RETURN; +END; + +DROP PROCEDURE IF EXISTS get_all_second_names; +CREATE PROCEDURE get_all_second_names +AS +BEGIN +SELECT LAST_NAME FROM person; +RETURN; +END; + +DROP PROCEDURE IF EXISTS get_all_first_and_second_names; +CREATE PROCEDURE get_all_first_and_second_names +AS +BEGIN +SELECT FIRST_NAME FROM person; +SELECT LAST_NAME FROM person; +RETURN; +END; + +DROP PROCEDURE IF EXISTS check_condition; +CREATE PROCEDURE check_condition +AS +BEGIN +DECLARE @v_condition BIT; +SET @v_condition = 1; +IF @v_condition = 1 +BEGIN +PRINT 'Condition is true'; +END +ELSE +BEGIN +PRINT 'Condition is false'; +END +END; + +DROP PROCEDURE IF EXISTS return_out_param_without_result_sets; +CREATE PROCEDURE +return_out_param_without_result_sets +@my_input VARCHAR(20), +@my_output INT OUTPUT +AS +BEGIN + IF @my_input = 'give me 1' + BEGIN + SELECT @my_output = 1; + END + ELSE + BEGIN + SELECT @my_output = 0; + END +END; \ No newline at end of file diff --git a/test/resources/create_stored_procedures_mysql.sql b/test/resources/create_stored_procedures_mysql.sql new file mode 100644 index 00000000..5da8b262 --- /dev/null +++ b/test/resources/create_stored_procedures_mysql.sql @@ -0,0 +1,41 @@ +DROP PROCEDURE IF EXISTS no_params; +CREATE PROCEDURE +no_params() +BEGIN +-- Do nothing +END; + +DROP PROCEDURE IF EXISTS get_second_name; +CREATE PROCEDURE +get_second_name (IN person_first_name VARCHAR(20), +OUT person_second_name VARCHAR(20)) +BEGIN +SELECT LAST_NAME +INTO person_second_name +FROM person +WHERE FIRST_NAME = person_first_name; +END; + +DROP PROCEDURE IF EXISTS get_all_second_names; +CREATE PROCEDURE get_all_second_names() +BEGIN +SELECT LAST_NAME FROM person; +END; + +DROP PROCEDURE IF EXISTS get_all_first_and_second_names; +CREATE PROCEDURE get_all_first_and_second_names() +BEGIN +SELECT FIRST_NAME FROM person; +SELECT LAST_NAME FROM person; +END; + +DROP PROCEDURE IF EXISTS check_condition; +CREATE PROCEDURE check_condition() +BEGIN + DECLARE v_condition BOOLEAN DEFAULT TRUE; + IF v_condition THEN + SELECT 'Condition is true' AS Result; + ELSE + SELECT 'Condition is false' AS Result; + END IF; +END \ No newline at end of file diff --git a/test/resources/create_stored_procedures_oracle.sql b/test/resources/create_stored_procedures_oracle.sql new file mode 100644 index 00000000..5e155da3 --- /dev/null +++ b/test/resources/create_stored_procedures_oracle.sql @@ -0,0 +1,43 @@ +CREATE OR REPLACE PROCEDURE +no_params AS +BEGIN +DBMS_OUTPUT.PUT_LINE('Hello, World!'); +END; + +CREATE OR REPLACE PROCEDURE +get_second_name (person_first_name IN VARCHAR, person_second_name OUT VARCHAR) AS +BEGIN +SELECT last_name +INTO person_second_name +FROM person +WHERE first_name = person_first_name; +END; + +CREATE OR REPLACE PROCEDURE +get_all_second_names (second_names_cursor OUT SYS_REFCURSOR) AS +BEGIN +OPEN second_names_cursor for +SELECT LAST_NAME FROM person; +END; + +-- parsing the SQL file fails because of the semicolon before the opening of the second cursor +-- , but it's needed +CREATE OR REPLACE PROCEDURE +get_all_first_and_second_names (first_names_cursor OUT SYS_REFCURSOR, second_names_cursor OUT SYS_REFCURSOR) AS +BEGIN +OPEN first_names_cursor for +SELECT FIRST_NAME FROM person; +OPEN second_names_cursor for +SELECT LAST_NAME FROM person; +END; + +CREATE OR REPLACE PROCEDURE +check_condition AS +v_condition BOOLEAN := TRUE; +BEGIN +IF v_condition THEN +DBMS_OUTPUT.PUT_LINE('Condition is true'); +ELSE +DBMS_OUTPUT.PUT_LINE('Condition is false'); +END IF; +END check_condition; \ No newline at end of file diff --git a/test/resources/create_stored_procedures_postgres.sql b/test/resources/create_stored_procedures_postgres.sql new file mode 100644 index 00000000..158f547e --- /dev/null +++ b/test/resources/create_stored_procedures_postgres.sql @@ -0,0 +1,71 @@ +DROP ROUTINE IF EXISTS no_params; +CREATE FUNCTION no_params() +RETURNS VOID +LANGUAGE plpgsql +AS +' +BEGIN +-- Do nothing +END +'; + +DROP ROUTINE IF EXISTS get_second_name; +CREATE FUNCTION +get_second_name (IN person_first_name VARCHAR(20), +OUT person_second_name VARCHAR(20)) +LANGUAGE plpgsql +AS +' +BEGIN +SELECT LAST_NAME INTO person_second_name +FROM person +WHERE FIRST_NAME = person_first_name; +END +'; + +DROP ROUTINE IF EXISTS get_all_second_names; +CREATE FUNCTION +get_all_second_names() +RETURNS TABLE (second_names VARCHAR(20)) +LANGUAGE plpgsql +AS +' +BEGIN +RETURN QUERY SELECT LAST_NAME FROM person; +END +'; + + +DROP ROUTINE IF EXISTS get_all_first_and_second_names; +CREATE FUNCTION +get_all_first_and_second_names(result1 refcursor, result2 refcursor) +RETURNS SETOF refcursor +LANGUAGE plpgsql +AS +' +BEGIN +OPEN result1 FOR SELECT FIRST_NAME FROM person; +RETURN NEXT result1; +OPEN result2 FOR SELECT LAST_NAME FROM person; +RETURN NEXT result2; +END +'; + +DROP ROUTINE IF EXISTS check_condition; +CREATE FUNCTION +check_condition() +RETURNS VOID +LANGUAGE plpgsql +AS +' +DECLARE + v_condition BOOLEAN := TRUE; + v_res BOOLEAN := TRUE; +BEGIN + IF v_condition THEN + v_res := TRUE; + ELSE + v_res := FALSE; + END IF; +END +'; \ No newline at end of file diff --git a/test/resources/excel_db_test_insertData.sql b/test/resources/excel_db_test_insertData.sql new file mode 100644 index 00000000..95b35188 --- /dev/null +++ b/test/resources/excel_db_test_insertData.sql @@ -0,0 +1,2 @@ +INSERT INTO [person$] VALUES(1,'Franz Allan','See'); +INSERT INTO [person$] VALUES(2,'Jerry','Schneider'); diff --git a/test/resources/insert_data_in_person_table.sql b/test/resources/insert_data_in_person_table.sql new file mode 100644 index 00000000..566fd1b2 --- /dev/null +++ b/test/resources/insert_data_in_person_table.sql @@ -0,0 +1,2 @@ +INSERT INTO person VALUES(1,'Franz Allan','See'); +INSERT INTO person VALUES(2,'Jerry','Schneider'); diff --git a/test/resources/insert_data_in_person_table_utf8.sql b/test/resources/insert_data_in_person_table_utf8.sql new file mode 100644 index 00000000..7a15eeb9 --- /dev/null +++ b/test/resources/insert_data_in_person_table_utf8.sql @@ -0,0 +1 @@ +INSERT INTO person VALUES(1,'Jürgen','Gernegroß') \ No newline at end of file diff --git a/test/resources/script_file_tests/select_with_robot_variables.sql b/test/resources/script_file_tests/select_with_robot_variables.sql new file mode 100644 index 00000000..8d8b042a --- /dev/null +++ b/test/resources/script_file_tests/select_with_robot_variables.sql @@ -0,0 +1 @@ +SELECT * FROM ${PERSON_TABLE} \ No newline at end of file diff --git a/test/resources/script_file_tests/semicolons_and_quotes_in_values.sql b/test/resources/script_file_tests/semicolons_and_quotes_in_values.sql new file mode 100644 index 00000000..1a9813b1 --- /dev/null +++ b/test/resources/script_file_tests/semicolons_and_quotes_in_values.sql @@ -0,0 +1,2 @@ +INSERT INTO person VALUES(5, 'Miles', 'O''Brian'); +INSERT INTO person VALUES(6, 'Keiko', 'O''Brian'); diff --git a/test/resources/script_file_tests/semicolons_in_values.sql b/test/resources/script_file_tests/semicolons_in_values.sql new file mode 100644 index 00000000..47f6f3f4 --- /dev/null +++ b/test/resources/script_file_tests/semicolons_in_values.sql @@ -0,0 +1,2 @@ +INSERT INTO person VALUES(3, 'Hello; world', 'Another; value'); +INSERT INTO person VALUES(4, 'May the Force; ', 'be with you;'); \ No newline at end of file diff --git a/test/resources/script_file_tests/split_commands.sql b/test/resources/script_file_tests/split_commands.sql new file mode 100644 index 00000000..c52a2c07 --- /dev/null +++ b/test/resources/script_file_tests/split_commands.sql @@ -0,0 +1,2 @@ +SELECT * FROM person; +SELECT * FROM person WHERE id=1; diff --git a/test/resources/script_file_tests/statements_in_one_line.sql b/test/resources/script_file_tests/statements_in_one_line.sql new file mode 100644 index 00000000..444900cf --- /dev/null +++ b/test/resources/script_file_tests/statements_in_one_line.sql @@ -0,0 +1 @@ +INSERT INTO person VALUES(6, 'Julian', 'Bashir'); INSERT INTO person VALUES(7, 'Jadzia', 'Dax'); \ No newline at end of file diff --git a/test/tests/__init__.py b/test/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/tests/__init__.robot b/test/tests/__init__.robot new file mode 100644 index 00000000..e62e8551 --- /dev/null +++ b/test/tests/__init__.robot @@ -0,0 +1,100 @@ +*** Settings *** +Documentation Set DB connection variables based on a single global variable +... which can be passed from outside (e.g. VS Code lauch config) + +Suite Setup Set DB Variables + + +*** Variables *** +${GLOBAL_DB_SELECTOR} None + + +*** Keywords *** +Set DB Variables + [Documentation] These are custom connection params for databases, + ... running locally on the developer's machine. + ... You might need other values for your databases! + IF "${GLOBAL_DB_SELECTOR}" == "PostgreSQL" + Set Global Variable ${DB_MODULE_MODE} standard + Set Global Variable ${DB_MODULE} psycopg2 + Set Global Variable ${DB_HOST} 127.0.0.1 + Set Global Variable ${DB_PORT} 5432 + Set Global Variable ${DB_NAME} db + Set Global Variable ${DB_USER} db_user + Set Global Variable ${DB_PASS} pass + ELSE IF "${GLOBAL_DB_SELECTOR}" == "oracledb" + Set Global Variable ${DB_MODULE_MODE} standard + Set Global Variable ${DB_MODULE} oracledb + Set Global Variable ${DB_HOST} 127.0.0.1 + Set Global Variable ${DB_PORT} 1521 + Set Global Variable ${DB_NAME} db + Set Global Variable ${DB_USER} db_user + Set Global Variable ${DB_PASS} pass + ELSE IF "${GLOBAL_DB_SELECTOR}" == "cx_Oracle" + Set Global Variable ${DB_MODULE_MODE} standard + Set Global Variable ${DB_MODULE} cx_Oracle + Set Global Variable ${DB_HOST} 127.0.0.1 + Set Global Variable ${DB_PORT} 1521 + Set Global Variable ${DB_NAME} db + Set Global Variable ${DB_USER} db_user + Set Global Variable ${DB_PASS} pass + ELSE IF "${GLOBAL_DB_SELECTOR}" == "SQLite" + Set Global Variable ${DB_MODULE_MODE} custom + Set Global Variable ${DB_MODULE} sqlite3 + ELSE IF "${GLOBAL_DB_SELECTOR}" == "IBM_DB2" + Set Global Variable ${DB_MODULE_MODE} standard + Set Global Variable ${DB_MODULE} ibm_db_dbi + Set Global Variable ${DB_HOST} 127.0.0.1 + Set Global Variable ${DB_PORT} 50000 + Set Global Variable ${DB_NAME} db + Set Global Variable ${DB_USER} db_user + Set Global Variable ${DB_PASS} pass + ELSE IF "${GLOBAL_DB_SELECTOR}" == "Teradata" + Set Global Variable ${DB_MODULE_MODE} standard + Set Global Variable ${DB_MODULE} teradata + Set Global Variable ${DB_HOST} 192.168.0.231 + Set Global Variable ${DB_PORT} 1025 + Set Global Variable ${DB_NAME} db + Set Global Variable ${DB_USER} dbc + Set Global Variable ${DB_PASS} dbc + ELSE IF "${GLOBAL_DB_SELECTOR}" == "MySQL_pymysql" + Set Global Variable ${DB_MODULE_MODE} standard + Set Global Variable ${DB_MODULE} pymysql + Set Global Variable ${DB_HOST} 127.0.0.1 + Set Global Variable ${DB_PORT} 3306 + Set Global Variable ${DB_NAME} db + Set Global Variable ${DB_USER} db_user + Set Global Variable ${DB_PASS} pass + ELSE IF "${GLOBAL_DB_SELECTOR}" == "MySQL_pyodbc" + Set Global Variable ${DB_MODULE_MODE} standard + Set Global Variable ${DB_MODULE} pyodbc + Set Global Variable ${DB_HOST} 127.0.0.1 + Set Global Variable ${DB_PORT} 3306 + Set Global Variable ${DB_NAME} db + Set Global Variable ${DB_USER} db_user + Set Global Variable ${DB_PASS} pass + ELSE IF "${GLOBAL_DB_SELECTOR}" == "Oracle_JDBC" + Set Global Variable ${DB_MODULE_MODE} custom + Set Global Variable ${DB_MODULE} jaydebeapi + Set Global Variable ${DB_HOST} 127.0.0.1 + Set Global Variable ${DB_PORT} 1521 + Set Global Variable ${DB_NAME} db + Set Global Variable ${DB_USER} db_user + Set Global Variable ${DB_PASS} pass + ELSE IF "${GLOBAL_DB_SELECTOR}" == "MSSQL" + Set Global Variable ${DB_MODULE_MODE} standard + Set Global Variable ${DB_MODULE} pymssql + Set Global Variable ${DB_HOST} 127.0.0.1 + Set Global Variable ${DB_PORT} 1433 + Set Global Variable ${DB_NAME} db + Set Global Variable ${DB_USER} SA + Set Global Variable ${DB_PASS} MyPass1234! + ELSE IF "${GLOBAL_DB_SELECTOR}" == "Excel" + Set Global Variable ${DB_MODULE_MODE} standard + Set Global Variable ${DB_MODULE} excel + Set Global Variable ${DB_NAME} db + ELSE IF "${GLOBAL_DB_SELECTOR}" == "Excel_RW" + Set Global Variable ${DB_MODULE_MODE} standard + Set Global Variable ${DB_MODULE} excelrw + Set Global Variable ${DB_NAME} db + END diff --git a/test/tests/common_tests/aliased_connection.robot b/test/tests/common_tests/aliased_connection.robot new file mode 100644 index 00000000..e61b3904 --- /dev/null +++ b/test/tests/common_tests/aliased_connection.robot @@ -0,0 +1,132 @@ +*** Settings *** +Resource ../../resources/common.resource +Suite Setup Skip If "${DB_MODULE}" == "sqlite3" +... Aliases tests don't work for SQLite as each connection is always a new file + +Test Setup Connect, Create Some Data And Disconnect +Test Teardown Connect, Clean Up Data And Disconnect + + +*** Test Cases *** +Connections Can Be Aliased + Connect To DB # default alias + Connect To DB alias=second + +Default Alias Can Be Empty + Connect To DB # default alias + Query SELECT * FROM person + Connect To DB alias=second + Query SELECT * FROM person + Query SELECT * FROM person alias=second + +Switch From Default And Disconnect + Connect To DB # default alias + Connect To DB alias=second + Switch Database second + Query SELECT * FROM person # query with 'second' connection + Disconnect From Database alias=second + Query SELECT * FROM person # query with 'default' connection + +Disconnect Not Existing Alias + Connect To DB # default alias + Disconnect From Database alias=idontexist # silent warning + Run Keyword And Expect Error ConnectionError: No open database connection to close + ... Disconnect From Database alias=idontexist error_if_no_connection=${True} + # default alias exist and can be closed + Disconnect From Database error_if_no_connection=${True} + +Switch Not Existing Alias + Run Keyword And Expect Error ValueError: Alias 'second' not found in existing connections. + ... Switch Database second + +Execute SQL Script - Insert Data In Person table + [Setup] Connect, Create Some Data And Disconnect Run SQL script=${False} + Connect To DB alias=aliased_conn + ${output} Insert Data In Person Table Using SQL Script alias=aliased_conn + Should Be Equal As Strings ${output} None + +Check If Exists In DB - Franz Allan + Connect To DB alias=aliased_conn + Check If Exists In Database SELECT id FROM person WHERE FIRST_NAME= 'Franz Allan' alias=aliased_conn + +Check If Not Exists In DB - Joe + Connect To DB alias=aliased_conn + Check If Not Exists In Database SELECT id FROM person WHERE FIRST_NAME= 'Joe' alias=aliased_conn + +Table Must Exist - person + Connect To DB alias=aliased_conn + Table Must Exist person alias=aliased_conn + +Verify Row Count is 0 + Connect To DB alias=aliased_conn + Row Count is 0 SELECT * FROM person WHERE FIRST_NAME= 'NotHere' alias=aliased_conn + +Verify Row Count is Equal to X + Connect To DB alias=aliased_conn + Row Count is Equal to X SELECT id FROM person 2 alias=aliased_conn + +Verify Row Count is Less Than X + Connect To DB alias=aliased_conn + Row Count is Less Than X SELECT id FROM person 3 alias=aliased_conn + +Verify Row Count is Greater Than X + Connect To DB alias=aliased_conn + Row Count is Greater Than X SELECT * FROM person 1 alias=aliased_conn + +Retrieve Row Count + Connect To DB alias=aliased_conn + ${output} Row Count SELECT id FROM person alias=aliased_conn + Log ${output} + Should Be Equal As Strings ${output} 2 + +Retrieve records from person table + Connect To DB alias=aliased_conn + ${output} Execute SQL String SELECT * FROM person + Log ${output} + Should Be Equal As Strings ${output} None + +Use Last Connected If Not Alias Provided + Connect To DB alias=aliased_conn + ${output} Query SELECT COUNT(*) FROM person + Log ${output} + Should Be Equal As Integers ${output}[0][0] 2 + +Verify Query - Get results as a list of dictionaries + Connect To DB alias=aliased_conn + ${output} Query SELECT * FROM person returnAsDict=True alias=aliased_conn + Log ${output} + # some databases lower field names and you can't do anything about it + TRY + ${value 1} Get From Dictionary ${output}[0] FIRST_NAME + EXCEPT Dictionary does not contain key 'FIRST_NAME'. + ${value 1} Get From Dictionary ${output}[0] first_name + END + TRY + ${value 2} Get From Dictionary ${output}[1] FIRST_NAME + EXCEPT Dictionary does not contain key 'FIRST_NAME'. + ${value 2} Get From Dictionary ${output}[1] first_name + END + Should Be Equal As Strings ${value 1} Franz Allan + Should Be Equal As Strings ${value 2} Jerry + +Verify Delete All Rows From Table + Connect To DB alias=aliased_conn + Delete All Rows From Table person alias=aliased_conn + Row Count Is 0 SELECT * FROM person alias=aliased_conn + + +*** Keywords *** +Connect, Create Some Data And Disconnect + [Arguments] ${Run SQL script}=${True} + Connect To DB + Create Person Table + IF $Run_SQL_script + Insert Data In Person Table Using SQL Script + END + Disconnect From Database + +Connect, Clean Up Data And Disconnect + Disconnect From All Databases + Connect To DB + Drop Tables Person And Foobar + Disconnect From Database diff --git a/test/tests/common_tests/assertion_data_types.robot b/test/tests/common_tests/assertion_data_types.robot new file mode 100644 index 00000000..c3e33849 --- /dev/null +++ b/test/tests/common_tests/assertion_data_types.robot @@ -0,0 +1,53 @@ +*** Settings *** +Documentation Simulate data type mismatch, check error messages and try converting to strings + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Person Table And Insert Data +Test Teardown Drop Tables Person And Foobar + + +*** Variables *** +${sql} SELECT id FROM person WHERE first_name = 'Franz Allan' + + +*** Test Cases *** +Int Contains String - Type Error + Run Keyword And Expect Error TypeError: Invalid assertion* + ... Check Query Result ${sql} contains 1 + +Assert As String - Int Contains String + Check Query Result ${sql} contains 1 assert_as_string=True + +Int Equals String - Not Equals Error Message + Run Keyword And Expect Error Wrong query result* + ... Check Query Result ${sql} == 1 + +Assert As String - Int Equals String + Check Query Result ${sql} == 1 assert_as_string=True + +Int > String - Type Error + Run Keyword And Expect Error TypeError: Invalid assertion* + ... Check Query Result ${sql} > 1 + +Int > String - Wrong Result Error + Run Keyword And Expect Error Wrong query result* + ... Check Query Result ${sql} > 1 assert_as_string=True + +Int Equals Int - Not Equals Error Message + Run Keyword And Expect Error Wrong query result* + ... Check Query Result ${sql} == ${2} + +Assert As String - Int Equals Int - Not Equals Error Message + Run Keyword And Expect Error Wrong query result* + ... Check Query Result ${sql} == ${2} assert_as_string=True + +Int Contains Int - Type Error + Run Keyword And Expect Error TypeError: Invalid assertion* + ... Check Query Result ${sql} contains ${1} + +Assert As String - Int Contains Int + Check Query Result ${sql} contains ${1} assert_as_string=True + diff --git a/test/tests/common_tests/assertion_error_messages.robot b/test/tests/common_tests/assertion_error_messages.robot new file mode 100644 index 00000000..2ab45a3c --- /dev/null +++ b/test/tests/common_tests/assertion_error_messages.robot @@ -0,0 +1,138 @@ +*** Settings *** +Documentation Simulate keyword fails and check that +... using custom and starndard error messages work as expected + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Person Table And Insert Data +Test Teardown Drop Tables Person And Foobar + + +*** Variables *** +${Error Message} My error message +${Non Existing Select} SELECT id FROM person WHERE first_name = 'Joe' +${Existing Select} SELECT id FROM person WHERE first_name = 'Franz Allan' + + +*** Test Cases *** +Check If Exists In DB Fails + ${expected error}= Catenate + ... Expected to have have at least one row, but got 0 rows from: + ... '${Non Existing Select}' + Run Keyword And Expect Error ${expected error} + ... Check If Exists In Database ${Non Existing Select} + +Check If Exists In DB Fails With Message + Run Keyword And Expect Error ${Error Message} + ... Check If Exists In Database ${Non Existing Select} + ... msg=${Error Message} + +Check If Not Exists In DB Fails + ${expected error}= Catenate + ... Expected to have have no rows from + ... '${Existing Select}', + ... but got some rows: * + Run Keyword And Expect Error ${expected error} + ... Check If Not Exists In Database ${Existing Select} + +Check If Not Exists In DB Fails With Message + Run Keyword And Expect Error ${Error Message} + ... Check If Not Exists In Database ${Existing Select} + ... msg=${Error Message} + +Table Must Exist Fails + ${expected error}= Catenate + ... Table 'nonexistent' does not exist in the db + Run Keyword And Expect Error ${expected error} + ... Table Must Exist nonexistent + +Table Must Exist Fails With Message + Run Keyword And Expect Error ${Error Message} + ... Table Must Exist nonexistent + ... msg=${Error Message} + +Verify Row Count Is 0 Fails + ${expected error}= Catenate + ... Expected 0 rows, but 1 were returned from: + ... '${Existing Select}' + Run Keyword And Expect Error ${expected error} + ... Row Count Is 0 ${Existing Select} + +Verify Row Count Is 0 Fails With Message + Run Keyword And Expect Error ${Error Message} + ... Row Count Is 0 ${Existing Select} + ... msg=${Error Message} + +Verify Row Count Is Equal To X Fails + ${expected error}= Catenate + ... Expected 9 rows, but 1 were returned from: + ... '${Existing Select}' + Run Keyword And Expect Error ${expected error} + ... Row Count Is Equal To X ${Existing Select} 9 + +Verify Row Count Is Equal To X Fails With Message + Run Keyword And Expect Error ${Error Message} + ... Row Count Is Equal To X + ... ${Existing Select} 9 msg=${Error Message} + +Verify Row Count Is Less Than X Fails + ${expected error}= Catenate + ... Expected less than 1 rows, but 1 were returned from + ... '${Existing Select}' + Run Keyword And Expect Error ${expected error} + ... Row Count Is Less Than X + ... ${Existing Select} 1 + +Verify Row Count Is Less Than X Fails With Message + Run Keyword And Expect Error ${Error Message} + ... Row Count Is Less Than X + ... ${Existing Select} 1 msg=${Error Message} + +Verify Row Count Is Greater Than X Fails + ${expected error}= Catenate + ... Expected more than 1 rows, but 1 were returned from + ... '${Existing Select}' + Run Keyword And Expect Error ${expected error} + ... Row Count Is Greater Than X + ... ${Existing Select} 1 + +Verify Row Count Is Greater Than X Fails With Message + Run Keyword And Expect Error ${Error Message} + ... Row Count Is Greater Than X + ... ${Existing Select} 1 + ... msg=${Error Message} + +Check Row Count With Assertion Engine Fails + ${expected value}= Set Variable 5 + ${expected error}= Catenate + ... Wrong row count: '1' (int) should be '${expected value}' (int) + Run Keyword And Expect Error + ... ${expected error} + ... Check Row Count ${Existing Select} equals ${expected value} + +Check Row Count With Assertion Engine Fails With Message + Run Keyword And Expect Error ${Error Message} + ... Check Row Count ${Existing Select} less than 1 + ... assertion_message=${Error Message} + + +Check Query Result With Assertion Engine Fails + ${expected value}= Set Variable ${5} + IF "${DB_MODULE}" == "jaydebeapi" + VAR ${Num Type}= jlong + ELSE + VAR ${Num Type}= int + END + ${expected error}= Catenate + ... Wrong query result: '1' (${Num Type}) should be '${expected value}' (int) + Run Keyword And Expect Error + ... ${expected error} + ... Check Query Result ${Existing Select} equals ${expected value} + + +Check Query Result With Assertion Engine Fails With Message + Run Keyword And Expect Error ${Error Message} + ... Check Query Result ${Existing Select} less than ${1} + ... assertion_message=${Error Message} diff --git a/test/tests/common_tests/assertion_retry.robot b/test/tests/common_tests/assertion_retry.robot new file mode 100644 index 00000000..47921da3 --- /dev/null +++ b/test/tests/common_tests/assertion_retry.robot @@ -0,0 +1,60 @@ +*** Settings *** +Documentation Tests for assertion keywords with retry mechanism + +Resource ../../resources/common.resource + +Suite Setup Connect To DB And Prepare Data +Suite Teardown Delete Data And Disconnect +Test Setup Save Start Time + +*** Variables *** +${Timeout} ${3} +${Tolerance} ${0.5} +${Request} SELECT first_name FROM person + +*** Test Cases *** +Check Query Results With Timeout - Fast If DB Ready + Check Query Result ${Request} contains Allan retry_timeout=${Timeout} seconds + ${End time}= Get Current Date + ${Execution time}= Subtract Date From Date ${End time} ${START_TIME} + Should Be True 0 <= $Execution_time <= $Tolerance + +Check Query Results With Timeout - Slow If Result Wrong + Run Keyword And Expect Error Wrong query result: 'Franz Allan' (str) should contain 'Blah' (str) + ... Check Query Result ${Request} contains Blah retry_timeout=${Timeout} seconds retry_pause=1s + ${End time}= Get Current Date + ${Execution time}= Subtract Date From Date ${End time} ${START_TIME} + Should Be True $Timeout <= $Execution_time <= $Timeout + $Tolerance + +Check Query Results With Timeout - Slow If Row Count Wrong + Run Keyword And Expect Error Checking row '5' is not possible, as query results contain 2 rows only! + ... Check Query Result ${Request} contains Blah row=5 retry_timeout=${Timeout} seconds + ${End time}= Get Current Date + ${Execution time}= Subtract Date From Date ${End time} ${START_TIME} + Should Be True $Timeout <= $Execution_time <= $Timeout + $Tolerance + +Check Row Count With Timeout - Fast If DB Ready + Check Row Count ${Request} == 2 retry_timeout=${Timeout} seconds + ${End time}= Get Current Date + ${Execution time}= Subtract Date From Date ${End time} ${START_TIME} + Should Be True 0 <= $Execution_time <= $Tolerance + +Check Row Count With Timeout - Slow If Result Wrong + Run Keyword And Expect Error Wrong row count: '2' (int) should be greater than '5' (int) + ... Check Row Count ${Request} > 5 retry_timeout=${Timeout} seconds retry_pause=1s + ${End time}= Get Current Date + ${Execution time}= Subtract Date From Date ${End time} ${START_TIME} + Should Be True $Timeout <= $Execution_time <= $Timeout + $Tolerance + +*** Keywords *** +Connect To DB And Prepare Data + Connect To DB + Create Person Table And Insert Data + +Delete Data And Disconnect + Drop Tables Person And Foobar + Disconnect From Database + +Save Start Time + ${START_TIME}= Get Current Date + Set Suite Variable ${START_TIME} diff --git a/test/tests/common_tests/basic_tests.robot b/test/tests/common_tests/basic_tests.robot new file mode 100644 index 00000000..d850beca --- /dev/null +++ b/test/tests/common_tests/basic_tests.robot @@ -0,0 +1,164 @@ +*** Settings *** +Documentation Tests which work with the same input params across all databases. + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Person Table And Insert Data +Test Teardown Drop Tables Person And Foobar + + +*** Test Cases *** +SQL Statement Ending Without Semicolon Works + Query SELECT * FROM person + +SQL Statement Ending With Semicolon Works + Query SELECT * FROM person; + +Create Person Table + [Setup] Log No setup for this test + ${output}= Create Person Table + Should Be Equal As Strings ${output} None + +Execute SQL Script - Insert Data In Person table + [Setup] Create Person Table + ${output}= Insert Data In Person Table Using SQL Script + Should Be Equal As Strings ${output} None + +Execute SQL String - Create Foobar Table + [Setup] Log No setup for this test + ${output}= Create Foobar Table + Should Be Equal As Strings ${output} None + +Simple Select With Multiple Rows + ${output}= Query select LAST_NAME from person + Length Should Be ${output} 2 + Should Be Equal ${output}[0][0] See + Should Be Equal ${output}[1][0] Schneider + +Check If Exists In DB - Franz Allan + Check If Exists In Database SELECT id FROM person WHERE FIRST_NAME= 'Franz Allan' + +Check If Not Exists In DB - Joe + Check If Not Exists In Database SELECT id FROM person WHERE FIRST_NAME= 'Joe' + +Table Must Exist - person + Table Must Exist person + +Verify Row Count is 0 + Row Count is 0 SELECT * FROM person WHERE FIRST_NAME= 'NotHere' + +Verify Row Count is Equal to X + Row Count is Equal to X SELECT id FROM person 2 + +Verify Row Count is Less Than X + Row Count is Less Than X SELECT id FROM person 3 + +Verify Row Count is Greater Than X + Row Count is Greater Than X SELECT * FROM person 1 + +Retrieve Row Count + ${output}= Row Count SELECT id FROM person + Log ${output} + Should Be Equal As Strings ${output} 2 + +Check Row Count With Assertion Engine + Check Row Count SELECT id FROM person == 2 + +Check Query Result With Assertion Engine + Check Query Result SELECT first_name FROM person contains Allan + +Check Query Result With Assertion Engine - Different Row And Col + Check Query Result SELECT first_name, last_name, id FROM person >= ${2} row=1 col=2 + +Check Query Result With Assertion Engine - Row Out Of Range + Run Keyword And Expect Error Checking row '2' is not possible, as query results contain 2 rows only! + ... Check Query Result SELECT first_name FROM person == Blah row=2 + +Check Query Result With Assertion Engine - Col Out Of Range + Run Keyword And Expect Error Checking column '5' is not possible, as query results contain 2 columns only! + ... Check Query Result SELECT id, first_name FROM person == Blah col=5 + +Retrieve records from person table + ${output}= Execute SQL String SELECT * FROM person + Log ${output} + Should Be Equal As Strings ${output} None + +Verify Query - Row Count person table + ${output}= Query SELECT COUNT(*) FROM person + Log ${output} + Should Be Equal As Integers ${output}[0][0] 2 + +Verify Query - Row Count foobar table + [Setup] Create Foobar Table + ${output}= Query SELECT COUNT(*) FROM foobar + Log ${output} + Should Be Equal As Integers ${output}[0][0] 0 + +Verify Query - Get results as a list of dictionaries + ${output}= Query SELECT * FROM person returnAsDict=True + Log ${output} + # some databases lower field names and you can't do anything about it + TRY + ${value 1}= Get From Dictionary ${output}[0] FIRST_NAME + EXCEPT Dictionary does not contain key 'FIRST_NAME'. + ${value 1}= Get From Dictionary ${output}[0] first_name + END + TRY + ${value 2}= Get From Dictionary ${output}[1] FIRST_NAME + EXCEPT Dictionary does not contain key 'FIRST_NAME'. + ${value 2}= Get From Dictionary ${output}[1] first_name + END + Should Be Equal As Strings ${value 1} Franz Allan + Should Be Equal As Strings ${value 2} Jerry + +Return As Dictionary - Dotted Syntax + ${output}= Query SELECT * FROM person return_dict=True + ${field_names}= Get Dictionary Keys ${output}[0] + IF "FIRST_NAME" in $field_names + VAR ${field_name}= FIRST_NAME + ELSE IF "first_name" in $field_names + VAR ${field_name}= first_name + ELSE + FAIL Unexpected field name in dictionary + END + Should Be Equal As Strings ${output[0].${field_name}} Franz Allan + Should Be Equal As Strings ${output[1].${field_name}} Jerry + +Verify Execute SQL String - Row Count person table + ${output}= Execute SQL String SELECT COUNT(*) FROM person + Log ${output} + Should Be Equal As Strings ${output} None + +Verify Execute SQL String - Row Count foobar table + [Setup] Create Foobar Table + ${output}= Execute SQL String SELECT COUNT(*) FROM foobar + Log ${output} + Should Be Equal As Strings ${output} None + +Insert Data Into Table foobar + [Setup] Create Foobar Table + ${output}= Execute SQL String INSERT INTO foobar VALUES(1,'Jerry') + Log ${output} + Should Be Equal As Strings ${output} None + +Verify Query - Row Count foobar table 1 row + [Setup] Create Foobar Table And Insert Data + ${output}= Query SELECT COUNT(*) FROM foobar + Log ${output} + Should Be Equal As Integers ${output}[0][0] 1 + +Verify Delete All Rows From Table - foobar + [Setup] Create Foobar Table And Insert Data + Delete All Rows From Table foobar + +Verify Query - Row Count foobar table 0 row + [Setup] Create Foobar Table And Insert Data + Delete All Rows From Table foobar + Row Count Is 0 SELECT * FROM foobar + +Query Returns Zero Results + [Documentation] Tests that nothing crashes when there are zero results + ${results}= Query SELECT * FROM person WHERE id < 0 + Should Be Empty ${results} \ No newline at end of file diff --git a/test/tests/common_tests/connection_params.robot b/test/tests/common_tests/connection_params.robot new file mode 100644 index 00000000..75f59ab5 --- /dev/null +++ b/test/tests/common_tests/connection_params.robot @@ -0,0 +1,174 @@ +*** Settings *** +Documentation Tests for the basic _Connect To Database_ keyword - with and without config files. +... The parameter handling is partly DB module specific. + +Resource ../../resources/common.resource + +Test Setup Skip If $DB_MODULE == "sqlite3" or $DB_MODULE == "jaydebeapi" +Test Teardown Disconnect From Database + +*** Variables *** +&{Errors psycopg2} +... missing basic params=OperationalError: connection to server on socket * +... invalid custom param=ProgrammingError: invalid dsn: invalid connection option "blah"* +&{Errors oracledb} +... missing basic params=DatabaseError: DPY-4001: no credentials specified +... invalid custom param=TypeError: connect() got an unexpected keyword argument 'blah' +&{Errors pymssql} +... missing basic params=OperationalError: (20002, b'DB-Lib error message 20002, severity 9* +... invalid custom param=TypeError: connect() got an unexpected keyword argument 'blah' +&{Errors pymysql} +... missing basic params=OperationalError: (1045, "Access denied* +... invalid custom param=REGEXP: TypeError.*__init__.*got an unexpected keyword argument 'blah' +&{Errors pyodbc} +... missing basic params=REGEXP: InterfaceError.*Data source name not found and no default driver specified.* + +&{Errors} +... psycopg2=${Errors psycopg2} +... oracledb=${Errors oracledb} +... pymssql=${Errors pymssql} +... pymysql=${Errors pymysql} +... pyodbc=${Errors pyodbc} + + +*** Test Cases *** +Mandatory params can't be missing + Run Keyword And Expect Error + ... ValueError: Required parameter 'db_module' was not provided* + ... Connect To Database db_name=${DB_NAME} + +All basic params, no config file + Connect To Database + ... db_module=${DB_MODULE} + ... db_name=${DB_NAME} + ... db_user=${DB_USER} + ... db_password=${DB_PASS} + ... db_host=${DB_HOST} + ... db_port=${DB_PORT} + ... odbc_driver=${DB_DRIVER} + +Missing basic params are accepted, error from Python DB module + Run Keyword And Expect Error + ... ${Errors}[${DB_MODULE}][missing basic params] + ... Connect To Database + ... db_module=${DB_MODULE} + +Custom params as keyword args - valid + Connect To Database + ... db_module=${DB_MODULE} + ... db_name=${DB_NAME} + ... db_host=${DB_HOST} + ... db_port=${DB_PORT} + ... odbc_driver=${DB_DRIVER} + ... user=${DB_USER} + ... password=${DB_PASS} + +Custom params as keyword args - invalid, error from Python DB module + Skip If $DB_MODULE == "pyodbc" + ... pyodbc doesn't always throw an error if some wrong parameter was provided + Run Keyword And Expect Error + ... ${Errors}[${DB_MODULE}][invalid custom param] + ... Connect To Database + ... db_module=${DB_MODULE} + ... db_name=${DB_NAME} + ... db_host=${DB_HOST} + ... db_port=${DB_PORT} + ... db_user=${DB_USER} + ... db_password=${DB_PASS} + ... odbc_driver=${DB_DRIVER} + ... blah=blah + +All basic params in config file + Connect Using Config File ${DB_MODULE}/simple_default_alias + +Deprecated basic params in config file + Connect Using Config File ${DB_MODULE}/old_param_names + +Missing basic params in config file are accepted, error from Python DB module + Run Keyword And Expect Error + ... ${Errors}[${DB_MODULE}][missing basic params] + ... Connect Using Config File + ... ${DB_MODULE}/some_basic_params_missing + +Custom params from config file - valid + Connect Using Config File ${DB_MODULE}/valid_custom_params + +Custom params from config file - invalid, error from Python DB module + Skip If $DB_MODULE == "pyodbc" + ... pyodbc doesn't always throw an error if some wrong parameter was provided + Run Keyword And Expect Error + ... ${Errors}[${DB_MODULE}][invalid custom param] + ... Connect Using Config File ${DB_MODULE}/invalid_custom_params + +Custom params as keyword args combined with custom params from config file + Connect Using Config File ${DB_MODULE}/custom_param_password + ... user=${DB_USER} + + +Keyword args override config file values - basic params + Connect Using Config File ${DB_MODULE}/wrong_password + ... db_password=${DB_PASS} + +Keyword args override config file values - custom params + Connect Using Config File ${DB_MODULE}/valid_custom_params + ... user=${DB_USER} + +Oracle specific - basic params, no config file, oracle_driver_mode + Skip If $DB_MODULE != "oracledb" + Connect To Database + ... db_module=${DB_MODULE} + ... db_name=${DB_NAME} + ... db_user=${DB_USER} + ... db_password=${DB_PASS} + ... db_host=${DB_HOST} + ... db_port=${DB_PORT} + ... oracle_driver_mode=thin + +Oracle specific - thick mode in config file - invalid + [Documentation] Invalid as mode switch during test execution is not supported + ... This test must run the last one in the suite, after others used thin mode already. + Skip If $DB_MODULE != "oracledb" + Run Keyword And Expect Error ProgrammingError: DPY-2019: python-oracledb thick mode cannot be used * + ... Connect Using Config File ${DB_MODULE}/thick_mode + + +MSSQL / MySQL / PyODBC specific - charset as keyword argument + Skip If $DB_MODULE not in ["pymssql", "pymysql", "pyodbc"] + Connect To Database + ... db_module=${DB_MODULE} + ... db_name=${DB_NAME} + ... db_user=${DB_USER} + ... db_password=${DB_PASS} + ... db_host=${DB_HOST} + ... db_port=${DB_PORT} + ... odbc_driver=${DB_DRIVER} + ... db_charset=LATIN1 + +MSSQL specific - charset in config file - invalid + Skip If $DB_MODULE not in ["pymssql"] + Run Keyword And Expect Error OperationalError: (20002, b'Unknown error') + ... Connect Using Config File ${DB_MODULE}/charset_invalid + +MySQL specific - charset in config file - invalid + Skip If $DB_MODULE not in ["pymysql"] + Run Keyword And Expect Error AttributeError: 'NoneType' object has no attribute 'encoding' + ... Connect Using Config File ${DB_MODULE}/charset_invalid + +PyODBC specific - charset in config file - invalid + Skip If $DB_MODULE not in ["pyodbc"] + Run Keyword And Expect Error REGEXP: .*Unknown character set: 'wrong'.* + ... Connect Using Config File ${DB_MODULE}/charset_invalid + + +SQlite specific - connection params as custom keyword args + [Setup] Skip If $DB_MODULE != "sqlite3" + Remove File ${DBName}.db + Connect To Database + ... db_module=${DB_MODULE} + ... database=./${DBName}.db + ... isolation_level=${EMPTY} + +SQlite specific - custom connection params in config file + [Setup] Skip If $DB_MODULE != "sqlite3" + Remove File ${DBName}.db + Connect Using Config File ${DB_MODULE}/simple_default_alias diff --git a/test/tests/common_tests/custom_connection.robot b/test/tests/common_tests/custom_connection.robot new file mode 100644 index 00000000..1e27e8c9 --- /dev/null +++ b/test/tests/common_tests/custom_connection.robot @@ -0,0 +1,51 @@ +*** Settings *** +Documentation Keyword 'Connect To Database Using Custom Params' should work properly +... for different DB modules. + +Resource ../../resources/common.resource + +Test Teardown Disconnect From Database + + +*** Variables *** +${CONNECTION_STRING} ${EMPTY} # the variable is set dynamically depending on the current DB module + + +*** Test Cases *** +Connect Using Custom Connection String + [Documentation] Connection string provided without additional quotes should work properly. + ${Connection String}= Build Connection String + Connect To Database Using Custom Connection String ${DB_MODULE} ${Connection String} + +Connect Using Custom Params + IF "${DB_MODULE}" == "oracledb" + ${Params}= Catenate + ... user='${DB_USER}', + ... password='${DB_PASS}', + ... dsn='${DB_HOST}:${DB_PORT}/${DB_NAME}' + ELSE IF "${DB_MODULE}" == "pyodbc" + ${Params}= Catenate + ... driver='${DB_DRIVER}', + ... charset='${DB_CHARSET}', + ... database='${DB_NAME}', + ... user='${DB_USER}', + ... password='${DB_PASS}', + ... host='${DB_HOST}', + ... port=${DB_PORT} + ELSE IF "${DB_MODULE}" == "sqlite3" + ${Params}= Catenate + ... database="./${DBName}.db", + ... isolation_level=None + ELSE IF "${DB_MODULE}" == "jaydebeapi" + Skip Connecting with custom params for Jaydebeapi is already done in all other tests + ELSE + ${Params}= Catenate + ... database='${DB_NAME}', + ... user='${DB_USER}', + ... password='${DB_PASS}', + ... host='${DB_HOST}', + ... port=${DB_PORT} + END + Connect To Database Using Custom Params + ... ${DB_MODULE} + ... ${Params} diff --git a/test/tests/common_tests/description.robot b/test/tests/common_tests/description.robot new file mode 100644 index 00000000..70b0dcfe --- /dev/null +++ b/test/tests/common_tests/description.robot @@ -0,0 +1,103 @@ +*** Settings *** +Documentation The result of the "description" request is very different depending on the database + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Tables Person And Foobar +Test Teardown Drop Tables Person And Foobar + + +*** Test Cases *** +Verify Person Description + @{queryResults} = Description SELECT * FROM person + Log Many @{queryResults} + Length Should Be ${queryResults} 3 + IF "${DB_MODULE}" == "oracledb" + Should Be Equal As Strings ${queryResults}[0] ('ID', , 39, None, 38, 0, False) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', , 20, 20, None, None, True) + Should Be Equal As Strings ${queryResults}[2] ('LAST_NAME', , 20, 20, None, None, True) + ELSE IF "${DB_MODULE}" == "jaydebeapi" + Should Be Equal As Strings ${queryResults}[0] ('ID', DBAPITypeObject('DECIMAL', 'NUMERIC'), 39, 39, 38, 0, 0) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', DBAPITypeObject('CHAR', 'NCHAR', 'NVARCHAR', 'VARCHAR', 'OTHER'), 20, 20, 20, 0, 1) + Should Be Equal As Strings ${queryResults}[2] ('LAST_NAME', DBAPITypeObject('CHAR', 'NCHAR', 'NVARCHAR', 'VARCHAR', 'OTHER'), 20, 20, 20, 0, 1) + ELSE IF "${DB_MODULE}" == "sqlite3" + Should Be Equal As Strings ${queryResults}[0] ('id', None, None, None, None, None, None) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', None, None, None, None, None, None) + Should Be Equal As Strings ${queryResults}[2] ('LAST_NAME', None, None, None, None, None, None) + ELSE IF "${DB_MODULE}" == "ibm_db_dbi" + Should Be True "${queryResults}[0]".startswith("['ID', DBAPITypeObject(") + Should Be True "${queryResults}[0]".endswith("), 11, 11, 10, 0, False]") + Should Be True "INT" in "${queryResults}[0]" + Should Be True "${queryResults}[1]".startswith("['FIRST_NAME', DBAPITypeObject(") + Should Be True "${queryResults}[1]".endswith("), 20, 20, 20, 0, True]") + Should Be True "VARCHAR" in "${queryResults}[1]" + Should Be True "${queryResults}[2]".startswith("['LAST_NAME', DBAPITypeObject(") + Should Be True "${queryResults}[2]".endswith("), 20, 20, 20, 0, True]") + Should Be True "VARCHAR" in "${queryResults}[2]" + ELSE IF "${DB_MODULE}" == "teradata" + Should Be Equal As Strings ${queryResults}[0] ('id', , None, 10, 0, None, 0) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', , None, 20, 0, None, 1) + Should Be Equal As Strings ${queryResults}[2] ('LAST_NAME', , None, 20, 0, None, 1) + ELSE IF "${DB_MODULE}" == "psycopg2" + Should Be Equal As Strings ${queryResults}[0] Column(name='id', type_code=23) + Should Be Equal As Strings ${queryResults}[1] Column(name='first_name', type_code=1043) + Should Be Equal As Strings ${queryResults}[2] Column(name='last_name', type_code=1043) + ELSE IF "${DB_MODULE}" == "pymysql" + Should Be Equal As Strings ${queryResults}[0] ('id', 3, None, 11, 11, 0, False) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', 253, None, 80, 80, 0, True) + Should Be Equal As Strings ${queryResults}[2] ('LAST_NAME', 253, None, 80, 80, 0, True) + ELSE IF "${DB_MODULE}" == "pyodbc" + Should Be Equal As Strings ${queryResults}[0] ('id', , None, 10, 10, 0, False) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', , None, 20, 20, 0, True) + Should Be Equal As Strings ${queryResults}[2] ('LAST_NAME', , None, 20, 20, 0, True) + ELSE IF "${DB_MODULE}" == "pymssql" + Should Be Equal As Strings ${queryResults}[0] ('id', 3, None, None, None, None, None) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', 1, None, None, None, None, None) + Should Be Equal As Strings ${queryResults}[2] ('LAST_NAME', 1, None, None, None, None, None) + ELSE + Should Be Equal As Strings ${queryResults}[0] Column(name='id', type_code=23) + Should Be Equal As Strings ${queryResults}[1] Column(name='first_name', type_code=1043) + Should Be Equal As Strings ${queryResults}[2] Column(name='last_name', type_code=1043) + END + +Verify Foobar Description + @{queryResults} = Description SELECT * FROM foobar + Log Many @{queryResults} + Length Should Be ${queryResults} 2 + IF "${DB_MODULE}" == "oracledb" + Should Be Equal As Strings ${queryResults}[0] ('ID', , 39, None, 38, 0, False) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', , 30, 30, None, None, False) + ELSE IF "${DB_MODULE}" == "jaydebeapi" + Should Be Equal As Strings ${queryResults}[0] ('ID', DBAPITypeObject('DECIMAL', 'NUMERIC'), 39, 39, 38, 0, 0) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', DBAPITypeObject('CHAR', 'NCHAR', 'NVARCHAR', 'VARCHAR', 'OTHER'), 30, 30, 30, 0, 0) + ELSE IF "${DB_MODULE}" == "sqlite3" + Should Be Equal As Strings ${queryResults}[0] ('id', None, None, None, None, None, None) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', None, None, None, None, None, None) + ELSE IF "${DB_MODULE}" == "ibm_db_dbi" + Should Be True "${queryResults}[0]".startswith("['ID', DBAPITypeObject(") + Should Be True "${queryResults}[0]".endswith("), 11, 11, 10, 0, False]") + Should Be True "INT" in "${queryResults}[0]" + Should Be True "${queryResults}[1]".startswith("['FIRST_NAME', DBAPITypeObject(") + Should Be True "${queryResults}[1]".endswith("), 30, 30, 30, 0, False]") + Should Be True "VARCHAR" in "${queryResults}[1]" + ELSE IF "${DB_MODULE}" == "teradata" + Should Be Equal As Strings ${queryResults}[0] ('id', , None, 10, 0, None, 0) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', , None, 30, 0, None, 0) + ELSE IF "${DB_MODULE}" == "psycopg2" + Should Be Equal As Strings ${queryResults}[0] Column(name='id', type_code=23) + Should Be Equal As Strings ${queryResults}[1] Column(name='first_name', type_code=1043) + ELSE IF "${DB_MODULE}" == "pymysql" + Should Be Equal As Strings ${queryResults}[0] ('id', 3, None, 11, 11, 0, False) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', 253, None, 120, 120, 0, False) + ELSE IF "${DB_MODULE}" in "pyodbc" + Should Be Equal As Strings ${queryResults}[0] ('id', , None, 10, 10, 0, False) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', , None, 30, 30, 0, False) + ELSE IF "${DB_MODULE}" == "pymssql" + Should Be Equal As Strings ${queryResults}[0] ('id', 3, None, None, None, None, None) + Should Be Equal As Strings ${queryResults}[1] ('FIRST_NAME', 1, None, None, None, None, None) + ELSE + Should Be Equal As Strings ${queryResults}[0] Column(name='id', type_code=23) + Should Be Equal As Strings ${queryResults}[1] Column(name='first_name', type_code=1043) + END diff --git a/test/tests/common_tests/disconnect_from_db.robot b/test/tests/common_tests/disconnect_from_db.robot new file mode 100644 index 00000000..76ae94fd --- /dev/null +++ b/test/tests/common_tests/disconnect_from_db.robot @@ -0,0 +1,33 @@ +*** Settings *** +Documentation Keyword 'Disconnect From Database' should work properly if there was no connection at all +... or if it was closed previously. +... It can be also configured to raise an exception if no connection was open. + +Resource ../../resources/common.resource + +Suite Teardown Disconnect From Database + + +*** Test Cases *** +Disconnect If No Connection - No Error Expected + Disconnect From Database + +Disconnect If No Connection - Error Expected + Disconnect From Database + Run Keyword And Expect Error + ... ConnectionError: No open database connection to close + ... Disconnect From Database + ... error_if_no_connection=True + +Disconnect If Connection Was Closed - No Error Expected + Connect To DB + Disconnect From Database + Disconnect From Database + +Disconnect If Connection Was Closed - Error Expected + Connect To DB + Disconnect From Database + Run Keyword And Expect Error + ... ConnectionError: No open database connection to close + ... Disconnect From Database + ... error_if_no_connection=True diff --git a/test/tests/common_tests/encoding.robot b/test/tests/common_tests/encoding.robot new file mode 100644 index 00000000..b7dd2d9b --- /dev/null +++ b/test/tests/common_tests/encoding.robot @@ -0,0 +1,27 @@ +*** Settings *** +Documentation Different non ASCII characters work fine + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Person Table +Test Teardown Drop Tables Person And Foobar + + +*** Test Cases *** +Non ASCII Characters In Values + Execute Sql String INSERT INTO person VALUES(1,'Jürgen','Gernegroß') + ${results}= Query + ... SELECT LAST_NAME FROM person WHERE FIRST_NAME='Jürgen' + Should Be Equal ${results}[0][0] Gernegroß + +Read SQL Script Files As UTF8 + [Documentation] If the SQL script file contains non ASCII characters and saved in UTF8 encoding, + ... Pytho might have an issue opening this file on Windows, as it doesn't use UTF8 by default. + ... In this case you the library should excplicitely set the UTF8 encoding when opening the script file. + ... https://dev.to/methane/python-use-utf-8-mode-on-windows-212i + Execute Sql Script ${CURDIR}/../../resources/insert_data_in_person_table_utf8.sql + ${results}= Query + ... SELECT LAST_NAME FROM person WHERE FIRST_NAME='Jürgen' + Should Be Equal ${results}[0][0] Gernegroß \ No newline at end of file diff --git a/test/tests/common_tests/import_params.robot b/test/tests/common_tests/import_params.robot new file mode 100644 index 00000000..f080bd7b --- /dev/null +++ b/test/tests/common_tests/import_params.robot @@ -0,0 +1,42 @@ +*** Settings *** +Documentation Tests for parameters used when importing the library + +*** Test Cases *** +Import Without Parameters Is Valid + Import Library DatabaseLibrary + +Log Query Results Params Cause No Crash + Import Library DatabaseLibrary log_query_results=False log_query_results_head=0 + +Log Query Results Head - Negative Value Not Allowed + Run Keyword And Expect Error + ... STARTS: Initializing library 'DatabaseLibrary' with arguments [ log_query_results_head=-1 ] failed: ValueError: Wrong log head value provided: -1. The value can't be negative! + ... Import Library DatabaseLibrary log_query_results_head=-1 + +Warn On Connection Overwrite Enabled + Skip If '${DB_MODULE}' != 'psycopg2' + Import Library DatabaseLibrary warn_on_connection_overwrite=True AS MyDBLib + FOR ${counter} IN RANGE 0 2 + MyDBLib.Connect To Database + ... db_module=${DB_MODULE} + ... db_name=${DB_NAME} + ... db_user=${DB_USER} + ... db_password=${DB_PASS} + ... db_host=${DB_HOST} + ... db_port=${DB_PORT} + END + [Teardown] MyDBLib.Disconnect From Database + +Warn On Connection Overwrite Disabled + Skip If '${DB_MODULE}' != 'psycopg2' + Import Library DatabaseLibrary warn_on_connection_overwrite=False AS MyDBLib2 + FOR ${counter} IN RANGE 0 2 + MyDBLib2.Connect To Database + ... db_module=${DB_MODULE} + ... db_name=${DB_NAME} + ... db_user=${DB_USER} + ... db_password=${DB_PASS} + ... db_host=${DB_HOST} + ... db_port=${DB_PORT} + END + [Teardown] MyDBLib2.Disconnect From Database \ No newline at end of file diff --git a/test/tests/common_tests/log_query_results.robot b/test/tests/common_tests/log_query_results.robot new file mode 100644 index 00000000..808763c1 --- /dev/null +++ b/test/tests/common_tests/log_query_results.robot @@ -0,0 +1,21 @@ +*** Settings *** +Documentation Tests for keywords controlling the logging query results + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Person Table And Insert Data +Test Teardown Drop Tables Person And Foobar + +*** Test Cases *** +Calling The Keyword Causes No Crash + Set Logging Query Results enabled=False + Set Logging Query Results enabled=True log_head=0 + Set Logging Query Results log_head=30 + +Check Max Height For Long Tables + FOR ${id} IN RANGE 10 50 + Execute Sql String INSERT INTO person VALUES(${id}, 'Somebody that', 'I used to know'); + END + Query SELECT * FROM person \ No newline at end of file diff --git a/test/tests/common_tests/query_params.robot b/test/tests/common_tests/query_params.robot new file mode 100644 index 00000000..4761b73d --- /dev/null +++ b/test/tests/common_tests/query_params.robot @@ -0,0 +1,71 @@ +*** Settings *** +Documentation Keywords with query params as separate arguments work across all databases. + +Resource ../../resources/common.resource + +Suite Setup Connect To DB And Build Query +Suite Teardown Disconnect From Database +Test Setup Create Person Table And Insert Data +Test Teardown Drop Tables Person And Foobar + + +*** Variables *** +@{SINGLE_PARAM} Franz Allan +@{MULTI_PARAM} Jerry Schneider + + +*** Keywords *** +Connect To DB And Build Query + Connect To DB + Build Query Strings With Params + +Build Query Strings With Params + ${placeholder}= Set Variable %s + IF "${DB_MODULE}" in ["oracledb", "cx_Oracle", "jaydebeapi"] + ${placeholder}= Set Variable :id + ELSE IF "${DB_MODULE}" in ["sqlite3", "pyodbc"] + ${placeholder}= Set Variable ? + END + Set Suite Variable ${QUERY_SINGLE_PARAM} SELECT id FROM person WHERE FIRST_NAME=${placeholder} + Set Suite Variable ${QUERY_MULTI_PARAM} ${QUERY_SINGLE_PARAM} AND LAST_NAME=${placeholder} + + +*** Test Cases *** +Query Single Param + ${out}= Query ${QUERY_SINGLE_PARAM} parameters=${SINGLE_PARAM} + Length Should Be ${out} 1 + +Query Multiple Params + ${out}= Query ${QUERY_MULTI_PARAM} parameters=${MULTI_PARAM} + Length Should Be ${out} 1 + +Row Count + ${out}= Row Count ${QUERY_SINGLE_PARAM} parameters=${SINGLE_PARAM} + Should Be Equal As Strings ${out} 1 + +Description + ${out}= Description ${QUERY_SINGLE_PARAM} parameters=${SINGLE_PARAM} + Length Should Be ${out} 1 + +Execute SQL String + Execute Sql String ${QUERY_SINGLE_PARAM} parameters=${SINGLE_PARAM} + +Check If Exists In DB + Check If Exists In Database ${QUERY_SINGLE_PARAM} parameters=${SINGLE_PARAM} + +Check If Not Exists In DB + @{Wrong params}= Create List Joe + Check If Not Exists In Database ${QUERY_SINGLE_PARAM} parameters=${Wrong params} + +Row Count is 0 + @{Wrong params}= Create List Joe + Row Count is 0 ${QUERY_SINGLE_PARAM} parameters=${Wrong params} + +Row Count is Equal to X + Row Count is Equal to X ${QUERY_SINGLE_PARAM} 1 parameters=${SINGLE_PARAM} + +Row Count is Less Than X + Row Count is Less Than X ${QUERY_SINGLE_PARAM} 5 parameters=${SINGLE_PARAM} + +Row Count is Greater Than X + Row Count is Greater Than X ${QUERY_SINGLE_PARAM} 0 parameters=${SINGLE_PARAM} diff --git a/test/tests/common_tests/replace_robot_variables.robot b/test/tests/common_tests/replace_robot_variables.robot new file mode 100644 index 00000000..7883f35e --- /dev/null +++ b/test/tests/common_tests/replace_robot_variables.robot @@ -0,0 +1,86 @@ +*** Settings *** +Documentation Tests which work with the same input params across all databases. + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Table And Set Test Variable +Test Teardown Drop Tables Person And Foobar + + +*** Variables *** +${Query with vars} SELECT * FROM \${PERSON_TABLE} +${Script with vars} ${Script files dir}/select_with_robot_variables.sql + +&{Error} +... psycopg2=*syntax error*$* +... oracledb=*$*invalid character* +... pymssql=*Incorrect syntax*$* +... pymysql=*error*syntax* +... pyodbc=*error*syntax* +... ibm_db_dbi=*Invalid SQL syntax* +... sqlite3=*unrecognized token*$* + + +*** Test Cases *** +Query + ${results}= Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Query ${Query with vars} + ${results}= Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Query ${Query with vars} replace_robot_variables=False + Query ${Query with vars} replace_robot_variables=True + +SQL String + Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Execute Sql String ${Query with vars} + Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Execute Sql String ${Query with vars} replace_robot_variables=False + Execute Sql String ${Query with vars} replace_robot_variables=True + +SQL Script + Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Execute Sql Script ${Script with vars} + Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Execute Sql Script ${Script with vars} replace_robot_variables=False + Execute Sql Script ${Script with vars} replace_robot_variables=True + +Row Count + ${result}= Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Row Count ${Query with vars} + ${result}= Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Row Count ${Query with vars} replace_robot_variables=False + ${result}= Row Count ${Query with vars} replace_robot_variables=True + +Description + ${result}= Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Description ${Query with vars} + ${result}= Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Description ${Query with vars} replace_robot_variables=False + ${result}= Description ${Query with vars} replace_robot_variables=True + +Check Query Result + Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Check Query Result ${Query with vars} contains Franz Allan col=1 + Run Keyword And Expect Error + ... ${Error}[${DB_MODULE}] + ... Check Query Result + ... ${Query with vars} + ... contains + ... Franz Allan + ... col=1 + ... replace_robot_variables=False + Check Query Result ${Query with vars} contains Franz Allan col=1 replace_robot_variables=True + +Check Row Count + Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Check Row Count ${Query with vars} == 2 + Run Keyword And Expect Error ${Error}[${DB_MODULE}] + ... Check Row Count ${Query with vars} == 2 replace_robot_variables=False + Check Row Count ${Query with vars} == 2 replace_robot_variables=True + + +*** Keywords *** +Create Table And Set Test Variable + Create Person Table And Insert Data + Set Test Variable ${PERSON_TABLE} person diff --git a/test/tests/common_tests/script_files.robot b/test/tests/common_tests/script_files.robot new file mode 100644 index 00000000..66d04bca --- /dev/null +++ b/test/tests/common_tests/script_files.robot @@ -0,0 +1,64 @@ +*** Settings *** +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Person Table +Test Teardown Drop Tables Person And Foobar + + +*** Test Cases *** +Semicolons As Statement Separators In One Line + Run SQL Script File statements_in_one_line + ${sql}= Catenate select * from person + ... where id=6 or id=7 + ${results}= Query ${sql} + Length Should Be ${results} 2 + Should Be Equal As Strings ${results}[0] (6, 'Julian', 'Bashir') + Should Be Equal As Strings ${results}[1] (7, 'Jadzia', 'Dax') + +Semicolons In Values + Run SQL Script File semicolons_in_values + ${sql}= Catenate select * from person + ... where id=3 or id=4 + ${results}= Query ${sql} + Length Should Be ${results} 2 + Should Be Equal As Strings ${results}[0] (3, 'Hello; world', 'Another; value') + Should Be Equal As Strings ${results}[1] (4, 'May the Force; ', 'be with you;') + +Semicolons And Quotes In Values + Run SQL Script File semicolons_and_quotes_in_values + ${sql}= Catenate select * from person + ... where LAST_NAME='O''Brian' + ${results}= Query ${sql} + Length Should Be ${results} 2 + Should Be Equal As Strings ${results}[0] (5, 'Miles', "O'Brian") + Should Be Equal As Strings ${results}[1] (6, 'Keiko', "O'Brian") + +Split Script Into Statements - Internal Parser + Insert Data In Person Table Using SQL Script + @{Expected commands}= Create List + ... SELECT * FROM person + ... SELECT * FROM person WHERE id=1 + ${extracted commands}= Split Sql Script ${Script files dir}/split_commands.sql + Lists Should Be Equal ${Expected commands} ${extracted commands} + FOR ${command} IN @{extracted commands} + ${results}= Query ${command} + END + +Split Script Into Statements - External Parser + Insert Data In Person Table Using SQL Script + @{Expected commands}= Create List + ... SELECT * FROM person; + ... SELECT * FROM person WHERE id=1; + ${extracted commands}= Split Sql Script ${Script files dir}/split_commands.sql external_parser=True + Lists Should Be Equal ${Expected commands} ${extracted commands} + FOR ${command} IN @{extracted commands} + ${results}= Query ${command} + END + + +*** Keywords *** +Run SQL Script File + [Arguments] ${File Name} + Execute Sql Script ${Script files dir}/${File Name}.sql diff --git a/test/tests/common_tests/stored_procedures.robot b/test/tests/common_tests/stored_procedures.robot new file mode 100644 index 00000000..7cc136aa --- /dev/null +++ b/test/tests/common_tests/stored_procedures.robot @@ -0,0 +1,124 @@ +*** Settings *** +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create And Fill Tables And Stored Procedures +Test Teardown Drop Tables Person And Foobar + + +*** Test Cases *** +Procedure Takes No Params + ${param values} ${result sets}= Call Stored Procedure no_params + Length Should Be ${param values} 0 + IF "${DB_MODULE}" in ["psycopg2", "psycopg3"] + Length Should Be ${result sets} 1 + Should Be Equal As Strings ${result sets}[0][0][0] ${EMPTY} + ELSE + Length Should Be ${result sets} 0 + END + +Procedure Returns Single Value As Param + IF "${DB_MODULE}" in ["psycopg2", "psycopg3"] + Skip PostgreSQL doesn't return single values as params, only as result sets + END + IF "${DB_MODULE}" in ["pymssql"] + Skip Returning values using OUT params in MS SQL is not supported, use result sets + END + @{params}= Create List Jerry OUTPUT + ${param values} ${result sets}= Call Stored Procedure get_second_name ${params} + Length Should Be ${result sets} 0 + Should Be Equal ${param values}[1] Schneider + +Procedure Returns Single Value As Result Set + IF "${DB_MODULE}" not in ["psycopg2", "psycopg3", "pymssql"] + Skip This test is not valid for '${DB_MODULE}' + END + @{params}= Create List Jerry + ${param values} ${result sets}= Call Stored Procedure get_second_name ${params} + Length Should Be ${param values} 1 + Should Be Equal ${param values}[0] Jerry + Length Should Be ${result sets} 1 + ${First result set}= Set Variable ${result sets}[0] + Length Should Be ${First result set} 1 + Should Be Equal ${First result set}[0][0] Schneider + +Procedure Returns Result Set Via CURSOR Param + IF "${DB_MODULE}" not in ["oracledb", "cx_Oracle"] + Skip This test is valid for Oracle only + END + @{params}= Create List CURSOR + ${param values} ${result sets}= Call Stored Procedure get_all_second_names ${params} + ${length of input params}= Get Length ${params} + Length Should Be ${param values} ${length of input params} + Length Should Be ${result sets} 1 + ${first result set}= Set Variable ${result sets}[0] + Length Should Be ${first result set} 2 + Should Be Equal ${first result set}[0][0] See + Should Be Equal ${first result set}[1][0] Schneider + +Procedure Returns Result Set Without CURSOR Param + IF "${DB_MODULE}" in ["oracledb", "cx_Oracle"] + Skip This test is not valid for Oracle + END + @{params}= Create List @{EMPTY} + ${param values} ${result sets}= Call Stored Procedure get_all_second_names ${params} + ${length of input params}= Get Length ${params} + Length Should Be ${param values} ${length of input params} + Length Should Be ${result sets} 1 + ${first result set}= Set Variable ${result sets}[0] + Length Should Be ${first result set} 2 + Should Be Equal ${first result set}[0][0] See + Should Be Equal ${first result set}[1][0] Schneider + +Procedure Returns Multiple Result Sets + IF "${DB_MODULE}" in ["oracledb", "cx_Oracle", "psycopg2", "psycopg3"] + @{params}= Create List CURSOR CURSOR + ELSE IF "${DB_MODULE}" in ["pymysql", "pymssql"] + @{params}= Create List @{EMPTY} + END + ${param values} ${result sets}= Call Stored Procedure get_all_first_and_second_names ${params} + ${length of input params}= Get Length ${params} + Length Should Be ${param values} ${length of input params} + Length Should Be ${result sets} 2 + ${first result set}= Set Variable ${result sets}[0] + Should Be Equal ${first result set}[0][0] Franz Allan + Should Be Equal ${first result set}[1][0] Jerry + ${second result set}= Set Variable ${result sets}[1] + Should Be Equal ${second result set}[0][0] See + Should Be Equal ${second result set}[1][0] Schneider + +Procedure With IF/ELSE Block + Call Stored Procedure check_condition + +MSSQL Procedure Returns OUT Param Without Result Sets + IF "${DB_MODULE}" not in ["pymssql"] + Skip This test is valid for pymssql only + END + @{params}= Create List give me 1 + @{out_params}= Create List ${9} + ${param values} ${result sets}= Call Stored Procedure return_out_param_without_result_sets + ... ${params} additional_output_params=${out_params} + Should Be Empty ${result sets} + Should Be Equal As Integers ${param values}[1] 1 + @{params}= Create List give me 0 + ${param values} ${result sets}= Call Stored Procedure return_out_param_without_result_sets + ... ${params} additional_output_params=${out_params} + Should Be Empty ${result sets} + Should Be Equal As Integers ${param values}[1] 0 + + +*** Keywords *** +Create And Fill Tables And Stored Procedures + Create Person Table And Insert Data + IF "${DB_MODULE}" in ["oracledb", "cx_Oracle"] + Execute SQL Script ${CURDIR}/../../resources/create_stored_procedures_oracle.sql + ELSE IF "${DB_MODULE}" in ["pymysql"] + Execute SQL Script ${CURDIR}/../../resources/create_stored_procedures_mysql.sql + ELSE IF "${DB_MODULE}" in ["psycopg2", "psycopg3"] + Execute SQL Script ${CURDIR}/../../resources/create_stored_procedures_postgres.sql + ELSE IF "${DB_MODULE}" in ["pymssql"] + Execute SQL Script ${CURDIR}/../../resources/create_stored_procedures_mssql.sql + ELSE + Skip Don't know how to create stored procedures for '${DB_MODULE}' + END diff --git a/test/tests/common_tests/transaction.robot b/test/tests/common_tests/transaction.robot new file mode 100644 index 00000000..cac6723b --- /dev/null +++ b/test/tests/common_tests/transaction.robot @@ -0,0 +1,93 @@ +*** Settings *** +Documentation Testing the transaction rollback requires savepoints - +... setting them is diffferent depending on the database + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Person Table +Test Teardown Drop Tables Person And Foobar + + +*** Test Cases *** +Transaction + IF "${DB_MODULE}" == "teradata" + Skip Teradata doesn't support savepoints + END + Begin first transaction + Add person in first transaction + Verify person in first transaction + Begin second transaction + Add person in second transaction + Verify persons in first and second transactions + Rollback second transaction + Verify second transaction rollback + Rollback first transaction + Verify first transaction rollback + + +*** Keywords *** +Begin first transaction + ${sql}= Set Variable SAVEPOINT first + IF "${DB_MODULE}" == "ibm_db_dbi" + ${sql}= Catenate ${sql} + ... ON ROLLBACK RETAIN CURSORS + ELSE IF "${DB_MODULE}" == "pymssql" + ${sql}= Set Variable SAVE TRANSACTION first + END + ${output}= Execute SQL String ${sql} True + Log ${output} + Should Be Equal As Strings ${output} None + +Add person in first transaction + ${output}= Execute SQL String INSERT INTO person VALUES(101,'Bilbo','Baggins') True + Log ${output} + Should Be Equal As Strings ${output} None + +Verify person in first transaction + Row Count is Equal to X SELECT * FROM person WHERE LAST_NAME= 'Baggins' 1 True + +Begin second transaction + ${sql}= Set Variable SAVEPOINT second + IF "${DB_MODULE}" == "ibm_db_dbi" + ${sql}= Catenate ${sql} + ... ON ROLLBACK RETAIN CURSORS + ELSE IF "${DB_MODULE}" == "pymssql" + ${sql}= Set Variable SAVE TRANSACTION second + END + ${output}= Execute SQL String ${sql} True + Log ${output} + Should Be Equal As Strings ${output} None + +Add person in second transaction + ${output}= Execute SQL String INSERT INTO person VALUES(102,'Frodo','Baggins') True + Log ${output} + Should Be Equal As Strings ${output} None + +Verify persons in first and second transactions + Row Count is Equal to X SELECT * FROM person WHERE LAST_NAME= 'Baggins' 2 True + +Rollback second transaction + ${sql}= Set Variable ROLLBACK TO SAVEPOINT second + IF "${DB_MODULE}" == "pymssql" + ${sql}= Set Variable ROLLBACK TRANSACTION second + END + ${output}= Execute SQL String ${sql} True + Log ${output} + Should Be Equal As Strings ${output} None + +Verify second transaction rollback + Row Count is Equal to X SELECT * FROM person WHERE LAST_NAME= 'Baggins' 1 True + +Rollback first transaction + ${sql}= Set Variable ROLLBACK TO SAVEPOINT first + IF "${DB_MODULE}" == "pymssql" + ${sql}= Set Variable ROLLBACK TRANSACTION first + END + ${output}= Execute SQL String ${sql} + Log ${output} + Should Be Equal As Strings ${output} None + +Verify first transaction rollback + Row Count is 0 SELECT * FROM person WHERE LAST_NAME= 'Baggins' True diff --git a/test/tests/custom_db_tests/db_update_in_background_commit.robot b/test/tests/custom_db_tests/db_update_in_background_commit.robot new file mode 100644 index 00000000..28ff9991 --- /dev/null +++ b/test/tests/custom_db_tests/db_update_in_background_commit.robot @@ -0,0 +1,17 @@ +*** Settings *** +Documentation Check if the SQL statement returns new results, if DB is being updated in the background - +... this requires a commit after each query. +... See https://github.com/MarketSquare/Robotframework-Database-Library/issues/237 + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Person Table And Insert Data +Test Teardown Drop Tables Person And Foobar + + +*** Test Cases *** +Use Auto retry + [Documentation] Update the DB manually in the background and check if the query returns the new results + Check Query Result SELECT LAST_NAME FROM person ORDER BY id == Musk retry_timeout=30s diff --git a/test/tests/custom_db_tests/excel.robot b/test/tests/custom_db_tests/excel.robot new file mode 100644 index 00000000..f61b793a --- /dev/null +++ b/test/tests/custom_db_tests/excel.robot @@ -0,0 +1,245 @@ +*** Settings *** +Documentation These tests are mostly different from common tests for other database + +Resource ../../resources/common.resource +Library ExcelLibrary + +Suite Setup Setup testing excel +Suite Teardown Cleanup testing excel + + +*** Variables *** +${DBHost} dummy +${DBName} ${CURDIR}/Test_Excel.xlsx +${DBPass} dummy +${DBPort} 80 +${DBUser} dummy + + +*** Test Cases *** +Create person table + ${output} = Execute SQL String CREATE TABLE person (id integer,first_name varchar(20),last_name varchar(20)); + Log ${output} + Should Be Equal As Strings ${output} None + +Execute SQL Script - Insert Data person table + log to console ${DBName} + ${output} = Execute SQL Script ${CURDIR}/../../resources/excel_db_test_insertData.sql + Log ${output} + Should Be Equal As Strings ${output} None + +Execute SQL String - Create Table + ${output} = Execute SQL String create table [foobar] ([id] integer, [firstname] varchar(20)) + Log ${output} + Should Be Equal As Strings ${output} None + +Check If Exists In DB - Franz Allan + Check If Exists In Database SELECT id FROM [person$] WHERE first_name = 'Franz Allan'; + +Check If Not Exists In DB - Joe + Check If Not Exists In Database SELECT id FROM [person$] WHERE first_name = 'Joe'; + +Verify Row Count is 0 + Row Count is 0 SELECT * FROM [person$] WHERE first_name = 'NotHere'; + +Verify Row Count is Equal to X + Row Count is Equal to X SELECT id FROM [person$]; 2 + +Verify Row Count is Less Than X + Row Count is Less Than X SELECT id FROM [person$]; 3 + +Verify Row Count is Greater Than X + Row Count is Greater Than X SELECT * FROM [person$]; 1 + +Retrieve Row Count + ${output} = Row Count SELECT id FROM [person$]; + Log ${output} + Should Be Equal As Strings ${output} 2 + +Retrieve records from person table + ${output} = Execute SQL String SELECT * FROM [person$]; + Log ${output} + Should Be Equal As Strings ${output} None + +Verify person Description + Comment Query db for table column descriptions + @{queryResults} = Description select TOP 1 * FROM [person$]; + Log Many @{queryResults} + ${output} = Set Variable ${queryResults[0]} + Should Be Equal As Strings ${output} ('id', , None, 255, 255, 0, True) + ${output} = Set Variable ${queryResults[1]} + Should Be Equal As Strings ${output} ('first_name', , None, 255, 255, 0, True) + ${output} = Set Variable ${queryResults[2]} + Should Be Equal As Strings ${output} ('last_name', , None, 255, 255, 0, True) + ${NumColumns} = Get Length ${queryResults} + Should Be Equal As Integers ${NumColumns} 3 + +Verify foobar Description + Comment Query db for table column descriptions + @{queryResults} = Description SELECT TOP 1 * FROM [foobar$]; + Log Many @{queryResults} + ${output} = Set Variable ${queryResults[0]} + Should Be Equal As Strings ${output} ('id', , None, 255, 255, 0, True) + ${output} = Set Variable ${queryResults[1]} + Should Be Equal As Strings ${output} ('firstname', , None, 255, 255, 0, True) + ${NumColumns} = Get Length ${queryResults} + Should Be Equal As Integers ${NumColumns} 2 + +Verify Query - Row Count person table + ${output} = Query SELECT COUNT(*) FROM [person$]; + Log ${output} + Should Be Equal As Integers ${output}[0][0] 2 + +Verify Query - Row Count foobar table + ${output} = Query SELECT COUNT(*) FROM foobar; + Log ${output} + Should Be Equal As Integers ${output}[0][0] 0 + +Verify Query - Get results as a list of dictionaries + ${output} = Query SELECT * FROM [person$]; \ True + Log ${output} + Should Be Equal As Strings ${output[0]}[first_name] Franz Allan + Should Be Equal As Strings ${output[1]}[first_name] Jerry + +Verify Execute SQL String - Row Count person table + ${output} = Execute SQL String SELECT COUNT(*) FROM [person$]; + Log ${output} + Should Be Equal As Strings ${output} None + +Verify Execute SQL String - Row Count foobar table + ${output} = Execute SQL String SELECT COUNT(*) FROM [foobar$]; + Log ${output} + Should Be Equal As Strings ${output} None + +Insert Data Into Table foobar + ${output} = Execute SQL String INSERT INTO [foobar$] VALUES(1,'Jerry'); + Log ${output} + Should Be Equal As Strings ${output} None + +Verify Query - Row Count foobar table 1 row + ${output} = Query SELECT COUNT(*) FROM [foobar$]; + Log ${output} + Should Be Equal As Integers ${output}[0][0] 1 + +Add person in first transaction + ${output} = Execute SQL String INSERT INTO [person$] VALUES(101,'Bilbo','Baggins'); True + Log ${output} + Should Be Equal As Strings ${output} None + +Verify person in first transaction + Row Count is Equal to X SELECT * FROM [person$] WHERE last_name = 'Baggins'; 1 True + +Add person in second transaction + ${output} = Execute SQL String INSERT INTO [person$] VALUES(102,'Frodo','Baggins'); True + Log ${output} + Should Be Equal As Strings ${output} None + +Verify persons in first and second transactions + Row Count is Equal to X SELECT * FROM [person$] WHERE last_name = 'Baggins'; 2 True + +Setup RO access to excel + Disconnect From Database + Connect To Database excel ${DBName} ${DBUser} ${DBPass} ${DBHost} ${DBPort} + +Check If Exists In RODB - Franz Allan + Check If Exists In Database SELECT id FROM [person$] WHERE first_name = 'Franz Allan'; + +Check If Not Exists In RODB - Joe + Check If Not Exists In Database SELECT id FROM [person$] WHERE first_name = 'Joe'; + +Verify Row Count is 0 RODB + Row Count is 0 SELECT * FROM [person$] WHERE first_name = 'NotHere'; + +Verify Row Count is Equal to X RODB + Row Count is Equal to X SELECT id FROM [person$]; 4 + +Verify Row Count is Less Than X RODB + Row Count is Less Than X SELECT id FROM [person$]; 5 + +Verify Row Count is Greater Than X RODB + Row Count is Greater Than X SELECT * FROM [person$]; 1 + +Retrieve Row Count RODB + ${output} = Row Count SELECT id FROM [person$]; + Log ${output} + Should Be Equal As Strings ${output} 4 + +Retrieve records from person table RODB + ${output} = Execute SQL String SELECT * FROM [person$]; + Log ${output} + Should Be Equal As Strings ${output} None + +Verify person Description RODB + Comment Query db for table column descriptions + @{queryResults} = Description select TOP 1 * FROM [person$]; + Log Many @{queryResults} + ${output} = Set Variable ${queryResults[0]} + Should Be Equal As Strings ${output} ('id', , None, 255, 255, 0, True) + ${output} = Set Variable ${queryResults[1]} + Should Be Equal As Strings ${output} ('first_name', , None, 255, 255, 0, True) + ${output} = Set Variable ${queryResults[2]} + Should Be Equal As Strings ${output} ('last_name', , None, 255, 255, 0, True) + ${NumColumns} = Get Length ${queryResults} + Should Be Equal As Integers ${NumColumns} 3 + +Verify foobar Description RODB + Comment Query db for table column descriptions + @{queryResults} = Description SELECT TOP 1 * FROM [foobar$]; + Log Many @{queryResults} + ${output} = Set Variable ${queryResults[0]} + Should Be Equal As Strings ${output} ('id', , None, 255, 255, 0, True) + ${output} = Set Variable ${queryResults[1]} + Should Be Equal As Strings ${output} ('firstname', , None, 255, 255, 0, True) + ${NumColumns} = Get Length ${queryResults} + Should Be Equal As Integers ${NumColumns} 2 + +Verify Query - Row Count person table RODB + ${output} = Query SELECT COUNT(*) FROM [person$]; + Log ${output} + Should Be Equal As Integers ${output}[0][0] 4 + +Verify Query - Row Count foobar table RODB + ${output} = Query SELECT COUNT(*) FROM [foobar$]; + Log ${output} + Should Be Equal As Integers ${output}[0][0] 1 + +Verify Query - Get results as a list of dictionaries RODB + ${output} = Query SELECT * FROM [person$]; \ True + Log ${output} + Should Be Equal As Strings ${output[0]}[first_name] Franz Allan + Should Be Equal As Strings ${output[1]}[first_name] Jerry + +Verify Execute SQL String - Row Count person table RODB + ${output} = Execute SQL String SELECT COUNT(*) FROM [person$]; + Log ${output} + Should Be Equal As Strings ${output} None + +Verify Execute SQL String - Row Count foobar table RODB + ${output} = Execute SQL String SELECT COUNT(*) FROM [foobar$]; + Log ${output} + Should Be Equal As Strings ${output} None + +Verify Query - Row Count foobar table 1 row RODB + ${output} = Query SELECT COUNT(*) FROM [foobar$]; + Log ${output} + Should Be Equal As Integers ${output}[0][0] 1 + +Setup RW access to excel + Disconnect From Database + Connect To Database excelrw ${DBName} ${DBUser} ${DBPass} ${DBHost} ${DBPort} + +Drop person and foobar tables + ${output} = Execute SQL String DROP TABLE [person$],[foobar$] + Log ${output} + Should Be Equal As Strings ${output} None + + +*** Keywords *** +Setup testing excel + Create Excel Document excel_db + Save Excel Document ${DBName} + Connect To Database excelrw ${DBName} ${DBUser} ${DBPass} ${DBHost} ${DBPort} + +Cleanup testing excel + Disconnect From Database + Remove File ${DBName} diff --git a/test/tests/custom_db_tests/multiple_connections.robot b/test/tests/custom_db_tests/multiple_connections.robot new file mode 100644 index 00000000..540aee71 --- /dev/null +++ b/test/tests/custom_db_tests/multiple_connections.robot @@ -0,0 +1,62 @@ +*** Settings *** +Documentation Connections to two different databases can be handled separately. +... These tests require two databases running in parallel. + +Resource ../../resources/common.resource + +Suite Setup Connect To All Databases +Suite Teardown Disconnect From All Databases +Test Setup Create Tables +Test Teardown Drop Tables + + +*** Variables *** +${Table_1} table_1 +${Table_2} table_2 + +${Alias_1} first +${Alias_2} second + + +*** Test Cases *** +First Table Was Created In First Database Only + Table Must Exist ${Table_1} alias=${Alias_1} + Run Keyword And Expect Error Table '${Table_2}' does not exist in the db + ... Table Must Exist ${Table_2} alias=${Alias_1} + +Second Table Was Created In Second Database Only + Table Must Exist ${Table_2} alias=${Alias_2} + Run Keyword And Expect Error Table '${Table_1}' does not exist in the db + ... Table Must Exist ${Table_1} alias=${Alias_2} + +Switching Default Alias + Switch Database ${Alias_1} + Table Must Exist ${Table_1} + Run Keyword And Expect Error Table '${Table_2}' does not exist in the db + ... Table Must Exist ${Table_2} + Switch Database ${Alias_2} + Table Must Exist ${Table_2} + Run Keyword And Expect Error Table '${Table_1}' does not exist in the db + ... Table Must Exist ${Table_1} + + +*** Keywords *** +Connect To All Databases + Connect To Database psycopg2 db db_user pass 127.0.0.1 5432 + ... alias=${Alias_1} + Connect To Database pymysql db db_user pass 127.0.0.1 3306 + ... alias=${Alias_2} + +Create Tables + ${sql_1}= Catenate + ... CREATE TABLE ${Table_1} + ... (id integer not null unique, FIRST_NAME varchar(20), LAST_NAME varchar(20)) + ${sql_2}= Catenate + ... CREATE TABLE ${Table_2} + ... (id integer not null unique, FIRST_NAME varchar(20), LAST_NAME varchar(20)) + Execute Sql String ${sql_1} alias=${Alias_1} + Execute Sql String ${sql_2} alias=${Alias_2} + +Drop Tables + Execute Sql String DROP TABLE ${Table_1} alias=${Alias_1} + Execute Sql String DROP TABLE ${Table_2} alias=${Alias_2} diff --git a/test/tests/custom_db_tests/oracle_blob.robot b/test/tests/custom_db_tests/oracle_blob.robot new file mode 100644 index 00000000..aae973c8 --- /dev/null +++ b/test/tests/custom_db_tests/oracle_blob.robot @@ -0,0 +1,29 @@ +*** Settings *** +Documentation Tests for querying a table with BLOB data type. +... The data type naming is DB specific, these tests are designed for Oracle DB only. + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Execute Sql String +... CREATE TABLE blob_table (id integer not null unique, data blob) +Test Teardown Execute Sql String DROP TABLE blob_table + + +*** Variables *** +${DB_MODULE} oracledb +${DB_HOST} 127.0.0.1 +${DB_PORT} 1521 +${DB_PASS} pass +${DB_USER} db_user +${DB_NAME} db +${ORACLE_LIB_DIR} ${EMPTY} + + +*** Test Cases *** +Blob Data Type - Logging Results Causes No Error + [Documentation] See https://github.com/MarketSquare/Robotframework-Database-Library/issues/244 + ${binary_data}= Evaluate b'abc' + Execute Sql String INSERT INTO blob_table VALUES(1, '${binary_data}') + ${result}= Query SELECT data FROM blob_table WHERE id=1 diff --git a/test/tests/custom_db_tests/oracle_omit_semicolon.robot b/test/tests/custom_db_tests/oracle_omit_semicolon.robot new file mode 100644 index 00000000..cbc9ae85 --- /dev/null +++ b/test/tests/custom_db_tests/oracle_omit_semicolon.robot @@ -0,0 +1,67 @@ +*** Settings *** +Documentation Tests for the parameter _omitTrailingSemicolon_ in the keyword +... _Execute SQL String_ - special for the issue #184: +... https://github.com/MarketSquare/Robotframework-Database-Library/issues/184 +... The _PLSQL BLOCK_ is most likely valid for Oracle DB only. + +Resource ../../resources/common.resource + +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Person Table And Insert Data +Test Teardown Drop Tables Person And Foobar + + +*** Variables *** +${NORMAL QUERY} SELECT * FROM person; +${PLSQL BLOCK} DECLARE ERRCODE NUMBER; ERRMSG VARCHAR2(200); BEGIN DBMS_OUTPUT.PUT_LINE('Hello!'); END; + +${ERROR SIMPLE QUERY} *ORA-03048: SQL reserved word ';' is not syntactically valid following* +${ERROR PLSQL} *PLS-00103: Encountered the symbol "end-of-file" when expecting one of the following* + + +*** Test Cases *** +Explicitely Omit Semicolon - Simple Query + [Documentation] Check if it works for Oracle - explicitly omitting the semicolon + ... is equal to the default behavior + Execute Sql String ${NORMAL QUERY} omit_trailing_semicolon=True + +Explicitely Don't Omit Semicolon - Simple Query + [Documentation] Check if Oracle throws an error + + Run Keyword And Expect Error ${ERROR SIMPLE QUERY} + ... Execute Sql String ${NORMAL QUERY} omit_trailing_semicolon=False + +Explicitely Omit Semicolon - PLSQL Block + [Documentation] Check if Oracle throws an error + Run Keyword And Expect Error ${ERROR PLSQL} + ... Execute Sql String ${PLSQL BLOCK} omit_trailing_semicolon=True + +Explicitely Don't Omit Semicolon - PLSQL Block + [Documentation] Should run without errors, because the semicolon is needed + ... at the end of the PLSQL block even with Oracle + Execute Sql String ${PLSQL BLOCK} omit_trailing_semicolon=False + +Explicitely Omit Semicolon With Keyword - Simple Query + [Documentation] Check if it works for Oracle - explicitly omitting the semicolon + ... is equal to the default behavior + Set Omit Trailing Semicolon True + Execute Sql String ${NORMAL QUERY} + +Explicitely Don't Omit Semicolon With Keyword - Simple Query + [Documentation] Check if Oracle throws an error + Set Omit Trailing Semicolon False + Run Keyword And Expect Error ${ERROR SIMPLE QUERY} + ... Execute Sql String ${NORMAL QUERY} + +Explicitely Omit Semicolon With Keyword - PLSQL Block + [Documentation] Check if Oracle throws an error + Set Omit Trailing Semicolon True + Run Keyword And Expect Error ${ERROR PLSQL} + ... Execute Sql String ${PLSQL BLOCK} + +Explicitely Don't Omit Semicolon With Keyword - PLSQL Block + [Documentation] Should run without errors, because the semicolon is needed + ... at the end of the PLSQL block even with Oracle + Set Omit Trailing Semicolon False + Execute Sql String ${PLSQL BLOCK} diff --git a/test/tests/custom_db_tests/oracle_thick_mode.robot b/test/tests/custom_db_tests/oracle_thick_mode.robot new file mode 100644 index 00000000..73d03e20 --- /dev/null +++ b/test/tests/custom_db_tests/oracle_thick_mode.robot @@ -0,0 +1,65 @@ +*** Settings *** +Documentation Tests of switching between thin and thick mode of oracledb client. +... Require the oracle client libraries installed. +... See more here: https://python-oracledb.readthedocs.io/en/latest/user_guide/initialization.html#initialization +... +... Due to current limitations of the oracledb module it's not possible to switch between thick and thin modes +... during a test execution session - even in different suites. +... So theses tests should be run separated only. + +Resource ../../resources/common.resource +Test Teardown Drop Tables And Disconnect + + +*** Variables *** +${DB_MODULE} oracledb +${DB_HOST} 127.0.0.1 +${DB_PORT} 1521 +${DB_PASS} pass +${DB_USER} db_user +${DB_NAME} db +${ORACLE_LIB_DIR} ${EMPTY} + + +*** Test Cases *** +Thick Mode Without Client Dir Specified + [Documentation] No client dir --> oracledb will search it in usual places + Connect And Run Simple Query oracle_driver_mode=thick + +Thick Mode With Client Dir Specified + [Documentation] Client dir specified --> oracledb will search it in this place + Connect And Run Simple Query oracle_driver_mode=thick,lib_dir=${ORACLE_LIB_DIR} + +Thin Mode - Default + [Documentation] No mode specified --> thin mode is used + Connect And Run Simple Query + +Thin Mode Explicitely Specified + [Documentation] Thin mode specified --> thin mode is used + Connect And Run Simple Query oracle_driver_mode=thin + +Wrong Mode + [Documentation] Wrong mode --> proper error message from the library + Run Keyword And Expect Error ValueError: Invalid Oracle client mode provided: wrong + ... Connect And Run Simple Query oracle_driver_mode=wrong + + +*** Keywords *** +Connect And Run Simple Query + [Documentation] Connect using usual params and client mode if provided + [Arguments] &{Extra params} + Connect To Database + ... ${DB_MODULE} + ... ${DB_NAME} + ... ${DB_USER} + ... ${DB_PASS} + ... ${DB_HOST} + ... ${DB_PORT} + ... &{Extra params} + Create Person Table + Query SELECT * FROM person + +Drop Tables And Disconnect + [Documentation] Clean data and disconnect + Drop Tables Person And Foobar + Disconnect From Database diff --git a/test/tests/custom_db_tests/sql_script_split_commands.robot b/test/tests/custom_db_tests/sql_script_split_commands.robot new file mode 100644 index 00000000..e6223ccb --- /dev/null +++ b/test/tests/custom_db_tests/sql_script_split_commands.robot @@ -0,0 +1,22 @@ +*** Settings *** +Documentation Tests for the parameter _split_ in the keyword +... _Execute SQL Script_ - special for the issue #184: +... https://github.com/MarketSquare/Robotframework-Database-Library/issues/184 + +Resource ../../resources/common.resource +Suite Setup Connect To DB +Suite Teardown Disconnect From Database +Test Setup Create Person Table +Test Teardown Drop Tables Person And Foobar + + +*** Test Cases *** +Split Commands + [Documentation] Such a simple script works always, + ... just check if the logs if the parameter value was processed properly + Execute Sql Script ${CURDIR}/../../resources/insert_data_in_person_table.sql split=True + +Don't Split Commands + [Documentation] Running such a script as a single statement works for PostgreSQL, + ... but fails in Oracle. Check in the logs if the splitting was disabled. + Execute Sql Script ${CURDIR}/../../resources/insert_data_in_person_table.sql split=False diff --git a/test/tests/utests/__init__.py b/test/tests/utests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/tests/utests/test_connection_manager.py b/test/tests/utests/test_connection_manager.py new file mode 100644 index 00000000..f53d8f01 --- /dev/null +++ b/test/tests/utests/test_connection_manager.py @@ -0,0 +1,37 @@ +import re +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from DatabaseLibrary.connection_manager import ConnectionManager + +TEST_DATA = Path(__file__).parent / "test_data" + + +class TestConnectWithConfigFile: + def test_connect_with_empty_config(self): + conn_manager = ConnectionManager() + config_path = str(TEST_DATA / "empty.cfg") + with pytest.raises( + ValueError, + match="Required parameter 'db_module' was not provided - neither in keyword arguments nor in config file", + ): + conn_manager.connect_to_database(config_file=config_path) + + def test_aliased_section(self): + conn_manager = ConnectionManager() + config_path = str(TEST_DATA / "alias.cfg") + with patch("importlib.import_module", new=MagicMock()) as client: + conn_manager.connect_to_database( + "my_client", + db_user="name", + db_password="password", + db_host="host", + db_port=0, + config_file=config_path, + alias="alias2", + ) + client.return_value.connect.assert_called_with( + database="example", user="name", password="password", host="host", port=0 + ) diff --git a/test/tests/utests/test_data/alias.cfg b/test/tests/utests/test_data/alias.cfg new file mode 100644 index 00000000..51b5ee56 --- /dev/null +++ b/test/tests/utests/test_data/alias.cfg @@ -0,0 +1,2 @@ +[alias2] +db_name = example diff --git a/test/tests/utests/test_data/empty.cfg b/test/tests/utests/test_data/empty.cfg new file mode 100644 index 00000000..e69de29b diff --git a/test/tests/utests/test_data/no_option.cfg b/test/tests/utests/test_data/no_option.cfg new file mode 100644 index 00000000..53c0731f --- /dev/null +++ b/test/tests/utests/test_data/no_option.cfg @@ -0,0 +1,3 @@ +[default] +db_name = example +db_user = example \ No newline at end of file