Introduction

This article is the last of my series of articles about QGIS forms. You can read the previous one here.

Today, we will focus on n,n relations. n,n is a database syntax to tell that one object of a table can have multiples values linked to another reference table. On a database schema, it looks like the following:

Image of n,n table schema

You can see that n,n relations are materialized by two 1,n relations, involving three tables:

  • The first data table (ANALYSIS).
  • The second data table (PESTICIDE).
  • A relation table (ANALYSIS_PESTICIDE) that make the connexion between the two tables mentionned above.

With such a mechanism, we are able to link multiple pesticides to multiple analysis without having to store a list into the ANALYSIS table. Instead, the central table (ANALYSIS_PESTICIDE) is used to make a link. Everytime you would like to implement n,n relations, just think about this intermediate table.

For the moment, QGIS doesn't support n,n tables on forms (but it supports 1,n with sub-forms). There is no control dedicated to that. But we can code it !

User Interface

What to do ?

Before diving into code, we need to solve the UI problem. What control are we going to use ? As we are not constrained by QGIS existing controls, we can imagine the following:

  • The main source of data is ANALYSIS: we want to add multiple pesticides from one analysis.
  • We only need the relevant information: which pesticides have been measured during the analysis.
  • If you have a catalog of ten thousand of pesticides, there is no need to show the whole list on the analysis form.
  • So we need a dedicated dialog for selecting pesticides. In this sub-form, the list of all the pesticides will be presented to the user in order to make a choice.
  • The pesticides table will store all informations about pesticides. We need to have a list of the pesticide's names and be able to choose multiple pesticides entries.
  • The simplest way to achieve this is to use a QListWidget with a checkbox for each pesticide entry. If the checkbox is checked, this analysis has this pesticide.
  • We need a "search engine" to quickly find pesticides from their names if the PESTICIDE table is huge.
  • Once selected, we need to only show the selected pesticides into analysis form.

Main form: analysis form

Here is a mockup of the analysis form:

Image of analysis form mockup

You can see that the last form control is a bit special: it is our QListWidget which lists all the pesticides that have been found in the displayed analysis. The list only shows the relevant information and when the control is not long enough, you have a vertical scrollbar. Elements of the list are selectable (multi or mono depending on QListWidget attributes) and you can copy/paste them in the clipboard. Whenever you need to have some information about the analysis, everything is displayed on only one form.

This form can't be autogenerated by QGIS because the QListWidget is mandatory and there is no field to hold pesticides values in ANALYSIS table. For n,n relations you have to use a custom ui form with qt4-designer like the following:

Image of Qtdesigner of the custom form for displaying pesticides results

Furthermore, printing information in the QListWidget of this form needs some code for a dedicated function in Python just to retrieve the good results from the ANALYSIS_PESTICIDE table. We will study this in the code part below.

What about editing pesticides into this analysis ?

Pesticide form

When you click on the "Modify" button, the following dialog will be printed:

Image pesticide dialog box

The dialog box is very simple: on the top, you have a QLineEdit which will be used to type the name of the pesticide you want. The main control of the dialog is a QListWidget with the name of all the pesticides. There is a checkbox to add or remove pesticides to the analysis. Checking a box will add the pesticide into the ANALYSIS_PESTICIDE table, unchecking will delete it from the table. With this dialog, you can add or delete as many pesticides you want for one analysis without bothering with the other controls.

Whenever your modifications are done, results need to affect the ANALYSIS_PESTICIDE table and it also needs some dedicated code.

Using QGIS relations and value relations

Now that the concepts of the UI part have been elaborated, we need to go further. Our approach seems to be good with one n,n relation. But imagine that you try to build a true complex GIS application that involves about 50 n,n relations. you can't put everything into code, it will take too much time to develop and to maintain. You will also make a lot of mistakes to try to keep the names of all the controls into Python code. So we need to be a little bit more generic.

QGIS has already a mechanism to handle 1,n relations: it is called "Relations". Relations are a way for QGIS to know that a table is linked to another. It is used to show sub-forms inside a parent form. So, could we try to use two 1,n relations and deal with it into code ? I have tried this but there is something more efficient. Creating two relations (first from ANALYSIS to ANALYSIS_PESTICIDE and second from PESTICIDE TO ANALYSIS_PESTICIDE) seems to be the good way but you have to remember what we want. We would like to display a list of pesticides names in the control and store the ID_PESTICIDE into ANALYSIS_PESTICIDE and there is nothing in a QGIS relation to tell that you want to display a field.

But there is something inside QGIS to deal with and it's called "Value Relation". When you define a Value Relation for a field, you are linking values from another layer and you can choose what field to show and what field is the ID. So instead of creating two relations, we could do the following:

  • Create only one relation: from ANALYSIS to ANALYSIS_PESTICIDE.
  • Create a Value Relation control for ID_PESTICIDE of ANALYSIS_PESTICIDE towards ID_PESTICIDE of table PESTICIDE and show PESTICIDE.NAME (this is the field we want to display in the n,n sub dialog).

Here is the definition of the relation:

Image pesticide dialog box

The name/id of the relation should be the same than the QListWidget of the custom .ui file.

Here is the definition of the Value Relation:

Image pesticide dialog box

This is a classic Value Relation configuration. It is made in the ANALYSIS_PESTICIDE table on the ID_PESTICIDE attribute.

The relation is used as the following:

  • the code launched when the ANALYSIS form is opened knows that the layer of the form is ANALYSIS.
  • with this name, it is easy to search inside the project relations (there is an API for that) where ANALYSIS is the parent layer of another.
  • code will take the name of the child layer (ANALYSIS_PESTICIDE) and extract the shared attributes (ID_ANALYSIS).
  • Now, we know from what table we must read the results to update the form (this will be ANALYSIS_PESTICIDE) and we also know what is the attribute to filter (ID_ANALYSIS) to have the corresponding value of the analysis that is displayed in the form.

The Value Relation is used as the following:

  • We already know that the intermediate table is ANALYSIS_PESTICIDE.
  • We search for its Value Relation form controls.
  • In its configuration, we find what is the last table (PESTICIDE), what field is displayed (NAME) and what field is used as ID.

The relation will be used inside ANALYSIS form to make the link between ANALYSIS and ANALYSIS_PESTICIDE. The QListWidget needs to have the name of the relation for the code to have a way to find which tables are involved. Value Relation is for the other part of the n,n relation: ANALYSIS_PESTICIDE to PESTICIDE.

We are done with the concepts !

Show me the code !

n,n dedicated dialog

Time to dive into Python...

First thing to do: the pesticide UI ! With PyQt you can create a .ui file and build it with qt4-designer. But loading a .ui file from Python can be unsafe: you have to deal with the file location. As the pesticide UI is very trivial, I prefer to build it with code. So, here is the Python code of the dialog:

   def setupUi(self):
        '''Builds the QDialog'''
        # Form building
        self.setObjectName(u"nnDialog")
        self.resize(550, 535)
        self.setMinimumSize(QtCore.QSize(0, 0))
        self.buttonBox = QtGui.QDialogButtonBox(self)
        self.buttonBox.setGeometry(QtCore.QRect(190, 500, 341, 32))
        self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
        self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok)
        self.buttonBox.setObjectName(u"buttonBox")
        self.verticalLayoutWidget = QtGui.QWidget(self)
        self.verticalLayoutWidget.setGeometry(QtCore.QRect(9, 9, 521, 491))
        self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
        self.verticalLayout = QtGui.QVBoxLayout(self.verticalLayoutWidget)
        self.verticalLayout.setMargin(0)
        self.verticalLayout.setObjectName(u"verticalLayout")
        self.horizontalLayout = QtGui.QHBoxLayout()
        self.horizontalLayout.setObjectName(u"horizontalLayout")
        self.label = QtGui.QLabel(self.verticalLayoutWidget)
        self.label.setObjectName(u"label")
        self.horizontalLayout.addWidget(self.label)
        self.SEARCH = QtGui.QLineEdit(self.verticalLayoutWidget)
        self.SEARCH.setObjectName(u"SEARCH")
        self.horizontalLayout.addWidget(self.SEARCH)
        self.verticalLayout.addLayout(self.horizontalLayout)
        self.horizontalLayout_2 = QtGui.QHBoxLayout()
        self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
        self.LIST = QtGui.QListWidget(self.verticalLayoutWidget)
        self.LIST.setObjectName(u"LIST")
        self.horizontalLayout_2.addWidget(self.LIST)
        self.verticalLayout.addLayout(self.horizontalLayout_2)

        self.buttonBox.accepted.connect(self.accept)
        self.buttonBox.rejected.connect(self.reject)
        QtCore.QMetaObject.connectSlotsByName(self)

Well, this is what you can have from pyuic4 from a qt4-designer .ui file. But this time you don't need the file anymore.

n,n list behaviour functions

Next thing to do is to populate the n,n dialog. We also need to add the "search engine" functions and a way to pre-check values that are in the ANALYSIS_PESTICIDE table for the current analysis. And at the end, we need to send the checked values to the main form (ANALYSIS one) in order to make the database update and to update the form control.

I've created a class for this:

class nnDialog(QtGui.QDialog):
    '''Dedicated n,n relations Form Class'''
    def __init__(self, parent, layer, shownField, IdField, initValues, search=False):
        '''Constructor'''
        QtGui.QDialog.__init__(self,parent)

        self.initValues = initValues
        self.shownField = shownField
        self.layer =  layer
        self.IdField = IdField
        self.search = search
        if self.layer is None and DEBUGMODE:
            QgsMessageLog.logMessage(u"nnDialog constructor: The layer {0} doesn't exists !".format(layer.name()),"nnForms", QgsMessageLog.INFO)

        # Build the GUI and populate the list with the good values
        self.setupUi()
        self.populateList()

        # Add dynamic control when list is changing
        self.SEARCH.textChanged.connect(self.populateList)
        self.LIST.itemChanged.connect(self.changeValues)

    def setupUi(self):
        '''Builds the QDialog'''
        # Form building
        self.setObjectName(u"nnDialog")
        self.resize(550, 535)
        self.setMinimumSize(QtCore.QSize(0, 0))
        self.buttonBox = QtGui.QDialogButtonBox(self)
        self.buttonBox.setGeometry(QtCore.QRect(190, 500, 341, 32))
        self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
        self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok)
        self.buttonBox.setObjectName(u"buttonBox")
        self.verticalLayoutWidget = QtGui.QWidget(self)
        self.verticalLayoutWidget.setGeometry(QtCore.QRect(9, 9, 521, 491))
        self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
        self.verticalLayout = QtGui.QVBoxLayout(self.verticalLayoutWidget)
        self.verticalLayout.setMargin(0)
        self.verticalLayout.setObjectName(u"verticalLayout")
        self.horizontalLayout = QtGui.QHBoxLayout()
        self.horizontalLayout.setObjectName(u"horizontalLayout")
        self.label = QtGui.QLabel(self.verticalLayoutWidget)
        self.label.setObjectName(u"label")
        self.horizontalLayout.addWidget(self.label)
        self.SEARCH = QtGui.QLineEdit(self.verticalLayoutWidget)
        self.SEARCH.setObjectName(u"SEARCH")
        self.horizontalLayout.addWidget(self.SEARCH)
        self.verticalLayout.addLayout(self.horizontalLayout)
        self.horizontalLayout_2 = QtGui.QHBoxLayout()
        self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
        self.LIST = QtGui.QListWidget(self.verticalLayoutWidget)
        self.LIST.setObjectName(u"LIST")
        self.horizontalLayout_2.addWidget(self.LIST)
        self.verticalLayout.addLayout(self.horizontalLayout_2)

        self.buttonBox.accepted.connect(self.accept)
        self.buttonBox.rejected.connect(self.reject)
        QtCore.QMetaObject.connectSlotsByName(self)

    def changeValues(self, element):
        '''Whenever a checkbox is checked, modify the values'''
        # Check if we check or uncheck the value:
        if element.checkState() == Qt.Checked:
            self.initValues.append(element.data(Qt.UserRole))
        else:
            self.initValues.remove(element.data(Qt.UserRole))

    def populateList(self, txtFilter=None):
        '''Fill the QListWidget with values'''
        # Delete everything
        self.LIST.clear()

        # We need a request
        request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
        if txtFilter is not None:
            fields = self.layer.dataProvider().fields()
            fieldname = fields[self.shownField].name()
            request.setFilterExpression(u"\"{0}\" LIKE '%{1}%'".format(fieldname, txtFilter))

        # Grab the results from the layer
        features = self.layer.getFeatures(request)

        for feature in sorted(features, key = lambda f: f[0]):
            attr = feature.attributes()
            value = attr[self.shownField]
            element = QListWidgetItem(value)
            element.setData(Qt.UserRole, attr[self.IdField])

            # initValues will be checked
            if attr[self.IdField] in self.initValues:
                element.setCheckState(Qt.Checked)
            else:
                element.setCheckState(Qt.Unchecked)
            self.LIST.addItem(element)

    def getValues(self):
        '''Return the selected values of the QListWidget'''
        return self.initValues

The class is named nnDialog and it deals with the n,n dialog used to add/remove pesticides of the current analysis. The constructor is very simple:

  • We need to know which layer will be displayed,
  • what is the name of the field that will be displayed in the list,
  • what is the name of the field used as ID,
  • what are the values already checked (stored into ANALYSIS_PESTICIDE)
  • once everything is transmitted by arguments to the constructor, we have to create the UI (see above),
  • populate the list
  • and add dynamic controls for search QLineEdit and QListWidget.

The changeValues method is called when you check a checkBox in the list. Whenever there is action, the ID_PESTICIDE value is added/removed from initValues.

The populateList method is used when the n,n dialog is opened (called by the constructor) and whenever there is some changes in the search text bar. This method is used to populate the list:

  • List is first cleared.
  • If there is some text in the search QLineEdit, we make a request on the displayed field (NAME is our case) of the current layer (PESTICIDE) to retrieve only the correct values.
  • Otherwise, we grab all the values of the layer.
  • We sort them alphabetically.
  • And for each value, we create a QListWidgetItem (element of a list) with a checkBox.
  • If the value is in the initValues, it is checked, otherwise, it is unchecked.

nnDialog class implements all the logic of the n,n Dialog and it's code is quite generic: every parameters are transmitted by the constructor. This class is used when you click on the "Modify" button at the right of the list of pesticides in the ANALYSIS form.

But to stay generic we have also to be generic with the code which triggers nnDialog...

main form code

Last thing to do: add logic to the ANALYSIS form. Here is the code:

class nnForm:
    '''Class to handle forms to type data'''
    def __init__(self, dialog, layerid, featureid):    
        self.dialog = dialog
        self.layerid = layerid
        self.featureid = featureid
        self.nullValue = QSettings().value("qgis/nullValue" , u"NULL")
        self.search = False

    def id2listWidget(self, table, values, listWidget):
        '''Show all the selected values of a link table on a QListWidget'''
        # Find the Widget
        if listWidget is None or table is None:
            QgsMessageLog.logMessage(u"id2listWidget: We need to have a relation and a true widget !", "DBPAT", QgsMessageLog.INFO)
            return False

        # Empty the list
        listWidget.clear()

        # Get the params (for the first child table)
        if self.valueRelationParams(table):
            params = self.valueRelationParams(table)[0]
        if params is None or not params:
            QgsMessageLog.logMessage(u"id2listWidget: You need to add Value Relation to layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
            return False

        # Get target layer:
        tgtLayer = params['tgtLayer']

        # Handle values: need to escape \' characters
        values = [v.replace(u"'", u"''") if isinstance(v, basestring) else v for v in values]

        ## Then, get the real values from other-side table
        if values:
            request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
            if params[u'tgtIdType'] in (QVariant.String, QVariant.Char):
                query = u"{0} IN ('{1}')".format(params[u'tgtId'], u"','".join(values))
            else:
                query = u"{0} IN ({1})".format(params[u'tgtId'], u",".join([unicode(x) for x in values]))
            request.setFilterExpression(query)

            # and display them in the QListWidget
            for feature in tgtLayer.getFeatures(request):
                value = feature.attributes()[params[u'tgtValueIdx']]
                if value != u"NULL":
                    element = QListWidgetItem(value)
                    element.setData(Qt.UserRole, feature.attributes()[params[u'tgtIdIdx']])
                    listWidget.addItem(element)

        return True

    def valueRelationParams(self,layer):
        '''Function that returns the configuration parameters of a valueRelation as a list of dict'''
        params = []
        if layer is not None:
            for idx, field in enumerate(layer.dataProvider().fields()):
                if layer.editorWidgetV2(idx) == u"ValueRelation":
                    param = {}
                    param[u'srcId'] = field.name()
                    param[u'srcIdIdx'] = idx
                    if u"Layer" in layer.editorWidgetV2Config(idx):
                        tgtLayerName = layer.editorWidgetV2Config(idx)[u"Layer"]
                        tgtLayer = QgsMapLayerRegistry.instance().mapLayer(tgtLayerName)
                        if tgtLayer is None:
                            QgsMessageLog.logMessage(u"valueRelationParams: Can't find the layer {0} !".format(tgtLayerName), "nnForms", QgsMessageLog.INFO)
                            return False

                        param[u'tgtLayer'] = tgtLayer
                        param[u'tgtId'] = layer.editorWidgetV2Config(idx)[u"Key"]
                        param[u'tgtValue'] = layer.editorWidgetV2Config(idx)[u"Value"]

                        # Find index of all fields:
                        for indx, f in enumerate(tgtLayer.dataProvider().fields()):
                            if f.name() == param[u'tgtId']:
                                param[u'tgtIdIdx'] = indx
                                param[u'tgtIdType'] = f.type()
                            if f.name() == param[u'tgtValue']:
                                param[u'tgtValueIdx'] = indx
                        params.append(param)

        # notification
        if not params:
            QgsMessageLog.logMessage(u"valueRelationParams: There is not Value Relation for the layer {0} !".format(layer.name()), "nnForms", QgsMessageLog.INFO)

        return params

    def manageMultiple(self):
        '''Handle specifics thesaurus form'''
        # Scan all of the QgsRelations of the project
        relations = QgsProject.instance().relationManager().relations()

        for listWidget in [f for f in self.dialog.findChildren(QListWidget) if u"REL_" in f.objectName()]:
            listName = listWidget.objectName()
            if listName not in relations.keys():
                QgsMessageLog.logMessage(u"manageMultiple: There is no Relation for control {0} !".format(listWidget.objectName()), "nnforms", QgsMessageLog.INFO)
                continue

            # Find what is the table to show
            relation = relations[listName]
            shownLayer = relation.referencingLayer()

            # Find other side of n,n relation
            if self.valueRelationParams(shownLayer):
                params = self.valueRelationParams(shownLayer)[0]
            if params is None:
                continue

            # When found, we are ready to populate the QListWidget with the good values
            values = []
            if self.featureid:
                # Get the features to display
                request = relation.getRelatedFeaturesRequest(self.featureid)
                request.setFlags(QgsFeatureRequest.NoGeometry)
                for feature in shownLayer.getFeatures(request):
                    values.append(feature.attributes()[params[u'srcIdIdx']])
                self.id2listWidget(shownLayer, values, listWidget)

            buttonWidget = self.dialog.findChild(QPushButton, listName+u"_B")
            if buttonWidget:
                if self.search or self.layerid.isEditable():
                    buttonWidget.clicked.connect(partial(self.openSubform, listWidget, relation, values))
                    buttonWidget.setEnabled(True)
                else:
                    buttonWidget.setEnabled(False)
            elif DEBUGMODE:
                QgsMessageLog.logMessage(u"manageMultiple: There is no button for control {0} !".format(listName), "nnForms", QgsMessageLog.INFO)

    def openSubform(self, widget, relation, values):
        '''Open a dedicated dialog form with values taken from a child table.'''
        table = relation.referencingLayer()
        if self.valueRelationParams(table):
            params = self.valueRelationParams(table)[0]

        if params is None or not params:
            QgsMessageLog.logMessage(u"openSubform: There is no Value Relation for layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
            return False

        if widget is None:
            QgsMessageLog.logMessage(u"openSubForm: no widgets found for field {0} !".format(field), "nnForms", QgsMessageLog.INFO)

        # Open the form with the good values
        dialog = nnDialog(self.dialog, params[u'tgtLayer'], params[u'tgtValueIdx'], params[u'tgtIdIdx'], values, self.search)

        # handle results
        if dialog.exec_():
            # Get the results:
            thevalues = dialog.getValues()

            # Modify target table if we have a featureid
            if self.featureid:
                table.startEditing()
                caps = table.dataProvider().capabilities()
                ## Delete all the previous values
                if caps & QgsVectorDataProvider.DeleteFeatures:
                    request = relation.getRelatedFeaturesRequest(self.featureid)
                    request.setFlags(QgsFeatureRequest.NoGeometry)
                    fids = [f.id() for f in table.getFeatures(request)]
                    table.dataProvider().deleteFeatures(fids)

                ## Add the new values
                if caps & QgsVectorDataProvider.AddFeatures:
                    for value in thevalues:
                        feat = QgsFeature()
                        feat.setAttributes([None, self.featureid.attributes()[0], value])
                        table.dataProvider().addFeatures([feat])
                ## Commit changes
                table.commitChanges()

            # refresh listWidget aspect
            self.id2listWidget(table, thevalues, widget)

The nnForm class will manage the form of ANALYSIS (or every form that has the same class Python function).

The manageMultiple method, will "scan" the layer form to find all QListWidgets with the same name than a relation. For each of those QListWidgets, we try to find what is the intermediate table (ANALYSIS_PESTICIDE) and what is the last table (from Value Relation). Then the QListWidget is populated with the values from ANALYSIS_PESTICIDE (and by retreiving the pesticides names). At last, the QPushButton that is named like the relation (+_B) is connected to a method which will open a nnDialog (see previous chapter).

OpenSubForm method is used to create the nnDialog (from the same named class), to give it the already checked values and to grab the result once the nnDialog dialog is closed. Most of the code of this method is for updating values with quite a brutal approach: we erase every data stored into ANALYSIS_PESTICIDE that have the same ID_ANALYSIS value than the current analysis ! Then, we re-add everything... But it seems to be faster than filtering the already checked values ! At last, th QListWidget involved is refreshed.

id2listWidget is the method used to populate and refresh a QListWidget with relations on the form. Everything is first cleared. A request to the last table is done (PESTICIDE) to grab the field that msut be shown (NAME). The values (IDs) are requested before and put into the constructor of this method.

valueRelationParams is used to find what are: the target layer, the shown field, the identifying field of a value relation control configuration of a table. It is used in manageMultiple and id2listWidget methods to find what to display.

Putting everything into one file

# -*- coding: utf-8 -*-

from PyQt4.QtCore import *
from PyQt4.QtGui import *
from qgis.core import QgsMapLayerRegistry, QgsMessageLog, QgsFeatureRequest, QgsFeature
from qgis.core import QgsRelationManager, QgsRelation, QgsProject, QgsVectorDataProvider
from qgis.utils import iface
from functools import partial
from PyQt4 import QtCore, QtGui

# Global variables
DEBUGMODE = True

class nnDialog(QtGui.QDialog):
    '''Dedicated n,n relations Form Class'''
    def __init__(self, parent, layer, shownField, IdField, initValues, search=False):
        '''Constructor'''
        QtGui.QDialog.__init__(self,parent)

        self.initValues = initValues
        self.shownField = shownField
        self.layer =  layer
        self.IdField = IdField
        self.search = search
        if self.layer is None and DEBUGMODE:
            QgsMessageLog.logMessage(u"nnDialog constructor: The layer {0} doesn't exists !".format(layer.name()),"Your App", QgsMessageLog.INFO)

        # Build the GUI and populate the list with the good values
        self.setupUi()
        self.populateList()

        # Add dynamic control when list is changing
        self.SEARCH.textChanged.connect(self.populateList)
        self.LIST.itemChanged.connect(self.changeValues)

    def setupUi(self):
        '''Builds the QDialog'''
        # Form building
        self.setObjectName(u"nnDialog")
        self.resize(550, 535)
        self.setMinimumSize(QtCore.QSize(0, 0))
        self.buttonBox = QtGui.QDialogButtonBox(self)
        self.buttonBox.setGeometry(QtCore.QRect(190, 500, 341, 32))
        self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
        self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok)
        self.buttonBox.setObjectName(u"buttonBox")
        self.verticalLayoutWidget = QtGui.QWidget(self)
        self.verticalLayoutWidget.setGeometry(QtCore.QRect(9, 9, 521, 491))
        self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
        self.verticalLayout = QtGui.QVBoxLayout(self.verticalLayoutWidget)
        self.verticalLayout.setMargin(0)
        self.verticalLayout.setObjectName(u"verticalLayout")
        self.horizontalLayout = QtGui.QHBoxLayout()
        self.horizontalLayout.setObjectName(u"horizontalLayout")
        self.label = QtGui.QLabel(self.verticalLayoutWidget)
        self.label.setObjectName(u"label")
        self.horizontalLayout.addWidget(self.label)
        self.SEARCH = QtGui.QLineEdit(self.verticalLayoutWidget)
        self.SEARCH.setObjectName(u"SEARCH")
        self.horizontalLayout.addWidget(self.SEARCH)
        self.verticalLayout.addLayout(self.horizontalLayout)
        self.horizontalLayout_2 = QtGui.QHBoxLayout()
        self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
        self.LIST = QtGui.QListWidget(self.verticalLayoutWidget)
        self.LIST.setObjectName(u"LIST")
        self.horizontalLayout_2.addWidget(self.LIST)
        self.verticalLayout.addLayout(self.horizontalLayout_2)

        self.buttonBox.accepted.connect(self.accept)
        self.buttonBox.rejected.connect(self.reject)
        QtCore.QMetaObject.connectSlotsByName(self)

    def changeValues(self, element):
        '''Whenever a checkbox is checked, modify the values'''
        # Check if we check or uncheck the value:
        if element.checkState() == Qt.Checked:
            self.initValues.append(element.data(Qt.UserRole))
        else:
            self.initValues.remove(element.data(Qt.UserRole))

    def populateList(self, txtFilter=None):
        '''Fill the QListWidget with values'''
        # Delete everything
        self.LIST.clear()

        # We need a request
        request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
        if txtFilter is not None:
            fields = self.layer.dataProvider().fields()
            fieldname = fields[self.shownField].name()
            request.setFilterExpression(u"\"{0}\" LIKE '%{1}%'".format(fieldname, txtFilter))

        # Grab the results from the layer
        features = self.layer.getFeatures(request)

        for feature in sorted(features, key = lambda f: f[0]):
            attr = feature.attributes()
            value = attr[self.shownField]
            element = QListWidgetItem(value)
            element.setData(Qt.UserRole, attr[self.IdField])

            # initValues will be checked
            if attr[self.IdField] in self.initValues:
                element.setCheckState(Qt.Checked)
            else:
                element.setCheckState(Qt.Unchecked)
            self.LIST.addItem(element)

    def getValues(self):
        '''Return the selected values of the QListWidget'''
        return self.initValues

class nnForm:
    '''Class to handle forms to type data'''
    def __init__(self, dialog, layerid, featureid):    
        self.dialog = dialog
        self.layerid = layerid
        self.featureid = featureid
        self.nullValue = QSettings().value("qgis/nullValue" , u"NULL")
        self.search = False

    def id2listWidget(self, table, values, listWidget):
        '''Show all the selected values of a link table on a QListWidget'''
        # Find the Widget
        if listWidget is None or table is None:
            QgsMessageLog.logMessage(u"id2listWidget: We need to have a relation and a true widget !", "nnForms", QgsMessageLog.INFO)
            return False

        # Empty the list
        listWidget.clear()

        # Get the params (for the first child table)
        if self.valueRelationParams(table):
            params = self.valueRelationParams(table)[0]
        if params is None or not params:
            QgsMessageLog.logMessage(u"id2listWidget: You need to add Value Relation to layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
            return False

        # Get target layer:
        tgtLayer = params['tgtLayer']

        # Handle values: need to escape \' characters
        values = [v.replace(u"'", u"''") if isinstance(v, basestring) else v for v in values]

        ## Then, get the real values from other-side table
        if values:
            request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
            if params[u'tgtIdType'] in (QVariant.String, QVariant.Char):
                query = u"{0} IN ('{1}')".format(params[u'tgtId'], u"','".join(values))
            else:
                query = u"{0} IN ({1})".format(params[u'tgtId'], u",".join([unicode(x) for x in values]))
            request.setFilterExpression(query)

            # and display them in the QListWidget
            for feature in tgtLayer.getFeatures(request):
                value = feature.attributes()[params[u'tgtValueIdx']]
                if value != u"NULL":
                    element = QListWidgetItem(value)
                    element.setData(Qt.UserRole, feature.attributes()[params[u'tgtIdIdx']])
                    listWidget.addItem(element)

        return True

    def valueRelationParams(self,layer):
        '''Function that returns the configuration parameters of a valueRelation as a list of dict'''
        params = []
        if layer is not None:
            for idx, field in enumerate(layer.dataProvider().fields()):
                if layer.editorWidgetV2(idx) == u"ValueRelation":
                    param = {}
                    param[u'srcId'] = field.name()
                    param[u'srcIdIdx'] = idx
                    if u"Layer" in layer.editorWidgetV2Config(idx):
                        tgtLayerName = layer.editorWidgetV2Config(idx)[u"Layer"]
                        tgtLayer = QgsMapLayerRegistry.instance().mapLayer(tgtLayerName)
                        if tgtLayer is None:
                            QgsMessageLog.logMessage(u"valueRelationParams: Can't find the layer {0} !".format(tgtLayerName), "nnForms", QgsMessageLog.INFO)
                            return False

                        param[u'tgtLayer'] = tgtLayer
                        param[u'tgtId'] = layer.editorWidgetV2Config(idx)[u"Key"]
                        param[u'tgtValue'] = layer.editorWidgetV2Config(idx)[u"Value"]

                        # Find index of all fields:
                        for indx, f in enumerate(tgtLayer.dataProvider().fields()):
                            if f.name() == param[u'tgtId']:
                                param[u'tgtIdIdx'] = indx
                                param[u'tgtIdType'] = f.type()
                            if f.name() == param[u'tgtValue']:
                                param[u'tgtValueIdx'] = indx
                        params.append(param)

        # notification
        if not params:
            QgsMessageLog.logMessage(u"valueRelationParams: There is not Value Relation for the layer {0} !".format(layer.name()), "nnForms", QgsMessageLog.INFO)

        return params

    def manageMultiple(self):
        '''Handle specifics thesaurus form'''
        # Scan all of the QgsRelations of the project
        relations = QgsProject.instance().relationManager().relations()

        for listWidget in [f for f in self.dialog.findChildren(QListWidget) if u"REL_" in f.objectName()]:
            listName = listWidget.objectName()
            if listName not in relations.keys():
                QgsMessageLog.logMessage(u"manageMultiple: There is no Relation for control {0} !".format(listWidget.objectName()), "nnforms", QgsMessageLog.INFO)
                continue

            # Find what is the table to show
            relation = relations[listName]
            shownLayer = relation.referencingLayer()

            # Find other side of n,n relation
            if self.valueRelationParams(shownLayer):
                params = self.valueRelationParams(shownLayer)[0]
            if params is None:
                continue

            # When found, we are ready to populate the QListWidget with the good values
            values = []
            if self.featureid:
                # Get the features to display
                request = relation.getRelatedFeaturesRequest(self.featureid)
                request.setFlags(QgsFeatureRequest.NoGeometry)
                for feature in shownLayer.getFeatures(request):
                    values.append(feature.attributes()[params[u'srcIdIdx']])
                self.id2listWidget(shownLayer, values, listWidget)

            buttonWidget = self.dialog.findChild(QPushButton, listName+u"_B")
            if buttonWidget:
                if self.search or self.layerid.isEditable():
                    buttonWidget.clicked.connect(partial(self.openSubform, listWidget, relation, values))
                    buttonWidget.setEnabled(True)
                else:
                    buttonWidget.setEnabled(False)
            elif DEBUGMODE:
                QgsMessageLog.logMessage(u"manageMultiple: There is no button for control {0} !".format(listName), "nnForms", QgsMessageLog.INFO)

    def openSubform(self, widget, relation, values):
        '''Open a dedicated dialog form with values taken from a child table.'''
        table = relation.referencingLayer()
        if self.valueRelationParams(table):
            params = self.valueRelationParams(table)[0]

        if params is None or not params:
            QgsMessageLog.logMessage(u"openSubform: There is no Value Relation for layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
            return False

        if widget is None:
            QgsMessageLog.logMessage(u"openSubForm: no widgets found for field {0} !".format(field), "nnForms", QgsMessageLog.INFO)

        # Open the form with the good values
        dialog = nnDialog(self.dialog, params[u'tgtLayer'], params[u'tgtValueIdx'], params[u'tgtIdIdx'], values, self.search)

        # handle results
        if dialog.exec_():
            # Get the results:
            thevalues = dialog.getValues()

            # Modify target table if we have a featureid
            if self.featureid:
                table.startEditing()
                caps = table.dataProvider().capabilities()
                ## Delete all the previous values
                if caps & QgsVectorDataProvider.DeleteFeatures:
                    request = relation.getRelatedFeaturesRequest(self.featureid)
                    request.setFlags(QgsFeatureRequest.NoGeometry)
                    fids = [f.id() for f in table.getFeatures(request)]
                    table.dataProvider().deleteFeatures(fids)

                ## Add the new values
                if caps & QgsVectorDataProvider.AddFeatures:
                    for value in thevalues:
                        feat = QgsFeature()
                        feat.setAttributes([None, self.featureid.attributes()[0], value])
                        table.dataProvider().addFeatures([feat])
                ## Commit changes
                table.commitChanges()

            # refresh listWidget aspect
            self.id2listWidget(table, thevalues, widget)

def opennnForm(dialog, layerid, featureid):
    '''Generic function to open a nnForm'''
    form = nnForm(dialog, layerid, featureid)
    QgsMessageLog.logMessage(u"opennnForm !", "nnforms", QgsMessageLog.INFO)
    form.manageMultiple()

Conclusion

Okay, this one is quite complex ! If you want to implement n,n forms, you have to code because QGIS is not able to handle them for the moment. For a true implementation into QGIS code, I would take a different path. I can imagine to have a new relation type dedicated to n,n relations. In those relations, you would have to:

  • Declare the "parent" layer (ANALYSIS in our case).
  • Declare the "intermediate" table (ANALYSIS_PESTICIDE) and the shared attributes.
  • Declare the "displayed" layer (PESTICIDE) and the shared attributes (with the intermediate table).

Once in the form, you would use a new form control to configure:

  • the displayed field(s) or expression(s).
  • if you want a search bar or not.
  • if you want to filter values of the "displayed" layer.
Posted lun. 19 oct. 2015 19:52:00

Introduction

This article is the next of my serie of articles about QGIS forms. You can read the previous one here. Today we will focus on photo form controls. In QGis, they are dedicated to image viewing. Change your Edit Widget to Photo. You can then edit the size of the photo display (seems to be required if you want to effectively display the photo on the form).

Image of Photo widget configuration

Nice widget, isn't it ? Be we can go further...

Custom UI

With this configuration, QGis is able to render photos on your Widget by its own means. You do not need to code. But you perhaps want to go further and show some url links for documents that are not photos or images but other things (think about a photo inside a PDF file or a video) ? Or perhaps, you would like to use a Photo widget into a custom UI ? Let's see how it can be achieved.

The first thing to do is to build a custom form with qtcreator (designer-qt4 i Debian Jessie). You'll have to create a QWidget. It will be named with the field you have used to store the path to the photo in the data layer. This QWidget will hold a QGridLayout. Inside this layout, put a QLabel and name it PhotoLabel (required name). It will be used to display the photo. Then, add a QLineEdit named lineEdit (required name). It will be used to show the path of the photo. You also need to add a QPushButton named FileChooserButton in the layout.

If you want to show an URL, add a QLabel and name it with the name of your choice. You'll have to be careful to add two properties for this QLabel:

  • openExternalLinks: checked (otherwise, you will not be able to open the file with a click).
  • textInteractionFlags: LinksAccessibleByMouse.

Image of Qt4 Designer for custom Photo form

With this form, QGIS will be able to display the photo in the fields and the widget will work as if it was auto-generated by QGIS.

Code

Time to dive into Python... We want to show a URL to open an external document. This document will be our image (that is presently shown) or another type of document (PDF/video/sound/etc.).

def manageIll(dialog, layerid, featureid):
    '''Handle link for files in Illustration'''
    # Find file value:
    child = dialog.findChild(QLineEdit, u"lineEdit")
    if child:
        child.textChanged.connect(partial(modifyPhoto, dialog))
        modifyPhoto(dialog)

def modifyPhoto(dialog):
    '''Function to buidl link for files in Illustration form'''
    # Find the fie path value
    lineEdit = dialog.findChild(QLineEdit, u"lineEdit")
    if lineEdit:
        fileValue = lineEdit.text()
        nullValue = QSettings().value("qgis/nullValue" , u"NULL")
        if fileValue == nullValue or fileValue is None:
            return False

        basename = os.path.basename(fileValue)
        filename = u"<a href=\"file:///{0}\">{1}</a>".format(fileValue,basename)

        # Affect the value to the URL QLabel
        urlLabel = dialog.findChild(QLabel, u"ILLURL_L")
        if urlLabel and fileValue is not None:
            urlLabel.setText(filename)

        # Determine if we can produce a QPixmap from the file
        if not QPixmap().load(fileValue):
            photoLabel = dialog.findChild(QLabel, u"PhotoLabel")
            if photoLabel:
                photoLabel.setText(u"This file is not a photo !")

The manageIll function will connect the signal textChanged of the QLineEdit named "lineEdit" (remember the UI part) to a dedicated function named modifyPhoto. modifyPhoto will take the value of "lineEdit" and will transform it into an "URL" that will be shown into a QLabel named ILLURL_L (see UI part). At the end, we try to find if the filepath from "lineEdit" is a valid image and if it is not the case, we display a text inside the dedicated photo QLabel (named PhotoLabel).

You'll need to change the objectName property to reflect the widgets names found in your form (and in your layer of course).

Conclusion

This article just show an easy way to add a QGis photo form control inside a custom form. You can also add code to change the default behaviour of your photo control with Python. Whitout doubt, I am sure that one day, the QGIS developpers will implement a better external documents behaviour in QGIS...

Posted sam. 17 oct. 2015 17:52:00

Introduction

A la suite de mes différents articles sur l'installation et l'empaquetage du client Oracle, il est temps de passer à l'objectif final de ces opérations: installer le provider Oracle Spatial (que j'appelle aussi connecteur Oracle Spatial) pour QGIS sous Debian Jessie.

Nous allons récupérer les sources de QGIS, basculer sur une branche stable, effectuer nos petites modifications pour inclure les bibliothèques Oracle et créer les différents paquets. A l'issue de cette procédure, nous disposerons d'une installation complète de QGIS avec la possibilité de se connecter à un serveur Oracle Spatial, que ce soit pour un usage station de travail ou pour utiliser QGIS Server.

Mode opératoire

Pour ma part, j'effectue l'empaquetage dans une machine virtuelle. En effet, cette opération d'empaquetage de QGIS nécéssite d'installer une sacrée liste de paquets et je ne souhaite pas alourdir ma station de travail avec des paquets qui ne me serviront qu'une seule fois (ou dans tous les cas assez peu souvent).

Récupérer les sources et pointer vers la bonne version de QGIS

$ mkdir -p ~/packaging
$ git clone https://github.com/qgis/QGIS.git
$ cd ~/packaging
$ git clone ../QGIS
$ git checkout final-2_10_0

C'est la version 2.10 qui sera utilisée dans la suite de cet article mais les instructions doivent fonctionner aussi bien pour les versions ultérieures que pour les versions antérieures, à partir de la version 2.8.

Paquets à installer

Avant de pouvoir empaqueter QGIS, il va vous falloir un sacré paquet de paquets !

Voici la liste:

# apt install build-essential ca-certificates devscripts fakeroot bison cmake debhelper flex grass-dev libexpat1-dev libfcgi-dev libgdal-dev libgeos-dev libgsl0-dev libpq-dev libproj-dev libqt4-dev libqt4-opengl-dev libqtwebkit-dev libqwt-dev libspatialite-dev libsqlite3-dev libspatialindex-dev pkg-config pyqt4-dev-tools python-all-dev python-dev python-qt4-dev python-sip-dev txt2tags doxygen python-qscintilla2 pyqt4.qsci-dev libosgearth-dev libopenscenegraph-dev libqscintilla2-dev graphviz xvfb xauth xfonts-base xfonts-100dpi xfonts-75dpi xfonts-scalable spawn-fcgi lighttpd poppler-utils python-pyspatialite qt4-doc-html libqt4-sql-sqlite python-matplotlib osgearth-data python-psycopg2 python-httplib2 python-jinja2 python-markupsafe liblwgeom-2.1.7 qt4-designer

Si vous avez suivi mon précédent article, vous pourrez bien entendu, installer les paquets oracle-instantclient et oracle-instantclient-dev pour pouvoir compiler le provider Oracle. Vous pouvez également simplement installer le client Oracle à la main.

Modification du fichier debian/control.in

Le fichier debian/control.in est un fichier modèle pour le fichier debian/control. Pour ceux qui font régulièrement de la fabrication de paquet Debian, ce fichier ne devrait pas avoir de secret. QGIS présente la particularité de mettre à part la construction du paquet dédié au connecteur Oracle Spatial. En effet, le client et le SDK Oracle étant non libres, ils ne sont pas empaquetés dans la distribution Debian.

Vous devez donc modifier le fichier debian/control.in pour générer le paquet du provider Oracle. Si vous l'omettez, le paquet ne sera pas construit et le connecteur ne sera pas disponible même si l'iĉone d'accès à la boîte de dialogue de sélection des couches sera présente dans l'interface de QGIS. Ajoutez les éléments à la fin du fichier:

Package: qgis-oracle-provider
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}, oracle-instantclient
Section: contrib/database
Description: QGIS oracle provider
 QGIS is a Geographic Information System (GIS) which manages, analyzes and
 display databases of geographic information.
 .
 This package contains the QGIS oracle provider.

Pensez à supprimer la dépendance vers oracle-instantclient si vous n'avez pas installé le client Oracle via le paquet non officiel que j'ai présenté dans cet article.

Modification du fichier debian/rules

Une fois le paquet du connecteur Oracle correctement déclaré, il reste à modifier le fichier debian/rules. En effet, par défaut, le fichier rules indique de ne fabriquer le paquet que sous la distribution sid-oracle. Or, nous sommes sous Jessie. Par défaut, la construction de paquet ne saura pas fabriquer le connecteur Oracle.

Vous devez modifier la variale CMAKE_OPTS et y inclure la directive -DWITH_ORACLE=TRUE, comme dans ce qui suit:

...
CMAKE_OPTS := \
        -DBUILDNAME=$(DEB_BUILD_NAME) \
        -DCMAKE_VERBOSE_MAKEFILE=1 \
        -DCMAKE_INSTALL_PREFIX=/usr \
        -DGRASS_PREFIX=/usr/lib/$(GRASS) \
        -DBINDINGS_GLOBAL_INSTALL=TRUE \
        -DPEDANTIC=TRUE \
        -DWITH_QSPATIALITE=TRUE \
        -DWITH_SERVER=TRUE \
        -DWITH_SERVER_PLUGINS=TRUE \
        -DSERVER_SKIP_ECW=TRUE \
    -DQGIS_CGIBIN_SUBDIR=/usr/lib/cgi-bin \
        -DWITH_APIDOC=TRUE \
        -DWITH_CUSTOM_WIDGETS=TRUE \
        -DWITH_ORACLE=TRUE \
        -DWITH_GLOBE=TRUE \
        -DWITH_INTERNAL_HTTPLIB2=FALSE \
        -DWITH_INTERNAL_JINJA2=FALSE \
        -DWITH_INTERNAL_MARKUPSAFE=FALSE \
        -DWITH_INTERNAL_PYGMENTS=FALSE \
        -DWITH_INTERNAL_DATEUTIL=FALSE \
        -DWITH_INTERNAL_PYTZ=FALSE \
        -DWITH_INTERNAL_SIX=FALSE
...

Modification de la configuration de CMake

Il faut modifier le fichier src/providers/oracle/ocispatial/cmake/FindOCI.cmake pour indiquer le répertoire du client Oracle et de son SDK. Si vous utilisez les paquets Debian du client Oracle OCI dont j'ai décris la réalisation, vous n'avez rien à faire. Si vous avez installé manuellement le client Oracle dans /opt, voici comment vous devez modifier le fichier:

FIND_PATH(OCI_INCLUDE_DIR oci.h
  PATHS
  /opt/oracle/instantclient_11_2/sdk/include
  $ENV{OSGEO4W_ROOT}/include
  $ENV{ORACLE_HOME}/rdbms/public
)

FIND_LIBRARY(OCI_LIBRARY clntsh oci
  PATHS
  /opt/oracle/instantclient_11_2/
  $ENV{OSGEO4W_ROOT}/lib
  $ENV{ORACLE_HOME}/lib
)
...

Création des paquets

Vous avez fait le plus dur ! Il ne reste qu'à lancer dpkg-buildpackage -us -uc -b et attendre ! Je vous conseille plutôt de lancer la commande qui suit:

$ DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage -us -uc -b

L'option nocheck permet de ne pas jouer la batterie de tests à la suite de la compilation. Sachez que les tests prennent facilement une bonne demi-heure... QGIS est assez long à empaqueter comme ça (compter au moins une demi-heure sur une machine récente) !

Si jamais le processus échoue à un moment donné, il vous reste à "hacker" dans le répertoire debian et ailleurs pour voir ce qui manque. En règle générale, il s'agit d'un problème de dépendances. Pour éviter que dpkg-buildpackage ne relance toute la compilation from scratch, utilisez l'option -nc qui permet de ne pas nettoyer l'arborescence des sources. c'est très utile si vous avez un problème au moment de la création du paquet avec dh_shlibdeps par exemple.

A la fin du processus et si tout se passe bien, vous aurez une liste impressionnante de paquets deb dans le répertoire ~/packaging dont le fameux qgis-oracle-provider_2.10.0_amd64.deb !

Pour installer en mode brutal:

dpkg -i ~/packaging/*.deb

Tests

Lancez le client QGIS et tentez de créer une connexion Oracle Spatial. Si tout se passe bien, vous aurez accès à la liste de vos couches et vous pourrez en ajouter une dans le canevas de cartes.

Si ce n'est pas le cas, il va vous falloir "hacker" un peu plus. Le principal coupable sera sans doute le fichier tnsnames.ora. Lors de mon précédent article, j'avais indiqué qu'il fallait paramétrer une variable d'environnement TNS_ADMIN. Mais cette dernière n'est disponible que via un appel à Bash. Si vous lancez QGIS depuis le bureau (via les suckless-tools bien sûr), cette variable n'est pas initialisée et aucune connexion ne fonctionnera.

Vérifiez votre fichier ~/.profile pour voir s'il contient bien la variable TNS_ADMIN:

export TNS_ADMIN=~/.oracle

Une dernière recommandation

Pour assurer de bonnes performances sous Oracle Spatial, utilisez absolument les index spatiaux. Contrairement à PostGIS, les index spatiaux sont pratiquement des pré-requis sous Oracle Spatial car un très grand nombre de fonctions ne sont pas utilisables sans.

En plus de la création de l'index, vous devez vous assurer que l'utilisateur qui accède à vos données disposent également des droits de lecture sur les tables d'index (tables dont le nom commence par MDRT) sinon le provider Oracle de QGIS ne pourra pas utiliser les fonctions d'index. Voici un petit script en PL/SQL pour vous assurer de ces droits une fois tout vos index créés (remplacez utilisateur par le nom du compte Oracle à qui vous voulez donner accès à vos index, ce script doit être lancé par l'utilisateur propriétaire du schéma Oracle):

set serveroutput on
DECLARE
 tbName VARCHAR2(200);
 CURSOR mdrtTables IS
 SELECT SDO_INDEX_TABLE FROM ALL_SDO_INDEX_METADATA;
BEGIN
 -- We need to scan all the Index tables
 FOR tb in mdrtTables
 LOOP
   DBMS_OUTPUT.PUT_LINE('Table: ' || tb.SDO_INDEX_TABLE);
   EXECUTE IMMEDIATE 'GRANT SELECT ON ' || tb.SDO_INDEX_TABLE || ' TO utilisateur';
 END LOOP;
END;
/
EXIT

Conclusion

Grâce à l'empaquetage Debian, il est possible d'inclure le connecteur Oracle de QGIS en modifiant quelques fichiers. Au final, le processus n'est pas si simple car il faut installer le client Oracle et recréer le paquet Debian à la main alors que si vous aviez un serveur PostgreSQL/PostGIS, tout serait prêt "out-of-the-box".

Mais bon, parfois, on n'a pas le choix ! J'ai bien galéré à trouver une méthode suffisamment propre et automatisée pour ce travail étant donné la complexité du projet QGIS et surtout la difficulté amenée par le provider Oracle qui s'appuie sur du logiciel non libre, mal intégré sous Debian.

Néanmoins, les entités qui souhaiteraient installer QGIS Server sous Debian et qui n'ont que des données géographiques disponibles sous Oracle pourront aller un peu plus loin grâce à ces instructions...

Posted sam. 25 juil. 2015 17:44:12

Introduction

Today I am going to inaugurate a new serie of articles about QGIS forms. QGIS is perhaps the best GIS software that ease the most attributes edition. You can build nearly complete GIS applications just by triggering some parameters on the QGIS fields dialog (and with a little bit of code, of course). I've made a whole application with QGIS forms. On the GIS side, it was not so hard: 8 layers to edit with simple geometries. But on the attributes side, it was a real challenge. Some facts about the database and the application:

  • About one hundred attributes tables stored on Oracle Database.
  • Some layers can have about 80 form controls.
  • n,n relations.
  • Specific tables to store tree data (more on this later).
  • Complete custom form controls.
  • We needed subforms to handle photos.
  • Of course you need a true search engine on all of the attribute fields.

Building such an application was not very easy even if you already know QGIS well. At the beginning of the project I was not really sure that QGIS could match the trick... ...But at last, I just want to say that QGis works perfectly well on this application even with a lot of data loaded.

When I started to implement features into this QGis project, I often faced QGIS lack of features on the forms. I've made some bug or features report on http://hub.qgis.org. In this serie of articles, I will try to show you how to circumvent the different problems I faced.

About QGIS forms

Before diving into code, let's have some basic informations about QGIS forms...

Forms are built by QGIS in order to edit (non-geometric) attributes for an object on a defined layer. You can read more about the process in the official QGis documentation.

You need to understand how forms are working in QGIS. To my mind, there are three parts on this subject:

  • Data fields: the goal of the form is to edit the values of the attributes in the layer. Data fields are named with the attributes name of the layer.
  • GUI: This is the part that QGIS show to the user to edit alphanumerical data. You can choose between three types: auto-generated (QGIS build everything, for basic forms), drag'n drop designed (you build the form using tabs and groupboxes inside QGIS) or customised (you need to use QtCreator to build a .ui file that will be shown by QGIS). To make link between form controls and fields values, there is a trivial rule: form controls are named like the field they represent.
  • Python code: QGIS allows you to add Python logic to the GUI part of the form. Whenever the form opens, you can call a Python function. This function can access to GUI objects using Qt functions but you also have the full power of QGIS Python API.

For Data fields definition, you have nothing to do: QGIS will use the layer definition to name attributes. For GUI, you have to configure (per-layer) the method (auto-generated/drag'n drop/custom) on the Fields tab of the layer properties dialog. You can read this introduction article to have further details...

For Python code, you just have to name the Python file (module) that will be used and the function that will be launched. Read this reference article to dig a little bit more.

Show a clickable URL on the form

Introduction

In QGIS forms, you can specify that a field will store a path to a file. You just have to use "File Name" for the Edit Widget. There is no option for this type of widget:

Fields dialog with File Name Edit Widget for DOCUMENT field

When you create a new feature on the layer, you will see the following control:

Choose a file dialog

Push the "..." button and you open a file choosing dialog (like every Qt file dialog). When you have chosen the file, the path (/home/medspx/clint_eastwood.ods) is stored in the line edit (a QLineEdit) right to the field name (DOCUMENT). But what if you want to open the file and not just store the path ? You have to trick QGIS a little bit.

Build a custom form like this

First of all you have to use custom form. Otherwise, QGis is using a QLineEdit to show the file path. We need to use a QLabel as only QLabel are able to show a URL link. In your .ui file just add the following to your layout:

QtCreator UI file for URL

As you can see, we have a QLabel to name the field ("Document"). Then you have another QLabel which contains "No file selected". Name this QLabel with a dedicated name (the objectName property will be DOCUMENT_URL). This QLabel will make the job of link URL presentation.Be careful to have a field which is named like the objectName otherwise, QGIS will not save the data into the layer.

You have to check two properties for this QLabel:

  • openExternalLinks: checked (otherwise, you will not be able to open the file with a click).
  • textInteractionFlags: LinksAccessibleByMouse.

QLabel properties

Then we have the QPushButton which will be named DOCUMENT_URL_B. And that's all for the ui part. Now, it is time to dive into Python...

First, the code:

def manageURL(dialog, layerid=None, featureid=None):
    '''General function to manage URL in subforms'''
    # Manage URL buttons
    for child in [f for f in dialog.findChildren(QPushButton) if u"URL_B" in f.objectName()]:
        try:
            child.clicked.disconnect()
        except:
            pass
        child.clicked.connect(partial(chooseFile, dialog, child.objectName()[:-2]))

    # Make URL fields always clickable (even in non-edit mode)
    for child in [f for f in dialog.findChildren(QLabel) if f.objectName()[-3:] == u"URL"]:
        child.setEnabled(True)

def chooseFile(dialog, field):
    '''Open a dedicated file picker form'''
    lineEdit = dialog.findChild(QLabel, field)
    if lineEdit is None:
        QgsMessageLog.logMessage(u"chooseFile: There is no QLabel for field {0} !".format(field), "DBPAT", QgsMessageLog.INFO)

    filename = QtGui.QFileDialog.getOpenFileName(dialog, u'Choisir un fichier...')
    if filename is not None:
        # parse the result to make a link
        basename = os.path.basename(filename)
        link = u"<a href=\"file:///{0}\">{1}</a>".format(filename,basename)
        lineEdit.setText(link)

When you want to display links, you just have to use manageURL function to do the job.

This function will try to find all of the QPushButton that are named like "SOMETHING_URL_B". Those buttons will be disconnected from their previous "clicked" signal. Then, we will re-connect the "clicked" signal to our "chooseFile" function. This function will do the job of building the link from the file path. manageURL ends with another trick: if you are not in edit mode, QLabel with links are not clickable. So we have to manually enable every QLabel that are links (ends with "URL" in our case).

chooseFile function opens a file chooser dialog (a standard QFileDialog from Qt). If the file path is valid, we extract the filename (the basename, only the last part of the path) from the file path. And then we build a link which is a simple <a>. At last, we update the QLabel with the link content.

Here is a rendered thing of the link:

URL link to open the file

You can click on the link and it will open the file with the default operating system defined viewer.

Conclusion

This first article just demonstrate that QGIS is so wide open to external code that you can do nearly everything you want with the forms. Embedding PyQt4 and giving access to the full QGIS API from Python is very interesting because you can go further than standard QGIS forms without recompiling QGIS.

As you can see, once you have written a dedicated function to handle URL links for File controls, it is very easy to expand it to any layer you want: just correctly name a form control on your custom form file and add the correct path to the function.

In the following article, we will focus on Photo edit widget and we will try to use it in a custom form with a little bit of code as an extra...

Posted dim. 19 avril 2015 15:59:55

Introduction

Since QGis 1.8 (and even before), you can use QGis to build forms to deal with the attributes when you create a new geographic object. Over the successive version of QGis, form building get plent of new features like a 1,1 value relation (v2.2) and now 1,n relations (v2.2). You can also use the autobuild feature with which QGis builds the form depending on what you put in the fields properties. There is alos a way to build you own form control with QTCreator ui files. Of course, you can control the form logic with Python code.

In the development of a specific form I had to build a cascading control: the value of one control is linked to the value of an upper control (controls are linked together). This is especially convenient for narrowing a choice. For example you want to choose a district or an area that restricts a list of counties that itself restricts a list of towns. I call this cascading form controls.

For the moment, QGis don't have an internal mechanism to deal with those controls. I've sent a feature request to implement it but I also found a workaround by using Python to control the controls content. Here I publish this solution.

Data presentation

I've got the following tables:

  • On geographic table named OBJECTGEO.
  • Flat tables
    • One for AREAs
    • One for Couties
    • Last one for towns

The flat tables are linked together: each county has got a reference to an area (with an ID) and each town has got a reference to a county.

Here the schemas of all of the tables

Schema for OBJECTGEO

Schema for AREA

Schema for COUNTY

Schema for TOWN

Build the form

You can build the form on OBJECTGEO table just by following what is written below:

Forms for OBJECTGEO

Be sure to add value relations controls for the ID_AREA, ID_COUNTY and ID_TOWN fields:

An example of value-relation form control for ID_TOWN

For the moment, your form looks like this:

Form without cascade control

You can find that the values of the form are not linked together (if you are French you know that Finistère is not in Pays-de-la-loire and that SAUMUR is not in Finistère). Time to change this...

Write some Python code

Now that you have some controls in your form, you need to add a little bit of logic in order to have cascading controls. Everything is made in the following Python code.

# -*- encoding: utf-8 -*-

from PyQt4.QtCore import *
from PyQt4.QtGui import *

myDialog = None

def formOpen(dialog,layerid,featureid):
    global myDialog
    myDialog = dialog

    # We need to introspect the form
    # In QGis v2.4 autobuild form controls do not have a name... (fixed in v2.6)
    # So we need to retrieve form controls by field order !
    lst_children = dialog.findChildren(QComboBox)
    area = lst_children[0]
    county = lst_children[1]
    town = lst_children[2]

    # Clear the children QComboBox widgets
    county.clear()
    town.clear()

    # When you change the value for area widget, change the content of the county widget
    area.currentIndexChanged.connect(lambda: cascadeManage('COUNTY', 'ID_COUNTY', 'COUNTY', 'ID_AREA', county, area))
    # When you change the value for county widget, change the content of the town widget
    county.currentIndexChanged.connect(lambda: cascadeManage('TOWN', 'ID_TOWN', 'TOWN', 'ID_COUNTY', town, county))

def cascadeManage(layername, id_idx, txt_idx, fltr, widget, parent_widget):
    '''Generic function to manage cascading QComboBox.
       * layername: the layer (flat table) to request values for this control
       * id_idx: primary key for the table (used in for the form control key)
       * txt_idx: field shown in the form control QComboBox
       * fltr: field which is used to filter the content of this widget based on the parent widget value
       * widget: widget object (QComboBox) to control
       * parent_widget: get the value from the parent QComboBox widget'''
    from qgis.core import QgsMapLayerRegistry, QgsExpression

    # Get the layer (flat table)
    layers = QgsMapLayerRegistry.instance().mapLayersByName(layername)
    if (len(layers) > 0):
        layer = layers[0]
    else:
        return False

    # get attributes indexes
    ididx = layer.fieldNameIndex(id_idx)
    txtidx = layer.fieldNameIndex(txt_idx)

    # Get the value of the parent QComboBox
    filter_code = str(parent_widget.itemData(parent_widget.currentIndex()))

    # Build an expression with it
    exp = QgsExpression("{0} = {1}".format(fltr, filter_code))
    exp.prepare(layer.pendingFields())

    # clear the current widget content
    widget.clear()

    # And fill it with filtered data:
    for feature in layer.getFeatures():
        value = exp.evaluate(feature)
        if exp.hasEvalError():
            raise ValueError(exp.evalErrorString())
        if bool(value):
            widget.addItem(feature.attributes()[txtidx], feature.attributes()[ididx])

You can find explanations directly in the code...

In action

Save the upper code in a file named MYFORM.py in the same directory than your QGis project.

Now, make the link between the form and the code: change the value of Python Init function text with MYFORM.formOpen:

  • MYFORM refers to MYFORM.py Python file.
  • formOpen is the name of the method which will be launched when the form opens.

Add Python init function

Now, you can have actives cascading controls:

Cascading controls form in action

Whenever you choose an area, it restricts the list of counties. Same thing with counties and towns...

Beware, there is a bug in QGis 2.4 which just duplicate the new object when you use a form with Python code (you can find twice on the attribute table). But it is already fixed in the master version of QGis which will become v2.6 in november 2014. For QGis 2.4, you will just delete the duplicate entry in the attribute table.

Conclusion

As a conclusion, we have a mean to implement cascading form controls even (and especially) with value-relation controls. The code could be greatly improved. For example, with the 2.6 version of QGis, you can use field name instead of trying to find the good widgets sorted by order. You could go a bit deeper and make your own ui form (with QtCreator) and create cascading form controls that only modify the value of one field (in our case, we add AREA and COUNTY fields in OBJECTGEO but the real information we want is the ID of the TOWN).

I hope that QGis will provide soon an intern (and easier to use without Python code) method to use cascading form controls !

Posted jeu. 02 oct. 2014 19:14:07

Introduction

Since QGis 2.0, you can use GeoAlgorithms (aka Processing or geoprocessing) to manipulate your data. But you can also developp your own GeoAlgo to do things that are not (already) included in QGis. As GeoAlgo is relatively young, the development on this part of QGis is important.

Today I propose a very basic GeoAlgo that takes an Oracle layer, use internal Oracle Spatial procedure (SDO_GEOM.VALIDATE_GEOMETRY_WITH_CONTEXT) to find non valid geometries and returns a layer with those geometries and an explanation of the errors that have been found.

Install cx_Oracle

The code needs cx_Oracle Python library to work. This library is a low-level communication Python (DBAPIv2) library for interconnections to Oracle databases. For the moment it is not included in QGis binary installation, so you have to install by yourself first.

If you are running a GNU/Linux distribution and have installed QGis from the official repositories, you'll have to install Oracle Client and SDK and then install cx_Oracle. Read this...

If you are running QGis on MS-Windows, you have to download the good version of cx_Oracle binaries (32 or 64 bits) and extract and copy cx_Oracle.pyd file in C:\Program Files\QGis Chugiak/apps/Python2.7/DLL .

To check for a valid installation of cx_Oracle in the QGis Python environment:

  • Launch QGis
  • Open the Python Console (Plugins -> Python Console)
  • Type:
import cx_Oracle
  • If you have no error message, you're done !

Source code for VerifyOracle.py

Here is the source of the GeoAlgo. Just save it in a file named VerifyOracle.py ans place it in the good directory. You'll have to find the directory that stores the official Processing plugin.

On MS-Windows systems, it is stored in C:\Program Files\QGIS Chugiak\apps\qgis\python\plugins\processing\algs\qgis.

# -*- coding: utf-8 -*-


#***************************************************************************
#    VerifyOracle.py
#    ---------------------
#    Date                 : September 2014
#    Copyright            : (C) 2014 by Médéric RIBREUX
#    Email                : mederic.ribreux@medspx.fr
#***************************************************************************
#*                                                                         *
#*   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.                                   *
#*                                                                         *
#***************************************************************************
#
#You need to install cx_Oracle under QGis Python directory.

__author__ = 'Médéric RIBREUX'
__date__ = 'September 2014'
__copyright__ = '(C) 2014, Médéric RIBREUX'

# This will get replaced with a git SHA1 when you do a git archive

__revision__ = '$Format:%H$'

from osgeo import gdal
import cx_Oracle
import re
from qgis.core import *
from PyQt4.QtCore import *
from processing.core.GeoAlgorithm import GeoAlgorithm
from processing.parameters.ParameterVector import ParameterVector
from processing.parameters.ParameterTableField import ParameterTableField
from processing.outputs.OutputVector import OutputVector
from processing.tools import dataobjects, vector
from processing.tools.general import *


class VerifyOracle(GeoAlgorithm):

    INPUT_VECTOR = 'INPUT_VECTOR'
    OUTPUT = 'OUTPUT'
    FIELD = 'FIELD'

    def defineCharacteristics(self):
        self.name = 'Verify layer geometries with Oracle Spatial engine'
        self.group = 'Vector analysis tools'

        self.addParameter(ParameterVector(self.INPUT_VECTOR, 'Vector layer',
                          [ParameterVector.VECTOR_TYPE_ANY]))
        self.addParameter(ParameterTableField(self.FIELD, 'UID Field for input vector layer',
                                              self.INPUT_VECTOR))
        self.addOutput(OutputVector(self.OUTPUT, 'Result Vector layer'))

    def processAlgorithm(self, progress):

        uri = self.getParameterValue(self.INPUT_VECTOR)
        layer = dataobjects.getObjectFromUri(uri)
        # Deals with fields
        fieldName = self.getParameterValue(self.FIELD)
        fieldIdx = layer.fieldNameIndex(fieldName)
        fields = layer.dataProvider().fields()

        # Add the Errors field
        fields.append(QgsField('Errors', QVariant.String))

        # Get connection parameters
        regexp = re.compile(".*dbname='(+)'.*user='(+).*password='(+)'.*table=(+).*\((+)\)")
        dbname, user, password, table, geocol = regexp.match(uri).groups()

        query = u"SELECT c.{0}, SDO_GEOM.VALIDATE_GEOMETRY_WITH_CONTEXT (c.{1}, 0.001) FROM {2} c WHERE SDO_GEOM.VALIDATE_GEOMETRY_WITH_CONTEXT (c.{1}, 0.001) <> 'TRUE'".format(self.getParameterValue(self.FIELD),geocol, table)

        # Make the connection and the query
        connection = cx_Oracle.connect( user, password, dbname)
        c = connection.cursor()
        c.execute(query)
    rows = c.fetchall()
        c.close()

        # Open a writer to the output vector layer
        writer = self.getOutputFromName(
                self.OUTPUT).getVectorWriter(fields,
                                             layer.dataProvider().geometryType(),
                                             layer.dataProvider().crs())


        # We get all of the features from the input vector layer
        features = vector.features(layer)

        # And make some computations for the progress bar
        total = 100.0 / float(len(features))
        current = 0

        outFeat = QgsFeature()


        # Build a list of errors (at least as big as number of features of the layer)
        errors = []
        for row in rows:
            errors.append({'GID':row[0], 'ERROR':row[1]})

        # Main loop
        for feature in features:
            gid = feature.attributes()[fieldIdx]
            # if the feature has got an error
            if gid in [x['GID'] for x in errors]:
                error = (x['ERROR'] for x in errors if x['GID'] == gid).next()
                geom = feature.geometry()
                attrs = feature.attributes()

                # write the feature to the output layer
                outFeat.setGeometry(geom)
                attrs.append(error)
                outFeat.setAttributes(attrs)
                writer.addFeature(outFeat)

            current += 1
            progress.setPercentage(int(current * total))

        del writer

Register GeoAlgo

For QGis to display the GeoAlgo in the algorithms tree, you have to register the Python module first. Registering the module is done by editing the file named QGISAlgorithmProvider.py. Just add the following lines:

...
# on the declaration of modules
from VerifyOracle import VerifyOracle
...
        self.alglist = [SumLines(), PointsInPolygon(), # just add a line to this list
...
                        VerifyOracle(),

In action

Just open an Oracle layer in QGis, then launch the GeoAlgo. It is located in QGis GeoAlgorithms and in Vector analysis tools:

GeoAlgo menu

Then you'll find the following dialog box:

GeoAlgo dialog box

  • In Vector layer choose the layer you want to verify.
  • In UID Field for vector layer choose an attribute which is a primary key for the layer.
  • In Result vector layer, you can add a path to a new shapefile or leave it blank to use a temporary file.
  • Click on the Run button.

Once run and done, you can find a layer named "Result Vector Layer" that contains the non-valid geometries. Every attributes are taken from the original layer and one column has been added at the end, which is named Errorsand contains what errors have been found by Oracle. The ring and edges numbers are very useful to find where is the problem as QGis uses the same numbers in its nodes tool:

GeoAlgo results

Conclusion

This code is provided as is ! There is plenty of room for improvement. You should consider it as something experimental... But it works !

Posted mer. 01 oct. 2014 18:34:01

Today I have been stuck by something quite strange under the Python environnement of QGis. I just wanted to open an Oracle vector layer and add it to the layer registry.

Here is what I tried (you can try it in the QGis Python Console from Extensions menu):

uri = QgsDataSourceURI()
uri.setConnection("","1521", "geobase", "user", "password")
uri.setDataSource("SCHEMA","TABLE",
v = QgsVectorLayer(uri.uri(), "MY_LAYER", "oracle")
QgsMapRegistry.instance().addMapLayer(v, True)

When launching the addMapLayer method, nothing moves on QGis interface and after a bit of digging in the log messages, I found nothing suspicious...

I tried to grab the state of the layer. You can do it with the isValid method which is inherited from the QgsMapLayer class. With all of the layers I tried to open, v.isValid() always returned False.

I tried to use the error() method which returned a QgsError object to try to find why my layer was invalid. But v.error().message() was always empty.

I remembered that I've got a special layer in my Oracle database. It is the only one which is well declared in Oracle metadata table (ALL_SDO_GEOM_METADATA view). When I tried the above lines code, I was able to open the layer in QGis map dialog. This special layer just have a well declared SRID, a well declared extent and stores only one type of geometry (Polygon).

I immediately struggle in QGis Python Console to find how to force a CRS for a QgsVectorLayer. The only way I found was to use the setCrs() method directly from the QgsVectorLayer class:

uri = QgsDataSourceURI()
uri.setConnection("","1521", "geobase", "user", "password")
uri.setDataSource("SCHEMA","TABLE",
v = QgsVectorLayer(uri.uri(), "MY_LAYER", "oracle")
v.setCrs(QgsCoordinateSystem(u"EPSG:27562")

But declaring the CRS is finally not required: if there is no SRID for the layer, QGis just use the CRS of the project (it depends on what you have configured in your QGis parameters). Furthermore, adding a CRS with the above method does not change the isValid() status...

Actually, the real problem was a wkbType one. All of my unopenable layers were declared with a wkbType to WkbUnknown. After re-reading QGis API documentation, the only way I've find so far to force the wkbType of a layer is to use uri.setWkbType() method. There is no other choice.

To open a layer with multiple geometry types, you have to add a little bit of code to the above snippet:

uri = QgsDataSourceURI()
uri.setConnection("","1521", "geobase", "user", "password")
uri.setDataSource("SCHEMA","TABLE",
uri.setWkbType(QGis.WkbPolygon)
v = QgsVectorLayer(uri.uri(), "MY_LAYER", "oracle")
QgsMapRegistry.instance().addMapLayer(v, True)

It took just 3 hours of work and search to find the solution. Why not make it public ?

Posted mer. 13 août 2014 18:46:35 Tags:

Introduction

QGis can open a wide bunch of raster formats even proprietary ones. If you have ArcGis 9.3 (well, if you are forced to have it !), you have perhaps generated map services. Those services can be shown on cartographic web applications by using some kind of tile map service (TMS). This service from Esri ArcGIS doesn't respect the TMS standard. The TMS standard uses a special projection (EPSG:3857) and a well-known algorithm to determine which tile to load. Google Maps and OpenStreet map tiling services use the TMS standard and you are able to show them in QGis with just an XML description file (see this).

But it is not the case with ArcGIS 9.3 which uses a different algorithm to calculate which tile should be requested. This "no-standard" seems to have been discarded in the next version of ArcGIS (10.x). But after a bit of search, I finally manage to show an ArcGIS 9.3 map service on QGis 2.4 and that's what will be presented in this article.

Methodology

I've just studied what can open ArcGIS 9.3 cache maps and found that nearly nothing can do it, with the exception of ESRI ArcGIS of course ! Actually, in the opensource world, nothing else than OpenLayers 2 (starting to version 2.11) can open it ! I think that it is so exotic that it is not supported by OpenLayers 3. On the other side, ESRI seems to have changed its way of dealing with TMS in the version 10.x of ArcGIS. I haven't tested it myself but there are plenty of examples on the Internet that shows that QGis can open ArcGIS 10.x Map Services with only an XML description file (for those who want to go further, they can try the experimental "ArcGIS REST API connector" plugin).

Then I realised that there is a QGis plugin called "OpenLayers". It is used to show GoogleMaps/Bing/OSM tiles by using a workaround at the times where QGis (actually GDAL) was not able to handle those rasters. It is still installed in the version 2.4 of QGis even if the plugin has been refactored.

Here what I've found about "OpenLayers" QGis Plugin underwork:

  • There is a core plugin (in Python) code.
  • This code uses HTML description files which are a simple page which calls Javascript to print a Map. You can open those files in your Web browser and content will be shown.
  • Core code executes Javascript and grabs the images.
  • When you zoom or move onto the map, core code execute an Openlayers function and grabs the images.

Quite simple. It is true that this seems to be an ugly hack. Using a browser engine (thanks to Qt) to grab some raster files from the web is not what I call a web service. But it works !

In order to open your ArcGIS 9.3 Map Service, you have to write an HTML description file which uses OpenLayers 2 special functions for ArcGIS 9.3 and let do the magic of the plugin operates ! Actually it is a bit more complicated...

Add the good version of OpenLayers

First of all, you need the good version of OpenLayers 2. Grab the latest from the Internet and save it in the plugin repository: .qgis2/python/plugins/openlayers_plugin/weblayers/html. Name it OpenLayers2.js for disambiguation with other version of the library that are already installed in the directory (OpenLayers.js and OpenLayers-2.8.js). You need the full version of the Javascript code, don't use mobile or light version because they do not contain the ArcGIS code.

Which files should be modified ?

Before diving into the code, be sure to have a global vision of what to change or to add (the root of the plugin is .qgis2/python/plugins/openlayers_plugin):

  • Modify 'weblayers/weblayer.py': Add a class to handle ArcGIS Cache services not projected in ESPG:3857.
  • Create 'weblayers/html/layer_x.html': New HTML(+JS) file(s) to add access to one service from ArcGIS 9.3 Cache Map. Add a new file for every new service.
  • Create 'weblayers/arcgis.py': New file to create a group of layers which will be named "ArcGIS Layers" in the plugin menu.
  • Modify 'openlayers_plugin.py': Import the new group of layers in the plugin and add them to the menu.

Once everything has been made, your layers will be available under the menu Internet -> OpenLayers plugin.

Hack core code

Once you've got the good version of OpenLayers, you have to modify the core code of the plugin (but just a very little bit). The hack is just a workaround to deal with projection. If you inspect .qgis2/python/plugins/openlayers_plugin/weblayers/weblayer.py, you'll find a definition for the WebLayer class from which the code deals with the declaration of the layers. Particulary, there is code to handle the projection. Then you find a children class called WebLayer3857. This class is used in every python group declaration file (a file that just declares what are the name of your layers and which HTML file is linked). It just declares a layer with the projection EPSG:3857 which is the projection of all TMS services (read the introduction of this article for further details).

The problem with this static projection declaration is that the plugin cannot handle TMS that are not projected into this projection. Actually, the ArcGIS 9.3 services I used for this test QGis were using EPSG:27592 and even with the correct HTML definition map file, I was not able to show them in QGis if I use a WebLayer3857 declaration. The requested tiles were always too far away from what the service was able to provide.

As a workaround, we just have to declare a new class which is derived from WebLayer and which just says that the projection of the layers declared with this class is always 27592. Just add the following lines at the end of weblayer.py:

class ArcGISLayer27592(WebLayer):

    epsgList = [27592]

The code of WebLayer class is just self-sufficient to handle the different projection. Then, you'll have to use the new ArcGISLayer27592 class to declare your layers in the group layer definition file.

Note to myself: the official plugin code for WebLayer could be updated to handle different kinds of projections...

Create the HTML definition map file

You just have to write down the definition of the map in an HTML file. Actually, it is much more Javascript than HTML. In the Javascript code, you'll have to use the OpenLayers functions that are able to open ArcGIS 9.3 Cache map. Be sure to load the good version of the OpenLayers2 library (OpenLayers2.js). Here is such a file:

<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>ArcGIS Cache Map</title>
    <link rel="stylesheet" href="qgis.css" type="text/css">
    <script src="OpenLayers2.js"></script>
    <script type="text/javascript">
        var map;
        var loadEnd;
    var layerURL = "http://arcgis.example.com/arcgis/rest/services/folder_name/layer_name/MapServer";
        var layerData = {
    "serviceDescription": "",
    "mapName": "Couches",
    "description": "",
    "copyrightText": "",
    "layers": [
        {
            "id": 0,
            "name": "The layer",
            "parentLayerId": -1,
            "defaultVisibility": true,
            "subLayerIds": null
        }
    ],
    "spatialReference": {
        "wkid": 27592
    },
    "singleFusedMapCache": true,
    "tileInfo": {
        "rows": 512,
        "cols": 512,
        "dpi": 96,
        "format": "PNG32",
        "compressionQuality": 0,
        "origin": {
            "x": 225000,
            "y": 401000
        },
        "spatialReference": {
            "wkid": 27592
        },
        "lods": [
            {
                "level": 0,
                "resolution": 0.6614596562526459,
                "scale": 2500
            },
            {
                "level": 1,
                "resolution": 0.26458386250105836,
                "scale": 1000
            },
            {
                "level": 2,
                "resolution": 0.13229193125052918,
                "scale": 500
            },
            {
                "level": 3,
                "resolution": 0.06614596562526459,
                "scale": 250
            }
        ]
    },
    "initialExtent": {
        "xmin": 301177.13180509704,
        "ymin": 258249.61360172715,
        "xmax": 301317.7581280163,
        "ymax": 258352.66901617133,
        "spatialReference": {
            "wkid": 27592
        }
    },
    "fullExtent": {
        "xmin": 270184,
        "ymin": 235952,
        "xmax": 332136,
        "ymax": 281008,
        "spatialReference": {
            "wkid": 27592
        }
    },
    "units": "esriMeters",
    "supportedImageFormatTypes": "PNG32,PNG24,PNG,JPG,DIB,TIFF,EMF,PS,PDF,GIF,SVG,SVGZ",
    "documentInfo": {
        "Title": "Layer",
        "Author": "author",
        "Comments": "",
        "Subject": "",
        "Category": "",
        "Keywords": "",
        "AntialiasingMode": "Normal",
        "TextAntialiasingMode": "Force"
    }
};
        function init() {

            loadEnd = false;
            function layerLoadStart(event)
            {
              loadEnd = false;
            }
            function layerLoadEnd(event)
            {
              loadEnd = true;
            }
        var baseLayer = new OpenLayers.Layer.ArcGISCache(
                "AGSCache",
                layerURL,
                {
                  layerInfo: layerData,
                  eventListeners: {
                    "loadstart": layerLoadStart,
                    "loadend": layerLoadEnd
                  }
                }
            );

            map = new OpenLayers.Map('map', {
            maxExtent: baseLayer.maxExtent,
                units: baseLayer.units,
                resolutions: baseLayer.resolutions,
                numZoomLevels: baseLayer.numZoomLevels,
                tileSize: baseLayer.tileSize,
                displayProjection: baseLayer.displayProjection
            });
            map.addLayer(baseLayer);
        }
    </script>
  </head>
  <body onload="init()">
    <div id="map"></div>
  </body>
</html>

You can see that there is a big variable called layerData. It describes the whole content definition of the ArcGIS 9.3 service. Without this variable, you are not able to open the layer in an OpenLayers map... you can grab the variable value by using this example from OpenLayers2 which uses the autoconfig mechanism of ArcGISCache. Just open the Firefox Developper Tools and get the variable value (or use console.info()).

Howto grab layer definition easily ?

Creating HTML definition map file is something that can be very fastidious. Actually, you have to grab the JSON value for the layerData variable which describes the content of the service. You can do it by using Firefox Developper Tools but this is not an effective method. To ease the pain, just load this HTML (+JS) file in your web browser and put the URL of an ArcGIS 9.3 Map service (for example: http://arcgis.example.com/arcgis/rest/services/name_of_folder/name_of_layer/MapServer) before clicking on the load button. You'll have to save the resulting HTML file in the plugin directory: .qgis2/python/plugins/openlayers_plugin/weblayers/html.

You need to have Internet access to use this code because I just grab OpenLayers2 from cloudflare CDN. But you can replace it with any other javascript file that contains OpenLayers.

<!DOCTYPE html>
<html>
  <head>
    <title>QGis OpenLayers ArcGIS 9.3 Cache config helper</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <style>
    .smallmap {
      width: 712px;
      height: 556px;
      border: 1px solid #ccc;
    }
    </style>

    <script src="http://cdnjs.cloudflare.com/ajax/libs/openlayers/2.11/OpenLayers.js"></script>

    <script type="text/javascript">
        var map;
    var layerURL;

        function init() {
        var url = document.getElementById("url").value;
        if (url) {
          layerURL = url;
              var jsonp = new OpenLayers.Protocol.Script();
              jsonp.createRequest(layerURL, {
                  f: 'json', 
                  pretty: 'true'
              }, initMap);
            }
        }

        function initMap(layerInfo){

        var layerData = JSON.stringify(layerInfo, null,'\t');

            var html_doc = "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n\
  <head>\n\
    <title>ArcGIS Cache Map<\/title>\n\
    <link rel=\"stylesheet\" href=\"qgis.css\" type=\"text\/css\">\n\
    <script src=\"OpenLayers2.js\"><\/script>\n\
    <script type=\"text/javascript\">\n\
        var map;\n\
        var loadEnd;\n\
   var layerURL = \"" + layerURL + "\";\n\
        var layerData = " + layerData +";";

           html_doc = html_doc + "\n\
        function init() {\n\n\
            loadEnd = false;\n\
            function layerLoadStart(event)\n\
            {\n\
              loadEnd = false;\n\
            }\n\
            function layerLoadEnd(event)\n\
            {\n\
              loadEnd = true;\n\
            }\n\
       var baseLayer = new OpenLayers.Layer.ArcGISCache(\n\
                \"AGSCache\",\n\
                layerURL,\n\
                {\n\
                  layerInfo: layerData,\n\
                  eventListeners: {\n\
                    \"loadstart\": layerLoadStart,\n\
                    \"loadend\": layerLoadEnd\n\
                  }\n\
                }\n\
            );\n\n\
            map = new OpenLayers.Map('map', {\n\
           maxExtent: baseLayer.maxExtent,\n\
                units: baseLayer.units,\n\
                resolutions: baseLayer.resolutions,\n\
                numZoomLevels: baseLayer.numZoomLevels,\n\
                tileSize: baseLayer.tileSize,\n\
                displayProjection: baseLayer.displayProjection\n\
            });\n\
            map.addLayer(baseLayer);\n\
        }\n\
    <\/script>\n\
  <\/head>\n\
  <body onload=\"init()\">\n\
    <div id=\"map\"><\/div>\n\
  <\/body>\n\
<\/html>";

        var html_textarea = document.getElementById("qgis_html");
        html_textarea.value = html_doc;

            // Send the HTML file to save in the local filesystem:
            var a = document.createElement('a');
            a.href = 'data:attachment/html,' + encodeURIComponent(html_doc);
            a.target = '_blank';
            a.download = 'arcgis_layer.html';

            document.body.appendChild(a);
            // send it to the browser
            a.click();

            var baseLayer = new OpenLayers.Layer.ArcGISCache("AGSCache", layerURL, {
                layerInfo: layerInfo
            });

            /*
             * Make sure our baselayer and our map are synced up
             */

            map = new OpenLayers.Map('map', { 
                maxExtent: baseLayer.maxExtent,
                units: baseLayer.units,
                resolutions: baseLayer.resolutions,
                numZoomLevels: baseLayer.numZoomLevels,
                tileSize: baseLayer.tileSize,
                displayProjection: baseLayer.displayProjection  
            });
            map.addLayer(baseLayer);

            map.addControl(new OpenLayers.Control.LayerSwitcher());
            map.addControl(new OpenLayers.Control.MousePosition() );            
            var bbox = layerInfo.initialExtent;
        map.zoomToExtent(new OpenLayers.Bounds(bbox.xmin, bbox.ymin, bbox.xmax, bbox.ymax));

        }
    </script>
  </head>
  <body>
    <h1 id="title">OpenLayers ArcGIS 9.3 Cache config helper</h1>

    <p id="shortdesc">
    <form id="ServiceURL">
            <p>
                <label for="url">Service URL:</label>
                <input id="url" type="url" style="width: 30%;" placeholder="http://arcgis.example.com/arcgis/rest/services/folder_name/layer_name/MapServer"/>
                <input type="button" value="Load" onclick="init();"/>
            </p>
        </form>
    </p>

    <form id="serviceFolders">

    </form>

    <div id="map" class="smallmap"></div>

    <div id="docs">
      <p>HTML to save in a file</p>
      <textarea id="qgis_html" style="width: 40%; height: 300px;"></textarea>
    </div>
  </body>
</html>

Make a group definition file

You have to build a group definition file. This is a purely declarative python file which just describes what are your layers. It is used to build the menu shown by the plugin and to make the link with the HTML files. The first class is the Group one. Then all of the layers that are described in HTML file have to be children class of the group one. Be sure to use strings (and not unicode things) in the name of the layers.

# -*- coding: utf-8 -*-
from weblayer import ArcGISLayer27592

class OlArcgisLayer(ArcGISLayer27592):

    emitsLoadEnd = True

    def __init__(self, name, html):
        ArcGISLayer27592.__init__(self, groupName="ArcGIS 9.3", groupIcon="arcgis_icon.png",
                              name=name, html=html)

class OlArcgisFondDePlanLayer(OlArcgisLayer):

    def __init__(self):
        OlArcgisLayer.__init__(self, name="Fond de plan", html="arcgis_fond_de_plan.html")

class OlArcgisEquipementPublicLayer(OlArcgisLayer):

    def __init__(self):
        OlArcgisLayer.__init__(self, name='Equipement public', html='arcgis_equipement_public.html')

class OlArcgisAdresseLayer(OlArcgisLayer):

    def __init__(self):
        OlArcgisLayer.__init__(self, name='Adresse', html='arcgis_adresse.html')

class OlArcgisArreteCirculationLayer(OlArcgisLayer):

    def __init__(self):
        OlArcgisLayer.__init__(self, name='Arrete de circulation', html='arcgis_arrete_circulation.html')

class OlArcgisAssainissementLayer(OlArcgisLayer):

    def __init__(self):
        OlArcgisLayer.__init__(self, name='Assainissement', html='arcgis_assainissement.html')

class OlArcgisCadastreNapoleonLayer(OlArcgisLayer):

    def __init__(self):
        OlArcgisLayer.__init__(self, name='Cadastre Napoleonien', html='arcgis_cadastre_napoleon.html')

class OlArcgisParcelleLayer(OlArcgisLayer):

    def __init__(self):
        OlArcgisLayer.__init__(self, name='Parcelle', html='arcgis_parcelle.html')

Register group definition file in the plugin

You have to modify openlayers_plugin.py to add the entry menus from your group definition file. The entries are built upon the python class, so you have to import them with a simple line like:

    ...
    from weblayers.arcgis import OlArcgisFondDePlan, OlArcgisEquipementPublic, ...
    ...

Once the modules (classes) are declared, you need to manually add them into the function initGui:

        ...
        self._olLayerTypeRegistry.register(OlArcgisFondDePlanLayer())
        self._olLayerTypeRegistry.register(OlArcgisEquipementPublicLayer())
        ...

Once this is done, you should be able to find your menu entries in the menu with a group of your own layers. Be careful about the names which can be a little type error prone.

User experiment

The OpenLayers QGis plugin is far from being perfect ! Actually it seems to be an ugly hack that works with very few efforts, so on... I've experienced some troubles by using this modified plugin with ArcGIS 9.3:

  • You should open another layer which is in the extent of your raster layer before opening the raster layer. Otherwise, the HTML script may crash because you are asking for a too much wide extent. It can block QGis, so be careful !
  • Sometimes when you zoom, not of all the tiles are shown on the map dialog. You have to unzoom and rezoom to have the whole map updated and shown.
  • Same problem with panning or moving. There you have to unzoom and rezoom to grab all of the tiles.
  • It seems to have a sort of cache: once you grabbed all the tiles at different zoom levels, map loading is more fast.
  • You can't have more than one OpenLayers layer on the map. Only the latest opened layer is shown, whatever the layer order. It is a limitation of the whole plugin, not restricted to ArcGIS services.
  • You can't save an OpenLayer layer on your project ! You have to reopen the layer each time you open your project. It is also a limitation of the plugin, not restricted to ArcGIS services.

I have taken time to test print composer with those services and it seems to be unaffected by the bugs I've seen in the map window. You will be able to generate PDF or print maps with your ArcGIS Map service layers. So far, so good...

Conclusion

QGis is good for you. It can even be interfaced with its greatest opponent in GIS Desktop: ESRI ArcGIS. If you haven't migrated to ArcGIS 10.x, you are still able to use your old ArcGIS 9.3 Cache map services for a while under QGis. There are great chances that no other GIS Desktop software is able to deal with these services nowadays. But QGis is one of them... To go further, I can only advice you to migrate to a true free software tiling solution like MapNik and carto-css. By using the TMS standard, you are 100% sure that QGis will be able to do it. Please read my wiki article to build your opinion on the ease to generate tiles with those tools.

Posted dim. 27 juil. 2014 19:12:05

Introduction

Dans mon précédent article sur QGis et Oracle Spatial, j'avais indiqué comment créer une couche dans Oracle pour la rendre "compatible" avec QGis. Si vous avez de nombreuses couches à importer, écrire manuellement ce code SQL peut entraîner des erreurs. De plus, certains paramètres tels que l'emprise de la bounding-box sont fastidieux à saisir et à calculer.

J'ai donc imaginé un moyen de rendre cet import plus facile. Ce travail est sans aucun doute facilement améliorable mais il a l'intérêt de proposer une méthode complètement intégrée à QGis sans devoir développer un outil tiers ou un plugin dédié.

La technique

Depuis la version 2.0 de QGis, il existe un plugin un peu particulier. C'est le plugin Processing, auparavant appelé Sextante. Il propose de reproduire le concept des géotraitements qu'on trouve dans ESRI ArcGIS Desktop. La ressemblance est d'ailleurs assez frappante lorsqu'on ouvre les deux interfaces graphiques pour comparer.

Processing met à disposition un ensemble de scripts Python qui forment des GéoAlgorithmes. Ces derniers réalisent des traitements sur des couches en entrée et envoient de la donnée en sortie, dans une autre couche ou dans d'autres types de sortie (fichier à plat, variable texte ou nombre, etc.). Il est possible de coupler ces GéoAlgorithmes entre eux pour former des files de traitement. Processing propose d'ailleurs une interface graphique dédiée qui vous permet de composer une ou plusieurs chaînes de traitement.

Composeur de chaîne de traitement

Processing propose également des scripts en Python qui sont exécutés avec une boîte de dialogue simple. Pour configurer cette boîte de dialogue, il suffit d'ajouter des commentaires spéciaux dans le code (marqués avec un double #).

Installer le script

Pour installer le script, le plus simple est de copier le fichier dans le répertoire utilisateur dédié à Processing:

  • ~/.qgis2/processing/scripts/ sous GNU/Linux
  • C:\Users\UTILISATEUR.qgis2\processing\scripts\ sous MS-Windows

A la suite de cet ajout et d'un redémarrage de QGis, le script devrait être disponible dans la fenêtre des géotraitements:

Fenêtre Processing

Le script

Voici le code du script en Python 2.7, version actuellement utilisée par la version 2.2 de QGis. Vous pourrez noter que les paramètres de la boîte de dialogue sont décrits dès le début du script.

# -*- coding: utf-8 -*-

##[Oracle]=group
##input=vector
##table_name=string
##schema=string
##output=output file D:/TEMP/test.sql
##drop_table=boolean False
##add_spatial_index=boolean False
##primary_key=field input
##uppercase_field_names=boolean False
##srid=number 2154
##geometry_type=selection UNKNOWN;POINT;MULTIPOINT;POINT+MULTIPOINT;LINESTRING;MULTILINESTRING;LINESTRING+MULTILINESTRING;POLYGON;MULTIPOLYGON;POLYGON+MULTIPOLYGON;Scan the layer to determine
##tolerance=number 0.01
##inject_data=boolean False


# This script generates an SQL File to create a table in Oracle Spatial
# You can also import the data

from qgis.core import *
from qgis.core import QgsCoordinateTransform
from PyQt4.QtCore import *
from processing.core.GeoAlgorithmExecutionException import GeoAlgorithmExecutionException

# Get some infos from the layer
layer = processing.getObject(input)
provider = layer.dataProvider()
fields = provider.fields()

# Get the extent and manage the SRID transformations:
crsSrc = layer.crs()
crsDest = QgsCoordinateReferenceSystem(int(srid), QgsCoordinateReferenceSystem.EpsgCrsId)
crsTransform = QgsCoordinateTransform(crsSrc, crsDest)

lower_bound = crsTransform.transform(layer.extent().xMinimum(), layer.extent().yMinimum())
upper_bound = crsTransform.transform(layer.extent().xMaximum(),layer.extent().yMaximum())

extent = { 'xmin': lower_bound.x(),
                'xmax': upper_bound.x(),
                'ymin': lower_bound.y(),
                'ymax': upper_bound.y() 
}

# Check schema and table
if not schema or not table_name:
    raise GeoAlgorithmExecutionException('Must have a schema and a table name !')

if len(table_name) > 30:
    raise GeoAlgorithmExecutionException('Table name must be 30 characters max !')

f = open(str(output), 'w')
insert = u'-- Import '+layer.name()+u" in Oracle Spatial As " + table_name+u"...\n"
f.write(insert.encode('utf8'))

# Table creation:
if drop_table:
    insert = u'-- Drop the table:\n'
    insert = insert + u'DROP TABLE '+schema+"."+table_name + u';\n\n'
    f.write(insert.encode('utf8'))

insert = u"-- Create the table:\nCREATE TABLE "+schema+"."+table_name+ u'(\n'
f.write(insert.encode('utf8'))

# Field scanning to discover attributes length
field_length = {}

# Populating with the layer definition
for field in fields:
    if uppercase_field_names:
        name = field.name().upper()
    else:
        name = field.name()
    if field.type() == QVariant.String or field.type() == QVariant.Int:
        field_length[name] = str(field.length())
    elif field.type() == QVariant.Date or field.type() == QVariant.DateTime:
        field_length[name] = ''
    elif field.type() == QVariant.Double:
        field_length[name]=str(field.length())+","+str(field.precision())
    else:
        raise GeoAlgorithmExecutionException('Unknown Field type:'+field.typeName())

# Scanning features to determine true length
feats = processing.features(layer)
nFeat = len(feats)
for inFeat in feats:
    attrs = inFeat.attributes()
    i = -1
    for field in fields:
            i = i +1
            if uppercase_field_names:
                name = field.name().upper()
            else:
                name = field.name()
            if field.type() == QVariant.String:
                if (len(attrs[i])) > int(field_length[name]):
                    field_length[name] = str(len(attrs[i]))
            elif field.type() == QVariant.Int:
                if (len(str(attrs[i]))) > int(field_length[name]):
                    field_length[name] = str(len(str(attrs[i])))
            elif field.type() == QVariant.Double:
                current_len = int(field_length[name].split(',')[0])
                current_prec = int(field_length[name].split(',')[1])
                if (len(str(attrs[i]))) > current_len:
                    current_len = len(str(attrs[i]))
                if len(str(attrs[i]).split('.')) > 1:
                    if (len(str(attrs[i]).split('.')[1])) > current_prec:
                        current_prec = len(str(attrs[i]).split('.')[1])
                field_length[name] = str(current_len)+","+str(current_prec)

# Create the table
for field in fields:
    if uppercase_field_names:
        name = field.name().upper()
    else:
        name = field.name()
    insert = u"\t"+name
    if field.type() == QVariant.String:
        insert = insert + u" VARCHAR2("+field_length[name]+")"
    elif field.type() == QVariant.Date or field.type() == QVariant.DateTime:
        insert = insert + u" DATE"
    elif field.type() == QVariant.Int or field.type() == QVariant.Double:
        insert = insert +u" NUMBER("+field_length[name]+")"
    else:
        raise GeoAlgorithmExecutionException('Unknown Field type:'+str(field.type()))
    if field.name() == primary_key:
        insert = insert + u" CONSTRAINT PK_"+table_name[0:27]+u" PRIMARY KEY"
    insert = insert + u",\n"
    f.write(insert.encode('utf8'))

# Add Geometry definition
insert = u"\tGEOM MDSYS.SDO_GEOMETRY\n);\n"
f.write(insert.encode('utf8'))

# Remove Metadata
insert = u"\n-- Update Oracle Spatial Metadata\nDELETE FROM USER_SDO_GEOM_METADATA WHERE TABLE_NAME = '"+table_name+"' ;\nCOMMIT;\n\n"
f.write(insert.encode('utf8'))

# Add new metadata
insert = u"INSERT INTO USER_SDO_GEOM_METADATA (TABLE_NAME,COLUMN_NAME,DIMINFO,SRID)\n"
insert = insert + u"VALUES ('"+table_name+"','GEOM',\n"
insert = insert + u"\tMDSYS.SDO_DIM_ARRAY(\n"
insert = insert + u"\t\tMDSYS.SDO_DIM_ELEMENT('X',"+str(extent['xmin'])+u","+str(extent['xmax'])+u","+str(tolerance)+u"),\n"
insert = insert +u"\t\tMDSYS.SDO_DIM_ELEMENT('Y',"+str(extent['ymin'])+u","+str(extent['ymax'])+u","+str(tolerance)+u")),\n"
insert = insert + u"\t"+str(srid)+");\nCOMMIT;\n"
f.write(insert.encode('utf8'))

# Add table constraints
geom_types=[]
if geometry_type == 10:
    #Scan geometry to determine Geometry type
    feats = processing.features(layer)
    for inFeat in feats:
        the_type = inFeat.geometry().wkbType()
        if the_type == QGis.WKBPoint and 2001 not in geom_types:
            geom_types.append(2001)
        elif the_type == QGis.WKBMultiPoint and 2005 not in geom_types:
            geom_types.append(2005)
        elif the_type == QGis.WKBLineString and 2002 not in geom_types:
            geom_types.append(2002)
        elif the_type == QGis.WKBMultiLineString and 2006 not in geom_types:
            geom_types.append(2006)
        elif the_type == QGis.WKBPolygon and 2003 not in geom_types:
            geom_types.append(2003)
        elif the_type == QGis.WKBMultiPolygon and 2007 not in geom_types:
            geom_types.append(2006)

if geometry_type != 0:
    if geometry_type == 1:
        geom_types.append(2001)
    elif geometry_type == 2:
        geom_types.append(2005)
    elif geometry_type == 3:
        geom_types.append(2001)
        geom_types.append(2005)
    elif geometry_type == 4:
        geom_types.append(2002)
    elif geometry_type == 5:
        geom_types.append(2006)
    elif geometry_type == 6:
        geom_types.append(2002)
        geom_types.append(2006)
    elif geometry_type == 7:
        geom_types.append(2003)
    elif geometry_type == 8:
        geom_types.append(2007)
    elif geometry_type == 9:
        geom_types.append(2003)
        geom_types.append(2007)
    geom_types_str= [ str(sdf) for sdf in geom_types]
    geo_constraint=u"SDO_GTYPE = "+u" OR SDO_GTYPE = ".join(geom_types_str)
    insert = u"\n-- Geometry constraints\n"
    insert = insert + u"ALTER TABLE "+table_name+"\n"
    insert = insert+ u"\tADD CONSTRAINT GC_"+table_name[0:27]+" CHECK ("+geo_constraint+") ENABLE;\n"
    f.write(insert.encode('utf-8'))

# Add spatial_index:
if add_spatial_index:
    insert = u"\n-- Create Spatial Index\n"
    insert = insert + u"DROP INDEX GI_"+table_name[0:27]+";\n"
    insert = insert + u"CREATE INDEX GI_"+table_name[0:27]+" ON " +table_name+"(GEOM)\n"
    insert = insert +u"\tINDEXTYPE IS MDSYS.SPATIAL_INDEX\n"
    insert = insert + u"\tPARAMETERS (SDO_DML_BATCH_SIZE = 1');\n"
    insert = insert +u"COMMIT;\n"
    f.write(insert.encode('utf8'))


if inject_data:
    nElement = 0
    feats = processing.features(layer)

    for inFeat in feats:
        progress.setPercentage(int(100 * nElement / nFeat))
        nElement += 1
        # Starts INSERT
        insert = u"INSERT INTO "+table_name+ u" VALUES ("
        # Inject attributes
        attrs = inFeat.attributes()
        i = -1
        for field in fields:
            i = i +1
            if field.type() == QVariant.String:
                value = u"'"+attrs[i]+u"'"
            elif field.type() == QVariant.Date or field.type() == QVariant.DateTime:
                value = u"TO_DATE('"+attrs[i].toString('yyyy-MM-dd')+u"','YYYY-MM-DD')"
            else :
                value = str(attrs[i])
            insert = insert + value +u", "                       
        # Injects geometry in WKT format
        inGeom = inFeat.geometry().exportToWkt()
        insert = insert + u"SDO_GEOMETRY('" + inGeom + u"')"
        insert = insert + u");\n"
        f.write (insert.encode('utf8'))

# This is the end... 
f.close()

Conclusion

Le script n'est pas parfait mais il permet de générer du SQL conforme à Oracle Spatial et qui permettra à QGis de bien découvrir les couches nouvellement créées.

Quelques améliorations peuvent être imaginée. La première consiste à améliorer la gestion de l'import des données. En effet, même si j'ai indiqué les instructions pour réaliser un import des données et pas uniquement de la structure, cet import ne fonctionne pas vraiment dans la pratique. En effet, Oracle ne permet pas des requêtes SQL texte dont la taille est trop longue (limité à 4000 caractères je crois). Dans les faits, un import de polygones sera donc impossible alors qu'un import de points sera sans doute applicable. Il me faudrait trouver un autre moyen, notamment, profiter de ce que QGis offre pour créer une table et y injecter un contenu.

Enfin, on pourrait aller plus loin en permettant l'import direct dans Oracle Spatial, à l'instar du GéoAlgorithme qui existe pour PostGIS. Mais pour l'instant, il existe au moins un outil pour créer une structure de table géographique Oracle Spatial à partir d'une couche ouverte dans QGis. C'est déjà un bon début et ça permettra à ceux qui veulent lire le code de mieux comprendre comment on créé un GéoAlgorithme en Python...

Posted dim. 01 juin 2014 18:12:05

Introduction

Spatial est le nom du cartouche spatial du SGBDR Oracle édité par la société éponyme. Pour ceux qui viennent du monde libre, Spatial correspond à la couche PostGIS de PostgreSQL. Si cela fait bien longtemps que QGis sait se connecter à une base PostGIS, ce n'est pas la même chose en ce qui concerne Oracle. En effet, c'est seulement depuis la version 2.0 de QGis qu'il existe un connecteur natif permettant de se connecter directement à un serveur Oracle Spatial. Cet ajout est issu d'un (gros) commit sur le code de QGis.

Comme toute nouveauté, il est important d'en faire le tour sachant que le service proposé a forcément des limites qu'il s'agit d'explorer et de bien comprendre. Faisons-donc le tour du connecteur Oracle Spatial, de ses limites et de son impact sur un entrepôt de données géographiques. Le contenu de cet article se base sur la version 2.2 de QGis sortie le 21 février 2014. L'article se veut aller au fond des choses, il sera donc volumineux et long à lire !

Le connecteur Oracle Spatial

L'accès aux bases Oracle est assez simple et il est complètement intégré à l'interface graphique de QGis. Dans la barre des connecteurs, il existe une icône dont voici la représentation:

Icône d'accès au connecteur Oracle

Voyons maintenant ce que nous propose le connecteur. La boîte de dialogue qui s'ouvre après avoir cliqué sur l'icône du connecteur Oracle permet de lister les tables d'une base Oracle. Pour la faire fonctionner, il faut créer une connexion. Le paramétrage de cette dernière sera conservé dans la configuration globale de QGis ce qui vous permettra de la réutiliser après avoir fermé Qgis. Si vous avez plus d'un serveur ou si vous avez un seul serveur avec des utilisateurs différents qui disposent de droits distincts, vous pourrez enregistrer ces connexions pour les réutiliser plus tard, ce qui est bien pratique.

Voici la boîte de dialogue qui permet de configurer une connexion Oracle Spatial:

Boîte de dialogue de configuration d'une connexion Oracle Spatial

Pour créer une nouvelle connexion, vous pouvez appuyer sur le bouton "Nouveau". Avant de créer une connexion, il est indispensable que vous disposiez d'un client Oracle correctement installé. Sous MS-Windows, vous devrez définir la variable d'environnement TNS_ADMIN pour qu'elle pointe vers le répertoire qui contient les définitions de connexion Oracle. En effet, en règle générale, pour se connecter, le client Oracle (et toute application qui se base dessus) utilise le fichier tnsnames.ora. Ce fichier fait le lien entre le nom du serveur Oracle, le nom de la base et le port. Il permet de définir des SID qui sont des raccourcis de connexion à la base. Pour que QGis puisse se connecter à une base Oracle Spatial, vous aurez besoin de disposer du fichier tnsnames.ora, qu'il soit accessible via la variable TNS_ADMIN et de connaître le SID qui vous permettra de vous connecter à la bonne base de données du bon serveur sur le bon port.

J'ai toutefois pu noter quelques différences sur deux postes de travail différents. Le premier sous MS-Windows XP n'utilisait pas forcément le SID du fichier tnsnames.ora. Le second, sous MS-Windows 7, ne fonctionnait qu'en renseignant le SID. Lorsque vous créez une connexion Oracle Spatial dans QGis, vous devez renseigner les champs suivants:

  • Nom: c'est le nom de la connexion. Vous pouvez mettre ce que vous voulez, c'est le nom qui sera affiché dans QGis pour repérer la connexion.
  • Base de données: si vous êtes sous MS-Windows XP, vous devez renseigner le nom de la base de données. Sous MS-Windows 7, il suffit de mettre le nom du SID tel que défini dans le fichier tnsnames.ora.
  • Hôte: Sous MS-Windows XP, vous pouvez renseigner le FQDN ou l'IP du serveur. Sous MS-Windows 7, il ne faut rien mettre, le SID suffit.
  • Port: sous MS-Windows XP, vous pouvez renseigner le port du listener Oracle. Sous MS-Windows 7, il ne faut rien mettre non plus.
  • Nom d'utilisateur: Le nom de l'utilisateur avec lequel on va faire la connexion.
  • Mot de passe: Un mot de passe qui peut se connecter à cette base.

D'une manière générale, si vous avez une erreur ORA-12154, c'est que vous avez rempli le nom d'hôte du serveur et que QGis utilise en fait le SID. Dans ces conditions, videz le champ hôte et refaîtes un test de connexion (avec le bouton adéquat).

A la suite de ces éléments de configuration, QGis nous propose quelques options qu'il me faut détailler:

  • Enregistrer le nom de l'utilisateur permet de stocker le login dans les paramètres de la connexion Oracle, pour la réutiliser plus tard. Si vous vous connectez toujours avec le même compte, n'hésitez pas à l'utiliser. De plus, la base de données peut être gérée pour affecter certains schémas à certains utilisateurs. Vous pouvez-donc sauvegarder plusieurs connexions différentes avec un nom d'utilisateur différent sur la même base pour accéder plus efficacement aux données du schéma auquel l'utilisateur a droit.
  • *Sauvegarder le mot de passe" permet de stocker le mot de passe. L'information est stockée en clair dans un fichier de configuration de QGis sous GNU/Linux et dans la base de registre sous MS-Windows (HKEY_CURRENT_USER\Software\QGIS\QGIS2\Oracle\connections). Je vous recommande de ne pas utiliser cette option: taper un mot de passe lors de la connexion est trivial et c'est beaucoup plus sécurisé.
  • Chercher uniquement dans la table de métadonnées est un mécanisme qui permet de lister plus rapidement les tables géographiques. Si la case est décochée, QGis parcoure la liste des tables et vérifie, une par une, si elles disposent d'une colonne géométrique. Si la case est cochée, QGis utilise les mécanismes d'Oracle pour afficher plus rapidement la liste des couches géographiques. En effet, comme sous PostGIS, Oracle maintient une liste des couches géographiques dans une table spécifique. C'est cette table qui est interrogée pour retourner en une seule requête la liste des tables géographiques. Cette table de métadonnées (qui est en fait une vue) se nomme ALL_SDO_GEOM_METADATA et elle est stockée dans le schéma MDSYS de la base Oracle. On y trouve les colonnes suivantes:

    • TABLE_NAME: le nom de la couche géographique.
    • COLUMN_NAME: le nom de la colonne qui porte la géométrie de la couche.
    • DIMINFO: une donnée présente sous forme d'un tableau (SDO_DIM_ARRAY) contenant les dimensions de la colonne géométrie. Sous Oracle Spatial, une dimension est déterminée en indiquant ses bornes spatiales ainsi que sa résolution via un objet SDO_DIM_ELEMENT.
    • SRID: le système de projection de la couche.
  • Chercher uniquement les tables de l'utilisateur permet de consulter uniquement la vue USER_SDO_GEOM_METADATA. Cette vue liste les tables géographiques de la base Oracle à la manière de la vue ALL_SDO_GEOM_METADATA mais elle extrait uniquement les tables que peut consulter l'utilisateur qui fait la connexion vers le serveur. Si vous avez un catalogue important, cette option permettra de limiter le nombre de couches retournées.

  • Lister les tables sans géométrie est une option pour afficher le maximum de tables en listant également celles qui ne sont pas géographiques.
  • *Utiliser la table de metadonnées estimées" est un mécanisme qui permet de déterminer plus rapidement le type géométrique de chaque couche. Quand cette option est activée, QGis ne cherche le type de géométrie que sur les 100 premières lignes de la couche. Si vous avez des couches contenant de nombreux objets géographiques, il vaut mieux activer cette option. Par nombreux objets j'entends supérieur à 200000 objets géographiques dans une couche. En deça, on ne peut pas vraiment faire la différence entre les temps de requêtes. L'autre intérêt de cette option c'est qu'elle permet à QGis de déterminer plus rapidement la bounding-box de la couche plutôt que de la calculer en fonction du contenu de la couche.
  • Seulement les types de géométrie existants permet de ne gérer que les types de données géométriques reconnus de QGis. En effet, Oracle Spatial peut gérer d'autres types d'objets géographiques comme des arcs ou des rectangles qui ont des définitions bien particulières (on stocke uniquement les coordonnées nécéssaires à leur représentation soit deux points pour le rectangle au lieu de 3 pour un polygone rectangulaire). Lorsque la case est décochée, on ajoute dans les types de géométrie de la couche, la valeur WKBUnknown ce qui vous permet de choisir dans le connecteur Oracle quel sera le type géométrique de la couche que vous allez ouvrir. A vous de choisir la bonne valeur. Dans la pratique, je vous recommande de cocher cette case pour être sûr que QGis délaisse les couches contenant des objets qu'il ne sait pas gérer.

Au delà de l'utilisation du connecteur, j'ai remarqué un bug assez ennuyeux si vous utilisez plusieurs connexions simultanées sur la même base mais avec un utilisateur différent sans enregistrer le mot de passe dans les paramètres de la connexion (ce qui est peu sécurisé). En effet, Qgis réutilise le nom de l'utilisateur qui s'est connecté en dernier à la base Oracle. En résumé, une fois que vous avez ouvert une connexion vers une base, impossible de changer d'utilisateur à moins d'avoir le mot de passe en dur dans la définition de la connexion. J'ai fait un rapport de bug sur ce point afin de faire évoluer la situation. Donc retenez qu'en cas de connexions multiples avec des utilisateurs différents, il faut stocker les mots de passe en dur.

Une fois que vous avez initié la connexion, Qgis affiche un arbre (qui ressemble assez à une liste) des couches suivant les schémas Oracle.

liste des couches après connexion

Voici le descriptif des colonnes:

  • Propriétaire: c'est le nom du schéma Oracle dans lequel est situé la couche.
  • Nom de la couche: c'est le nom de la table dans le serveur Oracle.
  • Type de géométrie: QGis affiche une icône qui indique le type de géométrie de la couche, tel qu'il a pu le repérer. Si vous avez décoché la case Seulement les types de géométrie existants, vous aurez pour chaque couche un doublon avec une icône en forme de raster qui vous permet de sélectionner le type de géométrie. Par ailleurs, si votre couche contient plusieurs types géométriques, elle sera listée plusieurs fois.
  • Colonne géométrique: Le nom de la colonne géométrique.
  • SRID: c'est le système de projection de la couche. Si QGis n'a pas pu le déterminer, ce sera à vous de renseigner une valeur avant de pouvoir sélectionner une couche.
  • Clef primaire: C'est la clef primaire de la table telle qu'elle a été déterminée par QGis.
  • Select At ID: Cette case est censée activer un cache pour la table attributaire mais j'ai l'impression que ce cache est toujours actif, même si on décoche la case. Je n'ai pas décelé de comportement particulier.
  • SQL: c'est la clause WHERE de la requête. Dès que vous saisissez des critères dans cette partie, toutes les requêtes vers Oracle Spatial auront cette clause WHERE. Cela permet d'être beaucoup plus efficace et de limiter le périmètre de vos recherches à ce qui vous intéresse tout en améliorant les temps de réponse du serveur qui ne sera pas obligé de remonter toutes les lignes. Pour rédiger plus efficacement une clause WHERE, double-cliquez sur la ligne de la couche pour faire apparaître la boîte de dialogue.

Travailler avec la liste des couches

QGis propose également un mode qui sera sans doute plus utile si vous désirez afficher plusieurs couches pour voir leur contenu. En effet, une fois que vous avez fermé la fenêtre du connecteur Oracle, la liste des couches de la base de données Oracle est vidée. Chaque fois que vous ouvrez le connecteur, il vous sera imposé de resélectionner une connexion et de cliquer sur le bouton "Connecter" ce qui aura pour effet de relancer les requêtes d'interrogation du catalogue Oracle, opération souvent coûteuse en temps. C'est particulièrement pénible si votre catalogue est imposant.

Heureusement, il est possible de conserver en mémoire la liste des couches d'une connexion grâce à l'onglet Parcourir.

Dans ce mode, apparaît un raccourci Oracle qui liste les connexions que vous avez configurées au niveau du connecteur Oracle Spatial. Pour les activer il suffit de déplier l'arbre en cliquant sur le "+" situé à gauche du nom de la connexion. Cette action entraîne une interrogation du catalogue Oracle (selon les paramètres de la connexion). Néanmoins, cette fois, les résultats restent présents dans l'arbre. On peut donc ajouter une couche, passer à l'onglet "Couches", travailler avec Qgis (en stylant la couche), puis revenir sur l'onglet "Parcourir" et retrouver la liste des couches disponibles intacte.

Si vous devez vraiment ajouter beaucoup de couches issues de différents horizons, je vous recommande à la place d'utiliser la mini-fenêtre "Navigateur". Cette mini-fenêtre se place en dessous de la liste des couches. Elle est donc immédiatement disponible pour vous aider à naviguer dans votre entrepôt de données. Pour l'activer, il est nécéssaire d'activer le panneau "Navigateur (2)" à partir du menu Vue -> Panneau. Vous pouvez également l'activer via un clic droit sur une barre d'outils et cocher la case "Navigateur (2)".

Menu navigateur

Ce panneau est vraiment intéressant car outre ses fonctions de navigation rapide, il est toujours actif. Par exemple, pendant que vous faîtes un inventaire des couches spatiales d'Oracle, vous pouvez vous ballader dans l'arborescence de fichiers de vos rasters. D'ailleurs une fois que vous l'avez activé, je vous recommande de désactiver l'onglet parcourir en le décochant depuis le menu Vue -> Panneau.

Je vous recommande donc d'utiliser ce mode si vous souhaitez parcourir un peu votre catalogue pour y trouver les couches dont vous avez besoin et être plus efficace avec QGis.

En dessous du capot du connecteur

Après avoir montré comment utiliser le connecteur pour se connecter à une base Oracle, voyons se qui se passe sous le capot.

Un petit coup de Wireshark peut nous révéler plusieurs instructions précieuses. Voici ce que j'ai pu découvrir après analyse.

Lister les couches disponibles dans l'entrepôt

Voici la requête SQL qu'on peut voir passer lorsqu'on se connecte à la base pour lister les couches disponibles en activant l'interrogation de la table de métadonnées et en limitant le périmètre de la recherche aux couches que possède l'utilisateur connecté:

   SELECT user AS owner,c.table_name,c.column_name,c.srid,o.object_type AS type
   FROM user_sdo_geom_metadata c
   JOIN user_objects o ON c.table_name=o.object_name AND o.object_type IN ('TABLE','VIEW','SYNONYM');

Cette requête permet d'interroger la vue USER_SDO_GEOM_METADATA pour récupérer la liste des tables et des vues géographiques. Qgis récupère ce dont il a besoin pour gérer la fenêtre de dialogue: l'utilisateur de la table, le nom de la table, la colonne qui stocke la géométrie, le système de projection ainsi que le type d'objet: table, vue ou synonym qui est un alias d'une table ou d'une vue. A noter qu'une vue matérialisée est vue comme une table.

Si vous avez décoché la case Chercher uniquement les tables de l'utilisateur, c'est la requête suivante qui est jouée:

    SELECT c.owner,c.table_name,c.column_name,c.srid,o.object_type AS type
    FROM all_sdo_geom_metadata c
    JOIN all_objects o ON c.table_name=o.object_name
      AND o.object_type IN ('TABLE','VIEW','SYNONYM')
      AND c.owner=o.owner;

On voit que dans cette configuration, on attaque la table ALL_SDO_GEOM_METADATA. Tout dépend de la manière dont vous avez organisé vos tables. En règle générale, il est bon qu'un utilisateur spécifique n'accède qu'aux tables qu'il possède. Cela permet de réduire fortement le temps d'attente avant de disposer de la liste des couches. Dans mon cas, l'ordre de grandeur pour afficher 2500 couches cataloguées est de l'ordre d'une trentaine de secondes.

Si on a décoché la case Chercher uniquement dans la table de métadonnées, QGis lance alors la requête suivante:

    SELECT user AS owner,c.table_name,c.column_name, NULL AS srid, o.object_type AS type
    FROM user_tab_columns c
    JOIN user_objects o ON c.table_name=o.object_name
      AND o.object_type IN ('TABLE','VIEW','SYNONYM')
    WHERE c.data_type='SDO_GEOMETRY';

La requête est proche de la précédente mais on voit qu'elle se focalise sur toutes les tables de la base. Si vous avez de nombreuses tables réparties dans des schémas distincts, cette requête peut prendre du temps...

Ensuite, suivant le résultat de ces requêtes d'inventaire, QGis va essayer d'en savoir plus sur les couches. Ainsi, pour chaque table retournée par une des requêtes précédentes, QGis va lancer la requête suivante:

    SELECT DISTINCT t."COLONNE_GEOMETRIQUE".SDO_GTYPE,t."COLONNE_GEOMETRIQUE".SDO_SRID
    FROM (SELECT "GEOM" FROM "SCHEMA"."TABLE_A_INTERROGER" WHERE "COLONNE_GEOMETRIQUE" IS NOT NULL AND rownum<=100) t
    WHERE NOT t."COLONNE_GEOMETRIQUE" IS NULL;

Cette requête sélectionne les 100 premières lignes de la table concernée (TABLE_A_INTERROGER dans mon exemple) contenant des géométries et va récupérer deux informations:

  • Le type de géométrie (Point/Multipoint/Lignes/Multilignes). S'il y a plusieurs types géométriques de détectés, il y aura plusieurs couches qui auront le même nom.
  • Le SRID des objets géographiques de la couche.

Si vous avez décoché la case "Utiliser la table de metadonnées estimée", QGis fait la requête sur toute la table, au lieu de sélectionner les 100 premières lignes. Si vous avez des couches volumineuses (par exemple, le bâti du cadastre d'un département), il vaudra mieux activer cette option, sous peine de se retrouver avec des temps de requête très longs.

Enfin, à la suite de cette requête unitaire, s'ajoute une deuxième requête par couche:

    SELECT column_name
    FROM all_tab_columns
    WHERE owner='USER' 
    AND table_name='TABLE_A_INTERROGER' ORDER BY column_id;

Cette table liste les colonnes de la couche concernée. QGis lance cette requête pour récupérer ce qui pourrait faire office de clef primaire.

Pour résumer, lorsque vous vous connectez sur une base Oracle Spatial et que vous demandez un inventaire via le connecteur, QGis va:

  • Récupérer la liste des couches.
  • Déterminer la géométrie de chaque couche.
  • Lister les champs pour déterminer la clef primaire de la table.

En conséquence, afficher la liste des couches peut prendre un temps non négligeable. Ce temps est lié essentiellement au contenu de votre entrepôt de données. Plus vous aurez de tables, plus l'inventaire sera long. Néanmoins, nous verrons par la suite qu'il existe certaines astuces pour faciliter cette étape d'inventaire.

On aurait pu penser que la partie dénommée "Options de recherche" ait une influence sur ce temps d'interrogation du catalogue. En effet, il existe de nombreuses options qui permettent d'affiner la recherche et de n'afficher, par exemple, que les tables qui commencent par 'QGIS_'. Néanmoins, ces options permettent de filtrer les résultats uniquement sur ce qui est retourné par le serveur Oracle, elles n'influencent pas du tout les requêtes sur le catalogue. Qu'on le veuille ou non, le parcours du catalogue prendra du temps...

Ce qui se passe quand QGis ouvre une table Oracle Spatial

Après avoir étudié le comportement du connecteur, il est temps d'observer le comportement de QGis lors de l'ouverture d'une table Oracle. Comme pour le connecteur, une série de requête est lancée pour déterminer le type de données disponibles, le SRID et un tas d'autres détails avant de récupérer les données proprement dites. Voyons cela en détails.

    SELECT user FROM dual;

La première requête lancée est triviale, elle sert à récupérer le nom de l'utilisateur connecté à la base de données. Pour cela, on utilise la table DUAL d'Oracle qui est une table spéciale à une seule colonne qui contient le résultat de notre sélection basique. Dans notre cas, on veut juste le nom de l'utilisateur (user).

    SELECT srid FROM mdsys.all_sdo_geom_metadata WHERE owner='USER' AND table_name='TABLE' AND column_name='GEOM';

La deuxième requête tente de déterminer le SRID de la couche en interrogeant la table de métadonnées d'Oracle Spatial (ALL_SDO_GEOM_METADATA). Dans mon exemple précis, la requête ne renvoit rien, QGis procède à une autre requête pour trouver le SRID.

    SELECT DISTINCT t."GEOM".sdo_gtype FROM "USER"."TABLE" t WHERE rownum<=2;

Cette troisième requête tente de récupérer le type de géométrie de la couche en interrogeant le champ de géométrie des deux premières lignes de la table. A moins d'avoir une table vide, il ne devrait pas y avoir de problème.

    SELECT comments FROM all_tab_comments WHERE owner='USER' AND table_name='TABLE';

Une fois le SRID récupéré, QGis récupère le commentaire de la table. A noter que la table qui est intérrogée pour récupérer les commentaires est ALL_TAB_COMMENTS. Cela signifie que QGis ne peut pas afficher les commentaires présents sur une vue matérialisée. J'ai déposé un rapport de bug dans l'outil de ticketing de QGis dans ce sens, on verra s'il est pris en compte pour la prochaine version.

    SELECT column_name, comments FROM all_col_comments t WHERE t.owner='USER' AND t.table_name='TABLE' AND t.column_name<>'GEOM';

Une fois le commentaire de table récupéré, on récupère les commentaires de chaque champ de la table pour nourrir le commentaire dans l'onglet champs des propriétés de la couche dans QGis.

    SELECT t.column_name,
           CASE WHEN t.data_type_owner IS NULL THEN t.data_type ELSE t.data_type_owner||'.'||t.data_type END,
           t.data_precision,
           t.data_scale,
           t.char_length,
           t.char_used,
           t.data_default
     FROM all_tab_columns t
     WHERE t.owner='USER' AND t.table_name='TABLE' AND t.column_name<>'GEOM'
     ORDER BY t.column_id;

Cette requête d'apparence plus complexe ne fait que récupérer les types de chaque champ. Rien de bien complexe ici...

    SELECT i.index_name, i.domidx_opstatus
    FROM all_indexes i
         JOIN all_ind_columns c ON
         i.owner=c.index_owner
         AND i.index_name=c.index_name
         AND c.column_name='GEOM'
    WHERE i.table_owner='USER'
          AND i.table_name='TABLE'
          AND i.ityp_owner='MDSYS'
          AND i.ityp_name='SPATIAL_INDEX';

La requête ci-dessus s'occupe de récupérer le nom et la validité de l'index spatial de la table. On voit que cet index doit travailler sur la colonne géométrique de la table. Si la table (ou la vue (matérialisée ou non) dispose d'un index spatial, QGis récupère ici son nom.

    SELECT * FROM "USER"."TABLE" WHERE 1=0;

Cette simple requête permet de récupérer les noms des champs pour traitement interne de QGis. C'est à partir de cette requête qui ne renvoie aucune donnée (sauf les noms des champs) que QGis fait l'appariement Nom du champ/Type de données/Commentaire.

    SELECT column_name
    FROM all_ind_columns a
         JOIN all_constraints b
         ON a.index_name=constraint_name
            AND a.index_owner=b.owner
    WHERE b.constraint_type='P'
          AND b.owner='USER'
          AND b.table_name='TABLE';

Une fois les champs obtenus, il reste à récupérer le nom de la clef primaire. Pour cela, on va lister les index de la table qui ont une contrainte de type 'P' (contrainte de clef primaire).

    SELECT 1 FROM all_tables WHERE owner='USER' AND table_name='TABLE';

La requête précédente est toujours dans la boucle de vérification de la clef primaire. Elle est implémentée pour vérifier que la table existe bien.

    SELECT coalesce(auth_name,'EPSG'), auth_srid, wktext FROM mdsys.cs._srs WHERE srid=0;

Après en avoir terminé avec les champs, les index, la clef primaire, QGis détermine le SRID de la couche. Si aucun SRID n'a été renseigné dans la table de métadonnées, c'est le SRID n°0 qui sera utilisé. QGis vérifie comment ce SRID est déclaré dans la table de référence des systèmes de projection d'Oracle. Dans notre cas, aucune donnée ne correspond au SRID 0, donc QGis va poser la question à l'utilisateur final avec une boîte de dialogue dédiée.

    
    SELECT SDO_TUNE.EXTENT_OF('USER.TABLE','GEOM') FROM dual;

Maintenant, c'est la bounding-box qu'on essaye de récupérer. La colonne SDO_TUNE.EXTENT_OF permet de récupérer cette information. On utilise la table dual pour récupérer un seul résultat (seule la colonne est retournée). Cette requête prend forcément du temps car Oracle Spatial va analyser toutes les géométries pour définir le rectangle qui les englobe toutes. On peut avoir des temps de l'ordre de la dizaine de secondes pour une centaine d'objets. Heureusement, cette requête n'est jouée qu'à l'ouverture de la table. Un moyen d'aller plus vite sur ce point est de cocher la case Utiliser la table de metadonnées estimées et de renseigner correctement les informations d'extent dans le catalogue Oracle Spatial (ALL_SDO_GEOM_METADATA). A noter que si vous n'avez pas d'index spatial sur la table, la requête sera différente. Elle utilisera la fonction SDO_AGGR_MBR qui est encore plus longue.

    SELECT "GEOM","CHAMP_1",..., "CHAMP_N"
    FROM "USER"."TABLE" "featureRequest"
    WHERE sdo_filter("GEOM",
                     mdsys.sdo_geometry(2003,
                                        NULL,
                                        NULL,
                                        mdsys.sdo_elem_info_array(1,1003,3),
                                        mdsys.sdo_ordinate_array(288332.71326791675528511,249155.39441391587024555,322322.672240078031318262,262604.34770948911318555)))
          ='TRUE';

Enfin, QGis lance une requête pour récupérer les données dans la bounding-box calculée précedemment. On peut noter que la requête géographique se déroule en utilisant la fonction sdo_filter. Pour faire simple, sdo_filter permet de savoir si deux géométries interagissent au niveau spatial (en gros, si la première est contenue ou touche la seconde). Si c'est le cas, sdo_filter retourne la valeur TRUE, sinon FALSE. SDO_FILTER utilise l'index spatial, quand il est disponible, pour faire la requête ce qui accélère grandement le calcul. Dans notre cas, on ne sélectionne que les géométries de la colonne "GEOM" de la table "TABLE" qui interagissent avec une autre géométrie dont la définition se fait avec la fonction mdsys.sdo_geometry.

Si vous vous referrez à la documentation Oracle, vous pouvez voir que sdo_geometry sert à construire une géométrie donnée, grâce aux éléments suivants:

    sdo_geometry(SDO_GTYPE, SDO_SRID, SDO_POINT(X, Y, Z), SDO_ELEM_INFO, SDO_ORDINATES).
  • SDO_GTYPE indique le type de géométrie. Dans notre cas, on a 2003, 2 pour un objet dans un espace à deux dimensions, 3 pour un polygone.
  • SDO_SRID indique le SRID utilisé, NULL dans notre cas: QGis a normalement déjà calculé les dimensions de la bounding-box dans le bon référentiel.
  • SDO_POINT sert uniquement si l'objet est un point (il en définit les coordonnées). Dans notre cas, on a un polygone donc, la valeur de SDO_POINT est nulle.
  • SDO_ELEM_INFO est un attribut qui permet de gérer le contenu de SDO_ORDINATES:

    • La première valeur indique à quel offset de SDO_ORDINATES le calcul de l'objet doit être fait.
    • La seconde valeur (SDO_ETYPE) précise le type d'objet décrit dans SDO_ORDINATES. Dans notre cas, 1003 correspond à un polygone extérieur.
    • La dernière valeur indique comment on doit interpréter les coordonnées décrites dans SDO_ORDINATE. Dans notre cas, 3 correspond à un objet de type rectangle qui se construit en utilisant seulement deux points.
  • SDO_ORDINATE qui est un tableau de valeurs qui sont les coordonnées à interpréter. Dans notre cas, ce sont les deux points (limite supérieure gauche et limite inférieure droite) qui sont consignés.

Cette requête récupère donc l'ensemble des objets qui sont situés dans le rectangle de l'emprise de la couche.

    SELECT 1 FROM v$option WHERE parameter='Spatial' AND value='TRUE';

Cette requête permet de savoir si Oracle dispose de l'option Spatial. Elle retourne 1 dans le cas positif. La table v$option est une table spéciale d'Oracle qui permet de lister les options d'installation de la base ainsi que les fonctionnalités installées. Cette requête est lancée par QGis pour contrôler que Oracle Spatial est bien installé. Elle est lancée à chaque constituion de la classe d'itération qui sert à QGis en interne pour parcourir les objets géographiques.

Si on résume les étapes d'ouverture d'une couche QGis, on obtient la liste suivante: - On récupère le nom de l'utilisateur. - On tente de retrouver le SRID de la couche via la table de métadonnée Oracle Spatiale. - Si ce n'est pas possible, on interroge les données de la couche pour le déterminer. - On récupère les commentaires de la couche. - On récupère les commentaires des champs de la couche. - On récupère les types de données de chaque champ. - On récupère le nom de l'index spatial s'il existe. - On récupère le nom des champs. - On détermine le champ de clef primaire. - On vérifie l'existence du SRID. - On calcule les coordonnées de l'enveloppe de la couche. - On fait la requête géographique pour récupérer les valeurs des champs et des objets géographiques.

En règle générale, seule la requête de calcul de l'enveloppe de la couche peut prendre du temps. Bien entendu, selon le volume des données à transférer, la requête finale sera plus ou moins longue.

Requêtes lors de manipulations sur une couche

Après avoir abordé la question de l'ouverture d'une couche, il reste à analyser le comportement de QGis lorsqu'on travaille avec une couche. Je vais illustrer les cas d'utilisation suivants:

  • Zoom sur la couche.
  • Sélection d'un objet avec l'outil d'information.
  • Sélection d'un objet avec le requêteur d'expression.
  • Gestion du cache.
  • Affichage des données attributaires.
  • Ajout d'une deuxième couche.

De ce côté, on peut dire que QGis connaît des points à améliorer. Pour commencer, sachez qu'il n'existe aucun mécanisme de cache de données, que ce soit sur Oracle Spatial ou sur PostGIS. Chaque fois que vous vous déplacez dans une couche, QGis lance une requête du même type que celle que j'ai présentée juste au dessus, dans la partie consacrée à l'ouverture d'une couche. Pour information, mon entrepôt de données Oracle Spatial est capable de retourner environ 5000 objets polygonaux à la seconde (avec les attributs également). Mon ressenti est donc basé sur cette expérience.

Pour contrecarrer un peu les performances, sachez que si vous disposez d'un index spatial, QGis utilise la fonction sdo_filter sur l'emprise de la fenêtre carte. Dans le cas contraire, QGis requête toute la table ! Si vous avez une table conséquente, ça peut prendre du temps et même si vous travaillez à une grande échelle, il faudra quand même tout recharger. En conclusion: l'index spatial est un pré-requis pour toute utilisation sérieuse d'Oracle Spatial.

Lorsque vous sélectionnez un objet, QGis va lancer une requête un peu plus intelligente que pour l'affichage global. En effet, il va effectuer un select sur l'emprise de l'objet sélectionner. Ce mécanisme permet d'être sûr de disposer de la dernière version de l'objet avant l'affichage sur l'écran. De plus, il est peu gourmand si vous avez un index spatial puisque la requête porte uniquement sur l'emprise de l'objet considéré. Néanmoins, à la suite de la sélection d'objet, QGis rafraîchi la vue d'ensemble ce qui prend encore du temps !

Mon conseil pour mieux gérer ces temps d'accès est d'utiliser un seuil de zoom pour ne rendre visible la donnée volumineuse que dans une gamme d'échelles pertinentes afin d'éviter d'afficher (et de requêter) trop d'objets à la fois.

Je vous recommande également de ne pas utiliser l'outil de sélection par expression. En effet, ce dernier va demander à rapatrier l'ensemble des données de la couche sur une requête globale qui sélectionne tous les champs , y compris ceux de la géométrie. A la place, je vous suggère de dupliquer la couche dans laquelle vous souhaitez faire une sélection selon un ou plusieurs champs puis d'utiliser l'outil de filtre sur la couche initiale. Vous aurez deux couches: la première contiendra tous les objets de la couche, la seconde, uniquement votre sélection qui sera bien plus rapide à obtenir puisque les résultats seront limités à la clause WHERE de la requête.

De même, quand vous ouvrez la table attributaire, QGis va lancer une requête sur tous les objets de la table (y compris les géométries). Ensuite, il va réaliser autant de requêtes de sélection (sans la géométrie cette fois) pour rapatrier ligne à ligne toutes les lignes visibles de la table attributaire. Ce mécanisme paraît assez absurde: la première requête retourne tout ce qu'il faut, y compris des géométries dont on n'a pas besoin puisqu'on souhaite uniquement avoir les attributs. Tout ceci peut être assez long si vous avez de nombreuses lignes dans votre table. Ce comportement spécifique permet toutefois de ne charger que ce qui est visible dans la fenêtre d'attributs. Si vous n'avez pas encore atteint la fin de cette fenêtre, QGis effectuera une requête sur chaque clef primaire de l'objet qui doit s'afficher dans la table d'attributs. En revanche, une fois que tout le contenu de la table est récupéré (vous avez scrollé jusqu'en bas de la table), QGis ne charge plus rien. Chaque fois que vous sélectionnez un objet dans la table d'attributs, QGis ré-intérroge la table spatiale en utilisant l'emprise de la fenêtre d'affichage des cartes.

Mon conseil pour la gestion de la table attributaire est de modifier le comportement par défaut de QGis qui consiste à afficher les attributs de tous les objets de la table. Pour mieux gérer ce problème, il faut modifier les options de la table attributaire et sélectionner de n'afficher que les objets visibles à l'écran. Cela permettra, combiné au seuil de zoom, de réduire fortement les temps de requête sur le serveur Oracle. De même, vous pouvez également désactiver le rendu de la couche dans la fenêtre carte pour effectuer des opérations de sélection dans la table d'attributs: c'est le rendu des objets sélectionnés qui prend du temps.

Lorsque vous ajoutez une nouvelle couche à votre liste de couches, QGis ré-interroge également toutes les couches pour extraire les objets qui se situent dans l'emprise de la fenêtre.

On comprend aisément que ces mécanismes d'interrogation sans cache mettent les performances de QGis à genoux dès que vous avez un grand nombre d'objets à gérer. Si vous vous trouvez dans un environnement contraint, c'est-à-dire que si le serveur Oracle n'est pas sur votre machine, vous aurez intérêt à développer certains réflexes:

  • Faire en sorte que vous couches disposent toutes d'un index spatial.
  • Ajouter une nouvelle couche uniquement sur un zoom à grande échelle.
  • Sélectionner un objet uniquement sur un zoom à grande échelle.

Il existe une demande d'amélioration qui porte sur le cache mais elle n'a pas été prise en compte malgré l'existence d'un patch. Il faut quand même noter que la prochaine version de QGis a fait un grand pas en avant dans le traitement des couches volumineuses. De plus, QGis v2.4 apportera la gestion multi-threadée des couches (une couche= un thread) ce qui devrait améliorer sensiblement les temps de réponse de l'interface qui ne devrait plus être bloquée pendant le chargement d'une couche comme c'est encore le cas actuellement. Maintenant que toutes les contraintes sur la gestion des couches volumineuses locales est géré, peut-être que les développeurs vont se concentrer à rendre QGis plus frugal avec les serveurs de bases de données spatiales.

Une solution de contournement pourrait consister à utiliser le plugin d'édition offline. Son principe est simple: on stocke le contenu d'une couche distante dans une base de données locale SpatiaLite. Lors de l'édition de cette couche, on peut ensuite resynchroniser avec la couche d'origine, une fois les modifications validées. Néanmoins, ce plugin ne fonctionne plus avec les dernières versions de QGis.

Modifications de données sous Oracle Spatial

Nous avons abordé pour l'instant les performances de lecture pour l'affichage de données issues d'un entrepôt de données sous Oracle Spatial. Mais QGis sert également à modifier des données. Il permet d'ajouter des objets géographiques et également de modifier les attributs d'une table. Il est donc important, pour faire le tour complet de la question de s'interesser également à ces points bien particuliers.

Voici ce que nous allons faire:

  • Créer une couche spatiale vide, dans les règles de l'art.
  • Importer de la données dedans.
  • Modifier un champ attributaire.
  • Ajouter un objet géographique dans QGis.
  • Modifier la structure de la table en modifiant un champ.
  • Modifier la structure de la table en ajoutant/supprimant un champ.
  • Créer un index spatial depuis QGIs.

Création d'une couche

Pour créer une couche dans les règles de l'art de QGis il faut bien prendre en compte les paramètres qui suivent:

  • Mettre des commentaires là où c'est possible.
  • Mettre obligatoire un SRID dans la couche de métadonnées.
  • Mettre obligatoirement les valeurs X et Y de l'emprise dans la couche de métadonnées.
  • Mettre obligatoirement une clef primaire.
  • Créer obligatoirement un index spatial.
  • Ajouter des éléments de contrainte de géométrie.

On obtient un script SQL de ce type:

    DROP TABLE TEST_QGIS_S;

    -- Création de la table:
    CREATE TABLE TEST_QGIS_S (
        GID NUMBER(10),
        DESCRIPTION varchar2(40),
        GEOM MDSYS.SDO_GEOMETRY
    );

    -- Ajout des commentaires
    COMMENT ON TABLE TEST_QGIS_S IS 'Table de test d''écriture pour QGIS';
    COMMENT ON COLUMN TEST_QGIS_S.GID IS 'Clef primaire de base';
    COMMENT ON COLUMN TEST_QGIS_S.DESCRIPTION IS 'Champ de description de l''objet';

    -- Contrainte de clef primaire
    ALTER TABLE TEST_QGIS_S ADD CONSTRAINT TEST_QGIS_S_IDX PRIMARY KEY (GID) USING INDEX TABLESPACE MMET_INDEX;

    -- Contrainte de Géométrie: Polygone
    ALTER TABLE TEST_QGIS_S ADD CONSTRAINT TEST_QGIS_S_GCHECK CHECK ( GEOM.SDO_GTYPE = 2003 );

    -- Enregistrement table de métadonnée Oracle Spatial
    DELETE FROM USER_SDO_GEOM_METADATA WHERE TABLE_NAME = 'TEST_QGIS_S';
    INSERT INTO USER_SDO_GEOM_METADATA ( TABLE_NAME, COLUMN_NAME, DIMINFO, SRID )
        VALUES ('TEST_QGIS_S', 'GEOM', MDSYS.SDO_DIM_ARRAY( MDSYS.SDO_DIM_ELEMENT('X',276000,322000,0.005), MDSYS.SDO_DIM_ELEMENT('Y',239000,271000,0.005)),27562);

    -- Création de l'index spatial
    DROP INDEX GI_TEST_QGIS_S FORCE;

    CREATE INDEX GI_TEST_QGIS_S ON TEST_QGIS_S(GEOM)
        INDEXTYPE IS MDSYS.SPATIAL_INDEX
        PARAMETERS ('layer_gtype=POLYGON TABLESPACE=USER_INDEX SDO_DML_BATCH_SIZE=1');

    COMMIT;

Il existe maintenant une couche TEST_QGIS_S de disponible pour l'édition. Première attention, si vous avez coché la case Seulement les types de géométrie existants dans la configuration du connecteur Oracle de QGis, votre couche n'apparaîtra pas. En effet, QGis lance une requête sur le contenu de la couche pour déterminer le type de géométrie. Si l'option est activée, QGis est plus strict en ce qui concerne la géométrie et n'affiche pas la couche dans la liste. Si la case est décochée, QGis vous propose de sélectionner le type de données. Dans notre cas, nous allons choisir le type Polygone.

Par ailleurs, si vous utilisez l'onglet Parcourir ou le panneau de navigation, QGis ne vous listera pas la couche. Pour ouvrir une couche géographique Oracle Spatial vide, il faut impérativement passer par la boîte de dialogue du connecteur Oracle. Autre effet de bord, vous serez également obligé d'indiquer manuellement le SRID de la couche car celui-ci est récupéré via une requête qui scanne les objets géographiques de la couche. Ce comportement est typiquement un bug que j'ai remonté.

Mode d'édition

Maintenant que nous disposons d'une couche vide, il faut la remplir.

Voyons maintenant le cas de la modification d'un objet dans la couche. Le constat est assez intéressant: dès qu'on active le mode d'édition, QGis va jouer systèmatiquement deux requêtes en préalable à toute action (mise à jour de l'emprise de la fenêtre carte, modification d'un objet, déplacement d'un point d'un polygone, sélection d'un objet, suppression d'un objet, modification du contenu d'un attribut, etc.):

  • D'abord, on constate que QGis rappatrie des objets à l'aide d'une requêt globale: SELECT "GEOM","GID","DESCRIPTION" FROM "MMET"."TEST_QGIS_S" "featureRequest"; Cette requête peut faire peur car elle est censée tout rapatrier. Néanmoins, QGis limite ce qui est récupéré à ce qui lui est utile. J'ai pu constater que le contenu de cette requête est digéré par QGis selon le zoom (et le nombre d'objets qui sont présents). Je ne suis pas parvenu à voir comment QGis faisait pour gérer ce qu'il rapatrie et quand il décide de mettre fin à la requête. Dans tous les cas, le volume de données retourné par la requête est fonction du zoom.
  • Ensuite, QGis rafraîchit son affichage et il relance une requête tenant compte de l'index: SELECT "GEOM","GID","DESCRIPTION" FROM "MMET"."TEST_QGIS_S" "featureRequest" WHERE sdo_filter("GEOM",mdsys.sdo_geometry(2003,27572,NULL,mdsys.sdo_elem_info_array(1,1003,3),mdsys.sdo_ordinate_array(302522.88477424852317199,256408.67948694800725207,305644.892545554557116702,258634.18810979597037658)))='TRUE';

La deuxième requête est contenue car elle ne concerne que l'emprise de la fenêtre carte. La première est gérée par QGis qui l'arrête en cas de besoin. Dans la pratique, le poids des deux requêtes est assez proche. Ce qui fait qu'on peut dire que les temps de réponse en mode édition sont globalement deux fois plus longs que ceux du mode de consultation.

Sélection d'un objet à éditer

Lorsqu'on sélectionne un objet, QGis effectue une requête spécifique qui rapatrie uniquement tous les attributs (y compris géométrique) de l'objet. Cette requête est suivie du rafraîchissement de la fenêtre carte. En mode édition, la sélection d'un objet ajoute une requête de plus: la requête globale maîtrisée par QGis. Concrètement, en mode édition, le temps de sélection est deux fois plus long que le temps en mode consultation.

Je vous conseille, pour les couches volumineuses, de travailler à une échelle adaptée qui limitera le nombre d'objets affichés en même temps.

Suppression d'un objet

Lorsqu'on supprime un objet, QGis lance une requête de rafraichissement de l'écran qui conduit à rejouer nos deux requêtes du mode édition. Ces deux requêtes s'ajoutent à la sélection de votre objet que vous avez du effectuer avant de lancer sa suppression. Pour propager la suppression, il faut soit sortir du mode d'édition, soit enregistrer les changements. Cette propagation se traduit par une requête DELETE (très légère), suivie d'un autre rafraîchissement (qui lui est forcément plus long).

Globalement, on peut dire que si on travaille avec un nombre d'objets raisonnable, les temps de réponse sont bons (<1 seconde pour 5000 objets visibles dans mon cas).

Déplacement des noeuds

Dès qu'on déplace un noeud d'un objet, QGis rafraîchit l'affichage et lance les deux requêtes du mode édition. Là encore, même pour cette opération simple, il faudra travailler à une échelle raisonnable.

Modification d'un objet

Comme dans le cas de la suppression, QGis ne va lancer les requêtes de modification des lignes de la table concernée (via un UPDATE) que lorsqu'on quitte le mode édition ou qu'on lance une sauvegarde de ses modifications (l'icône en forme de disquette). On peut distinguer deux types de modification:

  • Modification de la géométrie.
  • Modification uniquement des attributs.

Sur ce point, QGis se révèle assez intelligent: suivant l'un ou l'autre des cas, il n'effectue les requêtes d'UPDATE que sur les champs qui ont été modifiés. Si vous avez modifié deux attributs sur cinq, il y aura une seule requête UPDATE qui s'occupera de mettre à jour les deux attributs modifiés. Si vous avez modifié la géométrique, une requête UPDATE sera jouée sur le champ de géométrie de l'objet. Si vous avez modifiés attributs et géométrie, il y aura deux requêtes UPDATE, l'une pour les attributs, l'autre pour la géométrie.

Bien sûr, cette action entraîne le rafraîchissement de la couche dans QGis...

Modification de la structure de la couche

Si vous avez les droits suffisants sur la couche, QGis permet de changer la structure de la couche, dans les limites permises par l'outil. On ne peut pas modifier le nom ou le type d'un champ déjà existant. En revanche, il est possible à la fois de supprimer n'importe quel champ et également d'une ajouter un ou plusieurs. Cela peut être assez pratique pour caler votre modélisation et ajouter un champ rapidement pour y injecter des données. Sur ce point, QGis lance des requêtes ALTER TABLE. On peut indiquer des commentaires sur le champ et QGis les intègre directement au niveau Oracle Spatial.

Néanmoins, pour que vos changements soient propagés dans la couche sous QGis, il faudra l'ouvrir à nouveau manuellement.

En termes de bugs, j'ai juste pu noter que l'inclusion des champs VARCHAR2 et CHAR ne fonctionnait pas bien. En dehors de ça, pas de surprises...

Conclusion

D'une manière générale, le coeur du problème du comportement de QGis en édition est constitué par les deux requêtes qui sont lancées dès qu'une opération de rafraîchissement est lancée. Or, QGis lance souvent des requêtes de rafraichissement; en fait dès qu'une intervention graphique sur la fenêtre de carte a lieu. Le poids de ce rafraichissement est deux fois plus lourd qu'en mode consultation. Il faut donc tenir compte de cette contrainte supplémentaire pour travailler à des échelles n'affichant qu'un nombre raisonnable d'objets.

Au delà de cette contrainte, QGis donne toute satisfaction en édition. Il est capable de faire toutes les opérations de modification de géométrie et d'attributs dans des conditions correctes et efficaces.

Pour ma part, dans ma configuration de travail, je ramène environ 5000 objets par seconde (pour du parcellaire cadastral) à une échelle de 1/10000. Mon échelle de travail sur cette couche ne doit donc pas dépasser 1/15000 sous peine d'avoir des temps de latence trop longs et incompatible avec des conditions de travail. 1/15000 est une échelle assez large pour travailler sur du cadastre. Je ne serai donc pas obligé de trop zoomer pour pouvoir travailler correctement sur cette couche.

Analyse rapide du comportement de QGis avec une base PostGIS

Après avoir étudié le comportement de QGis avec Oracle, il convient, pour avoir de bons éléments de comparaison, de faire la même étude mais avec le connecteur PostGIS. Ce dernier est en effet implémenté depuis plus longtemps et on peut donc penser qu'il sera plus complet et qu'il sera plus performant. Mais encore faut-il le vérifier. C'est l'objet de ce passage.

Requêtes à l'ouverture du connecteur PostGIS

Lors du lancement du connecteur PostGIS, des requêtes sont lancées pour obtenir la liste des couches.

Voici la première:

     SELECT l.f_table_name,l.f_table_schema,l.f_geometry_column,upper(l.type),l.srid,c.relkind
     FROM geometry_columns l,pg_class c,pg_namespace n
     WHERE c.relname=l.f_table_name
       AND l.f_table_schema=n.nspname
       AND n.oid=c.relnamespace
       AND has_schema_privilege(n.nspname,'usage')
       AND has_table_privilege('"'||n.nspname||'"."'||c.relname||'"','select')
     ORDER BY n.nspname,c.relname,l.f_geometry_column

Cette requête permet de lister les tables géographiques listées dans la vue geometry_columns. Pour savoir ce que l'utilisateur qui fait la connexion a le droit d'ouvrir, on utilise quelques fonctions, notamme has_table_privilege. Par ailleurs, cette requête utilise également le catalogue pg_class qui permet de déterminer le type de table: table normale, vue, vue matérialisée, etc. Elle est en général assez rapide à effectuer.

Ensuite, pour chaque table, QGis va lancer une requête permettant de lister les attributs:

     SELECT attname,
            CASE WHEN typname = ANY(ARRAY['geometry','geography','topogeometry']) THEN 1 ELSE null END AS isSpatial
     FROM pg_attribute
       JOIN pg_type ON atttypid=pg_type.oid
     WHERE attrelid=regclass('"schema"."TABLE_GEOGRAPHIQUE"')

Cela permet de lister les attributs de chaque table et de déterminer la colonne spatiale. En règle générale, c'est une requête assez rapide même si dans certains cas, il faudra la multiplier par le nombre de tables dans l'entrepôt de données géographiques.

Une fois la liste des attributs récupérée, QGis lance une autre requête globale:

     SELECT l.f_table_name,l.f_table_schema,l.f_geography_column,upper(l.type),l.srid,c.relkind
     FROM geography_columns l,pg_class c,pg_namespace n
     WHERE c.relname=l.f_table_name
       AND l.f_table_schema=n.nspname
       AND n.oid=c.relnamespace
       AND has_schema_privilege(n.nspname,'usage')
       AND has_table_privilege('"'||n.nspname||'"."'||c.relname||'"','select')
     ORDER BY n.nspname,c.relname,l.f_geography_column

Et non, ce n'est pas la même que la première. En effet, celle-ci interroge la vue geography_columns au lieu de geometry_columns. La différence tient au nouveau type de données géographiques que PostGIS a mis en oeuvre. Le premier type se dénomme geometry. Pour ces objets, la plus courte distante est exprimée sur un plan. Donc le chemin le plus court entre deux objets "geometry" est une droite. Pour les objets "geography", c'est un arc de cercle car dans ce mode, la plus courte distance est exprimée sur une sphère. Ces objets sont utilisés pour avoir plus de précision par rapport aux calculs réalisés.

Ensuite, c'est le même topo, pour chacune de ces couches, on va récupérer la liste des attributs.

Enfin, QGis fait une requête pour lister les rasters et pour chaque raster on récupère les attributs.

Requêtes à l'ouverture d'une couche PostGIS

Lorsqu'on ouvre une couche, QGis va lancer une série de requêtes sur cette dernière dont voici la première:

    SELECT * FROM "schema"."TABLE_GEOGRAPHIQUE" LIMIT 1

Elle permet de récupérer la liste des attributs. Vient ensuite une petite vérification:

    SELECT pg_is_in_recovery()

Elle permet de se renseigner sur l'état du serveur PostgreSQL et de savoir si il est en opération de recovery.

On poursuit avec la gestion des droits de l'utilisateur:

     SELECT has_table_privilege('"schema"."TABLE_GEOGRAPHIQUE"','DELETE'),
            has_any_column_privilege('"schema"."TABLE_GEOGRAPHIQUE"','UPDATE'),
            has_column_privilege('"schema"."TABLE_GEOGRAPHIQUE"','geom','UPDATE'),
            has_table_privilege('"schema"."TABLE_GEOGRAPHIQUE"','INSERT'),
            current_schema()

Cette requête essaye de voir si on a le droit de modifier, d'ajouter ou de supprimer des données sur la table sélectionnée (TABLE_GEOGRAPHIQUE dans l'exemple).

Vient ensuite une série de requêtes qui tentent d'en savoir un peu plus sur la couche. On commence par récupérer l'oid de la table (dans l'exemple il vaudra 17734):

     SELECT regclass('"public"."CADASTRE_PARCELLE_S"')::oid;

Muni de cet oid, on récupère la description de la table (le commentaire):

     SELECT description FROM pg_description WHERE objoid=17734 AND objsubid=0;

Ensuite, QGis relance une lecture du nom des attributs de la table:

     SELECT * FROM "schema"."TABLE_GEOGRAPHIQUE" LIMIT 0

]

Maintenant, pour chaque attribut, QGis va lancer une série de 3 requêtes:

     SELECT typname,typtype,typelem,typlen FROM pg_type WHERE oid=23
     SELECT attnum,pg_catalog.format_type(atttypid,atttypmod) FROM pg_attribute WHERE attrelid=17734 AND attname='ATTRIBUT1'
     SELECT description FROM pg_description WHERE objoid=17734 AND objsubid=1

La première va permettre de déterminer le type PostgreSQL supposé de l'attribut tel que QGis l'a repéré. Par exemple, l'oid 23 de pg_type correspond à un type int4. QGis connaît les types des attributs grâce aux requêtes précédentes. La deuxième requête permet de déterminer le numéro d'attribut (dans l'ordre de la création de la table) ainsi que son type PostgreSQL. Enfin, on récupère le commentaire du champ.

On va maintenant déterminer si la couche dispose d'un index de clef primaire et on va récupérer son oid:

     SELECT indexrelid
     FROM pg_index
     WHERE indrelid='"schema"."TABLE_GEOGRAPHIQUE"'::regclass
       AND (indisprimary OR indisunique)
     ORDER BY CASE WHEN indisprimary THEN 1 ELSE 2 END
     LIMIT 1

Muni de cet index, QGis demande sur quel attribut il s'applique permettant de déterminer la clef primaire:

     SELECT attname FROM pg_index,pg_attribute WHERE indexrelid=17738 AND indrelid=attrelid AND pg_attribute.attnum=any(pg_index.indkey)

Ensuite, QGis essaye de déterminer s'il existe bien une seule colonne géométrique:

     SELECT count(*) FROM pg_stats WHERE schemaname='schema' AND tablename='TABLE_GEOGRAPHIQUE' AND attname='geom'

Par la suite, QGis détermine le nombre d'objets géographiques de la couche via la requête suivante:

     SELECT reltuples::int FROM pg_catalog.pg_class WHERE oid=regclass('"schema"."TABLE_GEOGRAPHIQUE"')::oid

Maintenant qu'on dispose des métadonnées de la couche, de sa clef primaire, de sa colonne géométrique, il faut calculer la bounding-box. QGis tente de le faire avec deux modes:

     SELECT st_estimatedextent('schema','TABLE_GEOGRAPHIQUE','geom')

Cette première requête utilise la fonction PostGIS d'estimation de la bouding-box de la couche. Cette estimation est calculées à chaque opération de VACCUUM. S'il n'y a rien de disponible, QGis calcule la bouding-box de manière traditionnelle:

     SELECT st_extent("geom") FROM "schema"."TABLE_GEOGRAPHIQUE"

Maintenant, nous avons tout ce qu'il faut pour rapatrier les données. QGis va le faire avec un curseur.

     DECLARE qgisf0_0 BINARY CURSOR FOR
       SELECT st_asbinary(st_snaptogrid("geom",28.3257),'NDR'),
              "id"
       FROM "schema"."TABLE_GEOGRAPHIQUE"
       WHERE "geom" && st_makeenvelope(276970.7717500000144355,236370.84200633913860656,321654.49825000000419095,274043.9679936608299613,27562)

Ici, on déclare un curseur nommé qgisf0_0 qui va se nourrir d'une requête qui récupère la géométrie (en binaire) ainsi que l'attribut id des objets qui intersectent (opérateur &&) l'emprise courante. Ensuite, QGis "fetch" par paquet de 2000:

     FETCH FORWARD 2000 FROM qgisf0_0

Quand QGis a fini, il ferme le curseur:

     CLOSE qgisf0_0

Pour résumer, lorsque QGis ouvre une couche, on a les étapes suivantes:

  • Sélection de la première ligne de la table
  • Etat de PostgreSQL
  • Récupération des droits de l'utilisateur sur la table
  • Récupération de l'oid de la table pour...
  • ...pouvoir récupérer les commentaires de la table (visibles dans l'onglet métadonnées de la couche dans QGis).
  • Récupération des noms de colonnes.
  • Pour chaque attribut, on récupère:
    • la définition de son type présumé
    • son ordre dans la table et son type avéré
    • les commentaires de la colonne.
  • Récupération de la clef primaire
  • Vérification de la colonne géométrique
  • Détermination du nombre d'objets de la couche
  • Récupération de la bounding-box (avec deux techniques)
  • Ouverture d'un curseur
  • Récupération des données géométrique et de clef primaire (donc pas la totalité des données) par paquets de 2000.
  • Fermeture du curseur.

La seule requête lourde est celle de la fin (avec le curseur). Néanmoins, seuls les données utiles sont rapatriées: la géométrie et la clef primaire. Le calcul de la bounding-box peut également prendre du temps par la commande st_extent. Il vaut donc mieux effectuer des VACUUMS régulièrement pour que cette emprise soit recalculée lors de cette opération de maintenance et non à chaque ouverture de la couche.

Déplacement et sélection dans une couche PostGIS

Lorsqu'on se déplace dans une couche PostGIS, QGis déclare un curseur avec la méthode présentée au dessus. A chaque fois que la fenêtre d'extent se modifie, un nouveau curseur est défini et les données sont à nouveau rapatriées.

Il n'y a pas de mécanisme de cache mais comparativement à ce qui se passe avec le connecteur Oracle, QGis récupère uniquement ce qui est utile: les géométries et la clef primaire. Les autres attributs ne sont pas concernés. Ce type de comportement se déclenche à chaque refresh de la fenêtre.

Lorsqu'on réalise une interrogation d'objet, la requête est un peu modifiée:

     DECLARE qgisf1_8 BINARY CURSOR FOR
       SELECT st_asbinary("geom",'NDR'),
              "ATTRIBUT1",
              "ATTRIBUT2",
              ...
              "ATTRIBUTN"
      FROM "schema"."TABLE_GEOGRAPHIQUE"
      WHERE "geom" && st_makeenvelope(296689.76752788928570226,256669.62860945626744069,296757.21800251881359145,256737.07908408585353754,27562)
            AND st_intersects("geom",st_makeenvelope(296689.76752788928570226,256669.62860945626744069,296757.21800251881359145,256737.07908408585353754,27562))

Elle récupère les attributs et restreint la sélection à l'objet qui se trouve dans l'emprise que QGis connaît de l'objet sélectionné. Il n'y a pas de refresh.

Lorsqu'on effectue une simple sélection, le comportement est le même que le refresh sauf qu'on restreint la requête à l'emprise de l'objet sélectionné. Après chaque sélection, il y a un refresh ce qui peut être long si vous avez beaucoup d'objets affichés en même temps.

La sélection par expression affiche les mêmes timings catastrophiques que sous Oracle. En effet, tous les objets sont alors rapatriés et la sélection se fait par les mécanismes internes de QGis.

Enfin, lorsqu'on demande le décompte des entités, QGis effectue une sélection sur tous les objets mais ne rappatrie que les attributs non géométriques ce qui limite le volume récolté (mais qui est loin d'être une requête du type count).

Ouverture de la table attributaire sous PostGIS

On procède de la même méthode du curseur que pour les sélections d'objet sauf qu'il n'y a plus d'emprise et que tous les attributs sont récupérés y compris la géométrie (à quoi bon ?). En conséquence, QGis récupère toutes les données. Ensuite, pour chaque ligne visible, QGis va lancer une requête de sélection limitée à la clef primaire de l'objet qui doit être affiché:

     DECLARE qgisf1_8 BINARY CURSOR FOR
       SELECT st_asbinary("geom",'NDR'),
              "ATTRIBUT1",
              "ATTRIBUT2",
              ...
              "ATTRIBUTN"
      FROM "schema"."TABLE_GEOGRAPHIQUE"
      WHERE "ATTRIBUT1"=123456

Néanmoins, il y a un mécanisme de cache: QGis ne récupère que les lignes qui n'ont pas encore été affichées. Le nombre de lignes en cache est limité à 100000. Par rapport, à Oracle, il n'y a pas vraiment de différences et on constate le même comportement bizarre de récupération de la géométrie.

Mode édition avec une couche PostGIS

On retrouve quasiment le même comportement qu'avec Oracle. Lors du passage en mode édition, QGis ajoute une requête globale qu'il gère comme un grand. Cette requête globale n'a aucun poids: le curseur est déclaré mais aucun Fetch n'est lancé et le curseur est fermé. A chaque sélection d'un objet, QGis requête l'objet comme pour une sélection et fait un refresh.

Lorsqu'on déplace un noeud, QGis réalise juste un refresh. En fonction du nombre d'objets affichés, ce sera plus ou moins long.

Attention, lorsque vous avez sélectionné le décompte des entités de la couche, le comportement de QGis pose problème. En effet, à chaque modification, ce dernier récupère la totalité des champs attributaires de toute la table, puis il fait un refresh ! En conséquence, la moindre modification d'objets sur une table volumineuse prend beaucoup de temps. Ce comportement me semble relever d'un bug !

Conclusion

Il manque encore des choses dans QGis pour faire aussi bien que ce qui existe avec PostGIS. Par exemple, le gestionnaire de base de données ne gère pas les bases Oracle. Impossible de créer des tables à la volée, de faire des requêtes intermédiaires. Je pense qu'il ne manque pas grand chose pour intégrer ce type de SGBDRS(patial) dans cet outil qui me semble incontournable pour QGis.

Oracle Spatial VS Local SpatiaLite VS MapInfo VS MapInfo Network

Pendant que nous sommes en train d'étudier le comportement du connecteur Oracle, parlons un bref moment de performances...

J'ai importé dans SpatiaLite, (dans une base de données sur un disque local), une couche Oracle Spatial de plus de 200000 objets polygoniaux (du cadastre). L'import prend près de 5 minutes mais reste mesuré en termes de consommation mémoire, si l'on compare avec un import par copier/coller des entités depuis Qgis. A partir de cette couche, j'ai généré une couche au format MapInfo TAB (l'export dure environ 30 secondes). J'ai dupliqué cette couche MapInfo sur un espace réseau sur du LAN à 100Mbits/s juste pour voir...

Je me suis amusé à mesurer les temps d'affichage à différents seuils de zoom en croisant avec les temps de MapInfo comparé à ceux de QGis. Toute est résumé dans le tableau suivant:

Echelle QGis fichier TAB local MapInfo fichier TAB local QGis fichier TAB réseau MapInfo fichier TAB réseau QGis Spatialite local Qgis Oracle Spatial MapInfo Oracle Spatial
1/10000 2s 1s 6s 5s 2s 2s 5s
1/25000 4s 2s 37s 11s 4s 7s 11s
1/50000 7s 3s 66s 20s 8s 17s 27s
1/100000 9s 3s 72s 22s 10s 25s 45s
1/250000 9s 3s 72s 22s 10s 25s 45s

L'analyse du tableau révèle les éléments suivants:

  • D'abord, au niveau de la connexion Oracle Spatial, QGis est deux fois plus performant (globalement) que MapInfo 10 (sans son mécanisme de cache).
  • Ensuite, QGis connecté à Oracle Spatial a environ les mêmes performances que MapInfo connecté à un lecteur réseau.
  • Spatialite en local est bien meilleur, il est environ deux fois plus performant qu'avec Oracle Spatial.
  • MapInfo sait bien gérer ses accès réseau. Le mécanisme d'accès à la donnée au sein des fichiers .DAT et .MAP est sans doute mieux codé que la bibliothèque GDAL qui offre l'ouverture des fichiers TAB dans QGis.
  • Dès qu'on revient à un stockage local des fichiers TAB, les performances de QGis redeviennent honnêtes.

Ce test met en oeuvre une couche lourde à gérer. Le nombre d'objets est assez important et les échelles utilisées permettent d'afficher un grand nombre de ces objets en même temps.

La tableau ci-dessous fait un petit comparatif des temps d'ouverture avec des rasters (ECW de 170Mo et GeoTiff de 550Mo):

Echelle MapInfo ECW réseau QGis ECW réseau
1/10000 2s 3s
1/25000 3s 3s
1/50000 3s 3s
1/100000 3s 3s
1/250000 3s 3s

Pour les dalles ECW, il n'y a pas de problème de temps d'ouverture. On voit que MapInfo comme QGis lisent uniquement ce qui leur est utile et affichent les données directement. Je n'ai donc pas effectué de test en local tellement les temps d'accès sont faibles. Pour d'autres formats, les chiffres peuvent être un peu différents. C'est le cas notamment du format GéoTiff comme le montrent les chiffres du tableau ci-dessous:

Echelle MapInfo GeoTiff local QGis GeoTiff local MapInfo GeoTiff réseau QGis GeoTiff réseau
1/1500 1s 2s 5s 6s
1/2500 1s 2s 7s 8s
1/5000 1s 2s 14s 15s
1/10000 2s 3s 28s 28s

On voit que les temps d'accès aux fichiers rasters sont du même ordre de grandeur entre QGis 2.2 et MapInfo 10. Au niveau accès réseau, le temps de 28 secondes correspond à la moitié du temps pour ouvrir la dalle raster en totalité, c'est à dire qu'il faut que QGis (ou MapInfo) rappatrie l'intégralité du fichier. Copier le fichier avec l'explorateur de fichiers depuis cet emplacement réseau donne un temps de lecture deux fois plus long. Pour pallier à ce problème, deux solutions s'offrent à nous:

  • Réduire la taille du fichier en utilisant les bonnes options de compression.
  • Utiliser le système des pyramides pour présenter des vues intermédiaires.

QGis propose une boite de dialogue assez performante pour générer des GéoTiffs bien compressés avec pyramides. On y accède en faisant un clic-droit sur la couche raster et faisant sauvegarde sous.... J'ai réalisé un test de compression sur le fichier raster cité en exemple et j'arrive à le compresser à 240 Mo (soit deux fois moins que le fichier d'origine sans perte liée à la compression) en ayant un temps d'ouverture quasi-constant, situé à 6 secondes comparé aux 28 secondes intiales.

Un peu d'historique sur le développement d'Oracle dans QGis

Histoire d'être un peu complet sur le sujet, faisons un tour du développement de la connexion vers Oracle Spatial dans QGis. C'est Jürgen Fischer qui s'occupe de ce développement. Le code de QGis étant gérer sur la plate-forme GitHub, il est facile d'avoir le déroulé des évolutions en consultant l'historique. Le premier commit date de janvier 2013. Ca fait donc près d'un an et demi que le code de QGis contient de quoi se connecter à Oracle Spatial.

Très rapidement, quelques corrections ont eu lieu. On peut citer dans l'ordre:

  • Une meilleure gestion des systèmes de projection par l'ajout de certains SRID dans la table de référence Oracle (sdo_coord_ref_system) lors de la création d'une nouvelle couche vide si le système n'est pas connu dans cette même table (commit).
  • Une meilleure gestion des tables sans géométrie récupérées depuis Oracle (commit).
  • L'accélération du calcul de la bounding-box de la couche si vous avez coché la case "Utiliser la table de métadonnées estimées", à la condition que les champs de définition des dimensions soient bien définis (voir dans le point consacré aux conseils de catalogage)(commit).
  • Une meilleure gestion des champs date et time dans Oracle (commit).
  • Réécriture des requêtes de listing des champs d'une table avec une bien meilleure rapidité (commit).

Dans les dernières améliorations, en nouveauté pour la version 2.4, on peut noter la mise en cache de la liste des couches d'une base Oracle. Sur de gros entrepôts de données, l'interrogation de la liste des couches hébergées peut prendre plusieurs minutes. Cette interrogation se fait quasiment à chaque fois que vous ouvrez le connecteur QGis. C'est particulièrement pénible si vous avez oublié d'ajouter une couche à votre projet. Pour éviter ce problème, ce commit permet de stocker le résultat de l'interrogation d'une connexion Oracle Spatial dans un cache local de QGis qui se trouve être une base de données QSLite dédiée. C'est une idée plutôt opérationnelle car attendre indéfiniment le rechargement de la liste des tables d'une base volumineuse est assez pénible dans la pratique. En annexe à cette possibilité, on trouve l'ajout d'une case à cocher pour que la boîte de dialogue du connecteur Oracle reste ouverte après avoir appuyé sur le bouton "Ajout".

Enfin, une bonne partie des bugs que j'ai remonté sont déjà corrigés dans le code et devraient impacter la version 2.4 de QGis qui devrait ouvrir les couches un peu plus rapidement qu'avant. De ce côté, on peut dire que le développement est vraiment actif.

Quelques conseils sur le catalogage dans Oracle Spatial

Le premier conseil que je peux vous donner est de vous assurer que la table de métadonnées Oracle (ALL_SDO_GEOM_METADATA) est bien à jour et que notamment, elle ne référence pas des tables qui n'existent plus. Dans le cas contraire, QGis va générer un message d'erreur et faire des requêtes intermédiaires inutiles. De plus, chaque requête qui échoue déclenche l'écriture (au moins en mémoire) d'un log de problème et qui viendra gonfler la table des erreurs de QGis qu'on peut consulter via le bouton adéquat.

Ensuite, il serait bon de penser à ajouter systématiquement le SRID (système de projection) dans ALL_SDO_GEOM_METADATA. Si ce n'est pas le cas, QGis interroge les objets géométriques de la couche pour le déterminer. Cette seconde requête ne fonctionne que si la couche n'est pas vide. Si une couche ne dispose pas de SRID, QGis demande impérativement qu'un SRID soit attribué à la couche et ouvre une boîte de dialogue adéquate. Si vous avez 20 couches à ouvrir, vous aurez 20 fois la question... Quelle perte de temps ! De plus, si vous avez plusieurs systèmes de projection, ça peut vite devenir complexe de gérer manuellement à la couche. Dans les bonnes pratiques de stockage de la donnée géographique, assurez-vous de mettre TOUT LE TEMPS le SRID dans la table de métadonnée (que ce soit pour Oracle ou PostGIS d'ailleurs).

Par ailleurs, veillez également à mettre des données qui sont toutes du même type géométrique dans une table. Les bonnes pratiques de stockage de l'information géographique recommandent de ne pas mixer les types de données et de disposer de couches mono-type, et ce même si la majorité des logiciels de SIG savent gérer une couche avec des types de géométries multiples. Mais, à priori, ce n'est pas le cas de QGis. Si vous avez des données ponctuelles dans une couche qui contient également des lignes, QGis va vous demander de choisir. Au niveau du connecteur Oracle, lorsqu'une couche contient plus d'un type de géométrie, QGis affiche deux couches au nom identique mais avec un type différent. Cela peut être facilement destabilisateur pour l'utilisateur final (quelle est la bonne couche ?). Je vous renvoie au point "Sous le capot" qui fait mention de la requête de détermination du type de géométrie. De ce côté, PostGIS est plus contraignant par défaut car en règle générale, lors de la création d'une couche géographique, on indique toujours une contrainte sur le type de géométrie de la couche.

En regardant le code du connecteur Oracle on peut constater que QGis peut récupérer les commentaires des noms de champs de la couche ouverte, il me paraît donc intéressant de les rajouter directement dans la table afin que les utilisateurs de la couche puissent avoir plus d'informations sur les attributs de cette dernière. D'ailleurs, si vous avez les droits d'écriture, QGis permet d'ajouter des commentaires aux champs que vous créez (QGis ne peut pas modifier les champs actuels).

N'hesitez pas à créer des index spatiaux sur vos couches. Lorsque vous ouvrez une couche avec QGis, celui-ci indique le message d'avertissement suivant: "No spatial index on column USER.TABLE.COLONNE_GEOMETRIQUE found - expect poor performance.". C'est vraiment ce qui va arriver car sans index spatial, QGis ramène la couche complète avec tous les objets ! Donc, une utilisation sérieuse d'un entrepôt de données Oracle Spatial impose de créer un index spatial. Franchement, c'est loin d'être complexe, il y a juste une ligne de SQL à rajouter pour améliorer fortement les performances de QGis... Pourquoi s'en priver ?

Un point important si vous souhaitez calculer rapidement l'emprise des couches est de bien renseigner vos champs de SDO_DIM_ELEMENT dans la table ALL_SDO_GEOM_METADATA. Pour que QGis puisse les prendre en compte, il faut qu'ils soient nommés respectivement 'X' et 'Y'. Sinon la requête de calcul du boudning-box se fera en mode lent: compter environ 10 secondes pour une couche avec une centaine d'objet (estimation non linéaire qui dépend de vos géométries). Pensez également à cocher la case Utiliser la table de métadonnées estimées pour vous servir de l'emprise déclarée dans ALL_SDO_GEOM_METADATA. Une fois ces pré-requis établis, l'ouverture d'une couche dans QGis devrait prendre beaucoup moins de temps car la requête de calcul d'emprise qui peut être très longue ne sera pas lancée.

Conclusion

Ce tour d'horizon du connecteur Oracle Spatial montre bien que QGis est prêt à utiliser des bases de données relationnelles spatiales avec ce type de technologie. Certes le passé de développement du connecteur est beaucoup moins long que celui de PostGIS mais l'essentiel est là. Vous devez néanmoins prendre des précautions quand vous élaborez votre entrepôt de données, notamment avec la gestion de la table de métadonnées et des SRID ainsi que des index spatiaux. Mais globalement ces conseils font partie des bonnes pratiques de gestion d'un entrepôt de données géographiques.

Comme avec le connecteur PostGIS, le connecteur Oracle Spatial souffre un peu du comportement de QGis qui fait souvent deux fois la même requête pour rien, c'est notamment le cas lors de l'affichage de la table attributaire. Une bonne remise à plat de ces tentatives de connexion pour rendre QGis plus frugal sur le plan du requêtage serait un bon travail sachant que le niveau de technique pour la gestion des couches volumineuses est maintenant achevé. Enfin, l'ajout d'une gestion de cache permettrait de minimiser les appels au serveur de base de données avec à la clef, un travail plus facile sur des couches complexes ou disposant de nombreux objets.

Pour terminer et être tout à fait complet sur le sujet, il manque encore au moins un élément dans QGis pour faire aussi bien que ce qui existe avec PostGIS. En effet, le gestionnaire de base de données (plugin DBManager) de QGis ne gère pas les bases Oracle. Impossible de créer des tables à la volée, de faire des requêtes intermédiaires. Je pense qu'il ne manque pas grand chose pour intégrer ce type de SGBDRS(patial) dans cet outil qui me semble incontournable pour QGis. D'ailleurs une demande d'évolution a déjà été déposée. Lorsqu'il sera prêt, QGis aura une nouvelle corde à son arc et pourra se targuer de se connecter à la majorité des SGBDRS du marché.

Je tiens également à noter que cet article ne se focalise que sur la partie vectorielle. En effet, à l'instar de PostGIS à partir de la version 2.0, Oracle est également capable de stocker des rasters.

En attendant, je souhaite bon courage à l'équipe de développement de QGis pour nous mener dans la bonne direction quant aux performances améliorables de Qgis et je salue déjà le travail sérieux qui a été mené...

Posted sam. 24 mai 2014 18:13:00 Tags: