জ্যাংগো অ্যাডমিন কুইজ ২ এর সমাধান

কিছুদিন আগে আরেকটি পোস্ট দিয়েছিলাম পাইথন বাংলাদেশ গ্রুপে। এবার সমাধানের পালা। প্রথমেই স্ক্রিনশট দেয়া যাক (যাতে ১, আমার টাইপ কম করতে হয় ও ২, ফেইসবুক শেয়ারের সময়ে আগের পোস্টটি স্ক্রীনশট আকারে থেকে যায়)

Screenshot

এখন, একটা খুবই সহজ সমাধান যা আমাদের মাথায় সবার আগে আসবে তা হল রিলেটেড নেইমের ব্যবহার। অর্থাৎ category.product_set.count() ব্যবহার করে একে মডেলের প্রপার্টি (অথবা অ্যাডমিনের মেথড যা ক্যাটেগরি অবজেক্টকে দ্বিতীয় প্যারামিটার হিসেবে নিবে) আকারে উপস্থাপন করা, যাতে করে আপনি list_display তে একে ব্যবহার করতে পারেন। এটি আপনাকে সঠিক রেজাল্ট দিবে কিছু দূর পর্যন্ত, কিন্তু আপনি সর্টিং করবেন কিভাবে? N+1 প্রব্লেম একটা বাজে জিনিস (লিঙ্কটি পড়লেই বুঝতে পারবেন)।

আরেকটা সমাধান হতে পারে যদি আপনি Category মডেলে number_of_products দিয়ে থাকেন এবং প্রতিবার Product অ্যাড/ডিলিটের সাথে সাথে ওই ফিল্ডটিকে বাড়াতে/কমাতে থাকেন। অন্যান্য ফিল্ডের ক্ষেত্রেও একি ব্যবস্থা। এই সল্যুশন পড়লেই কিন্তু প্রব্লেম প্রব্লেম গন্ধ পাওয়া যায়। কারণ, Category সৃষ্টি হবে প্রথমে, Product পরবর্তিতে, Category-র কিন্তু জানা উচিত না তাকে কে কে ফরেন কী হিসেবে ব্যবহার করেছে, কারণ আপনি আপনি আপনার সফ্টওয়্যারকে যতবার ইনহ্যান্স করবেন ততবার কি আপনি প্রতিটা প্রাক্তন টেবিলে গিয়ে গিয়ে ডেটাবেজ টেবিল অলটার করবেন? আরও বড় কথা হল, আপনার ডেইটাবেইজ স্ট্রাকচার কিন্তু আপনার অ্যাপ্লিকেশান লজিক অথবা এপিআই অনুযায়ী গঠিত হবে না, ডেইটাবেইজ এর লক্ষ্য, স্কোপ ও অপ্টীমাইজেশান প্রক্রিয়া, এপিআই অথবা আপনার অ্যাপ্লিকেশান এর লক্ষ্য, স্কোপ ও অপ্টীমাইজেশান প্রক্রিয়া থেকে ভিন্ন, আজকের এই মাইক্রোসার্ভিসের যুগে এটি আরও প্রযোজ্য। কাজেই এই টাইপের সল্যুশনকেও আমি বাদ দিয়েছি।

উপরের দুটি সল্যুশন শুধুমাত্র এই কুইজের জন্য বাদ যে তা নয়, আমি সাধারণ ব্যবহারেও নিরুৎসাহিত করব। প্রথমটি ব্যবহার তখনই করবেন যখন আপনার ডেইটা সাইজ ছোট অথবা এক্সপ্লরেটরি প্রোগ্রামিং করছেন শেলে। কিন্তু পরেরটি একটা ডিজাইনগত সমস্যা যা থেকে অবশ্যই দূরে থাকবেন।

এবার একটা সল্যুশনে আসা যাক। কিছু সময়ের জন্য ভুলে যান যে আপনি জ্যাংগো ব্যবহার করছেন এবং মনে করুন আপনি এটাকে সিকুয়েল দিয়ে সমাধান করবেন। অর্থাৎ Category.objects.all() এর পথে না গিয়ে SELECT * FROM Category পথ অবলম্বন করবেন। তাহলে কিন্তু অর্ধেক কাজ হয়ে যায়। SELECT name, (SELECT count(*) FROM Product WHERE category_id=c.id) number_of_product FROM Category c ব্যবহার করলে কিন্তু আপনি পাচ্ছেন এর একই কুয়েরিতে Category-কে রেফারকারি Product সংখ্যা, যা number_of_product হিসেবে থাকবে। SELECT name, (SELECT avg(price) FROM Product WHERE category_id=c.id) number_of_product FROM Category c থেকে আপনি পাবেন গড় মূল্য। তৃতীয়টিতে পরে আসি, কারণ সেটির সমাধান একই পদ্ধতিতে করা গেলেও জ্যাঙ্গো অ্যাডমিনের জন্য আমাদের কিছু সিদ্ধান্ত নিতে হবে যার জন্য উপরের দুটিকে ORM এ নিয়ে তাদের অ্যাডমিন ইন্টেগ্রেশান নিয়ে আগে কথা বলা জরুরি।

তো আমরা নেস্টেড কুয়েরি নিয়ে কাজ করতে চাচ্ছি, তবে জ্যাঙ্গোতে। যা তাৎক্ষনিকভাবে বুঝা যাচ্ছে তা হল Category.objects.all() হবে মূল কুয়েরি। এরপর আমাদের এর ভেতর annotate করতে হবে নেস্টেড কুয়েরি, অর্থাৎ Category.objects.annotate(total=Product.objects.filter(…))। ওই … এ যাওয়ার কথা WHERE category_id=c.id অংশ SQL কুয়েরির। অর্থাৎ, নেস্টেড কুয়েরি রেফার করছে নেস্ট-বহির্ভুত কুয়েরির কোন মেম্বারকে। জ্যাঙ্গো ORM একে চিনবে OuterRef হিসেবে, নাম শুনেই বুঝা যাচ্ছে যে এর কাজ হল যে কুয়েরি একে নিজের ভেতর নিয়ে নিবে তার স্কোপের কাউকে রেফার করা। কাজেই আমাদের জ্যাঙ্গো কুয়েরি দাঁড়ায়- Category.objects.annotate(total=Subquery(Product.objects.filter(category=OuterRef(“pk”)))। তবে এটি এরর দিবে কারণ আপনার একটা রো এবং একটা কলাম প্রয়োজন নেস্টেড কুয়েরির জন্য। আর তার জন্য আপনার দরকার values মেথড যা দিয়ে আপনি আপনার প্রয়োজন মত সিলেক্ট করে নিবেন ফিল্ড। উদাহরণস্বরূপ Category.objects.annotate(total=Subquery(Product.objects.filter(category=OuterRef(“pk”)).values(“category”).annotate(total=Count(“pk”)).values(“total”)))। অনেক ঝামেলা? একটু ভেঙ্গে চিন্তা করলে বুঝতে পারবেন আসলে তা না। আপনি সাবকুয়েরি তে প্রথমে values(“category”) এর মাধ্যমে ক্যাটেগরি সিলেক্ট করলেন, এরপর Productannotate করে Count নিলেন, এবং একটা কলাম রিটার্ন করতে সেই কলামটিকেই রিটার্ন করলেন, যা আবার annotate করা হয়েছে মূল কুয়েরি থেকে। অতএব আপনার কুয়েরি ফেরত রেজাল্টে total নামে একটি মেম্বার থাকবে যা Category মডেলের অন্তর্ভুক্ত Product সংখ্যা প্রদর্শন করবে। আর যদি আপনি একে আনুসঙ্গিক ModelAdmin সাবক্লাসের ওভাররাইডকৃত get_queryset এ ঢুকান তাহলে অ্যাডমিন একে রেজিস্টার্ড Model এর মেম্বার হিসেবে চিনবে, কাজেই একে আপনি সর্টের আওতায় আনতে পারবেন যদি বলে দিন admin_order_field এ ফিল্ডের নাম। আর আপনাকে সেই total মেথডের সমকক্ষ একটা মেথড লিখতে হবে total(self, obj) হিসেবে মডেলঅ্যাডমিন ক্লাসে কারণ রেজিস্ট্রেশানকালীন সময়ে ModelAdmin জানত না ওই নতুন মেম্বার সম্পর্কে।

বুঝাই যাচ্ছে গড় মূল্য বের করতে আপনাকে লিখতে হবে (একিভাবে) Category.objects.annotate(total=Subquery(Product.objects.filter(category=OuterRef(“pk”)).values(“category”).annotate(avg_price=Avg(“price”)).values(“avg_price”)))। এদেরকে স্টাইল করার জন্যে এদের আনুসঙ্গিক মেথডকে mark_safe এর মাধ্যমে এইচটিএমএল দিয়ে স্টাইল দিতে পারেন। শুধু মনে করে admin_order_field কে একটু চিনিয়ে দিন সর্ট কিভাবে করা লাগবে।

এবার আসা যাক তৃতীয়টি তে, এইখানে আপনাকে প্রথম তিনটি প্রডাক্টের নাম উল্লেখ করতে হবে, কিন্তু বলেছিলাম না যে আপনি সাব অর্থাৎ নেস্টেড কুয়েরিতে সর্বাধিক একটি রো ও একটি কলাম বের করতে পারবেন? তো কেমনে কি? একটা সলুশান হতে পারে, আপনি annotate এর মাধ্যমে সমস্ত name গুলিকে aggregate করবেন স্ট্রিং হিসেবে, অর্থাৎ Category.objects.annotate(product_names=Subquery(Product.objects.filter(category=OuterRef(“pk”)).values(“category”).annotate(names=StringAgg(“name”, “||”)).values(“names”)))। অর্থাৎ আমি || এর মাধ্যমে এদেরকে আলাদা করেছি এবং এদের খুলে UL>LI তে পরিণত করব আমি অ্যাডমিন মেথডে। সম্পূর্ন কোড দেখলেই বুঝতে পারবেন-

@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = "name", "number_of_products", "product_names", "average_price"
    ordering = "name",
    search_fields = "name", "description",

    def number_of_products(self, obj):
        return obj.total

    number_of_products.admin_order_field = "total"

    def product_names(self, obj):
        html = "<li>{}</li>"
        elems = "".join([html.format(i) for i in sorted(obj.product_names.split("||")[:3])])
        print("\n\n\n" + elems + "\n\n\n")
        return mark_safe(f"<ul>{elems}</ul>")

    product_names.admin_order_field = "product_names"

    def average_price(self, obj):
        res = "<div style='text-align:right; width=100%'>CA${:.2f}</div>".format(obj.average_price)
        return mark_safe(res)

    average_price.admin_order_field = "average_price"

    def get_queryset(self, request):
        return super().get_queryset(self).annotate(
            total=Subquery(Product.objects.filter(category=OuterRef("pk")).values("category").annotate(
                total=Count("pk")).values("total")),
            product_names=Subquery(
                Product.objects.filter(
                    category=OuterRef("pk")).values("category").annotate(names=StringAgg("name", "||")).values("names")
            ),
            average_price=Subquery(
                Product.objects.filter(
                    category=OuterRef("pk")).values("category").annotate(avg_price=Avg("price")).values("avg_price")
            ),
        )

তো এখন আমরা দেখতে পাচ্ছি কি করে আমাদের কুইজের সমাধান করা যায়। এখন যাওয়ার আগে কিছু কথা বলে রাখছি যা আমি পরবর্তি কোন এক পোস্টে উল্লেখ করব (নাও করতে পারি সময় না পেলে)-

  • যদি আপনার প্রডাক্ট সংখ্যা বেশি হয় তাহলে? মানে যদি ১০০০ প্রডাক্ট থাকে তাহলে কি ৩ এর লিমিট কুয়েরিতেই উল্লেখ করা ভাল না? তা কিভাবে করবেন? এই লিঙ্ক দেখে নিতে পারেন, আমি চেষ্টা করব সেটি নিয়ে লেখার।
  • উপরের কুয়েরিগুলোকে লিখার আরেকটি সহজ উপায় আছে, বলতে পারবেন কী? আমার আসল উদ্দেশ্য ছিল SubqueryOuterRef এর সাথে পরিচয় করানোর কিন্তু অন্যভাবে এদের সমাধান সম্ভব। একটি উদাহরণ দিতে পারি ওই লাইনে- Category.objects.annotate(total=Count(F(“product”)))। অন্যগুলি বের করে নিন এবং explain মেথডের মাধ্যমে বের করুন কোনটা ভাল।
  • এই বইটা পরে নিতে পারেন, খুব সংক্ষিপ্তভাবে এবং সুন্দরভাবে জাঙ্গো ORM কে উপস্থাপন করা হয়েছে কেইস বাই কেই বেসিসে। আপাতত আজকে এতটুকু থাক। পরবর্তি সিরিজে অ্যাডমিন নিয়ে আরও কিছু লিখব। কিন্তু বুঝতেই পারছেন যে আমার টপিকের টাইটেল অ্যাডমিন হলেও আমি লিখছি জ্যাঙ্গোর অন্যান্য বিষয় নিয়ে ও অ্যাডমিনকে শুধুমাত্র নয়েজ রিডাকশন ম্যাটেরিয়াল হিসেবে ব্যবহার করে। আবার লিখব কিছুদিন পর এবং হিন্ট দিয়ে রাখছি- ORM কিন্তু হবে পরবর্তি পোস্ট।
comments powered by Disqus