Rozdział 11
SQL Graph
Funkcjonalność SQL Graph stanowi alternatywną w stosunku do tradycyjnego podejścia metodę modelowania i odpytywania danych. Została wprowadzona w wydaniu SQL Server 2017 i dalej jest rozwijana i usprawniana. Z punktu widzenia modelowania obejmuje to używanie specjalizowanych tabel węzłów (node) oraz krawędzi (edge) do przechowywania danych, gdzie węzły reprezentują wierzchołki grafu, a krawędzie połączeniom pomiędzy parami węzłów. Odpytywanie danych wykorzystuje specjalizowaną składnię, w której wyliczamy obiekty uczestniczące w zapytaniu i wskazujemy ich połączenia w klauzuli MATCH.
Jeśli ktoś rozważa stosowanie projektu opartego na grafach, zalecałbym bardzo staranne przeczytanie tego rozdziału. Konieczne jest zrozumienie wszystkich zalet i wad tego podejścia, gdy porównamy je z tradycyjnym projektowaniem, aby móc zdecydować, czy te zalety usprawiedliwiają odmienne modelowanie i kodowanie. Jeszcze więcej ostrożności jest potrzebne, gdy rozważamy migrację istniejącej, tradycyjnej implementacji do opartej na SQL Graph, biorąc pod uwagę nieuniknione obciążenie refaktoryzacją całego kodu.
W prostych, typowych wymaganiach zapytań, gdzie po prostu łączymy węzły krawędziami, funkcja SQL Graph często daje w efekcie zwięźlejszy i bardziej intuicyjny kod zapytań, gdy porównamy go z bardziej tradycyjnymi zapytaniami wykorzystującymi złączenia i podobne narzędzia. Dla takich jedynie potrzeb trudno byłoby jednak usprawiedliwić odejście od tradycyjnego (doskonale znanego i opanowanego) modelowania i odpytywania danych. Funkcjonalność SQL Graph pokazuje swój potencjał i prawdziwie zaczyna błyszczeć dopiero w skomplikowanych scenariuszach, takich jak wyszukiwanie najkrótszej ścieżki pomiędzy węzłami grafu, tworzenie przechodniego domknięcia grafu, przechodzenie ścieżek o dowolnej, zadanej z góry długości i tak dalej. Niektóre z takich możliwości są już dostępne w języku T-SQL, ale nie wszystkie. W miarę jak więcej funkcji z tego obszaru będzie dodawanych do T-SQL, atrakcyjność używania opcji SQL Graph będzie się powiększać.
Rozdział ten rozpoczniemy od porównania modelu tradycyjnego i opartego na grafach dla fikcyjnej sieci społecznościowej, w tym tworzenia i wypełniania tabel przy wykorzystaniu obydwu modeli. Następnie zajmiemy się odpytywanie tabel grafów przy użyciu klauzuli MATCH, włącznie z zapytaniami rekurencyjnymi, a także użyciem opcji SHORTEST_PATH. Następnie omówimy uwarunkowania modyfikowania danych.
Uwaga Aby możliwe było uruchomienie przykładów kodu i wykonanie ćwiczeń z tego rozdziału, konieczne jest korzystanie z wydania SQL Server 2019 lub późniejszego albo z Azure SQL Database.
Tworzenie tabel
Scenariusz używany jako przykładowy w tym rozdziale dotyczy fikcyjnej sieci społecznościowej. Rozpocznę od zaprezentowania uczestniczących jednostek (bytów) i interakcji występujących pomiędzy nimi. Następnie pokażę, jak zaprojektować i zaimplementować dane przy wykorzystaniu tradycyjnego modelowania, a później zrealizuję to samo zadanie, ale przy wykorzystaniu modelowania opartego na grafach.
Jednostki uczestniczące w naszej sieci społecznościowej to:
Konta Mogą być to konta osobiste albo firmowe. Każde konto ma identyfikator, nazwę, datę dołączenia do sieci oraz punkty reputacji (co będziemy traktować jako ocenę uzyskaną w tej społeczności przez dane konto).
Posty Mogą być to komunikaty statusu albo elementy wiadomości, wysyłane przez pojedynczego autora (konto). Post zawiera identyfikator, datę i czas wysłania oraz tekst stanowiący treść.
Publikacje Są to książki, artykuły i blogi, tworzone przez jedno lub więcej kont. Publikacja ma identyfikator, datę publikowania oraz tytuł. W sytuacji rzeczywistej zapewne chcielibyśmy przechowywać więcej informacji na temat publikacji, ale dla zachowania prostoty przykładu zakładam, że te informacje będą (na razie) wystarczające.
Jednostki te wchodzą ze sobą w rozmaite interakcje:
Dwa konta mogą się zaznajomić ze sobą. Jest to relacja dwukierunkowa, w której jedno konto zgłasza propozycję znajomości, a drugie ją potwierdza. Po ustanowieniu relacji dwa konta stają się znajomymi i mają dostęp do pewnych elementów, które są zastrzeżone tylko dla znajomych. W odniesieniu do tej interakcji należy zarejestrować datę rozpoczęcia znajomości oraz oba uczestniczące w niej konta.
Konto może obserwować inne, aby automatycznie uzyskiwać dostęp do publikowanych przez nie wiadomości. Jest to relacja jednokierunkowa. Konto A może obserwować konto B, gdy konto B nie obserwuje konta A. Musimy tu zarejestrować, które konto jest obserwującym, a które obserwowanym, a także datę rozpoczęcia relacji.
Post jest wysyłany przez pewne konto.
Post może mieć post nadrzędny (rodzica) - w takiej sytuacji jest odpowiedzią.
Konto polubiło post. Trzeba zarejestrować datę i godzinę tego zdarzenia.
Pojawiła się publikacja utworzona przez jedno lub więcej kont.
Aby wyraźnie rozróżnić normalne tabele modelujące od tabel opartych na grafach, każdy zestaw tabel umieścimy w jego własnym schemacie. Schematy te nazwałem odpowiednio Norm i Graph. Uruchamiamy poniższy kod, aby utworzyć oba schematy w przykładowej bazie danych TSQLV6:
USE TSQLV6;
GO
CREATE SCHEMA Norm; -- schemat dla tradycyjnego modelowania
GO
CREATE SCHEMA Graph; -- schemat dla modelowania wykorzystującego graf
Sugeruję tu, aby przed zapoznaniem się z proponowanym przeze mnie projektem, Czytelnik spróbował samodzielnie utworzyć własny projekt tradycyjnego modelu naszej sieci społecznościowej, reprezentujący wymienione wyżej jednostki i interakcje pomiędzy nimi.
Modelowanie tradycyjne
Rysunek 11-1 przedstawia diagram proponowanego projektu bazy danych dla naszej fikcyjnej sieci społecznościowej, bazujący na modelowaniu tradycyjnym. Pokazuje uczestniczące w nim tabele, ich kolumny, klucze główne i klucze obce.
Rysunek 11-1 Tradycyjny model danych
Projekt ten jest całkiem prosty i samoobjaśniający. Nadal jednak chciałbym zwrócić uwagę na kilka wyborów projektowych opartych na tradycyjnym modelowaniu, które, jak się przekonamy za chwilę, w modelowaniu grafowym są obsługiwane w inny sposób:
W rozmaitych dyskusjach na temat modelowania danych często powraca zagadnienie, czy nazwy tabel powinny używać form liczby pojedynczej, czy mnogiej. Przypomnijmy, że tabela w SQL jest odpowiednikiem relacji (w rozumieniu teorii relacyjnej) i tak jak ciało relacji jest zbiorem krotek, ciało tabeli jest zbiorem wierszy. Tym samym w modelowaniu tradycyjnym bardziej odpowiednie wydaje się użycie form liczby mnogiej dla nazw tabel: Accounts, Posts, Publications i tak dalej.
W relacji znajomości (friendship) obydwa konta mają równorzędne role. Stwierdzenie, że konto A jest znajomym konta B jest równoważne powiedzeniu, że konto B jest znajomym konta A. Oznacza to, że relacja ta jest zwrotna. W modelowaniu tradycyjnym, aby zarejestrować fakt, że konta o identyfikatorach 71 i 379 są znajomymi, mamy do wyboru dwie opcje (z technicznego punktu widzenia), jak zapisać to w tabeli Friendships. Możemy albo zapisać wiersz, w którym kolumna accountid1 zawiera 71, a kolumna accountid2 to 379, albo odwrotnie. Przechowywanie dwóch wierszy z obydwoma wariantami uporządkowania byłoby nadmiarowe. Mamy do dyspozycji pewien trik chroniący przed tą nadmiarowością. Możemy wprowadzić do tabeli ograniczenie z predykatem accountid1 < accountid2, no i oczywiście zadbać o to, że przy wypełnianiu tabel będziemy dostarczać identyfikatory kont we właściwej kolejności, aby uniknąć naruszenia ograniczenia.
W przeciwieństwie do relacji znajomości, relacja obserwowania (follow) nie jest zwrotna. Konto A może obserwować konto B, gdy konto B wcale nie musi obserwować konta A. A jeśli obserwują się wzajemnie, może być tak, że konto A zaczęło obserwować konto B pewnego dnia, zaś konto B zaczęło obserwować konto A innego dnia. Zatem będziemy chcieli przechować każdą relację obserwowania w tabeli Followings jako oddzielny wiersz, gdzie accountid1 reprezentuje osobę obserwującą, a accountid2 osobę obserwowaną. Naturalnie tu nie będziemy wprowadzać żadnego ograniczenia w rodzaju tego zastosowanego w tabeli Friendships.
Post jest wysyłany przez tylko jedno konto, zatem nie potrzebujemy oddzielnej tabeli do przechowania informacji, jakie konta wysłały jaki post. Zamiast tego mamy informującą o tym kolumnę accountid w tabeli Posts. Klucz obcy dla kolumny Posts.accountid odwołujący się do Accounts.accountid wymusza integralność referencyjną.
Post może mieć albo jeden post nadrzędny (jeśli jest to odpowiedź), albo żadnego, zatem nie potrzebujemy oddzielnej tabeli do przechowania informacji, który post jest rodzicem którego postu. Mamy do tego w tabeli Posts kolumnę parentpostid, dopuszczającą znaczniki NULL oraz klucz obcy odwołujący się do tej samej tabeli, wymuszający integralność referencyjną.
W przeciwieństwie do postu, publikacja może mieć wielu autorów; dlatego model danych używa oddzielnej tabeli o nazwie AuthorsPublications, która przechowuje informacje o tym, które konta (współ)tworzyły które publikacje.
Ostatecznie nasz model danych składa się łącznie z siedmiu tabel.
Użyjemy poniższego kodu, aby utworzyć i wypełnić tabele uczestniczące w tradycyjnym modelu danych (wszystkie w schemacie Norm, aby można było je łatwo odróżnić od tabel Graph, które utworzymy później):
-- Tabela Accounts
CREATE TABLE Norm.Accounts
(
accountid INT NOT NULL,
accountname NVARCHAR(50) NOT NULL,
joindate DATE NOT NULL
CONSTRAINT DFT_Accounts_joindate DEFAULT(SYSDATETIME()),
reputationpoints INT NOT NULL
CONSTRAINT DFT_Accounts_reputationpoints DEFAULT(0),
CONSTRAINT PK_Accounts PRIMARY KEY(accountid)
);
INSERT INTO Norm.Accounts
(accountid, accountname, joindate, reputationpoints) VALUES
(641, N'Inka' , '20200801', 5),
( 71, N'Miko' , '20210514', 8),
(379, N'Tami' , '20211003', 5),
(421, N'Buzi' , '20210517', 8),
(661, N'Alma' , '20210119', 13),
( 2, N'Orli' , '20220202', 2),
(941, N'Stav' , '20220105', 1),
(953, N'Omer' , '20220315', 0),
(727, N'Mitzi' , '20200714', 3),
(883, N'Yatzek', '20210217', 3),
(199, N'Lilach', '20220112', 1);
-- Tabela Posts
CREATE TABLE Norm.Posts
(
postid INT NOT NULL,
parentpostid INT NULL,
accountid INT NOT NULL,
dt DATETIME2(0) NOT NULL
CONSTRAINT DFT_Posts_dt DEFAULT(SYSDATETIME()),
posttext NVARCHAR(1000) NOT NULL,
CONSTRAINT PK_Posts PRIMARY KEY(postid),
CONSTRAINT FK_Posts_Accounts FOREIGN KEY(accountid)
REFERENCES Norm.Accounts(accountid),
CONSTRAINT FK_Posts_Posts FOREIGN KEY(parentpostid)
REFERENCES Norm.Posts(postid)
);
INSERT INTO Norm.Posts
(postid, parentpostid, accountid, dt, posttext) VALUES
( 13, NULL, 727, '20200921 13:09:46' ,
N'Got a new kitten. Any suggestions for a name?'),
( 109, NULL, 71, '20210515 17:00:00' ,
N'Starting to hike the PCT today. Wish me luck!'),
( 113, NULL, 421, '20210517 10:21:33' ,
N'Buzi here. This is my first post.'),
( 149, NULL, 421, '20210519 14:05:45' ,
N'Buzi here. This is my second post.'
+ N' Aren''t. my posts exciting?'),
( 179, NULL, 421, '20210520 09:12:17' ,
N'Buzi here. Guess what; this is my third post!'),
( 199, NULL, 71, '20210802 15:56:02' ,
N'Made it to Oregon!'),
( 239, NULL, 883, '20220219 09:31:23' ,
N'I''m thinking of growing a mustache,'
+ N' but am worried about milk drinking...'),
( 281, NULL, 953, '20220318 08:14:24' ,
N'Burt Shavits: "A good day is when no one shows up'
+ N' and you don''t have to go anywhere."'),
( 449, 13, 641, '20200921 13:10:30' ,
N'Maybe Pickle?'),
( 677, 13, 883, '20200921 13:12:22' ,
N'Ambrosius?'),
( 857, 109, 883, '20210515 17:02:13' ,
N'Break a leg. I mean, don''t!'),
( 859, 109, 379, '20210515 17:04:21' ,
N'The longest I''ve seen you hike was...'
+ N'wait, I''ve never seen you hike ;)'),
( 883, 109, 199, '20210515 17:23:43' ,
N'Ha ha ha!'),
(1021, 449, 2, '20200921 13:44:17' ,
N'It does look a bit sour faced :)'),
(1031, 449, 379, '20200921 14:02:03' ,
N'How about Gherkin?'),
(1051, 883, 71, '20210515 17:24:35' ,
N'Jokes aside, is 95lbs reasonable for my backpack?'),
(1061, 1031, 727, '20200921 14:07:51' ,
N'I love Gherkin!'),
(1151, 1051, 379, '20210515 18:40:12' ,
N'Short answer, no! Long answer, nooooooo!!!'),
(1153, 1051, 883, '20210515 18:47:17' ,
N'Say what!?'),
(1187, 1061, 641, '20200921 14:07:52' ,
N'So you don''t like Pickle!? I''M UNFRIENDING YOU!!!'),
(1259, 1151, 71, '20210515 19:05:54' ,
N'Did I say that was without water?');
-- Tabela Publications
CREATE TABLE Norm.Publications
(
pubid INT NOT NULL,
pubdate DATE NOT NULL,
title NVARCHAR(100) NOT NULL,
CONSTRAINT PK_Publications PRIMARY KEY(pubid)
);
INSERT INTO Norm.Publications(pubid, pubdate, title) VALUES
(23977, '20200912' , N'When Mitzi met Inka'),
( 4967, '20210304' , N'When Mitzi left Inka'),
(27059, '20210401' , N'It''s actually Inka who left Mitzi'),
(14563, '20210802' ,
N'Been everywhere, seen it all; there''s no place like home!'),
(46601, '20220119' , N'Love at first second');
-- Tabela Friendships
CREATE TABLE Norm.Friendships
(
accountid1 INT NOT NULL,
accountid2 INT NOT NULL,
startdate DATE NOT NULL
CONSTRAINT DFT_Friendships_startdate DEFAULT(SYSDATETIME()),
CONSTRAINT PK_Friendships PRIMARY KEY(accountid1, accountid2),
-- nieskierowany graf; nie pozwalamy na lustrzane pary
CONSTRAINT CHK_Friendships_act1_lt_act2
CHECK(accountid1 < accountid2),
CONSTRAINT FK_Friendships_Accounts_act1 FOREIGN KEY(accountid1)
REFERENCES Norm.Accounts(accountid),
CONSTRAINT FK_Friendships_Accounts_act2 FOREIGN KEY(accountid2)
REFERENCES Norm.Accounts(accountid)
);
INSERT INTO Norm.Friendships
(accountid1, accountid2, startdate) VALUES
( 2, 379, '20220202'),
( 2, 641, '20220202'),
( 2, 727, '20220202'),
( 71, 199, '20220112'),
( 71, 379, '20211003'),
( 71, 661, '20210514'),
( 71, 883, '20210514'),
( 71, 953, '20220315'),
(199, 661, '20220112'),
(199, 883, '20220112'),
(199, 941, '20220112'),
(199, 953, '20220315'),
(379, 421, '20211003'),
(379, 641, '20211003'),
(421, 661, '20210517'),
(421, 727, '20210517'),
(641, 727, '20200801'),
(661, 883, '20210217'),
(661, 941, '20220105'),
(727, 883, '20210217'),
(883, 953, '20220315');
-- Tabela Followings
CREATE TABLE Norm.Followings
(
accountid1 INT NOT NULL,
accountid2 INT NOT NULL,
startdate DATE NOT NULL
CONSTRAINT DFT_Followings_startdate DEFAULT(SYSDATETIME()),
CONSTRAINT PK_Followings PRIMARY KEY(accountid1, accountid2),
CONSTRAINT FK_Followings_Accounts_act1 FOREIGN KEY(accountid1)
REFERENCES Norm.Accounts(accountid),
CONSTRAINT FK_Followings_Accounts_act2 FOREIGN KEY(accountid2)
REFERENCES Norm.Accounts(accountid)
);
INSERT INTO Norm.Followings
(accountid1, accountid2, startdate) VALUES
(641, 727, '20200802'),
(883, 199, '20220113'),
( 71, 953, '20220316'),
(661, 421, '20210518'),
(199, 941, '20220114'),
( 71, 883, '20210516'),
(199, 953, '20220317'),
(661, 941, '20220106'),
(953, 71, '20220316'),
(379, 2, '20220202'),
(421, 661, '20210518'),
(661, 71, '20210516'),
( 2, 727, '20220202'),
( 2, 379, '20220203'),
(379, 641, '20211004'),
(941, 199, '20220112'),
(727, 421, '20210518'),
(379, 71, '20211005'),
(941, 661, '20220105'),
(641, 2, '20220204'),
(953, 199, '20220316'),
(727, 883, '20210218'),
(421, 379, '20211004'),
( 71, 379, '20211004'),
(641, 379, '20211003'),
(199, 883, '20220114'),
(727, 2, '20220203'),
(199, 71, '20220113'),
(953, 883, '20220317'),
( 71, 661, '20210514');
-- Tabela Likes
CREATE TABLE Norm.Likes
(
accountid INT NOT NULL,
postid INT NOT NULL,
dt DATETIME2(0) NOT NULL
CONSTRAINT DFT_Likes_dt DEFAULT(SYSDATETIME()),
CONSTRAINT PK_Likes PRIMARY KEY(accountid, postid),
CONSTRAINT FK_Likes_Accounts FOREIGN KEY(accountid)
REFERENCES Norm.Accounts(accountid),
CONSTRAINT FK_Likes_Posts FOREIGN KEY(postid)
REFERENCES Norm.Posts(postid)
);
INSERT INTO Norm.Likes(accountid, postid, dt) VALUES
( 2, 13, '2020-09-21 15:33:46'),
(199, 109, '2021-05-16 03:24:00'),
(379, 109, '2021-05-15 21:48:00'),
(379, 113, '2021-05-19 04:45:33'),
(661, 113, '2021-05-17 21:33:33'),
(727, 113, '2021-05-18 09:33:33'),
(379, 179, '2021-05-21 10:00:17'),
(661, 179, '2021-05-20 22:00:17'),
(727, 179, '2021-05-21 00:24:17'),
(199, 199, '2021-08-02 22:20:02'),
( 71, 239, '2022-02-20 07:55:23'),
(199, 239, '2022-02-21 04:43:23'),
(661, 239, '2022-02-19 12:43:23'),
(727, 239, '2022-02-20 21:31:23'),
( 2, 449, '2020-09-21 20:22:30'),
(379, 449, '2020-09-22 12:22:30'),
(727, 449, '2020-09-21 19:34:30'),
( 71, 677, '2020-09-23 08:24:22'),
(199, 677, '2020-09-23 12:24:22'),
(661, 677, '2020-09-23 05:12:22'),
(727, 677, '2020-09-21 17:12:22'),
(953, 677, '2020-09-23 11:36:22'),
( 71, 857, '2021-05-16 09:50:13'),
(199, 857, '2021-05-17 00:14:13'),
(661, 857, '2021-05-16 08:14:13'),
(727, 857, '2021-05-17 07:26:13'),
(953, 857, '2021-05-16 11:26:13'),
( 2, 859, '2021-05-15 21:52:21'),
( 71, 859, '2021-05-17 05:04:21'),
(421, 859, '2021-05-17 11:28:21'),
( 71, 883, '2021-05-17 03:47:43'),
(379, 1021, '2020-09-22 20:56:17'),
(641, 1021, '2020-09-23 04:56:17'),
( 2, 1031, '2020-09-21 16:26:03'),
( 71, 1031, '2020-09-23 00:26:03'),
(421, 1031, '2020-09-23 10:02:03'),
(199, 1051, '2021-05-17 12:36:35'),
( 2, 1061, '2020-09-22 08:31:51'),
(421, 1061, '2020-09-23 06:07:51'),
(641, 1061, '2020-09-21 18:55:51'),
(883, 1061, '2020-09-21 20:31:51'),
( 2, 1151, '2021-05-17 13:04:12'),
( 71, 1151, '2021-05-16 22:40:12'),
(421, 1151, '2021-05-16 01:04:12'),
(641, 1151, '2021-05-15 22:40:12'),
( 2, 1187, '2020-09-23 13:19:52'),
(379, 1187, '2020-09-22 13:19:52');
-- Tabela AuthorsPublications
CREATE TABLE Norm.AuthorsPublications
(
accountid INT NOT NULL,
pubid INT NOT NULL,
CONSTRAINT PK_AuthorsPublications PRIMARY KEY(pubid, accountid),
CONSTRAINT FK_AuthorsPublications_Accounts FOREIGN KEY(accountid)
REFERENCES Norm.Accounts(accountid),
CONSTRAINT FK_AuthorsPublications_Publications FOREIGN KEY(pubid)
REFERENCES Norm.Publications(pubid)
);
INSERT INTO Norm.AuthorsPublications(accountid, pubid) VALUES
(727, 23977),
(641, 23977),
(727, 4967),
(641, 27059),
(883, 14563),
(883, 46601),
(199, 46601);
Jeśli przejrzymy kod tworzący i wypełniający tabele, możemy zauważyć, że można łatwo zidentyfikować wspomniane wcześniej elementy, takie jak ograniczenie check w tabeli Friendships, klucze główne, klucze obce i tak dalej.
Modelowanie grafu
W modelowaniu bazującym na grafach mamy dwa główne rodzaje jednostek: węzeł (node) oraz krawędź (edge). Węzły reprezentują wierzchołki, czyli punkty końcowe relacji. Krawędź reprezentuje to, jak jeden węzeł, nazywany początkowym (from node), jest powiązany z innym węzłem, określanym jako końcowy (to node).
Przy posługiwaniu się funkcjonalnością SQL Graph definiujemy tabelę dla każdego węzła i każdej krawędzi, oznaczając te tabele odpowiednio za pomocą klauzuli AS NODE lub AS EDGE. Podczas odpytywania danych, zamiast posługiwania się złączeniami do połączenia węzłów krawędziami, używamy klauzuli MATCH, a w niej formy strzałki od węzła początkowego do węzła końcowego, posługując się składnią przypominającą grafiki ASCII-art, jak poniżej:
węzeł_początkowy-(krawędź)->węzeł_końcowy
Oto przykład takiej strzałki łączącej węzeł Account z węzłem Post za pośrednictwem krawędzi Likes:
Account-(Likes)->Post
Jak można zauważyć, można ten kod po prostu przeczytać (po angielsku), co sprawia, że jest on bardzo intuicyjny.
W modelowaniu grafowym przyjęło się nazywanie węzłów za pomocą rzeczowników w formie liczby pojedynczej - na przykład Account, Post i Publication. W przypadku krawędzi zwyczajowo używamy czasowników w trzeciej osobie liczby pojedynczej - na przykład Follows. Można też próbować uwzględnić aspekt czasowy w nazewnictwie krawędzi. Jeśli krawędź reprezentuje relację, która obecnie jest aktywna, zazwyczaj wybieram czas teraźniejszy, na przykład Likes lub Follows. Gdy relacja oznacza coś, co miało miejsce w przeszłości, wolę używać czasu przeszłego, jak w Posted lub Authored. Niekiedy sensowne jest nawet poprzedzenie czasownika w nazwie krawędzi czasownikiem pomocniczym, takim jak is (jest), a czasem również uzupełnienie go dodatkowym przyimkiem, na przykład with (z), to (do, na) albo of, jak w IsReplyTo (jest odpowiedzią na) albo IsFriendOf. Wystarczy myśleć po angielsku, a intuicyjnie sformułujemy nazwę relacji.
Mając to na uwadze, byłem w stanie zidentyfikować trzy węzły oraz sześć krawędzi w grafowym modelu danych naszej fikcyjnej sieci społecznościowej, co pokazuje rysunek 11-2.
Rysunek 11-2 Węzły i krawędzie grafu
Jak można zauważyć, wygodne jest przedstawianie węzłów jako prostokątów, a krawędzi jako strzałek.
Zarówno węzły, jak i krawędzie są implementowane jako tabele w bazie danych. Rysunek 11-3 pokazuje szczegółowy model danych bazujący na grafach naszej fikcyjnej sieci społecznościowej.
Rysunek 11-3 Grafowy model danych
Warto odnotować kilka spostrzeżeń dotyczących grafowego modelu danych:
Tabele węzłów zawierają kolumnę o nazwie $node_id. Ta kolumna jest tworzona przez SQL Server jako uzupełnienie kolumn definiowanych przez użytkowników i unikatowo identyfikuje konkretny węzeł w bazie danych. Ten identyfikator jest tworzony jako kombinacja ID obiektu tabeli oraz automatycznie generowanej wartości typu BIGINT, znanej jako ID grafowe (identyfikator elementu, unikatowy w obrębie grafu). Jeśli odpytamy kolumnę $node_id, otrzymamy obliczoną reprezentację tej wartości w formacie ciągu JSON.
Analogicznie do tabel węzłów, które zawierają automatycznie tworzoną, niejawną kolumnę $node_id unikatowo identyfikującą węzeł w bazie danych, tabele krawędzi zawierają również automatycznie tworzoną kolumnę $edge_id, unikatowo identyfikującą krawędź. Jej wartości również są tworzone jako kombinacja ID obiektu tabeli i automatycznie generowanego ID grafowego typu BIGINT. Także w przypadku kolumny $edge_id jej odpytanie zwraca obliczoną reprezentację wartości w formacie ciągu JSON.
Jak pamiętamy, krawędź łączy dwa węzły - węzeł początkowy (from node) oraz końcowy (to node). Tym samym każda tabela krawędzi zawiera parę kolumn o nazwach $from_node i $to_node, które przechowują identyfikatory węzłów łączonych przez daną krawędź. Kiedy dodajemy wiersz do tabeli krawędzi, musimy zadbać o uzyskanie identyfikatorów węzłów dla węzła początkowego i końcowego, które ma połączyć nowa krawędź i zapisać je odpowiednio w kolumnach $from_id oraz $to_id.
Tabela krawędzi może, choć nie musi zawierać dodatkowe kolumny definiowane przez użytkownika, poza obowiązkowymi, tworzonymi przez system kolumnami $edge_id, $from_node i $to_node. Dla przykładu tabela krawędzi Follows zawiera zdefiniowaną przez użytkownika kolumnę o nazwie startdate, ale tabela krawędzi IsReplyTo nie zawiera żadnych kolumn zdefiniowanych przez użytkownika.
Przypomnijmy, że w tradycyjnym modelu danych tabela Posts zawiera kolumnę parentpostid, łączącą dany post z jego nadrzędnym (rodzicem), jeśli to jest istotne. Zauważmy, że w modelu grafowym tabela węzła Post nie zawiera podobnej kolumny parentpostid; zamiast tego mamy oddzielną tabelę krawędzi o nazwie IsReplyTo, która łączy post potomny (odpowiedź) z postem nadrzędnym. Analogicznie tabela Posts w tradycyjnym modelu zawiera kolumnę accountid, wskazującą ID konta, które utworzyło dany post. Jednak przeciwnie, w modelu grafowym informację o tym, które konto wysłało dany post, przechowujemy w oddzielnej tabeli krawędzi o nazwie Posted. Można tu zauważyć pewną wskazówkę - relacja klucza obcego w modelu tradycyjnym zazwyczaj przekłada się na tabele krawędzi w modelowaniu grafowym.
Aby móc wygodnie identyfikować i odpytywać wiersz węzła na podstawie cech definiowanych przez użytkownika, lubię wprowadzać do tabel węzłów własny, zdefiniowany przeze mnie klucz. Zwróćmy uwagę na kolumnę accountid w tabeli Account, postid w tabeli Post i tak dalej. Teoretycznie moglibyśmy polegać na generowanej przez system wartości ID grafowego jako części kolumny $node_id, ale posługiwanie się nią jest trochę nieporęczne. Co więcej, jeśli z jakiegokolwiek powodu będziemy musieli usunąć wiersz węzła, a później wstawić go ponownie, ta kopia otrzyma nową wartość ID grafowego.
Jak widać, model danych oparty na grafie doprowadził w sumie do dziewięciu tabel, w porównaniu do siedmiu tabel w modelu tradycyjnym.
Tworzenie tabel węzłów
Aby utworzyć tabelę węzłów, specyfikujemy klauzulę AS NODE na końcu definicji tabel. Definicje kolumn przekazujemy jak zwykle, wraz z ich nazwami, typami danych, nullowalnością i tak dalej. Nie podajemy natomiast definicji kolumny $node_id - przypomnijmy, że SQL Server tworzy tę kolumnę niejawnie.
Użyjemy poniższego kodu do utworzenia tabeli Account w schemacie Graph:
CREATE TABLE Graph.Account
(
accountid INT NOT NULL,
accountname NVARCHAR(50) NOT NULL,
joindate DATE NOT NULL
CONSTRAINT DFT_Account_joindate DEFAULT(SYSDATETIME()),
reputationpoints INT NOT NULL
CONSTRAINT DFT_Account_reputationpoints DEFAULT(0),
CONSTRAINT PK_Account PRIMARY KEY(accountid)
) AS NODE;
Poniższy kod wypełnia tę tabelę danymi:
INSERT INTO Graph.Account
(accountid, accountname, joindate, reputationpoints) VALUES
(641, N'Inka' , '20200801', 5),
( 71, N'Miko' , '20210514', 8),
(379, N'Tami' , '20211003', 5),
(421, N'Buzi' , '20210517', 8),
(661, N'Alma' , '20210119', 13),
( 2, N'Orli' , '20220202', 2),
(941, N'Stav' , '20220105', 1),
(953, N'Omer' , '20220315', 0),
(727, N'Mitzi' , '20200714', 3),
(883, N'Yatzek', '20210217', 3),
(199, N'Lilach', '20220112', 1);
Po utworzeniu możemy użyć poniższego kodu, aby sprawdzić zawartość tabeli:
SELECT * FROM Graph.Account;
Kod ten generuje poniższe wyjście (przeformatowane, aby zmieściło się na szerokość strony):
$node_id_778BA26000F9442194D5F7A4EFC932A0
-----------------------------------------------------------
{"type":"node","schema":"Graph","table":"Account","id":5}
{"type":"node","schema":"Graph","table":"Account","id":1}
{"type":"node","schema":"Graph","table":"Account","id":10}
{"type":"node","schema":"Graph","table":"Account","id":2}
{"type":"node","schema":"Graph","table":"Account","id":3}
{"type":"node","schema":"Graph","table":"Account","id":0}
{"type":"node","schema":"Graph","table":"Account","id":4}
{"type":"node","schema":"Graph","table":"Account","id":8}
{"type":"node","schema":"Graph","table":"Account","id":9}
{"type":"node","schema":"Graph","table":"Account","id":6}
{"type":"node","schema":"Graph","table":"Account","id":7}
accountid accountname joindate reputationpoints
---------- ------------ ---------- ----------------
2 Orli 2022-02-02 2
71 Miko 2021-05-14 8
199 Lilach 2022-01-12 1
379 Tami 2021-10-03 5
421 Buzi 2021-05-17 8
641 Inka 2020-08-01 5
661 Alma 2021-01-19 13
727 Mitzi 2020-07-14 3
883 Yatzek 2021-02-17 3
941 Stav 2022-01-05 1
953 Omer 2022-03-15 0
Jak wspomniałem wcześniej, gdy odczytujemy zawartość kolumny $node_id (w tym przykładzie jako część żądania SELECT *), otrzymujemy ciągi w formacie JSON reprezentujące wartości kolumny, a nie jej rzeczywistą, wewnętrzną postać. Można zauważyć, że automatycznie generowane wartości identyfikatora grafu zaczynają się od 0 (przypisanej do wiersza z wartością accountid równą 641, która została wyspecyfikowana jako pierwsza w instrukcji INSERT). Zwrócimy jednak uwagę, że w odróżnieniu od właściwości tożsamości (identity), jeśli wyczyścimy tabelę węzłów i wypełnimy ją ponownie, ID grafowy nie jest resetowany do 0, ale otrzymuje wartości kolejne. A także w przeciwieństwie do identity, nie istnieje sposób narzucenia własnych, jawnie zadeklarowanych wartości ID grafowego.
Poniższy kod pozwala utworzyć i wypełnić pozostałe tabele węzłów, czyli Post i Publication:
-- Tabela Post
CREATE TABLE Graph.Post
(
postid INT NOT NULL,
dt DATETIME2(0) NOT NULL
CONSTRAINT DFT_Post_dt DEFAULT(SYSDATETIME()),
posttext NVARCHAR(1000) NOT NULL,
CONSTRAINT PK_Post PRIMARY KEY(postid)
) AS NODE;
INSERT INTO Graph.Post(postid, dt, posttext) VALUES
( 13, '20200921 13:09:46' ,
N'Got a new kitten. Any suggestions for a name?'),
( 109, '20210515 17:00:00' ,
N'Starting to hike the PCT today. Wish me luck!'),
( 113, '20210517 10:21:33' ,
N'Buzi here. This is my first post.'),
( 149, '20210519 14:05:45' ,
N'Buzi here. This is my second post.'
+ N' Aren''t my posts exciting?'),
( 179, '20210520 09:12:17' ,
N'Buzi here. Guess what; this is my third post!'),
( 199, '20210802 15:56:02' ,
N'Made it to Oregon!'),
( 239, '20220219 09:31:23' ,
N'I''m thinking of growing a mustache,'
+ N' but am worried about milk drinking...'),
( 281, '20220318 08:14:24' ,
N'Burt Shavits: "A good day is when no one shows up'
+ N' and you don''t have to go anywhere."'),
( 449, '20200921 13:10:30' ,
N'Maybe Pickle?'),
( 677, '20200921 13:12:22' ,
N'Ambrosius?'),
( 857, '20210515 17:02:13' ,
N'Break a leg. I mean, don''t!'),
( 859, '20210515 17:04:21' ,
N'The longest I''ve seen you hike was...'
+ N'wait, I''ve never seen you hike ;)'),
( 883, '20210515 17:23:43' ,
N'Ha ha ha!'),
(1021, '20200921 13:44:17' ,
N'It does look a bit sour faced :)'),
(1031, '20200921 14:02:03' ,
N'How about Gherkin?'),
(1051, '20210515 17:24:35' ,
N'Jokes aside, is 95lbs reasonable for my backpack?'),
(1061, '20200921 14:07:51' ,
N'I love Gherkin!'),
(1151, '20210515 18:40:12' ,
N'Short answer, no! Long answer, nooooooo!!!'),
(1153, '20210515 18:47:17' ,
N'Say what!?'),
(1187, '20200921 14:07:52' ,
N'So you don''t like Pickle!? I''M UNFRIENDING YOU!!!'),
(1259, '20210515 19:05:54' ,
N'Did I say that was without water?');
-- Tabela Publication
CREATE TABLE Graph.Publication
(
pubid INT NOT NULL,
pubdate DATE NOT NULL,
title NVARCHAR(100) NOT NULL,
CONSTRAINT PK_Publication PRIMARY KEY(pubid)
) AS NODE;
INSERT INTO Graph.Publication(pubid, pubdate, title) VALUES
(23977, '20200912' , N'When Mitzi met Inka'),
( 4967, '20210304' , N'When Mitzi left Inka'),
(27059, '20210401' , N'It''s actually Inka who left Mitzi'),
(14563, '20210802' ,
N'Been everywhere, seen it all; there''s no place like home!'),
(46601, '20220119' , N'Love at first second');
Warto wykonać zapytania do tych tabel, aby sprawdzić ich zawartość.
Tworzenie tabel krawędzi
Podobnie jak w przypadku tabel węzłów, do utworzenia tabeli krawędzi używamy instrukcji CREATE TABLE. Specyfikujemy nazwę tabeli, definicję w nawiasach (czyli kolumny użytkowników i ograniczenia), a na końcu definicji dodajemy klauzulę AS EDGE. Nie podajemy definicji kolumn $node_id, $from_id ani $to_id; jak pamiętamy, SQL Server tworzy je niejawnie. Jeśli nie potrzebujemy definiować własnych kolumn i ograniczeń, jedynie podajemy nazwę tabeli uzupełnioną o klauzulę AS EDGE, jak poniżej:
CREATE TABLE <nazwa_tabeli> AS EDGE;
Jeśli chcemy zdefiniować własne kolumny i ograniczenia, podajemy ich specyfikację w nawiasach, jak zwykle:
CREATE TABLE <nazwa_tabeli> (<kolumny_i_ograniczenia>) AS EDGE;
Przy posługiwaniu się SQL Server 2019 lub wersją późniejszą albo Azure SQL Database można ograniczyć to, które tabele węzłów może łączyć dana tabela krawędzi, używając ograniczenia krawędzi. Pomagają one uniknąć powstawania osieroconych wierszy krawędzi bazujących na nieistniejących węzłach.
Składnia ograniczenia krawędzi wygląda następująco:
[CONSTRAINT <nazwa_ograniczenia>]
CONNECTION (<tabela_from_node_1> TO <tabela_to_node_1>
[ ... , <tabela_from_node_n> TO <tabela_to_node_n>])
[ON DELETE <akcja_referencyjna>]
Akcją referencyjną usuwania (ostatni wiersz składni) może być NO ACTION albo CASCADE. Ta pierwsza oznacza, że chcemy, aby SQL Server odrzucił próbę usuwania węzła, dla którego istnieją łączące krawędzie. Ta druga oznacza, że chcemy, aby przy usuwaniu węzła SQL Server automatycznie usunął wszystkie dotyczące go krawędzie. Nie jest dostępna akcja referencyjna aktualizacji, po prostu dlatego, że nie ma możliwości aktualizowania zawartości kolumny $node_id dla węzła.
Poniższy kod tworzy tabelę krawędzi IsReplyTo:
CREATE TABLE Graph.IsReplyTo
(
CONSTRAINT EC_IsReplyTo CONNECTION (Graph.Post TO Graph.Post)
ON DELETE NO ACTION
) AS EDGE;
Przypomnijmy, że krawędź przechowywana w tabeli IsReplyTo ma na celu połączenie postu podrzędnego (odpowiedzi) z postem nadrzędnym. Dlatego używamy ograniczenia krawędzi, aby narzucić łączenie jedynie tabeli węzłów Post reprezentującej post podrzędny z tabelą Post reprezentującą post nadrzędny. Jako że tabela krawędzi IsReplyTo nie zawiera żadnych kolumn definiowanych przez użytkownika, jej definicja zawiera tylko ograniczenie krawędzi.
Nie można zdefiniować ograniczeń klucza obcego dla kolumn $from_id ani $to_id tabeli krawędzi, aby odwoływały się do kolumn $node_id w odpowiednich tabelach węzłów. Ta restrykcja ma uzasadnienie. Co, jeśli chcielibyśmy użyć jednej tabeli krawędzi do przechowywania krawędzi łączących węzły pochodzące z więcej niż jednej pary tabel węzłów? Dwa różne ograniczenia klucza obcego byłyby w konflikcie ze sobą nawzajem. Ograniczenie krawędzi jest bardziej zaawansowane od ograniczenia klucza obcego pod tym względem, że wspiera ono specyfikowanie wielu par początkowa tabela węzłów - końcowa tabela węzłów w jednym warunku. Jeśli potrzebujemy obsłużyć taki przypadek, należy posłużyć się składnią podobną do pokazanej poniżej:
CREATE TABLE MyEdgeTable
(
CONSTRAINT MyConstraint
CONNECTION (MyFromNode1 TO MyToNode1, MyFromNode2 TO MyToNode2, ...)
ON DELETE NO ACTION
) AS EDGE;
W tym przypadku dowolne z ograniczeń musi być spełnione dla każdej pary węzłów, co oznacza, że nasza tabela krawędzi będzie obsługiwać pary węzłów ze wszystkich wyspecyfikowanych par tabel węzłów.
Nie należy specyfikować oddzielnych ograniczeń krawędzi dla każdej pary:
CREATE TABLE MyEdgeTable
(
CONSTRAINT MyConstraint1
CONNECTION (MyFromNode1 TO MyToNode1)
ON DELETE NO ACTION,
CONSTRAINT MyConstraint2
CONNECTION (MyFromNode2 TO MyToNode2)
ON DELETE NO ACTION,
...
) AS EDGE;
Taka składnia oznaczałaby, że mamy wiele obcych kluczy, które powinny być obsługiwane, zatem wszystkie ograniczenia musiałyby być spełnione dla każdej pary węzłów - coś, co typowo nie jest możliwe.
Aby zapobiec dodawania zduplikowanych wierszy krawędzi, czyli takich, których węzeł początkowy i końcowy należą do tej samej pary węzłów, dobrym pomysłem mogłoby być wprowadzenie ograniczenia unikatowości bazującego na kolumnach $from_id i $to_id. Nie może być to ograniczenie klucza głównego, gdyż kolumny te są nullowalne. Użyjemy poniższego kodu, aby wprowadzić tego rodzaju ograniczenie unikatowości do tabeli krawędzi IsReplyTo:
ALTER TABLE Graph.IsReplyTo
ADD CONSTRAINT UNQ_IsReplyTo_fromid_toid UNIQUE($from_id, $to_id);
Następnie musimy wstawić wiersze krawędzi do tabeli IsReplyTo, aby połączyć ze sobą pary postów o następujących wartościach postid:
postid węzła from postid węzła to
------------------- -----------------
449 13
677 13
857 109
859 109
883 109
1021 449
1031 449
1051 883
1061 1031
1151 1051
1153 1051
1187 1061
1259 1151
Jednak działanie funkcjonalności SQL Graph polega na tym, że powinniśmy przechować wartości $node_id dla węzła początkowego i końcowego w kolumnach $from_node oraz $to_node wiersza krawędzi, odpowiednio. To my jesteśmy odpowiedzialni za wydobycie odpowiednich wartości $node_id z uczestniczących tabel węzłów. Jeden ze sposobów, aby to osiągnąć, choć nieco rozwlekły, polega na użyciu skalarnego podzapytania dla każdego węzła, w którym odpytujemy tabelę węzłów, odfiltrowujemy wiersz z zadaną wartością postid i zwracamy odpowiadającą mu wartość $node_id, jak poniżej:
INSERT INTO Graph.IsReplyTo($from_id, $to_id) VALUES
( (SELECT $node_id FROM Graph.Post WHERE postid = 449),
(SELECT $node_id FROM Graph.Post WHERE postid = 13) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 677),
(SELECT $node_id FROM Graph.Post WHERE postid = 13) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 857),
(SELECT $node_id FROM Graph.Post WHERE postid = 109) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 859),
(SELECT $node_id FROM Graph.Post WHERE postid = 109) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 883),
(SELECT $node_id FROM Graph.Post WHERE postid = 109) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 1021),
(SELECT $node_id FROM Graph.Post WHERE postid = 449) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 1031),
(SELECT $node_id FROM Graph.Post WHERE postid = 449) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 1051),
(SELECT $node_id FROM Graph.Post WHERE postid = 883) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 1061),
(SELECT $node_id FROM Graph.Post WHERE postid = 1031) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 1151),
(SELECT $node_id FROM Graph.Post WHERE postid = 1051) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 1153),
(SELECT $node_id FROM Graph.Post WHERE postid = 1051) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 1187),
(SELECT $node_id FROM Graph.Post WHERE postid = 1061) ),
( (SELECT $node_id FROM Graph.Post WHERE postid = 1259),
(SELECT $node_id FROM Graph.Post WHERE postid = 1151) );
Możemy teraz odpytać tabelę IsReplyTo, aby zobaczyć, co w niej zostało zapisane:
SELECT * FROM Graph.IsReplyTo;
Kod ten generuje poniższe wyniki (ponownie sformatowane, aby zmieściły się na stronie książki):
$edge_id_8C951A35BF65467D9DC10CFB020A005B
-------------------------------------------------------------
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":0}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":1}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":2}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":3}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":4}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":5}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":6}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":7}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":8}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":9}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":10}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":11}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":12}
$from_id_6B162D7BAC56496E8F5D005313C07BEC
--------------------------------------------------------
{"type":"node","schema":"Graph","table":"Post","id":8}
{"type":"node","schema":"Graph","table":"Post","id":9}
{"type":"node","schema":"Graph","table":"Post","id":10}
{"type":"node","schema":"Graph","table":"Post","id":11}
{"type":"node","schema":"Graph","table":"Post","id":12}
{"type":"node","schema":"Graph","table":"Post","id":13}
{"type":"node","schema":"Graph","table":"Post","id":14}
{"type":"node","schema":"Graph","table":"Post","id":15}
{"type":"node","schema":"Graph","table":"Post","id":16}
{"type":"node","schema":"Graph","table":"Post","id":17}
{"type":"node","schema":"Graph","table":"Post","id":18}
{"type":"node","schema":"Graph","table":"Post","id":19}
{"type":"node","schema":"Graph","table":"Post","id":20}
$to_id_2EBB26F1CACA47AE8F0278E019472C7F
--------------------------------------------------------
{"type":"node","schema":"Graph","table":"Post","id":0}
{"type":"node","schema":"Graph","table":"Post","id":0}
{"type":"node","schema":"Graph","table":"Post","id":1}
{"type":"node","schema":"Graph","table":"Post","id":1}
{"type":"node","schema":"Graph","table":"Post","id":1}
{"type":"node","schema":"Graph","table":"Post","id":8}
{"type":"node","schema":"Graph","table":"Post","id":8}
{"type":"node","schema":"Graph","table":"Post","id":12}
{"type":"node","schema":"Graph","table":"Post","id":14}
{"type":"node","schema":"Graph","table":"Post","id":15}
{"type":"node","schema":"Graph","table":"Post","id":15}
{"type":"node","schema":"Graph","table":"Post","id":16}
{"type":"node","schema":"Graph","table":"Post","id":17}
Podobnie jak w przypadku nowych wierszy węzłów, każdemu nowemu wierszowi krawędzi przypisywana jest wewnętrznie generowana wartość ID grafowego, zaczynając od 0. W tym przypadku otrzymaliśmy wartości ID w zakresie od 0 do 12, jako że mamy 13 krawędzi. Pamiętajmy, że nie możemy podać własnych, jawnych wartości ID grafowego i jeśli usuniemy lub wyczyścimy wiersze, wartości te nie zostaną zresetowane - będą kontynuowane od wartości kolejnej po nadanej przy ostatnim wstawieniu. Co do wartości $from_id i $to_id, zostały one odczytane z odpowiednich wierszy tabeli węzłów Post.
Uwaga W chwili pisania tych słów nie jest możliwa zmiana (aktualizacja) wartości w kolumnach $from_id i $to_id dla już istniejącego wiersza krawędzi. Jeśli konieczne jest zastosowanie zmiany któregoś z końcowych węzłów (albo obydwu) istniejącej krawędzi, trzeba usunąć ten wiersz i wstawić nowy. Spowoduje to utworzenie nowej wartości ID grafowego jako części nowej wartości $edge_id.
Istnieje też bardziej zwięzła technika wstawienia zbioru wierszy na podstawie par kluczy węzłów definiowanych przez użytkownika (postid w naszym przypadku). Definiujemy konstruktor o wartościach tablicowych (szczegóły zawiera punkt "Wyrażenie INSERT VALUES" w rozdziale 8) z parami kluczy dostarczonych przez użytkownika i złączamy go z dwoma interesującymi nas tabelami węzłów, aby uzyskać odpowiednie wartości $node_id. Poniższy kod stosuje tę technikę w celu ponownego wstawienia wierszy krawędzi (naturalnie po wcześniejszym opróżnieniu tabeli):
TRUNCATE TABLE Graph.IsReplyTo;
INSERT INTO Graph.IsReplyTo($from_id, $to_id)
SELECT FP.$node_id AS fromid, TP.$node_id AS toid
FROM (VALUES( 449, 13),
( 677, 13),
( 857, 109),
( 859, 109),
( 883, 109),
(1021, 449),
(1031, 449),
(1051, 883),
(1061, 1031),
(1151, 1051),
(1153, 1051),
(1187, 1061),
(1259, 1151)) AS D(frompostid, topostid)
INNER JOIN Graph.Post AS FP
ON D.frompostid = FP.postid
INNER JOIN Graph.Post AS TP
ON D.topostid = TP.postid;
Ponownie odpytujemy tę tabelę:
SELECT * FROM Graph.IsReplyTo;
Kod ten generuje poniższe wyjście:
$edge_id_9638455E25A24E77B56C525D5B51294F
-------------------------------------------------------------
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":13}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":14}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":15}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":16}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":17}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":18}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":19}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":20}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":21}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":22}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":23}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":24}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":25}
$from_id_88C5F44AF3614A819FA26AFD5302DFF2
--------------------------------------------------------
{"type":"node","schema":"Graph","table":"Post","id":8}
{"type":"node","schema":"Graph","table":"Post","id":9}
{"type":"node","schema":"Graph","table":"Post","id":10}
{"type":"node","schema":"Graph","table":"Post","id":11}
{"type":"node","schema":"Graph","table":"Post","id":12}
{"type":"node","schema":"Graph","table":"Post","id":13}
{"type":"node","schema":"Graph","table":"Post","id":14}
{"type":"node","schema":"Graph","table":"Post","id":15}
{"type":"node","schema":"Graph","table":"Post","id":16}
{"type":"node","schema":"Graph","table":"Post","id":17}
{"type":"node","schema":"Graph","table":"Post","id":18}
{"type":"node","schema":"Graph","table":"Post","id":19}
{"type":"node","schema":"Graph","table":"Post","id":20}
$to_id_AE36188F967349FE85D008F44EEFCC49
--------------------------------------------------------
{"type":"node","schema":"Graph","table":"Post","id":0}
{"type":"node","schema":"Graph","table":"Post","id":0}
{"type":"node","schema":"Graph","table":"Post","id":1}
{"type":"node","schema":"Graph","table":"Post","id":1}
{"type":"node","schema":"Graph","table":"Post","id":1}
{"type":"node","schema":"Graph","table":"Post","id":8}
{"type":"node","schema":"Graph","table":"Post","id":8}
{"type":"node","schema":"Graph","table":"Post","id":12}
{"type":"node","schema":"Graph","table":"Post","id":14}
{"type":"node","schema":"Graph","table":"Post","id":15}
{"type":"node","schema":"Graph","table":"Post","id":15}
{"type":"node","schema":"Graph","table":"Post","id":16}
{"type":"node","schema":"Graph","table":"Post","id":17}
Wartości $from_id i $to_id oczywiście pozostają te same, co poprzednio, jako że łączymy ze sobą te same pary węzłów; można jednak zauważyć, że wartości ID grafowego stanowiące części $edge_id są nowe. Tym razem zaczynają się od 13 (następnej liczby po maksymalnej wartości 12 wygenerowanej w poprzednim wsadzie) i biegną do 25.
Jeśli wykonujemy migrację danych z już istniejącej implementacji opartej na modelowaniu tradycyjnym do nowej, bazującej na grafie, możemy posłużyć się następującym procesem:
1. Tworzymy tabele węzłów.
2. Wypełniamy każdą tabelę węzłów za pomocą instrukcji INSERT SELECT z odpowiedniej tabeli początkowej, pozostawiając SQL Server utworzenie nowych wartości $node_id.
3. Tworzymy tabele krawędzi.
4. Wypełniamy każdą tabelę krawędzi za pomocą instrukcji INSERT SELECT z tabeli początkowej zawierającej pary połączonych kluczy, złączonej z już istniejącymi tabelami węzłów, aby uzyskać odpowiadające tym kluczom wartości $node_id.
Kroki 1, 2 i 3 są oczywiste. Przypuśćmy zatem, że już je zastosowaliśmy w naszej bazie danych. Oto kod implementujący krok 4 w celu wypełnienia końcowej tabeli krawędzi Graph.IsReplyTo danymi z początkowej tabeli Norm.Posts (ponownie najpierw czyścimy ją z zawartości, którą zawierała wcześniej:
TRUNCATE TABLE Graph.IsReplyTo;
INSERT INTO Graph.IsReplyTo($from_id, $to_id)
SELECT FP.$node_id AS fromid, TP.$node_id AS toid
FROM Norm.Posts AS P
INNER JOIN Graph.Post AS FP
ON P.postid = FP.postid
INNER JOIN Graph.Post AS TP
ON P.parentpostid = TP.postid;
Możemy teraz odpytać tabelę IsReplyTo:
SELECT * FROM Graph.IsReplyTo;
Kod ten generuje poniższe wyjście:
$edge_id_9638455E25A24E77B56C525D5B51294F
--------------------------------------------------------------
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":26}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":27}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":28}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":29}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":30}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":31}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":32}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":33}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":34}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":35}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":36}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":37}
{"type":"edge","schema":"Graph","table":"IsReplyTo","id":38}
$from_id_88C5F44AF3614A819FA26AFD5302DFF2
--------------------------------------------------------
{"type":"node","schema":"Graph","table":"Post","id":8}
{"type":"node","schema":"Graph","table":"Post","id":9}
{"type":"node","schema":"Graph","table":"Post","id":10}
{"type":"node","schema":"Graph","table":"Post","id":11}
{"type":"node","schema":"Graph","table":"Post","id":12}
{"type":"node","schema":"Graph","table":"Post","id":13}
{"type":"node","schema":"Graph","table":"Post","id":14}
{"type":"node","schema":"Graph","table":"Post","id":15}
{"type":"node","schema":"Graph","table":"Post","id":16}
{"type":"node","schema":"Graph","table":"Post","id":17}
{"type":"node","schema":"Graph","table":"Post","id":18}
{"type":"node","schema":"Graph","table":"Post","id":19}
{"type":"node","schema":"Graph","table":"Post","id":20}
$to_id_AE36188F967349FE85D008F44EEFCC49
--------------------------------------------------------
{"type":"node","schema":"Graph","table":"Post","id":0}
{"type":"node","schema":"Graph","table":"Post","id":0}
{"type":"node","schema":"Graph","table":"Post","id":1}
{"type":"node","schema":"Graph","table":"Post","id":1}
{"type":"node","schema":"Graph","table":"Post","id":1}
{"type":"node","schema":"Graph","table":"Post","id":8}
{"type":"node","schema":"Graph","table":"Post","id":8}
{"type":"node","schema":"Graph","table":"Post","id":12}
{"type":"node","schema":"Graph","table":"Post","id":14}
{"type":"node","schema":"Graph","table":"Post","id":15}
{"type":"node","schema":"Graph","table":"Post","id":15}
{"type":"node","schema":"Graph","table":"Post","id":16}
{"type":"node","schema":"Graph","table":"Post","id":17}
Co oczywiste, krawędzie łączą te same wartości $from_id i $to_id, co poprzednio, ale uzyskały nowe wartości ID grafowego w zakresie od 26 do 38 jako części ich wartości $edge_id.
Mam nadzieję, że teraz jest już jasne, jak tworzyć i wypełniać tabele krawędzi. Poniższy kod tworzy i wypełnia pozostałe tabele krawędzi w naszym grafowym modelu danych (Posted, IsFriendOf, Follows, Likes oraz Authored):
-- Tabela Posted
CREATE TABLE Graph.Posted
(
CONSTRAINT EC_Posted CONNECTION (Graph.Account TO Graph.Post)
ON DELETE NO ACTION
) AS EDGE;
ALTER TABLE Graph.Posted
ADD CONSTRAINT UNQ_Posted_fromid_toid UNIQUE($from_id, $to_id);
INSERT INTO Graph.Posted($from_id, $to_id)
SELECT A.$node_id AS fromid, P.$node_id AS toid
FROM (VALUES(727, 13),
( 71, 109),
(421, 113),
(421, 149),
(421, 179),
( 71, 199),
(883, 239),
(953, 281),
(641, 449),
(883, 677),
(883, 857),
(379, 859),
(199, 883),
( 2, 1021),
(379, 1031),
( 71, 1051),
(727, 1061),
(379, 1151),
(883, 1153),
(641, 1187),
( 71, 1259)) AS D(accountid, postid)
INNER JOIN Graph.Account AS A
ON D.accountid = A.accountid
INNER JOIN Graph.Post AS P
ON D.postid = P.postid;
-- Tabela IsFriendOf
CREATE TABLE Graph.IsFriendOf
(
startdate DATE NOT NULL
CONSTRAINT DFT_Friendships_startdate DEFAULT(SYSDATETIME()),
CONSTRAINT EC_IsFriendOf CONNECTION (Graph.Account TO Graph.Account)
ON DELETE NO ACTION
) AS EDGE;
ALTER TABLE Graph.IsFriendOf
ADD CONSTRAINT UNQ_IsFriendOf_fromid_toid UNIQUE($from_id, $to_id);
INSERT INTO Graph.IsFriendOf($from_id, $to_id, startdate)
SELECT A1.$node_id AS fromid, A2.$node_id AS toid, D.startdate
FROM (VALUES( 2, 379, '20220202'),
( 2, 641, '20220202'),
( 2, 727, '20220202'),
( 71, 199, '20220112'),
( 71, 379, '20211003'),
( 71, 661, '20210514'),
( 71, 883, '20210514'),
( 71, 953, '20220315'),
(199, 661, '20220112'),
(199, 883, '20220112'),
(199, 941, '20220112'),
(199, 953, '20220315'),
(379, 421, '20211003'),
(379, 641, '20211003'),
(421, 661, '20210517'),
(421, 727, '20210517'),
(641, 727, '20200801'),
(661, 883, '20210217'),
(661, 941, '20220105'),
(727, 883, '20210217'),
(883, 953, '20220315'),
(379, 2, '20220202'),
(641, 2, '20220202'),
(727, 2, '20220202'),
(199, 71, '20220112'),
(379, 71, '20211003'),
(661, 71, '20210514'),
(883, 71, '20210514'),
(953, 71, '20220315'),
(661, 199, '20220112'),
(883, 199, '20220112'),
(941, 199, '20220112'),
(953, 199, '20220315'),
(421, 379, '20211003'),
(641, 379, '20211003'),
(661, 421, '20210517'),
(727, 421, '20210517'),
(727, 641, '20200801'),
(883, 661, '20210217'),
(941, 661, '20220105'),
(883, 727, '20210217'),
(953, 883, '20220315'))
AS D(accountid1, accountid2, startdate)
INNER JOIN Graph.Account AS A1
ON D.accountid1 = A1.accountid
INNER JOIN Graph.Account AS A2
ON D.accountid2 = A2.accountid;
-- Tabela Follows
CREATE TABLE Graph.Follows
(
startdate DATE NOT NULL
CONSTRAINT DFT_Follows_startdate DEFAULT(SYSDATETIME()),
CONSTRAINT EC_Follows CONNECTION (Graph.Account TO Graph.Account)
ON DELETE NO ACTION
) AS EDGE;
ALTER TABLE Graph.Follows
ADD CONSTRAINT UNQ_Follows_fromid_toid UNIQUE($from_id, $to_id);
INSERT INTO Graph.Follows($from_id, $to_id, startdate)
SELECT A1.$node_id AS fromid, A2.$node_id AS toid, D.startdate
FROM (VALUES(641, 727, '20200802'),
(883, 199, '20220113'),
( 71, 953, '20220316'),
(661, 421, '20210518'),
(199, 941, '20220114'),
( 71, 883, '20210516'),
(199, 953, '20220317'),
(661, 941, '20220106'),
(953, 71, '20220316'),
(379, 2, '20220202'),
(421, 661, '20210518'),
(661, 71, '20210516'),
( 2, 727, '20220202'),
( 2, 379, '20220203'),
(379, 641, '20211004'),
(941, 199, '20220112'),
(727, 421, '20210518'),
(379, 71, '20211005'),
(941, 661, '20220105'),
(641, 2, '20220204'),
(953, 199, '20220316'),
(727, 883, '20210218'),
(421, 379, '20211004'),
( 71, 379, '20211004'),
(641, 379, '20211003'),
(199, 883, '20220114'),
(727, 2, '20220203'),
(199, 71, '20220113'),
(953, 883, '20220317'),
( 71, 661, '20210514'))
AS D(accountid1, accountid2, startdate)
INNER JOIN Graph.Account AS A1
ON D.accountid1 = A1.accountid
INNER JOIN Graph.Account AS A2
ON D.accountid2 = A2.accountid;
-- Tabela Likes
CREATE TABLE Graph.Likes
(
dt DATETIME2(0) NOT NULL
CONSTRAINT DFT_Likes_dt DEFAULT(SYSDATETIME()),
CONSTRAINT EC_Likes CONNECTION (Graph.Account TO Graph.Post)
ON DELETE NO ACTION
) AS EDGE;
ALTER TABLE Graph.Likes
ADD CONSTRAINT UNQ_Likes_fromid_toid UNIQUE($from_id, $to_id);
INSERT INTO Graph.Likes($from_id, $to_id, dt)
SELECT A.$node_id AS fromid, P.$node_id AS toid, D.dt
FROM (VALUES( 2, 13, '2020-09-21 15:33:46'),
(199, 109, '2021-05-16 03:24:00'),
(379, 109, '2021-05-15 21:48:00'),
(379, 113, '2021-05-19 04:45:33'),
(661, 113, '2021-05-17 21:33:33'),
(727, 113, '2021-05-18 09:33:33'),
(379, 179, '2021-05-21 10:00:17'),
(661, 179, '2021-05-20 22:00:17'),
(727, 179, '2021-05-21 00:24:17'),
(199, 199, '2021-08-02 22:20:02'),
( 71, 239, '2022-02-20 07:55:23'),
(199, 239, '2022-02-21 04:43:23'),
(661, 239, '2022-02-19 12:43:23'),
(727, 239, '2022-02-20 21:31:23'),
( 2, 449, '2020-09-21 20:22:30'),
(379, 449, '2020-09-22 12:22:30'),
(727, 449, '2020-09-21 19:34:30'),
( 71, 677, '2020-09-23 08:24:22'),
(199, 677, '2020-09-23 12:24:22'),
(661, 677, '2020-09-23 05:12:22'),
(727, 677, '2020-09-21 17:12:22'),
(953, 677, '2020-09-23 11:36:22'),
( 71, 857, '2021-05-16 09:50:13'),
(199, 857, '2021-05-17 00:14:13'),
(661, 857, '2021-05-16 08:14:13'),
(727, 857, '2021-05-17 07:26:13'),
(953, 857, '2021-05-16 11:26:13'),
( 2, 859, '2021-05-15 21:52:21'),
( 71, 859, '2021-05-17 05:04:21'),
(421, 859, '2021-05-17 11:28:21'),
( 71, 883, '2021-05-17 03:47:43'),
(379, 1021, '2020-09-22 20:56:17'),
(641, 1021, '2020-09-23 04:56:17'),
( 2, 1031, '2020-09-21 16:26:03'),
( 71, 1031, '2020-09-23 00:26:03'),
(421, 1031, '2020-09-23 10:02:03'),
(199, 1051, '2021-05-17 12:36:35'),
( 2, 1061, '2020-09-22 08:31:51'),
(421, 1061, '2020-09-23 06:07:51'),
(641, 1061, '2020-09-21 18:55:51'),
(883, 1061, '2020-09-21 20:31:51'),
( 2, 1151, '2021-05-17 13:04:12'),
( 71, 1151, '2021-05-16 22:40:12'),
(421, 1151, '2021-05-16 01:04:12'),
(641, 1151, '2021-05-15 22:40:12'),
( 2, 1187, '2020-09-23 13:19:52'),
(379, 1187, '2020-09-22 13:19:52'))
AS D(accountid, postid, dt)
INNER JOIN Graph.Account AS A
ON D.accountid = A.accountid
INNER JOIN Graph.Post AS P
ON D.postid = P.postid;
-- Tabela Authored
CREATE TABLE Graph.Authored
(
CONSTRAINT EC_Authored CONNECTION
(Graph.Account TO Graph.Publication)
ON DELETE NO ACTION
) AS EDGE;
ALTER TABLE Graph.Authored
ADD CONSTRAINT UNQ_Authored_fromid_toid UNIQUE($from_id, $to_id);
INSERT INTO Graph.Authored($from_id, $to_id)
SELECT A.$node_id AS fromid, P.$node_id AS toid
FROM (VALUES(727, 23977),
(641, 23977),
(727, 4967),
(641, 27059),
(883, 14563),
(883, 46601),
(199, 46601)) AS D(accountid, pubid)
INNER JOIN Graph.Account AS A
ON D.accountid = A.accountid
INNER JOIN Graph.Publication AS P
ON D.pubid = P.pubid;
Ponownie warto wykonać zapytania do nowo utworzonych tabel krawędzi, aby zbadać ich zawartość.
Odpytywanie metadanych
SQL Server udostępnia widoki katalogowe i funkcje systemowe umożliwiające sprawdzanie metadanych naszych obiektów grafu.
Widok sys.tables zawiera parę kolumn o nazwach is_node i is_edge, wskazujących, czy dana tabela jest tabelą węzłów, czy tabelą krawędzi, odpowiednio. Wartość 1 w danej kolumnie należy rozumieć jako prawdę, a 0 oznacza fałsz. Dla przykładu poniższe zapytanie zwraca wszystkie tabele węzłów i krawędzi w bieżącej bazie danych:
SELECT SCHEMA_NAME(schema_id) + N'.' + name AS tablename,
CASE
WHEN is_node = 1 THEN 'NODE'
WHEN is_edge = 1 THEN 'EDGE'
ELSE 'Not SQLGraph table'
END AS tabletype
FROM sys.tables
WHERE is_node = 1 OR is_edge = 1;
Zapytanie to generuje poniższe wyjście:
tablename tabletype
------------------ ----------
Graph.Account NODE
Graph.Post NODE
Graph.Publication NODE
Graph.IsReplyTo EDGE
Graph.Posted EDGE
Graph.IsFriendOf EDGE
Graph.Follows EDGE
Graph.Likes EDGE
Graph.Authored EDGE
Widok sys.columns zawiera dwie kolumny o nazwach graph_type oraz graph_type_desc, które udostępniają numeryczny typ grafu oraz tekstowy opis tego typu dla kolumn, które w obiektach grafu mają specjalne znaczenie. Na przykład poniższe zapytanie zwraca informacje metadanych w kolumnach węzłów Graph.Account, włącznie z dwiema kolumnami zawierającymi informacje specyficzne dla grafu:
SELECT name, TYPE_NAME(user_type_id) AS typename, max_length,
graph_type, graph_type_desc
FROM sys.columns
WHERE object_id = OBJECT_ID('Graph.Account');
Zapytanie to generuje poniższe wyjście:
name
------------------------------------------
graph_id_E386DEE1CFDA4AF7B5384DF1581D3EB7
$node_id_C3736967DCB2474D966CA368C2D8AD4A
accountid
accountname
joindate
reputationpoints
typename max_length graph_type graph_type_desc
--------- ---------- ----------- ------------------
bigint 8 1 GRAPH_ID
nvarchar 2000 2 GRAPH_ID_COMPUTED
int 4 NULL NULL
nvarchar 100 NULL NULL
date 3 NULL NULL
int 4 NULL NULL
W każdej tabeli węzłów występują dwie kolumny specyficzne dla grafu - jedna używana wewnętrznie i jedna eksponowana dla użytkownika:
graph_id (z długim sufiksem) jest kolumną wewnętrzną przechowującą generowany przez system identyfikator grafu typu BIGINT, unikatowy dla każdego obiektu grafu (w tym przypadku węzła) w tabeli. Nie można bezpośrednio odwoływać się do tej kolumny, ale istnieje sposób pośredniego wydobycia tej wartości za pomocą funkcji, co zademonstruję za chwilę.
$node_id (z długim sufiksem) to kolumna obliczana zawierająca reprezentację ID węzła w postaci ciągu w formacie JSON. Jest unikatowa dla każdego obiektu grafu (w tym przypadku węzła) w bazie danych. Ciąg JSON przechowuje typ obiektu (węzeł lub krawędź), schemat, tabelę oraz ID grafowy. Ta kolumna jest dostępna bezpośrednio. W takim przypadku odwołujemy się do niej jako do $node_id, bez podawania sufiksu.
Poniższe zapytanie zwraca informacje metadanych dla kolumn w tabeli krawędzi Graph.Posted:
SELECT name, TYPE_NAME(user_type_id) AS typename, max_length,
graph_type, graph_type_desc
FROM sys.columns
WHERE object_id = OBJECT_ID('Graph.Posted');
Zapytanie to generuje poniższe wyjście:
name
---------------------------------------------
graph_id_C6C5CC1CFB91488AB15F67A888B8B019
$edge_id_9402E3E10FEB4C2097023334CDD93EFD
from_obj_id_921F3A63A5BD4363A7BF162B36E42F15
from_id_73466ABF0B1348C8A2A20706E0BFEAAA
$from_id_ED1EDE58038C45489E5A8346676CE7C6
to_obj_id_C8CD294E3DDF45308E3594490186AA69
to_id_91D22669EFCC4AF4A6C2A6877F28E1AA
$to_id_EB455237A45E4194A933F5F00B376C93
typename max_length graph_type graph_type_desc
--------- ---------- ----------- ----------------------
bigint 8 1 GRAPH_ID
nvarchar 2000 2 GRAPH_ID_COMPUTED
int 4 4 GRAPH_FROM_OBJ_ID
bigint 8 3 GRAPH_FROM_ID
nvarchar 2000 5 GRAPH_FROM_ID_COMPUTED
int 4 7 GRAPH_TO_OBJ_ID
bigint 8 6 GRAPH_TO_ID
nvarchar 2000 8 GRAPH_TO_ID_COMPUTED
Jak można zauważyć, tabela krawędzi zawiera osiem kolumn o znaczeniu specyficznym dla grafów. Część z nich to kolumny wykorzystywane wewnętrznie, niedostępne wprost dla użytkowników, a część jest eksponowanych i dostępnych do bezpośredniego odpytywania. Oto tych osiem kolumn (pominięte długie sufiksy nazw) i ich role:
graph_id jest kolumną wewnętrzną przechowującą generowaną przez system wartość ID grafowego typu BIGINT, unikatową dla każdej krawędzi w tabeli.
$edge_id jest eksponowaną zewnętrznie kolumną obliczaną, która przechowuje reprezentację ID krawędzi w formie ciągu JSON, unikatową dla obiektu grafu w obrębie bazy danych.
$from_id jest eksponowaną kolumną obliczaną przechowującą wartość $node_id wskazującą węzeł początkowy (from node) krawędzi. Kolumny wewnętrzne from_obj_id oraz from_id odpowiednio przechowują surowe wartości ID obiektu i ID grafowego dla węzła początkowego.
$to_id jest eksponowaną kolumną obliczaną przechowującą wartość $node_id wskazującą węzeł końcowy krawędzi. Kolumny wewnętrzne to_obj_id oraz to_id odpowiednio przechowują surowe wartości ID obiektu oraz ID grafowy węzła końcowego.
Przy odpytywaniu tabel grafu możemy bezpośrednio odwoływać się do kolumn z przedrostkiem $, czyli $node_id w tabelach węzłów oraz kolumn $edge_id, $from_id i $to_id w tabelach krawędzi. Wszystkie cztery są kolumnami obliczanymi, zawierającymi reprezentację pewnego obiektu grafowego w formacie ciągu JSON składającego się z typu obiektu (węzeł lub krawędź), schematu, tabeli oraz ID grafowy. Język T-SQL udostępnia też funkcje systemowe, które umożliwiają wydobycie surowych wartości identyfikatorów z powyższych czterech kolumn, a także konstruowanie ID węzła lub krawędzi z wejściowych surowych wartości ID grafowego i ID obiektu. Tabela 11-1 wylicza obsługiwane funkcje i ich przeznaczenie.
Tabela 11-1 Funkcje systemowe
Funkcja
Przeznaczenie
OBJECT_ID_FROM_NODE_ID
Wydobywa object_id z node_id.
GRAPH_ID_FROM_NODE_ID
Wydobywa graph_id z node_id.
NODE_ID_FROM_PARTS
Konstruuje node_id z wartości object_id oraz graph_id.
OBJECT_ID_FROM_EDGE_ID
Wydobywa object_id z edge_id.
GRAPH_ID_FROM_EDGE_ID
Wydobywa graph_id z edge_id.
EDGE_ID_FROM_PARTS
Konstruuje edge_id z wartości object_id i graph_id.
Dla przykładu poniższe zapytanie wydobywa wartości identyfikatorów obiektu i grafu z wartości $node_id dla węzłów przechowywanych w tabeli Account:
SELECT $node_id,
OBJECT_ID_FROM_NODE_ID($node_id) AS obj_id,
GRAPH_ID_FROM_NODE_ID($node_id) AS graph_id
FROM Graph.Account;
Zapytanie to generuje poniższe wyjście:
$node_id_40D6A9476A244872A6BFC576723DC0E8 obj_id graph_id
---------------------------------------------------------- ----------- --------
{"type":"node","schema":"Graph","table":"Account","id":0} 1275151588 0
{"type":"node","schema":"Graph","table":"Account","id":1} 1275151588 1
{"type":"node","schema":"Graph","table":"Account","id":2} 1275151588 2
{"type":"node","schema":"Graph","table":"Account","id":3} 1275151588 3
{"type":"node","schema":"Graph","table":"Account","id":4} 1275151588 4
{"type":"node","schema":"Graph","table":"Account","id":5} 1275151588 5
{"type":"node","schema":"Graph","table":"Account","id":6} 1275151588 6
{"type":"node","schema":"Graph","table":"Account","id":7} 1275151588 7
{"type":"node","schema":"Graph","table":"Account","id":8} 1275151588 8
{"type":"node","schema":"Graph","table":"Account","id":9} 1275151588 9
{"type":"node","schema":"Graph","table":"Account","id":10} 1275151588 10
Jak wspomniałem wcześniej, preferuję wstawienie do tabel węzłów własnej kolumny klucza, takiej jak accountid w tabeli Account. Sprawia to, że identyfikowanie węzła za pomocą filtrowania opartego na tym kluczu jest łatwiejsze i wygodniejsze. Jeśli jednak zdecydujemy się na opieranie się na generowanej przez system wartości grafowego identyfikatora, bardzo użyteczna staje się funkcja GRAPH_ID_FROM_NODE_ID, gdyż, jak pamiętamy, nie mamy bezpośredniego dostępu do wewnętrznej kolumny graph_id. W ostatnim zapytaniu widzieliśmy już, jak użyć tej funkcji do wydobycia wartości ID grafowego dla zadanego węzła na podstawie jego wartości $node_id. Analogicznie można użyć tej funkcji w predykacie filtru WHERE w celu identyfikowania węzła na podstawie podanej wartości ID grafowego. Na przykład poniższe zapytanie identyfikuje konto, którego wartość wewnętrznego identyfikatora grafowego wynosi 3:
SELECT $node_id, accountid, accountname
FROM Graph.Account
WHERE GRAPH_ID_FROM_NODE_ID($node_id) = 3;
To zapytanie generuje poniższe wyjście:
$node_id_40D6A9476A244872A6BFC576723DC0E8 accountid accountname
---------------------------------------------------------- --------- ------------
{"type":"node","schema":"Graph","table":"Account","id":3} 421 Buzi
Następne zapytanie pokazuje przykład użycia funkcji NODE_ID_FROM_PARTS do zbudowania identyfikatora węzła z podanych wartości identyfikatora obiektu i ID grafowego:
SELECT NODE_ID_FROM_PARTS(OBJECT_ID(N'Graph.Account'), 3);
To zapytanie zwraca poniższą wartość ID węzła:
{"type":"node","schema":"Graph","table":"Account","id":3}
Podobnie jak możemy manipulować obiektami węzłów za pomocą funkcji OBJECT_ID_FROM_NODE_ID, GRAPH_ID_FROM_NODE_ID oraz NODE_ID_FROM_PARTS, działania na obiektach krawędzi umożliwiają funkcje OBJECT_ID_FROM_EDGE_ID, GRAPH_ID_FROM_EDGE_ID i EDGE_ID_FROM_PARTS.
Odpytywanie danych
Odpytywanie danych grafu jest jednym z największych atutów funkcjonalności SQL Graph. Za pomocą rozszerzeń języka T-SQL o specjalizowane polecenia grafowe zazwyczaj uzyskujemy eleganckie, intuicyjne, a ponadto zwięźlejsze zapytania, niże te tworzone przy użyciu bardziej tradycyjnych narzędzi, takich jak złączenia. Mam nadzieję, że Microsoft nadal będzie rozwijać SQL Graph, dodając dalsze rozszerzenia tego rodzaju, dzięki czemu zaangażowanie w tę funkcjonalność będzie jeszcze bardziej opłacalne. W tym miejscu opiszę funkcje dostępne w T-SQL w chwili pisania tego rozdziału, ale również wskażę przykłady ważnych potencjalnych rozszerzeń, które mogą zostać dodane w przyszłości.
Klauzula MATCH
Najważniejsze rozszerzenie języka, które uzyskujemy w celu odpytywania danych grafu, to klauzula MATCH. Poniżej wyjaśniam, jak używać jej w wyrażeniach SELECT. W dalszej części rozdziału pokażę, jak posługiwać się tą klauzulą w instrukcjach modyfikacji.
Ogólna składnia zapytania SELECT względem obiektów grafu wygląda następująco:
SELECT <lista_wyboru>
FROM <lista obiektów grafowych rozdzielana przecinkami>
WHERE MATCH(<spec_dopasowania_1>) [ ... AND MATCH(<spec_dopasowania_n>)];
Część specyfikująca dopasowanie definiuje relacje pomiędzy obiektami grafowymi, wykorzystując elegancką składnię w stylu ASCII-art. Wykorzystuje również bardziej zaawansowane elementy, takie jak opcja SHORTEST_PATH opisana w dalszej części rozdziału.
Zapewne najlepszym sposobem opanowania tej składni jest bezpośrednie zagłębienie się w przykłady. Na początku będę pokazywać tradycyjne zapytania w porównaniu do zapytań grafowych, ale gdy (mam nadzieję) Czytelnicy już zrozumieją, w czym rzecz, będę się skupiać głównie na składni odpytywaniu grafów. Naturalnie w tych przypadkach, w których pokażę jedynie składnię grafową, zdecydowanie zachęcam do próbowania sformułowania tego samego zadania przy użyciu składni tradycyjnej. Takie ćwiczenie powinno nie tylko poprawić umiejętności, ale również pozwoli docenić korzyści wynikające ze stosowania specjalizowanych rozszerzeń języka.
Rozpocznijmy od bardzo prostego przykładu. Przypuśćmy, że chcemy dopasować konta do publikowanych przez nie postów. Oto sposób, jakim moglibyśmy to osiągnąć w tradycyjnym modelu danych, z wykorzystaniem złączenia:
SELECT A.accountid, A.accountname, P.postid, P.posttext
FROM Norm.Accounts AS A
INNER JOIN Norm.Posts AS P
ON A.accountid = P.accountid;
Zapytanie to generuje poniższe wyjście (treść postów została obcięta, aby zmieściła się na stronie książki):
accountid accountname postid posttext
---------- ------------ ------- --------------------------------------
727 Mitzi 13 Got a new kitten. Any suggestions f...
71 Miko 109 Starting to hike the PCT today. Wis...
421 Buzi 113 Buzi here. This is my first post.
421 Buzi 149 Buzi here. This is my second post. ...
421 Buzi 179 Buzi here. Guess what; this is my t...
71 Miko 199 Made it to Oregon!
883 Yatzek 239 I'm thinking of growing a mustache,...
953 Omer 281 Burt Shavits: "A good day is when n...
641 Inka 449 Maybe Pickle?
883 Yatzek 677 Ambrosius?
883 Yatzek 857 Break a leg. I mean, don't!
379 Tami 859 The longest I've seen you hike was....
199 Lilach 883 Ha ha ha!
2 Orli 1021 It does look a bit sour faced :)
379 Tami 1031 How about Gherkin?
71 Miko 1051 Jokes aside, is 95lbs reasonable fo...
727 Mitzi 1061 I love Gherkin!
379 Tami 1151 Short answer, no! Long answer, nooo...
883 Yatzek 1153 Say what!?
641 Inka 1187 So you don't like Pickle!? I'M UNFR...
71 Miko 1259 Did I say that was without water?
Przypomnijmy, że w tradycyjnym modelowaniu danych sposobem na powiązanie konta z postem jest utrzymywanie kolumny accountid w tabeli Posts. Jesteśmy w stanie posłużyć się takim projektem bez dodatkowej tabeli złączającej, gdyż każdy post może mieć tylko jedno powiązane z nim konto autora. Mamy tu relację typu jeden do jednego w kierunku od tabeli Posts do tabeli Accounts, oraz jeden do wielu w kierunku przeciwnym. Gdyby relacja była typu wiele do wielu, konieczne byłoby zastosowanie trzeciej, złączającej tabeli, a zapytanie potrzebowałoby dwóch złączeń, a nie tylko jednego.
Przy modelu grafowym węzły łączymy ze sobą za pomocą krawędzi, zatem zawsze używamy trzeciej tabeli (tabeli krawędzi) do połączenia węzłów. Jest to niezależne od rodzaju relacji pomiędzy węzłami, włącznie z przypadkiem relacji jeden do jednego, jak pomiędzy tabelami Post i Account. Tabela krawędzie łącząca węzły w naszym przypadku to tabela Posted.
Tak więc, aby dopasować odpowiadające konta do postów przy użyciu składni grafowej, trzeba wyliczyć wszystkie trzy obiekty w klauzuli FROM zapytania, oddzielając je przecinkami, jak poniżej:
FROM Graph.Account, Graph.Posted, Graph.Post
Następną częścią jest klauzula WHERE. Musimy tu ustalić postać składni ASCII-art, którą trzeba podać w klauzuli MATCH jako części specyfikacji dopasowania. Podstawowa składnia opisująca połączenie dwóch węzłów krawędzią wykorzystuje poniższy wzorzec:
węzeł_początkowy-(krawędź)->węzeł_końcowy
Tym, co jest tu ważne, jest kierunek strzałki. Tę samą relację moglibyśmy wyrazić, odwracając zarówno kolejność, w jakiej specyfikowane są węzły, jak i stronę wskazywaną przez strzałkę:
węzeł_końcowy<-(krawędź)-węzeł_początkowy
Przy tak prostym przykładzie nie ma większego znaczenia, którą opcję wykorzystamy. Jak jednak zobaczymy później, możliwość definiowania relacji zarówno za pomocą strzałki w prawo, jak i w lewo, okazuje się bardzo przydatna, gdy trzeba będzie definiować bardziej pracochłonne i zaawansowane związki.
Tak więc klauzula WHERE naszego zapytania powinna wyglądać następująco:
WHERE MATCH(Account-(Posted)->Post)
Pozostało nam jedynie dodanie klauzuli SELECT z listą kolumn, które chcemy zwrócić. Oto kompletne zapytanie:
SELECT accountid, accountname, postid, posttext
FROM Graph.Account, Graph.Posted, Graph.Post
WHERE MATCH(Account-(Posted)->Post);
Możemy przypisywać aliasy do tabel grafowych w klauzuli FROM. Jeśli to zrobimy, również w klauzuli MATCH trzeba używać tych aliasów, a nie oryginalnych nazw tabel. Dla przykładu, jeśli przypiszemy alias Act do tabeli Graph.Account, specyfikacja dopasowania powinna mieć postać Act-(Posted)->Post. Oto pełne zapytanie z użyciem aliasu:
SELECT accountid, accountname, postid, posttext
FROM Graph.Account AS Act, Graph.Posted, Graph.Post
WHERE MATCH(Act-(Posted)->Post);
W tym prostym przykładzie stosowanie aliasów nie ma dobrego uzasadnienia. Jak jednak zobaczymy późnej, jeśli potrzebujemy odwoływać się kilkukrotnie do tej samej tabeli w jednym zapytaniu, używanie aliasów tabel jest konieczne.
Wcześniej przedstawiłem tradycyjne zapytanie używające złączenia wewnętrznego w celu dopasowania postów do odpowiadających im kont autorów, a także alternatywną postać zapytania grafowego. Ktoś mógłby stąd wyciągnąć wniosek, że obie postaci są logicznie równoważne. W rzeczywistości jednak semantyka zapytania grafowego jest bliższa zapytaniu ze złączeniem zewnętrznym, w którym tabela krawędzi znajduje się po stronie zachowywanej. Użycie zapytania grafowego ze ścieżką Account-(Posted)->Post względem naszego modelu danych jest w istocie bardziej podobne do użycia poniższego zapytania wobec tradycyjnego modelu danych:
SELECT A.accountid, A.accountname, P.postid, P.posttext
FROM Norm.Posts AS P
LEFT OUTER JOIN Norm.Accounts AS A
ON P.accountid = A.accountid;
Rzecz w tym, że normalnie będziemy mieli ograniczenia gwarantujące, że krawędź łączy istniejące węzły. W modelu tradycyjnym w tym celu użylibyśmy kluczy obcych, a w modelu grafowym można posłużyć się ograniczeniami krawędzi. Jeśli możemy mieć przekonanie, że nie mogą istnieć osierocone krawędzie odwołujące się do nieistniejących węzłów, to po przekonwertowaniu ścieżek grafów na alternatywną składnię wykorzystującą złączenia obydwie wersje (ze złączeniem wewnętrznym i zewnętrznym) zwrócą te same wyniki.
Do tej pory pokazywałem tradycyjne zapytania T-SQL do tradycyjnego modelu danych oraz odpowiadające im zapytania ze składnią grafową do modelu grafowego. Jeśli kogoś ciekawi, czy możliwe jest użycie tradycyjnej składni T-SQL bazującej na złączeniach w grafowym modelu danych, to owszem - taki scenariusz jest w pełni obsługiwany i poprawny. W naszym przykładzie trzeba po prostu złączyć trzy interesujące nas tabele (tabelę krawędzi z dwiema tabelami węzłów) przy użyciu złączeń zewnętrznych, jeśli chcemy uzyskać prawdziwy logiczny ekwiwalent ścieżki grafu; naturalnie tabela krawędzi musi znajdować się po stronie zachowywanej złączenia. W predykatach złączeń musimy jawnie dopasować kolumny $from_id oraz $to_id tabeli krawędzi do kolumn $node_id odpowiednich tabel węzłów, jak poniżej:
SELECT
Account.accountid, Account.accountname,
Post.postid, Post.posttext
FROM Graph.Posted
LEFT OUTER JOIN Graph.Account
ON Posted.$from_id = Account.$node_id
LEFT OUTER JOIN Graph.Post
ON Posted.$to_id = Post.$node_id;
W możliwości wykorzystania tradycyjnej składni T-SQL względem obiektów grafu dostrzegam dwie główne korzyści. Jednak to rozwiązywanie problemów, gdy chcemy się upewnić, że specyfikacja ścieżki grafu w dopasowaniu rzeczywiście oznacza to, co chcieliśmy. Druga pojawia się w przypadkach, w których składnia grafu nie jest (jeszcze) obsługiwana wobec określonych obiektów, jak przy tworzeniu rekurencyjnych zapytań, czym zajmę się w następnym podrozdziale. W innych sytuacjach, gdy specjalizowana składnia grafowa jest obsługiwana, jest typowo preferowana względem składni tradycyjnej ze względu na jej elegancję i zwięzłość. Zauważmy, że choć możemy używać tradycyjnej składni względem obiektów grafowych, nie możemy stosować składni grafu do obiektów tradycyjnych.
Nasze kolejne zadanie to zwrócenie kont i odpowiadających im publikacji. Zacznijmy od modelu tradycyjnego. Związek pomiędzy kontami (przechowywanymi w tabeli Accounts) a ich publikacjami (w tabeli Publications) to relacja typu wiele do wielu. Tym samym model tradycyjny używa trzeciej tabeli o nazwie AuthorsPublications do połączenia obu pozostałych. Zadanie możemy zrealizować za pomocą trójstronnego złączenia, jak poniżej:
SELECT A.accountid, A.accountname, P.pubid, P.title
FROM Norm.Accounts AS A
INNER JOIN Norm.AuthorsPublications AS AP
ON A.accountid = AP.accountid
INNER JOIN Norm.Publications AS P
ON AP.pubid = P.pubid;
To zapytanie generuje poniższe wyjście:
accountid accountname pubid
---------- ------------ ------
727 Mitzi 4967
883 Yatzek 14563
641 Inka 23977
727 Mitzi 23977
641 Inka 27059
199 Lilach 46601
883 Yatzek 46601
title
---------------------------------------------------------
When Mitzi left Inka
Been everywhere, seen it all; there's no place like home!
When Mitzi met Inka
When Mitzi met Inka
It's actually Inka who left Mitzi
Love at first second
Love at first second
W przypadku modelu grafowego wyliczamy tabele Account (węzły), Authored (krawędzie) i Publication (węzły) w klauzuli FROM zapytania i specyfikujemy wzorzec Account-(Authored)->Publication w klauzuli MATCH będącej częścią filtru WHERE, jak poniżej:
SELECT accountid, accountname, pubid, title
FROM Graph.Account, Graph.Authored, Graph.Publication
WHERE MATCH(Account-(Authored)->Publication);
Ponownie użyłem złączeń wewnętrznych w tradycyjnym zapytaniu, ale jak wspomniałem wcześniej, zapytania grafowe używają semantyki złączenia zewnętrznego. Oto tradycyjne zapytanie ze złączeniami zewnętrznymi, które jest bliższe logicznemu równoważnikowi powyższego zapytania grafowego:
SELECT A.accountid, A.accountname, P.pubid, P.title
FROM Norm.AuthorsPublications AS AP
LEFT OUTER JOIN Norm.Accounts AS A
ON AP.accountid = A.accountid
LEFT OUTER JOIN Norm.Publications AS P
ON AP.pubid = P.pubid;
Wspomniałem, że przy ograniczeniach krawędzi zapewniających integralność danych grafu nigdy nie będziemy mieli osieroconych krawędzi. W takim przypadku nie zobaczymy żadnych zewnętrznych wierszy w wynikach zapytań, czyli ta postać jest równoważna wcześniejszej, ze złączeniami wewnętrznymi. Jeśli jednak nie stosujemy ograniczeń krawędzi, SQL Server nie będzie zakazywał powstawania osieroconych krawędzi, a jeśli takie istnieją, zapytania grafowe mogą zwracać wiersze zewnętrzne. Ich obecność jest jasnym sygnałem niespójności w naszych danych. Aby móc to zademonstrować, musimy najpierw tymczasowo wyłączyć wymuszanie integralności poprzez ograniczenia krawędzi. Użyjemy poniższego kodu do wyłączenia ograniczenia krawędzi EC_Authored w tabeli Authored:
ALTER TABLE Graph.Authored NOCHECK CONSTRAINT EC_Authored;
Ograniczenie to, gdy jest aktywne, gwarantuje, że każda krawędź łączy istniejące węzły autorów z istniejącymi węzłami publikacji.
Następnie wykonamy poniższy kod, aby wstawić kilka krawędzi, w których niektóre identyfikatory węzłów nie istnieją w odpowiednich tabelach węzłów:
INSERT INTO Graph.Authored($from_id, $to_id)
VALUES(NODE_ID_FROM_PARTS(OBJECT_ID(N'Graph.Account'), -1),
NODE_ID_FROM_PARTS(OBJECT_ID(N'Graph.Publication'), -1));
INSERT INTO Graph.Authored($from_id, $to_id)
VALUES(NODE_ID_FROM_PARTS(OBJECT_ID(N'Graph.Account'), -1),
NODE_ID_FROM_PARTS(OBJECT_ID(N'Graph.Publication'), 0));
INSERT INTO Graph.Authored($from_id, $to_id)
VALUES(NODE_ID_FROM_PARTS(OBJECT_ID(N'Graph.Account'), 0),
NODE_ID_FROM_PARTS(OBJECT_ID(N'Graph.Publication'), -1));
Przypomnijmy, że SQL Server automatycznie generuje identyfikatory węzłów, poczynając od 0 i podążając przez kolejne liczby naturalne. Użyłem wartości -1 jako identyfikatora oczywiście nieistniejącego węzła w tabeli krawędzi. Jednak mogłaby to być dowolna wartość ID węzła, która kiedyś była poprawna (wskazywała istniejący węzeł), ale została osierocona, gdyż węzeł ten został usunięty i nie było ograniczenia krawędzi, które pozwoliłoby tego uniknąć.
Poniższe zapytanie w składni grafowej ponownie zwraca konta i odpowiadające im publikacje:
SELECT accountid, accountname, pubid, title
FROM Graph.Account, Graph.Authored, Graph.Publication
WHERE MATCH(Account-(Authored)->Publication);
Tym razem wyjście zawiera wiersze zewnętrzne reprezentujące osierocone krawędzie ze znacznikami NULL w miejsce atrybutów nieistniejących węzłów:
accountid accountname pubid
---------- ------------ -----------
NULL NULL NULL
NULL NULL 23977
641 Inka NULL
641 Inka 23977
641 Inka 27059
727 Mitzi 23977
727 Mitzi 4967
883 Yatzek 14563
883 Yatzek 46601
199 Lilach 46601
title
---------------------------------------------------------
NULL
When Mitzi met Inka
NULL
When Mitzi met Inka
It's actually Inka who left Mitzi
When Mitzi met Inka
When Mitzi left Inka
Been everywhere, seen it all; there's no place like home!
Love at first second
Love at first second
Zwróćmy uwagę, że może istnieć krawędź z osieroconym węzłem konta, osieroconym węzłem publikacji lub obydwoma. Jak widać, zdecydowanie lepszym pomysłem jest utrzymywanie integralności danych za pomocą ograniczeń krawędzi.
Na koniec uruchamiamy poniższy kod, aby usunąć osierocone krawędzie:
DELETE FROM Graph.Authored
WHERE -1 IN (GRAPH_ID_FROM_NODE_ID($from_id),
GRAPH_ID_FROM_NODE_ID($to_id));
Następnie ponownie włączamy ograniczenia krawędzi:
ALTER TABLE Graph.Authored WITH CHECK CHECK CONSTRAINT EC_Authored;
Przejdźmy teraz do odpytywania grafów o nieco bardziej złożonych wzorcach dopasowania. Nasze kolejne zadanie to zwrócenie wszystkich postów - z których niektóre mogą być odpowiedziami na inne posty - oraz bezpośrednich odpowiedzi na nie. Zarówno dla postu, jak i odpowiedzi chcemy zwrócić nazwę wysyłającego go konta oraz tekst (treść) postu.
Mamy tu trzy relacje, które chcemy wyrazić w naszym zapytaniu:
1. Konto 1 wysłało post 1.
2. Konto 2 wysłało post 2.
3. Post 2 jest odpowiedzią na post 1.
Węzeł Account, krawędź Posted oraz węzeł Post odgrywają po dwie, różne role w powyższych zależnościach. Konieczne jest zatem dwukrotne wyliczenie każdej z tabel grafowych w klauzuli FROM zapytania i użycie różnych aliasów w celu rozróżnienia tych wystąpień.
Zaczynając od dwóch wystąpień tabeli węzłów Account, możemy użyć aliasów Account1 i Account2. W przypadku dwóch wystąpień tabeli węzłów Post jedna reprezentuje post nadrzędny, zatem możemy po prostu zachować dla niej nazwę Post, zaś drugie reprezentuje odpowiedź, więc można posłużyć się tu aliasem Reply. Jeśli zaś chodzi o dwa wystąpienia tabeli krawędzi Posted, ponownie możemy pozostawić nazwę Posted dla połączenia Account1 z Post, zaś dla drugiego wystąpienia łączącego Account2 z Reply możemy użyć aliasu RepliedWith (odpowiedział).
W klauzuli FROM musimy również sięgnąć po tabelę krawędzi IsReplyTo, ale ponieważ ta tabela odgrywa tylko jedną rolę w powyższych powiązaniach, trzeba wyspecyfikować ją tylko raz i nie ma potrzeby używania aliasu.
Tak więc nasza klauzula FROM powinna wyglądać podobnie do poniższej:
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo
Następnie przekształcając powyższe trzy relacje na bazowe wzorce dopasowania uzyskujemy następujące wyrażenia:
1. Account1-(Posted)->Post
2. Account2-(RepliedWith)->Reply
3. Reply-(IsReplyTo)->Post
T-SQL zapewnia pewną elastyczność składniową, gdy potrzebujemy wyrazić wiele relacji, jak te powyższe. Możemy scalić w jeden dwa wzorce, które mają wspólną tabelę węzłów. Musimy jedynie zadbać o właściwe kierunki strzałek na podstawie roli, którą dany węzeł odgrywa w każdej relacji.
Dla przykładu rozważmy wzorce N1-(E1)->N2 oraz N1-(E2)->N3. Tabela węzeł N1 odgrywa rolę węzła początkowego w obydwu wzorcach. Możemy zatem sformułować jeden scalony wzorzec z N1 w centrum i dwiema strzałkami wychodzącymi z niego w obie strony, aby wyrazić oryginalne wzorce bazowe. Innymi słowy, możemy sformułować scalony wzorzec jako N3<-(E2)-N1-(E1)->N2 albo jako N2<-(E1)-N1-(E2)->N3. Obydwie postaci znaczą to samo.
Jeśli pewien węzeł odgrywa rolę węzła końcowego w obu wzorcach, można te wzorce scalić za pomocą dwóch strzałek wskazujących do środka. Na przykład wzorce N2-(E1)->N1 oraz N3-(E2)->N1 można scalić jako N3-(E2)->N1<-(E1)-N2 albo jako N2-(E1)->N1<-(E2)-N3.
Może się też zdarzyć, że węzeł odgrywa rolę węzła początkowego w jednym wzorcu i węzła końcowego w drugim. Aby scalić dwa wzorce bazowe, tworzymy wzorzec z jedną strzałką biegnącą do wspólnego węzła i jedną wychodzącą z niego. Dla przykładu, aby scalić wzorce N2-(E1)->N1 i N1-(E2)->N3, można użyć albo formy N2-(E1)->N1-(E2)->N3, albo N3<-(E2)-N1<-(E1)-N2.
Bazując na powyższych strategiach scalania i przejmowania mamy tu dwa wzorce, które można wykorzystać w naszym zapytaniu. Wzorzec 1 (Account1-(Posted)->Post) i wzorzec 3 (Reply-(IsReplyTo)->Post) współdzielą tę samą tabelę węzłów Post, odgrywającą rolę węzła końcowego w obu wzorcach. Możemy zatem scalić je w jeden wzorzec: Account1-(Posted)->Post<-(IsReplyTo)-Reply. Następnie możemy scalić ten kombinowany wzorzec z wzorcem 2 (Account2-(RepliedWith)->Reply). Jak widzimy, tabela węzłów Reply odgrywa rolę najbardziej prawostronnego węzła początkowego w pierwszym wzorcu oraz rolę węzła końcowego w drugim, zatem można sformułować wynikowy wzorzec jako Account1-(Posted)->Post<-(IsReplyTo)-Reply<-(RepliedWith)-Account2.
Teraz, gdy ustaliliśmy już, jak wyliczyć tabele w klauzuli FROM i jak wyrazić wzorzec dopasowania, możemy utworzyć poniższe zapytanie, które jest kompletnym rozwiązaniem:
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo
WHERE MATCH(Account1-(Posted)->Post<-(IsReplyTo)-Reply
<-(RepliedWith)-Account2) -- spacje i inne białe znaki są ignorowane
ORDER BY Post.dt, Post.postid, Reply.dt;
To zapytanie generuje poniższe wyjście (sformatowane jako zawijane wiersze w celu pomieszczenia na stronie):
account1 posttext
account2 replytext
---------- ---------------------------------------------------
Mitzi Got a new kitten. Any suggestions for a name?
Inka Maybe Pickle?
Mitzi Got a new kitten. Any suggestions for a name?
Yatzek Ambrosius?
Inka Maybe Pickle?
Orli It does look a bit sour faced :)
Inka Maybe Pickle?
Tami How about Gherkin?
Tami How about Gherkin?
Mitzi I love Gherkin!
Mitzi I love Gherkin!
Inka So you don't like Pickle!? I'M UNFRIENDING YOU!!!
Miko Starting to hike the PCT today. Wish me luck!
Yatzek Break a leg. I mean, don't!
Miko Starting to hike the PCT today. Wish me luck!
Tami The longest I've seen you hike was...wait,
I've never seen you hike ;)
Miko Starting to hike the PCT today. Wish me luck!
Lilach Ha ha ha!
Lilach Ha ha ha!
Miko Jokes aside, is 95lbs reasonable for my backpack?
Miko Jokes aside, is 95lbs reasonable for my backpack?
Tami Short answer, no! Long answer, nooooooo!!!
Miko Jokes aside, is 95lbs reasonable for my backpack?
Yatzek Say what!?
Tami Short answer, no! Long answer, nooooooo!!!
Miko Did I say that was without water?
Co jednak, jeśli nie czujemy się dobrze przy korzystaniu z pojedynczego, ale długiego i pracochłonnego wzorca? T-SQL pozwala wyrazić specyfikację dopasowania jako koniunkcję wielu wzorców bazowych połączonych operatorami AND, nadal używając tylko jednej klauzuli MATCH, jak poniżej:
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo
WHERE MATCH(Account1-(Posted)->Post
AND Account2-(RepliedWith)->Reply
AND Reply-(IsReplyTo)->Post)
ORDER BY Post.dt, Post.postid, Reply.dt;
Jeszcze inna opcja to wyspecyfikowanie koniunkcji predykatów w klauzuli WHERE, każdego z oddzielną klauzulą MATCH i jej własnym wzorcem, połączonych operatorami AND, jak poniżej:
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo
WHERE MATCH(Account1-(Posted)->Post)
AND MATCH(Account2-(RepliedWith)->Reply)
AND MATCH(Reply-(IsReplyTo)->Post)
ORDER BY Post.dt, Post.postid, Reply.dt;
Wszystkie trzy opcje są logicznie równoważne. W tle wszystkie zostaną potraktowane tak samo przez SQL Server. Dodatkową zaletą jest to, że można nawet łączyć ze sobą te trzy techniki w razie potrzeby. Trzeba jedynie ustalić, które podejście wydaje się najwygodniejsze dla nas i naszego zespołu, jeśli chodzi o przejrzystość kodu i łatwość utrzymywania.
Dodajmy teraz jeszcze jedno wymaganie do ostatniego zadania. Tak jak wcześniej, potrzebujemy zwrócić posty oraz bezpośrednie odpowiedzi na nie; jednak tym razem konto odpowiadające musi być takim, które obserwuje konto wysyłające post nadrzędny. Załóżmy, że preferowany jest format z pojedynczym (długim) wzorcem. Wzorzec z poprzedniego zapytania to Account1-(Posted)->Post<-(IsReplyTo)-Reply<-(RepliedWith)-Account2. Potrzebujemy dodać do niego bazowy wzorzec Account2-(Follows)->Account1. Zgodnie z wymienionymi wcześniej regułami, zupełnie naturalne będzie scalenie tych wzorców do postaci Account1-(Posted)->Post<-(IsReplyTo)-Reply<-(RepliedWith)-Account2-(Follows)->Account1. Nowy wzorzec zawiera dodatkowo tabelę krawędzi Follows, zatem trzeba ją dodać do klauzuli FROM. Oto kompletne zapytanie:
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo, Graph.Follows
WHERE MATCH(Account1-(Posted)->Post<-(IsReplyTo)-Reply
<-(RepliedWith)-Account2-(Follows)->Account1)
ORDER BY Post.dt, Post.postid, Reply.dt;
Zwróćmy uwagę, że Account1 występuje dwukrotnie w scalonym wzorcu i jest to całkowicie poprawne.
Nasz wzorzec zawiera obecnie wszystkie cztery bazowe relacje. Im więcej relacji zawiera model, tym bardziej oparta na grafach składnia z wzorcami dopasowywania będzie oszczędna pod względem długości kodu, jeśli porównać ją ze składnią tradycyjną. Dla przykładu i porównania, oto rozwiązanie używające tradycyjnej składni bazującej na złączeniach (dla uproszczenia wykorzystuję tylko złączenia wewnętrzne):
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1
INNER JOIN Graph.Posted
ON Posted.$from_id = Account1.$node_id
INNER JOIN Graph.Post
ON Posted.$to_id = Post.$node_id
INNER JOIN Graph.IsReplyTo
ON IsReplyTo.$to_id = Post.$node_id
INNER JOIN Graph.Post AS Reply
ON IsReplyTo.$from_id = Reply.$node_id
INNER JOIN Graph.Posted AS RepliedWith
ON RepliedWith.$to_id = Reply.$node_id
INNER JOIN Graph.Account AS Account2
ON RepliedWith.$from_id = Account2.$node_id
INNER JOIN Graph.Follows
ON Follows.$from_id = Account2.$node_id
AND Follows.$to_id = Account1.$node_id
ORDER BY Post.dt, Post.postid, Reply.dt;
Oprócz tego, że jest bardziej zwięzła, składnia oparta na grafach jest bardziej naturalna i wzorce można czytać jak zwykły napis w języku angielskim. Trzeba jedynie pamiętać, aby każdy wzorzec bazowy odczytywać w kierunku strzałki. Tak więc wzorzec Account1-(Posted)->Post<-(IsReplyTo)-Reply<-(RepliedWith)-Account2-(Follows)->Account1 można przeczytać jako "Account 1 posted a post. The reply is a reply to that post. Account 2 replied with that reply. Account 2 follows account 1". (Konto 1 wysłało post. Odpowiedź jest odpowiedzią na ten post. Konto 2 odpowiedziało tą odpowiedzią. Konto 2 obserwuje konto 1).
I przeciwnie, próba analizowania rozwiązania opartego na złączeniach nie jest tak naturalna. Nie przekłada się tak dobrze na zwykły język. Jeśli się nad tym zastanowić, jednym z celów, jaki postawili sobie projektanci języka SQL, było stworzenie deklaratywnego, zbliżonego do naturalnego języka (angielskiego), który byłby tak bliski, jak to możliwe, temu, jak ludzie formułują swoje żądania. Składnia oparta na grafach wydaje się być bliższa realizacji tego celu, niż bardziej tradycyjna składnia.
W chwili pisania tych słów możliwe jest scalanie wzorców bazowych jedynie przy użyciu operatora logicznego AND - zarówno wtedy, gdy użyjemy pojedynczej klauzuli MATCH z wieloma bazowymi wzorcami łączonymi operatorami AND, jak i wtedy, gdy posłużymy się wieloma klauzulami MATCH. Co oczywiste, użycie pojedynczego scalonego wzorca również oznacza koniunkcję, czyli spełnienie wszystkich wchodzących w jego skład wzorców bazowych. Aktualnie T-SQL nie wspiera łączenia wzorców za pomocą alternatywy (operatora OR) ani zanegowania wzorca za pomocą operatora NOT.
Jak zatem możemy sobie poradzić z sytuacjami, gdy potrzebujemy użyć alternatywy lub negacji? Jedna z możliwości to użycie operatorów zbiorów, takich jak UNION w miejsce OR oraz EXCEPT zamiast NOT. Inna możliwość to użycie operatorów OR i NOT w połączeniu z predykatem EXISTS bazującym na podzapytaniu. Takie podejście ewidentnie jest obsługiwane, choć nie jest to fakt powszechnie znany. Podejście z podzapytaniem wydaje się być zwięźlejsze i dla mnie jest bardziej naturalne.
Dla przykładu spróbujmy rozbudować ostatnie zadanie jeszcze bardziej. Potrzebujemy zwrócić posty oraz bezpośrednie odpowiedzi na nie tylko wtedy, gdy konto, które odpowiedziało, obserwuje (follows) lub jest znajomym (friend) autora nadrzędnego postu.
Przy użyciu rachunku zbiorów możemy obsłużyć alternatywę (lub, czyli operator OR) za pomocą operatora UNION. Zasadniczo tworzymy dwa zapytania, każde z własnym wzorcem dopasowania, po czym stosujemy operator UNION do ich wyników. Obydwa wzorce powtarzają całą logikę koniunkcji i różnią się tylko w tej części, która jest alternatywą. W naszym przykładzie wzorzec jednego dopasowania to Account1-(Posted)->Post<-(IsReplyTo)-Reply<-(RepliedWith)-Account2-(Follows)->Account1, zaś drugie dopasowanie to Account1-(Posted)->Post<-(IsReplyTo)-Reply<-(RepliedWith)-Account2-(IsFriendOf)->Account1. Listy tabel w obu zapytaniach również nieco się różnią. Zapytanie zawierające wzorzec Account2-(Follows)->Account1 będzie oczywiście wyliczać tabelę krawędzi Follows, zaś to, które obejmuje wzorzec Account2-(IsFriendOf)->Account1, musi wyliczać tabelę krawędzi IsFriendOf.
Oto kompletny kod rozwiązania:
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo, Graph.Follows
WHERE MATCH(Account1-(Posted)->Post<-(IsReplyTo)-Reply
<-(RepliedWith)-Account2-(Follows)->Account1)
UNION
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo, Graph.IsFriendOf
WHERE MATCH(Account1-(Posted)->Post<-(IsReplyTo)-Reply
<-(RepliedWith)-Account2-(IsFriendOf)->Account1);
Głównym problemem, który można zauważyć w tym rozwiązaniu, jest to, że mamy tu bardzo dużo powtarzanego kodu, co prowadzi do nadmiernie długiego rozwiązania. A przecież pamiętamy, że jednym z powodów stosowania składni opartej na grafach była redukcja wielkości wymaganego kodu.
Drugą opcją jest użycie predykatu EXISTS i podzapytań. Idea polega na tym, że zapytanie zewnętrzne używa wzorca bazującego na całej logice koniunkcyjnej i oczywiście wylicza wszystkie tabele występujące we wzorcach bazowych w swojej klauzuli FROM. Następnie dla każdego składnika alternatywy używamy predykatu EXISTS z podzapytaniem i stosujemy zwykły operator OR pomiędzy predykatami EXISTS. Każde z podzapytań specyfikuje odpowiedni wzorzec bazowy w klauzuli WHERE i jawnie odwołuje się tylko do tej tabeli, która jest nowa w aktualnie obsługiwanym wzorcu bazowym, gdyż ma dostęp do wszystkich tabel zapytania zewnętrznego poprzez korelacje.
Oto kompletny kod rozwiązania:
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo
WHERE MATCH(Account1-(Posted)->Post<-(IsReplyTo)-Reply
<-(RepliedWith)-Account2)
AND (EXISTS (SELECT * FROM Graph.Follows
WHERE MATCH(Account2-(Follows)->Account1))
OR
EXISTS (SELECT * FROM Graph.IsFriendOf
WHERE MATCH(Account2-(IsFriendOf)->Account1)));
Zwróćmy uwagę na to, że podzapytania odwołują się do zewnętrznych tabel węzłów Account1 i Account2. Są to właśnie wspomniane korelacje.
Dwa ostatnie przykłady pokazały, jak poradzić sobie z logiczną alternatywą. Obsługa logiki negacji (NOT) jest bardzo podobna. Dla przykładu przyjmijmy, że naszym zadaniem jest zwrócenie postów i bezpośrednich odpowiedzi na nie tylko wtedy, gdy konto odpowiadające nie polubiło tego postu.
Przy rozwiązaniu bazującym na operatorach zbiorów tym razem użyjemy operatora EXCEPT, jak poniżej:
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo
WHERE MATCH(Account1-(Posted)->Post<-(IsReplyTo)-Reply
<-(RepliedWith)-Account2)
EXCEPT
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo, Graph.Likes
WHERE MATCH(Account1-(Posted)->Post<-(IsReplyTo)-Reply
<-(RepliedWith)-Account2-(Likes)->Post);
W przypadku rozwiązania bazującego na predykacie EXISTS po prostu użyjemy predykatu NOT EXISTS, tak jak tutaj:
SELECT Account1.accountname AS account1, Post.posttext,
Account2.accountname AS account2, Reply.posttext AS replytext
FROM Graph.Account AS Account1, Graph.Posted, Graph.Post,
Graph.Account AS Account2, Graph.Posted AS RepliedWith,
Graph.Post AS Reply, Graph.IsReplyTo
WHERE MATCH(Account1-(Posted)->Post<-(IsReplyTo)-Reply
<-(RepliedWith)-Account2)
AND NOT EXISTS
(SELECT *
FROM Graph.Likes
WHERE MATCH(Account2-(Likes)->Post));
Osobiście uważam tę technikę za całkiem wygodną. Zasadniczo pozwala nam obsłużyć logikę alternatywy i negacji przy użyciu operatorów OR i NOT, pomimo tego, że nie są one bezpośrednio obsługiwane we wzorcach dopasowania ani w łączeniu wielu wzorców ze sobą.
Zapytania rekurencyjne
Przed dodaniem funkcjonalności SQL Graph do języka T-SQL ludzie często wykorzystywali rekurencyjne zapytania bazujące na wyrażeniach tablicowych (rekurencyjnych CTE) w celu obsługi danych grafowych przechowywanych w tradycyjnych tabelach. (Zapytania rekurencyjne omówione zostały w rozdziale 5, "Wyrażenia tablicowe"). Działo się tak szczególnie w sytuacjach, gdy konieczne było przechodzenie przez struktury grafu poprzez ścieżki o dowolnej długości. Przykłady zadań wykorzystujących struktury tego rodzaju to uzyskanie podgrafu dla zadanego węzła (zwrócenie poddrzewa postów od wskazanego postu początkowego), pobranie łańcucha węzłów prowadzącego do danego węzła wejściowego (zwrócenie ścieżki wcześniejszych postów "przodków", prowadzących do zadanego postu) i tak dalej. W chwili pisania tych słów funkcjonalność SQL Graph nie obsługuje jeszcze zwykłych wzorców dopasowania wspierających ścieżki dowolnej długości; takie wzorce są obsługiwane jedynie przy jednoczesnym użyciu specjalizowanej opcji SHORTEST_PATH, którą omówię w dalszej części tego rozdziału. W międzyczasie wiele osób nadal używa zapytań rekurencyjnych i innych rozwiązań iteracyjnych w celu przechodzenia przez grafy po ścieżkach dowolnej długości, także wtedy, gdy są one przechowywane w tabelach SQL Graph. W tym podrozdziale wyjaśnię, jak używać rekurencyjnych zapytań do obiektów grafów, włącznie z ograniczeniami składni i metodami obejścia tych ograniczeń. Mam nadzieję, że w nieodległej przyszłości T-SQL uzyska obsługę zwykłych wzorców dopasowania, które będą wspierać ścieżki o dowolnej długości, co powinno znacząco zredukować potrzebę stosowania rekursji i innych rozwiązań iteracyjnych.
Typowe zadanie, jakie można próbować rozwiązać przy użyciu zapytań rekurencyjnych, to zwrócenie poddrzewa (podgrafu) dla zadanego węzła. Dla przykładu chcemy dla zadanego ID postu (do tego celu posłużymy się zmienną lokalną @postid) uzyskać ten post oraz wszystkie posty dziedziczące z niego - bezpośrednio i pośrednio. Inaczej mówiąc, chodzi o odpowiedzi, odpowiedzi na odpowiedzi i tak dalej. Dla każdego znalezionego postu potrzebujemy podać jego identyfikator, ID postu nadrzędnego oraz treść (tekst) postu.
Zakładając, że zapytania rekurencyjne są czymś znanym, ale Czytelnik jeszcze nie miał okazji ich użyć do odpytywania obiektów grafowych, w pierwszej próbie zapewne pojawi się rozwiązanie podobne do pokazanego poniżej (jako przykładowego wejścia używamy ID postu równego 13):
DECLARE @postid AS INT = 13;
WITH C AS
(
SELECT NULL AS parentpostid, postid, posttext
FROM Graph.Post
WHERE postid = @postid
UNION ALL
SELECT ParentPost.postid AS parentpostid,
ChildPost.postid, ChildPost.posttext
FROM C AS ParentPost, Graph.IsReplyTo, Graph.Post AS ChildPost
WHERE MATCH(ChildPost-(IsReplyTo)->ParentPost)
)
SELECT parentpostid, postid, posttext
FROM C;
Zapytanie zakotwiczające zwraca wiersz postu wejściowego, dla którego ID postu nadrzędnego to NULL, gdyż reprezentuje on korzeń podgrafu, który mamy zwrócić. Część rekurencyjna próbuje następnie użyć składni grafowej z bazowym wzorcem dopasowania (ChildPost-(IsReplyTo)->ParentPost), aby uzyskać bezpośrednie odpowiedzi na posty z wcześniejszej iteracji. Te zaś są reprezentowane poprzez rekurencyjne odwoływanie się do nazwy naszego CTE (C AS ParentPost). Problem w tym, że przynajmniej w chwili pisania tych słów klauzula MATCH nie wspiera używania rekurencyjnej referencji do nazwy CTE w miejsce obiektu grafowego we wzorcu dopasowania. Próba wykonania tego kodu kończy się niepowodzeniem z następującym komunikatem błędu:
Msg 13940, Level 16, State 1, Line 1373
Cannot use a derived table 'ParentPost' in a MATCH clause.
(Nie można użyć tabeli pochodnej 'ParentPost' w klauzuli MATCH.)
W tym momencie wielu użytkowników rezygnuje z używania składni bazującej na grafach w zapytaniu rekurencyjnym, zakładając, że po prostu nie jest to obsługiwane. Zamiast tego wracają do używania składni opartej na złączeniach, w której jawnie obsługujemy zależności pomiędzy obiektami grafowymi poprzez odpowiednie predykaty złączeń. Rozwiązanie takie przedstawię niedługo.
Mniej znanym faktem jest to, że możemy tu posłużyć się prostym obejściem, choć wymaga ono dodanie tabeli do zapytania rekurencyjnego, zatem wiąże się z tym pewien określony koszt. Idea polega na tym, że zamiast postrzegania rekurencyjnego wywołania nazwy CTE jako tabeli węzłów, dodajemy odpowiednią tabelę węzłów do zapytania (w naszym przykładzie będzie to Graph.Post AS ParentPost), a następnie używamy rekurencyjnego wywołania nazwy CTE jako proxy (pośrednika), dopasowując identyfikatory obu tych bytów w klauzuli WHERE zapytania. Możemy następnie bezpiecznie użyć aliasu przypisanego do tabeli węzłów (w naszym przykładzie ParentPost) jako części wzorca dopasowania. Oto jak można zastosować tę technikę w naszym rozwiązaniu:
DECLARE @postid AS INT = 13;
WITH C AS
(
SELECT NULL AS parentpostid, postid, posttext
FROM Graph.Post
WHERE postid = @postid
UNION ALL
SELECT ParentPost.postid AS parentpostid,
ChildPost.postid, ChildPost.posttext
FROM C, Graph.Post AS ParentPost, Graph.IsReplyTo,
Graph.Post AS ChildPost
WHERE ParentPost.postid = C.postid -- rekurencyjna referencja użyta
-- jako proxy
AND MATCH(ChildPost-(IsReplyTo)->ParentPost)
)
SELECT parentpostid, postid, posttext
FROM C;
Kod ten generuje następujące wyniki:
parentpostid postid posttext
------------ ----------- ------------------------------------------------
NULL 13 Got a new kitten. Any suggestions for a name?
13 449 Maybe Pickle?
13 677 Ambrosius?
449 1021 It does look a bit sour faced :)
449 1031 How about Gherkin?
1031 1061 I love Gherkin!
1061 1187 So you don't like Pickle!? I'M UNFRIENDING YOU!!!
Alternatywnie można zawsze użyć tradycyjnej składni bazującej na złączeniach, jak poniżej:
DECLARE @postid AS INT = 13;
WITH C AS
(
SELECT $node_id AS nodeid, NULL AS parentpostid, postid, posttext
FROM Graph.Post
WHERE postid = @postid
UNION ALL
SELECT CP.$node_id AS nodeid, PP.postid AS parentpostid,
CP.postid, CP.posttext
FROM C AS PP -- post nadrzędny
INNER JOIN Graph.IsReplyTo AS R
ON R.$to_id = PP.nodeid
INNER JOIN Graph.Post AS CP -- post podrzędny (odpowiedź)
ON R.$from_id = CP.$node_id
)
SELECT parentpostid, postid, posttext
FROM C;
Zaletą tradycyjnej składni jest to, że wykorzystujemy w niej jedną tabelę mniej, jako że nie potrzebujemy tu triku z proxy, zatem zapytanie powinno być nieco wydajniejsze. Korzyści wynikające ze składni grafowej omówiłem wcześniej - zazwyczaj brzmi bardziej naturalnie i jest bardziej zwięzła. Jeśli komuś składnia bazująca na grafach wydaje się bardziej atrakcyjna, rekomenduję przeprowadzenie testów w celu sprawdzenia, czy jej wydajność jest satysfakcjonująca pomimo dołączenia nadmiarowej tabeli.
Dodawanie sortowania i wcięć (formatowanie wyjścia)
Gdy zachodzi potrzeba zwrócenia podgrafu, zazwyczaj będziemy chcieli uzyskać wyniki posortowane zgodnie z tak zwanym porządkiem topologicznym. W takim uporządkowaniu węzły nadrzędne powinny być zwracane przed jego węzłami potomnymi. Ponadto, jeśli węzeł N1 wypada przed równorzędnym (siostrzanym) węzłem N2, wszystkie potomki węzła N1 powinny zostać uporządkowane po N1, ale przed N2. Inne typowe wymaganie to dodanie wizualnego efektu polegającego na przesunięciu pewnego atrybutu zwracanego węzła (na przykład tekstu postu w naszym przykładzie) w taki sposób, aby jego położenie w wierszu było proporcjonalne do jego poziomu zagnieżdżenia w poddrzewie. Dzięki posortowaniu i wcięciom możemy uzyskać bardzo czytelną i przekonującą reprezentację zależności pomiędzy zwracanymi węzłami.
Przed wyjaśnieniem, jak obsłużyć wymagania sortowania i wcięć, na chwilę wybiegnę w przód i pokażę pożądane wyniki rozwiązania, aby było jasne, co próbujemy osiągnąć:
post
-------------------------------------------------------------
Got a new kitten. Any suggestions for a name?
| Maybe Pickle?
| | It does look a bit sour faced :)
| | How about Gherkin?
| | | I love Gherkin!
| | | | So you don't like Pickle!? I'M UNFRIENDING YOU!!!
| Ambrosius?
Jeśli chodzi o kod rozwiązania, możemy rozpocząć od pokazanego wcześniej bazującego na grafach rozwiązania zadania poddrzewa, rekurencyjnie wykorzystującego nazwę CTE jako proxy tabeli węzłów postów nadrzędnych. Naturalnie można tu również użyć bardziej tradycyjnej składni opartej na złączeniach, ale zademonstruję rozwiązanie wykorzystujące składnię grafową. Dodajemy kolumnę (nazwaną lvl), która reprezentuje poziom zagnieżdżenia węzła w podgrafie (0 dla węzła wejściowego, 1 dla bezpośrednich odpowiedzi i tak dalej). W części zakotwiczającej po prostu przypisujemy stałą 0 do kolumny lvl (0 AS lvl). W części rekurencyjnej inkrementujemy (dodajemy jeden) poziom postu nadrzędnego, używając referencji do nazwy rekurencyjnego CTE (C.lvl + 1 AS lvl).
Dodajemy też dodatkową kolumnę sortkey, reprezentującą wartość porządkującą w postaci ciągu znakowego dla danego węzła. W części zakotwiczającej zapytania ustawiamy sortkey jako stałą '.'. W części rekurencyjnej scalamy wartość sortkey postu nadrzędnego z wartością ID bieżącego postu i '.'. Weźmy dla przykładu post o ID 1061. Jego post nadrzędny ma ID 1031, dla którego ID rodzica to 449, którego postem nadrzędnym jest ID 13 - czyli post wejściowy w naszym przykładzie. Kod powinien zatem wygenerować wartość sortkey równą '.449.1031.1061.' dla postu 1061.
Zewnętrzne zapytanie może zapewnić pożądane uporządkowanie, sortując wyniki według sortkey. Wcięcia można zrealizować, powielając lvl razy pewien ciąg, na przykład ' | ', i scalając z tym żądany atrybut (posttext). Oto kompletne rozwiązanie:
DECLARE @postid AS INT = 13;
WITH C AS
(
SELECT NULL AS parentpostid, postid, posttext,
0 AS lvl,
CAST('.' AS VARCHAR(MAX)) AS sortkey
FROM Graph.Post
WHERE postid = @postid
UNION ALL
SELECT ParentPost.postid AS parentpostid,
ChildPost.postid, ChildPost.posttext,
C.lvl + 1 AS lvl,
CONCAT(C.sortkey, ChildPost.postid, '.') AS sortkey
FROM C, Graph.Post AS ParentPost, Graph.IsReplyTo,
Graph.Post AS ChildPost
WHERE ParentPost.postid = C.postid -- recursive ref used as proxy
AND MATCH(ChildPost-(IsReplyTo)->ParentPost)
)
SELECT
REPLICATE(' | ', lvl) + posttext AS post
FROM C
ORDER BY sortkey;
Kod ten generuje wyjście pokazane wcześniej jako oczekiwane:
post
-------------------------------------------------------------
Got a new kitten. Any suggestions for a name?
| Maybe Pickle?
| | It does look a bit sour faced :)
| | How about Gherkin?
| | | I love Gherkin!
| | | | So you don't like Pickle!? I'M UNFRIENDING YOU!!!
| Ambrosius?
Jeśli techniki użyte w tym rozwiązaniu nie wydają się dostatecznie zrozumiałe, pomocne może być uruchomienie tego kodu ponownie z SELECT * jako zapytaniem zewnętrznym i zbadanie zawartości kolumn lvl oraz sortkey.
Opcja SHORTEST_PATH
Jedną z klasycznych potrzeb występujących przy przetwarzaniu grafów jest znalezienie najkrótszej ścieżki pomiędzy dwoma węzłami. Przykładem może być identyfikacja najkrótszej drogi pomiędzy dwiema lokalizacjami na mapie, a w naszym scenariuszu najkrótsza ścieżka znajomości pomiędzy dwoma kontami. Często chcemy znaleźć najkrótszą drogę niezależnie od liczby przeskoków lub poziomów, przez które trzeba przejść, ale niekiedy może potrzebować ograniczyć liczbę przeskoków. T-SQL pozwala obsłużyć takie wymaganie za pomocą składni odpytywania grafów z użyciem podklauzuli SHORTEST_PATH w klauzuli MATCH. Ta poklauzula jest dostępna od wersji SQL Server 2019, a także w Azure SQL Database. W chwili pisania tych słów T-SQL jeszcze nie obsługuje wyszukiwania ważonej najkrótszej ścieżki. Przykładem ważonej ścieżki jest odszukanie najkrótszej drogi w kilometrach pomiędzy dwoma miastami. Za pomocą opcji SHORTEST_PATH możemy obecnie znaleźć trasę, która biegnie przez najmniejszą liczbę segmentów dróg (przeskoków), ale nie najkrótszą, jeśli chodzi o całkowitą długość w kilometrach. Mam nadzieję, że taka możliwość zostanie dodana do T-SQL w niedalekiej przyszłości.
Opcji SHORTEST_PATH możemy użyć do znalezienia najkrótszej ścieżki pomiędzy pojedynczym węzłem źródłowym i wieloma węzłami docelowymi, pomiędzy dwoma konkretnymi węzłami albo pomiędzy wieloma węzłami początkowymi i wieloma końcowymi. Omawianie rozpocznę od zadania wyszukania najkrótszych ścieżek pomiędzy pojedynczym węzłem początkowym i wieloma węzłami końcowymi. Dla przykładu przypuśćmy, że chcemy znaleźć najkrótszą drogę znajomości pomiędzy kontem Orli a jej bezpośrednimi i pośrednimi znajomymi.
Wiemy już, jak zidentyfikować bezpośrednich znajomych konta, używając wzorca prostego dopasowania, jak poniżej:
SELECT Account1.accountname AS account1,
Account2.accountname AS account2
FROM Graph.Account AS Account1, Graph.IsFriendOf,
Graph.Account AS Account2
WHERE MATCH(Account1-(IsFriendOf)->Account2)
AND Account1.accountname = N'Orli';
Kod ten generuje poniższe wyjście:
account1 account2
----------- -----------
Orli Inka
Orli Tami
Orli Mitzi
Potrzebujemy opcji SHORTEST_PATH, aby uwzględnić pośrednią relację znajomości. Opcja SHORTEST_PATH pozwala wyspecyfikować wzorzec dopasowania o dowolnej długości za pomocą następującej składni: MATCH(SHORTEST_PATH(wzorzec_dowolnej_długości)). Sam wzorzec zawierający klauzulę SHORTEST_PATH znany jest pod nazwą wzorca dopasowania dowolnej długości (arbitrary length match pattern). Wzorzec specyfikowany wewnątrz klauzuli SHORTEST_PATH nazywamy wzorcem dowolnej długości (arbitrary length pattern). Wzorzec dowolnej długości zasadniczo jest rozszerzeniem prostego wzorca dopasowania. Obejmuje on część, która jest uwzględniana tylko raz, oraz część, która ma być powtarzana. Dla przykładu weźmy prosty wzorzec dopasowania z powyższego zapytania, który identyfikuje bezpośrednią relację znajomości:
Account1-(IsFriendOf)->Account2
Przypomnijmy, że normalnie w prostym wzorcu dopasowania wyliczamy wszystkie uczestniczące tabele grafu i ich aliasy w klauzuli FROM zapytania, po czym specyfikujemy nazwy/alisay tabel we wzorcu w klauzuli MATCH, przy czym żadna część wzorca nie jest uważana za powtarzającą się. W naszym zadaniu wyszukania najkrótszej ścieżki chcielibyśmy rozszerzyć prosty wzorzec dopasowania do wzorca o dowolnej długości, aby obsłużyć również przypadek pośredniej relacji, która może mieć dowolną długość. Punktem początkowym wzorca jest pierwszy węzeł początkowy relacji, którym w naszym przypadku jest alias tabeli węzłów Account1. Ta część musi być uwzględniona jedynie raz. Tym samym jak zwykle specyfikujemy nazwę tabeli źródłowej i jej alias Graph.Account AS Account1 w klauzuli FROM zapytania oraz alias Account1 jako początkową część wzorca. Jak dotąd, wygląda to tak samo, jak w prostym wzorcu dopasowania. Kolejna część jest odmienna - to ta część ma być powtarzana. Chcemy kontynuować wzorzec ze strzałką wskazującą węzeł końcowy, którym w naszym przypadku jest Account2, poprzez tabelę krawędzi IsFriendOf. W prostym wzorcu dopasowania wyrazilibyśmy tę część jako -(IsFriendOf)->Account2. We wzorcu o dowolnej długości potrzebujemy jakoś sformułować to, że ta część ta ma być powtarzana - przy czym nie znamy z góry liczby powtórzeń. Można to osiągnąć, stosując następującą składnię:
W klauzuli FROM zapytania specyfikujemy tabele grafowe z powtarzanej części wzorca, po każdej umieszczając dyrektywę FOR PATH, a następnie alias.
Powtarzaną część wzorca zapisujemy w nawiasach, po których następuje kwantyfikator wyrażeń regularnych. Kwantyfikator + powoduje, że liczba przeskoków nie jest limitowana (można to przeczytać jako "co najmniej jeden"), zaś dla ograniczenia liczby dozwolonych przeskoków do N, gdzie N jest dodatnią liczbą całkowitą, stosujemy kwantyfikator {1, N}.
Jeśli chodzi o część wzorca, która ma być powtarzana, do klauzuli FROM zapytania dodajemy wyrażenia Graph.IsFriendOf FOR PATH AS IFO (naturalnie można wybrać dowolny inny alias) oraz Graph.Account FOR PATH AS Account2. W samym wzorcu część powtarzana uzyskuje postać (-(IFO)->Account2)+. W tym przypadku używamy kwantyfikatora +, aby zaznaczyć, że nie chcemy ograniczać liczby przeskoków. Ostatecznie nasz wzorzec o dowolnej długości uzyskuje postać SHORTEST_PATH(Account1(-(IFO)->Account2)+) i jest umieszczany w klauzuli MATCH jako część filtra WHERE zapytania. Nadal potrzebujemy wyfiltrować konto Orli jako jedyny interesujący nas węzeł początkowy, używając dodatkowego predykatu w klauzuli WHERE.
W tym momencie mamy już klauzule FROM oraz WHERE naszego zapytania:
FROM
Graph.Account AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account FOR PATH AS Account2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+))
AND Account1.accountname = N'Orli'
Pozostała jeszcze jedna rzecz - klauzula SELECT. Wiersze dostarczane do fazy SELECT w logicznym przetwarzaniu zapytania grafowego z najkrótszą ścieżką wykorzystującego wzorzec o dowolnej długości mają pewną cechę szczególną. W zwykłych zapytaniach grafowych każda wynikowa wartość kolumny w każdym wierszu przetwarzanym w fazie SELECT reprezentuje pojedynczą wartość. Jest to nadal spełnione w zapytaniach z najkrótszą ścieżką dla wszystkich obiektów grafu, które uczestniczą w tej części wzorca, która jest uwzględniana tylko raz, czyli w naszym przykładowym wzorcu Account1. Do tych kolumn odwołujemy się w klauzuli SELECT tak, jak zwykle. Na przykład, jeśli chcemy zwrócić kolumnę accountname z tabeli Account1, użyjemy wyrażenia Account1.accountname. Jednak w przypadku obiektów grafu z powtarzanej części wzorca, takich jak IFO oraz Account2, każda wartość ich kolumn w każdym wierszu jest rozpatrywana jako uporządkowana kolekcja (ordered collection), z porządkiem ścieżki grafu (graph path). Zapewne najłatwiej będzie to zrozumieć za pomocą ilustracji. Przypuśćmy, że jedna z najkrótszych ścieżek znalezionych przez nasze zapytanie to Orli->Tami->Miko->Omer (nazwy kont występujące w ścieżce). Rysunek 11-4 pokazuje wartości kolumn Account1.accountname (indywidualną) oraz Account2.accountname (uporządkowana kolekcja) w odpowiadającym tej ścieżce wierszu wyników.
Rysunek 11-4 Wartości indywidualne kontra uporządkowane kolekcje w jednym wierszu wyników
Powinno być oczywiste, że użycie wyrażenia Account1.accountname na liście SELECT nie wiąże się z żadnym szczególnym problemem, jako że reprezentuje ono pojedynczą wartość dla każdego wiersza. Jednak w przypadku wartości kolumny reprezentującej kolekcję sytuacja jest odmienna. Normalnie T-SQL wymaga, aby wyrażenia z listy SELECT zwracały pojedyncze wartości kolumn dla każdego wiersza wyników. Dla przykładu spróbujmy wykonać poniższe zapytanie:
SELECT Account1.accountname, Account2.accountname
FROM
Graph.Account AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account FOR PATH AS Account2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+))
AND Account1.accountname = N'Orli';
Odwołanie do Account2.accountname jest niepoprawne, jako że wartość kolumny dla każdego wiersza reprezentuje kolekcję. Otrzymamy następujący błąd:
Msg 13961, Level 16, State 1, Line 1572
The alias or identifier 'Account2.accountname' cannot be used in the select
list, order by, group by, or having context.
(Alias lub identyfikator 'Account2.accountname' nie może być używany
w kontekście listy Select, Order By, Group by ani Having.)
T-SQL wymaga zastosowania pewnych przekształceń do wartości będącej kolekcją, aby uzyskać wartość pojedynczą. Aby to osiągnąć, T-SQL udostępnia specjalizowane funkcje agregujące, nazywane funkcjami agregującymi ścieżki grafu (graph path aggregate functions), do których należą funkcje STRING_AGG, LAST_VALUE, SUM, COUNT, AVG, MIN oraz MAX. Wyrażenie wykorzystujące kolumnę o wartościach kolekcji przekazujemy jako wejście do funkcji, dodając klauzulę porządkującą WITHIN GROUP (GRAPH PATH).
Dla przykładu, aby utworzyć ścieżkę w postaci ciąg znaków, złożoną z nazw kont na najkrótszej ścieżce znajomości w naszym zapytaniu, możemy użyć wyrażenia Account1.accountname + N'->' + STRING_AGG(Account2.accountname, N'->') WITHIN GROUP(GRAPH PATH). Zaczynamy ścieżkę od Account1.accountname, gdyż nie jest to część uporządkowanej kolekcji Account2.accountname. Następnie dodajemy separator '->' (albo dowolny inny, który wybierzemy). Uzyskany ciąg scalamy z wynikiem funkcji agregującej ścieżki grafu STRING_AGG zastosowanej do Account2.accountname, ponownie używając '->' jako separatora.
W ten sposób uzyskaliśmy już kompletne rozwiązanie naszego zadania:
SELECT
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS friendships
FROM
Graph.Account AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account FOR PATH AS Account2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+))
AND Account1.accountname = N'Orli';
Kod ten generuje następujący wynik:
friendships
------------------------------
Orli->Inka
Orli->Tami
Orli->Mitzi
Orli->Tami->Miko
Orli->Tami->Buzi
Orli->Mitzi->Orli
Orli->Mitzi->Yatzek
Orli->Tami->Buzi->Alma
Orli->Tami->Miko->Omer
Orli->Mitzi->Yatzek->Lilach
Orli->Tami->Buzi->Alma->Stav
Zwróćmy uwagę, że wyjście to zawiera ścieżki znajomości, w których pierwszy i ostatni węzeł są takie same. Za chwilę pokażę, jak wyeliminować takie ścieżki z wyników.
Jak można sobie wyobrazić, może istnieć wiele różnych ścieżek łączących dwa węzły. Wzorzec dopasowania o dowolnej długości jest tak zaprojektowany, że może zwrócić każdą z nich. Na razie nie mamy kontroli nad tym, którą uzyskamy, a w każdym razie nie jako części wzorca.
W powyższym zapytaniu użyliśmy kwantyfikatora wyrażeń regularnych +, aby wskazać, że nie chcemy ograniczać liczby przeskoków w znajdowanych ścieżkach. Przypomnijmy, że możemy użyć kwantyfikatora {1, N} w celu ograniczenia liczby przeskoków do (maksymalnie) N. Poniższa modyfikacja naszego zapytania zwraca tylko ścieżki zawierające co najwyżej dwa przeskoki:
SELECT
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS friendships
FROM
Graph.Account AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account FOR PATH AS Account2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2){1, 2}))
AND Account1.accountname = N'Orli';
Kod ten generuje poniższe wyjście:
friendships
------------------------------
Orli->Inka
Orli->Tami
Orli->Mitzi
Orli->Tami->Miko
Orli->Tami->Buzi
Orli->Mitzi->Orli
Orli->Mitzi->Yatzek
Zauważmy, że wzorzec o dowolnej długości nie musi zaczynać się od części rozpatrywanej tylko raz i następnie przechodzić do części powtarzanej. Każdy element wzorca możemy umieścić tam, gdzie ma to sens, stosownie do zadania. Dla przykładu przypuśćmy, że zamiast zwracania najkrótszych ścieżek biegnących od Orli potrzebujemy znaleźć ścieżki prowadzące do Orli. Aby to osiągnąć, trzeba dokonać kilku zmian w zapytaniu wyszukujących wszystkie ścieżki biegnące od Orli. Jedną z tych zmian jest przeformułowanie wzorca o dowolnej długości, aby zaczynał się od części powtarzanej (Account1-(IFO)->)+ i kończył się na Account2 jako części rozpatrywanej tylko raz, co daje wzorzec (Account1-(IFO)->)+Account2. Inną zmianą jest filtrowanie Account2.accountname = Orli w klauzuli WHERE. W istocie potrzeba jeszcze kilku innych zmian, które przedstawię w kolejnych akapitach.
Wydawać by się mogło, że przy używaniu takiego wzorca, którego pierwszą częścią jest część powtarzana, kolekcja jest zwracana w kolejności, którą można nazwać odwróconym porządkiem ścieżki. Dla przykładu Account2.accountname w tym przykładzie jest pojedynczą wartością - Orli. Account1.accountname jest wartością bazującą na kolekcji (Tami, Miko, Alma, Stav), w tej właśnie kolejności. Z punktu widzenia fizycznego przetwarzania naturalnie rozsądne jest rozpoczynanie od części pojedynczej, która w tym przypadku jest końcem ścieżki grafu, i przechodzenie ścieżki wstecz, od końca do początku. Dlatego zapewne elementy są zwracane właśnie w takiej kolejności, gdy część powtarzana znajduje się na początku wzorca. Aby utworzyć poprawną reprezentację ścieżki, możemy zacząć od Account2.accountname. Następnie scalamy z tą wartością strzałkę wskazującą w lewo '<-' jako separator. Później dołączamy wynik funkcji STRING_AGG zastosowanej do Account1.accountname, bazującej na porządku ścieżki grafu, z separatorem w postaci strzałki w lewo. Oto kompletne zapytanie stanowiące rozwiązanie zadania:
SELECT
Account2.accountname + N'<-'
+ STRING_AGG(Account1.accountname, N'<-')
WITHIN GROUP(GRAPH PATH) AS friendships
FROM
Graph.Account FOR PATH AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account AS Account2
WHERE MATCH(SHORTEST_PATH((Account1-(IFO)->)+Account2))
AND Account2.accountname = N'Orli';
Zapytanie to generuje poniższe wyjście:
friendships
------------------------------
Orli<-Inka
Orli<-Tami
Orli<-Mitzi
Orli<-Tami<-Miko
Orli<-Tami<-Buzi
Orli<-Mitzi<-Orli
Orli<-Mitzi<-Yatzek
Orli<-Tami<-Miko<-Alma
Orli<-Mitzi<-Yatzek<-Omer
Orli<-Mitzi<-Yatzek<-Lilach
Orli<-Tami<-Miko<-Alma<-Stav
Na wypadek pytań - nie, obecnie nie ma możliwości wyspecyfikowania DESCENDING (malejąco) w celu zmiany kierunku porządku ścieżki.
Kolejne zadanie to zwrócenie najkrótszej ścieżki łączącej dwa konkretne węzły - na przykład znalezienie najkrótszego połączenia znajomości pomiędzy Orli a Stavem. Aby uzyskać bezpośrednią relację znajomości pomiędzy nimi, o ile istnieje, użyjemy znanego już zapytania z prostym wzorcem dopasowania i dwoma podstawowymi predykatami filtru, wybierającymi wskazane nazwy kont:
SELECT Account1.accountname AS account1,
Account2.accountname AS account2
FROM Graph.Account AS Account1, Graph.IsFriendOf,
Graph.Account AS Account2
WHERE MATCH(Account1-(IsFriendOf)->Account2)
AND Account1.accountname = N'Orli'
AND Account2.accountname = N'Stav';
Jednak Orli i Stav nie są bezpośrednimi znajomymi, zatem wyjściem tego zapytania jest zbiór pusty:
account1 account2
----------- -----------
(0 rows affected)
Spróbujmy zatem rozszerzyć zapytanie, aby używało opcji SHORTEST_PATH. Jako punktu wyjścia możemy użyć tego samego zapytania, które odszukiwało najkrótsze ścieżki pomiędzy Orli i jej bezpośrednimi i pośrednimi znajomymi przy użyciu wzorca dowolnej długości Account1(-(IFO)->Account2)+. W tamtym zapytaniu użyliśmy predykatu filtru Account1.accountname = N'Orli'. Ktoś mógłby uznać, że wystarczy dodać jeszcze predykat filtru Account2.accountname = N'Stav', jak poniżej:
SELECT
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS friendships
FROM
Graph.Account AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account FOR PATH AS Account2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+))
AND Account1.accountname = N'Orli'
AND Account2.accountname = N'Stav'; -- niedozwolone
Jednak taka próba jest nieprawidłowa, gdyż Account2.accountname jest kolekcją. Zapytanie zakończy się niepowodzeniem z następującym komunikatem o błędzie (widzieliśmy go już wcześniej):
Msg 13961, Level 16, State 1, Line 1573
The alias or identifier 'Account2.accountname' cannot be used in the select
list, order by, group by, or having context.
Tym, co chcemy naprawdę zrobić, jest wydobycie nazwy ostatniego konta z kolekcji Account2.accountname i wyfiltrowanie wiersza, w którym to imię to Stav. Możemy do tego użyć funkcji LAST_VALUE bazującej na porządku ścieżki grafu. Należy to zrobić w liście SELECT zapytania, przypisując alias wynikowej kolumnie - na przykład lastnode. Nadal możemy oczywiście filtrować Account1.accountname, jak poprzednio. Następnie możemy zdefiniować wyrażenie tablicowe, na przykład CTE opartego na tym zapytaniu i filtrować wiersz, w którym ostatni węzeł to Stav, w zapytaniu zewnętrznym. Oto kompletny kod rozwiązania:
WITH C AS
(
SELECT
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS friendships,
LAST_VALUE(Account2.accountname)
WITHIN GROUP (GRAPH PATH) AS lastnode -- dostęp do ostatniego węzła
-- ścieżki
FROM
Graph.Account AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account FOR PATH AS Account2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+))
AND Account1.accountname = N'Orli'
)
SELECT friendships
FROM C
WHERE lastnode = N'Stav';
Kod ten generuje poniższe wyjście pokazujące, że pomiędzy Orli a Stavem istnieje ścieżka znajomości:
friendships
------------------------------
Orli->Tami->Buzi->Alma->Stav
Wróćmy na chwilę do zadania zwracania wszystkich ścieżek prowadzących do Orli, ale tym razem spróbujmy uzyskać je w poprawnej kolejności ścieżek, a nie odwróconej. Można to osiągnąć, dokonując niewielkiej zmiany w powyższym zapytaniu. Usuwamy filtr wewnętrznego zapytania dla Account1.accountname i zmieniamy filtr zewnętrznego zapytania, aby miał postać lastnode = N'Orli', jak poniżej:
WITH C AS
(
SELECT
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS friendships,
LAST_VALUE(Account2.accountname)
WITHIN GROUP (GRAPH PATH) AS lastnode
FROM
Graph.Account AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account FOR PATH AS Account2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+))
)
SELECT friendships
FROM C
WHERE lastnode = N'Orli';
Kod ten generuje poniższe wyjście:
Friendships
-----------------------------
Inka->Orli
Tami->Orli
Mitzi->Orli
Miko->Tami->Orli
Buzi->Tami->Orli
Orli->Mitzi->Orli
Yatzek->Mitzi->Orli
Alma->Miko->Tami->Orli
Omer->Yatzek->Mitzi->Orli
Lilach->Yatzek->Mitzi->Orli
Stav->Alma->Miko->Tami->Orli
Ostatni przypadek, czyli zwrócenie najkrótszych ścieżek pomiędzy wieloma węzłami początkowymi i wieloma końcowymi, czego przykładem mogą być ścieżki znajomości pomiędzy wszystkimi kontami, realizujemy usuwając po prostu obydwa filtry na nazwy kont. Dodatkowo, jeśli nie potrzebujemy ani zwracać, ani filtrować na podstawie ostatniego węzła, nie potrzebujemy już wyrażenia CTE. Oto zapytanie będące rozwiązaniem:
SELECT
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS friendships
FROM
Graph.Account AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account FOR PATH AS Account2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+));
Kod ten generuje poniższe wyjście (skrócone dla oszczędności miejsca):
Friendships
------------------------------
Inka->Tami
Inka->Orli
Inka->Mitzi
...
Inka->Orli->Inka
Inka->Tami->Miko
Inka->Tami->Buzi
Inka->Mitzi->Yatzek
Miko->Tami->Inka
Miko->Tami->Miko
Miko->Alma->Buzi
Miko->Tami->Orli
Miko->Alma->Stav
Miko->Yatzek->Mitzi
...
Stav->Alma->Miko->Tami->Inka
Stav->Alma->Buzi->Mitzi->Orli
(121 rows affected)
W tym momencie znaleźliśmy się bardzo blisko rozwiązania bardzo klasycznego zadania teorii grafów, jakim jest zwrócenie przechodniego domknięcia grafu. Domknięcie przechodnie grafu wejściowego G to nowy graf TC zawierający wyjściową parę węzłów dla każdej wejściowej pary węzłów, które są połączone ścieżką (bezpośrednio lub pośrednio) w grafie wejściowym. Na przykład domknięciem przechodnim naszego grafu znajomości będzie graf zawierający wszystkie pary kont, które znają się bezpośrednio lub pośrednio. Można to osiągnąć za pomocą naszego rozwiązania najkrótszych ścieżek "wiele do wielu", zwracając jedynie pierwszy i ostatni węzeł z każdej ścieżki, jak poniżej:
SELECT
Account1.accountname AS firstnode,
LAST_VALUE(Account2.accountname)
WITHIN GROUP (GRAPH PATH) AS lastnode
FROM
Graph.Account AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account FOR PATH AS Account2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+));
Kod ten generuje poniższe wyjście, ponownie skrócone dla oszczędności miejsca:
firstnode lastnode
---------- ---------
Inka Tami
Inka Orli
Inka Mitzi
Miko Tami
Miko Alma
Miko Omer
Miko Yatzek
Miko Lilach
Tami Inka
Tami Miko
...
Inka Inka
Inka Miko
Inka Buzi
Inka Yatzek
Miko Inka
Miko Miko
Miko Buzi
Miko Orli
Miko Stav
Miko Mitzi
...
Omer Inka
Omer Buzi
Omer Orli
Mitzi Stav
Lilach Inka
Lilach Orli
Inka Stav
Orli Stav
Stav Inka
Stav Orli
(121 rows affected)
Zwróćmy uwagę, że powyższe wyjście zawiera pary identycznościowe, takie jak (Inka, Inka), a także pary lustrzane, jak (Inka, Miko) oraz (Miko, Inka). Gdybyśmy chcieli je wyeliminować, możemy dodać filtr zachowujący jedynie te pary, w których firstnode < lastnode. Trzeba będzie użyć wyrażenia tablicowego, jak poprzednio, zanim będziemy mogli odwoływać się do aliasów wyników obliczania firstnode i lastnode w filtrze zapytania zewnętrznego. Gdybyśmy chcieli również policzyć przeskoki w znalezionych ścieżkach, moglibyśmy użyć funkcji COUNT bazującej na porządku ścieżki grafu. Oto kompletne zapytanie zawierające zarówno część filtrującą, jak i część zliczającą przeskoki:
WITH C AS
(
SELECT
Account1.accountname AS firstnode,
COUNT(Account2.accountid)
WITHIN GROUP(GRAPH PATH) AS hops,
LAST_VALUE(Account2.accountname)
WITHIN GROUP (GRAPH PATH) AS lastnode
FROM
Graph.Account AS Account1,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Account FOR PATH AS Account2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+))
)
SELECT firstnode AS account1, lastnode AS account2, hops
FROM C
WHERE firstnode < lastnode;
Kod ten generuje poniższe wyjście:
account1 account2 hops
----------- ----------- -----
Inka Tami 1
Inka Orli 1
Inka Mitzi 1
Miko Tami 1
Miko Omer 1
Miko Yatzek 1
Buzi Tami 1
Buzi Mitzi 1
Alma Miko 1
Alma Buzi 1
Alma Stav 1
Alma Yatzek 1
Alma Lilach 1
Orli Tami 1
Omer Yatzek 1
Mitzi Orli 1
Mitzi Yatzek 1
Lilach Miko 1
Lilach Stav 1
Lilach Omer 1
Lilach Yatzek 1
Inka Miko 2
Inka Yatzek 2
Miko Orli 2
Miko Stav 2
Miko Mitzi 2
Tami Yatzek 2
Buzi Inka 2
Buzi Miko 2
Buzi Orli 2
Buzi Stav 2
Buzi Yatzek 2
Buzi Lilach 2
Alma Tami 2
Alma Omer 2
Alma Mitzi 2
Orli Yatzek 2
Stav Yatzek 2
Omer Tami 2
Omer Stav 2
Mitzi Tami 2
Mitzi Omer 2
Lilach Tami 2
Lilach Mitzi 2
Inka Omer 3
Inka Lilach 3
Buzi Omer 3
Alma Inka 3
Alma Orli 3
Stav Tami 3
Omer Orli 3
Mitzi Stav 3
Lilach Orli 3
Inka Stav 4
Orli Stav 4
Używanie funkcji LAST_NODE
Funkcja LAST_NODE została zaprojektowana w celu umożliwienia łączenia ze sobą wielu wzorców o dowolnej długości. Używamy tej funkcji jako części wzorca wewnątrz opcji SHORTEST_PATH. Jako wejście funkcji stosujemy jakąś tabelę węzłów, a wyjście reprezentuje ostatni węzeł w najkrótszej ścieżce. Możemy zatem użyć tej funkcji do połączenia końca jednej najkrótszej ścieżki z początkiem następnej albo do spinania w łańcuch najkrótszych ścieżek na ich końcach.
Dla przykładu przypuśćmy, że chcemy zidentyfikować najkrótszy łańcuch znajomości biegnący do Orli do Yatzka za pośrednictwem Stava, ale taki, w którym ani Orli, ani Yatzek nie występują po drodze. Łańcuch będzie zatem składać się z dwóch ścieżek: pierwszej, która zaczyna się od Orli, kończy na Stavie i nie zawiera Yatzka jako pośredniego, oraz drugiej, która rozpoczyna się od Stava, kończy na Yatzku i nie zawiera Orli. Potrzebujemy zatem trzech odwołań do tabeli węzłów Graph.Account. Jedna referencja będzie reprezentować pojedynczy węzeł (nazwijmy ją Account1) rozpoczynający pierwszą najkrótszą ścieżkę. Druga referencja reprezentuje kolekcję FOR PATH (nazwijmy ją Account2), która stanowi dalszy ciąg i koniec pierwszej ścieżki, a zarazem początek drugiej. Możemy jako Account2 użyć tabeli pochodnej, która wyfiltruje wszystkie konta z wyjątkiem Yatzka. Trzecia referencja reprezentuje kolekcję FOR PATH (nazwijmy ją Account3), stanowiącą ciąg dalszy i koniec drugiej ścieżki. Ponownie możemy tu użyć jako Account3 tabeli pochodnej, która wyfiltruje wszystkie konta z wyjątkiem Orli. Potrzebujemy również dwóch referencji FOR PATH dla tabeli krawędzi Graph.IsFriendOf: jednej w celu połączenia Account1 z Account2 (nazwijmy ją IFO1) i drugiej do połączenia Account2 z Account3 (niech to będzie IFO2). Wzorzec dowolnej długości dla pierwszej ścieżki to zgodnie z oczekiwaniem Account1(-(IFO1)->Account2)+. Wzorzec dla drugiej najkrótszej ścieżki będzie podobny, ale rozpocznie się od funkcji LAST_NODE zastosowanej do Account2, co daje nam wzorzec LAST_NODE(Account2)(-(IFO2)->Account3)+. Każdą z powyższych ścieżek specyfikujemy wewnątrz opcji SHORTEST_PATH, oddzielając je operatorem AND. Możemy jak poprzednio użyć CTE, ale tym razem zastosujemy trzy filtry na nazwy kont. W wewnętrznym zapytaniu zastosujemy predykat filtru Account1.accountname = N'Orli'. Następnie używamy funkcji LAST_VALUE do wydobycia ostatniego węzła pierwszej ścieżki (nazwijmy go midnode), a także ostatniego węzła drugiej ścieżki, który nazwiemy lastnode. Następnie w zapytaniu zewnętrznym filtrujemy midnode równe Stav oraz lastnode równe Yatzek.
Oto pełny kod rozwiązania:
WITH C AS
(
SELECT
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) + N'->'
+ STRING_AGG(Account3.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS friendships,
LAST_VALUE(Account2.accountname)
WITHIN GROUP (GRAPH PATH) AS midnode,
LAST_VALUE(Account3.accountname)
WITHIN GROUP (GRAPH PATH) AS lastnode
FROM
Graph.Account AS Account1,
( SELECT * FROM Graph.Account
WHERE accountname <> N'Yatzek' ) FOR PATH AS Account2,
( SELECT * FROM Graph.Account
WHERE accountname <> N'Orli') FOR PATH AS Account3,
Graph.IsFriendOf FOR PATH AS IFO1,
Graph.IsFriendOf FOR PATH AS IFO2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO1)->Account2)+)
AND SHORTEST_PATH(LAST_NODE(Account2)(-(IFO2)->Account3)+))
AND Account1.accountname = N'Orli'
)
SELECT friendships
FROM C
WHERE midnode = N'Stav'
AND lastnode = N'Yatzek';
Kod ten generuje poniższe wyjście, pokazujące, że rzeczywiście istnieje ścieżka od Orli do Yatzka poprzez Stava, która nie zawiera Orli ani Yatzka jako kont pośrednich:
friendships
---------------------------------------------
Orli->Tami->Buzi->Alma->Stav->Lilach->Yatzek
Przypuśćmy, że potrzebujemy zwrócić wszystkie konta łączące Orli i Yatzka, bez nich samych jako pośredników, i najkrótsza ścieżka łącząca zawiera trzy konta, przy czym to trzecie jest znajomym spinającym dwa łańcuchy, ale tym razem nie narzucamy warunku, kim jest ten trzeci - innymi słowy, niekoniecznie ma to być Stav. Ponownie konto to musi być końcem najkrótszej ścieżki rozpoczynającej się od Orli, bez konta Yatzek występującego jako pośrednik. To samo konto musi rozpoczynać najkrótszą ścieżkę kończącą się na Yatzku, bez Orli występującej jako węzeł pośredni.
Aby to osiągnąć, możemy zacząć od poprzedniego zapytania jako punktu wyjścia. Usuwamy filtr dla midnode z zapytania zewnętrznego i dodajemy filtr gwarantujący, że węzeł łączący (midnode) jest różny od zarówno pierwszego, jak i końcowego węzła. Oto pełny kod rozwiązania:
WITH C AS
(
SELECT
Account1.accountname AS firstnode,
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) + N'->'
+ STRING_AGG(Account3.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS friendships,
LAST_VALUE(Account2.accountname)
WITHIN GROUP (GRAPH PATH) AS midnode,
LAST_VALUE(Account3.accountname)
WITHIN GROUP (GRAPH PATH) AS lastnode
FROM
Graph.Account AS Account1,
( SELECT * FROM Graph.Account
WHERE accountname <> N'Yatzek' ) FOR PATH AS Account2,
( SELECT * FROM Graph.Account
WHERE accountname <> N'Orli') FOR PATH AS Account3,
Graph.IsFriendOf FOR PATH AS IFO1,
Graph.IsFriendOf FOR PATH AS IFO2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO1)->Account2)+)
AND SHORTEST_PATH(LAST_NODE(Account2)(-(IFO2)->Account3)+))
AND Account1.accountname = N'Orli'
)
SELECT friendships, midnode
FROM C
WHERE lastnode = N'Yatzek'
AND midnode NOT IN (firstnode, lastnode);
Kod ten generuje poniższe wyjście:
friendships midnode
--------------------------------------------- --------
Orli->Tami->Miko->Yatzek Miko
Orli->Tami->Buzi->Alma->Yatzek Alma
Orli->Tami->Miko->Omer->Yatzek Omer
Orli->Mitzi->Yatzek Mitzi
Orli->Mitzi->Yatzek->Lilach->Yatzek Lilach
Orli->Inka->Mitzi->Yatzek Inka
Orli->Tami->Miko->Yatzek Tami
Orli->Tami->Buzi->Alma->Yatzek Buzi
Orli->Tami->Buzi->Alma->Stav->Lilach->Yatzek Stav
Przejdziemy teraz do spinania ze sobą najkrótszych ścieżek na ich końcach. Przypuśćmy, że potrzebujemy znaleźć najkrótsze ścieżki, w których jedna rozpoczyna się od Orli, inna od Yatzka i obydwie kończą się na tym samym koncie. Ponadto Orli ani Yatzek nie mogą występować jako konta pośrednie. Oto zapytanie rozwiązujące to zadanie:
WITH C AS
(
SELECT
Account1.accountname AS firstnode1,
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) + N'<-'
+ STRING_AGG(Account3.accountname, N'<-')
WITHIN GROUP(GRAPH PATH) AS friendships,
LAST_VALUE(Account2.accountname)
WITHIN GROUP (GRAPH PATH) AS midnode,
LAST_VALUE(Account3.accountname)
WITHIN GROUP (GRAPH PATH) AS firstnode2
FROM
Graph.Account AS Account1,
( SELECT * FROM Graph.Account
WHERE accountname <> N'Yatzek' ) FOR PATH AS Account2,
( SELECT * FROM Graph.Account
WHERE accountname <> N'Orli') FOR PATH AS Account3,
Graph.IsFriendOf FOR PATH AS IFO1,
Graph.IsFriendOf FOR PATH AS IFO2
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO1)->Account2)+)
AND SHORTEST_PATH((Account3-(IFO2)->)+LAST_NODE(Account2)))
AND Account1.accountname = N'Orli'
)
SELECT friendships, midnode
FROM C
WHERE firstnode2 = N'Yatzek'
AND midnode NOT IN (firstnode1, firstnode2);
Zwróćmy uwagę, że tym razem LAST_NODE(Account2) kończy wzorzec o dowolnej długości, a nie go rozpoczyna. Dodatkowo widzimy użycie strzałki w lewo jako separatora w drugiej ścieżce. Interesujący jest też fakt, że zastosowanie funkcji LAST_VALUE do Account3.accountname bazującej na porządku ścieżki grafu efektywnie zwraca nazwę konta pierwszego węzła drugiej ścieżki, jako że to jest ostatni węzeł odwiedzony przez drugą ścieżkę dowolnej długości. Zapytanie to generuje poniższe wyjście:
friendships midnode
--------------------------------------------- --------
Orli->Tami->Miko<-Yatzek Miko
Orli->Tami->Buzi->Alma<-Yatzek Alma
Orli->Tami->Miko->Omer<-Yatzek Omer
Orli->Mitzi<-Yatzek Mitzi
Orli->Mitzi->Lilach<-Yatzek Lilach
Orli->Inka<-Mitzi<-Yatzek Inka
Orli->Tami<-Miko<-Yatzek Tami
Orli->Tami->Buzi<-Alma<-Yatzek Buzi
Orli->Tami->Buzi->Alma->Stav<-Lilach<-Yatzek Stav
Wynik ten wygląda podejrzanie podobnie do poprzedniego wyjścia, jeśli nie liczyć odwróconego kierunku strzałek w drugiej ścieżce. Przypomnijmy, że relacja znajomości pomiędzy dwoma kontami jest nieskierowana; konkretniej mówiąc, jeśli Buzi jest znajomym Almy, oznacza to również, że Alma jest znajomą Buzi. W konsekwencji łączące pary najkrótszych ścieżek, gdzie koniec jednej jest początkiem drugiej, są w praktyce takie same, jak łączące pary najkrótszych ścieżek kończących się w tym samym węźle. Z taką sytuacją mamy jednak do czynienia tylko w grafie nieskierowanym. Spróbujmy zastosować te dwa rozwiązania do grafu skierowanego (nazywanego digrafem), takiego jak relacja obserwowania (follow), a nie znajomości - będą miały one różne znaczenia i, co oczywiste, mogą zwracać odmienne wyniki.
Poniżej pokazane jest pierwsze rozwiązanie, zwracające relację obserwowania pomiędzy Orli i Yatzkiem poprzez ten sam węzeł pośredniczący, przy czym ani Orli, ani Yatzek nie są pośrednikami:
WITH C AS
(
SELECT
Account1.accountname AS firstnode,
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) + N'->'
+ STRING_AGG(Account3.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS followings,
LAST_VALUE(Account2.accountname)
WITHIN GROUP (GRAPH PATH) AS midnode,
LAST_VALUE(Account3.accountname)
WITHIN GROUP (GRAPH PATH) AS lastnode
FROM
Graph.Account AS Account1,
( SELECT * FROM Graph.Account
WHERE accountname <> N'Yatzek' ) FOR PATH AS Account2,
( SELECT * FROM Graph.Account
WHERE accountname <> N'Orli') FOR PATH AS Account3,
Graph.Follows FOR PATH AS Follows1,
Graph.Follows FOR PATH AS Follows2
WHERE MATCH(SHORTEST_PATH(Account1(-(Follows1)->Account2)+)
AND SHORTEST_PATH(LAST_NODE(Account2)(-(Follows2)->Account3)+))
AND Account1.accountname = N'Orli'
)
SELECT followings, midnode
FROM C
WHERE lastnode = N'Yatzek'
AND midnode NOT IN (firstnode, lastnode);
Kod ten generuje poniższe wyjście:
followings midnode
---------------------------------------------- --------
Orli->Tami->Miko->Yatzek Miko
Orli->Tami->Miko->Omer->Yatzek Omer
Orli->Mitzi->Yatzek Mitzi
Orli->Mitzi->Lilach->Yatzek Lilach
Orli->Tami->Inka->Mitzi->Yatzek Inka
Orli->Tami->Miko->Yatzek Tami
Orli->Mitzi->Buzi->Alma->Miko->Yatzek Alma
Orli->Mitzi->Buzi->Alma->Stav->Lilach->Yatzek Stav
Orli->Mitzi->Buzi->Tami->Miko->Yatzek Buzi
Dla porównania drugie rozwiązanie, zwracające pary ścieżki obserwowania, przy czym jedna zaczyna się od Orli, a druga od Yatzka, a obie kończą się na tym samym koncie, bez Orli ani Yatzka jako pośredników:
WITH C AS
(
SELECT
Account1.accountname AS firstnode1,
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) + N'<-'
+ STRING_AGG(Account3.accountname, N'<-')
WITHIN GROUP(GRAPH PATH) AS followings,
LAST_VALUE(Account2.accountname)
WITHIN GROUP (GRAPH PATH) AS midnode,
LAST_VALUE(Account3.accountname)
WITHIN GROUP (GRAPH PATH) AS firstnode2
FROM
Graph.Account AS Account1,
( SELECT * FROM Graph.Account
WHERE accountname <> N'Yatzek' ) FOR PATH AS Account2,
( SELECT * FROM Graph.Account
WHERE accountname <> N'Orli') FOR PATH AS Account3,
Graph.Follows FOR PATH AS Follows1,
Graph.Follows FOR PATH AS Follows2
WHERE MATCH(SHORTEST_PATH(Account1(-(Follows1)->Account2)+)
AND SHORTEST_PATH((Account3-(Follows2)->)+LAST_NODE(Account2)))
AND Account1.accountname = N'Orli'
)
SELECT followings, midnode
FROM C
WHERE firstnode2 = N'Yatzek'
AND midnode NOT IN (firstnode1, firstnode2);
Tym razem kod generuje poniższe wyjście:
followings midnode
---------------------------------------------- --------
Orli->Mitzi->Lilach<-Yatzek Lilach
Orli->Tami->Miko<-Lilach<-Yatzek Miko
Orli->Mitzi->Buzi->Alma->Stav<-Lilach<-Yatzek Stav
Orli->Tami->Miko->Omer<-Lilach<-Yatzek Omer
Orli->Tami<-Miko<-Lilach<-Yatzek Tami
Orli->Mitzi->Buzi->Alma<-Miko<-Lilach<-Yatzek Alma
Orli->Tami->Inka<-Tami<-Miko<-Lilach<-Yatzek Inka
Orli->Mitzi->Buzi<-Alma<-Miko<-Lilach<-Yatzek Buzi
Orli->Mitzi<-Inka<-Tami<-Miko<-Lilach<-Yatzek Mitzi
Przypuśćmy, że chcemy zwrócić pary tworzące najkrótsze ścieżki znajomości i obserwowania, które zaczynają się i kończą tymi samymi parami kont. Dla obydwu będziemy chcieli zwrócić odpowiednie ścieżki nazw kont od lewej do prawej. W tej sytuacji T-SQL pozwala porównać dwa wywołania funkcji LAST_NODE, jak poniżej:
SELECT
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS friendships,
Account1.accountname + N'->'
+ STRING_AGG(Account3.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS followings,
Account1.accountname AS firstnode
FROM
Graph.Account AS Account1,
Graph.Account FOR PATH AS Account2,
Graph.Account FOR PATH AS Account3,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Follows FOR PATH AS FLO
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+)
AND SHORTEST_PATH(Account1(-(FLO)->Account3)+)
AND LAST_NODE(Account2) = LAST_NODE(Account3));
Zauważmy, że oba wzorce dowolnej długości rozpoczynają się od Account1, zatem nie potrzebujemy stosować filtru stwierdzającego, że obie ścieżki zaczynają się od tego samego konta.
Ten kod generuje poniższe wyjście, skrócone dla oszczędności miejsca:
friendships followings
------------------------------ ----------------------------------------
Inka->Tami Inka->Tami
Inka->Orli Inka->Orli
Inka->Mitzi Inka->Mitzi
Miko->Tami Miko->Tami
Miko->Alma Miko->Alma
Miko->Omer Miko->Omer
Miko->Yatzek Miko->Yatzek
Tami->Inka Tami->Inka
Tami->Miko Tami->Miko
Tami->Orli Tami->Orli
...
Orli->Tami->Buzi->Alma->Stav Orli->Tami->Miko->Alma->Stav
Stav->Alma->Miko->Tami->Inka Stav->Alma->Miko->Tami->Inka
Stav->Alma->Buzi->Mitzi->Orli Stav->Alma->Miko->Tami->Orli
Omer->Yatzek->Mitzi Omer->Miko->Tami->Inka->Mitzi
Yatzek->Mitzi->Inka Yatzek->Lilach->Miko->Tami->Inka
Yatzek->Alma->Buzi Yatzek->Lilach->Miko->Alma->Buzi
Yatzek->Mitzi->Orli Yatzek->Lilach->Miko->Tami->Orli
Lilach->Yatzek->Mitzi Lilach->Miko->Tami->Inka->Mitzi
Stav->Alma->Buzi->Mitzi Stav->Alma->Miko->Tami->Inka->Mitzi
Yatzek->Mitzi Yatzek->Lilach->Miko->Tami->Inka->Mitzi
(121 rows affected)
Moglibyśmy uzyskać ten sam wynik bez używania funkcji LAST_NODE, używając dwóch wywołań funkcji LAST_VALUE do wydobycia nazw kont dla ostatnich węzłów w obu ścieżkach i filtrując tylko te ścieżki, w których są one takie same. Będziemy potrzebować CTE, aby pośrednio zastosować filtrowanie, podobnie jak robiliśmy to wcześniej. Oto pełny kod rozwiązania:
WITH C AS
(
SELECT
Account1.accountname + N'->'
+ STRING_AGG(Account2.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS friendships,
Account1.accountname + N'->'
+ STRING_AGG(Account3.accountname, N'->')
WITHIN GROUP(GRAPH PATH) AS followings,
Account1.accountname AS firstnode,
LAST_VALUE(Account2.accountname)
WITHIN GROUP (GRAPH PATH) AS lastnode1,
LAST_VALUE(Account3.accountname)
WITHIN GROUP (GRAPH PATH) AS lastnode2
FROM
Graph.Account AS Account1,
Graph.Account FOR PATH AS Account2,
Graph.Account FOR PATH AS Account3,
Graph.IsFriendOf FOR PATH AS IFO,
Graph.Follows FOR PATH AS FLO
WHERE MATCH(SHORTEST_PATH(Account1(-(IFO)->Account2)+)
AND SHORTEST_PATH(Account1(-(FLO)->Account3)+))
)
SELECT friendships, followings
FROM C
WHERE lastnode1 = lastnode2;
Funkcjonalności zapytań SQL Graph, których nadal brakuje
Jak wspomniałem, większą częścią atrakcyjności SQL Graph jest zwięzłość i intuicyjność kodu, gdy porównamy go z tradycyjnym podejściem. Używanie wzorców dopasowania oszczędza mnóstwo kodowania, które w przeciwnym razie byłoby konieczne, z wykorzystaniem takich narzędzi, jak złączenia, podzapytania, a niekiedy nawet konstrukcje iteracyjne, jak pętle. Nadal istnieje potencjał dalszych ulepszeń, które jeszcze bardziej zwiększyłyby atrakcyjność SQL Graph. Przedstawiam tu kilka przykładów, aby dać Czytelnikom pojęcie, co jeszcze mogłoby się tu znaleźć, ale bez wątpienia może być ich więcej. Skupię się tu na potrzebie wprowadzenia wyszukiwania ważonych najkrótszych ścieżek oraz używaniu wzorców o dowolnej długości, ale nie powiązanych z wyszukiwaniem najkrótszej ścieżki.
Przy opisywaniu opcji SHORTEST_PATH wspomniałem, że obecnie T-SQL opiera się na liczbie przeskoków jako jedynym definiującym kryterium tego, która ścieżka jest najkrótsza. W rzeczywistości jednak wiele problemów najkrótszych ścieżek dotyczy ścieżek ważonych. Klasyczny przypadek wyszukiwania ważonych najkrótszych ścieżek dotyczy planowania podróży pomiędzy różnymi lokalizacjami. Dla przykładu przyjmijmy, że mamy tabelę węzłów o nazwie Location, przechowującą różne miejscowości, a w niej kolumnę locationname (nazwa lokalizacji), oraz tabelę krawędzi Road, która przechowuje segmenty dróg łączących te lokalizacje, z kolumnami distance (odległość) i avgspeed (średnia prędkość). Chcielibyśmy móc napisać zapytanie, które identyfikuje najkrótszą ścieżkę pomiędzy dwiema lokalizacjami wyrażoną w całkowitej odległości albo całkowitym czasie podróży. Byłoby świetnie, gdyby opcja SHORTEST_PATH pozwalała wyspecyfikować wyrażenie definiujące wagę dla wskazanej krawędzi. Poniżej pokazana jest możliwa składnia wykorzystująca fikcyjną klauzulę WEIGHT BY; przykład ten identyfikuje najkrótszą drogę pomiędzy Seattle a San Francisco (pod względem całkowitej długości):
WITH C AS
(
SELECT
Location1.locationname + N'->'
+ STRING_AGG(location2.locationname, N'->')
WITHIN GROUP(GRAPH PATH) AS route,
LAST_VALUE(location2.locationname)
WITHIN GROUP (GRAPH PATH) AS lastlocation
FROM
Location AS Location1,
Road FOR PATH AS IsConnectedTo,
Location FOR PATH AS Location2
WHERE
MATCH(SHORTEST_PATH(
Location1(-(IsConnectedTo WEIGHT BY SUM(distance))->Location2)+))
AND Location1.locationname = N'Seattle'
)
SELECT route
FROM C
WHERE lastlocation = N'San Francisco';
Podkreślmy to wyraźnie: taka składnia nie istnieje. Wynalazłem ją dla celów ilustracji. Zauważmy, że klauzulę WEIGHT BY specyfikuję obok aliasu krawędzi IsConnectedTo, która łączy Location1 z Location2. Co do samej wagi, stosujemy jakąś agregację pewnego wyrażenia. Zazwyczaj waga będzie sumą pewnego atrybutu z tabeli krawędzi. W powyższym zapytaniu wyrażeniem tym jest SUM(distance), co naturalnie daje całkowitą odległość. Jednak wyrażenie mogłoby być bardziej złożone, na przykład SUM(distance / avgspeed), czyli łączny czas przejazdu. Domyślnie przy braku wyspecyfikowania powinno być ono równoważne COUNT(*), które zlicza przeskoki (zatem wracamy do aktualnie działającej wersji). Jednak taka składnia jest jedynie moją przykładową propozycją. Jeśli Microsoft zdecyduje się dodać obsługę ważonych ścieżek, bardzo możliwe, że wybrana zostanie zupełnie inna składnia. Jakakolwiek by ta składnia nie była, obsługa tego problemu byłaby ważnym uzupełnieniem funkcjonalności SQL Graph.
Inną potencjalnie przydatną funkcjonalnością byłaby możliwość używania wzorców o dowolnej długości bez konieczności stosowania opcji SHORTEST_PATH, co zwracałoby oddzielny wiersza dla każdego dopasowania, w przeciwieństwie do uporządkowanej kolekcji. Każdy wiersz wynikowy reprezentowałby jedno dopasowanie węzeł początkowy-krawędź-węzeł końcowy i pozwalałby zwracać atrybuty ze wszystkich trzech tabel. Taka funkcjonalność byłaby bardzo przydatna dla klasycznych zadań, takich jak wyszukiwanie podgrafów i ścieżek bez konieczności sięgania po zapytania rekurencyjne lub inne rozwiązania iteracyjne. Przykładem żądania podgrafu jest zwrócenie wszystkich bezpośrednich i pośrednich odpowiedzi na zadany post wejściowy. Obecnie można to osiągnąć przy użyciu poniższego zapytania rekurencyjnego (używającego lokalnej @postid zainicjowanej wartością 13 jako postu wejściowego, w którym autor pyta o sugestie imienia dla nowego kociaka):
DECLARE @postid AS INT = 13;
WITH C AS
(
SELECT ParentPost.postid AS parentpostid,
ChildPost.postid, ChildPost.posttext
FROM Graph.Post AS ChildPost, Graph.IsReplyTo,
Graph.Post AS ParentPost
WHERE ParentPost.postid = @postid
AND MATCH(ChildPost-(IsReplyTo)->ParentPost)
UNION ALL
SELECT ParentPost.postid AS parentpostid,
ChildPost.postid, ChildPost.posttext
FROM Graph.Post AS ChildPost, Graph.IsReplyTo,
C, Graph.Post AS ParentPost
WHERE ParentPost.postid = C.postid
AND MATCH(ChildPost-(IsReplyTo)->ParentPost)
)
SELECT parentpostid, postid, posttext
FROM C;
Kod ten generuje poniższe wyjście:
parentpostid postid posttext
------------ ----------- -------------------------------------------------
13 449 Maybe Pickle?
13 677 Ambrosius?
449 1021 It does look a bit sour faced :)
449 1031 How about Gherkin?
1031 1061 I love Gherkin!
1061 1187 So you don't like Pickle!? I'M UNFRIENDING YOU!!!
Byłoby świetnie, gdybyśmy mogli sformułować to samo żądanie za pomocą wzorca dowolnej długości, bez potrzeby pisania tak długiego kodu. Oto przykład, jak taki kod mógłby wyglądać:
DECLARE @postid AS INT = 13;
SELECT ParentPost.postid AS parentpostid,
ChildPost.postid, ChildPost.posttext
FROM Graph.Post AS ChildPost, Graph.IsReplyTo,
(SELECT * FROM Graph.Post WHERE postid = @postid) AS ParentPost
WHERE MATCH((ChildPost-(IsReplyTo)->)+ParentPost);
Trzeba pamiętać, że kod ten to jedynie pobożne życzenie i nie jest obecnie obsługiwany, zatem nie próbujcie tego w domu. Koncepcja polega na tym, że wzorzec o dowolnej długości (ChildPost-(IsReplyTo)->)+ParentPost powinien zostać wewnętrznie przetłumaczony na pętlę, która w każdym obiegu obsługuje jeden podstawowy wzorzec ChildPost-(IsReplyTo)->ParentPost. Pierwsza iteracja używa tabeli pochodnej reprezentującej post wejściowy jako ParentPost w tym wzorcu. Kolejne iteracje używają węzła ChildPost z poprzedniej iteracji jako ParentPost w bieżącej iteracji. Wiersze z różnych iteracji są zbierane w tabelę wynikową (za pomocą UNION). Można zauważyć podobieństwo do logiki użytej w zapytaniu rekurencyjnym, ale ta składnia jest znacznie zwięźlejsza. Ponownie nie należy się przywiązywać do tej konkretnie składni; to jedynie wyobrażenie czegoś, co mogłoby być (teoretycznie) możliwe. Istota sprawy leży w tym, że potrzebujemy bardziej zwartej składni pozwalającej na formułowanie wzorców o dowolnej długości w innych miejscach, niż zapytania o najkrótsze ścieżki, aby zmniejszyć potrzebę tworzenia długich zapytań rekurencyjnych i innych rozwiązań iteracyjnych.
Uwarunkowania modyfikowania danych
Wcześniej w tym rozdziale przedstawiłem różne sposoby wstawiania danych do tabel węzłów i krawędzi i związane z tym uwarunkowania. W tym podrozdziale będę kontynuować omawianie modyfikowania tabel grafowych, a w szczególności usuwanie, aktualizowanie i scalanie danych.
Usuwanie i aktualizowanie danych
Usuwanie i aktualizowanie wierszy w tabeli węzłów, gdy potrzebujemy zidentyfikować odpowiednie wiersze na podstawie naszych własnych, zdefiniowanych przez użytkownika kluczy, nie jest niczym szczególnym pod żadnym względem. Tym, co wymaga nieco więcej wysiłku, to usuwanie i aktualizowanie wierszy w tabelach krawędzi, gdzie musimy zidentyfikować wiersze krawędzi na podstawie par kluczy użytkownika dla odpowiedniego węzła początkowego i końcowego, które łączy dana krawędź. Pamiętajmy, że wiersz krawędzi nie zawiera żadnych kluczy definiowanych przez użytkownika dla łączonych węzłów; zamiast tego używane są klucze wewnętrzne, generowane przez system. Trzeba zatem dodać nieco logiki, aby uzyskać te klucze systemowe na podstawie tych definiowanych przez użytkownika, które typowo otrzymujemy jako dane wejściowe.
Zanim zademonstruję usuwanie i aktualizowanie danych grafu, wykonamy poniższe zapytanie, które zwraca bieżące relacje obserwowania, w którym Alma jest obserwującym:
SELECT
Account1.accountid AS actid1, Account1.accountname AS actname1,
Account2.accountid AS actid2, Account2.accountname AS actname2,
Follows.startdate
FROM Graph.Account AS Account1, Graph.Follows,
Graph.Account AS Account2
WHERE MATCH(Account1-(Follows)->Account2)
AND Account1.accountid = 661; -- konto obserwujące to Alma
Kod ten generuje poniższe wyniki, pokazując, że Alma obserwuje trzy konta:
actid1 actname1 actid2 actname2 startdate
------- --------- ----------- --------- ----------
661 Alma 71 Miko 2021-05-16
661 Alma 421 Buzi 2021-05-18
661 Alma 941 Stav 2022-01-06
Przypuśćmy teraz, że Alma przestaje obserwować Buzi i musimy usunąć odpowiednią relację z tabeli Graph.Follows. Jako wejście mamy dwa klucze definiowane przez użytkownika, wskazujące uczestniczące węzły. Dla uproszczenia w tym przykładzie ad-hoc wykorzystamy zmienne lokalne @actid1 = 661 (Alma) oraz @actid2 = 421 (Buzi). W normalnej sytuacji mielibyśmy procedurę składowaną obsługującą to zadanie, ze zmiennymi @actid1 i @actid2 zdefiniowanymi jako parametry wejściowe procedury. Instrukcja DELETE jest taka sama, niezależnie od tego, czy dane wejściowe są zmiennymi w przykładzie ad-hoc, czy parametrami przekazanymi do procedury składowanej.
Jeden z możliwych sposobów realizacji tego zadania to wykorzystanie podzapytań. Wywołamy instrukcję DELETE względem tabeli Graph.Follows. W filtrze instrukcji porównamy kolumny $from_id i $to_id z wynikami skalarnych podzapytań do tabeli Graph.Account, które konwertują wejściowe klucze definiowane przez użytkownika na klucze generowane przez system.
Poniższy kod obsługuje zapytanie, a następnie ponownie odpytuje dane, aby pokazać stan po usunięciu dla relacji, w których Alma jest obserwującym:
BEGIN TRAN;
DECLARE @actid1 AS INT = 661, @actid2 AS INT = 421;
DELETE FROM Graph.Follows
WHERE $from_id = (SELECT $node_id FROM Graph.Account
WHERE accountid = @actid1)
AND $to_id = (SELECT $node_id FROM Graph.Account
WHERE accountid = @actid2);
SELECT
Account1.accountid AS actid1, Account1.accountname AS actname1,
Account2.accountid AS actid2, Account2.accountname AS actname2,
Follows.startdate
FROM Graph.Account AS Account1, Graph.Follows,
Graph.Account AS Account2
WHERE MATCH(Account1-(Follows)->Account2)
AND Account1.accountid = 661;
ROLLBACK TRAN;
Uwaga Transakcja została tu użyta jedynie do celów demonstracyjnych. Przy użyciu transakcji możemy łatwo przywrócić dane do stanu sprzed usunięcia po zbadaniu efektu tej zmiany. Kod otwiera transakcję, stosuje zmianę, odpytuje dane w celu zweryfikowania, co zostało usunięte na podstawie tego, co pozostało, po czym wycofuje transakcję, aby anulować usuwanie. Naturalnie w normalnej sytuacji chcielibyśmy zatwierdzić tę zmianę.
Kod ten generuje poniższe wyjście pokazujące, że Alma już nie obserwuje Buzi:
actid1 actname1 actid2 actname2 startdate
------- --------- ----------- --------- ----------
661 Alma 71 Miko 2021-05-16
661 Alma 941 Stav 2022-01-06
Innym, zapewne bardziej eleganckim sposobem realizacji tego zadania, jest użycie wzorca dopasowania. Użyjemy jednej klauzuli FROM w instrukcji DELETE do wyliczenia tabel uczestniczących we wzorcu dopasowania oraz ich aliasów. W naszym przykładzie będzie to Graph.Account AS Account1, Graph.Account AS Account2 oraz Graph.Follows. W klauzuli WHERE specyfikujemy klauzulę MATCH ze wzorcem Account1-(Follows)->Account2; dodatkowo filtrujemy Account1.accountid jako równy @actid1, a Account2.accountid jako równy @actid2. Na koniec używamy drugiej klauzuli DELETE na początku instrukcji, aby wskazać, że tabela Follows jest celem usuwania.
Poniżej pokazany jest kompletny kod rozwiązania wraz z zapytaniem weryfikującym zmianę, ponownie opakowane w transakcję, którą wycofujemy, aby anulować zmianę:
BEGIN TRAN;
DECLARE @actid1 AS INT = 661, @actid2 AS INT = 421;
DELETE FROM Follows
FROM Graph.Account AS Account1, Graph.Account AS Account2,
Graph.Follows
WHERE MATCH(Account1-(Follows)->Account2)
AND Account1.accountid = @actid1
AND Account2.accountid = @actid2;
SELECT
Account1.accountid AS actid1, Account1.accountname AS actname1,
Account2.accountid AS actid2, Account2.accountname AS actname2,
Follows.startdate
FROM Graph.Account AS Account1, Graph.Follows,
Graph.Account AS Account2
WHERE MATCH(Account1-(Follows)->Account2)
AND Account1.accountid = 661;
ROLLBACK TRAN;
Kod ten generuje poniższe wyjście pokazujące, że Alma nie obserwuje Buzi:
actid1 actname1 actid2 actname2 startdate
------- --------- ----------- --------- ----------
661 Alma 71 Miko 2021-05-16
661 Alma 941 Stav 2022-01-06
Jeśli chodzi o aktualizowanie tabel krawędzi, T-SQL nie obsługuje modyfikowania kolumn $from_node ani $to_node. Jeśli potrzebujemy zmienić węzły uczestniczące w danej krawędzi ("przekierować" ją), zasadniczo trzeba usunąć istniejący wiersz krawędzi i wprowadzić nowy. W przypadku aktualizowania innych atrybutów krawędzi modyfikacje takie wykonujemy analogicznie do sposobu obsługi usuwania, jeśli chodzi o identyfikowanie właściwych wierszy na podstawie podanych definiowanych przez użytkownika kluczy węzłów.
Dla przykładu przypuśćmy, że potrzebujemy zmienić datę początkową relacji obserwowania pomiędzy Almą i Buzi na 2 sierpnia 2021. Poniższy kod pokazuje, jak to osiągnąć przy użyciu podzapytań; jedynie tym razem użyłem klauzuli OUTPUT, aby pokazać, co się zmieniło:
BEGIN TRAN;
DECLARE @actid1 AS INT = 661, @actid2 AS INT = 421,
@startdate AS DATE = '20210802';
UPDATE Graph.Follows
SET startdate = @startdate
OUTPUT deleted.startdate AS olddate, inserted.startdate AS newdate
WHERE $from_id = (SELECT $node_id FROM Graph.Account
WHERE accountid = @actid1)
AND $to_id = (SELECT $node_id FROM Graph.Account
WHERE accountid = @actid2);
ROLLBACK TRAN;
Kod ten generuje poniższe wyjście:
olddate newdate
---------- ----------
2021-05-18 2021-08-02
Tak samo jak w przypadku usuwania, możemy alternatywnie użyć bardziej eleganckiego rozwiązania bazującego na wzorcu dopasowania, jak poniżej:
BEGIN TRAN;
DECLARE @actid1 AS INT = 661, @actid2 AS INT = 421,
@startdate AS DATE = '20210802';
UPDATE Follows
SET startdate = @startdate
OUTPUT deleted.startdate AS olddate, inserted.startdate AS newdate
FROM Graph.Account AS Account1, Graph.Account AS Account2,
Graph.Follows
WHERE MATCH(Account1-(Follows)->Account2)
AND Account1.accountid = @actid1
AND Account2.accountid = @actid2;
ROLLBACK TRAN;
Kod ten generuje takie samo wyjście:
olddate newdate
---------- ----------
2021-05-18 2021-08-02
Scalanie danych
T-SQL pozwala użyć klauzuli MATCH w instrukcji MERGE do identyfikowania statusu dopasowania scalania i odpowiednio aktywować właściwe działanie. Ta funkcjonalność jest dostępna od wersji SQL Server 2019 oraz w Azure SQL Database.
Zanim to zademonstruję, najpierw użyjemy poniższego zapytania, aby pokazać bieżące relacje obserwowania, w których Alma jest obserwującym albo Yatzek jest obserwowanym:
SELECT
Account1.accountid AS actid1, Account1.accountname AS actname1,
Account2.accountid AS actid2, Account2.accountname AS actname2,
Follows.startdate
FROM Graph.Account AS Account1, Graph.Follows,
Graph.Account AS Account2
WHERE MATCH(Account1-(Follows)->Account2)
AND (Account1.accountid = 661 -- Alma jest obserwującym
OR Account2.accountid = 883); -- Yatzek jest obserwowany
Kod ten generuje poniższe wyjście:
actid1 actname1 actid2 actname2 startdate
------- --------- ------ --------- ----------
661 Alma 421 Buzi 2021-05-18
71 Miko 883 Yatzek 2021-05-16
661 Alma 941 Stav 2022-01-06
661 Alma 71 Miko 2021-05-16
727 Mitzi 883 Yatzek 2021-02-18
199 Lilach 883 Yatzek 2022-01-14
953 Omer 883 Yatzek 2022-03-17
(7 rows affected)
Nasze zadanie to scalenie wejściowej relacji obserwowania reprezentowanej przez zmienne @actid1, @actid2 i @startdate z tabelą Graph.Follows. W pierwszym przykładzie przypisałem tym zmiennym następujące wartości: @actid1 = 661 (Alma), @actid2 = 421 (Buzi), @startdate = '20210802'. Jeśli ta relacja jeszcze nie istnieje w tabeli, oczekujemy, że do tabeli zostanie wstawiony nowy wiersz krawędzi. W przeciwnym przypadku istniejący wiersz powinien zostać zaktualizowany - jego kolumna startdate ma otrzymać nową wartość przekazaną w zmiennej @startdate.
Tabelę docelową specyfikujemy w klauzuli INTO instrukcji MERGE - oczywiście będzie to Graph.Follows.
Co do źródła aktywności scalania, powinniśmy dostarczyć je w klauzuli USING instrukcji MERGE. Jak pamiętamy, klauzula ta jest zaprojektowana podobnie do klauzuli FROM pod tym względem, że pozwala na używanie operatorów tabel, takich jak złączenia. Źródło aktywności scalania powinno uwzględniać dwa wystąpienia tabeli węzłów Graph.Account - jedno reprezentujące węzeł początkowy krawędzi stanowiącej wejście, a konkretnie wiersz reprezentowany przez @actid1, a drugie węzeł końcowy, czyli wiersz reprezentowany przez @actid2. Aby to osiągnąć, można zacząć od utworzenia tabeli pochodnej z jednym wierszem bazującej na zmiennych wejściowych, używając składni (SELECT @actid1, @actid2, @startdate) AS SRC(actid1, actid2, startdate). Następnie możemy użyć złączenia wewnętrznego łączącego SRC z dwiema instancjami tabeli Graph.Account, aby uzyskać odpowiednie wiersze węzłów.
Jako predykat scalania w klauzuli ON instrukcji MERGE specyfikujemy klauzulę MATCH ze wzorcem dopasowania Account1-(Follows)->Account2. Węzły są definiowane jako źródło instrukcji MERGE, a krawędź jako jej cel.
Tym, co pozostało, to zastosowanie akcji UPDATE w klauzuli WHEN MATCHED oraz akcji INSERT w klauzuli WHEN NOT MATCHED.
Oto pełna instrukcja MERGE obsługująca nasze zadanie - podobnie użyłem transakcji, aby po zapytaniu weryfikującym wykonane zmiany móc je łatwo wycofać:
BEGIN TRAN;
DECLARE @actid1 AS INT = 661, @actid2 AS INT = 421,
@startdate AS DATE = '20210802';
MERGE INTO Graph.Follows
USING (SELECT @actid1, @actid2, @startdate)
AS SRC(actid1, actid2, startdate)
INNER JOIN Graph.Account AS Account1
ON SRC.actid1 = Account1.accountid
INNER JOIN Graph.Account AS Account2
ON SRC.actid2 = Account2.accountid
ON MATCH(Account1-(Follows)->Account2)
WHEN MATCHED THEN UPDATE
SET startdate = SRC.startdate
WHEN NOT MATCHED THEN INSERT($from_id, $to_id, startdate)
VALUES(Account1.$node_id, Account2.$node_id, SRC.startdate);
SELECT
Account1.accountid AS actid1, Account1.accountname AS actname1,
Account2.accountid AS actid2, Account2.accountname AS actname2,
Follows.startdate
FROM Graph.Account AS Account1, Graph.Follows,
Graph.Account AS Account2
WHERE MATCH(Account1-(Follows)->Account2)
AND (Account1.accountid = 661
OR Account2.accountid = 883);
ROLLBACK TRAN;
Kod ten generuje poniższe wyjście:
actid1 actname1 actid2 actname2 startdate
------- --------- ------ --------- ----------
661 Alma 421 Buzi 2021-08-02
71 Miko 883 Yatzek 2021-05-16
661 Alma 941 Stav 2022-01-06
661 Alma 71 Miko 2021-05-16
727 Mitzi 883 Yatzek 2021-02-18
199 Lilach 883 Yatzek 2022-01-14
953 Omer 883 Yatzek 2022-03-17
(7 rows affected)
Zwróćmy uwagę, że relacja pomiędzy Almą i Buzi, która już istniała przed uruchomieniem tego kodu, ma zmienioną datę startową.
Wykonamy teraz drugie zadanie scalania, tym razem z nieistniejącą krawędzią, w której Alma (konto 661) obserwuje Yatzka (konto ID 883), z datą startową 2 sierpnia 2021:
BEGIN TRAN;
DECLARE @actid1 AS INT = 661, @actid2 AS INT = 883,
@startdate AS DATE = '20210802';
MERGE INTO Graph.Follows
USING (SELECT @actid1, @actid2, @startdate)
AS SRC(actid1, actid2, startdate)
INNER JOIN Graph.Account AS Account1
ON SRC.actid1 = Account1.accountid
INNER JOIN Graph.Account AS Account2
ON SRC.actid2 = Account2.accountid
ON MATCH(Account1-(Follows)->Account2)
WHEN MATCHED THEN UPDATE
SET startdate = SRC.startdate
WHEN NOT MATCHED THEN INSERT($from_id, $to_id, startdate)
VALUES(Account1.$node_id, Account2.$node_id, SRC.startdate);
SELECT
Account1.accountid AS actid1, Account1.accountname AS actname1,
Account2.accountid AS actid2, Account2.accountname AS actname2,
Follows.startdate
FROM Graph.Account AS Account1, Graph.Follows,
Graph.Account AS Account2
WHERE MATCH(Account1-(Follows)->Account2)
AND (Account1.accountid = 661
OR Account2.accountid = 883);
ROLLBACK TRAN;
Tym razem do tabeli Graph.Follows został wstawiony nowy wiersz, co widać w wyjściu generowanym przez ten kod:
actid1 actname1 actid2 actname2 startdate
------- --------- ------ --------- ----------
661 Alma 421 Buzi 2021-05-18
71 Miko 883 Yatzek 2021-05-16
661 Alma 941 Stav 2022-01-06
661 Alma 71 Miko 2021-05-16
727 Mitzi 883 Yatzek 2021-02-18
199 Lilach 883 Yatzek 2022-01-14
953 Omer 883 Yatzek 2022-03-17
661 Alma 883 Yatzek 2021-08-02
(8 rows affected)
Podsumowanie
SQL Graph jest intrygującą funkcjonalnością. Demonstruje, że można modelować te same dane na różne sposoby i że tradycyjne modelowanie relacyjne nie jest jedyną opcją. Z drugiej strony trzeba wziąć pod uwagę fakt, że modelowanie bazujące na SQL Graph jest znaczącym odejściem od tradycyjnego podejścia. Oznacza to migrowanie istniejących danych, stosowanie alternatywnej składni i inne wyzwania. Jedną z głównych zalet SQL Graph jest to, że zazwyczaj pozwala stosować mniej rozwlekłe zapytania. Inną jest to, że w pewnych scenariuszach modelowanie bazujące na grafach jest bardziej intuicyjne. Trzeba postawić sobie pytanie, czy używanie bardziej specjalizowanego modelu jest warte dodatkowego wysiłku. Z pewnością w miarę dodawania dalszych możliwości do SQL Graph przez Microsoft funkcjonalność ta będzie jeszcze bardziej atrakcyjna w porównaniu do tradycyjnego modelowania. Opisałem kilka przykładów brakujących możliwości, które mogłyby zwiększyć wartość tego rozwiązania.
Ćwiczenia
Ten podrozdział zawiera ćwiczenia pomocne w lepszym opanowaniu tematów omówionych w rozdziale 11. Wszystkie ćwiczenia wymagają połączenia z przykładową bazą danych TSQLV6 z modyfikacjami wprowadzonymi w początkowej części tego rozdziału (schemat Graph i zawarte w nim tabele grafowe).
Ćwiczenie 1
W tym ćwiczeniu będziesz odpytywać dane z tabel Graph.Account i Graph.Follows.
Ćwiczenie 1-1
Napisz zapytanie identyfikujące osoby obserwujące Stava.
Oczekiwane dane wyjściowe:
accountname
--------------
Alma
Lilach
(2 rows affected)
Ćwiczenie 1-2
Napisz zapytanie identyfikujące, kto obserwuje Stava, Yatzka albo obydwóch.
Oczekiwane dane wyjściowe:
accountname follows
-------------- ----------
Miko Yatzek
Alma Stav
Omer Yatzek
Mitzi Yatzek
Lilach Stav
Lilach Yatzek
(6 rows affected)
Ćwiczenie 1-3
Napisz zapytanie identyfikujące, kto obserwuje zarówno Stava, jak i Yatzka.
Oczekiwane dane wyjściowe:
accountname
--------------
Lilach
Ćwiczenie 1-4
Napisz zapytanie identyfikujące, kto obserwuje Stava, ale nie Yatzka.
Oczekiwane dane wyjściowe:
accountname
--------------
Alma
Ćwiczenie 2
W tym ćwiczeniu będziesz odpytywać dane z tabel Graph.Account, Graph.IsFriendOf i Graph.Follows.
Ćwiczenie 2-1
Napisz zapytanie zwracające relacje, w których pierwsze konto jest znajomym drugiego konta, obserwuje drugie konto albo jedno i drugie.
Oczekiwane dane wyjściowe:
actid1 act1 actid2 act2
------- ------- ------- -------
2 Orli 379 Tami
2 Orli 641 Inka
2 Orli 727 Mitzi
71 Miko 199 Lilach
71 Miko 379 Tami
71 Miko 661 Alma
71 Miko 883 Yatzek
71 Miko 953 Omer
199 Lilach 71 Miko
199 Lilach 661 Alma
199 Lilach 883 Yatzek
199 Lilach 941 Stav
199 Lilach 953 Omer
379 Tami 2 Orli
379 Tami 71 Miko
379 Tami 421 Buzi
379 Tami 641 Inka
421 Buzi 379 Tami
421 Buzi 661 Alma
421 Buzi 727 Mitzi
641 Inka 2 Orli
641 Inka 379 Tami
641 Inka 727 Mitzi
661 Alma 71 Miko
661 Alma 199 Lilach
661 Alma 421 Buzi
661 Alma 883 Yatzek
661 Alma 941 Stav
727 Mitzi 2 Orli
727 Mitzi 421 Buzi
727 Mitzi 641 Inka
727 Mitzi 883 Yatzek
883 Yatzek 71 Miko
883 Yatzek 199 Lilach
883 Yatzek 661 Alma
883 Yatzek 727 Mitzi
883 Yatzek 953 Omer
941 Stav 199 Lilach
941 Stav 661 Alma
953 Omer 71 Miko
953 Omer 199 Lilach
953 Omer 883 Yatzek
(42 rows affected)
Ćwiczenie 2-2
Napisz zapytanie, które zwraca relacje, w których pierwsze konto jest znajomy, ale nie obserwuje drugiego konta.
Oczekiwane dane wyjściowe:
actid1 act1 actid2 act2
------- ------- ------- -------
2 Orli 641 Inka
71 Miko 199 Lilach
199 Lilach 661 Alma
379 Tami 421 Buzi
421 Buzi 727 Mitzi
661 Alma 199 Lilach
661 Alma 883 Yatzek
727 Mitzi 641 Inka
883 Yatzek 71 Miko
883 Yatzek 661 Alma
883 Yatzek 727 Mitzi
883 Yatzek 953 Omer
(12 rows affected)
Ćwiczenie 3
Dla danego wejściowego identyfikatora postu, być może stanowiącego odpowiedź na inny post, zwróć łańcuch postów prowadzących do wejściowego. Użyj zapytania rekurencyjnego.
Wykorzystywane tabele: Graph.Post oraz Graph.IsReplyTo
Oczekiwane dane wyjściowe dla przykładowego postu o ID 1187:
postid posttext
------- -------------------------------------------------
13 Got a new kitten. Any suggestions for a name?
449 Maybe Pickle?
1031 How about Gherkin?
1061 I love Gherkin!
1187 So you don't like Pickle!? I'M UNFRIENDING YOU!!!
(5 rows affected)
Ćwiczenie 4
Rozwiąż ćwiczenie 3, ale tym razem przy użyciu opcji SHORTEST_PATH.
Rozwiązania
W tym podrozdziale udostępniono rozwiązania ćwiczeń wraz z odpowiednimi wyjaśnieniami.
Ćwiczenie 1
Ćwiczenie podzielone jest na cztery części. Poniższe punkty zawierają rozwiązania tych części.
Ćwiczenie 1-1
Aby zidentyfikować konto obserwujące Stav, odpytaj dwa wystąpienia tabeli węzłów Account, przypisując im różne aliasy - na przykład Account1 i Account2 - i połącz je z tabelą krawędzi Follows. W filtrze zapytania użyj klauzuli MATCH ze wzorcem Account2-(Follows)->Account1. Dodatkowo filtruj nazwę konta w instancji Account1, aby było równe Stav.
Oto pełne zapytanie:
SELECT Account2.accountname
FROM Graph.Account AS Account1, Graph.Account AS Account2,
Graph.Follows
WHERE MATCH(Account2-(Follows)->Account1)
AND Account1.accountname = N'Stav';
Ćwiczenie 1-2
Rozwiązanie tego ćwiczenia jest bardzo podobne do tego z ćwiczenia 1-1, z niewielką zmianą filtru zapytania. Wystarczy zmienić predykat filtru z Account1.accountname = N'Stav' na Account1.accountname IN (N'Stav', N'Yatzek'), jak poniżej:
SELECT Account2.accountname, Account1.accountname AS follows
FROM Graph.Account AS Account1, Graph.Account AS Account2,
Graph.Follows
WHERE MATCH(Account2-(Follows)->Account1)
AND Account1.accountname IN (N'Stav', N'Yatzek');
Ćwiczenie 1-3
Tym razem rozwiązanie nie jest tak trywialne, jak w przypadku ćwiczenia 1-2 ze względu na wymóg, aby obserwujący obserwował oba konta Stav i Yatzek, w przeciwieństwie do któregokolwiek. Aby spełnić nowe wymaganie, trzeba odpytać trzy wystąpienia tabeli węzłów Account - jedno reprezentujące Stava jako obserwowanego (alias Account1), jedno reprezentujące Yatzka jako obserwowanego (alias Account2) i jedno reprezentujące obserwującego (alias Account3). Potrzebne są również dwa wystąpienia tabeli krawędzi Follows - jedno z aliasem Follows1 łączy Account3 z Account1, a drugie z aliasem Follows2 łączy Account3 z Account2. Jako że Account3 jest węzłem początkowym w obu relacjach, możemy wyrazić te dwie relacje przy użyciu wzorca dopasowania z Account3 pośrodku, ze strzałkami biegnącymi od niego w obu kierunkach poprzez odpowiednie aliasy tabel krawędzi: Account2<-(Follows2)-Account3-(Follows1)->Account1. Potrzebne są również dodatkowe predykaty, aby wyfiltrować Stav jako nazwę konta z Account1 i Yatzka jako nazwę konta z Account2.
Oto pełne rozwiązanie:
SELECT Account3.accountname
FROM Graph.Account AS Account1, Graph.Account AS Account2,
Graph.Account AS Account3, Graph.Follows AS Follows1,
Graph.Follows AS Follows2
WHERE MATCH(Account2<-(Follows2)-Account3-(Follows1)->Account1)
AND Account1.accountname = N'Stav'
AND Account2.accountname = N'Yatzek';
Ćwiczenie 1-4
Przypomnijmy, że T-SQL aktualnie nie wspiera bezpośredniego negowania predykatów dopasowania. Wspiera jednak zanegowany predykat EXISTS, który z kolei może zawierać podzapytanie - także skorelowane - z predykatem dopasowania. Mając to na uwadze, zadanie można rozwiązać w następujący sposób:
W zapytaniu zewnętrznym obsłuż część identyfikującą osoby obserwujące Stava, analogicznie do tego, jak to zrobiłeś w ćwiczeniu 1-1. Ja użyłem aliasów Account3 dla węzła obserwującego, Account1 dla węzła obserwowanego (Stav) oraz Follows1 dla łączącej je krawędzi.
Użyj predykatu NOT EXISTS ze skorelowanym podzapytaniem, które obsługuje część z kontem, które nie obserwuje Yatzka. Ja użyłem korelacji z Account3 dla węzła obserwującego, aliasu Account2 dla węzła obserwowanego (Yatzek) oraz Follows2 dla łączącej je krawędzi.
Oto pełne rozwiązanie:
SELECT Account3.accountname
FROM Graph.Account AS Account1,
Graph.Account AS Account3, Graph.Follows AS Follows1
WHERE MATCH(Account3-(Follows1)->Account1)
AND Account1.accountname = N'Stav'
AND NOT EXISTS(SELECT *
FROM Graph.Account AS Account2,
Graph.Follows AS Follows2
WHERE MATCH(Account3-(Follows2)->Account2)
AND Account2.accountname = N'Yatzek');
Ćwiczenie 2
To ćwiczenie jest podzielone na dwie części. Poniższe punkty zawierają rozwiązania każdej z nich.
Ćwiczenie 2-1
T-SQL aktualnie nie wspiera bezpośredniego wyrażenia alternatywy predykatów dopasowania (czyli predykatów rozdzielonych operatorami OR). Konieczne jest znalezienie obejścia problemu. Długa droga realizacji zadania jest następująca:
Napisz zapytanie identyfikujące znajomości.
Napisz inne zapytanie identyfikujące obserwowanie.
Zastosuj operator UNION do wyników obu zapytań.
Oto kompletny kod rozwiązania:
SELECT
Account1.accountid AS actid1, Account1.accountname AS act1,
Account2.accountid AS actid2, Account2.accountname AS act2
FROM Graph.Account AS Account1, Graph.Account AS Account2,
Graph.IsFriendOf
WHERE MATCH(Account1-(IsFriendOf)->Account2)
UNION
SELECT
Account1.accountid AS actid1, Account1.accountname AS act1,
Account2.accountid AS actid2, Account2.accountname AS act2
FROM Graph.Account AS Account1, Graph.Account AS Account2,
Graph.Follows
WHERE MATCH(Account1-(Follows)->Account2);
Zadanie to można również rozwiązać krócej, jak następuje:
Napisz zapytanie zewnętrzne odwołujące się do dwóch instancji tabeli węzłów Account, nadając im aliasy Account1 i Account2.
W klauzuli WHERE zewnętrznego zapytania użyj alternatywy dwóch predykatów EXISTS ze skorelowanymi podzapytaniami: jednym łączącym Account1 i Account2 poprzez tabelę krawędzi IsFriendOf, a drugim łączącym te same tabele węzłów poprzez tabelę krawędzi Follows.
Oto pełny kod rozwiązania:
SELECT
Account1.accountid AS actid1, Account1.accountname AS act1,
Account2.accountid AS actid2, Account2.accountname AS act2
FROM Graph.Account AS Account1, Graph.Account AS Account2
WHERE EXISTS (SELECT * FROM Graph.IsFriendOf
WHERE MATCH(Account1-(IsFriendOf)->Account2))
OR EXISTS (SELECT * FROM Graph.Follows
WHERE MATCH(Account1-(Follows)->Account2));
Ćwiczenie 2-2
Rozwiązania tego ćwiczenia są podobne do rozwiązań ćwiczenia 2-1 z drobnymi zmianami. Rozwiązanie używające operatora zbiorowego trzeba zmienić, aby używało operatora EXCEPT zamiast UNION, jak poniżej:
SELECT
Account1.accountid AS actid1, Account1.accountname AS act1,
Account2.accountid AS actid2, Account2.accountname AS act2
FROM Graph.Account AS Account1, Graph.Account AS Account2,
Graph.IsFriendOf
WHERE MATCH(Account1-(IsFriendOf)->Account2)
EXCEPT
SELECT
Account1.accountid AS actid1, Account1.accountname AS act1,
Account2.accountid AS actid2, Account2.accountname AS act2
FROM Graph.Account AS Account1, Graph.Account AS Account2,
Graph.Follows
WHERE MATCH(Account1-(Follows)->Account2);
W przypadku rozwiązania używającego skorelowanych podzapytań trzeba zastąpić operator OR operatorem AND NOT, jak poniżej:
SELECT
Account1.accountid AS actid1, Account1.accountname AS act1,
Account2.accountid AS actid2, Account2.accountname AS act2
FROM Graph.Account AS Account1, Graph.Account AS Account2
WHERE EXISTS (SELECT * FROM Graph.IsFriendOf
WHERE MATCH(Account1-(IsFriendOf)->Account2))
AND NOT EXISTS (SELECT * FROM Graph.Follows
WHERE MATCH(Account1-(Follows)->Account2));
Ćwiczenie 3
Jak pamiętamy, przy używaniu rekurencyjnego CTE typowo mamy element zakotwiczający oraz element rekurencyjny. Dla realizacji naszego zadania element zakotwiczający powinien zwrócić wiersz dla postu wejściowego z tabeli węzłów Post, być może przypisując stałą 0 do kolumny wyników jako klucz sortowania. Element rekurencyjny powinien odwoływać się do nazwy CTE reprezentującej post podrzędny względem postu z poprzedniej rundy, łącząc go poprzez krawędź IsReplyTo z instancją tabeli Post reprezentującej post nadrzędny. Trzeba jednak pamiętać, że T-SQL nie wspiera potraktowania rekurencyjnej referencji do nazwy CTE jako tabeli węzłów we wzorcu dopasowania. Obejście problemu polega na dodaniu referencji do innej instancji tabeli węzłów reprezentującej węzeł podrzędny i użycie rekurencyjnej referencji członkowskiej jako pośrednika dla tej instancji, dopasowując klucze tych dwóch obiektów. Dodatkowo dodanie 1 do klucza sortowania z poprzedniej iteracji pozwoli uzyskać poprawne uporządkowanie w elemencie rekurencyjnym.
Oto kompletny kod rozwiązania, w tym przykładzie używający jako wejścia postu o ID 1187:
DECLARE @postid AS INT = 1187;
WITH C AS
(
SELECT postid, posttext, 0 AS sortkey
FROM Graph.Post
WHERE postid = @postid
UNION ALL
SELECT ParentPost.postid, ParentPost.posttext,
C.sortkey + 1 AS sortkey
FROM C, Graph.Post AS ParentPost, Graph.IsReplyTo,
Graph.Post AS ChildPost
WHERE ChildPost.postid = C.postid -- recursive ref used as proxy
AND MATCH(ChildPost-(IsReplyTo)->ParentPost)
)
SELECT postid, posttext
FROM C
ORDER BY sortkey DESC;
Alternatywnie można użyć jawnych złączeń w elemencie rekurencyjnym, dopasowując kolumnę nodeid rekurencyjnego wyrażenia CTE do kolumny $from_id tabeli krawędzi, a jej kolumnę $to_id do kolumny $node_id tabeli węzłów dla postu nadrzędnego.
Oto pełny kod takiego rozwiązania:
DECLARE @postid AS INT = 1187;
WITH C AS
(
SELECT $node_id AS nodeid, postid, posttext, 0 AS sortkey
FROM Graph.Post
WHERE postid = @postid
UNION ALL
SELECT PP.$node_id AS nodeid, PP.postid, PP.posttext,
CP.sortkey + 1 AS sortkey
FROM C AS CP
INNER JOIN Graph.IsReplyTo AS R
ON R.$from_id = CP.nodeid
INNER JOIN Graph.Post AS PP
ON R.$to_id = PP.$node_id
)
SELECT postid, posttext
FROM C
ORDER BY sortkey DESC;
Mamy tu pewien kompromis - pierwsze rozwiązanie pozwala użyć natywnej składni grafowej, ale kosztem dodania jeszcze jednej tabeli do zapytania, co na pewno oznacza jakiś spadek wydajności. Można przetestować obydwa rozwiązania i sprawdzić, czy ten koszt wydajnościowy jest dostatecznie mały, aby użycie składni natywnej było usprawiedliwione.
Ćwiczenie 4
W celu rozwiązania tego zadania przy użyciu opcji SHORTEST_PATH zastosuj następujące kroki:
Napisz proste zapytanie zwracające wiersz dla postu wejściowego z tabeli węzłów Post, przypisując stałą 0 jako klucz sortowania.
Napisz zapytanie grafowe używające opcji SHORTEST_PATH, aby uzyskać wszystkie posty nadrzędne (przodkowskie) względem postu wejściowego:Odpytaj jedną instancję tabeli Post jako wystąpienie Reply, tabelę krawędzi IsReplyTo z opcją FOR PATH, używając aliasu IRT i drugą instancję Post z opcją FOR PATH. Użyj wzorca dopasowania z opcją SHORTEST_PATH, rozpoczynającego się od węzła Reply, którego powtarzana część wzorca to (-(IRT)->Post)+. Przypomnijmy, że kwantyfikator plus (+) oznacza jedno lub więcej wystąpień. W ten sposób będziemy podążać dalej, aż zostaną wyczerpane wszystkie posty przodkowskie. W filtrze tego zapytania kolumna Reply.postid ma być równa wejściowej zmiennej @postid.Użyj funkcji LAST_VALUE w celu zwrócenia wartości postid i posttext ostatniego węzła ścieżki grafu. Użyj funkcji COUNT do obliczenia klucza sortowania.
Połącz wyniki obu zapytań z poprzednich kroków za pomocą operatora UNION ALL i zdefiniuj CTE bazujące na tym kodzie.
W zapytaniu zewnętrznym zwróć wartości postid oraz posttext z CTE, porządkując wiersze według obliczonego klucza sortowania.
Oto pełny kod rozwiązania:
DECLARE @postid AS INT = 1187;
WITH C AS
(
SELECT postid, posttext, 0 AS sortkey
FROM Graph.Post
WHERE postid = @postid
UNION ALL
SELECT
LAST_VALUE(Post.postid) WITHIN GROUP (GRAPH PATH) AS postid,
LAST_VALUE(Post.posttext) WITHIN GROUP (GRAPH PATH) AS posttext,
COUNT(Post.postid) WITHIN GROUP (GRAPH PATH) AS sortkey
FROM
Graph.Post AS Reply,
Graph.IsReplyTo FOR PATH AS IRT,
Graph.Post FOR PATH AS Post
WHERE MATCH(SHORTEST_PATH(Reply(-(IRT)->Post)+))
AND Reply.postid = @postid
)
SELECT postid, posttext
FROM C
ORDER BY sortkey DESC;
Zwróć uwagę, że żadne zapytanie w wyrażeniu CTE nie jest rekurencyjne.
Sprzątanie
Po zakończeniu lektury rozdziału i pracy nad ćwiczeniami można uruchomić poniższy kod, aby posprzątać bazę danych:
DROP TABLE IF EXISTS
Norm.Friendships,
Norm.Followings,
Norm.Likes,
Norm.AuthorsPublications,
Norm.Posts,
Norm.Accounts,
Norm.Publications;
DROP TABLE IF EXISTS
Graph.IsReplyTo,
Graph.IsFriendOf,
Graph.Follows,
Graph.Posted,
Graph.Likes,
Graph.Authored,
Graph.Post,
Graph.Account,
Graph.Publication;
GO
DROP SCHEMA IF EXISTS Norm;
DROP SCHEMA IF EXISTS Graph;