wanger 8 달 전
커밋
a40c96b4cd
100개의 변경된 파일25447개의 추가작업 그리고 0개의 파일을 삭제
  1. 152 0
      .gitignore
  2. 19 0
      MetaSearch/LICENSE.txt
  3. 29 0
      MetaSearch/__init__.py
  4. 19 0
      MetaSearch/dialogs/__init__.py
  5. 39 0
      MetaSearch/dialogs/apidialog.py
  6. 1018 0
      MetaSearch/dialogs/maindialog.py
  7. 195 0
      MetaSearch/dialogs/manageconnectionsdialog.py
  8. 112 0
      MetaSearch/dialogs/newconnectiondialog.py
  9. 43 0
      MetaSearch/dialogs/recorddialog.py
  10. 0 0
      MetaSearch/images/MetaSearch.svg
  11. 66 0
      MetaSearch/link_types.py
  12. 14 0
      MetaSearch/metadata.txt
  13. 98 0
      MetaSearch/plugin.py
  14. 21 0
      MetaSearch/resources/connections-default.xml
  15. 13 0
      MetaSearch/resources/templates/api_highlight.html
  16. 123 0
      MetaSearch/resources/templates/csw_service_metadata.html
  17. 67 0
      MetaSearch/resources/templates/oarec_service_metadata.html
  18. 86 0
      MetaSearch/resources/templates/record_metadata_dc.html
  19. 63 0
      MetaSearch/resources/templates/record_metadata_oarec.html
  20. 283 0
      MetaSearch/search_backend.py
  21. 91 0
      MetaSearch/ui/apidialog.ui
  22. 632 0
      MetaSearch/ui/maindialog.ui
  23. 98 0
      MetaSearch/ui/manageconnectionsdialog.ui
  24. 165 0
      MetaSearch/ui/newconnectiondialog.ui
  25. 74 0
      MetaSearch/ui/recorddialog.ui
  26. 190 0
      MetaSearch/util.py
  27. 15 0
      db_manager/LICENSE
  28. 35 0
      db_manager/TODO
  29. 25 0
      db_manager/__init__.py
  30. 490 0
      db_manager/db_manager.py
  31. 96 0
      db_manager/db_manager_plugin.py
  32. 660 0
      db_manager/db_model.py
  33. 73 0
      db_manager/db_plugins/__init__.py
  34. 241 0
      db_manager/db_plugins/connector.py
  35. 378 0
      db_manager/db_plugins/data_model.py
  36. 0 0
      db_manager/db_plugins/gpkg/__init__.py
  37. 849 0
      db_manager/db_plugins/gpkg/connector.py
  38. 84 0
      db_manager/db_plugins/gpkg/data_model.py
  39. 45 0
      db_manager/db_plugins/gpkg/info_model.py
  40. 338 0
      db_manager/db_plugins/gpkg/plugin.py
  41. 26 0
      db_manager/db_plugins/gpkg/sql_dictionary.py
  42. 170 0
      db_manager/db_plugins/html_elems.py
  43. 461 0
      db_manager/db_plugins/info_model.py
  44. 216 0
      db_manager/db_plugins/oracle/QtSqlDB.py
  45. 0 0
      db_manager/db_plugins/oracle/__init__.py
  46. 1738 0
      db_manager/db_plugins/oracle/connector.py
  47. 182 0
      db_manager/db_plugins/oracle/data_model.py
  48. 667 0
      db_manager/db_plugins/oracle/info_model.py
  49. 652 0
      db_manager/db_plugins/oracle/plugin.py
  50. 303 0
      db_manager/db_plugins/oracle/sql_dictionary.py
  51. 1389 0
      db_manager/db_plugins/plugin.py
  52. 0 0
      db_manager/db_plugins/postgis/__init__.py
  53. 1262 0
      db_manager/db_plugins/postgis/connector.py
  54. 78 0
      db_manager/db_plugins/postgis/connector_test.py
  55. 114 0
      db_manager/db_plugins/postgis/data_model.py
  56. 258 0
      db_manager/db_plugins/postgis/info_model.py
  57. 483 0
      db_manager/db_plugins/postgis/plugin.py
  58. 155 0
      db_manager/db_plugins/postgis/plugin_test.py
  59. 37 0
      db_manager/db_plugins/postgis/plugins/__init__.py
  60. 309 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/__init__.py
  61. 559 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/edge.qml
  62. 327 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/edge_label.qml
  63. 419 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/face.qml
  64. 328 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/face_left.qml
  65. 275 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/face_mbr.qml
  66. 328 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/face_right.qml
  67. 282 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/face_seed.qml
  68. 328 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/next_left.qml
  69. 328 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/next_right.qml
  70. 31 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/node.qml
  71. 133 0
      db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/node_label.qml
  72. 51 0
      db_manager/db_plugins/postgis/plugins/versioning/__init__.py
  73. 285 0
      db_manager/db_plugins/postgis/plugins/versioning/dlg_versioning.py
  74. 125 0
      db_manager/db_plugins/postgis/plugins/versioning/ui_DlgVersioning.py
  75. 234 0
      db_manager/db_plugins/postgis/sql_dictionary.py
  76. 0 0
      db_manager/db_plugins/spatialite/__init__.py
  77. 737 0
      db_manager/db_plugins/spatialite/connector.py
  78. 101 0
      db_manager/db_plugins/spatialite/data_model.py
  79. 67 0
      db_manager/db_plugins/spatialite/info_model.py
  80. 309 0
      db_manager/db_plugins/spatialite/plugin.py
  81. 156 0
      db_manager/db_plugins/spatialite/sql_dictionary.py
  82. 0 0
      db_manager/db_plugins/vlayers/__init__.py
  83. 424 0
      db_manager/db_plugins/vlayers/connector.py
  84. 169 0
      db_manager/db_plugins/vlayers/data_model.py
  85. 44 0
      db_manager/db_plugins/vlayers/info_model.py
  86. 195 0
      db_manager/db_plugins/vlayers/plugin.py
  87. 172 0
      db_manager/db_plugins/vlayers/sql_dictionary.py
  88. 177 0
      db_manager/db_tree.py
  89. 68 0
      db_manager/dlg_add_geometry_column.py
  90. 73 0
      db_manager/dlg_create_constraint.py
  91. 80 0
      db_manager/dlg_create_index.py
  92. 322 0
      db_manager/dlg_create_table.py
  93. 55 0
      db_manager/dlg_db_error.py
  94. 198 0
      db_manager/dlg_export_vector.py
  95. 90 0
      db_manager/dlg_field_properties.py
  96. 390 0
      db_manager/dlg_import_vector.py
  97. 388 0
      db_manager/dlg_query_builder.py
  98. 579 0
      db_manager/dlg_sql_layer_window.py
  99. 719 0
      db_manager/dlg_sql_window.py
  100. 362 0
      db_manager/dlg_table_properties.py

+ 152 - 0
.gitignore

@@ -0,0 +1,152 @@
+style.sld
+FunctionsToImplement.py
+record.txt
+package_test.py
+test.py
+.idea/
+
+# Created by https://www.toptal.com/developers/gitignore/api/python
+# Edit at https://www.toptal.com/developers/gitignore?templates=python
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+record.txt
+FunctionsToImplement.py
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+pytestdebug.log
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+doc/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+.vscode/
+.direnv/
+venv/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# End of https://www.toptal.com/developers/gitignore/api/python

+ 19 - 0
MetaSearch/LICENSE.txt

@@ -0,0 +1,19 @@
+Copyright (C) 2010 NextGIS (http://nextgis.ru),
+Alexander Bruy (alexander.bruy@gmail.com),
+Maxim Dubinin (sim@gis-lab.info),
+
+Copyright (C) 2014 Tom Kralidis (tomkralidis@gmail.com)
+
+This source is free software; you can redistribute it and/or modify it under
+the terms of the GNU General Public License as published by the Free
+Software Foundation; either version 2 of the License, or (at your option)
+any later version.
+
+This code is distributed in the hope that it will be useful, but WITHOUT ANY
+WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+details.
+
+You should have received a copy of the GNU General Public License along
+with this program; if not, write to the Free Software Foundation, Inc.,
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

+ 29 - 0
MetaSearch/__init__.py

@@ -0,0 +1,29 @@
+###############################################################################
+#
+# Copyright (C) 2010 NextGIS (http://nextgis.org),
+#                    Alexander Bruy (alexander.bruy@gmail.com),
+#                    Maxim Dubinin (sim@gis-lab.info),
+#
+# Copyright (C) 2014 Tom Kralidis (tomkralidis@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################
+
+
+def classFactory(iface):
+    """invoke plugin"""
+    from MetaSearch.plugin import MetaSearchPlugin
+    return MetaSearchPlugin(iface)

+ 19 - 0
MetaSearch/dialogs/__init__.py

@@ -0,0 +1,19 @@
+###############################################################################
+#
+# Copyright (C) 2014 Tom Kralidis (tomkralidis@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################

+ 39 - 0
MetaSearch/dialogs/apidialog.py

@@ -0,0 +1,39 @@
+###############################################################################
+#
+# CSW Client
+# ---------------------------------------------------------
+# QGIS Catalog Service client.
+#
+# Copyright (C) 2014 Tom Kralidis (tomkralidis@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################
+
+from qgis.PyQt.QtWidgets import QDialog
+
+from MetaSearch.util import get_ui_class
+
+BASE_CLASS = get_ui_class('apidialog.ui')
+
+
+class APIRequestResponseDialog(QDialog, BASE_CLASS):
+    """Raw XML Dialogue"""
+
+    def __init__(self):
+        """init"""
+
+        QDialog.__init__(self)
+        self.setupUi(self)

+ 1018 - 0
MetaSearch/dialogs/maindialog.py

@@ -0,0 +1,1018 @@
+###############################################################################
+#
+# CSW Client
+# ---------------------------------------------------------
+# QGIS Catalog Service client.
+#
+# Copyright (C) 2010 NextGIS (http://nextgis.org),
+#                    Alexander Bruy (alexander.bruy@gmail.com),
+#                    Maxim Dubinin (sim@gis-lab.info)
+#
+# Copyright (C) 2017 Tom Kralidis (tomkralidis@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################
+
+import json
+import os.path
+from urllib.request import build_opener, install_opener, ProxyHandler
+
+from qgis.PyQt.QtCore import Qt
+from qgis.PyQt.QtWidgets import (QDialog, QComboBox,
+                                 QDialogButtonBox, QMessageBox,
+                                 QTreeWidgetItem, QWidget)
+from qgis.PyQt.QtGui import QColor
+
+from qgis.core import (Qgis, QgsApplication, QgsCoordinateReferenceSystem,
+                       QgsCoordinateTransform, QgsGeometry, QgsPointXY,
+                       QgsProviderRegistry, QgsSettings, QgsProject,
+                       QgsRectangle, QgsSettingsTree)
+from qgis.gui import QgsRubberBand, QgsGui
+from qgis.utils import OverrideCursor
+
+from MetaSearch import link_types
+from MetaSearch.dialogs.manageconnectionsdialog import ManageConnectionsDialog
+from MetaSearch.dialogs.newconnectiondialog import NewConnectionDialog
+from MetaSearch.dialogs.recorddialog import RecordDialog
+from MetaSearch.dialogs.apidialog import APIRequestResponseDialog
+from MetaSearch.search_backend import get_catalog_service
+from MetaSearch.util import (clean_ows_url, get_connections_from_file,
+                             get_ui_class, get_help_url, highlight_content,
+                             normalize_text, open_url, render_template,
+                             serialize_string, StaticContext)
+
+BASE_CLASS = get_ui_class('maindialog.ui')
+
+
+class MetaSearchDialog(QDialog, BASE_CLASS):
+    """main dialogue"""
+
+    def __init__(self, iface):
+        """init window"""
+
+        QDialog.__init__(self)
+        self.setupUi(self)
+
+        self.iface = iface
+        self.map = iface.mapCanvas()
+        self.settings = QgsSettings()
+        self.catalog = None
+        self.catalog_url = None
+        self.catalog_username = None
+        self.catalog_password = None
+        self.catalog_type = None
+        self.context = StaticContext()
+
+        self.leKeywords.setShowSearchIcon(True)
+        self.leKeywords.setPlaceholderText(self.tr('Search keywords'))
+
+        self.setWindowTitle(self.tr('MetaSearch'))
+
+        self.rubber_band = QgsRubberBand(self.map, Qgis.GeometryType.Polygon)
+        self.rubber_band.setColor(QColor(255, 0, 0, 75))
+        self.rubber_band.setWidth(5)
+
+        # form inputs
+        self.startfrom = 1
+        self.constraints = []
+        self.maxrecords = int(self.settings.value('/MetaSearch/returnRecords', 10))
+        self.timeout = int(self.settings.value('/MetaSearch/timeout', 10))
+        self.disable_ssl_verification = self.settings.value(
+            '/MetaSearch/disableSSL', False, bool)
+
+        # Services tab
+        self.cmbConnectionsServices.activated.connect(self.save_connection)
+        self.cmbConnectionsSearch.activated.connect(self.save_connection)
+        self.btnServerInfo.clicked.connect(self.connection_info)
+        self.btnAddDefault.clicked.connect(self.add_default_connections)
+        self.btnRawAPIResponse.clicked.connect(self.show_api)
+        self.tabWidget.currentChanged.connect(self.populate_connection_list)
+
+        # server management buttons
+        self.btnNew.clicked.connect(self.add_connection)
+        self.btnEdit.clicked.connect(self.edit_connection)
+        self.btnDelete.clicked.connect(self.delete_connection)
+        self.btnLoad.clicked.connect(self.load_connections)
+        self.btnSave.clicked.connect(save_connections)
+
+        # Search tab
+        self.treeRecords.itemSelectionChanged.connect(self.record_clicked)
+        self.treeRecords.itemDoubleClicked.connect(self.show_metadata)
+        self.btnSearch.clicked.connect(self.search)
+        self.leKeywords.returnPressed.connect(self.search)
+        # prevent dialog from closing upon pressing enter
+        self.buttonBox.button(QDialogButtonBox.Close).setAutoDefault(False)
+        # launch help from button
+        self.buttonBox.helpRequested.connect(self.help)
+        self.btnCanvasBbox.setAutoDefault(False)
+        self.btnCanvasBbox.clicked.connect(self.set_bbox_from_map)
+        self.btnGlobalBbox.clicked.connect(self.set_bbox_global)
+
+        # navigation buttons
+        self.btnFirst.clicked.connect(self.navigate)
+        self.btnPrev.clicked.connect(self.navigate)
+        self.btnNext.clicked.connect(self.navigate)
+        self.btnLast.clicked.connect(self.navigate)
+
+        self.mActionAddWms.triggered.connect(self.add_to_ows)
+        self.mActionAddWfs.triggered.connect(self.add_to_ows)
+        self.mActionAddWcs.triggered.connect(self.add_to_ows)
+        self.mActionAddAms.triggered.connect(self.add_to_ows)
+        self.mActionAddAfs.triggered.connect(self.add_to_ows)
+        self.mActionAddGisFile.triggered.connect(self.add_gis_file)
+        self.btnViewRawAPIResponse.clicked.connect(self.show_api)
+
+        self.manageGui()
+
+    def manageGui(self):
+        """open window"""
+        def _on_timeout_change(value):
+            self.settings.setValue('/MetaSearch/timeout', value)
+            self.timeout = value
+
+        def _on_records_change(value):
+            self.settings.setValue('/MetaSearch/returnRecords', value)
+            self.maxrecords = value
+
+        def _on_ssl_state_change(state):
+            self.settings.setValue('/MetaSearch/disableSSL', bool(state))
+            self.disable_ssl_verification = bool(state)
+
+        self.tabWidget.setCurrentIndex(0)
+        self.populate_connection_list()
+        self.btnRawAPIResponse.setEnabled(False)
+
+        # load settings
+        self.spnRecords.setValue(self.maxrecords)
+        self.spnRecords.valueChanged.connect(_on_records_change)
+        self.spnTimeout.setValue(self.timeout)
+        self.spnTimeout.valueChanged.connect(_on_timeout_change)
+        self.disableSSLVerification.setChecked(self.disable_ssl_verification)
+        self.disableSSLVerification.stateChanged.connect(_on_ssl_state_change)
+
+        key = '/MetaSearch/%s' % self.cmbConnectionsSearch.currentText()
+        self.catalog_url = self.settings.value('%s/url' % key)
+        self.catalog_username = self.settings.value('%s/username' % key)
+        self.catalog_password = self.settings.value('%s/password' % key)
+        self.catalog_type = self.settings.value('%s/catalog-type' % key)
+
+        self.set_bbox_global()
+
+        self.reset_buttons()
+
+        # install proxy handler if specified in QGIS settings
+        self.install_proxy()
+
+    # Services tab
+
+    def populate_connection_list(self):
+        """populate select box with connections"""
+
+        self.settings.beginGroup('/MetaSearch/')
+        self.cmbConnectionsServices.clear()
+        self.cmbConnectionsServices.addItems(self.settings.childGroups())
+        self.cmbConnectionsSearch.clear()
+        self.cmbConnectionsSearch.addItems(self.settings.childGroups())
+        self.settings.endGroup()
+
+        self.set_connection_list_position()
+
+        if self.cmbConnectionsServices.count() == 0:
+            # no connections - disable various buttons
+            state_disabled = False
+            self.btnSave.setEnabled(state_disabled)
+            # and start with connection tab open
+            self.tabWidget.setCurrentIndex(1)
+            # tell the user to add services
+            msg = self.tr('No services/connections defined. To get '
+                          'started with MetaSearch, create a new '
+                          'connection by clicking \'New\' or click '
+                          '\'Add default services\'.')
+            self.textMetadata.setHtml('<p><h3>%s</h3></p>' % msg)
+        else:
+            # connections - enable various buttons
+            state_disabled = True
+
+        self.btnServerInfo.setEnabled(state_disabled)
+        self.btnEdit.setEnabled(state_disabled)
+        self.btnDelete.setEnabled(state_disabled)
+
+    def set_connection_list_position(self):
+        """set the current index to the selected connection"""
+        to_select = self.settings.value('/MetaSearch/selected')
+        conn_count = self.cmbConnectionsServices.count()
+
+        if conn_count == 0:
+            self.btnDelete.setEnabled(False)
+            self.btnServerInfo.setEnabled(False)
+            self.btnEdit.setEnabled(False)
+
+        # does to_select exist in cmbConnectionsServices?
+        exists = False
+        for i in range(conn_count):
+            if self.cmbConnectionsServices.itemText(i) == to_select:
+                self.cmbConnectionsServices.setCurrentIndex(i)
+                self.cmbConnectionsSearch.setCurrentIndex(i)
+                exists = True
+                break
+
+        # If we couldn't find the stored item, but there are some, default
+        # to the last item (this makes some sense when deleting items as it
+        # allows the user to repeatidly click on delete to remove a whole
+        # lot of items)
+        if not exists and conn_count > 0:
+            # If to_select is null, then the selected connection wasn't found
+            # by QgsSettings, which probably means that this is the first time
+            # the user has used CSWClient, so default to the first in the list
+            # of connetions. Otherwise default to the last.
+            if not to_select:
+                current_index = 0
+            else:
+                current_index = conn_count - 1
+
+            self.cmbConnectionsServices.setCurrentIndex(current_index)
+            self.cmbConnectionsSearch.setCurrentIndex(current_index)
+
+    def save_connection(self):
+        """save connection"""
+
+        caller = self.sender().objectName()
+
+        if caller == 'cmbConnectionsServices':  # servers tab
+            current_text = self.cmbConnectionsServices.currentText()
+        elif caller == 'cmbConnectionsSearch':  # search tab
+            current_text = self.cmbConnectionsSearch.currentText()
+
+        self.settings.setValue('/MetaSearch/selected', current_text)
+        key = '/MetaSearch/%s' % current_text
+
+        if caller == 'cmbConnectionsSearch':  # bind to service in search tab
+            self.catalog_url = self.settings.value('%s/url' % key)
+            self.catalog_username = self.settings.value('%s/username' % key)
+            self.catalog_password = self.settings.value('%s/password' % key)
+            self.catalog_type = self.settings.value('%s/catalog-type' % key)
+
+        if caller == 'cmbConnectionsServices':  # clear server metadata
+            self.textMetadata.clear()
+
+        self.btnRawAPIResponse.setEnabled(False)
+
+    def connection_info(self):
+        """show connection info"""
+
+        current_text = self.cmbConnectionsServices.currentText()
+        key = '/MetaSearch/%s' % current_text
+        self.catalog_url = self.settings.value('%s/url' % key)
+        self.catalog_username = self.settings.value('%s/username' % key)
+        self.catalog_password = self.settings.value('%s/password' % key)
+        self.catalog_type = self.settings.value('%s/catalog-type' % key)
+
+        # connect to the server
+        if not self._get_catalog():
+            return
+
+        if self.catalog:  # display service metadata
+            self.btnRawAPIResponse.setEnabled(True)
+            metadata = render_template('en', self.context,
+                                       self.catalog.conn,
+                                       self.catalog.service_info_template)
+            style = QgsApplication.reportStyleSheet()
+            self.textMetadata.clear()
+            self.textMetadata.document().setDefaultStyleSheet(style)
+            self.textMetadata.setHtml(metadata)
+
+            # clear results and disable buttons in Search tab
+            self.clear_results()
+
+    def add_connection(self):
+        """add new service"""
+
+        conn_new = NewConnectionDialog()
+        conn_new.setWindowTitle(self.tr('New Catalog Service'))
+        if conn_new.exec_() == QDialog.Accepted:  # add to service list
+            self.populate_connection_list()
+        self.textMetadata.clear()
+
+    def edit_connection(self):
+        """modify existing connection"""
+
+        current_text = self.cmbConnectionsServices.currentText()
+
+        url = self.settings.value('/MetaSearch/%s/url' % current_text)
+
+        conn_edit = NewConnectionDialog(current_text)
+        conn_edit.setWindowTitle(self.tr('Edit Catalog Service'))
+        conn_edit.leName.setText(current_text)
+        conn_edit.leURL.setText(url)
+        conn_edit.leUsername.setText(
+            self.settings.value('/MetaSearch/%s/username' % current_text))
+        conn_edit.lePassword.setText(
+            self.settings.value('/MetaSearch/%s/password' % current_text))
+
+        conn_edit.cmbCatalogType.setCurrentText(
+            self.settings.value('/MetaSearch/%s/catalog-type' % current_text))
+
+        if conn_edit.exec_() == QDialog.Accepted:  # update service list
+            self.populate_connection_list()
+
+    def delete_connection(self):
+        """delete connection"""
+
+        current_text = self.cmbConnectionsServices.currentText()
+
+        key = '/MetaSearch/%s' % current_text
+
+        msg = self.tr('Remove service {0}?').format(current_text)
+
+        result = QMessageBox.question(
+            self, self.tr('Delete Service'), msg,
+            QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
+        if result == QMessageBox.Yes:  # remove service from list
+            self.settings.remove(key)
+            index_to_delete = self.cmbConnectionsServices.currentIndex()
+            self.cmbConnectionsServices.removeItem(index_to_delete)
+            self.cmbConnectionsSearch.removeItem(index_to_delete)
+            self.set_connection_list_position()
+
+    def load_connections(self):
+        """load services from list"""
+
+        ManageConnectionsDialog(1).exec_()
+        self.populate_connection_list()
+
+    def add_default_connections(self):
+        """add default connections"""
+
+        filename = os.path.join(self.context.ppath,
+                                'resources', 'connections-default.xml')
+
+        doc = get_connections_from_file(self, filename)
+        if doc is None:
+            return
+
+        self.settings.beginGroup('/MetaSearch/')
+        keys = self.settings.childGroups()
+        self.settings.endGroup()
+
+        for server in doc.findall('csw'):
+            name = server.attrib.get('name')
+            # check for duplicates
+            if name in keys:
+                msg = self.tr('{0} exists.  Overwrite?').format(name)
+                res = QMessageBox.warning(self,
+                                          self.tr('Loading connections'), msg,
+                                          QMessageBox.Yes | QMessageBox.No)
+                if res != QMessageBox.Yes:
+                    continue
+
+            # no dups detected or overwrite is allowed
+            key = '/MetaSearch/%s' % name
+            self.settings.setValue('%s/url' % key, server.attrib.get('url'))
+            self.settings.setValue('%s/catalog-type' % key, server.attrib.get('catalog-type', 'OGC CSW 2.0.2'))
+
+        self.populate_connection_list()
+
+    # Settings tab
+
+    def set_ows_save_title_ask(self):
+        """save ows save strategy as save ows title, ask if duplicate"""
+
+        self.settings.setValue('/MetaSearch/ows_save_strategy', 'title_ask')
+
+    def set_ows_save_title_no_ask(self):
+        """save ows save strategy as save ows title, do NOT ask if duplicate"""
+
+        self.settings.setValue('/MetaSearch/ows_save_strategy', 'title_no_ask')
+
+    def set_ows_save_temp_name(self):
+        """save ows save strategy as save with a temporary name"""
+
+        self.settings.setValue('/MetaSearch/ows_save_strategy', 'temp_name')
+
+    # Search tab
+
+    def set_bbox_from_map(self):
+        """set bounding box from map extent"""
+
+        crs = self.map.mapSettings().destinationCrs()
+        try:
+            crsid = int(crs.authid().split(':')[1])
+        except IndexError:  # no projection
+            crsid = 4326
+
+        extent = self.map.extent()
+
+        if crsid != 4326:  # reproject to EPSG:4326
+            src = QgsCoordinateReferenceSystem(crsid)
+            dest = QgsCoordinateReferenceSystem("EPSG:4326")
+            xform = QgsCoordinateTransform(src, dest, QgsProject.instance())
+            minxy = xform.transform(QgsPointXY(extent.xMinimum(),
+                                               extent.yMinimum()))
+            maxxy = xform.transform(QgsPointXY(extent.xMaximum(),
+                                               extent.yMaximum()))
+            minx, miny = minxy
+            maxx, maxy = maxxy
+        else:  # 4326
+            minx = extent.xMinimum()
+            miny = extent.yMinimum()
+            maxx = extent.xMaximum()
+            maxy = extent.yMaximum()
+
+        self.leNorth.setText(str(maxy)[0:9])
+        self.leSouth.setText(str(miny)[0:9])
+        self.leWest.setText(str(minx)[0:9])
+        self.leEast.setText(str(maxx)[0:9])
+
+    def set_bbox_global(self):
+        """set global bounding box"""
+        self.leNorth.setText('90')
+        self.leSouth.setText('-90')
+        self.leWest.setText('-180')
+        self.leEast.setText('180')
+
+    def search(self):
+        """execute search"""
+
+        self.catalog = None
+        self.constraints = []
+
+        # clear all fields and disable buttons
+        self.clear_results()
+
+        # set current catalog
+        current_text = self.cmbConnectionsSearch.currentText()
+        key = '/MetaSearch/%s' % current_text
+        self.catalog_url = self.settings.value('%s/url' % key)
+        self.catalog_username = self.settings.value('%s/username' % key)
+        self.catalog_password = self.settings.value('%s/password' % key)
+        self.catalog_type = self.settings.value('%s/catalog-type' % key)
+
+        # start position and number of records to return
+        self.startfrom = 1
+
+        # bbox
+        # CRS is WGS84 with axis order longitude, latitude
+        # defined by 'urn:ogc:def:crs:OGC:1.3:CRS84'
+        minx = self.leWest.text()
+        miny = self.leSouth.text()
+        maxx = self.leEast.text()
+        maxy = self.leNorth.text()
+        bbox = [minx, miny, maxx, maxy]
+        keywords = self.leKeywords.text()
+
+        # build request
+        if not self._get_catalog():
+            return
+
+        # TODO: allow users to select resources types
+        # to find ('service', 'dataset', etc.)
+        try:
+            with OverrideCursor(Qt.WaitCursor):
+                self.catalog.query_records(bbox, keywords, self.maxrecords,
+                                           self.startfrom)
+
+        except Exception as err:
+            QMessageBox.warning(self, self.tr('Search error'),
+                                self.tr('Search error: {0}').format(err))
+            return
+
+        if self.catalog.matches == 0:
+            self.lblResults.setText(self.tr('0 results'))
+            return
+
+        self.display_results()
+
+    def display_results(self):
+        """display search results"""
+
+        self.treeRecords.clear()
+
+        position = self.catalog.returned + self.startfrom - 1
+
+        msg = self.tr('Showing {0} - {1} of %n result(s)', 'number of results',
+                      self.catalog.matches).format(self.startfrom, position)
+
+        self.lblResults.setText(msg)
+
+        for rec in self.catalog.records():
+            item = QTreeWidgetItem(self.treeRecords)
+            if rec['type']:
+                item.setText(0, normalize_text(rec['type']))
+            else:
+                item.setText(0, 'unknown')
+            if rec['title']:
+                item.setText(1, normalize_text(rec['title']))
+            if rec['identifier']:
+                set_item_data(item, 'identifier', rec['identifier'])
+
+        self.btnViewRawAPIResponse.setEnabled(True)
+
+        if self.catalog.matches < self.maxrecords:
+            disabled = False
+        else:
+            disabled = True
+
+        self.btnFirst.setEnabled(disabled)
+        self.btnPrev.setEnabled(disabled)
+        self.btnNext.setEnabled(disabled)
+        self.btnLast.setEnabled(disabled)
+        self.btnRawAPIResponse.setEnabled(False)
+
+    def clear_results(self):
+        """clear search results"""
+
+        self.lblResults.clear()
+        self.treeRecords.clear()
+        self.reset_buttons()
+
+    def record_clicked(self):
+        """record clicked signal"""
+
+        # disable only service buttons
+        self.reset_buttons(True, False, False)
+
+        self.rubber_band.reset()
+
+        if not self.treeRecords.selectedItems():
+            return
+
+        item = self.treeRecords.currentItem()
+        if not item:
+            return
+
+        identifier = get_item_data(item, 'identifier')
+        try:
+            record = next(item for item in self.catalog.records()
+                          if item['identifier'] == identifier)
+        except KeyError:
+            QMessageBox.warning(self,
+                                self.tr('Record parsing error'),
+                                'Unable to locate record identifier')
+            return
+
+        # if the record has a bbox, show a footprint on the map
+        if record['bbox'] is not None:
+            bx = record['bbox']
+            rt = QgsRectangle(float(bx['minx']), float(bx['miny']),
+                              float(bx['maxx']), float(bx['maxy']))
+            geom = QgsGeometry.fromRect(rt)
+
+            if geom is not None:
+                src = QgsCoordinateReferenceSystem("EPSG:4326")
+                dst = self.map.mapSettings().destinationCrs()
+                if src.postgisSrid() != dst.postgisSrid():
+                    ctr = QgsCoordinateTransform(
+                        src, dst, QgsProject.instance())
+                    try:
+                        geom.transform(ctr)
+                    except Exception as err:
+                        QMessageBox.warning(
+                            self,
+                            self.tr('Coordinate Transformation Error'),
+                            str(err))
+                self.rubber_band.setToGeometry(geom, None)
+
+        # figure out if the data is interactive and can be operated on
+        self.find_services(record, item)
+
+    def find_services(self, record, item):
+        """scan record for WMS/WMTS|WFS|WCS endpoints"""
+
+        services = {}
+        for link in record['links']:
+            link = self.catalog.parse_link(link)
+            if 'scheme' in link:
+                link_type = link['scheme']
+            elif 'protocol' in link:
+                link_type = link['protocol']
+            else:
+                link_type = None
+
+            if link_type is not None:
+                link_type = link_type.upper()
+
+            wmswmst_link_types = list(
+                map(str.upper, link_types.WMSWMST_LINK_TYPES))
+            wfs_link_types = list(map(str.upper, link_types.WFS_LINK_TYPES))
+            wcs_link_types = list(map(str.upper, link_types.WCS_LINK_TYPES))
+            ams_link_types = list(map(str.upper, link_types.AMS_LINK_TYPES))
+            afs_link_types = list(map(str.upper, link_types.AFS_LINK_TYPES))
+            gis_file_link_types = list(
+                map(str.upper, link_types.GIS_FILE_LINK_TYPES))
+
+            # if the link type exists, and it is one of the acceptable
+            # interactive link types, then set
+            all_link_types = (wmswmst_link_types + wfs_link_types +
+                              wcs_link_types + ams_link_types +
+                              afs_link_types + gis_file_link_types)
+
+            if all([link_type is not None, link_type in all_link_types]):
+                if link_type in wmswmst_link_types:
+                    services['wms'] = link['url']
+                    self.mActionAddWms.setEnabled(True)
+                if link_type in wfs_link_types:
+                    services['wfs'] = link['url']
+                    self.mActionAddWfs.setEnabled(True)
+                if link_type in wcs_link_types:
+                    services['wcs'] = link['url']
+                    self.mActionAddWcs.setEnabled(True)
+                if link_type in ams_link_types:
+                    services['ams'] = link['url']
+                    self.mActionAddAms.setEnabled(True)
+                if link_type in afs_link_types:
+                    services['afs'] = link['url']
+                    self.mActionAddAfs.setEnabled(True)
+                if link_type in gis_file_link_types:
+                    services['gis_file'] = link['url']
+                    services['title'] = record.get('title', '')
+                    self.mActionAddGisFile.setEnabled(True)
+                self.tbAddData.setEnabled(True)
+
+            set_item_data(item, 'link', json.dumps(services))
+
+    def navigate(self):
+        """manage navigation / paging"""
+
+        caller = self.sender().objectName()
+
+        if caller == 'btnFirst':
+            self.startfrom = 1
+        elif caller == 'btnLast':
+            self.startfrom = self.catalog.matches - self.maxrecords + 1
+        elif caller == 'btnNext':
+            if self.startfrom > self.catalog.matches - self.maxrecords:
+                msg = self.tr('End of results. Go to start?')
+                res = QMessageBox.information(self, self.tr('Navigation'),
+                                              msg,
+                                              (QMessageBox.Ok |
+                                               QMessageBox.Cancel))
+                if res == QMessageBox.Ok:
+                    self.startfrom = 1
+                else:
+                    return
+            else:
+                self.startfrom += self.maxrecords
+        elif caller == "btnPrev":
+            if self.startfrom == 1:
+                msg = self.tr('Start of results. Go to end?')
+                res = QMessageBox.information(self, self.tr('Navigation'),
+                                              msg,
+                                              (QMessageBox.Ok |
+                                               QMessageBox.Cancel))
+                if res == QMessageBox.Ok:
+                    self.startfrom = (self.catalog.matches -
+                                      self.maxrecords + 1)
+                else:
+                    return
+            elif self.startfrom <= self.maxrecords:
+                self.startfrom = 1
+            else:
+                self.startfrom -= self.maxrecords
+
+        # bbox
+        # CRS is WGS84 with axis order longitude, latitude
+        # defined by 'urn:ogc:def:crs:OGC:1.3:CRS84'
+        minx = self.leWest.text()
+        miny = self.leSouth.text()
+        maxx = self.leEast.text()
+        maxy = self.leNorth.text()
+        bbox = [minx, miny, maxx, maxy]
+        keywords = self.leKeywords.text()
+
+        try:
+            with OverrideCursor(Qt.WaitCursor):
+                self.catalog.query_records(bbox, keywords,
+                                           limit=self.maxrecords,
+                                           offset=self.startfrom)
+        except Exception as err:
+            QMessageBox.warning(self, self.tr('Search error'),
+                                self.tr('Search error: {0}').format(err))
+            return
+
+        self.display_results()
+
+    def add_to_ows(self):
+        """add to OWS provider connection list"""
+
+        conn_name_matches = []
+
+        item = self.treeRecords.currentItem()
+
+        if not item:
+            return
+
+        item_data = json.loads(get_item_data(item, 'link'))
+
+        caller = self.sender().objectName()
+
+        if caller == 'mActionAddWms':
+            service_type = 'OGC:WMS/OGC:WMTS'
+            sname = 'WMS'
+            dyn_param = ['wms']
+            provider_name = 'wms'
+            setting_node = QgsSettingsTree.node('connections').childNode('ows').childNode('connections')
+            data_url = item_data['wms']
+        elif caller == 'mActionAddWfs':
+            service_type = 'OGC:WFS'
+            sname = 'WFS'
+            dyn_param = ['wfs']
+            provider_name = 'WFS'
+            setting_node = QgsSettingsTree.node('connections').childNode('ows').childNode('connections')
+            data_url = item_data['wfs']
+        elif caller == 'mActionAddWcs':
+            service_type = 'OGC:WCS'
+            sname = 'WCS'
+            dyn_param = ['wcs']
+            provider_name = 'wcs'
+            setting_node = QgsSettingsTree.node('connections').childNode('ows').childNode('connections')
+            data_url = item_data['wcs']
+        elif caller == 'mActionAddAfs':
+            service_type = 'ESRI:ArcGIS:FeatureServer'
+            sname = 'AFS'
+            dyn_param = []
+            provider_name = 'arcgisfeatureserver'
+            setting_node = QgsSettingsTree.node('connections').childNode('arcgisfeatureserver')
+            data_url = (item_data['afs'].split('FeatureServer')[0] + 'FeatureServer')
+
+        keys = setting_node.items(dyn_param)
+
+        sname = '%s from MetaSearch' % sname
+        for key in keys:
+            if key.startswith(sname):
+                conn_name_matches.append(key)
+        if conn_name_matches:
+            sname = conn_name_matches[-1]
+
+        # check for duplicates
+        if sname in keys:  # duplicate found
+            msg = self.tr('Connection {0} exists. Overwrite?').format(sname)
+            res = QMessageBox.warning(
+                self, self.tr('Saving server'), msg,
+                QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
+            if res == QMessageBox.No:  # assign new name with serial
+                sname = serialize_string(sname)
+            elif res == QMessageBox.Cancel:
+                return
+
+        # no dups detected or overwrite is allowed
+        dyn_param.append(sname)
+        setting_node.childSetting('url').setValue(clean_ows_url(data_url), dyn_param)
+
+        # open provider window
+        ows_provider = QgsGui.sourceSelectProviderRegistry().\
+            createSelectionWidget(
+                provider_name, self, Qt.Widget,
+                QgsProviderRegistry.WidgetMode.Embedded)
+
+        # connect dialog signals to iface slots
+        if service_type == 'OGC:WMS/OGC:WMTS':
+            ows_provider.addRasterLayer.connect(self.iface.addRasterLayer)
+            conn_cmb = ows_provider.findChild(QWidget, 'cmbConnections')
+            connect = 'btnConnect_clicked'
+        elif service_type == 'OGC:WFS':
+            def addVectorLayer(path, name):
+                self.iface.addVectorLayer(path, name, 'WFS')
+
+            ows_provider.addVectorLayer.connect(addVectorLayer)
+            conn_cmb = ows_provider.findChild(QWidget, 'cmbConnections')
+            connect = 'connectToServer'
+        elif service_type == 'OGC:WCS':
+            ows_provider.addRasterLayer.connect(self.iface.addRasterLayer)
+            conn_cmb = ows_provider.findChild(QWidget, 'mConnectionsComboBox')
+            connect = 'mConnectButton_clicked'
+        elif service_type == 'ESRI:ArcGIS:FeatureServer':
+            def addAfsLayer(path, name):
+                self.iface.addVectorLayer(path, name, 'afs')
+
+            ows_provider.addVectorLayer.connect(addAfsLayer)
+            conn_cmb = ows_provider.findChild(QComboBox)
+            connect = 'connectToServer'
+
+        ows_provider.setModal(False)
+        ows_provider.show()
+
+        # open provider dialogue against added OWS
+        index = conn_cmb.findText(sname)
+        if index > -1:
+            conn_cmb.setCurrentIndex(index)
+            # only for wfs
+            if service_type == 'OGC:WFS':
+                ows_provider.cmbConnections_activated(index)
+            elif service_type == 'ESRI:ArcGIS:FeatureServer':
+                ows_provider.cmbConnections_activated(index)
+        getattr(ows_provider, connect)()
+
+    def add_gis_file(self):
+        """add GIS file from result"""
+        item = self.treeRecords.currentItem()
+
+        if not item:
+            return
+
+        item_data = json.loads(get_item_data(item, 'link'))
+        gis_file = item_data['gis_file']
+
+        title = item_data['title']
+
+        layer = self.iface.addVectorLayer(gis_file, title, "ogr")
+        if not layer:
+            self.iface.messageBar().pushWarning(None, "Layer failed to load!")
+
+    def show_metadata(self):
+        """show record metadata"""
+
+        if not self.treeRecords.selectedItems():
+            return
+
+        item = self.treeRecords.currentItem()
+        if not item:
+            return
+
+        identifier = get_item_data(item, 'identifier')
+
+        auth = None
+
+        if self.disable_ssl_verification:
+            try:
+                auth = Authentication(verify=False)
+            except NameError:
+                pass
+
+        try:
+            with OverrideCursor(Qt.WaitCursor):
+                cat = get_catalog_service(self.catalog_url,  # spellok
+                                          catalog_type=self.catalog_type,
+                                          timeout=self.timeout,
+                                          username=self.catalog_username or None,
+                                          password=self.catalog_password or None,
+                                          auth=auth)
+                record = cat.get_record(identifier)
+                if cat.type == 'OGC API - Records':
+                    record['url'] = cat.conn.request
+                elif cat.type == 'OGC CSW 2.0.2':
+                    record.url = cat.conn.request
+
+        except Exception as err:
+            QMessageBox.warning(
+                self, self.tr('GetRecords error'),
+                self.tr('Error getting response: {0}').format(err))
+            return
+        except KeyError as err:
+            QMessageBox.warning(
+                self, self.tr('Record parsing error'),
+                self.tr('Unable to locate record identifier: {0}').format(err))
+            return
+
+        crd = RecordDialog()
+        metadata = render_template('en', self.context,
+                                   record, self.catalog.record_info_template)
+
+        style = QgsApplication.reportStyleSheet()
+        crd.textMetadata.document().setDefaultStyleSheet(style)
+        crd.textMetadata.setHtml(metadata)
+        crd.exec_()
+
+    def show_api(self):
+        """show API request / response"""
+
+        crd = APIRequestResponseDialog()
+        request_html = highlight_content(self.context, self.catalog.request,
+                                         self.catalog.format)
+        response_html = highlight_content(self.context, self.catalog.response,
+                                          self.catalog.format)
+        style = QgsApplication.reportStyleSheet()
+        crd.txtbrAPIRequest.clear()
+        crd.txtbrAPIResponse.clear()
+        crd.txtbrAPIRequest.document().setDefaultStyleSheet(style)
+        crd.txtbrAPIResponse.document().setDefaultStyleSheet(style)
+        crd.txtbrAPIRequest.setHtml(request_html)
+        crd.txtbrAPIResponse.setHtml(response_html)
+        crd.exec_()
+
+    def reset_buttons(self, services=True, api=True, navigation=True):
+        """Convenience function to disable WMS/WMTS|WFS|WCS buttons"""
+
+        if services:
+            self.tbAddData.setEnabled(False)
+            self.mActionAddWms.setEnabled(False)
+            self.mActionAddWfs.setEnabled(False)
+            self.mActionAddWcs.setEnabled(False)
+            self.mActionAddAms.setEnabled(False)
+            self.mActionAddAfs.setEnabled(False)
+            self.mActionAddGisFile.setEnabled(False)
+
+        if api:
+            self.btnViewRawAPIResponse.setEnabled(False)
+
+        if navigation:
+            self.btnFirst.setEnabled(False)
+            self.btnPrev.setEnabled(False)
+            self.btnNext.setEnabled(False)
+            self.btnLast.setEnabled(False)
+
+    def help(self):
+        """launch help"""
+
+        open_url(get_help_url())
+
+    def reject(self):
+        """back out of dialogue"""
+
+        QDialog.reject(self)
+        self.rubber_band.reset()
+
+    def _get_catalog(self):
+        """convenience function to init catalog wrapper"""
+
+        auth = None
+
+        if self.disable_ssl_verification:
+            try:
+                auth = Authentication(verify=False)
+            except NameError:
+                pass
+
+        # connect to the server
+        with OverrideCursor(Qt.WaitCursor):
+            try:
+                self.catalog = get_catalog_service(
+                    self.catalog_url, catalog_type=self.catalog_type,
+                    timeout=self.timeout, username=self.catalog_username or None,
+                    password=self.catalog_password or None, auth=auth)
+                return True
+            except Exception as err:
+                msg = self.tr('Error connecting to service: {0}').format(err)
+
+        QMessageBox.warning(self, self.tr('CSW Connection error'), msg)
+        return False
+
+    def install_proxy(self):
+        """set proxy if one is set in QGIS network settings"""
+
+        # initially support HTTP for now
+        if self.settings.value('/proxy/proxyEnabled') == 'true':
+            if self.settings.value('/proxy/proxyType') == 'HttpProxy':
+                ptype = 'http'
+            else:
+                return
+
+            user = self.settings.value('/proxy/proxyUser')
+            password = self.settings.value('/proxy/proxyPassword')
+            host = self.settings.value('/proxy/proxyHost')
+            port = self.settings.value('/proxy/proxyPort')
+
+            proxy_up = ''
+            proxy_port = ''
+
+            if all([user != '', password != '']):
+                proxy_up = f'{user}:{password}@'
+
+            if port != '':
+                proxy_port = ':%s' % port
+
+            conn = f'{ptype}://{proxy_up}{host}{proxy_port}'
+            install_opener(build_opener(ProxyHandler({ptype: conn})))
+
+
+def save_connections():
+    """save servers to list"""
+
+    ManageConnectionsDialog(0).exec_()
+
+
+def get_item_data(item, field):
+    """return identifier for a QTreeWidgetItem"""
+
+    return item.data(_get_field_value(field), 32)
+
+
+def set_item_data(item, field, value):
+    """set identifier for a QTreeWidgetItem"""
+
+    item.setData(_get_field_value(field), 32, value)
+
+
+def _get_field_value(field):
+    """convenience function to return field value integer"""
+
+    value = 0
+
+    if field == 'identifier':
+        value = 0
+    if field == 'link':
+        value = 1
+
+    return value

+ 195 - 0
MetaSearch/dialogs/manageconnectionsdialog.py

@@ -0,0 +1,195 @@
+###############################################################################
+#
+# CSW Client
+# ---------------------------------------------------------
+# QGIS Catalog Service client.
+#
+# Copyright (C) 2010 NextGIS (http://nextgis.org),
+#                    Alexander Bruy (alexander.bruy@gmail.com),
+#                    Maxim Dubinin (sim@gis-lab.info)
+#
+# Copyright (C) 2014 Tom Kralidis (tomkralidis@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################
+
+import xml.etree.ElementTree as etree
+
+from qgis.core import QgsSettings
+from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QFileDialog, QListWidgetItem, QMessageBox  # noqa
+
+from MetaSearch.util import (get_connections_from_file, get_ui_class,
+                             prettify_xml)
+
+BASE_CLASS = get_ui_class('manageconnectionsdialog.ui')
+
+
+class ManageConnectionsDialog(QDialog, BASE_CLASS):
+    """manage connections"""
+
+    def __init__(self, mode):
+        """init dialog"""
+
+        QDialog.__init__(self)
+        self.setupUi(self)
+        self.settings = QgsSettings()
+        self.filename = None
+        self.mode = mode  # 0 - save, 1 - load
+        self.btnBrowse.clicked.connect(self.select_file)
+        self.manage_gui()
+
+    def manage_gui(self):
+        """manage interface"""
+
+        if self.mode == 1:
+            self.label.setText(self.tr('Load from file'))
+            self.buttonBox.button(QDialogButtonBox.Ok).setText(self.tr('Load'))
+        else:
+            self.label.setText(self.tr('Save to file'))
+            self.buttonBox.button(QDialogButtonBox.Ok).setText(self.tr('Save'))
+            self.populate()
+
+        self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)
+
+    def select_file(self):
+        """select file ops"""
+
+        label = self.tr('eXtensible Markup Language (*.xml *.XML)')
+
+        if self.mode == 0:
+            slabel = self.tr('Save Connections')
+            self.filename, filter = QFileDialog.getSaveFileName(self, slabel,
+                                                                '.', label)
+        else:
+            slabel = self.tr('Load Connections')
+            self.filename, selected_filter = QFileDialog.getOpenFileName(
+                self, slabel, '.', label)
+
+        if not self.filename:
+            return
+
+        # ensure the user never omitted the extension from the file name
+        if not self.filename.lower().endswith('.xml'):
+            self.filename = '%s.xml' % self.filename
+
+        self.leFileName.setText(self.filename)
+
+        if self.mode == 1:
+            self.populate()
+
+        self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(True)
+
+    def populate(self):
+        """populate connections list from settings"""
+
+        if self.mode == 0:
+            self.settings.beginGroup('/MetaSearch/')
+            keys = self.settings.childGroups()
+            for key in keys:
+                item = QListWidgetItem(self.listConnections)
+                item.setText(key)
+            self.settings.endGroup()
+
+        else:  # populate connections list from file
+            doc = get_connections_from_file(self, self.filename)
+            if doc is None:
+                self.filename = None
+                self.leFileName.clear()
+                self.listConnections.clear()
+                return
+
+            for catalog in doc.findall('csw'):
+                item = QListWidgetItem(self.listConnections)
+                item.setText(catalog.attrib.get('name'))
+
+    def save(self, connections):
+        """save connections ops"""
+
+        doc = etree.Element('qgsCSWConnections')
+        doc.attrib['version'] = '1.0'
+
+        for conn in connections:
+            url = self.settings.value('/MetaSearch/%s/url' % conn)
+            type_ = self.settings.value('/MetaSearch/%s/catalog-type' % conn)
+            if url is not None:
+                connection = etree.SubElement(doc, 'csw')
+                connection.attrib['name'] = conn
+                connection.attrib['type'] = type_ or 'OGC CSW 2.0.2'
+                connection.attrib['url'] = url
+
+        # write to disk
+        with open(self.filename, 'w') as fileobj:
+            fileobj.write(prettify_xml(etree.tostring(doc)))
+        QMessageBox.information(self, self.tr('Save Connections'),
+                                self.tr('Saved to {0}.').format(self.filename))
+        self.reject()
+
+    def load(self, items):
+        """load connections"""
+
+        self.settings.beginGroup('/MetaSearch/')
+        keys = self.settings.childGroups()
+        self.settings.endGroup()
+
+        exml = etree.parse(self.filename).getroot()
+
+        for catalog in exml.findall('csw'):
+            conn_name = catalog.attrib.get('name')
+
+            # process only selected connections
+            if conn_name not in items:
+                continue
+
+            # check for duplicates
+            if conn_name in keys:
+                label = self.tr('File {0} exists. Overwrite?').format(
+                    conn_name)
+                res = QMessageBox.warning(self, self.tr('Loading Connections'),
+                                          label,
+                                          QMessageBox.Yes | QMessageBox.No)
+                if res != QMessageBox.Yes:
+                    continue
+
+            # no dups detected or overwrite is allowed
+            url = '/MetaSearch/%s/url' % conn_name
+            self.settings.setValue(url, catalog.attrib.get('url'))
+            self.settings.setValue(url, catalog.attrib.get('catalog-type', 'OGC CSW 2.0.2'))
+
+    def accept(self):
+        """accept connections"""
+
+        selection = self.listConnections.selectedItems()
+        if len(selection) == 0:
+            return
+
+        items = []
+        for sel in selection:
+            items.append(sel.text())
+
+        if self.mode == 0:  # save
+            self.save(items)
+        else:  # load
+            self.load(items)
+
+        self.filename = None
+        self.leFileName.clear()
+        self.listConnections.clear()
+        self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)
+
+    def reject(self):
+        """back out of manage connections dialogue"""
+
+        QDialog.reject(self)

+ 112 - 0
MetaSearch/dialogs/newconnectiondialog.py

@@ -0,0 +1,112 @@
+###############################################################################
+#
+# CSW Client
+# ---------------------------------------------------------
+# QGIS Catalog Service client.
+#
+# Copyright (C) 2010 NextGIS (http://nextgis.org),
+#                    Alexander Bruy (alexander.bruy@gmail.com),
+#                    Maxim Dubinin (sim@gis-lab.info)
+#
+# Copyright (C) 2017 Tom Kralidis (tomkralidis@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################
+
+from qgis.core import QgsSettings
+from qgis.PyQt.QtWidgets import QDialog, QMessageBox
+
+from MetaSearch.util import get_ui_class
+from MetaSearch.search_backend import CATALOG_TYPES
+
+BASE_CLASS = get_ui_class('newconnectiondialog.ui')
+
+
+class NewConnectionDialog(QDialog, BASE_CLASS):
+    """Dialogue to add a new CSW entry"""
+
+    def __init__(self, conn_name=None):
+        """init"""
+
+        QDialog.__init__(self)
+        self.setupUi(self)
+        self.settings = QgsSettings()
+        self.conn_name = None
+        self.conn_name_orig = conn_name
+        self.username = None
+        self.password = None
+
+        self.cmbCatalogType.addItems(CATALOG_TYPES)
+
+    def accept(self):
+        """add CSW entry"""
+
+        conn_name = self.leName.text().strip()
+        conn_url = self.leURL.text().strip()
+        conn_username = self.leUsername.text().strip()
+        conn_password = self.lePassword.text().strip()
+        conn_catalog_type = self.cmbCatalogType.currentText()
+
+        if any([conn_name == '', conn_url == '']):
+            QMessageBox.warning(self, self.tr('Save Connection'),
+                                self.tr('Both Name and URL must be provided.'))
+            return
+
+        if '/' in conn_name:
+            QMessageBox.warning(self, self.tr('Save Connection'),
+                                self.tr('Name cannot contain \'/\'.'))
+            return
+
+        if conn_name is not None:
+            key = '/MetaSearch/%s' % conn_name
+            keyurl = '%s/url' % key
+            key_orig = '/MetaSearch/%s' % self.conn_name_orig
+
+            # warn if entry was renamed to an existing connection
+            if all([self.conn_name_orig != conn_name,
+                    self.settings.contains(keyurl)]):
+                res = QMessageBox.warning(
+                    self, self.tr('Save Connection'),
+                    self.tr('Overwrite {0}?').format(conn_name),
+                    QMessageBox.Ok | QMessageBox.Cancel)
+                if res == QMessageBox.Cancel:
+                    return
+
+            # on rename delete original entry first
+            if all([self.conn_name_orig is not None,
+                    self.conn_name_orig != conn_name]):
+                self.settings.remove(key_orig)
+
+            self.settings.setValue(keyurl, conn_url)
+            self.settings.setValue('/MetaSearch/selected', conn_name)
+
+            if conn_username != '':
+                self.settings.setValue('%s/username' % key, conn_username)
+            else:
+                self.settings.remove('%s/username' % key)
+            if conn_password != '':
+                self.settings.setValue('%s/password' % key, conn_password)
+            else:
+                self.settings.remove('%s/password' % key)
+
+            self.settings.setValue('%s/catalog-type' % key, conn_catalog_type)
+
+            QDialog.accept(self)
+
+    def reject(self):
+        """back out of dialogue"""
+
+        QDialog.reject(self)

+ 43 - 0
MetaSearch/dialogs/recorddialog.py

@@ -0,0 +1,43 @@
+###############################################################################
+#
+# CSW Client
+# ---------------------------------------------------------
+# QGIS Catalog Service client.
+#
+# Copyright (C) 2010 NextGIS (http://nextgis.org),
+#                    Alexander Bruy (alexander.bruy@gmail.com),
+#                    Maxim Dubinin (sim@gis-lab.info)
+#
+# Copyright (C) 2014 Tom Kralidis (tomkralidis@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################
+
+from qgis.PyQt.QtWidgets import QDialog
+
+from MetaSearch.util import get_ui_class
+
+BASE_CLASS = get_ui_class('recorddialog.ui')
+
+
+class RecordDialog(QDialog, BASE_CLASS):
+    """Record Metadata Dialogue"""
+
+    def __init__(self):
+        """init"""
+
+        QDialog.__init__(self)
+        self.setupUi(self)

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
MetaSearch/images/MetaSearch.svg


+ 66 - 0
MetaSearch/link_types.py

@@ -0,0 +1,66 @@
+###############################################################################
+#
+# MetaSearch Catalog Client
+#
+# Copyright (C) 2014 Tom Kralidis (tomkralidis@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################
+
+WMSWMST_LINK_TYPES = [
+    'WMS',
+    'WMTS',
+    'OGC:WMS',
+    'OGC:WMTS',
+    'OGC:WMS-1.1.1-http-get-map',
+    'OGC:WMS-1.1.1-http-get-capabilities',
+    'OGC:WMS-1.3.0-http-get-map',
+    'OGC:WMS-1.3.0-http-get-capabilities',
+    'urn:x-esri:specification:ServiceType:wms:url',
+    'urn:x-esri:specification:ServiceType:Gmd:URL.wms'
+]
+
+WFS_LINK_TYPES = [
+    'WFS',
+    'OGC:WFS',
+    'OGC:WFS-1.0.0-http-get-capabilities',
+    'OGC:WFS-1.1.0-http-get-capabilities',
+    'urn:x-esri:specification:ServiceType:wfs:url',
+    'urn:x-esri:specification:ServiceType:Gmd:URL.wfs'
+]
+
+WCS_LINK_TYPES = [
+    'WCS',
+    'OGC:WCS',
+    'OGC:WCS-1.1.0-http-get-capabilities',
+    'urn:x-esri:specification:ServiceType:wcs:url',
+    'urn:x-esri:specification:ServiceType:Gmd:URL.wcs'
+]
+
+AMS_LINK_TYPES = [
+    'ESRI:ArcGIS:MapServer',
+    'Esri REST: Map Service',
+    'ESRI REST'
+]
+
+AFS_LINK_TYPES = [
+    'ESRI:ArcGIS:FeatureServer',
+    'Esri REST: Feature Service'
+]
+
+GIS_FILE_LINK_TYPES = [
+    'FILE:GEO'
+]

+ 14 - 0
MetaSearch/metadata.txt

@@ -0,0 +1,14 @@
+[general]
+name=MetaSearch Catalog Client
+description=MetaSearch is a QGIS plugin to interact with metadata catalog services (CSW).
+about=MetaSearch is a QGIS plugin to interact with metadata catalog services, supporting the OGC Catalog Service for the Web (CSW) standard. MetaSearch provides an easy and intuitive approach and user-friendly interface to searching metadata catalogs within QGIS.
+category=Web
+version=0.3.6
+qgisMinimumVersion=3.0
+icon=images/MetaSearch.svg
+author=Tom Kralidis
+email=tomkralidis@gmail.com
+tags=web,catalog,service,metadata,csw
+homepage=https://qgis.org/
+tracker=https://github.com/qgis/QGIS/issues
+repository=https://github.com/qgis/QGIS/tree/master/python/plugins/MetaSearch

+ 98 - 0
MetaSearch/plugin.py

@@ -0,0 +1,98 @@
+###############################################################################
+#
+# Copyright (C) 2010 NextGIS (http://nextgis.org),
+#                    Alexander Bruy (alexander.bruy@gmail.com),
+#                    Maxim Dubinin (sim@gis-lab.info),
+#
+# Copyright (C) 2014 Tom Kralidis (tomkralidis@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################
+
+import logging
+
+from qgis.PyQt.QtCore import QCoreApplication
+from qgis.PyQt.QtWidgets import QAction
+from qgis.PyQt.QtGui import QIcon
+
+from qgis.core import QgsApplication
+from MetaSearch.dialogs.maindialog import MetaSearchDialog
+from MetaSearch.util import get_help_url, open_url, StaticContext
+
+LOGGER = logging.getLogger('MetaSearch')
+
+
+class MetaSearchPlugin:
+    """base plugin"""
+
+    def __init__(self, iface):
+        """init"""
+
+        self.iface = iface
+        self.context = StaticContext()
+        self.action_run = None
+        self.action_help = None
+        self.dialog = None
+        self.web_menu = '&MetaSearch'
+
+    def initGui(self):
+        """startup"""
+
+        # run
+        run_icon = QIcon('{}/{}'.format(self.context.ppath, 'images/MetaSearch.svg'))
+        self.action_run = QAction(run_icon, 'MetaSearch',
+                                  self.iface.mainWindow())
+        self.action_run.setWhatsThis(
+            QCoreApplication.translate('MetaSearch', 'MetaSearch plugin'))
+        self.action_run.setStatusTip(QCoreApplication.translate(
+            'MetaSearch', 'Search Metadata Catalogs'))
+
+        self.action_run.triggered.connect(self.run)
+
+        self.iface.addWebToolBarIcon(self.action_run)
+        self.iface.addPluginToWebMenu(self.web_menu, self.action_run)
+
+        # help
+        help_icon = QgsApplication.getThemeIcon('/mActionHelpContents.svg')
+        self.action_help = QAction(help_icon, 'Help', self.iface.mainWindow())
+        self.action_help.setWhatsThis(
+            QCoreApplication.translate('MetaSearch', 'MetaSearch plugin help'))
+        self.action_help.setStatusTip(QCoreApplication.translate(
+            'MetaSearch', 'Get Help on MetaSearch'))
+        self.action_help.triggered.connect(self.help)
+
+        self.iface.addPluginToWebMenu(self.web_menu, self.action_help)
+
+        # prefab the dialog but not open it yet
+        self.dialog = MetaSearchDialog(self.iface)
+
+    def unload(self):
+        """teardown"""
+
+        # remove the plugin menu item and icon
+        self.iface.removePluginWebMenu(self.web_menu, self.action_run)
+        self.iface.removePluginWebMenu(self.web_menu, self.action_help)
+        self.iface.removeWebToolBarIcon(self.action_run)
+
+    def run(self):
+        """open MetaSearch"""
+
+        self.dialog.exec_()
+
+    def help(self):
+        """open help in user's default web browser"""
+
+        open_url(get_help_url())

+ 21 - 0
MetaSearch/resources/connections-default.xml

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Guidance: https://docs.qgis.org/testing/en/docs/user_manual/plugins/core_plugins/plugins_metasearch.html#managing-catalog-services -->
+<qgsCSWConnections version="1.0">
+    <csw type="OGC CSW 2.0.2" name="Danmark: National CSW (geodata-info)" url="https://geodata-info.dk/srv/dan/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Finland: National CSW (Paikkatietohakemisto)" url="http://www.paikkatietohakemisto.fi/geonetwork/srv/fi/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Iceland: National CSW (Iceland Service)" url="https://gatt.lmi.is/geonetwork/srv/eng/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Italy: National CSW (Geoportale Nazionale - Servizio di ricerca Italiano)" url="http://www.pcn.minambiente.it/geoportal/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Italy: RNDT - Repertorio Nazionale dei Dati Territoriali - Servizio di ricerca" url="https://geodati.gov.it/RNDT/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Italy: Istituto Nazionale di Geofisica e Vulcanologia (INGV)" url="https://ogc.ingv.it/csw/"/>
+    <csw type="OGC CSW 2.0.2" name="New Zealand: LINZ Data Service" url="https://data.linz.govt.nz/services/csw/"/>
+    <csw type="OGC CSW 2.0.2" name="Netherlands: National CSW (Nationaal Georegister)" url="http://www.nationaalgeoregister.nl/geonetwork/srv/dut/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Norway: National CSW (Geonorge)" url="http://www.geonorge.no/geonetwork/srv/no/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Sweden: National CSW" url="https://www.geodata.se/geodataportalen/srv/eng/csw-inspire"/>
+    <csw type="OGC CSW 2.0.2" name="UK Location Catalogue Publishing Service" url="https://data.gov.uk/csw"/>
+    <csw type="OGC CSW 2.0.2" name="UNEP GRID-Geneva Metadata Catalog" url="https://datacore-gn.unepgrid.ch/geonetwork/srv/eng/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Portugal: Sistema Nacional de Informação Geográfica (SNIG)" url="https://snig.dgterritorio.gov.pt/rndg/srv/eng/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Spain: Centro Nacional de Información Geográfica (CNIG)" url="http://www.ign.es/csw-inspire/srv/spa/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Germany: GDI-DE Geodatenkatalog.de" url="https://gdk.gdi-de.org/gdi-de/srv/ger/csw"/>
+    <csw type="OGC CSW 2.0.2" name="Canada: Federal Geospatial Platform-Plateforme géospatiale fédérale (FGP-PGF)" url="https://maps.canada.ca/geonetwork/srv/csw"/>
+<!-- <csw type="OGC API - Records" name="Sample OARec entry" url="https://example.org/collections/collectionId"/> -->
+</qgsCSWConnections>

+ 13 - 0
MetaSearch/resources/templates/api_highlight.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8"/>
+        <title>XML</title>
+        <style type="text/css">
+        {{ css }}
+        </style>
+    </head>
+    <body>
+    {{ body }}
+    </body>
+</html>

+ 123 - 0
MetaSearch/resources/templates/csw_service_metadata.html

@@ -0,0 +1,123 @@
+<!DOCTYPE html>
+<html lang="{{ language }}">
+    <head>
+        <meta charset="utf-8"/>
+        <title>{{ gettext('Service Metadata') }}</title>
+        <style type="text/css">
+            body,h3, h4 {
+                background-color: #ffffff;
+                font-family: arial, verdana, sans-serif;
+                text-align: left;
+                float: left;
+            }
+            header {
+                display: inline-block;
+            }
+        </style>
+    </head>
+    <body>
+        <header>
+            <h3>{{ gettext('Service Metadata') }}</h3>
+        </header>
+        <section id="service-metadata">
+            <h4>{{ gettext('Service Identification') }}</h4>
+            <table>
+                <tr>
+                    <td>{{ gettext('Title') }}</td>
+                    <td>{{ obj.identification.title }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Abstract') }}</td>
+                    <td>{{ obj.identification.abstract }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Keywords') }}</td>
+                    <td>{{ obj.identification.keywords|join(',') }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Type') }}</td>
+                    <td>{{ obj.identification.type }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Version') }}</td>
+                    <td>{{ obj.identification.version }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Fees') }}</td>
+                    <td>{{ obj.identification.fees }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Access Constraints') }}</td>
+                    <td>{{ obj.identification.accessconstraints }}</td>
+                </tr>
+            </table>
+        </section>
+        <section id="service-provider">
+            <h4>{{ gettext('Service URL') }}</h4>
+            <p><a href="{{ obj.url }}">{{ obj.url}}</a></p>
+        </section>
+        <section id="service-provider">
+            <h4>{{ gettext('Service Provider') }}</h4>
+             <table>
+                <tr>
+                    <td>{{ gettext('Name') }}</td>
+                    <td>{{ obj.provider.name }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Site') }}</td>
+                    <td><a href="{{ obj.provider.url }}">{{ obj.provider.url }}</a></td>
+                </tr>
+            </table>
+        </section>
+        <section id="service-contact">
+            <h4>{{ gettext('Service Contact') }}</h4>
+            <table>
+                <tr>
+                    <td>{{ gettext('Name') }}</td>
+                    <td>{{ obj.provider.contact.name }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Position') }}</td>
+                    <td>{{ obj.provider.contact.position}}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Role') }}</td>
+                    <td>{{ obj.provider.contact.role }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Address') }}</td>
+                    <td>
+                        {{ obj.provider.contact.address }}<br/>
+                        {{ obj.provider.contact.city }}, {{ obj.provider.contact.region }}<br/>
+                        {{ obj.provider.contact.postcode }}<br/>
+                        {{ obj.provider.contact.country }}
+                    </td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Email') }}</td>
+                    <td><a href="mailto:{{ obj.provider.contact.email  }}">{{ obj.provider.contact.email }}</a></td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Phone') }}</td>
+                    <td>{{ obj.provider.contact.phone }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Fax') }}</td>
+                    <td>{{ obj.provider.contact.fax }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Url') }}</td>
+                    <td><a href="{{ obj.provider.contact.url }}">{{ obj.provider.contact.url }}</a></td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Hours of Service') }}</td>
+                    <td>{{ obj.provider.contact.hours }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Contact Instructions') }}</td>
+                    <td>{{ obj.provider.contact.instructions }}</td>
+                </tr>
+             </table>
+        </section>
+    </body>
+</html>

+ 67 - 0
MetaSearch/resources/templates/oarec_service_metadata.html

@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html lang="{{ language }}">
+    <head>
+        <meta charset="utf-8"/>
+        <title>{{ gettext('Service Metadata') }}</title>
+        <style type="text/css">
+            body,h3, h4 {
+                background-color: #ffffff;
+                font-family: arial, verdana, sans-serif;
+                text-align: left;
+                float: left;
+            }
+            header {
+                display: inline-block;
+            }
+        </style>
+    </head>
+    <body>
+        <header>
+            <h3>{{ gettext('Service Metadata') }}</h3>
+        </header>
+        <section id="service-metadata">
+            <h4>{{ gettext('Service Identification') }}</h4>
+            <table>
+                <tr>
+                    <td>{{ gettext('Title') }}</td>
+                    <td>{{ obj.title }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Abstract') }}</td>
+                    <td>{{ obj.description or obj['title'] }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Service URL') }}</td>
+                    <td><a href="{{ obj.url }}">{{ obj.url}}</a></td>
+                </tr>
+            </table>
+        </section>
+
+        <section id="collections">
+            <h4>{{ gettext('Collections') }}</h4>
+            {% for j in obj.collections()['collections'] %}
+                {% if j.id in obj.records() %}
+                    <p><b>{{ j.title }}</b><br/>{{ j.description }}</p>
+                {% endif %}
+            {% endfor %}
+        </section>
+
+        <section id="conformance">
+            <h4>{{ gettext('Conformance') }}</h4>
+            <ul>
+            {% for i in obj.conformance()['conformsTo'] %}
+                <li><a href="{{ i }}">{{ i }}</a></li>
+            {% endfor %}   
+            </ul>
+        </section>
+
+        <section id="links">
+            <h4>{{ gettext('Links') }}</h4>
+            <ul>
+                {% for link in obj.links %}
+                    <li><a href="{{ link['href'] }}">{{ link['title'] }}</a></li>
+                {% endfor %}
+            </ul>
+        </section>
+    </body>
+</html>

+ 86 - 0
MetaSearch/resources/templates/record_metadata_dc.html

@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<html lang="{{ language }}">
+    <head>
+        <meta charset="utf-8"/>
+        <title>{{ gettext('Record Metadata') }}</title>
+        <style type="text/css">
+            body, h3 {
+                background-color: #ffffff;
+                font-family: arial, verdana, sans-serif;
+                text-align: left;
+                float: left;
+            }
+            header {
+                display: inline-block;
+            }
+        </style>
+    </head>
+    <body>
+        <header>
+            <h3>{{ gettext('Record Metadata') }} (<a href="{{ obj.url }}">{{ gettext('View XML') }}</a>)</h3>
+        </header>
+        <section id="record-metadata">
+            <table>
+                <tr>
+                    <td>{{ gettext('Identifier') }}</td>
+                    <td>{{ obj.identifier }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Title') }}</td>
+                    <td>{{ obj.title }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Abstract') }}</td>
+                    <td>{{ obj.abstract }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Subjects') }}</td>
+                    <td>{{ obj.subjects|join(',') }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Creator') }}</td>
+                    <td>{{ obj.creator }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Contributor') }}</td>
+                    <td>{{ obj.contributor}}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Publisher') }}</td>
+                    <td>{{ obj.publisher}}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Modified') }}</td>
+                    <td>{{ obj.modified }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Language') }}</td>
+                    <td>{{ obj.language }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Format') }}</td>
+                    <td>{{ obj.format }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Rights') }}</td>
+                    <td>{{ obj.rights|join(',') }}</td>
+                </tr>
+                <tr>
+                    <td>{{ gettext('Bounding Box') }}</td>
+                    <td>{{ [obj.bbox.minx, obj.bbox.miny, obj.bbox.maxx, obj.bbox.maxy]|join(',') }}</td>
+                </tr>
+            </table>
+        </section>
+        <section id="links">
+            <h4>Links</h4>
+            <ul>
+            {% for link in obj.references %}
+                <li><a href="{{ link['url'] }}">{{ link['scheme'] if link['scheme'] not in [None, 'None', ''] else gettext('Access Link') }}</a></li>
+            {% endfor %}
+            {% for link in obj.uris %}
+                <li><a href="{{ link['url'] }}">{{ link['protocol'] if link['protocol'] not in [None, 'None', ''] else gettext('Access Link') }}</a></li>
+            {% endfor %}
+            </ul>
+        </section>
+    </body>
+</html>

+ 63 - 0
MetaSearch/resources/templates/record_metadata_oarec.html

@@ -0,0 +1,63 @@
+<!DOCTYPE html>
+{% macro render_item_value(v, width) -%}
+    {% set val = v | string | trim %}
+    {% if val|length and val.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp')) %}
+        {# Ends with image extension: render img element with link to image #}
+        <a href="{{ val }}"><img src="{{ val }}" alt="{{ val.split('/') | last }}" width="{{ width }}"/></a>
+    {% elif v is string or v is number %}
+        {{ val | urlize() }}
+    {% elif v is mapping %}
+        <ul>
+      {% for i,j in v.items() %}
+        <li><i>{{ gettext(i) }}:</i> {{ render_item_value(j, 60) }}</li>
+      {% endfor %}</ul>
+    {% elif v is iterable %}
+        <ul>
+      {% for i in v %}
+        <li>{{ render_item_value(i, 60) }}</li>
+      {% endfor %}
+        </ul>
+    {% else %}
+      {{ val | urlize() }}
+    {% endif %}
+{%- endmacro %}
+<html lang="{{ language }}">
+    <head>
+        <meta charset="utf-8"/>
+        <title>{{ gettext('Record Metadata') }}</title>
+        <style type="text/css">
+            body, h3 {
+                background-color: #ffffff;
+                font-family: arial, verdana, sans-serif;
+                text-align: left;
+                float: left;
+            }
+            header {
+                display: inline-block;
+            }
+        </style>
+    </head>
+    <body>
+        <header>
+            <h3>{{ gettext('Record Metadata') }} (<a href="{{ obj.url }}">{{ gettext('View JSON') }}</a>)</h3>
+        </header>
+        <section id="record-metadata">
+            <table>
+                <tr>
+                    <td>{{ gettext('Identifier') }}</td>
+                    <td>{{ obj['id'] }}</td>
+                </tr>
+                {% if (obj['properties']) %}
+                {% for a,b in obj['properties'].items() %}
+                    {% if a not in ['extent'] %}
+                        <tr>
+                            <td>{{ gettext(a) }}</td>
+                            <td>{{ render_item_value( b, 120 ) }}</td>
+                        </tr>
+                  {% endif %}
+                {% endfor %}
+                {% endif %}
+            </table>
+        </section>
+    </body>
+</html>

+ 283 - 0
MetaSearch/search_backend.py

@@ -0,0 +1,283 @@
+###############################################################################
+#
+# CSW Client
+# ---------------------------------------------------------
+# QGIS Catalog Service client.
+#
+# Copyright (C) 2023 Tom Kralidis (tomkralidis@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################
+
+import warnings
+
+import owslib
+from owslib.fes import BBox, PropertyIsLike
+
+with warnings.catch_warnings():
+    warnings.filterwarnings("ignore", category=ResourceWarning)
+    warnings.filterwarnings("ignore", category=ImportWarning)
+    from owslib.csw import CatalogueServiceWeb  # spellok
+
+if owslib.__version__ < '0.25':
+    OWSLIB_OAREC_SUPPORTED = False
+else:
+    OWSLIB_OAREC_SUPPORTED = True
+
+CATALOG_TYPES = [
+    'OGC CSW 2.0.2',
+    'OGC API - Records'
+]
+
+
+class SearchBase:
+    def __init__(self, url, timeout, username=None, password=None, auth=None):
+        self.url = url
+        self.timeout = timeout
+        self.username = username
+        self.password = password
+        self.auth = auth
+        self.service_info_template = None
+        self.record_info_template = None
+        self.request = None
+        self.response = None
+        self.matches = 0
+        self.returned = 0
+        self.format = None
+
+    def get_service_info(self):
+        pass
+
+    def query_records(self):
+        pass
+
+    def records(self):
+        pass
+
+    def get_record(self, identifier):
+        pass
+
+    def parse_link(self, link):
+        return link
+
+
+class CSW202Search(SearchBase):
+    def __init__(self, url, timeout, username, password, auth):
+        super().__init__(url, timeout, username, password, auth)
+
+        self.type = CATALOG_TYPES[0]
+        self.format = 'xml'
+        self.service_info_template = 'csw_service_metadata.html'
+        self.record_info_template = 'record_metadata_dc.html'
+        self.constraints = []
+
+        self.conn = CatalogueServiceWeb(self.url,  # spellok
+                                        timeout=self.timeout,
+                                        username=self.username,
+                                        password=self.password,
+                                        auth=self.auth)
+
+        self.request = self.conn.request
+        self.response = self.conn.response
+
+    def query_records(self, bbox=[], keywords=None, limit=10, offset=1):
+
+        self.constraints = []
+
+        # only apply spatial filter if bbox is not global
+        # even for a global bbox, if a spatial filter is applied, then
+        # the CSW server will skip records without a bbox
+        if bbox and bbox != ['-180', '-90', '180', '90']:
+            minx, miny, maxx, maxy = bbox
+            self.constraints.append(BBox([miny, minx, maxy, maxx],
+                                         crs='urn:ogc:def:crs:EPSG::4326'))
+
+        # keywords
+        if keywords:
+            # TODO: handle multiple word searches
+            self.constraints.append(PropertyIsLike('csw:AnyText', keywords))
+
+        if len(self.constraints) > 1:  # exclusive search (a && b)
+            self.constraints = [self.constraints]
+
+        self.conn.getrecords2(constraints=self.constraints, maxrecords=limit,
+                              startposition=offset, esn='full')
+
+        self.matches = self.conn.results['matches']
+        self.returned = self.conn.results['returned']
+
+        self.request = self.conn.request
+        self.response = self.conn.response
+
+    def records(self):
+        recs = []
+
+        for record in self.conn.records:
+            rec = {
+                'identifier': None,
+                'type': None,
+                'title': None,
+                'bbox': None
+            }
+
+            if self.conn.records[record].identifier:
+                rec['identifier'] = self.conn.records[record].identifier
+            if self.conn.records[record].type:
+                rec['type'] = self.conn.records[record].type
+            if self.conn.records[record].title:
+                rec['title'] = self.conn.records[record].title
+            if self.conn.records[record].bbox:
+                rec['bbox'] = bbox_list_to_dict(
+                    self.conn.records[record].bbox)
+
+            rec['links'] = (self.conn.records[record].uris +
+                            self.conn.records[record].references)
+
+            recs.append(rec)
+
+        return recs
+
+    def get_record(self, identifier):
+        self.conn.getrecordbyid([identifier])
+
+        return self.conn.records[identifier]
+
+
+class OARecSearch(SearchBase):
+    def __init__(self, url, timeout, auth):
+        try:
+            from owslib.ogcapi.records import Records
+        except ModuleNotFoundError:
+            # OWSLIB_OAREC_SUPPORTED already set to False
+            pass
+
+        super().__init__(url, timeout, auth)
+
+        self.type = CATALOG_TYPES[1]
+        self.format = 'json'
+        self.service_info_template = 'oarec_service_metadata.html'
+        self.record_info_template = 'record_metadata_oarec.html'
+        self.base_url = None
+        self.record_collection = None
+
+        if '/collections/' in self.url:  # catalog is a collection
+            self.base_url, self.record_collection = self.url.split('/collections/')  # noqa
+            self.conn = Records(
+                self.base_url, timeout=self.timeout, auth=self.auth)
+            c = self.conn.collection(self.record_collection)
+            try:
+                self.conn.links = c['links']
+                self.conn.title = c['title']
+                self.conn.description = c['description']
+            except KeyError:
+                pass
+            self.request = self.conn.request
+        else:
+            self.conn = Records(self.url, timeout=self.timeout, auth=self.auth)
+            self.request = None
+
+        self.response = self.conn.response
+
+    def query_records(self, bbox=[], keywords=None, limit=10, offset=1):
+        # set zero-based offset (default MetaSearch behavior is CSW-based
+        # offset of 1
+        offset2 = offset - 1
+
+        params = {
+            'collection_id': self.record_collection,
+            'limit': limit,
+            'startindex': offset2
+        }
+
+        if keywords:
+            params['q'] = keywords
+        if bbox and bbox != ['-180', '-90', '180', '90']:
+            params['bbox'] = bbox
+
+        self.response = self.conn.collection_items(**params)
+
+        self.matches = self.response.get('numberMatched', 0)
+        self.returned = self.response.get('numberReturned', 0)
+        self.request = self.conn.request
+
+    def records(self):
+        recs = []
+
+        for rec in self.response['features']:
+            rec1 = {
+                'identifier': rec['id'],
+                'type': rec['properties']['type'],
+                'bbox': None,
+                'title': rec['properties']['title'],
+                'links': rec.get('links', [])
+            }
+            try:
+                if rec.get('geometry') is not None:
+                    rec1['bbox'] = bbox_list_to_dict([
+                        rec['geometry']['coordinates'][0][0][0],
+                        rec['geometry']['coordinates'][0][0][1],
+                        rec['geometry']['coordinates'][0][2][0],
+                        rec['geometry']['coordinates'][0][2][1]
+                    ])
+            except KeyError:
+                pass
+
+            recs.append(rec1)
+
+        return recs
+
+    def get_record(self, identifier):
+        return self.conn.collection_item(self.record_collection, identifier)
+
+    def parse_link(self, link):
+        link2 = {}
+        if 'href' in link:
+            link2['url'] = link['href']
+        if 'type' in link:
+            link2['protocol'] = link['type']
+        if 'title' in link:
+            link2['title'] = link['title']
+        if 'id' in link:
+            link2['name'] = link['id']
+        return link2
+
+
+def get_catalog_service(url, catalog_type, timeout, username, password,
+                        auth=None):
+    if catalog_type in [None, CATALOG_TYPES[0]]:
+        return CSW202Search(url, timeout, username, password, auth)
+    elif catalog_type == CATALOG_TYPES[1]:
+        if not OWSLIB_OAREC_SUPPORTED:
+            raise ValueError("OGC API - Records requires OWSLib 0.25 or above")
+        return OARecSearch(url, timeout, auth)
+
+
+def bbox_list_to_dict(bbox):
+    if isinstance(bbox, list):
+        dict_ = {
+            'minx': bbox[0],
+            'maxx': bbox[2],
+            'miny': bbox[1],
+            'maxy': bbox[3]
+        }
+    else:
+        dict_ = {
+            'minx': bbox.minx,
+            'maxx': bbox.maxx,
+            'miny': bbox.miny,
+            'maxy': bbox.maxy
+        }
+    return dict_

+ 91 - 0
MetaSearch/ui/apidialog.ui

@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>APIRequestResponseDialog</class>
+ <widget class="QDialog" name="APIRequestResponseDialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>812</width>
+    <height>767</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>API Request / Response</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QLabel" name="lblAPIRequest">
+     <property name="text">
+      <string>Request</string>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QTextBrowser" name="txtbrAPIRequest"/>
+   </item>
+   <item>
+    <widget class="QLabel" name="lblAPIResponse">
+     <property name="text">
+      <string>Response</string>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QTextBrowser" name="txtbrAPIResponse">
+     <property name="lineWrapMode">
+      <enum>QTextEdit::NoWrap</enum>
+     </property>
+     <property name="openExternalLinks">
+      <bool>true</bool>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Close</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>APIRequestResponseDialog</receiver>
+   <slot>accept()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>248</x>
+     <y>254</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>157</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>APIRequestResponseDialog</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>316</x>
+     <y>260</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>286</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>

+ 632 - 0
MetaSearch/ui/maindialog.ui

@@ -0,0 +1,632 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MetaSearchDialog</class>
+ <widget class="QDialog" name="MetaSearchDialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>658</width>
+    <height>550</height>
+   </rect>
+  </property>
+  <property name="sizePolicy">
+   <sizepolicy hsizetype="Expanding" vsizetype="Maximum">
+    <horstretch>0</horstretch>
+    <verstretch>0</verstretch>
+   </sizepolicy>
+  </property>
+  <property name="windowTitle">
+   <string>MetaSearch</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QTabWidget" name="tabWidget">
+     <property name="currentIndex">
+      <number>1</number>
+     </property>
+     <widget class="QWidget" name="tabSearch">
+      <attribute name="title">
+       <string>Search</string>
+      </attribute>
+      <layout class="QVBoxLayout" name="verticalLayout_2">
+       <item>
+        <widget class="QGroupBox" name="groupBox">
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+         <property name="title">
+          <string>Find</string>
+         </property>
+         <layout class="QGridLayout" name="gridLayout_3">
+          <item row="0" column="1">
+           <widget class="QgsFilterLineEdit" name="leKeywords">
+            <property name="sizePolicy">
+             <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+              <horstretch>0</horstretch>
+              <verstretch>0</verstretch>
+             </sizepolicy>
+            </property>
+           </widget>
+          </item>
+          <item row="0" column="0">
+           <widget class="QLabel" name="label_3">
+            <property name="text">
+             <string>Keywords</string>
+            </property>
+           </widget>
+          </item>
+          <item row="2" column="3" colspan="2">
+           <widget class="QPushButton" name="btnCanvasBbox">
+            <property name="text">
+             <string>Map extent</string>
+            </property>
+           </widget>
+          </item>
+          <item row="2" column="5">
+           <widget class="QPushButton" name="btnSearch">
+            <property name="text">
+             <string>Search</string>
+            </property>
+           </widget>
+          </item>
+          <item row="1" column="3" colspan="2">
+           <widget class="QPushButton" name="btnGlobalBbox">
+            <property name="text">
+             <string>Set global</string>
+            </property>
+           </widget>
+          </item>
+          <item row="0" column="3" colspan="3">
+           <widget class="QComboBox" name="cmbConnectionsSearch">
+            <property name="sizePolicy">
+             <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+              <horstretch>0</horstretch>
+              <verstretch>0</verstretch>
+             </sizepolicy>
+            </property>
+           </widget>
+          </item>
+          <item row="1" column="0" rowspan="2" colspan="3">
+           <layout class="QGridLayout" name="gridLayout_2">
+            <item row="0" column="0">
+             <widget class="QLabel" name="label_4">
+              <property name="text">
+               <string>Xmax</string>
+              </property>
+             </widget>
+            </item>
+            <item row="0" column="1">
+             <widget class="QLineEdit" name="leEast">
+              <property name="enabled">
+               <bool>true</bool>
+              </property>
+              <property name="minimumSize">
+               <size>
+                <width>85</width>
+                <height>0</height>
+               </size>
+              </property>
+              <property name="text">
+               <string>180</string>
+              </property>
+             </widget>
+            </item>
+            <item row="0" column="2">
+             <widget class="QLabel" name="label_7">
+              <property name="text">
+               <string>Ymax</string>
+              </property>
+             </widget>
+            </item>
+            <item row="0" column="3" colspan="2">
+             <widget class="QLineEdit" name="leNorth">
+              <property name="minimumSize">
+               <size>
+                <width>85</width>
+                <height>0</height>
+               </size>
+              </property>
+              <property name="text">
+               <string>90</string>
+              </property>
+             </widget>
+            </item>
+            <item row="1" column="0">
+             <widget class="QLabel" name="label_6">
+              <property name="text">
+               <string>Xmin</string>
+              </property>
+             </widget>
+            </item>
+            <item row="1" column="1">
+             <widget class="QLineEdit" name="leWest">
+              <property name="minimumSize">
+               <size>
+                <width>85</width>
+                <height>0</height>
+               </size>
+              </property>
+              <property name="text">
+               <string>-180</string>
+              </property>
+             </widget>
+            </item>
+            <item row="1" column="2">
+             <widget class="QLabel" name="label_8">
+              <property name="text">
+               <string>Ymin</string>
+              </property>
+             </widget>
+            </item>
+            <item row="1" column="3" colspan="2">
+             <widget class="QLineEdit" name="leSouth">
+              <property name="minimumSize">
+               <size>
+                <width>85</width>
+                <height>0</height>
+               </size>
+              </property>
+              <property name="text">
+               <string>-90</string>
+              </property>
+             </widget>
+            </item>
+           </layout>
+          </item>
+          <item row="0" column="2">
+           <widget class="QLabel" name="label">
+            <property name="text">
+             <string>        From</string>
+            </property>
+           </widget>
+          </item>
+         </layout>
+        </widget>
+       </item>
+       <item>
+        <widget class="QGroupBox" name="groupBox_2">
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Preferred" vsizetype="Expanding">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+         <property name="title">
+          <string>Results</string>
+         </property>
+         <layout class="QGridLayout" name="gridLayout_5">
+          <item row="1" column="0" colspan="3">
+           <widget class="QLabel" name="lblResults">
+            <property name="text">
+             <string/>
+            </property>
+           </widget>
+          </item>
+          <item row="3" column="0">
+           <widget class="QPushButton" name="btnFirst">
+            <property name="text">
+             <string>&lt;&lt;</string>
+            </property>
+           </widget>
+          </item>
+          <item row="1" column="3" colspan="2">
+           <widget class="QPushButton" name="btnViewRawAPIResponse">
+            <property name="text">
+             <string>View Raw API Response</string>
+            </property>
+           </widget>
+          </item>
+          <item row="3" column="1">
+           <widget class="QPushButton" name="btnPrev">
+            <property name="minimumSize">
+             <size>
+              <width>145</width>
+              <height>27</height>
+             </size>
+            </property>
+            <property name="text">
+             <string>&lt;</string>
+            </property>
+           </widget>
+          </item>
+          <item row="2" column="0" colspan="5">
+           <widget class="QTreeWidget" name="treeRecords">
+            <property name="toolTip">
+             <string>Double-click to see full record information</string>
+            </property>
+            <property name="editTriggers">
+             <set>QAbstractItemView::NoEditTriggers</set>
+            </property>
+            <property name="alternatingRowColors">
+             <bool>true</bool>
+            </property>
+            <property name="rootIsDecorated">
+             <bool>false</bool>
+            </property>
+            <property name="itemsExpandable">
+             <bool>false</bool>
+            </property>
+            <property name="sortingEnabled">
+             <bool>true</bool>
+            </property>
+            <property name="allColumnsShowFocus">
+             <bool>true</bool>
+            </property>
+            <attribute name="headerStretchLastSection">
+             <bool>true</bool>
+            </attribute>
+            <column>
+             <property name="text">
+              <string>Type</string>
+             </property>
+            </column>
+            <column>
+             <property name="text">
+              <string>Title</string>
+             </property>
+            </column>
+           </widget>
+          </item>
+          <item row="3" column="4">
+           <widget class="QPushButton" name="btnLast">
+            <property name="minimumSize">
+             <size>
+              <width>140</width>
+              <height>0</height>
+             </size>
+            </property>
+            <property name="text">
+             <string>&gt;&gt;</string>
+            </property>
+           </widget>
+          </item>
+          <item row="3" column="3">
+           <widget class="QPushButton" name="btnNext">
+            <property name="minimumSize">
+             <size>
+              <width>140</width>
+              <height>0</height>
+             </size>
+            </property>
+            <property name="text">
+             <string>&gt;</string>
+            </property>
+           </widget>
+          </item>
+          <item row="5" column="0">
+           <widget class="QToolButton" name="tbAddData">
+            <property name="sizePolicy">
+             <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+              <horstretch>0</horstretch>
+              <verstretch>0</verstretch>
+             </sizepolicy>
+            </property>
+            <property name="minimumSize">
+             <size>
+              <width>145</width>
+              <height>27</height>
+             </size>
+            </property>
+            <property name="maximumSize">
+             <size>
+              <width>145</width>
+              <height>27</height>
+             </size>
+            </property>
+            <property name="text">
+             <string>Add Data</string>
+            </property>
+            <property name="popupMode">
+             <enum>QToolButton::InstantPopup</enum>
+            </property>
+            <property name="toolButtonStyle">
+             <enum>Qt::ToolButtonTextOnly</enum>
+            </property>
+            <property name="autoRaise">
+             <bool>false</bool>
+            </property>
+            <property name="arrowType">
+             <enum>Qt::NoArrow</enum>
+            </property>
+            <action name="mActionAddWms">
+             <property name="icon">
+              <iconset>
+               <normaloff>:/images/themes/default/mActionAddWmsLayer.svg</normaloff>:/images/themes/default/mActionAddWmsLayer.svg</iconset>
+             </property>
+             <property name="text">
+              <string>Add WMS/WMTS</string>
+             </property>
+            </action>
+            <action name="mActionAddWfs">
+             <property name="icon">
+              <iconset>
+               <normaloff>:/images/themes/default/mActionAddWfsLayer.svg</normaloff>:/images/themes/default/mActionAddWfsLayer.svg</iconset>
+             </property>
+             <property name="text">
+              <string>Add WFS</string>
+             </property>
+            </action>
+            <action name="mActionAddWcs">
+             <property name="icon">
+              <iconset>
+               <normaloff>:/images/themes/default/mActionAddWcsLayer.svg</normaloff>:/images/themes/default/mActionAddWcsLayer.svg</iconset>
+             </property>
+             <property name="text">
+              <string>Add WCS</string>
+             </property>
+            </action>
+            <action name="mActionAddAms">
+             <property name="icon">
+              <iconset>
+               <normaloff>:/images/themes/default/mActionAddAmsLayer.svg</normaloff>:/images/themes/default/mActionAddAmsLayer.svg</iconset>
+             </property>
+             <property name="text">
+              <string>Add ArcGIS MapServer</string>
+             </property>
+            </action>
+            <action name="mActionAddAfs">
+             <property name="icon">
+              <iconset>
+               <normaloff>:/images/themes/default/mActionAddAfsLayer.svg</normaloff>:/images/themes/default/mActionAddAfsLayer.svg</iconset>
+             </property>
+             <property name="text">
+              <string>Add ArcGIS FeatureServer</string>
+             </property>
+            </action>
+            <action name="mActionAddGisFile">
+             <property name="icon">
+              <iconset>
+               <normaloff>:/images/themes/default/mActionAddAfsLayer.svg</normaloff>:/images/themes/default/mActionAddAfsLayer.svg</iconset>
+             </property>
+             <property name="text">
+              <string>Add GIS File</string>
+             </property>
+            </action>
+            <addaction name="mActionAddWms"/>
+            <addaction name="mActionAddWfs"/>
+            <addaction name="mActionAddWcs"/>
+            <addaction name="mActionAddAms"/>
+            <addaction name="mActionAddAfs"/>
+            <addaction name="mActionAddGisFile"/>
+           </widget>
+          </item>
+         </layout>
+         <zorder>treeRecords</zorder>
+         <zorder>lblResults</zorder>
+         <zorder>btnPrev</zorder>
+         <zorder>btnFirst</zorder>
+         <zorder>btnViewRawAPIResponse</zorder>
+         <zorder>btnLast</zorder>
+         <zorder>btnNext</zorder>
+         <zorder>tbAddData</zorder>
+        </widget>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QWidget" name="tabServers">
+      <attribute name="title">
+       <string>Services</string>
+      </attribute>
+      <layout class="QGridLayout" name="gridLayout">
+       <item row="0" column="0" colspan="5">
+        <widget class="QComboBox" name="cmbConnectionsServices"/>
+       </item>
+       <item row="1" column="0">
+        <widget class="QPushButton" name="btnServerInfo">
+         <property name="text">
+          <string>Service Info</string>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="1" colspan="2">
+        <widget class="QPushButton" name="btnRawAPIResponse">
+         <property name="text">
+          <string>Raw API Response</string>
+         </property>
+        </widget>
+       </item>
+       <item row="2" column="0">
+        <widget class="QPushButton" name="btnNew">
+         <property name="text">
+          <string>New…</string>
+         </property>
+        </widget>
+       </item>
+       <item row="2" column="1">
+        <widget class="QPushButton" name="btnEdit">
+         <property name="text">
+          <string>Edit…</string>
+         </property>
+        </widget>
+       </item>
+       <item row="2" column="2">
+        <widget class="QPushButton" name="btnDelete">
+         <property name="text">
+          <string>Delete…</string>
+         </property>
+        </widget>
+       </item>
+       <item row="2" column="4">
+        <widget class="QPushButton" name="btnSave">
+         <property name="text">
+          <string>Save…</string>
+         </property>
+        </widget>
+       </item>
+       <item row="3" column="0" colspan="5">
+        <widget class="QTextBrowser" name="textMetadata">
+         <property name="openExternalLinks">
+          <bool>true</bool>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="3" colspan="2">
+        <widget class="QPushButton" name="btnAddDefault">
+         <property name="text">
+          <string>Add Default Services</string>
+         </property>
+        </widget>
+       </item>
+       <item row="2" column="3">
+        <widget class="QPushButton" name="btnLoad">
+         <property name="text">
+          <string>Load…</string>
+         </property>
+        </widget>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QWidget" name="tabSettings">
+      <attribute name="title">
+       <string>Settings</string>
+      </attribute>
+      <layout class="QVBoxLayout" name="verticalLayout_4">
+       <item>
+        <widget class="QGroupBox" name="groupBox_4">
+         <property name="title">
+          <string>Server</string>
+         </property>
+         <layout class="QGridLayout" name="gridLayout_4">
+          <item row="0" column="2">
+           <widget class="QSpinBox" name="spnTimeout">
+            <property name="value">
+             <number>10</number>
+            </property>
+           </widget>
+          </item>
+          <item row="1" column="0">
+           <widget class="QLabel" name="label_11">
+            <property name="text">
+             <string>Disable SSL verification</string>
+            </property>
+           </widget>
+          </item>
+          <item row="1" column="2">
+           <widget class="QCheckBox" name="disableSSLVerification">
+            <property name="text">
+             <string/>
+            </property>
+           </widget>
+          </item>
+          <item row="0" column="3">
+           <widget class="QLabel" name="label_9">
+            <property name="text">
+             <string>seconds</string>
+            </property>
+           </widget>
+          </item>
+          <item row="0" column="0">
+           <widget class="QLabel" name="label_10">
+            <property name="text">
+             <string>Timeout</string>
+            </property>
+           </widget>
+          </item>
+         </layout>
+        </widget>
+       </item>
+       <item>
+        <widget class="QGroupBox" name="groupBox_3">
+         <property name="title">
+          <string>Results Paging</string>
+         </property>
+         <layout class="QHBoxLayout" name="horizontalLayout_2">
+          <item>
+           <widget class="QLabel" name="label_5">
+            <property name="text">
+             <string>Show</string>
+            </property>
+           </widget>
+          </item>
+          <item>
+           <widget class="QSpinBox" name="spnRecords"/>
+          </item>
+          <item>
+           <widget class="QLabel" name="label_2">
+            <property name="text">
+             <string>results at a time</string>
+            </property>
+            <property name="alignment">
+             <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
+            </property>
+           </widget>
+          </item>
+         </layout>
+        </widget>
+       </item>
+       <item>
+        <spacer name="verticalSpacer">
+         <property name="orientation">
+          <enum>Qt::Vertical</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>20</width>
+           <height>40</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+      </layout>
+     </widget>
+    </widget>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Close|QDialogButtonBox::Help</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <customwidgets>
+  <customwidget>
+   <class>QgsFilterLineEdit</class>
+   <extends>QLineEdit</extends>
+   <header>qgis.gui</header>
+  </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>MetaSearchDialog</receiver>
+   <slot>accept()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>248</x>
+     <y>254</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>157</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>MetaSearchDialog</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>316</x>
+     <y>260</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>286</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>

+ 98 - 0
MetaSearch/ui/manageconnectionsdialog.ui

@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ManageConnectionsDialog</class>
+ <widget class="QDialog" name="ManageConnectionsDialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>400</width>
+    <height>300</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Manage Connections</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout">
+     <item>
+      <widget class="QLabel" name="label">
+       <property name="text">
+        <string>Save to file</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QLineEdit" name="leFileName"/>
+     </item>
+     <item>
+      <widget class="QPushButton" name="btnBrowse">
+       <property name="text">
+        <string>Browse</string>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+   <item>
+    <widget class="QListWidget" name="listConnections">
+     <property name="editTriggers">
+      <set>QAbstractItemView::NoEditTriggers</set>
+     </property>
+     <property name="alternatingRowColors">
+      <bool>true</bool>
+     </property>
+     <property name="selectionMode">
+      <enum>QAbstractItemView::ExtendedSelection</enum>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>ManageConnectionsDialog</receiver>
+   <slot>accept()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>248</x>
+     <y>254</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>157</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>ManageConnectionsDialog</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>316</x>
+     <y>260</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>286</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>

+ 165 - 0
MetaSearch/ui/newconnectiondialog.ui

@@ -0,0 +1,165 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>NewConnectionDialog</class>
+ <widget class="QDialog" name="NewConnectionDialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>585</width>
+    <height>327</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Create a new Catalog connection</string>
+  </property>
+  <layout class="QGridLayout" name="gridLayout">
+   <item row="1" column="1">
+    <widget class="QLineEdit" name="leURL"/>
+   </item>
+   <item row="1" column="0">
+    <widget class="QLabel" name="label_2">
+     <property name="text">
+      <string>URL</string>
+     </property>
+    </widget>
+   </item>
+   <item row="0" column="0">
+    <widget class="QLabel" name="label">
+     <property name="text">
+      <string>Name</string>
+     </property>
+    </widget>
+   </item>
+   <item row="4" column="1">
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+     </property>
+    </widget>
+   </item>
+   <item row="0" column="1">
+    <widget class="QLineEdit" name="leName"/>
+   </item>
+   <item row="3" column="0" colspan="2">
+    <widget class="QGroupBox" name="authenticationGroupBox">
+     <property name="title">
+      <string>Authentication</string>
+     </property>
+     <layout class="QFormLayout" name="formLayout_2">
+      <item row="0" column="0" colspan="2">
+       <widget class="QLabel" name="label_3">
+        <property name="text">
+         <string>If the service requires basic authentication, enter a user name and optional password</string>
+        </property>
+        <property name="wordWrap">
+         <bool>true</bool>
+        </property>
+       </widget>
+      </item>
+      <item row="1" column="0">
+       <widget class="QLabel" name="label_4">
+        <property name="text">
+         <string>User name</string>
+        </property>
+       </widget>
+      </item>
+      <item row="1" column="1">
+       <widget class="QLineEdit" name="leUsername"/>
+      </item>
+      <item row="2" column="0">
+       <widget class="QLabel" name="label_5">
+        <property name="text">
+         <string>Password</string>
+        </property>
+       </widget>
+      </item>
+      <item row="2" column="1">
+       <widget class="QgsPasswordLineEdit" name="lePassword"/>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item row="2" column="0" colspan="2">
+    <widget class="QGroupBox" name="optionsGroupBox">
+     <property name="title">
+      <string>Options</string>
+     </property>
+     <widget class="QLabel" name="leCatalogType">
+      <property name="geometry">
+       <rect>
+        <x>10</x>
+        <y>30</y>
+        <width>71</width>
+        <height>16</height>
+       </rect>
+      </property>
+      <property name="text">
+       <string>Catalog Type</string>
+      </property>
+     </widget>
+     <widget class="QComboBox" name="cmbCatalogType">
+      <property name="geometry">
+       <rect>
+        <x>100</x>
+        <y>30</y>
+        <width>151</width>
+        <height>22</height>
+       </rect>
+      </property>
+     </widget>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <tabstops>
+  <tabstop>leName</tabstop>
+  <tabstop>leURL</tabstop>
+  <tabstop>buttonBox</tabstop>
+ </tabstops>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>NewConnectionDialog</receiver>
+   <slot>accept()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>248</x>
+     <y>254</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>157</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>NewConnectionDialog</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>316</x>
+     <y>260</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>286</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+  <customwidgets>
+  <customwidget>
+   <class>QgsPasswordLineEdit</class>
+   <extends>QLineEdit</extends>
+   <header>qgis.gui</header>
+  </customwidget>
+ </customwidgets>
+</ui>

+ 74 - 0
MetaSearch/ui/recorddialog.ui

@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>RecordDialog</class>
+ <widget class="QDialog" name="RecordDialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>600</width>
+    <height>400</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Record Metadata</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QTextBrowser" name="textMetadata">
+     <property name="lineWrapMode">
+      <enum>QTextEdit::NoWrap</enum>
+     </property>
+     <property name="openExternalLinks">
+      <bool>true</bool>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Close</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>RecordDialog</receiver>
+   <slot>accept()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>248</x>
+     <y>254</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>157</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>RecordDialog</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>316</x>
+     <y>260</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>286</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>

+ 190 - 0
MetaSearch/util.py

@@ -0,0 +1,190 @@
+###############################################################################
+#
+# Copyright (C) 2010 NextGIS (http://nextgis.org),
+#                    Alexander Bruy (alexander.bruy@gmail.com),
+#                    Maxim Dubinin (sim@gis-lab.info)
+#
+# Copyright (C) 2014 Tom Kralidis (tomkralidis@gmail.com)
+# Copyright (C) 2014 Angelos Tzotsos (tzotsos@gmail.com)
+#
+# This source is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation; either version 2 of the License, or (at your option)
+# any later version.
+#
+# This code is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+###############################################################################
+
+from gettext import gettext, ngettext
+import json
+import logging
+import warnings
+import os
+import webbrowser
+from xml.dom.minidom import parseString
+import xml.etree.ElementTree as etree
+
+with warnings.catch_warnings():
+    warnings.filterwarnings("ignore", category=DeprecationWarning)
+    from jinja2 import Environment, FileSystemLoader
+
+from pygments import highlight
+from pygments.lexers import JsonLexer, XmlLexer
+from pygments.formatters import HtmlFormatter
+from qgis.PyQt.QtCore import QUrl, QUrlQuery
+from qgis.PyQt.QtWidgets import QMessageBox
+from qgis.PyQt.uic import loadUiType
+
+from qgis.core import Qgis, QgsSettings
+
+LOGGER = logging.getLogger('MetaSearch')
+
+
+class StaticContext:
+    """base configuration / scaffolding"""
+
+    def __init__(self):
+        """init"""
+        self.ppath = os.path.dirname(os.path.abspath(__file__))
+
+
+def get_ui_class(ui_file):
+    """return class object of a uifile"""
+    ui_file_full = '{}{}ui{}{}'.format(os.path.dirname(os.path.abspath(__file__)), os.sep, os.sep, ui_file)
+    return loadUiType(ui_file_full)[0]
+
+
+def render_template(language, context, data, template):
+    """Renders HTML display of raw API request/response/content"""
+
+    env = Environment(extensions=['jinja2.ext.i18n'],
+                      loader=FileSystemLoader(context.ppath))
+    env.install_gettext_callables(gettext, ngettext, newstyle=True)
+
+    template_file = 'resources/templates/%s' % template
+    template = env.get_template(template_file)
+    return template.render(language=language, obj=data)
+
+
+def get_connections_from_file(parent, filename):
+    """load connections from connection file"""
+
+    error = 0
+    try:
+        doc = etree.parse(filename).getroot()
+        if doc.tag != 'qgsCSWConnections':
+            error = 1
+            msg = parent.tr('Invalid Catalog connections XML.')
+    except etree.ParseError as err:
+        error = 1
+        msg = parent.tr('Cannot parse XML file: {0}').format(err)
+    except OSError as err:
+        error = 1
+        msg = parent.tr('Cannot open file: {0}').format(err)
+
+    if error == 1:
+        QMessageBox.information(parent, parent.tr('Loading Connections'), msg)
+        return
+    return doc
+
+
+def prettify_xml(xml):
+    """convenience function to prettify XML"""
+
+    if isinstance(xml, bytes):
+        xml = xml.decode('utf-8')
+
+    if xml.count('\n') > 20:  # likely already pretty printed
+        return xml
+
+    # check if it's a GET request
+    if xml.startswith('http'):
+        return xml
+    else:
+        return parseString(xml).toprettyxml()
+
+
+def highlight_content(context, content, mimetype):
+    """render content as highlighted HTML"""
+
+    hformat = HtmlFormatter()
+    css = hformat.get_style_defs('.highlight')
+    if mimetype == 'json':
+        body = highlight(json.dumps(content, indent=4), JsonLexer(), hformat)
+    elif mimetype == 'xml':
+        body = highlight(prettify_xml(content), XmlLexer(), hformat)
+
+    env = Environment(loader=FileSystemLoader(context.ppath))
+
+    template_file = 'resources/templates/api_highlight.html'
+    template = env.get_template(template_file)
+    return template.render(css=css, body=body)
+
+
+def get_help_url():
+    """return QGIS MetaSearch help documentation link"""
+
+    locale_name = QgsSettings().value('locale/userLocale')[0:2]
+    major, minor = Qgis.QGIS_VERSION.split('.')[:2]
+
+    if minor == '99':  # master
+        version = 'testing'
+    else:
+        version = '.'.join([major, minor])
+
+    path = f'{version}/{locale_name}/docs/user_manual/plugins/core_plugins/plugins_metasearch.html'  # noqa
+
+    return '/'.join(['https://docs.qgis.org', path])
+
+
+def open_url(url):
+    """open URL in web browser"""
+
+    webbrowser.open(url)
+
+
+def normalize_text(text):
+    """tidy up string"""
+
+    return text.replace('\n', '')
+
+
+def serialize_string(input_string):
+    """apply a serial counter to a string"""
+
+    s = input_string.strip().split()
+
+    last_token = s[-1]
+    all_other_tokens_as_string = input_string.replace(last_token, '')
+
+    if last_token.isdigit():
+        value = f'{all_other_tokens_as_string}{int(last_token) + 1}'
+    else:
+        value = '%s 1' % input_string
+
+    return value
+
+
+def clean_ows_url(url):
+    """clean an OWS URL of added basic service parameters"""
+
+    url = QUrl(url)
+    query_string = url.query()
+
+    if query_string:
+        query_string = QUrlQuery(query_string)
+        query_string.removeQueryItem('service')
+        query_string.removeQueryItem('SERVICE')
+        query_string.removeQueryItem('request')
+        query_string.removeQueryItem('REQUEST')
+        url.setQuery(query_string)
+
+    return url.toString()

+ 15 - 0
db_manager/LICENSE

@@ -0,0 +1,15 @@
+DB Manager * Copyright (c) 2011 Giuseppe Sucameli
+
+Licensed under the terms of GNU GPL v2 (or any layer)
+http://www.gnu.org/copyleft/gpl.html
+
+
+Code:
+- some code is derived from PG_Manager by Martin Dobias (GPLv2 license)
+
+Icons:
+- toolbar icons are derived from gis-0.1 iconset by Robert Szczepanek (Creative Commons Attribution-Share Alike 3.0 Unported license)
+- refresh toolbar icon is from Tango project (public domain)
+- table, view and namespace icons in database view are from pgAdmin3 (BSD license)
+- other icons are from QGIS project (GPLv2 license)
+- plugin icon by Sandro Santilli, using qgis icon and database icon by Dracos - http://commons.wikimedia.org/wiki/File:Applications-database.svg (Creative Commons Attribution-Share Alike 3.0 Unported license)

+ 35 - 0
db_manager/TODO

@@ -0,0 +1,35 @@
+DB Manager TODO and DONE list.
+
+
+DONE:
+* run only the selected part of a query (v0.1.20)
+* add versioning support to PostgreSQL dbs (v0.1.19)
+* completer for sql keywords/functions (v0.1.18)
+* highlights PG and SL functions, fix slow connection to PG db (v0.1.17)
+* add contestual menu to db tree, use service param when available to connect to PG dbs (v0.1.16)
+* close transactions before doing changes to tables (v0.1.15)
+* improve error handling running a query in sql window (v0.1.14)
+* fix error dialog (v0.1.13)
+* improve error handling, add Re-connect button (v0.1.12)
+* add "Run Vacuum" and "Move to schema" to menu (v0.1.11)
+* fix encoding support and import layer on Win (v0.1.10)
+* allow copying contents of views (v0.1.9)
+* GUI to import data by drag'n'drop (v0.1.8)
+* edit table (v0.1.8)
+* create table (v0.1.7)
+* display schemas and tables comments (v0.1.6)
+* SQL syntax highlighting (v0.1.5)
+* load a query as layer into canvas (v0.1.4)
+* import/export OGR layers and non-spatial data using Import Vector Layer feature (v.0.1.0)
+
+
+TODO:
+- PGManager
+    * GUI to import/export data (from shapefiles)
+- RT_Sql_Layer
+    * query builder
+    * query manager
+- QSpatialite
+    * GUI to import Qgis layer, QgsWkbTypes
+- SPIT
+    * mass import of shapefiles

+ 25 - 0
db_manager/__init__.py

@@ -0,0 +1,25 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+
+def classFactory(iface):
+    from .db_manager_plugin import DBManagerPlugin
+
+    return DBManagerPlugin(iface)

+ 490 - 0
db_manager/db_manager.py

@@ -0,0 +1,490 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+import functools
+
+from qgis.PyQt.QtCore import Qt, QByteArray, QSize
+from qgis.PyQt.QtWidgets import QMainWindow, QApplication, QMenu, QTabWidget, QGridLayout, QSpacerItem, QSizePolicy, QDockWidget, QStatusBar, QMenuBar, QToolBar, QTabBar
+from qgis.PyQt.QtGui import QIcon, QKeySequence
+
+from qgis.gui import QgsMessageBar
+from qgis.core import (
+    Qgis,
+    QgsApplication,
+    QgsSettings,
+    QgsMapLayerType
+)
+from qgis.utils import OverrideCursor
+
+from .info_viewer import InfoViewer
+from .table_viewer import TableViewer
+from .layer_preview import LayerPreview
+
+from .db_tree import DBTree
+
+from .db_plugins.plugin import BaseError
+from .dlg_db_error import DlgDbError
+
+
+class DBManager(QMainWindow):
+
+    def __init__(self, iface, parent=None):
+        QMainWindow.__init__(self, parent)
+        self.setAttribute(Qt.WA_DeleteOnClose)
+        self.setupUi()
+        self.iface = iface
+
+        # restore the window state
+        settings = QgsSettings()
+        self.restoreGeometry(settings.value("/DB_Manager/mainWindow/geometry", QByteArray(), type=QByteArray))
+        self.restoreState(settings.value("/DB_Manager/mainWindow/windowState", QByteArray(), type=QByteArray))
+
+        self.toolBar.setIconSize(self.iface.iconSize())
+        self.toolBarOrientation()
+        self.toolBar.orientationChanged.connect(self.toolBarOrientation)
+        self.tabs.currentChanged.connect(self.tabChanged)
+        self.tree.selectedItemChanged.connect(self.itemChanged)
+        self.tree.model().dataChanged.connect(self.iface.reloadConnections)
+        self.itemChanged(None)
+
+    def closeEvent(self, e):
+        self.unregisterAllActions()
+        # clear preview, this will delete the layer in preview tab
+        self.preview.loadPreview(None)
+
+        # save the window state
+        settings = QgsSettings()
+        settings.setValue("/DB_Manager/mainWindow/windowState", self.saveState())
+        settings.setValue("/DB_Manager/mainWindow/geometry", self.saveGeometry())
+
+        QMainWindow.closeEvent(self, e)
+
+    def refreshItem(self, item=None):
+        with OverrideCursor(Qt.WaitCursor):
+            try:
+                if item is None:
+                    item = self.tree.currentItem()
+                self.tree.refreshItem(item)  # refresh item children in the db tree
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+
+    def itemChanged(self, item):
+        with OverrideCursor(Qt.WaitCursor):
+            try:
+                self.reloadButtons()
+                # Force-reload information on the layer
+                self.info.setDirty()
+                # clear preview, this will delete the layer in preview tab
+                self.preview.loadPreview(None)
+                self.refreshTabs()
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+
+    def reloadButtons(self):
+        db = self.tree.currentDatabase()
+        if not hasattr(self, '_lastDb'):
+            self._lastDb = db
+
+        elif db == self._lastDb:
+            return
+
+        # remove old actions
+        if self._lastDb is not None:
+            self.unregisterAllActions()
+
+        # add actions of the selected database
+        self._lastDb = db
+        if self._lastDb is not None:
+            self._lastDb.registerAllActions(self)
+
+    def tabChanged(self, index):
+        with OverrideCursor(Qt.WaitCursor):
+            try:
+                self.refreshTabs()
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+
+    def refreshTabs(self):
+        index = self.tabs.currentIndex()
+        item = self.tree.currentItem()
+        table = self.tree.currentTable()
+
+        # enable/disable tabs
+        self.tabs.setTabEnabled(self.tabs.indexOf(self.table), table is not None)
+        self.tabs.setTabEnabled(self.tabs.indexOf(self.preview), table is not None and table.type in [table.VectorType,
+                                                                                                      table.RasterType] and table.geomColumn is not None)
+        # show the info tab if the current tab is disabled
+        if not self.tabs.isTabEnabled(index):
+            self.tabs.setCurrentWidget(self.info)
+
+        current_tab = self.tabs.currentWidget()
+        if current_tab == self.info:
+            self.info.showInfo(item)
+        elif current_tab == self.table:
+            self.table.loadData(item)
+        elif current_tab == self.preview:
+            self.preview.loadPreview(item)
+
+    def refreshActionSlot(self):
+        self.info.setDirty()
+        self.table.setDirty()
+        self.preview.setDirty()
+        self.refreshItem()
+
+    def importActionSlot(self):
+        db = self.tree.currentDatabase()
+        if db is None:
+            self.infoBar.pushMessage(self.tr("No database selected or you are not connected to it."),
+                                     Qgis.Info, self.iface.messageTimeout())
+            return
+
+        outUri = db.uri()
+        schema = self.tree.currentSchema()
+        if schema:
+            outUri.setDataSource(schema.name, "", "", "")
+
+        from .dlg_import_vector import DlgImportVector
+
+        dlg = DlgImportVector(None, db, outUri, self)
+        dlg.exec_()
+
+    def exportActionSlot(self):
+        table = self.tree.currentTable()
+        if table is None:
+            self.infoBar.pushMessage(self.tr("Select the table you want export to file."), Qgis.Info,
+                                     self.iface.messageTimeout())
+            return
+
+        inLayer = table.toMapLayer()
+        if inLayer.type() != QgsMapLayerType.VectorLayer:
+            self.infoBar.pushMessage(
+                self.tr("Select a vector or a tabular layer you want export."),
+                Qgis.Warning, self.iface.messageTimeout())
+            return
+
+        from .dlg_export_vector import DlgExportVector
+
+        dlg = DlgExportVector(inLayer, table.database(), self)
+        dlg.exec_()
+
+        inLayer.deleteLater()
+
+    def runSqlWindow(self):
+        db = self.tree.currentDatabase()
+        if db is None:
+            self.infoBar.pushMessage(self.tr("No database selected or you are not connected to it."),
+                                     Qgis.Info, self.iface.messageTimeout())
+            # force displaying of the message, it appears on the first tab (i.e. Info)
+            self.tabs.setCurrentIndex(0)
+            return
+
+        from .dlg_sql_window import DlgSqlWindow
+
+        query = DlgSqlWindow(self.iface, db, self)
+        dbname = db.connection().connectionName()
+        tabname = self.tr("Query ({0})").format(dbname)
+        index = self.tabs.addTab(query, tabname)
+        self.tabs.setTabIcon(index, db.connection().icon())
+        self.tabs.setCurrentIndex(index)
+        query.nameChanged.connect(functools.partial(self.update_query_tab_name, index, dbname))
+
+    def runSqlLayerWindow(self, layer):
+        from .dlg_sql_layer_window import DlgSqlLayerWindow
+        query = DlgSqlLayerWindow(self.iface, layer, self)
+        lname = layer.name()
+        tabname = self.tr("Layer ({0})").format(lname)
+        index = self.tabs.addTab(query, tabname)
+        # self.tabs.setTabIcon(index, db.connection().icon())
+        self.tabs.setCurrentIndex(index)
+
+    def update_query_tab_name(self, index, dbname, queryname):
+        if not queryname:
+            queryname = self.tr("Query")
+        tabname = "%s (%s)" % (queryname, dbname)
+        self.tabs.setTabText(index, tabname)
+
+    def showSystemTables(self):
+        self.tree.showSystemTables(self.actionShowSystemTables.isChecked())
+
+    def registerAction(self, action, menuName, callback=None):
+        """ register an action to the manager's main menu """
+        if not hasattr(self, '_registeredDbActions'):
+            self._registeredDbActions = {}
+
+        if callback is not None:
+            def invoke_callback(x):
+                return self.invokeCallback(callback)
+
+        if menuName is None or menuName == "":
+            self.addAction(action)
+
+            if menuName not in self._registeredDbActions:
+                self._registeredDbActions[menuName] = list()
+            self._registeredDbActions[menuName].append(action)
+
+            if callback is not None:
+                action.triggered.connect(invoke_callback)
+            return True
+
+        # search for the menu
+        actionMenu = None
+        helpMenuAction = None
+        for a in self.menuBar.actions():
+            if not a.menu() or a.menu().title() != menuName:
+                continue
+            if a.menu() != self.menuHelp:
+                helpMenuAction = a
+
+            actionMenu = a
+            break
+
+        # not found, add a new menu before the help menu
+        if actionMenu is None:
+            menu = QMenu(menuName, self)
+            if helpMenuAction is not None:
+                actionMenu = self.menuBar.insertMenu(helpMenuAction, menu)
+            else:
+                actionMenu = self.menuBar.addMenu(menu)
+
+        menu = actionMenu.menu()
+        menuActions = menu.actions()
+
+        # get the placeholder's position to insert before it
+        pos = 0
+        for pos in range(len(menuActions)):
+            if menuActions[pos].isSeparator() and menuActions[pos].objectName().endswith("_placeholder"):
+                menuActions[pos].setVisible(True)
+                break
+
+        if pos < len(menuActions):
+            before = menuActions[pos]
+            menu.insertAction(before, action)
+        else:
+            menu.addAction(action)
+
+        actionMenu.setVisible(True)  # show the menu
+
+        if menuName not in self._registeredDbActions:
+            self._registeredDbActions[menuName] = list()
+        self._registeredDbActions[menuName].append(action)
+
+        if callback is not None:
+            action.triggered.connect(invoke_callback)
+
+        return True
+
+    def invokeCallback(self, callback, *params):
+        """ Call a method passing the selected item in the database tree,
+                the sender (usually a QAction), the plugin mainWindow and
+                optionally additional parameters.
+
+                This method takes care to override and restore the cursor,
+                but also catches exceptions and displays the error dialog.
+        """
+        with OverrideCursor(Qt.WaitCursor):
+            try:
+                callback(self.tree.currentItem(), self.sender(), self, *params)
+            except BaseError as e:
+                # catch database errors and display the error dialog
+                DlgDbError.showError(e, self)
+
+    def unregisterAction(self, action, menuName):
+        if not hasattr(self, '_registeredDbActions'):
+            return
+
+        if menuName is None or menuName == "":
+            self.removeAction(action)
+
+            if menuName in self._registeredDbActions:
+                if self._registeredDbActions[menuName].count(action) > 0:
+                    self._registeredDbActions[menuName].remove(action)
+
+            action.deleteLater()
+            return True
+
+        for a in self.menuBar.actions():
+            if not a.menu() or a.menu().title() != menuName:
+                continue
+
+            menu = a.menu()
+            menuActions = menu.actions()
+
+            menu.removeAction(action)
+            if menu.isEmpty():  # hide the menu
+                a.setVisible(False)
+
+            if menuName in self._registeredDbActions:
+                if self._registeredDbActions[menuName].count(action) > 0:
+                    self._registeredDbActions[menuName].remove(action)
+
+                # hide the placeholder if there're no other registered actions
+                if len(self._registeredDbActions[menuName]) <= 0:
+                    for i in range(len(menuActions)):
+                        if menuActions[i].isSeparator() and menuActions[i].objectName().endswith("_placeholder"):
+                            menuActions[i].setVisible(False)
+                            break
+
+            action.deleteLater()
+            return True
+
+        return False
+
+    def unregisterAllActions(self):
+        if not hasattr(self, '_registeredDbActions'):
+            return
+
+        for menuName in self._registeredDbActions:
+            for action in list(self._registeredDbActions[menuName]):
+                self.unregisterAction(action, menuName)
+        del self._registeredDbActions
+
+    def close_tab(self, index):
+        widget = self.tabs.widget(index)
+        if widget not in [self.info, self.table, self.preview]:
+            if hasattr(widget, "close"):
+                if widget.close():
+                    self.tabs.removeTab(index)
+                    widget.deleteLater()
+            else:
+                self.tabs.removeTab(index)
+                widget.deleteLater()
+
+    def toolBarOrientation(self):
+        button_style = Qt.ToolButtonIconOnly
+        if self.toolBar.orientation() == Qt.Horizontal:
+            button_style = Qt.ToolButtonTextBesideIcon
+
+        widget = self.toolBar.widgetForAction(self.actionImport)
+        widget.setToolButtonStyle(button_style)
+        widget = self.toolBar.widgetForAction(self.actionExport)
+        widget.setToolButtonStyle(button_style)
+
+    def setupUi(self):
+        self.setWindowTitle(self.tr("DB Manager"))
+        self.setWindowIcon(QIcon(":/db_manager/icon"))
+        self.resize(QSize(700, 500).expandedTo(self.minimumSizeHint()))
+
+        # create central tab widget and add the first 3 tabs: info, table and preview
+        self.tabs = QTabWidget()
+        self.info = InfoViewer(self)
+        self.tabs.addTab(self.info, self.tr("Info"))
+        self.table = TableViewer(self)
+        self.tabs.addTab(self.table, self.tr("Table"))
+        self.preview = LayerPreview(self)
+        self.tabs.addTab(self.preview, self.tr("Preview"))
+        self.setCentralWidget(self.tabs)
+
+        # display close button for all tabs but the first 3 ones, i.e.
+        # HACK: just hide the close button where not needed (GS)
+        self.tabs.setTabsClosable(True)
+        self.tabs.tabCloseRequested.connect(self.close_tab)
+        tabbar = self.tabs.tabBar()
+        for i in range(3):
+            btn = tabbar.tabButton(i, QTabBar.RightSide) if tabbar.tabButton(i, QTabBar.RightSide) else tabbar.tabButton(i, QTabBar.LeftSide)
+            btn.resize(0, 0)
+            btn.hide()
+
+        # Creates layout for message bar
+        self.layout = QGridLayout(self.info)
+        self.layout.setContentsMargins(0, 0, 0, 0)
+        spacerItem = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)
+        self.layout.addItem(spacerItem, 1, 0, 1, 1)
+        # init messageBar instance
+        self.infoBar = QgsMessageBar(self.info)
+        sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
+        self.infoBar.setSizePolicy(sizePolicy)
+        self.layout.addWidget(self.infoBar, 0, 0, 1, 1)
+
+        # create database tree
+        self.dock = QDockWidget(self.tr("Providers"), self)
+        self.dock.setObjectName("DB_Manager_DBView")
+        self.dock.setFeatures(QDockWidget.DockWidgetMovable)
+        self.tree = DBTree(self)
+        self.dock.setWidget(self.tree)
+        self.addDockWidget(Qt.LeftDockWidgetArea, self.dock)
+
+        # create status bar
+        self.statusBar = QStatusBar(self)
+        self.setStatusBar(self.statusBar)
+
+        # create menus
+        self.menuBar = QMenuBar(self)
+        self.menuDb = QMenu(self.tr("&Database"), self)
+        self.menuBar.addMenu(self.menuDb)
+        self.menuSchema = QMenu(self.tr("&Schema"), self)
+        actionMenuSchema = self.menuBar.addMenu(self.menuSchema)
+        self.menuTable = QMenu(self.tr("&Table"), self)
+        actionMenuTable = self.menuBar.addMenu(self.menuTable)
+        self.menuHelp = None  # QMenu(self.tr("&Help"), self)
+        # actionMenuHelp = self.menuBar.addMenu(self.menuHelp)
+
+        self.setMenuBar(self.menuBar)
+
+        # create toolbar
+        self.toolBar = QToolBar(self.tr("Default"), self)
+        self.toolBar.setObjectName("DB_Manager_ToolBar")
+        self.addToolBar(self.toolBar)
+
+        # create menus' actions
+
+        # menu DATABASE
+        sep = self.menuDb.addSeparator()
+        sep.setObjectName("DB_Manager_DbMenu_placeholder")
+        sep.setVisible(False)
+
+        self.actionRefresh = self.menuDb.addAction(QgsApplication.getThemeIcon("/mActionRefresh.svg"), self.tr("&Refresh"),
+                                                   self.refreshActionSlot, QKeySequence("F5"))
+        self.actionSqlWindow = self.menuDb.addAction(QIcon(":/db_manager/actions/sql_window"), self.tr("&SQL Window"),
+                                                     self.runSqlWindow, QKeySequence("F2"))
+        self.menuDb.addSeparator()
+        self.actionClose = self.menuDb.addAction(QIcon(), self.tr("&Exit"), self.close, QKeySequence("CTRL+Q"))
+
+        # menu SCHEMA
+        sep = self.menuSchema.addSeparator()
+        sep.setObjectName("DB_Manager_SchemaMenu_placeholder")
+        sep.setVisible(False)
+
+        actionMenuSchema.setVisible(False)
+
+        # menu TABLE
+        sep = self.menuTable.addSeparator()
+        sep.setObjectName("DB_Manager_TableMenu_placeholder")
+        sep.setVisible(False)
+
+        self.actionImport = self.menuTable.addAction(QIcon(":/db_manager/actions/import"),
+                                                     QApplication.translate("DBManager", "&Import Layer/File…"),
+                                                     self.importActionSlot)
+        self.actionExport = self.menuTable.addAction(QIcon(":/db_manager/actions/export"),
+                                                     QApplication.translate("DBManager", "&Export to File…"),
+                                                     self.exportActionSlot)
+        self.menuTable.addSeparator()
+        # self.actionShowSystemTables = self.menuTable.addAction(self.tr("Show system tables/views"), self.showSystemTables)
+        # self.actionShowSystemTables.setCheckable(True)
+        # self.actionShowSystemTables.setChecked(True)
+        actionMenuTable.setVisible(False)
+
+        # add actions to the toolbar
+        self.toolBar.addAction(self.actionRefresh)
+        self.toolBar.addAction(self.actionSqlWindow)
+        self.toolBar.addSeparator()
+        self.toolBar.addAction(self.actionImport)
+        self.toolBar.addAction(self.actionExport)

+ 96 - 0
db_manager/db_manager_plugin.py

@@ -0,0 +1,96 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt
+from qgis.PyQt.QtWidgets import QAction, QApplication
+from qgis.PyQt.QtGui import QIcon
+
+from qgis.core import (
+    QgsProject,
+    QgsMapLayerType,
+    QgsDataSourceUri,
+    QgsApplication
+)
+
+from . import resources_rc  # NOQA
+
+
+class DBManagerPlugin:
+
+    def __init__(self, iface):
+        self.iface = iface
+        self.dlg = None
+
+    def initGui(self):
+        self.action = QAction(QgsApplication.getThemeIcon('dbmanager.svg'), QApplication.translate("DBManagerPlugin", "DB Manager…"),
+                              self.iface.mainWindow())
+
+        self.action.setObjectName("dbManager")
+        self.action.triggered.connect(self.run)
+        # Add toolbar button and menu item
+        if hasattr(self.iface, 'addDatabaseToolBarIcon'):
+            self.iface.addDatabaseToolBarIcon(self.action)
+        else:
+            self.iface.addToolBarIcon(self.action)
+        if hasattr(self.iface, 'addPluginToDatabaseMenu'):
+            self.iface.addPluginToDatabaseMenu(QApplication.translate("DBManagerPlugin", None), self.action)
+        else:
+            self.iface.addPluginToMenu(QApplication.translate("DBManagerPlugin", "DB Manager"), self.action)
+
+    def unload(self):
+        # Remove the plugin menu item and icon
+        if hasattr(self.iface, 'databaseMenu'):
+            self.iface.databaseMenu().removeAction(self.action)
+        else:
+            self.iface.removePluginMenu(QApplication.translate("DBManagerPlugin", "DB Manager"), self.action)
+        if hasattr(self.iface, 'removeDatabaseToolBarIcon'):
+            self.iface.removeDatabaseToolBarIcon(self.action)
+        else:
+            self.iface.removeToolBarIcon(self.action)
+
+        if self.dlg is not None:
+            self.dlg.close()
+
+    def onUpdateSqlLayer(self):
+        # Be able to update every Db layer from Postgres, Spatialite and Oracle
+        l = self.iface.activeLayer()
+        if l.dataProvider().name() in ['postgres', 'spatialite', 'oracle']:
+            self.run()
+            self.dlg.runSqlLayerWindow(l)
+        # virtual has QUrl source
+        # url = QUrl(QUrl.fromPercentEncoding(l.source()))
+        # url.queryItemValue('query')
+        # url.queryItemValue('uid')
+        # url.queryItemValue('geometry')
+
+    def run(self):
+        # keep opened only one instance
+        if self.dlg is None:
+            from .db_manager import DBManager
+
+            self.dlg = DBManager(self.iface)
+            self.dlg.destroyed.connect(self.onDestroyed)
+        self.dlg.show()
+        self.dlg.raise_()
+        self.dlg.setWindowState(self.dlg.windowState() & ~Qt.WindowMinimized)
+        self.dlg.activateWindow()
+
+    def onDestroyed(self, obj):
+        self.dlg = None

+ 660 - 0
db_manager/db_model.py

@@ -0,0 +1,660 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from functools import partial
+from qgis.PyQt.QtCore import Qt, QObject, qDebug, QByteArray, QMimeData, QDataStream, QIODevice, QFileInfo, QAbstractItemModel, QModelIndex, pyqtSignal
+from qgis.PyQt.QtWidgets import QApplication, QMessageBox
+from qgis.PyQt.QtGui import QIcon
+
+from .db_plugins import supportedDbTypes, createDbPlugin
+from .db_plugins.plugin import BaseError, Table, Database
+from .dlg_db_error import DlgDbError
+
+from qgis.core import (
+    QgsApplication,
+    QgsDataSourceUri,
+    QgsVectorLayer,
+    QgsRasterLayer,
+    QgsMimeDataUtils,
+    QgsProviderConnectionException,
+    QgsProviderRegistry,
+    QgsAbstractDatabaseProviderConnection,
+    QgsMessageLog,
+)
+
+from qgis.utils import OverrideCursor
+
+from . import resources_rc  # NOQA
+
+try:
+    from qgis.core import QgsVectorLayerExporter  # NOQA
+
+    isImportVectorAvail = True
+except:
+    isImportVectorAvail = False
+
+
+class TreeItem(QObject):
+    deleted = pyqtSignal()
+    changed = pyqtSignal()
+
+    def __init__(self, data, parent=None):
+        QObject.__init__(self, parent)
+        self.populated = False
+        self.itemData = data
+        self.childItems = []
+        if parent:
+            parent.appendChild(self)
+
+    def childRemoved(self):
+        self.itemChanged()
+
+    def itemChanged(self):
+        self.changed.emit()
+
+    def itemDeleted(self):
+        self.deleted.emit()
+
+    def populate(self):
+        self.populated = True
+        return True
+
+    def getItemData(self):
+        return self.itemData
+
+    def appendChild(self, child):
+        self.childItems.append(child)
+        child.deleted.connect(self.childRemoved)
+
+    def child(self, row):
+        return self.childItems[row]
+
+    def removeChild(self, row):
+        if row >= 0 and row < len(self.childItems):
+            self.childItems[row].itemData.deleteLater()
+            self.childItems[row].deleted.disconnect(self.childRemoved)
+            del self.childItems[row]
+
+    def childCount(self):
+        return len(self.childItems)
+
+    def columnCount(self):
+        return 1
+
+    def row(self):
+        if self.parent():
+            for row, item in enumerate(self.parent().childItems):
+                if item is self:
+                    return row
+        return 0
+
+    def data(self, column):
+        return "" if column == 0 else None
+
+    def icon(self):
+        return None
+
+    def path(self):
+        pathList = []
+        if self.parent():
+            pathList.extend(self.parent().path())
+        pathList.append(self.data(0))
+        return pathList
+
+
+class PluginItem(TreeItem):
+
+    def __init__(self, dbplugin, parent=None):
+        TreeItem.__init__(self, dbplugin, parent)
+
+    def populate(self):
+        if self.populated:
+            return True
+
+        # create items for connections
+        for c in self.getItemData().connections():
+            ConnectionItem(c, self)
+
+        self.populated = True
+        return True
+
+    def data(self, column):
+        if column == 0:
+            return self.getItemData().typeNameString()
+        return None
+
+    def icon(self):
+        return self.getItemData().icon()
+
+    def path(self):
+        return [self.getItemData().typeName()]
+
+
+class ConnectionItem(TreeItem):
+
+    def __init__(self, connection, parent=None):
+        TreeItem.__init__(self, connection, parent)
+        connection.changed.connect(self.itemChanged)
+        connection.deleted.connect(self.itemDeleted)
+
+        # load (shared) icon with first instance of table item
+        if not hasattr(ConnectionItem, 'connectedIcon'):
+            ConnectionItem.connectedIcon = QIcon(":/db_manager/icons/plugged.png")
+            ConnectionItem.disconnectedIcon = QIcon(":/db_manager/icons/unplugged.png")
+
+    def data(self, column):
+        if column == 0:
+            return self.getItemData().connectionName()
+        return None
+
+    def icon(self):
+        return self.getItemData().connectionIcon()
+
+    def populate(self):
+        if self.populated:
+            return True
+
+        connection = self.getItemData()
+        if connection.database() is None:
+            # connect to database
+            try:
+                if not connection.connect():
+                    return False
+
+            except BaseError as e:
+                DlgDbError.showError(e, None)
+                return False
+
+        database = connection.database()
+        database.changed.connect(self.itemChanged)
+        database.deleted.connect(self.itemDeleted)
+
+        schemas = database.schemas()
+        if schemas is not None:
+            for s in schemas:
+                SchemaItem(s, self)
+        else:
+            tables = database.tables()
+            for t in tables:
+                TableItem(t, self)
+
+        self.populated = True
+        return True
+
+    def isConnected(self):
+        return self.getItemData().database() is not None
+
+        # def icon(self):
+        #       return self.connectedIcon if self.isConnected() else self.disconnectedIcon
+
+
+class SchemaItem(TreeItem):
+
+    def __init__(self, schema, parent):
+        TreeItem.__init__(self, schema, parent)
+        schema.changed.connect(self.itemChanged)
+        schema.deleted.connect(self.itemDeleted)
+
+        # load (shared) icon with first instance of schema item
+        if not hasattr(SchemaItem, 'schemaIcon'):
+            SchemaItem.schemaIcon = QIcon(":/db_manager/icons/namespace.png")
+
+    def data(self, column):
+        if column == 0:
+            return self.getItemData().name
+        return None
+
+    def icon(self):
+        return self.schemaIcon
+
+    def populate(self):
+        if self.populated:
+            return True
+
+        for t in self.getItemData().tables():
+            TableItem(t, self)
+
+        self.populated = True
+        return True
+
+
+class TableItem(TreeItem):
+
+    def __init__(self, table, parent):
+        TreeItem.__init__(self, table, parent)
+        table.changed.connect(self.itemChanged)
+        table.deleted.connect(self.itemDeleted)
+        self.populate()
+
+        # load (shared) icon with first instance of table item
+        if not hasattr(TableItem, 'tableIcon'):
+            TableItem.tableIcon = QgsApplication.getThemeIcon("/mIconTableLayer.svg")
+            TableItem.viewIcon = QIcon(":/db_manager/icons/view.png")
+            TableItem.viewMaterializedIcon = QIcon(":/db_manager/icons/view_materialized.png")
+            TableItem.layerPointIcon = QgsApplication.getThemeIcon("/mIconPointLayer.svg")
+            TableItem.layerLineIcon = QgsApplication.getThemeIcon("/mIconLineLayer.svg")
+            TableItem.layerPolygonIcon = QgsApplication.getThemeIcon("/mIconPolygonLayer.svg")
+            TableItem.layerRasterIcon = QgsApplication.getThemeIcon("/mIconRasterLayer.svg")
+            TableItem.layerUnknownIcon = QIcon(":/db_manager/icons/layer_unknown.png")
+
+    def data(self, column):
+        if column == 0:
+            return self.getItemData().name
+        elif column == 1:
+            if self.getItemData().type == Table.VectorType:
+                return self.getItemData().geomType
+        return None
+
+    def icon(self):
+        if self.getItemData().type == Table.VectorType:
+            geom_type = self.getItemData().geomType
+            if geom_type is not None:
+                if geom_type.find('POINT') != -1:
+                    return self.layerPointIcon
+                elif geom_type.find('LINESTRING') != -1 or geom_type in ('CIRCULARSTRING', 'COMPOUNDCURVE', 'MULTICURVE'):
+                    return self.layerLineIcon
+                elif geom_type.find('POLYGON') != -1 or geom_type == 'MULTISURFACE':
+                    return self.layerPolygonIcon
+                return self.layerUnknownIcon
+
+        elif self.getItemData().type == Table.RasterType:
+            return self.layerRasterIcon
+
+        if self.getItemData().isView:
+            if hasattr(self.getItemData(), '_relationType') and self.getItemData()._relationType == 'm':
+                return self.viewMaterializedIcon
+            else:
+                return self.viewIcon
+        return self.tableIcon
+
+    def path(self):
+        pathList = []
+        if self.parent():
+            pathList.extend(self.parent().path())
+
+        if self.getItemData().type == Table.VectorType:
+            pathList.append("%s::%s" % (self.data(0), self.getItemData().geomColumn))
+        else:
+            pathList.append(self.data(0))
+
+        return pathList
+
+
+class DBModel(QAbstractItemModel):
+    importVector = pyqtSignal(QgsVectorLayer, Database, QgsDataSourceUri, QModelIndex)
+    notPopulated = pyqtSignal(QModelIndex)
+
+    def __init__(self, parent=None):
+        global isImportVectorAvail
+
+        QAbstractItemModel.__init__(self, parent)
+        self.treeView = parent
+        self.header = [self.tr('Databases')]
+
+        if isImportVectorAvail:
+            self.importVector.connect(self.vectorImport)
+
+        self.hasSpatialiteSupport = "spatialite" in supportedDbTypes()
+        self.hasGPKGSupport = "gpkg" in supportedDbTypes()
+
+        self.rootItem = TreeItem(None, None)
+        for dbtype in supportedDbTypes():
+            dbpluginclass = createDbPlugin(dbtype)
+            item = PluginItem(dbpluginclass, self.rootItem)
+            item.changed.connect(partial(self.refreshItem, item))
+
+    def refreshItem(self, item):
+        if isinstance(item, TreeItem):
+            # find the index for the tree item using the path
+            index = self._rPath2Index(item.path())
+        else:
+            # find the index for the db item
+            index = self._rItem2Index(item)
+        if index.isValid():
+            self._refreshIndex(index)
+        else:
+            qDebug("invalid index")
+
+    def _rItem2Index(self, item, parent=None):
+        if parent is None:
+            parent = QModelIndex()
+        if item == self.getItem(parent):
+            return parent
+
+        if not parent.isValid() or parent.internalPointer().populated:
+            for i in range(self.rowCount(parent)):
+                index = self.index(i, 0, parent)
+                index = self._rItem2Index(item, index)
+                if index.isValid():
+                    return index
+
+        return QModelIndex()
+
+    def _rPath2Index(self, path, parent=None, n=0):
+        if parent is None:
+            parent = QModelIndex()
+        if path is None or len(path) == 0:
+            return parent
+
+        for i in range(self.rowCount(parent)):
+            index = self.index(i, 0, parent)
+            if self._getPath(index)[n] == path[0]:
+                return self._rPath2Index(path[1:], index, n + 1)
+
+        return parent
+
+    def getItem(self, index):
+        if not index.isValid():
+            return None
+        return index.internalPointer().getItemData()
+
+    def _getPath(self, index):
+        if not index.isValid():
+            return None
+        return index.internalPointer().path()
+
+    def columnCount(self, parent):
+        return 1
+
+    def data(self, index, role):
+        if not index.isValid():
+            return None
+
+        if role == Qt.DecorationRole and index.column() == 0:
+            icon = index.internalPointer().icon()
+            if icon:
+                return icon
+
+        if role != Qt.DisplayRole and role != Qt.EditRole:
+            return None
+
+        retval = index.internalPointer().data(index.column())
+        return retval
+
+    def flags(self, index):
+        global isImportVectorAvail
+
+        if not index.isValid():
+            return Qt.NoItemFlags
+
+        flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
+
+        if index.column() == 0:
+            item = index.internalPointer()
+
+            if isinstance(item, SchemaItem) or isinstance(item, TableItem):
+                flags |= Qt.ItemIsEditable
+
+            if isinstance(item, TableItem):
+                flags |= Qt.ItemIsDragEnabled
+
+            # vectors/tables can be dropped on connected databases to be imported
+            if isImportVectorAvail:
+                if isinstance(item, ConnectionItem) and item.populated:
+                    flags |= Qt.ItemIsDropEnabled
+
+                if isinstance(item, (SchemaItem, TableItem)):
+                    flags |= Qt.ItemIsDropEnabled
+
+            # SL/Geopackage db files can be dropped everywhere in the tree
+            if self.hasSpatialiteSupport or self.hasGPKGSupport:
+                flags |= Qt.ItemIsDropEnabled
+
+        return flags
+
+    def headerData(self, section, orientation, role):
+        if orientation == Qt.Horizontal and role == Qt.DisplayRole and section < len(self.header):
+            return self.header[section]
+        return None
+
+    def index(self, row, column, parent):
+        if not self.hasIndex(row, column, parent):
+            return QModelIndex()
+
+        parentItem = parent.internalPointer() if parent.isValid() else self.rootItem
+        childItem = parentItem.child(row)
+        if childItem:
+            return self.createIndex(row, column, childItem)
+        return QModelIndex()
+
+    def parent(self, index):
+        if not index.isValid():
+            return QModelIndex()
+
+        childItem = index.internalPointer()
+        parentItem = childItem.parent()
+
+        if parentItem == self.rootItem:
+            return QModelIndex()
+
+        return self.createIndex(parentItem.row(), 0, parentItem)
+
+    def rowCount(self, parent):
+        parentItem = parent.internalPointer() if parent.isValid() else self.rootItem
+        if not parentItem.populated:
+            self._refreshIndex(parent, True)
+        return parentItem.childCount()
+
+    def hasChildren(self, parent):
+        parentItem = parent.internalPointer() if parent.isValid() else self.rootItem
+        return parentItem.childCount() > 0 or not parentItem.populated
+
+    def setData(self, index, value, role):
+        if role != Qt.EditRole or index.column() != 0:
+            return False
+
+        item = index.internalPointer()
+        new_value = str(value)
+
+        if isinstance(item, SchemaItem) or isinstance(item, TableItem):
+            obj = item.getItemData()
+
+            # rename schema or table or view
+            if new_value == obj.name:
+                return False
+
+            with OverrideCursor(Qt.WaitCursor):
+                try:
+                    obj.rename(new_value)
+                    self._onDataChanged(index)
+                except BaseError as e:
+                    DlgDbError.showError(e, self.treeView)
+                    return False
+                else:
+                    return True
+
+        return False
+
+    def removeRows(self, row, count, parent):
+        self.beginRemoveRows(parent, row, count + row - 1)
+        item = parent.internalPointer()
+        for i in range(row, count + row):
+            item.removeChild(row)
+        self.endRemoveRows()
+
+    def _refreshIndex(self, index, force=False):
+        with OverrideCursor(Qt.WaitCursor):
+            try:
+                item = index.internalPointer() if index.isValid() else self.rootItem
+                prevPopulated = item.populated
+                if prevPopulated:
+                    self.removeRows(0, self.rowCount(index), index)
+                    item.populated = False
+                if prevPopulated or force:
+                    if item.populate():
+                        for child in item.childItems:
+                            child.changed.connect(partial(self.refreshItem, child))
+                        self._onDataChanged(index)
+                    else:
+                        self.notPopulated.emit(index)
+
+            except BaseError:
+                item.populated = False
+
+    def _onDataChanged(self, indexFrom, indexTo=None):
+        if indexTo is None:
+            indexTo = indexFrom
+        self.dataChanged.emit(indexFrom, indexTo)
+
+    QGIS_URI_MIME = "application/x-vnd.qgis.qgis.uri"
+
+    def mimeTypes(self):
+        return ["text/uri-list", self.QGIS_URI_MIME]
+
+    def mimeData(self, indexes):
+        mimeData = QMimeData()
+        encodedData = QByteArray()
+
+        stream = QDataStream(encodedData, QIODevice.WriteOnly)
+
+        for index in indexes:
+            if not index.isValid():
+                continue
+            if not isinstance(index.internalPointer(), TableItem):
+                continue
+            table = self.getItem(index)
+            stream.writeQString(table.mimeUri())
+
+        mimeData.setData(self.QGIS_URI_MIME, encodedData)
+        return mimeData
+
+    def dropMimeData(self, data, action, row, column, parent):
+        global isImportVectorAvail
+
+        if action == Qt.IgnoreAction:
+            return True
+
+        # vectors/tables to be imported must be dropped on connected db, schema or table
+        canImportLayer = isImportVectorAvail and parent.isValid() and \
+            (isinstance(parent.internalPointer(), (SchemaItem, TableItem)) or
+             (isinstance(parent.internalPointer(), ConnectionItem) and parent.internalPointer().populated))
+
+        added = 0
+
+        if data.hasUrls():
+            for u in data.urls():
+                filename = u.toLocalFile()
+                if filename == "":
+                    continue
+
+                if self.hasSpatialiteSupport:
+                    from .db_plugins.spatialite.connector import SpatiaLiteDBConnector
+
+                    if SpatiaLiteDBConnector.isValidDatabase(filename):
+                        # retrieve the SL plugin tree item using its path
+                        index = self._rPath2Index(["spatialite"])
+                        if not index.isValid():
+                            continue
+                        item = index.internalPointer()
+
+                        conn_name = QFileInfo(filename).fileName()
+                        uri = QgsDataSourceUri()
+                        uri.setDatabase(filename)
+                        item.getItemData().addConnection(conn_name, uri)
+                        item.changed.emit()
+                        added += 1
+                        continue
+
+                if canImportLayer:
+                    if QgsRasterLayer.isValidRasterFileName(filename):
+                        layerType = 'raster'
+                        providerKey = 'gdal'
+                    else:
+                        layerType = 'vector'
+                        providerKey = 'ogr'
+
+                    layerName = QFileInfo(filename).completeBaseName()
+                    if self.importLayer(layerType, providerKey, layerName, filename, parent):
+                        added += 1
+
+        if data.hasFormat(self.QGIS_URI_MIME):
+            for uri in QgsMimeDataUtils.decodeUriList(data):
+                if canImportLayer:
+                    if self.importLayer(uri.layerType, uri.providerKey, uri.name, uri.uri, parent):
+                        added += 1
+
+        return added > 0
+
+    def importLayer(self, layerType, providerKey, layerName, uriString, parent):
+        global isImportVectorAvail
+
+        if not isImportVectorAvail:
+            return False
+
+        if layerType == 'raster':
+            return False  # not implemented yet
+            inLayer = QgsRasterLayer(uriString, layerName, providerKey)
+        else:
+            inLayer = QgsVectorLayer(uriString, layerName, providerKey)
+
+        if not inLayer.isValid():
+            # invalid layer
+            QMessageBox.warning(None, self.tr("Invalid layer"), self.tr("Unable to load the layer {0}").format(inLayer.name()))
+            return False
+
+        # retrieve information about the new table's db and schema
+        outItem = parent.internalPointer()
+        outObj = outItem.getItemData()
+        outDb = outObj.database()
+        outSchema = None
+        if isinstance(outItem, SchemaItem):
+            outSchema = outObj
+        elif isinstance(outItem, TableItem):
+            outSchema = outObj.schema()
+
+        # toIndex will point to the parent item of the new table
+        toIndex = parent
+        if isinstance(toIndex.internalPointer(), TableItem):
+            toIndex = toIndex.parent()
+
+        if inLayer.type() == inLayer.VectorLayer:
+            # create the output uri
+            schema = outSchema.name if outDb.schemas() is not None and outSchema is not None else ""
+            pkCol = geomCol = ""
+
+            # default pk and geom field name value
+            if providerKey in ['postgres', 'spatialite']:
+                inUri = QgsDataSourceUri(inLayer.source())
+                pkCol = inUri.keyColumn()
+                geomCol = inUri.geometryColumn()
+
+            outUri = outDb.uri()
+            outUri.setDataSource(schema, layerName, geomCol, "", pkCol)
+
+            self.importVector.emit(inLayer, outDb, outUri, toIndex)
+            return True
+
+        return False
+
+    def vectorImport(self, inLayer, outDb, outUri, parent):
+        global isImportVectorAvail
+
+        if not isImportVectorAvail:
+            return False
+
+        try:
+            from .dlg_import_vector import DlgImportVector
+
+            dlg = DlgImportVector(inLayer, outDb, outUri)
+            QApplication.restoreOverrideCursor()
+            if dlg.exec_():
+                self._refreshIndex(parent)
+        finally:
+            inLayer.deleteLater()

+ 73 - 0
db_manager/db_plugins/__init__.py

@@ -0,0 +1,73 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+
+class NotSupportedDbType(Exception):
+
+    def __init__(self, dbtype):
+        from qgis.PyQt.QtWidgets import QApplication
+        self.msg = QApplication.translate("DBManagerPlugin", "{0} is not supported yet").format(dbtype)
+        Exception(self, self.msg)
+
+    def __str__(self):
+        return self.msg.encode('utf-8')
+
+
+def initDbPluginList():
+    import os
+
+    current_dir = os.path.dirname(__file__)
+    for name in os.listdir(current_dir):
+        if name == '__pycache__':
+            continue
+        if not os.path.isdir(os.path.join(current_dir, name)):
+            continue
+
+        try:
+            exec("from .%s import plugin as mod" % name, globals())
+        except ImportError as e:
+            DBPLUGIN_ERRORS.append("%s: %s" % (name, str(e)))
+            continue
+
+        pluginclass = mod.classFactory()  # NOQA
+        SUPPORTED_DBTYPES[pluginclass.typeName()] = pluginclass
+
+    return len(SUPPORTED_DBTYPES) > 0
+
+
+def supportedDbTypes():
+    return sorted(SUPPORTED_DBTYPES.keys())
+
+
+def getDbPluginErrors():
+    return DBPLUGIN_ERRORS
+
+
+def createDbPlugin(dbtype, conn_name=None):
+    if dbtype not in SUPPORTED_DBTYPES:
+        raise NotSupportedDbType(dbtype)
+    dbplugin = SUPPORTED_DBTYPES[dbtype]
+    return dbplugin if conn_name is None else dbplugin(conn_name)
+
+
+# initialize the plugin list
+SUPPORTED_DBTYPES = {}
+DBPLUGIN_ERRORS = []
+initDbPluginList()

+ 241 - 0
db_manager/db_plugins/connector.py

@@ -0,0 +1,241 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.core import QgsDataSourceUri
+
+from .plugin import DbError, ConnectionError
+
+
+class DBConnector:
+
+    def __init__(self, uri):
+        """Creates a new DB connector
+        """
+
+        self.connection = None
+        self._uri = uri
+
+    def __del__(self):
+        pass  # print "DBConnector.__del__", self._uri.connectionInfo()
+        if self.connection is not None:
+            self.connection.close()
+        self.connection = None
+
+    def uri(self):
+        return QgsDataSourceUri(self._uri.uri(False))
+
+    def cancel(self):
+        pass
+
+    def publicUri(self):
+        publicUri = QgsDataSourceUri.removePassword(self._uri.uri(False))
+        return QgsDataSourceUri(publicUri)
+
+    def hasSpatialSupport(self):
+        return False
+
+    def canAddGeometryColumn(self, table):
+        return self.hasSpatialSupport()
+
+    def canAddSpatialIndex(self, table):
+        return self.hasSpatialSupport()
+
+    def hasRasterSupport(self):
+        return False
+
+    def hasCustomQuerySupport(self):
+        return False
+
+    def hasTableColumnEditingSupport(self):
+        return False
+
+    def hasCreateSpatialViewSupport(self):
+        return False
+
+    def execution_error_types(self):
+        raise Exception("DBConnector.execution_error_types() is an abstract method")
+
+    def connection_error_types(self):
+        raise Exception("DBConnector.connection_error_types() is an abstract method")
+
+    def error_types(self):
+        return self.connection_error_types() + self.execution_error_types()
+
+    def _execute(self, cursor, sql):
+        if cursor is None:
+            cursor = self._get_cursor()
+        try:
+            cursor.execute(sql)
+
+        except self.connection_error_types() as e:
+            raise ConnectionError(e)
+
+        except self.execution_error_types() as e:
+            # do the rollback to avoid a "current transaction aborted, commands ignored" errors
+            self._rollback()
+            raise DbError(e, sql)
+
+        return cursor
+
+    def _execute_and_commit(self, sql):
+        """ tries to execute and commit some action, on error it rolls back the change """
+        self._execute(None, sql)
+        self._commit()
+
+    def _get_cursor(self, name=None):
+        try:
+            if name is not None:
+                name = str(name).encode('ascii', 'replace').replace('?', "_")
+                self._last_cursor_named_id = 0 if not hasattr(self,
+                                                              '_last_cursor_named_id') else self._last_cursor_named_id + 1
+                return self.connection.cursor("%s_%d" % (name, self._last_cursor_named_id))
+
+            return self.connection.cursor()
+
+        except self.connection_error_types() as e:
+            raise ConnectionError(e)
+
+        except self.execution_error_types() as e:
+            # do the rollback to avoid a "current transaction aborted, commands ignored" errors
+            self._rollback()
+            raise DbError(e)
+
+    def _close_cursor(self, c):
+        try:
+            if c and not c.closed:
+                c.close()
+
+        except self.error_types():
+            pass
+
+        return
+
+    def _fetchall(self, c):
+        try:
+            return c.fetchall()
+
+        except self.connection_error_types() as e:
+            raise ConnectionError(e)
+
+        except self.execution_error_types() as e:
+            # do the rollback to avoid a "current transaction aborted, commands ignored" errors
+            self._rollback()
+            raise DbError(e)
+
+    def _fetchone(self, c):
+        try:
+            return c.fetchone()
+
+        except self.connection_error_types() as e:
+            raise ConnectionError(e)
+
+        except self.execution_error_types() as e:
+            # do the rollback to avoid a "current transaction aborted, commands ignored" errors
+            self._rollback()
+            raise DbError(e)
+
+    def _commit(self):
+        try:
+            self.connection.commit()
+
+        except self.connection_error_types() as e:
+            raise ConnectionError(e)
+
+        except self.execution_error_types() as e:
+            # do the rollback to avoid a "current transaction aborted, commands ignored" errors
+            self._rollback()
+            raise DbError(e)
+
+    def _rollback(self):
+        try:
+            self.connection.rollback()
+
+        except self.connection_error_types() as e:
+            raise ConnectionError(e)
+
+        except self.execution_error_types() as e:
+            raise DbError(e)
+
+    def _get_cursor_columns(self, c):
+        try:
+            if c.description:
+                return [x[0] for x in c.description]
+
+        except self.connection_error_types() + self.execution_error_types():
+            return []
+
+    @classmethod
+    def quoteId(self, identifier):
+        if hasattr(identifier, '__iter__') and not isinstance(identifier, str):
+            return '.'.join(
+                self.quoteId(i)
+                for i in identifier
+                if i is not None and i != ""
+            )
+
+        identifier = str(
+            identifier) if identifier is not None else ''  # make sure it's python unicode string
+        return '"%s"' % identifier.replace('"', '""')
+
+    @classmethod
+    def quoteString(self, txt):
+        """ make the string safe - replace ' with '' """
+        if hasattr(txt, '__iter__') and not isinstance(txt, str):
+            return '.'.join(
+                self.quoteString(i)
+                for i in txt
+                if i is not None
+            )
+
+        txt = str(txt) if txt is not None else ''  # make sure it's python unicode string
+        return "'%s'" % txt.replace("'", "''")
+
+    @classmethod
+    def getSchemaTableName(self, table):
+        if not hasattr(table, '__iter__') and not isinstance(table, str):
+            return (None, table)
+        if isinstance(table, str):
+            table = table.split('.')
+        if len(table) < 2:
+            return (None, table[0])
+        else:
+            return (table[0], table[1])
+
+    @classmethod
+    def getSqlDictionary(self):
+        """ return a generic SQL dictionary """
+        try:
+            from ..sql_dictionary import getSqlDictionary
+
+            return getSqlDictionary()
+        except ImportError:
+            return []
+
+    def getComment(self, tablename, field):
+        """Returns the comment for a field"""
+        return ''
+
+    def commentTable(self, schema, tablename, comment=None):
+        """Comment the table"""
+        pass
+
+    def getQueryBuilderDictionary(self):
+
+        return {}

+ 378 - 0
db_manager/db_plugins/data_model.py

@@ -0,0 +1,378 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import (Qt,
+                              QTime,
+                              QRegExp,
+                              QAbstractTableModel,
+                              pyqtSignal,
+                              QObject)
+from qgis.PyQt.QtGui import (QFont,
+                             QStandardItemModel,
+                             QStandardItem)
+from qgis.PyQt.QtWidgets import QApplication
+
+from qgis.core import QgsTask
+
+from .plugin import DbError, BaseError
+
+
+class BaseTableModel(QAbstractTableModel):
+
+    def __init__(self, header=None, data=None, parent=None):
+        QAbstractTableModel.__init__(self, parent)
+        self._header = header if header else []
+        self.resdata = data if data else []
+
+    def headerToString(self, sep="\t"):
+        header = self._header
+        return sep.join(header)
+
+    def rowToString(self, row, sep="\t"):
+        return sep.join(
+            str(self.getData(row, col))
+            for col in range(self.columnCount())
+        )
+
+    def getData(self, row, col):
+        return self.resdata[row][col]
+
+    def columnNames(self):
+        return list(self._header)
+
+    def rowCount(self, parent=None):
+        return len(self.resdata)
+
+    def columnCount(self, parent=None):
+        return len(self._header)
+
+    def data(self, index, role):
+        if role not in [Qt.DisplayRole,
+                        Qt.EditRole,
+                        Qt.FontRole]:
+            return None
+
+        val = self.getData(index.row(), index.column())
+
+        if role == Qt.EditRole:
+            return val
+
+        if role == Qt.FontRole:  # draw NULL in italic
+            if val is not None:
+                return None
+            f = QFont()
+            f.setItalic(True)
+            return f
+
+        if val is None:
+            return "NULL"
+        elif isinstance(val, memoryview):
+            # hide binary data
+            return None
+        elif isinstance(val, str) and len(val) > 300:
+            # too much data to display, elide the string
+            val = val[:300]
+        try:
+            return str(val)  # convert to Unicode
+        except UnicodeDecodeError:
+            return str(val, 'utf-8', 'replace')  # convert from utf8 and replace errors (if any)
+
+    def headerData(self, section, orientation, role):
+        if role != Qt.DisplayRole:
+            return None
+
+        if orientation == Qt.Vertical:
+            # header for a row
+            return section + 1
+        else:
+            # header for a column
+            return self._header[section]
+
+
+class TableDataModel(BaseTableModel):
+
+    def __init__(self, table, parent=None):
+        self.db = table.database().connector
+        self.table = table
+
+        fieldNames = [x.name for x in table.fields()]
+        BaseTableModel.__init__(self, fieldNames, None, parent)
+
+        # get table fields
+        self.fields = []
+        for fld in table.fields():
+            self.fields.append(self._sanitizeTableField(fld))
+
+        self.fetchedCount = 201
+        self.fetchedFrom = -self.fetchedCount - 1  # so the first call to getData will exec fetchMoreData(0)
+
+    def _sanitizeTableField(self, field):
+        """ quote column names to avoid some problems (e.g. columns with upper case) """
+        return self.db.quoteId(field)
+
+    def getData(self, row, col):
+        if row < self.fetchedFrom or row >= self.fetchedFrom + self.fetchedCount:
+            margin = self.fetchedCount / 2
+            start = int(self.rowCount() - margin if row + margin >= self.rowCount() else row - margin)
+            if start < 0:
+                start = 0
+            self.fetchMoreData(start)
+        return self.resdata[row - self.fetchedFrom][col]
+
+    def fetchMoreData(self, row_start):
+        pass
+
+    def rowCount(self, index=None):
+        # case for tables with no columns ... any reason to use them? :-)
+        return self.table.rowCount if self.table.rowCount is not None and self.columnCount(index) > 0 else 0
+
+
+class SqlResultModelAsync(QObject):
+    done = pyqtSignal()
+
+    def __init__(self):
+        super().__init__()
+        self.error = BaseError('')
+        self.status = None
+        self.model = None
+        self.task = None
+        self.canceled = False
+
+    def cancel(self):
+        self.canceled = True
+        if self.task:
+            self.task.cancel()
+
+    def modelDone(self):
+        if self.task:
+            self.status = self.task.status
+            self.model = self.task.model
+            self.error = self.task.error
+
+        self.done.emit()
+
+
+class SqlResultModelTask(QgsTask):
+
+    def __init__(self, db, sql, parent):
+        super().__init__(description=QApplication.translate("DBManagerPlugin", "Executing SQL"))
+        self.db = db
+        self.sql = sql
+        self.parent = parent
+        self.error = BaseError('')
+        self.model = None
+
+
+class SqlResultModel(BaseTableModel):
+
+    def __init__(self, db, sql, parent=None):
+        self.db = db.connector
+
+        t = QTime()
+        t.start()
+        c = self.db._execute(None, sql)
+
+        self._affectedRows = 0
+        data = []
+        header = self.db._get_cursor_columns(c)
+        if header is None:
+            header = []
+
+        try:
+            if len(header) > 0:
+                data = self.db._fetchall(c)
+            self._affectedRows = len(data)
+        except DbError:
+            # nothing to fetch!
+            data = []
+            header = []
+
+        super().__init__(header, data, parent)
+
+        # commit before closing the cursor to make sure that the changes are stored
+        self.db._commit()
+        c.close()
+        self._secs = t.elapsed() / 1000.0
+        del c
+        del t
+
+    def secs(self):
+        return self._secs
+
+    def affectedRows(self):
+        return self._affectedRows
+
+
+class SimpleTableModel(QStandardItemModel):
+
+    def __init__(self, header, editable=False, parent=None):
+        self.header = header
+        self.editable = editable
+        QStandardItemModel.__init__(self, 0, len(self.header), parent)
+
+    def rowFromData(self, data):
+        row = []
+        for c in data:
+            item = QStandardItem(str(c))
+            item.setFlags((item.flags() | Qt.ItemIsEditable) if self.editable else (item.flags() & ~Qt.ItemIsEditable))
+            row.append(item)
+        return row
+
+    def headerData(self, section, orientation, role):
+        if orientation == Qt.Horizontal and role == Qt.DisplayRole:
+            return self.header[section]
+        return None
+
+    def _getNewObject(self):
+        pass
+
+    def getObject(self, row):
+        return self._getNewObject()
+
+    def getObjectIter(self):
+        for row in range(self.rowCount()):
+            yield self.getObject(row)
+
+
+class TableFieldsModel(SimpleTableModel):
+
+    def __init__(self, parent, editable=False):
+        SimpleTableModel.__init__(self, ['Name', 'Type', 'Null', 'Default', 'Comment'], editable, parent)
+
+    def headerData(self, section, orientation, role):
+        if orientation == Qt.Vertical and role == Qt.DisplayRole:
+            return section + 1
+        return SimpleTableModel.headerData(self, section, orientation, role)
+
+    def flags(self, index):
+        flags = SimpleTableModel.flags(self, index)
+        if index.column() == 2 and flags & Qt.ItemIsEditable:  # set Null column as checkable instead of editable
+            flags = flags & ~Qt.ItemIsEditable | Qt.ItemIsUserCheckable
+        return flags
+
+    def append(self, fld):
+        data = [fld.name, fld.type2String(), not fld.notNull, fld.default2String(), fld.getComment()]
+        self.appendRow(self.rowFromData(data))
+        row = self.rowCount() - 1
+        self.setData(self.index(row, 0), fld, Qt.UserRole)
+        self.setData(self.index(row, 1), fld.primaryKey, Qt.UserRole)
+        self.setData(self.index(row, 2), None, Qt.DisplayRole)
+        self.setData(self.index(row, 2), Qt.Unchecked if fld.notNull else Qt.Checked, Qt.CheckStateRole)
+
+    def _getNewObject(self):
+        from .plugin import TableField
+
+        return TableField(None)
+
+    def getObject(self, row):
+        val = self.data(self.index(row, 0), Qt.UserRole)
+        fld = val if val is not None else self._getNewObject()
+        fld.name = self.data(self.index(row, 0)) or ""
+        typestr = self.data(self.index(row, 1)) or ""
+        regex = QRegExp("([^\\(]+)\\(([^\\)]+)\\)")
+        startpos = regex.indexIn(typestr)
+        if startpos >= 0:
+            fld.dataType = regex.cap(1).strip()
+            fld.modifier = regex.cap(2).strip()
+        else:
+            fld.modifier = None
+            fld.dataType = typestr
+
+        fld.notNull = self.data(self.index(row, 2), Qt.CheckStateRole) == Qt.Unchecked
+        fld.primaryKey = self.data(self.index(row, 1), Qt.UserRole)
+        fld.comment = self.data(self.index(row, 4))
+        return fld
+
+    def getFields(self):
+        return [
+            fld
+            for fld in self.getObjectIter()
+        ]
+
+
+class TableConstraintsModel(SimpleTableModel):
+
+    def __init__(self, parent, editable=False):
+        SimpleTableModel.__init__(self, [QApplication.translate("DBManagerPlugin", 'Name'),
+                                         QApplication.translate("DBManagerPlugin", 'Type'),
+                                         QApplication.translate("DBManagerPlugin", 'Column(s)')], editable, parent)
+
+    def append(self, constr):
+        field_names = [str(k_v[1].name) for k_v in iter(list(constr.fields().items()))]
+        data = [constr.name, constr.type2String(), ", ".join(field_names)]
+        self.appendRow(self.rowFromData(data))
+        row = self.rowCount() - 1
+        self.setData(self.index(row, 0), constr, Qt.UserRole)
+        self.setData(self.index(row, 1), constr.type, Qt.UserRole)
+        self.setData(self.index(row, 2), constr.columns, Qt.UserRole)
+
+    def _getNewObject(self):
+        from .plugin import TableConstraint
+
+        return TableConstraint(None)
+
+    def getObject(self, row):
+        constr = self.data(self.index(row, 0), Qt.UserRole)
+        if not constr:
+            constr = self._getNewObject()
+        constr.name = self.data(self.index(row, 0)) or ""
+        constr.type = self.data(self.index(row, 1), Qt.UserRole)
+        constr.columns = self.data(self.index(row, 2), Qt.UserRole)
+        return constr
+
+    def getConstraints(self):
+        return [
+            constr
+            for constr in self.getObjectIter()
+        ]
+
+
+class TableIndexesModel(SimpleTableModel):
+
+    def __init__(self, parent, editable=False):
+        SimpleTableModel.__init__(self, [QApplication.translate("DBManagerPlugin", 'Name'),
+                                         QApplication.translate("DBManagerPlugin", 'Column(s)')], editable, parent)
+
+    def append(self, idx):
+        field_names = [str(k_v1[1].name) for k_v1 in iter(list(idx.fields().items()))]
+        data = [idx.name, ", ".join(field_names)]
+        self.appendRow(self.rowFromData(data))
+        row = self.rowCount() - 1
+        self.setData(self.index(row, 0), idx, Qt.UserRole)
+        self.setData(self.index(row, 1), idx.columns, Qt.UserRole)
+
+    def _getNewObject(self):
+        from .plugin import TableIndex
+
+        return TableIndex(None)
+
+    def getObject(self, row):
+        idx = self.data(self.index(row, 0), Qt.UserRole)
+        if not idx:
+            idx = self._getNewObject()
+        idx.name = self.data(self.index(row, 0))
+        idx.columns = self.data(self.index(row, 1), Qt.UserRole)
+        return idx
+
+    def getIndexes(self):
+        return [
+            idx
+            for idx in self.getObjectIter()
+        ]

+ 0 - 0
db_manager/db_plugins/gpkg/__init__.py


+ 849 - 0
db_manager/db_plugins/gpkg/connector.py

@@ -0,0 +1,849 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : Oct 14 2016
+copyright            : (C) 2016 by Even Rouault
+                       (C) 2011 by Giuseppe Sucameli
+email                : even.rouault at spatialys.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from functools import cmp_to_key
+
+from qgis.PyQt.QtWidgets import QApplication
+from qgis.PyQt.QtCore import QThread
+
+from ..connector import DBConnector
+from ..plugin import ConnectionError, DbError, Table
+
+from qgis.utils import spatialite_connect
+from qgis.core import (
+    QgsApplication,
+    QgsProviderRegistry,
+    QgsAbstractDatabaseProviderConnection,
+    QgsProviderConnectionException,
+    QgsWkbTypes,
+)
+
+import sqlite3
+
+from osgeo import gdal, ogr, osr
+
+
+def classFactory():
+    return GPKGDBConnector
+
+
+class GPKGDBConnector(DBConnector):
+
+    def __init__(self, uri, connection):
+        """Creates a new GPKG connector
+
+        :param uri: data source URI
+        :type uri: QgsDataSourceUri
+        :param connection: the GPKGDBPlugin parent instance
+        :type connection: GPKGDBPlugin
+        """
+
+        DBConnector.__init__(self, uri)
+        self.dbname = uri.database()
+        self.connection = connection
+        self._current_thread = None
+        md = QgsProviderRegistry.instance().providerMetadata(connection.providerName())
+        # QgsAbstractDatabaseProviderConnection instance
+        self.core_connection = md.findConnection(connection.connectionName())
+        if self.core_connection is None:
+            self.core_connection = md.createConnection(uri.uri(), {})
+        self.has_raster = False
+        self.mapSridToName = {}
+        # To be removed when migration to new API is completed
+        self._opendb()
+
+    def _opendb(self):
+
+        # Keep this explicit assignment to None to make sure the file is
+        # properly closed before being re-opened
+        self.gdal_ds = None
+        self.gdal_ds = gdal.OpenEx(self.dbname, gdal.OF_UPDATE)
+        if self.gdal_ds is None:
+            self.gdal_ds = gdal.OpenEx(self.dbname)
+        if self.gdal_ds is None:
+            raise ConnectionError(QApplication.translate("DBManagerPlugin", '"{0}" not found').format(self.dbname))
+        if self.gdal_ds.GetDriver().ShortName != 'GPKG':
+            raise ConnectionError(QApplication.translate("DBManagerPlugin", '"{dbname}" not recognized as GPKG ({shortname} reported instead.)').format(dbname=self.dbname, shortname=self.gdal_ds.GetDriver().ShortName))
+        self.has_raster = self.gdal_ds.RasterCount != 0 or self.gdal_ds.GetMetadata('SUBDATASETS') is not None
+        self.connection = None
+        self._current_thread = None
+
+    @property
+    def connection(self):
+        """Creates and returns a spatialite connection, if
+        the existing connection was created in another thread
+        invalidates it and create a new one.
+        """
+
+        if self._connection is None or self._current_thread != int(QThread.currentThreadId()):
+            self._current_thread = int(QThread.currentThreadId())
+            try:
+                self._connection = spatialite_connect(str(self.dbname))
+            except self.connection_error_types() as e:
+                raise ConnectionError(e)
+        return self._connection
+
+    @connection.setter
+    def connection(self, conn):
+        self._connection = conn
+
+    def unquoteId(self, quotedId):
+        if len(quotedId) <= 2 or quotedId[0] != '"' or quotedId[len(quotedId) - 1] != '"':
+            return quotedId
+        unquoted = ''
+        i = 1
+        while i < len(quotedId) - 1:
+            if quotedId[i] == '"' and quotedId[i + 1] == '"':
+                unquoted += '"'
+                i += 2
+            else:
+                unquoted += quotedId[i]
+                i += 1
+        return unquoted
+
+    def _fetchOne(self, sql):
+
+        return self.core_connection.executeSql(sql)
+
+    def _fetchAll(self, sql, include_fid_and_geometry=False):
+
+        return self.core_connection.executeSql(sql)
+
+    def _fetchAllFromLayer(self, table):
+
+        lyr = self.gdal_ds.GetLayerByName(table.name)
+        if lyr is None:
+            return []
+
+        lyr.ResetReading()
+        ret = []
+        while True:
+            f = lyr.GetNextFeature()
+            if f is None:
+                break
+            else:
+                field_vals = [f.GetFID()]
+                if lyr.GetLayerDefn().GetGeomType() != ogr.wkbNone:
+                    geom = f.GetGeometryRef()
+                    if geom is not None:
+                        geom = geom.ExportToWkt()
+                    field_vals += [geom]
+                field_vals += [f.GetField(i) for i in range(f.GetFieldCount())]
+                ret.append(field_vals)
+        return ret
+
+    def _execute_and_commit(self, sql):
+        sql_lyr = self.gdal_ds.ExecuteSQL(sql)
+        self.gdal_ds.ReleaseResultSet(sql_lyr)
+
+    def _execute(self, cursor, sql):
+
+        if self.connection is None:
+            # Needed when evaluating a SQL query
+            try:
+                self.connection = spatialite_connect(str(self.dbname))
+            except self.connection_error_types() as e:
+                raise ConnectionError(e)
+
+        return DBConnector._execute(self, cursor, sql)
+
+    def _commit(self):
+        if self.connection is None:
+            return
+
+        try:
+            self.connection.commit()
+
+        except self.connection_error_types() as e:
+            raise ConnectionError(e)
+
+        except self.execution_error_types() as e:
+            # do the rollback to avoid a "current transaction aborted, commands ignored" errors
+            self._rollback()
+            raise DbError(e)
+
+    def cancel(self):
+        if self.connection:
+            self.connection.interrupt()
+
+    @classmethod
+    def isValidDatabase(cls, path):
+        if hasattr(gdal, 'OpenEx'):
+            ds = gdal.OpenEx(path)
+            if ds is None or ds.GetDriver().ShortName != 'GPKG':
+                return False
+        else:
+            ds = ogr.Open(path)
+            if ds is None or ds.GetDriver().GetName() != 'GPKG':
+                return False
+        return True
+
+    def getInfo(self):
+        return None
+
+    def getSpatialInfo(self):
+        return None
+
+    def hasSpatialSupport(self):
+        return True
+
+    # Used by DlgTableProperties
+    def canAddGeometryColumn(self, table):
+        _, tablename = self.getSchemaTableName(table)
+        lyr = self.gdal_ds.GetLayerByName(tablename)
+        if lyr is None:
+            return False
+        return lyr.GetGeomType() == ogr.wkbNone
+
+    # Used by DlgTableProperties
+    def canAddSpatialIndex(self, table):
+        _, tablename = self.getSchemaTableName(table)
+        lyr = self.gdal_ds.GetLayerByName(tablename)
+        if lyr is None or lyr.GetGeometryColumn() == '':
+            return False
+        return not self.hasSpatialIndex(table,
+                                        lyr.GetGeometryColumn())
+
+    def hasRasterSupport(self):
+        return self.has_raster
+
+    def hasCustomQuerySupport(self):
+        return True
+
+    def hasTableColumnEditingSupport(self):
+        return True
+
+    def hasCreateSpatialViewSupport(self):
+        return False
+
+    def fieldTypes(self):
+        # From "Table 1. GeoPackage Data Types" (http://www.geopackage.org/spec/)
+        return [
+            "TEXT",
+            "MEDIUMINT",
+            "INTEGER",
+            "TINYINT",
+            "SMALLINT",
+            "DOUBLE",
+            "FLOAT"
+            "DATE",
+            "DATETIME",
+            "BOOLEAN",
+        ]
+
+    def getSchemas(self):
+        return None
+
+    def getTables(self, schema=None, add_sys_tables=False):
+        """ get list of tables """
+        items = []
+
+        try:
+            vectors = self.getVectorTables(schema)
+            for tbl in vectors:
+                items.append(tbl)
+        except DbError:
+            pass
+
+        try:
+            rasters = self.getRasterTables(schema)
+            for tbl in rasters:
+                items.append(tbl)
+        except DbError:
+            pass
+
+        for i, tbl in enumerate(items):
+            tbl.insert(3, False)  # not system table
+
+        return sorted(items, key=cmp_to_key(lambda x, y: (x[1] > y[1]) - (x[1] < y[1])))
+
+    def getVectorTables(self, schema=None):
+        """Returns a list of vector table information
+        """
+
+        items = []
+        for table in self.core_connection.tables(schema, QgsAbstractDatabaseProviderConnection.Vector | QgsAbstractDatabaseProviderConnection.Aspatial):
+            if not (table.flags() & QgsAbstractDatabaseProviderConnection.Aspatial):
+                geom_type = table.geometryColumnTypes()[0]
+                # Use integer PG code for SRID
+                srid = geom_type.crs.postgisSrid()
+                geomtype_flatten = QgsWkbTypes.flatType(geom_type.wkbType)
+                geomname = 'GEOMETRY'
+                if geomtype_flatten == QgsWkbTypes.Point:
+                    geomname = 'POINT'
+                elif geomtype_flatten == QgsWkbTypes.LineString:
+                    geomname = 'LINESTRING'
+                elif geomtype_flatten == QgsWkbTypes.Polygon:
+                    geomname = 'POLYGON'
+                elif geomtype_flatten == QgsWkbTypes.MultiPoint:
+                    geomname = 'MULTIPOINT'
+                elif geomtype_flatten == QgsWkbTypes.MultiLineString:
+                    geomname = 'MULTILINESTRING'
+                elif geomtype_flatten == QgsWkbTypes.MultiPolygon:
+                    geomname = 'MULTIPOLYGON'
+                elif geomtype_flatten == QgsWkbTypes.GeometryCollection:
+                    geomname = 'GEOMETRYCOLLECTION'
+                elif geomtype_flatten == QgsWkbTypes.CircularString:
+                    geomname = 'CIRCULARSTRING'
+                elif geomtype_flatten == QgsWkbTypes.CompoundCurve:
+                    geomname = 'COMPOUNDCURVE'
+                elif geomtype_flatten == QgsWkbTypes.CurvePolygon:
+                    geomname = 'CURVEPOLYGON'
+                elif geomtype_flatten == QgsWkbTypes.MultiCurve:
+                    geomname = 'MULTICURVE'
+                elif geomtype_flatten == QgsWkbTypes.MultiSurface:
+                    geomname = 'MULTISURFACE'
+                geomdim = 'XY'
+                if QgsWkbTypes.hasZ(geom_type.wkbType):
+                    geomdim += 'Z'
+                if QgsWkbTypes.hasM(geom_type.wkbType):
+                    geomdim += 'M'
+                item = [
+                    Table.VectorType,
+                    table.tableName(),
+                    bool(table.flags() & QgsAbstractDatabaseProviderConnection.View),  # is_view
+                    table.tableName(),
+                    table.geometryColumn(),
+                    geomname,
+                    geomdim,
+                    srid
+                ]
+                self.mapSridToName[srid] = geom_type.crs.description()
+            else:
+                item = [
+                    Table.TableType,
+                    table.tableName(),
+                    bool(table.flags() & QgsAbstractDatabaseProviderConnection.View),
+                ]
+
+            items.append(item)
+
+        return items
+
+    def getRasterTables(self, schema=None):
+        """ get list of table with a geometry column
+                it returns:
+                        name (table name)
+                        type = 'view' (is a view?)
+                        geometry_column:
+                                r.table_name (the prefix table name, use this to load the layer)
+                                r.geometry_column
+                                srid
+        """
+
+        items = []
+        for table in self.core_connection.tables(schema, QgsAbstractDatabaseProviderConnection.Raster):
+            geom_type = table.geometryColumnTypes()[0]
+            # Use integer PG code for SRID
+            srid = geom_type.crs.postgisSrid()
+            item = [
+                Table.RasterType,
+                table.tableName(),
+                bool(table.flags() & QgsAbstractDatabaseProviderConnection.View),
+                table.tableName(),
+                table.geometryColumn(),
+                srid,
+            ]
+            self.mapSridToName[srid] = geom_type.crs.description()
+            items.append(item)
+
+        return items
+
+    def getTableRowCount(self, table):
+        lyr = self.gdal_ds.GetLayerByName(self.getSchemaTableName(table)[1])
+        return lyr.GetFeatureCount() if lyr is not None else None
+
+    def getTableFields(self, table):
+        """ return list of columns in table """
+        sql = "PRAGMA table_info(%s)" % (self.quoteId(table))
+        ret = self._fetchAll(sql)
+        if ret is None:
+            ret = []
+        return ret
+
+    def getTableIndexes(self, table):
+        """ get info about table's indexes """
+        sql = "PRAGMA index_list(%s)" % (self.quoteId(table))
+        indexes = self._fetchAll(sql)
+        if indexes is None:
+            return []
+
+        for i, idx in enumerate(indexes):
+            # sqlite has changed the number of columns returned by index_list since 3.8.9
+            # I am not using self.getInfo() here because this behavior
+            # can be changed back without notice as done for index_info, see:
+            # http://repo.or.cz/sqlite.git/commit/53555d6da78e52a430b1884b5971fef33e9ccca4
+            if len(idx) == 3:
+                num, name, unique = idx
+            if len(idx) == 5:
+                num, name, unique, createdby, partial = idx
+            sql = "PRAGMA index_info(%s)" % (self.quoteId(name))
+
+            idx = [num, name, unique]
+            cols = [
+                cid
+                for seq, cid, cname in self._fetchAll(sql)
+            ]
+            idx.append(cols)
+            indexes[i] = idx
+
+        return indexes
+
+    def getTableConstraints(self, table):
+        return None
+
+    def getTableTriggers(self, table):
+
+        _, tablename = self.getSchemaTableName(table)
+        # Do not list rtree related triggers as we don't want them to be dropped
+        sql = "SELECT name, sql FROM sqlite_master WHERE tbl_name = %s AND type = 'trigger'" % (self.quoteString(tablename))
+        if self.isVectorTable(table):
+            sql += " AND name NOT LIKE 'rtree_%%'"
+        elif self.isRasterTable(table):
+            sql += " AND name NOT LIKE '%%_zoom_insert'"
+            sql += " AND name NOT LIKE '%%_zoom_update'"
+            sql += " AND name NOT LIKE '%%_tile_column_insert'"
+            sql += " AND name NOT LIKE '%%_tile_column_update'"
+            sql += " AND name NOT LIKE '%%_tile_row_insert'"
+            sql += " AND name NOT LIKE '%%_tile_row_update'"
+        return self._fetchAll(sql)
+
+    def deleteTableTrigger(self, trigger, table=None):
+        """Deletes trigger """
+        sql = "DROP TRIGGER %s" % self.quoteId(trigger)
+        self._execute_and_commit(sql)
+
+    def getTableExtent(self, table, geom, force=False):
+        """ find out table extent """
+        _, tablename = self.getSchemaTableName(table)
+
+        if self.isRasterTable(table):
+
+            md = self.gdal_ds.GetMetadata('SUBDATASETS')
+            if md is None or len(md) == 0:
+                ds = self.gdal_ds
+            else:
+                subdataset_name = 'GPKG:%s:%s' % (self.gdal_ds.GetDescription(), tablename)
+                ds = gdal.Open(subdataset_name)
+            if ds is None:
+                return None
+            gt = ds.GetGeoTransform()
+            minx = gt[0]
+            maxx = gt[0] + gt[1] * ds.RasterYSize
+            maxy = gt[3]
+            miny = gt[3] + gt[5] * ds.RasterYSize
+
+            return (minx, miny, maxx, maxy)
+
+        lyr = self.gdal_ds.GetLayerByName(tablename)
+        if lyr is None:
+            return None
+        ret = lyr.GetExtent(force=force, can_return_null=True)
+        if ret is None:
+            return None
+        minx, maxx, miny, maxy = ret
+        return (minx, miny, maxx, maxy)
+
+    def getViewDefinition(self, view):
+        """ returns definition of the view """
+        return None
+
+    def getSpatialRefInfo(self, srid):
+        if srid in self.mapSridToName:
+            return self.mapSridToName[srid]
+
+        sql = "SELECT srs_name FROM gpkg_spatial_ref_sys WHERE srs_id = %s" % self.quoteString(srid)
+        res = self._fetchOne(sql)
+        if res is not None and len(res) > 0:
+            res = res[0]
+        self.mapSridToName[srid] = res
+        return res
+
+    def isVectorTable(self, table):
+
+        _, tablename = self.getSchemaTableName(table)
+        return self.gdal_ds.GetLayerByName(tablename) is not None
+
+    def isRasterTable(self, table):
+        if self.has_raster and not self.isVectorTable(table):
+            _, tablename = self.getSchemaTableName(table)
+            md = self.gdal_ds.GetMetadata('SUBDATASETS')
+            if md is None or len(md) == 0:
+                sql = "SELECT COUNT(*) FROM gpkg_contents WHERE data_type = 'tiles' AND table_name = %s" % self.quoteString(tablename)
+                ret = self._fetchOne(sql)
+                return ret != [] and ret[0][0] == 1
+            else:
+                subdataset_name = 'GPKG:%s:%s' % (self.gdal_ds.GetDescription(), tablename)
+                for key in md:
+                    if md[key] == subdataset_name:
+                        return True
+
+        return False
+
+    def getOGRFieldTypeFromSQL(self, sql_type):
+        ogr_type = ogr.OFTString
+        ogr_subtype = ogr.OFSTNone
+        width = 0
+        if not sql_type.startswith('TEXT ('):
+            pos = sql_type.find(' (')
+            if pos >= 0:
+                sql_type = sql_type[0:pos]
+        if sql_type == 'BOOLEAN':
+            ogr_type = ogr.OFTInteger
+            ogr_subtype = ogr.OFSTBoolean
+        elif sql_type in ('TINYINT', 'SMALLINT', 'MEDIUMINT'):
+            ogr_type = ogr.OFTInteger
+        elif sql_type == 'INTEGER':
+            ogr_type = ogr.OFTInteger64
+        elif sql_type == 'FLOAT':
+            ogr_type = ogr.OFTReal
+            ogr_subtype = ogr.OFSTFloat32
+        elif sql_type == 'DOUBLE':
+            ogr_type = ogr.OFTReal
+        elif sql_type == 'DATE':
+            ogr_type = ogr.OFTDate
+        elif sql_type == 'DATETIME':
+            ogr_type = ogr.OFTDateTime
+        elif sql_type.startswith('TEXT (') and sql_type.endswith(')'):
+            width = int(sql_type[len('TEXT ('):-1])
+        return (ogr_type, ogr_subtype, width)
+
+    def createOGRFieldDefnFromSQL(self, sql_fielddef):
+        f_split = sql_fielddef.split(' ')
+        quoted_name = f_split[0]
+        name = self.unquoteId(quoted_name)
+        sql_type = f_split[1].upper()
+        if len(f_split) >= 3 and f_split[2].startswith('(') and f_split[2].endswith(')'):
+            sql_type += ' ' + f_split[2]
+            f_split = [f for f in f_split[3:]]
+        else:
+            f_split = [f for f in f_split[2:]]
+        ogr_type, ogr_subtype, width = self.getOGRFieldTypeFromSQL(sql_type)
+        fld_defn = ogr.FieldDefn(name, ogr_type)
+        fld_defn.SetSubType(ogr_subtype)
+        fld_defn.SetWidth(width)
+        if len(f_split) >= 2 and f_split[0] == 'NOT' and f_split[1] == 'NULL':
+            fld_defn.SetNullable(False)
+            f_split = [f for f in f_split[2:]]
+        elif len(f_split) >= 1:
+            f_split = [f for f in f_split[1:]]
+        if len(f_split) >= 2 and f_split[0] == 'DEFAULT':
+            new_default = f_split[1]
+            if new_default == '':
+                fld_defn.SetDefault(None)
+            elif new_default == 'NULL' or ogr_type in (ogr.OFTInteger, ogr.OFTReal):
+                fld_defn.SetDefault(new_default)
+            elif new_default.startswith("'") and new_default.endswith("'"):
+                fld_defn.SetDefault(new_default)
+            else:
+                fld_defn.SetDefault(self.quoteString(new_default))
+        return fld_defn
+
+    def createTable(self, table, field_defs, pkey):
+        """Creates ordinary table
+                        'fields' is array containing field definitions
+                        'pkey' is the primary key name
+        """
+        if len(field_defs) == 0:
+            return False
+
+        options = []
+        if pkey is not None and pkey != "":
+            options += ['FID=' + pkey]
+        _, tablename = self.getSchemaTableName(table)
+        lyr = self.gdal_ds.CreateLayer(tablename, geom_type=ogr.wkbNone, options=options)
+        if lyr is None:
+            return False
+        for field_def in field_defs:
+            fld_defn = self.createOGRFieldDefnFromSQL(field_def)
+            if fld_defn.GetName() == pkey:
+                continue
+            if lyr.CreateField(fld_defn) != 0:
+                return False
+
+        return True
+
+    def deleteTable(self, table):
+        """Deletes table from the database """
+        if self.isRasterTable(table):
+            return False
+
+        _, tablename = self.getSchemaTableName(table)
+        for i in range(self.gdal_ds.GetLayerCount()):
+            if self.gdal_ds.GetLayer(i).GetName() == tablename:
+                return self.gdal_ds.DeleteLayer(i) == 0
+        return False
+
+    def emptyTable(self, table):
+        """Deletes all rows from table """
+        if self.isRasterTable(table):
+            return False
+
+        sql = "DELETE FROM %s" % self.quoteId(table)
+        self._execute_and_commit(sql)
+
+    def renameTable(self, table, new_table):
+        """Renames the table
+
+        :param table: tuple with schema and table names
+        :type table: tuple (str, str)
+        :param new_table: new table name
+        :type new_table: str
+        :return: true on success
+        :rtype: bool
+        """
+        try:
+            name = table[1]  # 0 is schema
+            vector_table_names = [t.tableName() for t in self.core_connection.tables('', QgsAbstractDatabaseProviderConnection.Vector)]
+            if name in vector_table_names:
+                self.core_connection.renameVectorTable('', name, new_table)
+            else:
+                self.core_connection.renameRasterTable('', name, new_table)
+            return True
+        except QgsProviderConnectionException:
+            return False
+
+    def moveTable(self, table, new_table, new_schema=None):
+        return self.renameTable(table, new_table)
+
+    def runVacuum(self):
+        """ run vacuum on the db """
+        self._execute_and_commit("VACUUM")
+
+    def addTableColumn(self, table, field_def):
+        """Adds a column to table """
+
+        _, tablename = self.getSchemaTableName(table)
+        lyr = self.gdal_ds.GetLayerByName(tablename)
+        if lyr is None:
+            return False
+        fld_defn = self.createOGRFieldDefnFromSQL(field_def)
+        return lyr.CreateField(fld_defn) == 0
+
+    def deleteTableColumn(self, table, column):
+        """Deletes column from a table """
+        if self.isGeometryColumn(table, column):
+            return False
+
+        _, tablename = self.getSchemaTableName(table)
+        lyr = self.gdal_ds.GetLayerByName(tablename)
+        if lyr is None:
+            return False
+        idx = lyr.GetLayerDefn().GetFieldIndex(column)
+        if idx >= 0:
+            return lyr.DeleteField(idx) == 0
+        return False
+
+    def updateTableColumn(self, table, column, new_name, new_data_type=None, new_not_null=None, new_default=None, comment=None):
+        if self.isGeometryColumn(table, column):
+            return False
+
+        _, tablename = self.getSchemaTableName(table)
+        lyr = self.gdal_ds.GetLayerByName(tablename)
+        if lyr is None:
+            return False
+        if lyr.TestCapability(ogr.OLCAlterFieldDefn) == 0:
+            return False
+        idx = lyr.GetLayerDefn().GetFieldIndex(column)
+        if idx >= 0:
+            old_fielddefn = lyr.GetLayerDefn().GetFieldDefn(idx)
+            flag = 0
+            if new_name is not None:
+                flag |= ogr.ALTER_NAME_FLAG
+            else:
+                new_name = column
+            if new_data_type is None:
+                ogr_type = old_fielddefn.GetType()
+                ogr_subtype = old_fielddefn.GetSubType()
+                width = old_fielddefn.GetWidth()
+            else:
+                flag |= ogr.ALTER_TYPE_FLAG
+                flag |= ogr.ALTER_WIDTH_PRECISION_FLAG
+                ogr_type, ogr_subtype, width = self.getOGRFieldTypeFromSQL(new_data_type)
+            new_fielddefn = ogr.FieldDefn(new_name, ogr_type)
+            new_fielddefn.SetSubType(ogr_subtype)
+            new_fielddefn.SetWidth(width)
+            if new_default is not None:
+                flag |= ogr.ALTER_DEFAULT_FLAG
+                if new_default == '':
+                    new_fielddefn.SetDefault(None)
+                elif new_default == 'NULL' or ogr_type in (ogr.OFTInteger, ogr.OFTReal):
+                    new_fielddefn.SetDefault(str(new_default))
+                elif new_default.startswith("'") and new_default.endswith("'"):
+                    new_fielddefn.SetDefault(str(new_default))
+                else:
+                    new_fielddefn.SetDefault(self.quoteString(new_default))
+            else:
+                new_fielddefn.SetDefault(old_fielddefn.GetDefault())
+            if new_not_null is not None:
+                flag |= ogr.ALTER_NULLABLE_FLAG
+                new_fielddefn.SetNullable(not new_not_null)
+            else:
+                new_fielddefn.SetNullable(old_fielddefn.IsNullable())
+            return lyr.AlterFieldDefn(idx, new_fielddefn, flag) == 0
+
+        return False
+
+    def isGeometryColumn(self, table, column):
+
+        _, tablename = self.getSchemaTableName(table)
+        lyr = self.gdal_ds.GetLayerByName(tablename)
+        if lyr is None:
+            return False
+        return column == lyr.GetGeometryColumn()
+
+    def addGeometryColumn(self, table, geom_column='geometry', geom_type='POINT', srid=-1, dim=2):
+
+        _, tablename = self.getSchemaTableName(table)
+        lyr = self.gdal_ds.GetLayerByName(tablename)
+        if lyr is None:
+            return False
+        ogr_type = ogr.wkbUnknown
+        if geom_type == 'POINT':
+            ogr_type = ogr.wkbPoint
+        elif geom_type == 'LINESTRING':
+            ogr_type = ogr.wkbLineString
+        elif geom_type == 'POLYGON':
+            ogr_type = ogr.wkbPolygon
+        elif geom_type == 'MULTIPOINT':
+            ogr_type = ogr.wkbMultiPoint
+        elif geom_type == 'MULTILINESTRING':
+            ogr_type = ogr.wkbMultiLineString
+        elif geom_type == 'MULTIPOLYGON':
+            ogr_type = ogr.wkbMultiPolygon
+        elif geom_type == 'GEOMETRYCOLLECTION':
+            ogr_type = ogr.wkbGeometryCollection
+
+        if dim == 3:
+            ogr_type = ogr_type | ogr.wkb25DBit
+        elif dim == 4:
+            if hasattr(ogr, 'GT_HasZ'):
+                ogr_type = ogr.GT_SetZ(ogr_type)
+            else:
+                ogr_type = ogr_type | ogr.wkb25DBit
+            if hasattr(ogr, 'GT_HasM'):
+                ogr_type = ogr.GT_SetM(ogr_type)
+
+        geom_field_defn = ogr.GeomFieldDefn(self.unquoteId(geom_column), ogr_type)
+        if srid > 0:
+            sr = osr.SpatialReference()
+            if sr.ImportFromEPSG(srid) == 0:
+                geom_field_defn.SetSpatialRef(sr)
+
+        if lyr.CreateGeomField(geom_field_defn) != 0:
+            return False
+        self._opendb()
+        return True
+
+    def deleteGeometryColumn(self, table, geom_column):
+        return False  # not supported
+
+    def addTableUniqueConstraint(self, table, column):
+        """Adds a unique constraint to a table """
+        return False  # constraints not supported
+
+    def deleteTableConstraint(self, table, constraint):
+        """Deletes constraint in a table """
+        return False  # constraints not supported
+
+    def addTablePrimaryKey(self, table, column):
+        """Adds a primery key (with one column) to a table """
+        sql = "ALTER TABLE %s ADD PRIMARY KEY (%s)" % (self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def createTableIndex(self, table, name, column, unique=False):
+        """Creates index on one column using default options """
+        unique_str = "UNIQUE" if unique else ""
+        sql = "CREATE %s INDEX %s ON %s (%s)" % (
+            unique_str, self.quoteId(name), self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def deleteTableIndex(self, table, name):
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "DROP INDEX %s" % self.quoteId((schema, name))
+        self._execute_and_commit(sql)
+
+    def createSpatialIndex(self, table, geom_column):
+        if self.isRasterTable(table):
+            return False
+        _, tablename = self.getSchemaTableName(table)
+        sql = "SELECT CreateSpatialIndex(%s, %s)" % (
+            self.quoteId(tablename), self.quoteId(geom_column))
+        try:
+            res = self._fetchOne(sql)
+        except QgsProviderConnectionException:
+            return False
+        return res is not None and res[0][0] == 1
+
+    def deleteSpatialIndex(self, table, geom_column):
+        if self.isRasterTable(table):
+            return False
+        _, tablename = self.getSchemaTableName(table)
+        sql = "SELECT DisableSpatialIndex(%s, %s)" % (
+            self.quoteId(tablename), self.quoteId(geom_column))
+        res = self._fetchOne(sql)
+        return len(res) > 0 and len(res[0]) > 0 and res[0][0] == 1
+
+    def hasSpatialIndex(self, table, geom_column):
+        if self.isRasterTable(table) or geom_column is None:
+            return False
+        _, tablename = self.getSchemaTableName(table)
+
+        # (only available in >= 2.1.2)
+        sql = "SELECT HasSpatialIndex(%s, %s)" % (self.quoteString(tablename), self.quoteString(geom_column))
+        gdal.PushErrorHandler()
+        ret = self._fetchOne(sql)
+        gdal.PopErrorHandler()
+
+        if len(ret) == 0:
+            # might be the case for GDAL < 2.1.2
+            sql = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE %s" % self.quoteString("%%rtree_" + tablename + "_%%")
+            ret = self._fetchOne(sql)
+        if len(ret) == 0:
+            return False
+        else:
+            return ret[0][0] >= 1
+
+    def execution_error_types(self):
+        return sqlite3.Error, sqlite3.ProgrammingError, sqlite3.Warning
+
+    def connection_error_types(self):
+        return sqlite3.InterfaceError, sqlite3.OperationalError
+
+    def getSqlDictionary(self):
+        from .sql_dictionary import getSqlDictionary
+
+        sql_dict = getSqlDictionary()
+
+        items = []
+        for tbl in self.getTables():
+            items.append(tbl[1])  # table name
+
+            for fld in self.getTableFields(tbl[0]):
+                items.append(fld[1])  # field name
+
+        sql_dict["identifier"] = items
+        return sql_dict
+
+    def getQueryBuilderDictionary(self):
+        from .sql_dictionary import getQueryBuilderDictionary
+
+        return getQueryBuilderDictionary()

+ 84 - 0
db_manager/db_plugins/gpkg/data_model.py

@@ -0,0 +1,84 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.core import QgsMessageLog
+
+from ..data_model import (TableDataModel,
+                          SqlResultModel,
+                          SqlResultModelAsync,
+                          SqlResultModelTask)
+from ..plugin import BaseError
+
+
+class GPKGTableDataModel(TableDataModel):
+
+    def __init__(self, table, parent=None):
+        TableDataModel.__init__(self, table, parent)
+
+        # fields_txt = ", ".join(self.fields)
+        # table_txt = self.db.quoteId((self.table.schemaName(), self.table.name))
+
+        # run query and get results
+        # sql = "SELECT %s FROM %s" % (fields_txt, table_txt)
+        # self.resdata = self.db._fetchAll(sql, include_fid_and_geometry = True)
+
+        self.resdata = self.db._fetchAllFromLayer(table)
+
+        self.fetchedFrom = 0
+        self.fetchedCount = len(self.resdata)
+
+    def _sanitizeTableField(self, field):
+        return self.db.quoteId(field.name)
+
+    def rowCount(self, index=None):
+        return self.fetchedCount
+
+
+class GPKGSqlResultModelTask(SqlResultModelTask):
+
+    def __init__(self, db, sql, parent):
+        super().__init__(db, sql, parent)
+
+    def run(self):
+        try:
+            self.model = GPKGSqlResultModel(self.db, self.sql, None)
+        except BaseError as e:
+            self.error = e
+            QgsMessageLog.logMessage(e.msg)
+            return False
+        return True
+
+    def cancel(self):
+        self.db.connector.cancel()
+        SqlResultModelTask.cancel(self)
+
+
+class GPKGSqlResultModelAsync(SqlResultModelAsync):
+
+    def __init__(self, db, sql, parent):
+        super().__init__()
+
+        self.task = GPKGSqlResultModelTask(db, sql, parent)
+        self.task.taskCompleted.connect(self.modelDone)
+        self.task.taskTerminated.connect(self.modelDone)
+
+
+class GPKGSqlResultModel(SqlResultModel):
+    pass

+ 45 - 0
db_manager/db_plugins/gpkg/info_model.py

@@ -0,0 +1,45 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtWidgets import QApplication
+
+from ..info_model import DatabaseInfo
+from ..html_elems import HtmlTable
+
+
+class GPKGDatabaseInfo(DatabaseInfo):
+
+    def __init__(self, db):
+        self.db = db
+
+    def connectionDetails(self):
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Filename:"), self.db.connector.dbname)
+        ]
+        return HtmlTable(tbl)
+
+    def generalInfo(self):
+        return None
+
+    def spatialInfo(self):
+        return None
+
+    def privilegesDetails(self):
+        return None

+ 338 - 0
db_manager/db_plugins/gpkg/plugin.py

@@ -0,0 +1,338 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+# this will disable the dbplugin if the connector raise an ImportError
+from .connector import GPKGDBConnector
+
+from qgis.PyQt.QtCore import Qt, QFileInfo, QCoreApplication
+from qgis.PyQt.QtGui import QIcon
+from qgis.PyQt.QtWidgets import QApplication, QAction, QFileDialog
+from qgis.core import (
+    Qgis,
+    QgsApplication,
+    QgsDataSourceUri,
+    QgsSettings,
+    QgsProviderRegistry,
+)
+from qgis.gui import QgsMessageBar
+
+from ..plugin import DBPlugin, Database, Table, VectorTable, RasterTable, TableField, TableIndex, TableTrigger, \
+    InvalidDataException
+
+
+def classFactory():
+    return GPKGDBPlugin
+
+
+class GPKGDBPlugin(DBPlugin):
+
+    @classmethod
+    def icon(self):
+        return QgsApplication.getThemeIcon("/mGeoPackage.svg")
+
+    @classmethod
+    def typeName(self):
+        return 'gpkg'
+
+    @classmethod
+    def typeNameString(self):
+        return QCoreApplication.translate('db_manager', 'GeoPackage')
+
+    @classmethod
+    def providerName(self):
+        return 'ogr'
+
+    @classmethod
+    def connectionSettingsKey(self):
+        return 'providers/ogr/GPKG/connections'
+
+    def databasesFactory(self, connection, uri):
+        return GPKGDatabase(connection, uri)
+
+    def connect(self, parent=None):
+        conn_name = self.connectionName()
+
+        md = QgsProviderRegistry.instance().providerMetadata(self.providerName())
+        conn = md.findConnection(conn_name)
+
+        if conn is None:  # non-existent entry?
+            raise InvalidDataException(self.tr('There is no defined database connection "{0}".').format(conn_name))
+
+        uri = QgsDataSourceUri()
+        uri.setDatabase(conn.uri())
+        return self.connectToUri(uri)
+
+    @classmethod
+    def addConnection(self, conn_name, uri):
+        md = QgsProviderRegistry.instance().providerMetadata(self.providerName())
+        conn = md.createConnection(uri.database(), {})
+        md.saveConnection(conn, conn_name)
+        return True
+
+    @classmethod
+    def addConnectionActionSlot(self, item, action, parent, index):
+        QApplication.restoreOverrideCursor()
+        try:
+            filename, selected_filter = QFileDialog.getOpenFileName(parent,
+                                                                    parent.tr("Choose GeoPackage file"), None, "GeoPackage (*.gpkg)")
+            if not filename:
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        conn_name = QFileInfo(filename).fileName()
+        uri = QgsDataSourceUri()
+        uri.setDatabase(filename)
+        self.addConnection(conn_name, uri)
+        index.internalPointer().itemChanged()
+
+
+class GPKGDatabase(Database):
+
+    def __init__(self, connection, uri):
+        Database.__init__(self, connection, uri)
+
+    def connectorsFactory(self, uri):
+        return GPKGDBConnector(uri, self.connection())
+
+    def dataTablesFactory(self, row, db, schema=None):
+        return GPKGTable(row, db, schema)
+
+    def vectorTablesFactory(self, row, db, schema=None):
+        return GPKGVectorTable(row, db, schema)
+
+    def rasterTablesFactory(self, row, db, schema=None):
+        return GPKGRasterTable(row, db, schema)
+
+    def info(self):
+        from .info_model import GPKGDatabaseInfo
+
+        return GPKGDatabaseInfo(self)
+
+    def sqlResultModel(self, sql, parent):
+        from .data_model import GPKGSqlResultModel
+
+        return GPKGSqlResultModel(self, sql, parent)
+
+    def sqlResultModelAsync(self, sql, parent):
+        from .data_model import GPKGSqlResultModelAsync
+
+        return GPKGSqlResultModelAsync(self, sql, parent)
+
+    def registerDatabaseActions(self, mainWindow):
+        action = QAction(self.tr("Run &Vacuum"), self)
+        mainWindow.registerAction(action, self.tr("&Database"), self.runVacuumActionSlot)
+
+        Database.registerDatabaseActions(self, mainWindow)
+
+    def runVacuumActionSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        try:
+            if not isinstance(item, (DBPlugin, Table)) or item.database() is None:
+                parent.infoBar.pushMessage(self.tr("No database selected or you are not connected to it."),
+                                           Qgis.Info, parent.iface.messageTimeout())
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        self.runVacuum()
+
+    def runVacuum(self):
+        self.database().aboutToChange.emit()
+        self.database().connector.runVacuum()
+        self.database().refresh()
+
+    def runAction(self, action):
+        action = str(action)
+
+        if action.startswith("vacuum/"):
+            if action == "vacuum/run":
+                self.runVacuum()
+                return True
+
+        return Database.runAction(self, action)
+
+    def uniqueIdFunction(self):
+        return None
+
+    def toSqlLayer(self, sql, geomCol, uniqueCol, layerName="QueryLayer", layerType=None, avoidSelectById=False, filter=""):
+        from qgis.core import QgsVectorLayer
+
+        vl = QgsVectorLayer(self.uri().database() + '|subset=' + sql, layerName, 'ogr')
+        return vl
+
+    def supportsComment(self):
+        return False
+
+
+class GPKGTable(Table):
+
+    def __init__(self, row, db, schema=None):
+        """Constructs a GPKGTable
+
+        :param row: a three elements array with: [table_name, is_view, is_sys_table]
+        :type row: array [str, bool, bool]
+        :param db: database instance
+        :type db:
+        :param schema: schema name, defaults to None, ignored by GPKG
+        :type schema: str, optional
+        """
+
+        Table.__init__(self, db, None)
+        self.name, self.isView, self.isSysTable = row
+
+    def ogrUri(self):
+        ogrUri = "%s|layername=%s" % (self.uri().database(), self.name)
+        return ogrUri
+
+    def mimeUri(self):
+        # QGIS has no provider to load Geopackage vectors, let's use OGR
+        return "vector:ogr:%s:%s" % (self.name, self.ogrUri())
+
+    def toMapLayer(self, geometryType=None, crs=None):
+        from qgis.core import QgsVectorLayer
+
+        provider = "ogr"
+        uri = self.ogrUri()
+
+        if geometryType:
+            geom_mapping = {
+                'POINT': 'Point',
+                'LINESTRING': 'LineString',
+                'POLYGON': 'Polygon',
+            }
+            geometryType = geom_mapping[geometryType]
+            uri = "{}|geometrytype={}".format(uri, geometryType)
+
+        return QgsVectorLayer(uri, self.name, provider)
+
+    def tableFieldsFactory(self, row, table):
+        return GPKGTableField(row, table)
+
+    def tableIndexesFactory(self, row, table):
+        return GPKGTableIndex(row, table)
+
+    def tableTriggersFactory(self, row, table):
+        return GPKGTableTrigger(row, table)
+
+    def tableDataModel(self, parent):
+        from .data_model import GPKGTableDataModel
+
+        return GPKGTableDataModel(self, parent)
+
+
+class GPKGVectorTable(GPKGTable, VectorTable):
+
+    def __init__(self, row, db, schema=None):
+        GPKGTable.__init__(self, row[:-5], db, schema)
+        VectorTable.__init__(self, db, schema)
+        # GPKG does case-insensitive checks for table names, but the
+        # GPKG provider didn't do the same in QGIS < 1.9, so self.geomTableName
+        # stores the table name like stored in the geometry_columns table
+        self.geomTableName, self.geomColumn, self.geomType, self.geomDim, self.srid = row[-5:]
+        self.extent = self.database().connector.getTableExtent((self.schemaName(), self.name), self.geomColumn, force=False)
+
+    def uri(self):
+        uri = self.database().uri()
+        uri.setDataSource('', self.geomTableName, self.geomColumn)
+        return uri
+
+    def hasSpatialIndex(self, geom_column=None):
+        geom_column = geom_column if geom_column is not None else self.geomColumn
+        return self.database().connector.hasSpatialIndex((self.schemaName(), self.name), geom_column)
+
+    def createSpatialIndex(self, geom_column=None):
+        self.aboutToChange.emit()
+        ret = VectorTable.createSpatialIndex(self, geom_column)
+        if ret is not False:
+            self.database().refresh()
+        return ret
+
+    def deleteSpatialIndex(self, geom_column=None):
+        self.aboutToChange.emit()
+        ret = VectorTable.deleteSpatialIndex(self, geom_column)
+        if ret is not False:
+            self.database().refresh()
+        return ret
+
+    def refreshTableEstimatedExtent(self):
+        return
+
+    def refreshTableExtent(self):
+        prevExtent = self.extent
+        self.extent = self.database().connector.getTableExtent((self.schemaName(), self.name), self.geomColumn, force=True)
+        if self.extent != prevExtent:
+            self.refresh()
+
+    def runAction(self, action):
+        if GPKGTable.runAction(self, action):
+            return True
+        return VectorTable.runAction(self, action)
+
+
+class GPKGRasterTable(GPKGTable, RasterTable):
+
+    def __init__(self, row, db, schema=None):
+        GPKGTable.__init__(self, row[:-3], db, schema)
+        RasterTable.__init__(self, db, schema)
+        self.prefixName, self.geomColumn, self.srid = row[-3:]
+        self.geomType = 'RASTER'
+        self.extent = self.database().connector.getTableExtent((self.schemaName(), self.name), self.geomColumn)
+
+    def gpkgGdalUri(self):
+        gdalUri = 'GPKG:%s:%s' % (self.uri().database(), self.prefixName)
+        return gdalUri
+
+    def mimeUri(self):
+        # QGIS has no provider to load rasters, let's use GDAL
+        uri = "raster:gdal:%s:%s" % (self.name, self.uri().database())
+        return uri
+
+    def toMapLayer(self, geometryType=None, crs=None):
+        from qgis.core import QgsRasterLayer, QgsContrastEnhancement
+
+        # QGIS has no provider to load rasters, let's use GDAL
+        uri = self.gpkgGdalUri()
+        rl = QgsRasterLayer(uri, self.name)
+        if rl.isValid():
+            rl.setContrastEnhancement(QgsContrastEnhancement.StretchToMinimumMaximum)
+        return rl
+
+
+class GPKGTableField(TableField):
+
+    def __init__(self, row, table):
+        TableField.__init__(self, table)
+        self.num, self.name, self.dataType, self.notNull, self.default, self.primaryKey = row
+        self.hasDefault = self.default
+
+
+class GPKGTableIndex(TableIndex):
+
+    def __init__(self, row, table):
+        TableIndex.__init__(self, table)
+        self.num, self.name, self.isUnique, self.columns = row
+
+
+class GPKGTableTrigger(TableTrigger):
+
+    def __init__(self, row, table):
+        TableTrigger.__init__(self, table)
+        self.name, self.function = row

+ 26 - 0
db_manager/db_plugins/gpkg/sql_dictionary.py

@@ -0,0 +1,26 @@
+"""
+***************************************************************************
+    sql_dictionary.py
+    ---------------------
+    Date                 : April 2012
+    Copyright            : (C) 2012 by Giuseppe Sucameli
+    Email                : brush dot tyler at gmail dot com
+***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************
+"""
+
+
+def getSqlDictionary(spatial=True):
+    from ..spatialite.sql_dictionary import getSqlDictionary
+    return getSqlDictionary(spatial)
+
+
+def getQueryBuilderDictionary():
+    from ..spatialite.sql_dictionary import getQueryBuilderDictionary
+    return getQueryBuilderDictionary()

+ 170 - 0
db_manager/db_plugins/html_elems.py

@@ -0,0 +1,170 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+
+class HtmlContent:
+
+    def __init__(self, data):
+        self.data = data if not isinstance(data, HtmlContent) else data.data
+
+    def toHtml(self):
+        if isinstance(self.data, list) or isinstance(self.data, tuple):
+            html = ''
+            for item in self.data:
+                html += HtmlContent(item).toHtml()
+            return html
+
+        if hasattr(self.data, 'toHtml'):
+            return self.data.toHtml()
+
+        html = str(self.data).replace("\n", "<br>")
+        return html
+
+    def hasContents(self):
+        if isinstance(self.data, list) or isinstance(self.data, tuple):
+            empty = True
+            for item in self.data:
+                if item.hasContents():
+                    empty = False
+                    break
+            return not empty
+
+        if hasattr(self.data, 'hasContents'):
+            return self.data.hasContents()
+
+        return len(self.data) > 0
+
+
+class HtmlElem:
+
+    def __init__(self, tag, data, attrs=None):
+        self.tag = tag
+        self.data = data if isinstance(data, HtmlContent) else HtmlContent(data)
+        self.attrs = attrs if attrs is not None else dict()
+        if 'tag' in self.attrs:
+            self.setTag(self.attrs['tag'])
+            del self.attrs['tag']
+
+    def setTag(self, tag):
+        self.tag = tag
+
+    def getOriginalData(self):
+        return self.data.data
+
+    def setAttr(self, name, value):
+        self.attrs[name] = value
+
+    def getAttrsHtml(self):
+        html = ''
+        for k, v in self.attrs.items():
+            html += ' %s="%s"' % (k, v)
+        return html
+
+    def openTagHtml(self):
+        return "<%s%s>" % (self.tag, self.getAttrsHtml())
+
+    def closeTagHtml(self):
+        return "</%s>" % self.tag
+
+    def toHtml(self):
+        return "%s%s%s" % (self.openTagHtml(), self.data.toHtml(), self.closeTagHtml())
+
+    def hasContents(self):
+        return self.data.toHtml() != ""
+
+
+class HtmlParagraph(HtmlElem):
+
+    def __init__(self, data, attrs=None):
+        HtmlElem.__init__(self, 'p', data, attrs)
+
+
+class HtmlListItem(HtmlElem):
+
+    def __init__(self, data, attrs=None):
+        HtmlElem.__init__(self, 'li', data, attrs)
+
+
+class HtmlList(HtmlElem):
+
+    def __init__(self, items, attrs=None):
+        # make sure to have HtmlListItem items
+        items = list(items)
+        for i, item in enumerate(items):
+            if not isinstance(item, HtmlListItem):
+                items[i] = HtmlListItem(item)
+        HtmlElem.__init__(self, 'ul', items, attrs)
+
+
+class HtmlTableCol(HtmlElem):
+
+    def __init__(self, data, attrs=None):
+        HtmlElem.__init__(self, 'td', data, attrs)
+
+    def closeTagHtml(self):
+        # FIX INVALID BEHAVIOR: an empty cell as last table's cell break margins
+        return "&nbsp;%s" % HtmlElem.closeTagHtml(self)
+
+
+class HtmlTableRow(HtmlElem):
+
+    def __init__(self, cols, attrs=None):
+        # make sure to have HtmlTableCol items
+        cols = list(cols)
+        for i, c in enumerate(cols):
+            if not isinstance(c, HtmlTableCol):
+                cols[i] = HtmlTableCol(c)
+        HtmlElem.__init__(self, 'tr', cols, attrs)
+
+
+class HtmlTableHeader(HtmlTableRow):
+
+    def __init__(self, cols, attrs=None):
+        HtmlTableRow.__init__(self, cols, attrs)
+        for c in self.getOriginalData():
+            c.setTag('th')
+
+
+class HtmlTable(HtmlElem):
+
+    def __init__(self, rows, attrs=None):
+        # make sure to have HtmlTableRow items
+        rows = list(rows)
+        for i, r in enumerate(rows):
+            if not isinstance(r, HtmlTableRow):
+                rows[i] = HtmlTableRow(r)
+        HtmlElem.__init__(self, 'table', rows, attrs)
+
+
+class HtmlWarning(HtmlContent):
+
+    def __init__(self, data):
+        data = ['<img src=":/icons/warning-20px.png">&nbsp;&nbsp; ', data]
+        HtmlContent.__init__(self, data)
+
+
+class HtmlSection(HtmlContent):
+
+    def __init__(self, title, content=None):
+        data = ['<div class="section"><h2>', title, '</h2>']
+        if content is not None:
+            data.extend(['<div>', content, '</div>'])
+        data.append('</div>')
+        HtmlContent.__init__(self, data)

+ 461 - 0
db_manager/db_plugins/info_model.py

@@ -0,0 +1,461 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtWidgets import QApplication
+
+from .html_elems import HtmlContent, HtmlSection, HtmlParagraph, HtmlList, HtmlTable, HtmlTableHeader, HtmlTableCol
+
+
+class DatabaseInfo:
+
+    def __init__(self, db):
+        self.db = db
+
+    def __del__(self):
+        self.db = None
+
+    def generalInfo(self):
+        info = self.db.connector.getInfo()
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Server version: "), info[0])
+        ]
+        return HtmlTable(tbl)
+
+    def connectionDetails(self):
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Host:"), self.db.connector.host),
+            (QApplication.translate("DBManagerPlugin", "User:"), self.db.connector.user)
+        ]
+        return HtmlTable(tbl)
+
+    def spatialInfo(self):
+        ret = []
+
+        info = self.db.connector.getSpatialInfo()
+        if info is None:
+            return
+
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Library:"), info[0]),
+            ("GEOS:", info[1]),
+            ("Proj:", info[2])
+        ]
+        ret.append(HtmlTable(tbl))
+
+        if not self.db.connector.has_geometry_columns:
+            ret.append(HtmlParagraph(
+                QApplication.translate("DBManagerPlugin", "<warning> geometry_columns table doesn't exist!\n"
+                                                          "This table is essential for many GIS applications for enumeration of tables.")))
+
+        return ret
+
+    def privilegesDetails(self):
+        details = self.db.connector.getDatabasePrivileges()
+        lst = []
+        if details[0]:
+            lst.append(QApplication.translate("DBManagerPlugin", "create new schemas"))
+        if details[1]:
+            lst.append(QApplication.translate("DBManagerPlugin", "create temporary tables"))
+        return HtmlList(lst)
+
+    def toHtml(self):
+        if self.db is None:
+            return HtmlSection(QApplication.translate("DBManagerPlugin", 'Not connected')).toHtml()
+
+        ret = []
+
+        # connection details
+        conn_details = self.connectionDetails()
+        if conn_details is None:
+            pass
+        else:
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'Connection details'), conn_details))
+
+        # database information
+        general_info = self.generalInfo()
+        if general_info is None:
+            pass
+        else:
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'General info'), general_info))
+
+        # has spatial enabled?
+        spatial_info = self.spatialInfo()
+        if spatial_info is None:
+            pass
+        else:
+            typename = self.db.connection().typeNameString()
+            spatial_info = HtmlContent(spatial_info)
+            if not spatial_info.hasContents():
+                spatial_info = QApplication.translate("DBManagerPlugin", '<warning> {0} support not enabled!').format(typename)
+            ret.append(HtmlSection(typename, spatial_info))
+
+        # privileges
+        priv_details = self.privilegesDetails()
+        if priv_details is None:
+            pass
+        else:
+            priv_details = HtmlContent(priv_details)
+            if not priv_details.hasContents():
+                priv_details = QApplication.translate("DBManagerPlugin", '<warning> This user has no privileges!')
+            else:
+                priv_details = [QApplication.translate("DBManagerPlugin", "User has privileges:"), priv_details]
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'Privileges'), priv_details))
+
+        return HtmlContent(ret).toHtml()
+
+
+class SchemaInfo:
+
+    def __init__(self, schema):
+        self.schema = schema
+
+    def __del__(self):
+        self.schema = None
+
+    def generalInfo(self):
+        tbl = [
+            # ("Tables:", self.schema.tableCount)
+        ]
+        if self.schema.owner:
+            tbl.append((QApplication.translate("DBManagerPlugin", "Owner:"), self.schema.owner))
+        if self.schema.comment:
+            tbl.append((QApplication.translate("DBManagerPlugin", "Comment:"), self.schema.comment))
+        return HtmlTable(tbl)
+
+    def privilegesDetails(self):
+        details = self.schema.database().connector.getSchemaPrivileges(self.schema.name)
+        lst = []
+        if details[0]:
+            lst.append(QApplication.translate("DBManagerPlugin", "create new objects"))
+        if details[1]:
+            lst.append(QApplication.translate("DBManagerPlugin", "access objects"))
+        return HtmlList(lst)
+
+    def toHtml(self):
+        ret = []
+
+        general_info = self.generalInfo()
+        if general_info is None:
+            pass
+        else:
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'Schema details'), general_info))
+
+        priv_details = self.privilegesDetails()
+        if priv_details is None:
+            pass
+        else:
+            priv_details = HtmlContent(priv_details)
+            if not priv_details.hasContents():
+                priv_details = QApplication.translate("DBManagerPlugin",
+                                                      '<warning> This user has no privileges to access this schema!')
+            else:
+                priv_details = [QApplication.translate("DBManagerPlugin", "User has privileges:"), priv_details]
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'Privileges'), priv_details))
+
+        return HtmlContent(ret).toHtml()
+
+
+class TableInfo:
+
+    def __init__(self, table):
+        self.table = table
+
+    def __del__(self):
+        self.table = None
+
+    def generalInfo(self):
+        if self.table.rowCount is None:
+            # row count information is not displayed yet, so just block
+            # table signals to avoid double refreshing (infoViewer->refreshRowCount->tableChanged->infoViewer)
+            self.table.blockSignals(True)
+            self.table.refreshRowCount()
+            self.table.blockSignals(False)
+
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Relation type:"),
+             QApplication.translate("DBManagerPlugin", "View") if self.table.isView else QApplication.translate(
+                 "DBManagerPlugin", "Table")),
+            (QApplication.translate("DBManagerPlugin", "Rows:"),
+             self.table.rowCount if self.table.rowCount is not None else QApplication.translate("DBManagerPlugin",
+                                                                                                'Unknown (<a href="action:rows/count">find out</a>)'))
+        ]
+        if self.table.comment:
+            tbl.append((QApplication.translate("DBManagerPlugin", "Comment:"), self.table.comment))
+
+        return HtmlTable(tbl)
+
+    def spatialInfo(self):  # implemented in subclasses
+        return None
+
+    def fieldsDetails(self):
+        tbl = []
+
+        # define the table header
+        header = (
+            "#", QApplication.translate("DBManagerPlugin", "Name"), QApplication.translate("DBManagerPlugin", "Type"),
+            QApplication.translate("DBManagerPlugin", "Null"), QApplication.translate("DBManagerPlugin", "Default"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for fld in self.table.fields():
+            is_null_txt = "N" if fld.notNull else "Y"
+
+            # make primary key field underlined
+            attrs = {"class": "underline"} if fld.primaryKey else None
+            name = HtmlTableCol(fld.name, attrs)
+
+            tbl.append((fld.num, name, fld.type2String(), is_null_txt, fld.default2String()))
+
+        return HtmlTable(tbl, {"class": "header"})
+
+    def constraintsDetails(self):
+        if self.table.constraints() is None or len(self.table.constraints()) <= 0:
+            return None
+
+        tbl = []
+
+        # define the table header
+        header = (QApplication.translate("DBManagerPlugin", "Name"), QApplication.translate("DBManagerPlugin", "Type"),
+                  QApplication.translate("DBManagerPlugin", "Column(s)"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for con in self.table.constraints():
+            # get the fields the constraint is defined on
+            cols = [p[1].name if p[1] is not None else "??? (#%d)" % p[0] for p in iter(list(con.fields().items()))]
+            tbl.append((con.name, con.type2String(), '\n'.join(cols)))
+
+        return HtmlTable(tbl, {"class": "header"})
+
+    def indexesDetails(self):
+        if self.table.indexes() is None or len(self.table.indexes()) <= 0:
+            return None
+
+        tbl = []
+
+        # define the table header
+        header = (
+            QApplication.translate("DBManagerPlugin", "Name"), QApplication.translate("DBManagerPlugin", "Column(s)"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for idx in self.table.indexes():
+            # get the fields the index is defined on
+            cols = [p[1].name if p[1] is not None else "??? (#%d)" % p[0] for p in iter(list(idx.fields().items()))]
+            tbl.append((idx.name, '\n'.join(cols)))
+
+        return HtmlTable(tbl, {"class": "header"})
+
+    def triggersDetails(self):
+        if self.table.triggers() is None or len(self.table.triggers()) <= 0:
+            return None
+
+        tbl = []
+
+        # define the table header
+        header = (
+            QApplication.translate("DBManagerPlugin", "Name"), QApplication.translate("DBManagerPlugin", "Function"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for trig in self.table.triggers():
+            name = '%(name)s (<a href="action:trigger/%(name)s/%(action)s">%(action)s</a>)' % {"name": trig.name,
+                                                                                               "action": "delete"}
+            tbl.append((name, trig.function.replace('<', '&lt;')))
+
+        return HtmlTable(tbl, {"class": "header"})
+
+    def getViewDefinition(self):
+        if not self.table.isView:
+            return None
+        return self.table.database().connector.getViewDefinition((self.table.schemaName(), self.table.name))
+
+    def getTableInfo(self):
+        ret = []
+
+        general_info = self.generalInfo()
+        if general_info is None:
+            pass
+        else:
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'General info'), general_info))
+
+        # spatial info
+        spatial_info = self.spatialInfo()
+        if spatial_info is None:
+            pass
+        else:
+            spatial_info = HtmlContent(spatial_info)
+            if not spatial_info.hasContents():
+                spatial_info = QApplication.translate("DBManagerPlugin", '<warning> This is not a spatial table.')
+            ret.append(HtmlSection(self.table.database().connection().typeNameString(), spatial_info))
+
+        # fields
+        fields_details = self.fieldsDetails()
+        if fields_details is None:
+            pass
+        else:
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'Fields'), fields_details))
+
+        # constraints
+        constraints_details = self.constraintsDetails()
+        if constraints_details is None:
+            pass
+        else:
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'Constraints'), constraints_details))
+
+        # indexes
+        indexes_details = self.indexesDetails()
+        if indexes_details is None:
+            pass
+        else:
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'Indexes'), indexes_details))
+
+        # triggers
+        triggers_details = self.triggersDetails()
+        if triggers_details is None:
+            pass
+        else:
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'Triggers'), triggers_details))
+
+        return ret
+
+    def getViewInfo(self):
+        if not self.table.isView:
+            return []
+
+        ret = self.getTableInfo()
+
+        # view definition
+        view_def = self.getViewDefinition()
+        if view_def is None:
+            pass
+        else:
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'View definition'), view_def))
+
+        return ret
+
+    def toHtml(self):
+        if self.table.isView:
+            ret = self.getViewInfo()
+        else:
+            ret = self.getTableInfo()
+        return HtmlContent(ret).toHtml()
+
+
+class VectorTableInfo(TableInfo):
+
+    def __init__(self, table):
+        TableInfo.__init__(self, table)
+
+    def spatialInfo(self):
+        ret = []
+        if self.table.geomType is None:
+            return ret
+
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Column:"), self.table.geomColumn),
+            (QApplication.translate("DBManagerPlugin", "Geometry:"), self.table.geomType)
+        ]
+
+        # only if we have info from geometry_columns
+        if self.table.geomDim:
+            tbl.append((QApplication.translate("DBManagerPlugin", "Dimension:"), self.table.geomDim))
+
+        srid = self.table.srid if self.table.srid not in (None, 0) else -1
+        sr_info = self.table.database().connector.getSpatialRefInfo(srid) if srid != -1 else QApplication.translate(
+            "DBManagerPlugin", "Undefined")
+        if sr_info:
+            tbl.append((QApplication.translate("DBManagerPlugin", "Spatial ref:"), "%s (%d)" % (sr_info, srid)))
+
+        # estimated extent
+        if not self.table.isView:
+            if self.table.estimatedExtent is None:
+                # estimated extent information is not displayed yet, so just block
+                # table signals to avoid double refreshing (infoViewer->refreshEstimatedExtent->tableChanged->infoViewer)
+                self.table.blockSignals(True)
+                self.table.refreshTableEstimatedExtent()
+                self.table.blockSignals(False)
+
+            if self.table.estimatedExtent is not None and self.table.estimatedExtent[0] is not None:
+                if isinstance(self.table.estimatedExtent, list):
+                    estimated_extent_str = ', '.join('%.5f' % e for e in self.table.estimatedExtent)
+                else:
+                    estimated_extent_str = '%.5f, %.5f - %.5f, %.5f' % self.table.estimatedExtent
+                tbl.append((QApplication.translate("DBManagerPlugin", "Estimated extent:"), estimated_extent_str))
+
+        # extent
+        if self.table.extent is not None and self.table.extent[0] is not None:
+            if isinstance(self.table.extent, list):
+                extent_str = ', '.join('%.5f' % e for e in self.table.extent)
+            else:
+                extent_str = '%.5f, %.5f - %.5f, %.5f' % self.table.extent
+        else:
+            extent_str = QApplication.translate("DBManagerPlugin",
+                                                '(unknown) (<a href="action:extent/get">find out</a>)')
+        tbl.append((QApplication.translate("DBManagerPlugin", "Extent:"), extent_str))
+
+        ret.append(HtmlTable(tbl))
+
+        # is there an entry in geometry_columns?
+        if self.table.geomType.lower() == 'geometry':
+            ret.append(HtmlParagraph(
+                QApplication.translate("DBManagerPlugin", "<warning> There is no entry in geometry_columns!")))
+
+        # find out whether the geometry column has spatial index on it
+        if not self.table.isView:
+            if not self.table.hasSpatialIndex():
+                ret.append(HtmlParagraph(QApplication.translate("DBManagerPlugin",
+                                                                '<warning> No spatial index defined (<a href="action:spatialindex/create">create it</a>)')))
+
+        return ret
+
+
+class RasterTableInfo(TableInfo):
+
+    def __init__(self, table):
+        TableInfo.__init__(self, table)
+
+    def spatialInfo(self):
+        ret = []
+        if self.table.geomType is None:
+            return ret
+
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Column:"), self.table.geomColumn),
+            (QApplication.translate("DBManagerPlugin", "Geometry:"), self.table.geomType)
+        ]
+
+        # only if we have info from geometry_columns
+        srid = self.table.srid if self.table.srid is not None else -1
+        sr_info = self.table.database().connector.getSpatialRefInfo(srid) if srid != -1 else QApplication.translate(
+            "DBManagerPlugin", "Undefined")
+        if sr_info:
+            tbl.append((QApplication.translate("DBManagerPlugin", "Spatial ref:"), "%s (%d)" % (sr_info, srid)))
+
+        # extent
+        if self.table.extent is not None and self.table.extent[0] is not None:
+            extent_str = '%.5f, %.5f - %.5f, %.5f' % self.table.extent
+        else:
+            extent_str = QApplication.translate("DBManagerPlugin",
+                                                '(unknown) (<a href="action:extent/get">find out</a>)')
+        tbl.append((QApplication.translate("DBManagerPlugin", "Extent:"), extent_str))
+
+        ret.append(HtmlTable(tbl))
+        return ret

+ 216 - 0
db_manager/db_plugins/oracle/QtSqlDB.py

@@ -0,0 +1,216 @@
+"""
+/***************************************************************************
+Name                 : QtSqlDB
+Description          : DB API 2.0 interface for QtSql
+Date                 : June 6, 2015
+Copyright            : (C) 2015 by Jürgen E. Fischer
+email                : jef at norbit dot de
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import QVariant, QDate, QTime, QDateTime, QByteArray
+from qgis.PyQt.QtSql import QSqlDatabase, QSqlQuery, QSqlField
+
+paramstyle = "qmark"
+threadsafety = 1
+apilevel = "2.0"
+
+import time
+import datetime
+
+
+def Date(year, month, day):
+    return datetime.date(year, month, day)
+
+
+def Time(hour, minute, second):
+    return datetime.time(hour, minute, second)
+
+
+def Timestamp(year, month, day, hour, minute, second):
+    return datetime.datetime(year, month, day, hour, minute, second)
+
+
+def DateFromTicks(ticks):
+    return Date(*time.localtime(ticks)[:3])
+
+
+def TimeFromTicks(ticks):
+    return Time(*time.localtime(ticks)[3:6])
+
+
+def TimestampFromTicks(ticks):
+    return Timestamp(*time.localtime(ticks)[:6])
+
+
+class ConnectionError(Exception):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+
+class ExecError(Exception):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+
+class QtSqlDBCursor:
+
+    def __init__(self, conn):
+        self.qry = QSqlQuery(conn)
+        self.description = None
+        self.rowcount = -1
+        self.arraysize = 1
+
+    def close(self):
+        self.qry.finish()
+
+    def execute(self, operation, parameters=[]):
+        if len(parameters) == 0:
+            if not self.qry.exec_(operation):
+                raise ExecError(self.qry.lastError().databaseText())
+        else:
+            if not self.qry.prepare(operation):
+                raise ExecError(self.qry.lastError().databaseText())
+
+            for i in range(len(parameters)):
+                self.qry.bindValue(i, parameters[i])
+
+            if not self.qry.exec_():
+                raise ExecError(self.qry.lastError().databaseText())
+
+        self.rowcount = self.qry.size()
+        self.description = []
+        for c in range(self.qry.record().count()):
+            f = self.qry.record().field(c)
+
+            if f.type() == QVariant.Date:
+                t = Date
+            elif f.type() == QVariant.Time:
+                t = Time
+            elif f.type() == QVariant.DateTime:
+                t = Timestamp
+            elif f.type() == QVariant.Double:
+                t = float
+            elif f.type() == QVariant.Int:
+                t = int
+            elif f.type() == QVariant.String:
+                t = str
+            elif f.type() == QVariant.ByteArray:
+                t = str
+            else:
+                continue
+
+            self.description.append([
+                f.name(),  # name
+                t,  # type_code
+                f.length(),  # display_size
+                f.length(),  # internal_size
+                f.precision(),  # precision
+                None,  # scale
+                f.requiredStatus() != QSqlField.Required  # null_ok
+            ])
+
+    def executemany(self, operation, seq_of_parameters):
+        if len(seq_of_parameters) == 0:
+            return
+
+        if not self.qry.prepare(operation):
+            raise ExecError(self.qry.lastError().databaseText())
+
+        for r in seq_of_parameters:
+            for i in range(len(r)):
+                self.qry.bindValue(i, r[i])
+
+            if not self.qry.exec_():
+                raise ExecError(self.qry.lastError().databaseText())
+
+    def scroll(self, row):
+        return self.qry.seek(row)
+
+    def fetchone(self):
+        if not self.qry.next():
+            return None
+
+        row = []
+        for i in range(len(self.description)):
+            value = self.qry.value(i)
+            if (isinstance(value, QDate) or
+                    isinstance(value, QTime) or
+                    isinstance(value, QDateTime)):
+                value = value.toString()
+            elif isinstance(value, QByteArray):
+                value = "GEOMETRY"
+                # value = value.toHex()
+
+            row.append(value)
+
+        return row
+
+    def fetchmany(self, size=10):
+        rows = []
+        while len(rows) < size:
+            row = self.fetchone()
+            if row is None:
+                break
+            rows.append(row)
+
+        return rows
+
+    def fetchall(self):
+        rows = []
+        while True:
+            row = self.fetchone()
+            if row is None:
+                break
+            rows.append(row)
+
+        return rows
+
+    def setinputsize(self, sizes):
+        raise ExecError("nyi")
+
+    def setoutputsize(self, size, column=None):
+        raise ExecError("nyi")
+
+
+class QtSqlDBConnection:
+    connections = 0
+
+    def __init__(self, driver, dbname, user, passwd):
+        self.conn = QSqlDatabase.addDatabase(
+            driver, "qtsql_%d" % QtSqlDBConnection.connections)
+        QtSqlDBConnection.connections += 1
+        self.conn.setDatabaseName(dbname)
+        self.conn.setUserName(user)
+        self.conn.setPassword(passwd)
+
+        if not self.conn.open():
+            raise ConnectionError(self.conn.lastError().databaseText())
+
+    def close(self):
+        self.conn.close()
+
+    def commit(self):
+        self.conn.commit()
+
+    def rollback(self):
+        self.conn.rollback()
+
+    def cursor(self):
+        return QtSqlDBCursor(self.conn)
+
+
+def connect(driver, dbname, user, passwd):
+    return QtSqlDBConnection(driver, dbname, user, passwd)

+ 0 - 0
db_manager/db_plugins/oracle/__init__.py


+ 1738 - 0
db_manager/db_plugins/oracle/connector.py

@@ -0,0 +1,1738 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS (Oracle)
+Date                 : Aug 27, 2014
+copyright            : (C) 2014 by Médéric RIBREUX
+email                : mederic.ribreux@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias <wonder.sk@gmail.com> (GPLv2 license)
+- DB Manager by Giuseppe Sucameli <brush.tyler@gmail.com> (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtSql import QSqlDatabase
+
+from ..connector import DBConnector
+from ..plugin import ConnectionError, DbError, Table
+
+import os
+from qgis.core import Qgis, QgsApplication, NULL, QgsWkbTypes
+from . import QtSqlDB
+import sqlite3
+
+from functools import cmp_to_key
+
+
+def classFactory():
+    if QSqlDatabase.isDriverAvailable("QOCISPATIAL"):
+        return OracleDBConnector
+    else:
+        return None
+
+
+class OracleDBConnector(DBConnector):
+    ORGeomTypes = {
+        2001: QgsWkbTypes.Point,
+        2002: QgsWkbTypes.LineString,
+        2003: QgsWkbTypes.Polygon,
+        2005: QgsWkbTypes.MultiPoint,
+        2006: QgsWkbTypes.MultiLineString,
+        2007: QgsWkbTypes.MultiPolygon,
+        3001: QgsWkbTypes.Point25D,
+        3002: QgsWkbTypes.LineString25D,
+        3003: QgsWkbTypes.Polygon25D,
+        3005: QgsWkbTypes.MultiPoint25D,
+        3006: QgsWkbTypes.MultiLineString25D,
+        3007: QgsWkbTypes.MultiPolygon25D
+    }
+
+    def __init__(self, uri, connName):
+        DBConnector.__init__(self, uri)
+
+        self.connName = connName
+        self.user = uri.username() or os.environ.get('USER')
+        self.passwd = uri.password()
+        self.host = uri.host()
+
+        if self.host != "":
+            self.dbname = self.host
+            if uri.port() != "" and uri.port() != "1521":
+                self.dbname += ":" + uri.port()
+            if uri.database() != "":
+                self.dbname += "/" + uri.database()
+        elif uri.database() != "":
+            self.dbname = uri.database()
+
+        # Connection options
+        self.useEstimatedMetadata = uri.useEstimatedMetadata()
+        self.userTablesOnly = uri.param('userTablesOnly').lower() == "true"
+        self.geometryColumnsOnly = uri.param(
+            'geometryColumnsOnly').lower() == "true"
+        self.allowGeometrylessTables = uri.param(
+            'allowGeometrylessTables').lower() == "true"
+        self.onlyExistingTypes = uri.param(
+            'onlyExistingTypes').lower() == "true"
+        self.includeGeoAttributes = uri.param(
+            'includeGeoAttributes').lower() == "true"
+
+        # For refreshing
+        self.populated = False
+        try:
+            self.connection = QtSqlDB.connect(
+                "QOCISPATIAL", self.dbname, self.user, self.passwd)
+
+        except self.connection_error_types() as e:
+            raise ConnectionError(e)
+
+        # Find if we can connect to data_sources_cache.db
+        sqlite_cache_file = os.path.join(
+            QgsApplication.qgisSettingsDirPath(), "data_sources_cache.db")
+        if (os.path.isfile(sqlite_cache_file)):
+            try:
+                self.cache_connection = sqlite3.connect(sqlite_cache_file)
+            except sqlite3.Error:
+                self.cache_connection = False
+        else:
+            self.cache_connection = False
+
+        # Find if there is cache for our connection:
+        if self.cache_connection:
+            try:
+                cache_c = self.cache_connection.cursor()
+                query = ("SELECT COUNT(*) FROM meta_oracle WHERE"
+                         " conn = '{}'".format(self.connName))
+                cache_c.execute(query)
+                has_cached = cache_c.fetchone()[0]
+                cache_c.close()
+                if not has_cached:
+                    self.cache_connection = False
+
+            except sqlite3.Error:
+                self.cache_connection = False
+
+        self._checkSpatial()
+        self._checkGeometryColumnsTable()
+
+    def _connectionInfo(self):
+        return str(self._uri.connectionInfo(True))
+
+    def _checkSpatial(self):
+        """Check whether Oracle Spatial is present in catalog."""
+        query = ("SELECT count(*) FROM v$option WHERE parameter = "
+                 " 'Spatial' AND value = 'TRUE'")
+        c = self._execute(None, query)
+        self.has_spatial = self._fetchone(c)[0] > 0
+        c.close()
+
+        return self.has_spatial
+
+    def _checkGeometryColumnsTable(self):
+        """Check if user can read *_SDO_GEOM_METADATA view."""
+        # First check if user can read ALL_SDO_GEOM_METADATA
+        privs = self.getRawTablePrivileges('ALL_SDO_GEOM_METADATA',
+                                           'MDSYS', 'PUBLIC')
+        # Otherwise, try with USER_SDO_GEOM_METADATA
+        if not privs[0]:
+            privs = self.getRawTablePrivileges('USER_SDO_GEOM_METADATA',
+                                               'MDSYS', 'PUBLIC')
+
+        if privs[0]:
+            self.has_geometry_columns = True
+            self.has_geometry_columns_access = True
+            self.is_geometry_columns_view = True
+            return True
+        else:
+            self.has_geometry_columns = False
+            self.has_geometry_columns_access = False
+            self.is_geometry_columns_view = False
+            return False
+
+    def getInfo(self):
+        """Returns Oracle Database server version."""
+        c = self._execute(None, "SELECT * FROM V$VERSION WHERE ROWNUM < 2")
+        res = self._fetchone(c)
+        c.close()
+        return res
+
+    def hasCache(self):
+        """Returns self.cache_connection."""
+        if self.cache_connection:
+            return True
+        return False
+
+    def getSpatialInfo(self):
+        """Returns Oracle Spatial version."""
+        if not self.has_spatial:
+            return
+
+        try:
+            c = self._execute(None, "SELECT SDO_VERSION FROM DUAL")
+        except DbError:
+            return
+        res = self._fetchone(c)
+        c.close()
+
+        return res
+
+    def hasSpatialSupport(self):
+        """Find if there is Spatial support."""
+        return self.has_spatial
+
+    def hasRasterSupport(self):
+        """No raster support for the moment!"""
+        # return self.has_raster
+        return False
+
+    def hasCustomQuerySupport(self):
+        """From QGIS v2.2 onwards Oracle custom queries are supported."""
+        return Qgis.QGIS_VERSION_INT >= 20200
+
+    def hasTableColumnEditingSupport(self):
+        """Tables can always be edited."""
+        return True
+
+    def hasCreateSpatialViewSupport(self):
+        """We can create Spatial Views."""
+        return True
+
+    def fieldTypes(self):
+        """From
+        http://docs.oracle.com/cd/B28359_01/server.111/b28318/datatype.htm#CNCPT1828
+        """
+        return [
+            "number", "number(9)",  # integers
+            "number(9,2)", "number(*,4)", "binary_float",
+            "binary_double",  # floats
+            "varchar2(255)", "char(20)", "nvarchar2(255)",
+            "nchar(20)",  # strings
+            "date", "timestamp"  # date/time
+        ]
+
+    def getSchemaPrivileges(self, schema):
+        """
+        Schema privileges:
+        (can create new objects, can access objects in schema)
+        """
+        # TODO: find the best way in Oracle do determine schema privileges
+        schema = self.user if not schema else schema
+
+        # In Oracle world, rights seems quite simple: only schema_owner can
+        # create table in the schema
+        if schema == self.user:
+            return (True, True)
+        # getSchemas request only extract schemas where user has access
+        return (False, True)
+
+    def getRawTablePrivileges(self, table, owner, grantee):
+        """
+        Retrieve privileges on a table in a schema for a specific
+        user.
+        """
+        result = [False, False, False, False]
+        # Inspect in all tab privs
+        sql = """
+        SELECT DISTINCT PRIVILEGE
+        FROM ALL_TAB_PRIVS_RECD
+        WHERE PRIVILEGE IN ('SELECT','INSERT','UPDATE','DELETE')
+          AND TABLE_NAME = {}
+          AND OWNER = {}
+          AND GRANTEE IN ({}, {})
+        """.format(self.quoteString(table),
+                   self.quoteString(owner),
+                   self.quoteString(grantee),
+                   self.quoteString(grantee.upper()))
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        c.close()
+
+        # Find which privilege is returned
+        for line in res:
+            if line[0] == "SELECT":
+                result[0] = True
+            if line[0] == "INSERT":
+                result[1] = True
+            if line[0] == "UPDATE":
+                result[2] = True
+            if line[0] == "DELETE":
+                result[3] = True
+
+        return result
+
+    def getTablePrivileges(self, table):
+        """Retrieve table privileges: (select, insert, update, delete)."""
+
+        schema, tablename = self.getSchemaTableName(table)
+        if self.user == schema:
+            return [True, True, True, True]
+        return self.getRawTablePrivileges(tablename, schema, self.user)
+
+    def getSchemasCache(self):
+        """Get the list of schemas from the cache."""
+        sql = """
+        SELECT DISTINCT ownername
+        FROM "oracle_{}"
+        ORDER BY ownername
+        """.format(self.connName)
+        c = self.cache_connection.cursor()
+        c.execute(sql)
+        res = c.fetchall()
+        c.close()
+
+        return res
+
+    def getSchemas(self):
+        """Get list of schemas in tuples:
+        (oid, name, owner, perms, comment).
+        """
+        if self.userTablesOnly:
+            return [(self.user,)]
+
+        if self.hasCache():
+            return self.getSchemasCache()
+
+        # Use cache if available:
+        metatable = ("all_objects WHERE object_type IN "
+                     "('TABLE','VIEW','SYNONYM')")
+        if self.geometryColumnsOnly:
+            metatable = "all_sdo_geom_metadata"
+
+        sql = """SELECT DISTINCT owner FROM {} ORDER BY owner""".format(
+            metatable)
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        c.close()
+
+        return res
+
+    def getTables(self, schema=None, add_sys_tables=False):
+        """Get list of tables."""
+        if self.hasCache() and not self.populated:
+            self.populated = True
+            return self.getTablesCache(schema)
+
+        tablenames = []
+        items = []
+
+        try:
+            vectors = self.getVectorTables(schema)
+            for tbl in vectors:
+                tablenames.append((tbl[2], tbl[1]))
+                items.append(tbl)
+        except DbError:
+            pass
+
+        if self.allowGeometrylessTables:
+            # get all non geographic tables and views
+            prefix = "ALL"
+            owner = "o.owner"
+            where = ""
+            if self.userTablesOnly:
+                prefix = "USER"
+                owner = "user As OWNER"
+            if schema and not self.userTablesOnly:
+                where = "AND o.owner = {} ".format(
+                    self.quoteString(schema))
+
+            sql = """
+            SELECT o.OBJECT_NAME, {},
+                   CASE o.OBJECT_TYPE
+                   WHEN 'VIEW' THEN 1
+                   ELSE 0 END As isView
+            FROM {}_OBJECTS o
+            WHERE o.object_type IN ('TABLE','VIEW','SYNONYM')
+            {} {}
+            ORDER BY o.OBJECT_NAME
+            """.format(owner, prefix, where,
+                       "" if add_sys_tables
+                       else "AND o.OBJECT_NAME NOT LIKE 'MDRT_%'")
+
+            c = self._execute(None, sql)
+            for tbl in self._fetchall(c):
+                if tablenames.count((tbl[1], tbl[0])) <= 0:
+                    item = list(tbl)
+                    item.insert(0, Table.TableType)
+                    items.append(item)
+
+            c.close()
+
+        self.populated = True
+
+        listTables = sorted(items, key=cmp_to_key(lambda x, y: (x[1] > y[1]) - (x[1] < y[1])))
+
+        if self.hasCache():
+            self.updateCache(listTables, schema)
+            return self.getTablesCache(schema)
+
+        return listTables
+
+    def getTablesCache(self, schema=None):
+        """Get list of tables from SQLite cache."""
+
+        tablenames = []
+        items = []
+
+        try:
+            vectors = self.getVectorTablesCache(schema)
+            for tbl in vectors:
+                tablenames.append((tbl[2], tbl[1]))
+                items.append(tbl)
+        except DbError:
+            pass
+
+        if not self.allowGeometrylessTables:
+            return sorted(items, key=cmp_to_key(lambda x, y: (x[1] > y[1]) - (x[1] < y[1])))
+
+        # get all non geographic tables and views
+        schema_where = ""
+        if self.userTablesOnly:
+            schema_where = "AND ownername = '{}'".format(
+                self.user)
+        if schema and not self.userTablesOnly:
+            schema_where = "AND ownername = '{}'".format(
+                schema)
+
+        sql = """
+        SELECT tablename, ownername, isview
+        FROM "oracle_{}"
+        WHERE geometrycolname IS '' {}
+        ORDER BY tablename
+        """.format(self.connName, schema_where)
+
+        c = self.cache_connection.cursor()
+        c.execute(sql)
+        for tbl in c.fetchall():
+            if tablenames.count((tbl[1], tbl[0])) <= 0:
+                item = list(tbl)
+                item.insert(0, Table.TableType)
+                items.append(item)
+        c.close()
+
+        return sorted(items, key=cmp_to_key(lambda x, y: (x[1] > y[1]) - (x[1] < y[1])))
+
+    def updateCache(self, tableList, schema=None):
+        """Updates the SQLite cache of table list for a schema."""
+
+        data = []
+        # First, we treat the list
+        for table in tableList:
+            line = ()
+            # if the table is a view bring pkCols
+            pkCols = None
+            if int(table[3]) == 1:
+                pkCols = self.pkCols((schema, table[1]))
+            # Deals with non-geographic tables
+            if table[0] == Table.TableType:
+                line = (table[1], table[2], int(table[3]),
+                        "",
+                        ",".join(pkCols) if pkCols else "",
+                        100, 0, "")
+            # Deals with vector tables
+            elif table[0] == Table.VectorType:
+                line = (table[1], table[2], int(table[3]),
+                        table[4],
+                        ",".join(pkCols) if pkCols else "",
+                        table[9],
+                        table[8] if table[10] == "-1" else table[10],
+                        "")
+            else:
+                continue
+            data.append(line)
+
+        # Then, empty the cache list
+        sql = """
+        DELETE FROM "oracle_{}" {}
+        """.format(self.connName,
+                   "WHERE ownername = '{}'".format(schema) if schema else "")
+        self.cache_connection.execute(sql)
+        self.cache_connection.commit()
+
+        # Then we insert into SQLite database
+        sql = """
+        INSERT INTO "oracle_{}"(tablename, ownername, isview,
+        geometrycolname, pkcols, geomtypes, geomsrids, sql)
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+        """.format(self.connName)
+        c = self.cache_connection.cursor()
+        c.executemany(sql, data)
+        c.close()
+        self.cache_connection.commit()
+
+    def singleGeomTypes(self, geomtypes, srids):
+        """Intelligent wkbtype grouping (multi with non multi)"""
+        if (QgsWkbTypes.Polygon in geomtypes
+                and QgsWkbTypes.MultiPolygon in geomtypes):
+            srids.pop(geomtypes.index(QgsWkbTypes.Polygon))
+            geomtypes.pop(geomtypes.index(QgsWkbTypes.Polygon))
+        if (QgsWkbTypes.Point in geomtypes
+                and QgsWkbTypes.MultiPoint in geomtypes):
+            srids.pop(geomtypes.index(QgsWkbTypes.Point))
+            geomtypes.pop(geomtypes.index(QgsWkbTypes.Point))
+        if (QgsWkbTypes.LineString in geomtypes
+                and QgsWkbTypes.MultiLineString in geomtypes):
+            srids.pop(geomtypes.index(QgsWkbTypes.LineString))
+            geomtypes.pop(geomtypes.index(QgsWkbTypes.LineString))
+        if QgsWkbTypes.Unknown in geomtypes and len(geomtypes) > 1:
+            srids.pop(geomtypes.index(QgsWkbTypes.Unknown))
+            geomtypes.pop(geomtypes.index(QgsWkbTypes.Unknown))
+
+        return geomtypes, srids
+
+    def getVectorTablesCache(self, schema=None):
+        """Get list of table with a geometry column from SQLite cache
+        it returns:
+        name (table name)
+        namespace (schema)
+        type = 'view' (is a view?)
+        geometry_column
+        geometry_types (as WKB type)
+        srids
+        """
+        schema_where = ""
+        if self.userTablesOnly:
+            schema_where = "AND ownername = '{}'".format(
+                self.user)
+        if schema and not self.userTablesOnly:
+            schema_where = "AND ownername = '{}'".format(
+                schema)
+
+        sql = """
+        SELECT tablename, ownername, isview,
+               geometrycolname,
+               geomtypes, geomsrids
+        FROM "oracle_{}"
+        WHERE geometrycolname IS NOT '' {}
+        ORDER BY tablename
+        """.format(self.connName, schema_where)
+
+        items = []
+
+        c = self.cache_connection.cursor()
+        c.execute(sql)
+        lst_tables = c.fetchall()
+        c.close()
+
+        # Handle multiple geometries tables
+        for i, tbl in enumerate(lst_tables):
+            item = list(tbl)
+            srids = item.pop()
+            geomtypes = item.pop()
+            item.insert(0, Table.VectorType)
+            if len(geomtypes) > 0 and len(srids) > 0:
+                geomtypes = [int(l) for l in str(geomtypes).split(",")]
+                srids = [int(l) for l in str(srids).split(",")]
+                geomtypes, srids = self.singleGeomTypes(geomtypes, srids)
+                for j in range(len(geomtypes)):
+                    buf = list(item)
+                    geomtype = geomtypes[j]
+                    srid = srids[j]
+                    datatype = QgsWkbTypes.displayString(QgsWkbTypes.flatType(QgsWkbTypes.singleType(geomtype)))
+                    geo = datatype.upper()
+                    buf.append(geo)
+                    buf.append(geomtype)
+                    buf.append(QgsWkbTypes.coordDimensions(geomtype))  # Dimensions
+                    buf.append(srid)
+                    buf.append(None)  # To respect ORTableVector row
+                    buf.append(None)  # To respect ORTableVector row
+                    items.append(buf)
+
+        return items
+
+    def getVectorTables(self, schema=None):
+        """Get list of table with a geometry column
+        it returns a table of tuples:
+            name (table name)
+            namespace (schema/owner)
+            isView (is a view?)
+            geometry_column
+            srid
+        """
+        if not self.has_spatial:
+            return []
+
+        # discovery of all geographic tables
+        prefix = "all"
+        owner = "c.owner"
+        where = None
+
+        if not self.geometryColumnsOnly:
+            where = "WHERE c.data_type = 'SDO_GEOMETRY'"
+        if schema and not self.userTablesOnly:
+            where = "{} c.owner = {}".format(
+                "{} AND".format(where) if where else "WHERE",
+                self.quoteString(schema))
+
+        if self.userTablesOnly:
+            prefix = "user"
+            owner = "user As owner"
+            if self.geometryColumnsOnly:
+                where = ""
+
+        sql = """
+        SELECT c.table_name, {0},
+               CASE o.OBJECT_TYPE
+               WHEN 'VIEW' THEN 1
+               ELSE 0 END As isView,
+               c.column_name,
+               {1}
+        FROM {2}_{3} c
+        JOIN {2}_objects o ON c.table_name = o.object_name
+             AND o.object_type IN ('TABLE','VIEW','SYNONYM') {4} {5}
+        ORDER BY TABLE_NAME
+        """.format(owner,
+                   "c.srid" if self.geometryColumnsOnly
+                   else "NULL as srid",
+                   prefix,
+                   "sdo_geom_metadata" if self.geometryColumnsOnly
+                   else "tab_columns",
+                   "" if self.userTablesOnly
+                   else "AND c.owner = o.owner",
+                   where)
+
+        # For each table, get all of the details
+        items = []
+
+        c = self._execute(None, sql)
+        lst_tables = self._fetchall(c)
+        c.close()
+
+        for i, tbl in enumerate(lst_tables):
+            item = list(tbl)
+            detectedSrid = item.pop()
+            if detectedSrid == NULL:
+                detectedSrid = "-1"
+            else:
+                detectedSrid = int(detectedSrid)
+
+            if schema:
+                table_name = "{}.{}".format(self.quoteId(schema), self.quoteId(item[0]))
+            else:
+                table_name = self.quoteId(item[0])
+            geocol = self.quoteId(item[3])
+            geomMultiTypes, multiSrids = self.getTableGeomTypes(
+                table_name, geocol)
+            geomtypes = list(geomMultiTypes)
+            srids = list(multiSrids)
+            item.insert(0, Table.VectorType)
+
+            geomtypes, srids = self.singleGeomTypes(geomtypes, srids)
+
+            for j in range(len(geomtypes)):
+                buf = list(item)
+                geomtype = geomtypes[j]
+                datatype = QgsWkbTypes.displayString(QgsWkbTypes.flatType(QgsWkbTypes.singleType(geomtype)))
+                geo = datatype.upper()
+                buf.append(geo)  # Geometry type as String
+                buf.append(geomtype)  # Qgis.WkbType
+                buf.append(QgsWkbTypes.coordDimensions(geomtype))  # Dimensions
+                buf.append(detectedSrid)  # srid
+                if not self.onlyExistingTypes:
+                    geomMultiTypes.append(0)
+                    multiSrids.append(multiSrids[0])
+                buf.append(",".join([str(x) for x in
+                                     geomMultiTypes]))
+                buf.append(",".join([str(x) for x in multiSrids]))
+                items.append(buf)
+
+            if self.allowGeometrylessTables and buf[-6] != "UNKNOWN":
+                copybuf = list(buf)
+                copybuf[4] = ""
+                copybuf[-6] = "UNKNOWN"
+                copybuf[-5] = QgsWkbTypes.NullGeometry
+                copybuf[-2] = QgsWkbTypes.NullGeometry
+                copybuf[-1] = "0"
+                items.append(copybuf)
+
+        return items
+
+    def getTableComment(self, table, objectType):
+        """Return the general comment for the object"""
+
+        schema, tablename = self.getSchemaTableName(table)
+        data_prefix = "ALL" if schema else "USER"
+        where = "AND OWNER = {}".format(
+            self.quoteString(schema)) if schema else ""
+        if objectType in ["TABLE", "VIEW"]:
+            data_table = "{}_TAB_COMMENTS"
+            table = "TABLE"
+        elif objectType == "MATERIALIZED VIEW":
+            data_table = "{}_MVIEW_COMMENTS"
+            table = "MVIEW"
+        else:
+            return None
+
+        data_table = data_table.format(data_prefix)
+        sql = """
+        SELECT COMMENTS FROM {} WHERE {}_NAME = {}
+        {}
+        """.format(data_table, table,
+                   self.quoteString(tablename),
+                   where)
+
+        c = self._execute(None, sql)
+        res = self._fetchone(c)
+        c.close()
+
+        if res:
+            return res[0]
+
+        return None
+
+    def getTableType(self, table):
+        """Return the type of a table between the following:
+        * Table
+        * View
+        * Materialized view
+        """
+
+        schema, tablename = self.getSchemaTableName(table)
+        sql = """
+        SELECT OBJECT_TYPE FROM {0} WHERE OBJECT_NAME = {1} {2}
+        """
+        if schema:
+            sql = sql.format("ALL_OBJECTS",
+                             self.quoteString(tablename),
+                             "AND OWNER = {}".format(
+                                 self.quoteString(schema)))
+        else:
+            sql = sql.format("USER_OBJECTS",
+                             self.quoteString(tablename),
+                             "")
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        c.close()
+
+        # Analyze return values
+        if not res:
+            return False
+        else:
+            types = [x[0] for x in res]
+            if "MATERIALIZED VIEW" in types:
+                return "MATERIALIZED VIEW"
+            elif "VIEW" in types:
+                return "VIEW"
+            else:
+                return "TABLE"
+
+    def pkCols(self, table):
+        """Return the primary keys candidates for a view."""
+        schema, tablename = self.getSchemaTableName(table)
+        sql = """
+        SELECT column_name
+        FROM all_tab_columns
+        WHERE owner={}
+        AND table_name={}
+        ORDER BY column_id
+        """.format(self.quoteString(schema) if schema else self.user,
+                   self.quoteString(tablename))
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        c.close()
+
+        return [x[0] for x in res] if res else None
+
+    def getTableGeomTypes(self, table, geomCol):
+        """Return all the wkbTypes for a table by requesting geometry
+        column.
+        """
+
+        estimated = ""
+        if self.useEstimatedMetadata:
+            estimated = "AND ROWNUM < 100"
+
+        # Grab all of geometry types from the layer
+        query = """
+        SELECT DISTINCT a.{0}.SDO_GTYPE As gtype,
+                        a.{0}.SDO_SRID
+        FROM {1} a
+        WHERE a.{0} IS NOT NULL {2}
+        ORDER BY a.{0}.SDO_GTYPE
+        """.format(geomCol, table, estimated)
+
+        try:
+            c = self._execute(None, query)
+        except DbError:  # handle error views or other problems
+            return [QgsWkbTypes.Unknown], [-1]
+
+        rows = self._fetchall(c)
+        c.close()
+
+        # Handle results
+        if len(rows) == 0:
+            return [QgsWkbTypes.Unknown], [-1]
+
+        # A dict to store the geomtypes
+        geomtypes = []
+        srids = []
+        for row in rows:
+            if row[1] == NULL:
+                srids.append(-1)
+            else:
+                srids.append(int(row[1]))
+            if int(row[0]) in list(OracleDBConnector.ORGeomTypes.keys()):
+                geomtypes.append(OracleDBConnector.ORGeomTypes[int(row[0])])
+            else:
+                geomtypes.append(QgsWkbTypes.Unknown)
+
+        return geomtypes, srids
+
+    def getTableMainGeomType(self, table, geomCol):
+        """Return the best wkbType for a table by requesting geometry
+        column.
+        """
+
+        geomTypes, srids = self.getTableGeomTypes(table, geomCol)
+
+        # Make the decision:
+        wkbType = QgsWkbTypes.Unknown
+        srid = -1
+        order = [QgsWkbTypes.MultiPolygon25D, QgsWkbTypes.Polygon25D,
+                 QgsWkbTypes.MultiPolygon, QgsWkbTypes.Polygon,
+                 QgsWkbTypes.MultiLineString25D, QgsWkbTypes.LineString25D,
+                 QgsWkbTypes.MultiLineString, QgsWkbTypes.LineString,
+                 QgsWkbTypes.MultiPoint25D, QgsWkbTypes.Point25D,
+                 QgsWkbTypes.MultiPoint, QgsWkbTypes.Point]
+        for geomType in order:
+            if geomType in geomTypes:
+                wkbType = geomType
+                srid = srids[geomTypes.index(geomType)]
+                break
+
+        return wkbType, srid
+
+    def getTableRowEstimation(self, table):
+        """ Find the estimated number of rows of a table. """
+        schema, tablename = self.getSchemaTableName(table)
+        prefix = "ALL" if schema else "USER"
+        where = "AND OWNER = {}".format(
+            self.quoteString(schema)) if schema else ""
+
+        sql = """
+        SELECT NUM_ROWS FROM {}_ALL_TABLES
+        WHERE TABLE_NAME = {}
+        {}
+        """.format(prefix, self.quoteString(tablename), where)
+
+        c = self._execute(None, sql)
+        res = self._fetchone(c)
+        c.close()
+
+        if not res or res[0] == NULL:
+            return 0
+        else:
+            return int(res[0])
+
+    def getTableDates(self, table):
+        """ Returns the modification/creation dates of an object"""
+        schema, tablename = self.getSchemaTableName(table)
+        prefix = "ALL" if schema else "USER"
+        where = "AND OWNER = {}".format(
+            self.quoteString(schema)) if schema else ""
+
+        sql = """
+        SELECT CREATED, LAST_DDL_TIME FROM {}_OBJECTS
+        WHERE OBJECT_NAME = {}
+        {}
+        """.format(prefix, self.quoteString(tablename), where)
+
+        c = self._execute(None, sql)
+        res = self._fetchone(c)
+        c.close()
+
+        if not res:
+            return None, None
+
+        return res[0], res[1]
+
+    def getTableRowCount(self, table):
+        """Returns the number of rows of the table."""
+        c = self._execute(
+            None, "SELECT COUNT(*) FROM {}".format(self.quoteId(table)))
+        res = self._fetchone(c)[0]
+        c.close()
+
+        return res
+
+    def getTableFields(self, table):
+        """Returns list of columns in table."""
+
+        schema, tablename = self.getSchemaTableName(table)
+        schema_where = " AND a.OWNER={}".format(
+            self.quoteString(schema) if schema else "")
+        sql = """
+        SELECT a.COLUMN_ID As ordinal_position,
+               a.COLUMN_NAME As column_name,
+               a.DATA_TYPE As data_type,
+               CASE a.DATA_TYPE
+                 WHEN 'NUMBER' THEN a.DATA_PRECISION
+                 ELSE a.DATA_LENGTH END As char_max_len,
+               a.DATA_SCALE As modifier,
+               a.NULLABLE As nullable,
+               a.DEFAULT_LENGTH As hasdefault,
+               a.DATA_DEFAULT As default_value,
+               a.DATA_TYPE As formatted_type,
+               c.COMMENTS
+        FROM ALL_TAB_COLUMNS a
+            JOIN ALL_COL_COMMENTS c ON
+                a.TABLE_NAME = c.TABLE_NAME
+                AND a.COLUMN_NAME = c.COLUMN_NAME
+                AND a.OWNER = c.OWNER
+        WHERE a.TABLE_NAME = {} {}
+        ORDER BY a.COLUMN_ID
+        """.format(self.quoteString(tablename), schema_where)
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        c.close()
+        return res
+
+    def getSpatialFields(self, table):
+        """Returns the list of geometric columns"""
+        fields = self.getTableFields(table)
+        geomFields = []
+        for field in fields:
+            if field[2] == "SDO_GEOMETRY":
+                geomFields.append(field[1])
+
+        return geomFields
+
+    def getTableIndexes(self, table):
+        """Get info about table's indexes."""
+        schema, tablename = self.getSchemaTableName(table)
+        schema_where = " AND i.OWNER = {} ".format(
+            self.quoteString(schema) if schema else "")
+
+        sql = """
+        SELECT i.INDEX_NAME, c.COLUMN_NAME, i.ITYP_NAME,
+               i.STATUS, i.LAST_ANALYZED, i.COMPRESSION,
+               i.UNIQUENESS
+        FROM ALL_INDEXES i
+        INNER JOIN ALL_IND_COLUMNS c ON i.index_name = c.index_name
+        WHERE i.table_name = {} {}
+        """.format(self.quoteString(tablename), schema_where)
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        c.close()
+
+        return res
+
+    def getMViewInfo(self, table):
+        """Find some information about materialized views"""
+        schema, tablename = self.getSchemaTableName(table)
+        where = " AND a.OWNER = {} ".format(
+            self.quoteString(schema)) if schema else ""
+        prefix = "ALL" if schema else "USER"
+        sql = """
+        SELECT a.REFRESH_MODE,
+               a.REFRESH_METHOD, a.BUILD_MODE, a.FAST_REFRESHABLE,
+               a.LAST_REFRESH_TYPE, a.LAST_REFRESH_DATE, a.STALENESS,
+               a.STALE_SINCE, a.COMPILE_STATE, a.USE_NO_INDEX
+        FROM {}_MVIEWS a
+        WHERE MVIEW_NAME = {}
+        {}
+        """.format(prefix, self.quoteString(tablename), where)
+
+        c = self._execute(None, sql)
+        res = self._fetchone(c)
+        c.close()
+
+        return res
+
+    def getTableConstraints(self, table):
+        """Find all the constraints for a table."""
+        schema, tablename = self.getSchemaTableName(table)
+        schema_where = " AND c.OWNER={} ".format(
+            self.quoteString(schema)) if schema else ""
+
+        sql = """
+        SELECT a.CONSTRAINT_NAME, a.CONSTRAINT_TYPE,
+               c.COLUMN_NAME, a.VALIDATED, a.GENERATED, a.STATUS,
+               a.SEARCH_CONDITION, a.DELETE_RULE,
+               CASE WHEN b.TABLE_NAME IS NULL THEN NULL
+                    ELSE b.OWNER || '.' || b.TABLE_NAME END
+               As F_TABLE, b.COLUMN_NAME As F_COLUMN
+        FROM ALL_CONS_COLUMNS c
+        INNER JOIN ALL_CONSTRAINTS a ON
+                   a.CONSTRAINT_NAME = c.CONSTRAINT_NAME
+        LEFT OUTER JOIN ALL_CONS_COLUMNS b ON
+                        b.CONSTRAINT_NAME = a.R_CONSTRAINT_NAME
+                        AND a.R_OWNER = b.OWNER
+                        AND b.POSITION = c.POSITION
+        WHERE c.TABLE_NAME = {} {}
+        """.format(self.quoteString(tablename), schema_where)
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        c.close()
+
+        return res
+
+    def getTableTriggers(self, table):
+        """Find all the triggers of the table."""
+        schema, tablename = self.getSchemaTableName(table)
+
+        sql = """
+        SELECT TRIGGER_NAME, TRIGGERING_EVENT, TRIGGER_TYPE, STATUS
+        FROM ALL_TRIGGERS
+        WHERE TABLE_OWNER = {}
+        AND TABLE_NAME = {}
+        """.format(self.quoteString(schema), self.quoteString(tablename))
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        c.close()
+
+        return res
+
+    def enableAllTableTriggers(self, enable, table):
+        """Enable or disable all triggers on table."""
+        triggers = [l[0] for l in self.getTableTriggers(table)]
+        for trigger in triggers:
+            self.enableTableTrigger(trigger, enable, table)
+
+    def enableTableTrigger(self, trigger, enable, table):
+        """Enable or disable one trigger on table."""
+        schema, tablename = self.getSchemaTableName(table)
+        trigger = ".".join([self.quoteId(schema), self.quoteId(trigger)])
+        sql = "ALTER TRIGGER {} {}".format(trigger, "ENABLE" if enable else "DISABLE")
+        self._execute_and_commit(sql)
+
+    def deleteTableTrigger(self, trigger, table):
+        """Deletes the trigger on a table."""
+        schema, tablename = self.getSchemaTableName(table)
+        trigger = ".".join([self.quoteId(schema), self.quoteId(trigger)])
+        sql = "DROP TRIGGER {}".format(trigger)
+        self._execute_and_commit(sql)
+
+    def canUpdateMetadata(self, table):
+        """Verify if user can update metadata table
+        returns False or metadata table name.
+        """
+        schema, tablename = self.getSchemaTableName(table)
+        metadata = False
+        # User can only update in USER_SDO_GEOM_METADATA
+        if self.getRawTablePrivileges('USER_SDO_GEOM_METADATA', 'MDSYS',
+                                      'PUBLIC')[2]:
+            tbQuery = """
+            SELECT COUNT(*) FROM USER_SDO_GEOM_METADATA
+            WHERE TABLE_NAME = {}
+            """.format(self.quoteString(tablename))
+            c = self._execute(None, tbQuery)
+            res = self._fetchone(c)
+            c.close()
+
+            if res:
+                if res[0] > 0:
+                    metadata = True
+
+        return metadata
+
+    def getTableExtent(self, table, geom):
+        """Calculate the real table extent."""
+        schema, tablename = self.getSchemaTableName(table)
+        tableQuote = "'{}.{}'".format(schema, tablename)
+        # Extent calculation without spatial index
+        extentFunction = """SDO_AGGR_MBR("{}")""".format(geom)
+        fromTable = '"{}"."{}"'.format(schema, tablename)
+
+        # if table as spatial index:
+        indexes = self.getTableIndexes(table)
+        if indexes:
+            if "SPATIAL_INDEX" in [f[2] for f in indexes]:
+                extentFunction = "SDO_TUNE.EXTENT_OF({}, {})".format(
+                    tableQuote, self.quoteString(geom))
+                fromTable = "DUAL"
+
+        sql = """
+        SELECT
+        SDO_GEOM.SDO_MIN_MBR_ORDINATE({0}, 1),
+        SDO_GEOM.SDO_MIN_MBR_ORDINATE({0}, 2),
+        SDO_GEOM.SDO_MAX_MBR_ORDINATE({0}, 1),
+        SDO_GEOM.SDO_MAX_MBR_ORDINATE({0}, 2)
+        FROM {1}
+        """.format(extentFunction, fromTable)
+
+        try:
+            c = self._execute(None, sql)
+        except DbError:  # no spatial index on table, try aggregation
+            return None
+
+        res = self._fetchone(c)
+        c.close()
+
+        if not res:
+            res = None
+
+        return res if res else None
+
+    def getTableEstimatedExtent(self, table, geom):
+        """Find out estimated extent (from metadata view)."""
+        res = []
+        schema, tablename = self.getSchemaTableName(table)
+        where = """
+        WHERE TABLE_NAME = {}
+        AND COLUMN_NAME = {}
+        """.format(self.quoteString(tablename),
+                   self.quoteString(geom))
+        if schema:
+            where = "{} AND OWNER = {}".format(
+                where, self.quoteString(schema))
+
+        request = """
+        SELECT SDO_LB, SDO_UB
+        FROM ALL_SDO_GEOM_METADATA m,
+             TABLE(m.DIMINFO)
+        {0}
+        AND SDO_DIMNAME = '{1}'
+        """
+        for dimension in ["X", "Y"]:
+            sql = request.format(where, dimension)
+            try:
+                c = self._execute(None, sql)
+            except DbError:  # no statistics for the current table
+                return None
+
+            res_d = self._fetchone(c)
+            c.close()
+
+            if not res_d or len(res_d) < 2:
+                return None
+            elif res_d[0] == NULL:
+                return None
+            else:
+                res.extend(res_d)
+
+        return [res[0], res[2], res[1], res[3]]
+
+    def getDefinition(self, view, objectType):
+        """Returns definition of the view."""
+
+        schema, tablename = self.getSchemaTableName(view)
+        where = ""
+        if schema:
+            where = " AND OWNER={} ".format(
+                self.quoteString(schema))
+
+        # Query to grab a view definition
+        if objectType == "VIEW":
+            sql = """
+            SELECT TEXT FROM ALL_VIEWS WHERE VIEW_NAME = {} {}
+            """.format(self.quoteString(tablename), where)
+        elif objectType == "MATERIALIZED VIEW":
+            sql = """
+            SELECT QUERY FROM ALL_MVIEWS WHERE MVIEW_NAME = {} {}
+            """.format(self.quoteString(tablename), where)
+        else:
+            return None
+
+        c = self._execute(None, sql)
+        res = self._fetchone(c)
+        c.close()
+
+        return res[0] if res else None
+
+    def getSpatialRefInfo(self, srid):
+        """Returns human name from an srid as describe in Oracle sys
+        table.
+        """
+        if not self.has_spatial:
+            return
+
+        try:
+            c = self._execute(
+                None,
+                ("SELECT CS_NAME FROM MDSYS.CS_SRS WHERE"
+                 " SRID = {}".format(srid)))
+        except DbError:
+            return
+        sr = self._fetchone(c)
+        c.close()
+
+        return sr[0] if sr else None
+
+    def isVectorTable(self, table):
+        """Determine if a table is a vector one by looking into
+        metadata view.
+        """
+        if self.has_geometry_columns and self.has_geometry_columns_access:
+            schema, tablename = self.getSchemaTableName(table)
+            where = "WHERE TABLE_NAME = {}".format(
+                self.quoteString(tablename))
+            if schema:
+                where = "{} AND OWNER = {}".format(where,
+                                                   self.quoteString(schema))
+            sql = """
+            SELECT COUNT(*)
+            FROM ALL_SDO_GEOM_METADATA
+            {}
+            """.format(where)
+
+            c = self._execute(None, sql)
+            res = self._fetchone(c)
+            c.close()
+            return res is not None and res[0] > 0
+
+        return False
+
+    def createTable(self, table, field_defs, pkey):
+        """Creates ordinary table
+        'fields' is array containing field definitions
+        'pkey' is the primary key name
+        """
+        if len(field_defs) == 0:
+            return False
+
+        sql = "CREATE TABLE {} (".format(self.quoteId(table))
+        sql += ", ".join(field_defs)
+        if pkey:
+            sql += ", PRIMARY KEY ({})".format(self.quoteId(pkey))
+        sql += ")"
+
+        self._execute_and_commit(sql)
+        return True
+
+    def deleteTable(self, table):
+        """Deletes table and its reference in sdo_geom_metadata."""
+
+        schema, tablename = self.getSchemaTableName(table)
+
+        if self.isVectorTable(table):
+            self.deleteMetadata(table)
+
+        sql = "DROP TABLE {}".format(self.quoteId(table))
+        self._execute_and_commit(sql)
+
+    def emptyTable(self, table):
+        """Deletes all the rows of a table."""
+
+        sql = "TRUNCATE TABLE {}".format(self.quoteId(table))
+        self._execute_and_commit(sql)
+
+    def renameTable(self, table, new_table):
+        """Renames a table inside the database."""
+        schema, tablename = self.getSchemaTableName(table)
+        if new_table == tablename:
+            return
+
+        c = self._get_cursor()
+
+        # update geometry_columns if Spatial is enabled
+        if self.isVectorTable(table):
+            self.updateMetadata(table, None, new_table=new_table)
+
+        sql = "RENAME {} TO {}".format(
+            self.quoteId(tablename), self.quoteId(new_table))
+        self._execute(c, sql)
+
+        self._commit()
+
+    def createView(self, view, query):
+        """Creates a view as defined."""
+        sql = "CREATE VIEW {} AS {}".format(self.quoteId(view), query)
+        self._execute_and_commit(sql)
+
+    def createSpatialView(self, view, query):
+        """Creates a spatial view and update metadata table."""
+        # What is the view name ?
+        if len(view.split(".")) > 1:
+            schema, view = view.split(".")
+        else:
+            schema = self.user
+        view = (schema, view)
+
+        # First create the view
+        self.createView(view, query)
+
+        # Grab the geometric column(s)
+        fields = self.getSpatialFields(view)
+        if not fields:
+            return False
+
+        for geoCol in fields:
+            # Grab SRID
+            geomTypes, srids = self.getTableGeomTypes(view, geoCol)
+
+            # Calculate the extent
+            extent = self.getTableExtent(view, geoCol)
+
+            # Insert information into metadata table
+            self.insertMetadata(view, geoCol, extent, srids[0])
+
+        return True
+
+    def deleteView(self, view):
+        """Deletes a view."""
+        schema, tablename = self.getSchemaTableName(view)
+
+        if self.isVectorTable(view):
+            self.deleteMetadata(view)
+
+        sql = "DROP VIEW {}".format(self.quoteId(view))
+        self._execute_and_commit(sql)
+
+    def createSchema(self, schema):
+        """Creates a new empty schema in database."""
+        # Not tested
+        sql = "CREATE SCHEMA AUTHORIZATION {}".format(
+            self.quoteId(schema))
+        self._execute_and_commit(sql)
+
+    def deleteSchema(self, schema):
+        """Drops (empty) schema from database."""
+        sql = "DROP USER {} CASCADE".format(self.quoteId(schema))
+        self._execute_and_commit(sql)
+
+    def renameSchema(self, schema, new_schema):
+        """Renames a schema in the database."""
+        # Unsupported in Oracle
+        pass
+
+    def addTableColumn(self, table, field_def):
+        """Adds a column to a table."""
+        sql = "ALTER TABLE {} ADD {}".format(self.quoteId(table), field_def)
+        self._execute_and_commit(sql)
+
+    def deleteTableColumn(self, table, column):
+        """Deletes column from a table."""
+        # Delete all the constraints for this column
+        constraints = [f[0] for f in self.getTableConstraints(table)
+                       if f[2] == column]
+        for constraint in constraints:
+            self.deleteTableConstraint(table, constraint)
+
+        # Delete all the indexes for this column
+        indexes = [f[0] for f in self.getTableIndexes(table) if f[1] == column]
+        for ind in indexes:
+            self.deleteTableIndex(table, ind)
+
+        # Delete metadata is we have a geo column
+        if self.isGeometryColumn(table, column):
+            self.deleteMetadata(table, column)
+
+        sql = "ALTER TABLE {} DROP COLUMN {}".format(
+            self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def updateTableColumn(self, table, column, new_name=None,
+                          data_type=None, not_null=None,
+                          default=None, comment=None):
+        """Updates properties of a column in a table."""
+
+        schema, tablename = self.getSchemaTableName(table)
+
+        c = self._get_cursor()
+
+        # update column definition
+        col_actions = []
+        if data_type:
+            col_actions.append("{}".format(data_type))
+        if default:
+            col_actions.append("DEFAULT {}".format(default))
+        else:
+            col_actions.append("DEFAULT NULL")
+
+        if not_null:
+            col_actions.append("NOT NULL")
+        if not_null is None:
+            col_actions.append("NULL")
+
+        if col_actions:
+            sql = "ALTER TABLE {} MODIFY ( {} {} )".format(
+                self.quoteId(table), self.quoteId(column),
+                " ".join(col_actions))
+            self._execute(c, sql)
+
+        # rename the column
+        if new_name and new_name != column:
+            isGeo = self.isGeometryColumn(table, column)
+            sql = "ALTER TABLE {} RENAME COLUMN {} TO {}".format(
+                self.quoteId(table), self.quoteId(column),
+                self.quoteId(new_name))
+            self._execute(c, sql)
+
+            # update geometry_columns if Spatial is enabled
+            if isGeo:
+                self.updateMetadata(table, column, new_name)
+
+        self._commit()
+
+    def renameTableColumn(self, table, column, new_name):
+        """Renames column in a table."""
+        return self.updateTableColumn(table, column, new_name)
+
+    def setTableColumnType(self, table, column, data_type):
+        """Changes column type."""
+        return self.updateTableColumn(table, column, None, data_type)
+
+    def setTableColumnNull(self, table, column, is_null):
+        """Changes whether column can contain null values."""
+        return self.updateTableColumn(table, column, None, None, not is_null)
+
+    def setTableColumnDefault(self, table, column, default):
+        """Changes column's default value.
+        If default=None or an empty string drop default value.
+        """
+        return self.updateTableColumn(table, column, None, None, None, default)
+
+    def isGeometryColumn(self, table, column):
+        """Find if a column is geometric."""
+        schema, tablename = self.getSchemaTableName(table)
+        prefix = "ALL" if schema else "USER"
+        where = "AND owner = {} ".format(
+            self.quoteString(schema)) if schema else ""
+
+        sql = """
+        SELECT COUNT(*)
+        FROM {}_SDO_GEOM_METADATA
+        WHERE TABLE_NAME = {}
+              AND COLUMN_NAME = {} {}
+        """.format(prefix, self.quoteString(tablename),
+                   self.quoteString(column.upper()), where)
+
+        c = self._execute(None, sql)
+        res = self._fetchone(c)[0] > 0
+
+        c.close()
+        return res
+
+    def refreshMView(self, table):
+        """Refreshes an MVIEW"""
+        schema, tablename = self.getSchemaTableName(table)
+        mview = "{}.{}".format(schema, tablename) if schema else tablename
+        sql = """
+        BEGIN
+          DBMS_MVIEW.REFRESH({},'?');
+        END;
+        """.format(self.quoteString(mview))
+
+        self._execute_and_commit(sql)
+
+    def deleteMetadata(self, table, geom_column=None):
+        """Deletes the metadata entry for a table"""
+        schema, tablename = self.getSchemaTableName(table)
+        if not (self.getRawTablePrivileges('USER_SDO_GEOM_METADATA',
+                                           'MDSYS',
+                                           'PUBLIC')[3] and
+                schema == self.user):
+            return False
+
+        where = "WHERE TABLE_NAME = {}".format(self.quoteString(tablename))
+        if geom_column:
+            where = ("{} AND COLUMN_NAME = "
+                     "{}".format(where,
+                                 self.quoteString(geom_column)))
+        sql = "DELETE FROM USER_SDO_GEOM_METADATA {}".format(where)
+
+        self._execute_and_commit(sql)
+
+    def updateMetadata(self, table, geom_column, new_geom_column=None,
+                       new_table=None, extent=None, srid=None):
+        """Updates the metadata table with the new information"""
+
+        schema, tablename = self.getSchemaTableName(table)
+        if not (self.getRawTablePrivileges('USER_SDO_GEOM_METADATA',
+                                           'MDSYS',
+                                           'PUBLIC')[2] and
+                schema == self.user):
+            return False
+
+        where = "WHERE TABLE_NAME = {}".format(self.quoteString(tablename))
+        if geom_column:
+            # in Metadata view, geographic column is always in uppercase
+            where = ("{} AND COLUMN_NAME = "
+                     "{}".format(where,
+                                 self.quoteString(geom_column.upper())))
+
+        update = "SET"
+        if srid == 0:
+            srid = -1
+
+        if srid:
+            update = "{} SRID = {}".format(update, srid)
+        if extent:
+            if len(extent) == 4:
+                if update != "SET":
+                    update = "{},".format(update)
+                update = """{4} DIMINFO = MDSYS.SDO_DIM_ARRAY(
+                MDSYS.SDO_DIM_ELEMENT('X', {0:.9f}, {1:.9f}, 0.005),
+                MDSYS.SDO_DIM_ELEMENT('Y', {2:.9f}, {3:.9f}, 0.005))
+                """.format(extent[0], extent[2], extent[1],
+                           extent[3], update)
+        if new_geom_column:
+            if update != "SET":
+                update = "{},".format(update)
+            # in Metadata view, geographic column is always in uppercase
+            update = ("{} COLUMN_NAME = "
+                      "{}".format(update,
+                                  self.quoteString(new_geom_column.upper())))
+
+        if new_table:
+            if update != "SET":
+                update = "{},".format(update)
+            update = ("{} TABLE_NAME = "
+                      "{}".format(update,
+                                  self.quoteString(new_table)))
+
+        sql = "UPDATE USER_SDO_GEOM_METADATA {} {}".format(update, where)
+
+        self._execute_and_commit(sql)
+
+    def insertMetadata(self, table, geom_column, extent, srid, dim=2):
+        """Inserts a line for the table in Oracle Metadata table."""
+        schema, tablename = self.getSchemaTableName(table)
+        if not (self.getRawTablePrivileges('USER_SDO_GEOM_METADATA',
+                                           'MDSYS',
+                                           'PUBLIC')[1] and
+                schema == self.user):
+            return False
+
+        # in Metadata view, geographic column is always in uppercase
+        geom_column = geom_column.upper()
+        if srid == 0:
+            srid = -1
+
+        if len(extent) != 4:
+            return False
+        dims = ['X', 'Y', 'Z', 'T']
+        extentParts = []
+        for i in range(dim):
+            extentParts.append(
+                """MDSYS.SDO_DIM_ELEMENT(
+                '{}', {:.9f}, {:.9f}, 0.005)""".format(dims[i], extent[i], extent[i + 1]))
+        extentParts = ",".join(extentParts)
+        sqlExtent = """MDSYS.SDO_DIM_ARRAY(
+                {})
+                """.format(extentParts)
+
+        sql = """
+        INSERT INTO USER_SDO_GEOM_METADATA (TABLE_NAME,
+                                           COLUMN_NAME, DIMINFO,
+                                           SRID)
+        VALUES({}, {},
+               {},
+               {})
+            """.format(self.quoteString(tablename),
+                       self.quoteString(geom_column),
+                       sqlExtent, str(srid))
+
+        self._execute_and_commit(sql)
+
+    def addGeometryColumn(self, table, geom_column='GEOM',
+                          geom_type=None, srid=-1, dim=2):
+        """Adds a geometry column and update Oracle Spatial
+        metadata.
+        """
+
+        schema, tablename = self.getSchemaTableName(table)
+        # in Metadata view, geographic column is always in uppercase
+        geom_column = geom_column.upper()
+
+        # Add the column to the table
+        sql = "ALTER TABLE {} ADD {} SDO_GEOMETRY".format(
+            self.quoteId(table), self.quoteId(geom_column))
+
+        self._execute_and_commit(sql)
+
+        # Then insert the metadata
+        extent = []
+        for i in range(dim):
+            extent.extend([-100000, 10000])
+        self.insertMetadata(table, geom_column,
+                            [-100000, 100000, -10000, 10000],
+                            srid, dim)
+
+    def deleteGeometryColumn(self, table, geom_column):
+        """Deletes a geometric column."""
+        return self.deleteTableColumn(table, geom_column)
+
+    def addTableUniqueConstraint(self, table, column):
+        """Adds a unique constraint to a table."""
+        sql = "ALTER TABLE {} ADD UNIQUE ({})".format(
+            self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def deleteTableConstraint(self, table, constraint):
+        """Deletes constraint in a table."""
+        sql = "ALTER TABLE {} DROP CONSTRAINT {}".format(
+            self.quoteId(table), self.quoteId(constraint))
+        self._execute_and_commit(sql)
+
+    def addTablePrimaryKey(self, table, column):
+        """Adds a primary key (with one column) to a table."""
+        sql = "ALTER TABLE {} ADD PRIMARY KEY ({})".format(
+            self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def createTableIndex(self, table, name, column):
+        """Creates index on one column using default options."""
+        sql = "CREATE INDEX {} ON {} ({})".format(
+            self.quoteId(name), self.quoteId(table),
+            self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def rebuildTableIndex(self, table, name):
+        """Rebuilds a table index"""
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "ALTER INDEX {} REBUILD".format(self.quoteId((schema, name)))
+        self._execute_and_commit(sql)
+
+    def deleteTableIndex(self, table, name):
+        """Deletes an index on a table."""
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "DROP INDEX {}".format(self.quoteId((schema, name)))
+        self._execute_and_commit(sql)
+
+    def createSpatialIndex(self, table, geom_column='GEOM'):
+        """Creates a spatial index on a geometric column."""
+        geom_column = geom_column.upper()
+        schema, tablename = self.getSchemaTableName(table)
+        idx_name = self.quoteId("sidx_{}_{}".format(tablename, geom_column))
+        sql = """
+        CREATE INDEX {}
+        ON {}({})
+        INDEXTYPE IS MDSYS.SPATIAL_INDEX
+        """.format(idx_name, self.quoteId(table),
+                   self.quoteId(geom_column))
+        self._execute_and_commit(sql)
+
+    def deleteSpatialIndex(self, table, geom_column='GEOM'):
+        """Deletes a spatial index of a geometric column."""
+        schema, tablename = self.getSchemaTableName(table)
+        idx_name = self.quoteId("sidx_{}_{}".format(tablename, geom_column))
+        return self.deleteTableIndex(table, idx_name)
+
+    def execution_error_types(self):
+        return QtSqlDB.ExecError
+
+    def connection_error_types(self):
+        return QtSqlDB.ConnectionError
+
+    def error_types(self):
+        return self.connection_error_types(), self.execution_error_types()
+
+    def _close_cursor(self, c):
+        """new implementation of _close_cursor (because c.closed is
+        psycopg2 specific and not DB API 2.0
+        """
+        try:
+            if c:
+                c.close()
+
+        except self.error_types():
+            pass
+
+        return
+
+    # moved into the parent class: DbConnector._execute()
+    # def _execute(self, cursor, sql):
+    #     pass
+
+    # moved into the parent class: DbConnector._execute_and_commit()
+    # def _execute_and_commit(self, sql):
+    #     pass
+
+    # moved into the parent class: DbConnector._get_cursor()
+    # def _get_cursor(self, name=None):
+    #     pass
+
+    # moved into the parent class: DbConnector._fetchall()
+    # def _fetchall(self, c):
+    #     pass
+
+    # moved into the parent class: DbConnector._fetchone()
+    # def _fetchone(self, c):
+    #     pass
+
+    # moved into the parent class: DbConnector._commit()
+    # def _commit(self):
+    #     pass
+
+    # moved into the parent class: DbConnector._rollback()
+    # def _rollback(self):
+    #     pass
+
+    # moved into the parent class: DbConnector._get_cursor_columns()
+    # def _get_cursor_columns(self, c):
+    #     pass
+
+    def getSqlDictionary(self):
+        """Returns the dictionary for SQL dialog."""
+        from .sql_dictionary import getSqlDictionary
+        sql_dict = getSqlDictionary()
+
+        # get schemas, tables and field names
+        items = []
+
+        # First look into the cache if available
+        if self.hasCache():
+            sql = """
+            SELECT DISTINCT tablename FROM "oracle_{0}"
+            UNION
+            SELECT DISTINCT ownername FROM "oracle_{0}"
+            """.format(self.connName)
+            if self.userTablesOnly:
+                sql = """
+                SELECT DISTINCT tablename
+                FROM "oracle_{conn}" WHERE ownername = '{user}'
+                UNION
+                SELECT DISTINCT ownername
+                FROM "oracle_{conn}" WHERE ownername = '{user}'
+                """.format(conn=self.connName, user=self.user)
+
+            c = self.cache_connection.cursor()
+            c.execute(sql)
+            for row in c.fetchall():
+                items.append(row[0])
+            c.close()
+
+        if self.hasCache():
+            sql = """
+            SELECT DISTINCT COLUMN_NAME FROM {}_TAB_COLUMNS
+            """.format("USER" if self.userTablesOnly else
+                       "ALL")
+        elif self.userTablesOnly:
+            sql = """
+            SELECT DISTINCT TABLE_NAME FROM USER_ALL_TABLES
+            UNION
+            SELECT USER FROM DUAL
+            UNION
+            SELECT DISTINCT COLUMN_NAME FROM USER_TAB_COLUMNS
+            """
+        else:
+            sql = """
+            SELECT TABLE_NAME FROM ALL_ALL_TABLES
+            UNION
+            SELECT DISTINCT OWNER FROM ALL_ALL_TABLES
+            UNION
+            SELECT DISTINCT COLUMN_NAME FROM ALL_TAB_COLUMNS
+            """
+
+        c = self._execute(None, sql)
+        for row in self._fetchall(c):
+            items.append(row[0])
+        c.close()
+
+        sql_dict["identifier"] = items
+        return sql_dict
+
+    def getQueryBuilderDictionary(self):
+        from .sql_dictionary import getQueryBuilderDictionary
+        return getQueryBuilderDictionary()
+
+    def cancel(self):
+        # how to cancel an Oracle query?
+        pass

+ 182 - 0
db_manager/db_plugins/oracle/data_model.py

@@ -0,0 +1,182 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS (Oracle)
+Date                 : Aug 27, 2014
+copyright            : (C) 2014 by Médéric RIBREUX
+email                : mederic.ribreux@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias <wonder.sk@gmail.com> (GPLv2 license)
+- DB Manager by Giuseppe Sucameli <brush.tyler@gmail.com> (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import QTime
+from qgis.core import QgsMessageLog
+from ..data_model import (TableDataModel,
+                          SqlResultModel,
+                          SqlResultModelAsync,
+                          SqlResultModelTask,
+                          BaseTableModel)
+from ..plugin import DbError
+from ..plugin import BaseError
+
+
+class ORTableDataModel(TableDataModel):
+
+    def __init__(self, table, parent=None):
+        self.cursor = None
+        TableDataModel.__init__(self, table, parent)
+
+        if not self.table.rowCount:
+            self.table.refreshRowCount()
+
+        self.table.aboutToChange.connect(self._deleteCursor)
+        self._createCursor()
+
+    def _createCursor(self):
+        fields_txt = ", ".join(self.fields)
+        table_txt = self.db.quoteId(
+            (self.table.schemaName(), self.table.name))
+
+        self.cursor = self.db._get_cursor()
+        sql = "SELECT {} FROM {}".format(fields_txt, table_txt)
+
+        self.db._execute(self.cursor, sql)
+
+    def _sanitizeTableField(self, field):
+        # get fields, ignore geometry columns
+        if field.dataType.upper() == "SDO_GEOMETRY":
+            return ("CASE WHEN {0} IS NULL THEN NULL ELSE 'GEOMETRY'"
+                    "END AS {0}".format(
+                        self.db.quoteId(field.name)))
+        if field.dataType.upper() == "DATE":
+            return "CAST({} AS VARCHAR2(8))".format(
+                self.db.quoteId(field.name))
+        if "TIMESTAMP" in field.dataType.upper():
+            return "TO_CHAR({}, 'YYYY-MM-DD HH:MI:SS.FF')".format(
+                self.db.quoteId(field.name))
+        if field.dataType.upper() == "NUMBER":
+            if not field.charMaxLen:
+                return "CAST({} AS VARCHAR2(135))".format(
+                    self.db.quoteId(field.name))
+            elif field.modifier:
+                nbChars = 2 + int(field.charMaxLen) + \
+                    int(field.modifier)
+                return "CAST({} AS VARCHAR2({}))".format(
+                    self.db.quoteId(field.name),
+                    str(nbChars))
+
+        return "CAST({} As VARCHAR2({}))".format(
+            self.db.quoteId(field.name), field.charMaxLen)
+
+    def _deleteCursor(self):
+        self.db._close_cursor(self.cursor)
+        self.cursor = None
+
+    def __del__(self):
+        self.table.aboutToChange.disconnect(self._deleteCursor)
+        self._deleteCursor()
+
+    def getData(self, row, col):
+        if (row < self.fetchedFrom or
+                row >= self.fetchedFrom + self.fetchedCount):
+            margin = self.fetchedCount / 2
+            if row + margin >= self.rowCount():
+                start = int(self.rowCount() - margin)
+            else:
+                start = int(row - margin)
+            if start < 0:
+                start = 0
+            self.fetchMoreData(start)
+
+        # For some improbable cases
+        if row - self.fetchedFrom >= len(self.resdata):
+            return None
+
+        return self.resdata[row - self.fetchedFrom][col]
+
+    def fetchMoreData(self, row_start):
+        if not self.cursor:
+            self._createCursor()
+
+        self.cursor.scroll(row_start - 1)
+
+        self.resdata = self.cursor.fetchmany(self.fetchedCount)
+        self.fetchedFrom = row_start
+
+
+class ORSqlResultModelTask(SqlResultModelTask):
+
+    def __init__(self, db, sql, parent):
+        super().__init__(db, sql, parent)
+
+    def run(self):
+        try:
+            self.model = ORSqlResultModel(self.db, self.sql, None)
+        except BaseError as e:
+            self.error = e
+            QgsMessageLog.logMessage(e.msg)
+            return False
+
+        return True
+
+    def cancel(self):
+        self.db.connector.cancel()
+        SqlResultModelTask.cancel(self)
+
+
+class ORSqlResultModelAsync(SqlResultModelAsync):
+
+    def __init__(self, db, sql, parent):
+        super().__init__()
+
+        self.task = ORSqlResultModelTask(db, sql, parent)
+        self.task.taskCompleted.connect(self.modelDone)
+        self.task.taskTerminated.connect(self.modelDone)
+
+
+class ORSqlResultModel(SqlResultModel):
+
+    def __init__(self, db, sql, parent=None):
+        self.db = db.connector
+
+        t = QTime()
+        t.start()
+        c = self.db._execute(None, str(sql))
+
+        self._affectedRows = 0
+        data = []
+        header = self.db._get_cursor_columns(c)
+        if not header:
+            header = []
+
+        try:
+            if len(header) > 0:
+                data = self.db._fetchall(c)
+            self._affectedRows = len(data)
+        except DbError:
+            # nothing to fetch!
+            data = []
+            header = []
+
+        self._secs = t.elapsed() / 1000.0
+        del t
+
+        BaseTableModel.__init__(self, header, data, parent)
+
+        # commit before closing the cursor to make sure that the
+        # changes are stored
+        self.db._commit()
+        c.close()
+        del c

+ 667 - 0
db_manager/db_plugins/oracle/info_model.py

@@ -0,0 +1,667 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS (Oracle)
+Date                 : Aug 27, 2014
+copyright            : (C) 2014 by Médéric RIBREUX
+email                : mederic.ribreux@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias <wonder.sk@gmail.com> (GPLv2 license)
+- DB Manager by Giuseppe Sucameli <brush.tyler@gmail.com> (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtWidgets import QApplication
+from qgis.core import QgsWkbTypes
+
+from ..info_model import TableInfo, VectorTableInfo, DatabaseInfo
+from ..html_elems import HtmlContent, HtmlSection, HtmlParagraph, \
+    HtmlTable, HtmlTableHeader, HtmlTableCol
+
+# Syntax Highlight for VIEWS/MVIEWS
+from pygments import highlight
+from pygments.lexers import get_lexer_by_name
+from pygments.formatters import HtmlFormatter
+
+
+class ORDatabaseInfo(DatabaseInfo):
+
+    def __init__(self, db):
+        self.db = db
+
+    def connectionDetails(self):
+        tbl = []
+
+        if self.db.connector.host != "":
+            tbl.append((QApplication.translate("DBManagerPlugin", "Host:"),
+                        self.db.connector.host))
+        tbl.append((QApplication.translate("DBManagerPlugin", "Database:"),
+                    self.db.connector.dbname))
+        tbl.append((QApplication.translate("DBManagerPlugin", "User:"),
+                    self.db.connector.user))
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "SQLite list tables cache:"),
+                    "Enabled" if self.db.connector.hasCache else
+                    "Unavailable"))
+
+        return HtmlTable(tbl)
+
+    def spatialInfo(self):
+        ret = []
+
+        info = self.db.connector.getSpatialInfo()
+        if not info:
+            return
+
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Oracle Spatial:"),
+             info[0])
+        ]
+        ret.append(HtmlTable(tbl))
+
+        if not self.db.connector.has_geometry_columns:
+            ret.append(
+                HtmlParagraph(
+                    QApplication.translate(
+                        "DBManagerPlugin",
+                        "<warning> ALL_SDO_GEOM_METADATA"
+                        " view doesn't exist!\n"
+                        "This view is essential for many"
+                        " GIS applications for enumeration of tables.")))
+
+        return ret
+
+    def privilegesDetails(self):
+        """ find if user can create schemas (CREATE ANY TABLE or something)"""
+        # TODO
+        return None
+
+
+class ORTableInfo(TableInfo):
+
+    def __init__(self, table):
+        self.table = table
+        if not self.table.objectType:
+            self.table.getType()
+        if not self.table.comment:
+            self.table.getComment()
+        if not self.table.estimatedRowCount and not self.table.isView:
+            self.table.refreshRowEstimation()
+        if not self.table.creationDate:
+            self.table.getDates()
+
+    def generalInfo(self):
+        ret = []
+
+        # if the estimation is less than 100 rows, try to count them - it
+        # shouldn't take long time
+        if (not self.table.isView and
+                not self.table.rowCount and
+                self.table.estimatedRowCount < 100):
+            # row count information is not displayed yet, so just block
+            # table signals to avoid double refreshing
+            # (infoViewer->refreshRowCount->tableChanged->infoViewer)
+            self.table.blockSignals(True)
+            self.table.refreshRowCount()
+            self.table.blockSignals(False)
+
+        relation_type = QApplication.translate(
+            "DBManagerPlugin", self.table.objectType) if isinstance(self.table.objectType, str) else QApplication.translate("DBManagerPlugin", "Unknown")
+
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Object type:"),
+             relation_type),
+            (QApplication.translate("DBManagerPlugin", "Owner:"),
+             self.table.owner)
+        ]
+
+        if self.table.comment:
+            tbl.append(
+                (QApplication.translate(
+                    "DBManagerPlugin",
+                    "Comment:"),
+                 self.table.comment))
+
+        # Estimated rows
+        if not self.table.isView:
+            tbl.append(
+                (QApplication.translate(
+                    "DBManagerPlugin", "Rows (estimation):"),
+                 self.table.estimatedRowCount)
+            )
+        if self.table.rowCount is not None and self.table.rowCount >= 0:
+            # Add a real count of rows
+            tbl.append(
+                (QApplication.translate("DBManagerPlugin", "Rows (counted):"),
+                 self.table.rowCount)
+            )
+        else:
+            tbl.append(
+                (QApplication.translate("DBManagerPlugin", "Rows (counted):"),
+                 'Unknown (<a href="action:rows/recount">find out</a>)')
+            )
+
+        # Add creation and modification dates
+        if self.table.creationDate:
+            tbl.append(
+                (QApplication.translate("DBManagerPlugin", "Creation Date:"),
+                 self.table.creationDate))
+
+        if self.table.modificationDate:
+            tbl.append(
+                (QApplication.translate(
+                    "DBManagerPlugin", "Last Modification Date:"),
+                 self.table.modificationDate))
+
+        # privileges
+        # has the user access to this schema?
+        schema_priv = self.table.database().connector.getSchemaPrivileges(
+            self.table.schemaName()) if self.table.schema() else None
+        if not schema_priv:
+            pass
+        elif schema_priv[1] is False:  # no usage privileges on the schema
+            tbl.append((QApplication.translate(
+                "DBManagerPlugin", "Privileges:"),
+                QApplication.translate(
+                "DBManagerPlugin",
+                "<warning> This user doesn't have usage privileges"
+                " for this schema!")))
+        else:
+            table_priv = self.table.database().connector.getTablePrivileges(
+                (self.table.schemaName(), self.table.name))
+            privileges = []
+            if table_priv[0]:
+                privileges.append("select")
+            if table_priv[1]:
+                privileges.append("insert")
+            if table_priv[2]:
+                privileges.append("update")
+            if table_priv[3]:
+                privileges.append("delete")
+
+            if len(privileges) > 0:
+                priv_string = ", ".join(privileges)
+            else:
+                priv_string = QApplication.translate(
+                    "DBManagerPlugin",
+                    '<warning> This user has no privileges!')
+
+            tbl.append(
+                (QApplication.translate(
+                    "DBManagerPlugin", "Privileges:"),
+                 priv_string))
+
+        ret.append(HtmlTable(tbl))
+
+        if schema_priv and schema_priv[1]:
+            if (table_priv[0] and
+                    not table_priv[1] and
+                    not table_priv[2] and
+                    not table_priv[3]):
+                ret.append(
+                    HtmlParagraph(QApplication.translate(
+                        "DBManagerPlugin",
+                        "<warning> This user has read-only privileges.")))
+
+        # primary key defined?
+        if (not self.table.isView and
+                self.table.objectType != "MATERIALIZED VIEW"):
+            pk = [fld for fld in self.table.fields() if fld.primaryKey]
+            if len(pk) <= 0:
+                ret.append(
+                    HtmlParagraph(QApplication.translate(
+                        "DBManagerPlugin",
+                        "<warning> No primary key defined for this table!")))
+
+        return ret
+
+    def getSpatialInfo(self):
+        ret = []
+
+        info = self.db.connector.getSpatialInfo()
+        if not info:
+            return
+
+        tbl = [
+            (QApplication.translate(
+             "DBManagerPlugin", "Library:"), info[0])  # ,
+        ]
+        ret.append(HtmlTable(tbl))
+
+        if not self.db.connector.has_geometry_columns:
+            ret.append(HtmlParagraph(
+                QApplication.translate(
+                    "DBManagerPlugin",
+                    "<warning> ALL_SDO_GEOM_METADATA table doesn't exist!\n"
+                    "This table is essential for many GIS"
+                    " applications for enumeration of tables.")))
+
+        return ret
+
+    def fieldsDetails(self):
+        tbl = []
+
+        # define the table header
+        header = (
+            "#",
+            QApplication.translate("DBManagerPlugin", "Name"),
+            QApplication.translate("DBManagerPlugin", "Type"),
+            QApplication.translate("DBManagerPlugin", "Length"),
+            QApplication.translate("DBManagerPlugin", "Null"),
+            QApplication.translate("DBManagerPlugin", "Default"),
+            QApplication.translate("DBManagerPlugin", "Comment"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for fld in self.table.fields():
+            char_max_len = fld.charMaxLen if fld.charMaxLen else ""
+            if fld.modifier:
+                char_max_len = "{},{}".format(char_max_len, fld.modifier)
+            is_null_txt = "N" if fld.notNull else "Y"
+
+            # make primary key field underlined
+            attrs = {"class": "underline"} if fld.primaryKey else None
+            name = HtmlTableCol(fld.name, attrs)
+
+            tbl.append(
+                (fld.num, name, fld.type2String(), char_max_len,
+                 is_null_txt, fld.default2String(), fld.comment))
+
+        return HtmlTable(tbl, {"class": "header"})
+
+    def constraintsDetails(self):
+        if not self.table.constraints():
+            return None
+
+        tbl = []
+
+        # define the table header
+        header = (QApplication.translate("DBManagerPlugin", "Name"),
+                  QApplication.translate("DBManagerPlugin", "Type"),
+                  QApplication.translate("DBManagerPlugin", "Column"),
+                  QApplication.translate("DBManagerPlugin", "Status"),
+                  QApplication.translate("DBManagerPlugin", "Validated"),
+                  QApplication.translate("DBManagerPlugin", "Generated"),
+                  QApplication.translate("DBManagerPlugin", "Check condition"),
+                  QApplication.translate("DBManagerPlugin", "Foreign Table"),
+                  QApplication.translate("DBManagerPlugin", "Foreign column"),
+                  QApplication.translate("DBManagerPlugin", "On Delete"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for con in self.table.constraints():
+            tbl.append((con.name, con.type2String(), con.column,
+                        con.status, con.validated, con.generated,
+                        con.checkSource, con.foreignTable,
+                        con.foreignKey, con.foreignOnDelete))
+
+        return HtmlTable(tbl, {"class": "header"})
+
+    def indexesDetails(self):
+        if not self.table.indexes():
+            return None
+
+        tbl = []
+
+        # define the table header
+        header = (QApplication.translate("DBManagerPlugin", "Name"),
+                  QApplication.translate("DBManagerPlugin", "Column(s)"),
+                  QApplication.translate("DBManagerPlugin", "Index Type"),
+                  QApplication.translate("DBManagerPlugin", "Status"),
+                  QApplication.translate("DBManagerPlugin", "Last analyzed"),
+                  QApplication.translate("DBManagerPlugin", "Compression"),
+                  QApplication.translate("DBManagerPlugin", "Uniqueness"),
+                  QApplication.translate("DBManagerPlugin", "Action"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for idx in self.table.indexes():
+            # get the fields the index is defined on
+            tbl.append((idx.name, idx.column, idx.indexType,
+                        idx.status, idx.analyzed, idx.compression,
+                        idx.isUnique,
+                        ('<a href="action:index/{}/rebuild">Rebuild'
+                         """</a>""".format(idx.name))))
+
+        return HtmlTable(tbl, {"class": "header"})
+
+    def triggersDetails(self):
+        if not self.table.triggers():
+            return None
+
+        ret = []
+
+        tbl = []
+        # define the table header
+        header = (
+            QApplication.translate("DBManagerPlugin", "Name"),
+            QApplication.translate("DBManagerPlugin", "Event"),
+            QApplication.translate("DBManagerPlugin", "Type"),
+            QApplication.translate("DBManagerPlugin", "Enabled"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for trig in self.table.triggers():
+            name = ("""{0} (<a href="action:trigger/"""
+                    """{0}/{1}">{1}</a>)""".format(trig.name, "delete"))
+
+            if trig.enabled == "ENABLED":
+                enabled, action = (
+                    QApplication.translate("DBManagerPlugin", "Yes"),
+                    "disable")
+            else:
+                enabled, action = (
+                    QApplication.translate("DBManagerPlugin", "No"),
+                    "enable")
+
+            txt_enabled = ("""{0} (<a href="action:trigger/"""
+                           """{1}/{2}">{2}</a>)""".format(
+                               enabled, trig.name, action))
+
+            tbl.append((name, trig.event, trig.type, txt_enabled))
+
+        ret.append(HtmlTable(tbl, {"class": "header"}))
+
+        ret.append(
+            HtmlParagraph(
+                QApplication.translate(
+                    "DBManagerPlugin",
+                    '<a href="action:triggers/enable">'
+                    'Enable all triggers</a> / '
+                    '<a href="action:triggers/disable">'
+                    'Disable all triggers</a>')))
+
+        return ret
+
+    def getTableInfo(self):
+        ret = []
+
+        general_info = self.generalInfo()
+        if not general_info:
+            pass
+        else:
+            ret.append(
+                HtmlSection(
+                    QApplication.translate(
+                        "DBManagerPlugin", 'General info'),
+                    general_info))
+
+        # spatial info
+        spatial_info = self.spatialInfo()
+        if not spatial_info:
+            pass
+        else:
+            spatial_info = HtmlContent(spatial_info)
+            if not spatial_info.hasContents():
+                spatial_info = QApplication.translate(
+                    "DBManagerPlugin",
+                    '<warning> This is not a spatial table.')
+            ret.append(
+                HtmlSection(
+                    self.table.database().connection().typeNameString(),
+                    spatial_info))
+
+        # fields
+        fields_details = self.fieldsDetails()
+        if not fields_details:
+            pass
+        else:
+            ret.append(
+                HtmlSection(
+                    QApplication.translate(
+                        "DBManagerPlugin",
+                        'Fields'),
+                    fields_details))
+
+        # constraints
+        constraints_details = self.constraintsDetails()
+        if not constraints_details:
+            pass
+        else:
+            ret.append(
+                HtmlSection(
+                    QApplication.translate(
+                        "DBManagerPlugin",
+                        'Constraints'),
+                    constraints_details))
+
+        # indexes
+        indexes_details = self.indexesDetails()
+        if not indexes_details:
+            pass
+        else:
+            ret.append(
+                HtmlSection(
+                    QApplication.translate(
+                        "DBManagerPlugin",
+                        'Indexes'),
+                    indexes_details))
+
+        # triggers
+        triggers_details = self.triggersDetails()
+        if not triggers_details:
+            pass
+        else:
+            ret.append(
+                HtmlSection(
+                    QApplication.translate(
+                        "DBManagerPlugin",
+                        'Triggers'),
+                    triggers_details))
+
+        if self.table.objectType == "MATERIALIZED VIEW":
+            mview_info = self.getMViewInfo()
+            ret.append(
+                HtmlSection(
+                    QApplication.translate(
+                        "DBManagerPlugin",
+                        'Materialized View information'),
+                    mview_info))
+
+        return ret
+
+    def getMViewInfo(self):
+        """If the table is a materialized view, grab more
+        information...
+        """
+        ret = []
+        tbl = []
+        values = self.table.getMViewInfo()
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "Refresh Mode:"),
+                    values[0]))
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "Refresh Method:"),
+                    values[1]))
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "Build Mode:"),
+                    values[2]))
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "Last Refresh Date:"),
+                    values[5]))
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "Last Refresh Type:"),
+                    values[4]))
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "Fast Refreshable:"),
+                    values[3]))
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "Staleness:"),
+                    values[6]))
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "Stale since:"),
+                    values[7]))
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "Compile State:"),
+                    values[8]))
+        tbl.append((QApplication.translate("DBManagerPlugin",
+                                           "Use no index:"),
+                    values[9]))
+        tbl.append(('<a href="action:mview/refresh">{}</a>'.format(
+            QApplication.translate("DBManagerPlugin", "Refresh the materialized view")),
+            ""))
+        ret.append(HtmlTable(tbl))
+
+        return ret
+
+    def getViewInfo(self):
+        """If the table is a view or a materialized view, add the
+        definition of the view.
+        """
+
+        if self.table.objectType not in ["VIEW", "MATERIALIZED VIEW"]:
+            return []
+
+        ret = self.getTableInfo()
+
+        # view definition
+        view_def = self.table.getDefinition()
+
+        # Syntax highlight
+        lexer = get_lexer_by_name("sql")
+        formatter = HtmlFormatter(
+            linenos=True, cssclass="source", noclasses=True)
+        result = highlight(view_def, lexer, formatter)
+
+        if view_def:
+            if self.table.objectType == "VIEW":
+                title = "View Definition"
+            else:
+                title = "Materialized View Definition"
+            ret.append(
+                HtmlSection(
+                    QApplication.translate("DBManagerPlugin", title),
+                    result))
+
+        return ret
+
+    def toHtml(self):
+        if self.table.objectType in ["VIEW", "MATERIALIZED VIEW"]:
+            ret = self.getViewInfo()
+        else:
+            ret = self.getTableInfo()
+        return HtmlContent(ret).toHtml()
+
+
+class ORVectorTableInfo(ORTableInfo, VectorTableInfo):
+
+    def __init__(self, table):
+        VectorTableInfo.__init__(self, table)
+        ORTableInfo.__init__(self, table)
+
+    def spatialInfo(self):
+        ret = []
+        if not self.table.geomType:
+            return ret
+
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Column:"),
+             self.table.geomColumn),
+            (QApplication.translate("DBManagerPlugin", "Geometry:"),
+             self.table.geomType),
+            (QApplication.translate("DBManagerPlugin",
+                                    "QGIS Geometry type:"),
+             QgsWkbTypes.displayString(self.table.wkbType))
+        ]
+
+        # only if we have info from geometry_columns
+        if self.table.geomDim:
+            tbl.append(
+                (QApplication.translate(
+                    "DBManagerPlugin",
+                    "Dimension:"),
+                 self.table.geomDim))
+
+        srid = self.table.srid if self.table.srid else -1
+        if srid != -1:
+            sr_info = (
+                self.table.database().connector.getSpatialRefInfo(srid))
+        else:
+            sr_info = QApplication.translate("DBManagerPlugin",
+                                             "Undefined")
+        if sr_info:
+            tbl.append(
+                (QApplication.translate(
+                    "DBManagerPlugin", "Spatial ref:"),
+                 "{} ({})".format(sr_info, srid)))
+
+        # estimated extent
+        if not self.table.estimatedExtent:
+            # estimated extent information is not displayed yet, so just block
+            # table signals to avoid double refreshing
+            # (infoViewer->refreshEstimatedExtent->tableChanged->infoViewer)
+            self.table.blockSignals(True)
+            self.table.refreshTableEstimatedExtent()
+            self.table.blockSignals(False)
+
+        if self.table.estimatedExtent:
+            estimated_extent_str = ("{:.9f}, {:.9f} - {:.9f}, "
+                                    "{:.9f}".format(
+                                        *self.table.estimatedExtent))
+
+            tbl.append(
+                (QApplication.translate(
+                    "DBManagerPlugin", "Estimated extent:"),
+                 estimated_extent_str))
+
+        # extent
+        extent_str = None
+        if self.table.extent and len(self.table.extent) == 4:
+            extent_str = ("{:.9f}, {:.9f} - {:.9f}, "
+                          "{:.9f}".format(*self.table.extent))
+        elif (self.table.rowCount is not None and self.table.rowCount > 0) or (self.table.estimatedRowCount is not None and self.table.estimatedRowCount > 0):
+            # Can't calculate an extent on empty layer
+            extent_str = QApplication.translate(
+                "DBManagerPlugin",
+                '(unknown) (<a href="action:extent/get">find out</a>)')
+
+        if extent_str:
+            tbl.append(
+                (QApplication.translate(
+                    "DBManagerPlugin", "Extent:"),
+                 extent_str))
+
+        ret.append(HtmlTable(tbl))
+
+        # Handle extent update metadata
+        if (self.table.extent and
+                self.table.extent != self.table.estimatedExtent and
+                self.table.canUpdateMetadata()):
+            ret.append(
+                HtmlParagraph(
+                    QApplication.translate(
+                        "DBManagerPlugin",
+                        '<warning> Metadata extent is different from'
+                        ' real extent. You should <a href="action:extent'
+                        '/update">update it</a>!')))
+
+        # is there an entry in geometry_columns?
+        if self.table.geomType.lower() == 'geometry':
+            ret.append(
+                HtmlParagraph(
+                    QApplication.translate(
+                        "DBManagerPlugin",
+                        "<warning> There is no entry in geometry_columns!")))
+
+        # find out whether the geometry column has spatial index on it
+        if not self.table.isView:
+            if not self.table.hasSpatialIndex():
+                ret.append(
+                    HtmlParagraph(
+                        QApplication.translate(
+                            "DBManagerPlugin",
+                            '<warning> No spatial index defined (<a href='
+                            '"action:spatialindex/create">'
+                            'create it</a>).')))
+
+        return ret

+ 652 - 0
db_manager/db_plugins/oracle/plugin.py

@@ -0,0 +1,652 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS (Oracle)
+Date                 : Aug 27, 2014
+copyright            : (C) 2014 by Médéric RIBREUX
+email                : mederic.ribreux@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias <wonder.sk@gmail.com> (GPLv2 license)
+- DB Manager by Giuseppe Sucameli <brush.tyler@gmail.com> (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+# this will disable the dbplugin if the connector raise an ImportError
+from typing import (
+    Optional,
+    Union
+)
+
+from .connector import OracleDBConnector
+
+from qgis.PyQt.QtCore import Qt, QCoreApplication
+from qgis.PyQt.QtGui import QIcon, QKeySequence
+from qgis.PyQt.QtWidgets import QAction, QApplication, QMessageBox
+
+from qgis.core import QgsApplication, QgsVectorLayer, NULL, QgsSettings
+
+from ..plugin import ConnectionError, InvalidDataException, DBPlugin, \
+    Database, Schema, Table, VectorTable, TableField, TableConstraint, \
+    TableIndex, TableTrigger
+
+from qgis.core import QgsCredentials
+
+
+def classFactory():
+    return OracleDBPlugin
+
+
+class OracleDBPlugin(DBPlugin):
+
+    @classmethod
+    def icon(self):
+        return QgsApplication.getThemeIcon("/mIconOracle.svg")
+
+    @classmethod
+    def typeName(self):
+        return 'oracle'
+
+    @classmethod
+    def typeNameString(self):
+        return QCoreApplication.translate('db_manager', 'Oracle Spatial')
+
+    @classmethod
+    def providerName(self):
+        return 'oracle'
+
+    @classmethod
+    def connectionSettingsKey(self):
+        return '/Oracle/connections'
+
+    def connectToUri(self, uri):
+        self.db = self.databasesFactory(self, uri)
+        if self.db:
+            return True
+        return False
+
+    def databasesFactory(self, connection, uri):
+        return ORDatabase(connection, uri)
+
+    def connect(self, parent=None):
+        conn_name = self.connectionName()
+        settings = QgsSettings()
+        settings.beginGroup("/{}/{}".format(
+            self.connectionSettingsKey(), conn_name))
+
+        if not settings.contains("database"):  # non-existent entry?
+            raise InvalidDataException(
+                self.tr('There is no defined database connection "{}".'.format(
+                    conn_name)))
+
+        from qgis.core import QgsDataSourceUri
+        uri = QgsDataSourceUri()
+
+        settingsList = ["host", "port", "database", "username", "password"]
+        host, port, database, username, password = (
+            settings.value(x, "", type=str) for x in settingsList)
+
+        # get all of the connection options
+
+        useEstimatedMetadata = settings.value(
+            "estimatedMetadata", False, type=bool)
+        uri.setParam('userTablesOnly', str(
+            settings.value("userTablesOnly", False, type=bool)))
+        uri.setParam('geometryColumnsOnly', str(
+            settings.value("geometryColumnsOnly", False, type=bool)))
+        uri.setParam('allowGeometrylessTables', str(
+            settings.value("allowGeometrylessTables", False, type=bool)))
+        uri.setParam('onlyExistingTypes', str(
+            settings.value("onlyExistingTypes", False, type=bool)))
+        uri.setParam('includeGeoAttributes', str(
+            settings.value("includeGeoAttributes", False, type=bool)))
+
+        settings.endGroup()
+
+        uri.setConnection(host, port, database, username, password)
+
+        uri.setUseEstimatedMetadata(useEstimatedMetadata)
+
+        err = ""
+        try:
+            return self.connectToUri(uri)
+        except ConnectionError as e:
+            err = str(e)
+
+        # ask for valid credentials
+        max_attempts = 3
+        for i in range(max_attempts):
+            (ok, username, password) = QgsCredentials.instance().get(
+                uri.connectionInfo(False), username, password, err)
+
+            if not ok:
+                return False
+
+            uri.setConnection(host, port, database, username, password)
+
+            try:
+                self.connectToUri(uri)
+            except ConnectionError as e:
+                if i == max_attempts - 1:  # failed the last attempt
+                    raise e
+                err = str(e)
+                continue
+
+            QgsCredentials.instance().put(
+                uri.connectionInfo(False), username, password)
+
+            return True
+
+        return False
+
+
+class ORDatabase(Database):
+
+    def __init__(self, connection, uri):
+        self.connName = connection.connectionName()
+        Database.__init__(self, connection, uri)
+
+    def connectorsFactory(self, uri):
+        return OracleDBConnector(uri, self.connName)
+
+    def dataTablesFactory(self, row, db, schema=None):
+        return ORTable(row, db, schema)
+
+    def vectorTablesFactory(self, row, db, schema=None):
+        return ORVectorTable(row, db, schema)
+
+    def info(self):
+        from .info_model import ORDatabaseInfo
+        return ORDatabaseInfo(self)
+
+    def schemasFactory(self, row, db):
+        return ORSchema(row, db)
+
+    def columnUniqueValuesModel(self, col, table, limit=10):
+        l = ""
+        if limit:
+            l = "WHERE ROWNUM < {:d}".format(limit)
+        con = self.database().connector
+        # Prevent geometry column show
+        tableName = table.replace('"', "").split(".")
+        if len(tableName) == 0:
+            tableName = [None, tableName[0]]
+        colName = col.replace('"', "").split(".")[-1]
+
+        if con.isGeometryColumn(tableName, colName):
+            return None
+
+        query = "SELECT DISTINCT {} FROM {} {}".format(col, table, l)
+        return self.sqlResultModel(query, self)
+
+    def sqlResultModel(self, sql, parent):
+        from .data_model import ORSqlResultModel
+        return ORSqlResultModel(self, sql, parent)
+
+    def sqlResultModelAsync(self, sql, parent):
+        from .data_model import ORSqlResultModelAsync
+
+        return ORSqlResultModelAsync(self, sql, parent)
+
+    def toSqlLayer(self, sql, geomCol, uniqueCol,
+                   layerName="QueryLayer", layerType=None,
+                   avoidSelectById=False, filter=""):
+
+        uri = self.uri()
+        con = self.database().connector
+
+        if uniqueCol is not None:
+            uniqueCol = uniqueCol.strip('"').replace('""', '"')
+
+        uri.setDataSource("", "({}\n)".format(
+            sql), geomCol, filter, uniqueCol)
+
+        if avoidSelectById:
+            uri.disableSelectAtId(True)
+        provider = self.dbplugin().providerName()
+        vlayer = QgsVectorLayer(uri.uri(False), layerName, provider)
+
+        # handling undetermined geometry type
+        if not vlayer.isValid():
+
+            wkbType, srid = con.getTableMainGeomType(
+                "({}\n)".format(sql), geomCol)
+            uri.setWkbType(wkbType)
+            if srid:
+                uri.setSrid(str(srid))
+            vlayer = QgsVectorLayer(uri.uri(False), layerName, provider)
+
+        return vlayer
+
+    def registerDatabaseActions(self, mainWindow):
+        action = QAction(QApplication.translate(
+            "DBManagerPlugin", "&Re-connect"), self)
+        mainWindow.registerAction(action, QApplication.translate(
+            "DBManagerPlugin", "&Database"), self.reconnectActionSlot)
+
+        if self.schemas():
+            action = QAction(QApplication.translate(
+                "DBManagerPlugin", "&Create Schema…"), self)
+            mainWindow.registerAction(action, QApplication.translate(
+                "DBManagerPlugin", "&Schema"), self.createSchemaActionSlot)
+            action = QAction(QApplication.translate(
+                "DBManagerPlugin", "&Delete (Empty) Schema…"), self)
+            mainWindow.registerAction(action, QApplication.translate(
+                "DBManagerPlugin", "&Schema"), self.deleteSchemaActionSlot)
+
+        action = QAction(QApplication.translate(
+            "DBManagerPlugin", "Delete Selected Item"), self)
+        mainWindow.registerAction(action, None, self.deleteActionSlot)
+        action.setShortcuts(QKeySequence.Delete)
+
+        action = QAction(QgsApplication.getThemeIcon("/mActionCreateTable.svg"),
+                         QApplication.translate(
+                             "DBManagerPlugin", "&Create Table…"), self)
+        mainWindow.registerAction(action, QApplication.translate(
+            "DBManagerPlugin", "&Table"), self.createTableActionSlot)
+        action = QAction(QgsApplication.getThemeIcon("/mActionEditTable.svg"),
+                         QApplication.translate(
+                             "DBManagerPlugin", "&Edit Table…"), self)
+        mainWindow.registerAction(action, QApplication.translate(
+            "DBManagerPlugin", "&Table"), self.editTableActionSlot)
+        action = QAction(QgsApplication.getThemeIcon("/mActionDeleteTable.svg"),
+                         QApplication.translate(
+                             "DBManagerPlugin", "&Delete Table/View…"), self)
+        mainWindow.registerAction(action, QApplication.translate(
+            "DBManagerPlugin", "&Table"), self.deleteTableActionSlot)
+        action = QAction(QApplication.translate(
+            "DBManagerPlugin", "&Empty Table…"), self)
+        mainWindow.registerAction(action, QApplication.translate(
+            "DBManagerPlugin", "&Table"), self.emptyTableActionSlot)
+
+    def supportsComment(self):
+        return False
+
+
+class ORSchema(Schema):
+
+    def __init__(self, row, db):
+        Schema.__init__(self, db)
+        # self.oid, self.name, self.owner, self.perms, self.comment = row
+        self.name = row[0]
+
+
+class ORTable(Table):
+
+    def __init__(self, row, db, schema=None):
+        Table.__init__(self, db, schema)
+        self.name, self.owner, isView = row
+
+        self.estimatedRowCount = None
+        self.objectType: Optional[Union[str, bool]] = None
+        self.isView = False
+        self.isMaterializedView = False
+        if isView == 1:
+            self.isView = True
+        self.creationDate = None
+        self.modificationDate = None
+
+    def getDates(self):
+        """Grab the creation/modification dates of the table"""
+        self.creationDate, self.modificationDate = (
+            self.database().connector.getTableDates((self.schemaName(),
+                                                     self.name)))
+
+    def refreshRowEstimation(self):
+        """Use ALL_ALL_TABLE to get an estimation of rows"""
+        if self.isView:
+            self.estimatedRowCount = 0
+
+        self.estimatedRowCount = (
+            self.database().connector.getTableRowEstimation(
+                (self.schemaName(), self.name)))
+
+    def getType(self):
+        """Grab the type of object for the table"""
+        self.objectType = self.database().connector.getTableType(
+            (self.schemaName(), self.name))
+
+    def getComment(self):
+        """Grab the general comment of the table/view"""
+        self.comment = self.database().connector.getTableComment(
+            (self.schemaName(), self.name), self.objectType)
+
+    def getDefinition(self):
+        return self.database().connector.getDefinition(
+            (self.schemaName(), self.name), self.objectType)
+
+    def getMViewInfo(self):
+        if self.objectType == "MATERIALIZED VIEW":
+            return self.database().connector.getMViewInfo(
+                (self.schemaName(), self.name))
+        else:
+            return None
+
+    def runAction(self, action):
+        action = str(action)
+
+        if action.startswith("rows/"):
+            if action == "rows/recount":
+                self.refreshRowCount()
+                return True
+        elif action.startswith("index/"):
+            parts = action.split('/')
+            index_name = parts[1]
+            index_action = parts[2]
+
+            msg = QApplication.translate(
+                "DBManagerPlugin",
+                "Do you want to {} index {}?".format(
+                    index_action, index_name))
+            QApplication.restoreOverrideCursor()
+            try:
+                if QMessageBox.question(
+                        None,
+                        QApplication.translate(
+                            "DBManagerPlugin", "Table Index"),
+                        msg,
+                        QMessageBox.Yes | QMessageBox.No) == QMessageBox.No:
+                    return False
+            finally:
+                QApplication.setOverrideCursor(Qt.WaitCursor)
+
+            if index_action == "rebuild":
+                self.aboutToChange.emit()
+                self.database().connector.rebuildTableIndex(
+                    (self.schemaName(), self.name), index_name)
+                self.refreshIndexes()
+                return True
+        elif action.startswith("mview/"):
+            if action == "mview/refresh":
+                self.aboutToChange.emit()
+                self.database().connector.refreshMView(
+                    (self.schemaName(), self.name))
+                return True
+
+        return Table.runAction(self, action)
+
+    def tableFieldsFactory(self, row, table):
+        return ORTableField(row, table)
+
+    def tableConstraintsFactory(self, row, table):
+        return ORTableConstraint(row, table)
+
+    def tableIndexesFactory(self, row, table):
+        return ORTableIndex(row, table)
+
+    def tableTriggersFactory(self, row, table):
+        return ORTableTrigger(row, table)
+
+    def info(self):
+        from .info_model import ORTableInfo
+        return ORTableInfo(self)
+
+    def tableDataModel(self, parent):
+        from .data_model import ORTableDataModel
+        return ORTableDataModel(self, parent)
+
+    def getValidQgisUniqueFields(self, onlyOne=False):
+        """ list of fields valid to load the table as layer in QGIS canvas.
+        QGIS automatically search for a valid unique field, so it's
+        needed only for queries and views.
+        """
+
+        ret = []
+
+        # add the pk
+        pkcols = [x for x in self.fields() if x.primaryKey]
+        if len(pkcols) == 1:
+            ret.append(pkcols[0])
+
+        # then add integer fields with an unique index
+        indexes = self.indexes()
+        if indexes is not None:
+            for idx in indexes:
+                if idx.isUnique and len(idx.columns) == 1:
+                    fld = idx.fields()[idx.columns[0]]
+                    if (fld.dataType == "NUMBER" and not fld.modifier and fld.notNull and fld not in ret):
+                        ret.append(fld)
+
+        # and finally append the other suitable fields
+        for fld in self.fields():
+            if (fld.dataType == "NUMBER" and not fld.modifier and fld.notNull and fld not in ret):
+                ret.append(fld)
+
+        if onlyOne:
+            return ret[0] if len(ret) > 0 else None
+        return ret
+
+    def uri(self):
+        uri = self.database().uri()
+        schema = self.schemaName() if self.schemaName() else ''
+        geomCol = self.geomColumn if self.type in [
+            Table.VectorType, Table.RasterType] else ""
+        uniqueCol = self.getValidQgisUniqueFields(
+            True) if self.isView else None
+        uri.setDataSource(schema, self.name, geomCol if geomCol else None,
+                          None, uniqueCol.name if uniqueCol else "")
+
+        # Handle geographic table
+        if geomCol:
+            uri.setWkbType(self.wkbType)
+            uri.setSrid(str(self.srid))
+
+        return uri
+
+
+class ORVectorTable(ORTable, VectorTable):
+
+    def __init__(self, row, db, schema=None):
+        ORTable.__init__(self, row[0:3], db, schema)
+        VectorTable.__init__(self, db, schema)
+        self.geomColumn, self.geomType, self.wkbType, self.geomDim, \
+            self.srid = row[-7:-2]
+
+    def info(self):
+        from .info_model import ORVectorTableInfo
+        return ORVectorTableInfo(self)
+
+    def runAction(self, action):
+        if action.startswith("extent/"):
+            if action == "extent/update":
+                self.aboutToChange.emit()
+                self.updateExtent()
+                return True
+
+        if ORTable.runAction(self, action):
+            return True
+
+        return VectorTable.runAction(self, action)
+
+    def canUpdateMetadata(self):
+        return self.database().connector.canUpdateMetadata((self.schemaName(),
+                                                            self.name))
+
+    def updateExtent(self):
+        self.database().connector.updateMetadata(
+            (self.schemaName(), self.name),
+            self.geomColumn, extent=self.extent)
+        self.refreshTableEstimatedExtent()
+        self.refresh()
+
+    def hasSpatialIndex(self, geom_column=None):
+        geom_column = geom_column if geom_column else self.geomColumn
+
+        for idx in self.indexes():
+            if geom_column == idx.column:
+                return True
+        return False
+
+
+class ORTableField(TableField):
+
+    def __init__(self, row, table):
+        """ build fields information from query and find primary key """
+        TableField.__init__(self, table)
+        self.num, self.name, self.dataType, self.charMaxLen, \
+            self.modifier, self.notNull, self.hasDefault, \
+            self.default, typeStr, self.comment = row
+
+        self.primaryKey = False
+        self.num = int(self.num)
+        if self.charMaxLen == NULL:
+            self.charMaxLen = None
+        else:
+            self.charMaxLen = int(self.charMaxLen)
+
+        if self.modifier == NULL:
+            self.modifier = None
+        else:
+            self.modifier = int(self.modifier)
+
+        if self.notNull.upper() == "Y":
+            self.notNull = False
+        else:
+            self.notNull = True
+
+        if self.comment == NULL:
+            self.comment = ""
+
+        # find out whether fields are part of primary key
+        for con in self.table().constraints():
+            if con.type == ORTableConstraint.TypePrimaryKey and self.name == con.column:
+                self.primaryKey = True
+                break
+
+    def type2String(self):
+        if ("TIMESTAMP" in self.dataType or self.dataType in ["DATE", "SDO_GEOMETRY", "BINARY_FLOAT", "BINARY_DOUBLE"]):
+            return "{}".format(self.dataType)
+        if self.charMaxLen in [None, -1]:
+            return "{}".format(self.dataType)
+        elif self.modifier in [None, -1, 0]:
+            return "{}({})".format(self.dataType, self.charMaxLen)
+
+        return "{}({},{})".format(self.dataType, self.charMaxLen,
+                                  self.modifier)
+
+    def update(self, new_name, new_type_str=None, new_not_null=None,
+               new_default_str=None):
+        self.table().aboutToChange.emit()
+        if self.name == new_name:
+            new_name = None
+        if self.type2String() == new_type_str:
+            new_type_str = None
+        if self.notNull == new_not_null:
+            new_not_null = None
+        if self.default2String() == new_default_str:
+            new_default_str = None
+
+        ret = self.table().database().connector.updateTableColumn(
+            (self.table().schemaName(), self.table().name),
+            self.name, new_name, new_type_str,
+            new_not_null, new_default_str)
+
+        # When changing a field, refresh also constraints and
+        # indexes.
+        if ret is not False:
+            self.table().refreshFields()
+            self.table().refreshConstraints()
+            self.table().refreshIndexes()
+        return ret
+
+
+class ORTableConstraint(TableConstraint):
+    TypeCheck, TypeForeignKey, TypePrimaryKey, \
+        TypeUnique, TypeUnknown = list(range(5))
+
+    types = {"c": TypeCheck, "r": TypeForeignKey,
+             "p": TypePrimaryKey, "u": TypeUnique}
+
+    def __init__(self, row, table):
+        """ build constraints info from query """
+        TableConstraint.__init__(self, table)
+        self.name, constr_type_str, self.column, self.validated, \
+            self.generated, self.status = row[0:6]
+        constr_type_str = constr_type_str.lower()
+
+        if constr_type_str in ORTableConstraint.types:
+            self.type = ORTableConstraint.types[constr_type_str]
+        else:
+            self.type = ORTableConstraint.TypeUnknown
+
+        if row[6] == NULL:
+            self.checkSource = ""
+        else:
+            self.checkSource = row[6]
+
+        if row[8] == NULL:
+            self.foreignTable = ""
+        else:
+            self.foreignTable = row[8]
+
+        if row[7] == NULL:
+            self.foreignOnDelete = ""
+        else:
+            self.foreignOnDelete = row[7]
+
+        if row[9] == NULL:
+            self.foreignKey = ""
+        else:
+            self.foreignKey = row[9]
+
+    def type2String(self):
+        if self.type == ORTableConstraint.TypeCheck:
+            return QApplication.translate("DBManagerPlugin", "Check")
+        if self.type == ORTableConstraint.TypePrimaryKey:
+            return QApplication.translate("DBManagerPlugin", "Primary key")
+        if self.type == ORTableConstraint.TypeForeignKey:
+            return QApplication.translate("DBManagerPlugin", "Foreign key")
+        if self.type == ORTableConstraint.TypeUnique:
+            return QApplication.translate("DBManagerPlugin", "Unique")
+
+        return QApplication.translate("DBManagerPlugin", 'Unknown')
+
+    def fields(self):
+        """ Hack to make edit dialog box work """
+        fields = self.table().fields()
+        field = None
+        for fld in fields:
+            if fld.name == self.column:
+                field = fld
+        cols = {}
+        cols[0] = field
+
+        return cols
+
+
+class ORTableIndex(TableIndex):
+
+    def __init__(self, row, table):
+        TableIndex.__init__(self, table)
+        self.name, self.column, self.indexType, self.status, \
+            self.analyzed, self.compression, self.isUnique = row
+
+    def fields(self):
+        """ Hack to make edit dialog box work """
+        self.table().refreshFields()
+        fields = self.table().fields()
+
+        field = None
+        for fld in fields:
+            if fld.name == self.column:
+                field = fld
+        cols = {}
+        cols[0] = field
+
+        return cols
+
+
+class ORTableTrigger(TableTrigger):
+
+    def __init__(self, row, table):
+        TableTrigger.__init__(self, table)
+        self.name, self.event, self.type, self.enabled = row

+ 303 - 0
db_manager/db_plugins/oracle/sql_dictionary.py

@@ -0,0 +1,303 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS (Oracle)
+Date                 : Aug 27, 2014
+copyright            : (C) 2014 by Médéric RIBREUX
+email                : mederic.ribreux@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias <wonder.sk@gmail.com> (GPLv2 license)
+- DB Manager by Giuseppe Sucameli <brush.tyler@gmail.com> (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+__author__ = 'Médéric RIBREUX'
+__date__ = 'August 2014'
+__copyright__ = '(C) 2014, Médéric RIBREUX'
+
+# keywords
+keywords = [
+    # From:
+    # http://docs.oracle.com/cd/B19306_01/server.102/b14200/ap_keywd.htm
+    "ACCESS", "ADD", "ALL", "ALTER", "AND", "ANY", "AS", "ASC",
+    "AUDIT", "BETWEEN", "BY", "CHAR", "CHECK", "CLUSTER", "COLUMN",
+    "COMMENT", "COMPRESS", "CONNECT", "CREATE", "CURRENT", "DATE",
+    "DECIMAL", "DEFAULT", "DELETE", "DESC", "DISTINCT", "DROP",
+    "ELSE", "EXCLUSIVE", "EXISTS", "FILE", "FLOAT", "FOR", "FROM",
+    "GRANT", "GROUP", "HAVING", "IDENTIFIED", "IMMEDIATE", "IN",
+    "INCREMENT", "INDEX", "INITIAL", "INSERT", "INTEGER", "INTERSECT",
+    "INTO", "IS", "LEVEL", "LIKE", "LOCK", "LONG", "MAXEXTENTS",
+    "MINUS", "MLSLABEL", "MODE", "MODIFY", "NOAUDIT", "NOCOMPRESS",
+    "NOT", "NOWAIT", "NULL", "NUMBER", "OF", "OFFLINE", "ON",
+    "ONLINE", "OPTION", "OR", "ORDER", "PCTFREE", "PRIOR",
+    "PRIVILEGES", "PUBLIC", "RAW", "RENAME", "RESOURCE", "REVOKE",
+    "ROW", "ROWID", "ROWNUM", "ROWS", "SELECT", "SESSION", "SET",
+    "SHARE", "SIZE", "SMALLINT", "START", "SUCCESSFUL", "SYNONYM",
+    "SYSDATE", "TABLE", "THEN", "TO", "TRIGGER", "UID", "UNION",
+    "UNIQUE", "UPDATE", "USER", "VALIDATE", "VALUES", "VARCHAR",
+    "VARCHAR2", "VIEW", "WHENEVER", "WHERE", "WITH",
+    # From http://docs.oracle.com/cd/B13789_01/appdev.101/a42525/apb.htm
+    "ADMIN", "CURSOR", "FOUND", "MOUNT", "AFTER", "CYCLE", "FUNCTION",
+    "NEXT", "ALLOCATE", "DATABASE", "GO", "NEW", "ANALYZE",
+    "DATAFILE", "GOTO", "NOARCHIVELOG", "ARCHIVE", "DBA", "GROUPS",
+    "NOCACHE", "ARCHIVELOG", "DEC", "INCLUDING", "NOCYCLE",
+    "AUTHORIZATION", "DECLARE", "INDICATOR", "NOMAXVALUE", "AVG",
+    "DISABLE", "INITRANS", "NOMINVALUE", "BACKUP", "DISMOUNT",
+    "INSTANCE", "NONE", "BEGIN", "DOUBLE", "INT", "NOORDER", "BECOME",
+    "DUMP", "KEY", "NORESETLOGS", "BEFORE", "EACH", "LANGUAGE",
+    "NORMAL", "BLOCK", "ENABLE", "LAYER", "NOSORT", "BODY", "END",
+    "LINK", "NUMERIC", "CACHE", "ESCAPE", "LISTS", "OFF", "CANCEL",
+    "EVENTS", "LOGFILE", "OLD", "CASCADE", "EXCEPT", "MANAGE", "ONLY",
+    "CHANGE", "EXCEPTIONS", "MANUAL", "OPEN", "CHARACTER", "EXEC",
+    "MAX", "OPTIMAL", "CHECKPOINT", "EXPLAIN", "MAXDATAFILES", "OWN",
+    "CLOSE", "EXECUTE", "MAXINSTANCES", "PACKAGE", "COBOL", "EXTENT",
+    "MAXLOGFILES", "PARALLEL", "COMMIT", "EXTERNALLY",
+    "MAXLOGHISTORY", "PCTINCREASE", "COMPILE", "FETCH",
+    "MAXLOGMEMBERS", "PCTUSED", "CONSTRAINT", "FLUSH", "MAXTRANS",
+    "PLAN", "CONSTRAINTS", "FREELIST", "MAXVALUE", "PLI", "CONTENTS",
+    "FREELISTS", "MIN", "PRECISION", "CONTINUE", "FORCE",
+    "MINEXTENTS", "PRIMARY", "CONTROLFILE", "FOREIGN", "MINVALUE",
+    "PRIVATE", "COUNT", "FORTRAN", "MODULE", "PROCEDURE", "PROFILE",
+    "SAVEPOINT", "SQLSTATE", "TRACING", "QUOTA", "SCHEMA",
+    "STATEMENT_ID", "TRANSACTION", "READ", "SCN", "STATISTICS",
+    "TRIGGERS", "REAL", "SECTION", "STOP", "TRUNCATE", "RECOVER",
+    "SEGMENT", "STORAGE", "UNDER", "REFERENCES", "SEQUENCE", "SUM",
+    "UNLIMITED", "REFERENCING", "SHARED", "SWITCH", "UNTIL",
+    "RESETLOGS", "SNAPSHOT", "SYSTEM", "USE", "RESTRICTED", "SOME",
+    "TABLES", "USING", "REUSE", "SORT", "TABLESPACE", "WHEN", "ROLE",
+    "SQL", "TEMPORARY", "WRITE", "ROLES", "SQLCODE", "THREAD", "WORK",
+    "ROLLBACK", "SQLERROR", "TIME", "ABORT", "BETWEEN", "CRASH",
+    "DIGITS", "ACCEPT", "BINARY_INTEGER", "CREATE", "DISPOSE",
+    "ACCESS", "BODY", "CURRENT", "DISTINCT", "ADD", "BOOLEAN",
+    "CURRVAL", "DO", "ALL", "BY", "CURSOR", "DROP", "ALTER", "CASE",
+    "DATABASE", "ELSE", "AND", "CHAR", "DATA_BASE", "ELSIF", "ANY",
+    "CHAR_BASE", "DATE", "END", "ARRAY", "CHECK", "DBA", "ENTRY",
+    "ARRAYLEN", "CLOSE", "DEBUGOFF", "EXCEPTION", "AS", "CLUSTER",
+    "DEBUGON", "EXCEPTION_INIT", "ASC", "CLUSTERS", "DECLARE",
+    "EXISTS", "ASSERT", "COLAUTH", "DECIMAL", "EXIT", "ASSIGN",
+    "COLUMNS", "DEFAULT", "FALSE", "AT", "COMMIT", "DEFINITION",
+    "FETCH", "AUTHORIZATION", "COMPRESS", "DELAY", "FLOAT", "AVG",
+    "CONNECT", "DELETE", "FOR", "BASE_TABLE", "CONSTANT", "DELTA",
+    "FORM", "BEGIN", "COUNT", "DESC", "FROM", "FUNCTION", "NEW",
+    "RELEASE", "SUM", "GENERIC", "NEXTVAL", "REMR", "TABAUTH", "GOTO",
+    "NOCOMPRESS", "RENAME", "TABLE", "GRANT", "NOT", "RESOURCE",
+    "TABLES", "GROUP", "NULL", "RETURN", "TASK", "HAVING", "NUMBER",
+    "REVERSE", "TERMINATE", "IDENTIFIED", "NUMBER_BASE", "REVOKE",
+    "THEN", "IF", "OF", "ROLLBACK", "TO", "IN", "ON", "ROWID", "TRUE",
+    "INDEX", "OPEN", "ROWLABEL", "TYPE", "INDEXES", "OPTION",
+    "ROWNUM", "UNION", "INDICATOR", "OR", "ROWTYPE", "UNIQUE",
+    "INSERT", "ORDER", "RUN", "UPDATE", "INTEGER", "OTHERS",
+    "SAVEPOINT", "USE", "INTERSECT", "OUT", "SCHEMA", "VALUES",
+    "INTO", "PACKAGE", "SELECT", "VARCHAR", "IS", "PARTITION",
+    "SEPARATE", "VARCHAR2", "LEVEL", "PCTFREE", "SET", "VARIANCE",
+    "LIKE", "POSITIVE", "SIZE", "VIEW", "LIMITED", "PRAGMA",
+    "SMALLINT", "VIEWS", "LOOP", "PRIOR", "SPACE", "WHEN", "MAX",
+    "PRIVATE", "SQL", "WHERE", "MIN", "PROCEDURE", "SQLCODE", "WHILE",
+    "MINUS", "PUBLIC", "SQLERRM", "WITH", "MLSLABEL", "RAISE",
+    "START", "WORK", "MOD", "RANGE", "STATEMENT", "XOR", "MODE",
+    "REAL", "STDDEV", "NATURAL", "RECORD", "SUBTYPE"
+]
+
+oracle_spatial_keywords = []
+
+# SQL functions
+# other than math/string/aggregate/date/conversion/xml/data mining
+functions = [
+    # FROM
+    # https://docs.oracle.com/cd/B19306_01/server.102/b14200/functions001.htm
+    "CAST", "COALESCE", "DECODE", "GREATEST", "LEAST", "LNNVL",
+    "NULLIF", "NVL", "NVL2", "SET", "UID", "USER", "USERENV"
+]
+
+# SQL math functions
+math_functions = [
+    'ABS', 'ACOS', 'ASIN', 'ATAN', 'ATAN2', 'BITAND', 'CEIL', 'COS',
+    'COSH', 'EXP', 'FLOOR', 'LN', 'LOG', 'MOD', 'NANVL', 'POWER',
+    'REMAINDER', 'ROUND', 'SIGN', 'SIN', 'SINH', 'SQRT', 'TAN',
+    'TANH', 'TRUNC', 'WIDTH_BUCKET'
+]
+
+# Strings functions
+string_functions = [
+    'CHR', 'CONCAT', 'INITCAP', 'LOWER', 'LPAD', 'LTRIM', 'NLS_INITCAP',
+    'NLS_LOWER', 'NLSSORT', 'NLS_UPPER', 'REGEXP_REPLACE', 'REGEXP_SUBSTR',
+    'REPLACE', 'RPAD', 'RTRIM', 'SOUNDEX', 'SUBSTR', 'TRANSLATE', 'TREAT',
+    'TRIM', 'UPPER', 'ASCII', 'INSTR', 'LENGTH', 'REGEXP_INSTR'
+]
+
+# Aggregate functions
+aggregate_functions = [
+    'AVG', 'COLLECT', 'CORR', 'COUNT', 'COVAR_POP', 'COVAR_SAMP', 'CUME_DIST',
+    'DENSE_RANK', 'FIRST', 'GROUP_ID', 'GROUPING', 'GROUPING_ID',
+    'LAST', 'MAX', 'MEDIAN', 'MIN', 'PERCENTILE_CONT',
+    'PERCENTILE_DISC', 'PERCENT_RANK', 'RANK',
+    'STATS_BINOMIAL_TEST', 'STATS_CROSSTAB', 'STATS_F_TEST',
+    'STATS_KS_TEST', 'STATS_MODE', 'STATS_MW_TEST',
+    'STATS_ONE_WAY_ANOVA', 'STATS_WSR_TEST', 'STDDEV',
+    'STDDEV_POP', 'STDDEV_SAMP', 'SUM', 'SYS_XMLAGG', 'VAR_POP',
+    'VAR_SAMP', 'VARIANCE', 'XMLAGG'
+]
+
+oracle_spatial_functions = [
+    # From http://docs.oracle.com/cd/B19306_01/appdev.102/b14255/toc.htm
+    # Spatial operators
+    "SDO_ANYINTERACT", "SDO_CONTAINS", "SDO_COVEREDBY", "SDO_COVERS",
+    "SDO_EQUAL", "SDO_FILTER", "SDO_INSIDE", "SDO_JOIN", "SDO_NN",
+    "SDO_NN_DISTANCE", "SDO_ON", "SDO_OVERLAPBDYDISJOINT",
+    "SDO_OVERLAPBDYINTERSECT", "SDO_OVERLAPS", "SDO_RELATE",
+    "SDO_TOUCH", "SDO_WITHIN_DISTANCE",
+    # SPATIAL AGGREGATE FUNCTIONS
+    "SDO_AGGR_CENTROID", "SDO_AGGR_CONCAT_LINES",
+    "SDO_AGGR_CONVEXHULL", "SDO_AGGR_LRS_CONCAT", "SDO_AGGR_MBR",
+    "SDO_AGGR_UNION",
+    # COORDINATE SYSTEM TRANSFORMATION (SDO_CS)
+    "SDO_CS.ADD_PREFERENCE_FOR_OP", "SDO_CS.CONVERT_NADCON_TO_XML",
+    "SDO_CS.CONVERT_NTV2_TO_XML", "SDO_CS.CONVERT_XML_TO_NADCON",
+    "SDO_CS.CONVERT_XML_TO_NTV2", "SDO_CS.CREATE_CONCATENATED_OP",
+    "SDO_CS.CREATE_OBVIOUS_EPSG_RULES",
+    "SDO_CS.CREATE_PREF_CONCATENATED_OP",
+    "SDO_CS.DELETE_ALL_EPSG_RULES", "SDO_CS.DELETE_OP",
+    "SDO_CS.DETERMINE_CHAIN", "SDO_CS.DETERMINE_DEFAULT_CHAIN",
+    "SDO_CS.FIND_GEOG_CRS", "SDO_CS.FIND_PROJ_CRS",
+    "SDO_CS.FROM_OGC_SIMPLEFEATURE_SRS", "SDO_CS.FROM_USNG",
+    "SDO_CS.MAP_EPSG_SRID_TO_ORACLE",
+    "SDO_CS.MAP_ORACLE_SRID_TO_EPSG",
+    "SDO_CS.REVOKE_PREFERENCE_FOR_OP",
+    "SDO_CS.TO_OGC_SIMPLEFEATURE_SRS", "SDO_CS.TO_USNG",
+    "SDO_CS.TRANSFORM", "SDO_CS.TRANSFORM_LAYER",
+    "SDO_CS.UPDATE_WKTS_FOR_ALL_EPSG_CRS",
+    "SDO_CS.UPDATE_WKTS_FOR_EPSG_CRS",
+    "SDO_CS.UPDATE_WKTS_FOR_EPSG_DATUM",
+    "SDO_CS.UPDATE_WKTS_FOR_EPSG_ELLIPS",
+    "SDO_CS.UPDATE_WKTS_FOR_EPSG_OP",
+    "SDO_CS.UPDATE_WKTS_FOR_EPSG_PARAM",
+    "SDO_CS.UPDATE_WKTS_FOR_EPSG_PM", "SDO_CS.VALIDATE_WKT",
+    "SDO_CS.VIEWPORT_TRANSFORM",
+    # GEOCODING (SDO_GCDR)
+    "SDO_GCDR.GEOCODE", "SDO_GCDR.GEOCODE_ADDR",
+    "SDO_GCDR.GEOCODE_ADDR_ALL", "SDO_GCDR.GEOCODE_ALL",
+    "SDO_GCDR.GEOCODE_AS_GEOMETRY", "SDO_GCDR.REVERSE_GEOCODE",
+    # GEOMETRY (SDO_GEOM)
+    "SDO_GEOM.RELATE", "SDO_GEOM.SDO_ARC_DENSIFY",
+    "SDO_GEOM.SDO_AREA", "SDO_GEOM.SDO_BUFFER",
+    "SDO_GEOM.SDO_CENTROID", "SDO_GEOM.SDO_CONVEXHULL",
+    "SDO_GEOM.SDO_DIFFERENCE", "SDO_GEOM.SDO_DISTANCE",
+    "SDO_GEOM.SDO_INTERSECTION", "SDO_GEOM.SDO_LENGTH",
+    "SDO_GEOM.SDO_MAX_MBR_ORDINATE", "SDO_GEOM.SDO_MBR",
+    "SDO_GEOM.SDO_MIN_MBR_ORDINATE", "SDO_GEOM.SDO_POINTONSURFACE",
+    "SDO_GEOM.SDO_UNION", "SDO_GEOM.SDO_XOR",
+    "SDO_GEOM.VALIDATE_GEOMETRY_WITH_CONTEXT",
+    "SDO_GEOM.VALIDATE_LAYER_WITH_CONTEXT",
+    "SDO_GEOM.WITHIN_DISTANCE",
+    # LINEAR REFERENCING SYSTEM (SDO_LRS)
+    "SDO_LRS.CLIP_GEOM_SEGMENT", "SDO_LRS.CONCATENATE_GEOM_SEGMENTS",
+    "SDO_LRS.CONNECTED_GEOM_SEGMENTS",
+    "SDO_LRS.CONVERT_TO_LRS_DIM_ARRAY", "SDO_LRS.CONVERT_TO_LRS_GEOM",
+    "SDO_LRS.CONVERT_TO_LRS_LAYER",
+    "SDO_LRS.CONVERT_TO_STD_DIM_ARRAY", "SDO_LRS.CONVERT_TO_STD_GEOM",
+    "SDO_LRS.CONVERT_TO_STD_LAYER", "SDO_LRS.DEFINE_GEOM_SEGMENT",
+    "SDO_LRS.DYNAMIC_SEGMENT", "SDO_LRS.FIND_LRS_DIM_POS",
+    "SDO_LRS.FIND_MEASURE", "SDO_LRS.FIND_OFFSET",
+    "SDO_LRS.GEOM_SEGMENT_END_MEASURE", "SDO_LRS.GEOM_SEGMENT_END_PT",
+    "SDO_LRS.GEOM_SEGMENT_LENGTH",
+    "SDO_LRS.GEOM_SEGMENT_START_MEASURE",
+    "SDO_LRS.GEOM_SEGMENT_START_PT", "SDO_LRS.GET_MEASURE",
+    "SDO_LRS.GET_NEXT_SHAPE_PT", "SDO_LRS.GET_NEXT_SHAPE_PT_MEASURE",
+    "SDO_LRS.GET_PREV_SHAPE_PT", "SDO_LRS.GET_PREV_SHAPE_PT_MEASURE",
+    "SDO_LRS.IS_GEOM_SEGMENT_DEFINED",
+    "SDO_LRS.IS_MEASURE_DECREASING", "SDO_LRS.IS_MEASURE_INCREASING",
+    "SDO_LRS.IS_SHAPE_PT_MEASURE", "SDO_LRS.LOCATE_PT",
+    "SDO_LRS.LRS_INTERSECTION", "SDO_LRS.MEASURE_RANGE",
+    "SDO_LRS.MEASURE_TO_PERCENTAGE", "SDO_LRS.OFFSET_GEOM_SEGMENT",
+    "SDO_LRS.PERCENTAGE_TO_MEASURE", "SDO_LRS.PROJECT_PT",
+    "SDO_LRS.REDEFINE_GEOM_SEGMENT", "SDO_LRS.RESET_MEASURE",
+    "SDO_LRS.REVERSE_GEOMETRY", "SDO_LRS.REVERSE_MEASURE",
+    "SDO_LRS.SET_PT_MEASURE", "SDO_LRS.SPLIT_GEOM_SEGMENT",
+    "SDO_LRS.TRANSLATE_MEASURE", "SDO_LRS.VALID_GEOM_SEGMENT",
+    "SDO_LRS.VALID_LRS_PT", "SDO_LRS.VALID_MEASURE",
+    "SDO_LRS.VALIDATE_LRS_GEOMETRY",
+    # SDO_MIGRATE
+    "SDO_MIGRATE.TO_CURRENT",
+    # SPATIAL ANALYSIS AND MINING (SDO_SAM)
+    "SDO_SAM.AGGREGATES_FOR_GEOMETRY", "SDO_SAM.AGGREGATES_FOR_LAYER",
+    "SDO_SAM.BIN_GEOMETRY", "SDO_SAM.BIN_LAYER",
+    "SDO_SAM.COLOCATED_REFERENCE_FEATURES",
+    "SDO_SAM.SIMPLIFY_GEOMETRY", "SDO_SAM.SIMPLIFY_LAYER",
+    "SDO_SAM.SPATIAL_CLUSTERS", "SDO_SAM.TILED_AGGREGATES",
+    "SDO_SAM.TILED_BINS",
+    # TUNING (SDO_TUNE)
+    "SDO_TUNE.AVERAGE_MBR", "SDO_TUNE.ESTIMATE_RTREE_INDEX_SIZE",
+    "SDO_TUNE.EXTENT_OF", "SDO_TUNE.MIX_INFO",
+    "SDO_TUNE.QUALITY_DEGRADATION",
+    # UTILITY (SDO_UTIL)
+    "SDO_UTIL.APPEND", "SDO_UTIL.CIRCLE_POLYGON",
+    "SDO_UTIL.CONCAT_LINES", "SDO_UTIL.CONVERT_UNIT",
+    "SDO_UTIL.ELLIPSE_POLYGON", "SDO_UTIL.EXTRACT",
+    "SDO_UTIL.FROM_WKBGEOMETRY", "SDO_UTIL.FROM_WKTGEOMETRY",
+    "SDO_UTIL.GETNUMELEM", "SDO_UTIL.GETNUMVERTICES",
+    "SDO_UTIL.GETVERTICES", "SDO_UTIL.INITIALIZE_INDEXES_FOR_TTS",
+    "SDO_UTIL.POINT_AT_BEARING", "SDO_UTIL.POLYGONTOLINE",
+    "SDO_UTIL.PREPARE_FOR_TTS", "SDO_UTIL.RECTIFY_GEOMETRY",
+    "SDO_UTIL.REMOVE_DUPLICATE_VERTICES",
+    "SDO_UTIL.REVERSE_LINESTRING", "SDO_UTIL.SIMPLIFY",
+    "SDO_UTIL.TO_GMLGEOMETRY", "SDO_UTIL.TO_WKBGEOMETRY",
+    "SDO_UTIL.TO_WKTGEOMETRY", "SDO_UTIL.VALIDATE_WKBGEOMETRY",
+    "SDO_UTIL.VALIDATE_WKTGEOMETRY"
+]
+
+# Oracle Operators
+operators = [
+    ' AND ', ' OR ', '||', ' < ', ' <= ', ' > ', ' >= ', ' = ',
+    ' <> ', '!=', '^=', ' IS ', ' IS NOT ', ' IN ', ' ANY ', ' SOME ',
+    ' NOT IN ', ' LIKE ', ' GLOB ', ' MATCH ', ' REGEXP ',
+    ' BETWEEN x AND y ', ' NOT BETWEEN x AND y ', ' EXISTS ',
+    ' IS NULL ', ' IS NOT NULL', ' ALL ', ' NOT ',
+    ' CASE {column} WHEN {value} THEN {value} '
+]
+
+# constants
+constants = ["null", "false", "true"]
+oracle_spatial_constants = []
+
+
+def getSqlDictionary(spatial=True):
+    k, c, f = list(keywords), list(constants), list(functions)
+
+    if spatial:
+        k += oracle_spatial_keywords
+        f += oracle_spatial_functions
+        c += oracle_spatial_constants
+
+    return {'keyword': k, 'constant': c, 'function': f}
+
+
+def getQueryBuilderDictionary():
+    # concat functions
+    def ff(l):
+        return [s for s in l if s[0] != '*']
+
+    def add_paren(l):
+        return [s + "(" for s in l]
+
+    foo = sorted(
+        add_paren(
+            ff(
+                list(
+                    set.union(set(functions),
+                              set(oracle_spatial_functions))))))
+    m = sorted(add_paren(ff(math_functions)))
+    agg = sorted(add_paren(ff(aggregate_functions)))
+    op = ff(operators)
+    s = sorted(add_paren(ff(string_functions)))
+    return {'function': foo, 'math': m, 'aggregate': agg,
+            'operator': op, 'string': s}

+ 1389 - 0
db_manager/db_plugins/plugin.py

@@ -0,0 +1,1389 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt, QObject, pyqtSignal, QByteArray
+
+from qgis.PyQt.QtWidgets import (
+    QFormLayout,
+    QComboBox,
+    QCheckBox,
+    QDialogButtonBox,
+    QPushButton,
+    QLabel,
+    QApplication,
+    QAction,
+    QMenu,
+    QInputDialog,
+    QMessageBox,
+    QDialog,
+    QWidget
+)
+
+from qgis.PyQt.QtGui import QKeySequence
+
+from qgis.core import (
+    Qgis,
+    QgsApplication,
+    QgsSettings,
+    QgsMapLayerType,
+    QgsWkbTypes,
+    QgsProviderConnectionException,
+    QgsProviderRegistry,
+    QgsVectorLayer,
+    QgsRasterLayer,
+    QgsProject,
+    QgsMessageLog,
+    QgsCoordinateReferenceSystem
+)
+
+from qgis.gui import (
+    QgsMessageBarItem,
+    QgsProjectionSelectionWidget
+)
+
+from ..db_plugins import createDbPlugin
+
+
+class BaseError(Exception):
+    """Base class for exceptions in the plugin."""
+
+    def __init__(self, e):
+        if isinstance(e, Exception):
+            msg = e.args[0] if len(e.args) > 0 else ''
+        else:
+            msg = e
+
+        if not isinstance(msg, str):
+            msg = str(msg, 'utf-8', 'replace')  # convert from utf8 and replace errors (if any)
+
+        self.msg = msg
+        Exception.__init__(self, msg)
+
+    def __unicode__(self):
+        return self.msg
+
+
+class InvalidDataException(BaseError):
+    pass
+
+
+class ConnectionError(BaseError):
+    pass
+
+
+class DbError(BaseError):
+
+    def __init__(self, e, query=None):
+        BaseError.__init__(self, e)
+        self.query = str(query) if query is not None else None
+
+    def __unicode__(self):
+        if self.query is None:
+            return BaseError.__unicode__(self)
+
+        msg = QApplication.translate("DBManagerPlugin", "Error:\n{0}").format(BaseError.__unicode__(self))
+        if self.query:
+            msg += QApplication.translate("DBManagerPlugin", "\n\nQuery:\n{0}").format(self.query)
+        return msg
+
+
+class DBPlugin(QObject):
+    deleted = pyqtSignal()
+    changed = pyqtSignal()
+    aboutToChange = pyqtSignal()
+
+    def __init__(self, conn_name, parent=None):
+        QObject.__init__(self, parent)
+        self.connName = conn_name
+        self.db = None
+
+    def __del__(self):
+        pass  # print "DBPlugin.__del__", self.connName
+
+    def connectionIcon(self):
+        return QgsApplication.getThemeIcon("/mIconDbSchema.svg")
+
+    def connectionName(self):
+        return self.connName
+
+    def database(self):
+        return self.db
+
+    def info(self):
+        from .info_model import DatabaseInfo
+
+        return DatabaseInfo(None)
+
+    def connect(self, parent=None):
+        raise NotImplementedError('Needs to be implemented by subclasses')
+
+    def connectToUri(self, uri):
+        self.db = self.databasesFactory(self, uri)
+        if self.db:
+            return True
+        return False
+
+    def reconnect(self):
+        if self.db is not None:
+            uri = self.db.uri()
+            self.db.deleteLater()
+            self.db = None
+            return self.connectToUri(uri)
+        return self.connect(self.parent())
+
+    def remove(self):
+
+        # Try the new API first, fallback to legacy
+        try:
+            md = QgsProviderRegistry.instance().providerMetadata(self.providerName())
+            md.deleteConnection(self.connectionName())
+        except (AttributeError, QgsProviderConnectionException):
+            settings = QgsSettings()
+            settings.beginGroup("/%s/%s" % (self.connectionSettingsKey(), self.connectionName()))
+            settings.remove("")
+
+        self.deleted.emit()
+        return True
+
+    @classmethod
+    def addConnection(self, conn_name, uri):
+        raise NotImplementedError('Needs to be implemented by subclasses')
+
+    @classmethod
+    def icon(self):
+        return None
+
+    @classmethod
+    def typeName(self):
+        # return the db typename (e.g. 'postgis')
+        pass
+
+    @classmethod
+    def typeNameString(self):
+        # return the db typename string (e.g. 'PostGIS')
+        pass
+
+    @classmethod
+    def providerName(self):
+        # return the provider's name (e.g. 'postgres')
+        pass
+
+    @classmethod
+    def connectionSettingsKey(self):
+        # return the key used to store the connections in settings
+        pass
+
+    @classmethod
+    def connections(self):
+        # get the list of connections
+
+        conn_list = []
+
+        # First try with the new core API, if that fails, proceed with legacy code
+        try:
+            md = QgsProviderRegistry.instance().providerMetadata(self.providerName())
+            for name in md.dbConnections(False).keys():
+                conn_list.append(createDbPlugin(self.typeName(), name))
+        except (AttributeError, QgsProviderConnectionException):
+            settings = QgsSettings()
+            settings.beginGroup(self.connectionSettingsKey())
+            for name in settings.childGroups():
+                conn_list.append(createDbPlugin(self.typeName(), name))
+            settings.endGroup()
+
+        return conn_list
+
+    def databasesFactory(self, connection, uri):
+        return None
+
+    @classmethod
+    def addConnectionActionSlot(self, item, action, parent):
+        raise NotImplementedError('Needs to be implemented by subclasses')
+
+    def removeActionSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        try:
+            res = QMessageBox.question(parent, QApplication.translate("DBManagerPlugin", "DB Manager"),
+                                       QApplication.translate("DBManagerPlugin",
+                                                              "Really remove connection to {0}?").format(item.connectionName()),
+                                       QMessageBox.Yes | QMessageBox.No)
+            if res != QMessageBox.Yes:
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        item.remove()
+
+
+class DbItemObject(QObject):
+    changed = pyqtSignal()
+    aboutToChange = pyqtSignal()
+    deleted = pyqtSignal()
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+
+    def database(self):
+        return None
+
+    def refresh(self):
+        self.changed.emit()  # refresh the item data reading them from the db
+
+    def info(self):
+        pass
+
+    def runAction(self):
+        pass
+
+    def registerActions(self, mainWindow):
+        pass
+
+
+class Database(DbItemObject):
+
+    def __init__(self, dbplugin, uri):
+        super().__init__(dbplugin)
+        self.connector = self.connectorsFactory(uri)
+
+    def connectorsFactory(self, uri):
+        return None
+
+    def __del__(self):
+        self.connector = None
+        pass  # print "Database.__del__", self
+
+    def connection(self):
+        return self.parent()
+
+    def dbplugin(self):
+        return self.parent()
+
+    def database(self):
+        return self
+
+    def uri(self):
+        return self.connector.uri()
+
+    def publicUri(self):
+        return self.connector.publicUri()
+
+    def delete(self):
+        self.aboutToChange.emit()
+        ret = self.connection().remove()
+        if ret is not False:
+            self.deleted.emit()
+        return ret
+
+    def info(self):
+        from .info_model import DatabaseInfo
+
+        return DatabaseInfo(self)
+
+    def sqlResultModel(self, sql, parent):
+        from .data_model import SqlResultModel
+
+        return SqlResultModel(self, sql, parent)
+
+    def sqlResultModelAsync(self, sql, parent):
+        from .data_model import SqlResultModelAsync
+
+        return SqlResultModelAsync(self, sql, parent)
+
+    def columnUniqueValuesModel(self, col, table, limit=10):
+        l = ""
+        if limit is not None:
+            l = "LIMIT %d" % limit
+        return self.sqlResultModel("SELECT DISTINCT %s FROM %s %s" % (col, table, l), self)
+
+    def uniqueIdFunction(self):
+        """Return a SQL function used to generate a unique id for rows of a query"""
+        # may be overloaded by derived classes
+        return "row_number() over ()"
+
+    def toSqlLayer(self, sql, geomCol, uniqueCol, layerName="QueryLayer", layerType=None, avoidSelectById=False, filter=""):
+        if uniqueCol is None:
+            if hasattr(self, 'uniqueIdFunction'):
+                uniqueFct = self.uniqueIdFunction()
+                if uniqueFct is not None:
+                    q = 1
+                    while "_subq_%d_" % q in sql:
+                        q += 1
+                    sql = "SELECT %s AS _uid_,* FROM (%s\n) AS _subq_%d_" % (uniqueFct, sql, q)
+                    uniqueCol = "_uid_"
+
+        uri = self.uri()
+        uri.setDataSource("", "(%s\n)" % sql, geomCol, filter, uniqueCol)
+        if avoidSelectById:
+            uri.disableSelectAtId(True)
+        provider = self.dbplugin().providerName()
+        if layerType == QgsMapLayerType.RasterLayer:
+            return QgsRasterLayer(uri.uri(False), layerName, provider)
+        return QgsVectorLayer(uri.uri(False), layerName, provider)
+
+    def registerAllActions(self, mainWindow):
+        self.registerDatabaseActions(mainWindow)
+        self.registerSubPluginActions(mainWindow)
+
+    def registerSubPluginActions(self, mainWindow):
+        # load plugins!
+        try:
+            exec("from .%s.plugins import load" % self.dbplugin().typeName(), globals())
+        except ImportError:
+            pass
+        else:
+            load(self, mainWindow)  # NOQA
+
+    def registerDatabaseActions(self, mainWindow):
+        action = QAction(QApplication.translate("DBManagerPlugin", "&Re-connect"), self)
+        mainWindow.registerAction(action, QApplication.translate("DBManagerPlugin", "&Database"),
+                                  self.reconnectActionSlot)
+
+        if self.schemas() is not None:
+            action = QAction(QApplication.translate("DBManagerPlugin", "&Create Schema…"), self)
+            mainWindow.registerAction(action, QApplication.translate("DBManagerPlugin", "&Schema"),
+                                      self.createSchemaActionSlot)
+            action = QAction(QApplication.translate("DBManagerPlugin", "&Delete (Empty) Schema"), self)
+            mainWindow.registerAction(action, QApplication.translate("DBManagerPlugin", "&Schema"),
+                                      self.deleteSchemaActionSlot)
+
+        action = QAction(QApplication.translate("DBManagerPlugin", "Delete Selected Item"), self)
+        mainWindow.registerAction(action, None, self.deleteActionSlot)
+        action.setShortcuts(QKeySequence.Delete)
+
+        action = QAction(QgsApplication.getThemeIcon("/mActionCreateTable.svg"),
+                         QApplication.translate("DBManagerPlugin", "&Create Table…"), self)
+        mainWindow.registerAction(action, QApplication.translate("DBManagerPlugin", "&Table"),
+                                  self.createTableActionSlot)
+        action = QAction(QgsApplication.getThemeIcon("/mActionEditTable.svg"),
+                         QApplication.translate("DBManagerPlugin", "&Edit Table…"), self)
+        mainWindow.registerAction(action, QApplication.translate("DBManagerPlugin", "&Table"), self.editTableActionSlot)
+        action = QAction(QgsApplication.getThemeIcon("/mActionDeleteTable.svg"),
+                         QApplication.translate("DBManagerPlugin", "&Delete Table/View…"), self)
+        mainWindow.registerAction(action, QApplication.translate("DBManagerPlugin", "&Table"),
+                                  self.deleteTableActionSlot)
+        action = QAction(QApplication.translate("DBManagerPlugin", "&Empty Table…"), self)
+        mainWindow.registerAction(action, QApplication.translate("DBManagerPlugin", "&Table"),
+                                  self.emptyTableActionSlot)
+
+        if self.schemas() is not None:
+            action = QAction(QApplication.translate("DBManagerPlugin", "&Move to Schema"), self)
+            action.setMenu(QMenu(mainWindow))
+
+            def invoke_callback():
+                return mainWindow.invokeCallback(self.prepareMenuMoveTableToSchemaActionSlot)
+
+            action.menu().aboutToShow.connect(invoke_callback)
+            mainWindow.registerAction(action, QApplication.translate("DBManagerPlugin", "&Table"))
+
+    def reconnectActionSlot(self, item, action, parent):
+        db = item.database()
+        db.connection().reconnect()
+        db.refresh()
+
+    def deleteActionSlot(self, item, action, parent):
+        if isinstance(item, Schema):
+            self.deleteSchemaActionSlot(item, action, parent)
+        elif isinstance(item, Table):
+            self.deleteTableActionSlot(item, action, parent)
+        else:
+            QApplication.restoreOverrideCursor()
+            parent.infoBar.pushMessage(QApplication.translate("DBManagerPlugin", "Cannot delete the selected item."),
+                                       Qgis.Info, parent.iface.messageTimeout())
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+    def createSchemaActionSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        try:
+            if not isinstance(item, (DBPlugin, Schema, Table)) or item.database() is None:
+                parent.infoBar.pushMessage(
+                    QApplication.translate("DBManagerPlugin", "No database selected or you are not connected to it."),
+                    Qgis.Info, parent.iface.messageTimeout())
+                return
+            (schema, ok) = QInputDialog.getText(parent, QApplication.translate("DBManagerPlugin", "New schema"),
+                                                QApplication.translate("DBManagerPlugin", "Enter new schema name"))
+            if not ok:
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        self.createSchema(schema)
+
+    def deleteSchemaActionSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        try:
+            if not isinstance(item, Schema):
+                parent.infoBar.pushMessage(
+                    QApplication.translate("DBManagerPlugin", "Select an empty schema for deletion."),
+                    Qgis.Info, parent.iface.messageTimeout())
+                return
+            res = QMessageBox.question(parent, QApplication.translate("DBManagerPlugin", "DB Manager"),
+                                       QApplication.translate("DBManagerPlugin",
+                                                              "Really delete schema {0}?").format(item.name),
+                                       QMessageBox.Yes | QMessageBox.No)
+            if res != QMessageBox.Yes:
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        item.delete()
+
+    def schemasFactory(self, row, db):
+        return None
+
+    def schemas(self):
+        schemas = self.connector.getSchemas()
+        if schemas is not None:
+            schemas = [self.schemasFactory(x, self) for x in schemas]
+        return schemas
+
+    def createSchema(self, name):
+        self.connector.createSchema(name)
+        self.refresh()
+
+    def createTableActionSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        if not hasattr(item, 'database') or item.database() is None:
+            parent.infoBar.pushMessage(
+                QApplication.translate("DBManagerPlugin", "No database selected or you are not connected to it."),
+                Qgis.Info, parent.iface.messageTimeout())
+            return
+        from ..dlg_create_table import DlgCreateTable
+
+        DlgCreateTable(item, parent).exec_()
+        QApplication.setOverrideCursor(Qt.WaitCursor)
+
+    def editTableActionSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        try:
+            if not isinstance(item, Table) or item.isView:
+                parent.infoBar.pushMessage(QApplication.translate("DBManagerPlugin", "Select a table to edit."),
+                                           Qgis.Info, parent.iface.messageTimeout())
+                return
+
+            if isinstance(item, RasterTable):
+                parent.infoBar.pushMessage(QApplication.translate("DBManagerPlugin", "Editing of raster tables is not supported."),
+                                           Qgis.Info, parent.iface.messageTimeout())
+                return
+
+            from ..dlg_table_properties import DlgTableProperties
+
+            DlgTableProperties(item, parent).exec_()
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+    def deleteTableActionSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        try:
+            if not isinstance(item, Table):
+                parent.infoBar.pushMessage(
+                    QApplication.translate("DBManagerPlugin", "Select a table/view for deletion."),
+                    Qgis.Info, parent.iface.messageTimeout())
+                return
+            res = QMessageBox.question(parent, QApplication.translate("DBManagerPlugin", "DB Manager"),
+                                       QApplication.translate("DBManagerPlugin",
+                                                              "Really delete table/view {0}?").format(item.name),
+                                       QMessageBox.Yes | QMessageBox.No)
+            if res != QMessageBox.Yes:
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        item.delete()
+
+    def emptyTableActionSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        try:
+            if not isinstance(item, Table) or item.isView:
+                parent.infoBar.pushMessage(QApplication.translate("DBManagerPlugin", "Select a table to empty it."),
+                                           Qgis.Info, parent.iface.messageTimeout())
+                return
+            res = QMessageBox.question(parent, QApplication.translate("DBManagerPlugin", "DB Manager"),
+                                       QApplication.translate("DBManagerPlugin",
+                                                              "Really delete all items from table {0}?").format(item.name),
+                                       QMessageBox.Yes | QMessageBox.No)
+            if res != QMessageBox.Yes:
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        item.empty()
+
+    def prepareMenuMoveTableToSchemaActionSlot(self, item, menu, mainWindow):
+        """ populate menu with schemas """
+
+        def slot(x):
+            return lambda: mainWindow.invokeCallback(self.moveTableToSchemaActionSlot, x)
+
+        menu.clear()
+        for schema in self.schemas():
+            menu.addAction(schema.name, slot(schema))
+
+    def moveTableToSchemaActionSlot(self, item, action, parent, new_schema):
+        QApplication.restoreOverrideCursor()
+        try:
+            if not isinstance(item, Table):
+                parent.infoBar.pushMessage(QApplication.translate("DBManagerPlugin", "Select a table/view."),
+                                           Qgis.Info, parent.iface.messageTimeout())
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        item.moveToSchema(new_schema)
+
+    def tablesFactory(self, row, db, schema=None):
+        typ, row = row[0], row[1:]
+        if typ == Table.VectorType:
+            return self.vectorTablesFactory(row, db, schema)
+        elif typ == Table.RasterType:
+            return self.rasterTablesFactory(row, db, schema)
+        return self.dataTablesFactory(row, db, schema)
+
+    def dataTablesFactory(self, row, db, schema=None):
+        return None
+
+    def vectorTablesFactory(self, row, db, schema=None):
+        return None
+
+    def rasterTablesFactory(self, row, db, schema=None):
+        return None
+
+    def tables(self, schema=None, sys_tables=False):
+        tables = self.connector.getTables(schema.name if schema else None, sys_tables)
+        if tables is not None:
+            ret = [
+                self.tablesFactory(t, self, schema)
+                for t in tables
+            ]
+        return ret
+
+    def createTable(self, table, fields, schema=None):
+        field_defs = [x.definition() for x in fields]
+        pkeys = [x for x in fields if x.primaryKey]
+        pk_name = pkeys[0].name if len(pkeys) > 0 else None
+
+        ret = self.connector.createTable((schema, table), field_defs, pk_name)
+        if ret is not False:
+            # Add comments if any, because definition does not include
+            # the comment
+            for f in fields:
+                if f.comment:
+                    self.connector.updateTableColumn(
+                        (schema, table), f.name, comment=f.comment
+                    )
+            self.refresh()
+        return ret
+
+    def createVectorTable(self, table, fields, geom, schema=None):
+        ret = self.createTable(table, fields, schema)
+        if not ret:
+            return False
+
+        try:
+            createGeomCol = geom is not None
+            if createGeomCol:
+                geomCol, geomType, geomSrid, geomDim = geom[:4]
+                createSpatialIndex = geom[4] if len(geom) > 4 else False
+
+                self.connector.addGeometryColumn((schema, table), geomCol, geomType, geomSrid, geomDim)
+
+                if createSpatialIndex:
+                    # commit data definition changes, otherwise index can't be built
+                    self.connector._commit()
+                    self.connector.createSpatialIndex((schema, table), geomCol)
+
+        finally:
+            self.refresh()
+        return True
+
+    def explicitSpatialIndex(self):
+        return False
+
+    def spatialIndexClause(self, src_table, src_column, dest_table, dest_table_column):
+        return None
+
+    def hasLowercaseFieldNamesOption(self):
+        return False
+
+
+class Schema(DbItemObject):
+
+    def __init__(self, db):
+        DbItemObject.__init__(self, db)
+        self.oid = self.name = self.owner = self.perms = None
+        self.comment = None
+        self.tableCount = 0
+
+    def __del__(self):
+        pass  # print "Schema.__del__", self
+
+    def database(self):
+        return self.parent()
+
+    def schema(self):
+        return self
+
+    def tables(self):
+        return self.database().tables(self)
+
+    def delete(self):
+        self.aboutToChange.emit()
+        ret = self.database().connector.deleteSchema(self.name)
+        if ret is not False:
+            self.deleted.emit()
+        return ret
+
+    def rename(self, new_name):
+        self.aboutToChange.emit()
+        ret = self.database().connector.renameSchema(self.name, new_name)
+        if ret is not False:
+            self.name = new_name
+            # FIXME: refresh triggers
+            self.refresh()
+        return ret
+
+    def info(self):
+        from .info_model import SchemaInfo
+
+        return SchemaInfo(self)
+
+
+class Table(DbItemObject):
+    TableType, VectorType, RasterType = list(range(3))
+
+    def __init__(self, db, schema=None, parent=None):
+        DbItemObject.__init__(self, db)
+        self._schema = schema
+        if hasattr(self, 'type'):
+            return
+        self.type = Table.TableType
+
+        self.name = self.isView = self.owner = self.pages = None
+        self.comment = None
+        self.rowCount = None
+
+        self._fields = self._indexes = self._constraints = self._triggers = self._rules = None
+
+    def __del__(self):
+        pass  # print "Table.__del__", self
+
+    def canBeAddedToCanvas(self):
+        return True
+
+    def database(self):
+        return self.parent()
+
+    def schema(self):
+        return self._schema
+
+    def schemaName(self):
+        return self.schema().name if self.schema() else None
+
+    def quotedName(self):
+        return self.database().connector.quoteId((self.schemaName(), self.name))
+
+    def delete(self):
+        self.aboutToChange.emit()
+        if self.isView:
+            ret = self.database().connector.deleteView((self.schemaName(), self.name))
+        else:
+            ret = self.database().connector.deleteTable((self.schemaName(), self.name))
+        if ret is not False:
+            self.deleted.emit()
+        return ret
+
+    def rename(self, new_name):
+        self.aboutToChange.emit()
+        ret = self.database().connector.renameTable((self.schemaName(), self.name), new_name)
+        if ret is not False:
+            self.name = new_name
+            self._triggers = None
+            self._rules = None
+            self._constraints = None
+            self.refresh()
+        return ret
+
+    def empty(self):
+        self.aboutToChange.emit()
+        ret = self.database().connector.emptyTable((self.schemaName(), self.name))
+        if ret is not False:
+            self.refreshRowCount()
+        return ret
+
+    def moveToSchema(self, schema):
+        self.aboutToChange.emit()
+        if self.schema() == schema:
+            return True
+        ret = self.database().connector.moveTableToSchema((self.schemaName(), self.name), schema.name)
+        if ret is not False:
+            self.schema().refresh()
+            schema.refresh()
+        return ret
+
+    def info(self):
+        from .info_model import TableInfo
+
+        return TableInfo(self)
+
+    def uri(self):
+        uri = self.database().uri()
+        schema = self.schemaName() if self.schemaName() else ''
+        geomCol = self.geomColumn if self.type in [Table.VectorType, Table.RasterType] else ""
+        uniqueCol = self.getValidQgisUniqueFields(True) if self.isView else None
+        uri.setDataSource(schema, self.name, geomCol if geomCol else None, None, uniqueCol.name if uniqueCol else "")
+        return uri
+
+    def crs(self):
+        """Returns the CRS of this table or an invalid CRS if this is not a spatial table
+        This should be overwritten by any additional db plugins"""
+        return QgsCoordinateReferenceSystem()
+
+    def mimeUri(self):
+        layerType = "raster" if self.type == Table.RasterType else "vector"
+        return "%s:%s:%s:%s" % (layerType, self.database().dbplugin().providerName(), self.name, self.uri().uri(False))
+
+    def toMapLayer(self, geometryType=None, crs=None):
+        provider = self.database().dbplugin().providerName()
+        dataSourceUri = self.uri()
+        if geometryType:
+            dataSourceUri.setWkbType(QgsWkbTypes.parseType(geometryType))
+
+        if crs:
+            dataSourceUri.setSrid(str(crs.postgisSrid()))
+
+        uri = dataSourceUri.uri(False)
+        if self.type == Table.RasterType:
+            return QgsRasterLayer(uri, self.name, provider)
+        return QgsVectorLayer(uri, self.name, provider)
+
+    def geometryType(self):
+        pass
+
+    def getValidQgisUniqueFields(self, onlyOne=False):
+        """ list of fields valid to load the table as layer in QGIS canvas.
+                QGIS automatically search for a valid unique field, so it's
+                needed only for queries and views """
+
+        ret = []
+
+        # add the pk
+        pkcols = [x for x in self.fields() if x.primaryKey]
+        if len(pkcols) == 1:
+            ret.append(pkcols[0])
+
+        # then add both oid, serial and int fields with an unique index
+        indexes = self.indexes()
+        if indexes is not None:
+            for idx in indexes:
+                if idx.isUnique and len(idx.columns) == 1:
+                    fld = idx.fields()[idx.columns[0]]
+                    if fld.dataType in ["oid", "serial", "int4", "int8"] and fld not in ret:
+                        ret.append(fld)
+
+        # and finally append the other suitable fields
+        for fld in self.fields():
+            if fld.dataType in ["oid", "serial", "int4", "int8"] and fld not in ret:
+                ret.append(fld)
+
+        if onlyOne:
+            return ret[0] if len(ret) > 0 else None
+        return ret
+
+    def tableDataModel(self, parent):
+        pass
+
+    def tableFieldsFactory(self, row, table):
+        raise NotImplementedError('Needs to be implemented by subclasses')
+
+    def fields(self):
+        if self._fields is None:
+            fields = self.database().connector.getTableFields((self.schemaName(), self.name))
+            if fields is not None:
+                self._fields = [self.tableFieldsFactory(x, self) for x in fields]
+        return self._fields
+
+    def refreshFields(self):
+        self._fields = None  # refresh table fields
+        self.refresh()
+
+    def addField(self, fld):
+        self.aboutToChange.emit()
+        ret = self.database().connector.addTableColumn((self.schemaName(), self.name), fld.definition())
+        if ret is not False:
+            self.refreshFields()
+        return ret
+
+    def deleteField(self, fld):
+        self.aboutToChange.emit()
+        ret = self.database().connector.deleteTableColumn((self.schemaName(), self.name), fld.name)
+        if ret is not False:
+            self.refreshFields()
+            self.refreshConstraints()
+            self.refreshIndexes()
+        return ret
+
+    def addGeometryColumn(self, geomCol, geomType, srid, dim, createSpatialIndex=False):
+        self.aboutToChange.emit()
+        ret = self.database().connector.addGeometryColumn((self.schemaName(), self.name), geomCol, geomType, srid, dim)
+        if not ret:
+            return False
+
+        try:
+            if createSpatialIndex:
+                # commit data definition changes, otherwise index can't be built
+                self.database().connector._commit()
+                self.database().connector.createSpatialIndex((self.schemaName(), self.name), geomCol)
+
+        finally:
+            self.schema().refresh() if self.schema() else self.database().refresh()  # another table was added
+        return True
+
+    def tableConstraintsFactory(self):
+        return None
+
+    def constraints(self):
+        if self._constraints is None:
+            constraints = self.database().connector.getTableConstraints((self.schemaName(), self.name))
+            if constraints is not None:
+                self._constraints = [self.tableConstraintsFactory(x, self) for x in constraints]
+        return self._constraints
+
+    def refreshConstraints(self):
+        self._constraints = None  # refresh table constraints
+        self.refresh()
+
+    def addConstraint(self, constr):
+        self.aboutToChange.emit()
+        if constr.type == TableConstraint.TypePrimaryKey:
+            ret = self.database().connector.addTablePrimaryKey((self.schemaName(), self.name),
+                                                               constr.fields()[constr.columns[0]].name)
+        elif constr.type == TableConstraint.TypeUnique:
+            ret = self.database().connector.addTableUniqueConstraint((self.schemaName(), self.name),
+                                                                     constr.fields()[constr.columns[0]].name)
+        else:
+            return False
+        if ret is not False:
+            self.refreshConstraints()
+        return ret
+
+    def deleteConstraint(self, constr):
+        self.aboutToChange.emit()
+        ret = self.database().connector.deleteTableConstraint((self.schemaName(), self.name), constr.name)
+        if ret is not False:
+            self.refreshConstraints()
+        return ret
+
+    def tableIndexesFactory(self):
+        return None
+
+    def indexes(self):
+        if self._indexes is None:
+            indexes = self.database().connector.getTableIndexes((self.schemaName(), self.name))
+            if indexes is not None:
+                self._indexes = [self.tableIndexesFactory(x, self) for x in indexes]
+        return self._indexes
+
+    def refreshIndexes(self):
+        self._indexes = None  # refresh table indexes
+        self.refresh()
+
+    def addIndex(self, idx):
+        self.aboutToChange.emit()
+        ret = self.database().connector.createTableIndex((self.schemaName(), self.name), idx.name,
+                                                         idx.fields()[idx.columns[0]].name)
+        if ret is not False:
+            self.refreshIndexes()
+        return ret
+
+    def deleteIndex(self, idx):
+        self.aboutToChange.emit()
+        ret = self.database().connector.deleteTableIndex((self.schemaName(), self.name), idx.name)
+        if ret is not False:
+            self.refreshIndexes()
+        return ret
+
+    def tableTriggersFactory(self, row, table):
+        return None
+
+    def triggers(self):
+        if self._triggers is None:
+            triggers = self.database().connector.getTableTriggers((self.schemaName(), self.name))
+            if triggers is not None:
+                self._triggers = [self.tableTriggersFactory(x, self) for x in triggers]
+        return self._triggers
+
+    def refreshTriggers(self):
+        self._triggers = None  # refresh table triggers
+        self.refresh()
+
+    def tableRulesFactory(self, row, table):
+        return None
+
+    def rules(self):
+        if self._rules is None:
+            rules = self.database().connector.getTableRules((self.schemaName(), self.name))
+            if rules is not None:
+                self._rules = [self.tableRulesFactory(x, self) for x in rules]
+        return self._rules
+
+    def refreshRules(self):
+        self._rules = None  # refresh table rules
+        self.refresh()
+
+    def refreshRowCount(self):
+        self.aboutToChange.emit()
+        prevRowCount = self.rowCount
+        try:
+            self.rowCount = self.database().connector.getTableRowCount((self.schemaName(), self.name))
+            self.rowCount = int(self.rowCount) if self.rowCount is not None else None
+        except DbError:
+            self.rowCount = None
+        if self.rowCount != prevRowCount:
+            self.refresh()
+
+    def runAction(self, action):
+        action = str(action)
+
+        if action.startswith("rows/"):
+            if action == "rows/count":
+                self.refreshRowCount()
+                return True
+
+        elif action.startswith("triggers/"):
+            parts = action.split('/')
+            trigger_action = parts[1]
+
+            msg = QApplication.translate("DBManagerPlugin", "Do you want to {0} all triggers?").format(trigger_action)
+            QApplication.restoreOverrideCursor()
+            try:
+                if QMessageBox.question(None, QApplication.translate("DBManagerPlugin", "Table triggers"), msg,
+                                        QMessageBox.Yes | QMessageBox.No) == QMessageBox.No:
+                    return False
+            finally:
+                QApplication.setOverrideCursor(Qt.WaitCursor)
+
+            if trigger_action == "enable" or trigger_action == "disable":
+                enable = trigger_action == "enable"
+                self.aboutToChange.emit()
+                self.database().connector.enableAllTableTriggers(enable, (self.schemaName(), self.name))
+                self.refreshTriggers()
+                return True
+
+        elif action.startswith("trigger/"):
+            parts = action.split('/')
+            trigger_name = parts[1]
+            trigger_action = parts[2]
+
+            msg = QApplication.translate("DBManagerPlugin", "Do you want to {0} trigger {1}?").format(
+                trigger_action, trigger_name)
+            QApplication.restoreOverrideCursor()
+            try:
+                if QMessageBox.question(None, QApplication.translate("DBManagerPlugin", "Table trigger"), msg,
+                                        QMessageBox.Yes | QMessageBox.No) == QMessageBox.No:
+                    return False
+            finally:
+                QApplication.setOverrideCursor(Qt.WaitCursor)
+
+            if trigger_action == "delete":
+                self.aboutToChange.emit()
+                self.database().connector.deleteTableTrigger(trigger_name, (self.schemaName(), self.name))
+                self.refreshTriggers()
+                return True
+
+            elif trigger_action == "enable" or trigger_action == "disable":
+                enable = trigger_action == "enable"
+                self.aboutToChange.emit()
+                self.database().connector.enableTableTrigger(trigger_name, enable, (self.schemaName(), self.name))
+                self.refreshTriggers()
+                return True
+
+        return False
+
+    def addExtraContextMenuEntries(self, menu):
+        """Called whenever a context menu is shown for this table. Can be used to add additional actions to the menu."""
+        pass
+
+
+class VectorTable(Table):
+
+    def __init__(self, db, schema=None, parent=None):
+        if not hasattr(self, 'type'):  # check if the superclass constructor was called yet!
+            Table.__init__(self, db, schema, parent)
+        self.type = Table.VectorType
+        self.geomColumn = self.geomType = self.geomDim = self.srid = None
+        self.estimatedExtent = self.extent = None
+
+    def info(self):
+        from .info_model import VectorTableInfo
+
+        return VectorTableInfo(self)
+
+    def uri(self):
+        uri = super().uri()
+        for f in self.fields():
+            if f.primaryKey:
+                uri.setKeyColumn(f.name)
+                break
+        uri.setWkbType(QgsWkbTypes.parseType(self.geomType))
+        return uri
+
+    def hasSpatialIndex(self, geom_column=None):
+        geom_column = geom_column if geom_column is not None else self.geomColumn
+        fld = None
+        for fld in self.fields():
+            if fld.name == geom_column:
+                break
+        if fld is None:
+            return False
+
+        for idx in self.indexes():
+            if fld.num in idx.columns:
+                return True
+        return False
+
+    def createSpatialIndex(self, geom_column=None):
+        self.aboutToChange.emit()
+        geom_column = geom_column if geom_column is not None else self.geomColumn
+        ret = self.database().connector.createSpatialIndex((self.schemaName(), self.name), geom_column)
+        if ret is not False:
+            self.refreshIndexes()
+        return ret
+
+    def deleteSpatialIndex(self, geom_column=None):
+        self.aboutToChange.emit()
+        geom_column = geom_column if geom_column is not None else self.geomColumn
+        ret = self.database().connector.deleteSpatialIndex((self.schemaName(), self.name), geom_column)
+        if ret is not False:
+            self.refreshIndexes()
+        return ret
+
+    def refreshTableExtent(self):
+        prevExtent = self.extent
+        try:
+            self.extent = self.database().connector.getTableExtent((self.schemaName(), self.name), self.geomColumn)
+        except DbError:
+            self.extent = None
+        if self.extent != prevExtent:
+            self.refresh()
+
+    def refreshTableEstimatedExtent(self):
+        prevEstimatedExtent = self.estimatedExtent
+        try:
+            self.estimatedExtent = self.database().connector.getTableEstimatedExtent((self.schemaName(), self.name),
+                                                                                     self.geomColumn)
+        except DbError:
+            self.estimatedExtent = None
+        if self.estimatedExtent != prevEstimatedExtent:
+            self.refresh()
+
+    def runAction(self, action):
+        action = str(action)
+
+        if action.startswith("spatialindex/"):
+            parts = action.split('/')
+            spatialIndex_action = parts[1]
+
+            msg = QApplication.translate("DBManagerPlugin", "Do you want to {0} spatial index for field {1}?").format(
+                spatialIndex_action, self.geomColumn)
+            QApplication.restoreOverrideCursor()
+            try:
+                if QMessageBox.question(None, QApplication.translate("DBManagerPlugin", "Spatial Index"), msg,
+                                        QMessageBox.Yes | QMessageBox.No) == QMessageBox.No:
+                    return False
+            finally:
+                QApplication.setOverrideCursor(Qt.WaitCursor)
+
+            if spatialIndex_action == "create":
+                self.createSpatialIndex()
+                return True
+            elif spatialIndex_action == "delete":
+                self.deleteSpatialIndex()
+                return True
+
+        if action.startswith("extent/"):
+            if action == "extent/get":
+                self.refreshTableExtent()
+                return True
+
+            if action == "extent/estimated/get":
+                self.refreshTableEstimatedExtent()
+                return True
+
+        return Table.runAction(self, action)
+
+    def addLayer(self, geometryType=None, crs=None):
+        layer = self.toMapLayer(geometryType, crs)
+        layers = QgsProject.instance().addMapLayers([layer])
+        if len(layers) != 1:
+            QgsMessageLog.logMessage(self.tr("{layer} is an invalid layer - not loaded").format(layer=layer.publicSource()))
+            msgLabel = QLabel(self.tr("{layer} is an invalid layer and cannot be loaded. Please check the <a href=\"#messageLog\">message log</a> for further info.").format(layer=layer.publicSource()), self.mainWindow.infoBar)
+            msgLabel.setWordWrap(True)
+            msgLabel.linkActivated.connect(self.mainWindow.iface.mainWindow().findChild(QWidget, "MessageLog").show)
+            msgLabel.linkActivated.connect(self.mainWindow.iface.mainWindow().raise_)
+            self.mainWindow.infoBar.pushItem(QgsMessageBarItem(msgLabel, Qgis.Warning))
+
+    def showAdvancedVectorDialog(self):
+        dlg = QDialog()
+        dlg.setObjectName('dbManagerAdvancedVectorDialog')
+        settings = QgsSettings()
+        dlg.restoreGeometry(settings.value("/DB_Manager/advancedAddDialog/geometry", QByteArray(), type=QByteArray))
+        layout = QFormLayout()
+        dlg.setLayout(layout)
+        dlg.setWindowTitle(self.tr('Add Layer {}').format(self.name))
+        geometryTypeComboBox = QComboBox()
+        geometryTypeComboBox.addItem(self.tr('Point'), 'POINT')
+        geometryTypeComboBox.addItem(self.tr('Line'), 'LINESTRING')
+        geometryTypeComboBox.addItem(self.tr('Polygon'), 'POLYGON')
+        layout.addRow(self.tr('Geometry Type'), geometryTypeComboBox)
+        zCheckBox = QCheckBox(self.tr('With Z'))
+        mCheckBox = QCheckBox(self.tr('With M'))
+        layout.addRow(zCheckBox)
+        layout.addRow(mCheckBox)
+        crsSelector = QgsProjectionSelectionWidget()
+        crsSelector.setCrs(self.crs())
+        layout.addRow(self.tr('CRS'), crsSelector)
+
+        def selectedGeometryType():
+            geomType = geometryTypeComboBox.currentData()
+            if zCheckBox.isChecked():
+                geomType += 'Z'
+            if mCheckBox.isChecked():
+                geomType += 'M'
+
+            return geomType
+
+        def selectedCrs():
+            return crsSelector.crs()
+
+        addButton = QPushButton(self.tr('Load Layer'))
+        addButton.clicked.connect(lambda: self.addLayer(selectedGeometryType(), selectedCrs()))
+        btns = QDialogButtonBox(QDialogButtonBox.Cancel)
+        btns.addButton(addButton, QDialogButtonBox.ActionRole)
+
+        layout.addRow(btns)
+
+        addButton.clicked.connect(dlg.accept)
+        btns.accepted.connect(dlg.accept)
+        btns.rejected.connect(dlg.reject)
+
+        dlg.exec_()
+
+        settings = QgsSettings()
+        settings.setValue("/DB_Manager/advancedAddDialog/geometry", dlg.saveGeometry())
+
+    def addExtraContextMenuEntries(self, menu):
+        """Called whenever a context menu is shown for this table. Can be used to add additional actions to the menu."""
+
+        if self.geomType == 'GEOMETRY':
+            menu.addAction(QApplication.translate("DBManagerPlugin", "Add Layer (Advanced)…"), self.showAdvancedVectorDialog)
+
+
+class RasterTable(Table):
+
+    def __init__(self, db, schema=None, parent=None):
+        if not hasattr(self, 'type'):  # check if the superclass constructor was called yet!
+            Table.__init__(self, db, schema, parent)
+        self.type = Table.RasterType
+        self.geomColumn = self.geomType = self.pixelSizeX = self.pixelSizeY = self.pixelType = self.isExternal = self.srid = None
+        self.extent = None
+
+    def info(self):
+        from .info_model import RasterTableInfo
+
+        return RasterTableInfo(self)
+
+
+class TableSubItemObject(QObject):
+
+    def __init__(self, table):
+        QObject.__init__(self, table)
+
+    def table(self):
+        return self.parent()
+
+    def database(self):
+        return self.table().database() if self.table() else None
+
+
+class TableField(TableSubItemObject):
+
+    def __init__(self, table):
+        TableSubItemObject.__init__(self, table)
+        self.num = self.name = self.dataType = self.modifier = self.notNull = self.default = self.hasDefault = self.primaryKey = None
+        self.comment = None
+
+    def type2String(self):
+        if self.modifier is None or self.modifier == -1:
+            return "%s" % self.dataType
+        return "%s (%s)" % (self.dataType, self.modifier)
+
+    def default2String(self):
+        if not self.hasDefault:
+            return ''
+        return self.default if self.default is not None else "NULL"
+
+    def definition(self):
+        from .connector import DBConnector
+
+        quoteIdFunc = self.database().connector.quoteId if self.database() else DBConnector.quoteId
+
+        name = quoteIdFunc(self.name)
+        not_null = "NOT NULL" if self.notNull else ""
+
+        txt = "%s %s %s" % (name, self.type2String(), not_null)
+        if self.hasDefault:
+            txt += " DEFAULT %s" % self.default2String()
+        return txt
+
+    def getComment(self):
+        """Returns the comment for a field"""
+        return ''
+
+    def delete(self):
+        return self.table().deleteField(self)
+
+    def rename(self, new_name):
+        return self.update(new_name)
+
+    def update(self, new_name, new_type_str=None, new_not_null=None, new_default_str=None, new_comment=None):
+        self.table().aboutToChange.emit()
+        if self.name == new_name:
+            new_name = None
+        if self.type2String() == new_type_str:
+            new_type_str = None
+        if self.notNull == new_not_null:
+            new_not_null = None
+        if self.default2String() == new_default_str:
+            new_default_str = None
+        if self.comment == new_comment:
+            new_comment = None
+        ret = self.table().database().connector.updateTableColumn((self.table().schemaName(), self.table().name),
+                                                                  self.name, new_name, new_type_str,
+                                                                  new_not_null, new_default_str, new_comment)
+        if ret is not False:
+            self.table().refreshFields()
+        return ret
+
+
+class TableConstraint(TableSubItemObject):
+    """ class that represents a constraint of a table (relation) """
+
+    TypeCheck, TypeForeignKey, TypePrimaryKey, TypeUnique, TypeExclusion, TypeUnknown = list(range(6))
+    types = {"c": TypeCheck, "f": TypeForeignKey, "p": TypePrimaryKey, "u": TypeUnique, "x": TypeExclusion}
+
+    onAction = {"a": "NO ACTION", "r": "RESTRICT", "c": "CASCADE", "n": "SET NULL", "d": "SET DEFAULT"}
+    matchTypes = {"u": "UNSPECIFIED", "f": "FULL", "p": "PARTIAL", "s": "SIMPLE"}
+
+    def __init__(self, table):
+        TableSubItemObject.__init__(self, table)
+        self.name = self.type = self.columns = None
+
+    def type2String(self):
+        if self.type == TableConstraint.TypeCheck:
+            return QApplication.translate("DBManagerPlugin", "Check")
+        if self.type == TableConstraint.TypePrimaryKey:
+            return QApplication.translate("DBManagerPlugin", "Primary key")
+        if self.type == TableConstraint.TypeForeignKey:
+            return QApplication.translate("DBManagerPlugin", "Foreign key")
+        if self.type == TableConstraint.TypeUnique:
+            return QApplication.translate("DBManagerPlugin", "Unique")
+        if self.type == TableConstraint.TypeExclusion:
+            return QApplication.translate("DBManagerPlugin", "Exclusion")
+        return QApplication.translate("DBManagerPlugin", 'Unknown')
+
+    def fields(self):
+        def fieldFromNum(num, fields):
+            """ return field specified by its number or None if doesn't exist """
+            for fld in fields:
+                if fld.num == num:
+                    return fld
+            return None
+
+        fields = self.table().fields()
+        cols = {}
+        for num in self.columns:
+            cols[num] = fieldFromNum(num, fields)
+        return cols
+
+    def delete(self):
+        return self.table().deleteConstraint(self)
+
+
+class TableIndex(TableSubItemObject):
+
+    def __init__(self, table):
+        TableSubItemObject.__init__(self, table)
+        self.name = self.columns = self.isUnique = None
+
+    def fields(self):
+        def fieldFromNum(num, fields):
+            """ return field specified by its number or None if doesn't exist """
+            for fld in fields:
+                if fld.num == num:
+                    return fld
+            return None
+
+        fields = self.table().fields()
+        cols = {}
+        for num in self.columns:
+            cols[num] = fieldFromNum(num, fields)
+        return cols
+
+    def delete(self):
+        return self.table().deleteIndex(self)
+
+
+class TableTrigger(TableSubItemObject):
+    """ class that represents a trigger """
+
+    # Bits within tgtype (pg_trigger.h)
+    TypeRow = (1 << 0)  # row or statement
+    TypeBefore = (1 << 1)  # before or after
+    # events: one or more
+    TypeInsert = (1 << 2)
+    TypeDelete = (1 << 3)
+    TypeUpdate = (1 << 4)
+    TypeTruncate = (1 << 5)
+
+    def __init__(self, table):
+        TableSubItemObject.__init__(self, table)
+        self.name = self.function = None
+
+    def type2String(self):
+        trig_type = ''
+        trig_type += "Before " if self.type & TableTrigger.TypeBefore else "After "
+        if self.type & TableTrigger.TypeInsert:
+            trig_type += "INSERT "
+        if self.type & TableTrigger.TypeUpdate:
+            trig_type += "UPDATE "
+        if self.type & TableTrigger.TypeDelete:
+            trig_type += "DELETE "
+        if self.type & TableTrigger.TypeTruncate:
+            trig_type += "TRUNCATE "
+        trig_type += "\n"
+        trig_type += "for each "
+        trig_type += "row" if self.type & TableTrigger.TypeRow else "statement"
+        return trig_type
+
+
+class TableRule(TableSubItemObject):
+
+    def __init__(self, table):
+        TableSubItemObject.__init__(self, table)
+        self.name = self.definition = None

+ 0 - 0
db_manager/db_plugins/postgis/__init__.py


+ 1262 - 0
db_manager/db_plugins/postgis/connector.py

@@ -0,0 +1,1262 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias <wonder.sk@gmail.com> (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from functools import cmp_to_key
+
+from qgis.PyQt.QtCore import (
+    QRegExp,
+    QFile,
+    QVariant,
+    QDateTime,
+    QTime,
+    QDate,
+    Qt,
+)
+from qgis.core import (
+    Qgis,
+    QgsCoordinateReferenceSystem,
+    QgsVectorLayer,
+    QgsDataSourceUri,
+    QgsProviderRegistry,
+    QgsProviderConnectionException,
+    QgsFeedback,
+)
+
+from ..connector import DBConnector
+from ..plugin import DbError, Table
+
+import os
+import re
+import psycopg2
+import psycopg2.extensions
+
+# use unicode!
+psycopg2.extensions.register_type(psycopg2.extensions.UNICODE)
+psycopg2.extensions.register_type(psycopg2.extensions.UNICODEARRAY)
+
+
+def classFactory():
+    return PostGisDBConnector
+
+
+class CursorAdapter():
+
+    def _debug(self, msg):
+        pass
+        # print("XXX CursorAdapter[" + hex(id(self)) + "]: " + msg)
+
+    def __init__(self, connection, sql=None, feedback=None):
+        self._debug("Created with sql: " + str(sql))
+        self.connection = connection
+        self.sql = sql
+        self.result = None
+        self.cursor = 0
+        self.feedback = feedback
+        self.closed = False
+        if (self.sql is not None):
+            self._execute()
+
+    def _toStrResultSet(self, res):
+        newres = []
+        for rec in res:
+            newrec = []
+            for col in rec:
+                if type(col) == type(QVariant(None)):  # noqa
+                    if (str(col) == 'NULL'):
+                        col = None
+                    else:
+                        col = str(col)  # force to string
+                if isinstance(col, QDateTime) or isinstance(col, QDate) or isinstance(col, QTime):
+                    col = col.toString(Qt.ISODate)
+                newrec.append(col)
+            newres.append(newrec)
+        return newres
+
+    def _execute(self, sql=None):
+        if (sql is None or self.sql == sql) and self.result is not None:
+            return
+        if (sql is not None):
+            self.sql = sql
+        if (self.sql is None):
+            return
+        self._debug("execute called with sql " + self.sql)
+        try:
+            result = self.connection.execSql(self.sql, feedback=self.feedback)
+            self._description = []  # reset description
+            self.result = self._toStrResultSet(result.rows())
+            for c in result.columns():
+                self._description.append([
+                    c,  # name
+                    '',  # type_code
+                    -1,  # display_size
+                    -1,  # internal_size
+                    -1,  # precision
+                    None,  # scale
+                    True  # null_ok
+                ])
+
+        except QgsProviderConnectionException as e:
+            self._description = None
+            raise DbError(e, self.sql)
+        self._debug("execute returned " + str(len(self.result)) + " rows")
+        self.cursor = 0
+
+    @property
+    def description(self):
+        """Returns columns description, it should be already set by _execute"""
+
+        if self._description is None:
+
+            self._description = []
+
+            if re.match('^SHOW', self.sql.strip().upper()):
+                try:
+                    count = len(self.connection.executeSql(self.sql)[0])
+                except QgsProviderConnectionException:
+                    count = 1
+                for i in range(count):
+                    self._description.append([
+                        '',  # name
+                        '',  # type_code
+                        -1,  # display_size
+                        -1,  # internal_size
+                        -1,  # precision
+                        None,  # scale
+                        True  # null_ok
+                    ])
+            else:
+                uri = QgsDataSourceUri(self.connection.uri())
+
+                # TODO: make this part provider-agnostic
+                sql = self.sql if self.sql.upper().find(' LIMIT ') >= 0 else self.sql + ' LIMIT 1 '
+                uri.setTable('(SELECT row_number() OVER () AS __rid__, * FROM (' + sql + ') as foo)')
+                uri.setKeyColumn('__rid__')
+                uri.setParam('checkPrimaryKeyUnicity', '0')
+                # TODO: fetch provider name from connection (QgsAbstractConnectionProvider)
+                # TODO: re-use the VectorLayer for fetching rows in batch mode
+                vl = QgsVectorLayer(uri.uri(False), 'dbmanager_cursor', 'postgres')
+
+                fields = vl.fields()
+
+                for i in range(1, len(fields)):  # skip first field (__rid__)
+                    f = fields[i]
+                    self._description.append([
+                        f.name(),  # name
+                        f.type(),  # type_code
+                        f.length(),  # display_size
+                        f.length(),  # internal_size
+                        f.precision(),  # precision
+                        None,  # scale
+                        True  # null_ok
+                    ])
+
+            self._debug("get_description returned " + str(len(self._description)) + " cols")
+
+        return self._description
+
+    def fetchone(self):
+        self._execute()
+        if len(self.result) - self.cursor:
+            res = self.result[self.cursor]
+            self.cursor += 1
+            return res
+        return None
+
+    def fetchmany(self, size):
+        self._execute()
+        if self.result is None:
+            self._debug("fetchmany: none result after _execute (self.sql is " + str(self.sql) + ", returning []")
+            return []
+        leftover = len(self.result) - self.cursor
+        self._debug("fetchmany: cursor: " + str(self.cursor) + " leftover: " + str(leftover) + " requested: " + str(size))
+        if leftover < 1:
+            return []
+        if size > leftover:
+            size = leftover
+        stop = self.cursor + size
+        res = self.result[self.cursor:stop]
+        self.cursor = stop
+        self._debug("fetchmany: new cursor: " + str(self.cursor) + " reslen: " + str(len(self.result)))
+        return res
+
+    def fetchall(self):
+        self._execute()
+        res = self.result[self.cursor:]
+        self.cursor = len(self.result)
+        return res
+
+    def scroll(self, pos, mode='relative'):
+        self._execute()
+        if pos < 0:
+            self._debug("scroll pos is negative: " + str(pos))
+        if mode == 'relative':
+            self.cursor = self.cursor + pos
+        elif mode == 'absolute':
+            self.cursor = pos
+
+    def close(self):
+        self.result = None
+        self.closed = True
+
+
+class PostGisDBConnector(DBConnector):
+
+    def __init__(self, uri, connection):
+        """Creates a new PostgreSQL connector
+
+        :param uri: data source URI
+        :type uri: QgsDataSourceUri
+        :param connection: the plugin parent instance
+        :type connection: PostGisDBPlugin
+        """
+        DBConnector.__init__(self, uri)
+
+        username = uri.username() or os.environ.get('PGUSER')
+
+        # Do not get db and user names from the env if service is used
+        if not uri.service():
+            if username is None:
+                username = os.environ.get('USER')
+            self.dbname = uri.database() or os.environ.get('PGDATABASE') or username
+            uri.setDatabase(self.dbname)
+
+        # self.connName = connName
+        # self.user = uri.username() or os.environ.get('USER')
+        # self.passwd = uri.password()
+        self.host = uri.host()
+
+        md = QgsProviderRegistry.instance().providerMetadata(connection.providerName())
+        # QgsAbstractDatabaseProviderConnection instance
+        self.core_connection = md.findConnection(connection.connectionName())
+        if self.core_connection is None:
+            self.core_connection = md.createConnection(uri.uri(), {})
+
+        c = self._execute(None, "SELECT current_user,current_database()")
+        self.user, self.dbname = self._fetchone(c)
+        self._close_cursor(c)
+
+        self._checkSpatial()
+        self._checkRaster()
+        self._checkGeometryColumnsTable()
+        self._checkRasterColumnsTable()
+
+        self.feedback = None
+
+    def _connectionInfo(self):
+        return str(self.uri().connectionInfo(True))
+
+    def _clearSslTempCertsIfAny(self, connectionInfo):
+        # remove certs (if any) of the connectionInfo
+        expandedUri = QgsDataSourceUri(connectionInfo)
+
+        def removeCert(certFile):
+            certFile = certFile.replace("'", "")
+            file = QFile(certFile)
+            # set permission to allow removing on Win.
+            # On linux and Mac if file is set with QFile::>ReadUser
+            # does not create problem removing certs
+            if not file.setPermissions(QFile.WriteOwner):
+                raise Exception('Cannot change permissions on {}: error code: {}'.format(file.fileName(), file.error()))
+            if not file.remove():
+                raise Exception('Cannot remove {}: error code: {}'.format(file.fileName(), file.error()))
+
+        sslCertFile = expandedUri.param("sslcert")
+        if sslCertFile:
+            removeCert(sslCertFile)
+
+        sslKeyFile = expandedUri.param("sslkey")
+        if sslKeyFile:
+            removeCert(sslKeyFile)
+
+        sslCAFile = expandedUri.param("sslrootcert")
+        if sslCAFile:
+            removeCert(sslCAFile)
+
+    def _checkSpatial(self):
+        """ check whether postgis_version is present in catalog """
+        c = self._execute(None, "SELECT COUNT(*) FROM pg_proc WHERE proname = 'postgis_version'")
+        self.has_spatial = self._fetchone(c)[0] > 0
+        self._close_cursor(c)
+        return self.has_spatial
+
+    def _checkRaster(self):
+        """ check whether postgis_version is present in catalog """
+        c = self._execute(None, "SELECT COUNT(*) FROM pg_proc WHERE proname = 'postgis_raster_lib_version'")
+        self.has_raster = self._fetchone(c)[0] > 0
+        self._close_cursor(c)
+        return self.has_raster
+
+    def _checkGeometryColumnsTable(self):
+        c = self._execute(None,
+                          "SELECT relkind = 'v' OR relkind = 'm' FROM pg_class WHERE relname = 'geometry_columns' AND relkind IN ('v', 'r', 'm', 'p')")
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        self.has_geometry_columns = (res is not None and len(res) != 0)
+
+        if not self.has_geometry_columns:
+            self.has_geometry_columns_access = self.is_geometry_columns_view = False
+        else:
+            self.is_geometry_columns_view = res[0]
+            # find out whether has privileges to access geometry_columns table
+            priv = self.getTablePrivileges('geometry_columns')
+            self.has_geometry_columns_access = priv[0]
+        return self.has_geometry_columns
+
+    def _checkRasterColumnsTable(self):
+        c = self._execute(None,
+                          "SELECT relkind = 'v' OR relkind = 'm' FROM pg_class WHERE relname = 'raster_columns' AND relkind IN ('v', 'r', 'm', 'p')")
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        self.has_raster_columns = (res is not None and len(res) != 0)
+
+        if not self.has_raster_columns:
+            self.has_raster_columns_access = self.is_raster_columns_view = False
+        else:
+            self.is_raster_columns_view = res[0]
+            # find out whether has privileges to access geometry_columns table
+            self.has_raster_columns_access = self.getTablePrivileges('raster_columns')[0]
+        return self.has_raster_columns
+
+    def cancel(self):
+        if self.connection:
+            self.connection.cancel()
+        if self.core_connection:
+            self.feedback.cancel()
+
+    def getInfo(self):
+        c = self._execute(None, "SELECT version()")
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        return res
+
+    def getPsqlVersion(self):
+        regex = r"^PostgreSQL\s([0-9]{1,2})"
+        match = re.match(regex, self.getInfo()[0])
+        if match:
+            return int(match.group(1))
+        raise DbError(f"Unknown PostgreSQL version: {self.getInfo()[0]}")
+
+    def getSpatialInfo(self):
+        """ returns tuple about PostGIS support:
+                - lib version
+                - geos version
+                - proj version
+                - installed scripts version
+                - released scripts version
+        """
+        if not self.has_spatial:
+            return
+
+        try:
+            c = self._execute(None,
+                              "SELECT postgis_lib_version(), postgis_geos_version(), postgis_proj_version(), postgis_scripts_installed(), postgis_scripts_released()")
+        except DbError:
+            return
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        return res
+
+    def hasSpatialSupport(self):
+        return self.has_spatial
+
+    def hasRasterSupport(self):
+        return self.has_raster
+
+    def hasCustomQuerySupport(self):
+        return Qgis.QGIS_VERSION[0:3] >= "1.5"
+
+    def hasTableColumnEditingSupport(self):
+        return True
+
+    def hasCreateSpatialViewSupport(self):
+        return True
+
+    def fieldTypes(self):
+        return [
+            "integer", "bigint", "smallint",  # integers
+            "serial", "bigserial",  # auto-incrementing ints
+            "real", "double precision", "numeric",  # floats
+            "varchar", "varchar(255)", "char(20)", "text",  # strings
+            "date", "time", "timestamp",  # date/time
+            "boolean"  # bool
+        ]
+
+    def getDatabasePrivileges(self):
+        """ db privileges: (can create schemas, can create temp. tables) """
+        sql = "SELECT has_database_privilege(current_database(), 'CREATE'), has_database_privilege(current_database(), 'TEMP')"
+        c = self._execute(None, sql)
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        return res
+
+    def getSchemaPrivileges(self, schema):
+        """ schema privileges: (can create new objects, can access objects in schema) """
+        schema = 'current_schema()' if schema is None else self.quoteString(schema)
+        sql = "SELECT has_schema_privilege(%(s)s, 'CREATE'), has_schema_privilege(%(s)s, 'USAGE')" % {'s': schema}
+        c = self._execute(None, sql)
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        return res
+
+    def getTablePrivileges(self, table):
+        """ table privileges: (select, insert, update, delete) """
+
+        schema, tablename = self.getSchemaTableName(table)
+        schema_priv = self.getSchemaPrivileges(schema)
+        if not schema_priv[1]:
+            return
+
+        t = self.quoteId(table)
+        sql = """SELECT has_table_privilege(%(t)s, 'SELECT'), has_table_privilege(%(t)s, 'INSERT'),
+                                has_table_privilege(%(t)s, 'UPDATE'), has_table_privilege(%(t)s, 'DELETE')""" % {
+            't': self.quoteString(t)}
+        c = self._execute(None, sql)
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        return res
+
+    def getSchemas(self):
+        """ get list of schemas in tuples: (oid, name, owner, perms) """
+        sql = "SELECT oid, nspname, pg_get_userbyid(nspowner), nspacl, pg_catalog.obj_description(oid) FROM pg_namespace WHERE nspname !~ '^pg_' AND nspname != 'information_schema' ORDER BY nspname"
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        self._close_cursor(c)
+        return res
+
+    def getTables(self, schema=None, add_sys_tables=False):
+        """ get list of tables """
+        tablenames = []
+        items = []
+
+        sys_tables = ["spatial_ref_sys", "geography_columns", "geometry_columns",
+                      "raster_columns", "raster_overviews"]
+
+        try:
+            vectors = self.getVectorTables(schema)
+            for tbl in vectors:
+                if not add_sys_tables and tbl[1] in sys_tables and tbl[2] in ['', 'public']:
+                    continue
+                tablenames.append((tbl[2], tbl[1]))
+                items.append(tbl)
+        except DbError:
+            pass
+
+        try:
+            rasters = self.getRasterTables(schema)
+            for tbl in rasters:
+                if not add_sys_tables and tbl[1] in sys_tables and tbl[2] in ['', 'public']:
+                    continue
+                tablenames.append((tbl[2], tbl[1]))
+                items.append(tbl)
+        except DbError:
+            pass
+
+        sys_tables = ["spatial_ref_sys", "geography_columns", "geometry_columns",
+                      "raster_columns", "raster_overviews"]
+
+        if schema:
+            schema_where = " AND nspname = %s " % self.quoteString(schema)
+        else:
+            schema_where = " AND (nspname != 'information_schema' AND nspname !~ 'pg_') "
+
+        # get all tables and views
+        sql = """SELECT
+                                                cla.relname, nsp.nspname, cla.relkind,
+                                                pg_get_userbyid(relowner), reltuples, relpages,
+                                                pg_catalog.obj_description(cla.oid)
+                                        FROM pg_class AS cla
+                                        JOIN pg_namespace AS nsp ON nsp.oid = cla.relnamespace
+                                        WHERE cla.relkind IN ('v', 'r', 'm', 'p') """ + schema_where + """
+                                        ORDER BY nsp.nspname, cla.relname"""
+
+        c = self._execute(None, sql)
+        for tbl in self._fetchall(c):
+            if tablenames.count((tbl[1], tbl[0])) <= 0:
+                item = list(tbl)
+                item.insert(0, Table.TableType)
+                items.append(item)
+        self._close_cursor(c)
+
+        return sorted(items, key=cmp_to_key(lambda x, y: (x[1] > y[1]) - (x[1] < y[1])))
+
+    def getVectorTables(self, schema=None):
+        """ get list of table with a geometry column
+                it returns:
+                        name (table name)
+                        namespace (schema)
+                        type = 'view' (is a view?)
+                        owner
+                        tuples
+                        pages
+                        geometry_column:
+                                f_geometry_column (or pg_attribute.attname, the geometry column name)
+                                type (or pg_attribute.atttypid::regtype, the geometry column type name)
+                                coord_dimension
+                                srid
+        """
+
+        if not self.has_spatial:
+            return []
+
+        if schema:
+            schema_where = " AND nspname = %s " % self.quoteString(schema)
+        else:
+            schema_where = " AND (nspname != 'information_schema' AND nspname !~ 'pg_') "
+
+        geometry_column_from = ""
+        geometry_fields_select = """att.attname,
+                                                textin(regtypeout(att.atttypid::regtype)),
+                                                NULL, NULL"""
+        if self.has_geometry_columns and self.has_geometry_columns_access:
+            geometry_column_from = """LEFT OUTER JOIN geometry_columns AS geo ON
+                                                cla.relname = geo.f_table_name AND nsp.nspname = f_table_schema AND
+                                                lower(att.attname) = lower(f_geometry_column)"""
+            geometry_fields_select = """CASE WHEN geo.f_geometry_column IS NOT NULL THEN geo.f_geometry_column ELSE att.attname END,
+                                                CASE WHEN geo.type IS NOT NULL THEN geo.type ELSE textin(regtypeout(att.atttypid::regtype)) END,
+                                                geo.coord_dimension, geo.srid"""
+
+        # discovery of all tables and whether they contain a geometry column
+        sql = """SELECT
+                                                cla.relname, nsp.nspname, cla.relkind,
+                                                pg_get_userbyid(relowner), cla.reltuples, cla.relpages,
+                                                pg_catalog.obj_description(cla.oid),
+                                                """ + geometry_fields_select + """
+
+                                        FROM pg_class AS cla
+                                        JOIN pg_namespace AS nsp ON
+                                                nsp.oid = cla.relnamespace
+
+                                        JOIN pg_attribute AS att ON
+                                                att.attrelid = cla.oid AND
+                                                att.atttypid = 'geometry'::regtype OR
+                                                att.atttypid IN (SELECT oid FROM pg_type WHERE typbasetype='geometry'::regtype )
+
+                                        """ + geometry_column_from + """
+
+                                        WHERE cla.relkind IN ('v', 'r', 'm', 'p') """ + schema_where + """
+                                        ORDER BY nsp.nspname, cla.relname, att.attname"""
+
+        items = []
+
+        c = self._execute(None, sql)
+        for i, tbl in enumerate(self._fetchall(c)):
+            item = list(tbl)
+            item.insert(0, Table.VectorType)
+            items.append(item)
+        self._close_cursor(c)
+
+        return items
+
+    def getRasterTables(self, schema=None):
+        """ get list of table with a raster column
+                it returns:
+                        name (table name)
+                        namespace (schema)
+                        type = 'view' (is a view?)
+                        owner
+                        tuples
+                        pages
+                        raster_column:
+                                r_raster_column (or pg_attribute.attname, the raster column name)
+                                pixel type
+                                block size
+                                internal or external
+                                srid
+        """
+
+        if not self.has_spatial:
+            return []
+        if not self.has_raster:
+            return []
+
+        if schema:
+            schema_where = " AND nspname = %s " % self.quoteString(schema)
+        else:
+            schema_where = " AND (nspname != 'information_schema' AND nspname !~ 'pg_') "
+
+        raster_column_from = ""
+        raster_fields_select = """att.attname, NULL, NULL, NULL, NULL, NULL"""
+        if self.has_raster_columns and self.has_raster_columns_access:
+            raster_column_from = """LEFT OUTER JOIN raster_columns AS rast ON
+                                                cla.relname = rast.r_table_name AND nsp.nspname = r_table_schema AND
+                                                lower(att.attname) = lower(r_raster_column)"""
+            raster_fields_select = """CASE WHEN rast.r_raster_column IS NOT NULL THEN rast.r_raster_column ELSE att.attname END,
+                                                rast.pixel_types,
+                                                rast.scale_x,
+                                                rast.scale_y,
+                                                rast.out_db,
+                                                rast.srid"""
+
+        # discovery of all tables and whether they contain a raster column
+        sql = """SELECT
+                                                cla.relname, nsp.nspname, cla.relkind,
+                                                pg_get_userbyid(relowner), cla.reltuples, cla.relpages,
+                                                pg_catalog.obj_description(cla.oid),
+                                                """ + raster_fields_select + """
+
+                                        FROM pg_class AS cla
+                                        JOIN pg_namespace AS nsp ON
+                                                nsp.oid = cla.relnamespace
+
+                                        JOIN pg_attribute AS att ON
+                                                att.attrelid = cla.oid AND
+                                                att.atttypid = 'raster'::regtype OR
+                                                att.atttypid IN (SELECT oid FROM pg_type WHERE typbasetype='raster'::regtype )
+
+                                        """ + raster_column_from + """
+
+                                        WHERE cla.relkind IN ('v', 'r', 'm', 'p') """ + schema_where + """
+                                        ORDER BY nsp.nspname, cla.relname, att.attname"""
+
+        items = []
+
+        c = self._execute(None, sql)
+        for i, tbl in enumerate(self._fetchall(c)):
+            item = list(tbl)
+            item.insert(0, Table.RasterType)
+            items.append(item)
+        self._close_cursor(c)
+
+        return items
+
+    def getTableRowCount(self, table):
+        c = self._execute(None, "SELECT COUNT(*) FROM %s" % self.quoteId(table))
+        res = self._fetchone(c)[0]
+        self._close_cursor(c)
+        return res
+
+    def getTableFields(self, table):
+        """ return list of columns in table """
+
+        schema, tablename = self.getSchemaTableName(table)
+        schema_where = " AND nspname=%s " % self.quoteString(schema) if schema is not None else ""
+
+        version_number = self.getPsqlVersion()
+        ad_col_name = 'adsrc' if version_number < 12 else 'adbin'
+
+        sql = """SELECT a.attnum AS ordinal_position,
+                                a.attname AS column_name,
+                                t.typname AS data_type,
+                                a.attlen AS char_max_len,
+                                a.atttypmod AS modifier,
+                                a.attnotnull AS notnull,
+                                a.atthasdef AS hasdefault,
+                                adef.%s AS default_value,
+                                pg_catalog.format_type(a.atttypid,a.atttypmod) AS formatted_type
+                        FROM pg_class c
+                        JOIN pg_attribute a ON a.attrelid = c.oid
+                        JOIN pg_type t ON a.atttypid = t.oid
+                        JOIN pg_namespace nsp ON c.relnamespace = nsp.oid
+                        LEFT JOIN pg_attrdef adef ON adef.adrelid = a.attrelid AND adef.adnum = a.attnum
+                        WHERE
+                          a.attnum > 0 AND c.relname=%s %s
+                        ORDER BY a.attnum""" % (ad_col_name, self.quoteString(tablename), schema_where)
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        self._close_cursor(c)
+        return res
+
+    def getTableIndexes(self, table):
+        """ get info about table's indexes. ignore primary key constraint index, they get listed in constraints """
+        schema, tablename = self.getSchemaTableName(table)
+        schema_where = " AND nspname=%s " % self.quoteString(schema) if schema is not None else ""
+
+        sql = """SELECT idxcls.relname, indkey, indisunique = 't'
+                                                FROM pg_index JOIN pg_class ON pg_index.indrelid=pg_class.oid
+                                                JOIN pg_class AS idxcls ON pg_index.indexrelid=idxcls.oid
+                                                JOIN pg_namespace nsp ON pg_class.relnamespace = nsp.oid
+                                                        WHERE pg_class.relname=%s %s
+                                                        AND indisprimary != 't' """ % (
+            self.quoteString(tablename), schema_where)
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        self._close_cursor(c)
+        return res
+
+    def getTableConstraints(self, table):
+
+        schema, tablename = self.getSchemaTableName(table)
+        schema_where = " AND nspname=%s " % self.quoteString(schema) if schema is not None else ""
+
+        version_number = self.getPsqlVersion()
+        con_col_name = 'consrc' if version_number < 12 else 'conbin'
+
+        # In the query below, we exclude rows where pg_constraint.contype whose values are equal to 't'
+        # because 't' describes a CONSTRAINT TRIGGER, which is not really a constraint in the traditional
+        # sense, but a special type of trigger, and an extension to the SQL standard.
+        sql = """SELECT c.conname, c.contype, c.condeferrable, c.condeferred, array_to_string(c.conkey, ' '), c.%s,
+                         t2.relname, c.confupdtype, c.confdeltype, c.confmatchtype, array_to_string(c.confkey, ' ') FROM pg_constraint c
+                  LEFT JOIN pg_class t ON c.conrelid = t.oid
+                        LEFT JOIN pg_class t2 ON c.confrelid = t2.oid
+                        JOIN pg_namespace nsp ON t.relnamespace = nsp.oid
+                        WHERE c.contype <> 't' AND t.relname = %s %s """ % (con_col_name, self.quoteString(tablename), schema_where)
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        self._close_cursor(c)
+        return res
+
+    def getTableTriggers(self, table):
+
+        schema, tablename = self.getSchemaTableName(table)
+        schema_where = " AND nspname=%s " % self.quoteString(schema) if schema is not None else ""
+
+        sql = """SELECT tgname, proname, tgtype, tgenabled NOT IN ('f', 'D')  FROM pg_trigger trig
+                          LEFT JOIN pg_class t ON trig.tgrelid = t.oid
+                                                        LEFT JOIN pg_proc p ON trig.tgfoid = p.oid
+                                                        JOIN pg_namespace nsp ON t.relnamespace = nsp.oid
+                                                        WHERE t.relname = %s %s """ % (
+            self.quoteString(tablename), schema_where)
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        self._close_cursor(c)
+        return res
+
+    def enableAllTableTriggers(self, enable, table):
+        """ enable or disable all triggers on table """
+        self.enableTableTrigger(None, enable, table)
+
+    def enableTableTrigger(self, trigger, enable, table):
+        """ enable or disable one trigger on table """
+        trigger = self.quoteId(trigger) if trigger is not None else "ALL"
+        sql = "ALTER TABLE %s %s TRIGGER %s" % (self.quoteId(table), "ENABLE" if enable else "DISABLE", trigger)
+        self._execute_and_commit(sql)
+
+    def deleteTableTrigger(self, trigger, table):
+        """Deletes trigger on table """
+        sql = "DROP TRIGGER %s ON %s" % (self.quoteId(trigger), self.quoteId(table))
+        self._execute_and_commit(sql)
+
+    def getTableRules(self, table):
+
+        schema, tablename = self.getSchemaTableName(table)
+        schema_where = " AND schemaname=%s " % self.quoteString(schema) if schema is not None else ""
+
+        sql = """SELECT rulename, definition FROM pg_rules
+                                        WHERE tablename=%s %s """ % (self.quoteString(tablename), schema_where)
+
+        c = self._execute(None, sql)
+        res = self._fetchall(c)
+        self._close_cursor(c)
+        return res
+
+    def deleteTableRule(self, rule, table):
+        """Deletes rule on table """
+        sql = "DROP RULE %s ON %s" % (self.quoteId(rule), self.quoteId(table))
+        self._execute_and_commit(sql)
+
+    def getTableExtent(self, table, geom):
+        """ find out table extent """
+        subquery = "SELECT st_extent(%s) AS extent FROM %s" % (self.quoteId(geom), self.quoteId(table))
+        sql = "SELECT st_xmin(extent), st_ymin(extent), st_xmax(extent), st_ymax(extent) FROM (%s) AS subquery" % subquery
+
+        c = self._execute(None, sql)
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        return res
+
+    def getTableEstimatedExtent(self, table, geom):
+        """ find out estimated extent (from the statistics) """
+        if self.isRasterTable(table):
+            return
+
+        schema, tablename = self.getSchemaTableName(table)
+        schema_part = "%s," % self.quoteString(schema) if schema is not None else ""
+
+        pgis_versions = self.getSpatialInfo()[0].split('.')
+        pgis_major_version = int(pgis_versions[0])
+        pgis_minor_version = int(pgis_versions[1])
+        pgis_old = False
+        if pgis_major_version < 2:
+            pgis_old = True
+        elif pgis_major_version == 2 and pgis_minor_version < 1:
+            pgis_old = True
+        subquery = "SELECT %s(%s%s,%s) AS extent" % (
+            'st_estimated_extent' if pgis_old else 'st_estimatedextent',
+            schema_part, self.quoteString(tablename), self.quoteString(geom))
+        sql = """SELECT st_xmin(extent), st_ymin(extent), st_xmax(extent), st_ymax(extent) FROM (%s) AS subquery """ % subquery
+
+        try:
+            c = self._execute(None, sql)
+        except DbError:  # No statistics for the current table
+            return
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        return res
+
+    def getViewDefinition(self, view):
+        """ returns definition of the view """
+
+        schema, tablename = self.getSchemaTableName(view)
+        schema_where = " AND nspname=%s " % self.quoteString(schema) if schema is not None else ""
+
+        sql = """SELECT pg_get_viewdef(c.oid) FROM pg_class c
+                                                JOIN pg_namespace nsp ON c.relnamespace = nsp.oid
+                        WHERE relname=%s %s AND (relkind='v' OR relkind='m') """ % (
+            self.quoteString(tablename), schema_where)
+
+        c = self._execute(None, sql)
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        return res[0] if res is not None else None
+
+    def getCrs(self, srid):
+        if not self.has_spatial:
+            return QgsCoordinateReferenceSystem()
+
+        try:
+            c = self._execute(None, "SELECT proj4text FROM spatial_ref_sys WHERE srid = '%d'" % srid)
+        except DbError:
+            return QgsCoordinateReferenceSystem()
+        res = self._fetchone(c)
+        self._close_cursor(c)
+        if res is None:
+            return QgsCoordinateReferenceSystem()
+
+        proj4text = res[0]
+        crs = QgsCoordinateReferenceSystem.fromProj(proj4text)
+        return crs
+
+    def getSpatialRefInfo(self, srid):
+        if not self.has_spatial:
+            return
+
+        try:
+            c = self._execute(None, "SELECT srtext FROM spatial_ref_sys WHERE srid = '%d'" % srid)
+        except DbError:
+            return
+        sr = self._fetchone(c)
+        self._close_cursor(c)
+        if sr is None:
+            return
+
+        srtext = sr[0]
+        # try to extract just SR name (should be quoted in double quotes)
+        regex = QRegExp('"([^"]+)"')
+        if regex.indexIn(srtext) > -1:
+            srtext = regex.cap(1)
+        return srtext
+
+    def isVectorTable(self, table):
+        if self.has_geometry_columns and self.has_geometry_columns_access:
+            schema, tablename = self.getSchemaTableName(table)
+            sql = "SELECT count(*) FROM geometry_columns WHERE f_table_schema = %s AND f_table_name = %s" % (
+                self.quoteString(schema), self.quoteString(tablename))
+
+            c = self._execute(None, sql)
+            res = self._fetchone(c)
+            self._close_cursor(c)
+            return res is not None and res[0] > 0
+
+        return False
+
+    def isRasterTable(self, table):
+        if self.has_raster_columns and self.has_raster_columns_access:
+            schema, tablename = self.getSchemaTableName(table)
+            sql = "SELECT count(*) FROM raster_columns WHERE r_table_schema = %s AND r_table_name = %s" % (
+                self.quoteString(schema), self.quoteString(tablename))
+
+            c = self._execute(None, sql)
+            res = self._fetchone(c)
+            self._close_cursor(c)
+            return res is not None and res[0] > 0
+
+        return False
+
+    def createTable(self, table, field_defs, pkey):
+        """Creates ordinary table
+                        'fields' is array containing field definitions
+                        'pkey' is the primary key name
+        """
+        if len(field_defs) == 0:
+            return False
+
+        sql = "CREATE TABLE %s (" % self.quoteId(table)
+        sql += ", ".join(field_defs)
+        if pkey is not None and pkey != "":
+            sql += ", PRIMARY KEY (%s)" % self.quoteId(pkey)
+        sql += ")"
+
+        self._execute_and_commit(sql)
+        return True
+
+    def deleteTable(self, table):
+        """Deletes table and its reference in either geometry_columns or raster_columns """
+        schema, tablename = self.getSchemaTableName(table)
+        schema_part = "%s, " % self.quoteString(schema) if schema is not None else ""
+        if self.isVectorTable(table):
+            sql = "SELECT DropGeometryTable(%s%s)" % (schema_part, self.quoteString(tablename))
+        elif self.isRasterTable(table):
+            # Fix #8521: delete raster table and references from raster_columns table
+            sql = "DROP TABLE %s" % self.quoteId(table)
+        else:
+            sql = "DROP TABLE %s" % self.quoteId(table)
+        self._execute_and_commit(sql)
+
+    def emptyTable(self, table):
+        """Deletes all rows from table """
+        sql = "TRUNCATE %s" % self.quoteId(table)
+        self._execute_and_commit(sql)
+
+    def renameTable(self, table, new_table):
+        """Renames a table in database """
+        schema, tablename = self.getSchemaTableName(table)
+        if new_table == tablename:
+            return
+
+        sql = "ALTER TABLE %s RENAME  TO %s" % (self.quoteId(table), self.quoteId(new_table))
+        self._executeSql(sql)
+
+        # update geometry_columns if PostGIS is enabled
+        if self.has_geometry_columns and not self.is_geometry_columns_view:
+            schema_where = " AND f_table_schema=%s " % self.quoteString(schema) if schema is not None else ""
+            sql = "UPDATE geometry_columns SET f_table_name=%s WHERE f_table_name=%s %s" % (
+                self.quoteString(new_table), self.quoteString(tablename), schema_where)
+            self._executeSql(sql)
+
+    def renameSchema(self, schema, new_schema):
+        try:
+            self.core_connection.renameSchema(schema, new_schema)
+            return True
+        except QgsProviderConnectionException:
+            return False
+
+    def commentTable(self, schema, tablename, comment=None):
+        if comment is None:
+            self._execute(None, 'COMMENT ON TABLE "{}"."{}" IS NULL;'.format(schema, tablename))
+        else:
+            self._execute(None, 'COMMENT ON TABLE "{}"."{}" IS $escape${}$escape$;'.format(schema, tablename, comment))
+
+    def getComment(self, tablename, field):
+        """Returns the comment for a field"""
+        # SQL Query checking if a comment exists for the field
+        sql_cpt = "Select count(*) from pg_description pd, pg_class pc, pg_attribute pa where relname = '%s' and attname = '%s' and pa.attrelid = pc.oid and pd.objoid = pc.oid and pd.objsubid = pa.attnum" % (tablename, field)
+        # SQL Query that return the comment of the field
+        sql = "Select pd.description from pg_description pd, pg_class pc, pg_attribute pa where relname = '%s' and attname = '%s' and pa.attrelid = pc.oid and pd.objoid = pc.oid and pd.objsubid = pa.attnum" % (tablename, field)
+        c = self._execute(None, sql_cpt)  # Execute Check query
+        res = self._fetchone(c)[0]  # Store result
+        if res == 1:
+            # When a comment exists
+            c = self._execute(None, sql)  # Execute query
+            res = self._fetchone(c)[0]  # Store result
+            self._close_cursor(c)  # Close cursor
+            return res  # Return comment
+        else:
+            return ''
+
+    def moveTableToSchema(self, table, new_schema):
+        schema, tablename = self.getSchemaTableName(table)
+        if new_schema == schema:
+            return
+
+        c = self._get_cursor()
+
+        sql = "ALTER TABLE %s SET SCHEMA %s" % (self.quoteId(table), self.quoteId(new_schema))
+        self._execute(c, sql)
+
+        # update geometry_columns if PostGIS is enabled
+        if self.has_geometry_columns and not self.is_geometry_columns_view:
+            schema, tablename = self.getSchemaTableName(table)
+            schema_where = " AND f_table_schema=%s " % self.quoteString(schema) if schema is not None else ""
+            sql = "UPDATE geometry_columns SET f_table_schema=%s WHERE f_table_name=%s %s" % (
+                self.quoteString(new_schema), self.quoteString(tablename), schema_where)
+            self._execute(c, sql)
+
+        self._commit()
+
+    def moveTable(self, table, new_table, new_schema=None):
+        schema, tablename = self.getSchemaTableName(table)
+        if new_schema == schema and new_table == tablename:
+            return
+        if new_schema == schema:
+            return self.renameTable(table, new_table)
+        if new_table == table:
+            return self.moveTableToSchema(table, new_schema)
+
+        c = self._get_cursor()
+        t = "__new_table__"
+
+        sql = "ALTER TABLE %s RENAME  TO %s" % (self.quoteId(table), self.quoteId(t))
+        self._execute(c, sql)
+
+        sql = "ALTER TABLE %s SET SCHEMA %s" % (self.quoteId((schema, t)), self.quoteId(new_schema))
+        self._execute(c, sql)
+
+        sql = "ALTER TABLE %s RENAME  TO %s" % (self.quoteId((new_schema, t)), self.quoteId(table))
+        self._execute(c, sql)
+
+        # update geometry_columns if PostGIS is enabled
+        if self.has_geometry_columns and not self.is_geometry_columns_view:
+            schema, tablename = self.getSchemaTableName(table)
+            schema_where = " f_table_schema=%s AND " % self.quoteString(schema) if schema is not None else ""
+            schema_part = " f_table_schema=%s, " % self.quoteString(new_schema) if schema is not None else ""
+            sql = "UPDATE geometry_columns SET %s f_table_name=%s WHERE %s f_table_name=%s" % (
+                schema_part, self.quoteString(new_table), schema_where, self.quoteString(tablename))
+            self._execute(c, sql)
+
+        self._commit()
+
+    def createView(self, view, query):
+        sql = "CREATE VIEW %s AS %s" % (self.quoteId(view), query)
+        self._execute_and_commit(sql)
+
+    def createSpatialView(self, view, query):
+        self.createView(view, query)
+
+    def deleteView(self, view, isMaterialized=False):
+        sql = "DROP %s VIEW %s" % ('MATERIALIZED' if isMaterialized else '', self.quoteId(view))
+        self._execute_and_commit(sql)
+
+    def renameView(self, view, new_name):
+        """Renames view in database """
+        self.renameTable(view, new_name)
+
+    def createSchema(self, schema):
+        """Creates a new empty schema in database """
+        sql = "CREATE SCHEMA %s" % self.quoteId(schema)
+        self._execute_and_commit(sql)
+
+    def deleteSchema(self, schema):
+        """Drops (empty) schema from database """
+        sql = "DROP SCHEMA %s" % self.quoteId(schema)
+        self._execute_and_commit(sql)
+
+    def renamesSchema(self, schema, new_schema):
+        """Renames a schema in database """
+        sql = "ALTER SCHEMA %s RENAME  TO %s" % (self.quoteId(schema), self.quoteId(new_schema))
+        self._execute_and_commit(sql)
+
+    def runVacuum(self):
+        """Runs vacuum on the db """
+        self._execute_and_commit("VACUUM")
+
+    def runVacuumAnalyze(self, table):
+        """Runs vacuum analyze on a table """
+        sql = "VACUUM ANALYZE %s" % self.quoteId(table)
+        self._execute(None, sql)
+        self._commit()
+
+    def runRefreshMaterializedView(self, table):
+        """Runs refresh materialized view on a table """
+        sql = "REFRESH MATERIALIZED VIEW %s" % self.quoteId(table)
+        self._execute(None, sql)
+        self._commit()
+
+    def addTableColumn(self, table, field_def):
+        """Adds a column to table """
+        sql = "ALTER TABLE %s ADD %s" % (self.quoteId(table), field_def)
+        self._execute_and_commit(sql)
+
+    def deleteTableColumn(self, table, column):
+        """Deletes column from a table """
+        if self.isGeometryColumn(table, column):
+            # use PostGIS function to delete geometry column correctly
+            schema, tablename = self.getSchemaTableName(table)
+            schema_part = "%s, " % self.quoteString(schema) if schema else ""
+            sql = "SELECT DropGeometryColumn(%s%s, %s)" % (
+                schema_part, self.quoteString(tablename), self.quoteString(column))
+        else:
+            sql = "ALTER TABLE %s DROP %s" % (self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def updateTableColumn(self, table, column, new_name=None, data_type=None, not_null=None, default=None, comment=None, test=None):
+        if new_name is None and data_type is None and not_null is None and default is None and comment is None:
+            return
+
+        c = self._get_cursor()
+
+        # update column definition
+        col_actions = []
+        if data_type is not None:
+            col_actions.append("TYPE %s" % data_type)
+        if not_null is not None:
+            col_actions.append("SET NOT NULL" if not_null else "DROP NOT NULL")
+        if default is not None:
+            if default and default != '':
+                col_actions.append("SET DEFAULT %s" % default)
+            else:
+                col_actions.append("DROP DEFAULT")
+        if len(col_actions) > 0:
+            sql = "ALTER TABLE %s" % self.quoteId(table)
+            alter_col_str = "ALTER %s" % self.quoteId(column)
+            for a in col_actions:
+                sql += " %s %s," % (alter_col_str, a)
+            self._execute(c, sql[:-1])
+
+        # Renames the column
+        if new_name is not None and new_name != column:
+            sql = "ALTER TABLE %s RENAME  %s TO %s" % (
+                self.quoteId(table), self.quoteId(column), self.quoteId(new_name))
+            self._execute(c, sql)
+
+            # update geometry_columns if PostGIS is enabled
+            if self.has_geometry_columns and not self.is_geometry_columns_view:
+                schema, tablename = self.getSchemaTableName(table)
+                schema_where = " f_table_schema=%s AND " % self.quoteString(schema) if schema is not None else ""
+                sql = "UPDATE geometry_columns SET f_geometry_column=%s WHERE %s f_table_name=%s AND f_geometry_column=%s" % (
+                    self.quoteString(new_name), schema_where, self.quoteString(tablename), self.quoteString(column))
+                self._execute(c, sql)
+
+        # comment the column
+        if comment is not None:
+            schema, tablename = self.getSchemaTableName(table)
+            column_name = new_name if new_name is not None and new_name != column else column
+            sql = "COMMENT ON COLUMN %s.%s.%s IS '%s'" % (schema, tablename, column_name, comment)
+            self._execute(c, sql)
+
+        self._commit()
+
+    def renamesTableColumn(self, table, column, new_name):
+        """Renames column in a table """
+        return self.updateTableColumn(table, column, new_name)
+
+    def setTableColumnType(self, table, column, data_type):
+        """Changes column type """
+        return self.updateTableColumn(table, column, None, data_type)
+
+    def setTableColumnNull(self, table, column, is_null):
+        """Changes whether column can contain null values """
+        return self.updateTableColumn(table, column, None, None, not is_null)
+
+    def setTableColumnDefault(self, table, column, default):
+        """Changes column's default value.
+                If default=None or an empty string drop default value """
+        return self.updateTableColumn(table, column, None, None, None, default)
+
+    def isGeometryColumn(self, table, column):
+
+        schema, tablename = self.getSchemaTableName(table)
+        schema_where = " f_table_schema=%s AND " % self.quoteString(schema) if schema is not None else ""
+
+        sql = "SELECT count(*) > 0 FROM geometry_columns WHERE %s f_table_name=%s AND f_geometry_column=%s" % (
+            schema_where, self.quoteString(tablename), self.quoteString(column))
+
+        c = self._execute(None, sql)
+        res = self._fetchone(c)[0] == 't'
+        self._close_cursor(c)
+        return res
+
+    def addGeometryColumn(self, table, geom_column='geom', geom_type='POINT', srid=-1, dim=2):
+        schema, tablename = self.getSchemaTableName(table)
+        schema_part = "%s, " % self.quoteString(schema) if schema else ""
+
+        sql = "SELECT AddGeometryColumn(%s%s, %s, %d, %s, %d)" % (
+            schema_part, self.quoteString(tablename), self.quoteString(geom_column), srid, self.quoteString(geom_type), dim)
+        self._execute_and_commit(sql)
+
+    def deleteGeometryColumn(self, table, geom_column):
+        return self.deleteTableColumn(table, geom_column)
+
+    def addTableUniqueConstraint(self, table, column):
+        """Adds a unique constraint to a table """
+        sql = "ALTER TABLE %s ADD UNIQUE (%s)" % (self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def deleteTableConstraint(self, table, constraint):
+        """Deletes constraint in a table """
+        sql = "ALTER TABLE %s DROP CONSTRAINT %s" % (self.quoteId(table), self.quoteId(constraint))
+        self._execute_and_commit(sql)
+
+    def addTablePrimaryKey(self, table, column):
+        """Adds a primery key (with one column) to a table """
+        sql = "ALTER TABLE %s ADD PRIMARY KEY (%s)" % (self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def createTableIndex(self, table, name, column):
+        """Creates index on one column using default options """
+        sql = "CREATE INDEX %s ON %s (%s)" % (self.quoteId(name), self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def deleteTableIndex(self, table, name):
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "DROP INDEX %s" % self.quoteId((schema, name))
+        self._execute_and_commit(sql)
+
+    def createSpatialIndex(self, table, geom_column='geom'):
+        schema, tablename = self.getSchemaTableName(table)
+        idx_name = self.quoteId("sidx_%s_%s" % (tablename, geom_column))
+        sql = "CREATE INDEX %s ON %s USING GIST(%s)" % (idx_name, self.quoteId(table), self.quoteId(geom_column))
+        self._execute_and_commit(sql)
+
+    def deleteSpatialIndex(self, table, geom_column='geom'):
+        schema, tablename = self.getSchemaTableName(table)
+        idx_name = self.quoteId("sidx_%s_%s" % (tablename, geom_column))
+        return self.deleteTableIndex(table, idx_name)
+
+    def execution_error_types(self):
+        return psycopg2.Error, psycopg2.ProgrammingError, psycopg2.Warning
+
+    def connection_error_types(self):
+        return psycopg2.InterfaceError, psycopg2.OperationalError
+
+    def _execute(self, cursor, sql):
+        if cursor is not None:
+            cursor._execute(sql)
+            return cursor
+        self.feedback = QgsFeedback()
+        return CursorAdapter(self.core_connection, sql, feedback=self.feedback)
+
+    def _executeSql(self, sql):
+        return self.core_connection.executeSql(sql)
+
+    def _get_cursor(self, name=None):
+        # if name is not None:
+        #   print("XXX _get_cursor called with a Name: " + name)
+        return CursorAdapter(self.core_connection, name)
+
+    def _commit(self):
+        pass
+
+    # moved into the parent class: DbConnector._rollback()
+    def _rollback(self):
+        pass
+
+    # moved into the parent class: DbConnector._get_cursor_columns()
+    # def _get_cursor_columns(self, c):
+    #       pass
+
+    def getSqlDictionary(self):
+        from .sql_dictionary import getSqlDictionary
+
+        sql_dict = getSqlDictionary()
+
+        # get schemas, tables and field names
+        sql = """SELECT nspname FROM pg_namespace WHERE nspname !~ '^pg_' AND nspname != 'information_schema'
+UNION SELECT relname FROM pg_class WHERE relkind IN ('v', 'r', 'm', 'p')
+UNION SELECT attname FROM pg_attribute WHERE attnum > 0"""
+        c = self._execute(None, sql)
+        items = [
+            row[0]
+            for row in self._fetchall(c)
+        ]
+        self._close_cursor(c)
+
+        sql_dict["identifier"] = items
+        return sql_dict
+
+    def getQueryBuilderDictionary(self):
+        from .sql_dictionary import getQueryBuilderDictionary
+
+        return getQueryBuilderDictionary()

+ 78 - 0
db_manager/db_plugins/postgis/connector_test.py

@@ -0,0 +1,78 @@
+"""
+***************************************************************************
+    connector_test.py
+    ---------------------
+    Date                 : May 2017
+    Copyright            : (C) 2017, Sandro Santilli
+    Email                : strk at kbt dot io
+***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************
+"""
+
+__author__ = 'Sandro Santilli'
+__date__ = 'May 2017'
+__copyright__ = '(C) 2017, Sandro Santilli'
+
+import os
+import qgis
+import unittest
+from qgis.testing import start_app, QgisTestCase
+from qgis.core import QgsDataSourceUri
+from qgis.utils import iface
+from qgis.PyQt.QtCore import QObject
+
+start_app()
+
+from db_manager.db_plugins.postgis.connector import PostGisDBConnector
+
+
+class TestDBManagerPostgisConnector(QgisTestCase):
+
+    # def setUpClass():
+
+    def _getUser(self, connector):
+        r = connector._execute(None, "SELECT USER")
+        val = connector._fetchone(r)[0]
+        connector._close_cursor(r)
+        return val
+
+    def _getDatabase(self, connector):
+        r = connector._execute(None, "SELECT current_database()")
+        val = connector._fetchone(r)[0]
+        connector._close_cursor(r)
+        return val
+
+    # See https://github.com/qgis/QGIS/issues/24525
+    # and https://github.com/qgis/QGIS/issues/19005
+    def test_dbnameLessURI(self):
+        obj = QObject()  # needs to be kept alive
+        obj.connectionName = lambda: 'fake'
+        obj.providerName = lambda: 'postgres'
+
+        c = PostGisDBConnector(QgsDataSourceUri(), obj)
+        self.assertIsInstance(c, PostGisDBConnector)
+        uri = c.uri()
+
+        # No username was passed, so we expect it to be taken
+        # from PGUSER or USER environment variables
+        expected_user = os.environ.get('PGUSER') or os.environ.get('USER')
+        actual_user = self._getUser(c)
+        self.assertEqual(actual_user, expected_user)
+
+        # No database was passed, so we expect it to be taken
+        # from PGDATABASE or expected user
+        expected_db = os.environ.get('PGDATABASE') or expected_user
+        actual_db = self._getDatabase(c)
+        self.assertEqual(actual_db, expected_db)
+
+    # TODO: add service-only test (requires a ~/.pg_service.conf file)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 114 - 0
db_manager/db_plugins/postgis/data_model.py

@@ -0,0 +1,114 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.core import QgsMessageLog
+from ..plugin import BaseError
+from ..data_model import (TableDataModel,
+                          SqlResultModel,
+                          SqlResultModelAsync,
+                          SqlResultModelTask)
+
+
+class PGTableDataModel(TableDataModel):
+
+    def __init__(self, table, parent=None):
+        self.cursor = None
+        TableDataModel.__init__(self, table, parent)
+
+        if self.table.rowCount is None:
+            self.table.refreshRowCount()
+            if self.table.rowCount is None:
+                return
+
+        self.table.aboutToChange.connect(self._deleteCursor)
+        self._createCursor()
+
+    def _createCursor(self):
+        fields_txt = ", ".join(self.fields)
+        table_txt = self.db.quoteId((self.table.schemaName(), self.table.name))
+
+        self.cursor = self.db._get_cursor()
+        sql = "SELECT %s FROM %s" % (fields_txt, table_txt)
+        self.db._execute(self.cursor, sql)
+
+    def _sanitizeTableField(self, field):
+        # get fields, ignore geometry columns
+        if field.dataType.lower() == "geometry":
+            return "CASE WHEN %(fld)s IS NULL THEN NULL ELSE GeometryType(%(fld)s) END AS %(fld)s" % {
+                'fld': self.db.quoteId(field.name)}
+        elif field.dataType.lower() == "raster":
+            return "CASE WHEN %(fld)s IS NULL THEN NULL ELSE 'RASTER' END AS %(fld)s" % {
+                'fld': self.db.quoteId(field.name)}
+        return "%s::text" % self.db.quoteId(field.name)
+
+    def _deleteCursor(self):
+        self.db._close_cursor(self.cursor)
+        self.cursor = None
+
+    def __del__(self):
+        self.table.aboutToChange.disconnect(self._deleteCursor)
+        self._deleteCursor()
+        pass  # print "PGTableModel.__del__"
+
+    def fetchMoreData(self, row_start):
+        if not self.cursor:
+            self._createCursor()
+
+        try:
+            self.cursor.scroll(row_start, mode='absolute')
+        except self.db.error_types():
+            self._deleteCursor()
+            return self.fetchMoreData(row_start)
+
+        self.resdata = self.cursor.fetchmany(self.fetchedCount)
+        self.fetchedFrom = row_start
+
+
+class PGSqlResultModelTask(SqlResultModelTask):
+
+    def __init__(self, db, sql, parent):
+        super().__init__(db, sql, parent)
+
+    def run(self):
+        try:
+            self.model = PGSqlResultModel(self.db, self.sql, None)
+        except BaseError as e:
+            self.error = e
+            QgsMessageLog.logMessage(e.msg)
+            return False
+        return True
+
+    def cancel(self):
+        self.db.connector.cancel()
+        SqlResultModelTask.cancel(self)
+
+
+class PGSqlResultModelAsync(SqlResultModelAsync):
+
+    def __init__(self, db, sql, parent):
+        super().__init__()
+
+        self.task = PGSqlResultModelTask(db, sql, parent)
+        self.task.taskCompleted.connect(self.modelDone)
+        self.task.taskTerminated.connect(self.modelDone)
+
+
+class PGSqlResultModel(SqlResultModel):
+    pass

+ 258 - 0
db_manager/db_plugins/postgis/info_model.py

@@ -0,0 +1,258 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtWidgets import QApplication
+
+from ..info_model import TableInfo, VectorTableInfo, RasterTableInfo, DatabaseInfo
+from ..html_elems import HtmlSection, HtmlParagraph, HtmlTable, HtmlTableHeader, HtmlTableCol
+
+
+class PGDatabaseInfo(DatabaseInfo):
+
+    def connectionDetails(self):
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Host:"), self.db.connector.host),
+            (QApplication.translate("DBManagerPlugin", "User:"), self.db.connector.user),
+            (QApplication.translate("DBManagerPlugin", "Database:"), self.db.connector.dbname)
+        ]
+        return HtmlTable(tbl)
+
+
+class PGTableInfo(TableInfo):
+
+    def __init__(self, table):
+        super().__init__(table)
+        self.table = table
+
+    def generalInfo(self):
+        ret = []
+
+        # if the estimation is less than 100 rows, try to count them - it shouldn't take long time
+        if self.table.rowCount is None and self.table.estimatedRowCount < 100:
+            # row count information is not displayed yet, so just block
+            # table signals to avoid double refreshing (infoViewer->refreshRowCount->tableChanged->infoViewer)
+            self.table.blockSignals(True)
+            self.table.refreshRowCount()
+            self.table.blockSignals(False)
+
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Relation type:"),
+             QApplication.translate("DBManagerPlugin", "View") if self.table._relationType == 'v' else
+             QApplication.translate("DBManagerPlugin", "Materialized view") if self.table._relationType == 'm' else
+             QApplication.translate("DBManagerPlugin", "Table")),
+            (QApplication.translate("DBManagerPlugin", "Owner:"), self.table.owner)
+        ]
+        if self.table.comment:
+            tbl.append((QApplication.translate("DBManagerPlugin", "Comment:"), self.table.comment))
+
+        tbl.extend([
+            (QApplication.translate("DBManagerPlugin", "Pages:"), self.table.pages),
+            (QApplication.translate("DBManagerPlugin", "Rows (estimation):"), self.table.estimatedRowCount)
+        ])
+
+        # privileges
+        # has the user access to this schema?
+        schema_priv = self.table.database().connector.getSchemaPrivileges(
+            self.table.schemaName()) if self.table.schema() else None
+        if schema_priv is None:
+            pass
+        elif not schema_priv[1]:  # no usage privileges on the schema
+            tbl.append((QApplication.translate("DBManagerPlugin", "Privileges:"),
+                        QApplication.translate("DBManagerPlugin",
+                                               "<warning> This user doesn't have usage privileges for this schema!")))
+        else:
+            table_priv = self.table.database().connector.getTablePrivileges((self.table.schemaName(), self.table.name))
+            privileges = []
+            if table_priv[0]:
+                privileges.append("select")
+
+                if self.table.rowCount is not None and self.table.rowCount >= 0:
+                    tbl.append((QApplication.translate("DBManagerPlugin", "Rows (counted):"),
+                                self.table.rowCount if self.table.rowCount is not None else QApplication.translate(
+                                    "DBManagerPlugin", 'Unknown (<a href="action:rows/count">find out</a>)')))
+
+            if table_priv[1]:
+                privileges.append("insert")
+            if table_priv[2]:
+                privileges.append("update")
+            if table_priv[3]:
+                privileges.append("delete")
+            priv_string = ", ".join(privileges) if len(privileges) > 0 else QApplication.translate("DBManagerPlugin",
+                                                                                                   '<warning> This user has no privileges!')
+            tbl.append((QApplication.translate("DBManagerPlugin", "Privileges:"), priv_string))
+
+        ret.append(HtmlTable(tbl))
+
+        if schema_priv is not None and schema_priv[1]:
+            if table_priv[0] and not table_priv[1] and not table_priv[2] and not table_priv[3]:
+                ret.append(HtmlParagraph(
+                    QApplication.translate("DBManagerPlugin", "<warning> This user has read-only privileges.")))
+
+        if not self.table.isView:
+            if self.table.rowCount is not None:
+                if abs(self.table.estimatedRowCount - self.table.rowCount) > 1 and \
+                        (self.table.estimatedRowCount > 2 * self.table.rowCount
+                         or self.table.rowCount > 2 * self.table.estimatedRowCount):
+                    ret.append(HtmlParagraph(QApplication.translate("DBManagerPlugin",
+                                                                    "<warning> There's a significant difference between estimated and real row count. "
+                                                                    'Consider running <a href="action:vacuumanalyze/run">VACUUM ANALYZE</a>.')))
+
+        # primary key defined?
+        if not self.table.isView:
+            if len([fld for fld in self.table.fields() if fld.primaryKey]) <= 0:
+                ret.append(HtmlParagraph(
+                    QApplication.translate("DBManagerPlugin", "<warning> No primary key defined for this table!")))
+
+        return ret
+
+    def getSpatialInfo(self):
+        ret = []
+
+        info = self.db.connector.getSpatialInfo()
+        if info is None:
+            return
+
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Library:"), info[0]),
+            (QApplication.translate("DBManagerPlugin", "Scripts:"), info[3]),
+            ("GEOS:", info[1]),
+            ("Proj:", info[2])
+        ]
+        ret.append(HtmlTable(tbl))
+
+        if info[1] is not None and info[1] != info[2]:
+            ret.append(HtmlParagraph(QApplication.translate("DBManagerPlugin",
+                                                            "<warning> Version of installed scripts doesn't match version of released scripts!\n"
+                                                            "This is probably a result of incorrect PostGIS upgrade.")))
+
+        if not self.db.connector.has_geometry_columns:
+            ret.append(HtmlParagraph(
+                QApplication.translate("DBManagerPlugin", "<warning> geometry_columns table doesn't exist!\n"
+                                                          "This table is essential for many GIS applications for enumeration of tables.")))
+        elif not self.db.connector.has_geometry_columns_access:
+            ret.append(HtmlParagraph(QApplication.translate("DBManagerPlugin",
+                                                            "<warning> This user doesn't have privileges to read contents of geometry_columns table!\n"
+                                                            "This table is essential for many GIS applications for enumeration of tables.")))
+
+        return ret
+
+    def fieldsDetails(self):
+        tbl = []
+
+        # define the table header
+        header = (
+            "#", QApplication.translate("DBManagerPlugin", "Name"), QApplication.translate("DBManagerPlugin", "Type"),
+            QApplication.translate("DBManagerPlugin", "Length"), QApplication.translate("DBManagerPlugin", "Null"),
+            QApplication.translate("DBManagerPlugin", "Default"), QApplication.translate("DBManagerPlugin", "Comment"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for fld in self.table.fields():
+            char_max_len = fld.charMaxLen if fld.charMaxLen is not None and fld.charMaxLen != -1 else ""
+            is_null_txt = "N" if fld.notNull else "Y"
+
+            # make primary key field underlined
+            attrs = {"class": "underline"} if fld.primaryKey else None
+            name = HtmlTableCol(fld.name, attrs)
+
+            tbl.append((fld.num, name, fld.type2String(), char_max_len, is_null_txt, fld.default2String(), fld.getComment()))
+
+        return HtmlTable(tbl, {"class": "header"})
+
+    def triggersDetails(self):
+        if self.table.triggers() is None or len(self.table.triggers()) <= 0:
+            return None
+
+        ret = []
+
+        tbl = []
+        # define the table header
+        header = (
+            QApplication.translate("DBManagerPlugin", "Name"), QApplication.translate("DBManagerPlugin", "Function"),
+            QApplication.translate("DBManagerPlugin", "Type"), QApplication.translate("DBManagerPlugin", "Enabled"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for trig in self.table.triggers():
+            name = '%(name)s (<a href="action:trigger/%(name)s/%(action)s">%(action)s</a>)' % {"name": trig.name,
+                                                                                               "action": "delete"}
+
+            (enabled, action) = (QApplication.translate("DBManagerPlugin", "Yes"), "disable") if trig.enabled else (
+                QApplication.translate("DBManagerPlugin", "No"), "enable")
+            txt_enabled = '%(enabled)s (<a href="action:trigger/%(name)s/%(action)s">%(action)s</a>)' % {
+                "name": trig.name, "action": action, "enabled": enabled}
+
+            tbl.append((name, trig.function, trig.type2String(), txt_enabled))
+
+        ret.append(HtmlTable(tbl, {"class": "header"}))
+
+        ret.append(HtmlParagraph(QApplication.translate("DBManagerPlugin",
+                                                        '<a href="action:triggers/enable">Enable all triggers</a> / <a href="action:triggers/disable">Disable all triggers</a>')))
+        return ret
+
+    def rulesDetails(self):
+        if self.table.rules() is None or len(self.table.rules()) <= 0:
+            return None
+
+        tbl = []
+        # define the table header
+        header = (
+            QApplication.translate("DBManagerPlugin", "Name"), QApplication.translate("DBManagerPlugin", "Definition"))
+        tbl.append(HtmlTableHeader(header))
+
+        # add table contents
+        for rule in self.table.rules():
+            name = '%(name)s (<a href="action:rule/%(name)s/%(action)s">%(action)s</a>)' % {"name": rule.name,
+                                                                                            "action": "delete"}
+            tbl.append((name, rule.definition))
+
+        return HtmlTable(tbl, {"class": "header"})
+
+    def getTableInfo(self):
+        ret = TableInfo.getTableInfo(self)
+
+        # rules
+        rules_details = self.rulesDetails()
+        if rules_details is None:
+            pass
+        else:
+            ret.append(HtmlSection(QApplication.translate("DBManagerPlugin", 'Rules'), rules_details))
+
+        return ret
+
+
+class PGVectorTableInfo(PGTableInfo, VectorTableInfo):
+
+    def __init__(self, table):
+        VectorTableInfo.__init__(self, table)
+        PGTableInfo.__init__(self, table)
+
+    def spatialInfo(self):
+        return VectorTableInfo.spatialInfo(self)
+
+
+class PGRasterTableInfo(PGTableInfo, RasterTableInfo):
+
+    def __init__(self, table):
+        RasterTableInfo.__init__(self, table)
+        PGTableInfo.__init__(self, table)
+
+    def spatialInfo(self):
+        return RasterTableInfo.spatialInfo(self)

+ 483 - 0
db_manager/db_plugins/postgis/plugin.py

@@ -0,0 +1,483 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+# this will disable the dbplugin if the connector raise an ImportError
+from .connector import PostGisDBConnector
+
+from qgis.PyQt.QtCore import Qt, QRegExp, QCoreApplication
+from qgis.PyQt.QtGui import QIcon
+from qgis.PyQt.QtWidgets import QAction, QApplication, QMessageBox
+from qgis.core import Qgis, QgsApplication, QgsSettings
+from qgis.gui import QgsMessageBar
+
+from ..plugin import ConnectionError, InvalidDataException, DBPlugin, Database, Schema, Table, VectorTable, RasterTable, \
+    TableField, TableConstraint, TableIndex, TableTrigger, TableRule
+
+import re
+
+
+def classFactory():
+    return PostGisDBPlugin
+
+
+class PostGisDBPlugin(DBPlugin):
+
+    @classmethod
+    def icon(self):
+        return QgsApplication.getThemeIcon("/mIconPostgis.svg")
+
+    @classmethod
+    def typeName(self):
+        return 'postgis'
+
+    @classmethod
+    def typeNameString(self):
+        return QCoreApplication.translate('db_manager', 'PostGIS')
+
+    @classmethod
+    def providerName(self):
+        return 'postgres'
+
+    @classmethod
+    def connectionSettingsKey(self):
+        return '/PostgreSQL/connections'
+
+    def databasesFactory(self, connection, uri):
+        return PGDatabase(connection, uri)
+
+    def connect(self, parent=None):
+        conn_name = self.connectionName()
+        settings = QgsSettings()
+        settings.beginGroup("/%s/%s" % (self.connectionSettingsKey(), conn_name))
+
+        if not settings.contains("database"):  # non-existent entry?
+            raise InvalidDataException(self.tr('There is no defined database connection "{0}".').format(conn_name))
+
+        from qgis.core import QgsDataSourceUri
+
+        uri = QgsDataSourceUri()
+
+        settingsList = ["service", "host", "port", "database", "username", "password", "authcfg"]
+        service, host, port, database, username, password, authcfg = (settings.value(x, "", type=str) for x in settingsList)
+
+        useEstimatedMetadata = settings.value("estimatedMetadata", False, type=bool)
+        try:
+            sslmode = settings.enumValue("sslmode", QgsDataSourceUri.SslPrefer)
+        except TypeError:
+            sslmode = QgsDataSourceUri.SslPrefer
+
+        settings.endGroup()
+
+        if hasattr(authcfg, 'isNull') and authcfg.isNull():
+            authcfg = ''
+
+        if service:
+            uri.setConnection(service, database, username, password, sslmode, authcfg)
+        else:
+            uri.setConnection(host, port, database, username, password, sslmode, authcfg)
+
+        uri.setUseEstimatedMetadata(useEstimatedMetadata)
+
+        try:
+            return self.connectToUri(uri)
+        except ConnectionError:
+            return False
+
+
+class PGDatabase(Database):
+
+    def __init__(self, connection, uri):
+        Database.__init__(self, connection, uri)
+
+    def connectorsFactory(self, uri):
+        return PostGisDBConnector(uri, self.connection())
+
+    def dataTablesFactory(self, row, db, schema=None):
+        return PGTable(row, db, schema)
+
+    def info(self):
+        from .info_model import PGDatabaseInfo
+        return PGDatabaseInfo(self)
+
+    def vectorTablesFactory(self, row, db, schema=None):
+        return PGVectorTable(row, db, schema)
+
+    def rasterTablesFactory(self, row, db, schema=None):
+        return PGRasterTable(row, db, schema)
+
+    def schemasFactory(self, row, db):
+        return PGSchema(row, db)
+
+    def sqlResultModel(self, sql, parent):
+        from .data_model import PGSqlResultModel
+
+        return PGSqlResultModel(self, sql, parent)
+
+    def sqlResultModelAsync(self, sql, parent):
+        from .data_model import PGSqlResultModelAsync
+
+        return PGSqlResultModelAsync(self, sql, parent)
+
+    def registerDatabaseActions(self, mainWindow):
+        Database.registerDatabaseActions(self, mainWindow)
+
+        # add a separator
+        separator = QAction(self)
+        separator.setSeparator(True)
+        mainWindow.registerAction(separator, self.tr("&Table"))
+
+        action = QAction(self.tr("Run &Vacuum Analyze"), self)
+        mainWindow.registerAction(action, self.tr("&Table"), self.runVacuumAnalyzeActionSlot)
+
+        action = QAction(self.tr("Run &Refresh Materialized View"), self)
+        mainWindow.registerAction(action, self.tr("&Table"), self.runRefreshMaterializedViewSlot)
+
+    def runVacuumAnalyzeActionSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        try:
+            if not isinstance(item, Table) or item.isView:
+                parent.infoBar.pushMessage(self.tr("Select a table for vacuum analyze."), Qgis.Info,
+                                           parent.iface.messageTimeout())
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        item.runVacuumAnalyze()
+
+    def runRefreshMaterializedViewSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        try:
+            if not isinstance(item, PGTable) or item._relationType != 'm':
+                parent.infoBar.pushMessage(self.tr("Select a materialized view for refresh."), Qgis.Info,
+                                           parent.iface.messageTimeout())
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        item.runRefreshMaterializedView()
+
+    def hasLowercaseFieldNamesOption(self):
+        return True
+
+    def supportsComment(self):
+        return True
+
+    def executeSql(self, sql):
+        return self.connector._executeSql(sql)
+
+
+class PGSchema(Schema):
+
+    def __init__(self, row, db):
+        Schema.__init__(self, db)
+        self.oid, self.name, self.owner, self.perms, self.comment = row
+
+
+class PGTable(Table):
+
+    def __init__(self, row, db, schema=None):
+        Table.__init__(self, db, schema)
+        self.name, schema_name, self._relationType, self.owner, self.estimatedRowCount, self.pages, self.comment = row
+        self.isView = self._relationType in {'v', 'm'}
+        self.estimatedRowCount = int(self.estimatedRowCount)
+
+    def runVacuumAnalyze(self):
+        self.aboutToChange.emit()
+        self.database().connector.runVacuumAnalyze((self.schemaName(), self.name))
+        # TODO: change only this item, not re-create all the tables in the schema/database
+        self.schema().refresh() if self.schema() else self.database().refresh()
+
+    def runRefreshMaterializedView(self):
+        self.aboutToChange.emit()
+        self.database().connector.runRefreshMaterializedView((self.schemaName(), self.name))
+        # TODO: change only this item, not re-create all the tables in the schema/database
+        self.schema().refresh() if self.schema() else self.database().refresh()
+
+    def runAction(self, action):
+        action = str(action)
+
+        if action.startswith("vacuumanalyze/"):
+            if action == "vacuumanalyze/run":
+                self.runVacuumAnalyze()
+                return True
+
+        elif action.startswith("rule/"):
+            parts = action.split('/')
+            rule_name = parts[1]
+            rule_action = parts[2]
+
+            msg = self.tr("Do you want to {0} rule {1}?").format(rule_action, rule_name)
+
+            QApplication.restoreOverrideCursor()
+
+            try:
+                if QMessageBox.question(None, self.tr("Table rule"), msg,
+                                        QMessageBox.Yes | QMessageBox.No) == QMessageBox.No:
+                    return False
+            finally:
+                QApplication.setOverrideCursor(Qt.WaitCursor)
+
+            if rule_action == "delete":
+                self.aboutToChange.emit()
+                self.database().connector.deleteTableRule(rule_name, (self.schemaName(), self.name))
+                self.refreshRules()
+                return True
+
+        elif action.startswith("refreshmaterializedview/"):
+            if action == "refreshmaterializedview/run":
+                self.runRefreshMaterializedView()
+                return True
+
+        return Table.runAction(self, action)
+
+    def tableFieldsFactory(self, row, table):
+        return PGTableField(row, table)
+
+    def tableConstraintsFactory(self, row, table):
+        return PGTableConstraint(row, table)
+
+    def tableIndexesFactory(self, row, table):
+        return PGTableIndex(row, table)
+
+    def tableTriggersFactory(self, row, table):
+        return PGTableTrigger(row, table)
+
+    def tableRulesFactory(self, row, table):
+        return PGTableRule(row, table)
+
+    def info(self):
+        from .info_model import PGTableInfo
+
+        return PGTableInfo(self)
+
+    def crs(self):
+        return self.database().connector.getCrs(self.srid)
+
+    def tableDataModel(self, parent):
+        from .data_model import PGTableDataModel
+
+        return PGTableDataModel(self, parent)
+
+    def delete(self):
+        self.aboutToChange.emit()
+        if self.isView:
+            ret = self.database().connector.deleteView((self.schemaName(), self.name), self._relationType == 'm')
+        else:
+            ret = self.database().connector.deleteTable((self.schemaName(), self.name))
+        if not ret:
+            self.deleted.emit()
+        return ret
+
+
+class PGVectorTable(PGTable, VectorTable):
+
+    def __init__(self, row, db, schema=None):
+        PGTable.__init__(self, row[:-4], db, schema)
+        VectorTable.__init__(self, db, schema)
+        self.geomColumn, self.geomType, self.geomDim, self.srid = row[-4:]
+
+    def info(self):
+        from .info_model import PGVectorTableInfo
+
+        return PGVectorTableInfo(self)
+
+    def runAction(self, action):
+        if PGTable.runAction(self, action):
+            return True
+        return VectorTable.runAction(self, action)
+
+    def geometryType(self):
+        """ Returns the proper WKT type.
+        PostGIS records type like this:
+        | WKT Type     | geomType    | geomDim |
+        |--------------|-------------|---------|
+        | LineString   | LineString  | 2       |
+        | LineStringZ  | LineString  | 3       |
+        | LineStringM  | LineStringM | 3       |
+        | LineStringZM | LineString  | 4       |
+        """
+        geometryType = self.geomType
+        if self.geomDim == 3 and self.geomType[-1] != "M":
+            geometryType += "Z"
+        elif self.geomDim == 4:
+            geometryType += "ZM"
+
+        return geometryType
+
+
+class PGRasterTable(PGTable, RasterTable):
+
+    def __init__(self, row, db, schema=None):
+        PGTable.__init__(self, row[:-6], db, schema)
+        RasterTable.__init__(self, db, schema)
+        self.geomColumn, self.pixelType, self.pixelSizeX, self.pixelSizeY, self.isExternal, self.srid = row[-6:]
+        self.geomType = 'RASTER'
+
+    def info(self):
+        from .info_model import PGRasterTableInfo
+
+        return PGRasterTableInfo(self)
+
+    def uri(self, uri=None):
+        """Returns the datasource URI for postgresraster provider"""
+
+        if not uri:
+            uri = self.database().uri()
+        service = ('service=\'%s\'' % uri.service()) if uri.service() else ''
+        dbname = ('dbname=\'%s\'' % uri.database()) if uri.database() else ''
+        host = ('host=%s' % uri.host()) if uri.host() else ''
+        user = ('user=%s' % uri.username()) if uri.username() else ''
+        passw = ('password=%s' % uri.password()) if uri.password() else ''
+        port = ('port=%s' % uri.port()) if uri.port() else ''
+
+        schema = self.schemaName() if self.schemaName() else 'public'
+        table = '"%s"."%s"' % (schema, self.name)
+
+        if not dbname:
+            # postgresraster provider *requires* a dbname
+            connector = self.database().connector
+            r = connector._execute(None, "SELECT current_database()")
+            dbname = ('dbname=\'%s\'' % connector._fetchone(r)[0])
+            connector._close_cursor(r)
+
+        # Find first raster field
+        col = ''
+        for fld in self.fields():
+            if fld.dataType == "raster":
+                col = 'column=\'%s\'' % fld.name
+                break
+
+        uri = '%s %s %s %s %s %s %s table=%s' % \
+            (service, dbname, host, user, passw, port, col, table)
+
+        return uri
+
+    def mimeUri(self):
+        uri = "raster:postgresraster:{}:{}".format(self.name, re.sub(":", r"\:", self.uri()))
+        return uri
+
+    def toMapLayer(self, geometryType=None, crs=None):
+        from qgis.core import QgsRasterLayer, QgsContrastEnhancement, QgsDataSourceUri, QgsCredentials
+
+        rl = QgsRasterLayer(self.uri(), self.name, "postgresraster")
+        if not rl.isValid():
+            err = rl.error().summary()
+            uri = QgsDataSourceUri(self.database().uri())
+            conninfo = uri.connectionInfo(False)
+            username = uri.username()
+            password = uri.password()
+
+            for i in range(3):
+                (ok, username, password) = QgsCredentials.instance().get(conninfo, username, password, err)
+                if ok:
+                    uri.setUsername(username)
+                    uri.setPassword(password)
+                    rl = QgsRasterLayer(self.uri(uri), self.name)
+                    if rl.isValid():
+                        break
+
+        if rl.isValid():
+            rl.setContrastEnhancement(QgsContrastEnhancement.StretchToMinimumMaximum)
+        return rl
+
+
+class PGTableField(TableField):
+
+    def __init__(self, row, table):
+        TableField.__init__(self, table)
+        self.num, self.name, self.dataType, self.charMaxLen, self.modifier, self.notNull, self.hasDefault, self.default, typeStr = row
+        self.primaryKey = False
+
+        # get modifier (e.g. "precision,scale") from formatted type string
+        trimmedTypeStr = typeStr.strip()
+        regex = QRegExp("\\((.+)\\)$")
+        startpos = regex.indexIn(trimmedTypeStr)
+        if startpos >= 0:
+            self.modifier = regex.cap(1).strip()
+        else:
+            self.modifier = None
+
+        # find out whether fields are part of primary key
+        for con in self.table().constraints():
+            if con.type == TableConstraint.TypePrimaryKey and self.num in con.columns:
+                self.primaryKey = True
+                break
+
+    def getComment(self):
+        """Returns the comment for a field"""
+        tab = self.table()
+        # SQL Query checking if a comment exists for the field
+        sql_cpt = "Select count(*) from pg_description pd, pg_class pc, pg_attribute pa where relname = '%s' and attname = '%s' and pa.attrelid = pc.oid and pd.objoid = pc.oid and pd.objsubid = pa.attnum" % (tab.name, self.name)
+        # SQL Query that return the comment of the field
+        sql = "Select pd.description from pg_description pd, pg_class pc, pg_attribute pa where relname = '%s' and attname = '%s' and pa.attrelid = pc.oid and pd.objoid = pc.oid and pd.objsubid = pa.attnum" % (tab.name, self.name)
+        c = tab.database().connector._execute(None, sql_cpt)  # Execute Check query
+        res = tab.database().connector._fetchone(c)[0]  # Store result
+        if res == 1:
+            # When a comment exists
+            c = tab.database().connector._execute(None, sql)  # Execute query
+            res = tab.database().connector._fetchone(c)[0]  # Store result
+            tab.database().connector._close_cursor(c)  # Close cursor
+            return res  # Return comment
+        else:
+            return ''
+
+
+class PGTableConstraint(TableConstraint):
+
+    def __init__(self, row, table):
+        TableConstraint.__init__(self, table)
+        self.name, constr_type_str, self.isDefferable, self.isDeffered, columns = row[:5]
+        self.columns = list(map(int, columns.split(' ')))
+
+        if constr_type_str in TableConstraint.types:
+            self.type = TableConstraint.types[constr_type_str]
+        else:
+            self.type = TableConstraint.TypeUnknown
+
+        if self.type == TableConstraint.TypeCheck:
+            self.checkSource = row[5]
+        elif self.type == TableConstraint.TypeForeignKey:
+            self.foreignTable = row[6]
+            self.foreignOnUpdate = TableConstraint.onAction[row[7]]
+            self.foreignOnDelete = TableConstraint.onAction[row[8]]
+            self.foreignMatchType = TableConstraint.matchTypes[row[9]]
+            self.foreignKeys = row[10]
+
+
+class PGTableIndex(TableIndex):
+
+    def __init__(self, row, table):
+        TableIndex.__init__(self, table)
+        self.name, columns, self.isUnique = row
+        self.columns = list(map(int, columns.split(' ')))
+
+
+class PGTableTrigger(TableTrigger):
+
+    def __init__(self, row, table):
+        TableTrigger.__init__(self, table)
+        self.name, self.function, self.type, self.enabled = row
+
+
+class PGTableRule(TableRule):
+
+    def __init__(self, row, table):
+        TableRule.__init__(self, table)
+        self.name, self.definition = row

+ 155 - 0
db_manager/db_plugins/postgis/plugin_test.py

@@ -0,0 +1,155 @@
+"""
+***************************************************************************
+    plugin_test.py
+    ---------------------
+    Date                 : May 2017
+    Copyright            : (C) 2017, Sandro Santilli
+    Email                : strk at kbt dot io
+***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************
+"""
+
+__author__ = 'Sandro Santilli'
+__date__ = 'May 2017'
+__copyright__ = '(C) 2017, Sandro Santilli'
+
+import os
+import re
+import qgis
+import unittest
+from qgis.testing import start_app, QgisTestCase
+from qgis.core import QgsDataSourceUri
+from qgis.utils import iface
+from qgis.PyQt.QtCore import QObject
+
+start_app()
+
+from db_manager.db_plugins.postgis.plugin import PostGisDBPlugin, PGRasterTable
+from db_manager.db_plugins.postgis.plugin import PGDatabase
+from db_manager.db_plugins.postgis.data_model import PGSqlResultModel
+from db_manager.db_plugins.plugin import Table
+from db_manager.db_plugins.postgis.connector import PostGisDBConnector
+
+
+class TestDBManagerPostgisPlugin(QgisTestCase):
+
+    @classmethod
+    def setUpClass(self):
+        self.old_pgdatabase_env = os.environ.get('PGDATABASE')
+        # QGIS_PGTEST_DB contains the full connection string and not only the DB name!
+        QGIS_PGTEST_DB = os.environ.get('QGIS_PGTEST_DB')
+        if QGIS_PGTEST_DB is not None:
+            test_uri = QgsDataSourceUri(QGIS_PGTEST_DB)
+            self.testdb = test_uri.database()
+        else:
+            self.testdb = 'qgis_test'
+        os.environ['PGDATABASE'] = self.testdb
+
+        # Create temporary service file
+        self.old_pgservicefile_env = os.environ.get('PGSERVICEFILE')
+        self.tmpservicefile = '/tmp/qgis-test-{}-pg_service.conf'.format(os.getpid())
+        os.environ['PGSERVICEFILE'] = self.tmpservicefile
+
+        f = open(self.tmpservicefile, "w")
+        f.write("[dbmanager]\ndbname={}\n".format(self.testdb))
+        # TODO: add more things if PGSERVICEFILE was already set ?
+        f.close()
+
+    @classmethod
+    def tearDownClass(self):
+        # Restore previous env variables if needed
+        if self.old_pgdatabase_env:
+            os.environ['PGDATABASE'] = self.old_pgdatabase_env
+        if self.old_pgservicefile_env:
+            os.environ['PGSERVICEFILE'] = self.old_pgservicefile_env
+        # Remove temporary service file
+        os.unlink(self.tmpservicefile)
+
+    # See https://github.com/qgis/QGIS/issues/24525
+
+    def test_rasterTableURI(self):
+
+        def check_rasterTableURI(expected_dbname):
+            tables = database.tables()
+            raster_tables_count = 0
+            for tab in tables:
+                if tab.type == Table.RasterType:
+                    raster_tables_count += 1
+                    uri = tab.uri()
+                    m = re.search(' dbname=\'([^ ]*)\' ', uri)
+                    self.assertTrue(m)
+                    actual_dbname = m.group(1)
+                    self.assertEqual(actual_dbname, expected_dbname)
+                # print(tab.type)
+                # print(tab.quotedName())
+                # print(tab)
+
+            # We need to make sure a database is created with at
+            # least one raster table !
+            self.assertGreaterEqual(raster_tables_count, 1)
+
+        obj = QObject()  # needs to be kept alive
+        obj.connectionName = lambda: 'fake'
+        obj.providerName = lambda: 'postgres'
+
+        # Test for empty URI
+        # See https://github.com/qgis/QGIS/issues/24525
+        # and https://github.com/qgis/QGIS/issues/19005
+
+        expected_dbname = self.testdb
+        os.environ['PGDATABASE'] = expected_dbname
+
+        database = PGDatabase(obj, QgsDataSourceUri())
+        self.assertIsInstance(database, PGDatabase)
+
+        uri = database.uri()
+        self.assertEqual(uri.host(), '')
+        self.assertEqual(uri.username(), '')
+        self.assertEqual(uri.database(), expected_dbname)
+        self.assertEqual(uri.service(), '')
+
+        check_rasterTableURI(expected_dbname)
+
+        # Test for service-only URI
+        # See https://github.com/qgis/QGIS/issues/24526
+
+        os.environ['PGDATABASE'] = 'fake'
+        database = PGDatabase(obj, QgsDataSourceUri('service=dbmanager'))
+        self.assertIsInstance(database, PGDatabase)
+
+        uri = database.uri()
+        self.assertEqual(uri.host(), '')
+        self.assertEqual(uri.username(), '')
+        self.assertEqual(uri.database(), '')
+        self.assertEqual(uri.service(), 'dbmanager')
+
+        check_rasterTableURI(expected_dbname)
+
+    # See https://github.com/qgis/QGIS/issues/24732
+    def test_unicodeInQuery(self):
+        os.environ['PGDATABASE'] = self.testdb
+        obj = QObject()  # needs to be kept alive
+        obj.connectionName = lambda: 'fake'
+        obj.providerName = lambda: 'postgres'
+        database = PGDatabase(obj, QgsDataSourceUri())
+        self.assertIsInstance(database, PGDatabase)
+        # SQL as string literal
+        res = database.sqlResultModel("SELECT 'é'::text", obj)
+        self.assertIsInstance(res, PGSqlResultModel)
+        dat = res.getData(0, 0)
+        self.assertEqual(dat, "é")
+        # SQL as unicode literal
+        res = database.sqlResultModel("SELECT 'é'::text", obj)
+        self.assertIsInstance(res, PGSqlResultModel)
+        dat = res.getData(0, 0)
+        self.assertEqual(dat, "é")
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 37 - 0
db_manager/db_plugins/postgis/plugins/__init__.py

@@ -0,0 +1,37 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+import os
+from importlib import import_module
+
+current_dir = os.path.dirname(__file__)
+
+
+def load(db, mainwindow):
+    for name in os.listdir(current_dir):
+        if not os.path.isdir(os.path.join(current_dir, name)):
+            continue
+        if name in ('__pycache__'):
+            continue
+        try:
+            plugin_module = import_module('.'.join((__package__, name)))
+        except ImportError:
+            continue
+        plugin_module.load(db, mainwindow)

+ 309 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/__init__.py

@@ -0,0 +1,309 @@
+"""
+/***************************************************************************
+Name                 : TopoViewer plugin for DB Manager
+Description          : Create a project to display topology schema on Qgis
+Date                 : Sep 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+                       (C) 2019 by Sandro Santilli
+email                : strk@kbt.io
+
+Based on qgis_pgis_topoview by Sandro Santilli <strk@kbt.io>
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtWidgets import QAction
+from qgis.PyQt.QtCore import Qt
+from qgis.PyQt.QtGui import QIcon
+from qgis.core import Qgis, QgsProject, QgsVectorLayer, QgsWkbTypes, QgsLayerTreeGroup
+from qgis.gui import QgsMessageBar
+
+import os
+
+current_path = os.path.dirname(__file__)
+
+
+# The load function is called when the "db" database or either one of its
+# children db objects (table o schema) is selected by the user.
+# @param db is the selected database
+# @param mainwindow is the DBManager mainwindow
+
+
+def load(db, mainwindow):
+    # check whether the selected database supports topology
+    # (search for topology.topology)
+    sql = """SELECT count(*)
+                FROM pg_class AS cls JOIN pg_namespace AS nsp ON nsp.oid = cls.relnamespace
+                WHERE cls.relname = 'topology' AND nsp.nspname = 'topology'"""
+    res = db.executeSql(sql)
+    if res is None or len(res) < 1 or int(res[0][0]) <= 0:
+        return
+
+    # add the action to the DBManager menu
+    action = QAction(QIcon(), "&TopoViewer", db)
+    mainwindow.registerAction(action, "&Schema", run)
+
+
+# The run function is called once the user clicks on the action TopoViewer
+# (look above at the load function) from the DBManager menu/toolbar.
+# @param item is the selected db item (either db, schema or table)
+# @param action is the clicked action on the DBManager menu/toolbar
+# @param mainwindow is the DBManager mainwindow
+def run(item, action, mainwindow):
+    db = item.database()
+    uri = db.uri()
+    iface = mainwindow.iface
+
+    quoteId = db.connector.quoteId
+    quoteStr = db.connector.quoteString
+
+    # check if the selected item is a topology schema
+    isTopoSchema = False
+
+    if not hasattr(item, 'schema'):
+        mainwindow.infoBar.pushMessage("Invalid topology", 'Select a topology schema to continue.', Qgis.Info,
+                                       mainwindow.iface.messageTimeout())
+        return False
+
+    if item.schema() is not None:
+        sql = "SELECT srid FROM topology.topology WHERE name = %s" % quoteStr(item.schema().name)
+        res = db.executeSql(sql)
+        isTopoSchema = len(res) > 0
+
+    if not isTopoSchema:
+        mainwindow.infoBar.pushMessage("Invalid topology",
+                                       'Schema "{}" is not registered in topology.topology.'.format(
+                                           item.schema().name), Qgis.Warning,
+                                       mainwindow.iface.messageTimeout())
+        return False
+
+    if (res[0][0] < 0):
+        mainwindow.infoBar.pushMessage("WARNING", 'Topology "{}" is registered as having a srid of {} in topology.topology, we will assume 0 (for unknown)'.format(item.schema().name, res[0]), Qgis.Warning, mainwindow.iface.messageTimeout())
+        toposrid = '0'
+    else:
+        toposrid = str(res[0][0])
+
+    # load layers into the current project
+    toponame = item.schema().name
+    template_dir = os.path.join(current_path, 'templates')
+
+    # do not refresh the canvas until all the layers are added
+    wasFrozen = iface.mapCanvas().isFrozen()
+    iface.mapCanvas().freeze()
+    try:
+        provider = db.dbplugin().providerName()
+        uri = db.uri()
+
+        # Force use of estimated metadata (topologies can be big)
+        uri.setUseEstimatedMetadata(True)
+
+        # FACES
+
+        # face mbr
+        uri.setDataSource(toponame, 'face', 'mbr', '', 'face_id')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.Polygon)
+        layerFaceMbr = QgsVectorLayer(uri.uri(False), '%s.face_mbr' % toponame, provider)
+        layerFaceMbr.loadNamedStyle(os.path.join(template_dir, 'face_mbr.qml'))
+
+        face_extent = layerFaceMbr.extent()
+
+        # face geometry
+        sql = 'SELECT face_id, mbr, topology.ST_GetFaceGeometry(%s,' \
+              'face_id)::geometry(polygon, %s) as geom ' \
+              'FROM %s.face WHERE face_id > 0' % \
+              (quoteStr(toponame), toposrid, quoteId(toponame))
+        uri.setDataSource('', '(%s\n)' % sql, 'geom', '', 'face_id')
+        uri.setParam('bbox', 'mbr')
+        uri.setParam('checkPrimaryKeyUnicity', '0')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.Polygon)
+        layerFaceGeom = QgsVectorLayer(uri.uri(False), '%s.face' % toponame, provider)
+        layerFaceGeom.setExtent(face_extent)
+        layerFaceGeom.loadNamedStyle(os.path.join(template_dir, 'face.qml'))
+
+        # face_seed
+        sql = 'SELECT face_id, mbr, ST_PointOnSurface(' \
+              'topology.ST_GetFaceGeometry(%s,' \
+              'face_id))::geometry(point, %s) as geom ' \
+              'FROM %s.face WHERE face_id > 0' % \
+              (quoteStr(toponame), toposrid, quoteId(toponame))
+        uri.setDataSource('', '(%s)' % sql, 'geom', '', 'face_id')
+        uri.setParam('bbox', 'mbr')
+        uri.setParam('checkPrimaryKeyUnicity', '0')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.Point)
+        layerFaceSeed = QgsVectorLayer(uri.uri(False), '%s.face_seed' % toponame, provider)
+        layerFaceSeed.setExtent(face_extent)
+        layerFaceSeed.loadNamedStyle(os.path.join(template_dir, 'face_seed.qml'))
+
+        # TODO: add polygon0, polygon1 and polygon2 ?
+
+        # NODES
+
+        # node
+        uri.setDataSource(toponame, 'node', 'geom', '', 'node_id')
+        uri.removeParam('bbox')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.Point)
+        layerNode = QgsVectorLayer(uri.uri(False), '%s.node' % toponame, provider)
+        layerNode.loadNamedStyle(os.path.join(template_dir, 'node.qml'))
+        node_extent = layerNode.extent()
+
+        # node labels
+        uri.setDataSource(toponame, 'node', 'geom', '', 'node_id')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.Point)
+        uri.removeParam('bbox')
+        layerNodeLabel = QgsVectorLayer(uri.uri(False), '%s.node_id' % toponame, provider)
+        layerNodeLabel.setExtent(node_extent)
+        layerNodeLabel.loadNamedStyle(os.path.join(template_dir, 'node_label.qml'))
+
+        # EDGES
+
+        # edge
+        uri.setDataSource(toponame, 'edge_data', 'geom', '', 'edge_id')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.LineString)
+        uri.removeParam('bbox')
+        layerEdge = QgsVectorLayer(uri.uri(False), '%s.edge' % toponame, provider)
+        edge_extent = layerEdge.extent()
+
+        # directed edge
+        uri.setDataSource(toponame, 'edge_data', 'geom', '', 'edge_id')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.LineString)
+        uri.removeParam('bbox')
+        layerDirectedEdge = QgsVectorLayer(uri.uri(False), '%s.directed_edge' % toponame, provider)
+        layerDirectedEdge.setExtent(edge_extent)
+        layerDirectedEdge.loadNamedStyle(os.path.join(template_dir, 'edge.qml'))
+
+        # edge labels
+        uri.setDataSource(toponame, 'edge_data', 'geom', '', 'edge_id')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.LineString)
+        uri.removeParam('bbox')
+        layerEdgeLabel = QgsVectorLayer(uri.uri(False), '%s.edge_id' % toponame, provider)
+        layerEdgeLabel.setExtent(edge_extent)
+        layerEdgeLabel.loadNamedStyle(os.path.join(template_dir, 'edge_label.qml'))
+
+        # face_left
+        uri.setDataSource(toponame, 'edge_data', 'geom', '', 'edge_id')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.LineString)
+        uri.removeParam('bbox')
+        layerFaceLeft = QgsVectorLayer(uri.uri(False), '%s.face_left' % toponame, provider)
+        layerFaceLeft.setExtent(edge_extent)
+        layerFaceLeft.loadNamedStyle(os.path.join(template_dir, 'face_left.qml'))
+
+        # face_right
+        uri.setDataSource(toponame, 'edge_data', 'geom', '', 'edge_id')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.LineString)
+        uri.removeParam('bbox')
+        layerFaceRight = QgsVectorLayer(uri.uri(False), '%s.face_right' % toponame, provider)
+        layerFaceRight.setExtent(edge_extent)
+        layerFaceRight.loadNamedStyle(os.path.join(template_dir, 'face_right.qml'))
+
+        # next_left
+        uri.setDataSource(toponame, 'edge_data', 'geom', '', 'edge_id')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.LineString)
+        uri.removeParam('bbox')
+        layerNextLeft = QgsVectorLayer(uri.uri(False), '%s.next_left' % toponame, provider)
+        layerNextLeft.setExtent(edge_extent)
+        layerNextLeft.loadNamedStyle(os.path.join(template_dir, 'next_left.qml'))
+
+        # next_right
+        uri.setDataSource(toponame, 'edge_data', 'geom', '', 'edge_id')
+        uri.setSrid(toposrid)
+        uri.setWkbType(QgsWkbTypes.LineString)
+        uri.removeParam('bbox')
+        layerNextRight = QgsVectorLayer(uri.uri(False), '%s.next_right' % toponame, provider)
+        layerNextRight.setExtent(edge_extent)
+        layerNextRight.loadNamedStyle(os.path.join(template_dir, 'next_right.qml'))
+
+        # Add layers to the layer tree
+
+        faceLayers = [layerFaceMbr, layerFaceGeom, layerFaceSeed]
+        nodeLayers = [layerNode, layerNodeLabel]
+        edgeLayers = [layerEdge, layerDirectedEdge, layerEdgeLabel, layerFaceLeft, layerFaceRight, layerNextLeft, layerNextRight]
+
+        QgsProject.instance().addMapLayers(faceLayers, False)
+        QgsProject.instance().addMapLayers(nodeLayers, False)
+        QgsProject.instance().addMapLayers(edgeLayers, False)
+
+        # Organize layers in groups
+
+        groupFaces = QgsLayerTreeGroup('Faces')
+        for layer in faceLayers:
+            nodeLayer = groupFaces.addLayer(layer)
+            nodeLayer.setItemVisibilityChecked(False)
+            nodeLayer.setExpanded(False)
+
+        groupNodes = QgsLayerTreeGroup('Nodes')
+        for layer in nodeLayers:
+            nodeLayer = groupNodes.addLayer(layer)
+            nodeLayer.setItemVisibilityChecked(False)
+            nodeLayer.setExpanded(False)
+
+        groupEdges = QgsLayerTreeGroup('Edges')
+        for layer in edgeLayers:
+            nodeLayer = groupEdges.addLayer(layer)
+            nodeLayer.setItemVisibilityChecked(False)
+            nodeLayer.setExpanded(False)
+
+        supergroup = QgsLayerTreeGroup('Topology "%s"' % toponame)
+        supergroup.insertChildNodes(-1, [groupFaces, groupNodes, groupEdges])
+
+        layerTree = QgsProject.instance().layerTreeRoot()
+
+        layerTree.addChildNode(supergroup)
+
+        # Set layers rendering order
+
+        order = layerTree.layerOrder()
+
+        order.insert(0, order.pop(order.index(layerFaceMbr)))
+        order.insert(0, order.pop(order.index(layerFaceGeom)))
+        order.insert(0, order.pop(order.index(layerEdge)))
+        order.insert(0, order.pop(order.index(layerDirectedEdge)))
+
+        order.insert(0, order.pop(order.index(layerNode)))
+        order.insert(0, order.pop(order.index(layerFaceSeed)))
+
+        order.insert(0, order.pop(order.index(layerNodeLabel)))
+        order.insert(0, order.pop(order.index(layerEdgeLabel)))
+
+        order.insert(0, order.pop(order.index(layerNextLeft)))
+        order.insert(0, order.pop(order.index(layerNextRight)))
+        order.insert(0, order.pop(order.index(layerFaceLeft)))
+        order.insert(0, order.pop(order.index(layerFaceRight)))
+
+        layerTree.setHasCustomLayerOrder(True)
+        layerTree.setCustomLayerOrder(order)
+
+    finally:
+
+        # Set canvas extent to topology extent, if not yet initialized
+        canvas = iface.mapCanvas()
+        if (canvas.fullExtent().isNull()):
+            ext = node_extent
+            ext.combineExtentWith(edge_extent)
+            # Grow by 1/20 of largest side
+            ext = ext.buffered(max(ext.width(), ext.height()) / 20)
+            canvas.setExtent(ext)
+
+        # restore canvas render flag
+        if not wasFrozen:
+            iface.mapCanvas().freeze(False)
+
+    return True

+ 559 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/edge.qml

@@ -0,0 +1,559 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="1" minLabelScale="1" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <renderer-v2 forceraster="0" symbollevels="0" type="RuleRenderer" enableorderby="0">
+    <rules key="{af3f602a-64a2-4e09-a50c-499737f5023b}">
+      <rule filter="edge_id % 10 = 0" key="{76481827-a439-4c6f-a64c-3d7b1523685d}" symbol="0"/>
+      <rule filter="edge_id % 10 = 1" key="{c6b69222-6658-4300-910a-c5d81fd0970e}" symbol="1"/>
+      <rule filter="edge_id % 10 = 2" key="{46ef8d14-9923-4a6b-a9ce-5837c8afaf6d}" symbol="2"/>
+      <rule filter="edge_id % 10 = 3" key="{eabc544a-372a-485b-b109-d9c66192840b}" symbol="3"/>
+      <rule filter="edge_id % 10 = 4" key="{90595400-41c2-45ae-ad49-265f7a8584e4}" symbol="4"/>
+      <rule filter="edge_id % 10 = 5" key="{27b3674f-0f36-4dff-a070-58125c5c770a}" symbol="5"/>
+      <rule filter="edge_id % 10 = 6" key="{768a9d58-ff7b-4385-a93a-a3d3521141d7}" symbol="6"/>
+      <rule filter="edge_id % 10 = 7" key="{127f7c84-7fa9-45bb-a8ea-77f7381a5144}" symbol="7"/>
+      <rule filter="edge_id % 10 = 8" key="{09801818-fd70-4acd-aa03-a7f3d1584dda}" symbol="8"/>
+      <rule filter="edge_id % 10 = 9" key="{9978b1a8-d70a-4e7f-8b98-b58714657a39}" symbol="9"/>
+    </rules>
+    <symbols>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="0">
+        <layer pass="0" class="MarkerLine" locked="0">
+          <prop k="interval" v="3"/>
+          <prop k="interval_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="interval_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_along_line" v="0"/>
+          <prop k="offset_along_line_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_along_line_unit" v="MM"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="placement" v="lastvertex"/>
+          <prop k="rotate" v="1"/>
+          <symbol alpha="1" clip_to_extent="1" type="marker" name="@0@0">
+            <layer pass="0" class="SimpleMarker" locked="0">
+              <prop k="angle" v="0"/>
+              <prop k="color" v="0,0,0,255"/>
+              <prop k="horizontal_anchor_point" v="1"/>
+              <prop k="joinstyle" v="bevel"/>
+              <prop k="name" v="arrowhead"/>
+              <prop k="offset" v="0,0"/>
+              <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="offset_unit" v="MM"/>
+              <prop k="outline_color" v="0,0,0,255"/>
+              <prop k="outline_style" v="solid"/>
+              <prop k="outline_width" v="0.6"/>
+              <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="outline_width_unit" v="MM"/>
+              <prop k="scale_method" v="area"/>
+              <prop k="size" v="6"/>
+              <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="size_unit" v="MM"/>
+              <prop k="vertical_anchor_point" v="1"/>
+            </layer>
+          </symbol>
+        </layer>
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="0,0,0,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.5"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="1">
+        <layer pass="0" class="MarkerLine" locked="0">
+          <prop k="interval" v="3"/>
+          <prop k="interval_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="interval_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_along_line" v="0"/>
+          <prop k="offset_along_line_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_along_line_unit" v="MM"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="placement" v="lastvertex"/>
+          <prop k="rotate" v="1"/>
+          <symbol alpha="1" clip_to_extent="1" type="marker" name="@1@0">
+            <layer pass="0" class="SimpleMarker" locked="0">
+              <prop k="angle" v="0"/>
+              <prop k="color" v="76,51,152,255"/>
+              <prop k="horizontal_anchor_point" v="1"/>
+              <prop k="joinstyle" v="bevel"/>
+              <prop k="name" v="arrowhead"/>
+              <prop k="offset" v="0,0"/>
+              <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="offset_unit" v="MM"/>
+              <prop k="outline_color" v="76,51,152,255"/>
+              <prop k="outline_style" v="solid"/>
+              <prop k="outline_width" v="0.6"/>
+              <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="outline_width_unit" v="MM"/>
+              <prop k="scale_method" v="area"/>
+              <prop k="size" v="6"/>
+              <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="size_unit" v="MM"/>
+              <prop k="vertical_anchor_point" v="1"/>
+            </layer>
+          </symbol>
+        </layer>
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="76,51,152,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.5"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="2">
+        <layer pass="0" class="MarkerLine" locked="0">
+          <prop k="interval" v="3"/>
+          <prop k="interval_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="interval_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_along_line" v="0"/>
+          <prop k="offset_along_line_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_along_line_unit" v="MM"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="placement" v="lastvertex"/>
+          <prop k="rotate" v="1"/>
+          <symbol alpha="1" clip_to_extent="1" type="marker" name="@2@0">
+            <layer pass="0" class="SimpleMarker" locked="0">
+              <prop k="angle" v="0"/>
+              <prop k="color" v="60,150,68,255"/>
+              <prop k="horizontal_anchor_point" v="1"/>
+              <prop k="joinstyle" v="bevel"/>
+              <prop k="name" v="arrowhead"/>
+              <prop k="offset" v="0,0"/>
+              <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="offset_unit" v="MM"/>
+              <prop k="outline_color" v="60,150,68,255"/>
+              <prop k="outline_style" v="solid"/>
+              <prop k="outline_width" v="0.6"/>
+              <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="outline_width_unit" v="MM"/>
+              <prop k="scale_method" v="area"/>
+              <prop k="size" v="6"/>
+              <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="size_unit" v="MM"/>
+              <prop k="vertical_anchor_point" v="1"/>
+            </layer>
+          </symbol>
+        </layer>
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="60,150,68,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.5"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="3">
+        <layer pass="0" class="MarkerLine" locked="0">
+          <prop k="interval" v="3"/>
+          <prop k="interval_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="interval_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_along_line" v="0"/>
+          <prop k="offset_along_line_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_along_line_unit" v="MM"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="placement" v="lastvertex"/>
+          <prop k="rotate" v="1"/>
+          <symbol alpha="1" clip_to_extent="1" type="marker" name="@3@0">
+            <layer pass="0" class="SimpleMarker" locked="0">
+              <prop k="angle" v="0"/>
+              <prop k="color" v="166,47,49,255"/>
+              <prop k="horizontal_anchor_point" v="1"/>
+              <prop k="joinstyle" v="bevel"/>
+              <prop k="name" v="arrowhead"/>
+              <prop k="offset" v="0,0"/>
+              <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="offset_unit" v="MM"/>
+              <prop k="outline_color" v="166,47,49,255"/>
+              <prop k="outline_style" v="solid"/>
+              <prop k="outline_width" v="0.6"/>
+              <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="outline_width_unit" v="MM"/>
+              <prop k="scale_method" v="area"/>
+              <prop k="size" v="6"/>
+              <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="size_unit" v="MM"/>
+              <prop k="vertical_anchor_point" v="1"/>
+            </layer>
+          </symbol>
+        </layer>
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="166,47,49,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.5"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="4">
+        <layer pass="0" class="MarkerLine" locked="0">
+          <prop k="interval" v="3"/>
+          <prop k="interval_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="interval_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_along_line" v="0"/>
+          <prop k="offset_along_line_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_along_line_unit" v="MM"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="placement" v="lastvertex"/>
+          <prop k="rotate" v="1"/>
+          <symbol alpha="1" clip_to_extent="1" type="marker" name="@4@0">
+            <layer pass="0" class="SimpleMarker" locked="0">
+              <prop k="angle" v="0"/>
+              <prop k="color" v="176,172,55,255"/>
+              <prop k="horizontal_anchor_point" v="1"/>
+              <prop k="joinstyle" v="bevel"/>
+              <prop k="name" v="arrowhead"/>
+              <prop k="offset" v="0,0"/>
+              <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="offset_unit" v="MM"/>
+              <prop k="outline_color" v="176,172,55,255"/>
+              <prop k="outline_style" v="solid"/>
+              <prop k="outline_width" v="0.6"/>
+              <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="outline_width_unit" v="MM"/>
+              <prop k="scale_method" v="area"/>
+              <prop k="size" v="6"/>
+              <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="size_unit" v="MM"/>
+              <prop k="vertical_anchor_point" v="1"/>
+            </layer>
+          </symbol>
+        </layer>
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="176,172,55,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.5"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="5">
+        <layer pass="0" class="MarkerLine" locked="0">
+          <prop k="interval" v="3"/>
+          <prop k="interval_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="interval_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_along_line" v="0"/>
+          <prop k="offset_along_line_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_along_line_unit" v="MM"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="placement" v="lastvertex"/>
+          <prop k="rotate" v="1"/>
+          <symbol alpha="1" clip_to_extent="1" type="marker" name="@5@0">
+            <layer pass="0" class="SimpleMarker" locked="0">
+              <prop k="angle" v="0"/>
+              <prop k="color" v="7,79,167,255"/>
+              <prop k="horizontal_anchor_point" v="1"/>
+              <prop k="joinstyle" v="bevel"/>
+              <prop k="name" v="arrowhead"/>
+              <prop k="offset" v="0,0"/>
+              <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="offset_unit" v="MM"/>
+              <prop k="outline_color" v="7,79,167,255"/>
+              <prop k="outline_style" v="solid"/>
+              <prop k="outline_width" v="0.6"/>
+              <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="outline_width_unit" v="MM"/>
+              <prop k="scale_method" v="area"/>
+              <prop k="size" v="6"/>
+              <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="size_unit" v="MM"/>
+              <prop k="vertical_anchor_point" v="1"/>
+            </layer>
+          </symbol>
+        </layer>
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="7,79,167,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.5"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="6">
+        <layer pass="0" class="MarkerLine" locked="0">
+          <prop k="interval" v="3"/>
+          <prop k="interval_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="interval_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_along_line" v="0"/>
+          <prop k="offset_along_line_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_along_line_unit" v="MM"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="placement" v="lastvertex"/>
+          <prop k="rotate" v="1"/>
+          <symbol alpha="1" clip_to_extent="1" type="marker" name="@6@0">
+            <layer pass="0" class="SimpleMarker" locked="0">
+              <prop k="angle" v="0"/>
+              <prop k="color" v="203,213,14,255"/>
+              <prop k="horizontal_anchor_point" v="1"/>
+              <prop k="joinstyle" v="bevel"/>
+              <prop k="name" v="arrowhead"/>
+              <prop k="offset" v="0,0"/>
+              <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="offset_unit" v="MM"/>
+              <prop k="outline_color" v="203,213,14,255"/>
+              <prop k="outline_style" v="solid"/>
+              <prop k="outline_width" v="0.6"/>
+              <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="outline_width_unit" v="MM"/>
+              <prop k="scale_method" v="area"/>
+              <prop k="size" v="6"/>
+              <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="size_unit" v="MM"/>
+              <prop k="vertical_anchor_point" v="1"/>
+            </layer>
+          </symbol>
+        </layer>
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="203,213,14,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.5"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="7">
+        <layer pass="0" class="MarkerLine" locked="0">
+          <prop k="interval" v="3"/>
+          <prop k="interval_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="interval_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_along_line" v="0"/>
+          <prop k="offset_along_line_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_along_line_unit" v="MM"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="placement" v="lastvertex"/>
+          <prop k="rotate" v="1"/>
+          <symbol alpha="1" clip_to_extent="1" type="marker" name="@7@0">
+            <layer pass="0" class="SimpleMarker" locked="0">
+              <prop k="angle" v="0"/>
+              <prop k="color" v="198,7,157,255"/>
+              <prop k="horizontal_anchor_point" v="1"/>
+              <prop k="joinstyle" v="bevel"/>
+              <prop k="name" v="arrowhead"/>
+              <prop k="offset" v="0,0"/>
+              <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="offset_unit" v="MM"/>
+              <prop k="outline_color" v="198,7,157,255"/>
+              <prop k="outline_style" v="solid"/>
+              <prop k="outline_width" v="0.6"/>
+              <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="outline_width_unit" v="MM"/>
+              <prop k="scale_method" v="area"/>
+              <prop k="size" v="6"/>
+              <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="size_unit" v="MM"/>
+              <prop k="vertical_anchor_point" v="1"/>
+            </layer>
+          </symbol>
+        </layer>
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="198,7,157,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.5"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="8">
+        <layer pass="0" class="MarkerLine" locked="0">
+          <prop k="interval" v="3"/>
+          <prop k="interval_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="interval_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_along_line" v="0"/>
+          <prop k="offset_along_line_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_along_line_unit" v="MM"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="placement" v="lastvertex"/>
+          <prop k="rotate" v="1"/>
+          <symbol alpha="1" clip_to_extent="1" type="marker" name="@8@0">
+            <layer pass="0" class="SimpleMarker" locked="0">
+              <prop k="angle" v="0"/>
+              <prop k="color" v="56,211,21,255"/>
+              <prop k="horizontal_anchor_point" v="1"/>
+              <prop k="joinstyle" v="bevel"/>
+              <prop k="name" v="arrowhead"/>
+              <prop k="offset" v="0,0"/>
+              <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="offset_unit" v="MM"/>
+              <prop k="outline_color" v="56,211,21,255"/>
+              <prop k="outline_style" v="solid"/>
+              <prop k="outline_width" v="0.6"/>
+              <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="outline_width_unit" v="MM"/>
+              <prop k="scale_method" v="area"/>
+              <prop k="size" v="6"/>
+              <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="size_unit" v="MM"/>
+              <prop k="vertical_anchor_point" v="1"/>
+            </layer>
+          </symbol>
+        </layer>
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="56,211,21,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.5"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="9">
+        <layer pass="0" class="MarkerLine" locked="0">
+          <prop k="interval" v="3"/>
+          <prop k="interval_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="interval_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_along_line" v="0"/>
+          <prop k="offset_along_line_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_along_line_unit" v="MM"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="placement" v="lastvertex"/>
+          <prop k="rotate" v="1"/>
+          <symbol alpha="1" clip_to_extent="1" type="marker" name="@9@0">
+            <layer pass="0" class="SimpleMarker" locked="0">
+              <prop k="angle" v="0"/>
+              <prop k="color" v="12,204,198,255"/>
+              <prop k="horizontal_anchor_point" v="1"/>
+              <prop k="joinstyle" v="bevel"/>
+              <prop k="name" v="arrowhead"/>
+              <prop k="offset" v="0,0"/>
+              <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="offset_unit" v="MM"/>
+              <prop k="outline_color" v="12,204,198,255"/>
+              <prop k="outline_style" v="solid"/>
+              <prop k="outline_width" v="0.6"/>
+              <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="outline_width_unit" v="MM"/>
+              <prop k="scale_method" v="area"/>
+              <prop k="size" v="6"/>
+              <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+              <prop k="size_unit" v="MM"/>
+              <prop k="vertical_anchor_point" v="1"/>
+            </layer>
+          </symbol>
+        </layer>
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="12,204,198,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.5"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+    </symbols>
+  </renderer-v2>
+</qgis>

+ 327 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/edge_label.qml

@@ -0,0 +1,327 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="1" minLabelScale="1" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <edittypes>
+    <edittype widgetv2type="TextEdit" name="edge_id">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="start_node">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="end_node">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="next_left_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="abs_next_left_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="next_right_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="abs_next_right_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="left_face">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="right_face">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+  </edittypes>
+  <renderer-v2 forceraster="0" symbollevels="0" type="singleSymbol" enableorderby="0">
+    <symbols>
+      <symbol alpha="0" clip_to_extent="1" type="line" name="0">
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="167,236,159,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.26"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+    </symbols>
+    <rotation/>
+    <sizescale scalemethod="diameter"/>
+  </renderer-v2>
+  <labeling type="simple"/>
+  <customproperties>
+    <property key="embeddedWidgets/count" value="0"/>
+    <property key="labeling" value="pal"/>
+    <property key="labeling/addDirectionSymbol" value="false"/>
+    <property key="labeling/angleOffset" value="0"/>
+    <property key="labeling/blendMode" value="0"/>
+    <property key="labeling/bufferBlendMode" value="0"/>
+    <property key="labeling/bufferColorA" value="255"/>
+    <property key="labeling/bufferColorB" value="255"/>
+    <property key="labeling/bufferColorG" value="255"/>
+    <property key="labeling/bufferColorR" value="255"/>
+    <property key="labeling/bufferDraw" value="true"/>
+    <property key="labeling/bufferJoinStyle" value="64"/>
+    <property key="labeling/bufferNoFill" value="false"/>
+    <property key="labeling/bufferSize" value="1"/>
+    <property key="labeling/bufferSizeInMapUnits" value="false"/>
+    <property key="labeling/bufferSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/bufferTransp" value="0"/>
+    <property key="labeling/centroidInside" value="false"/>
+    <property key="labeling/centroidWhole" value="false"/>
+    <property key="labeling/decimals" value="0"/>
+    <property key="labeling/displayAll" value="false"/>
+    <property key="labeling/dist" value="0"/>
+    <property key="labeling/distInMapUnits" value="false"/>
+    <property key="labeling/distMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/drawLabels" value="true"/>
+    <property key="labeling/enabled" value="true"/>
+    <property key="labeling/fieldName" value="edge_id"/>
+    <property key="labeling/fitInPolygonOnly" value="false"/>
+    <property key="labeling/fontCapitals" value="0"/>
+    <property key="labeling/fontFamily" value="Sans"/>
+    <property key="labeling/fontItalic" value="false"/>
+    <property key="labeling/fontLetterSpacing" value="0"/>
+    <property key="labeling/fontLimitPixelSize" value="false"/>
+    <property key="labeling/fontMaxPixelSize" value="10000"/>
+    <property key="labeling/fontMinPixelSize" value="3"/>
+    <property key="labeling/fontSize" value="8"/>
+    <property key="labeling/fontSizeInMapUnits" value="false"/>
+    <property key="labeling/fontSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/fontStrikeout" value="false"/>
+    <property key="labeling/fontUnderline" value="false"/>
+    <property key="labeling/fontWeight" value="50"/>
+    <property key="labeling/fontWordSpacing" value="0"/>
+    <property key="labeling/formatNumbers" value="false"/>
+    <property key="labeling/isExpression" value="false"/>
+    <property key="labeling/labelOffsetInMapUnits" value="true"/>
+    <property key="labeling/labelOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/labelPerPart" value="false"/>
+    <property key="labeling/leftDirectionSymbol" value="&lt;"/>
+    <property key="labeling/limitNumLabels" value="true"/>
+    <property key="labeling/maxCurvedCharAngleIn" value="25"/>
+    <property key="labeling/maxCurvedCharAngleOut" value="-25"/>
+    <property key="labeling/maxNumLabels" value="100"/>
+    <property key="labeling/mergeLines" value="false"/>
+    <property key="labeling/minFeatureSize" value="0"/>
+    <property key="labeling/multiLineLabels" value="false"/>
+    <property key="labeling/multilineAlign" value="0"/>
+    <property key="labeling/multilineHeight" value="1"/>
+    <property key="labeling/namedStyle" value=""/>
+    <property key="labeling/obstacle" value="true"/>
+    <property key="labeling/obstacleFactor" value="1"/>
+    <property key="labeling/obstacleType" value="0"/>
+    <property key="labeling/offsetType" value="0"/>
+    <property key="labeling/placeDirectionSymbol" value="0"/>
+    <property key="labeling/placement" value="2"/>
+    <property key="labeling/placementFlags" value="9"/>
+    <property key="labeling/plussign" value="true"/>
+    <property key="labeling/predefinedPositionOrder" value="TR,TL,BR,BL,R,L,TSR,BSR"/>
+    <property key="labeling/preserveRotation" value="true"/>
+    <property key="labeling/previewBkgrdColor" value="#ffffff"/>
+    <property key="labeling/priority" value="5"/>
+    <property key="labeling/quadOffset" value="4"/>
+    <property key="labeling/repeatDistance" value="0"/>
+    <property key="labeling/repeatDistanceMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/repeatDistanceUnit" value="1"/>
+    <property key="labeling/reverseDirectionSymbol" value="false"/>
+    <property key="labeling/rightDirectionSymbol" value=">"/>
+    <property key="labeling/scaleMax" value="10000000"/>
+    <property key="labeling/scaleMin" value="1"/>
+    <property key="labeling/scaleVisibility" value="false"/>
+    <property key="labeling/shadowBlendMode" value="6"/>
+    <property key="labeling/shadowColorB" value="0"/>
+    <property key="labeling/shadowColorG" value="0"/>
+    <property key="labeling/shadowColorR" value="0"/>
+    <property key="labeling/shadowDraw" value="false"/>
+    <property key="labeling/shadowOffsetAngle" value="135"/>
+    <property key="labeling/shadowOffsetDist" value="1"/>
+    <property key="labeling/shadowOffsetGlobal" value="true"/>
+    <property key="labeling/shadowOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowOffsetUnits" value="1"/>
+    <property key="labeling/shadowRadius" value="1.5"/>
+    <property key="labeling/shadowRadiusAlphaOnly" value="false"/>
+    <property key="labeling/shadowRadiusMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowRadiusUnits" value="1"/>
+    <property key="labeling/shadowScale" value="100"/>
+    <property key="labeling/shadowTransparency" value="30"/>
+    <property key="labeling/shadowUnder" value="0"/>
+    <property key="labeling/shapeBlendMode" value="0"/>
+    <property key="labeling/shapeBorderColorA" value="255"/>
+    <property key="labeling/shapeBorderColorB" value="128"/>
+    <property key="labeling/shapeBorderColorG" value="128"/>
+    <property key="labeling/shapeBorderColorR" value="128"/>
+    <property key="labeling/shapeBorderWidth" value="0"/>
+    <property key="labeling/shapeBorderWidthMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeBorderWidthUnits" value="1"/>
+    <property key="labeling/shapeDraw" value="false"/>
+    <property key="labeling/shapeFillColorA" value="255"/>
+    <property key="labeling/shapeFillColorB" value="255"/>
+    <property key="labeling/shapeFillColorG" value="255"/>
+    <property key="labeling/shapeFillColorR" value="255"/>
+    <property key="labeling/shapeJoinStyle" value="64"/>
+    <property key="labeling/shapeOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeOffsetUnits" value="1"/>
+    <property key="labeling/shapeOffsetX" value="0"/>
+    <property key="labeling/shapeOffsetY" value="0"/>
+    <property key="labeling/shapeRadiiMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeRadiiUnits" value="1"/>
+    <property key="labeling/shapeRadiiX" value="0"/>
+    <property key="labeling/shapeRadiiY" value="0"/>
+    <property key="labeling/shapeRotation" value="0"/>
+    <property key="labeling/shapeRotationType" value="0"/>
+    <property key="labeling/shapeSVGFile" value=""/>
+    <property key="labeling/shapeSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeSizeType" value="0"/>
+    <property key="labeling/shapeSizeUnits" value="1"/>
+    <property key="labeling/shapeSizeX" value="0"/>
+    <property key="labeling/shapeSizeY" value="0"/>
+    <property key="labeling/shapeTransparency" value="0"/>
+    <property key="labeling/shapeType" value="0"/>
+    <property key="labeling/substitutions" value="&lt;substitutions/>"/>
+    <property key="labeling/textColorA" value="255"/>
+    <property key="labeling/textColorB" value="0"/>
+    <property key="labeling/textColorG" value="0"/>
+    <property key="labeling/textColorR" value="0"/>
+    <property key="labeling/textTransp" value="0"/>
+    <property key="labeling/upsidedownLabels" value="0"/>
+    <property key="labeling/useSubstitutions" value="false"/>
+    <property key="labeling/wrapChar" value=""/>
+    <property key="labeling/xOffset" value="0"/>
+    <property key="labeling/xQuadOffset" value="0"/>
+    <property key="labeling/yOffset" value="0"/>
+    <property key="labeling/yQuadOffset" value="0"/>
+    <property key="labeling/zIndex" value="0"/>
+    <property key="variableNames"/>
+    <property key="variableValues"/>
+  </customproperties>
+  <blendMode>0</blendMode>
+  <featureBlendMode>0</featureBlendMode>
+  <layerTransparency>0</layerTransparency>
+  <displayfield>edge_id</displayfield>
+  <label>0</label>
+  <labelattributes>
+    <label fieldname="" text="Label"/>
+    <family fieldname="" name="Sans"/>
+    <size fieldname="" units="pt" value="12"/>
+    <bold fieldname="" on="0"/>
+    <italic fieldname="" on="0"/>
+    <underline fieldname="" on="0"/>
+    <strikeout fieldname="" on="0"/>
+    <color fieldname="" red="0" blue="0" green="0"/>
+    <x fieldname=""/>
+    <y fieldname=""/>
+    <offset x="0" y="0" units="pt" yfieldname="" xfieldname=""/>
+    <angle fieldname="" value="0" auto="0"/>
+    <alignment fieldname="" value="center"/>
+    <buffercolor fieldname="" red="255" blue="255" green="255"/>
+    <buffersize fieldname="" units="pt" value="1"/>
+    <bufferenabled fieldname="" on=""/>
+    <multilineenabled fieldname="" on=""/>
+    <selectedonly on=""/>
+  </labelattributes>
+  <SingleCategoryDiagramRenderer diagramType="Histogram" sizeLegend="0" attributeLegend="1">
+    <DiagramCategory penColor="#000000" labelPlacementMethod="XHeight" penWidth="0" diagramOrientation="Up" sizeScale="0,0,0,0,0,0" minimumSize="0" barWidth="5" penAlpha="255" maxScaleDenominator="1e+08" backgroundColor="#ffffff" transparency="0" width="15" scaleDependency="Area" backgroundAlpha="255" angleOffset="1440" scaleBasedVisibility="0" enabled="0" height="15" lineSizeScale="0,0,0,0,0,0" sizeType="MM" lineSizeType="MM" minScaleDenominator="inf">
+      <fontProperties description="Ubuntu,11,-1,5,50,0,0,0,0,0" style=""/>
+    </DiagramCategory>
+    <symbol alpha="1" clip_to_extent="1" type="marker" name="sizeSymbol">
+      <layer pass="0" class="SimpleMarker" locked="0">
+        <prop k="angle" v="0"/>
+        <prop k="color" v="255,0,0,255"/>
+        <prop k="horizontal_anchor_point" v="1"/>
+        <prop k="joinstyle" v="bevel"/>
+        <prop k="name" v="circle"/>
+        <prop k="offset" v="0,0"/>
+        <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="offset_unit" v="MM"/>
+        <prop k="outline_color" v="0,0,0,255"/>
+        <prop k="outline_style" v="solid"/>
+        <prop k="outline_width" v="0"/>
+        <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="outline_width_unit" v="MM"/>
+        <prop k="scale_method" v="diameter"/>
+        <prop k="size" v="2"/>
+        <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="size_unit" v="MM"/>
+        <prop k="vertical_anchor_point" v="1"/>
+      </layer>
+    </symbol>
+  </SingleCategoryDiagramRenderer>
+  <DiagramLayerSettings yPosColumn="-1" showColumn="-1" linePlacementFlags="10" placement="2" dist="0" xPosColumn="-1" priority="0" obstacle="0" zIndex="0" showAll="1"/>
+  <annotationform>.</annotationform>
+  <aliases>
+    <alias field="edge_id" index="0" name=""/>
+    <alias field="start_node" index="1" name=""/>
+    <alias field="end_node" index="2" name=""/>
+    <alias field="next_left_edge" index="3" name=""/>
+    <alias field="abs_next_left_edge" index="4" name=""/>
+    <alias field="next_right_edge" index="5" name=""/>
+    <alias field="abs_next_right_edge" index="6" name=""/>
+    <alias field="left_face" index="7" name=""/>
+    <alias field="right_face" index="8" name=""/>
+  </aliases>
+  <excludeAttributesWMS/>
+  <excludeAttributesWFS/>
+  <attributeactions default="-1"/>
+  <attributetableconfig actionWidgetStyle="dropDown" sortExpression="" sortOrder="0">
+    <columns>
+      <column width="-1" hidden="0" type="field" name="edge_id"/>
+      <column width="-1" hidden="0" type="field" name="start_node"/>
+      <column width="-1" hidden="0" type="field" name="end_node"/>
+      <column width="-1" hidden="0" type="field" name="next_left_edge"/>
+      <column width="-1" hidden="0" type="field" name="abs_next_left_edge"/>
+      <column width="-1" hidden="0" type="field" name="next_right_edge"/>
+      <column width="-1" hidden="0" type="field" name="abs_next_right_edge"/>
+      <column width="-1" hidden="0" type="field" name="left_face"/>
+      <column width="-1" hidden="0" type="field" name="right_face"/>
+      <column width="-1" hidden="1" type="actions"/>
+    </columns>
+  </attributetableconfig>
+  <editform>.</editform>
+  <editforminit/>
+  <editforminitcodesource>0</editforminitcodesource>
+  <editforminitfilepath></editforminitfilepath>
+  <editforminitcode><![CDATA[# -*- coding: utf-8 -*-
+"""
+QGIS forms can have a Python function that is called when the form is
+opened.
+
+Use this function to add extra logic to your forms.
+
+Enter the name of the function in the "Python Init function"
+field.
+An example follows:
+"""
+from qgis.PyQt.QtWidgets import QWidget
+
+def my_form_open(dialog, layer, feature):
+	geom = feature.geometry()
+	control = dialog.findChild(QWidget, "MyLineEdit")
+]]></editforminitcode>
+  <featformsuppress>0</featformsuppress>
+  <editorlayout>generatedlayout</editorlayout>
+  <widgets/>
+  <conditionalstyles>
+    <rowstyles/>
+    <fieldstyles/>
+  </conditionalstyles>
+  <defaults>
+    <default field="edge_id" expression=""/>
+    <default field="start_node" expression=""/>
+    <default field="end_node" expression=""/>
+    <default field="next_left_edge" expression=""/>
+    <default field="abs_next_left_edge" expression=""/>
+    <default field="next_right_edge" expression=""/>
+    <default field="abs_next_right_edge" expression=""/>
+    <default field="left_face" expression=""/>
+    <default field="right_face" expression=""/>
+  </defaults>
+  <previewExpression></previewExpression>
+  <layerGeometryType>1</layerGeometryType>
+</qgis>

+ 419 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/face.qml

@@ -0,0 +1,419 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="1" minLabelScale="1" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <edittypes>
+    <edittype widgetv2type="TextEdit" name="face_id">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+  </edittypes>
+  <renderer-v2 forceraster="0" symbollevels="0" type="RuleRenderer" enableorderby="0">
+    <rules key="{df5d9560-79e4-40c3-885e-901ca10adde1}">
+      <rule filter="face_id % 10 = 0" key="{7d81b349-2030-4050-8912-581b3a982fe2}" symbol="0"/>
+      <rule filter="face_id % 10 = 1" key="{2b163429-6828-41a2-acdd-fc98e059d99b}" symbol="1"/>
+      <rule filter="face_id % 10 = 2" key="{fdac76a1-4ac1-4a48-be62-72b0a3609a24}" symbol="2"/>
+      <rule filter="face_id % 10 = 3" key="{1f51f758-0602-456c-a60f-dfdfbbe1d258}" symbol="3"/>
+      <rule filter="face_id % 10 = 4" key="{21f82eb9-cc27-4fd9-83e3-725581a015c3}" symbol="4"/>
+      <rule filter="face_id % 10 = 5" key="{2da53dd9-fbfb-4834-9b4e-b07a8de0ca1e}" symbol="5"/>
+      <rule filter="face_id % 10 = 6" key="{f29e56c7-6488-4635-82c0-7ddfb9444637}" symbol="6"/>
+      <rule filter="face_id % 10 = 7" key="{eaba3b91-4833-4e06-b32b-e0b8a75d6fcb}" symbol="7"/>
+      <rule filter="face_id % 10 = 8" key="{7e21dc8a-137d-48a2-a4f6-b5cfc6646542}" symbol="8"/>
+      <rule filter="face_id % 10 = 9" key="{af7a0e20-58b2-4a83-b74f-702701fc44fc}" symbol="9"/>
+    </rules>
+    <symbols>
+      <symbol alpha="1" clip_to_extent="1" type="fill" name="0">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="191,122,200,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="fill" name="1">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="200,122,122,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="fill" name="2">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="122,200,122,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="fill" name="3">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="122,181,200,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="fill" name="4">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="122,136,200,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="fill" name="5">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="199,200,192,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="fill" name="6">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="122,200,156,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="fill" name="7">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="200,177,122,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="fill" name="8">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="200,122,166,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+      <symbol alpha="1" clip_to_extent="1" type="fill" name="9">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="188,200,122,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+    </symbols>
+  </renderer-v2>
+  <labeling type="simple"/>
+  <customproperties>
+    <property key="embeddedWidgets/count" value="0"/>
+    <property key="labeling" value="pal"/>
+    <property key="labeling/addDirectionSymbol" value="false"/>
+    <property key="labeling/angleOffset" value="0"/>
+    <property key="labeling/blendMode" value="0"/>
+    <property key="labeling/bufferBlendMode" value="0"/>
+    <property key="labeling/bufferColorA" value="255"/>
+    <property key="labeling/bufferColorB" value="255"/>
+    <property key="labeling/bufferColorG" value="255"/>
+    <property key="labeling/bufferColorR" value="255"/>
+    <property key="labeling/bufferDraw" value="true"/>
+    <property key="labeling/bufferJoinStyle" value="64"/>
+    <property key="labeling/bufferNoFill" value="false"/>
+    <property key="labeling/bufferSize" value="1"/>
+    <property key="labeling/bufferSizeInMapUnits" value="false"/>
+    <property key="labeling/bufferSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/bufferTransp" value="0"/>
+    <property key="labeling/centroidInside" value="false"/>
+    <property key="labeling/centroidWhole" value="false"/>
+    <property key="labeling/decimals" value="0"/>
+    <property key="labeling/displayAll" value="false"/>
+    <property key="labeling/dist" value="0"/>
+    <property key="labeling/distInMapUnits" value="false"/>
+    <property key="labeling/distMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/drawLabels" value="false"/>
+    <property key="labeling/enabled" value="false"/>
+    <property key="labeling/fieldName" value="face_id"/>
+    <property key="labeling/fitInPolygonOnly" value="false"/>
+    <property key="labeling/fontCapitals" value="0"/>
+    <property key="labeling/fontFamily" value="DejaVu Sans"/>
+    <property key="labeling/fontItalic" value="false"/>
+    <property key="labeling/fontLetterSpacing" value="0"/>
+    <property key="labeling/fontLimitPixelSize" value="false"/>
+    <property key="labeling/fontMaxPixelSize" value="10000"/>
+    <property key="labeling/fontMinPixelSize" value="3"/>
+    <property key="labeling/fontSize" value="8"/>
+    <property key="labeling/fontSizeInMapUnits" value="false"/>
+    <property key="labeling/fontSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/fontStrikeout" value="false"/>
+    <property key="labeling/fontUnderline" value="false"/>
+    <property key="labeling/fontWeight" value="50"/>
+    <property key="labeling/fontWordSpacing" value="0"/>
+    <property key="labeling/formatNumbers" value="false"/>
+    <property key="labeling/isExpression" value="false"/>
+    <property key="labeling/labelOffsetInMapUnits" value="true"/>
+    <property key="labeling/labelOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/labelPerPart" value="false"/>
+    <property key="labeling/leftDirectionSymbol" value="&lt;"/>
+    <property key="labeling/limitNumLabels" value="false"/>
+    <property key="labeling/maxCurvedCharAngleIn" value="25"/>
+    <property key="labeling/maxCurvedCharAngleOut" value="-25"/>
+    <property key="labeling/maxNumLabels" value="2000"/>
+    <property key="labeling/mergeLines" value="false"/>
+    <property key="labeling/minFeatureSize" value="0"/>
+    <property key="labeling/multilineAlign" value="0"/>
+    <property key="labeling/multilineHeight" value="1"/>
+    <property key="labeling/namedStyle" value="Condensed"/>
+    <property key="labeling/obstacle" value="true"/>
+    <property key="labeling/obstacleFactor" value="1"/>
+    <property key="labeling/obstacleType" value="0"/>
+    <property key="labeling/offsetType" value="0"/>
+    <property key="labeling/placeDirectionSymbol" value="0"/>
+    <property key="labeling/placement" value="0"/>
+    <property key="labeling/placementFlags" value="0"/>
+    <property key="labeling/plussign" value="true"/>
+    <property key="labeling/predefinedPositionOrder" value="TR,TL,BR,BL,R,L,TSR,BSR"/>
+    <property key="labeling/preserveRotation" value="true"/>
+    <property key="labeling/previewBkgrdColor" value="#ffffff"/>
+    <property key="labeling/priority" value="5"/>
+    <property key="labeling/quadOffset" value="4"/>
+    <property key="labeling/repeatDistance" value="0"/>
+    <property key="labeling/repeatDistanceMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/repeatDistanceUnit" value="1"/>
+    <property key="labeling/reverseDirectionSymbol" value="false"/>
+    <property key="labeling/rightDirectionSymbol" value=">"/>
+    <property key="labeling/scaleMax" value="10000000"/>
+    <property key="labeling/scaleMin" value="1"/>
+    <property key="labeling/scaleVisibility" value="false"/>
+    <property key="labeling/shadowBlendMode" value="6"/>
+    <property key="labeling/shadowColorB" value="0"/>
+    <property key="labeling/shadowColorG" value="0"/>
+    <property key="labeling/shadowColorR" value="0"/>
+    <property key="labeling/shadowDraw" value="false"/>
+    <property key="labeling/shadowOffsetAngle" value="135"/>
+    <property key="labeling/shadowOffsetDist" value="1"/>
+    <property key="labeling/shadowOffsetGlobal" value="true"/>
+    <property key="labeling/shadowOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowOffsetUnits" value="1"/>
+    <property key="labeling/shadowRadius" value="1.5"/>
+    <property key="labeling/shadowRadiusAlphaOnly" value="false"/>
+    <property key="labeling/shadowRadiusMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowRadiusUnits" value="1"/>
+    <property key="labeling/shadowScale" value="100"/>
+    <property key="labeling/shadowTransparency" value="30"/>
+    <property key="labeling/shadowUnder" value="0"/>
+    <property key="labeling/shapeBlendMode" value="0"/>
+    <property key="labeling/shapeBorderColorA" value="255"/>
+    <property key="labeling/shapeBorderColorB" value="128"/>
+    <property key="labeling/shapeBorderColorG" value="128"/>
+    <property key="labeling/shapeBorderColorR" value="128"/>
+    <property key="labeling/shapeBorderWidth" value="0"/>
+    <property key="labeling/shapeBorderWidthMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeBorderWidthUnits" value="1"/>
+    <property key="labeling/shapeDraw" value="false"/>
+    <property key="labeling/shapeFillColorA" value="255"/>
+    <property key="labeling/shapeFillColorB" value="255"/>
+    <property key="labeling/shapeFillColorG" value="255"/>
+    <property key="labeling/shapeFillColorR" value="255"/>
+    <property key="labeling/shapeJoinStyle" value="64"/>
+    <property key="labeling/shapeOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeOffsetUnits" value="1"/>
+    <property key="labeling/shapeOffsetX" value="0"/>
+    <property key="labeling/shapeOffsetY" value="0"/>
+    <property key="labeling/shapeRadiiMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeRadiiUnits" value="1"/>
+    <property key="labeling/shapeRadiiX" value="0"/>
+    <property key="labeling/shapeRadiiY" value="0"/>
+    <property key="labeling/shapeRotation" value="0"/>
+    <property key="labeling/shapeRotationType" value="0"/>
+    <property key="labeling/shapeSVGFile" value=""/>
+    <property key="labeling/shapeSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeSizeType" value="0"/>
+    <property key="labeling/shapeSizeUnits" value="1"/>
+    <property key="labeling/shapeSizeX" value="0"/>
+    <property key="labeling/shapeSizeY" value="0"/>
+    <property key="labeling/shapeTransparency" value="0"/>
+    <property key="labeling/shapeType" value="0"/>
+    <property key="labeling/substitutions" value="&lt;substitutions/>"/>
+    <property key="labeling/textColorA" value="255"/>
+    <property key="labeling/textColorB" value="0"/>
+    <property key="labeling/textColorG" value="0"/>
+    <property key="labeling/textColorR" value="170"/>
+    <property key="labeling/textTransp" value="0"/>
+    <property key="labeling/upsidedownLabels" value="0"/>
+    <property key="labeling/useSubstitutions" value="false"/>
+    <property key="labeling/wrapChar" value=""/>
+    <property key="labeling/xOffset" value="0"/>
+    <property key="labeling/xQuadOffset" value="0"/>
+    <property key="labeling/yOffset" value="0"/>
+    <property key="labeling/yQuadOffset" value="0"/>
+    <property key="labeling/zIndex" value="0"/>
+    <property key="variableNames"/>
+    <property key="variableValues"/>
+  </customproperties>
+  <blendMode>0</blendMode>
+  <featureBlendMode>0</featureBlendMode>
+  <layerTransparency>0</layerTransparency>
+  <displayfield>face_id</displayfield>
+  <label>0</label>
+  <labelattributes>
+    <label fieldname="" text="Label"/>
+    <family fieldname="" name="DejaVu Sans"/>
+    <size fieldname="" units="pt" value="12"/>
+    <bold fieldname="" on="0"/>
+    <italic fieldname="" on="0"/>
+    <underline fieldname="" on="0"/>
+    <strikeout fieldname="" on="0"/>
+    <color fieldname="" red="0" blue="0" green="0"/>
+    <x fieldname=""/>
+    <y fieldname=""/>
+    <offset x="0" y="0" units="pt" yfieldname="" xfieldname=""/>
+    <angle fieldname="" value="0" auto="0"/>
+    <alignment fieldname="" value="center"/>
+    <buffercolor fieldname="" red="255" blue="255" green="255"/>
+    <buffersize fieldname="" units="pt" value="1"/>
+    <bufferenabled fieldname="" on=""/>
+    <multilineenabled fieldname="" on=""/>
+    <selectedonly on=""/>
+  </labelattributes>
+  <SingleCategoryDiagramRenderer diagramType="Histogram" sizeLegend="0" attributeLegend="1">
+    <DiagramCategory penColor="#000000" labelPlacementMethod="XHeight" penWidth="0" diagramOrientation="Up" sizeScale="0,0,0,0,0,0" minimumSize="0" barWidth="5" penAlpha="255" maxScaleDenominator="1e+08" backgroundColor="#ffffff" transparency="0" width="15" scaleDependency="Area" backgroundAlpha="255" angleOffset="1440" scaleBasedVisibility="0" enabled="0" height="15" lineSizeScale="0,0,0,0,0,0" sizeType="MM" lineSizeType="MM" minScaleDenominator="inf">
+      <fontProperties description="Ubuntu,11,-1,5,50,0,0,0,0,0" style=""/>
+    </DiagramCategory>
+    <symbol alpha="1" clip_to_extent="1" type="marker" name="sizeSymbol">
+      <layer pass="0" class="SimpleMarker" locked="0">
+        <prop k="angle" v="0"/>
+        <prop k="color" v="255,0,0,255"/>
+        <prop k="horizontal_anchor_point" v="1"/>
+        <prop k="joinstyle" v="bevel"/>
+        <prop k="name" v="circle"/>
+        <prop k="offset" v="0,0"/>
+        <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="offset_unit" v="MM"/>
+        <prop k="outline_color" v="0,0,0,255"/>
+        <prop k="outline_style" v="solid"/>
+        <prop k="outline_width" v="0"/>
+        <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="outline_width_unit" v="MM"/>
+        <prop k="scale_method" v="diameter"/>
+        <prop k="size" v="2"/>
+        <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="size_unit" v="MM"/>
+        <prop k="vertical_anchor_point" v="1"/>
+      </layer>
+    </symbol>
+  </SingleCategoryDiagramRenderer>
+  <DiagramLayerSettings yPosColumn="-1" showColumn="-1" linePlacementFlags="10" placement="0" dist="0" xPosColumn="-1" priority="0" obstacle="0" zIndex="0" showAll="1"/>
+  <annotationform></annotationform>
+  <aliases>
+    <alias field="face_id" index="0" name=""/>
+  </aliases>
+  <excludeAttributesWMS/>
+  <excludeAttributesWFS/>
+  <attributeactions default="-1"/>
+  <attributetableconfig actionWidgetStyle="dropDown" sortExpression="" sortOrder="0">
+    <columns>
+      <column width="-1" hidden="0" type="field" name="face_id"/>
+      <column width="-1" hidden="1" type="actions"/>
+    </columns>
+  </attributetableconfig>
+  <editform></editform>
+  <editforminit/>
+  <editforminitcodesource>0</editforminitcodesource>
+  <editforminitfilepath></editforminitfilepath>
+  <editforminitcode><![CDATA[# -*- coding: utf-8 -*-
+"""
+QGIS forms can have a Python function that is called when the form is
+opened.
+
+Use this function to add extra logic to your forms.
+
+Enter the name of the function in the "Python Init function"
+field.
+An example follows:
+"""
+from qgis.PyQt.QtWidgets import QWidget
+
+def my_form_open(dialog, layer, feature):
+	geom = feature.geometry()
+	control = dialog.findChild(QWidget, "MyLineEdit")
+]]></editforminitcode>
+  <featformsuppress>0</featformsuppress>
+  <editorlayout>generatedlayout</editorlayout>
+  <widgets/>
+  <conditionalstyles>
+    <rowstyles/>
+    <fieldstyles/>
+  </conditionalstyles>
+  <defaults>
+    <default field="face_id" expression=""/>
+  </defaults>
+  <previewExpression></previewExpression>
+  <layerGeometryType>2</layerGeometryType>
+</qgis>

+ 328 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/face_left.qml

@@ -0,0 +1,328 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="1" minLabelScale="0" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <edittypes>
+    <edittype widgetv2type="TextEdit" name="edge_id">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="start_node">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="end_node">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="next_left_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="abs_next_left_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="next_right_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="abs_next_right_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="left_face">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="right_face">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+  </edittypes>
+  <renderer-v2 forceraster="0" symbollevels="0" type="singleSymbol" enableorderby="0">
+    <symbols>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="0">
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="54,31,10,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.26"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+    </symbols>
+    <rotation/>
+    <sizescale scalemethod="diameter"/>
+  </renderer-v2>
+  <labeling type="simple"/>
+  <customproperties>
+    <property key="embeddedWidgets/count" value="0"/>
+    <property key="labeling" value="pal"/>
+    <property key="labeling/addDirectionSymbol" value="false"/>
+    <property key="labeling/angleOffset" value="0"/>
+    <property key="labeling/blendMode" value="0"/>
+    <property key="labeling/bufferBlendMode" value="0"/>
+    <property key="labeling/bufferColorA" value="255"/>
+    <property key="labeling/bufferColorB" value="255"/>
+    <property key="labeling/bufferColorG" value="255"/>
+    <property key="labeling/bufferColorR" value="255"/>
+    <property key="labeling/bufferDraw" value="true"/>
+    <property key="labeling/bufferJoinStyle" value="64"/>
+    <property key="labeling/bufferNoFill" value="false"/>
+    <property key="labeling/bufferSize" value="1"/>
+    <property key="labeling/bufferSizeInMapUnits" value="false"/>
+    <property key="labeling/bufferSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/bufferTransp" value="0"/>
+    <property key="labeling/centroidInside" value="false"/>
+    <property key="labeling/centroidWhole" value="false"/>
+    <property key="labeling/decimals" value="0"/>
+    <property key="labeling/displayAll" value="false"/>
+    <property key="labeling/dist" value="7"/>
+    <property key="labeling/distInMapUnits" value="false"/>
+    <property key="labeling/distMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/drawLabels" value="true"/>
+    <property key="labeling/enabled" value="true"/>
+    <property key="labeling/fieldName" value="left_face"/>
+    <property key="labeling/fitInPolygonOnly" value="false"/>
+    <property key="labeling/fontBold" value="false"/>
+    <property key="labeling/fontCapitals" value="0"/>
+    <property key="labeling/fontFamily" value="Sans"/>
+    <property key="labeling/fontItalic" value="false"/>
+    <property key="labeling/fontLetterSpacing" value="0"/>
+    <property key="labeling/fontLimitPixelSize" value="false"/>
+    <property key="labeling/fontMaxPixelSize" value="10000"/>
+    <property key="labeling/fontMinPixelSize" value="3"/>
+    <property key="labeling/fontSize" value="7"/>
+    <property key="labeling/fontSizeInMapUnits" value="false"/>
+    <property key="labeling/fontSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/fontStrikeout" value="false"/>
+    <property key="labeling/fontUnderline" value="false"/>
+    <property key="labeling/fontWeight" value="50"/>
+    <property key="labeling/fontWordSpacing" value="0"/>
+    <property key="labeling/formatNumbers" value="false"/>
+    <property key="labeling/isExpression" value="false"/>
+    <property key="labeling/labelOffsetInMapUnits" value="true"/>
+    <property key="labeling/labelOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/labelPerPart" value="false"/>
+    <property key="labeling/leftDirectionSymbol" value="&lt;"/>
+    <property key="labeling/limitNumLabels" value="false"/>
+    <property key="labeling/maxCurvedCharAngleIn" value="20"/>
+    <property key="labeling/maxCurvedCharAngleOut" value="-20"/>
+    <property key="labeling/maxNumLabels" value="2000"/>
+    <property key="labeling/mergeLines" value="false"/>
+    <property key="labeling/minFeatureSize" value="0"/>
+    <property key="labeling/multiLineLabels" value="false"/>
+    <property key="labeling/multilineAlign" value="0"/>
+    <property key="labeling/multilineHeight" value="1"/>
+    <property key="labeling/namedStyle" value=""/>
+    <property key="labeling/obstacle" value="true"/>
+    <property key="labeling/obstacleFactor" value="1"/>
+    <property key="labeling/obstacleType" value="0"/>
+    <property key="labeling/offsetType" value="0"/>
+    <property key="labeling/placeDirectionSymbol" value="0"/>
+    <property key="labeling/placement" value="2"/>
+    <property key="labeling/placementFlags" value="2"/>
+    <property key="labeling/plussign" value="false"/>
+    <property key="labeling/predefinedPositionOrder" value="TR,TL,BR,BL,R,L,TSR,BSR"/>
+    <property key="labeling/preserveRotation" value="true"/>
+    <property key="labeling/previewBkgrdColor" value="#ffffff"/>
+    <property key="labeling/priority" value="3"/>
+    <property key="labeling/quadOffset" value="4"/>
+    <property key="labeling/repeatDistance" value="0"/>
+    <property key="labeling/repeatDistanceMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/repeatDistanceUnit" value="1"/>
+    <property key="labeling/reverseDirectionSymbol" value="false"/>
+    <property key="labeling/rightDirectionSymbol" value=">"/>
+    <property key="labeling/scaleMax" value="10000000"/>
+    <property key="labeling/scaleMin" value="1"/>
+    <property key="labeling/scaleVisibility" value="false"/>
+    <property key="labeling/shadowBlendMode" value="6"/>
+    <property key="labeling/shadowColorB" value="0"/>
+    <property key="labeling/shadowColorG" value="0"/>
+    <property key="labeling/shadowColorR" value="0"/>
+    <property key="labeling/shadowDraw" value="false"/>
+    <property key="labeling/shadowOffsetAngle" value="135"/>
+    <property key="labeling/shadowOffsetDist" value="1"/>
+    <property key="labeling/shadowOffsetGlobal" value="true"/>
+    <property key="labeling/shadowOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowOffsetUnits" value="1"/>
+    <property key="labeling/shadowRadius" value="1.5"/>
+    <property key="labeling/shadowRadiusAlphaOnly" value="false"/>
+    <property key="labeling/shadowRadiusMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowRadiusUnits" value="1"/>
+    <property key="labeling/shadowScale" value="100"/>
+    <property key="labeling/shadowTransparency" value="30"/>
+    <property key="labeling/shadowUnder" value="0"/>
+    <property key="labeling/shapeBlendMode" value="0"/>
+    <property key="labeling/shapeBorderColorA" value="255"/>
+    <property key="labeling/shapeBorderColorB" value="128"/>
+    <property key="labeling/shapeBorderColorG" value="128"/>
+    <property key="labeling/shapeBorderColorR" value="128"/>
+    <property key="labeling/shapeBorderWidth" value="0"/>
+    <property key="labeling/shapeBorderWidthMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeBorderWidthUnits" value="1"/>
+    <property key="labeling/shapeDraw" value="false"/>
+    <property key="labeling/shapeFillColorA" value="255"/>
+    <property key="labeling/shapeFillColorB" value="255"/>
+    <property key="labeling/shapeFillColorG" value="255"/>
+    <property key="labeling/shapeFillColorR" value="255"/>
+    <property key="labeling/shapeJoinStyle" value="64"/>
+    <property key="labeling/shapeOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeOffsetUnits" value="1"/>
+    <property key="labeling/shapeOffsetX" value="0"/>
+    <property key="labeling/shapeOffsetY" value="0"/>
+    <property key="labeling/shapeRadiiMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeRadiiUnits" value="1"/>
+    <property key="labeling/shapeRadiiX" value="0"/>
+    <property key="labeling/shapeRadiiY" value="0"/>
+    <property key="labeling/shapeRotation" value="0"/>
+    <property key="labeling/shapeRotationType" value="0"/>
+    <property key="labeling/shapeSVGFile" value=""/>
+    <property key="labeling/shapeSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeSizeType" value="0"/>
+    <property key="labeling/shapeSizeUnits" value="1"/>
+    <property key="labeling/shapeSizeX" value="0"/>
+    <property key="labeling/shapeSizeY" value="0"/>
+    <property key="labeling/shapeTransparency" value="0"/>
+    <property key="labeling/shapeType" value="0"/>
+    <property key="labeling/substitutions" value="&lt;substitutions/>"/>
+    <property key="labeling/textColorA" value="255"/>
+    <property key="labeling/textColorB" value="0"/>
+    <property key="labeling/textColorG" value="170"/>
+    <property key="labeling/textColorR" value="0"/>
+    <property key="labeling/textTransp" value="0"/>
+    <property key="labeling/upsidedownLabels" value="0"/>
+    <property key="labeling/useSubstitutions" value="false"/>
+    <property key="labeling/wrapChar" value=""/>
+    <property key="labeling/xOffset" value="0"/>
+    <property key="labeling/xQuadOffset" value="0"/>
+    <property key="labeling/yOffset" value="0"/>
+    <property key="labeling/yQuadOffset" value="0"/>
+    <property key="labeling/zIndex" value="0"/>
+    <property key="variableNames"/>
+    <property key="variableValues"/>
+  </customproperties>
+  <blendMode>0</blendMode>
+  <featureBlendMode>0</featureBlendMode>
+  <layerTransparency>100</layerTransparency>
+  <displayfield>edge_id</displayfield>
+  <label>0</label>
+  <labelattributes>
+    <label fieldname="" text="Label"/>
+    <family fieldname="" name="Sans"/>
+    <size fieldname="" units="pt" value="12"/>
+    <bold fieldname="" on="0"/>
+    <italic fieldname="" on="0"/>
+    <underline fieldname="" on="0"/>
+    <strikeout fieldname="" on="0"/>
+    <color fieldname="" red="0" blue="0" green="0"/>
+    <x fieldname=""/>
+    <y fieldname=""/>
+    <offset x="0" y="0" units="pt" yfieldname="" xfieldname=""/>
+    <angle fieldname="" value="0" auto="0"/>
+    <alignment fieldname="" value="center"/>
+    <buffercolor fieldname="" red="255" blue="255" green="255"/>
+    <buffersize fieldname="" units="pt" value="1"/>
+    <bufferenabled fieldname="" on=""/>
+    <multilineenabled fieldname="" on=""/>
+    <selectedonly on=""/>
+  </labelattributes>
+  <SingleCategoryDiagramRenderer diagramType="Histogram" sizeLegend="0" attributeLegend="1">
+    <DiagramCategory penColor="#000000" labelPlacementMethod="XHeight" penWidth="0" diagramOrientation="Up" sizeScale="0,0,0,0,0,0" minimumSize="0" barWidth="5" penAlpha="255" maxScaleDenominator="1e+08" backgroundColor="#ffffff" transparency="0" width="15" scaleDependency="Area" backgroundAlpha="255" angleOffset="1440" scaleBasedVisibility="0" enabled="0" height="15" lineSizeScale="0,0,0,0,0,0" sizeType="MM" lineSizeType="MM" minScaleDenominator="inf">
+      <fontProperties description="Ubuntu,11,-1,5,50,0,0,0,0,0" style=""/>
+    </DiagramCategory>
+    <symbol alpha="1" clip_to_extent="1" type="marker" name="sizeSymbol">
+      <layer pass="0" class="SimpleMarker" locked="0">
+        <prop k="angle" v="0"/>
+        <prop k="color" v="255,0,0,255"/>
+        <prop k="horizontal_anchor_point" v="1"/>
+        <prop k="joinstyle" v="bevel"/>
+        <prop k="name" v="circle"/>
+        <prop k="offset" v="0,0"/>
+        <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="offset_unit" v="MM"/>
+        <prop k="outline_color" v="0,0,0,255"/>
+        <prop k="outline_style" v="solid"/>
+        <prop k="outline_width" v="0"/>
+        <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="outline_width_unit" v="MM"/>
+        <prop k="scale_method" v="diameter"/>
+        <prop k="size" v="2"/>
+        <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="size_unit" v="MM"/>
+        <prop k="vertical_anchor_point" v="1"/>
+      </layer>
+    </symbol>
+  </SingleCategoryDiagramRenderer>
+  <DiagramLayerSettings yPosColumn="-1" showColumn="-1" linePlacementFlags="10" placement="2" dist="0" xPosColumn="-1" priority="0" obstacle="0" zIndex="0" showAll="1"/>
+  <annotationform>../../../../../../../../../src/qgis/plugins/db_manager/db_manager/db_plugins/postgis/plugins/qgis_topoview</annotationform>
+  <aliases>
+    <alias field="edge_id" index="0" name=""/>
+    <alias field="start_node" index="1" name=""/>
+    <alias field="end_node" index="2" name=""/>
+    <alias field="next_left_edge" index="3" name=""/>
+    <alias field="abs_next_left_edge" index="4" name=""/>
+    <alias field="next_right_edge" index="5" name=""/>
+    <alias field="abs_next_right_edge" index="6" name=""/>
+    <alias field="left_face" index="7" name=""/>
+    <alias field="right_face" index="8" name=""/>
+  </aliases>
+  <excludeAttributesWMS/>
+  <excludeAttributesWFS/>
+  <attributeactions default="-1"/>
+  <attributetableconfig actionWidgetStyle="dropDown" sortExpression="" sortOrder="0">
+    <columns>
+      <column width="-1" hidden="0" type="field" name="edge_id"/>
+      <column width="-1" hidden="0" type="field" name="start_node"/>
+      <column width="-1" hidden="0" type="field" name="end_node"/>
+      <column width="-1" hidden="0" type="field" name="next_left_edge"/>
+      <column width="-1" hidden="0" type="field" name="abs_next_left_edge"/>
+      <column width="-1" hidden="0" type="field" name="next_right_edge"/>
+      <column width="-1" hidden="0" type="field" name="abs_next_right_edge"/>
+      <column width="-1" hidden="0" type="field" name="left_face"/>
+      <column width="-1" hidden="0" type="field" name="right_face"/>
+      <column width="-1" hidden="1" type="actions"/>
+    </columns>
+  </attributetableconfig>
+  <editform>../../../../../../../../../src/qgis/plugins/db_manager/db_manager/db_plugins/postgis/plugins/qgis_topoview</editform>
+  <editforminit/>
+  <editforminitcodesource>0</editforminitcodesource>
+  <editforminitfilepath></editforminitfilepath>
+  <editforminitcode><![CDATA[# -*- coding: utf-8 -*-
+"""
+QGIS forms can have a Python function that is called when the form is
+opened.
+
+Use this function to add extra logic to your forms.
+
+Enter the name of the function in the "Python Init function"
+field.
+An example follows:
+"""
+from qgis.PyQt.QtWidgets import QWidget
+
+def my_form_open(dialog, layer, feature):
+	geom = feature.geometry()
+	control = dialog.findChild(QWidget, "MyLineEdit")
+]]></editforminitcode>
+  <featformsuppress>0</featformsuppress>
+  <editorlayout>generatedlayout</editorlayout>
+  <widgets/>
+  <conditionalstyles>
+    <rowstyles/>
+    <fieldstyles/>
+  </conditionalstyles>
+  <defaults>
+    <default field="edge_id" expression=""/>
+    <default field="start_node" expression=""/>
+    <default field="end_node" expression=""/>
+    <default field="next_left_edge" expression=""/>
+    <default field="abs_next_left_edge" expression=""/>
+    <default field="next_right_edge" expression=""/>
+    <default field="abs_next_right_edge" expression=""/>
+    <default field="left_face" expression=""/>
+    <default field="right_face" expression=""/>
+  </defaults>
+  <previewExpression></previewExpression>
+  <layerGeometryType>1</layerGeometryType>
+</qgis>

+ 275 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/face_mbr.qml

@@ -0,0 +1,275 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="1" minLabelScale="1" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <edittypes>
+    <edittype widgetv2type="TextEdit" name="face_id">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+  </edittypes>
+  <renderer-v2 forceraster="0" symbollevels="0" type="singleSymbol" enableorderby="0">
+    <symbols>
+      <symbol alpha="0.396078" clip_to_extent="1" type="fill" name="0">
+        <layer pass="0" class="SimpleFill" locked="0">
+          <prop k="border_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="color" v="170,170,170,255"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0.26"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="style" v="solid"/>
+        </layer>
+      </symbol>
+    </symbols>
+    <rotation/>
+    <sizescale scalemethod="diameter"/>
+  </renderer-v2>
+  <labeling type="simple"/>
+  <customproperties>
+    <property key="embeddedWidgets/count" value="0"/>
+    <property key="labeling" value="pal"/>
+    <property key="labeling/addDirectionSymbol" value="false"/>
+    <property key="labeling/angleOffset" value="0"/>
+    <property key="labeling/blendMode" value="0"/>
+    <property key="labeling/bufferBlendMode" value="0"/>
+    <property key="labeling/bufferColorA" value="255"/>
+    <property key="labeling/bufferColorB" value="255"/>
+    <property key="labeling/bufferColorG" value="255"/>
+    <property key="labeling/bufferColorR" value="255"/>
+    <property key="labeling/bufferDraw" value="true"/>
+    <property key="labeling/bufferJoinStyle" value="64"/>
+    <property key="labeling/bufferNoFill" value="false"/>
+    <property key="labeling/bufferSize" value="1"/>
+    <property key="labeling/bufferSizeInMapUnits" value="false"/>
+    <property key="labeling/bufferSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/bufferTransp" value="0"/>
+    <property key="labeling/centroidInside" value="false"/>
+    <property key="labeling/centroidWhole" value="false"/>
+    <property key="labeling/decimals" value="0"/>
+    <property key="labeling/displayAll" value="false"/>
+    <property key="labeling/dist" value="0"/>
+    <property key="labeling/distInMapUnits" value="false"/>
+    <property key="labeling/distMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/drawLabels" value="false"/>
+    <property key="labeling/enabled" value="false"/>
+    <property key="labeling/fieldName" value="face_id"/>
+    <property key="labeling/fitInPolygonOnly" value="false"/>
+    <property key="labeling/fontBold" value="false"/>
+    <property key="labeling/fontCapitals" value="0"/>
+    <property key="labeling/fontFamily" value="DejaVu Sans"/>
+    <property key="labeling/fontItalic" value="false"/>
+    <property key="labeling/fontLetterSpacing" value="0"/>
+    <property key="labeling/fontLimitPixelSize" value="false"/>
+    <property key="labeling/fontMaxPixelSize" value="10000"/>
+    <property key="labeling/fontMinPixelSize" value="3"/>
+    <property key="labeling/fontSize" value="8"/>
+    <property key="labeling/fontSizeInMapUnits" value="false"/>
+    <property key="labeling/fontSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/fontStrikeout" value="false"/>
+    <property key="labeling/fontUnderline" value="false"/>
+    <property key="labeling/fontWeight" value="50"/>
+    <property key="labeling/fontWordSpacing" value="0"/>
+    <property key="labeling/formatNumbers" value="false"/>
+    <property key="labeling/isExpression" value="false"/>
+    <property key="labeling/labelOffsetInMapUnits" value="true"/>
+    <property key="labeling/labelOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/labelPerPart" value="false"/>
+    <property key="labeling/leftDirectionSymbol" value="&lt;"/>
+    <property key="labeling/limitNumLabels" value="false"/>
+    <property key="labeling/maxCurvedCharAngleIn" value="20"/>
+    <property key="labeling/maxCurvedCharAngleOut" value="-20"/>
+    <property key="labeling/maxNumLabels" value="2000"/>
+    <property key="labeling/mergeLines" value="false"/>
+    <property key="labeling/minFeatureSize" value="0"/>
+    <property key="labeling/multilineAlign" value="0"/>
+    <property key="labeling/multilineHeight" value="1"/>
+    <property key="labeling/namedStyle" value="Condensed"/>
+    <property key="labeling/obstacle" value="true"/>
+    <property key="labeling/obstacleFactor" value="1"/>
+    <property key="labeling/obstacleType" value="0"/>
+    <property key="labeling/offsetType" value="0"/>
+    <property key="labeling/placeDirectionSymbol" value="0"/>
+    <property key="labeling/placement" value="0"/>
+    <property key="labeling/placementFlags" value="0"/>
+    <property key="labeling/plussign" value="false"/>
+    <property key="labeling/predefinedPositionOrder" value="TR,TL,BR,BL,R,L,TSR,BSR"/>
+    <property key="labeling/preserveRotation" value="true"/>
+    <property key="labeling/previewBkgrdColor" value="#ffffff"/>
+    <property key="labeling/priority" value="5"/>
+    <property key="labeling/quadOffset" value="4"/>
+    <property key="labeling/repeatDistance" value="0"/>
+    <property key="labeling/repeatDistanceMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/repeatDistanceUnit" value="1"/>
+    <property key="labeling/reverseDirectionSymbol" value="false"/>
+    <property key="labeling/rightDirectionSymbol" value=">"/>
+    <property key="labeling/scaleMax" value="10000000"/>
+    <property key="labeling/scaleMin" value="1"/>
+    <property key="labeling/scaleVisibility" value="false"/>
+    <property key="labeling/shadowBlendMode" value="6"/>
+    <property key="labeling/shadowColorB" value="0"/>
+    <property key="labeling/shadowColorG" value="0"/>
+    <property key="labeling/shadowColorR" value="0"/>
+    <property key="labeling/shadowDraw" value="false"/>
+    <property key="labeling/shadowOffsetAngle" value="135"/>
+    <property key="labeling/shadowOffsetDist" value="1"/>
+    <property key="labeling/shadowOffsetGlobal" value="true"/>
+    <property key="labeling/shadowOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowOffsetUnits" value="1"/>
+    <property key="labeling/shadowRadius" value="1.5"/>
+    <property key="labeling/shadowRadiusAlphaOnly" value="false"/>
+    <property key="labeling/shadowRadiusMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowRadiusUnits" value="1"/>
+    <property key="labeling/shadowScale" value="100"/>
+    <property key="labeling/shadowTransparency" value="30"/>
+    <property key="labeling/shadowUnder" value="0"/>
+    <property key="labeling/shapeBlendMode" value="0"/>
+    <property key="labeling/shapeBorderColorA" value="255"/>
+    <property key="labeling/shapeBorderColorB" value="128"/>
+    <property key="labeling/shapeBorderColorG" value="128"/>
+    <property key="labeling/shapeBorderColorR" value="128"/>
+    <property key="labeling/shapeBorderWidth" value="0"/>
+    <property key="labeling/shapeBorderWidthMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeBorderWidthUnits" value="1"/>
+    <property key="labeling/shapeDraw" value="false"/>
+    <property key="labeling/shapeFillColorA" value="255"/>
+    <property key="labeling/shapeFillColorB" value="255"/>
+    <property key="labeling/shapeFillColorG" value="255"/>
+    <property key="labeling/shapeFillColorR" value="255"/>
+    <property key="labeling/shapeJoinStyle" value="64"/>
+    <property key="labeling/shapeOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeOffsetUnits" value="1"/>
+    <property key="labeling/shapeOffsetX" value="0"/>
+    <property key="labeling/shapeOffsetY" value="0"/>
+    <property key="labeling/shapeRadiiMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeRadiiUnits" value="1"/>
+    <property key="labeling/shapeRadiiX" value="0"/>
+    <property key="labeling/shapeRadiiY" value="0"/>
+    <property key="labeling/shapeRotation" value="0"/>
+    <property key="labeling/shapeRotationType" value="0"/>
+    <property key="labeling/shapeSVGFile" value=""/>
+    <property key="labeling/shapeSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeSizeType" value="0"/>
+    <property key="labeling/shapeSizeUnits" value="1"/>
+    <property key="labeling/shapeSizeX" value="0"/>
+    <property key="labeling/shapeSizeY" value="0"/>
+    <property key="labeling/shapeTransparency" value="0"/>
+    <property key="labeling/shapeType" value="0"/>
+    <property key="labeling/substitutions" value="&lt;substitutions/>"/>
+    <property key="labeling/textColorA" value="255"/>
+    <property key="labeling/textColorB" value="0"/>
+    <property key="labeling/textColorG" value="0"/>
+    <property key="labeling/textColorR" value="170"/>
+    <property key="labeling/textTransp" value="0"/>
+    <property key="labeling/upsidedownLabels" value="0"/>
+    <property key="labeling/useSubstitutions" value="false"/>
+    <property key="labeling/wrapChar" value=""/>
+    <property key="labeling/xOffset" value="0"/>
+    <property key="labeling/xQuadOffset" value="0"/>
+    <property key="labeling/yOffset" value="0"/>
+    <property key="labeling/yQuadOffset" value="0"/>
+    <property key="labeling/zIndex" value="0"/>
+    <property key="variableNames"/>
+    <property key="variableValues"/>
+  </customproperties>
+  <blendMode>0</blendMode>
+  <featureBlendMode>0</featureBlendMode>
+  <layerTransparency>0</layerTransparency>
+  <displayfield>face_id</displayfield>
+  <label>0</label>
+  <labelattributes>
+    <label fieldname="" text="Label"/>
+    <family fieldname="" name="DejaVu Sans"/>
+    <size fieldname="" units="pt" value="12"/>
+    <bold fieldname="" on="0"/>
+    <italic fieldname="" on="0"/>
+    <underline fieldname="" on="0"/>
+    <strikeout fieldname="" on="0"/>
+    <color fieldname="" red="0" blue="0" green="0"/>
+    <x fieldname=""/>
+    <y fieldname=""/>
+    <offset x="0" y="0" units="pt" yfieldname="" xfieldname=""/>
+    <angle fieldname="" value="0" auto="0"/>
+    <alignment fieldname="" value="center"/>
+    <buffercolor fieldname="" red="255" blue="255" green="255"/>
+    <buffersize fieldname="" units="pt" value="1"/>
+    <bufferenabled fieldname="" on=""/>
+    <multilineenabled fieldname="" on=""/>
+    <selectedonly on=""/>
+  </labelattributes>
+  <SingleCategoryDiagramRenderer diagramType="Histogram" sizeLegend="0" attributeLegend="1">
+    <DiagramCategory penColor="#000000" labelPlacementMethod="XHeight" penWidth="0" diagramOrientation="Up" sizeScale="0,0,0,0,0,0" minimumSize="0" barWidth="5" penAlpha="255" maxScaleDenominator="1e+08" backgroundColor="#ffffff" transparency="0" width="15" scaleDependency="Area" backgroundAlpha="255" angleOffset="1440" scaleBasedVisibility="0" enabled="0" height="15" lineSizeScale="0,0,0,0,0,0" sizeType="MM" lineSizeType="MM" minScaleDenominator="0">
+      <fontProperties description="Ubuntu,11,-1,5,50,0,0,0,0,0" style=""/>
+    </DiagramCategory>
+    <symbol alpha="1" clip_to_extent="1" type="marker" name="sizeSymbol">
+      <layer pass="0" class="SimpleMarker" locked="0">
+        <prop k="angle" v="0"/>
+        <prop k="color" v="255,0,0,255"/>
+        <prop k="horizontal_anchor_point" v="1"/>
+        <prop k="joinstyle" v="bevel"/>
+        <prop k="name" v="circle"/>
+        <prop k="offset" v="0,0"/>
+        <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="offset_unit" v="MM"/>
+        <prop k="outline_color" v="0,0,0,255"/>
+        <prop k="outline_style" v="solid"/>
+        <prop k="outline_width" v="0"/>
+        <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="outline_width_unit" v="MM"/>
+        <prop k="scale_method" v="diameter"/>
+        <prop k="size" v="2"/>
+        <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="size_unit" v="MM"/>
+        <prop k="vertical_anchor_point" v="1"/>
+      </layer>
+    </symbol>
+  </SingleCategoryDiagramRenderer>
+  <DiagramLayerSettings yPosColumn="-1" showColumn="-1" linePlacementFlags="10" placement="0" dist="0" xPosColumn="-1" priority="0" obstacle="0" zIndex="0" showAll="1"/>
+  <annotationform></annotationform>
+  <aliases>
+    <alias field="face_id" index="0" name=""/>
+  </aliases>
+  <excludeAttributesWMS/>
+  <excludeAttributesWFS/>
+  <attributeactions default="-1"/>
+  <attributetableconfig actionWidgetStyle="dropDown" sortExpression="" sortOrder="0">
+    <columns>
+      <column width="-1" hidden="0" type="field" name="face_id"/>
+      <column width="-1" hidden="1" type="actions"/>
+    </columns>
+  </attributetableconfig>
+  <editform></editform>
+  <editforminit/>
+  <editforminitcodesource>0</editforminitcodesource>
+  <editforminitfilepath></editforminitfilepath>
+  <editforminitcode><![CDATA[# -*- coding: utf-8 -*-
+"""
+QGIS forms can have a Python function that is called when the form is
+opened.
+
+Use this function to add extra logic to your forms.
+
+Enter the name of the function in the "Python Init function"
+field.
+An example follows:
+"""
+from qgis.PyQt.QtWidgets import QWidget
+
+def my_form_open(dialog, layer, feature):
+	geom = feature.geometry()
+	control = dialog.findChild(QWidget, "MyLineEdit")
+]]></editforminitcode>
+  <featformsuppress>0</featformsuppress>
+  <editorlayout>generatedlayout</editorlayout>
+  <widgets/>
+  <conditionalstyles>
+    <rowstyles/>
+    <fieldstyles/>
+  </conditionalstyles>
+  <defaults>
+    <default field="face_id" expression=""/>
+  </defaults>
+  <previewExpression></previewExpression>
+  <layerGeometryType>2</layerGeometryType>
+</qgis>

+ 328 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/face_right.qml

@@ -0,0 +1,328 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="1" minLabelScale="0" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <edittypes>
+    <edittype widgetv2type="TextEdit" name="edge_id">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="start_node">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="end_node">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="next_left_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="abs_next_left_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="next_right_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="abs_next_right_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="left_face">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="right_face">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+  </edittypes>
+  <renderer-v2 forceraster="0" symbollevels="0" type="singleSymbol" enableorderby="0">
+    <symbols>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="0">
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="195,102,231,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.26"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+    </symbols>
+    <rotation/>
+    <sizescale scalemethod="diameter"/>
+  </renderer-v2>
+  <labeling type="simple"/>
+  <customproperties>
+    <property key="embeddedWidgets/count" value="0"/>
+    <property key="labeling" value="pal"/>
+    <property key="labeling/addDirectionSymbol" value="false"/>
+    <property key="labeling/angleOffset" value="0"/>
+    <property key="labeling/blendMode" value="0"/>
+    <property key="labeling/bufferBlendMode" value="0"/>
+    <property key="labeling/bufferColorA" value="255"/>
+    <property key="labeling/bufferColorB" value="255"/>
+    <property key="labeling/bufferColorG" value="255"/>
+    <property key="labeling/bufferColorR" value="255"/>
+    <property key="labeling/bufferDraw" value="true"/>
+    <property key="labeling/bufferJoinStyle" value="64"/>
+    <property key="labeling/bufferNoFill" value="false"/>
+    <property key="labeling/bufferSize" value="1"/>
+    <property key="labeling/bufferSizeInMapUnits" value="false"/>
+    <property key="labeling/bufferSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/bufferTransp" value="0"/>
+    <property key="labeling/centroidInside" value="false"/>
+    <property key="labeling/centroidWhole" value="false"/>
+    <property key="labeling/decimals" value="0"/>
+    <property key="labeling/displayAll" value="false"/>
+    <property key="labeling/dist" value="7"/>
+    <property key="labeling/distInMapUnits" value="false"/>
+    <property key="labeling/distMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/drawLabels" value="true"/>
+    <property key="labeling/enabled" value="true"/>
+    <property key="labeling/fieldName" value="right_face"/>
+    <property key="labeling/fitInPolygonOnly" value="false"/>
+    <property key="labeling/fontBold" value="false"/>
+    <property key="labeling/fontCapitals" value="0"/>
+    <property key="labeling/fontFamily" value="Sans"/>
+    <property key="labeling/fontItalic" value="false"/>
+    <property key="labeling/fontLetterSpacing" value="0"/>
+    <property key="labeling/fontLimitPixelSize" value="false"/>
+    <property key="labeling/fontMaxPixelSize" value="10000"/>
+    <property key="labeling/fontMinPixelSize" value="3"/>
+    <property key="labeling/fontSize" value="7"/>
+    <property key="labeling/fontSizeInMapUnits" value="false"/>
+    <property key="labeling/fontSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/fontStrikeout" value="false"/>
+    <property key="labeling/fontUnderline" value="false"/>
+    <property key="labeling/fontWeight" value="50"/>
+    <property key="labeling/fontWordSpacing" value="0"/>
+    <property key="labeling/formatNumbers" value="false"/>
+    <property key="labeling/isExpression" value="false"/>
+    <property key="labeling/labelOffsetInMapUnits" value="true"/>
+    <property key="labeling/labelOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/labelPerPart" value="false"/>
+    <property key="labeling/leftDirectionSymbol" value="&lt;"/>
+    <property key="labeling/limitNumLabels" value="false"/>
+    <property key="labeling/maxCurvedCharAngleIn" value="20"/>
+    <property key="labeling/maxCurvedCharAngleOut" value="-20"/>
+    <property key="labeling/maxNumLabels" value="2000"/>
+    <property key="labeling/mergeLines" value="false"/>
+    <property key="labeling/minFeatureSize" value="0"/>
+    <property key="labeling/multiLineLabels" value="false"/>
+    <property key="labeling/multilineAlign" value="0"/>
+    <property key="labeling/multilineHeight" value="1"/>
+    <property key="labeling/namedStyle" value=""/>
+    <property key="labeling/obstacle" value="true"/>
+    <property key="labeling/obstacleFactor" value="1"/>
+    <property key="labeling/obstacleType" value="0"/>
+    <property key="labeling/offsetType" value="0"/>
+    <property key="labeling/placeDirectionSymbol" value="0"/>
+    <property key="labeling/placement" value="2"/>
+    <property key="labeling/placementFlags" value="4"/>
+    <property key="labeling/plussign" value="false"/>
+    <property key="labeling/predefinedPositionOrder" value="TR,TL,BR,BL,R,L,TSR,BSR"/>
+    <property key="labeling/preserveRotation" value="true"/>
+    <property key="labeling/previewBkgrdColor" value="#ffffff"/>
+    <property key="labeling/priority" value="3"/>
+    <property key="labeling/quadOffset" value="4"/>
+    <property key="labeling/repeatDistance" value="0"/>
+    <property key="labeling/repeatDistanceMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/repeatDistanceUnit" value="1"/>
+    <property key="labeling/reverseDirectionSymbol" value="false"/>
+    <property key="labeling/rightDirectionSymbol" value=">"/>
+    <property key="labeling/scaleMax" value="10000000"/>
+    <property key="labeling/scaleMin" value="1"/>
+    <property key="labeling/scaleVisibility" value="false"/>
+    <property key="labeling/shadowBlendMode" value="6"/>
+    <property key="labeling/shadowColorB" value="0"/>
+    <property key="labeling/shadowColorG" value="0"/>
+    <property key="labeling/shadowColorR" value="0"/>
+    <property key="labeling/shadowDraw" value="false"/>
+    <property key="labeling/shadowOffsetAngle" value="135"/>
+    <property key="labeling/shadowOffsetDist" value="1"/>
+    <property key="labeling/shadowOffsetGlobal" value="true"/>
+    <property key="labeling/shadowOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowOffsetUnits" value="1"/>
+    <property key="labeling/shadowRadius" value="1.5"/>
+    <property key="labeling/shadowRadiusAlphaOnly" value="false"/>
+    <property key="labeling/shadowRadiusMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowRadiusUnits" value="1"/>
+    <property key="labeling/shadowScale" value="100"/>
+    <property key="labeling/shadowTransparency" value="30"/>
+    <property key="labeling/shadowUnder" value="0"/>
+    <property key="labeling/shapeBlendMode" value="0"/>
+    <property key="labeling/shapeBorderColorA" value="255"/>
+    <property key="labeling/shapeBorderColorB" value="128"/>
+    <property key="labeling/shapeBorderColorG" value="128"/>
+    <property key="labeling/shapeBorderColorR" value="128"/>
+    <property key="labeling/shapeBorderWidth" value="0"/>
+    <property key="labeling/shapeBorderWidthMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeBorderWidthUnits" value="1"/>
+    <property key="labeling/shapeDraw" value="false"/>
+    <property key="labeling/shapeFillColorA" value="255"/>
+    <property key="labeling/shapeFillColorB" value="255"/>
+    <property key="labeling/shapeFillColorG" value="255"/>
+    <property key="labeling/shapeFillColorR" value="255"/>
+    <property key="labeling/shapeJoinStyle" value="64"/>
+    <property key="labeling/shapeOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeOffsetUnits" value="1"/>
+    <property key="labeling/shapeOffsetX" value="0"/>
+    <property key="labeling/shapeOffsetY" value="0"/>
+    <property key="labeling/shapeRadiiMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeRadiiUnits" value="1"/>
+    <property key="labeling/shapeRadiiX" value="0"/>
+    <property key="labeling/shapeRadiiY" value="0"/>
+    <property key="labeling/shapeRotation" value="0"/>
+    <property key="labeling/shapeRotationType" value="0"/>
+    <property key="labeling/shapeSVGFile" value=""/>
+    <property key="labeling/shapeSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeSizeType" value="0"/>
+    <property key="labeling/shapeSizeUnits" value="1"/>
+    <property key="labeling/shapeSizeX" value="0"/>
+    <property key="labeling/shapeSizeY" value="0"/>
+    <property key="labeling/shapeTransparency" value="0"/>
+    <property key="labeling/shapeType" value="0"/>
+    <property key="labeling/substitutions" value="&lt;substitutions/>"/>
+    <property key="labeling/textColorA" value="255"/>
+    <property key="labeling/textColorB" value="0"/>
+    <property key="labeling/textColorG" value="0"/>
+    <property key="labeling/textColorR" value="170"/>
+    <property key="labeling/textTransp" value="0"/>
+    <property key="labeling/upsidedownLabels" value="0"/>
+    <property key="labeling/useSubstitutions" value="false"/>
+    <property key="labeling/wrapChar" value=""/>
+    <property key="labeling/xOffset" value="0"/>
+    <property key="labeling/xQuadOffset" value="0"/>
+    <property key="labeling/yOffset" value="0"/>
+    <property key="labeling/yQuadOffset" value="0"/>
+    <property key="labeling/zIndex" value="0"/>
+    <property key="variableNames"/>
+    <property key="variableValues"/>
+  </customproperties>
+  <blendMode>0</blendMode>
+  <featureBlendMode>0</featureBlendMode>
+  <layerTransparency>100</layerTransparency>
+  <displayfield>edge_id</displayfield>
+  <label>0</label>
+  <labelattributes>
+    <label fieldname="" text="Label"/>
+    <family fieldname="" name="Sans"/>
+    <size fieldname="" units="pt" value="12"/>
+    <bold fieldname="" on="0"/>
+    <italic fieldname="" on="0"/>
+    <underline fieldname="" on="0"/>
+    <strikeout fieldname="" on="0"/>
+    <color fieldname="" red="0" blue="0" green="0"/>
+    <x fieldname=""/>
+    <y fieldname=""/>
+    <offset x="0" y="0" units="pt" yfieldname="" xfieldname=""/>
+    <angle fieldname="" value="0" auto="0"/>
+    <alignment fieldname="" value="center"/>
+    <buffercolor fieldname="" red="255" blue="255" green="255"/>
+    <buffersize fieldname="" units="pt" value="1"/>
+    <bufferenabled fieldname="" on=""/>
+    <multilineenabled fieldname="" on=""/>
+    <selectedonly on=""/>
+  </labelattributes>
+  <SingleCategoryDiagramRenderer diagramType="Histogram" sizeLegend="0" attributeLegend="1">
+    <DiagramCategory penColor="#000000" labelPlacementMethod="XHeight" penWidth="0" diagramOrientation="Up" sizeScale="0,0,0,0,0,0" minimumSize="0" barWidth="5" penAlpha="255" maxScaleDenominator="1e+08" backgroundColor="#ffffff" transparency="0" width="15" scaleDependency="Area" backgroundAlpha="255" angleOffset="1440" scaleBasedVisibility="0" enabled="0" height="15" lineSizeScale="0,0,0,0,0,0" sizeType="MM" lineSizeType="MM" minScaleDenominator="0">
+      <fontProperties description="Ubuntu,11,-1,5,50,0,0,0,0,0" style=""/>
+    </DiagramCategory>
+    <symbol alpha="1" clip_to_extent="1" type="marker" name="sizeSymbol">
+      <layer pass="0" class="SimpleMarker" locked="0">
+        <prop k="angle" v="0"/>
+        <prop k="color" v="255,0,0,255"/>
+        <prop k="horizontal_anchor_point" v="1"/>
+        <prop k="joinstyle" v="bevel"/>
+        <prop k="name" v="circle"/>
+        <prop k="offset" v="0,0"/>
+        <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="offset_unit" v="MM"/>
+        <prop k="outline_color" v="0,0,0,255"/>
+        <prop k="outline_style" v="solid"/>
+        <prop k="outline_width" v="0"/>
+        <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="outline_width_unit" v="MM"/>
+        <prop k="scale_method" v="diameter"/>
+        <prop k="size" v="2"/>
+        <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="size_unit" v="MM"/>
+        <prop k="vertical_anchor_point" v="1"/>
+      </layer>
+    </symbol>
+  </SingleCategoryDiagramRenderer>
+  <DiagramLayerSettings yPosColumn="-1" showColumn="-1" linePlacementFlags="10" placement="2" dist="0" xPosColumn="-1" priority="0" obstacle="0" zIndex="0" showAll="1"/>
+  <annotationform>../../../../../../../../../src/qgis/plugins/db_manager/db_manager/db_plugins/postgis/plugins/qgis_topoview</annotationform>
+  <aliases>
+    <alias field="edge_id" index="0" name=""/>
+    <alias field="start_node" index="1" name=""/>
+    <alias field="end_node" index="2" name=""/>
+    <alias field="next_left_edge" index="3" name=""/>
+    <alias field="abs_next_left_edge" index="4" name=""/>
+    <alias field="next_right_edge" index="5" name=""/>
+    <alias field="abs_next_right_edge" index="6" name=""/>
+    <alias field="left_face" index="7" name=""/>
+    <alias field="right_face" index="8" name=""/>
+  </aliases>
+  <excludeAttributesWMS/>
+  <excludeAttributesWFS/>
+  <attributeactions default="-1"/>
+  <attributetableconfig actionWidgetStyle="dropDown" sortExpression="" sortOrder="0">
+    <columns>
+      <column width="-1" hidden="0" type="field" name="edge_id"/>
+      <column width="-1" hidden="0" type="field" name="start_node"/>
+      <column width="-1" hidden="0" type="field" name="end_node"/>
+      <column width="-1" hidden="0" type="field" name="next_left_edge"/>
+      <column width="-1" hidden="0" type="field" name="abs_next_left_edge"/>
+      <column width="-1" hidden="0" type="field" name="next_right_edge"/>
+      <column width="-1" hidden="0" type="field" name="abs_next_right_edge"/>
+      <column width="-1" hidden="0" type="field" name="left_face"/>
+      <column width="-1" hidden="0" type="field" name="right_face"/>
+      <column width="-1" hidden="1" type="actions"/>
+    </columns>
+  </attributetableconfig>
+  <editform>../../../../../../../../../src/qgis/plugins/db_manager/db_manager/db_plugins/postgis/plugins/qgis_topoview</editform>
+  <editforminit/>
+  <editforminitcodesource>0</editforminitcodesource>
+  <editforminitfilepath></editforminitfilepath>
+  <editforminitcode><![CDATA[# -*- coding: utf-8 -*-
+"""
+QGIS forms can have a Python function that is called when the form is
+opened.
+
+Use this function to add extra logic to your forms.
+
+Enter the name of the function in the "Python Init function"
+field.
+An example follows:
+"""
+from qgis.PyQt.QtWidgets import QWidget
+
+def my_form_open(dialog, layer, feature):
+	geom = feature.geometry()
+	control = dialog.findChild(QWidget, "MyLineEdit")
+]]></editforminitcode>
+  <featformsuppress>0</featformsuppress>
+  <editorlayout>generatedlayout</editorlayout>
+  <widgets/>
+  <conditionalstyles>
+    <rowstyles/>
+    <fieldstyles/>
+  </conditionalstyles>
+  <defaults>
+    <default field="edge_id" expression=""/>
+    <default field="start_node" expression=""/>
+    <default field="end_node" expression=""/>
+    <default field="next_left_edge" expression=""/>
+    <default field="abs_next_left_edge" expression=""/>
+    <default field="next_right_edge" expression=""/>
+    <default field="abs_next_right_edge" expression=""/>
+    <default field="left_face" expression=""/>
+    <default field="right_face" expression=""/>
+  </defaults>
+  <previewExpression></previewExpression>
+  <layerGeometryType>1</layerGeometryType>
+</qgis>

+ 282 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/face_seed.qml

@@ -0,0 +1,282 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="0" minLabelScale="0" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <edittypes>
+    <edittype widgetv2type="TextEdit" name="face_id">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+  </edittypes>
+  <renderer-v2 forceraster="0" symbollevels="0" type="singleSymbol" enableorderby="0">
+    <symbols>
+      <symbol alpha="1" clip_to_extent="1" type="marker" name="0">
+        <layer pass="0" class="SimpleMarker" locked="0">
+          <prop k="angle" v="0"/>
+          <prop k="color" v="240,243,23,255"/>
+          <prop k="horizontal_anchor_point" v="1"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="name" v="star"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0"/>
+          <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="scale_method" v="diameter"/>
+          <prop k="size" v="2.3"/>
+          <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="size_unit" v="MM"/>
+          <prop k="vertical_anchor_point" v="1"/>
+        </layer>
+      </symbol>
+    </symbols>
+    <rotation/>
+    <sizescale scalemethod="diameter"/>
+  </renderer-v2>
+  <labeling type="simple"/>
+  <customproperties>
+    <property key="embeddedWidgets/count" value="0"/>
+    <property key="labeling" value="pal"/>
+    <property key="labeling/addDirectionSymbol" value="false"/>
+    <property key="labeling/angleOffset" value="0"/>
+    <property key="labeling/blendMode" value="0"/>
+    <property key="labeling/bufferBlendMode" value="0"/>
+    <property key="labeling/bufferColorA" value="255"/>
+    <property key="labeling/bufferColorB" value="255"/>
+    <property key="labeling/bufferColorG" value="255"/>
+    <property key="labeling/bufferColorR" value="255"/>
+    <property key="labeling/bufferDraw" value="false"/>
+    <property key="labeling/bufferJoinStyle" value="64"/>
+    <property key="labeling/bufferNoFill" value="false"/>
+    <property key="labeling/bufferSize" value="1"/>
+    <property key="labeling/bufferSizeInMapUnits" value="false"/>
+    <property key="labeling/bufferSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/bufferTransp" value="0"/>
+    <property key="labeling/centroidInside" value="false"/>
+    <property key="labeling/centroidWhole" value="false"/>
+    <property key="labeling/decimals" value="0"/>
+    <property key="labeling/displayAll" value="false"/>
+    <property key="labeling/dist" value="0"/>
+    <property key="labeling/distInMapUnits" value="true"/>
+    <property key="labeling/distMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/drawLabels" value="true"/>
+    <property key="labeling/enabled" value="true"/>
+    <property key="labeling/fieldName" value="face_id"/>
+    <property key="labeling/fitInPolygonOnly" value="false"/>
+    <property key="labeling/fontCapitals" value="0"/>
+    <property key="labeling/fontFamily" value="Sans"/>
+    <property key="labeling/fontItalic" value="false"/>
+    <property key="labeling/fontLetterSpacing" value="0"/>
+    <property key="labeling/fontLimitPixelSize" value="false"/>
+    <property key="labeling/fontMaxPixelSize" value="10000"/>
+    <property key="labeling/fontMinPixelSize" value="3"/>
+    <property key="labeling/fontSize" value="8"/>
+    <property key="labeling/fontSizeInMapUnits" value="false"/>
+    <property key="labeling/fontSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/fontStrikeout" value="false"/>
+    <property key="labeling/fontUnderline" value="false"/>
+    <property key="labeling/fontWeight" value="50"/>
+    <property key="labeling/fontWordSpacing" value="0"/>
+    <property key="labeling/formatNumbers" value="false"/>
+    <property key="labeling/isExpression" value="false"/>
+    <property key="labeling/labelOffsetInMapUnits" value="true"/>
+    <property key="labeling/labelOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/labelPerPart" value="false"/>
+    <property key="labeling/leftDirectionSymbol" value="&lt;"/>
+    <property key="labeling/limitNumLabels" value="false"/>
+    <property key="labeling/maxCurvedCharAngleIn" value="25"/>
+    <property key="labeling/maxCurvedCharAngleOut" value="-25"/>
+    <property key="labeling/maxNumLabels" value="2000"/>
+    <property key="labeling/mergeLines" value="false"/>
+    <property key="labeling/minFeatureSize" value="0"/>
+    <property key="labeling/multiLineLabels" value="false"/>
+    <property key="labeling/multilineAlign" value="3"/>
+    <property key="labeling/multilineHeight" value="1"/>
+    <property key="labeling/namedStyle" value=""/>
+    <property key="labeling/obstacle" value="true"/>
+    <property key="labeling/obstacleFactor" value="1"/>
+    <property key="labeling/obstacleType" value="0"/>
+    <property key="labeling/offsetType" value="0"/>
+    <property key="labeling/placeDirectionSymbol" value="0"/>
+    <property key="labeling/placement" value="0"/>
+    <property key="labeling/placementFlags" value="0"/>
+    <property key="labeling/plussign" value="true"/>
+    <property key="labeling/predefinedPositionOrder" value="TR,TL,BR,BL,R,L,TSR,BSR"/>
+    <property key="labeling/preserveRotation" value="true"/>
+    <property key="labeling/previewBkgrdColor" value="#ffffff"/>
+    <property key="labeling/priority" value="5"/>
+    <property key="labeling/quadOffset" value="4"/>
+    <property key="labeling/repeatDistance" value="0"/>
+    <property key="labeling/repeatDistanceMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/repeatDistanceUnit" value="1"/>
+    <property key="labeling/reverseDirectionSymbol" value="false"/>
+    <property key="labeling/rightDirectionSymbol" value=">"/>
+    <property key="labeling/scaleMax" value="10000000"/>
+    <property key="labeling/scaleMin" value="1"/>
+    <property key="labeling/scaleVisibility" value="false"/>
+    <property key="labeling/shadowBlendMode" value="6"/>
+    <property key="labeling/shadowColorB" value="0"/>
+    <property key="labeling/shadowColorG" value="0"/>
+    <property key="labeling/shadowColorR" value="0"/>
+    <property key="labeling/shadowDraw" value="false"/>
+    <property key="labeling/shadowOffsetAngle" value="135"/>
+    <property key="labeling/shadowOffsetDist" value="1"/>
+    <property key="labeling/shadowOffsetGlobal" value="true"/>
+    <property key="labeling/shadowOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowOffsetUnits" value="1"/>
+    <property key="labeling/shadowRadius" value="1.5"/>
+    <property key="labeling/shadowRadiusAlphaOnly" value="false"/>
+    <property key="labeling/shadowRadiusMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowRadiusUnits" value="1"/>
+    <property key="labeling/shadowScale" value="100"/>
+    <property key="labeling/shadowTransparency" value="30"/>
+    <property key="labeling/shadowUnder" value="0"/>
+    <property key="labeling/shapeBlendMode" value="0"/>
+    <property key="labeling/shapeBorderColorA" value="255"/>
+    <property key="labeling/shapeBorderColorB" value="128"/>
+    <property key="labeling/shapeBorderColorG" value="128"/>
+    <property key="labeling/shapeBorderColorR" value="128"/>
+    <property key="labeling/shapeBorderWidth" value="0"/>
+    <property key="labeling/shapeBorderWidthMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeBorderWidthUnits" value="1"/>
+    <property key="labeling/shapeDraw" value="false"/>
+    <property key="labeling/shapeFillColorA" value="255"/>
+    <property key="labeling/shapeFillColorB" value="255"/>
+    <property key="labeling/shapeFillColorG" value="255"/>
+    <property key="labeling/shapeFillColorR" value="255"/>
+    <property key="labeling/shapeJoinStyle" value="64"/>
+    <property key="labeling/shapeOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeOffsetUnits" value="1"/>
+    <property key="labeling/shapeOffsetX" value="0"/>
+    <property key="labeling/shapeOffsetY" value="0"/>
+    <property key="labeling/shapeRadiiMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeRadiiUnits" value="1"/>
+    <property key="labeling/shapeRadiiX" value="0"/>
+    <property key="labeling/shapeRadiiY" value="0"/>
+    <property key="labeling/shapeRotation" value="0"/>
+    <property key="labeling/shapeRotationType" value="0"/>
+    <property key="labeling/shapeSVGFile" value=""/>
+    <property key="labeling/shapeSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeSizeType" value="0"/>
+    <property key="labeling/shapeSizeUnits" value="1"/>
+    <property key="labeling/shapeSizeX" value="0"/>
+    <property key="labeling/shapeSizeY" value="0"/>
+    <property key="labeling/shapeTransparency" value="0"/>
+    <property key="labeling/shapeType" value="0"/>
+    <property key="labeling/substitutions" value="&lt;substitutions/>"/>
+    <property key="labeling/textColorA" value="255"/>
+    <property key="labeling/textColorB" value="17"/>
+    <property key="labeling/textColorG" value="172"/>
+    <property key="labeling/textColorR" value="203"/>
+    <property key="labeling/textTransp" value="0"/>
+    <property key="labeling/upsidedownLabels" value="0"/>
+    <property key="labeling/useSubstitutions" value="false"/>
+    <property key="labeling/wrapChar" value=""/>
+    <property key="labeling/xOffset" value="0"/>
+    <property key="labeling/xQuadOffset" value="0"/>
+    <property key="labeling/yOffset" value="0"/>
+    <property key="labeling/yQuadOffset" value="0"/>
+    <property key="labeling/zIndex" value="0"/>
+    <property key="variableNames"/>
+    <property key="variableValues"/>
+  </customproperties>
+  <blendMode>0</blendMode>
+  <featureBlendMode>0</featureBlendMode>
+  <layerTransparency>0</layerTransparency>
+  <displayfield>face_id</displayfield>
+  <label>0</label>
+  <labelattributes>
+    <label fieldname="" text="Label"/>
+    <family fieldname="" name="Sans"/>
+    <size fieldname="" units="pt" value="12"/>
+    <bold fieldname="" on="0"/>
+    <italic fieldname="" on="0"/>
+    <underline fieldname="" on="0"/>
+    <strikeout fieldname="" on="0"/>
+    <color fieldname="" red="0" blue="0" green="0"/>
+    <x fieldname=""/>
+    <y fieldname=""/>
+    <offset x="0" y="0" units="pt" yfieldname="" xfieldname=""/>
+    <angle fieldname="" value="0" auto="0"/>
+    <alignment fieldname="" value="center"/>
+    <buffercolor fieldname="" red="255" blue="255" green="255"/>
+    <buffersize fieldname="" units="pt" value="1"/>
+    <bufferenabled fieldname="" on=""/>
+    <multilineenabled fieldname="" on=""/>
+    <selectedonly on=""/>
+  </labelattributes>
+  <SingleCategoryDiagramRenderer diagramType="Histogram" sizeLegend="0" attributeLegend="1">
+    <DiagramCategory penColor="#000000" labelPlacementMethod="XHeight" penWidth="0" diagramOrientation="Up" sizeScale="0,0,0,0,0,0" minimumSize="0" barWidth="5" penAlpha="255" maxScaleDenominator="1e+08" backgroundColor="#ffffff" transparency="0" width="15" scaleDependency="Area" backgroundAlpha="255" angleOffset="1440" scaleBasedVisibility="0" enabled="0" height="15" lineSizeScale="0,0,0,0,0,0" sizeType="MM" lineSizeType="MM" minScaleDenominator="0">
+      <fontProperties description="Ubuntu,11,-1,5,50,0,0,0,0,0" style=""/>
+    </DiagramCategory>
+    <symbol alpha="1" clip_to_extent="1" type="marker" name="sizeSymbol">
+      <layer pass="0" class="SimpleMarker" locked="0">
+        <prop k="angle" v="0"/>
+        <prop k="color" v="255,0,0,255"/>
+        <prop k="horizontal_anchor_point" v="1"/>
+        <prop k="joinstyle" v="bevel"/>
+        <prop k="name" v="circle"/>
+        <prop k="offset" v="0,0"/>
+        <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="offset_unit" v="MM"/>
+        <prop k="outline_color" v="0,0,0,255"/>
+        <prop k="outline_style" v="solid"/>
+        <prop k="outline_width" v="0"/>
+        <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="outline_width_unit" v="MM"/>
+        <prop k="scale_method" v="diameter"/>
+        <prop k="size" v="2"/>
+        <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="size_unit" v="MM"/>
+        <prop k="vertical_anchor_point" v="1"/>
+      </layer>
+    </symbol>
+  </SingleCategoryDiagramRenderer>
+  <DiagramLayerSettings yPosColumn="-1" showColumn="-1" linePlacementFlags="10" placement="0" dist="0" xPosColumn="-1" priority="0" obstacle="0" zIndex="0" showAll="1"/>
+  <annotationform>../../../../../../../../../src/qgis/plugins/db_manager/db_manager/db_plugins/postgis/plugins/qgis_topoview</annotationform>
+  <aliases>
+    <alias field="face_id" index="0" name=""/>
+  </aliases>
+  <excludeAttributesWMS/>
+  <excludeAttributesWFS/>
+  <attributeactions default="-1"/>
+  <attributetableconfig actionWidgetStyle="dropDown" sortExpression="" sortOrder="0">
+    <columns>
+      <column width="-1" hidden="0" type="field" name="face_id"/>
+      <column width="-1" hidden="1" type="actions"/>
+    </columns>
+  </attributetableconfig>
+  <editform>../../../../../../../../../src/qgis/plugins/db_manager/db_manager/db_plugins/postgis/plugins/qgis_topoview</editform>
+  <editforminit/>
+  <editforminitcodesource>0</editforminitcodesource>
+  <editforminitfilepath></editforminitfilepath>
+  <editforminitcode><![CDATA[# -*- coding: utf-8 -*-
+"""
+QGIS forms can have a Python function that is called when the form is
+opened.
+
+Use this function to add extra logic to your forms.
+
+Enter the name of the function in the "Python Init function"
+field.
+An example follows:
+"""
+from qgis.PyQt.QtWidgets import QWidget
+
+def my_form_open(dialog, layer, feature):
+	geom = feature.geometry()
+	control = dialog.findChild(QWidget, "MyLineEdit")
+]]></editforminitcode>
+  <featformsuppress>0</featformsuppress>
+  <editorlayout>generatedlayout</editorlayout>
+  <widgets/>
+  <conditionalstyles>
+    <rowstyles/>
+    <fieldstyles/>
+  </conditionalstyles>
+  <defaults>
+    <default field="face_id" expression=""/>
+  </defaults>
+  <previewExpression></previewExpression>
+  <layerGeometryType>0</layerGeometryType>
+</qgis>

+ 328 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/next_left.qml

@@ -0,0 +1,328 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="1" minLabelScale="0" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <edittypes>
+    <edittype widgetv2type="TextEdit" name="edge_id">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="start_node">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="end_node">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="next_left_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="abs_next_left_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="next_right_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="abs_next_right_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="left_face">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="right_face">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+  </edittypes>
+  <renderer-v2 forceraster="0" symbollevels="0" type="singleSymbol" enableorderby="0">
+    <symbols>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="0">
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="0,170,0,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.26"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+    </symbols>
+    <rotation/>
+    <sizescale scalemethod="diameter"/>
+  </renderer-v2>
+  <labeling type="simple"/>
+  <customproperties>
+    <property key="embeddedWidgets/count" value="0"/>
+    <property key="labeling" value="pal"/>
+    <property key="labeling/addDirectionSymbol" value="true"/>
+    <property key="labeling/angleOffset" value="0"/>
+    <property key="labeling/blendMode" value="0"/>
+    <property key="labeling/bufferBlendMode" value="0"/>
+    <property key="labeling/bufferColorA" value="255"/>
+    <property key="labeling/bufferColorB" value="255"/>
+    <property key="labeling/bufferColorG" value="255"/>
+    <property key="labeling/bufferColorR" value="255"/>
+    <property key="labeling/bufferDraw" value="true"/>
+    <property key="labeling/bufferJoinStyle" value="64"/>
+    <property key="labeling/bufferNoFill" value="false"/>
+    <property key="labeling/bufferSize" value="1"/>
+    <property key="labeling/bufferSizeInMapUnits" value="false"/>
+    <property key="labeling/bufferSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/bufferTransp" value="0"/>
+    <property key="labeling/centroidInside" value="false"/>
+    <property key="labeling/centroidWhole" value="false"/>
+    <property key="labeling/decimals" value="0"/>
+    <property key="labeling/displayAll" value="false"/>
+    <property key="labeling/dist" value="3"/>
+    <property key="labeling/distInMapUnits" value="false"/>
+    <property key="labeling/distMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/drawLabels" value="true"/>
+    <property key="labeling/enabled" value="true"/>
+    <property key="labeling/fieldName" value="next_left_edge"/>
+    <property key="labeling/fitInPolygonOnly" value="false"/>
+    <property key="labeling/fontBold" value="false"/>
+    <property key="labeling/fontCapitals" value="0"/>
+    <property key="labeling/fontFamily" value="Sans"/>
+    <property key="labeling/fontItalic" value="false"/>
+    <property key="labeling/fontLetterSpacing" value="0"/>
+    <property key="labeling/fontLimitPixelSize" value="false"/>
+    <property key="labeling/fontMaxPixelSize" value="10000"/>
+    <property key="labeling/fontMinPixelSize" value="3"/>
+    <property key="labeling/fontSize" value="7"/>
+    <property key="labeling/fontSizeInMapUnits" value="false"/>
+    <property key="labeling/fontSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/fontStrikeout" value="false"/>
+    <property key="labeling/fontUnderline" value="false"/>
+    <property key="labeling/fontWeight" value="50"/>
+    <property key="labeling/fontWordSpacing" value="0"/>
+    <property key="labeling/formatNumbers" value="false"/>
+    <property key="labeling/isExpression" value="false"/>
+    <property key="labeling/labelOffsetInMapUnits" value="true"/>
+    <property key="labeling/labelOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/labelPerPart" value="false"/>
+    <property key="labeling/leftDirectionSymbol" value="&lt; "/>
+    <property key="labeling/limitNumLabels" value="false"/>
+    <property key="labeling/maxCurvedCharAngleIn" value="20"/>
+    <property key="labeling/maxCurvedCharAngleOut" value="-20"/>
+    <property key="labeling/maxNumLabels" value="2000"/>
+    <property key="labeling/mergeLines" value="false"/>
+    <property key="labeling/minFeatureSize" value="0"/>
+    <property key="labeling/multiLineLabels" value="false"/>
+    <property key="labeling/multilineAlign" value="0"/>
+    <property key="labeling/multilineHeight" value="1"/>
+    <property key="labeling/namedStyle" value=""/>
+    <property key="labeling/obstacle" value="true"/>
+    <property key="labeling/obstacleFactor" value="1"/>
+    <property key="labeling/obstacleType" value="0"/>
+    <property key="labeling/offsetType" value="0"/>
+    <property key="labeling/placeDirectionSymbol" value="0"/>
+    <property key="labeling/placement" value="2"/>
+    <property key="labeling/placementFlags" value="2"/>
+    <property key="labeling/plussign" value="false"/>
+    <property key="labeling/predefinedPositionOrder" value="TR,TL,BR,BL,R,L,TSR,BSR"/>
+    <property key="labeling/preserveRotation" value="true"/>
+    <property key="labeling/previewBkgrdColor" value="#ffffff"/>
+    <property key="labeling/priority" value="2"/>
+    <property key="labeling/quadOffset" value="4"/>
+    <property key="labeling/repeatDistance" value="0"/>
+    <property key="labeling/repeatDistanceMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/repeatDistanceUnit" value="1"/>
+    <property key="labeling/reverseDirectionSymbol" value="false"/>
+    <property key="labeling/rightDirectionSymbol" value=" >"/>
+    <property key="labeling/scaleMax" value="10000000"/>
+    <property key="labeling/scaleMin" value="1"/>
+    <property key="labeling/scaleVisibility" value="false"/>
+    <property key="labeling/shadowBlendMode" value="6"/>
+    <property key="labeling/shadowColorB" value="0"/>
+    <property key="labeling/shadowColorG" value="0"/>
+    <property key="labeling/shadowColorR" value="0"/>
+    <property key="labeling/shadowDraw" value="false"/>
+    <property key="labeling/shadowOffsetAngle" value="135"/>
+    <property key="labeling/shadowOffsetDist" value="1"/>
+    <property key="labeling/shadowOffsetGlobal" value="true"/>
+    <property key="labeling/shadowOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowOffsetUnits" value="1"/>
+    <property key="labeling/shadowRadius" value="1.5"/>
+    <property key="labeling/shadowRadiusAlphaOnly" value="false"/>
+    <property key="labeling/shadowRadiusMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowRadiusUnits" value="1"/>
+    <property key="labeling/shadowScale" value="100"/>
+    <property key="labeling/shadowTransparency" value="30"/>
+    <property key="labeling/shadowUnder" value="0"/>
+    <property key="labeling/shapeBlendMode" value="0"/>
+    <property key="labeling/shapeBorderColorA" value="255"/>
+    <property key="labeling/shapeBorderColorB" value="128"/>
+    <property key="labeling/shapeBorderColorG" value="128"/>
+    <property key="labeling/shapeBorderColorR" value="128"/>
+    <property key="labeling/shapeBorderWidth" value="0"/>
+    <property key="labeling/shapeBorderWidthMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeBorderWidthUnits" value="1"/>
+    <property key="labeling/shapeDraw" value="false"/>
+    <property key="labeling/shapeFillColorA" value="255"/>
+    <property key="labeling/shapeFillColorB" value="255"/>
+    <property key="labeling/shapeFillColorG" value="255"/>
+    <property key="labeling/shapeFillColorR" value="255"/>
+    <property key="labeling/shapeJoinStyle" value="64"/>
+    <property key="labeling/shapeOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeOffsetUnits" value="1"/>
+    <property key="labeling/shapeOffsetX" value="0"/>
+    <property key="labeling/shapeOffsetY" value="0"/>
+    <property key="labeling/shapeRadiiMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeRadiiUnits" value="1"/>
+    <property key="labeling/shapeRadiiX" value="0"/>
+    <property key="labeling/shapeRadiiY" value="0"/>
+    <property key="labeling/shapeRotation" value="0"/>
+    <property key="labeling/shapeRotationType" value="0"/>
+    <property key="labeling/shapeSVGFile" value=""/>
+    <property key="labeling/shapeSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeSizeType" value="0"/>
+    <property key="labeling/shapeSizeUnits" value="1"/>
+    <property key="labeling/shapeSizeX" value="0"/>
+    <property key="labeling/shapeSizeY" value="0"/>
+    <property key="labeling/shapeTransparency" value="0"/>
+    <property key="labeling/shapeType" value="0"/>
+    <property key="labeling/substitutions" value="&lt;substitutions/>"/>
+    <property key="labeling/textColorA" value="255"/>
+    <property key="labeling/textColorB" value="0"/>
+    <property key="labeling/textColorG" value="170"/>
+    <property key="labeling/textColorR" value="0"/>
+    <property key="labeling/textTransp" value="0"/>
+    <property key="labeling/upsidedownLabels" value="0"/>
+    <property key="labeling/useSubstitutions" value="false"/>
+    <property key="labeling/wrapChar" value=""/>
+    <property key="labeling/xOffset" value="0"/>
+    <property key="labeling/xQuadOffset" value="0"/>
+    <property key="labeling/yOffset" value="0"/>
+    <property key="labeling/yQuadOffset" value="0"/>
+    <property key="labeling/zIndex" value="0"/>
+    <property key="variableNames"/>
+    <property key="variableValues"/>
+  </customproperties>
+  <blendMode>0</blendMode>
+  <featureBlendMode>0</featureBlendMode>
+  <layerTransparency>100</layerTransparency>
+  <displayfield>edge_id</displayfield>
+  <label>0</label>
+  <labelattributes>
+    <label fieldname="" text="Label"/>
+    <family fieldname="" name="Sans"/>
+    <size fieldname="" units="pt" value="12"/>
+    <bold fieldname="" on="0"/>
+    <italic fieldname="" on="0"/>
+    <underline fieldname="" on="0"/>
+    <strikeout fieldname="" on="0"/>
+    <color fieldname="" red="0" blue="0" green="0"/>
+    <x fieldname=""/>
+    <y fieldname=""/>
+    <offset x="0" y="0" units="pt" yfieldname="" xfieldname=""/>
+    <angle fieldname="" value="0" auto="0"/>
+    <alignment fieldname="" value="center"/>
+    <buffercolor fieldname="" red="255" blue="255" green="255"/>
+    <buffersize fieldname="" units="pt" value="1"/>
+    <bufferenabled fieldname="" on=""/>
+    <multilineenabled fieldname="" on=""/>
+    <selectedonly on=""/>
+  </labelattributes>
+  <SingleCategoryDiagramRenderer diagramType="Histogram" sizeLegend="0" attributeLegend="1">
+    <DiagramCategory penColor="#000000" labelPlacementMethod="XHeight" penWidth="0" diagramOrientation="Up" sizeScale="0,0,0,0,0,0" minimumSize="0" barWidth="5" penAlpha="255" maxScaleDenominator="1e+08" backgroundColor="#ffffff" transparency="0" width="15" scaleDependency="Area" backgroundAlpha="255" angleOffset="1440" scaleBasedVisibility="0" enabled="0" height="15" lineSizeScale="0,0,0,0,0,0" sizeType="MM" lineSizeType="MM" minScaleDenominator="0">
+      <fontProperties description="Ubuntu,11,-1,5,50,0,0,0,0,0" style=""/>
+    </DiagramCategory>
+    <symbol alpha="1" clip_to_extent="1" type="marker" name="sizeSymbol">
+      <layer pass="0" class="SimpleMarker" locked="0">
+        <prop k="angle" v="0"/>
+        <prop k="color" v="255,0,0,255"/>
+        <prop k="horizontal_anchor_point" v="1"/>
+        <prop k="joinstyle" v="bevel"/>
+        <prop k="name" v="circle"/>
+        <prop k="offset" v="0,0"/>
+        <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="offset_unit" v="MM"/>
+        <prop k="outline_color" v="0,0,0,255"/>
+        <prop k="outline_style" v="solid"/>
+        <prop k="outline_width" v="0"/>
+        <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="outline_width_unit" v="MM"/>
+        <prop k="scale_method" v="diameter"/>
+        <prop k="size" v="2"/>
+        <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="size_unit" v="MM"/>
+        <prop k="vertical_anchor_point" v="1"/>
+      </layer>
+    </symbol>
+  </SingleCategoryDiagramRenderer>
+  <DiagramLayerSettings yPosColumn="-1" showColumn="-1" linePlacementFlags="10" placement="2" dist="0" xPosColumn="-1" priority="0" obstacle="0" zIndex="0" showAll="1"/>
+  <annotationform>../../../../../../../../../src/qgis/plugins/db_manager/db_manager/db_plugins/postgis/plugins/qgis_topoview</annotationform>
+  <aliases>
+    <alias field="edge_id" index="0" name=""/>
+    <alias field="start_node" index="1" name=""/>
+    <alias field="end_node" index="2" name=""/>
+    <alias field="next_left_edge" index="3" name=""/>
+    <alias field="abs_next_left_edge" index="4" name=""/>
+    <alias field="next_right_edge" index="5" name=""/>
+    <alias field="abs_next_right_edge" index="6" name=""/>
+    <alias field="left_face" index="7" name=""/>
+    <alias field="right_face" index="8" name=""/>
+  </aliases>
+  <excludeAttributesWMS/>
+  <excludeAttributesWFS/>
+  <attributeactions default="-1"/>
+  <attributetableconfig actionWidgetStyle="dropDown" sortExpression="" sortOrder="0">
+    <columns>
+      <column width="-1" hidden="0" type="field" name="edge_id"/>
+      <column width="-1" hidden="0" type="field" name="start_node"/>
+      <column width="-1" hidden="0" type="field" name="end_node"/>
+      <column width="-1" hidden="0" type="field" name="next_left_edge"/>
+      <column width="-1" hidden="0" type="field" name="abs_next_left_edge"/>
+      <column width="-1" hidden="0" type="field" name="next_right_edge"/>
+      <column width="-1" hidden="0" type="field" name="abs_next_right_edge"/>
+      <column width="-1" hidden="0" type="field" name="left_face"/>
+      <column width="-1" hidden="0" type="field" name="right_face"/>
+      <column width="-1" hidden="1" type="actions"/>
+    </columns>
+  </attributetableconfig>
+  <editform>../../../../../../../../../src/qgis/plugins/db_manager/db_manager/db_plugins/postgis/plugins/qgis_topoview</editform>
+  <editforminit/>
+  <editforminitcodesource>0</editforminitcodesource>
+  <editforminitfilepath></editforminitfilepath>
+  <editforminitcode><![CDATA[# -*- coding: utf-8 -*-
+"""
+QGIS forms can have a Python function that is called when the form is
+opened.
+
+Use this function to add extra logic to your forms.
+
+Enter the name of the function in the "Python Init function"
+field.
+An example follows:
+"""
+from qgis.PyQt.QtWidgets import QWidget
+
+def my_form_open(dialog, layer, feature):
+	geom = feature.geometry()
+	control = dialog.findChild(QWidget, "MyLineEdit")
+]]></editforminitcode>
+  <featformsuppress>0</featformsuppress>
+  <editorlayout>generatedlayout</editorlayout>
+  <widgets/>
+  <conditionalstyles>
+    <rowstyles/>
+    <fieldstyles/>
+  </conditionalstyles>
+  <defaults>
+    <default field="edge_id" expression=""/>
+    <default field="start_node" expression=""/>
+    <default field="end_node" expression=""/>
+    <default field="next_left_edge" expression=""/>
+    <default field="abs_next_left_edge" expression=""/>
+    <default field="next_right_edge" expression=""/>
+    <default field="abs_next_right_edge" expression=""/>
+    <default field="left_face" expression=""/>
+    <default field="right_face" expression=""/>
+  </defaults>
+  <previewExpression></previewExpression>
+  <layerGeometryType>1</layerGeometryType>
+</qgis>

+ 328 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/next_right.qml

@@ -0,0 +1,328 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="1" minLabelScale="0" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <edittypes>
+    <edittype widgetv2type="TextEdit" name="edge_id">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="start_node">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="end_node">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="next_left_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="abs_next_left_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="next_right_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="abs_next_right_edge">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="left_face">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+    <edittype widgetv2type="TextEdit" name="right_face">
+      <widgetv2config IsMultiline="0" fieldEditable="1" constraint="" UseHtml="0" labelOnTop="0" constraintDescription="" notNull="0"/>
+    </edittype>
+  </edittypes>
+  <renderer-v2 forceraster="0" symbollevels="0" type="singleSymbol" enableorderby="0">
+    <symbols>
+      <symbol alpha="1" clip_to_extent="1" type="line" name="0">
+        <layer pass="0" class="SimpleLine" locked="0">
+          <prop k="capstyle" v="square"/>
+          <prop k="customdash" v="5;2"/>
+          <prop k="customdash_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="customdash_unit" v="MM"/>
+          <prop k="draw_inside_polygon" v="0"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="line_color" v="170,0,0,255"/>
+          <prop k="line_style" v="solid"/>
+          <prop k="line_width" v="0.26"/>
+          <prop k="line_width_unit" v="MM"/>
+          <prop k="offset" v="0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="use_custom_dash" v="0"/>
+          <prop k="width_map_unit_scale" v="0,0,0,0,0,0"/>
+        </layer>
+      </symbol>
+    </symbols>
+    <rotation/>
+    <sizescale scalemethod="diameter"/>
+  </renderer-v2>
+  <labeling type="simple"/>
+  <customproperties>
+    <property key="embeddedWidgets/count" value="0"/>
+    <property key="labeling" value="pal"/>
+    <property key="labeling/addDirectionSymbol" value="true"/>
+    <property key="labeling/angleOffset" value="0"/>
+    <property key="labeling/blendMode" value="0"/>
+    <property key="labeling/bufferBlendMode" value="0"/>
+    <property key="labeling/bufferColorA" value="255"/>
+    <property key="labeling/bufferColorB" value="255"/>
+    <property key="labeling/bufferColorG" value="255"/>
+    <property key="labeling/bufferColorR" value="255"/>
+    <property key="labeling/bufferDraw" value="true"/>
+    <property key="labeling/bufferJoinStyle" value="64"/>
+    <property key="labeling/bufferNoFill" value="false"/>
+    <property key="labeling/bufferSize" value="1"/>
+    <property key="labeling/bufferSizeInMapUnits" value="false"/>
+    <property key="labeling/bufferSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/bufferTransp" value="0"/>
+    <property key="labeling/centroidInside" value="false"/>
+    <property key="labeling/centroidWhole" value="false"/>
+    <property key="labeling/decimals" value="0"/>
+    <property key="labeling/displayAll" value="false"/>
+    <property key="labeling/dist" value="3"/>
+    <property key="labeling/distInMapUnits" value="false"/>
+    <property key="labeling/distMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/drawLabels" value="true"/>
+    <property key="labeling/enabled" value="true"/>
+    <property key="labeling/fieldName" value="next_right_edge"/>
+    <property key="labeling/fitInPolygonOnly" value="false"/>
+    <property key="labeling/fontBold" value="false"/>
+    <property key="labeling/fontCapitals" value="0"/>
+    <property key="labeling/fontFamily" value="Sans"/>
+    <property key="labeling/fontItalic" value="false"/>
+    <property key="labeling/fontLetterSpacing" value="0"/>
+    <property key="labeling/fontLimitPixelSize" value="false"/>
+    <property key="labeling/fontMaxPixelSize" value="10000"/>
+    <property key="labeling/fontMinPixelSize" value="3"/>
+    <property key="labeling/fontSize" value="7"/>
+    <property key="labeling/fontSizeInMapUnits" value="false"/>
+    <property key="labeling/fontSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/fontStrikeout" value="false"/>
+    <property key="labeling/fontUnderline" value="false"/>
+    <property key="labeling/fontWeight" value="50"/>
+    <property key="labeling/fontWordSpacing" value="0"/>
+    <property key="labeling/formatNumbers" value="false"/>
+    <property key="labeling/isExpression" value="false"/>
+    <property key="labeling/labelOffsetInMapUnits" value="true"/>
+    <property key="labeling/labelOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/labelPerPart" value="false"/>
+    <property key="labeling/leftDirectionSymbol" value="&lt; "/>
+    <property key="labeling/limitNumLabels" value="false"/>
+    <property key="labeling/maxCurvedCharAngleIn" value="20"/>
+    <property key="labeling/maxCurvedCharAngleOut" value="-20"/>
+    <property key="labeling/maxNumLabels" value="2000"/>
+    <property key="labeling/mergeLines" value="false"/>
+    <property key="labeling/minFeatureSize" value="0"/>
+    <property key="labeling/multiLineLabels" value="false"/>
+    <property key="labeling/multilineAlign" value="1"/>
+    <property key="labeling/multilineHeight" value="0.6"/>
+    <property key="labeling/namedStyle" value=""/>
+    <property key="labeling/obstacle" value="true"/>
+    <property key="labeling/obstacleFactor" value="1"/>
+    <property key="labeling/obstacleType" value="0"/>
+    <property key="labeling/offsetType" value="0"/>
+    <property key="labeling/placeDirectionSymbol" value="0"/>
+    <property key="labeling/placement" value="2"/>
+    <property key="labeling/placementFlags" value="4"/>
+    <property key="labeling/plussign" value="false"/>
+    <property key="labeling/predefinedPositionOrder" value="TR,TL,BR,BL,R,L,TSR,BSR"/>
+    <property key="labeling/preserveRotation" value="true"/>
+    <property key="labeling/previewBkgrdColor" value="#ffffff"/>
+    <property key="labeling/priority" value="3"/>
+    <property key="labeling/quadOffset" value="4"/>
+    <property key="labeling/repeatDistance" value="0"/>
+    <property key="labeling/repeatDistanceMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/repeatDistanceUnit" value="1"/>
+    <property key="labeling/reverseDirectionSymbol" value="true"/>
+    <property key="labeling/rightDirectionSymbol" value=" >"/>
+    <property key="labeling/scaleMax" value="10000000"/>
+    <property key="labeling/scaleMin" value="1"/>
+    <property key="labeling/scaleVisibility" value="false"/>
+    <property key="labeling/shadowBlendMode" value="6"/>
+    <property key="labeling/shadowColorB" value="0"/>
+    <property key="labeling/shadowColorG" value="0"/>
+    <property key="labeling/shadowColorR" value="0"/>
+    <property key="labeling/shadowDraw" value="false"/>
+    <property key="labeling/shadowOffsetAngle" value="135"/>
+    <property key="labeling/shadowOffsetDist" value="1"/>
+    <property key="labeling/shadowOffsetGlobal" value="true"/>
+    <property key="labeling/shadowOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowOffsetUnits" value="1"/>
+    <property key="labeling/shadowRadius" value="1.5"/>
+    <property key="labeling/shadowRadiusAlphaOnly" value="false"/>
+    <property key="labeling/shadowRadiusMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowRadiusUnits" value="1"/>
+    <property key="labeling/shadowScale" value="100"/>
+    <property key="labeling/shadowTransparency" value="30"/>
+    <property key="labeling/shadowUnder" value="0"/>
+    <property key="labeling/shapeBlendMode" value="0"/>
+    <property key="labeling/shapeBorderColorA" value="255"/>
+    <property key="labeling/shapeBorderColorB" value="128"/>
+    <property key="labeling/shapeBorderColorG" value="128"/>
+    <property key="labeling/shapeBorderColorR" value="128"/>
+    <property key="labeling/shapeBorderWidth" value="0"/>
+    <property key="labeling/shapeBorderWidthMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeBorderWidthUnits" value="1"/>
+    <property key="labeling/shapeDraw" value="false"/>
+    <property key="labeling/shapeFillColorA" value="255"/>
+    <property key="labeling/shapeFillColorB" value="255"/>
+    <property key="labeling/shapeFillColorG" value="255"/>
+    <property key="labeling/shapeFillColorR" value="255"/>
+    <property key="labeling/shapeJoinStyle" value="64"/>
+    <property key="labeling/shapeOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeOffsetUnits" value="1"/>
+    <property key="labeling/shapeOffsetX" value="0"/>
+    <property key="labeling/shapeOffsetY" value="0"/>
+    <property key="labeling/shapeRadiiMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeRadiiUnits" value="1"/>
+    <property key="labeling/shapeRadiiX" value="0"/>
+    <property key="labeling/shapeRadiiY" value="0"/>
+    <property key="labeling/shapeRotation" value="0"/>
+    <property key="labeling/shapeRotationType" value="0"/>
+    <property key="labeling/shapeSVGFile" value=""/>
+    <property key="labeling/shapeSizeMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeSizeType" value="0"/>
+    <property key="labeling/shapeSizeUnits" value="1"/>
+    <property key="labeling/shapeSizeX" value="0"/>
+    <property key="labeling/shapeSizeY" value="0"/>
+    <property key="labeling/shapeTransparency" value="0"/>
+    <property key="labeling/shapeType" value="0"/>
+    <property key="labeling/substitutions" value="&lt;substitutions/>"/>
+    <property key="labeling/textColorA" value="255"/>
+    <property key="labeling/textColorB" value="0"/>
+    <property key="labeling/textColorG" value="0"/>
+    <property key="labeling/textColorR" value="170"/>
+    <property key="labeling/textTransp" value="0"/>
+    <property key="labeling/upsidedownLabels" value="0"/>
+    <property key="labeling/useSubstitutions" value="false"/>
+    <property key="labeling/wrapChar" value=""/>
+    <property key="labeling/xOffset" value="0"/>
+    <property key="labeling/xQuadOffset" value="0"/>
+    <property key="labeling/yOffset" value="0"/>
+    <property key="labeling/yQuadOffset" value="0"/>
+    <property key="labeling/zIndex" value="0"/>
+    <property key="variableNames"/>
+    <property key="variableValues"/>
+  </customproperties>
+  <blendMode>0</blendMode>
+  <featureBlendMode>0</featureBlendMode>
+  <layerTransparency>100</layerTransparency>
+  <displayfield>edge_id</displayfield>
+  <label>0</label>
+  <labelattributes>
+    <label fieldname="" text="Label"/>
+    <family fieldname="" name="Sans"/>
+    <size fieldname="" units="pt" value="12"/>
+    <bold fieldname="" on="0"/>
+    <italic fieldname="" on="0"/>
+    <underline fieldname="" on="0"/>
+    <strikeout fieldname="" on="0"/>
+    <color fieldname="" red="0" blue="0" green="0"/>
+    <x fieldname=""/>
+    <y fieldname=""/>
+    <offset x="0" y="0" units="pt" yfieldname="" xfieldname=""/>
+    <angle fieldname="" value="0" auto="0"/>
+    <alignment fieldname="" value="center"/>
+    <buffercolor fieldname="" red="255" blue="255" green="255"/>
+    <buffersize fieldname="" units="pt" value="1"/>
+    <bufferenabled fieldname="" on=""/>
+    <multilineenabled fieldname="" on=""/>
+    <selectedonly on=""/>
+  </labelattributes>
+  <SingleCategoryDiagramRenderer diagramType="Histogram" sizeLegend="0" attributeLegend="1">
+    <DiagramCategory penColor="#000000" labelPlacementMethod="XHeight" penWidth="0" diagramOrientation="Up" sizeScale="0,0,0,0,0,0" minimumSize="0" barWidth="5" penAlpha="255" maxScaleDenominator="1e+08" backgroundColor="#ffffff" transparency="0" width="15" scaleDependency="Area" backgroundAlpha="255" angleOffset="1440" scaleBasedVisibility="0" enabled="0" height="15" lineSizeScale="0,0,0,0,0,0" sizeType="MM" lineSizeType="MM" minScaleDenominator="inf">
+      <fontProperties description="Ubuntu,11,-1,5,50,0,0,0,0,0" style=""/>
+    </DiagramCategory>
+    <symbol alpha="1" clip_to_extent="1" type="marker" name="sizeSymbol">
+      <layer pass="0" class="SimpleMarker" locked="0">
+        <prop k="angle" v="0"/>
+        <prop k="color" v="255,0,0,255"/>
+        <prop k="horizontal_anchor_point" v="1"/>
+        <prop k="joinstyle" v="bevel"/>
+        <prop k="name" v="circle"/>
+        <prop k="offset" v="0,0"/>
+        <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="offset_unit" v="MM"/>
+        <prop k="outline_color" v="0,0,0,255"/>
+        <prop k="outline_style" v="solid"/>
+        <prop k="outline_width" v="0"/>
+        <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="outline_width_unit" v="MM"/>
+        <prop k="scale_method" v="diameter"/>
+        <prop k="size" v="2"/>
+        <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+        <prop k="size_unit" v="MM"/>
+        <prop k="vertical_anchor_point" v="1"/>
+      </layer>
+    </symbol>
+  </SingleCategoryDiagramRenderer>
+  <DiagramLayerSettings yPosColumn="-1" showColumn="-1" linePlacementFlags="10" placement="2" dist="0" xPosColumn="-1" priority="0" obstacle="0" zIndex="0" showAll="1"/>
+  <annotationform>../../../../../../../../../src/qgis/plugins/db_manager/db_manager/db_plugins/postgis/plugins/qgis_topoview</annotationform>
+  <aliases>
+    <alias field="edge_id" index="0" name=""/>
+    <alias field="start_node" index="1" name=""/>
+    <alias field="end_node" index="2" name=""/>
+    <alias field="next_left_edge" index="3" name=""/>
+    <alias field="abs_next_left_edge" index="4" name=""/>
+    <alias field="next_right_edge" index="5" name=""/>
+    <alias field="abs_next_right_edge" index="6" name=""/>
+    <alias field="left_face" index="7" name=""/>
+    <alias field="right_face" index="8" name=""/>
+  </aliases>
+  <excludeAttributesWMS/>
+  <excludeAttributesWFS/>
+  <attributeactions default="-1"/>
+  <attributetableconfig actionWidgetStyle="dropDown" sortExpression="" sortOrder="0">
+    <columns>
+      <column width="-1" hidden="0" type="field" name="edge_id"/>
+      <column width="-1" hidden="0" type="field" name="start_node"/>
+      <column width="-1" hidden="0" type="field" name="end_node"/>
+      <column width="-1" hidden="0" type="field" name="next_left_edge"/>
+      <column width="-1" hidden="0" type="field" name="abs_next_left_edge"/>
+      <column width="-1" hidden="0" type="field" name="next_right_edge"/>
+      <column width="-1" hidden="0" type="field" name="abs_next_right_edge"/>
+      <column width="-1" hidden="0" type="field" name="left_face"/>
+      <column width="-1" hidden="0" type="field" name="right_face"/>
+      <column width="-1" hidden="1" type="actions"/>
+    </columns>
+  </attributetableconfig>
+  <editform>../../../../../../../../../src/qgis/plugins/db_manager/db_manager/db_plugins/postgis/plugins/qgis_topoview</editform>
+  <editforminit/>
+  <editforminitcodesource>0</editforminitcodesource>
+  <editforminitfilepath></editforminitfilepath>
+  <editforminitcode><![CDATA[# -*- coding: utf-8 -*-
+"""
+QGIS forms can have a Python function that is called when the form is
+opened.
+
+Use this function to add extra logic to your forms.
+
+Enter the name of the function in the "Python Init function"
+field.
+An example follows:
+"""
+from qgis.PyQt.QtWidgets import QWidget
+
+def my_form_open(dialog, layer, feature):
+	geom = feature.geometry()
+	control = dialog.findChild(QWidget, "MyLineEdit")
+]]></editforminitcode>
+  <featformsuppress>0</featformsuppress>
+  <editorlayout>generatedlayout</editorlayout>
+  <widgets/>
+  <conditionalstyles>
+    <rowstyles/>
+    <fieldstyles/>
+  </conditionalstyles>
+  <defaults>
+    <default field="edge_id" expression=""/>
+    <default field="start_node" expression=""/>
+    <default field="end_node" expression=""/>
+    <default field="next_left_edge" expression=""/>
+    <default field="abs_next_left_edge" expression=""/>
+    <default field="next_right_edge" expression=""/>
+    <default field="abs_next_right_edge" expression=""/>
+    <default field="left_face" expression=""/>
+    <default field="right_face" expression=""/>
+  </defaults>
+  <previewExpression></previewExpression>
+  <layerGeometryType>1</layerGeometryType>
+</qgis>

+ 31 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/node.qml

@@ -0,0 +1,31 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="0" minLabelScale="0" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <renderer-v2 forceraster="0" symbollevels="0" type="singleSymbol" enableorderby="0">
+    <symbols>
+      <symbol alpha="1" clip_to_extent="1" type="marker" name="0">
+        <layer pass="0" class="SimpleMarker" locked="0">
+          <prop k="angle" v="0"/>
+          <prop k="color" v="196,201,176,255"/>
+          <prop k="horizontal_anchor_point" v="1"/>
+          <prop k="joinstyle" v="bevel"/>
+          <prop k="name" v="circle"/>
+          <prop k="offset" v="0,0"/>
+          <prop k="offset_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="offset_unit" v="MM"/>
+          <prop k="outline_color" v="0,0,0,255"/>
+          <prop k="outline_style" v="solid"/>
+          <prop k="outline_width" v="0"/>
+          <prop k="outline_width_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="outline_width_unit" v="MM"/>
+          <prop k="scale_method" v="diameter"/>
+          <prop k="size" v="4"/>
+          <prop k="size_map_unit_scale" v="0,0,0,0,0,0"/>
+          <prop k="size_unit" v="MM"/>
+          <prop k="vertical_anchor_point" v="1"/>
+        </layer>
+      </symbol>
+    </symbols>
+    <rotation/>
+    <sizescale scalemethod="diameter"/>
+  </renderer-v2>
+</qgis>

+ 133 - 0
db_manager/db_plugins/postgis/plugins/qgis_topoview/templates/node_label.qml

@@ -0,0 +1,133 @@
+<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
+<qgis version="2.18.28" simplifyAlgorithm="0" minimumScale="0" maximumScale="1e+08" simplifyDrawingHints="0" minLabelScale="0" maxLabelScale="1e+08" simplifyDrawingTol="1" readOnly="0" simplifyMaxScale="1" hasScaleBasedVisibilityFlag="0" simplifyLocal="1" scaleBasedLabelVisibilityFlag="0">
+  <renderer-v2 forceraster="0" symbollevels="0" type="nullSymbol" enableorderby="0"/>
+  <labeling type="simple"/>
+  <customproperties>
+    <property key="labeling" value="pal"/>
+    <property key="labeling/bufferColorA" value="255"/>
+    <property key="labeling/bufferColorB" value="255"/>
+    <property key="labeling/bufferColorG" value="255"/>
+    <property key="labeling/bufferColorR" value="255"/>
+    <property key="labeling/bufferDraw" value="true"/>
+    <property key="labeling/bufferJoinStyle" value="64"/>
+    <property key="labeling/bufferNoFill" value="false"/>
+    <property key="labeling/bufferSize" value="1"/>
+    <property key="labeling/bufferSizeInMapUnits" value="false"/>
+    <property key="labeling/bufferTransp" value="0"/>
+    <property key="labeling/displayAll" value="false"/>
+    <property key="labeling/distInMapUnits" value="false"/>
+    <property key="labeling/drawLabels" value="true"/>
+    <property key="labeling/enabled" value="true"/>
+    <property key="labeling/fieldName" value="node_id"/>
+    <property key="labeling/fitInPolygonOnly" value="false"/>
+    <property key="labeling/fontBold" value="false"/>
+    <property key="labeling/fontCapitals" value="0"/>
+    <property key="labeling/fontFamily" value="Sans"/>
+    <property key="labeling/fontItalic" value="false"/>
+    <property key="labeling/fontLetterSpacing" value="0"/>
+    <property key="labeling/fontLimitPixelSize" value="false"/>
+    <property key="labeling/fontMaxPixelSize" value="10000"/>
+    <property key="labeling/fontMinPixelSize" value="3"/>
+    <property key="labeling/fontSize" value="8"/>
+    <property key="labeling/fontSizeInMapUnits" value="false"/>
+    <property key="labeling/fontStrikeout" value="false"/>
+    <property key="labeling/fontUnderline" value="false"/>
+    <property key="labeling/fontWeight" value="50"/>
+    <property key="labeling/fontWordSpacing" value="0"/>
+    <property key="labeling/formatNumbers" value="false"/>
+    <property key="labeling/isExpression" value="false"/>
+    <property key="labeling/labelOffsetInMapUnits" value="true"/>
+    <property key="labeling/labelOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/labelPerPart" value="false"/>
+    <property key="labeling/leftDirectionSymbol" value="&lt;"/>
+    <property key="labeling/limitNumLabels" value="true"/>
+    <property key="labeling/maxCurvedCharAngleIn" value="20"/>
+    <property key="labeling/maxCurvedCharAngleOut" value="-20"/>
+    <property key="labeling/maxNumLabels" value="100"/>
+    <property key="labeling/mergeLines" value="false"/>
+    <property key="labeling/minFeatureSize" value="0"/>
+    <property key="labeling/multiLineLabels" value="false"/>
+    <property key="labeling/multilineAlign" value="0"/>
+    <property key="labeling/multilineHeight" value="1"/>
+    <property key="labeling/namedStyle" value=""/>
+    <property key="labeling/obstacle" value="true"/>
+    <property key="labeling/obstacleFactor" value="1"/>
+    <property key="labeling/obstacleType" value="0"/>
+    <property key="labeling/offsetType" value="0"/>
+    <property key="labeling/placeDirectionSymbol" value="0"/>
+    <property key="labeling/placement" value="1"/>
+    <property key="labeling/placementFlags" value="0"/>
+    <property key="labeling/plussign" value="false"/>
+    <property key="labeling/predefinedPositionOrder" value="TR,TL,BR,BL,R,L,TSR,BSR"/>
+    <property key="labeling/preserveRotation" value="true"/>
+    <property key="labeling/previewBkgrdColor" value="#ffffff"/>
+    <property key="labeling/priority" value="5"/>
+    <property key="labeling/quadOffset" value="4"/>
+    <property key="labeling/repeatDistance" value="0"/>
+    <property key="labeling/repeatDistanceMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/repeatDistanceUnit" value="1"/>
+    <property key="labeling/reverseDirectionSymbol" value="false"/>
+    <property key="labeling/rightDirectionSymbol" value=">"/>
+    <property key="labeling/scaleMax" value="10000000"/>
+    <property key="labeling/scaleMin" value="1"/>
+    <property key="labeling/scaleVisibility" value="false"/>
+    <property key="labeling/shadowBlendMode" value="6"/>
+    <property key="labeling/shadowColorB" value="0"/>
+    <property key="labeling/shadowColorG" value="0"/>
+    <property key="labeling/shadowColorR" value="0"/>
+    <property key="labeling/shadowDraw" value="false"/>
+    <property key="labeling/shadowOffsetAngle" value="135"/>
+    <property key="labeling/shadowOffsetDist" value="1"/>
+    <property key="labeling/shadowOffsetGlobal" value="true"/>
+    <property key="labeling/shadowOffsetMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowOffsetUnits" value="1"/>
+    <property key="labeling/shadowRadius" value="1.5"/>
+    <property key="labeling/shadowRadiusAlphaOnly" value="false"/>
+    <property key="labeling/shadowRadiusMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shadowRadiusUnits" value="1"/>
+    <property key="labeling/shadowScale" value="100"/>
+    <property key="labeling/shadowTransparency" value="30"/>
+    <property key="labeling/shadowUnder" value="0"/>
+    <property key="labeling/shapeBlendMode" value="0"/>
+    <property key="labeling/shapeBorderColorA" value="255"/>
+    <property key="labeling/shapeBorderColorB" value="128"/>
+    <property key="labeling/shapeBorderColorG" value="128"/>
+    <property key="labeling/shapeBorderColorR" value="128"/>
+    <property key="labeling/shapeBorderWidth" value="0"/>
+    <property key="labeling/shapeBorderWidthMapUnitScale" value="0,0,0,0,0,0"/>
+    <property key="labeling/shapeBorderWidthUnits" value="1"/>
+    <property key="labeling/shapeDraw" value="false"/>
+    <property key="labeling/shapeFillColorA" value="255"/>
+    <property key="labeling/shapeFillColorB" value="255"/>
+    <property key="labeling/shapeFillColorG" value="255"/>
+    <property key="labeling/shapeFillColorR" value="255"/>
+    <property key="labeling/shapeJoinStyle" value="64"/>
+    <property key="labeling/textColorA" value="255"/>
+    <property key="labeling/textColorB" value="217"/>
+    <property key="labeling/textColorG" value="41"/>
+    <property key="labeling/textColorR" value="14"/>
+  </customproperties>
+  <layerTransparency>100</layerTransparency>
+  <displayfield>node_id</displayfield>
+  <label>0</label>
+  <labelattributes>
+    <label fieldname="" text="Label"/>
+    <family fieldname="" name="Sans"/>
+    <size fieldname="" units="pt" value="12"/>
+    <bold fieldname="" on="0"/>
+    <italic fieldname="" on="0"/>
+    <underline fieldname="" on="0"/>
+    <strikeout fieldname="" on="0"/>
+    <color fieldname="" red="0" blue="0" green="0"/>
+    <x fieldname=""/>
+    <y fieldname=""/>
+    <offset x="0" y="0" units="pt" yfieldname="" xfieldname=""/>
+    <angle fieldname="" value="0" auto="0"/>
+    <alignment fieldname="" value="center"/>
+    <buffercolor fieldname="" red="255" blue="255" green="255"/>
+    <buffersize fieldname="" units="pt" value="1"/>
+    <bufferenabled fieldname="" on=""/>
+    <multilineenabled fieldname="" on=""/>
+    <selectedonly on=""/>
+  </labelattributes>
+</qgis>

+ 51 - 0
db_manager/db_plugins/postgis/plugins/versioning/__init__.py

@@ -0,0 +1,51 @@
+"""
+/***************************************************************************
+Name                 : Versioning plugin for DB Manager
+Description          : Set up versioning support for a table
+Date                 : Mar 12, 2012
+copyright            : (C) 2012 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt
+from qgis.PyQt.QtWidgets import QAction, QApplication
+from qgis.PyQt.QtGui import QIcon
+
+
+# The load function is called when the "db" database or either one of its
+# children db objects (table o schema) is selected by the user.
+# @param db is the selected database
+# @param mainwindow is the DBManager mainwindow
+
+
+def load(db, mainwindow):
+    # add the action to the DBManager menu
+    action = QAction(QIcon(), QApplication.translate("DBManagerPlugin", "&Change Logging…"), db)
+    mainwindow.registerAction(action, QApplication.translate("DBManagerPlugin", "&Table"), run)
+
+
+# The run function is called once the user clicks on the action TopoViewer
+# (look above at the load function) from the DBManager menu/toolbar.
+# @param item is the selected db item (either db, schema or table)
+# @param action is the clicked action on the DBManager menu/toolbar
+# @param mainwindow is the DBManager mainwindow
+def run(item, action, mainwindow):
+    from .dlg_versioning import DlgVersioning
+
+    dlg = DlgVersioning(item, mainwindow)
+
+    QApplication.restoreOverrideCursor()
+    try:
+        dlg.exec_()
+    finally:
+        QApplication.setOverrideCursor(Qt.WaitCursor)

+ 285 - 0
db_manager/db_plugins/postgis/plugins/versioning/dlg_versioning.py

@@ -0,0 +1,285 @@
+"""
+/***************************************************************************
+Name                 : Versioning plugin for DB Manager
+Description          : Set up versioning support for a table
+Date                 : Mar 12, 2012
+copyright            : (C) 2012 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+Based on PG_Manager by Martin Dobias <wonder.sk@gmail.com> (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt
+from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QMessageBox, QApplication
+
+from .ui_DlgVersioning import Ui_DlgVersioning
+
+from .....dlg_db_error import DlgDbError
+from ....plugin import BaseError, Table
+
+
+class DlgVersioning(QDialog, Ui_DlgVersioning):
+
+    def __init__(self, item, parent=None):
+        QDialog.__init__(self, parent)
+        self.item = item
+        self.setupUi(self)
+
+        self.db = self.item.database()
+        self.schemas = self.db.schemas()
+        self.hasSchemas = self.schemas is not None
+
+        self.buttonBox.accepted.connect(self.onOK)
+        self.buttonBox.helpRequested.connect(self.showHelp)
+
+        self.populateSchemas()
+        self.populateTables()
+
+        if isinstance(item, Table):
+            index = self.cboTable.findText(self.item.name)
+            if index >= 0:
+                self.cboTable.setCurrentIndex(index)
+
+        self.cboSchema.currentIndexChanged.connect(self.populateTables)
+
+        # updates of SQL window
+        self.cboSchema.currentIndexChanged.connect(self.updateSql)
+        self.cboTable.currentIndexChanged.connect(self.updateSql)
+        self.chkCreateCurrent.stateChanged.connect(self.updateSql)
+        self.editPkey.textChanged.connect(self.updateSql)
+        self.editStart.textChanged.connect(self.updateSql)
+        self.editEnd.textChanged.connect(self.updateSql)
+        self.editUser.textChanged.connect(self.updateSql)
+
+        self.updateSql()
+
+    def populateSchemas(self):
+        self.cboSchema.clear()
+        if not self.hasSchemas:
+            self.hideSchemas()
+            return
+
+        index = -1
+        for schema in self.schemas:
+            self.cboSchema.addItem(schema.name)
+            if hasattr(self.item, 'schema') and schema.name == self.item.schema().name:
+                index = self.cboSchema.count() - 1
+        self.cboSchema.setCurrentIndex(index)
+
+    def hideSchemas(self):
+        self.cboSchema.setEnabled(False)
+
+    def populateTables(self):
+        self.tables = []
+
+        schemas = self.db.schemas()
+        if schemas is not None:
+            schema_name = self.cboSchema.currentText()
+            matching_schemas = [x for x in schemas if x.name == schema_name]
+            tables = matching_schemas[0].tables() if len(matching_schemas) > 0 else []
+        else:
+            tables = self.db.tables()
+
+        self.cboTable.clear()
+        for table in tables:
+            if table.type == table.VectorType:  # contains geometry column?
+                self.tables.append(table)
+                self.cboTable.addItem(table.name)
+
+    def get_escaped_name(self, schema, table, suffix):
+        name = self.db.connector.quoteId("%s%s" % (table, suffix))
+        schema_name = self.db.connector.quoteId(schema) if schema else None
+        return "%s.%s" % (schema_name, name) if schema_name else name
+
+    def updateSql(self):
+        if self.cboTable.currentIndex() < 0 or len(self.tables) < self.cboTable.currentIndex():
+            return
+
+        self.table = self.tables[self.cboTable.currentIndex()]
+        self.schematable = self.table.quotedName()
+
+        self.current = self.chkCreateCurrent.isChecked()
+
+        self.colPkey = self.db.connector.quoteId(self.editPkey.text())
+        self.colStart = self.db.connector.quoteId(self.editStart.text())
+        self.colEnd = self.db.connector.quoteId(self.editEnd.text())
+        self.colUser = self.db.connector.quoteId(self.editUser.text())
+
+        self.columns = [self.db.connector.quoteId(x.name) for x in self.table.fields()]
+
+        self.colOrigPkey = None
+        for constr in self.table.constraints():
+            if constr.type == constr.TypePrimaryKey:
+                self.origPkeyName = self.db.connector.quoteId(constr.name)
+                self.colOrigPkey = [self.db.connector.quoteId(x_y[1].name) for x_y in iter(list(constr.fields().items()))]
+                break
+
+        if self.colOrigPkey is None:
+            self.txtSql.setPlainText("Table doesn't have a primary key!")
+            self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)
+            return
+        elif len(self.colOrigPkey) > 1:
+            self.txtSql.setPlainText("Table has multicolumn primary key!")
+            self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)
+            return
+
+        # take first (and only column of the pkey)
+        self.colOrigPkey = self.colOrigPkey[0]
+
+        # define view, function, rule and trigger names
+        self.view = self.get_escaped_name(self.table.schemaName(), self.table.name, "_current")
+
+        self.func_at_time = self.get_escaped_name(self.table.schemaName(), self.table.name, "_at_time")
+        self.func_update = self.get_escaped_name(self.table.schemaName(), self.table.name, "_update")
+        self.func_insert = self.get_escaped_name(self.table.schemaName(), self.table.name, "_insert")
+
+        self.rule_del = self.get_escaped_name(None, self.table.name, "_del")
+        self.trigger_update = self.get_escaped_name(None, self.table.name, "_update")
+        self.trigger_insert = self.get_escaped_name(None, self.table.name, "_insert")
+
+        sql = []
+
+        # modify table: add serial column, start time, end time
+        sql.append(self.sql_alterTable())
+        # add primary key to the table
+        sql.append(self.sql_setPkey())
+
+        sql.append(self.sql_currentView())
+        # add X_at_time, X_update, X_delete functions
+        sql.append(self.sql_functions())
+        # add insert, update trigger, delete rule
+        sql.append(self.sql_triggers())
+        # add _current view + updatable
+        # if self.current:
+        sql.append(self.sql_updatesView())
+
+        self.txtSql.setPlainText('\n\n'.join(sql))
+        self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(True)
+
+        return sql
+
+    def showHelp(self):
+        helpText = """In this dialog you can set up versioning support for a table. The table will be modified so that all changes will be recorded: there will be a column with start time and end time. Every row will have its start time, end time is assigned when the feature gets deleted. When a row is modified, the original data is marked with end time and new row is created. With this system, it's possible to get back to state of the table any time in history. When selecting rows from the table, you will always have to specify at what time do you want the rows."""
+        QMessageBox.information(self, "Help", helpText)
+
+    def sql_alterTable(self):
+        return "ALTER TABLE %s ADD %s serial, ADD %s timestamp default '-infinity', ADD %s timestamp, ADD %s varchar;" % (
+            self.schematable, self.colPkey, self.colStart, self.colEnd, self.colUser)
+
+    def sql_setPkey(self):
+        return "ALTER TABLE %s DROP CONSTRAINT %s, ADD PRIMARY KEY (%s);" % (
+            self.schematable, self.origPkeyName, self.colPkey)
+
+    def sql_currentView(self):
+        cols = self.colPkey + "," + ",".join(self.columns)
+
+        return "CREATE VIEW %(view)s AS SELECT %(cols)s FROM %(schematable)s WHERE %(end)s IS NULL;" % \
+               {'view': self.view, 'cols': cols, 'schematable': self.schematable, 'end': self.colEnd}
+
+    def sql_functions(self):
+        cols = ",".join(self.columns)
+        all_cols = self.colPkey + "," + ",".join(self.columns)
+        old_cols = ",".join("OLD." + x for x in self.columns)
+
+        sql = """
+CREATE OR REPLACE FUNCTION %(func_at_time)s(timestamp)
+RETURNS SETOF %(view)s AS
+$$
+SELECT %(all_cols)s FROM %(schematable)s WHERE
+  ( SELECT CASE WHEN %(end)s IS NULL THEN (%(start)s <= $1) ELSE (%(start)s <= $1 AND %(end)s > $1) END );
+$$
+LANGUAGE 'sql';
+
+CREATE OR REPLACE FUNCTION %(func_update)s()
+RETURNS TRIGGER AS
+$$
+BEGIN
+  IF OLD.%(end)s IS NOT NULL THEN
+    RETURN NULL;
+  END IF;
+  IF NEW.%(end)s IS NULL THEN
+    INSERT INTO %(schematable)s (%(cols)s, %(start)s, %(end)s) VALUES (%(oldcols)s, OLD.%(start)s, current_timestamp);
+    NEW.%(start)s = current_timestamp;
+    NEW.%(user)s = current_user;
+  END IF;
+  RETURN NEW;
+END;
+$$
+LANGUAGE 'plpgsql';
+
+CREATE OR REPLACE FUNCTION %(func_insert)s()
+RETURNS trigger AS
+$$
+BEGIN
+  if NEW.%(start)s IS NULL then
+    NEW.%(start)s = now();
+    NEW.%(end)s = null;
+    NEW.%(user)s = current_user;
+  end if;
+  RETURN NEW;
+END;
+$$
+LANGUAGE 'plpgsql';""" % {'view': self.view, 'schematable': self.schematable, 'cols': cols, 'oldcols': old_cols,
+                          'start': self.colStart, 'end': self.colEnd, 'user': self.colUser, 'func_at_time': self.func_at_time,
+                          'all_cols': all_cols, 'func_update': self.func_update, 'func_insert': self.func_insert}
+        return sql
+
+    def sql_triggers(self):
+        return """
+CREATE RULE %(rule_del)s AS ON DELETE TO %(schematable)s
+DO INSTEAD UPDATE %(schematable)s SET %(end)s = current_timestamp WHERE %(pkey)s = OLD.%(pkey)s AND %(end)s IS NULL;
+
+CREATE TRIGGER %(trigger_update)s BEFORE UPDATE ON %(schematable)s
+FOR EACH ROW EXECUTE PROCEDURE %(func_update)s();
+
+CREATE TRIGGER %(trigger_insert)s BEFORE INSERT ON %(schematable)s
+FOR EACH ROW EXECUTE PROCEDURE %(func_insert)s();""" % \
+               {'rule_del': self.rule_del, 'trigger_update': self.trigger_update, 'trigger_insert': self.trigger_insert,
+                'func_update': self.func_update, 'func_insert': self.func_insert, 'schematable': self.schematable,
+                'pkey': self.colPkey, 'end': self.colEnd}
+
+    def sql_updatesView(self):
+        cols = ",".join(self.columns)
+        return_cols = self.colPkey + "," + ",".join(self.columns)
+        new_cols = ",".join("NEW." + x for x in self.columns)
+        assign_cols = ",".join("%s = NEW.%s" % (x, x) for x in self.columns)
+
+        return """
+CREATE OR REPLACE RULE "_DELETE" AS ON DELETE TO %(view)s DO INSTEAD
+  DELETE FROM %(schematable)s WHERE %(origpkey)s = old.%(origpkey)s;
+CREATE OR REPLACE RULE "_INSERT" AS ON INSERT TO %(view)s DO INSTEAD
+  INSERT INTO %(schematable)s (%(cols)s) VALUES (%(newcols)s) RETURNING %(return_cols)s;
+CREATE OR REPLACE RULE "_UPDATE" AS ON UPDATE TO %(view)s DO INSTEAD
+  UPDATE %(schematable)s SET %(assign)s WHERE %(origpkey)s = NEW.%(origpkey)s;""" % {'view': self.view,
+                                                                                     'schematable': self.schematable,
+                                                                                     'cols': cols, 'newcols': new_cols,
+                                                                                     'return_cols': return_cols,
+                                                                                     'assign': assign_cols,
+                                                                                     'origpkey': self.colOrigPkey}
+
+    def onOK(self):
+        # execute and commit the code
+        QApplication.setOverrideCursor(Qt.WaitCursor)
+        try:
+            sql = "\n".join(self.updateSql())
+            self.db.connector._execute_and_commit(sql)
+
+        except BaseError as e:
+            DlgDbError.showError(e, self)
+            return
+
+        finally:
+            QApplication.restoreOverrideCursor()
+
+        QMessageBox.information(self, "DB Manager", "Versioning was successfully created.")
+        self.accept()

+ 125 - 0
db_manager/db_plugins/postgis/plugins/versioning/ui_DlgVersioning.py

@@ -0,0 +1,125 @@
+# -*- coding: utf-8 -*-
+
+# Form implementation generated from reading ui file 'D:/src/osgeo4w/src/qgis-ltr/qgis/python/plugins/db_manager/db_plugins/postgis/plugins/versioning/DlgVersioning.ui'
+#
+# Created by: PyQt5 UI code generator 5.15.10
+#
+# WARNING: Any manual changes made to this file will be lost when pyuic5 is
+# run again.  Do not edit this file unless you know what you are doing.
+
+
+from PyQt5 import QtCore, QtGui, QtWidgets
+
+
+class Ui_DlgVersioning(object):
+    def setupUi(self, DlgVersioning):
+        DlgVersioning.setObjectName("DlgVersioning")
+        DlgVersioning.resize(774, 395)
+        self.gridLayout_3 = QtWidgets.QGridLayout(DlgVersioning)
+        self.gridLayout_3.setObjectName("gridLayout_3")
+        self.verticalLayout = QtWidgets.QVBoxLayout()
+        self.verticalLayout.setObjectName("verticalLayout")
+        self.label_4 = QtWidgets.QLabel(DlgVersioning)
+        self.label_4.setObjectName("label_4")
+        self.verticalLayout.addWidget(self.label_4)
+        self.gridLayout = QtWidgets.QGridLayout()
+        self.gridLayout.setObjectName("gridLayout")
+        self.cboSchema = QtWidgets.QComboBox(DlgVersioning)
+        self.cboSchema.setObjectName("cboSchema")
+        self.gridLayout.addWidget(self.cboSchema, 0, 1, 1, 1)
+        self.label_2 = QtWidgets.QLabel(DlgVersioning)
+        self.label_2.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter)
+        self.label_2.setObjectName("label_2")
+        self.gridLayout.addWidget(self.label_2, 0, 0, 1, 1)
+        self.label_3 = QtWidgets.QLabel(DlgVersioning)
+        sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred)
+        sizePolicy.setHorizontalStretch(0)
+        sizePolicy.setVerticalStretch(0)
+        sizePolicy.setHeightForWidth(self.label_3.sizePolicy().hasHeightForWidth())
+        self.label_3.setSizePolicy(sizePolicy)
+        self.label_3.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter)
+        self.label_3.setObjectName("label_3")
+        self.gridLayout.addWidget(self.label_3, 1, 0, 1, 1)
+        self.cboTable = QtWidgets.QComboBox(DlgVersioning)
+        self.cboTable.setObjectName("cboTable")
+        self.gridLayout.addWidget(self.cboTable, 1, 1, 1, 1)
+        self.verticalLayout.addLayout(self.gridLayout)
+        self.chkCreateCurrent = QtWidgets.QCheckBox(DlgVersioning)
+        self.chkCreateCurrent.setChecked(True)
+        self.chkCreateCurrent.setObjectName("chkCreateCurrent")
+        self.verticalLayout.addWidget(self.chkCreateCurrent)
+        self.groupBox_2 = QtWidgets.QGroupBox(DlgVersioning)
+        self.groupBox_2.setObjectName("groupBox_2")
+        self.gridLayout_2 = QtWidgets.QGridLayout(self.groupBox_2)
+        self.gridLayout_2.setObjectName("gridLayout_2")
+        self.label_6 = QtWidgets.QLabel(self.groupBox_2)
+        self.label_6.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter)
+        self.label_6.setObjectName("label_6")
+        self.gridLayout_2.addWidget(self.label_6, 0, 0, 1, 1)
+        self.editPkey = QtWidgets.QLineEdit(self.groupBox_2)
+        self.editPkey.setObjectName("editPkey")
+        self.gridLayout_2.addWidget(self.editPkey, 0, 1, 1, 1)
+        self.label_7 = QtWidgets.QLabel(self.groupBox_2)
+        self.label_7.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter)
+        self.label_7.setObjectName("label_7")
+        self.gridLayout_2.addWidget(self.label_7, 1, 0, 1, 1)
+        self.editStart = QtWidgets.QLineEdit(self.groupBox_2)
+        self.editStart.setObjectName("editStart")
+        self.gridLayout_2.addWidget(self.editStart, 1, 1, 1, 1)
+        self.label_8 = QtWidgets.QLabel(self.groupBox_2)
+        self.label_8.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter)
+        self.label_8.setObjectName("label_8")
+        self.gridLayout_2.addWidget(self.label_8, 2, 0, 1, 1)
+        self.editEnd = QtWidgets.QLineEdit(self.groupBox_2)
+        self.editEnd.setObjectName("editEnd")
+        self.gridLayout_2.addWidget(self.editEnd, 2, 1, 1, 1)
+        self.label_9 = QtWidgets.QLabel(self.groupBox_2)
+        self.label_9.setObjectName("label_9")
+        self.gridLayout_2.addWidget(self.label_9, 3, 0, 1, 1)
+        self.editUser = QtWidgets.QLineEdit(self.groupBox_2)
+        self.editUser.setObjectName("editUser")
+        self.gridLayout_2.addWidget(self.editUser, 3, 1, 1, 1)
+        self.verticalLayout.addWidget(self.groupBox_2)
+        spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
+        self.verticalLayout.addItem(spacerItem)
+        self.gridLayout_3.addLayout(self.verticalLayout, 0, 0, 2, 1)
+        self.label_5 = QtWidgets.QLabel(DlgVersioning)
+        self.label_5.setObjectName("label_5")
+        self.gridLayout_3.addWidget(self.label_5, 0, 1, 1, 1)
+        self.txtSql = QtWidgets.QTextBrowser(DlgVersioning)
+        self.txtSql.setObjectName("txtSql")
+        self.gridLayout_3.addWidget(self.txtSql, 1, 1, 1, 1)
+        self.buttonBox = QtWidgets.QDialogButtonBox(DlgVersioning)
+        self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
+        self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Help|QtWidgets.QDialogButtonBox.Ok)
+        self.buttonBox.setObjectName("buttonBox")
+        self.gridLayout_3.addWidget(self.buttonBox, 2, 0, 1, 2)
+
+        self.retranslateUi(DlgVersioning)
+        self.buttonBox.rejected.connect(DlgVersioning.reject) # type: ignore
+        QtCore.QMetaObject.connectSlotsByName(DlgVersioning)
+        DlgVersioning.setTabOrder(self.cboSchema, self.cboTable)
+        DlgVersioning.setTabOrder(self.cboTable, self.chkCreateCurrent)
+        DlgVersioning.setTabOrder(self.chkCreateCurrent, self.editPkey)
+        DlgVersioning.setTabOrder(self.editPkey, self.editStart)
+        DlgVersioning.setTabOrder(self.editStart, self.editEnd)
+        DlgVersioning.setTabOrder(self.editEnd, self.txtSql)
+        DlgVersioning.setTabOrder(self.txtSql, self.buttonBox)
+
+    def retranslateUi(self, DlgVersioning):
+        _translate = QtCore.QCoreApplication.translate
+        DlgVersioning.setWindowTitle(_translate("DlgVersioning", "Add Change Logging Support to a Table"))
+        self.label_4.setText(_translate("DlgVersioning", "Table should have a primary key"))
+        self.label_2.setText(_translate("DlgVersioning", "Schema"))
+        self.label_3.setText(_translate("DlgVersioning", "Table"))
+        self.chkCreateCurrent.setText(_translate("DlgVersioning", "Create a view with current content (<TABLE>_current)"))
+        self.groupBox_2.setTitle(_translate("DlgVersioning", "New columns"))
+        self.label_6.setText(_translate("DlgVersioning", "Primary key"))
+        self.editPkey.setText(_translate("DlgVersioning", "id_hist"))
+        self.label_7.setText(_translate("DlgVersioning", "Start time"))
+        self.editStart.setText(_translate("DlgVersioning", "time_start"))
+        self.label_8.setText(_translate("DlgVersioning", "End time"))
+        self.editEnd.setText(_translate("DlgVersioning", "time_end"))
+        self.label_9.setText(_translate("DlgVersioning", "User role"))
+        self.editUser.setText(_translate("DlgVersioning", "user_role"))
+        self.label_5.setText(_translate("DlgVersioning", "SQL to be executed"))

+ 234 - 0
db_manager/db_plugins/postgis/sql_dictionary.py

@@ -0,0 +1,234 @@
+"""
+***************************************************************************
+    sql_dictionary.py
+    ---------------------
+    Date                 : April 2012
+    Copyright            : (C) 2012 by Giuseppe Sucameli
+    Email                : brush dot tyler at gmail dot com
+***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************
+"""
+
+__author__ = 'Giuseppe Sucameli'
+__date__ = 'April 2012'
+__copyright__ = '(C) 2012, Giuseppe Sucameli'
+
+# keywords
+keywords = [
+    # TODO get them from a reference page
+    "action", "add", "after", "all", "alter", "analyze", "and", "as", "asc",
+    "before", "begin", "between", "by", "cascade", "case", "cast", "check",
+    "collate", "column", "commit", "constraint", "create", "cross", "current_date",
+    "current_time", "current_timestamp", "default", "deferrable", "deferred",
+    "delete", "desc", "distinct", "drop", "each", "else", "end", "escape",
+    "except", "exists", "for", "foreign", "from", "full", "group", "having",
+    "ignore", "immediate", "in", "initially", "inner", "insert", "intersect",
+    "into", "is", "isnull", "join", "key", "left", "like", "limit", "match",
+    "natural", "no", "not", "notnull", "null", "of", "offset", "on", "or", "order",
+    "outer", "primary", "references", "release", "restrict", "right", "rollback",
+    "row", "savepoint", "select", "set", "table", "temporary", "then", "to",
+    "transaction", "trigger", "union", "unique", "update", "using", "values",
+    "view", "when", "where",
+
+    "absolute", "admin", "aggregate", "alias", "allocate", "analyse", "any", "are",
+    "array", "asensitive", "assertion", "asymmetric", "at", "atomic",
+    "authorization", "avg", "bigint", "binary", "bit", "bit_length", "blob",
+    "boolean", "both", "breadth", "call", "called", "cardinality", "cascaded",
+    "catalog", "ceil", "ceiling", "char", "character", "character_length",
+    "char_length", "class", "clob", "close", "coalesce", "collation", "collect",
+    "completion", "condition", "connect", "connection", "constraints",
+    "constructor", "continue", "convert", "corr", "corresponding", "count",
+    "covar_pop", "covar_samp", "cube", "cume_dist", "current",
+    "current_default_transform_group", "current_path", "current_role",
+    "current_transform_group_for_type", "current_user", "cursor", "cycle", "data",
+    "date", "day", "deallocate", "dec", "decimal", "declare", "dense_rank",
+    "depth", "deref", "describe", "descriptor", "destroy", "destructor",
+    "deterministic", "diagnostics", "dictionary", "disconnect", "do", "domain",
+    "double", "dynamic", "element", "end-exec", "equals", "every", "exception",
+    "exec", "execute", "exp", "external", "extract", "false", "fetch", "filter",
+    "first", "float", "floor", "found", "free", "freeze", "function", "fusion",
+    "general", "get", "global", "go", "goto", "grant", "grouping", "hold", "host",
+    "hour", "identity", "ilike", "indicator", "initialize", "inout", "input",
+    "insensitive", "int", "integer", "intersection", "interval", "isolation",
+    "iterate", "language", "large", "last", "lateral", "leading", "less", "level",
+    "ln", "local", "localtime", "localtimestamp", "locator", "lower", "map", "max",
+    "member", "merge", "method", "min", "minute", "mod", "modifies", "modify",
+    "module", "month", "multiset", "names", "national", "nchar", "nclob", "new",
+    "next", "none", "normalize", "nullif", "numeric", "object", "octet_length",
+    "off", "old", "only", "open", "operation", "option", "ordinality", "out",
+    "output", "over", "overlaps", "overlay", "pad", "parameter", "parameters",
+    "partial", "partition", "path", "percentile_cont", "percentile_disc",
+    "percent_rank", "placing", "position", "postfix", "power", "precision",
+    "prefix", "preorder", "prepare", "preserve", "prior", "privileges",
+    "procedure", "public", "range", "rank", "read", "reads", "real", "recursive",
+    "ref", "referencing", "regr_avgx", "regr_avgy", "regr_count", "regr_intercept",
+    "regr_r2", "regr_slope", "regr_sxx", "regr_sxy", "regr_syy", "relative",
+    "result", "return", "returning", "returns", "revoke", "role", "rollup",
+    "routine", "rows", "row_number", "schema", "scope", "scroll", "search",
+    "second", "section", "sensitive", "sequence", "session", "session_user",
+    "sets", "similar", "size", "smallint", "some", "space", "specific",
+    "specifictype", "sql", "sqlcode", "sqlerror", "sqlexception", "sqlstate",
+    "sqlwarning", "sqrt", "start", "state", "statement", "static", "stddev_pop",
+    "stddev_samp", "structure", "submultiset", "substring", "sum", "symmetric",
+    "system", "system_user", "tablesample", "terminate", "than", "time",
+    "timestamp", "timezone_hour", "timezone_minute", "trailing", "translate",
+    "translation", "treat", "trim", "true", "uescape", "under", "unknown",
+    "unnest", "upper", "usage", "user", "value", "varchar", "variable", "varying",
+    "var_pop", "var_samp", "verbose", "whenever", "width_bucket", "window", "with",
+    "within", "without", "work", "write", "xml", "xmlagg", "xmlattributes",
+    "xmlbinary", "xmlcomment", "xmlconcat", "xmlelement", "xmlforest",
+    "xmlnamespaces", "xmlparse", "xmlpi", "xmlroot", "xmlserialize", "year", "zone"
+]
+postgis_keywords = []
+
+# functions
+functions = [
+    "coalesce",
+    "nullif", "quote", "random",
+    "replace", "soundex"
+]
+operators = [
+    ' AND ', ' OR ', '||', ' < ', ' <= ', ' > ', ' >= ', ' = ', ' <> ', ' IS ', ' IS NOT ', ' IN ', ' LIKE ', ' GLOB ', ' MATCH ', ' REGEXP '
+]
+
+math_functions = [
+    # SQL math functions
+    "Abs", "ACos", "ASin", "ATan", "Cos", "Cot", "Degrees", "Exp", "Floor", "Log", "Log2",
+    "Log10", "Pi", "Radians", "Round", "Sign", "Sin", "Sqrt", "StdDev_Pop", "StdDev_Samp", "Tan",
+    "Var_Pop", "Var_Samp"]
+
+string_functions = ["Length", "Lower", "Upper", "Like", "Trim", "LTrim", "RTrim", "Replace", "Substr"]
+
+aggregate_functions = [
+    "Max", "Min", "Avg", "Count", "Sum", "Group_Concat", "Total", "Var_Pop", "Var_Samp", "StdDev_Pop", "StdDev_Samp"
+]
+
+postgis_functions = [  # from http://www.postgis.org/docs/reference.html
+                       # 7.1. PostgreSQL PostGIS Types
+                       "*box2d", "*box3d", "*box3d_extent", "*geometry", "*geometry_dump", "*geography",
+                       # 7.2. Management Functions
+                       "*addgeometrycolumn", "*dropgeometrycolumn", "*dropgeometrytable", "*postgis_full_version",
+                       "*postgis_geos_version", "*postgis_libxml_version", "*postgis_lib_build_date",
+                       "*postgis_lib_version", "*postgis_proj_version", "*postgis_scripts_build_date",
+                       "*postgis_scripts_installed", "*postgis_scripts_released", "*postgis_uses_stats", "*postgis_version",
+                       "*populate_geometry_columns", "*probe_geometry_columns", "*updategeometrysrid",
+                       # 7.3. Geometry Constructors
+                       "*ST_bdpolyfromtext", "*ST_bdmpolyfromtext", "*ST_geogfromtext", "*ST_geographyfromtext",
+                       "*ST_geogfromwkb", "*ST_geomcollfromtext", "*ST_geomfromewkb", "*ST_geomfromewkt",
+                       "*ST_geometryfromtext", "*ST_geomfromgml", "*ST_geomfromkml", "*ST_gmltosql", "*ST_geomfromtext",
+                       "*ST_geomfromwkb", "*ST_linefrommultipoint", "*ST_linefromtext", "*ST_linefromwkb",
+                       "*ST_linestringfromwkb", "*ST_makebox2d", "*ST_makebox3d", "ST_MakeLine", "*ST_makeenvelope",
+                       "ST_MakePolygon", "ST_MakePoint", "ST_MakePointM", "*ST_MLinefromtext", "*ST_mpointfromtext",
+                       "*ST_mpolyfromtext", "ST_Point", "*ST_pointfromtext", "*ST_pointfromwkb", "ST_Polygon",
+                       "*ST_polygonfromtext", "*ST_wkbtosql", "*ST_wkttosql",
+                       # 7.4. Geometry Accessors
+                       "GeometryType", "ST_Boundary", "*ST_coorddim", "ST_Dimension", "ST_EndPoint", "ST_Envelope",
+                       "ST_ExteriorRing", "ST_GeometryN", "ST_GeometryType", "ST_InteriorRingN", "ST_isClosed",
+                       "ST_isEmpty", "ST_isRing", "ST_isSimple", "ST_isValid", "ST_isValidReason", "ST_M", "ST_NDims",
+                       "ST_NPoints", "ST_NRings", "ST_NumGeometries", "ST_NumInteriorrings", "ST_NumInteriorring",
+                       "ST_NumPoints", "ST_PointN", "ST_Srid", "ST_StartPoint", "ST_Summary", "ST_X", "ST_Y", "ST_Z",
+                       "*ST_zmflag",
+                       # 7.5. Geometry Editors
+                       "ST_AddPoint", "ST_Affine", "ST_Force2D", "*ST_Force3D", "*ST_Force3dZ", "*ST_Force3DM",
+                       "*ST_Force_4d", "*ST_force_collection", "*ST_forcerhr", "*ST_linemerge", "*ST_collectionextract",
+                       "ST_Multi", "*ST_removepoint", "*ST_reverse", "*ST_rotate", "*ST_rotatex", "*ST_rotatey",
+                       "*ST_rotatez", "*ST_scale", "*ST_segmentize", "*ST_setpoint", "ST_SetSrid", "ST_SnapToGrid",
+                       "ST_Transform", "ST_Translate", "*ST_transscale",
+                       # 7.6. Geometry Outputs
+                       "*ST_asbinary", "*ST_asewkb", "*ST_asewkt", "*ST_asgeojson", "*ST_asgml", "*ST_ashexewkb", "*ST_askml",
+                       "*ST_assvg", "*ST_geohash", "ST_Astext",
+                       # 7.7. Operators
+                       # 7.8. Spatial Relationships and Measurements
+                       "ST_Area", "ST_Azimuth", "ST_Centroid", "ST_ClosestPoint", "ST_Contains", "ST_ContainsProperly",
+                       "ST_Covers", "ST_CoveredBy", "ST_Crosses", "*ST_linecrossingdirection", "ST_Cisjoint",
+                       "ST_Distance", "*ST_hausdorffdistance", "*ST_maxdistance", "ST_Distance_Sphere",
+                       "ST_Distance_Spheroid", "*ST_DFullyWithin", "ST_DWithin", "ST_Equals", "*ST_hasarc",
+                       "ST_Intersects", "ST_Length", "*ST_Length2d", "*ST_length3d", "ST_Length_Spheroid",
+                       "*ST_length2d_spheroid", "*ST_length3d_spheroid", "*ST_longestline", "*ST_orderingequals",
+                       "ST_Overlaps", "*ST_perimeter", "*ST_perimeter2d", "*ST_perimeter3d", "ST_PointOnSurface",
+                       "ST_Relate", "ST_ShortestLine", "ST_Touches", "ST_Within",
+                       # 7.9. Geometry Processing Functions
+                       "ST_Buffer", "ST_BuildArea", "ST_Collect", "ST_ConvexHull", "*ST_curvetoline", "ST_Difference",
+                       "ST_Dump", "*ST_dumppoints", "*ST_dumprings", "ST_Intersection", "*ST_linetocurve", "*ST_memunion",
+                       "*ST_minimumboundingcircle", "*ST_polygonize", "*ST_shift_longitude", "ST_Simplify",
+                       "ST_SimplifyPreserveTopology", "ST_SymDifference", "ST_Union",
+                       # 7.10. Linear Referencing
+                       "ST_Line_Interpolate_Point", "ST_Line_Locate_Point", "ST_Line_Substring",
+                       "*ST_locate_along_measure", "*ST_locate_between_measures", "*ST_locatebetweenelevations",
+                       "*ST_addmeasure",
+                       # 7.11. Long Transactions Support
+                       "*addauth", "*checkauth", "*disablelongtransactions", "*enablelongtransactions", "*lockrow",
+                       "*unlockrows",
+                       # 7.12. Miscellaneous Functions
+                       "*ST_accum", "*box2d", "*box3d", "*ST_estimated_extent", "*ST_expand", "ST_Extent", "*ST_extent3d",
+                       "*find_srid", "*ST_mem_size", "*ST_point_inside_circle", "ST_XMax", "ST_XMin", "ST_YMax", "ST_YMin",
+                       "ST_ZMax", "ST_ZMin",
+                       # 7.13. Exceptional Functions
+                       "*postgis_addbbox", "*postgis_dropbbox", "*postgis_hasbbox",
+                       # Raster functions
+                       "AddRasterConstraints", "DropRasterConstraints", "AddOverviewConstraints", "DropOverviewConstraints",
+                       "PostGIS_GDAL_Version", "PostGIS_Raster_Lib_Build_Date", "PostGIS_Raster_Lib_Version", "ST_GDALDrivers",
+                       "UpdateRasterSRID", "ST_CreateOverview", "ST_AddBand", "ST_AsRaster", "ST_Band", "ST_MakeEmptyCoverage",
+                       "ST_MakeEmptyRaster", "ST_Tile", "ST_Retile", "ST_FromGDALRaster", "ST_GeoReference", "ST_Height",
+                       "ST_IsEmpty", "ST_MemSize", "ST_MetaData", "ST_NumBands", "ST_PixelHeight", "ST_PixelWidth", "ST_ScaleX",
+                       "ST_ScaleY", "ST_RasterToWorldCoord", "ST_RasterToWorldCoordX", "ST_RasterToWorldCoordY", "ST_Rotation",
+                       "ST_SkewX", "ST_SkewY", "ST_SRID", "ST_Summary", "ST_UpperLeftX", "ST_UpperLeftY", "ST_Width",
+                       "ST_WorldToRasterCoord", "ST_WorldToRasterCoordX", "ST_WorldToRasterCoordY", "ST_BandMetaData",
+                       "ST_BandNoDataValue", "ST_BandIsNoData", "ST_BandPath", "ST_BandFileSize", "ST_BandFileTimestamp",
+                       "ST_BandPixelType", "ST_MinPossibleValue", "ST_HasNoBand", "ST_PixelAsPolygon", "ST_PixelAsPolygons",
+                       "ST_PixelAsPoint", "ST_PixelAsPoints", "ST_PixelAsCentroid", "ST_PixelAsCentroids", "ST_Value",
+                       "ST_NearestValue", "ST_Neighborhood", "ST_SetValue", "ST_SetValues", "ST_DumpValues", "ST_PixelOfValue",
+                       "ST_SetGeoReference", "ST_SetRotation", "ST_SetScale", "ST_SetSkew", "ST_SetSRID", "ST_SetUpperLeft",
+                       "ST_Resample", "ST_Rescale", "ST_Reskew", "ST_SnapToGrid", "ST_Resize", "ST_Transform",
+                       "ST_SetBandNoDataValue", "ST_SetBandIsNoData", "ST_SetBandPath", "ST_SetBandIndex", "ST_Count",
+                       "ST_CountAgg", "ST_Histogram", "ST_Quantile", "ST_SummaryStats", "ST_SummaryStatsAgg", "ST_ValueCount",
+                       "ST_RastFromWKB", "ST_RastFromHexWKB", "ST_AsBinary", "ST_AsWKB", "ST_AsHexWKB", "ST_AsGDALRaster",
+                       "ST_AsJPEG", "ST_AsPNG", "ST_AsTIFF", "ST_Clip", "ST_ColorMap", "ST_Grayscale", "ST_Intersection",
+                       "ST_MapAlgebra", "ST_MapAlgebraExpr", "ST_MapAlgebraFct", "ST_MapAlgebraFctNgb", "ST_Reclass", "ST_Union",
+                       "ST_Distinct4ma", "ST_InvDistWeight4ma", "ST_Max4ma", "ST_Mean4ma", "ST_Min4ma", "ST_MinDist4ma",
+                       "ST_Range4ma", "ST_StdDev4ma", "ST_Sum4ma", "ST_Aspect", "ST_HillShade", "ST_Roughness", "ST_Slope",
+                       "ST_TPI", "ST_TRI",
+]
+
+# constants
+constants = ["null", "false", "true"]
+postgis_constants = []
+
+
+def getSqlDictionary(spatial=True):
+    def strip_star(s):
+        if s[0] == '*':
+            return s.lower()[1:]
+        else:
+            return s.lower()
+
+    k, c, f = list(keywords), list(constants), list(functions)
+
+    if spatial:
+        k += postgis_keywords
+        f += postgis_functions
+        c += postgis_constants
+
+    return {'keyword': list(map(strip_star, k)), 'constant': list(map(strip_star, c)), 'function': list(map(strip_star, f))}
+
+
+def getQueryBuilderDictionary():
+    # concat functions
+    def ff(l):
+        return [s for s in l if s[0] != '*']
+
+    def add_paren(l):
+        return [s + "(" for s in l]
+
+    foo = sorted(add_paren(ff(list(set.union(set(functions), set(postgis_functions))))))
+    m = sorted(add_paren(ff(math_functions)))
+    agg = sorted(add_paren(ff(aggregate_functions)))
+    op = ff(operators)
+    s = sorted(add_paren(ff(string_functions)))
+    return {'function': foo, 'math': m, 'aggregate': agg, 'operator': op, 'string': s}

+ 0 - 0
db_manager/db_plugins/spatialite/__init__.py


+ 737 - 0
db_manager/db_plugins/spatialite/connector.py

@@ -0,0 +1,737 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from functools import cmp_to_key
+
+from qgis.core import Qgis, QgsSqliteUtils
+from qgis.PyQt.QtCore import QFile
+from qgis.PyQt.QtWidgets import QApplication
+
+from ..connector import DBConnector
+from ..plugin import ConnectionError, DbError, Table
+
+from qgis.utils import spatialite_connect
+import sqlite3 as sqlite
+
+
+def classFactory():
+    return SpatiaLiteDBConnector
+
+
+class SpatiaLiteDBConnector(DBConnector):
+
+    def __init__(self, uri):
+        DBConnector.__init__(self, uri)
+
+        self.dbname = uri.database()
+        if not QFile.exists(self.dbname):
+            raise ConnectionError(QApplication.translate("DBManagerPlugin", '"{0}" not found').format(self.dbname))
+
+        try:
+            self.connection = spatialite_connect(self._connectionInfo())
+
+        except self.connection_error_types() as e:
+            raise ConnectionError(e)
+
+        self._checkSpatial()
+        self._checkRaster()
+
+    def _connectionInfo(self):
+        return str(self.dbname)
+
+    def cancel(self):
+        # https://www.sqlite.org/c3ref/interrupt.html
+        # This function causes any pending database operation to abort and return at its earliest opportunity.
+        if self.connection:
+            self.connection.interrupt()
+
+    @classmethod
+    def isValidDatabase(self, path):
+        if not QFile.exists(path):
+            return False
+        try:
+            conn = spatialite_connect(path)
+        except self.connection_error_types():
+            return False
+
+        isValid = False
+
+        try:
+            c = conn.cursor()
+            c.execute("SELECT count(*) FROM sqlite_master")
+            c.fetchone()
+            isValid = True
+        except sqlite.DatabaseError:
+            pass
+
+        conn.close()
+        return isValid
+
+    def _checkSpatial(self):
+        """ check if it's a valid SpatiaLite db """
+        self.has_spatial = self._checkGeometryColumnsTable()
+        return self.has_spatial
+
+    def _checkRaster(self):
+        """ check if it's a rasterite db """
+        self.has_raster = self._checkRasterTables()
+        return self.has_raster
+
+    def _checkGeometryColumnsTable(self):
+        try:
+            c = self._get_cursor()
+            self._execute(c, "SELECT CheckSpatialMetaData()")
+            v = c.fetchone()[0]
+            self.has_geometry_columns = v == 1 or v == 3
+            self.has_spatialite4 = v == 3
+        except Exception:
+            self.has_geometry_columns = False
+            self.has_spatialite4 = False
+
+        self.has_geometry_columns_access = self.has_geometry_columns
+        return self.has_geometry_columns
+
+    def _checkRasterTables(self):
+        c = self._get_cursor()
+        sql = "SELECT count(*) = 3 FROM sqlite_master WHERE name IN ('layer_params', 'layer_statistics', 'raster_pyramids')"
+        self._execute(c, sql)
+        ret = c.fetchone()
+        return ret and ret[0]
+
+    def getInfo(self):
+        c = self._get_cursor()
+        self._execute(c, "SELECT sqlite_version()")
+        return c.fetchone()
+
+    def getSpatialInfo(self):
+        """ returns tuple about SpatiaLite support:
+                - lib version
+                - geos version
+                - proj version
+        """
+        if not self.has_spatial:
+            return
+
+        c = self._get_cursor()
+        try:
+            self._execute(c, "SELECT spatialite_version(), geos_version(), proj4_version()")
+        except DbError:
+            return
+
+        return c.fetchone()
+
+    def hasSpatialSupport(self):
+        return self.has_spatial
+
+    def hasRasterSupport(self):
+        return self.has_raster
+
+    def hasCustomQuerySupport(self):
+        return Qgis.QGIS_VERSION[0:3] >= "1.6"
+
+    def hasTableColumnEditingSupport(self):
+        return False
+
+    def hasCreateSpatialViewSupport(self):
+        return True
+
+    def fieldTypes(self):
+        return [
+            "integer", "bigint", "smallint",  # integers
+            "real", "double", "float", "numeric",  # floats
+            "varchar", "varchar(255)", "character(20)", "text",  # strings
+            "date", "datetime"  # date/time
+        ]
+
+    def getSchemas(self):
+        return None
+
+    def getTables(self, schema=None, add_sys_tables=False):
+        """ get list of tables """
+        tablenames = []
+        items = []
+
+        sys_tables = QgsSqliteUtils.systemTables()
+
+        try:
+            vectors = self.getVectorTables(schema)
+            for tbl in vectors:
+                if not add_sys_tables and tbl[1] in sys_tables:
+                    continue
+                tablenames.append(tbl[1])
+                items.append(tbl)
+        except DbError:
+            pass
+
+        try:
+            rasters = self.getRasterTables(schema)
+            for tbl in rasters:
+                if not add_sys_tables and tbl[1] in sys_tables:
+                    continue
+                tablenames.append(tbl[1])
+                items.append(tbl)
+        except DbError:
+            pass
+
+        c = self._get_cursor()
+
+        if self.has_geometry_columns:
+            # get the R*Tree tables
+            sql = "SELECT f_table_name, f_geometry_column FROM geometry_columns WHERE spatial_index_enabled = 1"
+            self._execute(c, sql)
+            for idx_item in c.fetchall():
+                sys_tables.append('idx_%s_%s' % idx_item)
+                sys_tables.append('idx_%s_%s_node' % idx_item)
+                sys_tables.append('idx_%s_%s_parent' % idx_item)
+                sys_tables.append('idx_%s_%s_rowid' % idx_item)
+
+        sql = "SELECT name, type = 'view' FROM sqlite_master WHERE type IN ('table', 'view')"
+        self._execute(c, sql)
+
+        for tbl in c.fetchall():
+            if tablenames.count(tbl[0]) <= 0 and not tbl[0].startswith('idx_'):
+                if not add_sys_tables and tbl[0] in sys_tables:
+                    continue
+                item = list(tbl)
+                item.insert(0, Table.TableType)
+                items.append(item)
+
+        for i, tbl in enumerate(items):
+            tbl.insert(3, tbl[1] in sys_tables)
+
+        return sorted(items, key=cmp_to_key(lambda x, y: (x[1] > y[1]) - (x[1] < y[1])))
+
+    def getVectorTables(self, schema=None):
+        """ get list of table with a geometry column
+                it returns:
+                        name (table name)
+                        type = 'view' (is a view?)
+                        geometry_column:
+                                f_table_name (the table name in geometry_columns may be in a wrong case, use this to load the layer)
+                                f_geometry_column
+                                type
+                                coord_dimension
+                                srid
+        """
+
+        if self.has_geometry_columns:
+            if self.has_spatialite4:
+                cols = """CASE geometry_type % 10
+                                  WHEN 1 THEN 'POINT'
+                                  WHEN 2 THEN 'LINESTRING'
+                                  WHEN 3 THEN 'POLYGON'
+                                  WHEN 4 THEN 'MULTIPOINT'
+                                  WHEN 5 THEN 'MULTILINESTRING'
+                                  WHEN 6 THEN 'MULTIPOLYGON'
+                                  WHEN 7 THEN 'GEOMETRYCOLLECTION'
+                                  END AS gtype,
+                                  CASE geometry_type / 1000
+                                  WHEN 0 THEN 'XY'
+                                  WHEN 1 THEN 'XYZ'
+                                  WHEN 2 THEN 'XYM'
+                                  WHEN 3 THEN 'XYZM'
+                                  ELSE NULL
+                                  END AS coord_dimension"""
+            else:
+                cols = "g.type,g.coord_dimension"
+
+            # get geometry info from geometry_columns if exists
+            sql = """SELECT m.name, m.type = 'view', g.f_table_name, g.f_geometry_column, %s, g.srid
+                                                FROM sqlite_master AS m JOIN geometry_columns AS g ON upper(m.name) = upper(g.f_table_name)
+                                                WHERE m.type in ('table', 'view')
+                                                ORDER BY m.name, g.f_geometry_column""" % cols
+
+        else:
+            return []
+
+        c = self._get_cursor()
+        self._execute(c, sql)
+
+        items = []
+        for tbl in c.fetchall():
+            item = list(tbl)
+            item.insert(0, Table.VectorType)
+            items.append(item)
+
+        return items
+
+    def getRasterTables(self, schema=None):
+        """ get list of table with a geometry column
+                it returns:
+                        name (table name)
+                        type = 'view' (is a view?)
+                        geometry_column:
+                                r.table_name (the prefix table name, use this to load the layer)
+                                r.geometry_column
+                                srid
+        """
+
+        if not self.has_geometry_columns:
+            return []
+        if not self.has_raster:
+            return []
+
+        c = self._get_cursor()
+
+        # get geometry info from geometry_columns if exists
+        sql = """SELECT r.table_name||'_rasters', m.type = 'view', r.table_name, r.geometry_column, g.srid
+                                                FROM sqlite_master AS m JOIN geometry_columns AS g ON upper(m.name) = upper(g.f_table_name)
+                                                JOIN layer_params AS r ON upper(REPLACE(m.name, '_metadata', '')) = upper(r.table_name)
+                                                WHERE m.type in ('table', 'view') AND upper(m.name) = upper(r.table_name||'_metadata')
+                                                ORDER BY r.table_name"""
+
+        self._execute(c, sql)
+
+        items = []
+        for i, tbl in enumerate(c.fetchall()):
+            item = list(tbl)
+            item.insert(0, Table.RasterType)
+            items.append(item)
+
+        return items
+
+    def getTableRowCount(self, table):
+        c = self._get_cursor()
+        self._execute(c, "SELECT COUNT(*) FROM %s" % self.quoteId(table))
+        ret = c.fetchone()
+        return ret[0] if ret is not None else None
+
+    def getTableFields(self, table):
+        """ return list of columns in table """
+        c = self._get_cursor()
+        sql = "PRAGMA table_info(%s)" % (self.quoteId(table))
+        self._execute(c, sql)
+        return c.fetchall()
+
+    def getTableIndexes(self, table):
+        """ get info about table's indexes """
+        c = self._get_cursor()
+        sql = "PRAGMA index_list(%s)" % (self.quoteId(table))
+        self._execute(c, sql)
+        indexes = c.fetchall()
+
+        for i, idx in enumerate(indexes):
+            # sqlite has changed the number of columns returned by index_list since 3.8.9
+            # I am not using self.getInfo() here because this behavior
+            # can be changed back without notice as done for index_info, see:
+            # http://repo.or.cz/sqlite.git/commit/53555d6da78e52a430b1884b5971fef33e9ccca4
+            if len(idx) == 3:
+                num, name, unique = idx
+            if len(idx) == 5:
+                num, name, unique, createdby, partial = idx
+            sql = "PRAGMA index_info(%s)" % (self.quoteId(name))
+            self._execute(c, sql)
+
+            idx = [num, name, unique]
+            cols = [
+                cid
+                for seq, cid, cname in c.fetchall()
+            ]
+            idx.append(cols)
+            indexes[i] = idx
+
+        return indexes
+
+    def getTableConstraints(self, table):
+        return None
+
+    def getTableTriggers(self, table):
+        c = self._get_cursor()
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "SELECT name, sql FROM sqlite_master WHERE tbl_name = %s AND type = 'trigger'" % (
+            self.quoteString(tablename))
+        self._execute(c, sql)
+        return c.fetchall()
+
+    def deleteTableTrigger(self, trigger, table=None):
+        """Deletes trigger """
+        sql = "DROP TRIGGER %s" % self.quoteId(trigger)
+        self._execute_and_commit(sql)
+
+    def getTableExtent(self, table, geom):
+        """ find out table extent """
+        schema, tablename = self.getSchemaTableName(table)
+        c = self._get_cursor()
+
+        if self.isRasterTable(table):
+            tablename = tablename.replace('_rasters', '_metadata')
+            geom = 'geometry'
+
+        sql = """SELECT Min(MbrMinX(%(geom)s)), Min(MbrMinY(%(geom)s)), Max(MbrMaxX(%(geom)s)), Max(MbrMaxY(%(geom)s))
+                                                FROM %(table)s """ % {'geom': self.quoteId(geom),
+                                                                      'table': self.quoteId(tablename)}
+        self._execute(c, sql)
+        return c.fetchone()
+
+    def getViewDefinition(self, view):
+        """ returns definition of the view """
+        schema, tablename = self.getSchemaTableName(view)
+        sql = "SELECT sql FROM sqlite_master WHERE type = 'view' AND name = %s" % self.quoteString(tablename)
+        c = self._execute(None, sql)
+        ret = c.fetchone()
+        return ret[0] if ret is not None else None
+
+    def getSpatialRefInfo(self, srid):
+        sql = "SELECT ref_sys_name FROM spatial_ref_sys WHERE srid = %s" % self.quoteString(srid)
+        c = self._execute(None, sql)
+        ret = c.fetchone()
+        return ret[0] if ret is not None else None
+
+    def isVectorTable(self, table):
+        if self.has_geometry_columns:
+            schema, tablename = self.getSchemaTableName(table)
+            sql = "SELECT count(*) FROM geometry_columns WHERE upper(f_table_name) = upper(%s)" % self.quoteString(
+                tablename)
+            c = self._execute(None, sql)
+            ret = c.fetchone()
+            return ret is not None and ret[0] > 0
+        return True
+
+    def isRasterTable(self, table):
+        if self.has_geometry_columns and self.has_raster:
+            schema, tablename = self.getSchemaTableName(table)
+            if not tablename.endswith("_rasters"):
+                return False
+
+            sql = """SELECT count(*)
+                                        FROM layer_params AS r JOIN geometry_columns AS g
+                                                ON upper(r.table_name||'_metadata') = upper(g.f_table_name)
+                                        WHERE upper(r.table_name) = upper(REPLACE(%s, '_rasters', ''))""" % self.quoteString(
+                tablename)
+            c = self._execute(None, sql)
+            ret = c.fetchone()
+            return ret is not None and ret[0] > 0
+
+        return False
+
+    def createTable(self, table, field_defs, pkey):
+        """Creates ordinary table
+                        'fields' is array containing field definitions
+                        'pkey' is the primary key name
+        """
+        if len(field_defs) == 0:
+            return False
+
+        sql = "CREATE TABLE %s (" % self.quoteId(table)
+        sql += ", ".join(field_defs)
+        if pkey is not None and pkey != "":
+            sql += ", PRIMARY KEY (%s)" % self.quoteId(pkey)
+        sql += ")"
+
+        self._execute_and_commit(sql)
+        return True
+
+    def deleteTable(self, table):
+        """Deletes table from the database """
+        if self.isRasterTable(table):
+            return False
+
+        c = self._get_cursor()
+        sql = "DROP TABLE %s" % self.quoteId(table)
+        self._execute(c, sql)
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "DELETE FROM geometry_columns WHERE upper(f_table_name) = upper(%s)" % self.quoteString(tablename)
+        self._execute(c, sql)
+        self._commit()
+
+        return True
+
+    def emptyTable(self, table):
+        """Deletes all rows from table """
+        if self.isRasterTable(table):
+            return False
+
+        sql = "DELETE FROM %s" % self.quoteId(table)
+        self._execute_and_commit(sql)
+
+    def renameTable(self, table, new_table):
+        """ rename a table """
+        schema, tablename = self.getSchemaTableName(table)
+        if new_table == tablename:
+            return
+
+        if self.isRasterTable(table):
+            return False
+
+        c = self._get_cursor()
+
+        sql = "ALTER TABLE %s RENAME TO %s" % (self.quoteId(table), self.quoteId(new_table))
+        self._execute(c, sql)
+
+        # update geometry_columns
+        if self.has_geometry_columns:
+            sql = "UPDATE geometry_columns SET f_table_name = %s WHERE upper(f_table_name) = upper(%s)" % (
+                self.quoteString(new_table), self.quoteString(tablename))
+            self._execute(c, sql)
+
+        self._commit()
+        return True
+
+    def moveTable(self, table, new_table, new_schema=None):
+        return self.renameTable(table, new_table)
+
+    def createView(self, view, query):
+        sql = "CREATE VIEW %s AS %s" % (self.quoteId(view), query)
+        self._execute_and_commit(sql)
+
+    def deleteView(self, view):
+        c = self._get_cursor()
+
+        sql = "DROP VIEW %s" % self.quoteId(view)
+        self._execute(c, sql)
+
+        # update geometry_columns
+        if self.has_geometry_columns:
+            sql = "DELETE FROM geometry_columns WHERE f_table_name = %s" % self.quoteString(view)
+            self._execute(c, sql)
+
+        self._commit()
+
+    def renameView(self, view, new_name):
+        """ rename view """
+        return self.renameTable(view, new_name)
+
+    def createSpatialView(self, view, query):
+
+        self.createView(view, query)
+        # get type info about the view
+        sql = "PRAGMA table_info(%s)" % self.quoteString(view)
+        c = self._execute(None, sql)
+        geom_col = None
+        for r in c.fetchall():
+            if r[2].upper() in ('POINT', 'LINESTRING', 'POLYGON',
+                                'MULTIPOINT', 'MULTILINESTRING', 'MULTIPOLYGON'):
+                geom_col = r[1]
+                break
+        if geom_col is None:
+            return
+
+        # get geometry type and srid
+        sql = "SELECT geometrytype(%s), srid(%s) FROM %s LIMIT 1" % (self.quoteId(geom_col), self.quoteId(geom_col), self.quoteId(view))
+        c = self._execute(None, sql)
+        r = c.fetchone()
+        if r is None:
+            return
+
+        gtype, gsrid = r
+        gdim = 'XY'
+        if ' ' in gtype:
+            zm = gtype.split(' ')[1]
+            gtype = gtype.split(' ')[0]
+            gdim += zm
+        try:
+            wkbType = ('POINT', 'LINESTRING', 'POLYGON', 'MULTIPOINT', 'MULTILINESTRING', 'MULTIPOLYGON').index(gtype) + 1
+        except:
+            wkbType = 0
+        if 'Z' in gdim:
+            wkbType += 1000
+        if 'M' in gdim:
+            wkbType += 2000
+
+        sql = """INSERT INTO geometry_columns (f_table_name, f_geometry_column, geometry_type, coord_dimension, srid, spatial_index_enabled)
+                                        VALUES (%s, %s, %s, %s, %s, 0)""" % (self.quoteId(view), self.quoteId(geom_col), wkbType, len(gdim), gsrid)
+        self._execute_and_commit(sql)
+
+    def runVacuum(self):
+        """ run vacuum on the db """
+        # Workaround http://bugs.python.org/issue28518
+        self.connection.isolation_level = None
+        c = self._get_cursor()
+        c.execute('VACUUM')
+        self.connection.isolation_level = ''  # reset to default isolation
+
+    def addTableColumn(self, table, field_def):
+        """Adds a column to table """
+        sql = "ALTER TABLE %s ADD %s" % (self.quoteId(table), field_def)
+        self._execute(None, sql)
+
+        sql = "SELECT InvalidateLayerStatistics(%s)" % (self.quoteId(table))
+        self._execute(None, sql)
+
+        sql = "SELECT UpdateLayerStatistics(%s)" % (self.quoteId(table))
+        self._execute(None, sql)
+
+        self._commit()
+        return True
+
+    def deleteTableColumn(self, table, column):
+        """Deletes column from a table """
+        if not self.isGeometryColumn(table, column):
+            return False  # column editing not supported
+
+        # delete geometry column correctly
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "SELECT DiscardGeometryColumn(%s, %s)" % (self.quoteString(tablename), self.quoteString(column))
+        self._execute_and_commit(sql)
+
+    def updateTableColumn(self, table, column, new_name, new_data_type=None, new_not_null=None, new_default=None, comment=None):
+        return False  # column editing not supported
+
+    def renameTableColumn(self, table, column, new_name):
+        """ rename column in a table """
+        return False  # column editing not supported
+
+    def setColumnType(self, table, column, data_type):
+        """Changes column type """
+        return False  # column editing not supported
+
+    def setColumnDefault(self, table, column, default):
+        """Changes column's default value. If default=None drop default value """
+        return False  # column editing not supported
+
+    def setColumnNull(self, table, column, is_null):
+        """Changes whether column can contain null values """
+        return False  # column editing not supported
+
+    def isGeometryColumn(self, table, column):
+
+        c = self._get_cursor()
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "SELECT count(*) > 0 FROM geometry_columns WHERE upper(f_table_name) = upper(%s) AND upper(f_geometry_column) = upper(%s)" % (
+            self.quoteString(tablename), self.quoteString(column))
+        self._execute(c, sql)
+        return c.fetchone()[0] == 't'
+
+    def addGeometryColumn(self, table, geom_column='geometry', geom_type='POINT', srid=-1, dim=2):
+
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "SELECT AddGeometryColumn(%s, %s, %d, %s, %s)" % (
+            self.quoteString(tablename), self.quoteString(geom_column), srid, self.quoteString(geom_type), dim)
+        self._execute_and_commit(sql)
+
+    def deleteGeometryColumn(self, table, geom_column):
+        return self.deleteTableColumn(table, geom_column)
+
+    def addTableUniqueConstraint(self, table, column):
+        """Adds a unique constraint to a table """
+        return False  # constraints not supported
+
+    def deleteTableConstraint(self, table, constraint):
+        """Deletes constraint in a table """
+        return False  # constraints not supported
+
+    def addTablePrimaryKey(self, table, column):
+        """Adds a primery key (with one column) to a table """
+        sql = "ALTER TABLE %s ADD PRIMARY KEY (%s)" % (self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def createTableIndex(self, table, name, column, unique=False):
+        """Creates index on one column using default options """
+        unique_str = "UNIQUE" if unique else ""
+        sql = "CREATE %s INDEX %s ON %s (%s)" % (
+            unique_str, self.quoteId(name), self.quoteId(table), self.quoteId(column))
+        self._execute_and_commit(sql)
+
+    def deleteTableIndex(self, table, name):
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "DROP INDEX %s" % self.quoteId((schema, name))
+        self._execute_and_commit(sql)
+
+    def createSpatialIndex(self, table, geom_column='geometry'):
+        if self.isRasterTable(table):
+            return False
+
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "SELECT CreateSpatialIndex(%s, %s)" % (self.quoteString(tablename), self.quoteString(geom_column))
+        self._execute_and_commit(sql)
+
+    def deleteSpatialIndex(self, table, geom_column='geometry'):
+        if self.isRasterTable(table):
+            return False
+
+        schema, tablename = self.getSchemaTableName(table)
+        try:
+            sql = "SELECT DiscardSpatialIndex(%s, %s)" % (self.quoteString(tablename), self.quoteString(geom_column))
+            self._execute_and_commit(sql)
+        except DbError:
+            sql = "SELECT DeleteSpatialIndex(%s, %s)" % (self.quoteString(tablename), self.quoteString(geom_column))
+            self._execute_and_commit(sql)
+            # delete the index table
+            idx_table_name = "idx_%s_%s" % (tablename, geom_column)
+            self.deleteTable(idx_table_name)
+
+    def hasSpatialIndex(self, table, geom_column='geometry'):
+        if not self.has_geometry_columns or self.isRasterTable(table):
+            return False
+        c = self._get_cursor()
+        schema, tablename = self.getSchemaTableName(table)
+        sql = "SELECT spatial_index_enabled FROM geometry_columns WHERE upper(f_table_name) = upper(%s) AND upper(f_geometry_column) = upper(%s)" % (
+            self.quoteString(tablename), self.quoteString(geom_column))
+        self._execute(c, sql)
+        row = c.fetchone()
+        return row is not None and row[0] == 1
+
+    def execution_error_types(self):
+        return sqlite.Error, sqlite.ProgrammingError, sqlite.Warning
+
+    def connection_error_types(self):
+        return sqlite.InterfaceError, sqlite.OperationalError
+
+    # moved into the parent class: DbConnector._execute()
+    # def _execute(self, cursor, sql):
+    #       pass
+
+    # moved into the parent class: DbConnector._execute_and_commit()
+    # def _execute_and_commit(self, sql):
+    #       pass
+
+    # moved into the parent class: DbConnector._get_cursor()
+    # def _get_cursor(self, name=None):
+    #       pass
+
+    # moved into the parent class: DbConnector._fetchall()
+    # def _fetchall(self, c):
+    #       pass
+
+    # moved into the parent class: DbConnector._fetchone()
+    # def _fetchone(self, c):
+    #       pass
+
+    # moved into the parent class: DbConnector._commit()
+    # def _commit(self):
+    #       pass
+
+    # moved into the parent class: DbConnector._rollback()
+    # def _rollback(self):
+    #       pass
+
+    # moved into the parent class: DbConnector._get_cursor_columns()
+    # def _get_cursor_columns(self, c):
+    #       pass
+
+    def getSqlDictionary(self):
+        from .sql_dictionary import getSqlDictionary
+
+        sql_dict = getSqlDictionary()
+
+        items = []
+        for tbl in self.getTables():
+            items.append(tbl[1])  # table name
+
+            for fld in self.getTableFields(tbl[0]):
+                items.append(fld[1])  # field name
+
+        sql_dict["identifier"] = items
+        return sql_dict
+
+    def getQueryBuilderDictionary(self):
+        from .sql_dictionary import getQueryBuilderDictionary
+
+        return getQueryBuilderDictionary()

+ 101 - 0
db_manager/db_plugins/spatialite/data_model.py

@@ -0,0 +1,101 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.core import QgsMessageLog
+from ..plugin import BaseError
+from ..data_model import (TableDataModel,
+                          SqlResultModel,
+                          SqlResultModelAsync,
+                          SqlResultModelTask)
+from .plugin import SLDatabase
+
+
+class SLTableDataModel(TableDataModel):
+
+    def __init__(self, table, parent=None):
+        TableDataModel.__init__(self, table, parent)
+
+        fields_txt = ", ".join(self.fields)
+        table_txt = self.db.quoteId((self.table.schemaName(), self.table.name))
+
+        # run query and get results
+        sql = "SELECT %s FROM %s" % (fields_txt, table_txt)
+        c = self.db._get_cursor()
+        self.db._execute(c, sql)
+
+        self.resdata = self.db._fetchall(c)
+        c.close()
+        del c
+
+        self.fetchedFrom = 0
+        self.fetchedCount = len(self.resdata)
+
+    def _sanitizeTableField(self, field):
+        # get fields, ignore geometry columns
+        dataType = field.dataType.upper()
+        if dataType[:5] == "MULTI":
+            dataType = dataType[5:]
+        if dataType[-3:] == "25D":
+            dataType = dataType[:-3]
+        if dataType[-10:] == "COLLECTION":
+            dataType = dataType[:-10]
+        if dataType in ["POINT", "LINESTRING", "POLYGON", "GEOMETRY"]:
+            return 'GeometryType(%s)' % self.db.quoteId(field.name)
+        return self.db.quoteId(field.name)
+
+    def rowCount(self, index=None):
+        return self.fetchedCount
+
+
+class SLSqlResultModelTask(SqlResultModelTask):
+
+    def __init__(self, db, sql, parent):
+        super().__init__(db, sql, parent)
+        self.clone = None
+
+    def run(self):
+        try:
+            self.clone = SLDatabase(None, self.db.connector.uri())
+            self.model = SLSqlResultModel(self.clone, self.sql, None)
+        except BaseError as e:
+            self.error = e
+            QgsMessageLog.logMessage(e.msg)
+            return False
+
+        return True
+
+    def cancel(self):
+        if self.clone:
+            self.clone.connector.cancel()
+        SqlResultModelTask.cancel(self)
+
+
+class SLSqlResultModelAsync(SqlResultModelAsync):
+
+    def __init__(self, db, sql, parent):
+        super().__init__()
+
+        self.task = SLSqlResultModelTask(db, sql, parent)
+        self.task.taskCompleted.connect(self.modelDone)
+        self.task.taskTerminated.connect(self.modelDone)
+
+
+class SLSqlResultModel(SqlResultModel):
+    pass

+ 67 - 0
db_manager/db_plugins/spatialite/info_model.py

@@ -0,0 +1,67 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtWidgets import QApplication
+
+from ..info_model import DatabaseInfo
+from ..html_elems import HtmlTable, HtmlParagraph
+
+
+class SLDatabaseInfo(DatabaseInfo):
+
+    def __init__(self, db):
+        self.db = db
+
+    def connectionDetails(self):
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Filename:"), self.db.connector.dbname)
+        ]
+        return HtmlTable(tbl)
+
+    def generalInfo(self):
+        info = self.db.connector.getInfo()
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "SQLite version:"), info[0])
+        ]
+        return HtmlTable(tbl)
+
+    def spatialInfo(self):
+        ret = []
+
+        info = self.db.connector.getSpatialInfo()
+        if info is None:
+            return
+
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "Library:"), info[0]),
+            ("GEOS:", info[1]),
+            ("Proj:", info[2])
+        ]
+        ret.append(HtmlTable(tbl))
+
+        if not self.db.connector.has_geometry_columns:
+            ret.append(HtmlParagraph(
+                QApplication.translate("DBManagerPlugin", "<warning> geometry_columns table doesn't exist!\n"
+                                                          "This table is essential for many GIS applications for enumeration of tables.")))
+
+        return ret
+
+    def privilegesDetails(self):
+        return None

+ 309 - 0
db_manager/db_plugins/spatialite/plugin.py

@@ -0,0 +1,309 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+# this will disable the dbplugin if the connector raise an ImportError
+from .connector import SpatiaLiteDBConnector
+
+from qgis.PyQt.QtCore import Qt, QFileInfo, QCoreApplication
+from qgis.PyQt.QtGui import QIcon
+from qgis.PyQt.QtWidgets import QApplication, QAction, QFileDialog
+from qgis.core import Qgis, QgsApplication, QgsDataSourceUri, QgsSettings
+from qgis.gui import QgsMessageBar
+
+from ..plugin import DBPlugin, Database, Table, VectorTable, RasterTable, TableField, TableIndex, TableTrigger, \
+    InvalidDataException
+
+
+def classFactory():
+    return SpatiaLiteDBPlugin
+
+
+class SpatiaLiteDBPlugin(DBPlugin):
+
+    @classmethod
+    def icon(self):
+        return QgsApplication.getThemeIcon("/mIconSpatialite.svg")
+
+    @classmethod
+    def typeName(self):
+        return 'spatialite'
+
+    @classmethod
+    def typeNameString(self):
+        return QCoreApplication.translate('db_manager', 'SpatiaLite')
+
+    @classmethod
+    def providerName(self):
+        return 'spatialite'
+
+    @classmethod
+    def connectionSettingsKey(self):
+        return '/SpatiaLite/connections'
+
+    def databasesFactory(self, connection, uri):
+        return SLDatabase(connection, uri)
+
+    def connect(self, parent=None):
+        conn_name = self.connectionName()
+        settings = QgsSettings()
+        settings.beginGroup("/%s/%s" % (self.connectionSettingsKey(), conn_name))
+
+        if not settings.contains("sqlitepath"):  # non-existent entry?
+            raise InvalidDataException(self.tr('There is no defined database connection "{0}".').format(conn_name))
+
+        database = settings.value("sqlitepath")
+
+        uri = QgsDataSourceUri()
+        uri.setDatabase(database)
+        return self.connectToUri(uri)
+
+    @classmethod
+    def addConnection(self, conn_name, uri):
+        settings = QgsSettings()
+        settings.beginGroup("/%s/%s" % (self.connectionSettingsKey(), conn_name))
+        settings.setValue("sqlitepath", uri.database())
+        return True
+
+    @classmethod
+    def addConnectionActionSlot(self, item, action, parent, index):
+        QApplication.restoreOverrideCursor()
+        try:
+            filename, selected_filter = QFileDialog.getOpenFileName(parent, "Choose SQLite/SpatiaLite file")
+            if not filename:
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        conn_name = QFileInfo(filename).fileName()
+        uri = QgsDataSourceUri()
+        uri.setDatabase(filename)
+        self.addConnection(conn_name, uri)
+        index.internalPointer().itemChanged()
+
+
+class SLDatabase(Database):
+
+    def __init__(self, connection, uri):
+        Database.__init__(self, connection, uri)
+
+    def connectorsFactory(self, uri):
+        return SpatiaLiteDBConnector(uri)
+
+    def dataTablesFactory(self, row, db, schema=None):
+        return SLTable(row, db, schema)
+
+    def vectorTablesFactory(self, row, db, schema=None):
+        return SLVectorTable(row, db, schema)
+
+    def rasterTablesFactory(self, row, db, schema=None):
+        return SLRasterTable(row, db, schema)
+
+    def info(self):
+        from .info_model import SLDatabaseInfo
+
+        return SLDatabaseInfo(self)
+
+    def sqlResultModel(self, sql, parent):
+        from .data_model import SLSqlResultModel
+
+        return SLSqlResultModel(self, sql, parent)
+
+    def sqlResultModelAsync(self, sql, parent):
+        from .data_model import SLSqlResultModelAsync
+
+        return SLSqlResultModelAsync(self, sql, parent)
+
+    def registerDatabaseActions(self, mainWindow):
+        action = QAction(self.tr("Run &Vacuum"), self)
+        mainWindow.registerAction(action, self.tr("&Database"), self.runVacuumActionSlot)
+
+        Database.registerDatabaseActions(self, mainWindow)
+
+    def runVacuumActionSlot(self, item, action, parent):
+        QApplication.restoreOverrideCursor()
+        try:
+            if not isinstance(item, (DBPlugin, Table)) or item.database() is None:
+                parent.infoBar.pushMessage(self.tr("No database selected or you are not connected to it."),
+                                           Qgis.Info, parent.iface.messageTimeout())
+                return
+        finally:
+            QApplication.setOverrideCursor(Qt.WaitCursor)
+
+        self.runVacuum()
+
+    def runVacuum(self):
+        self.database().aboutToChange.emit()
+        self.database().connector.runVacuum()
+        self.database().refresh()
+
+    def runAction(self, action):
+        action = str(action)
+
+        if action.startswith("vacuum/"):
+            if action == "vacuum/run":
+                self.runVacuum()
+                return True
+
+        return Database.runAction(self, action)
+
+    def uniqueIdFunction(self):
+        return None
+
+    def explicitSpatialIndex(self):
+        return True
+
+    def spatialIndexClause(self, src_table, src_column, dest_table, dest_column):
+        return """ "%s".ROWID IN (\nSELECT ROWID FROM SpatialIndex WHERE f_table_name='%s' AND search_frame="%s"."%s") """ % (src_table, src_table, dest_table, dest_column)
+
+    def supportsComment(self):
+        return False
+
+
+class SLTable(Table):
+
+    def __init__(self, row, db, schema=None):
+        Table.__init__(self, db, None)
+        self.name, self.isView, self.isSysTable = row
+
+    def ogrUri(self):
+        ogrUri = "%s|layername=%s" % (self.uri().database(), self.name)
+        return ogrUri
+
+    def mimeUri(self):
+        return Table.mimeUri(self)
+
+    def toMapLayer(self, geometryType=None, crs=None):
+        from qgis.core import QgsVectorLayer
+
+        provider = self.database().dbplugin().providerName()
+        uri = self.uri().uri()
+
+        return QgsVectorLayer(uri, self.name, provider)
+
+    def tableFieldsFactory(self, row, table):
+        return SLTableField(row, table)
+
+    def tableIndexesFactory(self, row, table):
+        return SLTableIndex(row, table)
+
+    def tableTriggersFactory(self, row, table):
+        return SLTableTrigger(row, table)
+
+    def tableDataModel(self, parent):
+        from .data_model import SLTableDataModel
+
+        return SLTableDataModel(self, parent)
+
+
+class SLVectorTable(SLTable, VectorTable):
+
+    def __init__(self, row, db, schema=None):
+        SLTable.__init__(self, row[:-5], db, schema)
+        VectorTable.__init__(self, db, schema)
+        # SpatiaLite does case-insensitive checks for table names, but the
+        # SL provider didn't do the same in QGIS < 1.9, so self.geomTableName
+        # stores the table name like stored in the geometry_columns table
+        self.geomTableName, self.geomColumn, self.geomType, self.geomDim, self.srid = row[-5:]
+
+    def uri(self):
+        uri = self.database().uri()
+        uri.setDataSource('', self.geomTableName, self.geomColumn)
+        return uri
+
+    def hasSpatialIndex(self, geom_column=None):
+        geom_column = geom_column if geom_column is not None else self.geomColumn
+        return self.database().connector.hasSpatialIndex((self.schemaName(), self.name), geom_column)
+
+    def createSpatialIndex(self, geom_column=None):
+        self.aboutToChange.emit()
+        ret = VectorTable.createSpatialIndex(self, geom_column)
+        if ret is not False:
+            self.database().refresh()
+        return ret
+
+    def deleteSpatialIndex(self, geom_column=None):
+        self.aboutToChange.emit()
+        ret = VectorTable.deleteSpatialIndex(self, geom_column)
+        if ret is not False:
+            self.database().refresh()
+        return ret
+
+    def refreshTableEstimatedExtent(self):
+        return
+
+    def runAction(self, action):
+        if SLTable.runAction(self, action):
+            return True
+        return VectorTable.runAction(self, action)
+
+
+class SLRasterTable(SLTable, RasterTable):
+
+    def __init__(self, row, db, schema=None):
+        SLTable.__init__(self, row[:-3], db, schema)
+        RasterTable.__init__(self, db, schema)
+        self.prefixName, self.geomColumn, self.srid = row[-3:]
+        self.geomType = 'RASTER'
+
+        # def info(self):
+        # from .info_model import SLRasterTableInfo
+        # return SLRasterTableInfo(self)
+
+    def rasterliteGdalUri(self):
+        gdalUri = 'RASTERLITE:%s,table=%s' % (self.uri().database(), self.prefixName)
+        return gdalUri
+
+    def mimeUri(self):
+        # QGIS has no provider to load rasters, let's use GDAL
+        uri = "raster:gdal:%s:%s" % (self.name, self.uri().database())
+        return uri
+
+    def toMapLayer(self, geometryType=None, crs=None):
+        from qgis.core import QgsRasterLayer, QgsContrastEnhancement
+
+        # QGIS has no provider to load Rasterlite rasters, let's use GDAL
+        uri = self.rasterliteGdalUri()
+
+        rl = QgsRasterLayer(uri, self.name)
+        if rl.isValid():
+            rl.setContrastEnhancement(QgsContrastEnhancement.StretchToMinimumMaximum)
+        return rl
+
+
+class SLTableField(TableField):
+
+    def __init__(self, row, table):
+        TableField.__init__(self, table)
+        self.num, self.name, self.dataType, self.notNull, self.default, self.primaryKey = row
+        self.hasDefault = self.default
+
+
+class SLTableIndex(TableIndex):
+
+    def __init__(self, row, table):
+        TableIndex.__init__(self, table)
+        self.num, self.name, self.isUnique, self.columns = row
+
+
+class SLTableTrigger(TableTrigger):
+
+    def __init__(self, row, table):
+        TableTrigger.__init__(self, table)
+        self.name, self.function = row

+ 156 - 0
db_manager/db_plugins/spatialite/sql_dictionary.py

@@ -0,0 +1,156 @@
+"""
+***************************************************************************
+    sql_dictionary.py
+    ---------------------
+    Date                 : April 2012
+    Copyright            : (C) 2012 by Giuseppe Sucameli
+    Email                : brush dot tyler at gmail dot com
+***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************
+"""
+
+__author__ = 'Giuseppe Sucameli'
+__date__ = 'April 2012'
+__copyright__ = '(C) 2012, Giuseppe Sucameli'
+
+# keywords
+keywords = [
+    # TODO get them from a reference page
+    "action", "add", "after", "all", "alter", "analyze", "and", "as", "asc",
+    "before", "begin", "between", "by", "cascade", "case", "cast", "check",
+    "collate", "column", "commit", "constraint", "create", "cross", "current_date",
+    "current_time", "current_timestamp", "default", "deferrable", "deferred",
+    "delete", "desc", "distinct", "drop", "each", "else", "end", "escape",
+    "except", "exists", "for", "foreign", "from", "full", "group", "having",
+    "ignore", "immediate", "in", "initially", "inner", "insert", "intersect",
+    "into", "is", "isnull", "join", "key", "left", "like", "limit", "match",
+    "natural", "no", "not", "notnull", "null", "of", "offset", "on", "or", "order",
+    "outer", "primary", "references", "release", "restrict", "right", "rollback",
+    "row", "savepoint", "select", "set", "table", "temporary", "then", "to",
+    "transaction", "trigger", "union", "unique", "update", "using", "values",
+    "view", "when", "where",
+
+    "abort", "attach", "autoincrement", "conflict", "database", "detach",
+    "exclusive", "explain", "fail", "glob", "if", "index", "indexed", "instead",
+    "plan", "pragma", "query", "raise", "regexp", "reindex", "rename", "replace",
+    "temp", "vacuum", "virtual"
+]
+spatialite_keywords = []
+
+# functions
+functions = [
+    # TODO get them from a reference page
+    "changes", "coalesce", "glob", "ifnull", "hex", "last_insert_rowid",
+    "nullif", "quote", "random",
+    "randomblob", "replace", "round", "soundex", "total_change",
+    "typeof", "zeroblob", "date", "datetime", "julianday", "strftime"
+]
+operators = [
+    ' AND ', ' OR ', '||', ' < ', ' <= ', ' > ', ' >= ', ' = ', ' <> ', ' IS ', ' IS NOT ', ' IN ', ' LIKE ', ' GLOB ', ' MATCH ', ' REGEXP '
+]
+
+math_functions = [
+    # SQL math functions
+    "Abs", "ACos", "ASin", "ATan", "Cos", "Cot", "Degrees", "Exp", "Floor", "Log", "Log2",
+    "Log10", "Pi", "Radians", "Round", "Sign", "Sin", "Sqrt", "StdDev_Pop", "StdDev_Samp", "Tan",
+    "Var_Pop", "Var_Samp"]
+
+string_functions = ["Length", "Lower", "Upper", "Like", "Trim", "LTrim", "RTrim", "Replace", "Substr"]
+
+aggregate_functions = [
+    "Max", "Min", "Avg", "Count", "Sum", "Group_Concat", "Total", "Var_Pop", "Var_Samp", "StdDev_Pop", "StdDev_Samp"
+]
+
+spatialite_functions = [  # from www.gaia-gis.it/spatialite-2.3.0/spatialite-sql-2.3.0.html
+                          # SQL utility functions for BLOB objects
+                          "*iszipblob", "*ispdfblob", "*isgifblob", "*ispngblob", "*isjpegblob", "*isexifblob",
+                          "*isexifgpsblob", "*geomfromexifgpsblob", "MakePoint", "BuildMbr", "*buildcirclembr", "ST_MinX",
+                          "ST_MinY", "ST_MaxX", "ST_MaxY",
+                          # SQL functions for constructing a geometric object given its Well-known Text Representation
+                          "ST_GeomFromText", "*pointfromtext",
+                          # SQL functions for constructing a geometric object given its Well-known Binary Representation
+                          "*geomfromwkb", "*pointfromwkb",
+                          # SQL functions for obtaining the Well-known Text / Well-known Binary Representation of a geometric object
+                          "ST_AsText", "ST_AsBinary",
+                          # SQL functions supporting exotic geometric formats
+                          "*assvg", "*asfgf", "*geomfromfgf",
+                          # SQL functions on type Geometry
+                          "ST_Dimension", "ST_GeometryType", "ST_Srid", "ST_SetSrid", "ST_isEmpty", "ST_isSimple", "ST_isValid", "ST_Boundary",
+                          "ST_Envelope",
+                          # SQL functions on type Point
+                          "ST_X", "ST_Y",
+                          # SQL functions on type Curve [Linestring or Ring]
+                          "ST_StartPoint", "ST_EndPoint", "ST_Length", "ST_isClosed", "ST_isRing", "ST_Simplify",
+                          "*simplifypreservetopology",
+                          # SQL functions on type LineString
+                          "ST_NumPoints", "ST_PointN",
+                          # SQL functions on type Surface [Polygon or Ring]
+                          "ST_Centroid", "ST_PointOnSurface", "ST_Area",
+                          # SQL functions on type Polygon
+                          "ST_ExteriorRing", "ST_InteriorRingN",
+                          # SQL functions on type GeomCollection
+                          "ST_NumGeometries", "ST_GeometryN",
+                          # SQL functions that test approximative spatial relationships via MBRs
+                          "MbrEqual", "MbrDisjoint", "MbrTouches", "MbrWithin", "MbrOverlaps", "MbrIntersects",
+                          "MbrContains",
+                          # SQL functions that test spatial relationships
+                          "ST_Equals", "ST_Disjoint", "ST_Touches", "ST_Within", "ST_Overlaps", "ST_Crosses", "ST_Intersects", "ST_Contains",
+                          "ST_Relate",
+                          # SQL functions for distance relationships
+                          "ST_Distance",
+                          # SQL functions that implement spatial operators
+                          "ST_Intersection", "ST_Difference", "ST_Union", "ST_SymDifference", "ST_Buffer", "ST_ConvexHull",
+                          # SQL functions for coordinate transformations
+                          "ST_Transform",
+                          # SQL functions for Spatial-MetaData and Spatial-Index handling
+                          "*initspatialmetadata", "*addgeometrycolumn", "*recovergeometrycolumn", "*discardgeometrycolumn",
+                          "*createspatialindex", "*creatembrcache", "*disablespatialindex",
+                          # SQL functions implementing FDO/OGR compatibility
+                          "*checkspatialmetadata", "*autofdostart", "*autofdostop", "*initfdospatialmetadata",
+                          "*addfdogeometrycolumn", "*recoverfdogeometrycolumn", "*discardfdogeometrycolumn",
+                          # SQL functions for MbrCache-based queries
+                          "*filtermbrwithin", "*filtermbrcontains", "*filtermbrintersects", "*buildmbrfilter"
+]
+
+# constants
+constants = ["null", "false", "true"]
+spatialite_constants = []
+
+
+def getSqlDictionary(spatial=True):
+    def strip_star(s):
+        if s[0] == '*':
+            return s.lower()[1:]
+        else:
+            return s.lower()
+
+    k, c, f = list(keywords), list(constants), list(functions)
+
+    if spatial:
+        k += spatialite_keywords
+        f += spatialite_functions
+        c += spatialite_constants
+
+    return {'keyword': list(map(strip_star, k)), 'constant': list(map(strip_star, c)), 'function': list(map(strip_star, f))}
+
+
+def getQueryBuilderDictionary():
+    # concat functions
+    def ff(l):
+        return [s for s in l if s[0] != '*']
+
+    def add_paren(l):
+        return [s + "(" for s in l]
+
+    foo = sorted(add_paren(ff(list(set.union(set(functions), set(spatialite_functions))))))
+    m = sorted(add_paren(ff(math_functions)))
+    agg = sorted(add_paren(ff(aggregate_functions)))
+    op = ff(operators)
+    s = sorted(add_paren(ff(string_functions)))
+    return {'function': foo, 'math': m, 'aggregate': agg, 'operator': op, 'string': s}

+ 0 - 0
db_manager/db_plugins/vlayers/__init__.py


+ 424 - 0
db_manager/db_plugins/vlayers/connector.py

@@ -0,0 +1,424 @@
+"""
+/***************************************************************************
+Name                 : Virtual layers plugin for DB Manager
+Date                 : December 2015
+copyright            : (C) 2015 by Hugo Mercier
+email                : hugo dot mercier at oslandia dot com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import QUrl, QTemporaryFile
+
+from ..connector import DBConnector
+from ..plugin import Table
+
+from qgis.core import (
+    QgsDataSourceUri,
+    QgsVirtualLayerDefinition,
+    QgsProject,
+    QgsMapLayerType,
+    QgsVectorLayer,
+    QgsCoordinateReferenceSystem,
+    QgsWkbTypes
+)
+
+import sqlite3
+
+
+class sqlite3_connection:
+
+    def __init__(self, sqlite_file):
+        self.conn = sqlite3.connect(sqlite_file)
+
+    def __enter__(self):
+        return self.conn
+
+    def __exit__(self, ex_type, value, traceback):
+        self.conn.close()
+        return ex_type is None
+
+
+def getQueryGeometryName(sqlite_file):
+    # introspect the file
+    with sqlite3_connection(sqlite_file) as conn:
+        c = conn.cursor()
+        for r in c.execute("SELECT url FROM _meta"):
+            d = QgsVirtualLayerDefinition.fromUrl(QUrl(r[0]))
+            if d.hasDefinedGeometry():
+                return d.geometryField()
+        return None
+
+
+def classFactory():
+    return VLayerConnector
+
+
+# Tables in DB Manager are identified by their display names
+# This global registry maps a display name with a layer id
+# It is filled when getVectorTables is called
+class VLayerRegistry:
+    _instance = None
+
+    @classmethod
+    def instance(cls):
+        if cls._instance is None:
+            cls._instance = VLayerRegistry()
+        return cls._instance
+
+    def __init__(self):
+        self.layers = {}
+
+    def reset(self):
+        self.layers = {}
+
+    def has(self, k):
+        return k in self.layers
+
+    def get(self, k):
+        return self.layers.get(k)
+
+    def __getitem__(self, k):
+        return self.get(k)
+
+    def set(self, k, l):
+        self.layers[k] = l
+
+    def __setitem__(self, k, l):
+        self.set(k, l)
+
+    def items(self):
+        return list(self.layers.items())
+
+    def getLayer(self, l):
+        lid = self.layers.get(l)
+        if lid is None:
+            return lid
+        if lid not in QgsProject.instance().mapLayers().keys():
+            self.layers.pop(l)
+            return None
+        return QgsProject.instance().mapLayer(lid)
+
+
+class VLayerConnector(DBConnector):
+
+    def __init__(self, uri):
+        pass
+
+    def _execute(self, cursor, sql):
+        # This is only used to get list of fields
+        class DummyCursor:
+
+            def __init__(self, sql):
+                self.sql = sql
+
+            def close(self):
+                pass
+
+        return DummyCursor(sql)
+
+    def _get_cursor(self, name=None):
+        # fix_print_with_import
+        print(("_get_cursor_", name))
+
+    def _get_cursor_columns(self, c):
+        tf = QTemporaryFile()
+        tf.open()
+        tmp = tf.fileName()
+        tf.close()
+
+        df = QgsVirtualLayerDefinition()
+        df.setFilePath(tmp)
+        df.setQuery(c.sql)
+        p = QgsVectorLayer(df.toString(), "vv", "virtual")
+        if not p.isValid():
+            return []
+        f = [f.name() for f in p.fields()]
+        if p.geometryType() != QgsWkbTypes.NullGeometry:
+            gn = getQueryGeometryName(tmp)
+            if gn:
+                f += [gn]
+        return f
+
+    def uri(self):
+        return QgsDataSourceUri("qgis")
+
+    def getInfo(self):
+        return "info"
+
+    def getSpatialInfo(self):
+        return None
+
+    def hasSpatialSupport(self):
+        return True
+
+    def hasRasterSupport(self):
+        return False
+
+    def hasCustomQuerySupport(self):
+        return True
+
+    def hasTableColumnEditingSupport(self):
+        return False
+
+    def fieldTypes(self):
+        return [
+            "integer", "bigint", "smallint",  # integers
+            "real", "double", "float", "numeric",  # floats
+            "varchar", "varchar(255)", "character(20)", "text",  # strings
+            "date", "datetime"  # date/time
+        ]
+
+    def getSchemas(self):
+        return None
+
+    def getTables(self, schema=None, add_sys_tables=False):
+        """ get list of tables """
+        return self.getVectorTables()
+
+    def getVectorTables(self, schema=None):
+        """ get list of table with a geometry column
+                it returns:
+                        name (table name)
+                        is_system_table
+                        type = 'view' (is a view?)
+                        geometry_column:
+                                f_table_name (the table name in geometry_columns may be in a wrong case, use this to load the layer)
+                                f_geometry_column
+                                type
+                                coord_dimension
+                                srid
+        """
+        reg = VLayerRegistry.instance()
+        VLayerRegistry.instance().reset()
+        lst = []
+        for _, l in QgsProject.instance().mapLayers().items():
+            if l.type() == QgsMapLayerType.VectorLayer:
+
+                lname = l.name()
+                # if there is already a layer with this name, use the layer id
+                # as name
+                if reg.has(lname):
+                    lname = l.id()
+                VLayerRegistry.instance().set(lname, l.id())
+
+                geomType = None
+                dim = None
+                if l.isSpatial():
+                    g = l.dataProvider().wkbType()
+                    g_flat = QgsWkbTypes.flatType(g)
+                    geomType = QgsWkbTypes.displayString(g_flat).upper()
+                    if geomType:
+                        dim = 'XY'
+                        if QgsWkbTypes.hasZ(g):
+                            dim += 'Z'
+                        if QgsWkbTypes.hasM(g):
+                            dim += 'M'
+                    lst.append(
+                        (Table.VectorType, lname, False, False, l.id(), 'geometry', geomType, dim, l.crs().postgisSrid()))
+                else:
+                    lst.append((Table.TableType, lname, False, False))
+        return lst
+
+    def getRasterTables(self, schema=None):
+        return []
+
+    def getTableRowCount(self, table):
+        t = table[1]
+        l = VLayerRegistry.instance().getLayer(t)
+        if not l or not l.isValid():
+            return None
+        return l.featureCount()
+
+    def getTableFields(self, table):
+        """ return list of columns in table """
+        t = table[1]
+        l = VLayerRegistry.instance().getLayer(t)
+        if not l or not l.isValid():
+            return []
+        # id, name, type, nonnull, default, pk
+        n = l.dataProvider().fields().size()
+        f = [(i, f.name(), f.typeName(), False, None, False)
+             for i, f in enumerate(l.dataProvider().fields())]
+        if l.isSpatial():
+            f += [(n, "geometry", "geometry", False, None, False)]
+        return f
+
+    def getTableIndexes(self, table):
+        return []
+
+    def getTableConstraints(self, table):
+        return None
+
+    def getTableTriggers(self, table):
+        return []
+
+    def deleteTableTrigger(self, trigger, table=None):
+        return
+
+    def getTableExtent(self, table, geom):
+        is_id, t = table
+        if is_id:
+            l = QgsProject.instance().mapLayer(t)
+        else:
+            l = VLayerRegistry.instance().getLayer(t)
+        if not l or not l.isValid():
+            return None
+        e = l.extent()
+        r = (e.xMinimum(), e.yMinimum(), e.xMaximum(), e.yMaximum())
+        return r
+
+    def getViewDefinition(self, view):
+        print("**unimplemented** getViewDefinition")
+
+    def getSpatialRefInfo(self, srid):
+        crs = QgsCoordinateReferenceSystem(srid)
+        return crs.description()
+
+    def isVectorTable(self, table):
+        return True
+
+    def isRasterTable(self, table):
+        return False
+
+    def createTable(self, table, field_defs, pkey):
+        print("**unimplemented** createTable")
+        return False
+
+    def deleteTable(self, table):
+        print("**unimplemented** deleteTable")
+        return False
+
+    def emptyTable(self, table):
+        print("**unimplemented** emptyTable")
+        return False
+
+    def renameTable(self, table, new_table):
+        print("**unimplemented** renameTable")
+        return False
+
+    def moveTable(self, table, new_table, new_schema=None):
+        print("**unimplemented** moveTable")
+        return False
+
+    def createView(self, view, query):
+        print("**unimplemented** createView")
+        return False
+
+    def deleteView(self, view):
+        print("**unimplemented** deleteView")
+        return False
+
+    def renameView(self, view, new_name):
+        print("**unimplemented** renameView")
+        return False
+
+    def runVacuum(self):
+        print("**unimplemented** runVacuum")
+        return False
+
+    def addTableColumn(self, table, field_def):
+        print("**unimplemented** addTableColumn")
+        return False
+
+    def deleteTableColumn(self, table, column):
+        print("**unimplemented** deleteTableColumn")
+
+    def updateTableColumn(self, table, column, new_name, new_data_type=None, new_not_null=None, new_default=None, comment=None):
+        print("**unimplemented** updateTableColumn")
+
+    def renameTableColumn(self, table, column, new_name):
+        print("**unimplemented** renameTableColumn")
+        return False
+
+    def setColumnType(self, table, column, data_type):
+        print("**unimplemented** setColumnType")
+        return False
+
+    def setColumnDefault(self, table, column, default):
+        print("**unimplemented** setColumnDefault")
+        return False
+
+    def setColumnNull(self, table, column, is_null):
+        print("**unimplemented** setColumnNull")
+        return False
+
+    def isGeometryColumn(self, table, column):
+        print("**unimplemented** isGeometryColumn")
+        return False
+
+    def addGeometryColumn(self, table, geom_column='geometry', geom_type='POINT', srid=-1, dim=2):
+        print("**unimplemented** addGeometryColumn")
+        return False
+
+    def deleteGeometryColumn(self, table, geom_column):
+        print("**unimplemented** deleteGeometryColumn")
+        return False
+
+    def addTableUniqueConstraint(self, table, column):
+        print("**unimplemented** addTableUniqueConstraint")
+        return False
+
+    def deleteTableConstraint(self, table, constraint):
+        print("**unimplemented** deleteTableConstraint")
+        return False
+
+    def addTablePrimaryKey(self, table, column):
+        print("**unimplemented** addTablePrimaryKey")
+        return False
+
+    def createTableIndex(self, table, name, column, unique=False):
+        print("**unimplemented** createTableIndex")
+        return False
+
+    def deleteTableIndex(self, table, name):
+        print("**unimplemented** deleteTableIndex")
+        return False
+
+    def createSpatialIndex(self, table, geom_column='geometry'):
+        print("**unimplemented** createSpatialIndex")
+        return False
+
+    def deleteSpatialIndex(self, table, geom_column='geometry'):
+        print("**unimplemented** deleteSpatialIndex")
+        return False
+
+    def hasSpatialIndex(self, table, geom_column='geometry'):
+        print("**unimplemented** hasSpatialIndex")
+        return False
+
+    def execution_error_types(self):
+        print("**unimplemented** execution_error_types")
+        return False
+
+    def connection_error_types(self):
+        print("**unimplemented** connection_error_types")
+        return False
+
+    def getSqlDictionary(self):
+        from .sql_dictionary import getSqlDictionary
+        sql_dict = getSqlDictionary()
+
+        items = []
+        for tbl in self.getTables():
+            items.append(tbl[1])  # table name
+
+            for fld in self.getTableFields((None, tbl[1])):
+                items.append(fld[1])  # field name
+
+        sql_dict["identifier"] = items
+        return sql_dict
+
+    def getQueryBuilderDictionary(self):
+        from .sql_dictionary import getQueryBuilderDictionary
+
+        return getQueryBuilderDictionary()

+ 169 - 0
db_manager/db_plugins/vlayers/data_model.py

@@ -0,0 +1,169 @@
+"""
+/***************************************************************************
+Name                 : Virtual layers plugin for DB Manager
+Date                 : December 2015
+copyright            : (C) 2015 by Hugo Mercier
+email                : hugo dot mercier at oslandia dot com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from ..data_model import (TableDataModel,
+                          BaseTableModel,
+                          SqlResultModelAsync,
+                          SqlResultModelTask)
+
+from .connector import VLayerRegistry, getQueryGeometryName
+from .plugin import LVectorTable
+from ..plugin import DbError, BaseError
+
+from qgis.PyQt.QtCore import QTime, QTemporaryFile
+from qgis.core import (QgsVectorLayer,
+                       QgsWkbTypes,
+                       QgsVirtualLayerDefinition,
+                       QgsVirtualLayerTask,
+                       QgsTask)
+
+
+class LTableDataModel(TableDataModel):
+
+    def __init__(self, table, parent=None):
+        TableDataModel.__init__(self, table, parent)
+
+        self.layer = None
+
+        if isinstance(table, LVectorTable):
+            self.layer = VLayerRegistry.instance().getLayer(table.name)
+        else:
+            self.layer = VLayerRegistry.instance().getLayer(table)
+
+        if not self.layer:
+            return
+        # populate self.resdata
+        self.resdata = []
+        for f in self.layer.getFeatures():
+            a = f.attributes()
+            # add the geometry type
+            if f.hasGeometry():
+                a.append(QgsWkbTypes.displayString(f.geometry().wkbType()))
+            else:
+                a.append('None')
+            self.resdata.append(a)
+
+        self.fetchedFrom = 0
+        self.fetchedCount = len(self.resdata)
+
+    def rowCount(self, index=None):
+        if self.layer:
+            return self.layer.featureCount()
+        return 0
+
+
+class LSqlResultModelTask(SqlResultModelTask):
+
+    def __init__(self, db, sql, parent):
+        super().__init__(db, sql, parent)
+
+        tf = QTemporaryFile()
+        tf.open()
+        path = tf.fileName()
+        tf.close()
+
+        df = QgsVirtualLayerDefinition()
+        df.setFilePath(path)
+        df.setQuery(sql)
+
+        self.subtask = QgsVirtualLayerTask(df)
+        self.addSubTask(self.subtask, [], QgsTask.ParentDependsOnSubTask)
+
+    def run(self):
+        try:
+            path = self.subtask.definition().filePath()
+            sql = self.subtask.definition().query()
+            self.model = LSqlResultModel(self.db, sql, None, self.subtask.layer(), path)
+        except Exception as e:
+            self.error = BaseError(str(e))
+            return False
+        return True
+
+    def cancel(self):
+        SqlResultModelTask.cancel(self)
+
+
+class LSqlResultModelAsync(SqlResultModelAsync):
+
+    def __init__(self, db, sql, parent=None):
+        super().__init__()
+
+        self.task = LSqlResultModelTask(db, sql, parent)
+        self.task.taskCompleted.connect(self.modelDone)
+        self.task.taskTerminated.connect(self.modelDone)
+
+    def modelDone(self):
+        self.status = self.task.status
+        self.model = self.task.model
+        if self.task.subtask.exceptionText():
+            self.error = BaseError(self.task.subtask.exceptionText())
+        self.done.emit()
+
+
+class LSqlResultModel(BaseTableModel):
+
+    def __init__(self, db, sql, parent=None, layer=None, path=None):
+        t = QTime()
+        t.start()
+
+        if not layer:
+            tf = QTemporaryFile()
+            tf.open()
+            path = tf.fileName()
+            tf.close()
+
+            df = QgsVirtualLayerDefinition()
+            df.setFilePath(path)
+            df.setQuery(sql)
+            layer = QgsVectorLayer(df.toString(), "vv", "virtual")
+            self._secs = t.elapsed() / 1000.0
+
+        data = []
+        header = []
+
+        if not layer.isValid():
+            raise DbError(layer.dataProvider().error().summary(), sql)
+        else:
+            header = [f.name() for f in layer.fields()]
+            has_geometry = False
+            if layer.geometryType() != QgsWkbTypes.NullGeometry:
+                gn = getQueryGeometryName(path)
+                if gn:
+                    has_geometry = True
+                    header += [gn]
+
+            for f in layer.getFeatures():
+                a = f.attributes()
+                if has_geometry:
+                    if f.hasGeometry():
+                        a += [f.geometry().asWkt()]
+                    else:
+                        a += [None]
+                data += [a]
+
+        self._secs = 0
+        self._affectedRows = len(data)
+
+        BaseTableModel.__init__(self, header, data, parent)
+
+    def secs(self):
+        return self._secs
+
+    def affectedRows(self):
+        return self._affectedRows

+ 44 - 0
db_manager/db_plugins/vlayers/info_model.py

@@ -0,0 +1,44 @@
+"""
+/***************************************************************************
+Name                 : Virtual layers plugin for DB Manager
+Date                 : December 2015
+copyright            : (C) 2015 by Hugo Mercier
+email                : hugo dot mercier at oslandia dot com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtWidgets import QApplication
+
+from ..info_model import DatabaseInfo
+from ..html_elems import HtmlTable
+
+
+class LDatabaseInfo(DatabaseInfo):
+
+    def __init__(self, db):
+        self.db = db
+
+    def connectionDetails(self):
+        tbl = [
+        ]
+        return HtmlTable(tbl)
+
+    def generalInfo(self):
+        self.db.connector.getInfo()
+        tbl = [
+            (QApplication.translate("DBManagerPlugin", "SQLite version:"), "3")
+        ]
+        return HtmlTable(tbl)
+
+    def privilegesDetails(self):
+        return None

+ 195 - 0
db_manager/db_plugins/vlayers/plugin.py

@@ -0,0 +1,195 @@
+"""
+/***************************************************************************
+Name                 : DB Manager plugin for virtual layers
+Date                 : December 2015
+copyright            : (C) 2015 by Hugo Mercier
+email                : hugo dot mercier at oslandia dot com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+# this will disable the dbplugin if the connector raise an ImportError
+from .connector import VLayerConnector
+
+from qgis.PyQt.QtCore import QCoreApplication
+from qgis.PyQt.QtGui import QIcon
+from qgis.core import QgsApplication, QgsVectorLayer, QgsProject, QgsVirtualLayerDefinition
+
+from ..plugin import DBPlugin, Database, Table, VectorTable, TableField
+
+
+def classFactory():
+    return VLayerDBPlugin
+
+
+class VLayerDBPlugin(DBPlugin):
+
+    @classmethod
+    def icon(self):
+        return QgsApplication.getThemeIcon("/mIconVirtualLayer.svg")
+
+    def connectionIcon(self):
+        return QgsApplication.getThemeIcon("/providerQgis.svg")
+
+    @classmethod
+    def typeName(self):
+        return 'vlayers'
+
+    @classmethod
+    def typeNameString(self):
+        return QCoreApplication.translate('db_manager', 'Virtual Layers')
+
+    @classmethod
+    def providerName(self):
+        return 'virtual'
+
+    @classmethod
+    def connectionSettingsKey(self):
+        return 'vlayers'
+
+    @classmethod
+    def connections(self):
+        return [VLayerDBPlugin(QCoreApplication.translate('db_manager', 'Project layers'))]
+
+    def databasesFactory(self, connection, uri):
+        return FakeDatabase(connection, uri)
+
+    def database(self):
+        return self.db
+
+    # def info( self ):
+
+    def connect(self, parent=None):
+        self.connectToUri("qgis")
+        return True
+
+
+class FakeDatabase(Database):
+
+    def __init__(self, connection, uri):
+        Database.__init__(self, connection, uri)
+
+    def connectorsFactory(self, uri):
+        return VLayerConnector(uri)
+
+    def dataTablesFactory(self, row, db, schema=None):
+        return LTable(row, db, schema)
+
+    def vectorTablesFactory(self, row, db, schema=None):
+        return LVectorTable(row, db, schema)
+
+    def rasterTablesFactory(self, row, db, schema=None):
+        return None
+
+    def info(self):
+        from .info_model import LDatabaseInfo
+        return LDatabaseInfo(self)
+
+    def sqlResultModel(self, sql, parent):
+        from .data_model import LSqlResultModel
+        return LSqlResultModel(self, sql, parent)
+
+    def sqlResultModelAsync(self, sql, parent):
+        from .data_model import LSqlResultModelAsync
+        return LSqlResultModelAsync(self, sql, parent)
+
+    def toSqlLayer(self, sql, geomCol, uniqueCol, layerName="QueryLayer", layerType=None, avoidSelectById=False, _filter=""):
+        df = QgsVirtualLayerDefinition()
+        df.setQuery(sql)
+        if uniqueCol is not None:
+            uniqueCol = uniqueCol.strip('"').replace('""', '"')
+            df.setUid(uniqueCol)
+        if geomCol is not None:
+            df.setGeometryField(geomCol)
+        vl = QgsVectorLayer(df.toString(), layerName, "virtual")
+        if _filter:
+            vl.setSubsetString(_filter)
+        return vl
+
+    def registerDatabaseActions(self, mainWindow):
+        return
+
+    def runAction(self, action):
+        return
+
+    def uniqueIdFunction(self):
+        return None
+
+    def explicitSpatialIndex(self):
+        return True
+
+    def spatialIndexClause(self, src_table, src_column, dest_table, dest_column):
+        return '"%s"._search_frame_ = "%s"."%s"' % (src_table, dest_table, dest_column)
+
+    def supportsComment(self):
+        return False
+
+
+class LTable(Table):
+
+    def __init__(self, row, db, schema=None):
+        Table.__init__(self, db, None)
+        self.name, self.isView, self.isSysTable = row
+
+    def tableFieldsFactory(self, row, table):
+        return LTableField(row, table)
+
+    def tableDataModel(self, parent):
+        from .data_model import LTableDataModel
+        return LTableDataModel(self, parent)
+
+    def canBeAddedToCanvas(self):
+        return False
+
+
+class LVectorTable(LTable, VectorTable):
+
+    def __init__(self, row, db, schema=None):
+        LTable.__init__(self, row[:-5], db, schema)
+        VectorTable.__init__(self, db, schema)
+        # SpatiaLite does case-insensitive checks for table names, but the
+        # SL provider didn't do the same in QGIS < 1.9, so self.geomTableName
+        # stores the table name like stored in the geometry_columns table
+        self.geomTableName, self.geomColumn, self.geomType, self.geomDim, self.srid = row[
+            -5:]
+
+    def uri(self):
+        uri = self.database().uri()
+        uri.setDataSource('', self.geomTableName, self.geomColumn)
+        return uri
+
+    def hasSpatialIndex(self, geom_column=None):
+        return True
+
+    def createSpatialIndex(self, geom_column=None):
+        return
+
+    def deleteSpatialIndex(self, geom_column=None):
+        return
+
+    def refreshTableEstimatedExtent(self):
+        self.extent = self.database().connector.getTableExtent(
+            ("id", self.geomTableName), None)
+
+    def runAction(self, action):
+        return
+
+    def toMapLayer(self, geometryType=None, crs=None):
+        return QgsProject.instance().mapLayer(self.geomTableName)
+
+
+class LTableField(TableField):
+
+    def __init__(self, row, table):
+        TableField.__init__(self, table)
+        self.num, self.name, self.dataType, self.notNull, self.default, self.primaryKey = row
+        self.hasDefault = self.default

+ 172 - 0
db_manager/db_plugins/vlayers/sql_dictionary.py

@@ -0,0 +1,172 @@
+"""
+***************************************************************************
+    sql_dictionary.py
+    ---------------------
+    Date                 : December 2015
+    Copyright            : (C) 2015 by Hugo Mercier
+    Email                : hugo dot mercier at oslandia dot com
+***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************
+"""
+
+__author__ = 'Hugo Mercier'
+__date__ = 'December 2015'
+__copyright__ = '(C) 2015, Hugo Mercier'
+
+# keywords
+keywords = [
+    # TODO get them from a reference page
+    "action", "add", "after", "all", "alter", "analyze", "and", "as", "asc",
+    "before", "begin", "between", "by", "cascade", "case", "cast", "check",
+    "collate", "column", "commit", "constraint", "create", "cross", "current_date",
+    "current_time", "current_timestamp", "default", "deferrable", "deferred",
+    "delete", "desc", "distinct", "drop", "each", "else", "end", "escape",
+    "except", "exists", "for", "foreign", "from", "full", "group", "having",
+    "ignore", "immediate", "in", "initially", "inner", "insert", "intersect",
+    "into", "is", "isnull", "join", "key", "left", "like", "limit", "match",
+    "natural", "no", "not", "notnull", "null", "of", "offset", "on", "or", "order",
+    "outer", "primary", "references", "release", "restrict", "right", "rollback",
+    "row", "savepoint", "select", "set", "table", "temporary", "then", "to",
+    "transaction", "trigger", "union", "unique", "update", "using", "values",
+    "view", "when", "where",
+
+    "abort", "attach", "autoincrement", "conflict", "database", "detach",
+    "exclusive", "explain", "fail", "glob", "if", "index", "indexed", "instead",
+    "plan", "pragma", "query", "raise", "regexp", "reindex", "rename", "replace",
+    "temp", "vacuum", "virtual"
+]
+spatialite_keywords = []
+
+# functions
+functions = [
+    # TODO get them from a reference page
+    "changes", "coalesce", "glob", "ifnull", "hex", "last_insert_rowid",
+    "nullif", "quote", "random",
+    "randomblob", "replace", "round", "soundex", "total_change",
+    "typeof", "zeroblob", "date", "datetime", "julianday", "strftime"
+]
+operators = [
+    ' AND ', ' OR ', '||', ' < ', ' <= ', ' > ', ' >= ', ' = ', ' <> ', ' IS ', ' IS NOT ', ' IN ', ' LIKE ', ' GLOB ', ' MATCH ', ' REGEXP '
+]
+
+math_functions = [
+    # SQL math functions
+    "Abs", "ACos", "ASin", "ATan", "Cos", "Cot", "Degrees", "Exp", "Floor", "Log", "Log2",
+    "Log10", "Pi", "Radians", "Round", "Sign", "Sin", "Sqrt", "StdDev_Pop", "StdDev_Samp", "Tan",
+    "Var_Pop", "Var_Samp"]
+
+string_functions = ["Length", "Lower", "Upper", "Like", "Trim", "LTrim", "RTrim", "Replace", "Substr"]
+
+aggregate_functions = [
+    "Max", "Min", "Avg", "Count", "Sum", "Group_Concat", "Total", "Var_Pop", "Var_Samp", "StdDev_Pop", "StdDev_Samp"
+]
+
+spatialite_functions = [  # from www.gaia-gis.it/spatialite-2.3.0/spatialite-sql-2.3.0.html
+                          # SQL utility functions for BLOB objects
+                          "*iszipblob", "*ispdfblob", "*isgifblob", "*ispngblob", "*isjpegblob", "*isexifblob",
+                          "*isexifgpsblob", "*geomfromexifgpsblob", "MakePoint", "BuildMbr", "*buildcirclembr", "ST_MinX",
+                          "ST_MinY", "ST_MaxX", "ST_MaxY",
+                          # SQL functions for constructing a geometric object given its Well-known Text Representation
+                          "ST_GeomFromText", "*pointfromtext",
+                          # SQL functions for constructing a geometric object given its Well-known Binary Representation
+                          "*geomfromwkb", "*pointfromwkb",
+                          # SQL functions for obtaining the Well-known Text / Well-known Binary Representation of a geometric object
+                          "ST_AsText", "ST_AsBinary",
+                          # SQL functions supporting exotic geometric formats
+                          "*assvg", "*asfgf", "*geomfromfgf",
+                          # SQL functions on type Geometry
+                          "ST_Dimension", "ST_GeometryType", "ST_Srid", "ST_SetSrid", "ST_isEmpty", "ST_isSimple", "ST_isValid", "ST_Boundary",
+                          "ST_Envelope",
+                          # SQL functions on type Point
+                          "ST_X", "ST_Y",
+                          # SQL functions on type Curve [Linestring or Ring]
+                          "ST_StartPoint", "ST_EndPoint", "ST_Length", "ST_isClosed", "ST_isRing", "ST_Simplify",
+                          "*simplifypreservetopology",
+                          # SQL functions on type LineString
+                          "ST_NumPoints", "ST_PointN",
+                          # SQL functions on type Surface [Polygon or Ring]
+                          "ST_Centroid", "ST_PointOnSurface", "ST_Area",
+                          # SQL functions on type Polygon
+                          "ST_ExteriorRing", "ST_InteriorRingN",
+                          # SQL functions on type GeomCollection
+                          "ST_NumGeometries", "ST_GeometryN",
+                          # SQL functions that test approximative spatial relationships via MBRs
+                          "MbrEqual", "MbrDisjoint", "MbrTouches", "MbrWithin", "MbrOverlaps", "MbrIntersects",
+                          "MbrContains",
+                          # SQL functions that test spatial relationships
+                          "ST_Equals", "ST_Disjoint", "ST_Touches", "ST_Within", "ST_Overlaps", "ST_Crosses", "ST_Intersects", "ST_Contains",
+                          "ST_Relate",
+                          # SQL functions for distance relationships
+                          "ST_Distance",
+                          # SQL functions that implement spatial operators
+                          "ST_Intersection", "ST_Difference", "ST_Union", "ST_SymDifference", "ST_Buffer", "ST_ConvexHull",
+                          # SQL functions for coordinate transformations
+                          "ST_Transform",
+                          # SQL functions for Spatial-MetaData and Spatial-Index handling
+                          "*initspatialmetadata", "*addgeometrycolumn", "*recovergeometrycolumn", "*discardgeometrycolumn",
+                          "*createspatialindex", "*creatembrcache", "*disablespatialindex",
+                          # SQL functions implementing FDO/OGR compatibility
+                          "*checkspatialmetadata", "*autofdostart", "*autofdostop", "*initfdospatialmetadata",
+                          "*addfdogeometrycolumn", "*recoverfdogeometrycolumn", "*discardfdogeometrycolumn",
+                          # SQL functions for MbrCache-based queries
+                          "*filtermbrwithin", "*filtermbrcontains", "*filtermbrintersects", "*buildmbrfilter"
+]
+
+qgis_functions = [
+    "atan2", "round", "rand", "randf", "clamp", "scale_linear", "scale_polynomial", "scale_exponential", "_pi", "to_int", "toint", "to_real", "toreal",
+    "to_string", "tostring", "to_datetime", "todatetime", "to_date", "todate", "to_time", "totime", "to_interval", "tointerval",
+    "regexp_match", "now", "_now", "age", "year", "month", "week", "day", "hour", "minute", "second", "day_of_week", "title",
+    "levenshtein", "longest_common_substring", "hamming_distance", "wordwrap", "regexp_replace", "regexp_substr", "concat",
+    "strpos", "_left", "_right", "rpad", "lpad", "format", "format_number", "format_date", "color_rgb", "color_rgba", "ramp_color",
+    "color_hsl", "color_hsla", "color_hsv", "color_hsva", "color_cmyk", "color_cmyka", "color_part", "darker", "lighter",
+    "set_color_part", "point_n", "start_point", "end_point", "nodes_to_points", "segments_to_lines", "make_point",
+    "make_point_m", "make_line", "make_polygon", "x_min", "xmin", "x_max", "xmax", "y_min", "ymin", "y_max", "ymax", "geom_from_wkt",
+    "geomFromWKT", "geom_from_gml", "relate", "intersects_bbox", "bbox", "translate", "buffer", "point_on_surface", "reverse",
+    "exterior_ring", "interior_ring_n", "geometry_n", "bounds", "num_points", "num_interior_rings", "num_rings", "num_geometries",
+    "bounds_width", "bounds_height", "is_closed", "convex_hull", "sym_difference", "combine", "_union", "geom_to_wkt", "geomToWKT",
+    "transform", "uuid", "_uuid", "layer_property", "var", "_specialcol_", "project_color"]
+
+
+# constants
+constants = ["null", "false", "true"]
+spatialite_constants = []
+
+
+def getSqlDictionary(spatial=True):
+    def strip_star(s):
+        if s[0] == '*':
+            return s.lower()[1:]
+        else:
+            return s.lower()
+
+    k, c, f = list(keywords), list(constants), list(functions)
+
+    if spatial:
+        k += spatialite_keywords
+        f += spatialite_functions
+        f += qgis_functions
+        c += spatialite_constants
+
+    return {'keyword': list(map(strip_star, k)), 'constant': list(map(strip_star, c)), 'function': list(map(strip_star, f))}
+
+
+def getQueryBuilderDictionary():
+    # concat functions
+    def ff(l):
+        return [s for s in l if s[0] != '*']
+
+    def add_paren(l):
+        return [s + "(" for s in l]
+
+    foo = sorted(add_paren(ff(list(set.union(set(functions), set(spatialite_functions), set(qgis_functions))))))
+    m = sorted(add_paren(ff(math_functions)))
+    agg = sorted(add_paren(ff(aggregate_functions)))
+    op = ff(operators)
+    s = sorted(add_paren(ff(string_functions)))
+    return {'function': foo, 'math': m, 'aggregate': agg, 'operator': op, 'string': s}

+ 177 - 0
db_manager/db_tree.py

@@ -0,0 +1,177 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import pyqtSignal, QCoreApplication
+from qgis.PyQt.QtWidgets import QWidget, QTreeView, QMenu, QLabel
+
+from qgis.core import Qgis, QgsProject, QgsMessageLog
+from qgis.gui import QgsMessageBar, QgsMessageBarItem
+
+from .db_model import DBModel, PluginItem
+from .db_plugins.plugin import DBPlugin, Schema, Table
+from .db_plugins.vlayers.plugin import LTable
+
+
+class DBTree(QTreeView):
+    selectedItemChanged = pyqtSignal(object)
+
+    def __init__(self, mainWindow):
+        QTreeView.__init__(self, mainWindow)
+        self.mainWindow = mainWindow
+
+        self.setModel(DBModel(self))
+        self.setHeaderHidden(True)
+        self.setEditTriggers(QTreeView.EditKeyPressed | QTreeView.SelectedClicked)
+
+        self.setDragEnabled(True)
+        self.setAcceptDrops(True)
+        self.setDropIndicatorShown(True)
+
+        self.doubleClicked.connect(self.addLayer)
+        self.selectionModel().currentChanged.connect(self.currentItemChanged)
+        self.expanded.connect(self.itemChanged)
+        self.collapsed.connect(self.itemChanged)
+        self.model().dataChanged.connect(self.modelDataChanged)
+        self.model().notPopulated.connect(self.collapse)
+
+    def refreshItem(self, item=None):
+        if item is None:
+            item = self.currentItem()
+            if item is None:
+                return
+        self.model().refreshItem(item)
+
+    def showSystemTables(self, show):
+        pass
+
+    def currentItem(self):
+        indexes = self.selectedIndexes()
+        if len(indexes) <= 0:
+            return
+        return self.model().getItem(indexes[0])
+
+    def currentDatabase(self):
+        item = self.currentItem()
+        if item is None:
+            return
+
+        if isinstance(item, (DBPlugin, Schema, Table)):
+            return item.database()
+        return None
+
+    def currentSchema(self):
+        item = self.currentItem()
+        if item is None:
+            return
+
+        if isinstance(item, (Schema, Table)):
+            return item.schema()
+        return None
+
+    def currentTable(self):
+        item = self.currentItem()
+
+        if isinstance(item, Table):
+            return item
+        return None
+
+    def newConnection(self):
+        index = self.currentIndex()
+        if not index.isValid() or not isinstance(index.internalPointer(), PluginItem):
+            return
+        item = self.currentItem()
+        self.mainWindow.invokeCallback(item.addConnectionActionSlot, index)
+
+    def itemChanged(self, index):
+        self.setCurrentIndex(index)
+        self.selectedItemChanged.emit(self.currentItem())
+
+    def modelDataChanged(self, indexFrom, indexTo):
+        self.itemChanged(indexTo)
+
+    def currentItemChanged(self, current, previous):
+        self.itemChanged(current)
+
+    def contextMenuEvent(self, ev):
+        index = self.indexAt(ev.pos())
+        if not index.isValid():
+            return
+
+        if index != self.currentIndex():
+            self.itemChanged(index)
+
+        item = self.currentItem()
+
+        menu = QMenu(self)
+
+        if isinstance(item, (Table, Schema)) and not isinstance(item, LTable):
+            menu.addAction(QCoreApplication.translate("DBTree", "Rename…"), self.rename)
+            menu.addAction(QCoreApplication.translate("DBTree", "Delete…"), self.delete)
+
+            if isinstance(item, Table) and item.canBeAddedToCanvas():
+                menu.addSeparator()
+                menu.addAction(self.tr("Add to Canvas"), self.addLayer)
+                item.addExtraContextMenuEntries(menu)
+
+        elif isinstance(item, DBPlugin):
+            if item.database() is not None:
+                menu.addAction(self.tr("Re-connect"), self.reconnect)
+            menu.addAction(self.tr("Remove"), self.delete)
+
+        elif not index.parent().isValid() and item.typeName() in ("spatialite", "gpkg"):
+            menu.addAction(QCoreApplication.translate("DBTree", "New Connection…"), self.newConnection)
+
+        if not menu.isEmpty():
+            menu.exec_(ev.globalPos())
+
+        menu.deleteLater()
+
+    def rename(self):
+        item = self.currentItem()
+        if isinstance(item, (Table, Schema)):
+            self.edit(self.currentIndex())
+
+    def delete(self):
+        item = self.currentItem()
+        if isinstance(item, (Table, Schema)):
+            self.mainWindow.invokeCallback(item.database().deleteActionSlot)
+        elif isinstance(item, DBPlugin):
+            self.mainWindow.invokeCallback(item.removeActionSlot)
+
+    def addLayer(self):
+        table = self.currentTable()
+        if table is not None:
+            layer = table.toMapLayer(table.geometryType())
+            layers = QgsProject.instance().addMapLayers([layer])
+            if len(layers) != 1:
+                QgsMessageLog.logMessage(
+                    self.tr("%1 is an invalid layer - not loaded").replace("%1", layer.publicSource()))
+                msgLabel = QLabel(self.tr(
+                    "%1 is an invalid layer and cannot be loaded. Please check the <a href=\"#messageLog\">message log</a> for further info.").replace(
+                    "%1", layer.publicSource()), self.mainWindow.infoBar)
+                msgLabel.setWordWrap(True)
+                msgLabel.linkActivated.connect(self.mainWindow.iface.mainWindow().findChild(QWidget, "MessageLog").show)
+                msgLabel.linkActivated.connect(self.mainWindow.iface.mainWindow().raise_)
+                self.mainWindow.infoBar.pushItem(QgsMessageBarItem(msgLabel, Qgis.Warning))
+
+    def reconnect(self):
+        db = self.currentDatabase()
+        if db is not None:
+            self.mainWindow.invokeCallback(db.reconnectActionSlot)

+ 68 - 0
db_manager/dlg_add_geometry_column.py

@@ -0,0 +1,68 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : Oct 13, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt
+from qgis.PyQt.QtWidgets import QDialog, QMessageBox, QApplication
+from qgis.utils import OverrideCursor
+
+from .db_plugins.plugin import DbError
+from .dlg_db_error import DlgDbError
+
+from .ui.ui_DlgAddGeometryColumn import Ui_DbManagerDlgAddGeometryColumn as Ui_Dialog
+
+
+class DlgAddGeometryColumn(QDialog, Ui_Dialog):
+    GEOM_TYPES = ["POINT", "LINESTRING", "POLYGON", "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON",
+                  "GEOMETRYCOLLECTION"]
+
+    def __init__(self, parent=None, table=None, db=None):
+        QDialog.__init__(self, parent)
+        self.table = table
+        self.db = self.table.database() if self.table and self.table.database() else db
+        self.setupUi(self)
+
+        self.buttonBox.accepted.connect(self.createGeomColumn)
+
+    def createGeomColumn(self):
+        """ first check whether everything's fine """
+        if self.editName.text() == "":
+            QMessageBox.critical(self, self.tr("DB Manager"), self.tr("Field name must not be empty."))
+            return
+
+        name = self.editName.text()
+        geom_type = self.GEOM_TYPES[self.cboType.currentIndex()]
+        dim = self.spinDim.value()
+        try:
+            srid = int(self.editSrid.text())
+        except ValueError:
+            srid = -1
+        createSpatialIndex = False
+
+        # now create the geometry column
+        with OverrideCursor(Qt.WaitCursor):
+            try:
+                self.table.addGeometryColumn(name, geom_type, srid, dim, createSpatialIndex)
+            except DbError as e:
+                DlgDbError.showError(e, self)
+                return
+
+        self.accept()

+ 73 - 0
db_manager/dlg_create_constraint.py

@@ -0,0 +1,73 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : Oct 13, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt
+from qgis.PyQt.QtWidgets import QDialog, QApplication
+from qgis.utils import OverrideCursor
+
+from .db_plugins.plugin import DbError
+from .dlg_db_error import DlgDbError
+from .db_plugins.plugin import TableConstraint
+
+from .ui.ui_DlgCreateConstraint import Ui_DbManagerDlgCreateConstraint as Ui_Dialog
+
+
+class DlgCreateConstraint(QDialog, Ui_Dialog):
+
+    def __init__(self, parent=None, table=None, db=None):
+        QDialog.__init__(self, parent)
+        self.table = table
+        self.db = self.table.database() if self.table and self.table.database() else db
+        self.setupUi(self)
+
+        self.buttonBox.accepted.connect(self.createConstraint)
+        self.populateColumns()
+
+    def populateColumns(self):
+        self.cboColumn.clear()
+        for fld in self.table.fields():
+            self.cboColumn.addItem(fld.name)
+
+    def createConstraint(self):
+        constr = self.getConstraint()
+
+        # now create the constraint
+        with OverrideCursor(Qt.WaitCursor):
+            try:
+                self.table.addConstraint(constr)
+            except DbError as e:
+                DlgDbError.showError(e, self)
+                return
+
+        self.accept()
+
+    def getConstraint(self):
+        constr = TableConstraint(self.table)
+        constr.name = ""
+        constr.type = TableConstraint.TypePrimaryKey if self.radPrimaryKey.isChecked() else TableConstraint.TypeUnique
+        constr.columns = []
+        column = self.cboColumn.currentText()
+        for fld in self.table.fields():
+            if fld.name == column:
+                constr.columns.append(fld.num)
+                break
+        return constr

+ 80 - 0
db_manager/dlg_create_index.py

@@ -0,0 +1,80 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : Oct 13, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt
+from qgis.PyQt.QtWidgets import QDialog, QMessageBox, QApplication
+from qgis.utils import OverrideCursor
+
+from .db_plugins.plugin import DbError
+from .dlg_db_error import DlgDbError
+from .db_plugins.plugin import TableIndex
+
+from .ui.ui_DlgCreateIndex import Ui_DbManagerDlgCreateIndex as Ui_Dialog
+
+
+class DlgCreateIndex(QDialog, Ui_Dialog):
+
+    def __init__(self, parent=None, table=None, db=None):
+        QDialog.__init__(self, parent)
+        self.table = table
+        self.db = self.table.database() if self.table and self.table.database() else db
+        self.setupUi(self)
+
+        self.buttonBox.accepted.connect(self.createIndex)
+
+        self.cboColumn.currentIndexChanged.connect(self.columnChanged)
+        self.populateColumns()
+
+    def populateColumns(self):
+        self.cboColumn.clear()
+        for fld in self.table.fields():
+            self.cboColumn.addItem(fld.name)
+
+    def columnChanged(self):
+        self.editName.setText("idx_%s_%s" % (self.table.name, self.cboColumn.currentText()))
+
+    def createIndex(self):
+        idx = self.getIndex()
+        if idx.name == "":
+            QMessageBox.critical(self, self.tr("Error"), self.tr("Please enter a name for the index."))
+            return
+
+        # now create the index
+        with OverrideCursor(Qt.WaitCursor):
+            try:
+                self.table.addIndex(idx)
+            except DbError as e:
+                DlgDbError.showError(e, self)
+                return
+
+        self.accept()
+
+    def getIndex(self):
+        idx = TableIndex(self.table)
+        idx.name = self.editName.text()
+        idx.columns = []
+        colname = self.cboColumn.currentText()
+        for fld in self.table.fields():
+            if fld.name == colname:
+                idx.columns.append(fld.num)
+                break
+        return idx

+ 322 - 0
db_manager/dlg_create_table.py

@@ -0,0 +1,322 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : Oct 13, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt, QModelIndex
+from qgis.PyQt.QtWidgets import QItemDelegate, QComboBox, QDialog, QPushButton, QDialogButtonBox, QMessageBox, QApplication
+from qgis.PyQt.QtCore import QItemSelectionModel, pyqtSignal
+
+from qgis.utils import OverrideCursor
+
+from .db_plugins.data_model import TableFieldsModel
+from .db_plugins.plugin import DbError, ConnectionError
+from .dlg_db_error import DlgDbError
+
+from .ui.ui_DlgCreateTable import Ui_DbManagerDlgCreateTable as Ui_Dialog
+
+
+class TableFieldsDelegate(QItemDelegate):
+    """ delegate with some special item editors """
+
+    columnNameChanged = pyqtSignal()
+
+    def __init__(self, field_types, parent=None):
+        QItemDelegate.__init__(self, parent)
+        self.fieldTypes = field_types
+
+    def createEditor(self, parent, option, index):
+        # special combobox for field type
+        if index.column() == 1:
+            cbo = QComboBox(parent)
+            cbo.setEditable(True)
+            cbo.setFrame(False)
+            for item in self.fieldTypes:
+                cbo.addItem(item)
+            return cbo
+        return QItemDelegate.createEditor(self, parent, option, index)
+
+    def setEditorData(self, editor, index):
+        """ load data from model to editor """
+        m = index.model()
+        if index.column() == 1:
+            txt = m.data(index, Qt.DisplayRole)
+            editor.setEditText(txt)
+        else:
+            # use default
+            QItemDelegate.setEditorData(self, editor, index)
+
+    def setModelData(self, editor, model, index):
+        """ save data from editor back to model """
+        if index.column() == 1:
+            model.setData(index, editor.currentText())
+        else:
+            # use default
+            QItemDelegate.setModelData(self, editor, model, index)
+            if index.column() == 0:
+                self.columnNameChanged.emit()
+
+
+class DlgCreateTable(QDialog, Ui_Dialog):
+    GEOM_TYPES = ["POINT", "LINESTRING", "POLYGON", "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON",
+                  "GEOMETRYCOLLECTION"]
+
+    def __init__(self, item, parent=None):
+        QDialog.__init__(self, parent)
+        self.item = item
+        self.setupUi(self)
+
+        self.db = self.item.database()
+        self.schemas = self.db.schemas()
+        self.hasSchemas = self.schemas is not None
+        self.fieldTypes = self.db.connector.fieldTypes()
+
+        m = TableFieldsModel(self, True)  # it's editable
+        self.fields.setModel(m)
+        self.fields.setColumnHidden(3, True)  # hide Default column
+
+        d = TableFieldsDelegate(self.fieldTypes, self)
+        self.fields.setItemDelegate(d)
+
+        self.fields.setColumnWidth(0, 140)
+        self.fields.setColumnWidth(1, 140)
+        self.fields.setColumnWidth(2, 50)
+
+        b = QPushButton(self.tr("&Create"))
+        self.buttonBox.addButton(b, QDialogButtonBox.ActionRole)
+
+        self.btnAddField.clicked.connect(self.addField)
+        self.btnDeleteField.clicked.connect(self.deleteField)
+        self.btnFieldUp.clicked.connect(self.fieldUp)
+        self.btnFieldDown.clicked.connect(self.fieldDown)
+        b.clicked.connect(self.createTable)
+
+        self.chkGeomColumn.clicked.connect(self.updateUi)
+
+        self.fields.selectionModel().selectionChanged.connect(self.updateUiFields)
+        d.columnNameChanged.connect(self.updatePkeyCombo)
+
+        self.populateSchemas()
+        self.updateUi()
+        self.updateUiFields()
+
+    def populateSchemas(self):
+        self.cboSchema.clear()
+        if not self.hasSchemas:
+            self.hideSchemas()
+            return
+
+        index = -1
+        for schema in self.schemas:
+            self.cboSchema.addItem(schema.name)
+            if hasattr(self.item, 'schema') and schema.name == self.item.schema().name:
+                index = self.cboSchema.count() - 1
+        self.cboSchema.setCurrentIndex(index)
+
+    def hideSchemas(self):
+        self.cboSchema.setEnabled(False)
+
+    def updateUi(self):
+        useGeom = self.chkGeomColumn.isChecked()
+        self.cboGeomType.setEnabled(useGeom)
+        self.editGeomColumn.setEnabled(useGeom)
+        self.spinGeomDim.setEnabled(useGeom)
+        self.editGeomSrid.setEnabled(useGeom)
+        self.chkSpatialIndex.setEnabled(useGeom)
+
+    def updateUiFields(self):
+        fld = self.selectedField()
+        if fld is not None:
+            up_enabled = (fld != 0)
+            down_enabled = (fld != self.fields.model().rowCount() - 1)
+            del_enabled = True
+        else:
+            up_enabled, down_enabled, del_enabled = False, False, False
+        self.btnFieldUp.setEnabled(up_enabled)
+        self.btnFieldDown.setEnabled(down_enabled)
+        self.btnDeleteField.setEnabled(del_enabled)
+
+    def updatePkeyCombo(self, selRow=None):
+        """ called when list of columns changes. if 'sel' is None, it keeps current index """
+
+        if selRow is None:
+            selRow = self.cboPrimaryKey.currentIndex()
+
+        self.cboPrimaryKey.clear()
+
+        m = self.fields.model()
+        for row in range(m.rowCount()):
+            name = m.data(m.index(row, 0))
+            self.cboPrimaryKey.addItem(name)
+
+        self.cboPrimaryKey.setCurrentIndex(selRow)
+
+    def addField(self):
+        """Adds new field to the end of field table """
+        m = self.fields.model()
+        newRow = m.rowCount()
+        m.insertRows(newRow, 1)
+
+        indexName = m.index(newRow, 0, QModelIndex())
+        indexType = m.index(newRow, 1, QModelIndex())
+        indexNull = m.index(newRow, 2, QModelIndex())
+
+        m.setData(indexName, "new_field")
+        colType = self.fieldTypes[0]
+        if newRow == 0:
+            # adding the first row, use auto-incrementing column type if any
+            if "serial" in self.fieldTypes:  # PostgreSQL
+                colType = "serial"
+        m.setData(indexType, colType)
+        m.setData(indexNull, None, Qt.DisplayRole)
+        m.setData(indexNull, Qt.Unchecked, Qt.CheckStateRole)
+
+        # selects the new row
+        sel = self.fields.selectionModel()
+        sel.select(indexName, QItemSelectionModel.Rows | QItemSelectionModel.ClearAndSelect)
+
+        # starts editing
+        self.fields.edit(indexName)
+
+        self.updatePkeyCombo(0 if newRow == 0 else None)
+
+    def selectedField(self):
+        sel = self.fields.selectedIndexes()
+        if len(sel) < 1:
+            return None
+        return sel[0].row()
+
+    def deleteField(self):
+        """Deletes selected field """
+        row = self.selectedField()
+        if row is None:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("No field selected."))
+        else:
+            self.fields.model().removeRows(row, 1)
+
+        self.updatePkeyCombo()
+
+    def fieldUp(self):
+        """ move selected field up """
+        row = self.selectedField()
+        if row is None:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("No field selected."))
+            return
+        if row == 0:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("Field is already at the top."))
+            return
+
+        # take row and reinsert it
+        rowdata = self.fields.model().takeRow(row)
+        self.fields.model().insertRow(row - 1, rowdata)
+
+        # set selection again
+        index = self.fields.model().index(row - 1, 0, QModelIndex())
+        self.fields.selectionModel().select(index, QItemSelectionModel.Rows | QItemSelectionModel.ClearAndSelect)
+
+        self.updatePkeyCombo()
+
+    def fieldDown(self):
+        """ move selected field down """
+        row = self.selectedField()
+        if row is None:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("No field selected."))
+            return
+        if row == self.fields.model().rowCount() - 1:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("Field is already at the bottom."))
+            return
+
+        # take row and reinsert it
+        rowdata = self.fields.model().takeRow(row)
+        self.fields.model().insertRow(row + 1, rowdata)
+
+        # set selection again
+        index = self.fields.model().index(row + 1, 0, QModelIndex())
+        self.fields.selectionModel().select(index, QItemSelectionModel.Rows | QItemSelectionModel.ClearAndSelect)
+
+        self.updatePkeyCombo()
+
+    def createTable(self):
+        """Creates table with chosen fields, optionally add a geometry column """
+        if not self.hasSchemas:
+            schema = None
+        else:
+            schema = str(self.cboSchema.currentText())
+            if len(schema) == 0:
+                QMessageBox.information(self, self.tr("DB Manager"), self.tr("A valid schema must be selected first."))
+                return
+
+        table = str(self.editName.text())
+        if len(table) == 0:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("A valid table name is required."))
+            return
+
+        m = self.fields.model()
+        if m.rowCount() == 0:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("At least one field is required."))
+            return
+
+        useGeomColumn = self.chkGeomColumn.isChecked()
+        if useGeomColumn:
+            geomColumn = str(self.editGeomColumn.text())
+            if len(geomColumn) == 0:
+                QMessageBox.information(self, self.tr("DB Manager"), self.tr("A name is required for the geometry column."))
+                return
+
+            geomType = self.GEOM_TYPES[self.cboGeomType.currentIndex()]
+            geomDim = self.spinGeomDim.value()
+            try:
+                geomSrid = int(self.editGeomSrid.text())
+            except ValueError:
+                geomSrid = 0
+            useSpatialIndex = self.chkSpatialIndex.isChecked()
+
+        flds = m.getFields()
+        pk_index = self.cboPrimaryKey.currentIndex()
+        if pk_index >= 0:
+            flds[pk_index].primaryKey = True
+
+        # commit to DB
+        with OverrideCursor(Qt.WaitCursor):
+            try:
+                if not useGeomColumn:
+                    self.db.createTable(table, flds, schema)
+                else:
+                    geom = geomColumn, geomType, geomSrid, geomDim, useSpatialIndex
+                    self.db.createVectorTable(table, flds, geom, schema)
+
+            except (ConnectionError, DbError) as e:
+                DlgDbError.showError(e, self)
+
+        # clear UI
+        self.editName.clear()
+        self.fields.model().removeRows(0, self.fields.model().rowCount())
+        self.cboPrimaryKey.clear()
+        self.chkGeomColumn.setChecked(False)
+        self.chkSpatialIndex.setChecked(False)
+        self.editGeomSrid.clear()
+
+        self.cboGeomType.setEnabled(False)
+        self.editGeomColumn.setEnabled(False)
+        self.spinGeomDim.setEnabled(False)
+        self.editGeomSrid.setEnabled(False)
+        self.chkSpatialIndex.setEnabled(False)
+
+        QMessageBox.information(self, self.tr("DB Manager"), self.tr("Table created successfully."))

+ 55 - 0
db_manager/dlg_db_error.py

@@ -0,0 +1,55 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtWidgets import QDialog
+
+from .ui.ui_DlgDbError import Ui_DbManagerDlgDbError as Ui_Dialog
+from .db_plugins.plugin import DbError
+
+
+class DlgDbError(QDialog, Ui_Dialog):
+
+    def __init__(self, e, parent=None):
+        QDialog.__init__(self, parent)
+        self.setupUi(self)
+
+        def sanitize(txt):
+            return "" if txt is None else "<pre>" + txt.replace('<', '&lt;') + "</pre>"
+
+        if isinstance(e, DbError):
+            self.setQueryMessage(sanitize(e.msg), sanitize(e.query))
+        else:
+            self.setMessage(sanitize(e.msg))
+
+    def setMessage(self, msg):
+        self.txtErrorMsg.setHtml(msg)
+        self.stackedWidget.setCurrentIndex(0)
+
+    def setQueryMessage(self, msg, query):
+        self.txtQueryErrorMsg.setHtml(msg)
+        self.txtQuery.setHtml(query)
+        self.stackedWidget.setCurrentIndex(1)
+
+    @staticmethod
+    def showError(e, parent=None):
+        dlg = DlgDbError(e, parent)
+        dlg.exec_()

+ 198 - 0
db_manager/dlg_export_vector.py

@@ -0,0 +1,198 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : Oct 13, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt, QFileInfo
+from qgis.PyQt.QtWidgets import QDialog, QFileDialog, QMessageBox, QApplication
+from qgis.PyQt.QtGui import QCursor
+
+from qgis.core import (QgsVectorFileWriter,
+                       QgsVectorDataProvider,
+                       QgsCoordinateReferenceSystem,
+                       QgsVectorLayerExporter,
+                       QgsSettings)
+from qgis.utils import OverrideCursor
+
+from .ui.ui_DlgExportVector import Ui_DbManagerDlgExportVector as Ui_Dialog
+
+
+class DlgExportVector(QDialog, Ui_Dialog):
+
+    def __init__(self, inLayer, inDb, parent=None):
+        QDialog.__init__(self, parent)
+        self.inLayer = inLayer
+        self.db = inDb
+        self.setupUi(self)
+
+        vectorFilterName = "lastVectorFileFilter"  # "lastRasterFileFilter"
+        self.lastUsedVectorFilterSettingsKey = "/UI/{}".format(vectorFilterName)
+        self.lastUsedVectorDirSettingsKey = "/UI/{}Dir".format(vectorFilterName)
+
+        # update UI
+        self.setupWorkingMode()
+        self.populateFileFilters()
+        self.populateEncodings()
+
+    def setupWorkingMode(self):
+        # set default values
+        inCrs = self.inLayer.crs()
+        srid = inCrs.postgisSrid() if inCrs.isValid() else 4236
+        self.editSourceSrid.setText("%s" % srid)
+        self.editTargetSrid.setText("%s" % srid)
+
+        self.btnChooseOutputFile.clicked.connect(self.chooseOutputFile)
+        self.checkSupports()
+
+    def checkSupports(self):
+        """ update options available for the current input layer """
+        allowSpatial = self.db.connector.hasSpatialSupport()
+        hasGeomType = self.inLayer and self.inLayer.isSpatial()
+        self.chkSourceSrid.setEnabled(allowSpatial and hasGeomType)
+        self.chkTargetSrid.setEnabled(allowSpatial and hasGeomType)
+        # self.chkSpatialIndex.setEnabled(allowSpatial and hasGeomType)
+
+    def chooseOutputFile(self):
+        # get last used dir
+        settings = QgsSettings()
+        lastUsedDir = settings.value(self.lastUsedVectorDirSettingsKey, ".")
+
+        # get selected filter
+        selected_driver = self.cboFileFormat.currentData()
+        selected_filter = QgsVectorFileWriter.filterForDriver(selected_driver)
+
+        # ask for a filename
+        filename, filter = QFileDialog.getSaveFileName(self, self.tr("Choose where to save the file"), lastUsedDir,
+                                                       selected_filter)
+        if filename == "":
+            return
+
+        ext = selected_filter[selected_filter.find('.'):]
+        ext = ext[:ext.find(' ')]
+
+        if not filename.lower().endswith(ext):
+            filename += ext
+
+        # store the last used dir
+        settings.setValue(self.lastUsedVectorDirSettingsKey, QFileInfo(filename).filePath())
+
+        self.editOutputFile.setText(filename)
+
+    def populateEncodings(self):
+        # populate the combo with supported encodings
+        self.cboEncoding.addItems(QgsVectorDataProvider.availableEncodings())
+
+        # set the last used encoding
+        enc = self.inLayer.dataProvider().encoding()
+        idx = self.cboEncoding.findText(enc)
+        if idx < 0:
+            self.cboEncoding.insertItem(0, enc)
+            idx = 0
+        self.cboEncoding.setCurrentIndex(idx)
+
+    def populateFileFilters(self):
+        # populate the combo with supported vector file formats
+        for driver in QgsVectorFileWriter.ogrDriverList():
+            self.cboFileFormat.addItem(driver.longName, driver.driverName)
+
+        # set the last used filter
+        settings = QgsSettings()
+        filt = settings.value(self.lastUsedVectorFilterSettingsKey, "GPKG")
+
+        idx = self.cboFileFormat.findText(filt)
+        if idx < 0:
+            idx = 0
+        self.cboFileFormat.setCurrentIndex(idx)
+
+    def accept(self):
+        # sanity checks
+        if self.editOutputFile.text() == "":
+            QMessageBox.information(self, self.tr("Export to file"), self.tr("Output file name is required"))
+            return
+
+        if self.chkSourceSrid.isEnabled() and self.chkSourceSrid.isChecked():
+            try:
+                sourceSrid = int(self.editSourceSrid.text())
+            except ValueError:
+                QMessageBox.information(self, self.tr("Export to file"),
+                                        self.tr("Invalid source srid: must be an integer"))
+                return
+
+        if self.chkTargetSrid.isEnabled() and self.chkTargetSrid.isChecked():
+            try:
+                targetSrid = int(self.editTargetSrid.text())
+            except ValueError:
+                QMessageBox.information(self, self.tr("Export to file"),
+                                        self.tr("Invalid target srid: must be an integer"))
+                return
+
+        with OverrideCursor(Qt.WaitCursor):
+            # store current input layer crs, so I can restore it later
+            prevInCrs = self.inLayer.crs()
+            try:
+                uri = self.editOutputFile.text()
+                providerName = "ogr"
+
+                options = {}
+
+                # set the OGR driver will be used
+                driverName = self.cboFileFormat.currentData()
+                options['driverName'] = driverName
+
+                # set the output file encoding
+                if self.chkEncoding.isEnabled() and self.chkEncoding.isChecked():
+                    enc = self.cboEncoding.currentText()
+                    options['fileEncoding'] = enc
+
+                if self.chkDropTable.isChecked():
+                    options['overwrite'] = True
+
+                outCrs = QgsCoordinateReferenceSystem()
+                if self.chkTargetSrid.isEnabled() and self.chkTargetSrid.isChecked():
+                    targetSrid = int(self.editTargetSrid.text())
+                    outCrs = QgsCoordinateReferenceSystem(targetSrid)
+
+                # update input layer crs
+                if self.chkSourceSrid.isEnabled() and self.chkSourceSrid.isChecked():
+                    sourceSrid = int(self.editSourceSrid.text())
+                    inCrs = QgsCoordinateReferenceSystem(sourceSrid)
+                    self.inLayer.setCrs(inCrs)
+
+                # do the export!
+                ret, errMsg = QgsVectorLayerExporter.exportLayer(self.inLayer, uri, providerName, outCrs,
+                                                                 False, options)
+            except Exception as e:
+                ret = -1
+                errMsg = str(e)
+
+            finally:
+                # restore input layer crs and encoding
+                self.inLayer.setCrs(prevInCrs)
+
+        if ret != 0:
+            QMessageBox.warning(self, self.tr("Export to file"), self.tr("Error {0}\n{1}").format(ret, errMsg))
+            return
+
+        # create spatial index
+        # if self.chkSpatialIndex.isEnabled() and self.chkSpatialIndex.isChecked():
+        #       self.db.connector.createSpatialIndex( (schema, table), geom )
+
+        QMessageBox.information(self, self.tr("Export to file"), self.tr("Export finished."))
+        return QDialog.accept(self)

+ 90 - 0
db_manager/dlg_field_properties.py

@@ -0,0 +1,90 @@
+"""
+***************************************************************************
+    dlg_field_properties.py
+    ---------------------
+    Date                 : April 2012
+    Copyright            : (C) 2012 by Giuseppe Sucameli
+    Email                : brush dot tyler at gmail dot com
+***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************
+"""
+
+__author__ = 'Giuseppe Sucameli'
+__date__ = 'April 2012'
+__copyright__ = '(C) 2012, Giuseppe Sucameli'
+
+from qgis.PyQt.QtWidgets import QDialog, QMessageBox
+
+from .db_plugins.plugin import TableField
+from .ui.ui_DlgFieldProperties import Ui_DbManagerDlgFieldProperties as Ui_Dialog
+
+
+class DlgFieldProperties(QDialog, Ui_Dialog):
+
+    def __init__(self, parent=None, fld=None, table=None, db=None):
+        QDialog.__init__(self, parent)
+        self.fld = fld
+        self.table = self.fld.table() if self.fld and self.fld.table() else table
+        self.db = self.table.database() if self.table and self.table.database() else db
+        self.setupUi(self)
+
+        for item in self.db.connector.fieldTypes():
+            self.cboType.addItem(item)
+
+        supportCom = self.db.supportsComment()
+        if not supportCom:
+            self.label_6.setVisible(False)
+            self.editCom.setVisible(False)
+
+        self.setField(fld)
+
+        self.buttonBox.accepted.connect(self.onOK)
+
+    def setField(self, fld):
+        if fld is None:
+            return
+        self.editName.setText(fld.name)
+        self.cboType.setEditText(fld.dataType)
+        if fld.modifier:
+            self.editLength.setText(str(fld.modifier))
+        self.chkNull.setChecked(not fld.notNull)
+        if fld.hasDefault:
+            self.editDefault.setText(fld.default)
+        tab = self.table.name
+        field = fld.name
+        res = self.db.connector.getComment(tab, field)
+        self.editCom.setText(res)  # Set comment value
+
+    def getField(self, newCopy=False):
+        fld = TableField(self.table) if not self.fld or newCopy else self.fld
+        fld.name = self.editName.text()
+        fld.dataType = self.cboType.currentText()
+        fld.notNull = not self.chkNull.isChecked()
+        fld.default = self.editDefault.text()
+        fld.hasDefault = fld.default != ""
+        fld.comment = self.editCom.text()
+        # length field also used for geometry definition, so we should
+        # not cast its value to int
+        if self.editLength.text() != "":
+            fld.modifier = self.editLength.text()
+        else:
+            fld.modifier = None
+        return fld
+
+    def onOK(self):
+        """ first check whether everything's fine """
+        fld = self.getField(True)  # don't change the original copy
+        if fld.name == "":
+            QMessageBox.critical(self, self.tr("DB Manager"), self.tr("Field name must not be empty."))
+            return
+        if fld.dataType == "":
+            QMessageBox.critical(self, self.tr("DB Manager"), self.tr("Field type must not be empty."))
+            return
+
+        self.accept()

+ 390 - 0
db_manager/dlg_import_vector.py

@@ -0,0 +1,390 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : Oct 13, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt, QFileInfo
+from qgis.PyQt.QtWidgets import QDialog, QFileDialog, QMessageBox
+
+from qgis.core import (QgsDataSourceUri,
+                       QgsVectorDataProvider,
+                       QgsVectorLayer,
+                       QgsMapLayerType,
+                       QgsProviderRegistry,
+                       QgsCoordinateReferenceSystem,
+                       QgsVectorLayerExporter,
+                       QgsProject,
+                       QgsSettings)
+from qgis.gui import QgsMessageViewer
+from qgis.utils import OverrideCursor
+
+from .ui.ui_DlgImportVector import Ui_DbManagerDlgImportVector as Ui_Dialog
+
+
+class DlgImportVector(QDialog, Ui_Dialog):
+    HAS_INPUT_MODE, ASK_FOR_INPUT_MODE = list(range(2))
+
+    def __init__(self, inLayer, outDb, outUri, parent=None):
+        QDialog.__init__(self, parent)
+        self.inLayer = inLayer
+        self.db = outDb
+        self.outUri = outUri
+        self.setupUi(self)
+
+        supportCom = self.db.supportsComment()
+        if not supportCom:
+            self.chkCom.setVisible(False)
+            self.editCom.setVisible(False)
+
+        self.default_pk = "id"
+        self.default_geom = "geom"
+
+        self.mode = self.ASK_FOR_INPUT_MODE if self.inLayer is None else self.HAS_INPUT_MODE
+
+        # used to delete the inlayer whether created inside this dialog
+        self.inLayerMustBeDestroyed = False
+
+        self.populateSchemas()
+        self.populateTables()
+        self.populateLayers()
+        self.populateEncodings()
+
+        # updates of UI
+        self.setupWorkingMode(self.mode)
+        self.cboSchema.currentIndexChanged.connect(self.populateTables)
+        self.widgetSourceSrid.setCrs(QgsProject.instance().crs())
+        self.widgetTargetSrid.setCrs(QgsProject.instance().crs())
+        self.updateInputLayer()
+
+    def setupWorkingMode(self, mode):
+        """ hide the widget to select a layer/file if the input layer is already set """
+        self.wdgInput.setVisible(mode == self.ASK_FOR_INPUT_MODE)
+        self.resize(450, 350)
+
+        self.cboTable.setEditText(self.outUri.table())
+
+        if mode == self.ASK_FOR_INPUT_MODE:
+            self.btnChooseInputFile.clicked.connect(self.chooseInputFile)
+            self.cboInputLayer.currentTextChanged.connect(self.updateInputLayer)
+
+            self.editPrimaryKey.setText(self.default_pk)
+            self.editGeomColumn.setText(self.default_geom)
+
+            self.chkLowercaseFieldNames.setEnabled(self.db.hasLowercaseFieldNamesOption())
+            if not self.chkLowercaseFieldNames.isEnabled():
+                self.chkLowercaseFieldNames.setChecked(False)
+        else:
+            # set default values
+            self.checkSupports()
+            self.updateInputLayer()
+
+    def checkSupports(self):
+        """ update options available for the current input layer """
+        allowSpatial = self.db.connector.hasSpatialSupport()
+        hasGeomType = self.inLayer and self.inLayer.isSpatial()
+        isShapefile = self.inLayer and self.inLayer.providerType() == "ogr" and self.inLayer.storageType() == "ESRI Shapefile"
+
+        self.chkGeomColumn.setEnabled(allowSpatial and hasGeomType)
+        if not self.chkGeomColumn.isEnabled():
+            self.chkGeomColumn.setChecked(False)
+
+        self.chkSourceSrid.setEnabled(allowSpatial and hasGeomType)
+        if not self.chkSourceSrid.isEnabled():
+            self.chkSourceSrid.setChecked(False)
+        self.chkTargetSrid.setEnabled(allowSpatial and hasGeomType)
+        if not self.chkTargetSrid.isEnabled():
+            self.chkTargetSrid.setChecked(False)
+
+        self.chkSinglePart.setEnabled(allowSpatial and hasGeomType and isShapefile)
+        if not self.chkSinglePart.isEnabled():
+            self.chkSinglePart.setChecked(False)
+
+        self.chkSpatialIndex.setEnabled(allowSpatial and hasGeomType)
+        if not self.chkSpatialIndex.isEnabled():
+            self.chkSpatialIndex.setChecked(False)
+
+        self.chkLowercaseFieldNames.setEnabled(self.db.hasLowercaseFieldNamesOption())
+        if not self.chkLowercaseFieldNames.isEnabled():
+            self.chkLowercaseFieldNames.setChecked(False)
+
+    def populateLayers(self):
+        self.cboInputLayer.clear()
+        for nodeLayer in QgsProject.instance().layerTreeRoot().findLayers():
+            layer = nodeLayer.layer()
+            # TODO: add import raster support!
+            if layer is not None and layer.type() == QgsMapLayerType.VectorLayer:
+                self.cboInputLayer.addItem(layer.name(), layer.id())
+
+    def deleteInputLayer(self):
+        """ unset the input layer, then destroy it but only if it was created from this dialog """
+        if self.mode == self.ASK_FOR_INPUT_MODE and self.inLayer:
+            if self.inLayerMustBeDestroyed:
+                self.inLayer.deleteLater()
+            self.inLayer = None
+            self.inLayerMustBeDestroyed = False
+            return True
+        return False
+
+    def chooseInputFile(self):
+        vectorFormats = QgsProviderRegistry.instance().fileVectorFilters()
+        # get last used dir and format
+        settings = QgsSettings()
+        lastDir = settings.value("/db_manager/lastUsedDir", "")
+        lastVectorFormat = settings.value("/UI/lastVectorFileFilter", "")
+        # ask for a filename
+        filename, lastVectorFormat = QFileDialog.getOpenFileName(self, self.tr("Choose the file to import"),
+                                                                 lastDir, vectorFormats, lastVectorFormat)
+        if filename == "":
+            return
+        # store the last used dir and format
+        settings.setValue("/db_manager/lastUsedDir", QFileInfo(filename).filePath())
+        settings.setValue("/UI/lastVectorFileFilter", lastVectorFormat)
+
+        self.cboInputLayer.setCurrentIndex(-1)
+        self.cboInputLayer.setEditText(filename)
+
+    def reloadInputLayer(self):
+        """Creates the input layer and update available options """
+        if self.mode != self.ASK_FOR_INPUT_MODE:
+            return True
+
+        self.deleteInputLayer()
+
+        index = self.cboInputLayer.currentIndex()
+        if index < 0:
+            filename = self.cboInputLayer.currentText()
+            if filename == "":
+                return False
+
+            layerName = QFileInfo(filename).completeBaseName()
+            layer = QgsVectorLayer(filename, layerName, "ogr")
+            if not layer.isValid() or layer.type() != QgsMapLayerType.VectorLayer:
+                layer.deleteLater()
+                return False
+
+            self.inLayer = layer
+            self.inLayerMustBeDestroyed = True
+
+        else:
+            layerId = self.cboInputLayer.itemData(index)
+            self.inLayer = QgsProject.instance().mapLayer(layerId)
+            self.inLayerMustBeDestroyed = False
+
+        self.checkSupports()
+        return True
+
+    def updateInputLayer(self):
+        if not self.reloadInputLayer() or not self.inLayer:
+            return False
+
+        # update the output table name, pk and geom column
+        self.cboTable.setEditText(self.inLayer.name())
+
+        srcUri = QgsDataSourceUri(self.inLayer.source())
+        pk = srcUri.keyColumn() if srcUri.keyColumn() else self.default_pk
+        self.editPrimaryKey.setText(pk)
+        geom = srcUri.geometryColumn() if srcUri.geometryColumn() else self.default_geom
+        self.editGeomColumn.setText(geom)
+
+        srcCrs = self.inLayer.crs()
+        if not srcCrs.isValid():
+            srcCrs = QgsCoordinateReferenceSystem("EPSG:4326")
+        self.widgetSourceSrid.setCrs(srcCrs)
+        self.widgetTargetSrid.setCrs(srcCrs)
+
+        return True
+
+    def populateSchemas(self):
+        if not self.db:
+            return
+
+        self.cboSchema.clear()
+        schemas = self.db.schemas()
+        if schemas is None:
+            self.hideSchemas()
+            return
+
+        index = -1
+        for schema in schemas:
+            self.cboSchema.addItem(schema.name)
+            if schema.name == self.outUri.schema():
+                index = self.cboSchema.count() - 1
+        self.cboSchema.setCurrentIndex(index)
+
+    def hideSchemas(self):
+        self.cboSchema.setEnabled(False)
+
+    def populateTables(self):
+        if not self.db:
+            return
+
+        currentText = self.cboTable.currentText()
+
+        schemas = self.db.schemas()
+        if schemas is not None:
+            schema_name = self.cboSchema.currentText()
+            matching_schemas = [x for x in schemas if x.name == schema_name]
+            tables = matching_schemas[0].tables() if len(matching_schemas) > 0 else []
+        else:
+            tables = self.db.tables()
+
+        self.cboTable.clear()
+        for table in tables:
+            self.cboTable.addItem(table.name)
+
+        self.cboTable.setEditText(currentText)
+
+    def populateEncodings(self):
+        # populate the combo with supported encodings
+        self.cboEncoding.addItems(QgsVectorDataProvider.availableEncodings())
+
+        self.cboEncoding.insertItem(0, self.tr('Automatic'), "")
+        self.cboEncoding.setCurrentIndex(0)
+
+    def accept(self):
+        if self.mode == self.ASK_FOR_INPUT_MODE:
+            # create the input layer (if not already done) and
+            # update available options
+            self.reloadInputLayer()
+
+        # sanity checks
+        if self.inLayer is None:
+            QMessageBox.critical(self, self.tr("Import to Database"), self.tr("Input layer missing or not valid."))
+            return
+
+        if self.cboTable.currentText() == "":
+            QMessageBox.critical(self, self.tr("Import to Database"), self.tr("Output table name is required."))
+            return
+
+        if self.chkSourceSrid.isEnabled() and self.chkSourceSrid.isChecked():
+            if not self.widgetSourceSrid.crs().isValid():
+                QMessageBox.critical(self, self.tr("Import to Database"),
+                                     self.tr("Invalid source srid: must be a valid crs."))
+                return
+
+        if self.chkTargetSrid.isEnabled() and self.chkTargetSrid.isChecked():
+            if not self.widgetTargetSrid.crs().isValid():
+                QMessageBox.critical(self, self.tr("Import to Database"),
+                                     self.tr("Invalid target srid: must be a valid crs."))
+                return
+
+        with OverrideCursor(Qt.WaitCursor):
+            # store current input layer crs and encoding, so I can restore it
+            prevInCrs = self.inLayer.crs()
+            prevInEncoding = self.inLayer.dataProvider().encoding()
+
+            try:
+                schema = self.outUri.schema() if not self.cboSchema.isEnabled() else self.cboSchema.currentText()
+                table = self.cboTable.currentText()
+
+                # get pk and geom field names from the source layer or use the
+                # ones defined by the user
+                srcUri = QgsDataSourceUri(self.inLayer.source())
+
+                pk = srcUri.keyColumn() if not self.chkPrimaryKey.isChecked() else self.editPrimaryKey.text()
+                if not pk:
+                    pk = self.default_pk
+
+                if self.inLayer.isSpatial() and self.chkGeomColumn.isEnabled():
+                    geom = srcUri.geometryColumn() if not self.chkGeomColumn.isChecked() else self.editGeomColumn.text()
+                    if not geom:
+                        geom = self.default_geom
+                else:
+                    geom = None
+
+                options = {}
+                if self.chkLowercaseFieldNames.isEnabled() and self.chkLowercaseFieldNames.isChecked():
+                    pk = pk.lower()
+                    if geom:
+                        geom = geom.lower()
+                    options['lowercaseFieldNames'] = True
+
+                # get output params, update output URI
+                self.outUri.setDataSource(schema, table, geom, "", pk)
+                typeName = self.db.dbplugin().typeName()
+                providerName = self.db.dbplugin().providerName()
+                if typeName == 'gpkg':
+                    uri = self.outUri.database()
+                    options['update'] = True
+                    options['driverName'] = 'GPKG'
+                    options['layerName'] = table
+                else:
+                    uri = self.outUri.uri(False)
+
+                if self.chkDropTable.isChecked():
+                    options['overwrite'] = True
+
+                if self.chkSinglePart.isEnabled() and self.chkSinglePart.isChecked():
+                    options['forceSinglePartGeometryType'] = True
+
+                outCrs = QgsCoordinateReferenceSystem()
+                if self.chkTargetSrid.isEnabled() and self.chkTargetSrid.isChecked():
+                    outCrs = self.widgetTargetSrid.crs()
+
+                # update input layer crs and encoding
+                if self.chkSourceSrid.isEnabled() and self.chkSourceSrid.isChecked():
+                    inCrs = self.widgetSourceSrid.crs()
+                    self.inLayer.setCrs(inCrs)
+
+                if self.chkEncoding.isEnabled() and self.chkEncoding.isChecked() and self.cboEncoding.currentData() is None:
+                    enc = self.cboEncoding.currentText()
+                    self.inLayer.setProviderEncoding(enc)
+
+                onlySelected = self.chkSelectedFeatures.isChecked()
+
+                # do the import!
+                ret, errMsg = QgsVectorLayerExporter.exportLayer(self.inLayer, uri, providerName, outCrs, onlySelected, options)
+            except Exception as e:
+                ret = -1
+                errMsg = str(e)
+
+            finally:
+                # restore input layer crs and encoding
+                self.inLayer.setCrs(prevInCrs)
+                self.inLayer.setProviderEncoding(prevInEncoding)
+
+        if ret != 0:
+            output = QgsMessageViewer()
+            output.setTitle(self.tr("Import to Database"))
+            output.setMessageAsPlainText(self.tr("Error {0}\n{1}").format(ret, errMsg))
+            output.showMessage()
+            return
+
+        # create spatial index
+        if self.chkSpatialIndex.isEnabled() and self.chkSpatialIndex.isChecked():
+            self.db.connector.createSpatialIndex((schema, table), geom)
+
+        # add comment on table
+        supportCom = self.db.supportsComment()
+        if self.chkCom.isEnabled() and self.chkCom.isChecked() and supportCom:
+            # using connector executing COMMENT ON TABLE query (with editCome.text() value)
+            com = self.editCom.text()
+            self.db.connector.commentTable(schema, table, com)
+
+        self.db.connection().reconnect()
+        self.db.refresh()
+        QMessageBox.information(self, self.tr("Import to Database"), self.tr("Import was successful."))
+        return QDialog.accept(self)
+
+    def closeEvent(self, event):
+        # destroy the input layer instance but only if it was created
+        # from this dialog!
+        self.deleteInputLayer()
+        QDialog.closeEvent(self, event)

+ 388 - 0
db_manager/dlg_query_builder.py

@@ -0,0 +1,388 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : March 2015
+copyright            : (C) 2015 Hugo Mercier / Oslandia
+email                : hugo dot mercier at oslandia dot com
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+
+Query builder dialog, based on the QSpatialite plugin (GPLv2+) by Romain Riviere
+"""
+
+from qgis.PyQt.QtCore import Qt, QObject, QEvent
+from qgis.PyQt.QtWidgets import QDialog, QMessageBox, QTextEdit
+
+from .ui.ui_DlgQueryBuilder import Ui_DbManagerQueryBuilderDlg as Ui_Dialog
+from .db_plugins.plugin import VectorTable
+
+
+class FocusEventFilter(QObject):
+
+    def __init__(self, parent):
+        QObject.__init__(self, parent)
+        self.focus = ''
+
+    def eventFilter(self, obj, event):
+        if event.type() == QEvent.FocusIn:
+            self.focus = obj.objectName()
+        return QObject.eventFilter(self, obj, event)
+
+
+def insertWithSelection(widget, text):
+    if widget.textCursor().hasSelection():  # user has selectedsomething...
+        selection = widget.textCursor().selectedText()
+        widget.insertPlainText(text + selection + ")")
+    else:
+        widget.insertPlainText(text)
+
+
+def insertWithSelectionOn(parent, objectname, text):
+    """Insert the text in a QTextEdit given by its objectname"""
+    w = parent.findChild(QTextEdit, objectname)
+    insertWithSelection(w, text)
+
+
+class QueryBuilderDlg(QDialog):
+    # object used to store parameters between invocations
+    saveParameter = None
+
+    def __init__(self, iface, db, parent=None, reset=False):
+        QDialog.__init__(self, parent)
+        self.iface = iface
+        self.db = db
+        self.query = ''
+        self.ui = Ui_Dialog()
+        self.ui.setupUi(self)
+        self.ui.group.setMaximumHeight(self.ui.tab.sizeHint().height())
+        self.ui.order.setMaximumHeight(self.ui.tab.sizeHint().height())
+
+        self.evt = FocusEventFilter(self)
+        self.ui.col.installEventFilter(self.evt)
+        self.ui.where.installEventFilter(self.evt)
+        self.ui.group.installEventFilter(self.evt)
+        self.ui.order.installEventFilter(self.evt)
+
+        d = self.db.connector.getQueryBuilderDictionary()
+        # Application default parameters
+        self.table = None
+        self.col_col = []
+        self.col_where = []
+        self.coltables = []
+        self.ui.extract.setChecked(True)
+        # ComboBox default values
+        self.ui.functions.insertItems(1, d['function'])
+        self.ui.math.insertItems(1, d['math'])
+        self.ui.aggregates.insertItems(1, d['aggregate'])
+        self.ui.operators.insertItems(1, d['operator'])
+        self.ui.stringfct.insertItems(1, d['string'])
+        # self.ui.Rtree.insertItems(1,rtreecommand)
+
+        # restore last query if needed
+        if reset:
+            QueryBuilderDlg.saveParameter = None
+        if QueryBuilderDlg.saveParameter is not None:
+            self.restoreLastQuery()
+
+        # Show Tables
+        self.show_tables()
+
+        # Signal/slot
+        self.ui.aggregates.currentIndexChanged.connect(self.add_aggregate)
+        self.ui.stringfct.currentIndexChanged.connect(self.add_stringfct)
+        self.ui.operators.currentIndexChanged.connect(self.add_operators)
+        self.ui.functions.currentIndexChanged.connect(self.add_functions)
+        self.ui.math.currentIndexChanged.connect(self.add_math)
+        self.ui.tables.currentIndexChanged.connect(self.add_tables)
+        self.ui.tables.currentIndexChanged.connect(self.list_cols)
+        self.ui.columns.currentIndexChanged.connect(self.add_columns)
+        self.ui.columns_2.currentIndexChanged.connect(self.list_values)
+        self.ui.reset.clicked.connect(self.reset)
+        self.ui.extract.stateChanged.connect(self.list_values)
+        self.ui.values.doubleClicked.connect(self.query_item)
+        self.ui.buttonBox.accepted.connect(self.validate)
+        self.ui.checkBox.stateChanged.connect(self.show_tables)
+
+        if self.db.explicitSpatialIndex():
+            self.tablesGeo = [table for table in self.tables if isinstance(table, VectorTable)]
+            tablesGeo = ['"%s"."%s"' % (table.name, table.geomColumn) for table in self.tablesGeo]
+            self.ui.table_target.insertItems(1, tablesGeo)
+            self.idxTables = [table for table in self.tablesGeo if table.hasSpatialIndex()]
+            idxTables = ['"%s"."%s"' % (table.name, table.geomColumn) for table in self.idxTables]
+            self.ui.table_idx.insertItems(1, idxTables)
+
+            self.ui.usertree.clicked.connect(self.use_rtree)
+        else:
+            self.ui.toolBox.setItemEnabled(2, False)
+
+    def update_table_list(self):
+        self.tables = []
+        add_sys_tables = self.ui.checkBox.isChecked()
+        schemas = self.db.schemas()
+        if schemas is None:
+            self.tables = self.db.tables(None, add_sys_tables)
+        else:
+            for schema in schemas:
+                self.tables += self.db.tables(schema, add_sys_tables)
+
+    def show_tables(self):
+        self.update_table_list()
+        self.ui.tables.clear()
+        self.ui.tables.insertItems(0, ["Tables"])
+        self.ui.tables.insertItems(1, [t.name for t in self.tables])
+
+    def add_aggregate(self):
+        if self.ui.aggregates.currentIndex() <= 0:
+            return
+        ag = self.ui.aggregates.currentText()
+
+        insertWithSelection(self.ui.col, ag)
+
+        self.ui.aggregates.setCurrentIndex(0)
+
+    def add_functions(self):
+        if self.ui.functions.currentIndex() <= 0:
+            return
+        ag = self.ui.functions.currentText()
+
+        insertWithSelectionOn(self, self.evt.focus, ag)
+
+        self.ui.functions.setCurrentIndex(0)
+
+    def add_stringfct(self):
+        if self.ui.stringfct.currentIndex() <= 0:
+            return
+        ag = self.ui.stringfct.currentText()
+
+        insertWithSelectionOn(self, self.evt.focus, ag)
+
+        self.ui.stringfct.setCurrentIndex(0)
+
+    def add_math(self):
+        if self.ui.math.currentIndex() <= 0:
+            return
+        ag = self.ui.math.currentText()
+
+        insertWithSelectionOn(self, self.evt.focus, ag)
+
+        self.ui.math.setCurrentIndex(0)
+
+    def add_operators(self):
+        if self.ui.operators.currentIndex() <= 0:
+            return
+        ag = self.ui.operators.currentText()
+
+        if self.evt.focus == "where":  # in where section
+            self.ui.where.insertPlainText(ag)
+        else:
+            self.ui.col.insertPlainText(ag)
+        self.ui.operators.setCurrentIndex(0)
+
+    def add_tables(self):
+        if self.ui.tables.currentIndex() <= 0:
+            return
+        ag = self.ui.tables.currentText()
+        # Retrieve Table Object from txt
+        tableObj = [table for table in self.tables if table.name.upper() == ag.upper()]
+        if len(tableObj) != 1:
+            return  # No object with this name
+        self.table = tableObj[0]
+        if (ag in self.coltables):  # table already use
+            response = QMessageBox.question(self, "Table already used", "Do you want to add table %s again?" % ag, QMessageBox.Yes | QMessageBox.No)
+            if response == QMessageBox.No:
+                return
+        ag = self.table.quotedName()
+        txt = self.ui.tab.text()
+        if (txt is None) or (txt in ("", " ")):
+            self.ui.tab.setText('%s' % ag)
+        else:
+            self.ui.tab.setText('%s, %s' % (txt, ag))
+        self.ui.tables.setCurrentIndex(0)
+
+    def add_columns(self):
+        if self.ui.columns.currentIndex() <= 0:
+            return
+        ag = self.ui.columns.currentText()
+        if self.evt.focus == "where":  # in where section
+            if ag in self.col_where:  # column already called in where section
+                response = QMessageBox.question(self, "Column already used in WHERE clause", "Do you want to add column %s again?" % ag, QMessageBox.Yes | QMessageBox.No)
+                if response == QMessageBox.No:
+                    self.ui.columns.setCurrentIndex(0)
+                    return
+            self.ui.where.insertPlainText(ag)
+            self.col_where.append(ag)
+        elif self.evt.focus == "col":
+            if ag in self.col_col:  # column already called in col section
+                response = QMessageBox.question(self, "Column already used in COLUMNS section", "Do you want to add column %s again?" % ag, QMessageBox.Yes | QMessageBox.No)
+                if response == QMessageBox.No:
+                    self.ui.columns.setCurrentIndex(0)
+                    return
+            if len(self.ui.col.toPlainText().strip()) > 0:
+                self.ui.col.insertPlainText(",\n" + ag)
+            else:
+                self.ui.col.insertPlainText(ag)
+            self.col_col.append(ag)
+        elif self.evt.focus == "group":
+            if len(self.ui.group.toPlainText().strip()) > 0:
+                self.ui.group.insertPlainText(", " + ag)
+            else:
+                self.ui.group.insertPlainText(ag)
+        elif self.evt.focus == "order":
+            if len(self.ui.order.toPlainText().strip()) > 0:
+                self.ui.order.insertPlainText(", " + ag)
+            else:
+                self.ui.order.insertPlainText(ag)
+
+        self.ui.columns.setCurrentIndex(0)
+
+    def list_cols(self):
+        table = self.table
+        if (table is None):
+            return
+        if (table.name in self.coltables):
+            return
+
+        columns = ['"%s"."%s"' % (table.name, col.name) for col in table.fields()]
+        # add special '*' column:
+        columns = ['"%s".*' % table.name] + columns
+        self.coltables.append(table.name)  # table columns have been listed
+        # first and second col combobox
+        end = self.ui.columns.count()
+        self.ui.columns.insertItems(end, columns)
+        self.ui.columns_2.insertItems(end, columns)
+        end = self.ui.columns.count()
+        self.ui.columns.insertSeparator(end)
+        self.ui.columns_2.insertSeparator(end)
+
+    def list_values(self):
+        if self.ui.columns_2.currentIndex() <= 0:
+            return
+        item = self.ui.columns_2.currentText()
+        # recover column and table:
+        column = item.split(".")  # "table".'column'
+        table = column[0]
+        if column[1] == '*':
+            return
+        table = table[1:-1]
+
+        qtable = [t for t in self.tables if t.name.lower() == table.lower()][0].quotedName()
+
+        if self.ui.extract.isChecked():
+            limit = 10
+        else:
+            limit = None
+        model = self.db.columnUniqueValuesModel(item, qtable, limit)
+        self.ui.values.setModel(model)
+
+    def query_item(self, index):
+        value = index.data(Qt.EditRole)
+
+        if value is None:
+            queryWord = 'NULL'
+        elif isinstance(value, (int, float)):
+            queryWord = str(value)
+        else:
+            queryWord = self.db.connector.quoteString(value)
+
+        if queryWord.strip() != '':
+            self.ui.where.insertPlainText(' ' + queryWord)
+            self.ui.where.setFocus()
+
+    def use_rtree(self):
+        idx = self.ui.table_idx.currentText()
+        if idx in (None, "", " ", "Table (with Spatial Index)"):
+            return
+        try:
+            tab_idx = idx.split(".")[0][1:-1]  # remove "
+            col_idx = idx.split(".")[1][1:-1]  # remove '
+        except:
+            QMessageBox.warning(self, "Use R-Tree", "All fields are necessary", QMessageBox.Cancel)
+        tgt = self.ui.table_target.currentText()
+        if tgt in (None, "", " ", "Table (Target)"):
+            return
+        tgt_tab = tgt.split('.')[0][1:-1]
+        tgt_col = tgt.split('.')[1][1:-1]
+        sql = ""
+        if self.ui.where.toPlainText() not in (None, "", " "):
+            sql += "\nAND"
+        sql += self.db.spatialIndexClause(tab_idx, col_idx, tgt_tab, tgt_col)
+        self.ui.where.insertPlainText(sql)
+
+    def reset(self):
+        # reset lists:
+        self.ui.values.setModel(None)
+        self.ui.columns_2.clear()
+        self.ui.columns.insertItems(0, ["Columns"])
+        self.ui.columns_2.insertItems(0, ["Columns"])
+        self.coltables = []
+        self.col_col = []
+        self.col_where = []
+
+    def validate(self):
+        query_col = str(self.ui.col.toPlainText())
+        query_table = str(self.ui.tab.text())
+        query_where = str(self.ui.where.toPlainText())
+        query_group = str(self.ui.group.toPlainText())
+        query_order = str(self.ui.order.toPlainText())
+        query = ""
+        if query_col.strip() != '':
+            query += "SELECT %s \nFROM %s" % (query_col, query_table)
+        if query_where.strip() != '':
+            query += "\nWHERE %s" % query_where
+        if query_group.strip() != '':
+            query += "\nGROUP BY %s" % query_group
+        if query_order.strip() != '':
+            query += "\nORDER BY %s" % query_order
+        if query == '':
+            return
+        self.query = query
+
+        saveParameter = {
+            "coltables": self.coltables,
+            "col_col": self.col_col,
+            "col_where": self.col_where,
+            "col": query_col,
+            "tab": query_table,
+            "where": query_where,
+            "group": query_group,
+            "order": query_order,
+        }
+
+        QueryBuilderDlg.saveParameter = saveParameter
+
+    def restoreLastQuery(self):
+        self.update_table_list()
+
+        saveParameter = QueryBuilderDlg.saveParameter
+        self.coltables = saveParameter["coltables"]
+        self.col_col = saveParameter["col_col"]
+        self.col_where = saveParameter["col_where"]
+        self.ui.col.insertPlainText(saveParameter["col"])
+        self.ui.tab.setText(saveParameter["tab"])
+        self.ui.where.insertPlainText(saveParameter["where"])
+        self.ui.order.setPlainText(saveParameter["order"])
+        self.ui.group.setPlainText(saveParameter["group"])
+        # list previous colist:
+        for tablename in self.coltables:
+            # Retrieve table object from table name:
+            table = [table for table in self.tables if table.name.upper() == tablename.upper()]
+            if len(table) != 1:
+                break
+            table = table[0]
+            columns = ['"%s"."%s"' % (table.name, col.name) for col in table.fields()]
+            # first and second col combobox
+            end = self.ui.columns.count()
+            self.ui.columns.insertItems(end, columns)
+            self.ui.columns_2.insertItems(end, columns)
+            end = self.ui.columns.count()
+            self.ui.columns.insertSeparator(end)
+            self.ui.columns_2.insertSeparator(end)

+ 579 - 0
db_manager/dlg_sql_layer_window.py

@@ -0,0 +1,579 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+from hashlib import md5
+
+from qgis.PyQt.QtCore import Qt, pyqtSignal
+from qgis.PyQt.QtWidgets import (QDialog,
+                                 QWidget,
+                                 QAction,
+                                 QApplication,
+                                 QStyledItemDelegate,
+                                 QMessageBox
+                                 )
+from qgis.PyQt.QtGui import (QKeySequence,
+                             QCursor,
+                             QClipboard,
+                             QIcon,
+                             QStandardItemModel,
+                             QStandardItem
+                             )
+from qgis.PyQt.Qsci import QsciAPIs
+from qgis.PyQt.QtXml import QDomDocument
+
+from qgis.core import (
+    QgsProject,
+    QgsDataSourceUri,
+    QgsReadWriteContext,
+    QgsMapLayerType
+)
+from qgis.utils import OverrideCursor
+
+from .db_plugins import createDbPlugin
+from .db_plugins.plugin import BaseError
+from .db_plugins.postgis.plugin import PGDatabase
+from .dlg_db_error import DlgDbError
+from .dlg_query_builder import QueryBuilderDlg
+
+try:
+    from qgis.gui import QgsCodeEditorSQL  # NOQA
+except:
+    from .sqledit import SqlEdit
+    from qgis import gui
+
+    gui.QgsCodeEditorSQL = SqlEdit
+
+from .ui.ui_DlgSqlLayerWindow import Ui_DbManagerDlgSqlLayerWindow as Ui_Dialog
+
+import re
+
+
+class DlgSqlLayerWindow(QWidget, Ui_Dialog):
+    nameChanged = pyqtSignal(str)
+    hasChanged = False
+
+    def __init__(self, iface, layer, parent=None):
+        QWidget.__init__(self, parent)
+        self.iface = iface
+        self.layer = layer
+
+        uri = QgsDataSourceUri(layer.source())
+        dbplugin = None
+        db = None
+        if layer.dataProvider().name() == 'postgres':
+            dbplugin = createDbPlugin('postgis', 'postgres')
+        elif layer.dataProvider().name() == 'spatialite':
+            dbplugin = createDbPlugin('spatialite', 'spatialite')
+        elif layer.dataProvider().name() == 'oracle':
+            dbplugin = createDbPlugin('oracle', 'oracle')
+        elif layer.dataProvider().name() == 'virtual':
+            dbplugin = createDbPlugin('vlayers', 'virtual')
+        elif layer.dataProvider().name() == 'ogr':
+            dbplugin = createDbPlugin('gpkg', 'gpkg')
+        if dbplugin:
+            dbplugin.connectToUri(uri)
+            db = dbplugin.db
+
+        self.dbplugin = dbplugin
+        self.db = db
+        self.filter = ""
+        self.allowMultiColumnPk = isinstance(db, PGDatabase)  # at the moment only PostgreSQL allows a primary key to span multiple columns, SpatiaLite doesn't
+        self.aliasSubQuery = isinstance(db, PGDatabase)  # only PostgreSQL requires subqueries to be aliases
+        self.setupUi(self)
+        self.setWindowTitle(
+            "%s - %s [%s]" % (self.windowTitle(), db.connection().connectionName(), db.connection().typeNameString()))
+
+        self.defaultLayerName = self.tr('QueryLayer')
+
+        if self.allowMultiColumnPk:
+            self.uniqueColumnCheck.setText(self.tr("Column(s) with unique values"))
+        else:
+            self.uniqueColumnCheck.setText(self.tr("Column with unique values"))
+
+        self.editSql.setFocus()
+        self.editSql.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+        self.editSql.setLineNumbersVisible(True)
+        self.initCompleter()
+        self.editSql.textChanged.connect(lambda: self.setHasChanged(True))
+
+        # allow copying results
+        copyAction = QAction("copy", self)
+        self.viewResult.addAction(copyAction)
+        copyAction.setShortcuts(QKeySequence.Copy)
+
+        copyAction.triggered.connect(self.copySelectedResults)
+
+        self.btnExecute.clicked.connect(self.executeSql)
+        self.btnSetFilter.clicked.connect(self.setFilter)
+        self.btnClear.clicked.connect(self.clearSql)
+
+        self.presetStore.clicked.connect(self.storePreset)
+        self.presetDelete.clicked.connect(self.deletePreset)
+        self.presetCombo.activated[str].connect(self.loadPreset)
+        self.presetCombo.activated[str].connect(self.presetName.setText)
+
+        self.editSql.textChanged.connect(self.updatePresetButtonsState)
+        self.presetName.textChanged.connect(self.updatePresetButtonsState)
+        self.presetCombo.currentIndexChanged.connect(self.updatePresetButtonsState)
+
+        self.updatePresetsCombobox()
+
+        self.geomCombo.setEditable(True)
+        self.geomCombo.lineEdit().setReadOnly(True)
+
+        self.uniqueCombo.setEditable(True)
+        self.uniqueCombo.lineEdit().setReadOnly(True)
+        self.uniqueModel = QStandardItemModel(self.uniqueCombo)
+        self.uniqueCombo.setModel(self.uniqueModel)
+        if self.allowMultiColumnPk:
+            self.uniqueCombo.setItemDelegate(QStyledItemDelegate())
+            self.uniqueModel.itemChanged.connect(self.uniqueChanged)  # react to the (un)checking of an item
+            self.uniqueCombo.lineEdit().textChanged.connect(
+                self.uniqueTextChanged)  # there are other events that change the displayed text and some of them can not be caught directly
+
+        self.layerTypeWidget.hide()  # show if load as raster is supported
+        # self.loadLayerBtn.clicked.connect(self.loadSqlLayer)
+        self.updateLayerBtn.clicked.connect(self.updateSqlLayer)
+        self.getColumnsBtn.clicked.connect(self.fillColumnCombos)
+
+        self.queryBuilderFirst = True
+        self.queryBuilderBtn.setIcon(QIcon(":/db_manager/icons/sql.gif"))
+        self.queryBuilderBtn.clicked.connect(self.displayQueryBuilder)
+
+        self.presetName.textChanged.connect(self.nameChanged)
+
+        # Update from layer
+        # First the SQL from QgsDataSourceUri table
+        sql = uri.table().replace('\n', ' ').strip()
+        if uri.keyColumn() == '_uid_':
+            match = re.search(r'^\(SELECT .+ AS _uid_,\* FROM \((.*)\) AS _subq_.+_\s*\)$', sql, re.S | re.X | re.IGNORECASE)
+            if match:
+                sql = match.group(1)
+        else:
+            match = re.search(r'^\((SELECT .+ FROM .+)\)$', sql, re.S | re.X | re.IGNORECASE)
+            if match:
+                sql = match.group(1)
+        # Need to check on table() since the parentheses were removed by the regexp
+        if not uri.table().startswith('(') and not uri.table().endswith(')'):
+            schema = uri.schema()
+            if schema and schema.upper() != 'PUBLIC':
+                sql = 'SELECT * FROM {}.{}'.format(self.db.connector.quoteId(schema), self.db.connector.quoteId(sql))
+            else:
+                sql = 'SELECT * FROM {}'.format(self.db.connector.quoteId(sql))
+        self.editSql.setText(sql)
+        self.executeSql()
+
+        # Then the columns
+        self.geomCombo.setCurrentIndex(self.geomCombo.findText(uri.geometryColumn(), Qt.MatchExactly))
+        if uri.keyColumn() != '_uid_':
+            self.uniqueColumnCheck.setCheckState(Qt.Checked)
+            if self.allowMultiColumnPk:
+                # Unchecked default values
+                for item in self.uniqueModel.findItems("*", Qt.MatchWildcard):
+                    if item.checkState() == Qt.Checked:
+                        item.setCheckState(Qt.Unchecked)
+                # Get key columns
+                itemsData = uri.keyColumn().split(',')
+                # Checked key columns
+                for keyColumn in itemsData:
+                    for item in self.uniqueModel.findItems(keyColumn):
+                        item.setCheckState(Qt.Checked)
+            else:
+                keyColumn = uri.keyColumn()
+                if self.uniqueModel.findItems(keyColumn):
+                    self.uniqueCombo.setCurrentIndex(self.uniqueCombo.findText(keyColumn, Qt.MatchExactly))
+
+        # Finally layer name, filter and selectAtId
+        self.layerNameEdit.setText(layer.name())
+        self.filter = uri.sql()
+        if uri.selectAtIdDisabled():
+            self.avoidSelectById.setCheckState(Qt.Checked)
+
+    def getQueryHash(self, name):
+        return 'q%s' % md5(name.encode('utf8')).hexdigest()
+
+    def updatePresetButtonsState(self, *args):
+        """Slot called when the combo box or the sql or the query name have changed:
+           sets store button state"""
+        self.presetStore.setEnabled(bool(self._getSqlQuery() and self.presetName.text()))
+        self.presetDelete.setEnabled(bool(self.presetCombo.currentIndex() != -1))
+
+    def updatePresetsCombobox(self):
+        self.presetCombo.clear()
+
+        names = []
+        entries = QgsProject.instance().subkeyList('DBManager', 'savedQueries')
+        for entry in entries:
+            name = QgsProject.instance().readEntry('DBManager', 'savedQueries/' + entry + '/name')[0]
+            names.append(name)
+
+        for name in sorted(names):
+            self.presetCombo.addItem(name)
+        self.presetCombo.setCurrentIndex(-1)
+
+    def storePreset(self):
+        query = self._getSqlQuery()
+        if query == "":
+            return
+        name = self.presetName.text()
+        QgsProject.instance().writeEntry('DBManager', 'savedQueries/' + self.getQueryHash(name) + '/name', name)
+        QgsProject.instance().writeEntry('DBManager', 'savedQueries/' + self.getQueryHash(name) + '/query', query)
+        index = self.presetCombo.findText(name)
+        if index == -1:
+            self.presetCombo.addItem(name)
+            self.presetCombo.setCurrentIndex(self.presetCombo.count() - 1)
+        else:
+            self.presetCombo.setCurrentIndex(index)
+
+    def deletePreset(self):
+        name = self.presetCombo.currentText()
+        QgsProject.instance().removeEntry('DBManager', 'savedQueries/q' + self.getQueryHash(name))
+        self.presetCombo.removeItem(self.presetCombo.findText(name))
+        self.presetCombo.setCurrentIndex(-1)
+
+    def loadPreset(self, name):
+        query = QgsProject.instance().readEntry('DBManager', 'savedQueries/' + self.getQueryHash(name) + '/query')[0]
+        self.editSql.setText(query)
+
+    def clearSql(self):
+        self.editSql.clear()
+        self.editSql.setFocus()
+        self.filter = ""
+
+    def executeSql(self):
+
+        sql = self._getSqlQuery()
+        if sql == "":
+            return
+
+        with OverrideCursor(Qt.WaitCursor):
+
+            # delete the old model
+            old_model = self.viewResult.model()
+            self.viewResult.setModel(None)
+            if old_model:
+                old_model.deleteLater()
+
+            quotedCols = []
+
+            try:
+                # set the new model
+                model = self.db.sqlResultModel(sql, self)
+                self.viewResult.setModel(model)
+                self.lblResult.setText(self.tr("{0} rows, {1:.3f} seconds").format(model.affectedRows(), model.secs()))
+                cols = self.viewResult.model().columnNames()
+                for col in cols:
+                    quotedCols.append(self.db.connector.quoteId(col))
+
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+                self.uniqueModel.clear()
+                self.geomCombo.clear()
+                return
+
+            self.setColumnCombos(cols, quotedCols)
+
+            self.update()
+
+    def _getSqlLayer(self, _filter):
+        hasUniqueField = self.uniqueColumnCheck.checkState() == Qt.Checked
+        if hasUniqueField and self.allowMultiColumnPk:
+            checkedCols = [
+                item.data()
+                for item in self.uniqueModel.findItems("*", Qt.MatchWildcard)
+                if item.checkState() == Qt.Checked
+            ]
+
+            uniqueFieldName = ",".join(checkedCols)
+        elif (
+            hasUniqueField
+            and not self.allowMultiColumnPk
+            and self.uniqueCombo.currentIndex() >= 0
+        ):
+            uniqueFieldName = self.uniqueModel.item(self.uniqueCombo.currentIndex()).data()
+        else:
+            uniqueFieldName = None
+        hasGeomCol = self.hasGeometryCol.checkState() == Qt.Checked
+        if hasGeomCol:
+            geomFieldName = self.geomCombo.currentText()
+        else:
+            geomFieldName = None
+
+        query = self._getSqlQuery()
+        if query == "":
+            return None
+
+        # remove a trailing ';' from query if present
+        if query.strip().endswith(';'):
+            query = query.strip()[:-1]
+
+        layerType = QgsMapLayerType.VectorLayer if self.vectorRadio.isChecked() else QgsMapLayerType.RasterLayer
+
+        # get a new layer name
+        names = []
+        for layer in list(QgsProject.instance().mapLayers().values()):
+            names.append(layer.name())
+
+        layerName = self.layerNameEdit.text()
+        if layerName == "":
+            layerName = self.defaultLayerName
+        newLayerName = layerName
+        index = 1
+        while newLayerName in names:
+            index += 1
+            newLayerName = "%s_%d" % (layerName, index)
+
+        # create the layer
+        layer = self.db.toSqlLayer(query, geomFieldName, uniqueFieldName, newLayerName, layerType,
+                                   self.avoidSelectById.isChecked(), _filter)
+        if layer.isValid():
+            return layer
+        else:
+            return None
+
+    def loadSqlLayer(self):
+        with OverrideCursor(Qt.WaitCursor):
+            layer = self._getSqlLayer(self.filter)
+            if layer is None:
+                return
+
+            QgsProject.instance().addMapLayers([layer], True)
+
+    def updateSqlLayer(self):
+        with OverrideCursor(Qt.WaitCursor):
+            layer = self._getSqlLayer(self.filter)
+            if layer is None:
+                return
+
+            # self.layer.dataProvider().setDataSourceUri(layer.dataProvider().dataSourceUri())
+            # self.layer.dataProvider().reloadData()
+            XMLDocument = QDomDocument("style")
+            XMLMapLayers = XMLDocument.createElement("maplayers")
+            XMLMapLayer = XMLDocument.createElement("maplayer")
+            self.layer.writeLayerXml(XMLMapLayer, XMLDocument, QgsReadWriteContext())
+            XMLMapLayer.firstChildElement("datasource").firstChild().setNodeValue(layer.source())
+            XMLMapLayers.appendChild(XMLMapLayer)
+            XMLDocument.appendChild(XMLMapLayers)
+            self.layer.readLayerXml(XMLMapLayer, QgsReadWriteContext())
+            self.layer.reload()
+            self.iface.actionDraw().trigger()
+            self.iface.mapCanvas().refresh()
+
+    def fillColumnCombos(self):
+        query = self._getSqlQuery()
+        if query == "":
+            return
+
+        with OverrideCursor(Qt.WaitCursor):
+            # remove a trailing ';' from query if present
+            if query.strip().endswith(';'):
+                query = query.strip()[:-1]
+
+            # get all the columns
+            quotedCols = []
+            connector = self.db.connector
+            if self.aliasSubQuery:
+                # get a new alias
+                aliasIndex = 0
+                while True:
+                    alias = "_subQuery__%d" % aliasIndex
+                    escaped = re.compile('\\b("?)' + re.escape(alias) + '\\1\\b')
+                    if not escaped.search(query):
+                        break
+                    aliasIndex += 1
+
+                sql = "SELECT * FROM (%s\n) AS %s LIMIT 0" % (str(query), connector.quoteId(alias))
+            else:
+                sql = "SELECT * FROM (%s\n) WHERE 1=0" % str(query)
+
+            c = None
+            try:
+                c = connector._execute(None, sql)
+                cols = connector._get_cursor_columns(c)
+                for col in cols:
+                    quotedCols.append(connector.quoteId(col))
+
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+                self.uniqueModel.clear()
+                self.geomCombo.clear()
+                return
+
+            finally:
+                if c:
+                    c.close()
+                    del c
+
+            self.setColumnCombos(cols, quotedCols)
+
+    def setColumnCombos(self, cols, quotedCols):
+        # get sensible default columns. do this before sorting in case there's hints in the column order (e.g., id is more likely to be first)
+        try:
+            defaultGeomCol = next(col for col in cols if col in ['geom', 'geometry', 'the_geom', 'way'])
+        except:
+            defaultGeomCol = None
+        try:
+            defaultUniqueCol = [col for col in cols if 'id' in col][0]
+        except:
+            defaultUniqueCol = None
+
+        colNames = sorted(zip(cols, quotedCols))
+        newItems = []
+        uniqueIsFilled = False
+        for (col, quotedCol) in colNames:
+            item = QStandardItem(col)
+            item.setData(quotedCol)
+            item.setEnabled(True)
+            item.setCheckable(self.allowMultiColumnPk)
+            item.setSelectable(not self.allowMultiColumnPk)
+            if self.allowMultiColumnPk:
+                matchingItems = self.uniqueModel.findItems(col)
+                if matchingItems:
+                    item.setCheckState(matchingItems[0].checkState())
+                    uniqueIsFilled = uniqueIsFilled or matchingItems[0].checkState() == Qt.Checked
+                else:
+                    item.setCheckState(Qt.Unchecked)
+            newItems.append(item)
+        if self.allowMultiColumnPk:
+            self.uniqueModel.clear()
+            self.uniqueModel.appendColumn(newItems)
+            self.uniqueChanged()
+        else:
+            previousUniqueColumn = self.uniqueCombo.currentText()
+            self.uniqueModel.clear()
+            self.uniqueModel.appendColumn(newItems)
+            if self.uniqueModel.findItems(previousUniqueColumn):
+                self.uniqueCombo.setEditText(previousUniqueColumn)
+                uniqueIsFilled = True
+
+        oldGeometryColumn = self.geomCombo.currentText()
+        self.geomCombo.clear()
+        self.geomCombo.addItems(cols)
+        self.geomCombo.setCurrentIndex(self.geomCombo.findText(oldGeometryColumn, Qt.MatchExactly))
+
+        # set sensible default columns if the columns are not already set
+        try:
+            if self.geomCombo.currentIndex() == -1:
+                self.geomCombo.setCurrentIndex(cols.index(defaultGeomCol))
+        except:
+            pass
+        items = self.uniqueModel.findItems(defaultUniqueCol)
+        if items and not uniqueIsFilled:
+            if self.allowMultiColumnPk:
+                items[0].setCheckState(Qt.Checked)
+            else:
+                self.uniqueCombo.setEditText(defaultUniqueCol)
+
+    def copySelectedResults(self):
+        if len(self.viewResult.selectedIndexes()) <= 0:
+            return
+        model = self.viewResult.model()
+
+        # convert to string using tab as separator
+        text = model.headerToString("\t")
+        for idx in self.viewResult.selectionModel().selectedRows():
+            text += "\n" + model.rowToString(idx.row(), "\t")
+
+        QApplication.clipboard().setText(text, QClipboard.Selection)
+        QApplication.clipboard().setText(text, QClipboard.Clipboard)
+
+    def initCompleter(self):
+        dictionary = None
+        if self.db:
+            dictionary = self.db.connector.getSqlDictionary()
+        if not dictionary:
+            # use the generic sql dictionary
+            from .sql_dictionary import getSqlDictionary
+
+            dictionary = getSqlDictionary()
+
+        wordlist = []
+        for name, value in dictionary.items():
+            wordlist += value  # concat lists
+        wordlist = list(set(wordlist))  # remove duplicates
+
+        api = QsciAPIs(self.editSql.lexer())
+        for word in wordlist:
+            api.add(word)
+
+        api.prepare()
+        self.editSql.lexer().setAPIs(api)
+
+    def displayQueryBuilder(self):
+        dlg = QueryBuilderDlg(self.iface, self.db, self, reset=self.queryBuilderFirst)
+        self.queryBuilderFirst = False
+        r = dlg.exec_()
+        if r == QDialog.Accepted:
+            self.editSql.setText(dlg.query)
+
+    def _getSqlQuery(self):
+        sql = self.editSql.selectedText()
+        if len(sql) == 0:
+            sql = self.editSql.text()
+        return sql
+
+    def uniqueChanged(self):
+        # when an item is (un)checked, simply trigger an update of the combobox text
+        self.uniqueTextChanged(None)
+
+    def uniqueTextChanged(self, text):
+        # Whenever there is new text displayed in the combobox, check if it is the correct one and if not, display the correct one.
+        checkedItems = [
+            item.text()
+            for item in self.uniqueModel.findItems("*", Qt.MatchWildcard)
+            if item.checkState() == Qt.Checked
+        ]
+
+        label = ", ".join(checkedItems)
+        if text != label:
+            self.uniqueCombo.setEditText(label)
+
+    def setFilter(self):
+        from qgis.gui import QgsQueryBuilder
+        layer = self._getSqlLayer("")
+        if not layer:
+            return
+
+        dlg = QgsQueryBuilder(layer)
+        dlg.setSql(self.filter)
+        if dlg.exec_():
+            self.filter = dlg.sql()
+        layer.deleteLater()
+
+    def setHasChanged(self, hasChanged):
+        self.hasChanged = hasChanged
+
+    def close(self):
+        if self.hasChanged:
+            ret = QMessageBox.question(
+                self, self.tr('Unsaved Changes?'),
+                self.tr('There are unsaved changes. Do you want to keep them?'),
+                QMessageBox.Save | QMessageBox.Cancel | QMessageBox.Discard, QMessageBox.Cancel)
+
+            if ret == QMessageBox.Save:
+                self.saveAsFilePreset()
+                return True
+            elif ret == QMessageBox.Discard:
+                return True
+            else:
+                return False
+        else:
+            return True

+ 719 - 0
db_manager/dlg_sql_window.py

@@ -0,0 +1,719 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : May 23, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+from hashlib import md5
+
+import os
+
+from qgis.PyQt.QtCore import Qt, pyqtSignal, QDir, QCoreApplication
+from qgis.PyQt.QtWidgets import (QDialog,
+                                 QWidget,
+                                 QAction,
+                                 QApplication,
+                                 QInputDialog,
+                                 QStyledItemDelegate,
+                                 QTableWidgetItem,
+                                 QFileDialog,
+                                 QMessageBox
+                                 )
+from qgis.PyQt.QtGui import (QKeySequence,
+                             QCursor,
+                             QClipboard,
+                             QIcon,
+                             QStandardItemModel,
+                             QStandardItem
+                             )
+from qgis.PyQt.Qsci import QsciAPIs, QsciScintilla
+
+from qgis.core import (
+    QgsProject,
+    QgsApplication,
+    QgsTask,
+    QgsSettings,
+    QgsMapLayerType
+)
+from qgis.utils import OverrideCursor
+
+from .db_plugins.plugin import BaseError
+from .db_plugins.postgis.plugin import PGDatabase
+from .dlg_db_error import DlgDbError
+from .dlg_query_builder import QueryBuilderDlg
+
+try:
+    from qgis.gui import QgsCodeEditorSQL  # NOQA
+except:
+    from .sqledit import SqlEdit
+    from qgis import gui
+
+    gui.QgsCodeEditorSQL = SqlEdit
+
+from .ui.ui_DlgSqlWindow import Ui_DbManagerDlgSqlWindow as Ui_Dialog
+
+import re
+
+
+def check_comments_in_sql(raw_sql_input):
+    lines = []
+    for line in raw_sql_input.splitlines():
+        if not line.strip().startswith('--'):
+            if '--' in line:
+                comments = re.finditer(r'--', line)
+                comment_positions = [
+                    match.start()
+                    for match in comments
+                ]
+                identifiers = re.finditer(r'"(?:[^"]|"")*"', line)
+                quotes = re.finditer(r"'(?:[^']|'')*'", line)
+                quote_positions = []
+                for match in identifiers:
+                    quote_positions.append((match.start(), match.end()))
+                for match in quotes:
+                    quote_positions.append((match.start(), match.end()))
+                unquoted_comments = comment_positions.copy()
+                for comment in comment_positions:
+                    for quote_position in quote_positions:
+                        if comment >= quote_position[0] and comment < quote_position[1]:
+                            unquoted_comments.remove(comment)
+                if len(unquoted_comments) > 0:
+                    lines.append(line[:unquoted_comments[0]])
+                else:
+                    lines.append(line)
+            else:
+                lines.append(line)
+    sql = ' '.join(lines)
+    return sql.strip()
+
+
+class DlgSqlWindow(QWidget, Ui_Dialog):
+    nameChanged = pyqtSignal(str)
+    QUERY_HISTORY_LIMIT = 20
+    hasChanged = False
+
+    def __init__(self, iface, db, parent=None):
+        QWidget.__init__(self, parent)
+        self.mainWindow = parent
+        self.iface = iface
+        self.db = db
+        self.dbType = db.connection().typeNameString()
+        self.connectionName = db.connection().connectionName()
+        self.filter = ""
+        self.modelAsync = None
+        self.allowMultiColumnPk = isinstance(db,
+                                             PGDatabase)  # at the moment only PostgreSQL allows a primary key to span multiple columns, SpatiaLite doesn't
+        self.aliasSubQuery = isinstance(db, PGDatabase)  # only PostgreSQL requires subqueries to be aliases
+        self.setupUi(self)
+        self.setWindowTitle(
+            self.tr("{0} - {1} [{2}]").format(self.windowTitle(), self.connectionName, self.dbType))
+
+        self.defaultLayerName = self.tr('QueryLayer')
+
+        if self.allowMultiColumnPk:
+            self.uniqueColumnCheck.setText(self.tr("Column(s) with unique values"))
+        else:
+            self.uniqueColumnCheck.setText(self.tr("Column with unique values"))
+
+        self.editSql.setFocus()
+        self.editSql.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+        self.editSql.setLineNumbersVisible(True)
+        self.initCompleter()
+        self.editSql.textChanged.connect(lambda: self.setHasChanged(True))
+
+        settings = QgsSettings()
+        self.history = settings.value('DB_Manager/queryHistory/' + self.dbType, {self.connectionName: []})
+        if self.connectionName not in self.history:
+            self.history[self.connectionName] = []
+
+        self.queryHistoryWidget.setVisible(False)
+        self.queryHistoryTableWidget.verticalHeader().hide()
+        self.queryHistoryTableWidget.doubleClicked.connect(self.insertQueryInEditor)
+        self.populateQueryHistory()
+        self.btnQueryHistory.toggled.connect(self.showHideQueryHistory)
+
+        self.btnCancel.setEnabled(False)
+        self.btnCancel.clicked.connect(self.executeSqlCanceled)
+        self.btnCancel.setShortcut(QKeySequence.Cancel)
+        self.progressBar.setEnabled(False)
+        self.progressBar.setRange(0, 100)
+        self.progressBar.setValue(0)
+        self.progressBar.setFormat("")
+        self.progressBar.setAlignment(Qt.AlignCenter)
+
+        # allow copying results
+        copyAction = QAction("copy", self)
+        self.viewResult.addAction(copyAction)
+        copyAction.setShortcuts(QKeySequence.Copy)
+
+        copyAction.triggered.connect(self.copySelectedResults)
+
+        self.btnExecute.clicked.connect(self.executeSql)
+        self.btnSetFilter.clicked.connect(self.setFilter)
+        self.btnClear.clicked.connect(self.clearSql)
+
+        self.presetStore.clicked.connect(self.storePreset)
+        self.presetSaveAsFile.clicked.connect(self.saveAsFilePreset)
+        self.presetLoadFile.clicked.connect(self.loadFilePreset)
+        self.presetDelete.clicked.connect(self.deletePreset)
+        self.presetCombo.activated[str].connect(self.loadPreset)
+        self.presetCombo.activated[str].connect(self.presetName.setText)
+
+        self.updatePresetsCombobox()
+
+        self.geomCombo.setEditable(True)
+        self.geomCombo.lineEdit().setReadOnly(True)
+
+        self.uniqueCombo.setEditable(True)
+        self.uniqueCombo.lineEdit().setReadOnly(True)
+        self.uniqueModel = QStandardItemModel(self.uniqueCombo)
+        self.uniqueCombo.setModel(self.uniqueModel)
+        if self.allowMultiColumnPk:
+            self.uniqueCombo.setItemDelegate(QStyledItemDelegate())
+            self.uniqueModel.itemChanged.connect(self.uniqueChanged)  # react to the (un)checking of an item
+            self.uniqueCombo.lineEdit().textChanged.connect(
+                self.uniqueTextChanged)  # there are other events that change the displayed text and some of them can not be caught directly
+
+        # hide the load query as layer if feature is not supported
+        self._loadAsLayerAvailable = self.db.connector.hasCustomQuerySupport()
+        self.loadAsLayerGroup.setVisible(self._loadAsLayerAvailable)
+        if self._loadAsLayerAvailable:
+            self.layerTypeWidget.hide()  # show if load as raster is supported
+            self.loadLayerBtn.clicked.connect(self.loadSqlLayer)
+            self.getColumnsBtn.clicked.connect(self.fillColumnCombos)
+            self.loadAsLayerGroup.toggled.connect(self.loadAsLayerToggled)
+            self.loadAsLayerToggled(False)
+
+        self._createViewAvailable = self.db.connector.hasCreateSpatialViewSupport()
+        self.btnCreateView.setVisible(self._createViewAvailable)
+        if self._createViewAvailable:
+            self.btnCreateView.clicked.connect(self.createView)
+
+        self.queryBuilderFirst = True
+        self.queryBuilderBtn.setIcon(QIcon(":/db_manager/icons/sql.gif"))
+        self.queryBuilderBtn.clicked.connect(self.displayQueryBuilder)
+
+        self.presetName.textChanged.connect(self.nameChanged)
+
+    def insertQueryInEditor(self, item):
+        sql = item.data(Qt.DisplayRole)
+        self.editSql.insertText(sql)
+
+    def showHideQueryHistory(self, visible):
+        self.queryHistoryWidget.setVisible(visible)
+
+    def populateQueryHistory(self):
+        self.queryHistoryTableWidget.clearContents()
+        self.queryHistoryTableWidget.setRowCount(0)
+        dictlist = self.history[self.connectionName]
+
+        if not dictlist:
+            return
+
+        for i in range(len(dictlist)):
+            self.queryHistoryTableWidget.insertRow(0)
+            queryItem = QTableWidgetItem(dictlist[i]['query'])
+            rowsItem = QTableWidgetItem(str(dictlist[i]['rows']))
+            durationItem = QTableWidgetItem(str(dictlist[i]['secs']))
+            self.queryHistoryTableWidget.setItem(0, 0, queryItem)
+            self.queryHistoryTableWidget.setItem(0, 1, rowsItem)
+            self.queryHistoryTableWidget.setItem(0, 2, durationItem)
+
+        self.queryHistoryTableWidget.resizeColumnsToContents()
+        self.queryHistoryTableWidget.resizeRowsToContents()
+
+    def writeQueryHistory(self, sql, affectedRows, secs):
+        if len(self.history[self.connectionName]) >= self.QUERY_HISTORY_LIMIT:
+            self.history[self.connectionName].pop(0)
+
+        settings = QgsSettings()
+        self.history[self.connectionName].append({'query': sql,
+                                                  'rows': affectedRows,
+                                                  'secs': secs})
+        settings.setValue('DB_Manager/queryHistory/' + self.dbType, self.history)
+
+        self.populateQueryHistory()
+
+    def getQueryHash(self, name):
+        return 'q%s' % md5(name.encode('utf8')).hexdigest()
+
+    def updatePresetsCombobox(self):
+        self.presetCombo.clear()
+
+        names = []
+        entries = QgsProject.instance().subkeyList('DBManager', 'savedQueries')
+        for entry in entries:
+            name = QgsProject.instance().readEntry('DBManager', 'savedQueries/' + entry + '/name')[0]
+            names.append(name)
+
+        for name in sorted(names):
+            self.presetCombo.addItem(name)
+        self.presetCombo.setCurrentIndex(-1)
+
+    def storePreset(self):
+        query = self._getSqlQuery()
+        if query == "":
+            return
+        name = str(self.presetName.text())
+        QgsProject.instance().writeEntry('DBManager', 'savedQueries/' + self.getQueryHash(name) + '/name', name)
+        QgsProject.instance().writeEntry('DBManager', 'savedQueries/' + self.getQueryHash(name) + '/query', query)
+        index = self.presetCombo.findText(name)
+        if index == -1:
+            self.presetCombo.addItem(name)
+            self.presetCombo.setCurrentIndex(self.presetCombo.count() - 1)
+        else:
+            self.presetCombo.setCurrentIndex(index)
+
+    def saveAsFilePreset(self):
+        settings = QgsSettings()
+        lastDir = settings.value('DB_Manager/lastDirSQLFIle', "")
+
+        query = self.editSql.text()
+        if query == "":
+            return
+
+        filename, _ = QFileDialog.getSaveFileName(
+            self,
+            self.tr('Save SQL Query'),
+            lastDir,
+            self.tr("SQL File (*.sql *.SQL)"))
+
+        if filename:
+            if not filename.lower().endswith('.sql'):
+                filename += ".sql"
+
+            with open(filename, 'w') as f:
+                f.write(query)
+                lastDir = os.path.dirname(filename)
+                settings.setValue('DB_Manager/lastDirSQLFile', lastDir)
+
+    def loadFilePreset(self):
+        settings = QgsSettings()
+        lastDir = settings.value('DB_Manager/lastDirSQLFIle', "")
+
+        filename, _ = QFileDialog.getOpenFileName(
+            self,
+            self.tr("Load SQL Query"),
+            lastDir,
+            self.tr("SQL File (*.sql *.SQL);;All Files (*)"))
+
+        if filename:
+            with open(filename) as f:
+                self.editSql.clear()
+                for line in f:
+                    self.editSql.insertText(line)
+                lastDir = os.path.dirname(filename)
+                settings.setValue('DB_Manager/lastDirSQLFile', lastDir)
+
+    def deletePreset(self):
+        name = self.presetCombo.currentText()
+        QgsProject.instance().removeEntry('DBManager', 'savedQueries/' + self.getQueryHash(name))
+        self.presetCombo.removeItem(self.presetCombo.findText(name))
+        self.presetCombo.setCurrentIndex(-1)
+
+    def loadPreset(self, name):
+        query = QgsProject.instance().readEntry('DBManager', 'savedQueries/' + self.getQueryHash(name) + '/query')[0]
+        self.editSql.setText(query)
+
+    def loadAsLayerToggled(self, checked):
+        self.loadAsLayerGroup.setChecked(checked)
+        self.loadAsLayerWidget.setVisible(checked)
+        if checked:
+            self.fillColumnCombos()
+
+    def clearSql(self):
+        self.editSql.clear()
+        self.editSql.setFocus()
+        self.filter = ""
+        self.setHasChanged(True)
+
+    def updateUiWhileSqlExecution(self, status):
+        if status:
+            for i in range(0, self.mainWindow.tabs.count()):
+                if i != self.mainWindow.tabs.currentIndex():
+                    self.mainWindow.tabs.setTabEnabled(i, False)
+
+            self.mainWindow.menuBar.setEnabled(False)
+            self.mainWindow.toolBar.setEnabled(False)
+            self.mainWindow.tree.setEnabled(False)
+
+            for w in self.findChildren(QWidget):
+                w.setEnabled(False)
+
+            self.btnCancel.setEnabled(True)
+            self.progressBar.setEnabled(True)
+            self.progressBar.setRange(0, 0)
+        else:
+            for i in range(0, self.mainWindow.tabs.count()):
+                if i != self.mainWindow.tabs.currentIndex():
+                    self.mainWindow.tabs.setTabEnabled(i, True)
+
+            self.mainWindow.refreshTabs()
+            self.mainWindow.menuBar.setEnabled(True)
+            self.mainWindow.toolBar.setEnabled(True)
+            self.mainWindow.tree.setEnabled(True)
+
+            for w in self.findChildren(QWidget):
+                w.setEnabled(True)
+
+            self.btnCancel.setEnabled(False)
+            self.progressBar.setRange(0, 100)
+            self.progressBar.setEnabled(False)
+
+    def executeSqlCanceled(self):
+        self.btnCancel.setEnabled(False)
+        self.btnCancel.setText(QCoreApplication.translate("DlgSqlWindow", "Canceling…"))
+        self.modelAsync.cancel()
+
+    def executeSqlCompleted(self):
+        self.updateUiWhileSqlExecution(False)
+
+        with OverrideCursor(Qt.WaitCursor):
+            if self.modelAsync.task.status() == QgsTask.Complete:
+                model = self.modelAsync.model
+                self.showError(None)
+                self.viewResult.setModel(model)
+                self.lblResult.setText(self.tr("{0} rows, {1:.3f} seconds").format(model.affectedRows(), model.secs()))
+                cols = self.viewResult.model().columnNames()
+                quotedCols = [
+                    self.db.connector.quoteId(col)
+                    for col in cols
+                ]
+
+                self.setColumnCombos(cols, quotedCols)
+
+                self.writeQueryHistory(self.modelAsync.task.sql, model.affectedRows(), model.secs())
+                self.update()
+            elif not self.modelAsync.canceled:
+                self.showError(self.modelAsync.error)
+
+                self.uniqueModel.clear()
+                self.geomCombo.clear()
+
+            self.btnCancel.setText(self.tr("Cancel"))
+
+    def executeSql(self):
+        sql = self._getExecutableSqlQuery()
+        if sql == "":
+            return
+
+        # delete the old model
+        old_model = self.viewResult.model()
+        self.viewResult.setModel(None)
+        if old_model:
+            old_model.deleteLater()
+
+        try:
+            self.modelAsync = self.db.sqlResultModelAsync(sql, self)
+            self.modelAsync.done.connect(self.executeSqlCompleted)
+            self.updateUiWhileSqlExecution(True)
+            QgsApplication.taskManager().addTask(self.modelAsync.task)
+        except Exception as e:
+            self.showError(e)
+            self.uniqueModel.clear()
+            self.geomCombo.clear()
+            return
+
+    def showError(self, error):
+        '''Shows the error or hides it if error is None'''
+        if error:
+            self.viewResult.setVisible(False)
+            self.errorText.setVisible(True)
+            self.errorText.setText(error.msg)
+            self.errorText.setWrapMode(QsciScintilla.WrapWord)
+        else:
+            self.viewResult.setVisible(True)
+            self.errorText.setVisible(False)
+
+    def _getSqlLayer(self, _filter):
+        hasUniqueField = self.uniqueColumnCheck.checkState() == Qt.Checked
+        if hasUniqueField and self.allowMultiColumnPk:
+            uniqueFieldName = ",".join(
+                item.data()
+                for item in self.uniqueModel.findItems("*", Qt.MatchWildcard)
+                if item.checkState() == Qt.Checked
+            )
+        elif (
+            hasUniqueField
+            and not self.allowMultiColumnPk
+            and self.uniqueCombo.currentIndex() >= 0
+        ):
+            uniqueFieldName = self.uniqueModel.item(self.uniqueCombo.currentIndex()).data()
+        else:
+            uniqueFieldName = None
+        hasGeomCol = self.hasGeometryCol.checkState() == Qt.Checked
+        if hasGeomCol:
+            geomFieldName = self.geomCombo.currentText()
+        else:
+            geomFieldName = None
+
+        query = self._getExecutableSqlQuery()
+        if query == "":
+            return None
+
+        # remove a trailing ';' from query if present
+        if query.strip().endswith(';'):
+            query = query.strip()[:-1]
+
+        layerType = QgsMapLayerType.VectorLayer if self.vectorRadio.isChecked() else QgsMapLayerType.RasterLayer
+
+        # get a new layer name
+        names = []
+        for layer in list(QgsProject.instance().mapLayers().values()):
+            names.append(layer.name())
+
+        layerName = self.layerNameEdit.text()
+        if layerName == "":
+            layerName = self.defaultLayerName
+        newLayerName = layerName
+        index = 1
+        while newLayerName in names:
+            index += 1
+            newLayerName = "%s_%d" % (layerName, index)
+
+        # create the layer
+        layer = self.db.toSqlLayer(query, geomFieldName, uniqueFieldName, newLayerName, layerType,
+                                   self.avoidSelectById.isChecked(), _filter)
+        if layer.isValid():
+            return layer
+        else:
+            e = BaseError(self.tr("There was an error creating the SQL layer, please check the logs for further information."))
+            DlgDbError.showError(e, self)
+            return None
+
+    def loadSqlLayer(self):
+        with OverrideCursor(Qt.WaitCursor):
+            layer = self._getSqlLayer(self.filter)
+            if layer is None:
+                return
+
+            QgsProject.instance().addMapLayers([layer], True)
+
+    def fillColumnCombos(self):
+        query = self._getExecutableSqlQuery()
+        if query == "":
+            return
+
+        with OverrideCursor(Qt.WaitCursor):
+            # remove a trailing ';' from query if present
+            if query.strip().endswith(';'):
+                query = query.strip()[:-1]
+
+            # get all the columns
+            quotedCols = []
+            connector = self.db.connector
+            if self.aliasSubQuery:
+                # get a new alias
+                aliasIndex = 0
+                while True:
+                    alias = "_subQuery__%d" % aliasIndex
+                    escaped = re.compile('\\b("?)' + re.escape(alias) + '\\1\\b')
+                    if not escaped.search(query):
+                        break
+                    aliasIndex += 1
+
+                sql = "SELECT * FROM (%s\n) AS %s LIMIT 0" % (str(query), connector.quoteId(alias))
+            else:
+                sql = "SELECT * FROM (%s\n) WHERE 1=0" % str(query)
+
+            c = None
+            try:
+                c = connector._execute(None, sql)
+                cols = connector._get_cursor_columns(c)
+                for col in cols:
+                    quotedCols.append(connector.quoteId(col))
+
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+                self.uniqueModel.clear()
+                self.geomCombo.clear()
+                return
+
+            finally:
+                if c:
+                    c.close()
+                    del c
+
+            self.setColumnCombos(cols, quotedCols)
+
+    def setColumnCombos(self, cols, quotedCols):
+        # get sensible default columns. do this before sorting in case there's hints in the column order (e.g., id is more likely to be first)
+        try:
+            defaultGeomCol = next(col for col in cols if col in ['geom', 'geometry', 'the_geom', 'way'])
+        except:
+            defaultGeomCol = None
+        try:
+            defaultUniqueCol = [col for col in cols if 'id' in col][0]
+        except:
+            defaultUniqueCol = None
+
+        colNames = sorted(zip(cols, quotedCols))
+        newItems = []
+        uniqueIsFilled = False
+        for (col, quotedCol) in colNames:
+            item = QStandardItem(col)
+            item.setData(quotedCol)
+            item.setEnabled(True)
+            item.setCheckable(self.allowMultiColumnPk)
+            item.setSelectable(not self.allowMultiColumnPk)
+            if self.allowMultiColumnPk:
+                matchingItems = self.uniqueModel.findItems(col)
+                if matchingItems:
+                    item.setCheckState(matchingItems[0].checkState())
+                    uniqueIsFilled = uniqueIsFilled or matchingItems[0].checkState() == Qt.Checked
+                else:
+                    item.setCheckState(Qt.Unchecked)
+            newItems.append(item)
+        if self.allowMultiColumnPk:
+            self.uniqueModel.clear()
+            self.uniqueModel.appendColumn(newItems)
+            self.uniqueChanged()
+        else:
+            previousUniqueColumn = self.uniqueCombo.currentText()
+            self.uniqueModel.clear()
+            self.uniqueModel.appendColumn(newItems)
+            if self.uniqueModel.findItems(previousUniqueColumn):
+                self.uniqueCombo.setEditText(previousUniqueColumn)
+                uniqueIsFilled = True
+
+        oldGeometryColumn = self.geomCombo.currentText()
+        self.geomCombo.clear()
+        self.geomCombo.addItems(cols)
+        self.geomCombo.setCurrentIndex(self.geomCombo.findText(oldGeometryColumn, Qt.MatchExactly))
+
+        # set sensible default columns if the columns are not already set
+        try:
+            if self.geomCombo.currentIndex() == -1:
+                self.geomCombo.setCurrentIndex(cols.index(defaultGeomCol))
+        except:
+            pass
+        items = self.uniqueModel.findItems(defaultUniqueCol)
+        if items and not uniqueIsFilled:
+            if self.allowMultiColumnPk:
+                items[0].setCheckState(Qt.Checked)
+            else:
+                self.uniqueCombo.setEditText(defaultUniqueCol)
+
+    def copySelectedResults(self):
+        if len(self.viewResult.selectedIndexes()) <= 0:
+            return
+        model = self.viewResult.model()
+
+        # convert to string using tab as separator
+        text = model.headerToString("\t")
+        for idx in self.viewResult.selectionModel().selectedRows():
+            text += "\n" + model.rowToString(idx.row(), "\t")
+
+        QApplication.clipboard().setText(text, QClipboard.Selection)
+        QApplication.clipboard().setText(text, QClipboard.Clipboard)
+
+    def initCompleter(self):
+        dictionary = None
+        if self.db:
+            dictionary = self.db.connector.getSqlDictionary()
+        if not dictionary:
+            # use the generic sql dictionary
+            from .sql_dictionary import getSqlDictionary
+
+            dictionary = getSqlDictionary()
+
+        wordlist = []
+        for value in dictionary.values():
+            wordlist += value  # concat lists
+        wordlist = list(set(wordlist))  # remove duplicates
+
+        api = QsciAPIs(self.editSql.lexer())
+        for word in wordlist:
+            api.add(word)
+
+        api.prepare()
+        self.editSql.lexer().setAPIs(api)
+
+    def displayQueryBuilder(self):
+        dlg = QueryBuilderDlg(self.iface, self.db, self, reset=self.queryBuilderFirst)
+        self.queryBuilderFirst = False
+        r = dlg.exec_()
+        if r == QDialog.Accepted:
+            self.editSql.setText(dlg.query)
+
+    def createView(self):
+        name, ok = QInputDialog.getText(None, self.tr("View Name"), self.tr("View name"))
+        if ok:
+            try:
+                self.db.connector.createSpatialView(name, self._getExecutableSqlQuery())
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+
+    def _getSqlQuery(self):
+        sql = self.editSql.selectedText()
+        if len(sql) == 0:
+            sql = self.editSql.text()
+        return sql
+
+    def _getExecutableSqlQuery(self):
+        sql = self._getSqlQuery().strip()
+
+        uncommented_sql = check_comments_in_sql(sql)
+        uncommented_sql = uncommented_sql.rstrip(';')
+        return uncommented_sql
+
+    def uniqueChanged(self):
+        # when an item is (un)checked, simply trigger an update of the combobox text
+        self.uniqueTextChanged(None)
+
+    def uniqueTextChanged(self, text):
+        # Whenever there is new text displayed in the combobox, check if it is the correct one and if not, display the correct one.
+        label = ", ".join(
+            item.text()
+            for item in self.uniqueModel.findItems("*", Qt.MatchWildcard)
+            if item.checkState() == Qt.Checked
+        )
+        if text != label:
+            self.uniqueCombo.setEditText(label)
+
+    def setFilter(self):
+        from qgis.gui import QgsQueryBuilder
+        layer = self._getSqlLayer("")
+        if not layer:
+            return
+
+        dlg = QgsQueryBuilder(layer)
+        dlg.setSql(self.filter)
+        if dlg.exec_():
+            self.filter = dlg.sql()
+        layer.deleteLater()
+
+    def setHasChanged(self, hasChanged):
+        self.hasChanged = hasChanged
+
+    def close(self):
+        if self.hasChanged:
+            ret = QMessageBox.question(
+                self, self.tr('Unsaved Changes?'),
+                self.tr('There are unsaved changes. Do you want to keep them?'),
+                QMessageBox.Save | QMessageBox.Cancel | QMessageBox.Discard, QMessageBox.Cancel)
+
+            if ret == QMessageBox.Save:
+                self.saveAsFilePreset()
+                return True
+            elif ret == QMessageBox.Discard:
+                return True
+            else:
+                return False
+        else:
+            return True

+ 362 - 0
db_manager/dlg_table_properties.py

@@ -0,0 +1,362 @@
+"""
+/***************************************************************************
+Name                 : DB Manager
+Description          : Database manager plugin for QGIS
+Date                 : Oct 13, 2011
+copyright            : (C) 2011 by Giuseppe Sucameli
+email                : brush.tyler@gmail.com
+
+The content of this file is based on
+- PG_Manager by Martin Dobias (GPLv2 license)
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or modify  *
+ *   it under the terms of the GNU General Public License as published by  *
+ *   the Free Software Foundation; either version 2 of the License, or     *
+ *   (at your option) any later version.                                   *
+ *                                                                         *
+ ***************************************************************************/
+"""
+
+from qgis.PyQt.QtCore import Qt, pyqtSignal
+from qgis.PyQt.QtWidgets import QDialog, QMessageBox, QApplication
+
+from qgis.utils import OverrideCursor
+
+from .db_plugins.data_model import TableFieldsModel, TableConstraintsModel, TableIndexesModel
+from .db_plugins.plugin import BaseError, DbError
+from .dlg_db_error import DlgDbError
+
+from .dlg_field_properties import DlgFieldProperties
+from .dlg_add_geometry_column import DlgAddGeometryColumn
+from .dlg_create_constraint import DlgCreateConstraint
+from .dlg_create_index import DlgCreateIndex
+
+from .ui.ui_DlgTableProperties import Ui_DbManagerDlgTableProperties as Ui_Dialog
+
+
+class DlgTableProperties(QDialog, Ui_Dialog):
+    aboutToChangeTable = pyqtSignal()
+
+    def __init__(self, table, parent=None):
+        QDialog.__init__(self, parent)
+        self.table = table
+        self.setupUi(self)
+
+        self.db = self.table.database()
+
+        supportCom = self.db.supportsComment()
+        if not supportCom:
+            self.tabs.removeTab(3)
+
+        m = TableFieldsModel(self)
+        self.viewFields.setModel(m)
+
+        m = TableConstraintsModel(self)
+        self.viewConstraints.setModel(m)
+
+        m = TableIndexesModel(self)
+        self.viewIndexes.setModel(m)
+
+        # Display comment in line edit
+        m = self.table.comment
+        self.viewComment.setText(m)
+
+        self.btnAddColumn.clicked.connect(self.addColumn)
+        self.btnAddGeometryColumn.clicked.connect(self.addGeometryColumn)
+        self.btnEditColumn.clicked.connect(self.editColumn)
+        self.btnDeleteColumn.clicked.connect(self.deleteColumn)
+
+        self.btnAddConstraint.clicked.connect(self.addConstraint)
+        self.btnDeleteConstraint.clicked.connect(self.deleteConstraint)
+
+        self.btnAddIndex.clicked.connect(self.createIndex)
+        self.btnAddSpatialIndex.clicked.connect(self.createSpatialIndex)
+        self.btnDeleteIndex.clicked.connect(self.deleteIndex)
+
+        # Connect button add Comment to function
+        self.btnAddComment.clicked.connect(self.createComment)
+        # Connect button delete Comment to function
+        self.btnDeleteComment.clicked.connect(self.deleteComment)
+
+        self.refresh()
+
+    def refresh(self):
+        self.populateViews()
+        self.checkSupports()
+
+    def checkSupports(self):
+        allowEditColumns = self.db.connector.hasTableColumnEditingSupport()
+        self.btnEditColumn.setEnabled(allowEditColumns)
+        self.btnDeleteColumn.setEnabled(allowEditColumns)
+
+        self.btnAddGeometryColumn.setEnabled(self.db.connector.canAddGeometryColumn((self.table.schemaName(), self.table.name)))
+        self.btnAddSpatialIndex.setEnabled(self.db.connector.canAddSpatialIndex((self.table.schemaName(), self.table.name)))
+
+    def populateViews(self):
+        self.populateFields()
+        self.populateConstraints()
+        self.populateIndexes()
+
+    def populateFields(self):
+        """ load field information from database """
+        m = self.viewFields.model()
+        m.clear()
+
+        for fld in self.table.fields():
+            m.append(fld)
+
+        for col in range(4):
+            self.viewFields.resizeColumnToContents(col)
+
+    def currentColumn(self):
+        """ returns row index of selected column """
+        sel = self.viewFields.selectionModel()
+        indexes = sel.selectedRows()
+        if len(indexes) == 0:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("No columns were selected."))
+            return -1
+        return indexes[0].row()
+
+    def addColumn(self):
+        """ open dialog to set column info and add column to table """
+        dlg = DlgFieldProperties(self, None, self.table)
+        if not dlg.exec_():
+            return
+        fld = dlg.getField()
+
+        with OverrideCursor(Qt.WaitCursor):
+            self.aboutToChangeTable.emit()
+            try:
+                # add column to table
+                self.table.addField(fld)
+                self.refresh()
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+
+    def addGeometryColumn(self):
+        """ open dialog to add geometry column """
+        dlg = DlgAddGeometryColumn(self, self.table)
+        if not dlg.exec_():
+            return
+        self.refresh()
+
+    def editColumn(self):
+        """ open dialog to change column info and alter table appropriately """
+        index = self.currentColumn()
+        if index == -1:
+            return
+
+        m = self.viewFields.model()
+        # get column in table
+        # (there can be missing number if someone deleted a column)
+        fld = m.getObject(index)
+
+        dlg = DlgFieldProperties(self, fld, self.table)
+        if not dlg.exec_():
+            return
+        new_fld = dlg.getField(True)
+
+        with OverrideCursor(Qt.WaitCursor):
+            self.aboutToChangeTable.emit()
+            try:
+                fld.update(new_fld.name, new_fld.type2String(), new_fld.notNull, new_fld.default2String(), new_fld.comment)
+                self.refresh()
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+
+    def deleteColumn(self):
+        """Deletes currently selected column """
+        index = self.currentColumn()
+        if index == -1:
+            return
+
+        m = self.viewFields.model()
+        fld = m.getObject(index)
+
+        res = QMessageBox.question(self, self.tr("Delete Column"),
+                                   self.tr("Are you sure you want to delete column '{0}'?").format(fld.name),
+                                   QMessageBox.Yes | QMessageBox.No)
+        if res != QMessageBox.Yes:
+            return
+
+        with OverrideCursor(Qt.WaitCursor):
+            self.aboutToChangeTable.emit()
+            try:
+                fld.delete()
+                self.refresh()
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+
+    def populateConstraints(self):
+        constraints = self.table.constraints()
+        if constraints is None:
+            self.hideConstraints()  # not supported
+            return
+
+        m = self.viewConstraints.model()
+        m.clear()
+
+        for constr in constraints:
+            m.append(constr)
+
+        for col in range(3):
+            self.viewConstraints.resizeColumnToContents(col)
+
+    def hideConstraints(self):
+        index = self.tabs.indexOf(self.tabConstraints)
+        if index >= 0:
+            self.tabs.setTabEnabled(index, False)
+
+    def addConstraint(self):
+        """Adds primary key or unique constraint """
+
+        dlg = DlgCreateConstraint(self, self.table)
+        if not dlg.exec_():
+            return
+        self.refresh()
+
+    def deleteConstraint(self):
+        """Deletes a constraint """
+
+        index = self.currentConstraint()
+        if index == -1:
+            return
+
+        m = self.viewConstraints.model()
+        constr = m.getObject(index)
+
+        res = QMessageBox.question(self, self.tr("Delete Constraint"),
+                                   self.tr("Are you sure you want to delete constraint '{0}'?").format(constr.name),
+                                   QMessageBox.Yes | QMessageBox.No)
+        if res != QMessageBox.Yes:
+            return
+
+        with OverrideCursor(Qt.WaitCursor):
+            self.aboutToChangeTable.emit()
+            try:
+                constr.delete()
+                self.refresh()
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+
+    def currentConstraint(self):
+        """ returns row index of selected index """
+        sel = self.viewConstraints.selectionModel()
+        indexes = sel.selectedRows()
+        if len(indexes) == 0:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("No constraints were selected."))
+            return -1
+        return indexes[0].row()
+
+    def populateIndexes(self):
+        indexes = self.table.indexes()
+        if indexes is None:
+            self.hideIndexes()
+            return
+
+        m = self.viewIndexes.model()
+        m.clear()
+
+        for idx in indexes:
+            m.append(idx)
+
+        for col in range(2):
+            self.viewIndexes.resizeColumnToContents(col)
+
+    def hideIndexes(self):
+        index = self.tabs.indexOf(self.tabIndexes)
+        if index >= 0:
+            self.tabs.setTabEnabled(index, False)
+
+    def createIndex(self):
+        """Creates an index """
+        dlg = DlgCreateIndex(self, self.table)
+        if not dlg.exec_():
+            return
+        self.refresh()
+
+    def createSpatialIndex(self):
+        """Creates spatial index for the geometry column """
+        if self.table.type != self.table.VectorType:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("The selected table has no geometry."))
+            return
+
+        res = QMessageBox.question(self, self.tr("Create Spatial Index"),
+                                   self.tr("Create spatial index for field {0}?").format(self.table.geomColumn),
+                                   QMessageBox.Yes | QMessageBox.No)
+        if res != QMessageBox.Yes:
+            return
+
+        # TODO: first check whether the index doesn't exist already
+        with OverrideCursor(Qt.WaitCursor):
+            self.aboutToChangeTable.emit()
+
+            try:
+                self.table.createSpatialIndex()
+                self.refresh()
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+
+    def currentIndex(self):
+        """ returns row index of selected index """
+        sel = self.viewIndexes.selectionModel()
+        indexes = sel.selectedRows()
+        if len(indexes) == 0:
+            QMessageBox.information(self, self.tr("DB Manager"), self.tr("No indices were selected."))
+            return -1
+        return indexes[0].row()
+
+    def deleteIndex(self):
+        """Deletes currently selected index """
+        index = self.currentIndex()
+        if index == -1:
+            return
+
+        m = self.viewIndexes.model()
+        idx = m.getObject(index)
+
+        res = QMessageBox.question(self, self.tr("Delete Index"),
+                                   self.tr("Are you sure you want to delete index '{0}'?").format(idx.name),
+                                   QMessageBox.Yes | QMessageBox.No)
+        if res != QMessageBox.Yes:
+            return
+
+        with OverrideCursor(Qt.WaitCursor):
+            self.aboutToChangeTable.emit()
+            try:
+                idx.delete()
+                self.refresh()
+            except BaseError as e:
+                DlgDbError.showError(e, self)
+
+    def createComment(self):
+        """Adds a comment to the selected table"""
+        try:
+            schem = self.table.schema().name
+            tab = self.table.name
+            com = self.viewComment.text()
+            self.db.connector.commentTable(schem, tab, com)
+        except DbError as e:
+            DlgDbError.showError(e, self)
+            return
+        self.refresh()
+        # Display successful message
+        QMessageBox.information(self, self.tr("Add comment"), self.tr("Table successfully commented"))
+
+    def deleteComment(self):
+        """Drops the comment on the selected table"""
+        try:
+            schem = self.table.schema().name
+            tab = self.table.name
+            self.db.connector.commentTable(schem, tab)
+        except DbError as e:
+            DlgDbError.showError(e, self)
+            return
+        self.refresh()
+        # Refresh line edit, put a void comment
+        self.viewComment.setText('')
+        # Display successful message
+        QMessageBox.information(self, self.tr("Delete comment"), self.tr("Comment deleted"))

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.