零基础入门python33:Django QuerySet——从商品查询到数据库 SQL
一、上一篇课后练习讲解
删除被商品使用的分类会触发ProtectedError。这不是异常写错了,而是on_delete=models.PROTECT主动阻止破坏数据关系。
上一篇课后练习完整答案
上一篇练习的要求已落实到下面完整文件;先运行项目测试,再用 curl 对照状态码和数据库持久化结果
答案要点:Category/Product 模型使用 PROTECT、DecimalField、PositiveIntegerField 和唯一 slug;迁移前检查 sqlmigrate。
文件:shop/models.py
完整参考答案文件
完整文件:shop/models.py
fromdjango.dbimportmodelsclassCategory(models.Model):name=models.CharField(max_length=80,unique=True)slug=models.SlugField(unique=True)classProduct(models.Model):category=models.ForeignKey(Category,on_delete=models.PROTECT,related_name="products")name=models.CharField(max_length=120)slug=models.SlugField(unique=True)price=models.DecimalField(max_digits=10,decimal_places=2)stock=models.PositiveIntegerField(default=0)is_active=models.BooleanField(default=True)完整参考答案文件
本篇对应的交付源码完整文件:django-shop/shop/models.py
fromdjango.confimportsettingsfromdjango.dbimportmodelsclassCategory(models.Model):name=models.CharField(max_length=60,unique=True)slug=models.SlugField(max_length=60,unique=True)def__str__(self):returnself.nameclassProduct(models.Model):category=models.ForeignKey(Category,on_delete=models.PROTECT,related_name='products')name=models.CharField(max_length=120)slug=models.SlugField(max_length=120,unique=True)price=models.DecimalField(max_digits=10,decimal_places=2)stock=models.PositiveIntegerField(default=0)is_active=models.BooleanField(default=True)created_at=models.DateTimeField(auto_now_add=True)def__str__(self):returnself.nameclassOrder(models.Model):classStatus(models.TextChoices):PENDING='pending','待处理'PAID='paid','已支付'CANCELLED='cancelled','已取消'user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,related_name='orders')status=models.CharField(max_length=20,choices=Status.choices,default=Status.PENDING)total_amount=models.DecimalField(max_digits=12,decimal_places=2,default=0)created_at=models.DateTimeField(auto_now_add=True)classOrderItem(models.Model):order=models.ForeignKey(Order,on_delete=models.CASCADE,related_name='items')product=models.ForeignKey(Product,on_delete=models.PROTECT)product_name=models.CharField(max_length=120)price=models.DecimalField(max_digits=10,decimal_places=2)quantity=models.PositiveIntegerField()验收命令:python -m pytest -q(Django 项目使用 python manage.py test)。预期测试通过;若失败先检查迁移、配置和事务回滚。
二、本篇完成什么
用 QuerySet 实现商品列表、分类筛选、关键词搜索和库存排序,并理解 QuerySet 为什么是延迟执行的。
三、常用查询
products=(Product.objects.filter(is_active=True).select_related('category').order_by('-created_at'))filter只是构造查询,真正访问数据库通常发生在迭代、list()或序列化时。select_related用 JOIN 一次加载外键分类,避免列表中每个商品再发一次查询。
搜索示例:
query=Product.objects.filter(is_active=True)ifkeyword:query=query.filter(name__icontains=keyword)ifcategory_id:query=query.filter(category_id=category_id)不要把用户输入拼进原生 SQL;ORM 参数化会正确处理特殊字符。分页前必须稳定排序,否则商品新增时页码内容会跳动。
四、验收
在 shell 中分别执行全部商品、分类筛选、关键词搜索和库存降序查询,并打开 Django SQL 日志观察查询次数。课后练习:实现“库存小于 5 的商品”查询并写测试。
项目增量:QuerySet、JOIN 和 N+1
列表页显示分类名时,循环访问 product.category 会产生 N+1。使用 select_related 把外键 JOIN 到一次查询;多对多关系使用 prefetch_related。
products=Product.objects.select_related('category').filter(is_active=True).order_by('-created_at')打开 Django connection.queries 或使用 assertNumQueries 记录查询次数;不要为了“看起来 ORM”把所有商品先转成 list 再 Python 过滤。课后练习写一个详情页测试,证明列表查询不会随商品数量增长。
五、把 QuerySet 当作“延迟执行的查询计划”
Product.objects.filter(...)不会马上访问数据库,只有迭代、list()、count()或序列化时才执行。这个特性允许我们在不同条件下逐步构造查询,但也意味着在循环里调用count()会产生很多 SQL。示例:
fromdjango.db.modelsimportQdefvisible_products(params):qs=Product.objects.filter(is_active=True)ifkeyword:=params.get("q"):qs=qs.filter(Q(name__icontains=keyword)|Q(category__name__icontains=keyword))ifparams.get("low_stock"):qs=qs.filter(stock__lt=5)returnqs.select_related("category").order_by("id")Q让名称和分类名使用 OR;最终查询仍由数据库参数化,特殊字符不会改变 SQL 结构。order_by("id")为分页提供稳定顺序。
六、N+1 实验:用 assertNumQueries 说服自己
fromdjango.testimportTestCaseclassProductQueryTests(TestCase):deftest_list_does_not_grow_queries(self):ProductFactory.create_batch(10)withself.assertNumQueries(1):list(Product.objects.select_related("category").values("name","category__name"))去掉select_related后,模板中逐个访问product.category.name会额外执行 10 次查询。多对多标签应使用prefetch_related,它通常是两条 SQL,而不是一条 JOIN;选择哪种优化要看关系基数和返回字段。
七、上一篇练习讲解、输出与故障排查
上一篇的stock >= 0约束必须同时在模型验证和数据库中存在;full_clean()不会自动在save()时执行,所以服务层仍应主动调用。运行库存筛选:
python manage.py shell-c"from shop.models import Product; print(list(Product.objects.filter(stock__lt=5).values_list('name','stock')))"若查询结果包含下架商品,检查是否漏了is_active=True;若 SQL 数量超标,用 Django Debug Toolbar 或connection.queries定位循环里的隐式查询。
八、本篇练习
实现商品详情 API,返回分类名和库存,并写测试证明查询次数固定为 1;再增加按价格区间筛选。下一篇将把这些字段配置到 Admin,形成运营可用的后台。
九、查看 ORM 生成的 SQL
开发环境可以临时打开:
fromdjango.dbimportconnection qs=Product.objects.select_related("category").filter(stock__lt=5)list(qs)print(connection.queries[-1]["sql"])看到的 SQL 应包含 JOIN 和参数占位符,而不是把用户关键词直接拼进字符串。生产环境不要长期开启connection.queries,它会保存每条 SQL 并增加内存开销;改用数据库慢查询日志和采样 APM。把 QuerySet 写成函数后,视图、导出任务和测试都能复用同一过滤逻辑。
十、先写查询契约再优化
给visible_products写契约测试:下架商品永远不出现;关键词同时匹配商品名和分类;库存筛选与关键词可以组合;结果按 id 稳定排序。优化select_related后重新运行assertNumQueries,如果字段需求改变导致 JOIN 不再必要,再删掉优化。性能优化应由测试和 SQL 证据驱动,而不是凭“ORM 很慢”的印象。
本篇结束:完整模块文件
下面是交付项目中真实存在的完整文件 django-shop/shop/models.py。它覆盖本篇新增逻辑以及前文已经完成的依赖代码;复制单个函数会丢失上下文,因此这里提供整份文件。
fromdjango.confimportsettingsfromdjango.dbimportmodelsclassCategory(models.Model):name=models.CharField(max_length=60,unique=True)slug=models.SlugField(max_length=60,unique=True)def__str__(self):returnself.nameclassProduct(models.Model):category=models.ForeignKey(Category,on_delete=models.PROTECT,related_name='products')name=models.CharField(max_length=120)slug=models.SlugField(max_length=120,unique=True)price=models.DecimalField(max_digits=10,decimal_places=2)stock=models.PositiveIntegerField(default=0)is_active=models.BooleanField(default=True)created_at=models.DateTimeField(auto_now_add=True)def__str__(self):returnself.nameclassOrder(models.Model):classStatus(models.TextChoices):PENDING='pending','待处理'PAID='paid','已支付'CANCELLED='cancelled','已取消'user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,related_name='orders')status=models.CharField(max_length=20,choices=Status.choices,default=Status.PENDING)total_amount=models.DecimalField(max_digits=12,decimal_places=2,default=0)created_at=models.DateTimeField(auto_now_add=True)classOrderItem(models.Model):order=models.ForeignKey(Order,on_delete=models.CASCADE,related_name='items')product=models.ForeignKey(Product,on_delete=models.PROTECT)product_name=models.CharField(max_length=120)price=models.DecimalField(max_digits=10,decimal_places=2)quantity=models.PositiveIntegerField()