Se ha denunciado esta presentación.
Utilizamos tu perfil de LinkedIn y tus datos de actividad para personalizar los anuncios y mostrarte publicidad más relevante. Puedes cambiar tus preferencias de publicidad en cualquier momento.

Leveraging the Power of Graph Databases in PHP

704 visualizaciones

Publicado el

Publicado en: Tecnología
  • Sé el primero en comentar

Leveraging the Power of Graph Databases in PHP

  1. 1. Leveraging the Power of Graph Databases in PHP Jeremy Kendall Atlanta PHP April 2015
  2. 2. Obligatory Intro Slide
  3. 3. Also - New Father
  4. 4. What Kind of Database?
  5. 5. Graphs != Charts https://www.flickr.com/photos/markgroves/3065192499/
  6. 6. Graphs != Charts http://stephenwildish.tumblr.com/post/101408321763/friday-project-witch-moral-compass
  7. 7. Graph Databases • Data Model • Nodes with properties • Typed relationships • Strengths • Highly connected data • ACID • Weaknesses • Paradigm shift • Examples • Neo4j, Titan, OrientDB
  8. 8. Why Care? • All the NoSQL Joy • Schema-less • Semi-structured data • Escape from JOIN Hell • Speed
  9. 9. Why Care? • Relationships have 1st class status • Just as important as the objects they connect • You can have properties & labels • Multiple relationships
  10. 10. Why Care?
  11. 11. Speed Depth MySQL Query Time Neo4j Query Time Records Returned 2 0.028 (28 MS) 0.04 ~900 3 0.213 0.06 ~999 4 10.273 0.07 ~999 5 92.613 0.07 ~999 1,000 people with an average 50 friends each
  12. 12. Crazy Speed Depth MySQL Query Time Neo4j Query Time Records Returned 2 0.016 (16 MS) 0.01 ~2500 3 30.27 0.168 ~125,000 4 1543.505 1.359 ~600,000 5 Stopped after 1 hour 2.132 ~800,000 1,000,000 people with an average 50 friends each
  13. 13. Neo4j + Cypher
  14. 14. Cypher • Neo4j’s declarative query language • Easy to pick up • Some clauses and concepts familiar from SQL
  15. 15. Simple Example
  16. 16. Goal
  17. 17. Create Some Nodes CREATE (jk:Person { name: "Jeremy Kendall" }) CREATE (gs:Company { name: "Graph Story" }) CREATE (tn:State { name: "Tennessee" }) CREATE (memphis:City { name: "Memphis" }) CREATE (nashville:City { name: "Nashville" }) CREATE (hotchicken:Food { name: "Hot Chicken" }) CREATE (bbq:Food { name: "Barbecue" }) CREATE (photography:Hobby { name: "Photography" }) CREATE (language:Language { name: "PHP" }) // . . . snip . . .
  18. 18. Create Some Relationships // . . . snip . . . CREATE (jk)-[:WORKS_AT { title: {"CTO"}]->(gs), (jk)-[:LIVES_IN]->(memphis)-[:LIVED_IN]->(nashville), (hotchicken)-[:ONLY_IN]->(nashville), (bbq)-[:ONLY_IN]->(memphis), (jk)-[:LOVES]->(hotchicken), // . . . snip . . .
  19. 19. Create Some Relationships // . . . snip . . . CREATE (jk)-[:WORKS_AT { title: {"CTO"}]->(gs), (jk)-[:LIVES_IN]->(memphis)-[:LIVED_IN]->(nashville), (hotchicken)-[:ONLY_IN]->(nashville), (bbq)-[:ONLY_IN]->(memphis), (jk)-[:LOVES]->(hotchicken), // . . . snip . . .
  20. 20. Create Some Relationships // . . . snip . . . CREATE (jk)-[:WORKS_AT { title: {"CTO"}]->(gs), (jk)-[:LIVES_IN]->(memphis)-[:LIVED_IN]->(nashville), (hotchicken)-[:ONLY_IN]->(nashville), (bbq)-[:ONLY_IN]->(memphis), (jk)-[:LOVES]->(hotchicken), // . . . snip . . .
  21. 21. Create Some Relationships // . . . snip . . . CREATE (jk)-[:WORKS_AT { title: {"CTO"}]->(gs), (jk)-[:LIVES_IN]->(memphis)-[:LIVED_IN]->(nashville), (hotchicken)-[:ONLY_IN]->(nashville), (bbq)-[:ONLY_IN]->(memphis), (jk)-[:LOVES]->(hotchicken), // . . . snip . . .
  22. 22. Example Cypher Query MATCH (p:Person { name: "Jeremy Kendall" })-[:LOVES]->(l) WITH p, l MATCH (p)-[:WORKS_AT]->(j) WITH p, l, j MATCH (p)-[:LIVES_IN]->(c:City)-[:LIVED_IN*0..]->(o:City) RETURN p, l, j, o
  23. 23. Example Cypher Query MATCH (p:Person { name: "Jeremy Kendall" })-[:LOVES]->(l) WITH p, l MATCH (p)-[:WORKS_AT]->(j) WITH p, l, j MATCH (p)-[:LIVES_IN]->(c:City)-[:LIVED_IN*0..]->(o:City) RETURN p, l, j, o
  24. 24. Example Cypher Query MATCH (p:Person { name: "Jeremy Kendall" })-[:LOVES]->(l) WITH p, l MATCH (p)-[:WORKS_AT]->(j) WITH p, l, j MATCH (p)-[:LIVES_IN]->(c:City)-[:LIVED_IN*0..]->(o:City) RETURN p, l, j, o
  25. 25. Example Cypher Query MATCH (p:Person { name: "Jeremy Kendall" })-[:LOVES]->(l) WITH p, l MATCH (p)-[:WORKS_AT]->(j) WITH p, l, j MATCH (p)-[:LIVES_IN]->(c:City)-[:LIVED_IN*0..]->(o:City) RETURN p, l, j, o
  26. 26. Example Cypher Query MATCH (p:Person { name: "Jeremy Kendall" })-[:LOVES]->(l) WITH p, l MATCH (p)-[:WORKS_AT]->(j) WITH p, l, j MATCH (p)-[:LIVES_IN]->(c:City)-[:LIVED_IN*0..]->(o:City) RETURN p, l, j, o
  27. 27. Example Cypher Query MATCH (p:Person { name: "Jeremy Kendall" })-[:LOVES]->(l) WITH p, l MATCH (p)-[:WORKS_AT]->(j) WITH p, l, j MATCH (p)-[:LIVES_IN]->(c:City)-[:LIVED_IN*0..]->(o:City) RETURN p, l, j, o
  28. 28. Example Cypher Query MATCH (p:Person { name: "Jeremy Kendall" })-[:LOVES]->(l) WITH p, l MATCH (p)-[:WORKS_AT]->(j) WITH p, l, j MATCH (p)-[:LIVES_IN]->(c:City)-[:LIVED_IN*0..]->(o:City) RETURN p, l, j, o
  29. 29. Query Result
  30. 30. However!
  31. 31. Easy to Model, Challenging to Master
  32. 32. Subtle(-ish) Bug
  33. 33. Subtle(-ish) Bug
  34. 34. Neo4j + PHP
  35. 35. Neo4jPHP • PHP wrapper for the Neo4j REST API • Installable via Composer • Used internally at Graph Story • Used in this presentation • Well tested • https://packagist.org/packages/everyman/ neo4jphp
  36. 36. Also see: NeoClient • Written by Neoxygen • Alternative PHP wrapper for the Neo4j REST API • Installable via Composer • Accepted for internal use at Graph Story • Well tested • https://packagist.org/packages/neoxygen/neoclient
  37. 37. Connecting $neo4jClient = new EverymanNeo4jClient( ‘yourgraph.example.com’, 7473 ); $neo4jClient->getTransport() ->setAuth('username', 'password') ->getTransport()->useHttps();
  38. 38. Creating a Node and Label $node = new Node($neo4jClient); $label = $neo4jClient->makeLabel('Person'); $node->setProperty('name', ‘Jeremy Kendall'); $node->save()->addLabels(array($label));
  39. 39. Searching // Searching for a label by property $label = $neo4jClient->makeLabel('Person'); $nodes = $label->getNodes('name', $name);
  40. 40. Querying (Cypher) $queryString = 'MATCH (p:Person { name: { name }}) RETURN p'; $query = new EverymanNeo4jCypherQuery( $neo4jClient, $queryString, ['name' => ‘Jeremy Kendall'] ); $result = $query->getResultSet();
  41. 41. Named Parameters
  42. 42. Named Parameters $queryString = 'MATCH (p:Person { name: { name }}) RETURN p'; $query = new EverymanNeo4jCypherQuery( $neo4jClient, $queryString, ['name' => ‘Jeremy Kendall'] ); $result = $query->getResultSet();
  43. 43. Named Parameters $queryString = 'MATCH (p:Person { name: { name }}) RETURN p'; $query = new EverymanNeo4jCypherQuery( $neo4jClient, $queryString, ['name' => ‘Jeremy Kendall'] ); $result = $query->getResultSet();
  44. 44. Content Modeling: News Feeds Graph Kit for PHP https://github.com/GraphStory/graph-kit-php
  45. 45. News Feed • Modeled as a list of posts • Newest post first • All subsequent posts follow • Relationships: LASTPOST and NEXTPOST
  46. 46. LASTPOST
  47. 47. NEXTPOST
  48. 48. The Content Model class Content { public $node; public $nodeId; public $contentId; public $title; public $url; public $tagstr; public $timestamp; public $userNameForPost; public $owner = false; }
  49. 49. Adding Content public static function add($username, Content $content) { $queryString =<<<CYPHER MATCH (user { username: {u}}) OPTIONAL MATCH (user)-[r:LASTPOST]->(lastpost) DELETE r CREATE (user)-[:LASTPOST]->(p:Content { title:{title}, url:{url}, tagstr:{tagstr}, timestamp:{timestamp}, contentId: {contentId} }) WITH p, collect(lastpost) as lastposts FOREACH (x IN lastposts | CREATE p-[:NEXTPOST]->x) RETURN p, {u} as username, true as owner CYPHER; $query = new Query( Neo4jClient::client(), $queryString, array( 'u' => $username, 'title' => $content->title, 'url' => $content->url, 'tagstr' => $content->tagstr, 'timestamp' => time(), 'contentId' => uniqid() ) ); $result = $query->getResultSet(); return self::returnMappedContent($result); }
  50. 50. Adding Content public static function add($username, Content $content) { $queryString =<<<CYPHER MATCH (user { username: {u}}) OPTIONAL MATCH (user)-[r:LASTPOST]->(lastpost) DELETE r CREATE (user)-[:LASTPOST]->(p:Content { title:{title}, url:{url}, tagstr:{tagstr}, timestamp:{timestamp}, contentId: {contentId} }) WITH p, collect(lastpost) as lastposts FOREACH (x IN lastposts | CREATE p-[:NEXTPOST]->x) RETURN p, {u} as username, true as owner CYPHER; $query = new Query( Neo4jClient::client(), $queryString, array( 'u' => $username, 'title' => $content->title, 'url' => $content->url, 'tagstr' => $content->tagstr, 'timestamp' => time(), 'contentId' => uniqid() ) ); $result = $query->getResultSet(); return self::returnMappedContent($result); }
  51. 51. Adding Content public static function add($username, Content $content) { $queryString =<<<CYPHER MATCH (user { username: {u}}) OPTIONAL MATCH (user)-[r:LASTPOST]->(lastpost) DELETE r CREATE (user)-[:LASTPOST]->(p:Content { title:{title}, url:{url}, tagstr:{tagstr}, timestamp:{timestamp}, contentId: {contentId} }) WITH p, collect(lastpost) as lastposts FOREACH (x IN lastposts | CREATE p-[:NEXTPOST]->x) RETURN p, {u} as username, true as owner CYPHER; $query = new Query( Neo4jClient::client(), $queryString, array( 'u' => $username, 'title' => $content->title, 'url' => $content->url, 'tagstr' => $content->tagstr, 'timestamp' => time(), 'contentId' => uniqid() ) ); $result = $query->getResultSet(); return self::returnMappedContent($result); }
  52. 52. Adding Content MATCH (user { username: {u}}) OPTIONAL MATCH (user)-[r:LASTPOST]->(lastpost) DELETE r CREATE (user)-[:LASTPOST]->(p:Content { title:{title}, url: {url}, tagstr:{tagstr}, timestamp:{timestamp}, contentId: {contentId} }) WITH p, collect(lastpost) as lastposts FOREACH (x IN lastposts | CREATE p-[:NEXTPOST]->x) RETURN p, {u} as username, true as owner
  53. 53. Adding Content MATCH (user { username: {u}}) OPTIONAL MATCH (user)-[r:LASTPOST]->(lastpost) DELETE r CREATE (user)-[:LASTPOST]->(p:Content { title:{title}, url: {url}, tagstr:{tagstr}, timestamp:{timestamp}, contentId: {contentId} }) WITH p, collect(lastpost) as lastposts FOREACH (x IN lastposts | CREATE p-[:NEXTPOST]->x) RETURN p, {u} as username, true as owner
  54. 54. Adding Content MATCH (user { username: {u}}) OPTIONAL MATCH (user)-[r:LASTPOST]->(lastpost) DELETE r CREATE (user)-[:LASTPOST]->(p:Content { title:{title}, url: {url}, tagstr:{tagstr}, timestamp:{timestamp}, contentId: {contentId} }) WITH p, collect(lastpost) as lastposts FOREACH (x IN lastposts | CREATE p-[:NEXTPOST]->x) RETURN p, {u} as username, true as owner
  55. 55. Adding Content MATCH (user { username: {u}}) OPTIONAL MATCH (user)-[r:LASTPOST]->(lastpost) DELETE r CREATE (user)-[:LASTPOST]->(p:Content { title:{title}, url: {url}, tagstr:{tagstr}, timestamp:{timestamp}, contentId: {contentId} }) WITH p, collect(lastpost) as lastposts FOREACH (x IN lastposts | CREATE p-[:NEXTPOST]->x) RETURN p, {u} as username, true as owner
  56. 56. Adding Content MATCH (user { username: {u}}) OPTIONAL MATCH (user)-[r:LASTPOST]->(lastpost) DELETE r CREATE (user)-[:LASTPOST]->(p:Content { title:{title}, url: {url}, tagstr:{tagstr}, timestamp:{timestamp}, contentId: {contentId} }) WITH p, collect(lastpost) as lastposts FOREACH (x IN lastposts | CREATE p-[:NEXTPOST]->x) RETURN p, {u} as username, true as owner
  57. 57. Adding Content MATCH (user { username: {u}}) OPTIONAL MATCH (user)-[r:LASTPOST]->(lastpost) DELETE r CREATE (user)-[:LASTPOST]->(p:Content { title:{title}, url: {url}, tagstr:{tagstr}, timestamp:{timestamp}, contentId: {contentId} }) WITH p, collect(lastpost) as lastposts FOREACH (x IN lastposts | CREATE p-[:NEXTPOST]->x) RETURN p, {u} as username, true as owner
  58. 58. Adding Content $query = new Query( $neo4jClient, $queryString, array( 'u' => $username, 'title' => $content->title, 'url' => $content->url, 'tagstr' => $content->tagstr, 'timestamp' => time(), 'contentId' => uniqid() ) ); $result = $query->getResultSet();
  59. 59. Retrieving Content public static function getContent($username, $skip) { $queryString = <<<CYPHER MATCH (u:User { username: { u }})-[:FOLLOWS*0..1]->f WITH DISTINCT f, u MATCH f-[:LASTPOST]-lp-[:NEXTPOST*0..]-p RETURN p, f.username as username, f = u as owner ORDER BY p.timestamp desc SKIP { skip } LIMIT 4 CYPHER; $query = new Query( Neo4jClient::client(), $queryString, array( 'u' => $username, 'skip' => $skip, ) ); $result = $query->getResultSet(); return self::returnMappedContent($result); }
  60. 60. Retrieving Content MATCH (u:User { username: { u }})-[:FOLLOWS*0..1]->f WITH DISTINCT f, u MATCH f-[:LASTPOST]-lp-[:NEXTPOST*0..]-p RETURN p, f.username as username, f = u as owner ORDER BY p.timestamp desc SKIP { skip } LIMIT 4
  61. 61. Retrieving Content MATCH (u:User { username: { u }})-[:FOLLOWS*0..1]->f WITH DISTINCT f, u MATCH f-[:LASTPOST]-lp-[:NEXTPOST*0..]-p RETURN p, f.username as username, f = u as owner ORDER BY p.timestamp desc SKIP { skip } LIMIT 4
  62. 62. Retrieving Content MATCH (u:User { username: { u }})-[:FOLLOWS*0..1]->f WITH DISTINCT f, u MATCH f-[:LASTPOST]-lp-[:NEXTPOST*0..]-p RETURN p, f.username as username, f = u as owner ORDER BY p.timestamp desc SKIP { skip } LIMIT 4
  63. 63. Retrieving Content MATCH (u:User { username: { u }})-[:FOLLOWS*0..1]->f WITH DISTINCT f, u MATCH f-[:LASTPOST]-lp-[:NEXTPOST*0..]-p RETURN p, f.username as username, f = u as owner ORDER BY p.timestamp desc SKIP { skip } LIMIT 4
  64. 64. Retrieving Content MATCH (u:User { username: { u }})-[:FOLLOWS*0..1]->f WITH DISTINCT f, u MATCH f-[:LASTPOST]-lp-[:NEXTPOST*0..]-p RETURN p, f.username as username, f = u as owner ORDER BY p.timestamp desc SKIP { skip } LIMIT 4
  65. 65. Retrieving Content MATCH (u:User { username: { u }})-[:FOLLOWS*0..1]->f WITH DISTINCT f, u MATCH f-[:LASTPOST]-lp-[:NEXTPOST*0..]-p RETURN p, f.username as username, f = u as owner ORDER BY p.timestamp desc SKIP { skip } LIMIT 4
  66. 66. Editing Content public static function edit(Content $content) { $updatedAt = time(); $node = $content->node; $node->setProperty('title', $content->title); $node->setProperty('url', $content->url); $node->setProperty('tagstr', $content->tagstr); $node->setProperty('updated', $updatedAt); $node->save(); $content->updated = $updatedAt; return $content; }
  67. 67. Editing Content public static function edit(Content $content) { $updatedAt = time(); $node = $content->node; $node->setProperty('title', $content->title); $node->setProperty('url', $content->url); $node->setProperty('tagstr', $content->tagstr); $node->setProperty('updated', $updatedAt); $node->save(); $content->updated = $updatedAt; return $content; }
  68. 68. Editing Content public static function edit(Content $content) { $updatedAt = time(); $node = $content->node; $node->setProperty('title', $content->title); $node->setProperty('url', $content->url); $node->setProperty('tagstr', $content->tagstr); $node->setProperty('updated', $updatedAt); $node->save(); $content->updated = $updatedAt; return $content; }
  69. 69. Editing Content public static function edit(Content $content) { $updatedAt = time(); $node = $content->node; $node->setProperty('title', $content->title); $node->setProperty('url', $content->url); $node->setProperty('tagstr', $content->tagstr); $node->setProperty('updated', $updatedAt); $node->save(); $content->updated = $updatedAt; return $content; }
  70. 70. Editing Content public static function edit(Content $content) { $updatedAt = time(); $node = $content->node; $node->setProperty('title', $content->title); $node->setProperty('url', $content->url); $node->setProperty('tagstr', $content->tagstr); $node->setProperty('updated', $updatedAt); $node->save(); $content->updated = $updatedAt; return $content; }
  71. 71. Deleting Content public static function delete($username, $contentId) { $queryString = self::getDeleteQueryString( $username, $contentId ); $params = array( 'username' => $username, 'contentId' => $contentId, ); $query = new Query( $neo4jClient, $queryString, $params ); $query->getResultSet(); }
  72. 72. Deleting Content public static function delete($username, $contentId) { $queryString = self::getDeleteQueryString( $username, $contentId ); $params = array( 'username' => $username, 'contentId' => $contentId, ); $query = new Query( $neo4jClient, $queryString, $params ); $query->getResultSet(); }
  73. 73. Deleting Content public static function delete($username, $contentId) { $queryString = self::getDeleteQueryString( $username, $contentId ); $params = array( 'username' => $username, 'contentId' => $contentId, ); $query = new Query( $neo4jClient, $queryString, $params ); $query->getResultSet(); }
  74. 74. Deleting Content public static function delete($username, $contentId) { $queryString = self::getDeleteQueryString( $username, $contentId ); $params = array( 'username' => $username, 'contentId' => $contentId, ); $query = new Query( $neo4jClient, $queryString, $params ); $query->getResultSet(); }
  75. 75. Deleting Content: Leaf // If leaf MATCH (u:User { username: { username }})-[:LASTPOST| NEXTPOST*0..]->(c:Content { contentId: { contentId }}) WITH c MATCH (c)-[r]-() DELETE c, r
  76. 76. Deleting Content: Leaf // If leaf MATCH (u:User { username: { username }})-[:LASTPOST| NEXTPOST*0..]->(c:Content { contentId: { contentId }}) WITH c MATCH (c)-[r]-() DELETE c, r
  77. 77. Deleting Content: Leaf // If leaf MATCH (u:User { username: { username }})-[:LASTPOST| NEXTPOST*0..]->(c:Content { contentId: { contentId }}) WITH c MATCH (c)-[r]-() DELETE c, r
  78. 78. Deleting Content: Leaf // If leaf MATCH (u:User { username: { username }})-[:LASTPOST| NEXTPOST*0..]->(c:Content { contentId: { contentId }}) WITH c MATCH (c)-[r]-() DELETE c, r
  79. 79. Deleting Content: Leaf // If leaf MATCH (u:User { username: { username }})-[:LASTPOST| NEXTPOST*0..]->(c:Content { contentId: { contentId }}) WITH c MATCH (c)-[r]-() DELETE c, r
  80. 80. Deleting Content: LASTPOST // If last MATCH (u:User { username: { username }})-[lp:LASTPOST]- >(del:Content { contentId: { contentId }})-[np:NEXTPOST]- >(nextPost) CREATE UNIQUE (u)-[:LASTPOST]->(nextPost) DELETE lp, del, np
  81. 81. Deleting Content: LASTPOST // If last MATCH (u:User { username: { username }})-[lp:LASTPOST]- >(del:Content { contentId: { contentId }})-[np:NEXTPOST]- >(nextPost) CREATE UNIQUE (u)-[:LASTPOST]->(nextPost) DELETE lp, del, np
  82. 82. Deleting Content: LASTPOST // If last MATCH (u:User { username: { username }})-[lp:LASTPOST]- >(del:Content { contentId: { contentId }})-[np:NEXTPOST]- >(nextPost) CREATE UNIQUE (u)-[:LASTPOST]->(nextPost) DELETE lp, del, np
  83. 83. Deleting Content: LASTPOST // If last MATCH (u:User { username: { username }})-[lp:LASTPOST]- >(del:Content { contentId: { contentId }})-[np:NEXTPOST]- >(nextPost) CREATE UNIQUE (u)-[:LASTPOST]->(nextPost) DELETE lp, del, np
  84. 84. Deleting Content: Other // All other MATCH (u:User { username: { username }})-[:LASTPOST| NEXTPOST*0..]->(before), (before)-[delBefore]->(del:Content { contentId: { contentId }})-[delAfter]->(after) CREATE UNIQUE (before)-[:NEXTPOST]->(after) DELETE del, delBefore, delAfter
  85. 85. Deleting Content: Other // All other MATCH (u:User { username: { username }})-[:LASTPOST| NEXTPOST*0..]->(before), (before)-[delBefore]->(del:Content { contentId: { contentId }})-[delAfter]->(after) CREATE UNIQUE (before)-[:NEXTPOST]->(after) DELETE del, delBefore, delAfter
  86. 86. Deleting Content: Other // All other MATCH (u:User { username: { username }})-[:LASTPOST| NEXTPOST*0..]->(before), (before)-[delBefore]->(del:Content { contentId: { contentId }})-[delAfter]->(after) CREATE UNIQUE (before)-[:NEXTPOST]->(after) DELETE del, delBefore, delAfter
  87. 87. Deleting Content: Other // All other MATCH (u:User { username: { username }})-[:LASTPOST| NEXTPOST*0..]->(before), (before)-[delBefore]->(del:Content { contentId: { contentId }})-[delAfter]->(after) CREATE UNIQUE (before)-[:NEXTPOST]->(after) DELETE del, delBefore, delAfter
  88. 88. Deleting Content: Other // All other MATCH (u:User { username: { username }})-[:LASTPOST| NEXTPOST*0..]->(before), (before)-[delBefore]->(del:Content { contentId: { contentId }})-[delAfter]->(after) CREATE UNIQUE (before)-[:NEXTPOST]->(after) DELETE del, delBefore, delAfter
  89. 89. Questions?
  90. 90. Thanks! jeremy.kendall@graphstory.com @JeremyKendall http://www.graphstory.com

×