en-UShe-IL
You are here:   Blog
Register   |  Login

Blog Archive:

Maximize
* Can be used in order to search for older blogs Entries

Search in blogs


Blog Categories:

Maximize
* Can be used in order to search for blogs Entries by Categories

Blog Tags:

Maximize
* Can be used in order to search for blogs by keywords

TNWikiSummit


Awared MVP

 


Microsoft® Community Contributor 


Microsoft® Community Contributor


 Read first, before you you use the blog! Maximize

Recent Entries

Minimize
נוב16

Written by: ronen ariely
16/11/2011 11:22 RssIcon

Select First "Log entery" row from Log Table

תקציר

בבלוג זה אני מציג פתרון לבעיה שהוצגה באחד הפורומים ברשת. הדגש במדריך יהיה לא רק הצגת הפתרון כי אם הצגת דרך המחשבה ופיתוח הדרך אל הפתרון. בחלק הראשון של המדריך אציג דרכי מחשבה שונים וכיצד הגענו לפתרונות. בחלק השני נערוך השוואה קצרה בין הפתרונות השונים. ולסיום נציג בחלק השלישי של המדריך נקודות למחשבה ונסביר חלק מהסיבות להבדלים שקיבלנו. כאמור מטרת מדריך זה לא רק להצגת הפתרון הסופי כי אם ההסברים הנלווים לפתרון.

השאלה בה נדון:

השמירה בבניין החברה מתבצעת על ידי שומרים העובדים במשמרות. בחדר הכניסה לבניין (הלובי) של החברה נרשמים קבצי לוג יומיים של השומרים. בכל כניסה של שומר אל חדר הלובי נרשמת רשומה בקובץ הלוג הכוללת שעת הכניסה ושם המשתמש. עם תחילת כל משמרת מבוצעת למעשה הכניסה הראשונה של השומר אל הבניין ומגדירה את זמן תחילת המשמרת שלו. במשך המשמרת השומר מבצע פטרולים בזמנים לא קבועים ובסופם חוזר אל הלובי. בכל חזרה אל הלובי כאמור נרשמת שוב רשומה בקובץ הלוג המצביעה על כניסת השומר.

* שומר יכול לעבוד יותר ממשמרת. כמו כן אין הגבלה על אורך כל משמרת. ז"א אם שומר A התחיל בבוקר והוחלף על ידי שומר B הרי ששומר A יכול לחזור ולהחליף את השומר הקודם.

המטרה

אנו רוצים למצוא את הכניסה הראשונה של כל משתמש במשמרת מסוימת שלו (על מנת לדעת מתי התחילה המשמרת).

דוגמה לקובץ/טבלת לוגים יומי:

/*
User1        12:00
User1        12:01
User1        12:02
User1        12:03
User1        12:04
User1        12:05
User1        12:06
User2        12:07
User2        12:08
User2        12:09
User2        12:10
User2        12:11
User1        12:12
User1        12:13
*/

הנתונים שאנו רוצים לתפוס הם הנתונים המסומנים באדום. יש לשים לב שאם הייתה משמרת אחת בלבד לכל שומר הרי שלא הייתה בעיה לתת פתרון מיידי על ידי בחירת top 1 או בכל צורה אחרת כאשר מבצעים קיבוץ של הנתונים לפי שם המשתמש. אבל במקרה הנוכחי אנו רוצים להפריד בין משמרות שונות של אותו משתמש. מדריך זה ידון במציאת הנתונים?

הקדמה: DDL + DML


-- ניגש אל מסד הנתונים למשחקים שלנו
use QQ
go
 
-- נכין לנו טבלה בסיסית בשביל ההדגמה
CREATE table TestLagLeadFunc(
    [User] [nvarchar](10) NULL,
    [Time] [time](7) NULL
)
 
-- נכניס כמה נתונים לדוגמה
insert TestLagLeadFunc
select 'User1', '12:00' UNION
select 'User1', '12:01' UNION
select 'User1', '12:02' UNION
select 'User1', '12:03' UNION
select 'User1', '12:04' UNION
select 'User1', '12:05' UNION
select 'User1', '12:06' UNION
select 'User2', '12:07' UNION
select 'User2', '12:08' UNION
select 'User2', '12:09' UNION
select 'User2', '12:10' UNION
select 'User2', '12:11' UNION
select 'User1', '12:12' UNION
select 'User1', '12:13'
 
-- נבדוק שהנתונים נכנסו כראוי
select * from TestLagLeadFunc

חלק 1: דרכי מחשבה לפתרונות שנים לבעיה

אם נסתכל על הנתונים שלנו נוכל לראות שאנו מחפשים את הרשומות בהן שם המשתמש משתנה. ז"א אנו רוצים את כל הרשומות בהם הרשומה הקודמת הייתה של משתמש אחר ולכן הרשומה הנוכחית מהווה רשומה ראשונה של המשתמש במשמרת זו.

בשלב זה אני מעלה את הנתונים בפורמט שאילתה. בהמשך אנסה להגיע לעיצוב המדריך וכן את המשך המסקנות ארשום בהתאם לזמן שיעמוד לרשותי. המשך המדריך עדיין בכתיבה...

/*************************************************************/
-- נמספר את הרשומות על מנת שנוכל לשלוף ליד כל רשומה את המשתמש של הרשומה הקודמת
select
    *
    ,ROW_NUMBER() over (order by [Time] )
from TestLagLeadFunc
order by [Time]
 
-- נעזר בטבלת
-- CTE
-- הכוללת את כל הנתונים שלנו ממוספרים
-- על מנת להוציא את הנתונים של הרשומה הנוכחית ביחד עם הנתון של המתמש של הרשומה הקודמת
-- לרשומה הראשונה אין רשומה קודמת ולכן נקבל כשם משתמש קודם ערך ריק
;with MyCTE as
(
    select
        [USER]
        , [Time]
        , ROW_NUMBER() over (order by [Time] ) RN
    from TestLagLeadFunc
)
SELECT
    T1.[USER]
    , T1.[Time]
    , T1.RN
    , T2.[USER]
from MyCTE T1
left join MyCTE T2 on T1.RN = T2.RN + 1
 
-- ניתן לראות שרשומה מספר 1 שם המשמש הנוכחי הוא
-- User1
-- ושם המשתמש הקודם הוא ערך ריק מכיוון שנתון זה מהווה הנתון הראשון שיש לנו בקובץ הלוג
-- לכן זוהיא הכניסה הראשונה של השומר הראשון היום
-- באופן דומה בכל מקום בו נכנס שומר בפעם הראשונה במשמרת נקבל ששם המשתמש הקודם שונה מהשם של המשתמש הנוכחי
-- לכן כל מה שנשאר לנו זה לסנן את הנתונים ולקבל את התוצאה שמחפשים
;with MyCTE as
(
    select
        [USER]
        , [Time]
        , ROW_NUMBER() over (order by [Time] ) RN
    from TestLagLeadFunc
)
SELECT
    T1.[USER]
    , T1.[Time]
    , T1.RN
    , T2.[USER]
from MyCTE T1
left join MyCTE T2 on T1.RN = T2.RN + 1
where not T1.[USER] = isnull(T2.[USER],'')
-- הגענו אם כן לפתרון הראשון שלנו
/*************************************************************/
-- שימוש במיספור הרשומות היא פעולה שיכולה להיות יקרה מאוד בטבלה גדולה
-- כמו כן בפתרון הקודם לא יכולנו לנצל אינדקסים שיש לנו בטבלה
-- מכיוון שכל הפעולה של המיון/סידור התבססה על שדה מיספור חדש שאנו מוסיפים לטבלת התוצאות
-- ננסה לגשת לפתרון בצורה שונה תוך שימוש בשאילתה פנימית
-- ברגע שהבנו שאנו מחפשים להצמיד לכל רשומה נתון של רשומה שונה יכולנו לבחור בדרך זו
select
    [USER]
    , [Time]
    ,(
        select top 1 [USER] from TestLagLeadFunc as A
        where A.[Time] < B.[Time]
        order by [Time] desc
    ) as [OldUser]
from TestLagLeadFunc B
order by [OldUser]
 
--  וכל מה שנשאר לנו זה לבצע את הסינון של הנתונים ולקבל את הפתרון השני
select * from
(
    select
        [USER]
        , [Time]
        ,(
            select top 1 [USER] from TestLagLeadFunc as A
            where A.[Time] < B.[Time]
            order by [Time] desc
        ) as [OldUser]
    from TestLagLeadFunc B
) T
where not isnull([OldUser],'') = [User]
order by [Time]
 
/*************************************************************/
-- ננסה למצוא פתרון ללא שימוש בשאילתה פנימית
-- שאילתה פנימית חייבה אותנו להריץ למעשה שאילתה בתוך שאילתה בצורת
-- Nested Loops
-- האם נוכל להגיע לפתרון ישיר ללא לולאות בתוך לולאות?
-- כמובן שכן! לגבי היעילות נבדוק ונדבר על כך כבר בחלק הבא של המדריך
-- נחזור לבסיס שלנו ונמספר את הרשומות שלנו אבל הפעם נבצע חיבור של הנתונים לפי עמודת המשתמש
select
    *
    ,ROW_NUMBER() over (partition by [User] order by [Time] )
from TestLagLeadFunc
order by [Time]
 
-- ניתן לזהות בתוצאות השאילתה מעל שכל התוצאות שמספרן 1 הן התוצאות שאנו מחפשים
-- אלו הן רשומות הלוג של הכניסות הראשונות של כל משתמש
-- אבל אם נבחר רק את התוצאות שמספרן 1 אז נאבד את כל המשמרות של שומרים ששמרו יותר מפעם אחת ביום
-- מבט על הנתונים שקיבלנו יכול להקפיץ לנו רעיון...
-- ננסה להציג גם מיספור רגיל של כל העמודות שלנו כדי לבדוק את הרעיון
select
    *
    ,ROW_NUMBER() over (partition by [User] order by [Time] ) RowNumberByUser
    ,ROW_NUMBER() over (order by [Time] ) RowNumber
from TestLagLeadFunc
order by [Time]
 
-- נראה שהרעיון אכן הולך לכיוון חיובי...
-- אם נשים לב הרי שחיסור העמודות של המספור לפי המשתמש מהעמודה של המספור הרגיל
-- תביא לנו תוצאות של קבוצות נתונים בהתאם למשמרת
select
    *
    ,ROW_NUMBER() over (order by [Time] ) - ROW_NUMBER() over (partition by [User] order by [Time] ) as Shift
from TestLagLeadFunc
order by [Time]
 
-- אם כן כל מה שנשאר לנו זה לשלוף את הרשומות הראשונות בכל משמרת
-- נעזר למשל בפונקציה מינימום על זמן המשמרת ונקבץ לפי מספר המשמרת שלנו מהשאילתה הקודמת
-- וקיבלנו את הפתרון השלישי שלנו
select
    [User]
    , MIN([Time])
    , Shift
from
(
    select
        *
        ,ROW_NUMBER() over (order by [Time] ) - ROW_NUMBER() over (partition by [User] order by [Time] ) as Shift
    from TestLagLeadFunc
) T
group by Shift,[User]
order by MIN([Time])
 
/*************************************************************/
-- פתרון רביעי שלנו יהיה זהה לפתרון הקודם
-- רק שבמקום לעשות שימוש בפונקציות אגריגציה נעזר שוב בפונקציית המיספור שלנו
SELECT
    [User]
    , [Time]
    , ROW_NUMBER() over (partition by [Shift] order by [Time])
from
(
    select
        *
        ,ROW_NUMBER() over (order by [Time] ) - ROW_NUMBER() over (partition by [User] order by [Time] ) as Shift
    from TestLagLeadFunc
) T
order by [Time]
-- וכל מה שנשאר לנו זה לסנן ולקחת את הנתונים שנמצאים במיקום הראשון
SELECT * from
(
    SELECT
        [User]
        , [Time]
        , ROW_NUMBER() over (partition by [Shift] order by [Time]) EnterTimeInOneShift
    from
    (
        select
            *
            ,ROW_NUMBER() over (order by [Time] ) - ROW_NUMBER() over (partition by [User] order by [Time] ) as Shift
        from TestLagLeadFunc
    ) T
) T
where EnterTimeInOneShift = 1
order by [Time]
 

חלק 2: בדיקות על הפתרונות השונים

נציג בצורה מרוכזת את כל הפתרונות אותן אנו רוצים לבדוק. שיטה זו תאפשר לנו להריץ את כל הפתרונות ביחד ולהשוות את תוכניות ההרצה אשר יוצגו לנו זו מתחת לזו. כמו כן בצורה זו יוצג לנו ליד כל תוכנית הרצה של שאילתה אחוז המשקל שלה ביחס לשאר השאילתות.

/*************************************************************/
/************************************************** יעילות **/
/*************************************************************/
/*************************************************************/
USE QQ
go
 
-- 1. Using CTE & ROW_NUMBER
;with MyCTE as
(
    select
        [USER]
        , [Time]
        , ROW_NUMBER() over (order by [Time] ) RN
    from TestLagLeadFunc
)
SELECT
    T1.[USER]
    , T1.[Time]
    , T1.RN
    , T2.[USER]
from MyCTE T1
left join MyCTE T2 on T1.RN = T2.RN + 1
where not T1.[USER] = isnull(T2.[USER],'')
order by [Time]
GO
 
-- 2. Using Nested Loops (Sub Query)
select * from
(
    select
        [USER]
        , [Time]
        ,(
            select top 1 [USER] from TestLagLeadFunc as A
            where A.[Time] < B.[Time]
            order by [Time] desc
        ) as [OldUser]
    from TestLagLeadFunc B
) T
where not isnull([OldUser],'') = [User]
order by [Time]
 
-- 3. Using ROW_NUMBER With Min
select
    [User]
    , MIN([Time])
    , Shift
from
(
    select
        *
        ,ROW_NUMBER() over (order by [Time] ) - ROW_NUMBER() over (partition by [User] order by [Time] ) as Shift
    from TestLagLeadFunc
) T
group by Shift,[User]
order by MIN([Time])
 
-- 4. Using ROW_NUMBER
SELECT * from
(
    SELECT
        [User]
        , [Time]
        , ROW_NUMBER() over (partition by [Shift] order by [Time]) EnterTimeInOneShift
    from
    (
        select
            *
            ,ROW_NUMBER() over (order by [Time] ) - ROW_NUMBER() over (partition by [User] order by [Time] ) as Shift
        from TestLagLeadFunc
    ) T
) T
where EnterTimeInOneShift = 1
order by [Time]

מבט חטוף על האחוזים שכל שאילתה דרשה ביחס לכלל השאילתות על פי תמונות תוכנית ההרצה שאנו רואים כאן מצביע על שאילתה אחת שבברור נראית כשאילתה הכי פחות יעילה. האם זה המצב הנכון? מבט נוסף בתוכנית ההרצה עצמו יכול ללמד אותנו מה נעשה מאחורי הקלעים ויכול לרמוז שאולי דווקא שאילתה זו יכולה להיות מיטבית במצב של מערכת חיה. שימוש לב ש 89% אחוז מההשקעה בשאילתה השניה בוצע על פעולת המיון. מיון זה בוצע על מנת שנוכל לבחור את התוצאה הראשונה Top 1 שביצענו בשאילתה. האם מערכת חיה אכן צריכה להריץ פעולה זו כאשר שדה זה מהווה את השדה המרכזי שלנו לבחירה? האם אינדקס על שדה זה לא יבטל לחלוטין את חלק זה ויאשר לנו בחירה ישירה של רשומה בודדת מהאינדקס?

נדגיש מעט את המורכבות של הנושא וננסה לעבוד על טבלה הנמצאת בזכרון. נבצע זאת על ידי הגדרת משתנה טבלה והרצת 2 שאילתות לדוגמה. נוכל לראות שבמקרה הבא היוצרות כבר מתחלפות. יתרה מכך תוכנית ההרצה של השאילתה העושה שימוש בשאילתה פנימית משתנה (שאילתה 2 מהשאילתות בתמונות מעל).

/*************************************************************/
/************************************************** טבלה זמנית **/
/*************************************************************/
/*************************************************************/
DECLARE @Tbl as table(
    [User] [nvarchar](10) NULL,
    [Time] [time](7) NULL
)
 
insert @Tbl
select 'User1', '12:00' UNION
select 'User1', '12:01' UNION
select 'User1', '12:02' UNION
select 'User1', '12:03' UNION
select 'User1', '12:04' UNION
select 'User1', '12:05' UNION
select 'User1', '12:06' UNION
select 'User2', '12:07' UNION
select 'User2', '12:08' UNION
select 'User2', '12:09' UNION
select 'User2', '12:10' UNION
select 'User2', '12:11' UNION
select 'User1', '12:12' UNION
select 'User1', '12:13'
 
/*************************************************************/
select * from
(
    select
        *
        ,(select top 1 [USER] from @Tbl as A where A.[Time] < B.[Time] order by [Time] desc) as dd
    from @Tbl B
) T
where not isnull(dd,'') = [User]
order by [Time]
 
/*************************************************************/
;with CTE  as (
    select [USER], [Time], row_number() over (order by time) rn from @Tbl
select    CTE.[USER], CTE.time
from CTE left join CTE other on other.rn = CTE .rn - 1
where other.[USER] is null or CTE .[USER] <> other.[USER]

בשאילתה מעל הפעלתי שוב את 2 השאילתות הראשונות שהיו לנו בחלק הקודם. שאילתה מספר 2 הפעם היא השאילתה העושה שימוש בשאילתה פנימית (זו שקודם נראתה כפתרון הכי לא יעיל) ושאילתה 3 כאן היא למעשה השאילתה שעשתה שימוש ב CTE+ROW_NUMBER ונראתה קודם בשאילתה היעילה ביותר. הפעם הרצתי את השאילתות על טבלה הנמצאת בזכרון. ניתן לראות שתוכנית ההרצה הראשונה שונה מאוד מזו שנקבעה במקרה של טבלה קבועה במערכת. פתאום תוכניות ההרצה פועלות בזמן זהה וניתן כבר לזהות שתוכנית ההרצה של השאילתה שעושה שימוש בשאילתה פנימית נראית דווקא יעילה יותר ואינה כוללת פעולות שמבצועת בשאילתת ה CTE (לא קשה להעריך שהזמנים הזהים נובעים פשוט בשל שימוש בטבלה מיזערית וככל שהטבלה תגדל ההבלים יגדלו נגד השימוש בשאילתת ה CTE).

נבצע שינוי נוסף ונגדיר אינדקס על הטבלה המקורית שלנו.

--CREATE INDEX
CREATE INDEX [Time_Index]
ON TestLagLeadFunc ([Time])

והפלא ופלא השאילתה שקודם היתה הכי לא יעילה הפכה להיות השאילתה הכי יעילה בסדרי גודל! ומה אם נתחיל להכניס עוד נתונים לטבלה? ככל שנכניס יותר נתונים יעילות השיטה שעשתה שימוש בשאילתה הפנימית תגדל עוד יותר.

* כתיבת בלוג בסגנון זה לוקחת לצערי זמן רב בשל הצורך בהכנת התמונות ופורמט הטקס בצורה התואמת לבלוג. בהמשך בהתאם לזמן שיהיה ברשותי נראה את המצב האמיתי בטבלה עם כמה עשרות רשומות ואינדקס על שדה הזמן. בינתיים אתן כמה הסברים גם על המצב הנוכחי וגם על המצב במערכת חיה (מערכת כבדה יותר, מאונדקסת כיאות). והנה בדיקה נוספת לנושא עליו נדון בהמשך והוא אפיון מעט שונה על ידי הוספת עמודת ID כמפתח ייחודי לקבצי הלוגים שלנו.

/*************************************************************/
/************************************************** טבלה זמנית **/
/******* הוספת עמודת מספור בטבלה המקורית משנה פלאים את העבודה ***/
/*************************************************************/
  
DECLARE @Tbl as table(
    [id] int,
    [User] [nvarchar](10) NULL,
    [Time] [time](7) NULL
)
  
insert @Tbl
select 1,'User1', '12:00' UNION
select 2,'User1', '12:01' UNION
select 3,'User1', '12:02' UNION
select 4,'User1', '12:03' UNION
select 5,'User1', '12:04' UNION
select 6,'User1', '12:05' UNION
select 7,'User1', '12:06' UNION
select 8,'User2', '12:07' UNION
select 9,'User2', '12:08' UNION
select 10,'User2', '12:09' UNION
select 11,'User2', '12:10' UNION
select 12,'User2', '12:11' UNION
select 13,'User1', '12:12' UNION
select 14,'User1', '12:13'
  
/*************************************************************/
select * from
(
    select
        *
        ,(select [USER] from @Tbl as A where A.id = B.id -1) as dd
    from @Tbl B
) T
where not isnull(dd,'') = [User]
order by id
  
/*************************************************************/
select * from
(
    select
        *
        ,ROW_NUMBER() over (partition by [New] order by [id] ) as New2
    from
    (
        select
            *
            ,id - ROW_NUMBER() over (partition by [User] order by [id] ) as New
        from @Tbl
        --order by id
    ) as T
) as T
where New2 = 1
order by id

****

***

חלק 3: דיון ונקודות למחשבה

הרצת אותן שאילתות עם טבלאות זמניות נתנו תוצאות הפוכות לחלוטין מאלו של שימוש בטבלה הקבועה מבחינת אחוזי הריצה
תוכנית ההרצה בשימוש בטבלה זמנית היתה שונה מזו שנבחרה בשימוש בטבלה קבועה.

הוספת אינדקסים משנה קיצונית את תוכנית ההרצה ומורידה את הייתרון של שימוש במיספור דינאמי לעומת שימוש בשדה המאונדקס ישירות.

בשאילתה הכוללת שאילתה פנימית ללא שימוש במיספור ניתן לראות שהחלק הכבד בשימוש הוא המיון של שדה הזמן. שימוש בשדה זמן מאונדקס שלקח כ 90% מהשאילתה יורד לכדי 0% כאשר השדה מאונדקס.

ככל שהטבלה גדולה יותר השימוש במספור אוטומטי גורר יותר משאבים הן בשלב ביצוע המיספור האוטומטי והן בסידור הנתונים בשלב השליפה הבא.

כאמור המדריך עדיין עדיין בכתיבה וישנם עוד הרבה בדיקות שערכתי עם תוצאות קטנות במסד הנתונים הגוררים עדיפות שונה לכל שאילתה. מומלץ תמיד לבדוק ספציפית במסד הנתונים איתו עובדים כמובן כמו תמיד

בין הבדיקות שערכתי גם היתה בדיקה של שימוש ב CLR שכמצופה למקרה הנוכחי בו אין מניפולציות על הנתונים אלא רק בין הנתונים לא היתה שום עדיפות.

הוספת עמודת מיספור בטבלה המקורית משנה פלאים את אופן העבודה. מומלץ מאוד להכניס לטבלת הלוג לצורך העבודה הנוכחית שדה נוסף של ID לצורך העבודה! יש להקפיד במקרה זה לא למחוק רשומות כפי שאכן נעשה בקבצי לוגים כדי להמשיך ולשמור את השדה הייחודי רציף.

לעיתים העתקת הטבלה לטבלה זמנית (נוח בהמרה של טבלאות קטנות) ואינדוקס של הטבלה הזמנית לפי שדה מיספור חדש שמוסיפים תייעל מאוד את העבודה. מצד שני בטבלה גדולה השימוש בטבלה זמנית מכביד על העבודה וגוזל משאבים מיותרים.

חלק 4: נספחים

גרי רשף (DBA ומנהל פורום SQL בתפוז) מציע לבצע את השינוי הבא לטבלה תוך שימוש באינדקס מעט שונה:

CREATE table TestLagLeadFunc(
     [User] [nvarchar](10) Not Null,
    [Time] [time](7) Not Null
);
Alter Table TestLagLeadFunc Add Constraint PK_TestLagLeadFunc Primary Key Clustered(Time, [User]);

המשך זמני (כרגע בניסוח)...

פתרון 1: שימוש ב ROW_NUMBER על מנת למספר את הרשומות

שימוש בשאילתה בסיסית של בחירה מאפשר לנו לשלוף נתונים מרשומה אחת ו/או לצרף אל הנתונים של הרשומה ששולפים נתונים של רשומה אחרת מטבלה זו (Self Join) או מטבלה אחרת (Join). אבל כיצד נמצא את הרשומה הקודמת לרשומה הנוכחית בצורה יעילה כדי לבדוק אם הרשומה הנוכחית אכן שייכת למשתמש חדש?

השיטה הראשונה שעולה היא שאם היה לנו מספור של הרשומות היינו פשוט יכולים לשלוף עבור כל רשומה בשאילתה פנימית את הרשומה הקודמת לה

* אציין בשלב זה שבשרתים שונים כגון Oracle קיימות פונקציות המאפשרות לנו גישה ישירה את הרשומה הקודמת או הרשומה הבאה (או כל רשומה הנמצאת במיקום מסוים ביחס לרשומה הנוכחית). בגרסת ה SQL הבאה הוסיפו לנו פונקציות אלו. במדריך זה נדון בשרתים בהם אפשרות זו אינה מובנית כמובן.

 

פתרון 2: פתרון המבוסס על שאילתה פנימית

ניתן לזהות שאם נעבור