Files
davideisinger.com/static/archive/inviqa-com-kztbkj.txt
2024-01-17 00:10:00 -05:00

748 lines
26 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#[1]alternate
IFRAME: [2]https://www.googletagmanager.com/ns.html?id=GTM-NBN52P
[3]Skip to main content
Search ______________________________
Search
[4]Home
Main navigation (BUTTON) Menu
* [5]Who we are
+ [6]About Inviqa
+ [7]About Havas
+ [8]Our Sustainability Journey
* [9]What we do
+ [10]Digital Strategy Consulting
+ [11]Digital Roadmap Development
+ [12]Digital Product Design
+ [13]User Research
+ [14]Usability Testing
+ [15]Technical Architecture Consulting & Development
+ [16]Digital Platform Implementation
+ [17]Experience Optimisation
+ [18]All services
* [19]Case studies
+ [20]B2B case studies
+ [21]Fashion & Luxury case studies
+ [22]Not-For-Profit case studies
+ [23]Retail & DTC case studies
+ [24]Sport, Leisure & Entertainment case studies
+ [25]Travel & Hotels case studies
+ [26]All case studies
* [27]Partners
+ [28]Akeneo
+ [29]BigCommerce
+ [30]Drupal
+ [31]Magento / Adobe Commerce
+ [32]Spryker
+ [33]All partners
* [34]Careers
+ [35]Life at Inviqa
+ [36]Current Vacancies
* [37]Insights
+ [38]DTC Ecommerce Report 2023
+ [39]PIM Readiness Framework
+ [40]Retail Optimisation Whitepaper
+ [41]Blog
+ [42]All insights
* [43]Contact
+ [44]Get in Touch
* [45]EN
* [46]DE
Storing graphs in the database: SQL meets social network
By Lorenzo Alberton
7 September 2009 [47]Technology engineering
Graphs are ubiquitous. Social or P2P networks, thesauri, route planning
systems, recommendation systems, collaborative filtering, even the
World Wide Web itself is ultimately a graph!
Given their importance, it's surely worth spending some time in
studying some algorithms and models to represent and work with them
effectively. In this short article, we're going to see how we can store
a graph in a DBMS. Given how much attention my talk about storing a
tree data structure in the db received, it's probably going to be
interesting to many. Unfortunately, the Tree models/techniques do not
apply to generic graphs, so let's discover how we can deal with them.
What's a graph
A graph is a set of nodes (vertices) interconnected by links (edges).
When the edges have no orientation, the graph is called an undirected
graph. In contrast, a graph where the edges have a specific orientation
from a node to another is called directed:
" "
A graph is called complete when there's an edge between any two
nodes, dense when the number of edges is close to the maximal number of
edges, and sparse when it has only a few edges:
" "
Representing a graph
Two main data structures for the representation of graphs are used in
practice. The first is called an adjacency list, and is implemented as
an array with one linked list for each source node, containing the
destination nodes of the edges that leave each node. The second is a
two-dimensional boolean adjacency matrix, in which the rows and columns
are the source and destination vertices, and entries in the array
indicate whether an edge exists between the vertices. Adjacency lists
are preferred for sparse graphs; otherwise, an adjacency matrix is a
good choice. [1]
" " " "
When dealing with databases, most of the times the adjacency matrix is
not a viable option, for two reasons: there is a hard limit in the
number of columns that a table can have, and adding or removing a node
requires a DDL statement.
Joe Celko dedicates a short chapter to graphs in his '[48]SQL for
Smarties' book, but the topic is treated in a quite hasty way, which is
surprising given his usual high standards.
One of the basic rules of a successful representation is to separate
the nodes and the edges, to avoid [49]DKNF problems. Thus, we create
two tables:
CREATE TABLE nodes (
id INTEGER PRIMARY KEY,
name VARCHAR(10) NOT NULL,
feat1 CHAR(1), -- e.g., age
feat2 CHAR(1) -- e.g., school attended or company
);
CREATE TABLE edges (
a INTEGER NOT NULL REFERENCES nodes(id) ON UPDATE CASCADE ON DELETE CASCADE,
b INTEGER NOT NULL REFERENCES nodes(id) ON UPDATE CASCADE ON DELETE CASCADE,
PRIMARY KEY (a, b)
);
CREATE INDEX a_idx ON edges (a);
CREATE INDEX b_idx ON edges (b);
The first table (nodes) contains the actual node payload, with all the
interesting information we need to store about a node (in the
example, feat1 and feat2 represent two node features, like the age of
the person, or the location, etc.).
If we want to represent an undirected graph, we need to add a CHECK
constraint on the uniqueness of the pair.
Since the SQL standard does not allow a subquery in the CHECK
constraint, we first create a function and then we use it in the CHECK
constraint (this example is for PostgreSQL, but can be easily ported to
other DBMS):
CREATE FUNCTION check_unique_pair(IN id1 INTEGER, IN id2 INTEGER) RETURNS INTEGE
R AS $body$
DECLARE retval INTEGER DEFAULT 0;
BEGIN
SELECT COUNT(*) INTO retval FROM (
SELECT * FROM edges WHERE a = id1 AND b = id2
UNION ALL
SELECT * FROM edges WHERE a = id2 AND b = id1
) AS pairs;
RETURN retval;
END
$body$
LANGUAGE 'plpgsql';
ALTER TABLE edges ADD CONSTRAINT unique_pair CHECK (check_unique_pair(a, b) < 1)
;
NB: a UDF in a CHECK constraint might be a bit slow [4]. An alternative
is to have a materialized view [5] or force an order in the node pair
(i.e. "CHECK (a < b)", and then using a stored procedure to insert the
nodes in the correct order).
If we also want to prevent self-loops (i.e. a node linking to itself),
we can add another CHECK constraint:
ALTER TABLE edges ADD CONSTRAINT no_self_loop CHECK (a <> b)
" " " "
Traversing the graph
Now that we know how to store the graph, we might want to know which
nodes are connected. Listing the directly connected nodes is very
simple:
SELECT *
FROM nodes n
LEFT JOIN edges e ON n.id = e.b
WHERE e.a = 1; -- retrieve nodes connected to node 1
or, in the case of undirected edges:
SELECT * FROM nodes WHERE id IN (
SELECT a FROM edges WHERE b = 1
UNION
SELECT b FROM edges WHERE a = 1
);
-- or alternatively:
SELECT * FROM nodes where id IN (
SELECT CASE WHEN a = 1 THEN b ELSE a END
FROM edges
WHERE 1 IN (a, b)
);
Traversing the full graph usually requires more than a query: we can
either loop through the connected nodes, one level a time, or we can
create a temporary table holding all the possible paths between two
nodes.
We could use Oracles CONNECT BY syntax or SQL standards Common Table
Expressions (CTEs) to recurse through the nodes, but since the graph
can contain loops, wed get errors (unless were very careful, as well
see in a moment).
Kendall Willets [2] proposes a way of traversing (BFS) the graph using
a temporary table. It is quite robust, since it doesnt fail on graphs
with cycles (and when dealing with trees, he shows there are better
algorithms available). His solution is just one of the many available,
but quite good.
The problem with temporary tables holding all the possible paths is it
has to be maintained. Depending on how frequently the data is accessed
and updated it might still be worth it, but its quite expensive. If
you do resort to such a solution, these references may be of use [13]
[14].
Before going further in our analysis, we need to introduce a new
concept: the transitive closure of a graph.
Transitive closure
The transitive closure of a graph G = (V,E) is a graph G* = (V,E*) such
that E* contains an edge (u,v) if and only if G contains a path from u
to v.
In other words, the transitive closure of a graph is a graph which
contains an edge (u,v) whenever there is a directed path from u to v.
" "
Graph: transitive closure
As already mentioned, SQL has historically been unable [3] to express
recursive functions needed to maintain the transitive closure of a
graph without an auxiliary table. There are many solutions to solve
this problem with a temporary table (some even elegant [2]), but I
still haven't found one to do it dynamically.
Here's my clumsy attempt at a possible solution using CTEs
First, this is how we can write the WITH RECURSIVE statement for a
Directed (Cyclic) Graph:
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string
FROM edges
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges AS e
JOIN transitive_closure AS tc
ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
)
SELECT * FROM transitive_closure
ORDER BY a, b, distance;
Notice the WHERE condition, which stops the recursion in the presence
of loops. This is very important to avoid errors.
Sample output:
" "
This is a slightly modified version of the same query to deal with
Undirected graphs (NB: this is probably going to be rather slow if done
in real time):
-- DROP VIEW edges2;
CREATE VIEW edges2 (a, b) AS (
SELECT a, b FROM edges
UNION ALL
SELECT b, a FROM edges
);
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string
FROM edges2
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
)
SELECT * FROM transitive_closure
ORDER BY a, b, distance;
Linkedin: Degrees of separation
One of the fundamental characteristics of networks (or graphs in
general) is connectivity. We might want to know how to go from A to B,
or how two people are connected, and we also want to know how many
"hops" separate two nodes, to have an idea about the distance.
For instance, social networks like LinkedIN show our connections or
search results sorted by degree of separation, and trip planning sites
show how many flights you have to take to reach your destination,
usually listing direct connections first.
There are some database extensions or hybrid solutions like SPARQL on
Virtuoso [11] that add a TRANSITIVE clause [12] to make this kind of
queries both easy and efficient, but we want to see how to reach the
same goal with standard SQL.
As you might guess, this becomes really easy once you have the
transitive closure of the graph, we only have to add a WHERE clause
specifying what our source and destination nodes are:
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string
FROM edges
WHERE a = 1 -- source
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
)
SELECT * FROM transitive_closure
WHERE b=6 -- destination
ORDER BY a, b, distance;
" "
If we're showing the trip planning results, then we have a list of all
possible travel solutions; instead of sorting by distance, we might
sort by price or other parameters with little changes.
If we're showing how two people are connected (LinkedIN), then we can
limit the result set to the first row, since we're probably interested
in showing the shortest distance only and not all the other
alternatives.
Instead of adding a LIMIT clause, it's probably more efficient to add
"AND tc.distance = 0" to the WHERE clause of the recursive part of the
CTE, or a GROUP BY clause as follows:
WITH RECURSIVE transitive_closure(a, b, distance, path_string)
AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string
FROM edges2
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
)
SELECT a, b, min(distance) AS dist FROM transitive_closure
--WHERE a = 1 AND b=6
GROUP BY a, b
ORDER BY a, dist, b;
" "
If you are interested in the immediate connections of a certain node,
then specify the starting node and a distance equals to one (by
limiting the recursion at the first level)
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance, a || '.' || b || '.' AS path_string
FROM edges2
WHERE a = 1 -- set the starting node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance = 0 -- limit recursion at the first level
)
SELECT b FROM transitive_closure;
Of course to get the immediate connections there's no need for a
recursive query (just use the one presented at the previous paragraph),
but I thought I'd show it anyway as a first step towards more complex
queries.
LinkedIN has a nice feature to show "How this user is connected to you"
for non directly connected nodes.
If the distance between the two nodes is equal to 2, you can show the
shared connections:
SELECT b FROM (
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance, a || '.' || b || '.' AS path_string
FROM edges2
WHERE a = 1 -- set the starting node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance = 0
)
SELECT b FROM transitive_closure
UNION ALL
(WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance, a || '.' || b || '.' AS path_string
FROM edges2
WHERE a = 4 -- set the target node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance = 0
)
SELECT b FROM transitive_closure
)) AS immediate_connections
GROUP BY b
HAVING COUNT(b) > 1;
In the above query, we select the immediate connections of the two
nodes separately, and then select the shared ones.
For nodes having a distance equals to 3, the approach is slightly
different.
First, you check that the two nodes are indeed at a minimum distance of
3 nodes (you're probably not interested in showing the relationship
between two nodes when the distance is bigger):
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string
FROM edges2
WHERE a = 1 -- set the starting node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance < 3 -- stop the recursion after 3 levels
)
SELECT a, b, min(distance) FROM transitive_closure
WHERE b=4 -- set the target node
GROUP BY a, b
HAVING min(distance) = 3; --set the minimum distance
Then you select the paths between those nodes.
But there's a different approach which is more generic and efficient,
and can be used for all the nodes whose distance is bigger than 2.
The idea is to select the immediate neighbours of the starting node
that are also in the path to the other node.
Depending on the distance, you can have either the shared nodes
(distance = 2), or the connections that could lead to the other node
(distance > 2). In the latter case, you could for instance show how A
is connected to B:
" "
Linkedin
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string,
b AS direct_connection
FROM edges2
WHERE a = 1 -- set the starting node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string,
tc.direct_connection
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance < 3
)
SELECT * FROM transitive_closure
--WHERE b=3 -- set the target node
ORDER BY a,b,distance
" "
Facebook: You might also know
A similar but slightly different requirement is to find those nodes
that are most strongly related, but not directly connected yet. In
other words, it's interesting to find out which and how many connected
nodes are shared between any two nodes, i.e. how many 'friends' are
shared between two individuals. Or better yet, to find those nodes
sharing a certain (minimum) number of nodes with the current one.
This could be useful to suggest a new possible friend, or in the case
of recommendation systems, to suggest a new item/genre that matches the
user's interests.
There are many ways of doing this. In theory, this is bordering on the
domain of collaborative filtering [6][7][8], so using Pearson's
correlation [9] or a similar distance measure with an appropriate
algorithm [10] is going to generate the best results. Collaborative
filtering is an incredibly interesting topic on its own, but outside
the scope of this article.
A rough and inexpensive alternative is to find the nodes having
distance equals to 2, and filter those that either have a common
characteristic with the source node (went to the same school / worked
at the same company, belong to the same interest group / are items of
the same genre) or have several mutual 'friends'.
" "
Facebook
This, again, is easily done once you have the transitive closure of the
graph:
SELECT a AS you,
b AS mightknow,
shared_connection,
CASE
WHEN (n1.feat1 = n2.feat1 AND n1.feat1 = n3.feat1) THEN 'feat1 in commo
n'
WHEN (n1.feat2 = n2.feat2 AND n1.feat2 = n3.feat2) THEN 'feat2 in commo
n'
ELSE 'nothing in common'
END AS reason
FROM (
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string,
b AS direct_connection
FROM edges2
WHERE a = 1 -- set the starting node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string,
tc.direct_connection
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance < 2
)
SELECT a,
b,
direct_connection AS shared_connection
FROM transitive_closure
WHERE distance = 2
) AS youmightknow
LEFT JOIN nodes AS n1 ON youmightknow.a = n1.id
LEFT JOIN nodes AS n2 ON youmightknow.b = n2.id
LEFT JOIN nodes AS n3 ON youmightknow.shared_connection = n3.id
WHERE (n1.feat1 = n2.feat1 AND n1.feat1 = n3.feat1)
OR (n1.feat2 = n2.feat2 AND n1.feat2 = n3.feat2);
" "
Once you have selected these nodes, you can filter those recurring more
often, or give more importance to those having a certain feature in
common, or pick one randomly (so you don't end up suggesting the same
node over and over).
Conclusion
In this article I had some fun with the new and powerful CTEs, and
showed some practical examples where they can be useful. I also showed
some approaches at solving the challenges faced by any social network
or recommendation system.
You are advised that depending on the size of the graph and the
performance requirements of your application, the above queries might
be too slow to run in realtime. Caching is your friend.
Update: Many of the queries in this article have been revised, so
please refer
to [50]http://www.slideshare.net/quipo/rdbms-in-the-social-networks-age
for changes.
References
[1] [51]http://willets.org/sqlgraphs.html
[2] [52]http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.48.53
[3] [53]http://sqlblog.com/blogs/alexander_kuznetsov/archive/2009/06/25
/scalar-udfs-wrapped-in-check-constraints-are-very-slow-and-may-fail-fo
r-multirow-updates.aspx
[4] [54]http://www.dbazine.com/oracle/or-articles/tropashko8
[5] [55]http://en.wikipedia.org/wiki/Collaborative_filtering
[6] [56]http://en.wikipedia.org/wiki/Slope_One
[7] blog.charliezhu.com/2008/07/21/implementing-slope-one-in-t-sql/
[8] bakara.eng.tau.ac.il/~semcomm/slides7/grouplensAlgs-Kahn.pps
[9] [57]http://www.slideshare.net/denisparra/evaluation-of-collaborativ
e-filtering-algorithms-for-recommending-articles-on-citeulike
[10] [58]http://virtuoso.openlinksw.com/
[11] [59]http://www.openlinksw.com/weblog/oerling/?id=1433
[12] [60]http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.48.53
[13] [61]http://en.wikipedia.org/wiki/Transitive_reduction
You might also like...
A woman reviews code on her laptop
Headless commerce: everything you need to know
What the heck is headless? Discover the what, why, and when of headless
architectures with our guide to headless commerce.
Drupal consulting and web development at Inviqa
The Drupal 9 upgrade Config Split issue and how to fix it
In this article we look back at an issue weve encountered with Drupal
Config Split when upgrading Drupal 8 to 9 and we share how to fix it,
so you dont have to run into the same issue when upgrading to Drupal
9.
Inviqa, winner of Webby Awards The Webby Awards winner
Inviqa named one of Top 100 Agencies in Econsultancy Top 100 Digital
Agencies
Inviqa UXUK Awards winner UXUK Awards winner
DADI Award winner of 'Best UX / Usability category' DADI Award winner
Footer Main Navigation
* [62]Home
* [63]Who we are
* [64]What we do
* [65]Case studies
* [66]Careers
* [67]Insights
* [68]Contact
* [69]Accessibility statement
About us
Together with your teams, we shape the digital products, teams,
processes, and software systems you need to meet diverse customer needs
and accelerate your business growth.
© 2007-2024, Inviqa UK Ltd. Registered No. 06278367. Registered Office:
Havas House, Hermitage Court, Hermitage Lane, Maidstone, ME16 9NT, UK.
Footer Legal Links
* [70]Covid-19
* [71]Privacy policy
* [72]Sitemap
References
Visible links:
1. https://inviqa.com/blog/storing-graphs-database-sql-meets-social-network
2. https://www.googletagmanager.com/ns.html?id=GTM-NBN52P
3. https://inviqa.com/blog/storing-graphs-database-sql-meets-social-network#main-content
4. https://inviqa.com/
5. https://inviqa.com/who-we-are
6. https://inviqa.com/who-we-are
7. https://www.havas.com/
8. https://inviqa.com/digital-sustainability-journey
9. https://inviqa.com/what-we-do
10. https://inviqa.com/what-we-do/digital-strategy-consulting-and-development
11. https://inviqa.com/what-we-do/digital-roadmap-development
12. https://inviqa.com/what-we-do/digital-product-design
13. https://inviqa.com/what-we-do/user-research
14. https://inviqa.com/what-we-do/usability-testing
15. https://inviqa.com/what-we-do/technical-architecture-consulting-and-development
16. https://inviqa.com/what-we-do/digital-platform-consulting-and-implementation
17. https://inviqa.com/what-we-do/experience-optimisation
18. https://inviqa.com/what-we-do
19. https://inviqa.com/case-studies
20. https://inviqa.com/case-studies?category=b2b
21. https://inviqa.com/case-studies#fashion
22. https://inviqa.com/case-studies#charity
23. https://inviqa.com/case-studies?category=retail
24. https://inviqa.com/case-studies#leisure
25. https://inviqa.com/case-studies?category=travel
26. https://inviqa.com/case-studies
27. https://inviqa.com/partners
28. https://inviqa.com/akeneo-pim-consulting-and-implementation
29. https://inviqa.com/blog/bigcommerce-7-best-sites
30. https://inviqa.com/drupal-consulting-and-web-development
31. https://inviqa.com/magento-consulting-and-web-development
32. https://inviqa.com/blog/spryker-commerce-platform-introduction
33. https://inviqa.com/partners
34. https://careers.inviqa.com/
35. https://careers.inviqa.com/
36. https://careers.inviqa.com/jobs
37. https://inviqa.com/insights
38. https://inviqa.com/insights/dtc-ecommerce-report-2023
39. https://inviqa.com/insights/PIM-readiness-framework
40. https://inviqa.com/insights/retail-optimisation-guide-2023
41. https://inviqa.com/blog
42. https://inviqa.com/insights
43. https://inviqa.com/contact
44. https://inviqa.com/contact
45. https://inviqa.com/
46. https://inviqa.de/
47. https://inviqa.com/blog#Technology engineering
48. https://www.amazon.com/Joe-Celkos-SQL-Smarties-Programming/dp/0123693799/157-5667933-6571053?ie=UTF8&redirect=true&tag=postcarfrommy-20
49. https://en.wikipedia.org/wiki/Domain-key_normal_form
50. http://www.slideshare.net/quipo/rdbms-in-the-social-networks-age
51. http://willets.org/sqlgraphs.html
52. http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.48.53
53. http://sqlblog.com/blogs/alexander_kuznetsov/archive/2009/06/25/scalar-udfs-wrapped-in-check-constraints-are-very-slow-and-may-fail-for-multirow-updates.aspx
54. http://www.dbazine.com/oracle/or-articles/tropashko8/
55. https://en.wikipedia.org/wiki/Collaborative_filtering
56. https://en.wikipedia.org/wiki/Slope_One
57. http://www.slideshare.net/denisparra/evaluation-of-collaborative-filtering-algorithms-for-recommending-articles-on-citeulike
58. http://virtuoso.openlinksw.com/
59. http://www.openlinksw.com/weblog/oerling/?id=1433
60. http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.48.53
61. https://en.wikipedia.org/wiki/Transitive_reduction
62. https://inviqa.com/we-craft-game-changing-digital-experiences
63. https://inviqa.com/who-we-are
64. https://inviqa.com/what-we-do
65. https://inviqa.com/case-studies
66. https://careers.inviqa.com/
67. https://inviqa.com/insights
68. https://inviqa.com/contact
69. https://inviqa.com/accessibility-statement
70. https://inviqa.com/covid-19-measures
71. https://inviqa.com/privacy-policy-UK
72. https://inviqa.com/sitemap
Hidden links:
74. https://inviqa.com/blog/headless-commerce-everything-you-need-know
75. https://inviqa.com/blog/drupal-9-upgrade-config-split-issue-and-how-fix-it