04-Flask数据库操作SQLAlchemy

Flask数据库操作SQLAlchemy

本文是Flask服务器专栏的第四篇,系统讲解Flask与SQLAlchemy数据库操作。从ORM基础到高级查询,从模型定义到数据库迁移,从性能优化到实战案例,助你掌握Flask数据库开发核心技能。

引言

在现代Web应用开发中,数据库操作是最核心的功能之一。Flask本身不内置数据库功能,而是通过扩展与Python最强大的ORM框架SQLAlchemy深度集成,为开发者提供了一套完整、高效、优雅的数据库操作方案。本文将从零开始,系统讲解Flask数据库开发的完整知识体系,共分为十章,助你掌握Flask数据库开发核心技能。

各参数详解:

参数 默认值 说明
SQLALCHEMY_DATABASE_URI None 主数据库连接URI(必填)
SQLALCHEMY_BINDS {} 多数据库绑定配置字典
SQLALCHEMY_ENGINE_OPTIONS {} 引擎选项(连接池等),传给create_engine
SQLALCHEMY_ECHO False 是否在日志中打印执行的SQL语句(调试用)
SQLALCHEMY_RECORD_QUERIES None 是否记录查询统计信息(用于性能分析)
SQLALCHEMY_TRACK_MODIFICATIONS False 是否追踪对象修改(3.x已废弃,建议设为False)
2.3.1 SQLALCHEMY_TRACK_MODIFICATIONS

这个参数在Flask-SQLAlchemy 3.x中已不再需要,建议显式设为False以避免警告:

python 复制代码
# False: 不追踪修改(推荐,节省内存)
# True: 追踪对象修改,会额外内存开销(不推荐)
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
2.3.2 SQLALCHEMY_ECHO

开发调试时非常有用的参数,会在日志中打印所有执行的SQL:

python 复制代码
# 开发环境开启,生产环境关闭
app.config['SQLALCHEMY_ECHO'] = True  # 开发环境
# 日志输出示例:
# 2024-01-01 10:00:00 INFO sqlalchemy.engine.Engine SELECT users.id, users.name FROM users WHERE users.name = ?
# 2024-01-01 10:00:00 INFO sqlalchemy.engine.Engine [generated in 0.00012s] ('张三',)
2.3.3 SQLALCHEMY_ENGINE_OPTIONS

这是最重要的配置之一,用于控制数据库引擎和连接池行为:

python 复制代码
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    # --- 连接池配置 ---
    'pool_size': 10,            # 连接池中保持的连接数(默认5)
    'max_overflow': 5,          # 超出pool_size后允许的额外连接数(默认10)
    'pool_timeout': 30,         # 获取连接的超时时间(秒,默认30)
    'pool_recycle': 3600,       # 连接自动回收时间(秒,默认-1不回收)
    'pool_pre_ping': True,      # 使用连接前先检测是否有效(推荐开启)

    # --- 日志配置 ---
    'echo': False,              # 是否打印SQL(同SQLALCHEMY_ECHO)
    'echo_pool': False,         # 是否打印连接池日志

    # --- 其他配置 ---
    'isolation_level': 'READ_COMMITTED',  # 事务隔离级别
    'future': True,             # 使用SQLAlchemy 2.0风格(默认True)
}

2.4 初始化SQLAlchemy实例

初始化是Flask-SQLAlchemy使用的第一步,有两种方式:直接初始化和延迟初始化。

2.4.1 直接初始化(简单项目)
python 复制代码
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

# 创建Flask应用
app = Flask(__name__)

# 配置数据库
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

# 初始化SQLAlchemy ------ 直接传入app
db = SQLAlchemy(app)

# 定义模型
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)

# 使用
with app.app_context():
    db.create_all()
    user = User(username='张三')
    db.session.add(user)
    db.session.commit()
2.4.2 延迟初始化(推荐,适合大型项目)
python 复制代码
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

# 先创建db对象,不传入app
db = SQLAlchemy()

def create_app():
    """应用工厂函数"""
    app = Flask(__name__)
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

    # 延迟初始化 ------ 此时才关联app和db
    db.init_app(app)

    # 在应用上下文中创建表
    with app.app_context():
        db.create_all()

    return app

if __name__ == '__main__':
    app = create_app()
    app.run(debug=True)

2.5 工厂模式中的数据库初始化

在大型Flask项目中,通常使用工厂模式(Application Factory Pattern)来创建应用实例。这种模式下,数据库的初始化需要特别处理。

2.5.1 完整的工厂模式项目结构
复制代码
myapp/
├── app/
│   ├── __init__.py          # 应用工厂
│   ├── extensions.py        # 扩展实例(不绑定app)
│   ├── models.py            # 数据模型
│   ├── config.py            # 配置
│   ├── auth/
│   │   ├── __init__.py
│   │   └── routes.py
│   └── blog/
│       ├── __init__.py
│       └── routes.py
├── migrations/              # 迁移脚本
├── tests/
├── run.py                   # 启动脚本
└── requirements.txt

app/extensions.py ------ 存放扩展实例:

python 复制代码
"""
扩展实例文件
所有扩展在这里创建,但不绑定到具体的app
在应用工厂函数中通过init_app()方法绑定
"""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

# 创建SQLAlchemy实例(尚未绑定到任何app)
db = SQLAlchemy()
# 创建Migrate实例(用于数据库迁移)
migrate = Migrate()

app/config.py ------ 配置文件:

python 复制代码
import os
from pathlib import Path

basedir = Path(__file__).resolve().parent.parent

class Config:
    """基础配置"""
    SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')

    # 数据库配置
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        'DATABASE_URL',
        f'sqlite:///{basedir / "app.db"}'
    )
    SQLALCHEMY_TRACK_MODIFICATIONS = False

    # 引擎选项
    SQLALCHEMY_ENGINE_OPTIONS = {
        'pool_size': 10,
        'max_overflow': 5,
        'pool_timeout': 30,
        'pool_recycle': 3600,
        'pool_pre_ping': True,
    }

class DevelopmentConfig(Config):
    """开发环境配置"""
    DEBUG = True
    SQLALCHEMY_ECHO = True  # 开发环境打印SQL

class ProductionConfig(Config):
    """生产环境配置"""
    DEBUG = False
    SQLALCHEMY_ECHO = False
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')

class TestingConfig(Config):
    """测试环境配置"""
    TESTING = True
    SQLALCHEMY_DATABASE_URI = 'sqlite://'  # 内存数据库
    SQLALCHEMY_ENGINE_OPTIONS = {}  # 测试不需要连接池

# 配置映射
config = {
    'development': DevelopmentConfig,
    'production': ProductionConfig,
    'testing': TestingConfig,
    'default': DevelopmentConfig,
}

app/__init__.py ------ 应用工厂:

python 复制代码
"""
应用工厂模块
通过create_app()函数创建和配置Flask应用
"""
from flask import Flask
from app.config import config
from app.extensions import db, migrate

def create_app(config_name='default'):
    """创建Flask应用实例

    Args:
        config_name: 配置名称(development/production/testing)

    Returns:
        Flask应用实例
    """
    app = Flask(__name__)

    # 加载配置
    app.config.from_object(config[config_name])

    # 初始化扩展
    db.init_app(app)
    migrate.init_app(app, db)  # 必须在db.init_app之后

    # 注册蓝图
    from app.auth import auth_bp
    from app.blog import blog_bp
    app.register_blueprint(auth_bp)
    app.register_blueprint(blog_bp)

    # 导入模型(确保模型被SQLAlchemy注册)
    from app import models

    return app

run.py ------ 启动脚本:

python 复制代码
import os
from app import create_app

# 从环境变量读取配置名,默认使用development
config_name = os.environ.get('FLASK_CONFIG', 'development')
app = create_app(config_name)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

2.6 多数据库绑定

在某些场景下,一个应用可能需要连接多个数据库。例如:用户数据在一个数据库,日志数据在另一个数据库。Flask-SQLAlchemy通过SQLALCHEMY_BINDS支持多数据库绑定。

2.6.1 配置多数据库
python 复制代码
app = Flask(__name__)

# 主数据库
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:pass@localhost/main_db'

# 绑定的其他数据库
app.config['SQLALCHEMY_BINDS'] = {
    'users': 'mysql+pymysql://root:pass@localhost/users_db',
    'logs': 'postgresql://user:pass@localhost/logs_db',
    'cache': 'sqlite:///cache.db',
}
2.6.2 在模型中指定数据库
python 复制代码
db = SQLAlchemy(app)

class User(db.Model):
    """用户模型 ------ 存储在users数据库"""
    __bind_key__ = 'users'
    __tablename__ = 'users'

    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)

class AccessLog(db.Model):
    """访问日志模型 ------ 存储在logs数据库"""
    __bind_key__ = 'logs'
    __tablename__ = 'access_logs'

    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, nullable=False)
    ip_address = db.Column(db.String(45))
    accessed_at = db.Column(db.DateTime, server_default=db.func.now())

class CacheItem(db.Model):
    """缓存模型 ------ 存储在cache数据库"""
    __bind_key__ = 'cache'
    __tablename__ = 'cache_items'

    id = db.Column(db.Integer, primary_key=True)
    key = db.Column(db.String(200), unique=True, nullable=False)
    value = db.Column(db.Text)
    expires_at = db.Column(db.DateTime)

class Article(db.Model):
    """文章模型 ------ 存储在主数据库(不指定__bind_key__)"""
    __tablename__ = 'articles'

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    content = db.Column(db.Text)
2.6.3 操作多数据库
python 复制代码
with app.app_context():
    # 创建所有数据库的表
    db.create_all()

    # 也可以只创建指定bind的表
    db.create_all(bind_key='users')

    # 操作不同数据库的模型 ------ 使用方式完全一致
    user = User(username='张三', email='zhangsan@example.com')
    db.session.add(user)  # 自动路由到users数据库

    log = AccessLog(user_id=1, ip_address='192.168.1.1')
    db.session.add(log)  # 自动路由到logs数据库

    article = Article(title='Flask教程', content='...')
    db.session.add(article)  # 路由到主数据库

    db.session.commit()  # 一次性提交所有数据库的变更
2.6.4 多数据库绑定的注意事项
python 复制代码
# 注意: 跨数据库的外键关系通常不被支持!
class User(db.Model):
    __bind_key__ = 'users'
    id = db.Column(db.Integer, primary_key=True)

class Article(db.Model):
    # 主数据库的表不能引用users数据库的外键
    # author_id = db.Column(db.Integer, db.ForeignKey('users.id'))  # 这会报错!
    author_id = db.Column(db.Integer)  # 只能用普通字段,在应用层维护关系

2.7 连接池配置

连接池(Connection Pool)是数据库性能优化的关键。它维护一组数据库连接,避免频繁创建和销毁连接的开销。

2.7.1 连接池工作原理
复制代码
应用代码                        连接池                    数据库
  │                              │                         │
  │── 获取连接 ──────────────────>│                         │
  │<── 返回空闲连接 ──────────────│                         │
  │                              │                         │
  │── 执行SQL ─────────────────────────────────────────────>│
  │<── 返回结果 ────────────────────────────────────────────│
  │                              │                         │
  │── 归还连接 ──────────────────>│                         │
  │                              │                         │
2.7.2 连接池参数详解
python 复制代码
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'pool_size': 10,        # 连接池大小
    'max_overflow': 5,      # 最大溢出连接数
    'pool_timeout': 30,     # 获取连接超时时间
    'pool_recycle': 3600,   # 连接回收时间
    'pool_pre_ping': True,  # 连接前预检测
}

各参数的含义和工作机制:

python 复制代码
# 假设配置: pool_size=10, max_overflow=5
#
# 连接数变化:
# ┌─────────────────────────────────────────────────┐
# │ 状态              │ 活跃连接 │ 空闲连接 │ 总连接 │
# ├─────────────────────────────────────────────────┤
# │ 初始(无请求)      │    0     │    0     │    0   │
# │ 5个并发请求        │    5     │    0     │    5   │
# │ 请求完成(归还5个)  │    0     │    5     │    5   │
# │ 15个并发请求       │   10     │    0     │   10   │ (达到pool_size)
# │ 又来5个请求(溢出)  │   15     │    0     │   15   │ (达到pool_size+max_overflow)
# │ 又来1个请求        │  等待... │    0     │   15   │ (等待pool_timeout秒后报错)
# └─────────────────────────────────────────────────┘
2.7.3 不同场景的连接池配置建议
python 复制代码
# ========== 开发环境 ==========
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'pool_size': 5,
    'max_overflow': 2,
    'pool_timeout': 10,
    'pool_pre_ping': True,
}

# ========== 生产环境(中型应用) ==========
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'pool_size': 20,
    'max_overflow': 10,
    'pool_timeout': 30,
    'pool_recycle': 1800,   # 30分钟回收,避免数据库端超时断开
    'pool_pre_ping': True,
}

# ========== 生产环境(高并发) ==========
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'pool_size': 50,
    'max_overflow': 20,
    'pool_timeout': 60,
    'pool_recycle': 1200,   # 20分钟回收
    'pool_pre_ping': True,
}

# ========== SQLite(不支持连接池,使用QueuePool的替代方案) ==========
# SQLite默认使用SingletonThreadPool或NullPool
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'poolclass': 'sqlalchemy.pool.SingletonThreadPool',  # 每个线程一个连接
}
2.7.4 pool_pre_ping的重要性
python 复制代码
# 问题场景: 数据库连接被服务端断开(超时、重启等)
# 如果不开pool_pre_ping,会抛出异常:
# sqlalchemy.exc.OperationalError: (pymysql.err.OperationalError)
# (2006, "MySQL server has gone away")

# 开启pool_pre_ping后,每次从连接池取连接前先执行一个简单查询
# 如果连接已断开,自动重新创建连接
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'pool_pre_ping': True,  # 强烈建议开启!
}
2.7.5 自定义连接池类
python 复制代码
from sqlalchemy.pool import QueuePool, NullPool, SingletonThreadPool

# NullPool: 不使用连接池,每次都创建新连接(适合短生命周期的脚本)
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'poolclass': NullPool,
}

# QueuePool: 标准连接池(默认)
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'poolclass': QueuePool,
    'pool_size': 10,
    'max_overflow': 5,
}

2.8 数据库连接最佳实践

2.8.1 环境变量管理敏感信息
python 复制代码
import os
from urllib.parse import quote_plus

# 从环境变量读取数据库配置,避免在代码中硬编码
DB_USER = os.environ.get('DB_USER', 'root')
DB_PASSWORD = os.environ.get('DB_PASSWORD', '')
DB_HOST = os.environ.get('DB_HOST', 'localhost')
DB_PORT = os.environ.get('DB_PORT', '3306')
DB_NAME = os.environ.get('DB_NAME', 'myapp')

# 密码URL编码(处理特殊字符)
encoded_password = quote_plus(DB_PASSWORD)

SQLALCHEMY_DATABASE_URI = (
    f'mysql+pymysql://{DB_USER}:{encoded_password}'
    f'@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4'
)

使用.env文件管理环境变量:

python 复制代码
# 安装python-dotenv: pip install python-dotenv
from dotenv import load_dotenv
load_dotenv()  # 从.env文件加载环境变量

# .env文件内容:
# DB_USER=root
# DB_PASSWORD=my_secret_password
# DB_HOST=localhost
# DB_PORT=3306
# DB_NAME=myapp
# SECRET_KEY=your-secret-key
2.8.2 正确处理Session生命周期
python 复制代码
from flask import Flask
from contextlib import contextmanager

app = Flask(__name__)
db = SQLAlchemy(app)

@contextmanager
def session_scope():
    """提供事务范围的上下文管理器

    使用方式:
    with session_scope() as session:
        session.add(user)
        # ... 其他操作
    # 退出with块时自动commit或rollback
    """
    session = db.session
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()

# 使用示例
@app.route('/transfer', methods=['POST'])
def transfer():
    try:
        with session_scope() as session:
            from_account = session.get(Account, request.form['from_id'])
            to_account = session.get(Account, request.form['to_id'])
            amount = float(request.form['amount'])

            from_account.balance -= amount
            to_account.balance += amount
            # 退出with块时自动提交

        return '转账成功!'
    except Exception as e:
        return f'转账失败: {e}', 400

2.9 完整配置示例

下面是一个生产级Flask应用的完整数据库配置示例:

python 复制代码
"""
完整配置示例 ------ 生产级Flask应用的数据库配置
"""
import os
from pathlib import Path
from urllib.parse import quote_plus
from dotenv import load_dotenv

# 加载.env文件
load_dotenv()

basedir = Path(__file__).resolve().parent.parent


class Config:
    """基础配置类"""

    # Flask配置
    SECRET_KEY = os.environ.get('SECRET_KEY', 'change-me-in-production')

    # ====== 数据库主配置 ======
    # 优先使用环境变量中的DATABASE_URL
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        'DATABASE_URL',
        f'sqlite:///{basedir / "instance" / "app.db"}'
    )

    # 禁用修改追踪(节省内存)
    SQLALCHEMY_TRACK_MODIFICATIONS = False

    # ====== 引擎选项(连接池) ======
    SQLALCHEMY_ENGINE_OPTIONS = {
        # 连接池大小
        'pool_size': int(os.environ.get('DB_POOL_SIZE', 10)),
        # 最大溢出连接数
        'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', 5)),
        # 获取连接超时(秒)
        'pool_timeout': int(os.environ.get('DB_POOL_TIMEOUT', 30)),
        # 连接回收周期(秒) ------ 防止数据库端断开连接
        'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', 1800)),
        # 使用前预检测连接有效性 ------ 防止"server has gone away"
        'pool_pre_ping': True,
    }

    # ====== 多数据库绑定(可选) ======
    SQLALCHEMY_BINDS = {}

    @staticmethod
    def init_app(app):
        """应用初始化钩子"""
        # 确保instance目录存在
        instance_dir = basedir / 'instance'
        instance_dir.mkdir(exist_ok=True)


class DevelopmentConfig(Config):
    """开发环境配置"""
    DEBUG = True
    SQLALCHEMY_ECHO = os.environ.get('SQL_ECHO', 'true').lower() == 'true'
    SQLALCHEMY_ENGINE_OPTIONS = {
        **Config.SQLALCHEMY_ENGINE_OPTIONS,
        'pool_size': 5,
        'max_overflow': 2,
    }


class ProductionConfig(Config):
    """生产环境配置"""
    DEBUG = False

    @staticmethod
    def init_app(app):
        Config.init_app(app)

        # 生产环境日志配置
        import logging
        from logging.handlers import RotatingFileHandler

        # 确保日志目录存在
        log_dir = basedir / 'logs'
        log_dir.mkdir(exist_ok=True)

        handler = Rota
## 第四章 数据库操作: CRUD

CRUD是Create(创建)、Read(读取)、Update(更新)、Delete(删除)四种基本数据库操作的缩写。本章将全面讲解Flask-SQLAlchemy中的CRUD操作,包括Session管理、事务控制和并发控制等核心知识。

### 4.1 创建记录

创建记录是将新的数据行插入到数据库表中的操作。

#### 4.1.1 创建单条记录

```python
from datetime import datetime

# 创建模型实例
user = User(
    username='zhangsan',
    email='zhangsan@example.com',
    is_active=True
)

# 方式1: 使用db.session.add()添加到session,然后commit
db.session.add(user)       # 将对象添加到session(暂存区)
db.session.commit()        # 提交事务,真正写入数据库
# 提交后,user.id会被自动赋值(获取自增主键的值)
print(f"新用户ID: {user.id}")

# 方式2: 在Flask视图函数中创建记录
@app.route('/register', methods=['POST'])
def register():
    username = request.form.get('username')
    email = request.form.get('email')
    password = request.form.get('password')

    # 检查用户名是否已存在
    existing = db.session.execute(
        select(User).where(User.username == username)
    ).scalar_one_or_none()

    if existing:
        return '用户名已存在', 400

    # 创建新用户
    user = User(username=username, email=email)
    user.set_password(password)

    db.session.add(user)
    db.session.commit()

    return f'注册成功!用户ID: {user.id}', 201
4.1.2 创建多条记录
python 复制代码
# 方式1: 使用add_all()批量添加
users = [
    User(username='user1', email='user1@example.com'),
    User(username='user2', email='user2@example.com'),
    User(username='user3', email='user3@example.com'),
    User(username='user4', email='user4@example.com'),
    User(username='user5', email='user5@example.com'),
]

db.session.add_all(users)  # 批量添加到session
db.session.commit()        # 一次性提交
# 所有用户都会被插入,各自获得自增ID

# 方式2: 循环添加(不推荐,效率较低)
# for user in users:
#     db.session.add(user)
# db.session.commit()

# 方式3: 批量插入(高性能,跳过ORM对象创建)
# 适用于大批量数据导入
db.session.execute(
    db.insert(User),
    [
        {'username': 'user6', 'email': 'user6@example.com'},
        {'username': 'user7', 'email': 'user7@example.com'},
        {'username': 'user8', 'email': 'user8@example.com'},
    ]
)
db.session.commit()
4.1.3 创建带关系的记录
python 复制代码
# 创建带一对多关系的记录
category = Category(name='Python')
category.articles = [
    Article(title='Python基础'),
    Article(title='Python进阶'),
    Article(title='Python高级'),
]
db.session.add(category)  # 级联添加: category和3个articles都会被添加
db.session.commit()

# 创建带多对多关系的记录
flask_tag = Tag(name='Flask')
python_tag = Tag(name='Python')

article = Article(title='Flask教程')
article.tags = [flask_tag, python_tag]  # 设置多对多关系

db.session.add_all([flask_tag, python_tag, article])
db.session.commit()
4.1.4 get_or_create模式
python 复制代码
def get_or_create(model, defaults=None, **kwargs):
    """获取或创建记录

    如果满足条件的记录已存在,返回该记录;
    如果不存在,创建新记录并返回。

    Args:
        model: 模型类
        defaults: 创建时的默认值字典
        **kwargs: 查询条件
    """
    instance = db.session.execute(
        select(model).filter_by(**kwargs)
    ).scalar_one_or_none()

    if instance:
        return instance, False  # 已存在,返回(实例, False)

    # 不存在,创建新记录
    params = kwargs.copy()
    if defaults:
        params.update(defaults)

    instance = model(**params)
    db.session.add(instance)
    db.session.commit()
    return instance, True  # 新创建,返回(实例, True)

# 使用示例
tag, created = get_or_create(Tag, name='Flask')
if created:
    print(f"创建了新标签: {tag.name}")
else:
    print(f"标签已存在: {tag.name}")

4.2 查询记录

查询是数据库操作中最常用的操作。SQLAlchemy 2.0推荐使用select()语句来构建查询。

4.2.1 基本查询
python 复制代码
from sqlalchemy import select

# ========== SQLAlchemy 2.0 风格 ==========

# 查询所有记录
stmt = select(User)
users = db.session.execute(stmt).scalars().all()
# scalars()提取每一行的第一个元素(即User对象)
# all()将结果转换为列表

# 查询单条记录(按主键)
user = db.session.get(User, 1)  # 按主键查询,最高效的方式
# 等价于: user = db.session.execute(select(User).where(User.id == 1)).scalar_one()

# 查询第一条记录
stmt = select(User).where(User.is_active == True)
user = db.session.execute(stmt).scalars().first()  # 返回第一条或None

# 查询唯一记录(如果结果不唯一会抛异常)
stmt = select(User).where(User.username == 'zhangsan')
user = db.session.execute(stmt).scalar_one()  # 结果必须恰好一条,否则报错

# 查询唯一记录或None(如果结果为空返回None,多条报错)
stmt = select(User).where(User.email == 'zhangsan@example.com')
user = db.session.execute(stmt).scalar_one_or_none()
4.2.2 查询指定列
python 复制代码
from sqlalchemy import select

# 查询指定列(返回Row对象,不是模型实例)
stmt = select(User.username, User.email)
result = db.session.execute(stmt).all()
for row in result:
    print(row.username, row.email)  # 通过属性名访问

# 查询指定列并转为字典
stmt = select(User.id, User.username, User.email)
result = db.session.execute(stmt)
users_list = [row._mapping for row in result]  # 转为字典列表

# 使用with_entities指定列
# SQLAlchemy 2.0中,with_entities仍然可用
users = db.session.query(User).with_entities(User.id, User.username).all()

4.3 查询过滤器

过滤器用于在查询中添加条件,缩小查询范围。

4.3.1 filter vs filter_by
python 复制代码
# ========== filter_by: 使用关键字参数,简洁但功能有限 ==========
# 适合简单的等值查询
users = db.session.execute(
    select(User).filter_by(username='zhangsan', is_active=True)
).scalars().all()

# ========== filter: 使用表达式,功能强大 ==========
# 支持各种比较运算符
users = db.session.execute(
    select(User).filter(
        User.username == 'zhangsan',      # 等于
        User.is_active == True,           # 等于
    )
).scalars().all()
4.3.2 排序: order_by
python 复制代码
from sqlalchemy import select, desc, asc

# 升序排列(默认)
users = db.session.execute(
    select(User).order_by(User.username)  # 按用户名升序
).scalars().all()

# 降序排列
users = db.session.execute(
    select(User).order_by(desc(User.created_at))  # 按创建时间降序
).scalars().all()

# 多列排序
users = db.session.execute(
    select(User).order_by(
        User.is_active.desc(),   # 先按是否激活降序(激活的在前)
        User.username.asc(),     # 再按用户名升序
    )
).scalars().all()

# NULL值的排序位置
from sqlalchemy import nullsfirst, nullslast
users = db.session.execute(
    select(User).order_by(
        nullslast(User.last_login)  # NULL值排在最后
    )
).scalars().all()
4.3.3 分组与聚合: group_by, having
python 复制代码
from sqlalchemy import func, select

# 按状态分组,统计每种状态的文章数量
stmt = (
    select(Article.status, func.count(Article.id).label('count'))
    .group_by(Article.status)
)
result = db.session.execute(stmt).all()
for row in result:
    print(f"状态: {row.status}, 数量: {row.count}")

# 使用having过滤分组结果(只显示文章数大于5的状态)
stmt = (
    select(Article.status, func.count(Article.id).label('count'))
    .group_by(Article.status)
    .having(func.count(Article.id) > 5)
)
result = db.session.execute(stmt).all()

# 按分类分组,统计每个分类的文章数和总浏览量
stmt = (
    select(
        Category.name.label('category_name'),
        func.count(Article.id).label('article_count'),
        func.sum(Article.view_count).label('total_views'),
        func.avg(Article.view_count).label('avg_views'),
    )
    .join(Article, Category.id == Article.category_id)
    .group_by(Category.id, Category.name)
    .having(func.count(Article.id) > 0)
    .order_by(func.sum(Article.view_count).desc())
)
result = db.session.execute(stmt).all()
4.3.4 去重: distinct
python 复制代码
from sqlalchemy import distinct

# 查询所有不同的城市
cities = db.session.execute(
    select(distinct(UserProfile.address))
).scalars().all()

# 查询文章中不同的标签组合
stmt = select(Article.id, Article.title).distinct()
articles = db.session.execute(stmt).scalars().all()

# 统计不同城市的数量
city_count = db.session.execute(
    select(func.count(distinct(UserProfile.address)))
).scalar()
4.3.5 限制与偏移: limit, offset
python 复制代码
# 查询前10条记录
users = db.session.execute(
    select(User).limit(10)
).scalars().all()

# 跳过前20条,查询10条(第3页,每页10条)
users = db.session.execute(
    select(User).offset(20).limit(10)
).scalars().all()

# 使用切片(更Pythonic)
# 注意: 在SQLAlchemy 2.0中,select语句不支持切片
# 需要使用limit和offset
page = 3
per_page = 10
offset = (page - 1) * per_page
users = db.session.execute(
    select(User).offset(offset).limit(per_page)
).scalars().all()

4.4 常用查询函数

python 复制代码
from sqlalchemy import select, func

# ========== count: 统计记录数 ==========
# 方式1
total = db.session.execute(
    select(func.count(User.id))
).scalar()

# 方式2: 统计满足条件的记录数
active_count = db.session.execute(
    select(func.count(User.id)).where(User.is_active == True)
).scalar()

# 方式3: 使用select与exists
from sqlalchemy import exists
has_admin = db.session.execute(
    select(exists().where(User.is_admin == True))
).scalar()

# ========== first: 获取第一条 ==========
first_user = db.session.execute(
    select(User).order_by(User.id)
).scalars().first()  # 返回User对象或None

# ========== one: 获取唯一一条(不存在或多条都会报错) ==========
try:
    user = db.session.execute(
        select(User).where(User.username == 'zhangsan')
    ).scalar_one()
except NoResultFound:
    print("未找到记录")
except MultipleResultsFound:
    print("找到多条记录")

# ========== scalar: 获取标量值(第一行第一列) ==========
count = db.session.execute(
    select(func.count(User.id))
).scalar()  # 直接返回整数值

# ========== get: 按主键查询(最优化) ==========
user = db.session.get(User, 1)  # 按主键查询,利用identity map缓存

# ========== exists: 检查记录是否存在 ==========
user_exists = db.session.execute(
    select(User.id).where(User.email == 'zhangsan@example.com')
).first() is not None

4.5 更新记录

4.5.1 直接修改属性
python 复制代码
# 方式1: 查询后修改属性
user = db.session.get(User, 1)
if user:
    user.email = 'new_email@example.com'
    user.is_active = False
    db.session.commit()  # 提交修改

# 方式2: 在视图函数中更新
@app.route('/user/<int:user_id>/update', methods=['POST'])
def update_user(user_id):
    user = db.session.get(User, user_id)
    if not user:
        return '用户不存在', 404

    if 'email' in request.form:
        user.email = request.form['email']
    if 'username' in request.form:
        user.username = request.form['username']

    db.session.commit()
    return '更新成功'
4.5.2 批量更新
python 复制代码
from sqlalchemy import update

# 方式1: 使用update()语句批量更新(SQLAlchemy 2.0推荐)
# 将所有状态为draft的文章的状态更新为archived
db.session.execute(
    update(Article)
    .where(Article.status == 'draft')
    .values(status='archived', updated_at=datetime.utcnow())
)
db.session.commit()

# 方式2: 使用 synchronize_session 控制session同步策略
db.session.execute(
    update(Article)
    .where(Article.status == 'draft')
    .values(status='archived')
    .execution_options(synchronize_session='fetch')
    # synchronize_session选项:
    # 'auto': 自动选择(默认)
    # 'fetch': 先查询受影响的行,再更新session中的对象
    # 'evaluate': 在Python层面评估条件,更新匹配的session对象
    # False: 不同步session(更新后session中的对象可能过期)
)
db.session.commit()

# 方式3: 查询后循环修改(不推荐大数据量)
articles = db.session.execute(
    select(Article).where(Article.status == 'draft')
).scalars().all()
for article in articles:
    article.status = 'archived'
db.session.commit()
# 这种方式会产生N条UPDATE语句,效率低
4.5.3 更新JSON字段
python 复制代码
# 更新整个JSON字段
user = db.session.get(User, 1)
user.metadata_json = {'theme': 'dark', 'language': 'zh-CN'}
db.session.commit()

# PostgreSQL: 更新JSON字段的某个键
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy import update
from sqlalchemy.dialects.postgresql import JSONB

# 使用JSON路径更新(PostgreSQL特有)
db.session.execute(
    update(User)
    .where(User.id == 1)
    .values(
        metadata_json=User.metadata_json['theme'].astext == 'dark'
    )
)

4.6 删除记录

4.6.1 删除单条记录
python 复制代码
# 方式1: 查询后删除
user = db.session.get(User, 1)
if user:
    db.session.delete(user)  # 从session标记为删除
    db.session.commit()      # 提交,真正从数据库删除

# 方式2: 在视图函数中删除
@app.route('/user/<int:user_id>/delete', methods=['POST'])
def delete_user(user_id):
    user = db.session.get(User, user_id)
    if not user:
        return '用户不存在', 404

    db.session.delete(user)
    db.session.commit()
    return '删除成功'
4.6.2 批量删除
python 复制代码
from sqlalchemy import delete

# 方式1: 使用delete()语句批量删除(SQLAlchemy 2.0推荐)
# 删除所有未激活的用户
result = db.session.execute(
    delete(User).where(User.is_active == False)
)
db.session.commit()
print(f"删除了 {result.rowcount} 条记录")

# 方式2: 查询后循环删除(不推荐大数据量)
inactive_users = db.session.execute(
    select(User).where(User.is_active == False)
).scalars().all()
for user in inactive_users:
    db.session.delete(user)
db.session.commit()

# 方式3: 软删除(推荐,数据可恢复)
db.session.execute(
    update(User)
    .where(User.is_active == False)
    .values(is_deleted=True, updated_at=datetime.utcnow())
)
db.session.commit()

4.7 事务管理

事务是数据库操作的核心概念。Session本身就是事务的载体。

4.7.1 基本事务操作
python 复制代码
# ========== commit: 提交事务 ==========
# 将session中所有挂起的变更写入数据库
db.session.commit()

# ========== rollback: 回滚事务 ==========
# 撤销所有未提交的变更
try:
    user1 = User(username='user1', email='user1@example.com')
    user2 = User(username='user2', email='user1@example.com')  # 邮箱重复!
    db.session.add_all([user1, user2])
    db.session.commit()
except Exception as e:
    db.session.rollback()  # 回滚,撤销所有变更
    print(f"操作失败: {e}")

# ========== flush: 刷新(不提交) ==========
# 将session中的变更发送到数据库,但不提交事务
# 主要用于获取自增ID或验证约束
user = User(username='flush_test', email='flush@example.com')
db.session.add(user)
db.session.flush()  # 发送到数据库,获取user.id
print(f"用户ID: {user.id}")  # 此时可以获取ID
# 但事务尚未提交,其他连接看不到这条记录
db.session.commit()  # 正式提交

# ========== close: 关闭session ==========
db.session.close()  # 关闭session,释放资源
4.7.2 嵌套事务与SAVEPOINT
python 复制代码
# 使用begin_nested()创建SAVEPOINT(嵌套事务)
try:
    # 外层事务
    user = User(username='outer', email='outer@example.com')
    db.session.add(user)
    db.session.flush()

    # 内层事务(SAVEPOINT)
    try:
        nested = db.session.begin_nested()
        inner_user = User(username='inner', email='inner@example.com')
        db.session.add(inner_user)
        nested.commit()  # 提交SAVEPOINT
    except Exception:
        nested.rollback()  # 回滚SAVEPOINT,外层事务不受影响

    # 外层事务继续
    db.session.commit()  # 提交外层事务
except Exception:
    db.session.rollback()
4.7.3 事务上下文管理器
python 复制代码
from contextlib import contextmanager

@contextmanager
def transaction():
    """事务上下文管理器

    自动管理事务的提交和回滚
    """
    try:
        yield db.session
        db.session.commit()
    except Exception:
        db.session.rollback()
        raise

# 使用示例
@app.route('/transfer', methods=['POST'])
def transfer_money():
    from_id = int(request.form['from_id'])
    to_id = int(request.form['to_id'])
    amount = float(request.form['amount'])

    try:
        with transaction() as session:
            from_account = session.get(Account, from_id)
            to_account = session.get(Account, to_id)

            if from_account.balance < amount:
                raise ValueError("余额不足")

            from_account.balance -= amount
            to_account.balance += amount

        return '转账成功'
    except ValueError as e:
        return str(e), 400
    except Exception as e:
        return f'系统错误: {e}', 500

4.8 事务隔离级别

事务隔离级别控制并发事务之间的可见性。SQLAlchemy支持设置不同的事务隔离级别:

python 复制代码
from sqlalchemy import create_engine

# 在引擎配置中设置隔离级别
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'isolation_level': 'READ_COMMITTED',  # 设置隔离级别
}

# 事务隔离级别对照表:
# READ UNCOMMITTED  - 读未提交(脏读)
# READ COMMITTED    - 读已提交(防止脏读)  ← PostgreSQL/Oracle默认
# REPEATABLE READ   - 可重复读(防止脏读、不可重复读) ← MySQL默认
# SERIALIZABLE      - 串行化(最高隔离级别,防止所有并发问题)
python 复制代码
# 在运行时动态设置隔离级别
from sqlalchemy import text

# 设置当前会话的隔离级别
db.session.execute(text("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED"))

# 查询当前隔离级别
result = db.session.execute(text("SELECT @@transaction_isolation"))
isolation = result.scalar()
print(f"当前隔离级别: {isolation}")

4.9 乐观锁与悲观锁

在并发环境下,多个请求可能同时修改同一条记录,需要锁机制来保证数据一致性。

4.9.1 乐观锁

乐观锁假设冲突很少发生,通过版本号机制在更新时检测冲突:

python 复制代码
class Product(db.Model):
    __tablename__ = 'products'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    stock = db.Column(db.Integer, default=0)
    version = db.Column(db.Integer, default=0)  # 版本号,用于乐观锁
    price = db.Column(db.Numeric(10, 2))

def purchase_with_optimistic_lock(product_id, quantity):
    """使用乐观锁进行库存扣减

    如果在读取和更新之间,记录被其他事务修改了,
    更新会失败(影响行数为0),此时重试。
    """
    max_retries = 3
    for attempt in range(max_retries):
        # 1. 读取当前数据(包含版本号)
        product = db.session.get(Product, product_id)
        if not product:
            raise ValueError("商品不存在")

        if product.stock < quantity:
            raise ValueError("库存不足")

        # 2. 计算新库存
        new_stock = product.stock - quantity
        old_version = product.version

        # 3. 尝试更新(带版本号检查)
        # WHERE条件中包含version,如果version变了则更新0行
        result = db.session.execute(
            db.update(Product)
            .where(
                Product.id == product_id,
                Product.version == old_version  # 乐观锁检查
            )
            .values(
                stock=new_stock,
                version=old_version + 1  # 版本号+1
            )
        )

        if result.rowcount == 1:
            db.session.commit()
            return True  # 更新成功
        else:
            # 版本号不匹配,说明被其他事务修改了
            db.session.rollback()
            if attempt == max_retries - 1:
                raise ValueError("并发冲突,重试次数用尽")
            continue  # 重试

    return False
4.9.2 悲观锁

悲观锁假设冲突经常发生,在读取数据时就加锁:

python 复制代码
from sqlalchemy import select, with_for_update

def purchase_with_pessimistic_lock(product_id, quantity):
    """使用悲观锁进行库存扣减

    SELECT ... FOR UPDATE 会对查询的行加排他锁,
    其他事务必须等待锁释放才能读取或修改。
    """
    try:
        # 使用with_for_update()加行锁
        stmt = (
            select(Product)
            .where(Product.id == product_id)
            .with_for_update()  # SELECT ... FOR UPDATE
        )
        product = db.session.execute(stmt).scalar_one()

        if product.stock < quantity:
            raise ValueError("库存不足")

        product.stock -= quantity
        db.session.commit()
        return True

    except Exception:
        db.session.rollback()
        raise

# with_for_update的参数
stmt = (
    select(Product)
    .where(Product.id == product_id)
    .with_for_update(
        nowait=True,      # 不等待锁,获取不到立即报错
        # skip_locked=True, # 跳过已锁定的行(MySQL/PostgreSQL)
        # of=Product,       # 指定锁定的表
    )
)

乐观锁与悲观锁对比:

特性 乐观锁 悲观锁
实现 版本号 + CAS SELECT FOR UPDATE
并发性能 高(不阻塞读) 低(阻塞其他事务)
适用场景 冲突少、读多写少 冲突多、写操作频繁
回滚成本 需要重试 直接等待
死锁风险

4.10 CRUD完整实战案例

下面是一个完整的用户管理API,涵盖所有CRUD操作:

python 复制代码
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import select, or_
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users_demo.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)


class User(db.Model):
    """用户模型"""
    __tablename__ = 'users'

    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False, index=True)
    email = db.Column(db.String(120), unique=True, nullable=False, index=True)
    password_hash = db.Column(db.String(256), nullable=False)
    full_name = db.Column(db.String(100))
    is_active = db.Column(db.Boolean, default=True, server_default='true')
    is_admin = db.Column(db.Boolean, default=False, server_default='false')
    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now())
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)

    def to_dict(self):
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            'full_name': self.full_name,
            'is_active': self.is_active,
            'is_admin': self.is_admin,
            'created_at': self.created_at.isoformat() if self.created_at else None,
            'updated_at': self.updated_at.isoformat() if self.updated_at else None,
        }

    def __repr__(self):
        return f'<User {self.username}>'


# ========== CREATE: 创建用户 ==========
@app.route('/api/users', methods=['POST'])
def create_user():
    """创建新用户"""
    data = request.get_json()

    # 验证必填字段
    required_fields = ['username', 'email', 'password']
    for field in required_fields:
        if not data.get(field):
            return jsonify({'error': f'缺少必填字段: {field}'}), 400

    # 检查用户名和邮箱是否已存在
    existing = db.session.execute(
        select(User).where(
            or_(
                User.username == data['username'],
                User.email == data['email']
            )
        )
    ).scalar_one_or_none()

    if existing:
        if existing.username == data['username']:
            return jsonify({'error': '用户名已存在'}), 409
        else:
            return jsonify({'error': '邮箱已存在'}), 409

    # 创建用户
    user = User(
        username=data['username'],
        email=data['email'],
        full_name=data.get('full_name'),
    )
    user.set_password(data['password'])

    db.session.add(user)
    db.session.commit()

    return jsonify(user.to_dict()), 201


# ========== READ: 查询用户 ==========
@app.route('/api/users', methods=['GET'])
def list_users():
    """获取用户列表(支持分页、搜索、过滤)"""
    page = int(request.args.get('page', 1))
    per_page = min(int(request.args.get('per_page', 10)), 100)  # 最大100条
    search = request.args.get('search', '').strip()
    is_active = request.args.get('is_active')

    # 构建查询
    stmt = select(User)

    # 搜索(用户名或邮箱)
    if search:
        stmt = stmt.where(
            or_(
                User.username.ilike(f'%{search}%'),
                User.email.ilike(f'%{search}%'),
                User.full_name.ilike(f'%{search}%'),
            )
        )

    # 过滤激活状态
    if is_active is not None:
        stmt = stmt.where(User.is_active == (is_active.lower() == 'true'))

    # 排序
    stmt = stmt.order_by(User.created_at.desc())

    # 统计总数
    from sqlalchemy import func
    count_stmt = select(func.count()).select_from(stmt.subquery())
    total = db.session.execute(count_stmt).scalar()

    # 分页
    offset = (page - 1) * per_page
    stmt = stmt.offset(offset).limit(per_page)

    users = db.session.execute(stmt).scalars().all()

    return jsonify({
        'users': [user.to_dict() for user in users],
        'pagination': {
            'page': page,
            'per_page': per_page,
            'total': total,
            'pages': (total + per_page - 1) // per_page,
        }
    })


@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    """获取单个用户详情"""
    user = db.session.get(User, user_id)
    if not user:
        return jsonify({'error': '用户不存在'}), 404
    return jsonify(user.to_dict())


# ========== UPDATE: 更新用户 ==========
@app.route('/api/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
    """更新用户信息"""
    user = db.session.get(User, user_id)
    if not user:
        return jsonify({'error': '用户不存在'}), 404

    data = request.get_json()

    # 更新字段(只更新提供的字段)
    if 'email' in data:
        # 检查邮箱是否被其他用户使用
        existing = db.session.execute(
            select(User).where(
                User.email == data['email'],
                User.id != user_id
            )
        ).scalar_one_or_none()
        if existing:
            return jsonify({'error': '邮箱已被使用'}), 409
        user.email = data['email']

    if 'full_name' in data:
        user.full_name = data['full_name']

    if 'is_active' in data:
        user.is_active = data['is_active']

    if 'password' in data:
        user.set_password(data['password'])

    db.session.commit()
    return jsonify(user.to_dict())


# ========== DELETE: 删除用户 ==========
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
    """删除用户"""
    user = db.session.get(User, user_id)
    if not user:
        return jsonify({'error': '用户不存在'}), 404

    db.session.delete(user)
    db.session.commit()
    return '', 204


# ========== 批量操作 ==========
@app.route('/api/users/batch', methods=['POST'])
def batch_create_users():
    """批量创建用户"""
    data = request.get_json()
    users_data = data.get('users', [])

    if not users_data:
        return jsonify({'error': '没有提供用户数据'}), 400

    created = []
    errors = []

    for i, user_data in enumerate(users_data):
        try:
            user = User(
                username=user_data['username'],
                email=user_data['email'],
                full_name=user_data.get('full_name'),
            )
            user.set_password(user_data['password'])
            db.session.add(user)
            db.session.flush()  # 获取ID但不提交
            created.append(user.to_dict())
        except Exception as e:
            errors.append({'index': i, 'error': str(e)})
            db.session.rollback()
            continue

    db.session.commit()

    return jsonify({
        'created': created,
        'created_count': len(created),
        'errors': errors,
    }), 201


# 初始化数据库并运行
with app.app_context():
    db.create_all()

if __name__ == '__main__':
    app.run(debug=True)

第五章 高级查询

SQLAlchemy的查询能力是其最强大的特性之一。从简单的条件过滤到复杂的窗口函数、CTE、子查询,SQLAlchemy几乎能表达任何SQL查询。本章将全面讲解SQLAlchemy 2.0风格的高级查询技巧。

5.1 SQLAlchemy 2.0查询语法

SQLAlchemy 2.0全面采用select()语句作为查询的统一入口。这是与1.x版本最大的区别。

python 复制代码
from sqlalchemy import select

# ========== 基本select()语句 ==========
# select()是所有查询的起点
stmt = select(User)                           # 查询所有列
stmt = select(User.id, User.username)         # 查询指定列
stmt = select(User).where(User.is_active)     # 带条件
stmt = select(User).order_by(User.id)         # 带排序
stmt = select(User).limit(10)                 # 带限制

# 执行查询
result = db.session.execute(stmt)             # 返回Result对象
users = result.scalars().all()                # 提取所有User对象
user = result.scalars().first()               # 提取第一个
user = result.scalars().one()                 # 提取唯一一个(不存在或多条会报错)

# ========== select()的链式调用 ==========
# select()返回Select对象,支持链式调用
stmt = (
    select(User)
    .where(User.is_active == True)
    .where(User.email.isnot(None))         # 多个where是AND关系
    .order_by(User.created_at.desc())
    .limit(10)
)
# 等价SQL:
# SELECT * FROM users
# WHERE is_active = 1 AND email IS NOT NULL
# ORDER BY created_at DESC
# LIMIT 10

# ========== 执行并获取结果 ==========
result = db.session.execute(stmt)
# 方式1: scalars() + all() ------ 获取模型对象列表
users = result.scalars().all()

# 方式2: 遍历结果
for user in db.session.execute(stmt).scalars():
    print(user.username)

# 方式3: 获取指定列的值
stmt = select(User.username, User.email)
for row in db.session.execute(stmt):
    print(row.username, row.email)  # Row对象,通过属性名访问

5.2 旧版Query API

虽然SQLAlchemy 2.0推荐使用select(),但旧版的Query API仍然被支持。Flask-SQLAlchemy 3.x也保留了db.session.query()接口。

python 复制代码
# ========== 旧版Query API(仍然可用,但不推荐新项目使用) ==========

# 基本查询
users = User.query.all()                              # 查询所有
user = User.query.first()                             # 第一条
user = User.query.get(1)                              # 按主键
user = User.query.filter_by(username='张三').first()  # 按条件

# 链式查询
users = (
    User.query
    .filter(User.is_active == True)
    .order_by(User.created_at.desc())
    .limit(10)
    .all()
)

# 聚合查询
count = User.query.count()
count = User.query.filter(User.is_active == True).count()

# ========== 新旧API对比 ==========
# 旧版: User.query.filter(User.name == '张三').first()
# 新版: db.session.execute(select(User).where(User.name == '张三')).scalars().first()

# 旧版: User.query.filter_by(name='张三').all()
# 新版: db.session.execute(select(User).filter_by(name='张三')).scalars().all()

# 旧版: User.query.get(1)
# 新版: db.session.get(User, 1)

# 旧版: User.query.order_by(User.name.desc()).limit(10).all()
# 新版: db.session.execute(select(User).order_by(User.name.desc()).limit(10)).scalars().all()

迁移建议 : 新项目直接使用select()风格。旧项目可以逐步迁移,两种风格可以混用。Flask-SQLAlchemy的Model.query属性在3.x中仍然保留。

5.3 条件表达式

SQLAlchemy提供了丰富的条件表达式,映射到SQL的各种操作符。

python 复制代码
from sqlalchemy import select

# ========== 比较运算符 ==========
stmt = select(User).where(User.age == 25)         # 等于
stmt = select(User).where(User.age != 25)         # 不等于
stmt = select(User).where(User.age > 18)          # 大于
stmt = select(User).where(User.age < 60)          # 小于
stmt = select(User).where(User.age >= 18)         # 大于等于
stmt = select(User).where(User.age <= 60)         # 小于等于

# ========== LIKE和ILIKE ==========
# LIKE: 区分大小写的模糊匹配
stmt = select(User).where(User.username.like('%张%'))
# SQL: WHERE username LIKE '%张%'

# ILIKE: 不区分大小写的模糊匹配(PostgreSQL/SQLite支持)
stmt = select(User).where(User.username.ilike('%john%'))
# SQL: WHERE username ILIKE '%john%' (PostgreSQL)

# MySQL不原生支持ILIKE,可以使用lower()函数实现
stmt = select(User).where(db.func.lower(User.username).like('%john%'))

# ========== IN和NOT IN ==========
# IN: 在指定列表中
stmt = select(User).where(User.id.in_([1, 2, 3, 4, 5]))
# SQL: WHERE id IN (1, 2, 3, 4, 5)

# NOT IN: 不在指定列表中
stmt = select(User).where(User.id.notin_([1, 2, 3]))
# 或者使用 ~ 取反
stmt = select(User).where(~User.id.in_([1, 2, 3]))

# IN 子查询
subq = select(Article.user_id).where(Article.status == 'published')
stmt = select(User).where(User.id.in_(subq))
# SQL: WHERE id IN (SELECT user_id FROM articles WHERE status = 'published')

# ========== IS NULL和IS NOT NULL ==========
stmt = select(User).where(User.email.is_(None))       # IS NULL
stmt = select(User).where(User.email.is_not(None))    # IS NOT NULL

# ========== BETWEEN ==========
stmt = select(User).where(User.age.between(18, 60))
# SQL: WHERE age BETWEEN 18 AND 60

# ========== 字符串方法 ==========
stmt = select(User).where(User.username.startswith('张'))   # 前缀匹配
stmt = select(User).where(User.username.endswith('三'))     # 后缀匹配
stmt = select(User).where(User.username.contains('张'))     # 包含匹配

# 这些方法底层使用LIKE:
# startswith('张') → LIKE '张%'
# endswith('三') → LIKE '%三'
# contains('张') → LIKE '%张%'

5.4 逻辑运算

在实际查询中,经常需要组合多个条件。SQLAlchemy提供了and_or_not_三个逻辑运算函数。

python 复制代码
from sqlalchemy import and_, or_, not_, select

# ========== AND: 与运算 ==========
# 方式1: 多个where()自动是AND关系
stmt = select(User).where(User.is_active == True).where(User.age > 18)
# SQL: WHERE is_active = 1 AND age > 18

# 方式2: 使用and_()显式组合
stmt = select(User).where(
    and_(
        User.is_active == True,
        User.age > 18,
        User.age < 60,
    )
)
# SQL: WHERE is_active = 1 AND age > 18 AND age < 60

# 方式3: 直接传多个条件(也是AND)
stmt = select(User).where(
    User.is_active == True,
    User.age > 18,
)

# ========== OR: 或运算 ==========
stmt = select(User).where(
    or_(
        User.username == '张三',
        User.username == '李四',
        User.username == '王五',
    )
)
# SQL: WHERE username = '张三' OR username = '李四' OR username = '王五'

# 等价的IN写法
stmt = select(User).where(User.username.in_(['张三', '李四', '王五']))

# ========== NOT: 非运算 ==========
stmt = select(User).where(not_(User.is_active == True))
# SQL: WHERE NOT (is_active = 1)
# 等价于: User.is_active != True 或 User.is_active == False

# 也可以使用~操作符
stmt = select(User).where(~(User.age > 60))
# SQL: WHERE NOT (age > 60)

# ========== 组合使用 ==========
# 查找: (活跃用户 且 年龄>18) 或 (管理员)
stmt = select(User).where(
    or_(
        and_(
            User.is_active == True,
            User.age > 18,
        ),
        User.is_admin == True,
    )
)
# SQL: WHERE (is_active = 1 AND age > 18) OR is_admin = 1

# 复杂组合示例
stmt = select(Article).where(
    and_(
        Article.status == 'published',
        or_(
            Article.title.ilike('%Flask%'),
            Article.title.ilike('%Python%'),
        ),
        not_(Article.is_deleted == True),
        Article.created_at >= '2024-01-01',
    )
).order_by(Article.created_at.desc())

5.5 聚合查询

聚合函数用于对一组值进行计算,返回单个汇总值。

python 复制代码
from sqlalchemy import func, select

# ========== COUNT: 计数 ==========
# 统计总用户数
total = db.session.execute(select(func.count(User.id))).scalar()
# SQL: SELECT COUNT(users.id) FROM users

# 统计满足条件的用户数
active_count = db.session.execute(
    select(func.count(User.id)).where(User.is_active == True)
).scalar()
# SQL: SELECT COUNT(users.id) FROM users WHERE is_active = 1

# COUNT(*) ------ 统计行数
total = db.session.execute(select(func.count()).select_from(User)).scalar()
# SQL: SELECT COUNT(*) FROM users

# COUNT(DISTINCT) ------ 统计去重后的数量
unique_emails = db.session.execute(
    select(func.count(func.distinct(User.email)))
).scalar()
# SQL: SELECT COUNT(DISTINCT email) FROM users

# ========== SUM: 求和 ==========
total_views = db.session.execute(
    select(func.sum(Article.view_count))
).scalar()
# SQL: SELECT SUM(view_count) FROM articles

# ========== AVG: 平均值 ==========
avg_views = db.session.execute(
    select(func.avg(Article.view_count))
).scalar()
# SQL: SELECT AVG(view_count) FROM articles

# ========== MIN和MAX: 最小值和最大值 ==========
min_views = db.session.execute(
    select(func.min(Article.view_count))
).scalar()

max_views = db.session.execute(
    select(func.max(Article.view_count))
).scalar()

# ========== 综合聚合查询 ==========
# 一条查询获取多个聚合值
stmt = select(
    func.count(Article.id).label('total_articles'),
    func.sum(Article.view_count).label('total_views'),
    func.avg(Article.view_count).label('avg_views'),
    func.min(Article.view_count).label('min_views'),
    func.max(Article.view_count).label('max_views'),
).where(Article.status == 'published')

result = db.session.execute(stmt).one()
print(f"文章总数: {result.total_articles}")
print(f"总浏览量: {result.total_views}")
print(f"平均浏览量: {result.avg_views}")
print(f"最低浏览量: {result.min_views}")
print(f"最高浏览量: {result.max_views}")

5.6 分组与聚合

group_by将数据按指定列分组,having对分组结果进行过滤。

python 复制代码
from sqlalchemy import func, select

# ========== 基本分组 ==========
# 按文章状态分组,统计每种状态的文章数
stmt = (
    select(
        Article.status,
        func.count(Article.id).label('count'),
    )
    .group_by(Article.status)
)
result = db.session.execute(stmt).all()
for row in result:
    print(f"状态: {row.status}, 文章数: {row.count}")

# ========== 多列分组 ==========
# 按用户和文章状态分组,统计每个用户每种状态的文章数
stmt = (
    select(
        User.username,
        Article.status,
        func.count(Article.id).label('count'),
        func.sum(Article.view_count).label('total_views'),
    )
    .join(Article, User.id == Article.author_id)
    .group_by(User.id, User.username, Article.status)
    .order_by(User.username, Article.status)
)
result = db.session.execute(stmt).all()

# ========== HAVING: 分组后过滤 ==========
# 查找发表文章数超过10篇的用户
stmt = (
    select(
        User.username,
        func.count(Article.id).label('article_count'),
    )
    .join(Article, User.id == Article.author_id)
    .group_by(User.id, User.username)
    .having(func.count(Article.id) > 10)
    .order_by(func.count(Article.id).desc())
)
result = db.session.execute(stmt).all()

# ========== 按日期分组 ==========
# 按天统计文章发布数量
from sqlalchemy import func

# 使用func.date()提取日期部分
stmt = (
    select(
        func.date(Article.created_at).label('date'),
        func.count(Article.id).label('count'),
    )
    .where(Article.status == 'published')
    .group_by(func.date(Article.created_at))
    .order_by(func.date(Article.created_at).desc())
    .limit(30)
)
result = db.session.execute(stmt).all()
for row in result:
    print(f"日期: {row.date}, 发布数: {row.count}")

# 按月分组(PostgreSQL)
stmt = (
    select(
        func.to_char(Article.created_at, 'YYYY-MM').label('month'),
        func.count(Article.id).label('count'),
    )
    .group_by('month')
    .order_by('month')
)

# 按年-月分组(MySQL)
stmt = (
    select(
        func.date_format(Article.created_at, '%Y-%m').label('month'),
        func.count(Article.id).label('count'),
    )
    .group_by('month')
    .order_by('month')
)

5.7 排序

python 复制代码
from sqlalchemy import select, desc, asc, nullsfirst, nullslast

# ========== 基本排序 ==========
# 升序(默认)
stmt = select(User).order_by(User.username)

# 降序
stmt = select(User).order_by(User.username.desc())
# 或使用desc()函数
stmt = select(User).order_by(desc(User.username))

# ========== 多列排序 ==========
# 先按is_active降序,再按created_at降序
stmt = select(User).order_by(
    User.is_active.desc(),
    User.created_at.desc(),
)
# SQL: ORDER BY is_active DESC, created_at DESC

# ========== NULL值排序 ==========
# NULL值排在最前
stmt = select(User).order_by(nullsfirst(User.last_login))
# SQL: ORDER BY last_login NULLS FIRST

# NULL值排在最后
stmt = select(User).order_by(nullslast(User.last_login))
# SQL: ORDER BY last_login NULLS LAST

# ========== 按表达式排序 ==========
# 按字段长度排序
stmt = select(User).order_by(func.length(User.username).desc())

# 按计算字段排序
stmt = (
    select(Article)
    .order_by((Article.view_count + Article.like_count * 5).desc())
)

# ========== 按关联模型字段排序 ==========
# 按作者名排序文章
stmt = (
    select(Article)
    .join(User, Article.author_id == User.id)
    .order_by(User.username, Article.created_at.desc())
)

# ========== 随机排序 ==========
# MySQL/SQLite
stmt = select(User).order_by(func.random())
# PostgreSQL
stmt = select(User).order_by(func.random())
# MySQL
stmt = select(User).order_by(func.rand())

5.8 限制与偏移

python 复制代码
from sqlalchemy import select

# ========== LIMIT: 限制返回行数 ==========
# 查询前10条
stmt = select(User).limit(10)

# ========== OFFSET: 跳过指定行数 ==========
# 跳过前20条,取10条
stmt = select(User).offset(20).limit(10)

# ========== 分页查询封装 ==========
def paginate(query, page, per_page):
    """通用分页函数

    Args:
        query: select语句
        page: 页码(从1开始)
        per_page: 每页条数

    Returns:
        dict: 包含数据和分页信息
    """
    # 计算偏移量
    offset = (page - 1) * per_page

    # 统计总数
    from sqlalchemy import func
    count_stmt = select(func.count()).select_from(query.subquery())
    total = db.session.execute(count_stmt).scalar()

    # 查询当前页数据
    page_stmt = query.offset(offset).limit(per_page)
    items = db.session.execute(page_stmt).scalars().all()

    return {
        'items': items,
        'page': page,
        'per_page': per_page,
        'total': total,
        'pages': (total + per_page - 1) // per_page,
        'has_prev': page > 1,
        'has_next': page < (total + per_page - 1) // per_page,
    }

# 使用分页函数
base_query = select(Article).where(Article.status == 'published').order_by(Article.created_at.desc())
result = paginate(base_query, page=2, per_page=10)
print(f"第{result['page']}页,共{result['pages']}页,总数{result['total']}")
for article in result['items']:
    print(article.title)

5.9 连接查询

JOIN是关系型数据库最强大的查询能力之一,用于将多个表的数据关联在一起。

python 复制代码
from sqlalchemy import select, aliased

# ========== INNER JOIN(内连接) ==========
# 查询文章及其作者信息
stmt = (
    select(Article, User)
    .join(User, Article.author_id == User.id)  # 指定连接条件
)
result = db.session.execute(stmt).all()
for article, user in result:
    print(f"文章: {article.title}, 作者: {user.username}")
# SQL: SELECT articles.*, users.*
#      FROM articles
#      INNER JOIN users ON articles.author_id = users.id

# ========== 简化的JOIN(自动推断连接条件) ==========
# 如果有外键关系,可以省略连接条件
stmt = select(Article, User).join(Article.author)
# SQLAlchemy自动使用Article.author_id → User.id的外键关系

# ========== LEFT JOIN(左外连接) ==========
# 查询所有用户,以及他们发表的文章(可能为空)
stmt = (
    select(User, Article)
    .outerjoin(Article, User.id == Article.author_id)
)
result = db.session.execute(stmt).all()
# SQL: SELECT users.*, articles.*
#      FROM users
#      LEFT OUTER JOIN articles ON users.id = articles.author_id

# ========== RIGHT JOIN(右外连接) ==========
# SQLAlchemy 2.0使用 .join_from() 实现右连接
stmt = (
    select(User, Article)
    .join_from(Article, User, Article.author_id == User.id, isouter=True)
)
# 实际上是把Article作为左表,User作为右表的LEFT JOIN

# ========== 多表JOIN ==========
# 查询文章、作者、分类信息
stmt = (
    select(Article.title, User.username, Category.name)
    .join(User, Article.author_id == User.id)
    .join(Category, Article.category_id == Category.id)
    .where(Article.status == 'published')
)
result = db.session.execute(stmt).all()
for row in result:
    print(f"文章: {row.title}, 作者: {row.username}, 分类: {row.name}")

# ========== 使用aliased(别名) ==========
# 当同一张表需要JOIN多次时,使用aliased创建别名
# 例如: 查询文章及其编辑者(也是User表)
Author = aliased(User, name='author')
Editor = aliased(User, name='editor')

stmt = (
    select(Article.title, Author.username.label('author_name'), Editor.username.label('editor_name'))
    .join(Author, Article.author_id == Author.id)
    .outerjoin(Editor, Article.editor_id == Editor.id)
)
result = db.session.execute(stmt).all()

# ========== 自连接 ==========
# 查询员工及其直属上级
Employee = aliased(db.Model.metadata.tables['employees'], name='employee')
Manager = aliased(db.Model.metadata.tables['employees'], name='manager')

# 使用ORM方式
emp_alias1 = aliased(Employee)
emp_alias2 = aliased(Employee)
stmt = (
    select(emp_alias1.name.label('employee_name'), emp_alias2.name.label('manager_name'))
    .join(emp_alias2, emp_alias1.manager_id == emp_alias2.id)
)

# ========== JOIN with FILTER ==========
# 查询发表过文章的用户
stmt = (
    select(User)
    .join(Article, User.id == Article.author_id)
    .where(Article.status == 'published')
    .distinct()  # 去重,一个用户可能有多篇文章
)

# 查询没有发表过文章的用户(LEFT JOIN + IS NULL)
stmt = (
    select(User)
    .outerjoin(Article, User.id == Article.author_id)
    .where(Article.id.is_(None))
)
# SQL: SELECT users.* FROM users
#      LEFT OUTER JOIN articles ON users.id = articles.author_id
#      WHERE articles.id IS NULL

5.10 子查询

子查询(Subquery)是嵌套在另一个查询中的查询。

python 复制代码
from sqlalchemy import select, func, subquery

# ========== 标量子查询(返回单个值) ==========
# 查询浏览量高于平均值的文章
avg_subq = (
    select(func.avg(Article.view_count))
    .where(Article.status == 'published')
    .scalar_subquery()
)
stmt = select(Article).where(Article.view_count > avg_subq)
# SQL: SELECT * FROM articles
#      WHERE view_count > (SELECT AVG(view_count) FROM articles WHERE status = 'published')

# ========== IN子查询 ==========
# 查询发表过已发布文章的用户
subq = (
    select(Article.author_id)
    .where(Article.status == 'published')
    .distinct()
)
stmt = select(User).where(User.id.in_(subq))
# SQL: SELECT * FROM users
#      WHERE id IN (SELECT DISTINCT author_id FROM articles WHERE status = 'published')

# ========== 子查询作为派生表 ==========
# 查询每个用户发表文章数,并按数量排序
subq = (
    select(
        Article.author_id.label('user_id'),
        func.count(Article.id).label('article_count'),
    )
    .group_by(Article.author_id)
    .subquery()  # 转换为子查询(派生表)
)

stmt = (
    select(User.username, subq.c.article_count)
    .join(subq, User.id == subq.c.user_id)
    .order_by(subq.c.article_count.desc())
)
result = db.session.execute(stmt).all()
# SQL: SELECT users.username, sub.article_count
#      FROM users
#      INNER JOIN (
#          SELECT author_id AS user_id, COUNT(id) AS article_count
#          FROM articles
#          GROUP BY author_id
#      ) AS sub ON users.id = sub.user_id
#      ORDER BY sub.article_count DESC

# ========== EXISTS子查询 ==========
from sqlalchemy import exists

# 查询有文章的用户
subq = select(Article.id).where(Article.author_id == User.id)
stmt = select(User).where(exists(subq))
# SQL: SELECT * FROM users
#      WHERE EXISTS (SELECT id FROM articles WHERE author_id = users.id)

# NOT EXISTS: 查询没有文章的用户
stmt = select(User).where(~exists(subq))

# ========== 子查询在SELECT子句中 ==========
# 查询每个用户及其文章数
article_count_subq = (
    select(func.count(Article.id))
    .where(Article.author_id == User.id)
    .correlate(User)  # 关联外层查询的User表
    .scalar_subquery()
    .label('article_count')
)

stmt = select(User.username, article_count_subq)
result = db.session.execute(stmt).all()

5.11 联合查询

集合操作用于合并多个查询的结果。

python 复制代码
from sqlalchemy import select, union, union_all, except_, intersect

# ========== UNION: 并集(去重) ==========
# 查询所有作者和编辑的名字(去重)
authors = select(User.username).join(Article, User.id == Article.author_id)
editors = select(User.username).join(Article, User.id == Article.editor_id)

stmt = union(authors, editors)
result = db.session.execute(stmt).all()
# SQL: SELECT username FROM users JOIN articles ON ...
#      UNION
#      SELECT username FROM users JOIN articles ON ...

# ========== UNION ALL: 并集(不去重) ==========
# 性能更好,因为不需要去重
stmt = union_all(authors, editors)

# ========== EXCEPT: 差集 ==========
# 查询是作者但不是编辑的用户
stmt = except_(authors, editors)
# SQL: SELECT ... EXCEPT SELECT ...

# ========== INTERSECT: 交集 ==========
# 查询既是作者又是编辑的用户
stmt = intersect(authors, editors)
# SQL: SELECT ... INTERSECT SELECT ...

# ========== 组合使用 ==========
# 查询最近7天和最近30天的文章数
recent_7 = (
    select(func.count(Article.id).label('count'))
    .where(Article.created_at >= func.date_sub(func.now(), text('INTERVAL 7 DAY')))
)
recent_30 = (
    select(func.count(Article.id).label('count'))
    .where(Article.created_at >= func.date_sub(func.now(), text('INTERVAL 30 DAY')))
)

# 使用UNION ALL合并结果
stmt = union_all(
    select(text("'7 days'").label('period'), recent_7.scalar_subquery().label('count')),
    select(text("'30 days'").label('period'), recent_30.scalar_subquery().label('count')),
)

5.12 窗口函数

窗口函数(Window Function)允许在不分组的情况下对结果集进行聚合计算。

python 复制代码
from sqlalchemy import func, select, over

# ========== ROW_NUMBER: 行号 ==========
# 为每篇文章按浏览量排名
stmt = (
    select(
        Article.title,
        Article.view_count,
        func.row_number().over(
            order_by=Article.view_count.desc()
        ).label('rank'),
    )
)
result = db.session.execute(stmt).all()
# SQL: SELECT title, view_count,
#             ROW_NUMBER() OVER (ORDER BY view_count DESC) AS rank
#      FROM articles

# ========== RANK和DENSE_RANK: 排名 ==========
# 按分类内浏览量排名(分区)
stmt = (
    select(
        Article.title,
        Category.name.label('category'),
        Article.view_count,
        func.rank().over(
            partition_by=Article.category_id,
            order_by=Article.view_count.desc(),
        ).label('category_rank'),
    )
    .join(Category)
)
# SQL: SELECT title, ...,
#             RANK() OVER (PARTITION BY category_id ORDER BY view_count DESC) AS category_rank
#      FROM articles JOIN categories ON ...

# RANK vs DENSE_RANK:
# RANK: 并列后跳号(1,1,3,4,...)
# DENSE_RANK: 并列后不跳号(1,1,2,3,...)

# ========== LAG和LEAD: 偏移函数 ==========
# 查询每篇文章与前一篇的浏览量差值
stmt = (
    select(
        Article.title,
        Article.view_count,
        func.lag(Article.view_count).over(
            order_by=Article.created_at
        ).label('prev_views'),
        (Article.view_count - func.lag(Article.view_count).over(
            order_by=Article.created_at
        )).label('views_diff'),
    )
)
# SQL: SELECT title, view_count,
#             LAG(view_count) OVER (ORDER BY created_at) AS prev_views,
#             view_count - LAG(view_count) OVER (ORDER BY created_at) AS views_diff
#      FROM articles

# ========== 累计求和 ==========
# 按日期累计浏览量
stmt = (
    select(
        func.date(Article.created_at).label('date'),
        func.sum(Article.view_count).over(
            order_by=func.date(Article.created_at)
        ).label('cumulative_views'),
    )
    .group_by(func.date(Article.created_at))
)

# ========== 使用over()对象 ==========
# 更灵活的窗口函数定义
window = over(
    partition_by=Article.category_id,
    order_by=Article.view_count.desc(),
)

stmt = select(
    Article.title,
    func.row_number().over(
        partition_by=Article.category_id,
        order_by=Article.view_count.desc(),
    ).label('rank_in_category'),
).where(Article.status == 'published')

5.13 CTE(Common Table Expression)

CTE(公用表表达式)是一种临时的命名结果集,类似于子查询但可读性更好,且支持递归。

python 复制代码
from sqlalchemy import select, func

# ========== 基本CTE ==========
# 查询每个用户的文章数,然后筛选大于5篇的
article_counts = (
    select(
        Article.author_id.label('user_id'),
        func.count(Article.id).label('count'),
    )
    .group_by(Article.author_id)
    .cte('article_counts')  # 命名为CTE
)

stmt = (
    select(User.username, article_counts.c.count)
    .join(article_counts, User.id == article_counts.c.user_id)
    .where(article_counts.c.count > 5)
    .order_by(article_counts.c.count.desc())
)
result = db.session.execute(stmt).all()
# SQL: WITH article_counts AS (
#          SELECT author_id AS user_id, COUNT(id) AS count
#          FROM articles
#          GROUP BY author_id
#      )
#      SELECT users.username, article_counts.count
#      FROM users
#      JOIN article_counts ON users.id = article_counts.user_id
#      WHERE article_counts.count > 5
#      ORDER BY article_counts.count DESC

# ========== 递归CTE: 树形结构遍历 ==========
# 查询分类的树形结构(父分类 → 子分类)
# 假设Category有parent_id字段表示父分类

# 基础查询: 选择根分类(parent_id为NULL)
base = (
    select(
        Category.id,
        Category.name,
        Category.parent_id,
        text('0').label('level'),
        Category.name.label('path'),
    )
    .where(Category.parent_id.is_(None))
    .cte('category_tree', recursive=True)
)

# 递归查询: 选择子分类
child = (
    select(
        Category.id,
        Category.name,
        Category.parent_id,
        (category_tree.c.level + 1).label('level'),
        (category_tree.c.path + ' > ' + Category.name).label('path'),
    )
    .join(category_tree, Category.parent_id == category_tree.c.id)
)

# 合并基础查询和递归查询
recursive_cte = base.union_all(child)

# 查询完整的分类树
stmt = select(recursive_cte).order_by(recursive_cte.c.path)
result = db.session.execute(stmt).all()
for row in result:
    print(f"{'  ' * row.level}{row.name} (ID: {row.id}, 层级: {row.level})")

# ========== 多个CTE ==========
# 一个查询中使用多个CTE
published_articles = (
    select(Article)
    .where(Article.status == 'published')
    .cte('published_articles')
)

top_authors = (
    select(
        published_articles.c.author_id,
        func.count(published_articles.c.id).label('article_count'),
    )
    .group_by(published_articles.c.author_id)
    .cte('top_authors')
)

stmt = (
    select(User.username, top_authors.c.article_count)
    .join(top_authors, User.id == top_authors.c.author_id)
    .order_by(top_authors.c.article_count.desc())
)

5.14 原生SQL执行

当ORM的表达能力不足以满足需求时,SQLAlchemy允许直接执行原生SQL。

python 复制代码
from sqlalchemy import text

# ========== 使用text()执行原生SQL ==========
# 简单查询
stmt = text("SELECT id, username, email FROM users WHERE is_active = 1")
result = db.session.execute(stmt)
for row in result:
    print(row.id, row.username, row.email)

# 带参数的查询(参数化,防止SQL注入)
stmt = text("SELECT * FROM users WHERE username = :username AND is_active = :active")
result = db.session.execute(stmt, {'username': '张三', 'active': 1})

# IN查询
stmt = text("SELECT * FROM users WHERE id IN :ids")
# 注意: 不同数据库对IN参数的处理不同
# 使用expanding bind参数(SQLAlchemy 2.0推荐)
stmt = text("SELECT * FROM users WHERE id IN :ids").bindparams(
    db.bindparam('ids', expanding=True)
)
result = db.session.execute(stmt, {'ids': [1, 2, 3, 4, 5]})

# ========== INSERT/UPDATE/DELETE ==========
# 插入
stmt = text(
    "INSERT INTO users (username, email, password_hash) "
    "VALUES (:username, :email, :password_hash)"
)
db.session.execute(stmt, {
    'username': '原生SQL',
    'email': 'raw@example.com',
    'password_hash': 'hashed_password',
})
db.session.commit()

# 更新
stmt = text("UPDATE users SET is_active = :active WHERE id = :id")
db.session.execute(stmt, {'active': 0, 'id': 1})
db.session.commit()

# 删除
stmt = text("DELETE FROM users WHERE is_active = :active")
result = db.session.execute(stmt, {'active': 0})
db.session.commit()
print(f"删除了 {result.rowcount} 条记录")

# ========== 将原生SQL结果映射到模型 ==========
# 使用select + from_statement将原生SQL映射到ORM
from sqlalchemy import select
stmt = select(User).from_statement(
    text("SELECT * FROM users WHERE is_active = 1 ORDER BY created_at DESC LIMIT 10")
)
users = db.session.execute(stmt).scalars().all()
# 返回的是User模型对象列表

# ========== 复杂原生SQL示例 ==========
# 使用原生SQL进行复杂统计
stmt = text("""
    SELECT
        u.username,
        COUNT(DISTINCT a.id) AS article_count,
        COALESCE(SUM(a.view_count), 0) AS total_views,
        COALESCE(AVG(a.view_count), 0) AS avg_views
    FROM users u
    LEFT JOIN articles a ON u.id = a.author_id AND a.status = 'published'
    WHERE u.is_active = 1
    GROUP BY u.id, u.username
    HAVING COUNT(DISTINCT a.id) > 0
    ORDER BY total_views DESC
    LIMIT 20
""")

result = db.session.execute(stmt)
for row in result:
    print(f"用户: {row.username}, 文章数: {row.article_count}, "
          f"总浏览: {row.total_views}, 平均浏览: {row.avg_views}")

5.15 高级查询实战案例集

案例1: 多维度文章统计报表
python 复制代码
def get_article_statistics():
    """获取文章多维统计报表

    包括: 按状态统计、按月份统计、Top作者统计
    """
    from sqlalchemy import func, select, case

    # 1. 按状态统计
    status_stats = (
        select(
            Article.status,
            func.count(Article.id).label('count'),
            func.sum(Article.view_count).label('total_views'),
            func.avg(Article.view_count).label('avg_views'),
        )
        .group_by(Article.status)
    )

    # 2. 按月份统计(PostgreSQL语法)
    monthly_stats = (
        select(
            func.to_char(Article.created_at, 'YYYY-MM').label('month'),
            func.count(Article.id).label('count'),
            func.sum(
                case(
                    (Article.status == 'published', 1),
                    else_=0,
                )
            ).label('published_count'),
        )
        .group_by('month')
        .order_by('month')
    )

    # 3. Top 10 作者
    top_authors = (
        select(
            User.username,
            func.count(Article.id).label('article_count'),
            func.sum(Article.view_count).label('total_views'),
            func.avg(Article.view_count).label('avg_views'),
        )
        .join(Article, User.id == Article.author_id)
        .where(Article.status == 'published')
        .group_by(User.id, User.username)
        .order_by(func.sum(Article.view_count).desc())
        .limit(10)
    )

    # 执行查询
    status_result = db.session.execute(status_stats).all()
    monthly_result = db.session.execute(monthly_stats).all()
    authors_result = db.session.execute(top_authors).all()

    return {
        'by_status': [
            {
                'status': r.status,
                'count': r.count,
                'total_views': r.total_views,
                'avg_views': float(r.avg_views) if r.avg_views else 0,
            }
            for r in status_result
        ],
        'by_month': [
            {
                'month': r.month,
                'total': r.count,
                'published': r.published_count,
            }
            for r in monthly_result
        ],
        'top_authors': [
            {
                'username': r.username,
                'article_count': r.article_count,
                'total_views': r.total_views,
                'avg_views': float(r.avg_views) if r.avg_views else 0,
            }
            for r in authors_result
        ],
    }
案例2: 标签云统计
python 复制代码
def get_tag_cloud(limit=50):
    """获取标签云数据(标签使用频率统计)"""
    from sqlalchemy import func, select

    # 统计每个标签被多少篇文章使用
    stmt = (
        select(
            Tag.id,
            Tag.name,
            func.count(article_tag.c.article_id).label('article_count'),
        )
        .join(article_tag, Tag.id == article_tag.c.tag_id)
        .group_by(Tag.id, Tag.name)
        .order_by(func.count(article_tag.c.article_id).desc())
        .limit(limit)
    )

    result = db.session.execute(stmt).all()

    # 计算标签权重(1-5级)
    if not result:
        return []

    max_count = result[0].article_count
    min_count = result[-1].article_count

    tag_cloud = []
    for row in result:
        # 根据使用频率计算权重(1-5)
        if max_count == min_count:
            weight = 3
        else:
            weight = int((row.article_count - min_count) / (max_count - min_count) * 4) + 1

        tag_cloud.append({
            'id': row.id,
            'name': row.name,
            'count': row.article_count,
            'weight': weight,  # 用于前端字体大小
        })

    return tag_cloud
案例3: 全文搜索查询
python 复制代码
def search_articles(keyword, page=1, per_page=10):
    """全文搜索文章

    在标题和内容中搜索关键词
    """
    from sqlalchemy import or_, select, func

    # 构建搜索条件
    search_filter = or_(
        Article.title.ilike(f'%{keyword}%'),
        Article.content.ilike(f'%{keyword}%'),
        Article.summary.ilike(f'%{keyword}%'),
    )

    # 基础查询
    base_query = (
        select(Article)
        .where(
            and_(
                Article.status == 'published',
                search_filter,
            )
        )
    )

    # 统计总数
    count_stmt = select(func.count()).select_from(base_query.subquery())
    total = db.session.execute(count_stmt).scalar()

    # 分页查询
    stmt = (
        base_query
        .order_by(Article.created_at.desc())
        .offset((page - 1) * per_page)
        .limit(per_page)
    )
    articles = db.session.execute(stmt).scalars().all()

    # 计算相关性分数(简单的基于出现次数的评分)
    scored_articles = []
    for article in articles:
        score = 0
        if keyword.lower() in (article.title or '').lower():
            score += 10  # 标题匹配权重高
        if keyword.lower() in (article.summary or '').lower():
            score += 5   # 摘要匹配权重中
        if keyword.lower() in (article.content or '').lower():
            score += 1   # 内容匹配权重低
        scored_articles.append((article, score))

    # 按相关性排序
    scored_articles.sort(key=lambda x: x[1], reverse=True)

    return {
        'keyword': keyword,
        'results': [
            {
                'article': article.to_dict(),
                'score': score,
            }
            for article, score in scored_articles
        ],
        'pagination': {
            'page': page,
            'per_page': per_page,
            'total': total,
            'pages': (total + per_page - 1) // per_page,
        }
    }

第六章 关系查询与预加载

关系查询是ORM最强大也最容易出问题的领域。如果不了解关系加载策略,很容易遇到著名的"N+1查询问题"------一次查询获取N条主记录,然后为每条记录单独查询关联数据,产生N+1次数据库查询。本章将深入讲解SQLAlchemy的关系加载机制和预加载策略。

6.1 关系加载策略概述

当通过ORM访问关联数据时,SQLAlchemy需要决定何时 以及如何 从数据库加载关联记录。这个决策由lazy参数控制。

python 复制代码
class Category(db.Model):
    __tablename__ = 'categories'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50))

    # lazy参数控制articles关系的加载策略
    articles = db.relationship('Article', backref='category', lazy='select')

默认情况下(lazy='select'),关系数据是懒加载 的:只有在真正访问category.articles属性时,才会向数据库发起查询。

python 复制代码
# 懒加载的工作流程
categories = db.session.execute(select(Category)).scalars().all()
# 此时只查询了categories表,articles尚未加载

for category in categories:
    print(category.name)
    # 第一次访问 category.articles 时,触发一次SQL查询
    for article in category.articles:
        print(f"  - {article.title}")
    # 如果有5个分类,这里会产生5次额外的SQL查询 ------ 这就是N+1问题!

6.2 lazy参数详解

lazy参数支持多种加载策略,每种策略在查询次数和内存使用上有不同的权衡。

策略 说明 查询次数 适用场景
select (默认) 懒加载,访问时才查询 N+1 关联数据很少访问
joined JOIN查询,一次获取 1 一对一、多对一关系
subquery 子查询,二次查询 2 一对多,需要预加载
selectin IN查询,二次查询 2 一对多,推荐的预加载方式
dynamic 返回Query对象,不立即加载 按需 需要进一步过滤关联数据
raise 访问时抛异常 0 强制显式预加载
noload 永不加载,返回空 0 临时禁用关系加载
python 复制代码
class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80))

    # 1. select(默认): 懒加载
    articles = db.relationship('Article', lazy='select')

    # 2. joined: 使用JOIN一次性加载
    articles_joined = db.relationship('Article', lazy='joined')

    # 3. subquery: 使用子查询加载
    articles_subquery = db.relationship('Article', lazy='subquery')

    # 4. selectin: 使用IN查询加载(SQLAlchemy 2.0推荐)
    articles_selectin = db.relationship('Article', lazy='selectin')

    # 5. dynamic: 返回Query对象(不立即加载)
    articles_dynamic = db.relationship('Article', lazy='dynamic')

    # 6. raise: 访问时抛出异常(强制显式加载)
    articles_raise = db.relationship('Article', lazy='raise')

    # 7. noload: 永不加载
    articles_noload = db.relationship('Article', lazy='noload')
6.2.1 各策略的SQL行为对比
python 复制代码
# 假设有3个用户,每个用户有2篇文章

# ========== lazy='select'(默认,懒加载) ==========
users = db.session.execute(select(User)).scalars().all()
# SQL: SELECT * FROM users  (1次查询)

for user in users:
    print(user.articles)
# SQL: SELECT * FROM articles WHERE user_id = ?  (3次查询,每用户1次)
# 总计: 1 + 3 = 4次查询 (N+1问题)

# ========== lazy='joined'(JOIN加载) ==========
users = db.session.execute(select(User)).scalars().all()
# SQL: SELECT users.*, articles.* FROM users
#      LEFT OUTER JOIN articles ON users.id = articles.user_id  (1次查询)
# 总计: 1次查询
# 注意: 如果一个用户有多篇文章,结果会有重复行(SQLAlchemy会自动去重)

# ========== lazy='subquery'(子查询加载) ==========
users = db.session.execute(select(User)).scalars().all()
# SQL 1: SELECT * FROM users  (第1次查询)
# SQL 2: SELECT articles.* FROM articles
#        WHERE articles.user_id IN (
#            SELECT users.id FROM users
#        )  (第2次查询,一次获取所有用户的文章)
# 总计: 2次查询

# ========== lazy='selectin'(IN查询加载,推荐) ==========
users = db.session.execute(select(User)).scalars().all()
# SQL 1: SELECT * FROM users  (第1次查询)
# SQL 2: SELECT articles.* FROM articles
#        WHERE articles.user_id IN (?, ?, ?)  (第2次查询,直接用ID列表)
# 总计: 2次查询
# 与subquery类似,但使用IN而非子查询,通常性能更好

# ========== lazy='dynamic'(动态加载) ==========
users = db.session.execute(select(User)).scalars().all()
# SQL: SELECT * FROM users  (1次查询)

for user in users:
    # user.articles返回的是Query对象,不触发查询
    published = user.articles.filter_by(status='published').all()
    # SQL: SELECT * FROM articles WHERE user_id = ? AND status = 'published'
# 总计: 1 + N次查询(但可以进一步过滤)

6.3 预加载解决N+1问题

预加载(Eager Loading)是在查询主记录时,同时(或紧接着)加载关联数据,从而避免N+1查询。

6.3.1 joinedload: JOIN预加载
python 复制代码
from sqlalchemy.orm import joinedload

# 使用joinedload在查询时一次性加载关联数据
stmt = (
    select(Category)
    .options(joinedload(Category.articles))
)
categories = db.session.execute(stmt).scalars().all()
# SQL: SELECT categories.*, articles.*
#      FROM categories
#      LEFT OUTER JOIN articles ON categories.id = articles.category_id
# 总计: 1次查询!

# 访问关联数据时不再触发额外查询
for category in categories:
    print(f"{category.name}:")
    for article in category.articles:  # 已经加载,不触发SQL
        print(f"  - {article.title}")

# ========== joinedload的多层嵌套 ==========
# 加载分类 -> 文章 -> 作者(三层关系)
stmt = (
    select(Category)
    .options(
        joinedload(Category.articles)
        .joinedload(Article.author)
    )
)
categories = db.session.execute(stmt).scalars().all()
# 一次JOIN查询获取所有数据

# ========== joinedload的innerjoin参数 ==========
# 默认使用LEFT OUTER JOIN,可以改为INNER JOIN
stmt = (
    select(Category)
    .options(
        joinedload(Category.articles, innerjoin=True)
    )
)
# 只返回有文章的分类

# ========== 注意: joinedload与一对多 ==========
# joinedload在一对多关系上可能导致大量重复数据(笛卡尔积)
# 因为JOIN会将每个子记录作为单独的行返回
# 对于一对多关系,推荐使用selectinload
6.3.2 selectinload: IN查询预加载(推荐)
python 复制代码
from sqlalchemy.orm import selectinload

# selectinload使用IN查询预加载关联数据
stmt = (
    select(Category)
    .options(selectinload(Category.articles))
)
categories = db.session.execute(stmt).scalars().all()
# SQL 1: SELECT * FROM categories
# SQL 2: SELECT * FROM articles WHERE category_id IN (?, ?, ?, ...)
# 总计: 2次查询,无论有多少个分类!

# 访问关联数据不触发额外查询
for category in categories:
    print(f"{category.name}: {len(category.articles)} 篇文章")

# ========== selectinload的多层嵌套 ==========
# 加载用户 -> 文章 -> 评论(三层)
stmt = (
    select(User)
    .options(
        selectinload(User.articles)
        .selectinload(Article.comments)
    )
)
users = db.session.execute(stmt).scalars().all()
# SQL 1: SELECT * FROM users
# SQL 2: SELECT * FROM articles WHERE user_id IN (...)
# SQL 3: SELECT * FROM comments WHERE article_id IN (...)
# 总计: 3次查询

# ========== selectinload vs joinedload ==========
# 一对一/多对一: 推荐joinedload(一条SQL搞定)
# 一对多/多对多: 推荐selectinload(避免JOIN产生的重复数据)
6.3.3 subqueryload: 子查询预加载
python 复制代码
from sqlalchemy.orm import subqueryload

# subqueryload使用子查询预加载
stmt = (
    select(Category)
    .options(subqueryload(Category.articles))
)
categories = db.session.execute(stmt).scalars().all()
# SQL 1: SELECT * FROM categories
# SQL 2: SELECT articles.*, anon_1.category_id AS anon_1_category_id
#        FROM articles
#        RIGHT JOIN (
#            SELECT categories.id AS category_id FROM categories
#        ) AS anon_1 ON articles.category_id = anon_1.category_id
# 总计: 2次查询

# selectinload vs subqueryload:
# selectinload: 第二次查询用 IN (id1, id2, ...) ------ 更快
# subqueryload: 第二次查询用子查询 ------ 当ID列表太长时可能更优
# 一般推荐selectinload
6.3.4 预加载策略选择指南
python 复制代码
from sqlalchemy.orm import joinedload, selectinload, subqueryload

# 场景1: 加载用户及其详情(一对一)
stmt = select(User).options(joinedload(User.profile))

# 场景2: 加载分类及其文章(一对多)
stmt = select(Category).options(selectinload(Category.articles))

# 场景3: 加载文章及其作者(多对一)
stmt = select(Article).options(joinedload(Article.author))

# 场景4: 加载文章及其标签(多对多)
stmt = select(Article).options(selectinload(Article.tags))

# 场景5: 多层关系加载
stmt = (
    select(User)
    .options(
        selectinload(User.articles)          # 第1层: 一对多,用selectinload
        .joinedload(Article.author)          # 第2层: 多对一,用joinedload
        .selectinload(Article.tags)          # 第2层: 多对多,用selectinload
    )
)

# 场景6: 混合使用多种策略
stmt = (
    select(Article)
    .options(
        joinedload(Article.category),        # 分类: joinedload(多对一)
        joinedload(Article.author),          # 作者: joinedload(多对一)
        selectinload(Article.comments),      # 评论: selectinload(一对多)
        selectinload(Article.tags),          # 标签: selectinload(多对多)
    )
)
articles = db.session.execute(stmt).scalars().all()
# 所有关联数据一次性加载,无N+1问题

6.4 contains_eager预加载

contains_eager是一种特殊的预加载策略,它不会自动生成JOIN,而是要求你在查询中手动添加JOIN,然后告诉SQLAlchemy将JOIN的结果填充到关系属性中。

python 复制代码
from sqlalchemy.orm import contains_eager

# 场景: 查询已发布文章的分类,同时加载每个分类下的已发布文章

# 如果用selectinload,会加载所有文章(包括非已发布的)
# 使用contains_eager可以精确控制加载哪些关联数据

stmt = (
    select(Category)
    .join(Article, Category.id == Article.category_id)  # 手动JOIN
    .where(Article.status == 'published')               # 过滤条件
    .options(contains_eager(Category.articles))         # 告诉SQLAlchemy填充articles
    .order_by(Category.name, Article.created_at.desc())
)
categories = db.session.execute(stmt).scalars().all()

for category in categories:
    # category.articles只包含已发布的文章
    print(f"{category.name}:")
    for article in category.articles:
        print(f"  - {article.title} ({article.status})")

# ========== contains_eager与过滤结合 ==========
# 查询用户及其最近5篇文章
stmt = (
    select(User)
    .outerjoin(Article, and_(
        User.id == Article.author_id,
        Article.created_at >= '2024-01-01',  # 只加载2024年后的文章
    ))
    .options(contains_eager(User.articles))
    .order_by(User.username)
)
users = db.session.execute(stmt).scalars().unique().all()
# .unique()是必须的,因为JOIN可能产生重复的User行

for user in users:
    print(f"{user.username}的近期文章:")
    for article in user.articles:
        print(f"  - {article.title}")

6.5 with_entities指定查询列

with_entities用于指定查询返回的列,而不是返回完整的模型对象。

python 复制代码
from sqlalchemy import select

# ========== 查询指定列 ==========
# 只查询用户名和邮箱(不加载完整User对象)
stmt = select(User.username, User.email)
result = db.session.execute(stmt).all()
for row in result:
    print(row.username, row.email)

# ========== 查询计算列 ==========
from sqlalchemy import func

# 查询用户名和文章数
stmt = (
    select(
        User.username,
        func.count(Article.id).label('article_count'),
    )
    .outerjoin(Article, User.id == Article.author_id)
    .group_by(User.id, User.username)
)
result = db.session.execute(stmt).all()

# ========== 使用with_entities(旧版API) ==========
# 查询所有用户的用户名列表
usernames = db.session.query(User).with_entities(User.username).all()

# 查询特定列并转换为列表
usernames_list = [
    row[0] for row in
    db.session.query(User.username).all()
]

# ========== 使用select只查询需要的列(2.0推荐) ==========
# 只查询ID和标题,减少数据传输
stmt = select(Article.id, Article.title).where(Article.status == 'published')
result = db.session.execute(stmt).all()
articles = [{'id': r.id, 'title': r.title} for r in result]

6.6 关系查询实战: 多层级联数据查询

下面通过一个完整的示例,展示如何高效查询多层级联数据。

python 复制代码
# 假设有以下模型关系:
# User (1) -> (N) Article (1) -> (N) Comment
# Article (N) <-> (N) Tag
# Article (N) -> (1) Category

def get_user_dashboard(user_id):
    """获取用户仪表盘数据(多层级联查询)

    需要的数据:
    - 用户信息
    - 用户的所有文章(含分类、标签)
    - 每篇文章的评论数
    - 用户的总浏览量、总点赞数
    """
    from sqlalchemy import func, select
    from sqlalchemy.orm import selectinload, joinedload

    # ========== 方法1: 使用预加载一次性获取(推荐) ==========
    stmt = (
        select(User)
        .options(
            # 预加载文章
            selectinload(User.articles)
            # 预加载文章的分类(多对一,用joinedload)
            .joinedload(Article.category)
            # 预加载文章的标签(多对多,用selectinload)
            .selectinload(Article.tags)
            # 预加载文章的评论(一对多,用selectinload)
            .selectinload(Article.comments),
        )
        .where(User.id == user_id)
    )

    user = db.session.execute(stmt).scalars().one()

    # 组装结果(所有数据已在内存中,不触发额外查询)
    result = {
        'user': {
            'id': user.id,
            'username': user.username,
            'email': user.email,
        },
        'articles': [],
        'total_views': 0,
        'total_likes': 0,
    }

    for article in user.articles:
        result['articles'].append({
            'id': article.id,
            'title': article.title,
            'category': article.category.name if article.category else '未分类',
            'tags': [tag.name for tag in article.tags],
            'comment_count': len(article.comments),
            'view_count': article.view_count,
            'like_count': article.like_count,
        })
        result['total_views'] += article.view_count or 0
        result['total_likes'] += article.like_count or 0

    return result


def get_category_tree_with_articles():
    """获取分类树及其文章(使用递归CTE + 预加载)"""
    from sqlalchemy import select
    from sqlalchemy.orm import selectinload

    # 使用预加载一次获取所有分类和文章
    stmt = (
        select(Category)
        .options(
            selectinload(Category.articles)
            .joinedload(Article.author)
        )
        .order_by(Category.name)
    )

    categories = db.session.execute(stmt).scalars().all()

    # 构建分类树
    result = []
    for category in categories:
        result.append({
            'id': category.id,
            'name': category.name,
            'article_count': len(category.articles),
            'articles': [
                {
                    'title': article.title,
                    'author': article.author.username,
                    'view_count': article.view_count,
                }
                for article in category.articles
            ]
        })

    return result

6.7 N+1问题检测与解决

N+1问题是ORM最常见的性能陷阱。本节介绍如何检测和解决它。

6.7.1 N+1问题示例
python 复制代码
# ========== 典型的N+1问题 ==========
from sqlalchemy import select

# 查询所有文章
articles = db.session.execute(
    select(Article).where(Article.status == 'published')
).scalars().all()
# SQL: SELECT * FROM articles WHERE status = 'published'  (1次查询)

# 遍历文章,访问每篇文章的作者
for article in articles:
    author_name = article.author.username  # 每次访问都触发一次SQL!
    print(f"{article.title} - {author_name}")
# 如果有100篇文章,这里会产生100次SQL查询!
# SQL: SELECT * FROM users WHERE id = ?  (100次查询)
# 总计: 1 + 100 = 101次查询 (这就是N+1问题)

# ========== 解决方案1: joinedload(多对一关系,推荐) ==========
from sqlalchemy.orm import joinedload

stmt = (
    select(Article)
    .options(joinedload(Article.author))  # 预加载作者
    .where(Article.status == 'published')
)
articles = db.session.execute(stmt).scalars().all()
# SQL: SELECT articles.*, users.*
#      FROM articles
#      LEFT OUTER JOIN users ON articles.author_id = users.id
#      WHERE articles.status = 'published'  (1次查询!)

for article in articles:
    print(f"{article.title} - {article.author.username}")  # 不触发额外查询

# ========== 解决方案2: selectinload(一对多关系,推荐) ==========
from sqlalchemy.orm import selectinload

stmt = (
    select(User)
    .options(selectinload(User.articles))  # 预加载文章
    .where(User.is_active == True)
)
users = db.session.execute(stmt).scalars().all()
# SQL 1: SELECT * FROM users WHERE is_active = 1
# SQL 2: SELECT * FROM articles WHERE user_id IN (?, ?, ...)  (2次查询!)

for user in users:
    print(f"{user.username}: {len(user.articles)} 篇文章")  # 不触发额外查询
6.7.2 检测N+1问题
python 复制代码
# ========== 方法1: 开启SQL日志 ==========
app.config['SQLALCHEMY_ECHO'] = True  # 打印所有SQL
# 然后在日志中观察是否有大量相似的查询

# ========== 方法2: 使用Flask-SQLAlchemy的查询统计 ==========
from flask import g
from time import time

@app.before_request
def before_request():
    g.query_count = 0
    g.query_start = time()

@app.after_request
def after_request(response):
    if hasattr(g, 'query_count'):
        duration = time() - g.query_start
        if g.query_count > 10:  # 查询数超过10次时告警
            app.logger.warning(
                f"可能的N+1查询: {g.query_count}次查询, 耗时{duration:.2f}s"
            )
    return response

# ========== 方法3: 使用SQLAlchemy事件监听 ==========
from sqlalchemy import event

query_count = 0

@event.listens_for(db.engine, 'before_cursor_execute')
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
    global query_count
    query_count += 1
    context._query_start_time = time()

@event.listens_for(db.engine, 'after_cursor_execute')
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
    duration = time() - context._query_start_time
    if duration > 0.1:  # 慢查询(超过100ms)
        app.logger.warning(
            f"慢查询 ({duration:.3f}s): {statement[:200]}"
        )

6.8 查询性能优化技巧

6.8.1 只查询需要的列
python 复制代码
from sqlalchemy import select

# 不好的做法: 查询所有列,包括不需要的大字段
articles = db.session.execute(
    select(Article).where(Article.status == 'published')
).scalars().all()
# 如果Article有content(TEXT)字段,会加载所有内容到内存

# 好的做法: 只查询需要的列
stmt = (
    select(Article.id, Article.title, Article.created_at)
    .where(Article.status == 'published')
)
result = db.session.execute(stmt).all()

# 或者使用defer延迟加载大字段
from sqlalchemy.orm import defer

stmt = (
    select(Article)
    .options(defer(Article.content))  # 延迟加载content字段
    .where(Article.status == 'published')
)
articles = db.session.execute(stmt).scalars().all()
# content字段不会立即加载,只有访问article.content时才查询

# 使用undefer强制加载(覆盖lazy='deferred'的列)
from sqlalchemy.orm import undefer
stmt = select(Article).options(undefer(Article.content))
6.8.2 使用yield_per分批获取
python 复制代码
from sqlalchemy import select

# 处理大量数据时,使用yield_per分批获取,避免内存溢出
stmt = select(Article).where(Article.status == 'published')

# 每次获取100条,处理完再获取下一批
result = db.session.execute(stmt).scalars()
for article in result.yield_per(100):
    process_article(article)  # 处理每篇文章
# 不会一次性将所有文章加载到内存

# 或者使用stream_results(更底层的方式)
stmt = select(Article).execution_options(stream_results=True)
6.8.3 使用Redis缓存查询结果
python 复制代码
import json
from datetime import timedelta

def get_cached_articles(category_id, page=1, per_page=10):
    """带缓存的查询

    将常用查询结果缓存到Redis,减少数据库压力
    """
    cache_key = f"articles:category:{category_id}:page:{page}"

    # 尝试从缓存获取
    cached = redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    # 缓存未命中,查询数据库
    stmt = (
        select(Article)
        .where(
            and_(
                Article.category_id == category_id,
                Article.status == 'published',
            )
        )
        .order_by(Article.created_at.desc())
        .offset((page - 1) * per_page)
        .limit(per_page)
    )
    articles = db.session.execute(stmt).scalars().all()

    result = [article.to_dict() for article in articles]

    # 写入缓存(5分钟过期)
    redis_client.setex(
        cache_key,
        timedelta(minutes=5),
        json.dumps(result, ensure_ascii=False)
    )

    return result
6.8.4 批量查询替代循环查询
python 复制代码
from sqlalchemy import select

# 不好的做法: 循环中查询(产生N次SQL)
article_ids = [1, 2, 3, 4, 5]
articles = []
for aid in article_ids:
    article = db.session.get(Article, aid)  # 每次一次SQL
    articles.append(article)
# 5次SQL查询

# 好的做法: 一次查询所有
stmt = select(Article).where(Article.id.in_(article_ids))
articles = db.session.execute(stmt).scalars().all()
# 1次SQL查询

# 如果需要按ID顺序返回,可以排序
articles_dict = {a.id: a for a in articles}
articles_ordered = [articles_dict[aid] for aid in article_ids if aid in articles_dict]

第七章 数据库迁移: Flask-Migrate

在项目开发过程中,数据库结构会不断变化:新增表、修改字段、添加索引等。如何安全、可控地管理这些变更?数据库迁移工具应运而生。Flask-Migrate是Flask生态中最常用的数据库迁移工具,本章将全面讲解其使用方法。

7.1 数据库迁移的概念与必要性

数据库迁移(Database Migration)是指对数据库结构进行版本化管理的过程。它类似于代码的版本控制(Git),但针对的是数据库结构(schema)的变更。

7.1.1 为什么需要数据库迁移

在没有迁移工具时,数据库变更面临以下问题:

问题 无迁移工具 有迁移工具
新环境部署 手动执行所有DDL 一条命令自动建表
结构变更 手动编写ALTER语句 自动生成迁移脚本
版本回退 几乎不可能 一条命令回退
团队协作 各自手动同步 提交迁移脚本,自动同步
生产部署 高风险,容易遗漏 迁移脚本经过测试
text 复制代码
开发流程对比:

【无迁移工具】
开发者A修改了表结构 → 手动在开发库执行ALTER → 
开发者B不知道 → 代码运行报错 → 手动排查 → 手动执行ALTER

【有迁移工具】
开发者A修改了模型 → flask db migrate → 生成迁移脚本 → 
提交到Git → 开发者B拉取代码 → flask db upgrade → 自动同步结构
7.1.2 迁移工具的工作原理
复制代码
┌─────────────┐     自动检测      ┌──────────────┐     执行      ┌──────────┐
│  模型定义     │ ──────────────> │  迁移脚本     │ ──────────> │  数据库   │
│ (models.py) │  (对比数据库现状)  │ (migrations/) │  (ALTER等)   │ (tables) │
└─────────────┘                  └──────────────┘             └──────────┘
       │                                │                          │
       │         版本记录                 │                          │
       └─────────────────────────────────┘ <────────────────────────┘
                  alembic_version表(记录当前版本)

7.2 Alembic简介

Alembic是SQLAlchemy官方的数据库迁移工具,由SQLAlchemy的作者Michael Bayer开发。Flask-Migrate本质上是对Alembic的Flask封装。

7.2.1 Alembic的核心概念
概念 说明
Migration Script 迁移脚本,包含upgrade()和downgrade()函数
Revision 版本号,每个迁移脚本有唯一的revision ID
alembic_version 数据库中的版本记录表,记录当前迁移版本
autogenerate 自动生成迁移脚本(对比模型与数据库的差异)
upgrade 执行升级(向前迁移)
downgrade 执行降级(回退迁移)
7.2.2 迁移脚本的结构
python 复制代码
"""添加用户年龄字段

Revision ID: a1b2c3d4e5f6
Revises: 9z8y7x6w5v4u
Create Date: 2024-01-15 10:00:00
"""
from alembic import op
import sqlalchemy as sa

# revision identifiers
revision = 'a1b2c3d4e5f6'
down_revision = '9z8y7x6w5v4u'
branch_labels = None
depends_on = None

def upgrade():
    """升级: 添加age字段"""
    op.add_column('users', sa.Column('age', sa.Integer(), nullable=True))

def downgrade():
    """降级: 删除age字段"""
    op.drop_column('users', 'age')

7.3 Flask-Migrate安装与初始化

python 复制代码
# 安装
# pip install Flask-Migrate

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db = SQLAlchemy(app)
migrate = Migrate(app, db)  # 初始化Flask-Migrate,传入app和db

# 定义模型(必须在使用migrate之前或同时导入)
class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)

# 在工厂模式中初始化
def create_app():
    app = Flask(__name__)
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'

    db.init_app(app)
    migrate.init_app(app, db)  # 必须在db.init_app之后

    # 导入模型(确保模型被注册)
    from app import models

    return app

7.4 创建迁移仓库

bash 复制代码
# 初始化迁移仓库(只需执行一次)
# 会在项目根目录创建migrations/文件夹
flask db init

# 迁移仓库结构:
# migrations/
# ├── env.py              # 迁移环境配置
# ├── README              # 说明文件
# ├── script.py.mako      # 迁移脚本模板
# └── versions/           # 迁移脚本存放目录(初始为空)

env.py文件配置(通常不需要修改):

python 复制代码
# migrations/env.py 核心配置
from flask import current_app

def run_migrations_online():
    # 获取Flask-SQLAlchemy的引擎配置
    configuration = config.get_section(config.config_ini_section)
    configuration['sqlalchemy.url'] = current_app.config.get(
        'SQLALCHEMY_DATABASE_URI'
    )
    # ... 使用该配置运行迁移

7.5 生成迁移脚本

bash 复制代码
# 自动检测模型变更并生成迁移脚本
flask db migrate -m "添加用户年龄字段"

# 输出示例:
# INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
# INFO  [alembic.runtime.migration] Generating new revision
# INFO  [alembic.autogenerate.compare] Detected added column 'age' on 'users' table
# ...
# INFO  [alembic.runtime.migration] Revision ID: a1b2c3d4e5f6
# INFO  [alembic.runtime.migration] Revises: 9z8y7x6w5v4u
# ...
# 生成文件: migrations/versions/a1b2c3d4e5f6_添加用户年龄字段.py

生成的迁移脚本:

python 复制代码
"""添加用户年龄字段

Revision ID: a1b2c3d4e5f6
Revises: 9z8y7x6w5v4u
Create Date: 2024-01-15 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa

revision = 'a1b2c3d4e5f6'
down_revision = '9z8y7x6w5v4u'
branch_labels = None
depends_on = None

def upgrade():
    # ### commands auto generated by Alembic ###
    op.add_column('users', sa.Column('age', sa.Integer(), nullable=True))
    # ### end Alembic commands ###

def downgrade():
    # ### commands auto generated by Alembic ###
    op.drop_column('users', 'age')
    # ### end Alembic commands ###
7.5.1 检查并修改迁移脚本

自动生成的脚本不一定完美,需要人工检查:

python 复制代码
def upgrade():
    # 添加age字段
    op.add_column('users', sa.Column('age', sa.Integer(), nullable=True))

    # 人工补充: 为已有数据设置默认年龄
    op.execute("UPDATE users SET age = 18 WHERE age IS NULL")

    # 人工补充: 添加非空约束
    op.alter_column('users', 'age',
        existing_type=sa.Integer(),
        nullable=False,
        server_default='18',
    )

    # 人工补充: 添加索引
    op.create_index('idx_users_age', 'users', ['age'])

def downgrade():
    op.drop_index('idx_users_age', table_name='users')
    op.drop_column('users', 'age')

7.6 应用迁移

bash 复制代码
# 应用所有待执行的迁移(升级到最新版本)
flask db upgrade

# 输出示例:
# INFO  [alembic.runtime.migration] Running upgrade 9z8y7x6w5v4u -> a1b2c3d4e5f6, 添加用户年龄字段

# 升级到指定版本
flask db upgrade a1b2c3d4e5f6

# 查看当前版本
flask db current

# 输出:
# INFO  [alembic.runtime.migration] Current revision for sqlite:///app.db: a1b2c3d4e5f6 (head)

7.7 回滚迁移

bash 复制代码
# 回退一个版本
flask db downgrade

# 回退到指定版本
flask db downgrade 9z8y7x6w5v4u

# 回退到最初(撤销所有迁移)
flask db downgrade base

7.8 查看迁移历史

bash 复制代码
# 查看所有迁移历史
flask db history

# 输出示例:
# a1b2c3d4e5f6 (head) -> 添加用户年龄字段
# 9z8y7x6w5v4u -> 创建用户表
# <base> -> 初始状态

# 查看详细历史
flask db history --verbose

# 查看指定范围的迁移
flask db history -r 9z8y7x6w5v4u:a1b2c3d4e5f6

7.9 迁移脚本编写规范

python 复制代码
"""
良好实践的迁移脚本示例

规范:
1. 消息描述清晰,说明做了什么变更
2. upgrade和downgrade必须对称
3. 数据迁移和结构迁移分开
4. 复杂操作分步骤执行
5. 添加注释说明
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime

revision = 'b2c3d4e5f6g7'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None

def upgrade():
    """升级: 创建文章表并迁移初始数据"""

    # 步骤1: 创建articles表
    op.create_table(
        'articles',
        sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
        sa.Column('title', sa.String(200), nullable=False, comment='文章标题'),
        sa.Column('content', sa.Text(), nullable=True, comment='文章内容'),
        sa.Column('status', sa.String(20), nullable=False, server_default='draft'),
        sa.Column('author_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
        sa.Column('category_id', sa.Integer(), sa.ForeignKey('categories.id'), nullable=True),
        sa.Column('view_count', sa.Integer(), server_default='0', nullable=False),
        sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
        sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
        sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False),
    )

    # 步骤2: 创建索引
    op.create_index('idx_articles_status', 'articles', ['status'])
    op.create_index('idx_articles_author', 'articles', ['author_id'])
    op.create_index('idx_articles_created', 'articles', ['created_at'])
    op.create_index('idx_articles_status_created', 'articles', ['status', 'created_at'])

    # 步骤3: 数据迁移(如果需要)
    # op.execute("INSERT INTO articles (title, content, author_id) VALUES (...)")

def downgrade():
    """降级: 删除文章表"""
    # 删除索引(顺序与创建相反)
    op.drop_index('idx_articles_status_created', table_name='articles')
    op.drop_index('idx_articles_created', table_name='articles')
    op.drop_index('idx_articles_author', table_name='articles')
    op.drop_index('idx_articles_status', table_name='articles')

    # 删除表
    op.drop_table('articles')

7.10 多环境迁移配置

开发、测试、生产环境通常需要不同的迁移策略:

python 复制代码
# migrations/env.py 中的多环境配置

def run_migrations_online():
    # 根据环境变量选择配置
    import os
    environment = os.environ.get('FLASK_ENV', 'development')

    if environment == 'production':
        # 生产环境: 不允许自动生成,只能手动编写
        config.set_main_option('sqlalchemy.url', os.environ['DATABASE_URL'])
    elif environment == 'testing':
        # 测试环境: 使用内存数据库
        config.set_main_option('sqlalchemy.url', 'sqlite://')
    else:
        # 开发环境
        config.set_main_option(
            'sqlalchemy.url',
            current_app.config.get('SQLALCHEMY_DATABASE_URI')
        )

    # ... 运行迁移
bash 复制代码
# 不同环境执行迁移
# 开发环境
export FLASK_ENV=development
flask db upgrade

# 生产环境
export FLASK_ENV=production
export DATABASE_URL=postgresql://user:pass@prod-host/mydb
flask db upgrade

7.11 迁移常见问题与解决方案

问题1: autogenerate检测不到变更
bash 复制代码
# 原因: 模型没有正确导入到迁移环境
# 解决: 确保在migrate.init_app之后导入了所有模型

# migrations/env.py 中确保导入了模型
# 在run_migrations_online()中添加:
from app import models  # 导入所有模型
target_metadata = db.metadata
问题2: 迁移冲突(多人同时修改)
bash 复制代码
# 场景: 开发者A和B同时基于同一版本创建了迁移
# 解决: 使用merge合并分支

flask db merge -m "合并A和B的迁移" a1b2c3d4e5f6 b2c3d4e5f6g7
问题3: SQLite不支持某些ALTER操作
python 复制代码
# SQLite不支持ALTER COLUMN的某些操作(如修改列类型)
# 使用batch mode解决:

def upgrade():
    # 使用batch_alter_table在SQLite上执行复杂的ALTER操作
    with op.batch_alter_table('users') as batch_op:
        batch_op.alter_column('username',
            existing_type=sa.String(80),
            type_=sa.String(200),
            nullable=True,
        )
        batch_op.drop_column('old_column')
问题4: 数据丢失风险
python 复制代码
# 安全的列类型变更(先添加新列,迁移数据,再删除旧列)
def upgrade():
    # 1. 添加新列
    op.add_column('users', sa.Column('age_new', sa.Integer(), nullable=True))

    # 2. 迁移数据
    op.execute("UPDATE users SET age_new = CAST(age_string AS INTEGER)")

    # 3. 删除旧列
    op.drop_column('users', 'age_string')

    # 4. 重命名新列
    op.alter_column('users', 'age_new', new_column_name='age')

7.12 迁移最佳实践

  1. 每次模型变更后立即生成迁移脚本: 不要积累大量变更再生成
  2. 检查自动生成的脚本: autogenerate不是万能的,需要人工审查
  3. 先在开发环境测试: 不要直接在生产环境执行未测试的迁移
  4. 备份生产数据: 执行迁移前先备份数据库
  5. 保持迁移线性: 避免分叉,及时合并
  6. 不要修改已执行的迁移: 如果需要修改,创建新的迁移
  7. 将迁移脚本纳入版本控制: 迁移脚本应该提交到Git
  8. downgrade必须可用: 确保每个迁移都能正确回退
bash 复制代码
# 完整的迁移工作流

# 1. 修改模型
# vim models.py (添加新字段)

# 2. 生成迁移脚本
flask db migrate -m "添加xxx字段"

# 3. 检查脚本
# vim migrations/versions/xxx_添加xxx字段.py

# 4. 在开发环境测试
flask db upgrade
# 测试应用功能

# 5. 测试回退
flask db downgrade
# 确认回退正常

# 6. 重新升级
flask db upgrade

# 7. 提交到版本控制
git add migrations/ models.py
git commit -m "添加xxx字段"

# 8. 部署到生产
# 先备份
# pg_dump mydb > backup.sql
# 执行迁移
flask db upgrade

第八章 高级主题

本章探讨SQLAlchemy的高级功能,包括事件监听、混合属性、自定义类型、软删除、多租户架构、读写分离、分库分表和全文搜索等。这些功能在实际项目开发中非常实用,能解决许多复杂场景的需求。

8.1 事件监听

SQLAlchemy提供了强大的事件系统,允许开发者在ORM操作的不同阶段插入自定义逻辑。

8.1.1 常用事件类型
事件 触发时机 典型用途
before_insert INSERT执行前 数据验证、自动填充字段
after_insert INSERT执行后 记录日志、触发通知
before_update UPDATE执行前 数据验证、更新时间戳
after_update UPDATE执行后 记录变更日志
before_delete DELETE执行前 级联清理、权限检查
after_delete DELETE执行后 清理关联文件
before_commit COMMIT前 全局数据检查
after_commit COMMIT后 发送通知、推送消息
8.1.2 使用事件监听
python 复制代码
from sqlalchemy import event
from datetime import datetime

# ========== 方式1: 使用@event.listens_for装饰器 ==========

# 在User插入前自动填充created_at
@event.listens_for(User, 'before_insert')
def set_created_at(mapper, connection, target):
    """target就是即将插入的User实例"""
    if not target.created_at:
        target.created_at = datetime.utcnow()

# 在User更新前自动更新updated_at
@event.listens_for(User, 'before_update')
def set_updated_at(mapper, connection, target):
    """target就是即将更新的User实例"""
    target.updated_at = datetime.utcnow()

# 在文章删除后清理关联的评论和标签
@event.listens_for(Article, 'after_delete')
def cleanup_after_article_delete(mapper, connection, target):
    """文章删除后,清理关联数据"""
    # 删除关联评论
    connection.execute(
        text("DELETE FROM comments WHERE article_id = :aid"),
        {'aid': target.id}
    )
    # 删除标签关联
    connection.execute(
        text("DELETE FROM article_tags WHERE article_id = :aid"),
        {'aid': target.id}
    )

# ========== 方式2: 使用事件函数 ==========
def log_insert(mapper, connection, target):
    """记录插入日志"""
    print(f"[LOG] 插入{target.__class__.__name__}: {target}")

event.listen(User, 'after_insert', log_insert)
event.listen(Article, 'after_insert', log_insert)

# ========== 方式3: Session级别事件 ==========
@event.listens_for(db.session, 'before_commit')
def before_commit(session):
    """提交前检查"""
    for obj in session.new:  # 新增的对象
        if hasattr(obj, 'created_at') and not obj.created_at:
            obj.created_at = datetime.utcnow()

@event.listens_for(db.session, 'after_commit')
def after_commit(session):
    """提交后发送通知(此时数据已持久化)"""
    for obj in session.new:
        if isinstance(obj, Article) and obj.status == 'published':
            # 发送文章发布通知
            send_notification(f"新文章发布: {obj.title}")
8.1.3 实战: 自动密码哈希
python 复制代码
from werkzeug.security import generate_password_hash

@event.listens_for(User, 'before_insert')
@event.listens_for(User, 'before_update')
def hash_password_if_changed(mapper, connection, target):
    """如果password字段被修改,自动哈希"""
    # 检查password是否在变更历史中
    history = db.inspect(target).attrs.password.history
    if history.has_changes():
        # 获取新密码(未哈希的)
        new_password = target.password
        if new_password and not new_password.startswith('pbkdf2:'):
            target.password_hash = generate_password_hash(new_password)
            target.password = None  # 清除明文密码

8.2 混合属性

混合属性(Hybrid Property)允许同一个属性在Python层面和SQL层面有不同的实现,使得属性既能用于实例操作,也能用于查询过滤。

python 复制代码
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import case, func

class Article(db.Model):
    __tablename__ = 'articles'
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    content = db.Column(db.Text)
    view_count = db.Column(db.Integer, default=0)
    like_count = db.Column(db.Integer, default=0)
    comment_count = db.Column(db.Integer, default=0)
    status = db.Column(db.String(20), default='draft')
    is_deleted = db.Column(db.Boolean, default=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    @hybrid_property
    def is_published(self):
        """Python层面: 判断是否已发布"""
        return self.status == 'published' and not self.is_deleted

    @is_published.expression
    def is_published(cls):
        """SQL层面: 生成SQL表达式

        可以在查询中使用:
        select(Article).where(Article.is_published == True)
        """
        return (cls.status == 'published') & (~cls.is_deleted)

    @hybrid_property
    def popularity_score(self):
        """Python层面: 计算热度分数"""
        return self.view_count + self.like_count * 5 + self.comment_count * 3

    @popularity_score.expression
    def popularity_score(cls):
        """SQL层面: 生成热度SQL表达式"""
        return cls.view_count + cls.like_count * 5 + cls.comment_count * 3

    @popularity_score.comparator
    def popularity_score(cls):
        """自定义比较器: 支持 > < 等运算符"""
        # 使用现成的表达式
        expr = cls.view_count + cls.like_count * 5 + cls.comment_count * 3
        return expr

    @hybrid_property
    def word_count(self):
        """Python层面: 计算字数"""
        return len(self.content) if self.content else 0

    @word_count.expression
    def word_count(cls):
        """SQL层面: 使用数据库函数计算字数"""
        return func.length(cls.content)


# 使用示例
with app.app_context():
    # Python层面使用
    article = db.session.get(Article, 1)
    print(article.is_published)       # True/False
    print(article.popularity_score)   # 1230

    # SQL层面使用(在查询中使用)
    from sqlalchemy import select

    # 过滤已发布文章
    stmt = select(Article).where(Article.is_published == True)

    # 按热度排序
    stmt = select(Article).order_by(Article.popularity_score.desc())

    # 过滤高热度文章
    stmt = select(Article).where(Article.popularity_score > 1000)

8.3 自定义类型

当SQLAlchemy内置的列类型无法满足需求时,可以通过TypeDecorator创建自定义类型。

python 复制代码
from sqlalchemy.types import TypeDecorator, String, Text
import json

class JSONEncodedDict(TypeDecorator):
    """自定义JSON类型

    在数据库中存储为TEXT,在Python中自动转换为dict/list
    适用于不支持原生JSON的数据库(如旧版SQLite)
    """
    impl = Text  # 底层使用Text类型存储
    cache_ok = True  # 启用缓存(安全类型)

    def process_bind_param(self, value, dialect):
        """Python -> 数据库: 将Python对象序列化为JSON字符串"""
        if value is not None:
            return json.dumps(value, ensure_ascii=False)
        return None

    def process_result_value(self, value, dialect):
        """数据库 -> Python: 将JSON字符串反序列化为Python对象"""
        if value is not None:
            return json.loads(value)
        return None


class EncryptedString(TypeDecorator):
    """加密字符串类型

    存储时自动加密,读取时自动解密
    """
    impl = String
    cache_ok = True

    def __init__(self, key=None, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.key = key or 'default-secret-key'

    def process_bind_param(self, value, dialect):
        """加密"""
        if value is not None:
            # 简单的加密示例(实际应使用AES等)
            from cryptography.fernet import Fernet
            import base64
            key = base64.urlsafe_b64encode(self.key.encode().ljust(32)[:32])
            f = Fernet(key)
            return f.encrypt(value.encode()).decode()
        return None

    def process_result_value(self, value, dialect):
        """解密"""
        if value is not None:
            from cryptography.fernet import Fernet
            import base64
            key = base64.urlsafe_b64encode(self.key.encode().ljust(32)[:32])
            f = Fernet(key)
            return f.decrypt(value.encode()).decode()
        return None


# 使用自定义类型
class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80))

    # 使用自定义JSON类型
    preferences = db.Column(JSONEncodedDict, default=dict)
    settings = db.Column(JSONEncodedDict, default=dict)

    # 使用加密类型
    phone = db.Column(EncryptedString(100))
    id_card = db.Column(EncryptedString(200))

# 使用
user = User(
    username='张三',
    preferences={'theme': 'dark', 'language': 'zh-CN'},
    phone='13800138000',  # 自动加密存储
)
db.session.add(user)
db.session.commit()

# 读取时自动解密
user = db.session.get(User, 1)
print(user.preferences)  # {'theme': 'dark', 'language': 'zh-CN'}
print(user.phone)        # 13800138000 (自动解密)

8.4 计算列与虚拟列

某些数据库支持计算列(Generated Column),即列的值由其他列计算得出。

python 复制代码
# PostgreSQL: 生成列
class Product(db.Model):
    __tablename__ = 'products'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100))
    price = db.Column(db.Numeric(10, 2))
    discount = db.Column(db.Numeric(5, 2), default=0)

    # 使用混合属性模拟计算列(Python层面)
    @hybrid_property
    def final_price(self):
        """最终价格 = 原价 * (1 - 折扣)"""
        return self.price * (1 - self.discount / 100)

    @final_price.expression
    def final_price(cls):
        return cls.price * (1 - cls.discount / 100.0)

# 如果使用PostgreSQL,可以使用原生生成列
# CREATE TABLE products (
#     price NUMERIC(10,2),
#     discount NUMERIC(5,2) DEFAULT 0,
#     final_price NUMERIC(10,2) GENERATED ALWAYS AS (price * (1 - discount/100)) STORED
# );

8.5 软删除实现

软删除(Soft Delete)是将删除操作标记为"已删除"而非真正从数据库删除,数据可以恢复。

python 复制代码
from sqlalchemy import event

class SoftDeleteMixin:
    """软删除Mixin

    使用方式: class User(SoftDeleteMixin, db.Model): ...
    """
    is_deleted = db.Column(db.Boolean, default=False, nullable=False, index=True)
    deleted_at = db.Column(db.DateTime, nullable=True)

    def soft_delete(self, commit=True):
        """执行软删除"""
        self.is_deleted = True
        self.deleted_at = datetime.utcnow()
        db.session.add(self)
        if commit:
            db.session.commit()

    def restore(self, commit=True):
        """恢复软删除的记录"""
        self.is_deleted = False
        self.deleted_at = None
        db.session.add(self)
        if commit:
            db.session.commit()

    @classmethod
    def get_active(cls):
        """获取未删除的记录的查询"""
        from sqlalchemy import select
        return select(cls).where(cls.is_deleted == False)

    @classmethod
    def get_deleted(cls):
        """获取已删除的记录"""
        from sqlalchemy import select
        return select(cls).where(cls.is_deleted == True)


# ========== 使用事件自动过滤软删除记录 ==========
@event.listens_for(SoftDeleteMixin, 'before_delete', propagate=True)
def prevent_hard_delete(mapper, connection, target):
    """拦截物理删除,转为软删除"""
    raise ValueError(
        f"不允许物理删除{target.__class__.__name__},请使用soft_delete()方法"
    )


# ========== 查询过滤器Mixin ==========
class QueryWithSoftDelete:
    """自动过滤软删除记录的查询基类"""

    @classmethod
    def query_active(cls):
        """查询未删除的记录"""
        from sqlalchemy import select
        return select(cls).where(cls.is_deleted == False)

# 使用
class Article(SoftDeleteMixin, db.Model):
    __tablename__ = 'articles'
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    content = db.Column(db.Text)
    status = db.Column(db.String(20), default='draft')

# 软删除操作
article = db.session.get(Article, 1)
article.soft_delete()  # 标记为已删除

# 查询未删除的文章
from sqlalchemy import select
stmt = Article.get_active().where(Article.status == 'published')
articles = db.session.execute(stmt).scalars().all()

# 恢复
article.restore()  # 恢复记录

8.6 多租户架构

多租户(Multi-tenancy)是指一个应用实例为多个客户(租户)提供服务,数据需要隔离。

8.6.1 行隔离方案(共享表,添加tenant_id)
python 复制代码
class TenantMixin:
    """多租户Mixin"""
    tenant_id = db.Column(db.Integer, db.ForeignKey('tenants.id'), nullable=False, index=True)

class Tenant(db.Model):
    __tablename__ = 'tenants'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    domain = db.Column(db.String(200), unique=True)

class Article(TenantMixin, db.Model):
    __tablename__ = 'articles'
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200))
    content = db.Column(db.Text)

# 查询时自动过滤当前租户
def get_current_tenant_id():
    """从请求上下文获取当前租户ID"""
    from flask import g
    return getattr(g, 'tenant_id', None)

def get_tenant_articles():
    """获取当前租户的文章"""
    tenant_id = get_current_tenant_id()
    stmt = select(Article).where(Article.tenant_id == tenant_id)
    return db.session.execute(stmt).scalars().all()
8.6.2 Schema隔离方案(PostgreSQL)
python 复制代码
# PostgreSQL支持多Schema,每个租户一个Schema
from sqlalchemy import text

def create_tenant_schema(tenant_name):
    """为新租户创建独立的Schema"""
    schema_name = f"tenant_{tenant_name}"
    db.session.execute(text(f'CREATE SCHEMA IF NOT EXISTS {schema_name}'))
    db.session.commit()

def set_search_path(tenant_name):
    """设置当前会话的搜索路径"""
    schema_name = f"tenant_{tenant_name}"
    db.session.execute(text(f'SET search_path TO {schema_name}, public'))

8.7 读写分离实现

读写分离是将读操作路由到从库,写操作路由到主库,提升数据库并发处理能力。

python 复制代码
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

class RoutingSession(Session):
    """自定义Session,实现读写分离"""

    def get_bind(self, mapper=None, clause=None, **kw):
        """根据操作类型选择引擎"""
        # 写操作(INSERT, UPDATE, DELETE)使用主库
        if self._flushing or isinstance(clause, (Update, Delete, Insert)):
            return self.binds.get('master') or self.bind

        # 读操作(SELECT)使用从库
        return self.binds.get('slave') or self.bind


# 创建主从引擎
master_engine = create_engine('mysql+pymysql://root:pass@master-host/mydb')
slave_engine = create_engine('mysql+pymysql://root:pass@slave-host/mydb')

# 创建Session工厂
session_factory = sessionmaker(
    class_=RoutingSession,
    binds={
        'master': master_engine,
        'slave': slave_engine,
    }
)

# 在Flask-SQLAlchemy中使用自定义Session
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'pool_size': 10,
}

# 更简单的方式: 在查询时手动选择
@app.route('/api/articles')
def get_articles():
    # 读操作: 可以使用从库
    # 实际中通常通过中间件或代理自动路由
    stmt = select(Article).where(Article.status == 'published')
    articles = db.session.execute(stmt).scalars().all()
    return jsonify([a.to_dict() for a in articles])

@app.route('/api/articles', methods=['POST'])
def create_article():
    # 写操作: 使用主库
    article = Article(title=request.json['title'])
    db.session.add(article)
    db.session.commit()
    return jsonify(article.to_dict()), 201

8.8 分库分表策略

当单表数据量过大(如超过1000万行)时,需要考虑分库分表。

python 复制代码
# ========== 水平分表: 按日期分表 ==========
def get_article_table_name(date):
    """根据日期生成分表名"""
    return f"articles_{date.strftime('%Y_%m')}"

def get_article_model(date):
    """动态创建分表模型"""
    table_name = get_article_table_name(date)

    class DynamicArticle(db.Model):
        __tablename__ = table_name
        __table_args__ = {'extend_existing': True}

        id = db.Column(db.Integer, primary_key=True)
        title = db.Column(db.String(200))
        content = db.Column(db.Text)
        created_at = db.Column(db.DateTime)

    return DynamicArticle

# 使用
model = get_article_model(datetime(2024, 1, 15))
article = model(title='测试', content='...')
db.session.add(article)
db.session.commit()

# ========== 使用SQLAlchemy的分片(Sharding) ==========
from sqlalchemy.orm import ShardedSession

# 配置分片规则
shard_1_engine = create_engine('mysql+pymysql://shard1/mydb')
shard_2_engine = create_engine('mysql+pymysql://shard2/mydb')

def shard_chooser(mapper, instance, clause=None):
    """选择分片: 根据用户ID决定存储到哪个分片"""
    if isinstance(instance, User):
        return 'shard1' if instance.id % 2 == 0 else 'shard2'
    return 'shard1'

def id_chooser(query, ident):
    """根据ID选择分片"""
    return ['shard1', 'shard2']  # 查询所有分片

def query_chooser(query):
    """根据查询条件选择分片"""
    return ['shard1', 'shard2']

8.9 全文搜索集成

python 复制代码
# ========== 方案1: 使用数据库内置全文搜索(PostgreSQL) ==========

class Article(db.Model):
    __tablename__ = 'articles'
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200))
    content = db.Column(db.Text)

    # PostgreSQL全文搜索向量列
    # from sqlalchemy.dialects.postgresql import TSVECTOR
    # search_vector = db.Column(TSVECTOR)

# 创建全文搜索索引
# CREATE INDEX idx_articles_search ON articles USING gin(to_tsvector('chinese', title || ' ' || content));

# 使用全文搜索查询
from sqlalchemy import text

def fulltext_search(keyword):
    """PostgreSQL全文搜索"""
    stmt = text("""
        SELECT id, title, ts_rank_cd(search_vector, query) AS rank
        FROM articles, to_tsquery(:keyword) query
        WHERE search_vector @@ query
        ORDER BY rank DESC
        LIMIT 20
    """)
    result = db.session.execute(stmt, {'keyword': keyword})
    return result.all()

# ========== 方案2: 使用SQLAlchemy-Searchable(PostgreSQL) ==========
# pip install SQLAlchemy-Searchable
# from sqlalchemy_searchable import make_searchable
# from sqlalchemy_utils import TSVectorType
#
# make_searchable(db.metadata)
#
# class Article(db.Model):
#     __tablename__ = 'articles'
#     id = db.Column(db.Integer, primary_key=True)
#     title = db.Column(db.String(200))
#     content = db.Column(db.Text)
#     search_vector = db.Column(TSVectorType('title', 'content'))
#
# # 使用
# Article.search('Flask', sort=True)  # 搜索并按相关性排序

# ========== 方案3: 使用Whoosh(纯Python,适合中小项目) ==========
# pip install whoosh
from whoosh.index import create_in, open_dir
from whoosh.fields import Schema, TEXT, ID
from whoosh.qparser import QueryParser
import os

# 创建索引
def create_search_index():
    """创建Whoosh索引"""
    schema = Schema(
        id=ID(stored=True, unique=True),
        title=TEXT(stored=True),
        content=TEXT(stored=True),
    )
    if not os.path.exists('search_index'):
        os.mkdir('search_index')
    ix = create_in('search_index', schema)

    # 将文章数据导入索引
    writer = ix.writer()
    articles = db.session.execute(select(Article)).scalars().all()
    for article in articles:
        writer.add_document(
            id=str(article.id),
            title=article.title,
            content=article.content or '',
        )
    writer.commit()

# 搜索
def search_with_whoosh(keyword, limit=20):
    """使用Whoosh搜索"""
    ix = open_dir('search_index')
    with ix.searcher() as searcher:
        query = QueryParser('content', ix.schema).parse(keyword)
        results = searcher.search(query, limit=limit)
        return [int(r['id']) for r in results]

8.10 JSON字段操作

PostgreSQL的JSONB类型提供了强大的JSON操作能力。

python 复制代码
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import select, cast, String

class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80))
    # 使用JSONB存储结构化数据
    preferences = db.Column(JSONB, default=dict)
    metadata_json = db.Column(JSONB, default=dict)

# ========== JSON字段操作 ==========

# 1. 设置JSON值
user = User(
    username='张三',
    preferences={
        'theme': 'dark',
        'language': 'zh-CN',
        'notifications': {
            'email': True,
            'push': False,
        }
    }
)

# 2. 查询JSON字段中的值(PostgreSQL)
# 查询主题为dark的用户
stmt = select(User).where(
    User.preferences['theme'].astext == 'dark'
)
# SQL: WHERE preferences->>'theme' = 'dark'

# 查询开启了邮件通知的用户
stmt = select(User).where(
    User.preferences['notifications']['email'].astext == 'true'
)
# SQL: WHERE preferences->'notifications'->>'email' = 'true'

# 3. JSON包含查询
# 查询preferences中包含theme键的用户
stmt = select(User).where(
    User.preferences.has_key('theme')
)
# SQL: WHERE preferences ? 'theme'

# 查询preferences中包含特定JSON的用户
stmt = select(User).where(
    User.preferences.contains({'language': 'zh-CN'})
)
# SQL: WHERE preferences @> '{"language": "zh-CN"}'

# 4. 更新JSON字段的特定键(PostgreSQL 9.5+)
from sqlalchemy import update
stmt = (
    update(User)
    .where(User.id == 1)
    .values({
        'preferences': User.preferences.op('||')(
            {'theme': 'light', 'font_size': 14}
        )
    })
)
# SQL: UPDATE users SET preferences = preferences || '{"theme": "light", "font_size": 14}'::jsonb

# 5. 提取JSON数组中的元素
# 假设preferences中有一个tags数组: {"tags": ["python", "flask"]}
stmt = select(User).where(
    User.preferences['tags'].astext.contains('flask')
)

# 6. JSON路径查询
# 使用jsonb_path_query(PostgreSQL 12+)
stmt = text("""
    SELECT id, username, jsonb_path_query(preferences, '$.notifications.*') as notif
    FROM users
    WHERE jsonb_path_exists(preferences, '$.notifications.email ? (@ == true)')
""")

第九章 实战: 博客系统数据库设计

本章将综合运用前八章的知识,完整设计一个博客系统的数据库。从需求分析到ER图设计,从模型实现到常用查询,展示Flask-SQLAlchemy在实际项目中的完整应用。

9.1 需求分析与ER图设计

9.1.1 功能需求

一个完整的博客系统通常需要以下功能模块:

模块 功能
用户管理 注册、登录、个人资料、密码管理
文章管理 发布、编辑、删除、草稿、发布状态管理
评论系统 评论、回复、嵌套评论、审核
标签系统 文章标签、标签云、按标签筛选
分类系统 树形分类、按分类浏览
关注系统 用户关注/被关注、动态 Feed
搜索 全文搜索文章
统计 浏览量、点赞、文章数统计
9.1.2 ER图设计
复制代码
┌──────────┐     1:N      ┌──────────┐     N:1      ┌──────────┐
│   User   │─────────────>│ Article  │<─────────────│ Category │
│          │              │          │              │ (自引用)  │
└──────────┘              └──────────┘              └──────────┘
     │                        │  │                       │
     │    1:N                 │  │ N:N                   │ N:1
     │                        │  │                       │
     v                        v  v                       v
┌──────────┐           ┌──────────┐  ┌──────┐    ┌──────────┐
│ Comment  │           │ Comment  │  │ Tag  │    │Category  │
│ (嵌套)   │           │          │  │      │    │(parent)  │
└──────────┘           └──────────┘  └──────┘    └──────────┘
     ^                                     ^
     │              N:N                    │
     └─────────── Follow ──────────────────┘
              (用户关注用户)

9.2 用户表设计

python 复制代码
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

class User(db.Model):
    """用户模型

    存储用户基本信息和认证数据
    """
    __tablename__ = 'users'

    # 主键
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)

    # 基本信息
    username = db.Column(db.String(80), unique=True, nullable=False, index=True, comment='用户名')
    email = db.Column(db.String(120), unique=True, nullable=False, index=True, comment='邮箱')
    password_hash = db.Column(db.String(256), nullable=False, comment='密码哈希')

    # 个人资料
    nickname = db.Column(db.String(80), comment='昵称')
    avatar_url = db.Column(db.String(500), comment='头像URL')
    bio = db.Column(db.Text, comment='个人简介')
    website = db.Column(db.String(200), comment='个人网站')

    # 状态
    is_active = db.Column(db.Boolean, default=True, server_default='true', nullable=False)
    is_admin = db.Column(db.Boolean, default=False, server_default='false', nullable=False)
    is_deleted = db.Column(db.Boolean, default=False, server_default='false', nullable=False)

    # 时间戳
    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now(), nullable=False)
    last_login = db.Column(db.DateTime, comment='最后登录时间')

    # 关系
    articles = db.relationship('Article', backref='author', lazy='dynamic',
                               foreign_keys='Article.author_id')
    comments = db.relationship('Comment', backref='author', lazy='dynamic')
    profile = db.relationship('UserProfile', backref='user', uselist=False, cascade='all, delete-orphan')

    # ========== 密码管理 ==========
    def set_password(self, password: str):
        """设置密码(自动哈希)"""
        self.password_hash = generate_password_hash(password)

    def check_password(self, password: str) -> bool:
        """验证密码"""
        return check_password_hash(self.password_hash, password)

    # ========== 属性 ==========
    @property
    def display_name(self):
        """显示名称(优先使用昵称)"""
        return self.nickname or self.username

    @property
    def article_count(self):
        """已发布文章数"""
        return self.articles.filter_by(status='published').count()

    @property
    def follower_count(self):
        """粉丝数"""
        return self.followers.count()

    @property
    def following_count(self):
        """关注数"""
        return self.following.count()

    # ========== 序列化 ==========
    def to_dict(self, include_profile=False):
        data = {
            'id': self.id,
            'username': self.username,
            'nickname': self.nickname,
            'avatar_url': self.avatar_url,
            'bio': self.bio,
            'is_admin': self.is_admin,
            'created_at': self.created_at.isoformat() if self.created_at else None,
            'article_count': self.article_count,
            'follower_count': self.follower_count,
        }
        if include_profile and self.profile:
            data['profile'] = self.profile.to_dict()
        return data

    def __repr__(self):
        return f'<User {self.username}>'


class UserProfile(db.Model):
    """用户详情模型(一对一)"""
    __tablename__ = 'user_profiles'

    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), unique=True, nullable=False)

    # 扩展信息
    phone = db.Column(db.String(20), comment='手机号')
    location = db.Column(db.String(100), comment='所在地')
    birthday = db.Column(db.Date, comment='生日')
    gender = db.Column(db.String(10), comment='性别')

    # 偏好设置(JSON)
    preferences = db.Column(db.JSON, default=dict, comment='用户偏好设置')

    def to_dict(self):
        return {
            'phone': self.phone,
            'location': self.location,
            'birthday': self.birthday.isoformat() if self.birthday else None,
            'gender': self.gender,
            'preferences': self.preferences or {},
        }

9.3 文章表设计

python 复制代码
import enum

class ArticleStatus(enum.Enum):
    """文章状态枚举"""
    draft = 'draft'          # 草稿
    published = 'published'  # 已发布
    archived = 'archived'    # 已归档
    private = 'private'      # 私密

class Article(db.Model):
    """文章模型"""
    __tablename__ = 'articles'

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)

    # 内容
    title = db.Column(db.String(200), nullable=False, comment='标题')
    slug = db.Column(db.String(200), unique=True, nullable=False, index=True, comment='URL友好的标识')
    summary = db.Column(db.String(500), comment='摘要')
    content = db.Column(db.Text, nullable=False, comment='正文')
    cover_image = db.Column(db.String(500), comment='封面图URL')

    # 状态
    status = db.Column(db.Enum(ArticleStatus), default=ArticleStatus.draft, nullable=False, index=True)
    is_pinned = db.Column(db.Boolean, default=False, comment='是否置顶')
    is_deleted = db.Column(db.Boolean, default=False, server_default='false', nullable=False)

    # 统计
    view_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
    like_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
    comment_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)

    # 外键
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
    category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True, index=True)

    # 时间戳
    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False, index=True)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now(), nullable=False)
    published_at = db.Column(db.DateTime, nullable=True, comment='发布时间')

    # 关系
    comments = db.relationship('Comment', backref='article', lazy='dynamic',
                               cascade='all, delete-orphan',
                               order_by='Comment.created_at.desc()')
    tags = db.relationship('Tag', secondary='article_tags', backref='articles', lazy='selectin')

    # ========== 属性 ==========
    @property
    def is_published(self):
        return self.status == ArticleStatus.published

    @property
    def reading_time(self):
        """估算阅读时间(分钟)"""
        if not self.content:
            return 1
        return max(1, len(self.content) // 400)

    @property
    def popularity(self):
        """热度分数"""
        return self.view_count + self.like_count * 5 + self.comment_count * 3

    # ========== 方法 ==========
    def increment_view_count(self):
        """增加浏览量(使用原子操作)"""
        self.view_count = Article.view_count + 1
        db.session.commit()

    def publish(self):
        """发布文章"""
        self.status = ArticleStatus.published
        self.published_at = datetime.utcnow()
        db.session.commit()

    def to_dict(self, include_content=False):
        data = {
            'id': self.id,
            'title': self.title,
            'slug': self.slug,
            'summary': self.summary,
            'cover_image': self.cover_image,
            'status': self.status.value if self.status else None,
            'view_count': self.view_count,
            'like_count': self.like_count,
            'comment_count': self.comment_count,
            'reading_time': self.reading_time,
            'author': self.author.to_dict() if self.author else None,
            'category': {'id': self.category.id, 'name': self.category.name} if self.category else None,
            'tags': [{'id': t.id, 'name': t.name} for t in self.tags],
            'created_at': self.created_at.isoformat() if self.created_at else None,
            'published_at': self.published_at.isoformat() if self.published_at else None,
        }
        if include_content:
            data['content'] = self.content
        return data

    def __repr__(self):
        return f'<Article {self.title}>'

9.4 评论表设计

python 复制代码
class Comment(db.Model):
    """评论模型(支持嵌套评论)"""
    __tablename__ = 'comments'

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)

    # 内容
    content = db.Column(db.Text, nullable=False, comment='评论内容')

    # 状态
    is_deleted = db.Column(db.Boolean, default=False, server_default='false', nullable=False)
    is_approved = db.Column(db.Boolean, default=True, server_default='true', nullable=False)

    # 外键
    article_id = db.Column(db.Integer, db.ForeignKey('articles.id'), nullable=False, index=True)
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)

    # 嵌套评论(自引用)
    parent_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True, index=True)

    # 统计
    like_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)

    # 时间戳
    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now(), nullable=False)

    # 关系(自引用: 父评论和子评论)
    replies = db.relationship(
        'Comment',
        backref=db.backref('parent', remote_side='Comment.id'),
        lazy='selectin',
        cascade='all, delete-orphan',
        order_by='Comment.created_at.asc()',
    )

    @property
    def reply_count(self):
        """回复数量"""
        return self.replies.count() if hasattr(self.replies, 'count') else len(self.replies)

    def to_dict(self, include_replies=True):
        data = {
            'id': self.id,
            'content': self.content,
            'like_count': self.like_count,
            'created_at': self.created_at.isoformat() if self.created_at else None,
            'author': self.author.to_dict() if self.author else None,
            'parent_id': self.parent_id,
        }
        if include_replies:
            data['replies'] = [r.to_dict(include_replies=False) for r in self.replies]
        return data

    def __repr__(self):
        return f'<Comment id={self.id} by={self.author_id}>'

9.5 标签系统设计

python 复制代码
# 多对多关联表
article_tags = db.Table(
    'article_tags',
    db.Column('article_id', db.Integer, db.ForeignKey('articles.id'), primary_key=True),
    db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True),
    db.Column('created_at', db.DateTime, default=datetime.utcnow, server_default=db.func.now()),
)

class Tag(db.Model):
    """标签模型"""
    __tablename__ = 'tags'

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    name = db.Column(db.String(50), unique=True, nullable=False, index=True, comment='标签名')
    slug = db.Column(db.String(50), unique=True, nullable=False, comment='URL标识')
    description = db.Column(db.String(200), comment='标签描述')
    usage_count = db.Column(db.Integer, default=0, server_default='0', comment='使用次数')

    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)

    @property
    def article_count(self):
        """使用此标签的文章数"""
        return len(self.articles)

    def to_dict(self):
        return {
            'id': self.id,
            'name': self.name,
            'slug': self.slug,
            'description': self.description,
            'article_count': self.article_count,
        }

    def __repr__(self):
        return f'<Tag {self.name}>'

9.6 分类系统设计(自引用树形结构)

python 复制代码
class Category(db.Model):
    """分类模型(树形结构,自引用)"""
    __tablename__ = 'categories'

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    name = db.Column(db.String(50), nullable=False, comment='分类名称')
    slug = db.Column(db.String(50), unique=True, nullable=False, index=True, comment='URL标识')
    description = db.Column(db.Text, comment='分类描述')
    icon = db.Column(db.String(100), comment='分类图标')
    sort_order = db.Column(db.Integer, default=0, comment='排序权重')

    # 自引用: 父分类
    parent_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True, index=True)

    # 时间戳
    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)

    # 关系(自引用)
    children = db.relationship(
        'Category',
        backref=db.backref('parent', remote_side='Category.id'),
        lazy='selectin',
        order_by='Category.sort_order.asc()',
    )

    articles = db.relationship('Article', backref='category', lazy='dynamic')

    @property
    def article_count(self):
        """本分类下的文章数(不含子分类)"""
        return self.articles.filter_by(status='published').count()

    @property
    def total_article_count(self):
        """本分类及所有子分类的文章总数"""
        count = self.article_count
        for child in self.children:
            count += child.total_article_count  # 递归计算
        return count

    def get_descendants(self):
        """获取所有子孙分类"""
        result = []
        for child in self.children:
            result.append(child)
            result.extend(child.get_descendants())
        return result

    def get_ancestors(self):
        """获取所有祖先分类"""
        result = []
        if self.parent:
            result.append(self.parent)
            result.extend(self.parent.get_ancestors())
        return result

    def to_dict(self, include_children=True):
        data = {
            'id': self.id,
            'name': self.name,
            'slug': self.slug,
            'description': self.description,
            'icon': self.icon,
            'sort_order': self.sort_order,
            'parent_id': self.parent_id,
            'article_count': self.article_count,
        }
        if include_children:
            data['children'] = [c.to_dict(include_children=True) for c in self.children]
        return data

    def __repr__(self):
        return f'<Category {self.name}>'

9.7 关注系统设计(多对多自引用)

python 复制代码
# 用户关注关联表(多对多自引用)
user_follows = db.Table(
    'user_follows',
    db.Column('follower_id', db.Integer, db.ForeignKey('users.id'), primary_key=True, comment='关注者'),
    db.Column('followed_id', db.Integer, db.ForeignKey('users.id'), primary_key=True, comment='被关注者'),
    db.Column('created_at', db.DateTime, default=datetime.utcnow, server_default=db.func.now()),
)

# 在User模型中添加关注关系
# followers: 谁关注了我(粉丝)
# following: 我关注了谁

# 在User类中添加(补充):
# followers = db.relationship(
#     'User',
#     secondary=user_follows,
#     primaryjoin=(user_follows.c.followed_id == id),
#     secondaryjoin=(user_follows.c.follower_id == id),
#     backref=db.backref('following', lazy='dynamic'),
#     lazy='dynamic'
# )

# 使用示例:
# user.following.append(target_user)  # 关注
# user.following.remove(target_user)  # 取消关注
# user.following.all()  # 我关注的人
# user.followers.all()  # 关注我的人

9.8 数据库索引设计

python 复制代码
# ========== 索引设计原则 ==========
# 1. 在WHERE、JOIN、ORDER BY频繁出现的列上建索引
# 2. 选择区分度高的列建索引
# 3. 复合索引遵循"最左前缀"原则
# 4. 避免过度索引(影响写入性能)

# articles表的索引(在模型定义中通过index=True或__table_args__设置):
class Article(db.Model):
    __tablename__ = 'articles'
    # ... 字段定义 ...

    __table_args__ = (
        # 复合索引: 按状态+创建时间查询(最常用的查询模式)
        db.Index('idx_status_created', 'status', 'created_at'),
        # 复合索引: 按作者+状态查询
        db.Index('idx_author_status', 'author_id', 'status'),
        # 复合索引: 按分类+状态查询
        db.Index('idx_category_status', 'category_id', 'status'),
        # 全文搜索索引(PostgreSQL)
        # db.Index('idx_content_search', 'content', postgresql_using='gin'),
    )

# comments表的索引
class Comment(db.Model):
    __tablename__ = 'comments'
    # ... 字段定义 ...

    __table_args__ = (
        # 按文章+创建时间查询评论
        db.Index('idx_article_created', 'article_id', 'created_at'),
        # 按父评论查询回复
        db.Index('idx_parent', 'parent_id'),
    )

9.9 完整模型代码实现

以下是博客系统的完整模型定义(整合版):

python 复制代码
"""
博客系统完整模型定义
文件: app/models.py
"""
from datetime import datetime
import enum
from werkzeug.security import generate_password_hash, check_password_hash
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()


# ========== 枚举定义 ==========
class ArticleStatus(enum.Enum):
    draft = 'draft'
    published = 'published'
    archived = 'archived'
    private = 'private'


# ========== 关联表 ==========
article_tags = db.Table(
    'article_tags',
    db.Column('article_id', db.Integer, db.ForeignKey('articles.id'), primary_key=True),
    db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True),
    db.Column('created_at', db.DateTime, default=datetime.utcnow),
)

user_follows = db.Table(
    'user_follows',
    db.Column('follower_id', db.Integer, db.ForeignKey('users.id'), primary_key=True),
    db.Column('followed_id', db.Integer, db.ForeignKey('users.id'), primary_key=True),
    db.Column('created_at', db.DateTime, default=datetime.utcnow),
)


# ========== 用户模型 ==========
class User(db.Model):
    __tablename__ = 'users'

    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False, index=True)
    email = db.Column(db.String(120), unique=True, nullable=False, index=True)
    password_hash = db.Column(db.String(256), nullable=False)
    nickname = db.Column(db.String(80))
    avatar_url = db.Column(db.String(500))
    bio = db.Column(db.Text)
    is_active = db.Column(db.Boolean, default=True, server_default='true', nullable=False)
    is_admin = db.Column(db.Boolean, default=False, server_default='false', nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now(), nullable=False)
    last_login = db.Column(db.DateTime)

    # 关系
    articles = db.relationship('Article', backref='author', lazy='dynamic', foreign_keys='Article.author_id')
    comments = db.relationship('Comment', backref='author', lazy='dynamic')
    profile = db.relationship('UserProfile', backref='user', uselist=False, cascade='all, delete-orphan')
    followers = db.relationship(
        'User', secondary=user_follows,
        primaryjoin=(user_follows.c.followed_id == id),
        secondaryjoin=(user_follows.c.follower_id == id),
        backref=db.backref('following', lazy='dynamic'),
        lazy='dynamic'
    )

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)

    def follow(self, user):
        if not self.is_following(user):
            self.followers.append(user)

    def unfollow(self, user):
        if self.is_following(user):
            self.followers.remove(user)

    def is_following(self, user):
        return self.following.filter(user_follows.c.followed_id == user.id).count() > 0

    @property
    def display_name(self):
        return self.nickname or self.username

    def to_dict(self):
        return {
            'id': self.id,
            'username': self.username,
            'nickname': self.nickname,
            'avatar_url': self.avatar_url,
            'bio': self.bio,
            'created_at': self.created_at.isoformat() if self.created_at else None,
        }

    def __repr__(self):
        return f'<User {self.username}>'


class UserProfile(db.Model):
    __tablename__ = 'user_profiles'
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), unique=True, nullable=False)
    phone = db.Column(db.String(20))
    location = db.Column(db.String(100))
    birthday = db.Column(db.Date)
    preferences = db.Column(db.JSON, default=dict)


# ========== 分类模型 ==========
class Category(db.Model):
    __tablename__ = 'categories'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), nullable=False)
    slug = db.Column(db.String(50), unique=True, nullable=False, index=True)
    description = db.Column(db.Text)
    sort_order = db.Column(db.Integer, default=0)
    parent_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True, index=True)
    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now())

    children = db.relationship('Category', backref=db.backref('parent', remote_side='Category.id'), lazy='selectin', order_by='Category.sort_order')
    articles = db.relationship('Article', backref='category', lazy='dynamic')

    def to_dict(self, include_children=True):
        data = {'id': self.id, 'name': self.name, 'slug': self.slug, 'description': self.description}
        if include_children:
            data['children'] = [c.to_dict() for c in self.children]
        return data


# ========== 文章模型 ==========
class Article(db.Model):
    __tablename__ = 'articles'
    __table_args__ = (
        db.Index('idx_status_created', 'status', 'created_at'),
        db.Index('idx_author_status', 'author_id', 'status'),
    )

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    slug = db.Column(db.String(200), unique=True, nullable=False, index=True)
    summary = db.Column(db.String(500))
    content = db.Column(db.Text, nullable=False)
    cover_image = db.Column(db.String(500))
    status = db.Column(db.Enum(ArticleStatus), default=ArticleStatus.draft, nullable=False, index=True)
    view_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
    like_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
    comment_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
    category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True, index=True)
    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False, index=True)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now(), nullable=False)
    published_at = db.Column(db.DateTime, nullable=True)

    comments = db.relationship('Comment', backref='article', lazy='dynamic', cascade='all, delete-orphan', order_by='Comment.created_at.desc()')
    tags = db.relationship('Tag', secondary=article_tags, backref='articles', lazy='selectin')

    def to_dict(self, include_content=False):
        data = {
            'id': self.id, 'title': self.title, 'slug': self.slug, 'summary': self.summary,
            'status': self.status.value if self.status else None,
            'view_count': self.view_count, 'like_count': self.like_count,
            'comment_count': self.comment_count,
            'author': self.author.to_dict() if self.author else None,
            'category': {'id': self.category.id, 'name': self.category.name} if self.category else None,
            'tags': [{'id': t.id, 'name': t.name} for t in self.tags],
            'created_at': self.created_at.isoformat() if self.created_at else None,
        }
        if include_content:
            data['content'] = self.content
        return data


# ========== 标签模型 ==========
class Tag(db.Model):
    __tablename__ = 'tags'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), unique=True, nullable=False, index=True)
    slug = db.Column(db.String(50), unique=True, nullable=False)
    description = db.Column(db.String(200))
    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now())

    def to_dict(self):
        return {'id': self.id, 'name': self.name, 'slug': self.slug, 'description': self.description}


# ========== 评论模型 ==========
class Comment(db.Model):
    __tablename__ = 'comments'
    __table_args__ = (
        db.Index('idx_article_created', 'article_id', 'created_at'),
    )

    id = db.Column(db.Integer, primary_key=True)
    content = db.Column(db.Text, nullable=False)
    is_deleted = db.Column(db.Boolean, default=False, server_default='false', nullable=False)
    like_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
    article_id = db.Column(db.Integer, db.ForeignKey('articles.id'), nullable=False, index=True)
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
    parent_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True, index=True)
    created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now())

    replies = db.relationship('Comment', backref=db.backref('parent', remote_side='Comment.id'), lazy='selectin', cascade='all, delete-orphan', order_by='Comment.created_at.asc()')

    def to_dict(self, include_replies=True):
        data = {
            'id': self.id, 'content': self.content, 'like_count': self.like_count,
            'created_at': self.created_at.isoformat() if self.created_at else None,
            'author': self.author.to_dict() if self.author else None,
            'parent_id': self.parent_id,
        }
        if include_replies:
            data['replies'] = [r.to_dict(include_replies=False) for r in self.replies]
        return data

9.10 常用查询实现

python 复制代码
from sqlalchemy import select, func, and_, or_, desc
from sqlalchemy.orm import selectinload, joinedload

# ========== 1. 首页文章列表(分页) ==========
def get_home_articles(page=1, per_page=10):
    """获取首页已发布文章"""
    stmt = (
        select(Article)
        .where(Article.status == ArticleStatus.published)
        .options(
            joinedload(Article.author),
            joinedload(Article.category),
            selectinload(Article.tags),
        )
        .order_by(Article.is_pinned.desc(), Article.created_at.desc())
    )

    # 分页
    total = db.session.execute(
        select(func.count()).select_from(stmt.subquery())
    ).scalar()

    articles = db.session.execute(
        stmt.offset((page - 1) * per_page).limit(per_page)
    ).scalars().all()

    return {
        'articles': [a.to_dict() for a in articles],
        'total': total,
        'page': page,
        'per_page': per_page,
        'pages': (total + per_page - 1) // per_page,
    }


# ========== 2. 文章详情 ==========
def get_article_detail(slug):
    """获取文章详情(含作者、分类、标签、评论)"""
    stmt = (
        select(Article)
        .where(Article.slug == slug, Article.status == ArticleStatus.published)
        .options(
            joinedload(Article.author),
            joinedload(Article.category),
            selectinload(Article.tags),
            selectinload(Article.comments).joinedload(Comment.author),
        )
    )
    article = db.session.execute(stmt).scalars().one_or_none()
    return article.to_dict(include_content=True) if article else None


# ========== 3. 按分类查询文章 ==========
def get_articles_by_category(category_slug, page=1, per_page=10):
    """获取分类下的文章(含子分类)"""
    # 先找到分类
    category = db.session.execute(
        select(Category).where(Category.slug == category_slug)
    ).scalar_one_or_none()

    if not category:
        return None

    # 获取所有子分类ID(含自身)
    category_ids = [category.id] + [c.id for c in category.get_descendants()]

    stmt = (
        select(Article)
        .where(
            and_(
                Article.category_id.in_(category_ids),
                Article.status == ArticleStatus.published,
            )
        )
        .options(joinedload(Article.author))
        .order_by(Article.created_at.desc())
    )

    total = db.session.execute(select(func.count()).select_from(stmt.subquery())).scalar()
    articles = db.session.execute(stmt.offset((page - 1) * per_page).limit(per_page)).scalars().all()

    return {
        'category': category.to_dict(),
        'articles': [a.to_dict() for a in articles],
        'total': total,
        'page': page,
    }


# ========== 4. 按标签查询文章 ==========
def get_articles_by_tag(tag_slug, page=1, per_page=10):
    """获取标签下的文章"""
    tag = db.session.execute(
        select(Tag).where(Tag.slug == tag_slug)
    ).scalar_one_or_none()

    if not tag:
        return None

    stmt = (
        select(Article)
        .join(article_tags)
        .where(
            and_(
                article_tags.c.tag_id == tag.id,
                Article.status == ArticleStatus.published,
            )
        )
        .options(joinedload(Article.author))
        .order_by(Article.created_at.desc())
    )

    total = db.session.execute(select(func.count()).select_from(stmt.subquery())).scalar()
    articles = db.session.execute(stmt.offset((page - 1) * per_page).limit(per_page)).scalars().all()

    return {
        'tag': tag.to_dict(),
        'articles': [a.to_dict() for a in articles],
        'total': total,
    }


# ========== 5. 用户文章列表 ==========
def get_user_articles(username, page=1, per_page=10, include_draft=False):
    """获取用户的文章列表"""
    user = db.session.execute(
        select(User).where(User.username == username)
    ).scalar_one_or_none()

    if not user:
        return None

    stmt = select(Article).where(Article.author_id == user.id)

    if not include_draft:
        stmt = stmt.where(Article.status == ArticleStatus.published)

    stmt = stmt.order_by(Article.created_at.desc())

    total = db.session.execute(select(func.count()).select_from(stmt.subquery())).scalar()
    articles = db.session.execute(stmt.offset((page - 1) * per_page).limit(per_page)).scalars().all()

    return {
        'user': user.to_dict(),
        'articles': [a.to_dict() for a in articles],
        'total': total,
    }


# ========== 6. 热门文章 ==========
def get_hot_articles(limit=10, days=7):
    """获取最近N天的热门文章"""
    from datetime import timedelta
    since = datetime.utcnow() - timedelta(days=days)

    stmt = (
        select(Article)
        .where(
            and_(
                Article.status == ArticleStatus.published,
                Article.created_at >= since,
            )
        )
        .order_by(
            (Article.view_count + Article.like_count * 5 + Article.comment_count * 3).desc()
        )
        .limit(limit)
        .options(joinedload(Article.author))
    )
    articles = db.session.execute(stmt).scalars().all()
    return [a.to_dict() for a in articles]


# ========== 7. 标签云 ==========
def get_tag_cloud(limit=50):
    """获取标签云"""
    stmt = (
        select(
            Tag.id, Tag.name, Tag.slug,
            func.count(article_tags.c.article_id).label('count'),
        )
        .join(article_tags, Tag.id == article_tags.c.tag_id)
        .join(Article, and_(
            article_tags.c.article_id == Article.id,
            Article.status == ArticleStatus.published,
        ))
        .group_by(Tag.id, Tag.name, Tag.slug)
        .order_by(func.count(article_tags.c.article_id).desc())
        .limit(limit)
    )
    result = db.session.execute(stmt).all()
    return [{'id': r.id, 'name': r.name, 'slug': r.slug, 'count': r.count} for r in result]


# ========== 8. 分类树 ==========
def get_category_tree():
    """获取完整分类树"""
    stmt = select(Category).where(Category.parent_id.is_(None)).order_by(Category.sort_order)
    root_categories = db.session.execute(stmt).scalars().all()
    return [c.to_dict() for c in root_categories]


# ========== 9. 文章评论列表(嵌套) ==========
def get_article_comments(article_id):
    """获取文章的评论(嵌套结构)"""
    stmt = (
        select(Comment)
        .where(
            and_(
                Comment.article_id == article_id,
                Comment.parent_id.is_(None),  # 只查顶级评论
                Comment.is_deleted == False,
            )
        )
        .options(
            selectinload(Comment.replies).joinedload(Comment.author),
            joinedload(Comment.author),
        )
        .order_by(Comment.created_at.desc())
    )
    comments = db.session.execute(stmt).scalars().all()
    return [c.to_dict() for c in comments]


# ========== 10. 用户Feed(关注的人的文章) ==========
def get_user_feed(user_id, page=1, per_page=10):
    """获取用户关注的人的文章"""
    stmt = (
        select(Article)
        .join(user_follows, Article.author_id == user_follows.c.followed_id)
        .where(
            and_(
                user_follows.c.follower_id == user_id,
                Article.status == ArticleStatus.published,
            )
        )
        .options(joinedload(Article.author))
        .order_by(Article.created_at.desc())
    )

    total = db.session.execute(select(func.count()).select_from(stmt.subquery())).scalar()
    articles = db.session.execute(stmt.offset((page - 1) * per_page).limit(per_page)).scalars().all()

    return {
        'articles': [a.to_dict() for a in articles],
        'total': total,
        'page': page,
    }
相关推荐
Data_Journal1 小时前
Playwright vs Selenium:哪个是最佳无头浏览器
开发语言·python·scrapy·microsoft·编辑器
跨境技工小黎1 小时前
LLM蜜罐系统是什么?AI爬虫如何精准绕过?
开发语言·人工智能·python
lskblog1 小时前
Ubuntu 18.04 安装 LibreOffice(用于 Word 在线预览转换)
java·linux·python·ubuntu·word·php·laravel
王志来137944730081 小时前
多元场景催生工控服务器机箱差异化需求匀天以柔性适配回应行业挑战
运维·服务器·人工智能·python
浔溺2 小时前
al+大数据每日学习笔记28
笔记·学习
FakeOccupational4 小时前
【github 有趣项目】OpenPLC: 支持通用硬件的开源 PLC 软件平台‌
笔记
circuitsosk4 小时前
NL2SQL在工业级场景下的精度优化:Schema Linking + 动态Few-shot实战
人工智能·python·sql·大模型·nl2sql
kobe_OKOK_10 小时前
DRF接口幂等操作
python·django
xian_wwq10 小时前
【学习笔记】-深度认知系列-第2讲-大模型到底是什么?——拆解“参数、训练、推理”
笔记·学习·深度认知