例如: 一站表
id | uid | login_time |
---|---|---|
1 | 1 | 2018-04-10 18:19:14 |
2 | 1 | 2019-04-10 18:19:14 |
3 | 1 | 2020-04-10 18:19:14 |
4 | 2 | 2019-04-10 18:19:14 |
那么,最终结果类似于:
uid | average |
---|---|
1 | (2020-04-10 18:19:14 - 2018-04-10 18:19:14) / 2 |
1 |
|
需要买本高级查询的书了-_-!
例如: 一站表
id | uid | login_time |
---|---|---|
1 | 1 | 2018-04-10 18:19:14 |
2 | 1 | 2019-04-10 18:19:14 |
3 | 1 | 2020-04-10 18:19:14 |
4 | 2 | 2019-04-10 18:19:14 |
那么,最终结果类似于:
uid | average |
---|---|
1 | (2020-04-10 18:19:14 - 2018-04-10 18:19:14) / 2 |
1 |
|
需要买本高级查询的书了-_-!
字段类型是在定义模型时, 对每个Column
的类型约定. 不同类型的字段类型在输入输出上, 及支持的操作方面, 有所区别.
这里只介绍sqlalchemy.types.*
中的类型, SQL 标准类型方面, 是写什么最后生成的DDL
语句就是什么, 比如 BIGINT
, BLOG
这些, 但是这些类型并不一定在所有数据库中都有支持. 除此而外, SQLAlchemy
也支持一些特定数据库的特定类型, 这些需要从具体的 dialects
实现里导入.
整形.
布尔类型. Python 中表现为 True/False , 数据库根据支持情况, 表现为 BOOLEAN 或SMALLINT . 实例化时可以指定是否创建约束(默认创建).
日期类型, Time 和 DateTime 实例化时可以指定是否带时区信息.
时间偏差类型. 在 Python 中表现为 datetime.timedelta() , 数据库不支持此类型则存为日期.
枚举类型, 根据数据库支持情况, SQLAlchemy 会使用原生支持或者使用 VARCHAR 类型附加约束的方式实现. 原生支持中涉及新类型创建, 细节在实例化时控制.
浮点小数.
定点小数, Python 中表现为 Decimal .
字节数据. 根据数据库实现, 在实例化时可能需要指定大小.
Python 对象的序列化类型.
字符串类型, Python 中表现为 Unicode , 数据库表现为 VARCHAR , 通常都需要指定长度.
类似与字符串类型, 在某些数据库实现下, 会明确表示支持非 ASCII 字符. 同时输入输出也强制是 Unicode 类型.
长文本类型, Python 表现为 Unicode , 数据库表现为 TEXT .
参考 Unicode .
SQLAlchemy 的session
是用于管理数据库操作的一个像容器一样的东西. 模型实例对象本身独立存在, 而要让其修改(创建)生效, 则需要把它们加入某个session
. 同时你也可以把模型实例对象从session
中去除. 被session
管理的实例对象, 在session.commit()
时被提交到数据库. 同时session.rollback()
是回滚变更.
session.flush()
的作用是在事务管理内与数据库发生交互, 对应的实例状态被反映到数据库. 比如自增ID
被填充上值.
1 | user = User(name=u'名字') |
SQLAlchemy 的 Query 支持 select ... for update / share
.
1 | session.Query(User).with_for_update().first() |
完整形式是:
1 | with_for_update(read=False, nowait=False, of=None) |
是标识加互斥锁还是共享锁. 当为True
时, 即for share
的语句, 是共享锁. 多个事务可以获取共享锁, 互斥锁只能一个事务获取. 有”多个地方”都希望是”这段时间我获取的数据不能被修改, 我也不会改”, 那么只能使用共享锁.
其它事务碰到锁, 是否不等待直接”报错”.
指明上锁的表, 如果不指明, 则查询中涉及的所有表(行)都会加锁.
SQLAlchemy 中的事务嵌套有两种情况. 一是在session
中管理的事务, 本身有层次性. 二是session
和原始的connection
之间, 是一种层次关系, 在这session
,connection
两个概念之中的事务同样具有这样的层次.session
中的事务, 可能通过begin_nested()
方法做savepoint
:
1 | session.add(u1) |
或者使用上下文对象:
1 | for record in records: |
嵌套的事务的一个效果, 是最外层事务提交整个变更才会生效.
1 | user = User(name='2') |
于是, 前面说的第二种情况有一种应用方式, 就是在connection
上做一个事务, 最终也在connection
上回滚这个事务, 如果session
是bind
到这个连接上的, 那么 session
上所做的更改全部不会生效:
1 | conn = Engine.connect() |
在测试中这种方式可能会有用.
二段式提交, Two-Phase
, 是为解决分布式环境下多点事务控制的一套协议.
与一般事务控制的不同是, 一般事务是 begin
, 之后 commit
结束.
而二段式提交的流程上, begin
之后, 是 prepare transaction
transaction_id
, 这时相关事务数据已经持久化了. 之后, 再在任何时候(哪怕重启服务), 作 commit prepared
transaction_id
或者 rollback prepared
transaction_id
.
从多点事务的控制来看, 应用层要做的事是, 先把任务分发出去, 然后收集”事务准备”的状态(prepare transaction 的结果). 根据收集的结果决定最后是commit
还是rollback
.
简单来说, 就是事务先保存, 再说提交的事.
SQLAlchemy 中对这个机制的支持, 是在构建会话类是加入twophase
参数:
1 | Session = sessionmaker(twophase=True) |
然后会话类可以根据一些策略, 绑定多个Engine
, 可以是多个数据库连接, 比如:
1 | Session = sessionmaker(twophase=True) |
这样, 在获取一个会话实例之后, 就处在二段式提交机制的支持之下, SQLAlchemy 自己会作多点的协调了. 完整的流程:
1 | Engine = create_engine('postgresql://test@localhost:5432/test', echo=True) |
对应的SQL
大概就是:
1 | begin; |
使用时, Postgresql
数据库需要把 max_prepared_transactions
这个配置项的值改成大于 0
假设有一个简单的银行系统,一共两名用户:
1 | class User(BaseModel): |
然后开两个 session,同时进行两次转账操作:
1 | session1 = DB_Session() |
现在看看结果:
1 |
|
两次转账都成功了,但是只转走了一笔钱,这明显不科学。
可见 MySQL InnoDB 虽然支持事务,但并不是那么简单的,还需要手动加锁。
首先来试试读锁:
1 | user1 = session1.query(User).with_lockmode('read').get(1) |
现在在执行 session1.commit() 的时候,因为 user1 和 user2 都被 session2 加了读锁,所以会等待锁被释放。超时以后,session1.commit() 会抛出个超时的异常,如果捕捉了的话,或者 session2 在另一个进程,那么 session2.commit() 还是能正常提交的。这种情况下,有一个事务是肯定会提交失败的,所以那些更改等于白做了。
接下来看看写锁,把上段代码中的 ‘read’ 改成 ‘update’ 即可。这次在执行 select 的时候就会被阻塞了:
1 | user1 = session2.query(User).with_lockmode('update').get(1) |
这样只要在超时期间内,session1 完成了提交或回滚,那么 session2 就能正常判断 user1.money >= 100 是否成立了。
由此可见,如果需要更改数据,最好加写锁。
那么什么时候用读锁呢?如果要保证事务运行期间内,被读取的数据不被修改,自己也不去修改,加读锁即可。
举例来说,假设我查询一个用户的开支记录(同时包含余额和转账记录),可以直接把 user 和 tansefer_log 做个内连接。
但如果用户的转账记录特别多,我在查询前想先验证用户的密码(假设在 user 表中),确认相符后才查询转账记录。而这两次查询的期间内,用户可能收到了一笔转账,导致他的 money 字段被修改了,但我在展示给用户时,用户的余额仍然没变,这就不正常了。
而如果我在读取 user 时加了读锁,用户是无法收到转账的(因为无法被另一个事务加写锁来修改 money 字段),这就保证了不会查出额外的 tansefer_log 记录。等我查询完两张表,释放了读锁后,转账就可以继续进行了,不过我显示的数据在当时的确是正确和一致的。
另外要注意的是,如果被查询的字段没有加索引的话,就会变成锁整张表了:
1 | session1.query(User).filter(User.id > 50).with_lockmode('update').all() |
要避免的话,可以这样:
1 | money = Column(DECIMAL(10, 2), index=True) |
另一个注意点是子事务。
InnoDB 支持子事务(savepoint 语句),可以简化一些逻辑。
例如有的方法是用于改写数据库的,它执行时可能提交了事务,但在后续的流程中却执行失败了,却没法回滚那个方法中已经提交的事务。这时就可以把那个方法当成子事务来运行了:
1 | def step1(): |
此外,rollback 一个子事务,可以释放这个子事务中获得的锁,提高并发性和降低死锁概率。
最简单的办法就是获取时加上写锁:
1 | user = session.query(User).with_lockmode('update').get(1) |
如果不想多一次读的话,这样写也是可以的:
1 | session.query(User).filter(User.id == 1).update({ |
使用 ForeignKey 来定义一个外键约定:
1 | from sqlalchemy import Column, ForeignKey |
创建时:
1 | session = Session() |
session.flush()
是进行数据库交互, 但是事务并没有提交. 进行数据库交互之后,user.id
才有值.
定义了外键, 对查询来说, 并没有影响. 外键只是单纯的一条约束而已. 当然, 可以在外键上定义一些关联的事件操作, 比如当外键条目被删除时, 字段置成null
, 或者关联条目也被删除等.
要定义关系, 必有使用 ForeignKey 约束. 当然, 这里说的只是在定义模型时必有要有, 至于数据库中是否真有外键约定, 这并不重要.
1 | from sqlalchemy import Column, ForeignKey |
关系只是 SQLAlchemy 提供的工具, 与数据库无关, 所以任何时候添加都是可以的.
上面的 User-Blog 是一个”一对多”关系, 通过Blog
的user
这个ForeignKey
, SQLAlchemy 可以自动处理关系的定义. 在查询时, 返回的结果自然也是, 一个是列表, 一个是单个对象:
1 | session = Session() |
这种关系的定义, 并不影响查询并获取对象的行为, 不会添加额外的join
操作. 在对象上取一个user_obj 或者取blog_list
都是发生了一个新的查询操作.
上面的关系定义, 对应的属性是实际查询出的实例列表, 当条目数多的时候, 这样可能会有问题. 比如用户名下有成千上万的文章,一次全取出就太暴力了. 关系对应的属性可以定义成一个QuerySet
:
1 | class User(BaseModel): |
这样在获取实例时就可以自由控制了:
1 | session.query(User).get(1).blog_list.all() |
关系定义之后, 除了在查询时会有自动关联的效果, 在作查询时, 也可以对定义的关系做操作:
1 | class Blog(BaseModel): |
对于 一对多 的关系, 使用any()
函数查询:
1 | user = session.query(User).filter(User.blogs.any(Blog.title == u'A')).first() |
SQLAlchemy 会使用exists
条件, 类似于:
1 | SELECT * |
反之, 如果是 多对一 的关系, 则使用has()
函数查询:
1 |
|
最后的 SQL 语句都是一样的.
前面介绍的关系定义中, 提到了两种关系的获取形式, 一种是:
1 | user_obj = relationship('User') |
这种是在对象上获取关系对象时, 再去查询.
另一种是:
1 | blog_list = relationship('Blog', lazy="dynamic") |
这种的结果, 是在对象上获取关系对象时, 只返回 Query , 而查询的细节由人为来控制.
总的来说, 关系的获取分成两种,Lazy
或Eager
. 在直接查询层面, 上面两种都属于Lazy
的方式, 而Eager
的一种, 就是在获取对象时的查询语句, 是直接带join
的, 这样关系对象的数据在一个查询语句中就直接获取到了:
1 | class Blog(BaseModel): |
这样在查询时:
1 | blog = session.query(Blog).first() |
便会多出LEFT OUTER JOIN
的语句, 结果中直接获取到对应的User
实例对象.
也可以把joined
换成子查询,subquery
:
1 | class User(BaseModel): |
子查询会用到临时表.
上面定义的:
1 | blog_list = relationship('Blog', lazy="dynamic") |
都算是一种默认方式. 在具体使用查询时, 还可以通过 options() 方法定义关联的获取方式:
1 | from sqlalchemy.orm import lazyload, joinedload, subqueryload |
更多的用法:
1 | session.query(Parent).options( |
如果关联的定义之前是 Lazy 的, 但是实际使用中, 希望在手工join
之后, 把关联对象直接包含进结果实例, 可以使用contains_eager()
来包装一下:
1 | from sqlalchemy.orm import contains_eager |
关系在对象属性中的表现, 默认是列表, 但是, 这不是唯一的形式. 根据需要, 可以作成 dictionary , set 或者其它你需要的对象.
1 | class Blog(BaseModel): |
对于上面的两个模型:
1 | user = session.query(User).first() |
现在user.blogs
是一个列表. 我们可以在relationship()
调用时通过collection_class
参数指定一个类, 来重新定义关系的表现形式:
1 | user = User(name=u'XX') |
1 | blogs = relationship('Blog', collection_class=set) |
1 | from sqlalchemy.orm.collections import attribute_mapped_collection |
如果title
重复的话, 结果会覆盖.
1 | from sqlalchemy.orm.collections import mapped_collection |
先考虑典型的多对多关系结构:
1 | class Blog(BaseModel): |
在 Blog 中的:
1 | tag_list = relationship('Tag') |
显示是错误的, 因为在 Tag 中并没有外键. 而:
1 | tag_list = relationship('BlogAndTag') |
这样虽然正确, 但是 tag_list 的关系只是到达 BlogAndTag 这一层, 并没有到达我们需要的 Tag.
这种情况下, 一个多对多关系是有三张表来表示的, 在定义 relationship 时, 就需要一个secondary 参数来指明关系表:
1 | class Blog(BaseModel): |
这样, 在操作时可以直接获取到对应的实例列表:
1 | blog = session.query(Blog).filter(Blog.title == 'a').one() |
访问 tag_list 时, SQLAlchemy 做的是一个普通的多表查询.
tag_list 属性同时支持赋值操作:
1 | session = Session() |
提交时, SQLAlchemy 总是会创建 Tag , 及对应的关系 BlogAndTag .
而如果是:
1 | session = Session() |
那么 SQLAlchemy 只会删除对应的关系 BlogAndTag , 不会删除实体 Tag .
如果你直接删除实体, 那么对应的关系是不会自动删除的:
1 | session = Session() |
前面提到的, 当操作关系, 实体时, 与其相关联的关系, 实体是否会被自动处理的问题, 在 SQLAlchemy 中是通过Cascades
机制来定义和解决的. ( Cascades 这个词是来源于Hibernate .)
cascade 是一个relationship
的参数, 其值是逗号分割的多个字符串, 以表示不同的行为. 默认值是 save-update, merge
,稍后会介绍每个词项的作用.
这里的所有规则介绍, 只涉及从Parent
到Child
, Parent
即定义relationship
的类. 不涉及backref
.
cascade 所有的可选字符串项是:
当一个对象被添加进 session 后, 此对象标记为 save-update 的 relationship 关系对象也会同时添加进这个 session .
1 | class Blog(BaseModel): |
当一个对象在 session 中被标记为删除时, 其属性中 relationship 关联的对象也会被标记成删除, 否则, 关联对象中的对应外键字段会被改成 NULL , 不能为 NULL 则报错.
1 | class Blog(BaseModel): |
当 relationship 属性变化时, 被 “去掉” 的对象会被自动删除. 比如之前是:
1 | user.blog_list = [blog, blog2] |
现在变成:
1 | user.blog_list = [blog2] |
那么 blog 这个关联实体是会自动删除的.
这各机制只适用于 “一对多” 的关系中, “多对多” 和反过来的 “多对一” 都不适用. 在relationship 定义时, 可以添加 single_parent = True 参数来强制约束. 当然, 在实现上 SQLAlchemy 是会先查出所有关联实体, 然后计算差集确认哪些需要被删除.
1 | class Blog(BaseModel): |
这个选项是标识在 session.merge() 时处理关联对象. session.merge() 的作用, 是把一个会话外的实例, “整合”进会话, 比如 “有则修改, 无则创建” 就是典型的一种 “整合”:
1 | user = User(id=1, name="1") |
cascade 中的 merge 作用:
1 | class Blog(BaseModel): |
当使用 session.expire() 标识一个对象过期时, 此对象的关联对象是否也被标识为过期(访问属性会重新查询数据库).
1 | class Blog(BaseModel): |
与 merge 相反, 当 session.expunge() 把对象从会话中去除的时候, 此对象的关联对象也同时从会话中消失.
1 | class Blog(BaseModel): |
考虑这样的情况, 关系是关联的整个模型对象的, 但是, 有时我们对于这个关系, 并不关心整个对象, 只关心其中的某个属性. 考虑下面的场景:
1 | from sqlalchemy.ext.associationproxy import association_proxy |
blog_list 是一个正确的一对多关系. 下面的 blog_title_list 就是这个关系上的一个属性代理.blog_title_list 只处理 blog_list 这个关系中对应的对象的 title 属性, 包括获取和设置两个方向.
1 | session = Session() |
上面是获取属性的示例. 在”设置”, 或者说”创建”时, 直接操作是有错的:
1 | user = session.query(User).first() |
原因在于, 对于类 Blog 的初始化形式. association_proxy(‘blog_list’, ‘title’) 中的 title只是获取时的属性定义, 而在上面的设置过程中, 实际上的调用形式为:
Blog(‘NEW’)
Blog 类没有明确定义 init() 方法, 所有这种形式的调用会报错. 可以把 init() 方法补上:
1 | class Blog(BaseModel): |
这样调用就没有问题了.
另一个方法, 是在调用association_proxy()
时使用creator
参数明确定义”值”和”实例”的关系:
1 | class User(BaseModel): |
creator 定义的方法, 返回的对象可以被对应的 blog_list 关系接收即可.
在查询方面, 多对一 的关系代理上, 可以直接使用属性:
1 | class Blog(BaseModel): |
查询:
1 | blog = session.query(Blog).filter(Blog.user_name == u'XX').first() |
反过来的 一对多 关系代理上, 可以使用contains()
函数:
1 | user = session.query(User).filter(User.blogs_title.contains('A')).first() |
对于 Table 的定义, 本来是直接的实例化调用, 通过 declarative
的包装, 可以像”定义类”这样的更直观的方式来完成.
1 | user = Table('user', metadata, |
1 | # -*- coding: utf-8 -*- |
1 | session = Session() |
执行的顺序并不一定会和代码顺序一致, SQLAlchemy 自己会整合逻辑再执行.
SQLAlchemy 实现的查询非常强大, 写起来有一种随心所欲的感觉.
查询的结果, 有几种不同的类型, 这个需要注意, 像是:
1 | session.query(User).filter_by(username='abc').all() |
1 | session.query(Blog, User).filter(Blog.user == User.id).first().User.username |
1 | from sqlalchemy import or_, not_ |
1 | from sqlalchemy import func |
还是通常的两种方式:
1 | session.query(User).filter(User.username == 'abc').update({'name': '123'}) |
如果涉及对属性原值的引用, 则要考虑synchronize_session
这个参数.
evaluate
默认值, 会同时修改当前 session 中的对象属性.fetch
修改前, 会先通过 select 查询条目的值.False
不修改当前 session 中的对象属性.在默认情况下, 因为会有修改当前会话中的对象属性, 所以如果语句中有 SQL 函数, 或者”原值引用”, 那是无法完成的操作, 自然也会报错, 比如:
1 | from sqlalchemy import func |
这种情况下, 就不能要求 SQLAlchemy 修改当前 session 的对象属性了, 而是直接进行数据库的交互, 不管当前会话值:
1 | session.query(User).update({User.name: User.name + 'x'}, synchronize_session=False) |
是否修改当前会话的对象属性, 涉及到当前会话的状态. 如果当前会话过期, 那么在获取相关对象的属性值时, SQLAlchemy 会自动作一次数据库查询, 以便获取正确的值:
1 | user = session.query(User).filter_by(username='abc').scalar() |
执行了update
之后, 虽然相关对象的实际的属性值已变更, 但是当前会话中的对象属性值并没有改变. 直到session.commit()
之后, 当前会话变成”过期”状态, 再次获取user.name
时, SQLAlchemy 通过user
的 id
属性, 重新去数据库查询了新值. (如果 user 的 id 变了呢? 那就会出事了啊.)synchronize_session
设置成 fetch
不会有这样的问题, 因为在做 update 时已经修改了当前会话中的对象了.
不管 synchronize_session
的行为如何, commit
之后 session
都会过期, 再次获取相关对象值时, 都会重新作一次查询.
1 | session.query(User).filter_by(username='abc').delete() |
删除同样有像修改一样的 synchronize_session 参数的问题, 影响当前会话的状态.
SQLAlchemy 可以很直观地作 join 的支持:
1 | r = session.query(Blog, User).join(User, Blog.user == User.id).all() |
在写单元测试的时候,有时候需要伪造时间,例如一个token的有效期30天,但是我的单元测试不可能等30天或其他什么的,所以,,,可以使用freezegun这个库去伪造时间,更多不介绍了,官网,很详细的使用说明.
假设有这样的场景, 某实体在具体条目上, 其属性是不定的, 或者其属性是充分稀疏的:
id | name | attr_0 | attr_1 | attr_2 | … | attr_n |
---|---|---|---|---|---|---|
1 | foo | 1 | abc | 33 | … | any |
这种情况下, 把属性看成是单独的实体, 是一个更好的建模方式:
id | name |
---|---|
1 | foo |
id | entity_id | attr_name | attr_value |
---|---|---|---|
1 | 1 | attr_0 | 1 |
2 | 1 | attr_1 | 33 |
… | … | … | … |
n | 1 | attr_n | any |
这种模型下, ORM 层面我们考虑封装一个对操作更友好的上层操作接口, 比如:
1 | obj = Entity() |
实现上, 就是把对象的方法, 包装成 SQLAlchemy
的 ORM
中的对应的关系操作.
1 | class BaseModel(declarative_base()): |
实现上就两点:
_attributes
关系中, 指定 collection_class
, 于是就可以得到一个像 dict
的属性对象了.association_proxy
从 dict
的属性对象中只抽出我们关心的 value
属性值.
这个场景中, 还可以再进一步, 在 Entity
类上实现 dict
的一些方法, 直接操作其 attributes
属性, association_proxy
就直接返回 Entity
的实例, 这样代码可以变成这样:
1 | entity = Entity(name=u'ABC') |
1 | from sqlalchemy import create_engine |
engine 的定义包含了三部分的内容:
所谓的数据库类型即是MYSQL
, Postgresql
, SQLite
这些不同的数据库.
一般创建 engine 是使用create_engine
方法:
1 | engine = create_engine('postgresql+psycopg2://scott:tiger@localhost/mydatabase') |
对于这个字符串, SQLAlchemy 提供了工具可用于处理它:
1 | # -*- coding: utf-8 -*- |
create_engine
函数有很多的控制参数, 这个后面再详细说.
create_engine
的调用, 实际上会变成strategy.create
的调用. 而strategy
就是engine
的实现细节. strategy
可以在create_engine
调用时通过strategy
参数指定, 目前官方的支持有三种:
mock
这个实现, 会把所有的 SQL
语句的执行交给指定的函数来做, 这个函数是由create_engine
的 executor
参数指定:
1 | def f(sql, *args, **kargs): |
各数据库的实现在 SQLAlchemy 中分成了两个部分, 一是数据库的类型, 二是具体数据库中适配的客户端实现. 比如对于 Postgresql 的访问, 可以使用psycopg2
, 也可以使用pg8000
:
1 | s = 'postgresql+psycopg2://test@localhost:5432/bbcustom' |
具体的适配工作, 是需要在代码中实现一个 Dialect 类来完成的. 官方的实现在 dialects 目录下.
获取具体的 Dialect 的行为, 则是前面提到的URL
对象的get_dialect
方法.create_engine
时你单传一个字符串,SQLAlchemy
自己也会使用make_url
得到一个URL
的实例).
SQLAlchemy 支持连接池, 在create_engine
时添加相关参数即可使用.
连接池效果:
1 | # -*- coding: utf-8 -*- |
连接池的实现, 在create_engine
调用时也可以指定:
1 | from sqlalchemy.pool import QueuePool |
还有:
1 | from sqlalchemy.pool import NullPool |
或者仅仅是获取连接的方法:
1 | import sqlalchemy.pool as pool |
连接池可以被单独使用:
1 | import sqlalchemy.pool as pool |
连接池可以被多个 engine 共享使用:
1 | e = create_engine('postgresql://', pool=mypool) |
缺失模块。
1、请确保node版本大于6.2
2、在博客根目录(注意不是yilia根目录)执行以下命令:
npm i hexo-generator-json-content --save
3、在根目录_config.yml里添加配置:
jsonContent: meta: false pages: false posts: title: true date: true path: true text: false raw: false content: false slug: false updated: false comments: false link: false permalink: false excerpt: false categories: false tags: true