সরাসরি পর্বে যান
পর্ব ২৫ · ৬৭-এর মধ্যে · মডিউল ৪

CTE আর recursive query

Common Table Expressions — WITH, Multiple CTEs, Materialization, Data-Modifying CTEs and WITH RECURSIVE for Series, Hierarchies and Graphs

Taught in Bangla, this lesson covers common table expressions: breaking queries into named WITH steps, multiple and reused CTEs, CTEs versus views, derived tables and temp tables, MATERIALIZED hints, and WITH RECURSIVE with anchor and recursive members, the working table and termination, hierarchies such as category trees and bills of materials, graph paths and cycles, and guarding against infinite recursion, practised in live SQL editors.

এই পর্বে যা শিখবেন

  • WITH দিয়ে জটিল কোয়েরিকে নাম দেওয়া ধাপে লেখা, একাধিক CTE আর একটি CTE থেকে আরেকটি গড়া
  • CTE, view, derived table আর temporary table-এর পার্থক্য, আর materialization কী
  • Recursive CTE-র গঠন, working table দিয়ে এটি কীভাবে চলে, আর কেন থামে
  • সংখ্যা ও তারিখের ধারা, hierarchy, bill of materials আর graph-এ চক্র ঠেকিয়ে পথ খোঁজা
  • অসীম recursion-এর পাহারা, বিভিন্ন DBMS-এর নিয়ম, পারফরম্যান্স আর Oracle CONNECT BY-এর ইতিহাস

১ · CTE কী — কোয়েরিকে নাম দেওয়া ধাপে ভাঙা

মুদি দোকানের মাসিক হিসাব করতে বসে মালিক খাতার পাশে একটি কাগজে আগে লেখেন: “ধাপ ১ — বাতিল অর্ডার বাদ দিলাম। ধাপ ২ — প্রতিটি পণ্যের মোট বিক্রি। ধাপ ৩ — সেই বিক্রির গড়।” তারপর শেষ প্রশ্নের উত্তর দেন: কোন পণ্য গড়ের চেয়ে বেশি বিক্রি হয়েছে? প্রতিটি ধাপের একটি নাম আছে, আর পরের ধাপ আগের ধাপের নাম ধরে কথা বলে।

পর্ব ২১-এ (Subquery — nested, correlated আর EXISTS) একই ধরনের প্রশ্ন আপনি বন্ধনীর ভেতরে বন্ধনী দিয়ে লিখেছেন। ফল ঠিক আসে, কিন্তু পড়তে হয় ভেতর থেকে বাইরে, আর একই টুকরো দুবার লিখতে হয়। CTE (common table expression) ঠিক ওই কাগজের ধাপগুলোর মতো। WITH শব্দের পরে প্রতিটি ধাপকে একটি নাম দিয়ে লেখা হয়, আর শেষে মূল SELECT সেই নামগুলোকে টেবিলের মতো ব্যবহার করে।

WITH নাম1 AS (SELECT …),            -- ধাপ ১
     নাম2 AS (SELECT … FROM নাম1)    -- ধাপ ২: আগের ধাপ ব্যবহার করে
SELECT … FROM নাম2;                 -- মূল কোয়েরি

এই পর্বের লাইভ SQL চলে আগের পর্বগুলোর দুটি নমুনা ডেটাবেসে। db-shop একটি দোকান: customers (customer_id, name, city — ৮ জন), categories, products (product_id, name, category_id, price, stock — ১০টি পণ্য), orders (order_id, customer_id, order_date, status — সেপ্টেম্বর আর অক্টোবর ২০২৫-এর ১০টি অর্ডার, একটি cancelled) আর order_items (order_id, product_id, quantity, unit_price). db-uni একটি বিশ্ববিদ্যালয়; এই পর্বে মূলত লাগবে instructors (instructor_id, name, dept_id, salary, mentor_id) — mentor_id হলো নতুন শিক্ষককে দেখাশোনা করেন যে সিনিয়র শিক্ষক, তাঁর instructor_id. কিছু এডিটরে নিজস্ব ছোট টেবিল যোগ হয়, সেগুলোর বর্ণনা সেখানেই পাবেন। প্রতিবার চালালে ডেটাবেস নতুন করে তৈরি হয়।

তিন স্তরের subquery, আর তার CTE-রূপ

প্রশ্ন: বাতিল অর্ডার বাদ দিয়ে কোন পণ্যের মোট বিক্রি (revenue) সব বিক্রি-হওয়া পণ্যের গড় বিক্রির চেয়ে বেশি? পর্ব ২১-এর কৌশলে লিখলে দাঁড়ায় এমন:

SELECT name FROM products
WHERE product_id IN (
  SELECT product_id FROM (
    SELECT product_id, SUM(quantity * unit_price) AS revenue
    FROM order_items
    WHERE order_id IN (SELECT order_id FROM orders WHERE status <> 'cancelled')
    GROUP BY product_id) AS r
  WHERE r.revenue > (
    SELECT AVG(revenue) FROM (
      SELECT SUM(quantity * unit_price) AS revenue          -- একই টুকরো আবার
      FROM order_items
      WHERE order_id IN (SELECT order_id FROM orders WHERE status <> 'cancelled')
      GROUP BY product_id)));

এটি চলে, আর ফল দেয় Mobile Phone আর Smart Watch. কিন্তু “বাতিল বাদ দাও” আর “পণ্যভিত্তিক বিক্রি” দুবার করে লেখা। কাল কেউ “pending-ও বাদ দাও” বললে দুই জায়গাতেই বদলাতে হবে, আর একটিতে ভুলে গেলে গড় আর তুলনা দুই আলাদা ডেটা থেকে আসবে। CTE-রূপে একই প্রশ্ন তিনটি নাম দেওয়া ধাপ:

CTE আর তার ফললাল = এই ধাপে যা তৈরি হচ্ছে

            
অবস্থা
এই ধাপ
সারি
কাকে ব্যবহার করে
  1. গঠন। তিনটি CTE কমা দিয়ে আলাদা, একটিই WITH. শেষে একটি সাধারণ SELECT. পড়ার ক্রম আর চিন্তার ক্রম এখন একই: ওপর থেকে নিচে।
  2. valid_orders. শুধু বাতিল নয় এমন অর্ডার। ১০টির মধ্যে 1004 বাদ, ৯টি থাকে। “কোন অর্ডার গুনব” — এই নিয়ম এখন এক জায়গায় লেখা।
  3. revenue. valid_orders-এর অর্ডারগুলোর লাইন নিয়ে পণ্যভিত্তিক মোট বিক্রি। Saree (107) শুধু বাতিল অর্ডারে ছিল, Power Bank (103) কোথাও নেই, তাই ৮টি পণ্য।
  4. avg_rev. revenue-র ৮টি মানের গড়: 52320 ÷ ৮ = 6540. এটি একটি সারি আর একটি কলাম। revenue নামটি এখানে দ্বিতীয়বার ব্যবহার হলো, অথচ আবার লিখতে হয়নি।
  5. মূল SELECT. revenue-র প্রতিটি সারি 6540-এর সাথে মেলানো হয়। Mobile Phone (29500) আর Smart Watch (7000) টেকে। ফল nested কোয়েরির সমান — CTE অর্থ বদলায়নি, শুধু লেখার গঠন বদলেছে।
WITH valid_orders AS (
  SELECT order_id FROM orders WHERE status <> 'cancelled'
),
revenue AS (
  SELECT product_id, SUM(quantity * unit_price) AS revenue
  FROM order_items
  WHERE order_id IN (SELECT order_id FROM valid_orders)
  GROUP BY product_id
),
avg_rev AS (SELECT AVG(revenue) AS avg_revenue FROM revenue)
SELECT p.name, r.revenue, a.avg_revenue
FROM revenue r
JOIN products p ON p.product_id = r.product_id
CROSS JOIN avg_rev a
WHERE r.revenue > a.avg_revenue
ORDER BY r.revenue DESC;

ফল দুটি সারি: Mobile Phone | 29500 | 6540 আর Smart Watch | 7000 | 6540. avg_rev-এ ঠিক একটি সারি, তাই CROSS JOIN প্রতিটি revenue-সারির পাশে গড়টি বসিয়ে দেয় — সারি বাড়ে না। মূল SELECT-কে মুছে SELECT * FROM revenue; লিখে চালান: যেকোনো মাঝের ধাপ আলাদা করে দেখা যায়। ডিবাগ করার এই সুবিধাই CTE-র সবচেয়ে বড় ব্যবহারিক লাভ। খেয়াল করুন, 1009-এর Mobile Phone বিক্রি হয়েছে 14500-এ, তাই revenue 29500, 30000 নয় — unit_price অর্ডারের দিনের দাম।

২ · একাধিক CTE, একটির ওপর আরেকটি, আর একাধিকবার ব্যবহার

উপরের উদাহরণে তিনটি নিয়ম কাজ করেছে, আলাদা করে লিখে রাখুন:

  • একটি WITH, অনেক CTE: CTE-গুলো কমা দিয়ে আলাদা হয়। প্রতিটির আগে আবার WITH লেখা ভুল।
  • পরেরটি আগেরটিকে দেখে: revenue ব্যবহার করেছে valid_orders, avg_rev ব্যবহার করেছে revenue. উল্টোটা সাধারণ (non-recursive) CTE-তে চলে না — যে নাম এখনো লেখা হয়নি, তা ব্যবহার করা যায় না।
  • নামের আয়ু এক statement: শেষ সেমিকোলনের পর valid_orders আর নেই। পরের কোয়েরিতে আবার লাগলে আবার লিখতে হবে, নয়তো view বানাতে হবে (পর্ব ২৪)।

CTE-র কলামের নাম দুইভাবে দেওয়া যায়: ভেতরের SELECT-এর alias দিয়ে (AS revenue), অথবা নামের পাশে বন্ধনীতে তালিকা দিয়ে — WITH t(product_id, revenue) AS (…). Recursive CTE-তে দ্বিতীয় রূপটিই প্রচলিত, অংশ ৪-এ দেখবেন। আর একটি CTE যদি কোনো আসল টেবিলের নামে হয়, যেমন WITH orders AS (…), তবে সেই statement-এ orders মানে CTE-টি, আসল টেবিল নয়। এটি বৈধ, কিন্তু বিভ্রান্তিকর — এড়িয়ে চলুন।

পর্ব ২১-এ একটি কোয়েরি ঠিক ফল দিলেও পড়তে কষ্টকর ছিল: “যে অর্ডার নিজের গ্রাহকের গড় অর্ডারের চেয়ে বড়”, যেখানে অর্ডারের মোটের derived table দুবার লিখতে হয়েছিল। CTE-তে সেটি একবার লেখা হয়, আর ব্যবহার হয় দুবার — একবার বাইরে, একবার correlated subquery-র ভেতরে:

WITH order_totals AS (
  SELECT o.order_id, o.customer_id, SUM(i.quantity * i.unit_price) AS total
  FROM orders o JOIN order_items i ON i.order_id = o.order_id
  GROUP BY o.order_id, o.customer_id
)
SELECT order_id, customer_id, total
FROM order_totals ot
WHERE total > (SELECT AVG(total) FROM order_totals
               WHERE customer_id = ot.customer_id)
ORDER BY order_id;

ফল তিনটি সারি: 1001 | 1 | 17400, 1007 | 2 | 3250 আর 1009 | 1 | 18000 — পর্ব ২১-এর চার স্তরের কোয়েরির হুবহু একই ফল। Rahim (1)-এর তিনটি অর্ডারের গড় 12393.33; 1001 আর 1009 বড়, 1003 (1780) ছোট। Karima (2)-র গড় 2500; 1007 (3250) বড়। যাঁদের একটিই অর্ডার, তাঁদের অর্ডার নিজের গড়ের সমান, বড় নয়। একটি সূক্ষ্ম কথা: ভেতরের order_totals আর বাইরের ot একই CTE-র দুটি ব্যবহার, কিন্তু দুটি আলাদা “কপি” — ঠিক যেন একই টেবিলের দুটি alias (পর্ব ২০-এর self join).

CTE কোথায় বসে। SELECT ছাড়াও INSERT, UPDATE আর DELETE-এর আগে WITH বসানো যায় (SQLite, PostgreSQL আর SQL Server সবাই এটি চালায়; MySQL 8-এ UPDATE আর DELETE-এর আগে বসে, কিন্তু INSERT-এ WITH বসে ভেতরের SELECT-এর ঠিক আগে), যেমন WITH old AS (SELECT …) DELETE FROM t WHERE id IN (SELECT id FROM old);. আবার একটি subquery-র ভেতরেও নিজস্ব WITH থাকতে পারে। তবে প্রায় সবসময় পরিষ্কার উপায় হলো সব ধাপ statement-এর একদম শুরুতে রাখা।

৩ · CTE বনাম view, derived table আর temp table — আর materialization

চারটি জিনিসই “একটি কোয়েরির ফলকে টেবিলের মতো ব্যবহার” করতে দেয়। পার্থক্য হলো তারা কতক্ষণ বাঁচে, কোথায় সংরক্ষিত থাকে, আর কে ব্যবহার করতে পারে:

বৈশিষ্ট্যCTEView (পর্ব ২৪)Derived tableTemp table
কতক্ষণ বাঁচেএকটি statementস্থায়ী, DROP না করা পর্যন্তএকটি statement, এক জায়গায়সাধারণত connection বা session শেষ পর্যন্ত
কী সংরক্ষিতকিছুই নাশুধু সংজ্ঞা (materialized view-তে ডেটাও)কিছুই নাআসল ডেটা
একই কোয়েরিতে নাম ধরে বারবারহ্যাঁহ্যাঁনা, আবার লিখতে হয়হ্যাঁ, অনেক statement-এ
Recursionহ্যাঁ (WITH RECURSIVE)সংজ্ঞার ভেতরে recursive CTE রাখা যায়নানা (লুপ চালাতে হয় অ্যাপ বা procedure থেকে)
নিজস্ব indexনানা (materialized view-তে হ্যাঁ)নাহ্যাঁ
অন্য ব্যবহারকারীকে অনুমতিপ্রযোজ্য নয়হ্যাঁ, GRANT দিয়েপ্রযোজ্য নয়সাধারণত শুধু নিজের session
কখন বেছে নেবেনএকটি জটিল কোয়েরি পড়ার মতো করতেবহু কোয়েরি আর মানুষ একই সংজ্ঞা ব্যবহার করবেছোট, একবারের ধাপবড় মাঝের ফল, যার ওপর অনেক কোয়েরি চলবে

Materialize, নাকি inline?

DBMS একটি CTE দুইভাবে চালাতে পারে। এক, materialization: CTE-র পুরো ফল আগে একটি অস্থায়ী জায়গায় বানিয়ে রাখা, তারপর মূল কোয়েরি সেখান থেকে পড়ে। দুই, inlining: CTE-কে derived table-এর মতো মূল কোয়েরির ভেতরে বসিয়ে দেওয়া, যাতে optimizer বাইরের WHERE ভেতরে ঠেলে দিতে পারে আর index ব্যবহার করতে পারে। CTE বারবার ব্যবহার হলে ও হিসাবটি ভারী হলে প্রথমটি ভালো; একবার ব্যবহার হলে আর বাইরে কড়া ছাঁকনি থাকলে দ্বিতীয়টি।

PostgreSQL-এ ১২ সংস্করণের (২০১৯) আগে প্রতিটি CTE সবসময় materialize হতো, তাই CTE ছিল একটি optimization fence: বাইরের শর্ত ভেতরে ঢুকত না। ১২ থেকে নিয়ম বদলেছে — যে non-recursive CTE-র কোনো পার্শ্বপ্রতিক্রিয়া নেই আর মাত্র একবার ব্যবহার হয়, সেটি স্বাভাবিকভাবে inline হয়। একাধিকবার ব্যবহার হলে materialize হয়। নিজে ঠিক করে দিতে চাইলে AS MATERIALIZED বা AS NOT MATERIALIZED লেখা যায়। SQLite-ও ৩.৩৫ সংস্করণ (২০২১) থেকে এই দুটি শব্দ বোঝে। নিচে একই প্রশ্ন দুই ইঙ্গিতে চালিয়ে SQLite-এর পরিকল্পনা দেখুন:

-- MATERIALIZED: আগে পুরো CTE একটি অস্থায়ী টেবিলে
EXPLAIN QUERY PLAN
WITH t AS MATERIALIZED (SELECT * FROM orders WHERE status = 'delivered')
SELECT order_id FROM t WHERE customer_id = 2;

-- NOT MATERIALIZED: CTE বাইরের কোয়েরির সাথে মিশে যায়
EXPLAIN QUERY PLAN
WITH t AS NOT MATERIALIZED (SELECT * FROM orders WHERE status = 'delivered')
SELECT order_id FROM t WHERE customer_id = 2;

-- ফল দুই ক্ষেত্রেই একই
WITH t AS MATERIALIZED (SELECT * FROM orders WHERE status = 'delivered')
SELECT order_id FROM t WHERE customer_id = 2;

প্রথম পরিকল্পনার detail কলামে তিনটি লাইন: MATERIALIZE t, তার নিচে SCAN orders, তারপর SCAN t — আগে পাঁচটি delivered অর্ডার একটি অস্থায়ী টেবিলে লেখা হলো, তারপর সেটি থেকে customer_id = 2 খোঁজা হলো। দ্বিতীয় পরিকল্পনায় একটিই লাইন: SCAN orders — দুটি শর্ত একসাথে আসল টেবিলে, কোনো অস্থায়ী টেবিল নেই। তৃতীয় কোয়েরির ফল দুটি সারি: 1002 আর 1007. Materialization অর্থ বদলায় না, শুধু কাজের পথ বদলায়। (EXPLAIN QUERY PLAN-এর লেখা সংস্করণভেদে সামান্য আলাদা হতে পারে।)

-- বড় হিসাব একবারই হোক, যদিও তিনবার ব্যবহার হবে
WITH order_totals AS MATERIALIZED (
  SELECT order_id, SUM(quantity * unit_price) AS total
  FROM order_items GROUP BY order_id
)
SELECT … ;

-- উল্টো: একবার ব্যবহার, বাইরে index-যোগ্য শর্ত — inline হোক
WITH recent AS NOT MATERIALIZED (SELECT * FROM orders)
SELECT * FROM recent WHERE order_id = 1007;   -- primary key index ব্যবহার হবে

অন্যদের আচরণ আলাদা। SQL Server CTE-কে কখনো নিজে থেকে সংরক্ষণ করে না — প্রতিটি ব্যবহারে CTE-র সংজ্ঞাটি কোয়েরিতে বসিয়ে দেয়, তাই তিনবার ব্যবহার মানে তিনবার হিসাব হতে পারে; একবার হিসাব চাইলে temp table নিন। MySQL 8 সাধারণ derived table-এর মতোই CTE-কে মিশিয়ে দেয় বা materialize করে, আর একাধিকবার ব্যবহার হলে সাধারণত একবার materialize করে। তাই “CTE দ্রুত না ধীর” প্রশ্নের একটিই উত্তর নেই — উত্তরটি পাবেন নিজের DBMS-এর query plan-এ।

Data-modifying CTE — PostgreSQL-এর বিশেষ ক্ষমতা

PostgreSQL ৯.১ (২০১১) থেকে WITH-এর ভেতরে শুধু SELECT নয়, INSERT, UPDATE বা DELETE-ও বসানো যায়; তার বদলানো সারি পরের অংশে পড়তে চাইলে তাতে RETURNING লাগে। একে বলে data-modifying CTE. ধরুন মুদি পণ্যের (category 4) দাম ৫% বাড়বে, আর প্রতিটি পরিবর্তন একটি লগ টেবিলে লিখে রাখতে হবে — একটিই statement-এ:

CREATE TABLE price_log (product_id int, new_price numeric, changed_on date);

WITH changed AS (
  UPDATE products SET price = price * 1.05
  WHERE category_id = 4
  RETURNING product_id, price AS new_price      -- বদলানো সারিগুলো ফেরত দেয়
)
INSERT INTO price_log (product_id, new_price, changed_on)
SELECT product_id, new_price, CURRENT_DATE FROM changed;

এখানে তিনটি নিয়ম জানা জরুরি, PostgreSQL-এর ডকুমেন্টেশনেই লেখা। (১) WITH-এর সব অংশ আর মূল statement একই snapshot দেখে — একটি অংশের পরিবর্তন অন্য অংশ টেবিলে দেখে না, শুধু RETURNING-এর মাধ্যমে পায়। (২) Data-modifying অংশটি সবসময় একবার আর পুরোপুরি চলে, মূল কোয়েরি তার ফল পড়ুক বা না পড়ুক। (৩) একই statement-এ একই সারি দুবার বদলানোর চেষ্টা করলে কোন পরিবর্তন টিকবে তা নিশ্চিত নয় — এমন লেখা এড়িয়ে চলুন। পুরো কাজটি একটি statement বলে এটি নিজেই atomic: হয় দাম বদলাবে আর লগ হবে, নয়তো কিছুই না (পর্ব ৪৪)।

SQLite আর MySQL-এ CTE-র ভেতরটি অবশ্যই SELECT; সেখানে একই কাজ করতে একটি transaction-এর ভেতরে দুটি আলাদা statement লাগে, অথবা পর্ব ২৯-এর trigger. SQL Server-এ ভিন্ন একটি কৌশল প্রচলিত: সহজ CTE-র মাধ্যমে নিচের টেবিল বদলানো যায় — WITH d AS (SELECT …) DELETE FROM d WHERE … — যা duplicate মোছার কাজে বহুল ব্যবহৃত (পর্ব ২৭)।

৪ · Recursive CTE-র গঠন — anchor, recursive member আর UNION ALL

পর্ব ২০-এ (Self join, cross join, semi join আর anti join) দেখেছেন, mentor-এর mentor খুঁজতে দুটি self join লাগে, আর k স্তর ওপরে যেতে k-টি। গভীরতা আগে থেকে জানা না থাকলে self join দিয়ে কোয়েরি লেখাই যায় না। এবার সমাধান। একটি CTE যদি নিজের নাম নিজের ভেতরে ব্যবহার করে, তবে সেটি recursive CTE. তার দুটি অংশ, মাঝে UNION ALL:

WITH RECURSIVE nums(n) AS (
  SELECT 1                              -- anchor member: শুরুর সারি
  UNION ALL
  SELECT n + 1 FROM nums WHERE n < 10   -- recursive member: আগের সারি থেকে পরের
)
SELECT n FROM nums;
  • Anchor member: নিজের নাম ব্যবহার করে না। একবারই চলে, আর প্রথম সারিগুলো দেয়।
  • Recursive member: FROM-এ নিজের নাম (nums) ব্যবহার করে। কিন্তু প্রতিবার সে পুরো nums দেখে না — দেখে শুধু আগের ধাপে নতুন আসা সারিগুলো। পরের অংশে এটি ধাপে ধাপে দেখবেন।
  • থামার শর্ত: WHERE n < 10. recursive member যখন একটিও নতুন সারি দেয় না, recursion থামে।
  • RECURSIVE শব্দ: standard SQL, PostgreSQL আর MySQL-এ বাধ্যতামূলক। SQLite-এ ঐচ্ছিক, আর SQL Server ও Oracle-এ এই শব্দটি লেখাই যায় না — সেখানে শুধু WITH.

এই কাঠামো দিয়ে সংখ্যার ধারা, আর একটু বুদ্ধি খাটালে factorial আর Fibonacci-ও বানানো যায়। প্রতিটি সারি আগের সারির মান বহন করে, ঠিক লুপের ভেতরের চলকের মতো:

-- ১ থেকে ১০
WITH RECURSIVE nums(n) AS (
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM nums WHERE n < 10
)
SELECT n FROM nums;

-- একই লুপে factorial আর Fibonacci: প্রতিটি সারি আগের মানগুলো বয়ে আনে
WITH RECURSIVE f(n, fact, fib, fib_next) AS (
  SELECT 1, 1, 1, 1
  UNION ALL
  SELECT n + 1, fact * (n + 1), fib_next, fib + fib_next FROM f WHERE n < 10
)
SELECT n, fact, fib FROM f;

প্রথম ফলে ১০টি সারি: 1, 2, … 10. দ্বিতীয় ফলেও ১০টি সারি, শেষটি 10 | 3628800 | 55. কয়েকটি মাঝের সারি: 5 | 120 | 5, 7 | 5040 | 13. প্রতিটি ধাপে fact নিজেকে (n + 1) দিয়ে গুণ করে, আর fib_next পরের ধাপের fib হয়ে যায় — দুটি চলক একসাথে সরে, যেমন C বা Python-এর লুপে a, b = b, a + b. থামার শর্তটি পরীক্ষা হয় আগের সারির ওপর: n = 9-এর সারি থেকে n = 10 তৈরি হয়, কারণ 9 < 10; n = 10-এর সারি থেকে আর কিছু হয় না।

PostgreSQL-এ সংখ্যা বা তারিখের ধারার জন্য তৈরি ফাংশন আছে — generate_series(1, 10). SQLite-এ generate_series একটি extension হিসেবে আসে, যা সব build-এ থাকে না; তাই সব জায়গায় চলবে এমন উপায় হলো recursive CTE.

তারিখের ধারা — অক্টোবরের কোন দিন অর্ডার আসেনি

পর্ব ১৮-এ দেখেছেন, GROUP BY শুধু ডেটায় যা আছে তা থেকে দল বানায়। যে দিন একটিও অর্ডার আসেনি, সেই দিনের সারিই তৈরি হয় না — দৈনিক বিক্রির চার্টে দিনটি নিঃশব্দে হারিয়ে যায়। সমাধান: আগে মাসের প্রতিটি দিনের একটি তালিকা বানান, তারপর তার সাথে orders-এর LEFT JOIN. বড় সিস্টেমে এর জন্য একটি স্থায়ী calendar table রাখা হয়; একবারের কাজে recursive CTE যথেষ্ট:

WITH RECURSIVE october(day) AS (
  SELECT '2025-10-01'
  UNION ALL
  SELECT date(day, '+1 day') FROM october WHERE day < '2025-10-31'
)
SELECT d.day,
       substr('SunMonTueWedThuFriSat', 1 + 3 * strftime('%w', d.day), 3) AS wd,
       COUNT(o.order_id) AS n_orders
FROM october d
LEFT JOIN orders o ON o.order_date = d.day
GROUP BY d.day
ORDER BY d.day;

ফলে ঠিক ৩১টি সারি, 2025-10-01 থেকে 2025-10-31. প্রথম সারি 2025-10-01 | Wed | 1 (অর্ডার 1007), তারপর 2025-10-02 | Thu | 0. চারটি দিনে 1: ১, ৫, ৮ আর ৯ অক্টোবর; বাকি ২৭ দিনে 0. COUNT(o.order_id) লেখা জরুরি — COUNT(*) লিখলে LEFT JOIN-এর NULL-ভরা সারিকেও ১ গুনত, আর প্রতিটি ফাঁকা দিন দেখাত 1. তারিখগুলো ISO লেখা ('YYYY-MM-DD'), তাই day < '2025-10-31' লেখা-তুলনাতেও সঠিক ক্রম দেয় (পর্ব ১৭)। strftime('%w', …) সপ্তাহের দিন দেয় 0 (রবিবার) থেকে 6, আর substr সেটিকে তিন অক্ষরের নামে বদলায়। ঈদের ছুটির মতো দিন চিহ্নিত করতে চাইলে এই তালিকাতেই আরেকটি কলাম যোগ করা যায়।

SELECT d::date AS day, COUNT(o.order_id) AS n_orders
FROM generate_series(date '2025-10-01', date '2025-10-31', interval '1 day') AS d
LEFT JOIN orders o ON o.order_date = d::date
GROUP BY d ORDER BY d;

৫ · ভেতরে কী ঘটে — working table আর থামার নিয়ম

Recursive CTE-কে “নিজেকে ডাকা ফাংশন” ভাবলে ভুল হয়। আসলে এটি একটি লুপ, যা দুটি টেবিল রাখে: ফল (এ পর্যন্ত যত সারি এসেছে) আর working table (শুধু শেষ ধাপে নতুন আসা সারি)। নিয়মটি:

ফল    ← anchor চালাও
W     ← ফল                                  -- working table
যতক্ষণ W খালি নয়:
    নতুন ← recursive member চালাও, যেখানে নিজের নাম = W
    (UNION হলে: নতুন থেকে আগে-আসা সারি বাদ দাও)
    ফল   ← ফল ∪ নতুন
    W    ← নতুন
মূল SELECT চলে ফল-এর ওপর

এবার db-uni-র ছয়জন শিক্ষক নিয়ে লুপটি চালিয়ে দেখি। প্রশ্ন: প্রত্যেক শিক্ষক mentor-শৃঙ্খলের কোন স্তরে? যাঁর mentor নেই, তিনি স্তর ১. মনে রাখুন: Shahana-র mentor 1 (Anisur), Rumana-র 3 (Mahbub), Tasnim-এর 2 (Shahana).

instructors আর working tableলাল = এই ধাপে নতুন সারি

            
লুপের অবস্থা
ধাপ
working table (W)
এই ধাপে নতুন
ফলে মোট সারি
  1. ডেটা আর নিয়ম। anchor বেছে নেবে শৃঙ্খলের মাথাগুলো (mentor_id NULL). recursive member প্রতিবার instructors-কে working table-এর সাথে join করবে: যাঁর mentor W-তে আছেন, তিনি পরের স্তরে।
  2. Anchor. তিনজনের mentor নেই: Anisur, Mahbub, Kamrul — সবাই level 1. এই তিনটি সারি ফলে যায়, আর এরাই প্রথম working table.
  3. ধাপ ১. W = {1, 3, 5}. Shahana-র mentor 1, Rumana-র 3 — দুজনেই level 2. Tasnim-এর mentor 2 এখনো W-তে নেই, তাই এই ধাপে তিনি আসেন না। নতুন W = {2, 4}; পুরোনো তিনজন আর W-তে নেই।
  4. ধাপ ২. W = {2, 4}. Tasnim-এর mentor 2 — level 3. Rumana কারও mentor নন। নতুন W = {6}.
  5. ধাপ ৩. W = {6}. কারও mentor_id 6 নয়, তাই recursive member ০টি সারি দেয়। Working table খালি — লুপ থামে। থামার জন্য আলাদা কোনো WHERE লাগেনি: ডেটা ফুরিয়ে গেছে।
  6. ফল। মোট ৬টি সারি — ছয়জন শিক্ষকের প্রত্যেকে একবার, সঠিক স্তরসহ। ডেটায় স্তর ১০টি হলেও একই কোয়েরি কাজ করত; শুধু লুপ ১০ বার ঘুরত।

কেন থামে — একটি ছোট প্রমাণ

ধরুন mentor-সম্পর্কে কোনো চক্র নেই (কেউ নিজের পূর্বসূরির mentor নন), আর শিক্ষক মোট n জন। ধাপ k-এর working table-এর প্রতিটি সারি একটি শৃঙ্খল-পথের শেষ মাথা, যে পথে ঠিক k + 1 জন আলাদা মানুষ — আলাদা, কারণ কোনো চক্র নেই। n জনের বেশি আলাদা মানুষ নেই, তাই k + 1 ≤ n, অর্থাৎ ধাপ n - 1-এর পরে working table অবশ্যই খালি। লুপ সর্বোচ্চ n বার ঘোরে। চক্র থাকলে এই যুক্তি ভাঙে — পথ অসীম লম্বা হতে পারে — আর তখনই অংশ ৭ আর ৮-এর পাহারা লাগে।

তত্ত্বের ভাষায়, ফলটি একটি fixpoint: এমন একটি টেবিল R, যার ওপর “anchor ∪ recursive(R)” চালালে আবার R-ই পাওয়া যায়। প্রতিটি ধাপে ফল শুধু বাড়ে, কমে না (monotone), আর এমন প্রক্রিয়া সবচেয়ে ছোট fixpoint-এ গিয়ে থামে। এই কারণেই standard SQL recursive member-এ aggregate, window function বা NOT EXISTS-এর মতো “অ-monotone” জিনিস সীমিত করে — SQLite-এ recursive অংশে aggregate দিলে সরাসরি ত্রুটি আসে: recursive aggregate queries not supported. আর recursive member-এ নিজের নাম একবারই থাকতে পারে (linear recursion); দুবার দিলে SQLite বলে multiple references to recursive table.

৬ · Hierarchy — mentor-শৃঙ্খল, category tree আর bill of materials

mentor_id-এর মতো “নিজের টেবিলের দিকে আঙুল” দিয়ে গাছ রাখাকে বলে adjacency list. অফিসের boss–কর্মী (org chart), জেলা–উপজেলা–ইউনিয়ন, ফেসবুক মন্তব্যের উত্তর — সবই এমন hierarchical data. Recursive CTE দিয়ে এগুলোর ওপর তিনটি প্রশ্ন সবচেয়ে বেশি আসে: কে কোন স্তরে, ওপর থেকে নিচ পর্যন্ত পথ কী, আর একটি শাখার নিচে কারা কারা।

Mentor-শৃঙ্খল, পথসহ

নিচের কোয়েরিতে দুটি CTE একটিই WITH RECURSIVE-এর নিচে: প্রথমটি (short) সাধারণ — নাম থেকে “Dr. ” বাদ দিয়ে প্রথম শব্দটি নেয়; দ্বিতীয়টি (chain) recursive, আর প্রথমটিকে ব্যবহার করে। WITH RECURSIVE লিখলে তার নিচের সব CTE-ই নিজেকে ডাকতে পারে, কিন্তু ডাকতে বাধ্য নয়:

WITH RECURSIVE
short AS (
  SELECT instructor_id, mentor_id,
         substr(replace(name, 'Dr. ', ''), 1,
                instr(replace(name, 'Dr. ', '') || ' ', ' ') - 1) AS nick
  FROM instructors
),
chain(id, level, path) AS (
  SELECT instructor_id, 1, nick FROM short WHERE mentor_id IS NULL
  UNION ALL
  SELECT s.instructor_id, c.level + 1, c.path || ' > ' || s.nick
  FROM short s JOIN chain c ON s.mentor_id = c.id
)
SELECT level, path FROM chain ORDER BY path;

ফলে ৬টি সারি: 1 | Anisur, 2 | Anisur > Shahana, 3 | Anisur > Shahana > Tasnim, 1 | Kamrul, 1 | Mahbub, 2 | Mahbub > Rumana. path কলামটি প্রতিটি ধাপে আগের পথের শেষে একটি নাম জোড়ে — তাই path-এ সাজালে প্রত্যেক শিষ্য তাঁর mentor-এর ঠিক নিচে আসেন, যেন একটি গাছ। recursive member-এর ভেতরে আসলে একটি সাধারণ self join: short s JOIN chain c, কিন্তু chain-এর জায়গায় প্রতিবার শুধু আগের ধাপের নতুন সারি। উল্টো দিকে, অর্থাৎ Tasnim থেকে ওপরে সব পূর্বসূরি খুঁজতে join-এর শর্ত উল্টে দিন — অনুশীলন ১-এ দেখবেন।

Category tree — গাছের মতো সাজানো আর একটি শাখার সব সদস্য

অনলাইন দোকানে শ্রেণির ভেতরে উপশ্রেণি থাকে। এই এডিটরে একটি নতুন টেবিল cat_tree (id, name, parent_id) যোগ হয়: Electronics-এর নিচে Mobile আর Accessories, Accessories-এর নিচে Charger আর Headphone; Books-এর নিচে Academic আর Novel, Academic-এর নিচে Programming.

-- পুরো গাছ, স্তর অনুযায়ী ডানে সরানো
WITH RECURSIVE tree(id, name, depth, sort_key) AS (
  SELECT id, name, 0, name FROM cat_tree WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, t.depth + 1, t.sort_key || '/' || c.name
  FROM cat_tree c JOIN tree t ON c.parent_id = t.id
)
SELECT substr('............', 1, 3 * depth) || name AS category, depth
FROM tree ORDER BY sort_key;

-- Accessories (3)-এর নিচে সবাই, নিজেসহ
WITH RECURSIVE sub(id) AS (
  SELECT 3
  UNION ALL
  SELECT c.id FROM cat_tree c JOIN sub s ON c.parent_id = s.id
)
SELECT c.id, c.name FROM cat_tree c WHERE c.id IN (SELECT id FROM sub);

প্রথম ফলে ৯টি সারি, গাছের মতো: Books | 0, ...Academic | 1, ......Programming | 2, ...Novel | 1, Electronics | 0, ...Accessories | 1, ......Charger | 2, ......Headphone | 2, ...Mobile | 1. প্রতিটি স্তরে তিনটি বিন্দু; sort_key ('Electronics/Accessories/Charger') দিয়ে সাজানোয় প্রত্যেক সন্তান তার অভিভাবকের ঠিক নিচে। দ্বিতীয় ফলে ৩টি সারি: 3 | Accessories, 4 | Charger, 5 | Headphone. অনলাইন দোকানে “Accessories-এর সব পণ্য দেখাও” চাপলে ঠিক এই কোয়েরির পরে products-এর সাথে join হয় — উপশ্রেণির পণ্যও আসে, যত গভীরেই থাকুক।

Bill of materials — একটি রিকশায় কয়টি spoke?

কারখানায় একটি পণ্য তৈরি হয় অংশ দিয়ে, অংশ তৈরি হয় আরও ছোট অংশ দিয়ে। এই তালিকাকে বলে bill of materials (BOM). এখানে গাছের প্রতিটি ডালে একটি সংখ্যাও আছে, আর গভীরে যেতে যেতে সংখ্যাগুলো গুণ হয়। এডিটরে দুটি টেবিল: parts (part) আর bom (parent, child, qty). ধরা যাক একটি রিকশায় ৩টি চাকা, ১টি সিট আর ১টি চেইন; প্রতিটি চাকায় ৩৬টি spoke, ১টি টায়ার আর ১টি rim; সিটে ৪টি spring (সংখ্যাগুলো উদাহরণের জন্য):

WITH RECURSIVE need(part, qty, level) AS (
  SELECT 'Rickshaw', 1, 0
  UNION ALL
  SELECT b.child, n.qty * b.qty, n.level + 1      -- গভীরে গেলে গুণ
  FROM bom b JOIN need n ON b.parent = n.part
)
SELECT part, SUM(qty) AS total_qty
FROM need
WHERE part NOT IN (SELECT parent FROM bom)        -- শুধু একেবারে নিচের অংশ
GROUP BY part ORDER BY part;

ফলে ৫টি সারি: Chain | 1, Rim | 3, Spoke | 108, Spring | 4, Tyre | 3. Spoke-এর হিসাব: রিকশা থেকে Wheel-এ qty = 1 × 3 = 3, তারপর Spoke-এ 3 × 36 = 108. aggregate (SUM) recursive অংশের বাইরে, মূল SELECT-এ — recursive member-এর ভেতরে সেটি নিষেধ, কিন্তু ফল তৈরি হওয়ার পরে যত খুশি। একই অংশ দুই শাখায় থাকলে (যেমন একই স্ক্রু চাকা আর সিট দুই জায়গায়) SUM দুটিকে যোগ করে দেয় — এজন্যই এখানে UNION ALL; UNION দিলে একই (part, qty, level) সারি দুই শাখা থেকে এলে একটি মুছে যেত, আর হিসাব কমে যেত। bom.parent-এ NULL নেই, তাই NOT IN এখানে নিরাপদ (পর্ব ২১)।

৭ · Graph — রাস্তা, চক্র আর সবচেয়ে কম ধাপের পথ

গাছে প্রত্যেকের একজনই অভিভাবক, তাই কোনো চক্র নেই। রাস্তার মানচিত্র তেমন নয়: ঢাকা থেকে কুমিল্লা যাওয়া যায়, কুমিল্লা থেকে ঢাকাও। এখানে কাজ করতে হয় graph নিয়ে। এডিটরের টেবিল roads (src, dst) একটি সরলীকৃত মানচিত্র — বাস্তবের সব শহর বা দূরত্ব নয়: Dhaka–Cumilla, Dhaka–Sylhet, Cumilla–Sylhet, Cumilla–Feni আর Feni–Chattogram. প্রতিটি রাস্তা দুই দিকে চলে, তাই টেবিলে ১০টি সারি। Dhaka–Cumilla–Sylhet একটি ত্রিভুজ — অর্থাৎ চক্র।

Recursive member যদি শুধু “এই শহর থেকে পাশের শহরে যাও” বলে, তবে Dhaka → Cumilla → Dhaka → Cumilla … কখনো শেষ হবে না। পাহারা হলো প্রতিটি সারিতে এ পর্যন্ত যাওয়া শহরগুলোর একটি পথ-স্ট্রিং রাখা, যেমন /Dhaka/Cumilla/, আর পরের শহরটি সেই স্ট্রিংয়ে থাকলে আর না যাওয়া। এটি cycle detection-এর সবচেয়ে প্রচলিত SQL কৌশল। শহরের নামের দুই পাশে “/” রাখার কারণ: শুধু নাম খুঁজলে একটি নাম আরেকটির অংশ হলে (ধরুন “Feni” আর “Fenix”) ভুল মিল হতো।

নতুন সারি (city, hops, path)✗ = শহরটি এই পথে আগেই এসেছে

            
লুপের অবস্থা
ধাপ
এই ধাপে নতুন সারি
ফলে মোট সারি
চক্রের কারণে বাদ
  1. মানচিত্র। পাঁচটি শহর, পাঁচটি দুই-দিকের রাস্তা। Dhaka, Cumilla আর Sylhet একটি ত্রিভুজ বানায় — পাহারা না থাকলে এখানে অনন্তকাল ঘোরা যায়।
  2. Anchor. যাত্রা শুরু Dhaka থেকে: hops 0, path /Dhaka/.
  3. ধাপ ১. Dhaka-র দুটি রাস্তা: Cumilla আর Sylhet. কোনোটিই path-এ নেই, তাই দুটি নতুন সারি, hops 1.
  4. ধাপ ২. Cumilla থেকে Dhaka-য় ফেরা বাদ (path-এ আছে); Sylhet আর Feni নতুন। Sylhet থেকে Dhaka বাদ, Cumilla নতুন। খেয়াল করুন, Cumilla দুটি আলাদা পথে আসছে — প্রতিটি সারি নিজের পথ মনে রাখে, পুরো ফলের নয়।
  5. ধাপ ৩. /Dhaka/Cumilla/Sylhet/ থেকে দুটি পথই বন্ধ। Feni থেকে Chattogram — hops 3. /Dhaka/Sylhet/Cumilla/ থেকে শুধু Feni নতুন।
  6. ধাপ ৪. Chattogram থেকে Feni-তে ফেরা বাদ। ঘুরপথের Feni থেকে Chattogram — hops 4, একটি দীর্ঘতর পথ।
  7. ধাপ ৫. শেষ সারিটির একমাত্র রাস্তা Feni, যা path-এ আছে। নতুন সারি ০টি — লুপ থামে। মোট ৯টি সারি, অর্থাৎ Dhaka থেকে শুরু করে চক্রহীন ৯টি পথ।
  8. সবচেয়ে কম hop. প্রতিটি শহরের সারিগুলোর মধ্যে MIN(hops) নিলেই shortest path (ধাপের হিসাবে): Chattogram ৩ ধাপে, পথ Dhaka → Cumilla → Feni → Chattogram.
WITH RECURSIVE trip(city, hops, path) AS (
  SELECT 'Dhaka', 0, '/Dhaka/'
  UNION ALL
  SELECT r.dst, t.hops + 1, t.path || r.dst || '/'
  FROM trip t JOIN roads r ON r.src = t.city
  WHERE instr(t.path, '/' || r.dst || '/') = 0     -- আগে যাওয়া শহরে নয়
)
SELECT city, MIN(hops) AS min_hops, COUNT(*) AS n_paths
FROM trip GROUP BY city ORDER BY min_hops, city;

-- Dhaka থেকে Chattogram-এর সবচেয়ে কম ধাপের পথ: মূল SELECT বদলে দেখুন
-- SELECT path, hops FROM trip WHERE city = 'Chattogram' ORDER BY hops LIMIT 1;

ফলে ৫টি সারি: Dhaka | 0 | 1, Cumilla | 1 | 2, Sylhet | 1 | 2, Feni | 2 | 2, Chattogram | 3 | 2. n_paths দেখায় প্রতিটি শহরে চক্রহীন কয়টি পথ আছে — ঠিক স্টেপারের ৯টি সারি। মন্তব্য করা শেষ লাইনটি দিয়ে মূল SELECT বদলালে পাবেন /Dhaka/Cumilla/Feni/Chattogram/ | 3. instr-এর বদলে t.path NOT LIKE '%/' || r.dst || '/%' লিখলেও একই কাজ হয়; PostgreSQL-এ পথটি একটি array রেখে r.dst <> ALL(t.path) লেখা আরও পরিচ্ছন্ন। দূরত্বের (কিলোমিটার) হিসাবে সবচেয়ে ছোট পথ চাইলে hops-এর জায়গায় দূরত্ব যোগ করে MIN নিন — তবে সব পথ তৈরি করে তারপর বাছাই করা, তাই বড় graph-এ এটি Dijkstra-র মতো দক্ষ নয়।

৮ · অসীম recursion থেকে বাঁচা — UNION, depth আর LIMIT

Visited path ছাড়াও তিনটি পাহারা আছে, প্রতিটির নিজের সুবিধা আর সীমা। নিচের এডিটরে একই roads টেবিলে তিনটিই চালিয়ে দেখুন। সতর্কতা: এই এডিটরগুলোর থামার শর্ত মুছে চালালে কোয়েরি কখনো শেষ হবে না, আর ব্রাউজারের ট্যাবটি আটকে যেতে পারে — SQLite নিজে থেকে recursion থামায় না।

-- ১. UNION: হুবহু একই সারি আবার এলে বাদ — শুধু শহর রাখলে চক্র থামে
WITH RECURSIVE reach(city) AS (
  SELECT 'Dhaka'
  UNION
  SELECT r.dst FROM reach JOIN roads r ON r.src = reach.city
)
SELECT city FROM reach;

-- ২. depth কলাম: পাহারা নেই, শুধু ৬ ধাপে থামা — সারি কত দ্রুত বাড়ে?
WITH RECURSIVE walk(city, hops) AS (
  SELECT 'Dhaka', 0
  UNION ALL
  SELECT r.dst, w.hops + 1 FROM walk w JOIN roads r ON r.src = w.city
  WHERE w.hops < 6
)
SELECT hops, COUNT(*) AS n_rows FROM walk GROUP BY hops;

-- ৩. LIMIT: থামার শর্তই নেই, কিন্তু ৫টি সারির পর থামে (SQLite)
WITH RECURSIVE forever(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM forever LIMIT 5)
SELECT n FROM forever;

প্রথম ফলে ৫টি শহর: Dhaka, Cumilla, Sylhet, Feni, Chattogram — transitive closure, অর্থাৎ যেখানে যেকোনো পথে পৌঁছানো যায়। UNION আগে-আসা সারি ফেলে দেয়, শহর মাত্র ৫টি, তাই working table একসময় খালি হবেই। কিন্তু সারিতে hops বা path যোগ করলেই প্রতিটি সারি “নতুন” হয়ে যায়, আর UNION আর রক্ষা করে না — hops < 50 দিয়ে চালালে UNION থাকা সত্ত্বেও ফলে ২৪৭টি সারি আসে। দ্বিতীয় ফল: hops 0 থেকে 6-এ সারি 1, 2, 5, 11, 25, 55, 123 — প্রায় প্রতি ধাপে দ্বিগুণের বেশি, কারণ প্রতিটি শহর থেকে গড়ে দুটি রাস্তা, আর ফিরে আসা পথও গোনা হচ্ছে। depth সীমা কোয়েরিকে থামায়, কিন্তু অপ্রয়োজনীয় সারির বিস্ফোরণ থামায় না। তৃতীয় ফল: 1 থেকে 5. SQLite-এ recursive অংশের LIMIT মোট সারির সংখ্যা সীমিত করে — ডিবাগ করার সময় নিরাপত্তা-জাল হিসেবে ভালো, কিন্তু কোন ৫টি সারি আসবে তা ডেটার ক্রমের ওপর নির্ভর করে।

DBMS-ভেদে নিয়ম

DBMSলেখার নিয়মনিজস্ব পাহারা
SQLite (৩.৮.৩, ২০১৪ থেকে)RECURSIVE ঐচ্ছিক; recursive অংশে LIMIT, এমনকি ORDER BY চলেকোনো সীমা নেই
PostgreSQL (৮.৪, ২০০৯ থেকে)RECURSIVE বাধ্যতামূলক; ১৪ থেকে CYCLE আর SEARCH clauseকোনো সীমা নেই; statement_timeout দিয়ে সময় বেঁধে দিন
MySQL (৮.০ থেকে)RECURSIVE বাধ্যতামূলক; ৮.০.১৯ থেকে recursive অংশে LIMITcte_max_recursion_depth, ডিফল্ট ১০০০ ধাপ
SQL Server (২০০৫ থেকে)RECURSIVE লেখা যায় না; UNION ALL বাধ্যতামূলকMAXRECURSION, ডিফল্ট ১০০; ০ মানে সীমাহীন
Oracle (১১g R2, ২০০৯ থেকে)RECURSIVE লেখা হয় না, কিন্তু CTE-র নামের পাশে কলামের তালিকা বাধ্যতামূলক; পুরোনো CONNECT BY-ও আছেচক্র ধরলে ত্রুটি দেয়; CYCLE clause
WITH RECURSIVE trip(city, hops) AS (
  SELECT 'Dhaka', 0
  UNION ALL
  SELECT r.dst, t.hops + 1 FROM trip t JOIN roads r ON r.src = t.city
) CYCLE city SET is_cycle USING path      -- path কলাম PostgreSQL নিজে বানায়
SELECT city, hops, path FROM trip WHERE NOT is_cycle;

CYCLE clause হাতে লেখা visited path-এর কাজটি নিজে করে: যে সারি একটি আগে-আসা city-তে ফিরে আসে, সেটিকে is_cycle = true চিহ্ন দেয় আর সেখান থেকে আর এগোয় না। চিহ্নিত সারিটি ফলে থাকে, তাই WHERE NOT is_cycle দিয়ে বাদ দিতে হয়। SEARCH BREADTH FIRST BY city SET ord লিখলে ফলকে স্তর-ক্রমে (BFS) সাজানোর একটি কলামও পাওয়া যায়।

SET SESSION cte_max_recursion_depth = 5000;   -- ডিফল্ট 1000

WITH RECURSIVE chain (id, path) AS (
  SELECT instructor_id, CAST(name AS CHAR(500))  -- anchor-এর type-ই পুরো কলামের type
  FROM instructors WHERE mentor_id IS NULL
  UNION ALL
  SELECT i.instructor_id, CONCAT(c.path, ' > ', i.name)
  FROM instructors i JOIN chain c ON i.mentor_id = c.id
)
SELECT * FROM chain;
-- ডিফল্ট সীমায় (1000) পেরোলে: Recursive query aborted after 1001 iterations ...

MySQL-এ CTE-র প্রতিটি কলামের type ঠিক হয় শুধু anchor দেখে। CAST না দিলে path কলামের type হয় name কলামের type (ধরুন VARCHAR(100)), তাই কয়েকটি নাম জুড়ে পথ লম্বা হলে strict mode-এ ত্রুটি আসে (Data too long for column). PostgreSQL-এ একই কারণে anchor আর recursive অংশের type না মিললে ত্রুটি দেয়; সমাধান name::text.

WITH chain (id, lvl) AS (               -- RECURSIVE শব্দ নেই
  SELECT instructor_id, 1 FROM instructors WHERE mentor_id IS NULL
  UNION ALL
  SELECT i.instructor_id, c.lvl + 1
  FROM instructors i JOIN chain c ON i.mentor_id = c.id
)
SELECT * FROM chain
OPTION (MAXRECURSION 200);              -- ডিফল্ট 100, 0 = সীমাহীন

SQL Server ১০০ স্তরের পরে কোয়েরিটি ত্রুটি দিয়ে থামিয়ে দেয়। এটি ডেটার ভুলে তৈরি চক্র ধরার ভালো পাহারা, কিন্তু গভীর ডেটায় (যেমন ১০০-র বেশি দিনের তারিখের ধারা) সীমাটি বাড়াতে হয়।

৯ · পারফরম্যান্স আর ইতিহাস

Recursive CTE-র খরচ বুঝতে লুপটির কথা ভাবুন। প্রতিটি ধাপে working table-এর সারিগুলো মূল টেবিলের সাথে join হয়। hierarchy-তে মোট কাজ মোটামুটি ধাপের সংখ্যা × প্রতি ধাপের join-এর খরচ। join-এর কলামে (mentor_id, parent_id, roads.src) index থাকলে প্রতিটি সারির সন্তান খুঁজতে পুরো টেবিল পড়তে হয় না — B+ tree-তে O(log n) খোঁজ, তারপর মিল-পাওয়া সারিগুলো (পর্ব ৪০)। index না থাকলে প্রতিটি ধাপ একটি পুরো scan, আর d স্তরের গাছে খরচ d × n. foreign key কলামে অনেক DBMS নিজে থেকে index বানায় না (PostgreSQL আর SQLite বানায় না), তাই hierarchy-র কলামে index নিজে দিন।

  • পথ-স্ট্রিং বাড়ে: d গভীরতার প্রতিটি সারিতে path-এর দৈর্ঘ্য d-এর সমানুপাতিক, তাই n সারির ফলে মেমরি O(n × d).
  • Graph-এ পথের বিস্ফোরণ: visited path চক্র ঠেকায়, কিন্তু প্রতিটি সারি শুধু নিজের পথ মনে রাখে। ঘন graph-এ চক্রহীন পথের সংখ্যা শহরের সংখ্যার সাথে exponential হারে বাড়তে পারে। শুধু “কোথায় পৌঁছানো যায়” চাইলে UNION আর শুধু city কলাম রাখুন।
  • বারবার একই প্রশ্ন: প্রতিদিন হাজারবার “এই শ্রেণির সব উপশ্রেণি” লাগলে প্রতিবার recursion না চালিয়ে closure table (প্রতিটি পূর্বসূরি–উত্তরসূরি জোড়ার একটি সারি), materialized path (যেমন '1/3/4', PostgreSQL-এর ltree extension) বা nested set মডেল ভাবুন — পড়া দ্রুত, লেখা জটিল (পর্ব ৩৮-এর denormalization-এর মতো দর-কষাকষি)।

১৯৭৯ সালে Alfred Aho আর Jeffrey Ullman প্রমাণ করেন, Codd-এর relational algebra দিয়ে transitive closure — “কে কার পূর্বসূরি, যত স্তরই হোক” — লেখা যায় না; প্রতিটি কোয়েরি নির্দিষ্ট সংখ্যক join-এর বেশি যেতে পারে না। তাঁরা একটি fixpoint অপারেটর যোগ করার প্রস্তাব দেন, আর পরের দশকে Datalog ভাষার গবেষণায় এই ধারণা কেন্দ্রীয় হয়ে ওঠে। ব্যবহারিক দিকে Oracle নিজস্ব সমাধান আনে: CONNECT BY, যা SQL standard-এ recursion আসার বহু আগে থেকে Oracle-এ আছে। SQL:1999 মানে অবশেষে WITH আর WITH RECURSIVE যুক্ত হয়। তারপর এক এক করে SQL Server ২০০৫, PostgreSQL ৮.৪ (২০০৯), Oracle ১১g R2 (২০০৯), SQLite ৩.৮.৩ (২০১৪) আর MySQL ৮.০ (২০১৮) এটি চালু করে।

SELECT LEVEL, name,
       SYS_CONNECT_BY_PATH(name, ' > ') AS path   -- ' > Dr. Anisur Rahman > Shahana Parvin'
FROM instructors
START WITH mentor_id IS NULL                      -- anchor
CONNECT BY PRIOR instructor_id = mentor_id        -- recursive member-এর join
ORDER SIBLINGS BY name;

CONNECT BY-তে LEVEL হলো স্তর, PRIOR মানে “আগের স্তরের সারি”, আর চক্র থাকলে CONNECT BY NOCYCLE লিখে CONNECT_BY_ISCYCLE দিয়ে চক্রের সারি চেনা যায়। এটি সংক্ষিপ্ত, কিন্তু শুধু Oracle-এ চলে (এবং আংশিকভাবে কয়েকটি Oracle-সামঞ্জস্যপূর্ণ ডেটাবেসে)। নতুন কোড লিখলে standard WITH RECURSIVE বেছে নিন, যাতে অন্য DBMS-এ সরাতে সমস্যা না হয়।

আরও গভীরে: working table-এর এই কৌশলের নাম semi-naive evaluation, যা Datalog গবেষণা থেকে এসেছে: প্রতিটি ধাপে শুধু “নতুন” সারি (delta) নিয়ে join, পুরো ফল নিয়ে নয় — ফলে একই সারি বারবার বের করার অপচয় এড়ানো যায়। SQLite আরেকটু এগিয়ে working table-কে একটি queue হিসেবে রাখে; recursive অংশে ORDER BY দিলে সেটি priority queue হয়ে যায়, আর তা দিয়ে depth-first বা breadth-first ক্রম বেছে নেওয়া যায়। SQL:2023 মান আরও এক ধাপ এগিয়ে SQL/PGQ (property graph query) যোগ করেছে, যেখানে GRAPH_TABLE … MATCH দিয়ে graph-এর পথ সরাসরি লেখা যায় — পর্ব ৫৯-এর graph ডেটাবেসের ধারণা SQL-এর ভেতরে।

১০ · AI-এর যুগে CTE আর recursive query

AI-কে “সব উপশ্রেণি দাও” বা “mentor-শৃঙ্খল দেখাও” বললে সে সাধারণত দ্রুত একটি recursive CTE লেখে, আর ছোট ডেটায় সেটি চলেও। কিন্তু recursive কোয়েরির ভুলগুলো বেশিরভাগ ছোট নমুনায় দেখা দেয় না — দেখা দেয় চক্র এলে, গভীরতা বাড়লে বা অন্য DBMS-এ সরালে:

AI-এর ভুলফলকীভাবে ধরবেন
graph-এ visited path বা depth পাহারা নেইকোয়েরি থামে না, সার্ভার আটকে যায়পরীক্ষার ডেটায় ইচ্ছা করে একটি চক্র রাখুন
join-এর দিক উল্টো (c.mentor_id = i.id)নিচে নামার বদলে ওপরে ওঠে, বা ফাঁকা ফলহাতে দুই স্তর trace করুন
BOM-এ UNION ALL-এর বদলে UNIONএকই অংশ দুই শাখায় থাকলে হিসাব কমএকই অংশ দুবার রেখে মোট যাচাই করুন
MySQL/PostgreSQL-এ path কলামে CAST নেইদৈর্ঘ্যের বা type-এর ত্রুটিলক্ষ্য DBMS-এ সবচেয়ে লম্বা পথ দিয়ে চালান
SQL Server-এর জন্য WITH RECURSIVE লিখেছেsyntax errorলক্ষ্য DBMS-এর নাম প্রম্পটে লিখুন
calendar join-এ COUNT(*)ফাঁকা দিনে 0-এর বদলে 1জানা ফাঁকা দিনের সারি দেখুন

প্রম্পটে schema, লক্ষ্য DBMS আর ডেটার ঝুঁকি (চক্র, গভীরতা) স্পষ্ট করে বলুন:

আমার PostgreSQL 16 ডেটাবেসে টেবিল আছে: categories(id, name, parent_id NULL হতে পারে), products(product_id, name, category_id). প্রশ্ন: একটি শ্রেণি (id দেওয়া থাকবে) আর তার সব উপশ্রেণির পণ্যের সংখ্যা, প্রতিটি উপশ্রেণির স্তর আর পুরো পথ (যেমন Electronics > Accessories > Charger) সহ। ১. WITH RECURSIVE দিয়ে লেখো; anchor, recursive member আর থামার শর্ত আলাদা করে ব্যাখ্যা করো। ২. ভুল ডেটায় parent_id-তে চক্র থাকতে পারে — কোয়েরি যেন তবুও থামে; কোন পাহারা দিলে, কেন। ৩. path কলামের type anchor আর recursive অংশে মেলাও। ৪. কোন কলামে index লাগবে বলো, আর EXPLAIN-এ কী দেখলে বুঝব index ব্যবহার হচ্ছে। ৫. শুধু আমার দেওয়া কলাম ব্যবহার করো; ৩ স্তরের ৬টি শ্রেণি আর একটি চক্রসহ পরীক্ষার ডেটা দাও, প্রত্যাশিত ফলসহ।

AI-এর লেখা recursive কোয়েরি হাতে পেলে — যাচাইয়ের চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • CTE আর temporary table-এর পার্থক্য? — CTE একটি statement-এর ভেতরে নাম দেওয়া কোয়েরি, কিছু সংরক্ষিত হয় না; temp table আসল ডেটা রাখে, session জুড়ে থাকে, index নিতে পারে।
  • Recursive CTE-র দুই অংশ কী কী? — anchor member (একবার চলে, শুরুর সারি) আর recursive member (নিজের নাম ব্যবহার করে, আগের ধাপের নতুন সারি থেকে পরের সারি); মাঝে UNION ALL বা UNION.
  • Recursive CTE কখন থামে? — যখন recursive member একটিও নতুন সারি দেয় না, অর্থাৎ working table খালি।
  • Employee টেবিলে (emp_id, manager_id) একজন ম্যানেজারের অধীনে সব কর্মী বের করুন। — anchor-এ WHERE emp_id = ?, recursive অংশে JOIN tree t ON e.manager_id = t.emp_id.
  • Graph-এ recursive CTE অসীম লুপে পড়ে কেন, সমাধান? — চক্রের কারণে; visited path, depth সীমা, UNION, বা PostgreSQL ১৪+-এর CYCLE clause.

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১WITH RECURSIVE আসার আগে SQL-92-এ “Dr. Anisur Rahman-এর অধীনে সবাই, যত স্তরই হোক” কেন লেখা যেত না? এটি কি শুধু ভাষার একটি অভাব ছিল, নাকি গাণিতিক সীমা?

এটি একটি প্রমাণিত গাণিতিক সীমা। SQL-92-এর SELECT, JOIN, subquery আর GROUP BY মিলিয়ে যত বড় কোয়েরিই লিখুন, তার আকার নির্দিষ্ট: ধরুন সেখানে k-টি join. তাহলে কোয়েরিটি সর্বোচ্চ k স্তর দূরের সম্পর্ক দেখতে পারে। কিন্তু ডেটায় শৃঙ্খল k + 1 স্তরের হলে কোয়েরিটি শেষ মানুষটিকে ধরতে পারবে না। আর কোয়েরি লেখা হয় ডেটা দেখার আগে, তাই কোনো একটি নির্দিষ্ট কোয়েরি সব ডেটার জন্য কাজ করবে না।

১৯৭৯ সালে Alfred Aho আর Jeffrey Ullman এই কথাটি কঠোরভাবে প্রমাণ করেন: relational algebra (আর তার সমান শক্তির relational calculus) দিয়ে transitive closure প্রকাশ করা যায় না। প্রমাণের মূল ধারণা হলো, algebra-র প্রতিটি অভিব্যক্তি একটি সীমিত “দৃষ্টিসীমা” নিয়ে কাজ করে, আর যথেষ্ট লম্বা শৃঙ্খলে সেই সীমা পেরিয়ে যাওয়া যায়। তাঁদের প্রস্তাব ছিল algebra-য় একটি fixpoint অপারেটর যোগ করা — “এই নিয়মটি বারবার চালাও যতক্ষণ না নতুন কিছু আসে”।

এই ধারণা থেকে ১৯৮০-এর দশকে Datalog ভাষার গবেষণা জোর পায়, যেখানে recursion স্বাভাবিক: ancestor(X, Y) :- mentor(X, Y). আর ancestor(X, Z) :- mentor(X, Y), ancestor(Y, Z). এর কার্যকর মূল্যায়নের পদ্ধতি হিসেবে semi-naive evaluation তৈরি হয় — ঠিক যে working table এই পর্বে দেখেছেন। ব্যবহারিক দিকে Oracle নিজস্ব CONNECT BY দিয়ে সমস্যাটি সমাধান করে, আর SQL:1999 মানে WITH RECURSIVE আসে। তার আগে মানুষ কী করত? অ্যাপের কোডে লুপ চালিয়ে বারবার কোয়েরি পাঠাত (প্রতি স্তরে একটি), অথবা stored procedure-এ লুপ লিখত, অথবা ডেটাকেই বদলে closure table বা nested set রাখত।

একটি সূক্ষ্ম বিষয়: recursion যোগ করার পর SQL-এর ক্ষমতা অনেক বেড়েছে। recursive CTE আর সাধারণ পাটিগণিত মিলিয়ে কার্যত যেকোনো হিসাব লেখা যায় — মানুষ SQL দিয়ে sudoku সমাধান আর Mandelbrot ছবিও এঁকেছে (SQLite-এর ডকুমেন্টেশনেই এমন উদাহরণ আছে)। ক্ষমতার দাম হলো, এখন কোয়েরি না-ও থামতে পারে — এজন্যই অংশ ৮-এর পাহারাগুলো লাগে।

মূল উপলব্ধি: নির্দিষ্ট আকারের কোয়েরি শুধু নির্দিষ্ট দূরত্ব দেখতে পারে। অজানা গভীরতার জন্য লাগে “থামা পর্যন্ত বারবার” — fixpoint, যা SQL-এ এসেছে WITH RECURSIVE হয়ে।

প্র ০২PostgreSQL ১২-এর আগে অনেক ডেভেলপার ইচ্ছা করে CTE ব্যবহার করতেন optimizer-কে “বেঁধে রাখতে”। ১২-তে স্বাভাবিক আচরণ বদলানো কেন বিতর্কিত ছিল, আর MATERIALIZED শব্দটি কোন সমস্যার সমাধান?

PostgreSQL ৮.৪ থেকে ১১ পর্যন্ত প্রতিটি CTE আলাদা করে, পুরোপুরি হিসাব হতো, আর মূল কোয়েরি সেই ফল পড়ত। optimizer CTE-র ভেতরে ঢুকত না — বাইরের WHERE order_id = 1007 ভেতরে গিয়ে index ব্যবহার করত না। এটি “optimization fence” নামে পরিচিত ছিল। এর দুই রকম ফল হয়েছিল। যাঁরা শুধু পড়ার সুবিধার জন্য CTE লিখতেন, তাঁদের কোয়েরি কখনো কখনো অনেক ধীর হতো, কারণ কোটি সারির CTE পুরোটা বানিয়ে তারপর একটি সারি নেওয়া হতো। আর অভিজ্ঞ ডেভেলপাররা এই বেড়াকে একটি “hint” হিসেবে ব্যবহার করতেন: optimizer ভুল join-ক্রম বাছলে কোয়েরির একটি অংশ CTE-তে রেখে তাকে আগে চালাতে বাধ্য করতেন। PostgreSQL-এ সাধারণ query hint নেই, তাই এটি ছিল কয়েকটি পরিচিত কৌশলের একটি।

১২ সংস্করণে (২০১৯) নিয়ম বদলায়: যে CTE recursive নয়, যার কোনো পার্শ্বপ্রতিক্রিয়া নেই আর যা একবার ব্যবহার হয়, সেটি স্বাভাবিকভাবে inline হয়। বেশিরভাগ কোয়েরি এতে দ্রুত হয়েছে। কিন্তু যাঁরা fence-এর ওপর নির্ভর করতেন, upgrade-এর পর তাঁদের কিছু কোয়েরির plan বদলে যেতে পারত — ফল একই, কিন্তু গতি আলাদা। ডেটাবেস upgrade-এ এমন “নিঃশব্দ” পরিবর্তন সবসময় বিতর্ক তৈরি করে।

সমাধান হিসেবে একই সংস্করণে দুটি শব্দ আসে। AS MATERIALIZED পুরোনো আচরণ ফিরিয়ে দেয় — আপনি স্পষ্ট বলছেন “এটি একবার হিসাব করো, আলাদা রাখো”। AS NOT MATERIALIZED উল্টোটা করে, এমনকি একাধিকবার ব্যবহৃত CTE-কেও inline করতে বলে — তখন হিসাবটি একাধিকবার হতে পারে, কিন্তু প্রতিটি জায়গায় index কাজে লাগে। ভালো দিকটি হলো, উদ্দেশ্য এখন কোডে লেখা থাকে। পুরোনো fence ছিল একটি অলিখিত পার্শ্বপ্রতিক্রিয়া, যা কোড পড়ে বোঝা যেত না।

অন্য DBMS-এ ছবি আলাদা — SQL Server কখনো CTE-কে নিজে থেকে বেড়া বানায়নি, আর SQLite ৩.৩৫ থেকে একই দুটি শব্দ বোঝে। তাই CTE-কে পারফরম্যান্সের হাতিয়ার হিসেবে ব্যবহার করলে প্রতিটি DBMS-এর আচরণ আলাদাভাবে যাচাই করতে হয়।

মূল উপলব্ধি: পারফরম্যান্সের জন্য কোনো পার্শ্বপ্রতিক্রিয়ার ওপর ভরসা করলে upgrade-এ তা হারাতে পারেন। উদ্দেশ্য থাকলে সেটি স্পষ্ট লিখুন — MATERIALIZED ঠিক সেই কাজ করে।

প্র ০৩একটি বড় অনলাইন দোকানের category tree-তে কয়েক হাজার শ্রেণি, আর প্রতিটি পাতা খুললে “এই শ্রেণির সব উপশ্রেণির পণ্য” লাগে। adjacency list + recursive CTE, নাকি closure table, materialized path বা nested set? কোনটি কখন?

Adjacency list (parent_id) সবচেয়ে সরল: প্রতিটি শ্রেণির একটি সারি, একটি কলাম। নতুন শ্রেণি যোগ করা বা একটি শাখাকে অন্য অভিভাবকের নিচে সরানো — একটি UPDATE. দুর্বলতা পড়ায়: প্রতিবার “সব উপশ্রেণি” চাইলে recursion চালাতে হয়, প্রতিটি স্তরে একটি join. কয়েক হাজার শ্রেণি আর parent_id-তে index থাকলে এটি সাধারণত মিলিসেকেন্ডের ব্যাপার, তাই অনেক দোকানের জন্য এটিই যথেষ্ট।

Closure table প্রতিটি পূর্বসূরি–উত্তরসূরি জোড়ার জন্য একটি সারি রাখে, দূরত্বসহ: (ancestor, descendant, depth). “সব উপশ্রেণি” তখন একটি সাধারণ WHERE ancestor = 3 — কোনো recursion নেই। দাম হলো জায়গা আর লেখা: d গভীরতার গাছে প্রতিটি শ্রেণির জন্য প্রায় d-টি সারি, আর একটি শাখা সরালে তার সব জোড়া মুছে নতুন করে লিখতে হয়।

Materialized path প্রতিটি সারিতে পুরো পথ রাখে, যেমন '1/3/4'. সব উত্তরসূরি = path LIKE '1/3/%', যা সাধারণ B+ tree index-এ prefix খোঁজ হিসেবে চলতে পারে। PostgreSQL-এর ltree extension এর জন্য বিশেষ অপারেটর আর index দেয়। শাখা সরালে নিচের সব সারির path বদলাতে হয়।

Nested set (Joe Celko-র বইয়ের মাধ্যমে জনপ্রিয়) প্রতিটি শ্রেণিকে দুটি সংখ্যা দেয়, lft আর rgt, গাছে depth-first ঘোরার ক্রমে। উপশ্রেণি = যাদের lft আর rgt অভিভাবকের দুই সংখ্যার মাঝে। পড়া খুব দ্রুত, কিন্তু একটি শ্রেণি যোগ করলে তার ডানের প্রায় সব সারির সংখ্যা বদলায় — ঘন ঘন লেখা হয় এমন গাছে এটি কষ্টকর।

বাস্তব সিদ্ধান্ত তাই পড়া আর লেখার অনুপাতের ওপর। শ্রেণির গাছ বছরে কয়েকবার বদলায়, কিন্তু দিনে লাখবার পড়া হয় — তাই closure table বা materialized path যুক্তিসংগত, অথবা adjacency list রেখে ফলটি cache-এ রাখা (পর্ব ৫৮-এর Redis). মন্তব্যের উত্তরের মতো দ্রুত বাড়তে থাকা গাছে adjacency list-ই সাধারণত ভালো। অনেক দল দুটিই রাখে: সত্যের উৎস parent_id, আর পড়ার জন্য একটি তৈরি-করা closure table, যা trigger বা নিয়মিত কাজ দিয়ে হালনাগাদ হয়।

মূল উপলব্ধি: recursion হলো পড়ার সময় হিসাব; অন্য মডেলগুলো সেই হিসাব লেখার সময় আগেই করে রাখে। কোনটি ভালো, তা ঠিক করে আপনার পড়া-লেখার অনুপাত।

প্র ০৪রাস্তার graph-এ পাহারা ছাড়া ৬ ধাপে সারি হলো 1, 2, 5, 11, 25, 55, 123. visited path দিলে মাত্র ৯টি। তবু কেন বলা হয় বড় graph-এ SQL-এর recursive CTE দিয়ে shortest path খোঁজা অদক্ষ? graph ডেটাবেস বা DSA-র BFS কী আলাদা করে?

মূল পার্থক্য হলো “visited” কার। DSA-র BFS-এ একটিই global visited set থাকে: কোনো শহর একবার queue-তে ঢুকলে আর কখনো ঢোকে না। তাই BFS প্রতিটি শহর একবার আর প্রতিটি রাস্তা সর্বোচ্চ দুবার দেখে — মোট কাজ O(V + E). প্রথমবার যে hop-এ শহরে পৌঁছায়, সেটিই সবচেয়ে কম hop.

Recursive CTE-তে এমন global স্মৃতি নেই। recursive member শুধু working table দেখে, পুরো ফল নয়; তাই প্রতিটি সারি নিজের পথ নিজে বয়ে বেড়ায়। ফলে কোয়েরি আসলে প্রতিটি শহর নয়, প্রতিটি চক্রহীন পথ তৈরি করে। ছোট মানচিত্রে ৯টি পথ, কিন্তু ঘন graph-এ চক্রহীন পথের সংখ্যা শহরের সংখ্যার সাথে exponential হারে বাড়তে পারে — সম্পূর্ণ graph-এ n শহরে তা মোটামুটি (n − 1)!-এর ক্রমে। তারপর শেষে MIN(hops) নিয়ে প্রায় সবই ফেলে দেওয়া হয়। আর প্রতিটি সারির path-স্ট্রিং লম্বা হতে থাকে, তাই মেমরিও বাড়ে।

UNION দিয়ে শুধু city রাখলে global “visited”-এর কাছাকাছি একটি আচরণ পাওয়া যায় — আগে-আসা শহর আবার আসে না — কিন্তু তখন hops বা path রাখা যায় না, কারণ সেগুলো রাখলে সারি আর হুবহু এক থাকে না। কিছু DBMS-এ বাড়তি কৌশল আছে: PostgreSQL-এর CYCLE আর SEARCH BREADTH FIRST লেখাকে সহজ করে, কিন্তু মৌলিক সমস্যা (প্রতি পথে একটি সারি) মেটায় না। কিছু ব্যবস্থা pgRouting-এর মতো extension দিয়ে Dijkstra-র মতো আসল graph অ্যালগরিদম ডেটাবেসের ভেতরে চালায়।

Graph ডেটাবেস (Neo4j ইত্যাদি, পর্ব ৫৯) প্রতিটি node-এর সাথে তার প্রতিবেশীর সরাসরি সংযোগ সংরক্ষণ করে, আর shortest path-এর জন্য তৈরি অ্যালগরিদম দেয়। SQL:2023-এর SQL/PGQ অংশ property graph query মানে এনেছে, যাতে relational ডেটাবেসেও MATCH দিয়ে পথ খোঁজা যায়; বিভিন্ন DBMS-এ এর সমর্থন এখনো ধীরে আসছে।

তাহলে recursive CTE কোথায় ঠিক? গাছ আর অগভীর graph-এ (৩–৪ ধাপের বন্ধুত্ব, ছোট রাস্তার মানচিত্র, অনুমতির শৃঙ্খল) এটি চমৎকার — ডেটা যেখানে আছে সেখানেই প্রশ্ন, আলাদা সিস্টেম লাগে না। বড়, ঘন আর গভীর graph-এ বিশেষ অ্যালগরিদম লাগে।

মূল উপলব্ধি: BFS শহর গোনে, recursive CTE পথ গোনে। পথের সংখ্যা শহরের সংখ্যার চেয়ে অনেক দ্রুত বাড়ে — এখানেই খরচের পার্থক্য।

প্র ০৫একটি ব্যাংক Oracle থেকে PostgreSQL-এ যাচ্ছে, আর পুরোনো রিপোর্টে শত শত CONNECT BY কোয়েরি। রূপান্তরের সময় কোন পার্থক্যগুলো ফল বদলে দিতে পারে?

প্রথম পার্থক্য সারির ক্রম. CONNECT BY স্বাভাবিকভাবে depth-first ক্রমে সারি দেয়: অভিভাবক, তারপর তার প্রথম সন্তান, সেই সন্তানের সন্তানেরা, তারপর দ্বিতীয় সন্তান। ORDER SIBLINGS BY একই অভিভাবকের সন্তানদের ক্রম ঠিক করে, গাছের আকার না ভেঙে। WITH RECURSIVE-এর ফলের কোনো নিশ্চিত ক্রম নেই; বাস্তবে প্রায়ই স্তর-ক্রমে (breadth-first) আসে। রিপোর্ট যদি ক্রমের ওপর নির্ভর করে — যেমন indent করা গাছ — তবে এই পর্বের sort_key-এর মতো একটি পথ-কলাম বানিয়ে ORDER BY দিতে হবে, অথবা PostgreSQL ১৪+-এর SEARCH DEPTH FIRST BY.

দ্বিতীয় পার্থক্য WHERE কোথায় কাজ করে. CONNECT BY কোয়েরিতে WHERE প্রয়োগ হয় গাছ তৈরির পরে — একটি সারি বাদ পড়লেও তার সন্তানেরা ফলে থাকতে পারে। আর CONNECT BY-এর শর্তে দেওয়া ছাঁকনি গাছের হাঁটাকেই থামায়। recursive CTE-তে একই শর্ত recursive member-এ দিলে পুরো শাখা কাটা যায়, মূল SELECT-এ দিলে শুধু সারিটি। ভুল জায়গায় শর্ত বসালে সংখ্যা চুপচাপ বদলে যায়।

তৃতীয়ত বিশেষ ফাংশন: LEVEL, SYS_CONNECT_BY_PATH, CONNECT_BY_ROOT, CONNECT_BY_ISLEAF — প্রতিটির জন্য CTE-তে নিজে কলাম বানাতে হয় (level + 1, path || ' > ' || name, anchor-এর মান বয়ে আনা, আর leaf-এর জন্য NOT EXISTS). SYS_CONNECT_BY_PATH পথের শুরুতেও বিভাজক বসায় (' > Dr. Anisur Rahman > …'), যা হুবহু মেলাতে হলে মনে রাখতে হবে।

চতুর্থত চক্র. Oracle চক্র পেলে ত্রুটি দেয়, আর NOCYCLE দিলে চক্রের আগে থেমে যায়। PostgreSQL কোনো পাহারা ছাড়া অনন্তকাল চলবে। তাই প্রতিটি রূপান্তরিত কোয়েরিতে visited path বা CYCLE clause যোগ করুন। আর পঞ্চমত, type: PostgreSQL anchor আর recursive অংশের type মেলাতে কড়া, তাই path কলামে ::text লাগবে।

পেশাদার পদ্ধতি হলো প্রতিটি রিপোর্টের জন্য পুরোনো আর নতুন সিস্টেমে একই ডেটায় ফল চালিয়ে সারি-ধরে মেলানো, ক্রমসহ। এই কাজে AI রূপান্তরের প্রথম খসড়া দ্রুত দিতে পারে, কিন্তু WHERE-এর অবস্থান আর ক্রমের মতো সূক্ষ্ম পার্থক্য সে প্রায়ই এড়িয়ে যায়।

মূল উপলব্ধি: CONNECT BY আর WITH RECURSIVE একই প্রশ্নের উত্তর দেয়, কিন্তু ক্রম, ছাঁকনি আর চক্রের নিয়ম আলাদা। রূপান্তর মানে শুধু syntax বদলানো নয়, অর্থ মেলানো।

অনুশীলন

উপরের যেকোনো মিল-থাকা এডিটরে কোড বদলে নিজের উত্তর চালিয়ে দেখতে পারেন।

  1. কোয়েরি লিখুন: db-uni-তে Tasnim Chowdhury (instructor_id 6) থেকে ওপরের দিকে সব পূর্বসূরি বের করুন — নিজেকে ধাপ 0 ধরে, প্রতিটি পূর্বসূরির ধাপসহ।
    WITH RECURSIVE up(id, name, mentor_id, step) AS (
      SELECT instructor_id, name, mentor_id, 0
      FROM instructors WHERE instructor_id = 6
      UNION ALL
      SELECT i.instructor_id, i.name, i.mentor_id, u.step + 1
      FROM instructors i JOIN up u ON i.instructor_id = u.mentor_id
    )
    SELECT step, name FROM up;

    ফল: 0 | Tasnim Chowdhury, 1 | Shahana Parvin, 2 | Dr. Anisur Rahman. নিচে নামার কোয়েরির সাথে পার্থক্য শুধু join-এর দিকে: সেখানে i.mentor_id = c.id (যার mentor আমি), এখানে i.instructor_id = u.mentor_id (আমার mentor কে). Anisur-এর mentor_id NULL, তাই পরের ধাপে join-এ কেউ মেলে না আর লুপ থামে।

  2. ফল আগে বলুন (trace): WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n * 2 FROM t WHERE n < 20) SELECT n FROM t; — কয়টি সারি আর শেষ মান কত? প্রতিটি ধাপের working table লিখুন।

    ৬টি সারি: 1, 2, 4, 8, 16, 32. Anchor: W = {1}. 1 < 20, তাই W = {2}; তারপর {4}, {8}, {16}. 16 < 20 সত্য, তাই 32 তৈরি হয় — শেষ মানটি 20 ছাড়িয়ে যায়! তারপর W = {32}; 32 < 20 মিথ্যা, নতুন সারি নেই, থামে। শিক্ষা: থামার শর্ত পরীক্ষা হয় আগের সারির ওপর, নতুন সারির ওপর নয়। 20-এর বেশি না চাইলে লিখুন WHERE n * 2 <= 20, অথবা মূল SELECT-এ WHERE n <= 20.

  3. কোয়েরি লিখুন: db-shop-এ সেপ্টেম্বর ২০২৫-এর কয়টি দিনে একটিও অর্ডার আসেনি? recursive CTE দিয়ে মাসের দিন বানিয়ে LEFT JOIN … IS NULL দিয়ে লিখুন।
    WITH RECURSIVE sept(day) AS (
      SELECT '2025-09-01'
      UNION ALL
      SELECT date(day, '+1 day') FROM sept WHERE day < '2025-09-30'
    )
    SELECT COUNT(*) AS empty_days FROM sept s
    LEFT JOIN orders o ON o.order_date = s.day
    WHERE o.order_id IS NULL;

    ফল: 24. সেপ্টেম্বরে ৩০ দিন, অর্ডার এসেছে ৬টি আলাদা দিনে (১, ৩, ১০, ১২, ১৫ আর ২০ তারিখ), তাই ৩০ − ৬ = ২৪. একই দিনে দুটি অর্ডার থাকলেও হিসাব ঠিক থাকত, কারণ যে দিনে অর্ডার আছে সেখানে IS NULL কখনো সত্য নয়। 1004 বাতিল হলেও এখানে গোনা হয়েছে; শুধু সফল অর্ডার ধরতে চাইলে ON-এ AND o.status <> 'cancelled' যোগ করুন — WHERE-এ নয়, নইলে LEFT JOIN-এর অর্থ ভাঙবে (পর্ব ১৯)।

  4. ভুল খুঁজুন: একজন সহকর্মী roads টেবিলে (src, dst; প্রতিটি রাস্তা দুই দিকে সংরক্ষিত) লিখেছেন WITH RECURSIVE r(city) AS (SELECT 'Dhaka' UNION ALL SELECT x.dst FROM r JOIN roads x ON x.src = r.city) SELECT * FROM r; কোয়েরিটি কেন শেষ হয় না? দুটি আলাদা উপায়ে ঠিক করুন।

    কারণ: রাস্তা দুই দিকের, তাই Dhaka → Cumilla → Dhaka → Cumilla … পথ কখনো ফুরায় না। UNION ALL কোনো সারি বাদ দেয় না, আর কোনো থামার শর্ত নেই, তাই working table কখনো খালি হয় না।

    উপায় ১ — UNION: UNION ALL-কে UNION করুন। সারিতে শুধু city, তাই আগে-আসা শহর আবার এলে বাদ পড়ে; ৫টি শহরের পর নতুন কিছু থাকে না, লুপ থামে। ফল: Dhaka, Cumilla, Sylhet, Feni, Chattogram. উপায় ২ — visited path: একটি path কলাম যোগ করে WHERE instr(r.path, '/' || x.dst || '/') = 0, যেমন অংশ ৭-এ। এতে ধাপ আর পথও পাওয়া যায়। শুধু hops < 6-এর মতো depth সীমা দিলেও থামবে, কিন্তু ৬ ধাপেই ১২৩টি সারি — বড় graph-এ তা বিস্ফোরক।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি recursive CTE যুক্তির দিক থেকে কোন ক্রমে মূল্যায়ন হয়, সাজান (▲▼ চেপে সরান)

  1. anchor member একবার চালিয়ে শুরুর সারিগুলো পাওয়া
  2. সেই সারিগুলো ফলে রাখা আর working table বানানো
  3. working table-কে নিজের নাম ধরে recursive member চালানো
  4. নতুন সারিগুলো ফলে যোগ করা আর সেগুলোকেই নতুন working table বানানো
  5. working table খালি হলে থামা, তারপর মূল SELECT ফলের ওপর চালানো

WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n + 2 FROM t WHERE n < 9) SELECT COUNT(*) FROM t; কী ফল দেবে?

  • ৪ (1, 3, 5, 7)
  • ৫ (1, 3, 5, 7, 9)
  • ৬ (1 থেকে 11)
  • কোয়েরি কখনো থামবে না

একটি recursive CTE ঠিক কখন থামে?

  • anchor member চলার সাথে সাথে
  • প্রতিটি DBMS-এ ঠিক ১০০ ধাপ পরে
  • ফলে anchor-এর সমান সংখ্যক সারি হলে
  • recursive member একটিও নতুন সারি না দিলে, অর্থাৎ working table খালি হলে

PostgreSQL ১২ বা পরের সংস্করণে একটি non-recursive CTE, যার কোনো পার্শ্বপ্রতিক্রিয়া নেই আর যা মূল কোয়েরিতে মাত্র একবার ব্যবহার হয়েছে, কোনো ইঙ্গিত ছাড়া কীভাবে চলে?

  • মূল কোয়েরির ভেতরে inline হয়
  • সবসময় আলাদা করে materialize হয়
  • একটি স্থায়ী টেবিল হিসেবে ডিস্কে সংরক্ষিত হয়
  • ত্রুটি দেয়, কারণ CTE অন্তত দুবার ব্যবহার করতে হয়

roads(src, dst) টেবিলে রাস্তাগুলো দুই দিকে সংরক্ষিত, আর মানচিত্রে চক্র আছে। প্রতিটি শহরে পৌঁছানোর পথ আর ধাপ-সংখ্যা চাই। অসীম লুপ ঠেকাতে কোনটি সবচেয়ে উপযুক্ত?

  • UNION ALL-এর জায়গায় UNION লেখা, কলাম বদলানো ছাড়া
  • মূল SELECT-এ ORDER BY দেওয়া
  • প্রতিটি সারিতে visited path রাখা, আর পরের শহরটি path-এ থাকলে বাদ দেওয়া
  • anchor member-এ WHERE শর্ত দেওয়া

SQLite-এ recursive CTE-র recursive member-এ SELECT MAX(n) + 1 FROM t লিখলে কী হয়?

  • MAX উপেক্ষা করে সাধারণ n + 1 চলে
  • ত্রুটি দেয় — recursive অংশে aggregate চলে না
  • প্রতিটি ধাপে পুরো ফলের সর্বোচ্চ মান নেয়
  • অসীম লুপে পড়ে

একটি রিপোর্টে কোটি সারি থেকে তৈরি একটি মাঝের ফল পরের দশটি আলাদা কোয়েরিতে লাগবে, আর সেই ফলের একটি কলামে খোঁজার জন্য index-ও দরকার। কোনটি সবচেয়ে উপযুক্ত?

  • CTE
  • Derived table
  • সাধারণ view
  • Temporary table

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. CTE = WITH নাম AS (…): একটি statement-এর ভেতরে নাম দেওয়া ধাপ। একটি WITH-এ অনেক CTE, পরেরটি আগেরটিকে ব্যবহার করে, আর একটি CTE একাধিকবার ব্যবহার করা যায়।
  2. CTE কিছু সংরক্ষণ করে না; DBMS সেটিকে materialize বা inline করে। PostgreSQL ১২+ আর SQLite ৩.৩৫+-এ MATERIALIZED / NOT MATERIALIZED; বহুবার, বহু কোয়েরিতে লাগলে temp table বা view.
  3. Recursive CTE = anchor + UNION ALL + recursive member. প্রতিটি ধাপ শুধু আগের ধাপের নতুন সারি (working table) দেখে; নতুন সারি না এলে থামে।
  4. ধারা (সংখ্যা, তারিখ), hierarchy (mentor, category, BOM) আর graph — একই কৌশল। recursive অংশে aggregate নয়; হিসাব মূল SELECT-এ।
  5. চক্রে visited path, depth সীমা, UNION বা LIMIT দিয়ে পাহারা দিন; MySQL ১০০০, SQL Server ১০০ ধাপে থামায়, SQLite আর PostgreSQL থামায় না। join-কলামে index দিন।

পরের পর্বে: CTE দিয়ে ধাপে ধাপে হিসাব শিখলেন। কিন্তু “প্রতিটি গ্রাহকের অর্ডারের পাশে তাঁর আগের অর্ডারের তারিখ” বা “মাসভিত্তিক running total” — যেখানে প্রতিটি সারি তার আশপাশের সারি দেখতে চায়, GROUP BY দিয়ে সারি না হারিয়ে? পর্ব ২৬-এ window function: ROW_NUMBER, RANK, LAG আর running total.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান