This section describes the use of operations available in the library. Some of these operations (projection, input/output) have already been seen in the preceding sections, in the form of methods of the cogitant::Environment class. But these methods are merely shortcuts for using cogitant::Operation (and its sub-classes), this is why, in the frame of an advanced use of the library, it is better to know the use of this class in order to understand well the functioning of these methods corresponding to the operations defined based on the model, on input/output features as well as to define new operations. In this part, we explain the main library operations, and describe how to use these operations as instances of subclasses of cogitant::Operation and through "shortcuts" methods of cogitant::Environment.
The approach chosen to offer operations in the library is then to use objects instance of cogitant::Operation (or rather of its subclasses, because it is an abstract class). This solution has been preferred to the definition of methods in the cogitant::Graph or cogitant::Rule class as it offers more flexibility. Indeed, whether you have to define new operations (for example, managing a new file format) or you have to provide a new implementation of existing operations (for example a more effective projection algorithm), the solution of using cogitant::Graph methods require the definition of a new subclass, in which the method corresponding to the operation is redefined. This obviously raises the problem of the definition of several new operations: in this case, how to organize the inheritance relationship on (the) new class(es). On the contrary, the kept solution can define new operation classes that receive model objects as parameters. Thus, you can easily define new implementations of existing or new operations. In addition, the library handling references to instances of (subclasses of) cogitant::Operation, it is very easy to incorporate these new operations to the library, and to use methods of the library that will call, in an absolutely transparent way, new operations.
Generally, operations are used by instanciating an object of a subclass of cogitant::Operation, by calling setParamxxx() methods to fix the different parameters of the operation, then by calling the run() method which carries out the calculation. Finally, getResultxxx() methods can retrieve the result.
Conceptual graph operations
Connected component determination
Conceptual graphs handled in Cogitant may be unconnected. However structuring a graph in connected components can be useful, this is why several methods of the library can determine whether a graph is connected or not, how many connected components of a graph and what are these connected components (vertices contained in each component). All this information can be calculated by calling cogitant::Environment::isConnected() (graph connectivity), cogitant::Environment::connectedComponentsSize() (number of connected components) and cogitant::Environment::connectedComponents() (connected components) methods. All these methods take as a parameter a (pointer to a) cogitant::Graph and a cogitant::iSet locating the cogitant::InternalGraph identifier whose connectivity must be determined (by default 0, i.e. the graph of level 0). Indeed, in the case of nested graphs, a cogitant::Graph contains several "graphs," and if there is no interest in determining the number of connected components of all these graphs, it may be interesting to determine this information on one of them (and not just on the graph of level 0).
#include <iostream>
using namespace std;
int main(int, char* [])
{
cout << "graph is connected." << endl;
else
cout << "graph is not connected." << endl;
vector<cogitant::GraphSubset> cc;
cout << "components:" << endl;
for (vector<cogitant::GraphSubset>::const_iterator i=cc.begin(); i!=cc.end(); i++)
cout << (*i).selectedNodes() << " nodes: " << (*i) << endl;
return 0;
}
Calculating the disjoint sum
The disjoint sum is simply accessible from the cogitant::Environment::disjointSum() method which takes as parameters two (pointers to) cogitant::Graph and which modifies the first for adding it the second (which is not modified). This method actually uses the cogitant::OpeDisjointSum class, whose documentation describes the various features offered by this class that can make operations more complex than the simple disjoint sum (this operation is especially used when loading files when a reference to an already existing graph is done in a nesting (the graph must then be copied as nested graph, and coreference classes may be updated) and when applying a rule according to a projection (adding the conclusion and merging vertices, see below)).
Graph checkouts
Projection calculation
Search of projections from a graph into another is done thanks to the cogitant::Environment::projections() method, to which one must pass the projected graph and the graph in which one projects. This method is actually overloaded and can take as parameters graph identifiers (cogitant::iSet identifying the graph in the environment) or pointers to cogitant::Graph. The third parameter of this method, a cogitant::ResultOpeProjection passed by reference, will contain, after the call, the result of the method execution. But this object can also set up the search. Indeed, according to the uses, one can be interested in various searches, which are more or less costly in time and memory. On can mainly distinguish 3 uses, which are presented here from the less to the most expensive one, and illustrated with an example.
-
If you are just interested in the existence of a projection between the two graphs, the search operation doesn't have to search several graphs or to store couples of vertices composing each found projection. In this case, it is appropriate to call methods cogitant::ResultOpeProjection::memoProjections with
false as a parameter (to indicate that projections should not be stored), and cogitant::ResultOpeProjection::maxSize (cogitant::nSet) with 1 as a parameter (to indicate that the search must be stopped as soon as a projection is found). Both methods should be called on cogitant::ResultOpeProjection before calling a cogitant::Environment::projections(). Once this method has been called, it is necessary to call cogitant::ResultOpeProjection::isEmpty() to determine whether a projection was found.
-
In case where one just seeks to know the number of projections without trying to store couples of vertices, one have to call cogitant::ResultOpeProjection::memoProjections() with
false as a parameter before the call to cogitant::Environment::projections(). After the call, the method cogitant::ResultOpeProjection::size() allows you to know the number of found projections.
-
Finally, in the general case, we seek all projections from a graph into another, and we are interested in the projections themselves, i.e., for each found projection, by the image of each vertex of the projected graph. In this case, no particular method of cogitant::ResultOpeProjection should be called. After the call cogitant::Environment::projections(), the method cogitant::ResultOpeProjection::size() can provide the number of found projections, cogitant::ResultOpeProjection::projections() to all found projections, i.e. an instance of cogitant::Set<cogitant::Projection*>. It is then possible to traverse this set (cf. Containers classes) to perform a special processing on each found projection.
Example.The example below shows the three cases presented above. The display of projections in the third case uses the output operator of cogitant::Projection which displays couples of vertex identifiers in graphs: the first element of each couple is the identifier (iSet) of a cogitant::GraphObject of the projected graph and the second element is the identifier of its image.
#include <iostream>
using namespace std;
using namespace cogitant;
int main(int, char* [])
{
cout << "Pas de projection" << endl;
else
cout << "Il y a (au moins) une projection" << endl;
cout <<
"Nombre de projections " << proj2.
size() << endl;
{
cout << (i+1) << " : ";
}
return 0;
}
Projection iterator
With the use of a cogitant::OpeProjectionBundle and a cogitant::ProjectionIterator, projections are not calculated by a simple call (such as in the cogitant::Environment::projections() method): after an initialization by a call to cogitant::OpeProjectionBundle::begin(), a cogitant::ProjectionIterator calculates the next cogitant::Projection each time that its ++ operator is called. Please note that modifying the two graphs while projections are computed is strictly forbidden. Note also that an cogitant::OpeProjectionBundle can only calculate projections between two graphs at a time (even if two cogitant::ProjectionIterator are declared). So, in order to calculate simulaneaously projections between several couples of graphs, several cogitant::OpeProjectionBundle are needed, and they can be obtained by cogitant::Environment::newOpeProjectionBundle().
Example.
#include <iostream>
using namespace std;
using namespace cogitant;
int main(int, char* [])
{
unsigned long nbproj = 0;
cout << *i << endl;
cout << nbproj << " projections found." << endl;
delete opeproj;
return 0;
}
Changes in the projection search operation
Like all other methods of cogitant::Environment which give access to operations, cogitant::Environment::projections() is merely a shortcut that uses subclasses of cogitant::Operation. More specifically, this is the cogitant::OpeProjection class which takes charge of calculating projections. This operation searches projections by a backtrack algorithm that calculates in a first-time possible image lists (of each cogitant::GraphObject of the projected graph in cogitant::GraphObject objects of the graph in which we seeks projections), then filters these lists. Filtering is done by choosing a vertex o1 of the projected graph, and one of its images o2 among its list of possible images. By choosing this couple as part of the projection, this induces constraints on neighbors of o1: if there is an edge labeled i between o1 and o3, then o3 can have as images only vertices o4 such that there is an edge labeled i between o2 and o4. The list of possible images of o3 can then be filtered.
The cogitant::OpeProjection operation merely defines the main scheme of projection search. The run() method of this method actually makes calls to other operations, specialized in a task. Thus, to modify the projection search operation, you "just" have to write a subclass of cogitant::OpeProjection (at worst) or a subclass of one of the following operations to change a part of the search behaviour:
-
cogitant::OpeProjPrecalcImages can check whether, for a given vertex of the projected graph, the list of possible images must be calculated before the backtrack launching, or if this list will be calculated only "when required" during the backtrack execution.
-
In the case where the list of possible images must be calculated before the backtrack launching, the cogitant::OpeProjLIPInit operation takes charge of the calculation.
-
Whether before or during backtrack, the calculation of a list of possible images is done through the calculation of the vertex "compatibility", that is to say "Does a vertex of a given label may be projected onto another vertex of a given label?". This calculation is done by cogitant::OpeGraphObjectCompatibility. By default, this operation uses the usual conditions of the conceptual graph model, meaning that a concept vertex is compatible with not more than a single concept vertex, etc, that the label specialization is taken into account on concepts, relations and nestings. To define a variant or an extension of the projection, with different conditions for possible images of a vertex, it will be then required to define a subclass of cogitant::OpeGraphObjectCompatibility and to redefine the run() method. An example is provided below.
-
cogitant::OpeProjBacktrackChoice takes charge of determining which vertex is chosen (among those having no image whithin projection being calculated) to be filtered. The pick of the vertex is important because depending on how it is done, the calculation of projections may be more or less fast. It is reasonable to choose a "very constrained" vertex, meaning a vertex having a small list of possible images. By default, this operation selects the neighbor of the last vertex added to the projection being calculated, which has a list of images as short as possible. If no vertex is found (because all neighbors are already included in the projection being calculated), a relationship is chosen, otherwise, a cogitant::GraphObject among those which are not already into the projection.
-
cogitant::OpeProjLIPUpdate takes charge of filtering a list of possible images during the backtrack while considering the graph structure, i.e. edges and their labels.
-
cogitant::OpeProjAcceptableCouple takes charge of accepting or refusing a couple in the projection. Parameters supplied to this operation are "correct" couples, i.e. they satisfy all the conditions on the vertex labels, the edges and their labels. By default, all couples are accepted. However, to only calculate injective projections for example, this is when adding a couple to the projection that we can decide whether a "correct" couple shall be accepted or not (if the second element of the couple already appears in the projection, the couple must be refused, as in this case, the projection is not injective).
-
cogitant::OpeProjAcceptableLIPs takes charge of the analysis of all possible image lists calculated at a given time of the algorithm to determine whether these possible images can lead to an "interesting" projection. By default, such an operation is not used by the projection, but it is possible to redefine a subclass and make special processing to guide the projection and to remove as soon as possible uninteresting projections.
The "personalization" of the projection calculation can then be done by writing a subclass of one or more classes presented above. Obviously, in this case, it is necessary to specify to cogitant::OpeProjection that this new class should be used. For this, it is necessary to instantiate the new class and to pass an instance to the instance of the used cogitant::OpeProjection in order to inform it to use the new operation. But this requires an access to an instance of cogitant::OpeProjection. Therefore you have to instantiate this class and to provide it instances of all side operations, whether it is "standard" classes or new subclasses. This way of doing things is not very pleasant to use because it requires to instantiate several classes, and requires to directly use operations with setParamxxx() methods. Moreover, in this way, the cogitant::Environment::projections() method always uses the "standard" projection. This is why it is preferable to "better" integrate the projection variant to the library, and to comply with the usual usage for the projection search. Indeed, usually, one do not instantiate cogitant::OpeProjection, because cogitant::Environment::projections() should be use to calculate projections. In fact, this method uses an instance of cogitant::OpeProjection (as well as all other classes above). Thus, it is this instance that must be modified so that a new operation is taken into account by cogitant::Environment::projections(), as shown in the example below.
Example.The program below redefines the compatibility operation between concept vertices (and only concept vertices): for each couple of 2 labels of concept vertices, these 2 labels are compatible. In other words, a concept vertex can have as an image any concept vertex. In the example, a subclass of cogitant::OpeGraphObjectCompatibility is thus defined, and associated with the operation of searching environment projections.
#include <iostream>
using namespace std;
using namespace cogitant;
{
public:
{};
void run()
{
if ((i_1->objectType() == GraphObject::OT_CONCEPT)
&& (i_2->objectType() == GraphObject::OT_CONCEPT))
o_result = true;
else
OpeGraphObjectCompatibility::run();
}
};
{
cout << proj.
size() <<
" projections" << endl;
{
cout << (i+1) << " : ";
}
}
int main(int, char* [])
{
MyOpeGraphObjectCompatibility myope(&env);
cout << "Projection standard" << endl;
run(env, g1, g1);
cout << "Modified projection" << endl;
run(env, g1, g1);
cout << "Return to the standard projection" << endl;
run(env, g1, g1);
return 0;
}
Example. The program below redefines the filter operation of lists of possible images to filter certain projections. Actually, this example only forbids the 45th update of a list of possible images (and force thus the backtrack at this time), so that some of the projections (among the 360 that are normally found between these 2 graphs) are never reached.
#include <iostream>
using namespace std;
using namespace cogitant;
{
public:
{};
public:
void run()
{
switch (i_step)
{
case INITLIP:
cout << "InitLIP " << i_o << endl; break;
case INITLIPEND:
cout << "InitLIPEnd" << endl; break;
case UPDATELIP:
cout << "UpdateLIP " << i_o << endl; break;
case UPDATELIPBACKTRACK:
cout << "UpdateLIPBacktrack " << i_o << endl; break;
}
o_result = true;
if (i_step == UPDATELIP)
{
static int cpt = 0;
cpt++;
if (cpt == 45)
o_result = false;
}
}
};
{
cout << proj.
size() <<
" projections" << endl;
}
int main(int, char* [])
{
MyOpeProjAcceptableLIPs myope(&env);
cout << "Standard projection" << endl;
run(env, g1, g2);
cout << "Modified projection" << endl;
run(env, g1, g2);
cout << "Return to the standard projection" << endl;
run(env, g1, g2);
return 0;
}
Example. The program below redefines the compatibility between concept vertices and uses this new compatibility operation in order to calculate a max join between two graphs.
#include <iostream>
using namespace std;
using namespace cogitant;
{
public:
OpeGraphObjectCompatibilityComparable(
Environment * env)
{};
void run()
{
if ((i_1->objectType() == GraphObject::OT_CONCEPT) && (i_2->objectType() == GraphObject::OT_CONCEPT))
{
o_result = environment()->support()->conceptTypesOrder()->comparison(i_1c->
primitiveType(), i_2c->
primitiveType()) != PartialOrder::UNCOMPARABLE;
}
else if ((i_1->objectType() == GraphObject::OT_RELATION) && (i_2->objectType() == GraphObject::OT_RELATION))
o_result = environment()->support()->relationTypesOrder()->comparison(i_1->asRelation()->type(), i_2->asRelation()->type()) != PartialOrder::UNCOMPARABLE;
else
OpeGraphObjectCompatibility::run();
}
};
{
if (add)
else
if (g->
nodes(node)->objectType() == GraphObject::OT_CONCEPT)
{
for (
iSet i=edges->iBegin(); i!=edges->iEnd(); edges->iNext(i))
if (edges->iGetContent(i).isEdge())
{
if (add)
subgraph.
select(edges->iGetContent(i).m_end);
else
subgraph.
deselect(edges->iGetContent(i).m_end);
}
}
}
{
op->setParamResult(&rop);
}
{
static int depth=0;
depth++;
for (int id=0; id<depth; id++) cout << " ";
cout << "Trying " << currentsubgrapha << " ";
if (projectionSubGraph(env, currentsubgrapha, grapha, graphb))
{
cout << " => Ok ! (New max join)" << endl;
bestsubgrapha = currentsubgrapha;
}
else
{
cout << "No projection" << endl;
if (currentsubgrapha.selectedNodes() > bestsubgrapha.
selectedNodes())
{
for (
iSet i=currentsubgrapha.first(); i!=
ISET_NULL; i=currentsubgrapha.next(i))
{
if ((grapha->
nodes(i)->objectType() == GraphObject::OT_CONCEPT) || (grapha->
nodes(i)->objectType() == GraphObject::OT_RELATION))
{
addRemoveNode(false, grapha, copycurrentsubgrapha, i);
if ((copycurrentsubgrapha.selectedNodes() > bestsubgrapha.
selectedNodes()) && env.
isConnected(grapha, grapha->
root(), ©currentsubgrapha))
runMaxJoin(env, grapha, graphb, copycurrentsubgrapha, bestsubgrapha);
}
}
}
}
depth--;
}
int main(int argc, char* argv[])
{
string prefix;
if (argc == 2) prefix = argv[1]; else prefix = "../bcgct/sisyphus";
OpeGraphObjectCompatibilityComparable myopecompatibility(&env);
runMaxJoin(env, env.
graphs(igrapha), env.
graphs(igraphb), subgrapha, bestsubgrapha);
cout <<
"max join: " << bestsubgrapha.
selectedNodes() <<
" " << bestsubgrapha << endl;
}
Image of a graph through a projection
Normal form and normalization
#include <iostream>
using namespace std;
int main(int, char* [])
{
if (nf)
cout << "Graph is in normal form." << endl;
for (int i=0; j != fact->conceptEnd(); j++)
{
i++;
}
if (!nf)
{
cout << "Graph is not in normal form, because of the following concept nodes: ";
for (list<cogitant::OpeVerification::ErrorInfo *>::const_iterator i=errors.
elements().begin(); i!=errors.
elements().end(); i++)
cout << dynamic_cast<cogitant::OpeVerification::ErrorISet const *>(*i)->m_iset << " ";
cout << endl;
}
if (nf)
cout << "Graph is in normal form again." << endl;
return 0;
}
Operations on graph rules
Input/output operations
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
if (argc != 3)
{
cerr << "Usage: support_convert source dest" << endl;
return 0;
}
try
{
cout << "Loading support: " << argv[1] << endl;
cout << "Saving support: " << argv[2] << endl;
cout << "ok" << endl;
}
{
}
return 0;
}
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
if (argc != 4)
{
cerr << "Usage: graph_convert support source dest" << endl;
return 0;
}
try
{
cout << "Loading support: " << argv[1] << endl;
cout << "Loading graph: " << argv[2] << endl;
cout << "Saving graph: " << argv[3] << endl;
cout << "ok" << endl;
}
{
}
return 0;
}
Input/output operations do not inevitably act on files, but can take as parameters C++ streams, i.e. instances of subclasses std::istream and std::ostream. By the way, most methods are overloaded in order to receive either a filename (in the form of a string) or a stream (input or output, depending on whether it is an input or an output operation).
A classic use of this possibility is to write on the screen a support or graphs for the purpose of debugging or quick viewing of a result. To get this result, simply call the cogitant::Environment::writeGraph() or cogitant::Environment::writeSupport() methods which take as a parameter a std::ostream and pass them std::court. This possibility is used in several examples of this documentation.
Note that the format detection being performed by the cogitant::IOHandler from the extension of the filename, it cannot be done from the stream. That is why methods taking a stream as a parameter also take a file format as a parameter.
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
if (argc != 3)
{
cerr << "Usage: " << argv[0] << " rdffile cgfile" << endl;
return 0;
}
try
{
vector<cogitant::iSet> graphs;
cout << "Loading rdf file: " << argv[1] << endl;
env.
read(
string(argv[1]), &graphs);
cout << "Saving cg file: " << argv[2] << endl;
cout << "ok" << endl;
}
{
}
return 0;
}
Input/output in a buffer
Classes std::istringstream and std::ostringstream enable to consider a buffer in memory as a stream, and, of course input/output operations of Cogitant can be used on such streams. These streams enable, in addition, to access their content as a string, which enables many uses, as a series of bytes can easily be sent to another tool. This function is used in the example below to store and read support and graph into a PostgreSQL database.
Example. The program below uses a PostgreSQL database (http://www.postgresql.org) for storing Cogitant objects (a support and a graph in the example) in the CoGXML format into a BLOB (called BYTEA in PostgreSQL). More specifically, a table (CogitantStreams) is created, with two columns: id contains an object identifier enabling to regain it and cogxml is a series of bytes containing the CoGXML serialization of the object. Various operations are done by the program to demonstrate the use of the table.
Note that this program requires the libpqxx (http://thaiopensource.org/development/libpqxx/), a C++ API for the DBMS PostgreSQL. If you want to compile this program, don't forget to link your executable to the library in addition to the Cogitant library (a -lpqxx passed to the linker is enough under Unix).
Even if different operations of reading and writing are done, it's the same principle that is done each time:
-
When writing in the database, a
std::ostringstream is declared, the CoGXML output is sent to this stream, then, in order to send the CoGXML code in the database, the method std::ostrinstream::str() is called and returns a (long) string that contains the full CoGXML code. This string is then sent to the database with a call to the library libpqxx.
-
For a loading from the database, the approach is reversed: a long string is read in the database, and a
std::istringstream is built from this string, and this stream is past to Cogitant loading methods.
#include <sstream>
#include <iostream>
#include <pqxx/pqxx>
using namespace pqxx;
using namespace std;
using namespace cogitant;
class PgSqlIO
{
private:
connection* m_pgsqlconnection;
work* m_pgsqltransaction;
public:
PgSqlIO()
{
string args="dbname=cogitant user=cogitant password=cogitant host=127.0.0.1";
m_pgsqlconnection = new connection(args);
m_pgsqltransaction = new work(*m_pgsqlconnection);
};
~PgSqlIO()
{
m_pgsqltransaction->commit();
delete m_pgsqltransaction;
delete m_pgsqlconnection;
};
void createTable()
{
m_pgsqltransaction->exec("CREATE TABLE CogitantStreams(\
id VARCHAR(50) CONSTRAINT cskey PRIMARY KEY,\
cogxml BYTEA)");
};
void dropTable()
{
m_pgsqltransaction->exec("DROP TABLE IF EXISTS CogitantStreams");
};
void prepareStatements()
{
if (!m_pgsqlconnection->supports(connection_base::cap_prepared_statements))
{
cout << "Backend version does not support prepared statements.";
exit(1);
}
m_pgsqlconnection->prepare("storeobject", "INSERT INTO CogitantStreams VALUES ($1, $2)")
("varchar", prepare::treat_string)("bytea", prepare::treat_binary);
m_pgsqlconnection->prepare_now("storeobject");
m_pgsqlconnection->prepare("readobject", "SELECT cogxml FROM CogitantStreams WHERE id=$1")
("varchar", prepare::treat_string);
m_pgsqlconnection->prepare_now("readobject");
m_pgsqlconnection->prepare("updateobject", "UPDATE CogitantStreams SET cogxml=$2 WHERE id=$1")
("varchar", prepare::treat_string)("bytea", prepare::treat_binary);
m_pgsqlconnection->prepare_now("updateobject");
};
void storeObject(string const & id, ostringstream const & value)
{
m_pgsqltransaction->prepared("storeobject")(id)(value.str()).exec();
}
void updateObject(string const & id, ostringstream const & value)
{
if (m_pgsqltransaction->prepared("updateobject")(id)(value.str()).exec().affected_rows() != 1)
cerr << "Warning: updateObject " << id << endl;
}
bool readObject(string const & id, istringstream & input)
{
const result r(m_pgsqltransaction->prepared("readobject")(id).exec());
if (r.empty()) return false;
binarystring bs(r.front()[0]);
input.str(bs.str());
return true;
}
};
void fillDatabase(PgSqlIO & tdb, string const & prefix)
{
env.
readSupport(prefix +
"/bcgct/bucolic/bucolic.bcs");
iSet gtmp = env.
readGraphs(prefix +
"/bcgct/bucolic/simplequery.bcg");
ostringstream sstr1;
tdb.storeObject("s1", sstr1);
ostringstream sstr2;
tdb.storeObject("g1", sstr2);
}
void updateDatabase(PgSqlIO & tdb)
{
{
istringstream input;
tdb.readObject("s1", input);
}
{
istringstream input;
tdb.readObject("g1", input);
ig1 = env2.
readGraphs(input,
"g1", NULL, IOHandler::COGXML);
}
for (int i=0; i<5; i++)
{
ostringstream output;
tdb.updateObject("g1", output);
}
}
void readDatabase(PgSqlIO & tdb)
{
{
istringstream input;
tdb.readObject("s1", input);
}
{
istringstream input;
tdb.readObject("g1", input);
ig1 = env.
readGraphs(input,
"g1", NULL, IOHandler::COGXML);
}
}
int main(int argc, char* argv[])
{
string prefix;
if (argc == 2) prefix = argv[1]; else prefix = "..";
PgSqlIO tdb;
tdb.dropTable();
tdb.createTable();
tdb.prepareStatements();
fillDatabase(tdb, prefix);
updateDatabase(tdb);
readDatabase(tdb);
}
Of course, the same mechanism can be used to make input/output in a DBMS other than PostgreSQL (or with another API of PostgreSQL) or with other tools that can take their input and ouput in strings.
Definition of new operations
(This section will be soon completed)