SQL Server Statistics – podgląd danych

W poprzednim poście, wyjaśniłem do czego służą statystyki w SQL Server. Do dyspozycji MSSQL ma gęstość oraz dystrybucję danych. Dzisiaj pokażę, jak przeglądać te dane, aby przekonać się samemu, że faktycznie powyższe metryki istnieją.

Załóżmy, że mamy tabele Articles z dwoma kolumnami, ID oraz Price. Następnie tworzymy indeks, czyli zarazem statystykę na kolumnie Price. W Sql Server studio będzie to wyglądać następująco:

1

Dodajmy również kilka wierszy z tymi samymi cenami. W celu wyświetlenia statystyk należy wywołać DBCC SHOW_STATISTICS:

DBCC SHOW_STATISTICS('dbo.Articles',PRICE_INDEX)

Wynik będzie wyglądać następująco:

2

Pierwsza tabela przedstawia nazwę statystyki wraz z podsumowaniem. Widzimy m.in. datę ostatniej aktualizacji czy liczbę wierszy, które brały udział w obliczaniu metryk.

Druga tabela to gęstość. Wartość 1 oznacza, że nie ma unikalnych wierwszy. 0.125 to z kolei gęstość klucza.Mamy osiem wierszy i klucz główny zawsze ma unikalną wartość, stąd 1/8 równa się 0.125.

Ostatnia tabela to dystrybucja danych w formie histogramu. Histogram opisany jest za pomocą kilku kolumn tzn.:

RANGE_HI_KEY
RANGE_ROWS
EQ_ROWS
DISTINCT_RANGE_ROWS
AVG_RANGE_ROWS

RANGE_HI_KEY to wartość pojedynczego progu w hisogramie. Kolejne metryki opisują ile wartości przypada na ten próg (m.in. włączając granice progu, przypadające dokładnie w miejsce progu, liczba unikalnych wartość).

Szczegóły można znaleźć w dokumentacji m.in. tutaj. W praktyce oczywiście nie musimy analizować tych danych. Bardzo ważne jednak jest, aby mieć świadomość o SQL Statistics ponieważ statystyki mają ogromny wpływ podczas generowania planu wykonania.

SQL Server Statistics

SQL Server buduje statystyki opisujące przechowywane dane. Dzięki tym statystkom łatwiej jest wygenerować optymalny “execution plan”.  Jak wiemy,  plan wykonania zależy m.in. od stworzonych indeksów oraz przekazanych parametrów do danego zapytania\procedury.  Problem w tym, że bez wiedzy o konkretnych danych w bazie, trudno wygenerować optymalny plan. Jeśli wiemy, że zapytanie zwróci wszystkie wiersze, wtedy nie ma sensu korzystać z żadnych indeksów, ponieważ skanowanie będzie i tak szybsze.

Dzięki statystykom, SQL Server wie m.in. o dystrybucji i gęstości konkretnych danych\kolumn. Jeśli kolumna B ma jedną wartość we wszystkich wierszach, wtedy filtrowanie po niej nie jest zbyt selektywne. Takie informacje są bardzo przydatne w czasie tworzenia planu. W momencie, gdy wykonujemy jakiekolwiek zapytanie czy procedurę następuje kilka ważnych etapów:

  1. Zapytanie jest przekazane do SQL Server query optimizer.
  2. Jeśli plan zapytania istnieje w cache, wtedy po prostu jest zwracany.
  3. Tworzony jest nowy plan na podstawie dostępnych indeksów oraz typu przechowywanych danych.  To tutaj SQL Optimizer decyduje czy warto użyć indeksu czy lepsze może okazać się standardowe skanowanie. Jeśli optymizer zdecyduje się na użycie indeksu, wtedy musi również wybrać ten najbardziej optymalny.
  4. Nowo stworzony plan jest umieszczany w cache, wiec następne zapytania nie będą musiały być ponownie analizowane.

Do dyspozycji SQL Server ma dwie metryki, zagęszczenie danych oraz ich dystrybucję.

  • Gęstość  danych(Density)

Pierwszą zbieraną metryką jest zagęszczenie danych, czyli współczynnik pokazujący jak wiele unikalnych wartości jest w danej kolumnie. Można to opisać następującym wzorem:

Density of X = 1 / liczba unikalnych wartości w kolumnie X

Wysoka gęstość oznacza zatem, że jest tam mało unikalnych wartości, a co za tym idzie, zapytanie na takiej kolumnie będzie mało selektywne i nie ma sensu korzystać z indeksu.

  • Dystrybucja danych

Dystrybucja danych również opisuje zagęszczenie danych, ale pokazuje jakie konkretne dane występują najczęściej. Innymi słowy, dystrybucja danych to histogram. Dane są zatem dzielone na odpowiednie przedziały (oś X), a potem na osi Y mamy liczbę wystąpień znajdujących się w danym przedziale.   Jeśli w jakimś zapytaniu mamy “WHERE X> 200 AND X < 500, wtedy za pomocą dystrybucji można określić ile w przybliżeniu zapytanie zwróci wierszy. Jeśli histogram w tych przedziałach ma wysokie wartości, wtedy zapytanie jest bardzo selektywne, a co za tym idzie, indeks jest dobrym rozwiązaniem.

W SQL Server występuje 200 wspomnianych przedziałów. Trochę upraszczam tutaj, bo SQL Server przechowuje kilka innych wartości histogramu, ale główną ideą jest posiadanie opisu gęstości konkretnych przedziałów danych.

Kiedy są zatem tworzone statystyki? Domyślnie są tworzone dla indeksów, w tym klucza głównego (clustered index). Jeśli AUTO_CREATE_STATISTICS jest uaktywnione na bazie, wtedy również będą tworzone dla kolumn w zapytaniach, które używane są do filtrowania danych (klauzula WHERE, JOIN).

W następnym wpisie przyjrzyjmy się jak obejrzeć powyższe statystyki w SQL Server.

tSQLt – jak testować “constraints”

Załóżmy, że mamy następującą tabelę:

CREATE TABLE [dbo].[Articles](
	[Id] [int] NOT NULL,
	[Price] [int] NULL,
 CONSTRAINT [PK_Articles] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]

GO

ALTER TABLE [dbo].[Articles]  WITH CHECK ADD  CONSTRAINT [CK_Articles] CHECK  (([Price]>(5)))
GO

ALTER TABLE [dbo].[Articles] CHECK CONSTRAINT [CK_Articles]
GO

Oprócz tabeli, dodaliśmy walidację danych. Cena zawsze musi być większą niż 5. Jeśli spróbujemy dodać wartość mniejszą niż 5, zakończy się to błędem.

INSERT INTO Articles Values(1,4)

Błąd:

The INSERT statement conflicted with the CHECK constraint "CK_Articles". The conflict occurred in database "testdb", table "dbo.Articles", column 'Price'.

Spróbujmy teraz wykonać taką samą operację w teście:

EXEC tSQLt.FakeTable 'dbo.Articles'
INSERT INTO Articles Values(1,4)

Dodanie wartości 4 nie spowoduje żadnego błędu. Wynika to z tego, że każdy stub pozbawiony jest jakichkolwiek restrykcji. Zwykle jest to bardzo korzystne, ponieważ jeśli testujemy tylko jedną kolumnę, nie musimy martwić się o resztę wartości.
Jeśli z kolei chcemy, aby nasze testy pokryły również walidację danych (constraints), wtedy mamy do dyspozycji metodę ApplyConstraint:

EXEC tSQLt.FakeTable 'dbo.Articles'
EXEC tSQLt.ApplyConstraint @TableName='dbo.Articles', @ConstraintName="CK_Articles"

INSERT INTO Articles Values(1,4)

Dzięki ApplyConstraint możemy dodawać ograniczenia jedno po drugim, co umożliwia prawidłową implementację testów jednostkowych, gdzie chcemy testować wyłącznie pojedyncze rzeczy. Prawidłowa implementacja testu wygląda zatem następująco:

EXEC tSQLt.FakeTable 'dbo.Articles'
EXEC tSQLt.ApplyConstraint @TableName='dbo.Articles', @ConstraintName="CK_Articles"

EXEC tSQLt.ExpectException

INSERT INTO Articles Values(1,4)

Analogicznie, powinniśmy przetestować wartości, które są zgodne z wymaganiami:

EXEC tSQLt.FakeTable 'dbo.Articles'
EXEC tSQLt.ApplyConstraint @TableName='dbo.Articles', @ConstraintName="CK_Articles"

EXEC tSQLt.ExpectNoException

INSERT INTO Articles Values(1,10)

Testowanie kluczy obcych odbywa się również za pomocą ApplyContraint. Bardzo podobną metodą jest ApplyTrigger:

tSQLt.ApplyTrigger [@TableName = ] 'table name'
                    , [@TriggerName = ] 'trigger name'

BenchmarkDotNet – prosta biblioteka do testów wydajnościowych

Dzisiaj chciałbym pokazać BenchmarkDotNet. Dzięki niemu w łatwy sposób można przetestować wydajność konkretnych metod w c#. Na blogu temat wydajności poruszałem już wiele razy i wiemy,  nie jest łatwe prawidłowe zmierzenie czasu wykonania kodu. Pamiętajmy, że kod wykonany pierwszy raz zawsze musi zostać przetłumaczony do kodu maszynowego (JIT). W momencie wywołania pierwszy raz jakiejkolwiek metody, CLR sprawdzi czy dana metoda ma już kod maszynowy. Jeśli jakaś metoda w ogóle nie została wykonana, wtedy nie ma nawet potrzeby generowania kodu maszynowego. W celu rzetelnego przetestowania jakiejkolwiek metody, należy zawsze wykonać “warm-up”.

Dzięki BenchmarkDotNet nie musimy się o to martwić. Automatycznie zostaną stworzone odizolowane projekty dla każdego z testów. Framework zadba o wyliczenie statystyk takich jak m.in. mediana czy odchylenie standardowe.

Zacznijmy od instalacji pakietu NuGet:

PM> Install-Package BenchmarkDotNet

Kod, który będziemy testować wygląda następująco:

    public class SomeCode
    {
        public void CalculateSlow()
        {
            Thread.Sleep(5000);
        }

        public void CalculateFast()
        {
            Thread.Sleep(5);
        }
    }

Sam test z kolei, to nic innego jak klasa oznaczona atrybutami [Benchmark]:

    public class PerformanceTests
    {
        private readonly SomeCode _sut=new SomeCode();

        [Benchmark]
        public void CalculateSlowTest()
        {
            _sut.CalculateSlow();
        }

        [Benchmark]
        public void CalculateFastTest()
        {
            _sut.CalculateFast();
        }
    }

W celu wykonania testu, należy skorzystać z klasy BenchmarkRunner:

Summary summary = BenchmarkRunner.Run<PerformanceTests>();

Jak widzimy, w wyniku dostaniemy obiekt Summary, zawierający szczegóły z testów. Nie musimy się jednak nim przejmować teraz, ponieważ framework wygeneruje automatycznie raporty zarówno w formie plików tekstowych, jak i na wyjściu aplikacji konsolowej. Po odpaleniu powyższego kodu, na początku zobaczymy:
1

Przez następne kilka sekund\minut będą wykonywane powyższe testy. Po zakończeniu, na ekranie konsoli zobaczymy podsumowanie:
2

Oprócz tego, w folderze bin zobaczymy kilka raportów m.in. PerformanceTests-measurements.csv, PerformanceTests-report.csv, PerformanceTests-report.html jak i również w formie markdown. Jeśli nie wystarczają one, można napisać własne i odpowiednio skonfigurować BenchmarkDotNet.

LINQ: GroupJoin

Dzisiaj podstawy, ale wcześniej nie miałem potrzeby skorzystania z  funkcji GroupJoin. Myślę, że prosty przykład jest najlepszą dokumentacją.

Dosyć częstą używaną funkcją jest GroupBy. Jeśli mamy np. listę zamówień w postaci (IdCustomer, Name), wykonując GroupBy na IdCustomer otrzymamy słownik, gdzie kluczem jest identyfikator zamówienia, a wartością lista zamówień danego klienta.

GroupJoin, jak sama nazwa sugeruje jest połączeniem Join z GroupBy. Załóżmy, że mamy następujące encje:

    class CustomerInfo
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }

    class Order
    {
        public int IdCustomer { get; set; }
        public string Name { get; set; }
    }

Następnie przykładowe dane, definiujące relacje wyglądają następująco:

            var customers = new CustomerInfo[3];
            customers[0] = new CustomerInfo() { Id = 1, Name = "Piotr" };
            customers[1] = new CustomerInfo() { Id = 2, Name = "Pawel" };
            customers[2] = new CustomerInfo() { Id = 3, Name = "tertert" };

            var orderList = new Order[5];
            orderList[0] = new Order() { IdCustomer = 1, Name = "Zamowienie 1" };
            orderList[1] = new Order() { IdCustomer = 1, Name = "Zamowienie 1 a" };
            orderList[2] = new Order() { IdCustomer = 2, Name = "Zamowienie 2 a" };
            orderList[3] = new Order() { IdCustomer = 2, Name = "Zamowienie 2 b" };
            orderList[4] = new Order() { IdCustomer = 3, Name = "Zamowienie 3" };

W celu uzyskania zamówień dla konkretnego klienta wystarczy:

            var customerOrders = customers.GroupJoin(orderList, x => x.Id, x => x.IdCustomer, (customer, orders) => new
            {
                CustomerName = customer.Name,
                Orders = orders.ToArray()
            });

            foreach (var customerOrder in customerOrders)
            {
                Console.WriteLine(customerOrder.CustomerName);

                foreach (var order in customerOrder.Orders)
                {
                    Console.WriteLine("\t{0}", order.Name);
                }
            }

Wynikiem będzie lista klientów wraz z zamówieniami:
1

tSQLt – integracja z TeamCity

Kilka postów poświęciłem już na temat pisania testów jednostkowych dla SQL Server. Tak samo jak ze zwykłymi testami w C#, chcemy je wykonywać jako etap w CI. Ostatnio zwykle pracuję z TeamCity, dlatego w tym wpisie pokażę plugin właśnie dla TC.

Integracja TeamCity z tSQLt sprowadza się do instalacji następującego plugina:

https://github.com/cprieto/tsqlt-teamcity

Plugin umiezczamy w “C:\ProgramData\JetBrains\TeamCity\plugins”. Następnie, przy tworzeniu kolejnego etapu w CI, do dyspozycji będziemy mieli tSQLt runner (screen z oficjalnej dokumentacji):

Analogicznie do testów jednostkowych nUnit, będziemy mogli przeglądać, które testy zostały wykonane. Konfiguracja zatem sprowadza się do instalacji plugina oraz podania connection string.

Definiowanie własnych typów danych w C# (statyczne typowanie)

Zwykle programiści korzystają z podstawowych typów dostarczonych przez C#, takich jak String, Int32 czy Double. W świecie programowania obiektowego można jednak pójść o krok dalej i budować własne typy danych. Przeważnie programiści korzystają z nich wyłącznie, gdy do zaimplementowania jest jakaś logika. Dlaczego nie tworzyć ich nawet w sytuacjach, gdy mają one przechowywać wyłącznie dane?

Problem z podstawowymi typami takimi jak String Czy Int32 to fakt, że stanowią one wyłącznie fizyczną, a nie logiczną reprezentację danych. Int32 opisuje każdą liczbę całkowitą, co jest dość szerokim określeniem. Załóżmy, że w kodzie mamy dwie jednostki do opisania:
– prędkość (km/h)
– temperatura (C)

Standardowe rozwiązanie to użycie typów podstawowych, np. Int32:

int velocity = 85;
int temperature = 20;

Niestety powyższy kod nie będzie mógł korzystać z zalet statycznego typowania. Poniższy kod skompiluje się i o błędzie dowiemy się dopiero przy wykonywaniu jakiś operacji:

int velocity = 85;
int temperature = 20;

int result = velocity * temperature;

Prawdopodobnie nie ma sensu mnożyć prędkości przez temperaturę. Analogicznie typy podstawowe nie dostarczają żadnej walidacji logicznej reprezentacji. Co prawda w przypadku liczb, możemy zadeklarować velocity jako uint co ma większy sens ponieważ unikniemy wtedy błędów z związanych z ujemną prędkością. Problem jednak pojawi się w przypadku string:

string email="test@test.com";

W tym przypadku zdecydowanie potrzebna jest walidacja.

Zdefiniujmy zatem typy opisujące logiczną reprezentację danych:

    struct Velocity
    {
        private readonly uint _value;

        public Velocity(uint value)
        {
            _value = value;
        }
    }

    struct Temperature
    {
        private readonly int _value;

        public Temperature(int value)
        {
            _value = value;
        }
    }

Od tej pory, niemożliwe jest popełnienie następującego błędu:

var velocity = new Velocity(43);
var temperature = new Temperature(5);

var result = velocity + temperature;

Kod zakończy się błędem kompilacji: “Operator ‘+’ cannot be applied to operands of type ‘Velocity’ and ‘Temperature'”. Błąd wykryty na etapie kompilacji jest oczywiście dużo bardziej pożądany niż te spotykane już w trakcie działania aplikacji.

Oprócz korzyści z związanych z bezpiecznym typowaniem, kod jest łatwiejszy w zrozumieniu. Typ nadaje kontekst danej wartości.
Dodanie walidacji sprowadza się teraz wyłącznie do:

    struct Temperature
    {
        private readonly int _value;

        public Temperature(int value)
        {        
            _value = value;

            if (!IsValid(value))
                throw new ArgumentException("Temperature must be between -2000 and 2000.");
        }

        private bool IsValid(int value)
        {
            return value < 2000 && value > -2000;
        }
    }

Oczywiście to dopiero początek. W praktyce chcemy mieć do dyspozycji pewne operatory, np.:

            var temperature1 = new Temperature(42);
            var temperature2 = new Temperature(42);
            var temperature3 = new Temperature(41);

            Console.WriteLine(temperature1 == temperature2); // true
            Console.WriteLine(temperature1 == temperature3); // false

Jeśli chcemy wspierać ==, wtedy wystarczy napisać:

    struct Temperature
    {
        private readonly int _value;

        public Temperature(int value)
        {        
            _value = value;

            if (!IsValid(value))
                throw new ArgumentException("Temperature must be between -2000 and 2000.");
        }

        private bool IsValid(int value)
        {
            return value < 2000 && value > -2000;
        }

        public static bool operator ==(Temperature t1, Temperature t2)
        {
            return t1.Equals(t2);
        }

        public static bool operator !=(Temperature t1, Temperature t2)
        {
            return !t1.Equals(t2);
        }
    }

W praktyce warto zadeklarować bazowy typ, który implementuje najczęściej używane operacje (==,!=,>,<,+,-). Nie można również przesadać w drugą stronę i każdą zmienną owijać w typ. Moim zdaniem bardzo szybko taki kod stanie się przykładem overengineering. Jeśli jakiś typ danych występuje bardzo często w kodzie (np. money), wtedy warto o tym pomyśleć.

tSQLt – asercja błędów

W nUnit możemy sprawdzać, czy funkcja wyrzuciła dany wyjątek. W tSQLt analogicznie istnieje taka możliwość za pomocą procedur
EXPECTEXCEPTION oraz EXPECTNOEXCEPTION. SQL Server oczywiście nie ma jako takich wyjątków, znanych ze świata C#. Mamy za to pojęcie Severity Level. Określa one jak bardzo dany błąd jest poważny. Stwórzmy procedurę, która próbuje użyć nieistniejącej tabeli:

CREATE PROCEDURE DoSomething
AS
BEGIN
		SELECT * FROM NOT_EXISTING_TABLE;

END
GO

Jeśli wykonamy ją w tSQLt, test zakończy się oczywiście niepowodzeniem. Jeśli chcemy zweryfikować, że dowolny błąd został wyrzucony wtedy:

EXEC tSQLt.ExpectException 
EXEC DoSomething;

Metodę ExpectException musimy wykonać przed SUT. Może wydawać się to trochę mało intuicyjne ponieważ w C# zwykle DoSomething wywołujemy jako wyrażenie lambda.
Powyższy kod nie jest jednak dobrą praktyką ponieważ wyłapuje wszystkie błędy. Metoda ExpectException przyjmuje kilka interesujących, opcjonalnych parametrów:

tSQLt.ExpectException 
                     [  [@ExpectedMessage= ] 'expected error message']
                     [, [@ExpectedSeverity= ] 'expected error severity']
                     [, [@ExpectedState= ] 'expected error state']
                     [, [@Message= ] 'supplemental fail message']
                     [, [@ExpectedMessagePattern= ] 'expected error message pattern']
                     [, [@ExpectedErrorNumber= ] 'expected error number']

Zwykle chcemy ustalić przynajmniej ExpectedSeverity, aby wiedzieć jakiego typu błędy są spodziewane i akceptowane.
Druga, analogicnza metoda to ExpectNoException:

EXEC tSQLt.ExpectNoException 
EXEC DoSomething;

Nie trudno domyślić się, że używamy ją w sytuacjach, gdy nie spodziewamy się wyjątku.

tSQLt – izolacja funkcji

Izolacja funkcji w tSQLt niestety wygląda trochę inaczej niż miało miejsce to w przypadku izolacji procedur. Do dyspozycji mamy następującą funkcję:

tSQLt.FakeFunction [@FunctionName = ] 'function name'
                 , [@FakeFunctionName = ] 'fake function name'

Jak widzimy z sygnatury, musimy dostarczyć nazwę funkcji, która ma wykonać się zamiast oryginalnego kodu. Załóżmy, że chcemy stworzyć stub GetDate, który zwraca zawsze stałą wartość. Niestety, GetDate jest funkcją systemową i takich nie można bezpośrednio modyfikować. Stwórzmy zatem wrapper:

CREATE FUNCTION GetDateWrapper
(
)
RETURNS DATETIME
AS
BEGIN
	RETURN GETDATE()

END

Następnie nasz stub będzie zwracał zawsze stałą datę:

CREATE FUNCTION sampleTests.GetDateFake
(
)
RETURNS DATETIME
AS
BEGIN
	RETURN DATETIMEFROMPARTS(2015,1,1,12,10,14,4)

END

GetDateFake zwraca 2015-01-01. Przykładowy test może wyglądać następująco:

EXEC tsqlt.FakeFunction @FunctionName = N'dbo.GetDateWrapper', -- nvarchar(max)
    @FakeFunctionName = N'sampleTests.GetDateFake' -- nvarchar(max)

SELECT dbo.GetDateWrapper();

W przypadku GetDate nie jest to najlepsza praktyka. W zależności od scenariusza, lepszym rozwiązaniem może okazać się przekazywanie czasu jako parametr wejściowy. FakeFunction zdecydowanie jest praktyczny, gdy w kodzie wywołujemy skomplikowane funkcje i nie chcemy ich akurat weryfikować w danym teście.

tSQLt – izolacja procedur

W ostatnich wpisach pokazałem jak izolować dane w formie tabel. W skomplikowanych przypadkach jednak, chcemy również izolacji procedur. Klasyczny przykład to operacje bazujące na czasie lub losowych wartościach. Załóżmy, że mamy następującą procedurę:

CREATE PROCEDURE GetCurrentDate
@outputDate DATETIME output
AS
BEGIN
	SELECT @outputDate=GETDATE();
END

Następnie chcemy przetestować procedurę, która korzysta z powyższej metody. Oczywiście będzie to bardzo trudne, ponieważ wynik prawdopodobnie zależy od aktualnego czasu. Nie trudno wyobrazić sobie zapytanie typu:

SELECT * FROM Table where EndDate > GetDate();

Wynika z tego, że musimy odizolować GetCurrentDate i wstrzyknąć zawartość, którą możemy kontrolować. Izolacja procedur odbywa się za pomocą metody SpyProcedure:

EXEC tSQLt.SpyProcedure 'dbo.GetCurrentDate', 'SET @outputDate = DATEFROMPARTS(2015,01,5)';

Od tego momentu, jakiekolwiek wywołanie GetCurrentDate, wykona kod przekazany jako drugi parametr. W powyższym przypadku, GetCurrentDate zawsze zwraca 2015-01-05. Całość można przetestować wykonując następujący kod:

DECLARE @date datetime;

EXEC tSQLt.SpyProcedure 'dbo.GetCurrentDate', 'SET @outputDate = DATEFROMPARTS(2015,01,5)';

EXEC dbo.GetCurrentDate @date OUTPUT;

SELECT @date;

Niestety, możliwości są dużo mniejsze niż te co mamy w przypadku C#+moq. Język TSQL jest jednak dość prosty. Moim zdaniem jest to wystarczające, szczególnie, że procedury T-SQL są dużo prostsze niż logika zaimplementowana w kodzie Java\C#.