1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2012, 2012 MediaGoblin contributors. See AUTHORS.
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 from sqlalchemy
import (
20 Table
, Column
, MetaData
, Index
21 Integer
, Float
, Unicode
, UnicodeText
, DateTime
, Boolean
,
22 ForeignKey
, UniqueConstraint
, PickleType
)
23 from sqlalchemy
.orm
import sessionmaker
, relationship
24 from sqlalchemy
.ext
.declarative
import declarative_base
25 from sqlalchemy
.sql
import select
, insert
26 from migrate
import changeset
28 from mediagoblin
.db
.sql
.base
import GMGTableBase
31 # This one will get filled with local migrations
35 #######################################################
36 # Migration set 1: Define initial models, no migrations
37 #######################################################
39 Base1
= declarative_base(cls
=GMGTableBase
)
41 class Creature1(Base1
):
42 __tablename__
= "creature"
44 id = Column(Integer
, primary_key
=True)
45 name
= Column(Unicode
, unique
=True, nullable
=False, index
=True)
46 num_legs
= Column(Integer
, nullable
=False)
47 is_demon
= Column(Boolean
)
50 __tablename__
= "level"
52 id = Column(Unicode
, primary_key
=True)
53 name
= Column(Unicode
)x
54 description
= Column(Unicode
)
55 exits
= Column(PickleType
)
57 SET1_MODELS
= [Creature1
, Level1
]
61 #######################################################
62 # Migration set 2: A few migrations and new model
63 #######################################################
65 Base2
= declarative_base(cls
=GMGTableBase
)
67 class Creature2(Base2
):
68 __tablename__
= "creature"
70 id = Column(Integer
, primary_key
=True)
71 name
= Column(Unicode
, unique
=True, nullable
=False, index
=True)
72 num_legs
= Column(Integer
, nullable
=False)
73 magical_powers
= relationship("CreaturePower2")
75 class CreaturePower2(Base2
):
76 __tablename__
= "creature_power"
78 id = Column(Integer
, primary_key
=True)
80 Integer
, ForeignKey('creature.id'), nullable
=False)
81 name
= Column(Unicode
)
82 description
= Column(Unicode
)
83 hitpower
= Column(Integer
, nullable
=False)
86 __tablename__
= "level"
88 id = Column(Unicode
, primary_key
=True)
89 name
= Column(Unicode
)
90 description
= Column(Unicode
)
92 class LevelExit2(Base2
):
93 __tablename__
= "level_exit"
95 id = Column(Integer
, primary_key
=True)
96 name
= Column(Unicode
)
98 Unicode
, ForeignKey('level.id'), nullable
=False)
100 Unicode
, ForeignKey('level.id'), nullable
=False)
102 SET2_MODELS
= [Creature2
, CreaturePower2
, Level2
, LevelExit2
]
105 @RegisterMigration(1, FULL_MIGRATIONS
)
106 def creature_remove_is_demon(db_conn
):
108 Remove the is_demon field from the creature model. We don't need
111 metadata
= MetaData(bind
=db_conn
.engine
)
112 creature_table
= Table(
113 'creature', metadata
,
114 autoload
=True, autoload_with
=db_conn
.engine
)
115 creature_table
.drop_column('is_demon')
118 @RegisterMigration(2, FULL_MIGRATIONS
)
119 def creature_powers_new_table(db_conn
):
121 Add a new table for creature powers. Nothing needs to go in it
122 yet though as there wasn't anything that previously held this
125 metadata
= MetaData(bind
=db_conn
.engine
)
126 creature_powers
= Table(
127 'creature_power', metadata
,
128 Column('id', Integer
, primary_key
=True),
130 Integer
, ForeignKey('creature.id'), nullable
=False),
131 Column('name', Unicode
),
132 Column('description', Unicode
),
133 Column('hitpower', Integer
, nullable
=False))
134 metadata
.create_all(db_conn
.engine
)
137 @RegisterMigration(3, FULL_MIGRATIONS
)
138 def level_exits_new_table(db_conn
):
140 Make a new table for level exits and move the previously pickled
141 stuff over to here (then drop the old unneeded table)
143 # First, create the table
144 # -----------------------
145 metadata
= MetaData(bind
=db_conn
.engine
)
147 'level_exit', metadata
,
148 Column('id', Integer
, primary_key
=True),
149 Column('name', Unicode
),
151 Integer
, ForeignKey('level.id'), nullable
=False),
153 Integer
, ForeignKey('level.id'), nullable
=False))
154 metadata
.create_all(db_conn
.engine
)
156 # And now, convert all the old exit pickles to new level exits
157 # ------------------------------------------------------------
159 # Minimal representation of level table.
160 # Not auto-introspecting here because of pickle table. I'm not
161 # sure sqlalchemy can auto-introspect pickle columns.
164 Column('id', Integer
, primary_key
=True),
165 Column('exits', PickleType
))
167 # query over and insert
168 result
= db_conn
.execute(
169 select([levels
], levels
.c
.exits
!=None))
172 this_exit
= level
['exits']
174 # Insert the level exit
176 level_exits
.insert().values(
177 name
=this_exit
['name'],
178 from_level
=this_exit
['from_level'],
179 to_level
=this_exit
['to_level']))
181 # Finally, drop the old level exits pickle table
182 # ----------------------------------------------
183 levels
.drop_column('exits')
186 # A hack! At this point we freeze-fame and get just a partial list of
189 SET2_MIGRATIONS
= copy
.copy(FULL_MIGRATIONS
)
191 #######################################################
192 # Migration set 3: Final migrations
193 #######################################################
195 Base3
= declarative_base(cls
=GMGTableBase
)
197 class Creature3(Base3
):
198 __tablename__
= "creature"
200 id = Column(Integer
, primary_key
=True)
201 name
= Column(Unicode
, unique
=True, nullable
=False, index
=True)
202 num_limbs
= Column(Integer
, nullable
=False)
204 class CreaturePower3(Base3
):
205 __tablename__
= "creature_power"
207 id = Column(Integer
, primary_key
=True)
209 Integer
, ForeignKey('creature.id'), nullable
=False, index
=True)
210 name
= Column(Unicode
)
211 description
= Column(Unicode
)
212 hitpower
= Column(Float
, nullable
=False)
213 magical_powers
= relationship("CreaturePower3")
216 __tablename__
= "level"
218 id = Column(Unicode
, primary_key
=True)
219 name
= Column(Unicode
)
220 description
= Column(Unicode
)
222 class LevelExit3(Base3
):
223 __tablename__
= "level_exit"
225 id = Column(Integer
, primary_key
=True)
226 name
= Column(Unicode
)
228 Unicode
, ForeignKey('level.id'), nullable
=False, index
=True)
230 Unicode
, ForeignKey('level.id'), nullable
=False, index
=True)
233 SET3_MODELS
= [Creature3
, CreaturePower3
, Level3
, LevelExit3
]
236 @RegisterMigration(4, FULL_MIGRATIONS
)
237 def creature_num_legs_to_num_limbs(db_conn
):
239 Turns out we're tracking all sorts of limbs, not "legs"
240 specifically. Humans would be 4 here, for instance. So we
243 metadata
= MetaData(bind
=db_conn
.engine
)
244 creature_table
= Table(
245 'creature', metadata
,
246 autoload
=True, autoload_with
=db_conn
.engine
)
247 creature_table
.c
.num_legs
.alter(name
=u
"num_limbs")
250 @RegisterMigration(5, FULL_MIGRATIONS
)
251 def level_exit_index_from_and_to_level(db_conn
):
253 Index the from and to levels of the level exit table.
255 metadata
= MetaData(bind
=db_conn
.engine
)
257 'level_exit', metadata
,
258 autoload
=True, autoload_with
=db_conn
.engine
)
259 Index('ix_from_level', level_exit
.c
.from_level
).create(engine
)
260 Index('ix_to_exit', level_exit
.c
.to_exit
).create(engine
)
263 @RegisterMigration(6, FULL_MIGRATIONS
)
264 def creature_power_index_creature(db_conn
):
266 Index our foreign key relationship to the creatures
268 metadata
= MetaData(bind
=db_conn
.engine
)
269 creature_power
= Table(
270 'creature_power', metadata
,
271 autoload
=True, autoload_with
=db_conn
.engine
)
272 Index('ix_creature', creature_power
.c
.creature
).create(engine
)
275 @RegisterMigration(7, FULL_MIGRATIONS
)
276 def creature_power_hitpower_to_float(db_conn
):
278 Convert hitpower column on creature power table from integer to
281 Turns out we want super precise values of how much hitpower there
284 metadata
= MetaData(bind
=db_conn
.engine
)
285 creature_power
= Table(
286 'creature_power', metadata
,
287 autoload
=True, autoload_with
=db_conn
.engine
)
288 creature_power
.c
.hitpower
.alter(type=Float
)
291 def _insert_migration1_objects(session
):
293 Test objects to insert for the first set of things
297 [Creature1(name
=u
'centipede',
300 Creature1(name
=u
'wolf',
303 # don't ask me what a wizardsnake is.
304 Creature1(name
=u
'wizardsnake',
310 [Level1(id=u
'necroplex',
311 name
=u
'The Necroplex',
312 description
=u
'A complex full of pure deathzone.',
314 'deathwell': 'evilstorm',
315 'portal': 'central_park'}),
316 Level1(id=u
'evilstorm',
318 description
=u
'A storm full of pure evil.',
319 exits
={}), # you can't escape the evilstorm
320 Level1(id=u
'central_park'
321 name
=u
'Central Park, NY, NY',
322 description
=u
"New York's friendly Central Park.",
324 'portal': 'necroplex'})])
329 def _insert_migration2_objects(session
):
331 Test objects to insert for the second set of things
344 description
=u
"A blast of icy breath!",
348 description
=u
"A frightening stare, for sure!",
355 name
=u
'death_rattle',
356 description
=u
'A rattle... of DEATH!',
359 name
=u
'sneaky_stare',
360 description
=u
"The sneakiest stare you've ever seen!"
363 name
=u
'slithery_smoke',
364 description
=u
"A blast of slithery, slithery smoke.",
367 name
=u
'treacherous_tremors',
368 description
=u
"The ground shakes beneath footed animals!",
373 [Level2(id=u
'necroplex',
374 name
=u
'The Necroplex',
375 description
=u
'A complex full of pure deathzone.'),
376 Level2(id=u
'evilstorm',
378 description
=u
'A storm full of pure evil.',
379 exits
=[]), # you can't escape the evilstorm
380 Level2(id=u
'central_park'
381 name
=u
'Central Park, NY, NY',
382 description
=u
"New York's friendly Central Park.")])
386 [LevelExit2(name
=u
'deathwell',
387 from_level
=u
'necroplex',
388 to_level
=u
'evilstorm'),
389 LevelExit2(name
=u
'portal',
390 from_level
=u
'necroplex',
391 to_level
=u
'central_park')])
393 # there are no evilstorm exits because there is no exit from the
398 [LevelExit2(name
=u
'portal',
399 from_level
=u
'central_park',
400 to_level
=u
'necroplex')])
405 def _insert_migration3_objects(session
):
407 Test objects to insert for the third set of things
420 description
=u
"A blast of icy breath!",
424 description
=u
"A frightening stare, for sure!",
431 name
=u
'death_rattle',
432 description
=u
'A rattle... of DEATH!',
435 name
=u
'sneaky_stare',
436 description
=u
"The sneakiest stare you've ever seen!"
439 name
=u
'slithery_smoke',
440 description
=u
"A blast of slithery, slithery smoke.",
443 name
=u
'treacherous_tremors',
444 description
=u
"The ground shakes beneath footed animals!",
446 # annnnnd one more to test a floating point hitpower
453 description
=u
'Smitten by holy wrath!',
458 [Level3(id=u
'necroplex',
459 name
=u
'The Necroplex',
460 description
=u
'A complex full of pure deathzone.'),
461 Level3(id=u
'evilstorm',
463 description
=u
'A storm full of pure evil.',
464 exits
=[]), # you can't escape the evilstorm
465 Level3(id=u
'central_park'
466 name
=u
'Central Park, NY, NY',
467 description
=u
"New York's friendly Central Park.")])
471 [LevelExit3(name
=u
'deathwell',
472 from_level
=u
'necroplex',
473 to_level
=u
'evilstorm'),
474 LevelExit3(name
=u
'portal',
475 from_level
=u
'necroplex',
476 to_level
=u
'central_park')])
478 # there are no evilstorm exits because there is no exit from the
483 [LevelExit3(name
=u
'portal',
484 from_level
=u
'central_park',
485 to_level
=u
'necroplex')])
490 def CollectingPrinter(object):
494 def __call__(self
, string
):
495 self
.collection
.append(string
)
498 def combined_string(self
):
499 return u
''.join(self
.collection
)
502 def create_test_engine():
503 from sqlalchemy
import create_engine
504 engine
= create_engine('sqlite:///:memory:', echo
=False)
505 sqlalchemy
.orm
import sessionmaker
506 Session
= sessionmaker(bind
=engine
)
507 return engine
, Session
510 def assert_col_type(column
, class):
511 assert isinstance(column
.type, class)
514 def test_set1_to_set3():
515 # Create / connect to database
516 # ----------------------------
518 engine
, Session
= create_test_engine()
520 # Create tables by migrating on empty initial set
521 # -----------------------------------------------
523 printer
= CollectingPrinter
524 migration_manager
= MigrationManager(
525 '__main__', SET1_MODELS
, SET1_MIGRATIONS
, Session(),
528 # Check latest migration and database current migration
529 assert migration_manager
.latest_migration
== 0
530 assert migration_manager
.database_current_migration
== None
532 result
= migration_manager
.init_or_migrate()
534 # Make sure output was "inited"
535 assert result
== u
'inited'
537 assert printer
.combined_string
== (
538 "-> Initializing main mediagoblin tables... done.\n")
539 # Check version in database
540 assert migration_manager
.latest_migration
== 0
541 assert migration_manager
.database_current_migration
== 0
543 # Install the initial set
544 # -----------------------
546 _insert_migration1_objects(Session())
548 # Try to "re-migrate" with same manager settings... nothing should happen
549 migration_manager
= MigrationManager(
550 '__main__', SET1_MODELS
, SET1_MIGRATIONS
, Session(),
552 assert migration_manager
.init_or_migrate() == None
554 # Check version in database
555 assert migration_manager
.latest_migration
== 0
556 assert migration_manager
.database_current_migration
== 0
558 # Sanity check a few things in the database...
559 metadata
= MetaData(bind
=db_conn
.engine
)
561 # Check the structure of the creature table
562 creature_table
= Table(
563 'creature', metadata
,
564 autoload
=True, autoload_with
=db_conn
.engine
)
565 assert set(creature_table
.c
.keys()) == set(
566 ['id', 'name', 'num_legs', 'is_demon'])
567 assert_col_type(creature_table
.c
.id, Integer
)
568 assert_col_type(creature_table
.c
.name
, Unicode
)
569 assert creature_table
.c
.name
.nullable
is False
570 assert creature_table
.c
.name
.index
is True
571 assert creature_table
.c
.name
.unique
is True
572 assert_col_type(creature_table
.c
.num_legs
, Integer
)
573 assert creature_table
.c
.num_legs
.nullable
is False
574 assert_col_type(creature_table
.c
.is_demon
, Boolean
)
576 # Check the structure of the level table
579 autoload
=True, autoload_with
=db_conn
.engine
)
580 assert set(level_table
.c
.keys()) == set(
581 ['id', 'name', 'description', 'exits'])
582 assert_col_type(level_table
.c
.id, Unicode
)
583 assert level_table
.c
.id.primary_key
is True
584 assert_col_type(level_table
.c
.name
, Unicode
)
585 assert_col_type(level_table
.c
.description
, Unicode
)
586 # Skipping exits... Not sure if we can detect pickletype, not a
587 # big deal regardless.
589 # Now check to see if stuff seems to be in there.
590 creature
= session
.query(Creature1
).filter_by(
591 name
=u
'centipede').one()
592 assert creature
.num_legs
== 100
593 assert creature
.is_demon
== False
595 creature
= session
.query(Creature1
).filter_by(
597 assert creature
.num_legs
== 4
598 assert creature
.is_demon
== False
600 creature
= session
.query(Creature1
).filter_by(
601 name
=u
'wizardsnake').one()
602 assert creature
.num_legs
== 0
603 assert creature
.is_demon
== True
605 level
= session
.query(Level1
).filter_by(
607 assert level
.name
== u
'The Necroplex'
608 assert level
.description
== u
'A complex of pure deathzone.'
609 assert level
.exits
== {
610 'deathwell': 'evilstorm',
611 'portal': 'central_park'}
613 level
= session
.query(Level1
).filter_by(
615 assert level
.name
== u
'Evil Storm'
616 assert level
.description
== u
'A storm full of pure evil.'
617 assert level
.exits
== {} # You still can't escape the evilstorm!
619 level
= session
.query(Level1
).filter_by(
621 assert level
.name
== u
'Central Park, NY, NY'
622 assert level
.description
== u
"New York's friendly Central Park."
623 assert level
.exits
== {
624 'portal': 'necroplex'}
626 # Create new migration manager, but make sure the db migration
627 # isn't said to be updated yet
628 printer
= CollectingPrinter
629 migration_manager
= MigrationManager(
630 '__main__', SET3_MODELS
, SET3_MIGRATIONS
, Session(),
633 assert migration_manager
.latest_migration
== 3
634 assert migration_manager
.database_current_migration
== 0
637 result
= migration_manager
.init_or_migrate()
639 # Make sure result was "migrated"
640 assert result
== u
'migrated'
642 # TODO: Check output to user
643 assert printer
.combined_string
== """\
644 -> Updating main mediagoblin tables...
645 + Running migration 1, "creature_remove_is_demon"... done.
646 + Running migration 2, "creature_powers_new_table"... done.
647 + Running migration 3, "level_exits_new_table"... done."""
649 # Make sure version matches expected
650 migration_manager
= MigrationManager(
651 '__main__', SET3_MODELS
, SET3_MIGRATIONS
, Session(),
653 assert migration_manager
.latest_migration
== 3
654 assert migration_manager
.database_current_migration
== 3
656 # Check all things in database match expected
658 # Check the creature table
659 creature_table
= Table(
660 'creature', metadata
,
661 autoload
=True, autoload_with
=db_conn
.engine
)
662 assert set(creature_table
.c
.keys()) == set(
663 ['id', 'name', 'num_limbs'])
664 assert_col_type(creature_table
.c
.id, Integer
)
665 assert_col_type(creature_table
.c
.name
, Unicode
)
666 assert creature_table
.c
.name
.nullable
is False
667 assert creature_table
.c
.name
.index
is True
668 assert creature_table
.c
.name
.unique
is True
669 assert_col_type(creature_table
.c
.num_legs
, Integer
)
670 assert creature_table
.c
.num_legs
.nullable
is False
672 # Check the CreaturePower table
673 creature_power_table
= Table(
674 'creature_power', metadata
,
675 autoload
=True, autoload_with
=db_conn
.engine
)
676 assert set(creature_power_table
.c
.keys()) == set(
677 ['id', 'creature', 'name', 'description', 'hitpower'])
678 assert_col_type(creature_power_table
.c
.id, Integer
)
679 assert_col_type(creature_power_table
.c
.creature
, Integer
)
680 assert creature_power_table
.c
.creature
.nullable
is False
681 assert_col_type(creature_power_table
.c
.name
, Unicode
)
682 assert_col_type(creature_power_table
.c
.description
, Unicode
)
683 assert_col_type(creature_power_table
.c
.hitpower
, Float
)
684 assert creature_power_table
.c
.hitpower
.nullable
is False
686 # Check the structure of the level table
689 autoload
=True, autoload_with
=db_conn
.engine
)
690 assert set(level_table
.c
.keys()) == set(
691 ['id', 'name', 'description'])
692 assert_col_type(level_table
.c
.id, Unicode
)
693 assert level_table
.c
.id.primary_key
is True
694 assert_col_type(level_table
.c
.name
, Unicode
)
695 assert_col_type(level_table
.c
.description
, Unicode
)
697 # Check the structure of the level_exits table
698 level_exit_table
= Table(
699 'level_exit', metadata
,
700 autoload
=True, autoload_with
=db_conn
.engine
)
701 assert set(level_exit_table
.c
.keys()) == set(
702 ['id', 'name', 'from_level', 'to_level'])
703 assert_col_type(level_exit_table
.c
.id, Integer
)
704 assert_col_type(level_exit_table
.c
.name
, Unicode
)
705 assert_col_type(level_exit_table
.c
.from_level
, Unicode
)
706 assert level_exit_table
.c
.from_level
.nullable
is False
707 assert level_exit_table
.c
.from_level
.indexed
is True
708 assert_col_type(level_exit_table
.c
.to_level
, Unicode
)
709 assert level_exit_table
.c
.to_level
.nullable
is False
710 assert level_exit_table
.c
.to_level
.indexed
is True
712 # Now check to see if stuff seems to be in there.
713 creature
= session
.query(Creature1
).filter_by(
714 name
=u
'centipede').one()
715 assert creature
.num_legs
== 100.0
716 assert creature
.creature_powers
== []
718 creature
= session
.query(Creature1
).filter_by(
720 assert creature
.num_legs
== 4.0
721 assert creature
.creature_powers
== []
723 creature
= session
.query(Creature1
).filter_by(
724 name
=u
'wizardsnake').one()
725 assert creature
.num_legs
== 0.0
726 assert creature
.creature_powers
== []
731 def test_set2_to_set3():
732 # Create / connect to database
733 # Create tables by migrating on empty initial set
735 # Install the initial set
736 # Check version in database
737 # Sanity check a few things in the database
740 # Make sure version matches expected
741 # Check all things in database match expected
745 def test_set1_to_set2_to_set3():
746 # Create / connect to database
747 # Create tables by migrating on empty initial set
749 # Install the initial set
750 # Check version in database
751 # Sanity check a few things in the database
754 # Make sure version matches expected
755 # Check all things in database match expected
758 # Make sure version matches expected again
759 # Check all things in database match expected again
762 # creature_table = Table(
763 # 'creature', metadata,
764 # autoload=True, autoload_with=db_conn.engine)
765 # assert set(creature_table.c.keys()) == set(
766 # ['id', 'name', 'num_legs'])
767 # assert_col_type(creature_table.c.id, Integer)
768 # assert_col_type(creature_table.c.name, Unicode)
769 # assert creature_table.c.name.nullable is False
770 # assert creature_table.c.name.index is True
771 # assert creature_table.c.name.unique is True
772 # assert_col_type(creature_table.c.num_legs, Integer)
773 # assert creature_table.c.num_legs.nullable is False
775 # # Check the CreaturePower table
776 # creature_power_table = Table(
777 # 'creature_power', metadata,
778 # autoload=True, autoload_with=db_conn.engine)
779 # assert set(creature_power_table.c.keys()) == set(
780 # ['id', 'creature', 'name', 'description', 'hitpower'])
781 # assert_col_type(creature_power_table.c.id, Integer)
782 # assert_col_type(creature_power_table.c.creature, Integer)
783 # assert creature_power_table.c.creature.nullable is False
784 # assert_col_type(creature_power_table.c.name, Unicode)
785 # assert_col_type(creature_power_table.c.description, Unicode)
786 # assert_col_type(creature_power_table.c.hitpower, Integer)
787 # assert creature_power_table.c.hitpower.nullable is False
789 # # Check the structure of the level table
790 # level_table = Table(
792 # autoload=True, autoload_with=db_conn.engine)
793 # assert set(level_table.c.keys()) == set(
794 # ['id', 'name', 'description'])
795 # assert_col_type(level_table.c.id, Unicode)
796 # assert level_table.c.id.primary_key is True
797 # assert_col_type(level_table.c.name, Unicode)
798 # assert_col_type(level_table.c.description, Unicode)
800 # # Check the structure of the level_exits table
801 # level_exit_table = Table(
802 # 'level_exit', metadata,
803 # autoload=True, autoload_with=db_conn.engine)
804 # assert set(level_exit_table.c.keys()) == set(
805 # ['id', 'name', 'from_level', 'to_level'])
806 # assert_col_type(level_exit_table.c.id, Integer)
807 # assert_col_type(level_exit_table.c.name, Unicode)
808 # assert_col_type(level_exit_table.c.from_level, Unicode)
809 # assert level_exit_table.c.from_level.nullable is False
810 # assert_col_type(level_exit_table.c.to_level, Unicode)