- Funkcja: zwraca pusty tekst, gdy akcję wykonać można, a w przeciwnym razie krótki kod odmowy.
- Dwa miejsca: zapytanie przycisku i procedura zapisu pytają tę samą funkcję, więc reguła nie rozjeżdża się w dwóch miejscach.
- Termin: termin zapisany jako tekst oceniamy w pięciu stanach, z progiem w miesiącu kalendarzowym od dnia oceny.
- Test: przykłady sprawdziliśmy na poziomach zgodności 130 i 160.
Funkcja tabelowa inline, która zwraca kod odmowy, pozwala zapisać regułę dopuszczalności raz i oprzeć na niej przycisk oraz zapis. Piszemy dla programisty T-SQL, który chce, żeby pytanie „czy można” miało jedno miejsce w kodzie i sterowało jednocześnie dostępnością przycisku oraz procedurą zapisu. Funkcja zwraca pusty tekst, gdy akcję wykonać można, albo niepusty kod odmowy. Zapytanie przycisku i procedura zapisu pytają tę samą funkcję.
Reguła „czy można wykonać akcję” zapisana jako funkcja tabelowa inline zwraca pusty tekst albo kod odmowy, a ta sama funkcja steruje zapytaniem przycisku i procedurą zapisu, więc obejście przycisku nie zmienia danych. Ocena terminu w tej samej konstrukcji ma pięć stanów, a próg liczy się jako miesiąc kalendarzowy od dnia przekazanego jako parametr.
Dlaczego reguła w dwóch miejscach z czasem daje dwa różne wyniki
Reguła „czy można wykonać akcję” pojawia się zwykle w dwóch miejscach: w zapytaniu, które decyduje o dostępności przycisku, i w procedurze, która zapisuje decyzję. Gdy oba miejsca liczą ją osobno, pierwsza zmiana dotyka tylko jednego z nich i wyniki zaczynają się różnić. Przycisk bywa wtedy nieaktywny, choć procedura przyjęłaby zapis, albo aktywny w sytuacji, w której zapis powinien zostać odrzucony.
Wzorzec z tego artykułu usuwa drugą regułę. Jest jedna funkcja, a zapytanie przycisku i procedura zapisu ją wywołują, więc nie ma drugiego kodu, który mógłby odejść od pierwszego. Przykład ma trzy elementy: ocenę terminu zapisanego jako tekst, funkcję-strażnika, która składa z tej oceny kod odmowy, oraz procedurę zapisu decyzji. Ocena terminu i strażnik to dwie osobne funkcje, a nie jeden kod.
Jeśli przycisk i zapis pytają tę samą funkcję, nie ma drugiej reguły, która mogłaby się rozjechać.
Jak ocenić termin zapisany jako tekst i dlaczego daje pięć stanów
Termin w przykładzie jest tekstem wpisywanym ręcznie, więc funkcja oceny przyjmuje parametr tekstowy, a nie datę. Drugi parametr to dzień oceny przekazany przez wywołującego, a nie bieżąca data pobrana z zegara. Poniższy blok tworzy trzy tabele przykładu (zlecenia z terminem, rejestr decyzji i listę loginów uprawnionych do decyzji) oraz funkcję oceny. Wywołanie GO jest separatorem narzędzia, a nie instrukcją T-SQL.
| Stan (przykład) | Warunek | Skutek |
|---|---|---|
| brak | wpis pusty albo NULL | bez kontroli |
| blad | wpis nie jest datą albo nie podano dnia oceny | odmowa |
| minal | termin przed dniem oceny | odmowa |
| wymaga_zgody | termin nie przed dniem oceny i przed dniem oceny plus miesiąc | wydanie po zgodzie |
| ok | termin nie przed dniem oceny plus miesiąc | bez ograniczeń |
Pięć stanów zwracanych przez funkcję oceny terminu.
CREATE TABLE dbo.zlecenie_przyklad (id int PRIMARY KEY, termin varchar(30) NULL);
CREATE TABLE dbo.decyzja_przyklad (zlecenie_id int PRIMARY KEY, decyzja char(1) NOT NULL, termin_wpisu date NULL);
CREATE TABLE dbo.przelozony_przyklad (login varchar(30) PRIMARY KEY);
GO
CREATE FUNCTION dbo.ocena_terminu_przyklad (@txt varchar(30), @dzis date)
RETURNS TABLE
AS
RETURN
SELECT x.termin,
CAST(CASE WHEN x.txt IS NULL THEN 'brak'
WHEN x.termin IS NULL THEN 'blad'
WHEN @dzis IS NULL THEN 'blad'
WHEN x.termin < @dzis THEN 'minal'
WHEN x.termin < DATEADD(MONTH, 1, @dzis) THEN 'wymaga_zgody'
ELSE 'ok' END AS varchar(15)) AS stan
FROM (SELECT NULLIF(LTRIM(RTRIM(@txt)), '') AS txt,
TRY_CAST(NULLIF(LTRIM(RTRIM(@txt)), '') AS date) AS termin) AS x;
Trzy tabele przykładu i funkcja oceny terminu.
Wewnętrzne podzapytanie przycina tekst i zamienia pusty tekst na NULL, a potem próbuje zamienić go na datę przez TRY_CAST. Pusty wpis daje stan brak, a wpis niebędący datą daje stan blad. TRY_CAST zwraca NULL zamiast przerywać zapytanie, więc niepoprawny tekst trafia do stanu blad i nie zatrzymuje całej listy zleceń. Stan blad dostaje też wywołanie bez dnia oceny.
Kolejność gałęzi CASE ma znaczenie, bo wygrywa pierwsza spełniona: najpierw brak wpisu, potem wpis niebędący datą albo brak dnia oceny, a dopiero po nich porównania dat. Termin w dniu oceny nie jest jeszcze miniony i wymaga zgody. CAST do varchar(15) nadaje wynikowi jeden typ, więc stany tworzą jedną kolumnę o stałej szerokości. Inną ocenę liczoną w T-SQL opisujemy w artykule o sposobie oceny w T-SQL, a tutaj interesuje nas tylko wykorzystanie oceny przez strażnika.
Jak funkcja tabelowa inline zwraca pusty tekst albo kod odmowy
Funkcja-strażnik składa kod odmowy z oceny terminu i z kilku sprawdzeń pomocniczych. Jest to funkcja tabelowa inline, podobna konstrukcją do tej, którą opisujemy w tekście o funkcji inline zwracającej użytkownika. Zwraca jedną kolumnę z kodem: pusty tekst oznacza, że można, a niepusta wartość jest kodem odmowy.
CREATE FUNCTION dbo.mozna_przyklad (@id int, @decyzja char(1), @login varchar(30), @dzis date)
RETURNS TABLE
AS
RETURN
SELECT CAST(CASE
WHEN z.id IS NULL OR ISNULL(@decyzja, '') NOT IN ('1', '2') THEN 'brak_wyboru'
WHEN NOT EXISTS (SELECT 1 FROM dbo.przelozony_przyklad AS p WHERE p.login = @login) THEN 'brak_uprawnien'
WHEN o.stan IN ('minal', 'blad') THEN 'termin_minal'
WHEN o.stan IN ('ok', 'brak') THEN 'niepotrzebna'
WHEN d.decyzja = @decyzja AND d.termin_wpisu = o.termin THEN 'juz_podjeta'
ELSE '' END AS varchar(20)) AS kod
FROM (SELECT 1 AS x) AS s
LEFT JOIN dbo.zlecenie_przyklad AS z ON z.id = @id
LEFT JOIN dbo.decyzja_przyklad AS d ON d.zlecenie_id = z.id
OUTER APPLY dbo.ocena_terminu_przyklad(z.termin, @dzis) AS o;
Funkcja-strażnik zwracająca pusty tekst albo kod odmowy.
Gałęzie CASE sprawdzają kolejno pięć powodów odmowy.
| Kolejność | Kod | Znaczenie |
|---|---|---|
| 1 | brak_wyboru | zlecenie nie istnieje albo decyzja jest inna niż 1 i 2 |
| 2 | brak_uprawnien | login nie występuje na liście uprawnionych |
| 3 | termin_minal | stan terminu to minal albo blad |
| 4 | niepotrzebna | stan terminu to ok albo brak, bo zgoda nie jest wtedy potrzebna |
| 5 | juz_podjeta | ta sama decyzja jest już zapisana dla tego samego terminu |
Kody odmowy zwracane przez funkcję-strażnika.
Zapytanie startuje od jednowierszowego zbioru, a lewe złączenia i OUTER APPLY sprawiają, że funkcja zwraca wiersz także wtedy, gdy zlecenia nie ma. Brak zlecenia kończy się więc kodem brak_wyboru, a nie pustym wynikiem. Kod jest tylko krótkim identyfikatorem powodu, a komunikat dla użytkownika powstaje poza funkcją.
Granica wzorca: funkcja decyduje o dopuszczeniu akcji i nie zastępuje blokad. Pusty kod mówi tylko tyle, że w chwili pytania akcję wykonać można.
Jak przycisk i procedura zapisu korzystają z tej samej funkcji
Zapytanie przycisku pyta funkcję i zależnie od wyniku zwraca adres okna decyzji, gdy kod jest pusty, albo adres komunikatu z kodem odmowy. W teście sprowadzamy je do samego kodu, żeby porównać go z wynikiem zapisu. Procedura zapisu woła tę samą funkcję przed zapisem decyzji, a przy niepustym kodzie zwraca wiersz z wynikiem ERR i kodem i nie zmienia danych.
CREATE PROCEDURE dbo.decyzja_przyklad_zapisz @id int, @decyzja char(1), @login varchar(30), @dzis date
AS
BEGIN
SET NOCOUNT ON;
DECLARE @kod varchar(20) = (SELECT kod FROM dbo.mozna_przyklad(@id, @decyzja, @login, @dzis));
IF ISNULL(@kod, 'brak_wyboru') <> ''
BEGIN
SELECT 'ERR' AS wynik, ISNULL(@kod, 'brak_wyboru') AS kod;
RETURN;
END;
DELETE dbo.decyzja_przyklad WHERE zlecenie_id = @id;
INSERT dbo.decyzja_przyklad (zlecenie_id, decyzja, termin_wpisu)
SELECT @id, @decyzja, o.termin
FROM dbo.zlecenie_przyklad AS z CROSS APPLY dbo.ocena_terminu_przyklad(z.termin, @dzis) AS o
WHERE z.id = @id;
SELECT 'OK' AS wynik, '' AS kod;
END;
Procedura zapisu decyzji pytająca funkcję-strażnika.
Pusty kod prowadzi do DELETE i INSERT, które zapisują decyzję razem z terminem, którego dotyczy. Termin zapisany przy decyzji jest później porównywany z terminem bieżącym, więc po jego zmianie decyzja przestaje obowiązywać i wymagana jest nowa. Gdy funkcja nie zwróci wiersza, procedura traktuje to jak brak wyboru. O budowie procedury zapisu piszemy szerzej w tekście o procedurze zapisu w T-SQL.
Granica wzorca: sprawdzenie funkcją i zapis decyzji to dwa osobne kroki bez transakcji. Przykład nie jest więc odporny na jednoczesne wywołanie z dwóch sesji, w którym obie przejdą sprawdzenie, zanim któraś zapisze decyzję.
Dlaczego miesiąc kalendarzowy ma granicę zależną od dnia
Próg oceny to DATEADD(MONTH, 1, dzień oceny), czyli przesunięcie o miesiąc według kalendarza, a nie o stałą liczbę dni. Zgody wymaga termin, który przypada nie przed dniem oceny i przed progiem, a termin dokładnie miesiąc po dniu oceny zgody już nie wymaga. Termin przypadający dzień przed progiem nadal wymaga zgody.
Granica zależy od dnia oceny. Gdy dzień oceny przypada na 31. dzień miesiąca, a następny miesiąc jest krótszy, DATEADD zwraca ostatni dzień tego krótszego miesiąca. Próg nie jest więc równy trzydziestu dniom, a wzorzec, który dodałby stałą liczbę dni, dałby inną granicę niż kalendarz. Test zawiera oba przypadki: termin dzień przed granicą krótszego miesiąca oraz termin dokładnie w dniu granicy.
SELECT t.opis, t.dzis, t.wejscie, o.stan, t.oczekiwane,
CASE WHEN o.stan = t.oczekiwane THEN 'OK' ELSE 'BLAD' END AS wynik
FROM (VALUES
(N'brak (NULL)', CAST('2026-10-12' AS date), CAST(NULL AS varchar(30)), 'brak'),
(N'pusty tekst', '2026-10-12', '', 'brak'),
(N'nie jest datą', '2026-10-12', 'abc', 'blad'),
(N'nieistniejąca data', '2026-10-12', '2026-02-30', 'blad'),
(N'wczoraj', '2026-10-12', '2026-10-11', 'minal'),
(N'dziś (dzień terminu)', '2026-10-12', '2026-10-12', 'wymaga_zgody'),
(N'miesiąc minus dzień', '2026-10-12', '2026-11-11', 'wymaga_zgody'),
(N'dokładnie miesiąc', '2026-10-12', '2026-11-12', 'ok'),
(N'za rok', '2026-10-12', '2027-10-12', 'ok'),
(N'31 stycznia, dzień przed granicą', '2026-01-31', '2026-02-27', 'wymaga_zgody'),
(N'31 stycznia, granica 28 lutego', '2026-01-31', '2026-02-28', 'ok'),
(N'brak dnia oceny', NULL, '2026-10-20', 'blad')) AS t(opis, dzis, wejscie, oczekiwane)
CROSS APPLY dbo.ocena_terminu_przyklad(t.wejscie, t.dzis) AS o;
Dwanaście przypadków oceny terminu z oczekiwanym stanem.
| Przypadek | Termin | Dzień oceny | Stan | Wynik |
|---|---|---|---|---|
| brak (NULL) | NULL | podany | brak | OK |
| pusty tekst | pusty | podany | brak | OK |
| nie jest datą | abc | podany | blad | OK |
| nieistniejąca data | zapis ISO z nieistniejącym dniem miesiąca | podany | blad | OK |
| wczoraj | dzień oceny minus 1 dzień | podany | minal | OK |
| dzień oceny | dzień oceny | podany | wymaga_zgody | OK |
| miesiąc minus dzień | miesiąc po dniu oceny minus 1 dzień | podany | wymaga_zgody | OK |
| dokładnie miesiąc | dokładnie miesiąc po dniu oceny | podany | ok | OK |
| za rok | rok po dniu oceny | podany | ok | OK |
| dzień przed granicą krótszego miesiąca | dzień przed granicą | 31. dzień miesiąca, następny krótszy | wymaga_zgody | OK |
| granica krótszego miesiąca | ostatni dzień krótszego miesiąca | 31. dzień miesiąca, następny krótszy | ok | OK |
| brak dnia oceny | poprawna data | NULL | blad | OK |
Wynik zapytania oceniającego dwanaście przypadków.
Dwanaście przypadków daje oczekiwany stan na poziomach zgodności 130 i 160, w tym granicę krótszego miesiąca i brak dnia oceny.
Jakie przypadki brzegowe sprawdzić, zanim reguła trafi do użytku
Oprócz oceny terminu sprawdzamy zgodność przycisku i zapisu. Test wstawia pięć zleceń i dodaje szóste, które nie istnieje, a potem pyta funkcję tak jak przycisk i wywołuje procedurę zapisu z tą samą decyzją, tym samym loginem uprawnionego i tym samym dniem oceny. Pytanie przycisku przechodzi przez CROSS APPLY po liście zleceń, więc jedno zapytanie ocenia całą listę. Wynik zapisu trafia do tabeli tymczasowej, a ostatnie zapytanie zestawia oba kody.
INSERT dbo.zlecenie_przyklad (id, termin) VALUES (1, '2026-10-11'), (2, '2026-10-20'), (3, '2026-12-01'), (4, 'abc'), (5, NULL);
INSERT dbo.przelozony_przyklad (login) VALUES ('przelozony1');
DECLARE @dzis date = '2026-10-12';
SELECT z.id, m.kod AS kod_przycisku INTO #przycisk
FROM (SELECT id FROM dbo.zlecenie_przyklad UNION ALL SELECT 6) AS z
CROSS APPLY dbo.mozna_przyklad(z.id, '1', 'przelozony1', @dzis) AS m;
CREATE TABLE #zapis (id int, wynik varchar(3), kod varchar(20));
DECLARE @id int = 1;
WHILE @id <= 6
BEGIN
INSERT #zapis (wynik, kod) EXEC dbo.decyzja_przyklad_zapisz @id, '1', 'przelozony1', @dzis;
UPDATE #zapis SET id = @id WHERE id IS NULL;
SET @id += 1;
END;
SELECT p.id, p.kod_przycisku, s.wynik, s.kod AS kod_zapisu,
CASE WHEN p.kod_przycisku = s.kod THEN 'zgodne' ELSE 'ROZNE' END AS zgodnosc
FROM #przycisk AS p JOIN #zapis AS s ON s.id = p.id ORDER BY p.id;
Test zgodności: kod dla przycisku i wynik zapisu dla sześciu zleceń.
| Zlecenie (termin) | Kod dla przycisku | Wynik zapisu | Kod zapisu | Zgodność |
|---|---|---|---|---|
| 1 (dzień oceny minus 1 dzień) | termin_minal | ERR | termin_minal | zgodne |
| 2 (dzień oceny plus 8 dni) | pusty | OK | pusty | zgodne |
| 3 (ponad miesiąc po dniu oceny) | niepotrzebna | ERR | niepotrzebna | zgodne |
| 4 (tekst abc) | termin_minal | ERR | termin_minal | zgodne |
| 5 (brak terminu) | niepotrzebna | ERR | niepotrzebna | zgodne |
| 6 (zlecenie nie istnieje) | brak_wyboru | ERR | brak_wyboru | zgodne |
Wynik testu zgodności przycisku i zapisu.
Przycisk i zapis dają ten sam kod dla każdego z sześciu zleceń przykładowych. Zlecenie 2 ma termin dzień oceny plus 8 dni, czyli krótszy niż miesiąc, więc strażnik dla loginu z listy zwraca pusty kod, a procedura zapisuje decyzję. Dla loginu spoza listy ta sama funkcja zwraca brak_uprawnien, i to samo dostaje procedura wywołana bez przycisku.
SELECT 'po decyzji' AS moment, kod FROM dbo.mozna_przyklad(2, '1', 'przelozony1', '2026-10-12')
UNION ALL
SELECT 'inny login', kod FROM dbo.mozna_przyklad(2, '1', 'ktos_obcy', '2026-10-12');
UPDATE dbo.zlecenie_przyklad SET termin = '2026-10-25' WHERE id = 2;
SELECT 'po zmianie terminu' AS moment, kod FROM dbo.mozna_przyklad(2, '1', 'przelozony1', '2026-10-12');
Strażnik po zapisaniu decyzji, dla innego loginu i po zmianie terminu.
| Moment | Kod |
|---|---|
| po decyzji (ten sam termin) | juz_podjeta |
| inny login | brak_uprawnien |
| po zmianie terminu na dzień oceny plus 13 dni | pusty (decyzja przestała obowiązywać) |
Wynik trzech zapytań po zapisaniu decyzji.
Po zapisaniu decyzji strażnik zwraca juz_podjeta. Gdy termin w zleceniu zmieni się na dzień oceny plus 13 dni, decyzja traci ważność i wraca pusty kod, bo rejestr pamięta termin, którego decyzja dotyczyła.
Ostatni przypadek dotyczy formatu daty, bo konwersja tekstu z kropkami zależy od ustawienia sesji. TRY_CAST na tekście z kropkami daje przy ustawieniu DATEFORMAT dmy datę, w której pierwszy człon jest dniem, a przy mdy datę, w której pierwszy człon jest miesiącem. Zapis ISO daje w obu ustawieniach tę samą datę.
SET DATEFORMAT dmy;
SELECT 'dmy' AS ustawienie, TRY_CAST('12.10.2026' AS date) AS z_kropkami, TRY_CAST('2026-10-12' AS date) AS iso;
SET DATEFORMAT mdy;
SELECT 'mdy' AS ustawienie, TRY_CAST('12.10.2026' AS date) AS z_kropkami, TRY_CAST('2026-10-12' AS date) AS iso;
Ta sama konwersja przy dwóch ustawieniach formatu daty.
| Ustawienie | Zapis z kropkami | Zapis ISO |
|---|---|---|
| DATEFORMAT dmy | pierwszy człon czytany jako dzień | ta sama data |
| DATEFORMAT mdy | pierwszy człon czytany jako miesiąc, więc inna data | ta sama data |
Wynik konwersji przy dwóch ustawieniach formatu daty.
Granica wzorca: ocena terminu rozpoznaje datę zgodnie z ustawieniem sesji, więc zapis z kropkami daje różne daty przy różnych ustawieniach formatu. Gdy pole jest tekstem, zapis ISO ma stałe znaczenie, a zapis z kropkami zależy od sesji; funkcja nie próbuje rozpoznać dowolnego formatu, a tekst, który nie jest datą w danym ustawieniu, daje stan blad. Walidację dat w procedurze omawiamy osobno w artykule o walidacji dat w procedurze.
Pytania do analizy
Przed zastosowaniem wzorca odpowiedz na cztery pytania.
- Miejsca reguły: Ile miejsc w Twoim systemie sprawdza tę samą regułę dopuszczalności osobno i czy zmiana reguły dociera do nich jednocześnie?
- Kod odmowy: Czy odmowa ma kod, który da się przełożyć na komunikat, czy tylko wartość logiczną bez powodu?
- Format daty: W jakim formacie użytkownicy wpisują datę, jeśli pole jest tekstem, i co robi konwersja przy ustawieniu formatu inaczej, niż oczekujesz?
- Unieważnianie decyzji: Które dane powinny unieważniać poprzednią decyzję i czy rejestr decyzji pamięta, czego decyzja dotyczyła?