9 Additional Features
Full-Text Search
Full-text search refers to searching for documents written in natural language and sorting the results by relevance to the search query. In the simplest and most typical case, the query consists of one or more words, and the relevance is defined by the frequency of these words in the document. This is more or less what happens when we type a phrase in Google or Yandex search engines. However, despite SQL’s powerful capabilities, it is not always sufficient for effective data handling. This has become increasingly evident with the rise of Big Data, which is often poorly structured and difficult to parse.
There is a large number of search engines, free and paid, that enable you to index the whole collection of your documents and set up search of quite decent quality. In this case, the index—an essential search tool for speeding up queries—is separate from the database. It means that many valuable database features become unavailable: database synchronization, transaction isolation, accessing and using metadata to limit the search range, setting up access policies, and many more.
Shortcomings of document-oriented database management systems usually have a similar nature: they have rich full-text search functionality, but data security and synchronization features are of low priority. Besides, such databases (for example, MongoDB) are usually NoSQL ones, so by design, they lack all the power of SQL accumulated over years.
However, traditional SQL database systems do have built-in full-text search capabilities. The LIKE operator is part of the standard SQL syntax, but its flexibility is often insufficient. Therefore, developers had to implement their own extensions of the SQL standard. In PostgreSQL, these are comparison operators ILIKE, ~, ~*, but they don’t solve all the problems either, as they don’t take into account grammatical forms, are not suitable for ranking, and work rather slowly.
When talking about the tools of the full-text search itself, it’s important to understand that they are far from being standardized: each database system uses its own approach and syntax. Russian users of PostgreSQL have some advantage here: its full-text search extensions were created by Russian developers, so there is a possibility to contact the experts directly or even attend their lectures to go into low-level details, if required. Here we’ll only provide some simple examples.
To learn about the full-text search capabilities, we are going to create one more table in the demo database. Let it be a lecturer’s draft notes split into chapters by lecture topics:
Now let’s enter the text of the first lectures for our courses CS301 and CS305:
Check the result:
Now let’s find some information in our database with the help of traditional SQL means (using the LIKE operator):
It’s easy to guess the result: 0 rows. The LIKE operator sees no connection between “fascination” and the words “fascinating” and “fascinated” present in the text.
The query
will return the row from chapter II (but not from chapter I, where the adjective “fascinating” is used):
PostgreSQL provides the ILIKE operator, which allows us not to worry about letter cases; otherwise, you would also have to take uppercase and lowercase letters into account. True, there are also regular expressions (search patterns), and setting them up is a truly engaging task, little short of art, but sometimes you just want a tool that can simply do the job. So let’s add an additional column to the course_chapters table; it will have a special type called tsvector:
As we can see, the rows have changed:
Words are reduced to their root forms (lexemes).
Numbers have appeared. They indicate the word position in the text.
There are no prepositions included (and neither there would be any conjunctions or other parts of the sentence that are unimportant for search; they are the so-called stop words).
The search will be even more efficient if it includes chapter titles, which are also given more weight in respect to the rest of the text (it can be done using the setweight function). Let’s modify the table:
Lexemes are assigned relative weight markers: B and D (possible options are A, B, C, D). We’ll assign real weights when writing queries, which will give us more flexibility.
Fully armed, let’s return to search. The to_tsquery function resembles the to_tsvector function we have seen above: it converts a string to the tsquery data type used in queries.
You can check that the query 'fascinated & database' and its other grammatical variants will return the same result. Here we have used the comparison operator @@, which plays the same role in full-text search as the LIKE operator does in regular search. The syntax of the @@ operator does not allow natural language expressions with spaces, so words in the query are connected by the “and” logical operator.
The english argument indicates the configuration used by PostgreSQL. The configuration defines pluggable dictionaries and the parser, which splits the phrase into separate lexemes.
Despite their name, dictionaries enable all kinds of lexeme transformations. For example, a simple stemmer dictionary like snowball, which is used by default, reduces the word to its unchangeable part; it allows search to ignore word endings in queries. You can also plug in other dictionaries, for example:
regular dictionaries like
ispell,myspell, orhunspell, which can better handle word morphologydictionaries of synonyms
thesaurus
unaccent, which can remove diacritics from letters
Thanks to assigned weights, the displayed search results are ranked:
The {0.1, 0.0, 1.0, 0.0} array sets the weights. It is an optional argument of the ts_rank_cd function. By default, array {0.1, 0.2, 0.4, 1.0} corresponds to D, C, B, A. The word’s weight affects ranking of the returned row.
In the final experiment, let’s modify the display format. Suppose we would like to highlight the found words in the html page using the bold type. The ts_headline function specifies the symbols used to highlight words and sets the minimum and maximum number of words displayed in a single line:
To speed up full-text search, special indexes are used: GiST, GIN, and RUM, which are different from regular database indexes. However, like many other useful full-text search features, they fall outside the scope of this guide.
To learn more about full-text search, see the documentation: www.postgrespro.com/doc/textsearch.
Using JSON and JSONB
SQL-based relational databases were originally designed with a strong emphasis on data consistency and security, at a time when data volumes were far smaller than they are today. When NoSQL databases appeared, it caused concern in the community: lack of strict consistency support and a much simpler data structure (at first, it was simply a storage of key–value pairs) provided a remarkable search speedup. Actively using parallel computations, they could process unprecedented volumes of information and were easy to scale.
Once the initial shock had passed, it became clear that such a simple structure was insufficient for most practical tasks. Composite keys were introduced, and then groups of keys appeared. To remain competitive, relational database systems began incorporating features commonly associated with NoSQL.
Since changing the database schema in a relational database incurs high costs, a new JSON data type came in handy. Having a hierarchical structure similar to XML, it was first targeted at JavaScript development (hence JS in the title), including AJAX application development. JSON’s flexibility allows developers to store and manage data with dynamic, unpredictable structures without requiring database schema modifications.
Suppose we need to enter personal data into the students demo database. We conducted a survey and collected the information from professors. Some questions in the questionnaire are optional, while other questions include the“add more information at your discretion” and “other” fields. If we followed the traditional approach, the information that does not fit the current structure would require adding multiple new tables and columns with lots of empty fields. As the dataset grows, the whole database may have to be refactored.
We can solve this problem using json or jsonb types. The jsonb type, which appeared after json, stores data in a compact binary form and, unlike json, supports indexes, which can sometimes speed up search by an order of magnitude.
Let’s create a table with JSON objects:
Let’s verify that the data has been correctly inserted. For convenience, we will join the student_details and students tables using the WHERE clause, as the new table does not contain students’ names:
Suppose we are interested in entries that hold information about the students’ merits. Let’s access the values of the “merits” key using a special operator ->>:
We have seen that the two entries are related to Anna and Nina’s merits, but the result is misleading, as Anna’s merits are actually “none.” Let’s fix the query:
The new query only returns Nina, whose merits are real.
However, this method is not always effective. Let’s attempt to retrieve the guitars that Victor plays:
This query won’t return anything. It’s because the corresponding key–value pair is located inside the JSON hierarchy, nested into the pairs of a higher level:
To retrieve the list of guitars, let’s use the #> operator and go down the hierarchy, starting from “hobbies”:
We can see that Victor is a fan of Fender:
The json type has a younger sibling: jsonb. The letter “b” implies the binary (rather than text) format of data storage and structure, which can often result in faster search. Nowadays, jsonb is used much more frequently than json.
Besides the notation differences, the order of values has changed: Alex, who lacks additional information, now appears before Mark. It’s not a disadvantage of jsonb as compared to json, it’s simply its data storage specifics.
The jsonb type is supported by a larger number of operators than json. A most useful one is the “contains” operator @>. It works similar to the #> operator for json.
For example, let’s find the entry that mentions Lucie, one of the mother-of-five’s children:
We have used the jsonb_pretty() function, which formats the output of the jsonb type.
Alternatively, you can use the jsonb_each() function, which expands key–value pairs:
Note that the name of Nina’s child is replaced by an empty space {} in the query. This syntax adds flexibility to the process of application development.
But what’s more important, jsonb allows you to create indexes that support the @> operator, its inverse <@, and many other ones (the GIN index is typically the most efficient). The json type does not support indexes, so for high-load applications it is usually recommended to use jsonb.
To learn more about json and jsonb data types and their associated functions, see the PostgreSQL documentation at postgrespro.com/doc/datatype-json and postgrespro.com/doc/functions-json.
When the SQL:2016 standard was published, which included the SQL/JSON Path language, Postgres Professional developed its implementation, providing the jsonpath type. It was later committed to PostgreSQL 12.
$.a.b.creplaces the'a'->'b'->'c'syntax that had to be used in PostgreSQL 11 or lower.The
$symbol represents the current context element, that is, the JSON fragment that has to be parsed.@represents the current context element in filter expressions. All the paths available in the$expression are searched.*is a wildcard symbol. In expressions with$and@it denotes any value of the path taking the hierarchy into account.**is a wildcard that can denote any part of the path in expressions with$or@, without taking the hierarchy into account. It comes in handy if you don’t know the exact nesting level of the elements.
- The
?operator is used to create a filter similar toWHERE. For example:$.a.b.c ? (@.x > 10).
To find cosplay enthusiasts using the jsonb_path_query() function, you can write the following query:
This query searches only through the JSON branch that begins with the “hobbies” key, checking whether the corresponding value equals “cosplay.” But if we replace “cosplay” with “guitarist,” the query won’t return anything because “guitarist” is used in our table as a key, not as a value of the nested element.
Queries can operate on two hierarchical levels: one inside the path expression, which defines the search area, and the other inside the filter expression, which matches the results with the specified condition. It means there are different ways to reach the same goal.
For example, the query
and the query
return the same result:
In the first example, we defined a filter expression for each entry within the “hobbies.guitarist.band” branch. If we take a look at the JSON itself, we can see that this branch has only one value: “Postgressors.” So there was actually nothing to filter out. In the second example, the filter is applied one step higher, so we have to specify the path to the“group”within the filter expression; otherwise, the filter won’t find any values. If we use such syntax, we have to know the JSON hierarchy in advance. But what if we don’t know the hierarchy?
In this case, we can use the ** wildcard. It is an extremely useful feature! Suppose we are not sure what a “Strat” is: whether it’s a high-altitude balloon, a guitar, or a member of the highest social stratum. But we have to find out whether we have this word in our table at all. Previously, it would have required a complex search through the JSON document (unless we converted jsonb to text). Now you can simply run the following query:
You can learn more about SQL/JSON Path capabilities in the documentation (postgrespro.com/doc/datatype-json#DATATYPE-JSONPATH) and in the article “JSONPath in PostgreSQL: committing patches and selecting apartments” (habr.com/en/company/postgrespro/blog/500440/).
The SQL:2016 Standard defines a few more convenient features and expressions for working with JSON. Eventually, they make their way into PostgreSQL.We will discuss some of them here; for the full list, see the documentation: postgrespro.com/doc/functions-json.
Let’s get back to our friends Anna, Victor and Nina. They took part in a poll to determine what TV shows the younger generation likes. The results are stored in a table.
s_id int REFERENCES students(s_id),
Database engineers analyze the results. They’ve got a powerful toolkit: aggregation functions, check constraints, an assortment of json(b) tools.
The arrays should first be split into separate elements (unnest). It can be done with jsonb_array_elements, but there’s an issue:
Naturally, the data from the questionnaires has been put into the database carelessly: Student 1432 does not have a corresponding array (of one element only, but still). Just to make sure, use IS [NOT] JSON ARRAY:
We can fix the type with jsonb_set, which can modify jsonb structure:
Now:
Here is our final list. Note that IS JSON ARRAY belongs to a group of predicates, together with IS JSON SCALAR, IS JSON OBJECT and the more general IS JSON. See the documentation for more: postgrespro.com/doc/functions-json.
Integration with External Systems
Real-world applications are not isolated, and they often have to send data to each other. Such interactions can be implemented at the application level, for example, using web services or file exchange, or you can rely on the database functionality for this purpose.
PostgreSQL supports the ISO/IEC 9075-9 standard (SQL/MED, Management of External Data), which defines access to external data sources from SQL via a special mechanism of foreign data wrappers.
The idea is to access external (foreign) data as if it were located in regular PostgreSQL tables. It requires creating foreign tables, which do not contain any data themselves and only redirect all queries to an external data source. This approach facilitates application development, as it allows to abstract from specifics of a particular external source.
Creating a foreign table involves several steps.
The
CREATE FOREIGN DATA WRAPPERcommand plugs in a library for working with a particular data source.The
CREATE SERVERcommand defines a foreign server. You should usually specify such connection parameters as host name, port number, and database name.The
CREATE USER MAPPINGcommand provides username mapping since different PostgreSQL users can connect to one and the same foreign source on behalf of different external users.The
CREATE FOREIGN TABLEcommand creates foreign tables for the specified external tables and views, whileIMPORT FOREIGN SCHEMAallows to import descriptions of some or all tables from the external schema.
Below we’ll discuss PostgreSQL integration with the most popular databases: Oracle, MySQL, SQL Server, and PostgreSQL itself. But first we need to install the libraries required for working with these databases.
Installing Extensions
The PostgreSQL distribution includes two foreign data wrappers: postgres_fdw and file_fdw. The first one is designed for working with external PostgreSQL databases, while the second one works with files on a server. Besides, the community develops and supports various libraries that provide access to many popular databases. To get the full list, take a look at pgxn.org/tag/fdw.
Foreign data wrappers for Oracle, MySQL, and SQL Server are available as extensions:
Oracle — github.com/laurenz/oracle_fdw
SQL Server — github.com/tds-fdw/tds_fdw
Follow the instructions on these web pages to build and install these extensions. If all goes well, you will see the corresponding foreign data wrappers in the list of available extensions. For example, for oracle_fdw:
Oracle
First, let’s create an extension, which will add a foreign data wrapper:
Check that the corresponding wrapper has been added:
The next step is setting up a foreign server. In the OPTIONS clause, you have to specify the dbserver option, which defines connection parameters for the Oracle instance: server name, port number, and instance name.
The PostgreSQL user postgres will be connecting to the Oracle instance as scott.
We’ll import foreign tables into a separate schema. Let’s create it:
Now let’s import some foreign tables. We’ll do it for just two popular tables, dept and emp:
Note that Oracle data dictionary stores object names in uppercase, while PostgreSQL system catalog saves them in lowercase. When working with external data in PostgreSQL, you have to double-quote uppercase Oracle schema names to avoid their conversion to lowercase.
Let’s view the list of foreign tables:
Now run the following queries on the foreign tables to access the external data:
Write operations on external data are also allowed:
MySQL
Create an extension for the required foreign data wrapper:
The foreign server for the external instance is defined by the host and port parameters:
We are going to establish connections on behalf of a MySQL superuser:
The wrapper supports the IMPORT FOREIGN SCHEMA command, but we can also create a foreign table manually:
Check the result:
Just like the Oracle wrapper, mysql_fdw allows both read and write operations.
SQL Server
Create an extension for the required foreign data wrapper:
Create a foreign server:
The required connection information is the same: you have to provide the host name, the port number, and the database name. But the OPTIONS clause takes different parameters as compared to oracle_fdw and mysql_fdw.
We are going to establish connections on behalf of an SQL Server superuser:
Let’s create a separate schema for foreign tables:
Import the whole HumanResources schema into the created PostgreSQL schema:
You can display the list of imported tables using the \det command, or find them in the system catalog by running the following query:
Object names are case-sensitive, so they should be enclosed in double quotes in PostgreSQL queries:
Currently tds_fdw supports only reading; write operations are not allowed.
PostgreSQL
Create an extension and a wrapper:
We are going to connect to another database of the same server instance, so we only have to provide the dbname parameter when creating a foreign server. Other parameters (such as host, port, etc.) can be omitted.
There is no need to specify the password if you create a user mapping within a single cluster:
Import all tables and views of the bookings schema:
Check the result:
To learn more about postgres_fdw, see the documentation: postgrespro.com/doc/postgres-fdw.
Foreign data wrappers are also worth mentioning as the community considers them to be the basis for built-in sharding in PostgreSQL. Sharding is similar to partitioning: they both use a particular criterion to split a table into several parts that are stored independently. The difference is that partitions are stored on the same server, while shards are located on different ones. Partitioning has been available in PostgreSQL for quite a long time. Starting from version 10, this mechanism is being actively developed, and many useful features have already been added: declarative syntax, dynamic partition pruning, support for parallel operations, and other miscellaneous enhancements. You can also use foreign tables as partitions, which virtually turns partitioning into sharding.
However, significant work remains before sharding is fully practical:
- Consistency is not guaranteed: external data is accessed in separate local transactions rather than in a single distributed one.
You can’t duplicate the same data on different servers to enhance fault tolerance.
All actions required to create tables on shards and the corresponding foreign tables have to be done manually.
These challenges are already addressed in Shardman, a new tool developed by Postgres Professional (postgrespro.ru/products/shardman).
Another extension included into the distribution for working with PostgreSQL databases is dblink. It allows you to explicitly manage connections (to connect and disconnect), execute queries, and get the results asynchronously: postgrespro.com/doc/dblink.