SQL HowTo: بناء سلاسل باستخدام وظائف النافذة

في بعض الأحيان ، عند تحليل البيانات ، تنشأ مشكلة التمييز بين "سلاسل" في عينة - أي تسلسلات مرتبة من السجلات ، يتم استيفاء شرط معين لكل منها.

يمكن أن يكون هذا شرطًا على بيانات السجل نفسه ، أو تعبيرًا معقدًا فيما يتعلق بسجل سابق أو أكثر - على سبيل المثال ، طول الفاصل الزمني بين عينات الوقت القريب.



توفر الحلول التقليدية خيارات مختلفة لـ "الربط الذاتي" ، عندما تتصل العينة بنفسها ، أو باستخدام بعض الحقائق "خارج البيانات" - على سبيل المثال ، يجب أن تحتوي السجلات على خطوة محددة بدقة (N + 1 ، "لكل يوم" ، ... ).

غالبًا ما يؤدي الخيار الأول إلى التعقيد التربيعي للخوارزمية من حيث عدد السجلات ، وهو أمر غير مقبول في العينات الكبيرة ، والثاني يمكن أن "ينهار" بسهولة إذا لم تكن هناك فجأة عينات في البيانات المصدر.

ولكن هذه المهمة ستساعدنا في حل وظائف النافذة بفعالية في PostgreSQL.

المهمة: عد أموال الآخرين


النظر في أبسط حالة لسلسلة - عندما يتم تحديد حالة الاستمرارية من خلال بيانات السجل نفسه.

لا يتعين القيام بجميع العمليات الأخرى بشكل منفصل. ولكن من أجل وضوح الخوارزمية ، سأقسمها إلى خطوات متتالية ، وفي النهاية سأعرض ما وكيفية تحسينه .

دعونا نتخيل أن لدينا بنكًا صغيرًا يحتفظ بأرصدة في حسابات العملاء في الجدول. بمجرد حدوث معاملة الاستلام والنفقات ، يعمل هذا التاريخ أيضًا على إصلاح إجمالي مبلغ الفاتورة في نهاية اليوم.
بعد عطلة رأس السنة الطويلة ، قرر البنك مكافأة عملائه - وكل شخص فتح حسابًا هذا العام يتراكم بالإضافة إلى 1٪ من متوسط ​​الرصيد اليومي لأطول فترة متواصلة عندما لا يتم "إعادة ضبط" الحساب .
هنا هو معيارنا لاستمرارية "السلسلة". حسنًا ، سيتم تحديد ترتيب البيانات حسب تواريخ الأرصدة.

لقد جلبوا لنا مثل هذا ملف CSV ، وطلبوا بسرعة حساب من ومدى هذا الكرم الذي يجب أن يحصل عليه البنك:

date;client;balance 01.01.2020;;150 01.01.2020;;100 02.01.2020;;100 02.01.2020;;150 03.01.2020;;200 05.01.2020;;0 06.01.2020;;50 08.01.2020;;0 08.01.2020;;200 09.01.2020;;0 09.01.2020;;0 10.01.2020;;5 

لاحظ فقط بعض الحقائق التي يمكن ملاحظتها في هذه البيانات:

  • 01.01 كانت عطلة ، ولم يعمل البنك. لذلك ، ليس لدى أي من العملاء سجلات للتغييرات في الرصيد في ذلك اليوم ، لكن لديهم أموال في حساباتهم. بمعنى أن خوارزميات "القوة الغاشمة" التي تتكرر يوميًا لن تعمل بشكل طبيعي.
  • 04.01 لم تقم أليس بإجراء أي عمليات ، لذلك لا يوجد إدخال. ولكن قبل 05.01 ، كان المبلغ في حسابها غير صفري - يجب أخذ ذلك في الاعتبار في التحليل.
  • نجري التحليل في 01.01-12.01 ، لكن رصيد حساب Alice في نهاية هذه الفترة هو غير صفري. نأخذ أيضًا في الاعتبار الحاجة إلى الحد من هذه الفترة.

CSV إلى المائدة


أفضل طريقة للاستيراد من CSV هي استخدام مشغل COPY . لكننا سنحاول القيام بذلك من خلال التعبيرات المعتادة للإحماء:

 CREATE TEMPORARY TABLE tbl AS SELECT to_date(prt[1], 'DD.MM.YYYY') dt , prt[2] client , prt[3]::numeric(32,2) balance FROM ( SELECT regexp_split_to_array(str, ';') prt FROM ( SELECT regexp_split_to_table( $$ date;client;balance 01.01.2020;;150 01.01.2020;;100 02.01.2020;;100 02.01.2020;;150 03.01.2020;;200 05.01.2020;;0 06.01.2020;;50 08.01.2020;;0 08.01.2020;;200 09.01.2020;;0 09.01.2020;;0 10.01.2020;;5 $$ , E'\\n') str ) T WHERE str <> '' OFFSET 1 ) T; 

هذه طريقة "غير شريفة" بمعنى أنها لن تهضم بشكل صحيح ، على سبيل المثال ، حماية فاصل في جسم الحقل. ولكن بالنسبة لمعظم التطبيقات البسيطة - مناسبة.

الخطوة 1: إصلاح حالة التطبيق


في حالتنا ، حالة استمرارية السلسلة هي رصيد غير صفري. نعرضه كحقل منفصل ، من أجل الوضوح ، ترتيب زمني حسب العميل:

 SELECT * , balance > 0 cond FROM tbl ORDER BY client, dt; 

 dt | client | balance | cond ------------------------------------ 2020-01-01 |  | 150.00 | t 2020-01-02 |  | 100.00 | t 2020-01-03 |  | 200.00 | t 2020-01-05 |  | 0.00 | f 2020-01-06 |  | 50.00 | t 2020-01-08 |  | 0.00 | f 2020-01-09 |  | 0.00 | f 2020-01-10 |  | 5.00 | t 2020-01-01 |  | 100.00 | t 2020-01-02 |  | 150.00 | t 2020-01-08 |  | 200.00 | t 2020-01-09 |  | 0.00 | f 

الخطوة 2: حساب المفقودين


لاحظ أن مبلغ بوب لم يتغير من 02.01 إلى 08.01. ووفقًا لظروف المشكلة ، يجب علينا حساب متوسط ​​الوقت المتبقي يوميًا - أي أننا نحتاج إلى معلومات حول هذه الأيام "الضائعة". أو على الأقل عدد الأيام التي ظلت فيها القيمة كما هي:

 coalesce(lead(dt) OVER(PARTITION BY client ORDER BY dt), '2020-01-12') - dt days 

 dt | client | balance | cond | days ------------------------------------------- 2020-01-01 |  | 150.00 | t | 1 2020-01-02 |  | 100.00 | t | 1 2020-01-03 |  | 200.00 | t | 2 2020-01-05 |  | 0.00 | f | 1 2020-01-06 |  | 50.00 | t | 2 2020-01-08 |  | 0.00 | f | 1 2020-01-09 |  | 0.00 | f | 1 2020-01-10 |  | 5.00 | t | 2 2020-01-01 |  | 100.00 | t | 1 2020-01-02 |  | 150.00 | t | 6 2020-01-08 |  | 200.00 | t | 1 2020-01-09 |  | 0.00 | f | 3 

باستخدام وظيفة نافذة lead () ، تعلمنا التاريخ من السجل التالي بالترتيب ، ومن خلال التماسك ، حددنا الفاصل الزمني لآخر واحد. في الوقت نفسه ، استخدموا الخاصية المفيدة التي ترجع الفرق بين تاريخين في PostgreSQL إلى عدد صحيح من الأيام بينهما.

كمكافأة مجانية تقريبًا ، حصلنا على جميع المعلومات نفسها للسجلات ذات الرصيد الصفري. ولكن إذا كان هناك الكثير من الخطوط التي لا تفي بالشروط التي لا تهمنا ، فمن المنطقي دفع مثل هذه الحسابات تحت CASE من أجل توفير موارد الخادم.

الخطوة 3: العثور على نقاط الاستراحة


بداية كل سلسلة تهتم بها هي النقطة التي تتغير فيها قيمة الشرط المحسوب مسبقًا بالنسبة للسجل السابق . سنستخدم الدالة lag () للعثور على مثل هذه النقاط:

 lag(cond) OVER(PARTITION BY client ORDER BY dt) IS DISTINCT FROM cond chain_start 

 dt | client | balance | cond | days | chain_start --------------------------------------------------------- 2020-01-01 |  | 150.00 | t | 1 | t 2020-01-02 |  | 100.00 | t | 1 | f 2020-01-03 |  | 200.00 | t | 2 | f 2020-01-05 |  | 0.00 | f | 1 | t 2020-01-06 |  | 50.00 | t | 2 | t 2020-01-08 |  | 0.00 | f | 1 | t 2020-01-09 |  | 0.00 | f | 1 | f 2020-01-10 |  | 5.00 | t | 2 | t 2020-01-01 |  | 100.00 | t | 1 | t 2020-01-02 |  | 150.00 | t | 6 | f 2020-01-08 |  | 200.00 | t | 1 | f 2020-01-09 |  | 0.00 | f | 3 | t 

باستخدام IS IS DISTINCT FROM بدلاً من <> ، تجنبنا مشاكل المقارنة مع NULL للسجلات الأولى لكل عميل. وفقًا لذلك ، جميع الخطوط التي تكون فيها القيمة TRUE هي بداية سلسلة جديدة ، و FALSE هي استمرارها.

الخطوة 4: سلسلة الروابط


لتجميع البيانات داخل كل سلسلة فردية ، من الأسهل تعيين المعرّف نفسه لجميع سجلاته. الرقم التسلسلي للسلسلة نفسها مثالي لذلك. وهو يساوي بالضبط عدد "بدايات" السلاسل التي تم العثور عليها أعلى في العينة.

يمكن حسابها إما من خلال الجمع "window" لمجموع قيم القيم المنطقية ({boolean} :: integer) ، أو عن طريق حساب عدد السجلات المطابقة لعداد count (*) FILTER (WHERE {boolean}. سوف نستخدم الخيار الثاني:

 count(*) FILTER(WHERE chain_start) OVER(PARTITION BY client ORDER BY dt) grpid 

 dt | client | balance | cond | days | chain_start | grpid ----------------------------------------------------------------- 2020-01-01 |  | 150.00 | t | 1 | t | 1 2020-01-02 |  | 100.00 | t | 1 | f | 1 2020-01-03 |  | 200.00 | t | 2 | f | 1 2020-01-06 |  | 50.00 | t | 2 | t | 2 2020-01-10 |  | 5.00 | t | 2 | t | 3 2020-01-01 |  | 100.00 | t | 1 | t | 1 2020-01-02 |  | 150.00 | t | 6 | f | 1 2020-01-08 |  | 200.00 | t | 1 | f | 1 

في هذه الخطوة ، نعلم بالفعل طول جميع الروابط في كل سلسلة ، لم نعد بحاجة إلى سجلات "غير مهمة" ، لذلك فقط قم بتصفيتها.

الخطوة 5: وضع سلاسل


لحساب متوسط ​​كل الأيام في السلسلة ، نحتاج إلى إجمالي عدد الأيام والرصيد "المتكامل":

 SELECT client , min(dt) chain_dt , sum(days * balance) balance , sum(days) days FROM ... GROUP BY 1, grpid ORDER BY 1, grpid; 

 client | chain_dt | balance | days -------------------------------------  | 2020-01-01 | 650.00 | 4  | 2020-01-06 | 100.00 | 2  | 2020-01-10 | 10.00 | 2  | 2020-01-01 | 1200.00 | 8 

الخطوة 6: البحث عن الارتفاعات التطبيقية


باستخدام ميزة DISTINCT ON ، سنترك سجلًا واحدًا (بحد أقصى قيمة الأيام) لكل عميل:

 SELECT DISTINCT ON(client) * FROM ... ORDER BY client, days DESC; 

 client | chain_dt | balance | days -------------------------------------  | 2020-01-01 | 650.00 | 4  | 2020-01-01 | 1200.00 | 8 

في الواقع ، هذا كل شيء ، كل ما تبقى هو ...

نحن الجمع بين وتحسين


ملخص الطلب
 WITH step123 AS ( SELECT * , CASE WHEN cond THEN lag(cond) OVER(w) IS DISTINCT FROM cond END chain_start , CASE WHEN cond THEN coalesce(lead(dt) OVER(w), '2020-01-12') - dt END days FROM tbl , LATERAL(SELECT balance > 0 cond) T WINDOW w AS (PARTITION BY client ORDER BY dt) ) , step4 AS ( SELECT * , count(*) FILTER(WHERE chain_start) OVER(PARTITION BY client ORDER BY dt) grpid FROM step123 WHERE cond ) SELECT DISTINCT ON(client) client , sum(days) OVER(w) days , min(dt) OVER(w) chain_dt , sum(days * balance) OVER(w) balance FROM step4 WINDOW w AS (PARTITION BY client, grpid) ORDER BY 1, 2 DESC; 

هنا قمنا بدمج وتحسين الخطوات الثلاث الأولى:

  • سمح لنا الاستعلام الفرعي LATERAL بحساب حقل إضافي دون المرور دون تحديد خلال استخدامه واستخدامه على الفور في الوظيفة
  • تساعد إزالة تعريف عام ضمن WINDOW PostgreSQL على عدم القيام بفرز مزدوج لتشكيل "نافذة" وحساب كلتا الوظيفتين في عقدة واحدة في WindowAgg
  • حساب "كسول" وظيفة تحت CASE يقلل من عدد العمليات المنفذة

وبالمثل ، قمنا بدمج الخطوتين التاليتين. لكن ترتيب "نافذة" حساب المجاميع (العميل ، grpid) والتمييز (client ، sum (days)) لم يتزامن ، لذلك لا يزال هناك عقدان من الفرز في الكتلة الأخيرة - قبل WindowAgg وقبل Unique.


[انظروا شرح.tensor.ru]

ألاحظ أنه عند سلاسل الترقيم ، يتم استيفاء الشرط "WHERE" أولاً ، وبالتالي فإن الأرقام الناتجة عن وظيفة النافذة تكون متسلسلة.

Source: https://habr.com/ru/post/ar483460/


All Articles