Repository: cmancone/mygrations (language: python)

Issue:
Missing database check: doesn't realize when an index references a column that doesn't exist
Input:
```
CREATE TABLE `roles` (
  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(255) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  KEY `account_id` (`account_id`)
);
```
Expected result: `mygrate.py check` should return an error message along the lines of `Invalid index: roles.account_id references column 'account_id' that does not exist`


Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..68be53b
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,12 @@
+# See https://pre-commit.com for more information
+# See https://pre-commit.com/hooks.html for more hooks
+repos:
+-   repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v3.2.0
+    hooks:
+    -   id: trailing-whitespace
+    -   id: end-of-file-fixer
+-   repo: https://github.com/pre-commit/mirrors-yapf
+    rev: v0.32.0
+    hooks:
+    -   id: yapf
diff --git a/README.md b/README.md
index 2c90c27..80ba37c 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,24 @@
-[![Build Status](https://travis-ci.org/cmancone/mygrations.svg?branch=master)](https://travis-ci.org/cmancone/mygrations)
-
 # mygrations
 
-A general purpose migration tool for managing MySQL updates.  Written in python but intended for use in other systems as well, especially PHP.
+A stateless database migrator via Schema as Code!
 
 ## About
 
-`mygrations` manages database migrations with a declarative approach.  Rather than having many migration files, each table in your database has one SQL file which contains a `CREATE TABLE` command that gives the table definition.  This file defines the table structure that you want your table to have (potentially with records as well).  When it comes time to migrate your database, `mygrations` parses the SQL in these files to determine what the structure of your database should be.  It then compares this to the actual table structure in MySQL to determine all of the changes that need to happen to bring the database up to spec.  Finally, it generates any necessary `ALTER TABLE`, `CREATE TABLE`, or `DROP TABLE` commands to update the database.
-
-When you need to change a table structure you don't generate additional migration files, but instead simply edit the `CREATE TABLE` command inside the original table definition file.  When migrating multiple tables related via foreign key constraints you simply have to define your foreign keys in the `CREATE TABLE` command normally.  `mygrations` will take note of the foreign keys and and automatically calculate and resolve dependencies while migrating.
+`mygrations` is not so much a database migration system as it is "Schema as Code".  Rather than having a migration file dedicated to each change in your database, you describe your database schema via standard `CREATE TABLE` and `INSERT` commands.  `mygrations` can validate your schema both in isolation and against an actual production database, allowing you to make your database migration process fully testable.
 
-## Installation
+The actual database migration process is stateless - no need to add a table to your database to keep track of which migrations have run.  Instead, `mygrations` compares your declared schema against the current schema of your databse, determines the necessary steps to make your database match, and updates your database accordingly.  As a result, updating your database structure with `mygrations` doesn't involve creating additional migration files.  Instead, you simply edit the `CREATE TABLE` command inside the original table definition file to add/remove columns, indexes, or constraints as needed.
 
-1. Requires Python3
-2. Requires [MySQLdb For python3](https://pypi.python.org/pypi/mysqlclient).  (For ubuntu: `sudo apt-get install python3-mysqldb`)
+The main disadvantage with `mygrations` stems from one of its key advantages (statelessness).  `mygrations` cannot handle stateful operations: renaming tables, renaming columns, moving or transforming data.  The only good way to handle those is by using the same methods as every other migration system out there (i.e. migration files with a record of which have run), and there isn't much point in adding that to this tool.  If you really need that, you're better off using more typical migration tools.
 
-Note that installing this via pip will **not** install the MySQLdb dependency.  I did this because installing MySQLdb through pip can be a pain, requiring additional dependencies and a compiler.  In contrast, MySQLdb can usually be installed from a package manager with a one liner (see above).  To install `mygrations`:
+## Installation
 
 ```
 pip3 install mygrations
 ```
 
-Then you just need to download and install the mygrations runner.  Something like this works:
+Then you just need to download and install the mygrations runner. Something like this works:
 
-```bash
+```
 wget 'https://raw.githubusercontent.com/cmancone/mygrations/master/mygrate.py'
 chmod a+x mygrate.py
 sudo mv mygrate.py /usr/local/bin/mygrate.py
@@ -31,7 +26,7 @@ sudo mv mygrate.py /usr/local/bin/mygrate.py
 
 Your mileage may vary.
 
-## Setup
+## Command line setup
 
 The calling sequence of the mygration runner is not yet very flexible, requiring you to have your environment setup in a particular way.  Right now it assumes:
 
@@ -44,7 +39,6 @@ When you run `mygrate.py` it will read the `mygrate.conf` file in your current d
 ```
 /var/www/example.com/.env
 /var/www/example.com/mygrate.conf
-/var/www/example.com/public/
 /var/www/example.com/database/*.sql
 ```
 
@@ -66,15 +60,16 @@ hostname_key = "DB_HOSTNAME"
 username_key = "DB_USERNAME"
 password_key = "DB_PASSWORD"
 database_key = "DB_DATABASE"
+env_file = ".env"
 
 files_directory = "database/"
 ```
 
-To be clear, you don't put your database credentials in your `mygrate.conf` file: instead you simply tell it which keys to grab the database credentials out of from your `.env` file.  This way you can just have one `mygrate.conf` file that works in all environments.  The files directory tells it where to find your `*.sql` files.  You simply specify the location of the directory containing those files, relative to the `mygrate.conf` file.  It will automatically read any `*.sql` files in that directory and use the structure in those files to determine the "truth" of what your database should look like.
+To be clear, you don't put your database credentials in your `mygrate.conf` file: instead you simply tell it which keys to grab the database credentials out of from your `.env` file.  This way you can just have one `mygrate.conf` file that works in all environments.  The files directory tells it where to find your `*.sql` files.  You simply specify the location of the directory containing those files, relative to the `mygrate.conf` file.  It will automatically read any `*.sql` files in that directory and use the structure in those files to determine the "truth" of what your database should look like.  Finally, the `env_file` setting tells mygrations where to find your `.env` file.  It should be relative to the location of your `mygrate.conf` file (i.e. `.env` if the file is in the same directory or `sub_directory/.env` if the `.env` file lives in a sub directory relative to the `mygrate.conf` file).
 
 To make things super clear I have created an example repository that is ready to run mygrations.  Instructions [here](https://github.com/cmancone/mygrations_example).
 
-## Usage
+## Command line usage
 
 Currently the system supports 5 modes:
 
@@ -92,49 +87,60 @@ Each should be executed by running the mygration command with the desired mode a
 
 The typical use case would be to run `mygrate.py plan` and inspect the results.  If things seem reasonable then simply  `mygrate.py execute`
 
-## Advantages
-
-There are plenty of migration tools out there, and many frameworks come with their own.  So why would I write another, and why would anyone setup a new tool if one comes out-of-the-box with their framework of choice?  Because the declarative approach taken by `mygrations` has a number of concrete advantages.
+## Calling from python
 
-**1. Database structure trackable by version control**
+If you happen to be using python then you can call mygrations directly if you prefer.  This allows you to manage the connection to the database yourself (but still requires pymysql).  Here's a quick and dirty example (in lieu of proper documentation):
 
-In `mygrations` each database is defined by a single `CREATE TABLE` command living in one file.  Adjusting a table's structure means adjusting the `CREATE TABLE` command in the table's definition file.  As a result, if two developers attempt to change the same table in conflicting ways, the conflict will be picked up immediately at merge time by your version control system.  Because normal migration systems put each database change in its own file version control cannot pick up any conflicts.  Instead, conflicting table definitions are not found until after a merge when the next migration is run and an SQL error is generated.  This way, potential conflicts are found much sooner.
+```
+from mygrations.core.commands import execute
+import pymysql
+
+connection = pymysql.connect(
+    user='[DATABASE_USERNAME]',
+    password='[DATABASE_PASSWORD]',
+    host='[DATABASE_HOST]',
+    database='[DATABASE_NAME]',
+    autocommit=False,
+    connect_timeout=2,
+    cursorclass=pymysql.cursors.DictCursor
+)
+
+execute('plan', {'connection': connection, 'sql_files': '/folder/with/sql/files'})
+execute('apply', {'connection': connection, 'sql_files': '/folder/with/sql/files'})
+```
+`sql_files` can be the absolute path to a folder that contains your SQL files, or it can be a string with actual SQL (as always, just separate multiple SQL commands with a semi-colon).
 
-**2. MySQL Linting**
+## Advantages
 
-Unlike normal migration systems which simply apply developer-provided transformations to update your tables, `mygrations` is an intelligent tool that understands the actual structure you are trying to create.  As a result it is possible to perform checks to find easy-to-miss syntax errors, as well as enforce project standards.  There are many possibilities:
+There are plenty of migration tools out there, and many frameworks come with their own.  So why would I write another, and why would anyone setup a new tool if one comes out-of-the-box with their framework of choice?  Because the declarative approach taken by `mygrations` has a number of concrete advantages.
 
-1. Generate warnings about syntax errors in your SQL files
-2. Enforce system-wide naming conventions on column names
-3. Verify that all foreign key columns actually have foreign key constraints
-4. Set rules to determine what column types should/should not be used
-5. ... more when I think of it
+### 1. Database Schema Tests
 
-Mainly though, my intention is to make a linting system that is fully configurable by the end user.
+`mygrations` has a couple main testing modes that grant the equivalent of unit and integration testing for your database schema.  For isolated testing, you can point it to your database schema files and it will parse them, inspect them, and warn you of any schema errors - no actual database is required for this!  It will check for things like foreign key errors, duplicate key names, etc.  This allows you to test your schema without having to actually apply it to a database.
 
-**3. Better foreign key errors**
+Additionally, `mygrations` can validate a schema against an actual database.  This can pick up more subtle errors before trying to update things.  For instance, it will warn you if your schema will add a unique index on a column with duplicate values, or attempt to disallow nulls on a column that already contains null values.  These sorts of subtle issues can be easy-to-miss before running a database migration, but `mygrations` can detect them before applying changes.
 
-Does this look familiar?
+In short, you can add `mygrations` to your CI/CD process right "next" to your usual code tests to ensure that code and database changes can safely deploy together.
 
-```ERROR 1215 (HY000): Cannot add foreign key constraint```
+### 2. Database structure trackable by version control
 
-I've seen developers at all skill levels waste many hours while creating foreign key constraints due to the above error from MySQL, which gives absolutely no hints as to what the problem actually is.  Because `mygrations` understands what the database is supposed to look like, it can detect the actual conditions that cause this error and provide a specific and actionable error message to the developer.
+In `mygrations` each database is defined by a single `CREATE TABLE` command living in a file.  Adjusting a table's structure means adjusting the `CREATE TABLE` command in the table's definition file.  As a result, if two developers attempt to change the same table in conflicting ways, the conflict will be picked up immediately at merge time by your version control system.  Because normal migration systems put each database change in its own file version control cannot pick up any conflicts.  Instead, conflicting table definitions are not found until after a merge when the next migration is run and an SQL error is generated.  This way, potential conflicts are found much sooner.
 
-**4. Migration plans**
+### 3. Migration plans
 
 Again, because `mygrations` operates with knowledge of both the current database and the target database, it can present an actual migration plan before making any changes.  This makes it easy for the developer to have one last spot check before making changes, if desired.
 
-**5. One table, one file**
+### 4. Clear Schemas
 
-Standard migration systems dedicate a file to each change of a database table.  As a result, it is very difficult to figure out what the database structure *should* be simply by looking at the contents of the migration directory.  Having one table per file makes it easy to spot check your migrations and make sure nothing has been missed.
+Standard migration systems dedicate a file to each change of a database table.  As a result, it is very difficult to figure out what the database structure *should* be simply by looking at the contents of the migration directory.  `mygrations` makes it easier for a developer to look at the database schema files and understand exactly what the database should look like.
 
-**6. No Migration table**
+### 5. No Migration table
 
 Since `mygrations` works directly with the database structure it doesn't need to keep a history of which migrations it has run.  Instead, it brings your database up-to-spec no matter what state it is in: no more hassle if your migration table somehow gets out of sync with your migration files.
 
-**7. Automatic migration builder**
+### 6. Roll Forward, Roll Back
 
-Since the migration files are just simple `CREATE TABLE` commands, `mygrations` can create the migration files for you from your database.  Although you probably won't have to do that very often because the migrations files are very easy to build anyway.  A simple `SHOW CREATE TABLE` command from MySQL is all you need.
+Because `mygrations` is stateless it has no concept of forward or backward, and no need to define separate instructions for "do" or "undo".  Whether you are changing branches, rolling back to a previous commit, or simply pull down the latest changes, `mygrations` sees no difference and updates your database just the same.
 
 ## Roadmap to 1.0
 
@@ -145,6 +151,8 @@ This is a brand new venture that is a long way from complete.  To give some guid
 3. Ability to migrate database to match definitions from any state (Done)
 4. Generation of migration commands (Done)
 5. Generation of migration files (Done)
-6. More flexible methods for credential management
+6. More flexible methods for credential management (load from environment, specify name of .env file, accept cursor from Python)
+7. Checks against a live database
+8. Auto install PyMySQL and runner
 
 Currently the system has reached a complete enough state that it is being tested in our real-world systems.
diff --git a/mygrate.py b/mygrate.py
index 4056add..a5c2fd1 100755
--- a/mygrate.py
+++ b/mygrate.py
@@ -10,9 +10,8 @@
     nargs='?',
     default='version',
     choices=['version', 'apply', 'check', 'import', 'plan', 'plan_export'],
-    help='Action to execute (default: check)'
+    help='Action to execute (default: version)'
 )
-parser.add_argument('--env', default='.env', help='Location of environment file (default: .env)')
 parser.add_argument(
     '--config', default='mygrate.conf', help='Location of mygrate configuration file (default: mygrate.conf)'
 )
diff --git a/mygrations/__init__.py b/mygrations/__init__.py
index 2d7893e..1f356cc 100644
--- a/mygrations/__init__.py
+++ b/mygrations/__init__.py
@@ -1,1 +1,1 @@
-__version__ = '0.13.0'
+__version__ = '1.0.0'
diff --git a/mygrations/core/__init__.py b/mygrations/core/__init__.py
index 8b13789..e69de29 100644
--- a/mygrations/core/__init__.py
+++ b/mygrations/core/__init__.py
@@ -1,1 +0,0 @@
-
diff --git a/mygrations/core/commands/apply.py b/mygrations/core/commands/apply.py
index 1269d9e..17a3cdc 100644
--- a/mygrations/core/commands/apply.py
+++ b/mygrations/core/commands/apply.py
@@ -1,12 +1,13 @@
-from .plan import plan
-from mygrations.drivers.mysqldb.mysqldb import mysqldb
+from .plan import Plan
 def execute(options):
 
-    obj = apply(options)
+    obj = Apply(options)
     obj.execute()
-class apply(plan):
+class Apply(Plan):
+    needs_cursor = False
+
     def execute(self):
 
         commands = self.build_commands()
-        connection = mysqldb(self.credentials)
+        connection = self.get_driver()
         connection.execute(['%s' % command for command in commands])
diff --git a/mygrations/core/commands/base.py b/mygrations/core/commands/base.py
index 0aaa0ad..b2222a8 100644
--- a/mygrations/core/commands/base.py
+++ b/mygrations/core/commands/base.py
@@ -1,45 +1,77 @@
 import os, os.path
 from mygrations.helpers.dotenv import dotenv
-from mygrations.helpers.db_credentials import db_credentials
-class base(object):
-
-    credentials = {}
-    config = {}
+from mygrations.helpers.db_credentials import DbCredentials
+from mygrations.drivers.pymysql.pymysql import PyMySQL
+class Base(object):
+    options = None
+    config = None
+    _config_loaded = False
 
     def __init__(self, options):
-
         self.options = options
 
-        if not 'env' in self.options:
-            raise ValueError('Missing "env" in options for commands.import_files')
+    def get_env(self):
+        if 'env' not in self.options:
+            raise KeyError(f"'env' was not provided in options for command '{self.__class__.__name__}'")
+
+    def get_driver(self):
+        if 'driver' not in self.options:
+            if 'connection' in self.options:
+                self.options['driver'] = PyMySQL(connection=self.options['connection'])
+            else:
+                self._load_config()
+                self.options['driver'] = PyMySQL(DbCredentials(self.config['env_file'], self.config))
+        return self.options['driver']
+
+    def get_sql_files(self):
+        if 'sql_files' not in self.options:
+            self._load_config()
+            self.options['sql_files'] = self.config['files_directory']
+        return self.options['sql_files']
+
+    def _load_config(self):
+        if self._config_loaded:
+            return True
 
-        if not 'config' in self.options:
-            raise ValueError('Missing "config" in options for commands.import_files')
+        if 'config' not in self.options:
+            raise ValueError(f"Missing required 'config' parameter in options for commands '{self.__class__.__name__}'")
+
+        if 'env' not in self.options:
+            raise ValueError(f"Missing required 'env' parameter in options for command '{self.__class__.__name__}'")
 
         # load up the mygration configuration (which includes the path to the files we will import)
-        config_abs_path = self._resolve_and_check_config_file(self.options['config'])
+        # note that self.options['config'] is supposed to contain the filename or absolute path to the
+        # mygrations configuration file.  By default, this is called `mygrate.conf`.  In return
+        # we get the absolute path to this file, which contains the config we need to do anything from
+        # the command line (we shouldn't end up here if called programmatically).
+
+        # this can be slightly confusing because of the mixture of self.config and self.options.  It's a simple
+        # difference though: self.options is a dictionary passed in by whoever calls us (probably the command line runner),
+        # while self.config is a dictionary containing configuration settings loaded out of the mygrate.conf file.
+        config_abs_path = self._resolve_and_check_config_file_path(self.options['config'])
         self.config = dotenv(config_abs_path)
+        if 'files_directory' not in self.config:
+            raise ValueError(
+                "Configuration file is missing 'files_directory' configuration, which should point to the directory containing your *.sql files"
+            )
         self.config['files_directory'] = self._relative_to_config_abs_path(
-            self.config['files_directory'],
-            config_abs_path
+            self.config['files_directory'], config_abs_path
         )
-        self.options['env'] = self._relative_to_config_abs_path(
-            self.options['env'],
-            config_abs_path
-        )
-
-        # and load up the database credentials
-        self.credentials = db_credentials(self.options['env'], self.config)
 
-        if not 'files_directory' in self.config:
-            raise ValueError('Missing files_directory configuration setting in configuration file')
+        if 'env_file' not in self.config:
+            raise ValueError(
+                "Configuration file is missing 'env_file' configuration, which should point to the .env file for your application (so mygrations can fetch database credentials"
+            )
+        # convert the env file path to an absolute path
+        self.config['env_file'] = self._relative_to_config_abs_path(self.config['env_file'], config_abs_path)
+        self._config_loaded = True
 
     def execute(self):
         raise NotImplementedError()
 
-    def _resolve_and_check_config_file(self, file_path):
+    def _resolve_and_check_config_file_path(self, file_path):
         if not len(file_path):
-            raise ValueError('Missing path to env file')
+            raise ValueError('Missing path to configuration file')
 
         # We want to find the location of the mygrate.conf file.  For the most part expect file_path to literally
         # be `mygrate.conf`, as that is the default sent in by the runner.  However the user could have specified
@@ -48,18 +80,18 @@ def _resolve_and_check_config_file(self, file_path):
         # want to search for the given file in current directory or its parents.
         if file_path[0] == '/':
             if not os.path.isfile(file_path):
-                raise ValueError("Specified env file '%s' does not exist" % file_path)
+                raise ValueError("Specified configuration file '%s' does not exist" % file_path)
             return file_path
 
         directories = ('%s/%s' % (os.getcwd(), file_path)).strip('/').split('/')
         filename = directories.pop()
         ndirectories = len(directories)
         for i in range(ndirectories):
-            abs_path = '/%s/%s' % ('/'.join(directories[:ndirectories-i]), filename)
+            abs_path = '/%s/%s' % ('/'.join(directories[:ndirectories - i]), filename)
             if os.path.isfile(abs_path):
                 return abs_path
 
-        raise ValueError("Could not find env file '%s' in current directory or parents" % file_path)
+        raise ValueError("Could not find configuration file '%s' in current directory or parents" % file_path)
 
     def _relative_to_config_abs_path(self, relative_path, config_abs_path):
         # Our file directory and our .env file path should both be relative to the location of the mygrate.conf file.
diff --git a/mygrations/core/commands/check.py b/mygrations/core/commands/check.py
index 18cf112..40434d5 100644
--- a/mygrations/core/commands/check.py
+++ b/mygrations/core/commands/check.py
@@ -1,31 +1,27 @@
-import MySQLdb
-
-from .base import base
-from mygrations.formats.mysql.file_reader.database import database as database_parser
-from mygrations.formats.mysql.db_reader.database import database as database_reader
-from mygrations.formats.mysql.mygrations.mygration import mygration
+from .base import Base
+from mygrations.formats.mysql.file_reader.database import Database as DatabaseParser
+from mygrations.formats.mysql.mygrations.mygration import Mygration
 def execute(options):
-
-    obj = check(options)
+    obj = Check(options)
     obj.execute()
-class check(base):
+class Check(Base):
     def execute(self):
-        files_database = database_parser(self.config['files_directory'])
+        files_database = DatabaseParser(self.get_sql_files())
 
         # any errors or warnings?
         errors = False
         if files_database.errors:
-            print('Errors found in *.sql files')
+            print('Errors found in *.sql files:')
             errors = True
             for error in files_database.errors:
                 print(error)
 
-        errors_1215 = files_database.errors_1215
-        if errors_1215:
-            print('1215 Errors encountered')
+        warnings = files_database.warnings
+        if warnings:
+            print('Warnings found in *.sql files:')
             errors = True
-            for error in errors_1215:
-                print(error)
+            for warning in warnings:
+                print(warning)
 
         if not errors:
             print("No problems found")
diff --git a/mygrations/core/commands/import_files.py b/mygrations/core/commands/import_files.py
deleted file mode 100644
index 37b07fe..0000000
--- a/mygrations/core/commands/import_files.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from .base import base
-from mygrations.formats.mysql.file_reader.database import database as database_parser
-from mygrations.formats.mysql.db_reader.database import database as database_reader
-from mygrations.formats.mysql.mygrations.mygration import mygration
-from mygrations.drivers.mysqldb.mysqldb import mysqldb
-def execute(options):
-
-    obj = import_files(options)
-    obj.execute()
-class import_files(base):
-    def execute(self):
-
-        files_database = database_parser(self.config['files_directory'])
-
-        # any errors or warnings?
-        if files_database.errors:
-            print('Errors found in *.sql files')
-            for error in files_database.errors:
-                print(error)
-
-            return False
-
-        # use the credentials to load up a database connection
-        live_database = database_reader(mysqldb(self.credentials))
-
-        # we have to tell the live database to load records
-        # for any tables we are tracking records for.
-        # We use the "files" database as the "truth" because
-        # just about any database can have records, but
-        # that doesn't mean we want to track them.  We only
-        # track them if the file has rows.
-        for table in files_database.tables.values():
-            if not table.tracking_rows:
-                continue
-
-            if not table.name in live_database.tables:
-                continue
-
-            live_database.read_rows(table)
-
-        mygrate = mygration(files_database, live_database)
-        if mygrate.errors_1215:
-            print('1215 Errors encountered')
-            for error in mygrate.errors_1215:
-                print(error)
-
-        else:
-            for op in mygrate.operations:
-                live_database.apply_to_source(op)
diff --git a/mygrations/core/commands/plan.py b/mygrations/core/commands/plan.py
index e9fd6a7..34a49e0 100644
--- a/mygrations/core/commands/plan.py
+++ b/mygrations/core/commands/plan.py
@@ -1,17 +1,18 @@
-from .base import base
-from mygrations.formats.mysql.file_reader.database import database as database_parser
-from mygrations.formats.mysql.db_reader.database import database as database_reader
-from mygrations.formats.mysql.mygrations.mygration import mygration
-from mygrations.formats.mysql.mygrations.row_mygration import row_mygration
-from mygrations.drivers.mysqldb.mysqldb import mysqldb
-from mygrations.formats.mysql.mygrations.operations.disable_checks import disable_checks
-from mygrations.formats.mysql.mygrations.operations.enable_checks import enable_checks
+from .base import Base
+from mygrations.formats.mysql.file_reader.database import Database as DatabaseParser
+from mygrations.formats.mysql.db_reader.database import Database as DatabaseReader
+from mygrations.formats.mysql.mygrations.mygration import Mygration
+from mygrations.formats.mysql.mygrations.row_mygration import RowMygration
+from mygrations.formats.mysql.mygrations.operations.disable_checks import DisableChecks
+from mygrations.formats.mysql.mygrations.operations.enable_checks import EnableChecks
 import sys
 def execute(options):
 
-    obj = plan(options)
+    obj = Plan(options)
     obj.execute()
-class plan(base):
+class Plan(Base):
+    needs_cursor = True
+
     def execute(self):
 
         commands = self.build_commands()
@@ -19,7 +20,7 @@ def execute(self):
 
     def build_commands(self):
 
-        files_database = database_parser(self.config['files_directory'])
+        files_database = DatabaseParser(self.get_sql_files())
 
         # any errors or warnings?
         quit_early = False
@@ -29,18 +30,11 @@ def build_commands(self):
             for error in files_database.errors:
                 print(error, file=sys.stderr)
 
-        # or 1215 errors?
-        if files_database.errors_1215 and not self.options['force']:
-            print('1215 errors encountered', sys.stderr)
-            quit_early = True
-            for error in files_database.errors_1215:
-                print(error, file=sys.stderr)
-
         if quit_early:
             return []
 
         # use the credentials to load up a database connection
-        live_database = database_reader(mysqldb(self.credentials))
+        live_database = DatabaseReader(self.get_driver())
 
         # we have to tell the live database to load records
         # for any tables we are tracking records for.
@@ -57,13 +51,13 @@ def build_commands(self):
 
             live_database.read_rows(table)
 
-        mygrate = mygration(files_database, live_database, False)
+        mygrate = Mygration(files_database, live_database, False)
 
         ops = []
         if mygrate.operations:
             ops.extend(mygrate.operations)
 
-        rows = row_mygration(files_database, live_database)
+        rows = RowMygration(files_database, live_database)
         if rows.operations:
             ops.extend(rows.operations)
 
@@ -71,7 +65,7 @@ def build_commands(self):
             return []
 
         return [
-            disable_checks(),
+            DisableChecks(),
             *ops,
-            enable_checks(),
+            EnableChecks(),
         ]
diff --git a/mygrations/core/commands/plan_export.py b/mygrations/core/commands/plan_export.py
index b108c0b..7999abf 100644
--- a/mygrations/core/commands/plan_export.py
+++ b/mygrations/core/commands/plan_export.py
@@ -1,30 +1,30 @@
 from collections import OrderedDict
 
-from .base import base
-from mygrations.formats.mysql.file_reader.database import database as database_parser
-from mygrations.formats.mysql.db_reader.database import database as database_reader
-from mygrations.formats.mysql.mygrations.mygration import mygration
-from mygrations.formats.mysql.mygrations.row_mygration import row_mygration
-from mygrations.formats.mysql.mygrations.operations.row_insert import row_insert
+from .base import Base
+from mygrations.formats.mysql.file_reader.database import Database as DatabaseParser
+from mygrations.formats.mysql.db_reader.database import Database as DatabaseReader
+from mygrations.formats.mysql.mygrations.mygration import Mygration
+from mygrations.formats.mysql.mygrations.row_mygration import RowMygration
+from mygrations.formats.mysql.mygrations.operations.row_insert import RowInsert
 from mygrations.drivers.mysqldb.mysqldb import mysqldb
 def execute(options):
 
-    obj = plan_export(options)
+    obj = PlanExport(options)
     obj.execute()
-class plan_export(base):
+class PlanExport(base):
     def execute(self):
 
-        files_database = database_parser(self.config['files_directory'])
+        files_database = DatabaseParser(self.get_sql_files())
 
         # use the credentials to load up a database connection
-        live_database = database_reader(mysqldb(self.credentials))
+        live_database = DatabaseReader(self.get_driver())
 
         # we aren't outputting operations.  Instead we just need to know what tables
         # have changed (either structure or records).  The easiest (and slightly hack)
         # way to do that is to simply run an actual mygration and grab out the names
         # of the tables that have changed.  Cheating, I know
         self.modified_tables = {}
-        mygrate = mygration(live_database, files_database, False)
+        mygrate = Mygration(live_database, files_database, False)
         if mygrate.operations:
             for op in mygrate.operations:
                 self.modified_tables[op.table_name] = True
@@ -39,7 +39,7 @@ def execute(self):
                 continue
             live_database.read_rows(table)
 
-        rows = row_mygration(live_database, files_database)
+        rows = RowMygration(live_database, files_database)
         if rows.operations:
             for op in rows.operations:
                 self.modified_tables[op.table_name] = True
diff --git a/mygrations/core/commands/version.py b/mygrations/core/commands/version.py
index 91194f1..b524466 100644
--- a/mygrations/core/commands/version.py
+++ b/mygrations/core/commands/version.py
@@ -2,10 +2,10 @@
 import mygrations
 def execute(options):
 
-    obj = version(options)
+    obj = Version(options)
     obj.execute()
-class version(base):
+class Version(base):
     def execute(self):
         print('mygrations v%s' % mygrations.__version__)
-        print('Copyright (c) 2017 Conor Mancone (cmancone@gmail.com)')
+        print('Copyright (c) 2021 Conor Mancone (cmancone@gmail.com)')
         print('MIT License')
diff --git a/tests/__init__.py b/mygrations/core/definitions/__init__.py
similarity index 100%
rename from tests/__init__.py
rename to mygrations/core/definitions/__init__.py
diff --git a/mygrations/core/definitions/base.py b/mygrations/core/definitions/base.py
new file mode 100644
index 0000000..70bd2b0
--- /dev/null
+++ b/mygrations/core/definitions/base.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+from typing import List
+class Base:
+    _parsing_errors = None
+    _parsing_warnings = None
+
+    @property
+    def parsing_errors(self) -> List[str]:
+        """ Public getter.  Returns a list of parsing errors """
+        return self._parsing_errors if self._parsing_errors is not None else []
+
+    @property
+    def parsing_warnings(self) -> List[str]:
+        """ Public getter.  Returns a list of parsing errors """
+        return self._parsing_warnings if self._parsing_warnings is not None else []
diff --git a/mygrations/core/definitions/columns/__init__.py b/mygrations/core/definitions/columns/__init__.py
new file mode 100644
index 0000000..cf578a4
--- /dev/null
+++ b/mygrations/core/definitions/columns/__init__.py
@@ -0,0 +1,4 @@
+from .date import Date
+from .enum import Enum
+from .numeric import Numeric
+from .string import String
diff --git a/mygrations/core/definitions/columns/column.py b/mygrations/core/definitions/columns/column.py
new file mode 100644
index 0000000..fecd8ba
--- /dev/null
+++ b/mygrations/core/definitions/columns/column.py
@@ -0,0 +1,266 @@
+from __future__ import annotations
+from typing import Union, List
+from ..base import Base
+class Column(Base):
+    _auto_increment: bool = None
+    _character_set: str = None
+    _collate: str = None
+    _column_type: str = None
+    _has_default: bool = False
+    _default: Union[str, int] = None
+    _length: Union[str, int] = None
+    _name: str = ''
+    _null: bool = None
+    _unsigned: bool = None
+    _allowed_column_types = []
+    _enum_values: List[str] = None    # enum/set only
+    _parsing_errors: List[str] = None
+    _parsing_warnings: List[str] = None
+
+    def __init__(
+        self,
+        name: str = '',
+        column_type: str = '',
+        length: Union[str, int] = None,
+        null: bool = True,
+        has_default: bool = False,
+        default: Union[str, int] = None,
+        unsigned: bool = None,
+        character_set: str = None,
+        collate: str = None,
+        auto_increment: bool = False,
+        enum_values: List[str] = None,
+        parsing_errors: List[str] = None,
+        parsing_warnings: List[str] = None,
+    ):
+        self._auto_increment = auto_increment
+        self._character_set = character_set
+        self._collate = collate
+        self._column_type = column_type
+        self._has_default = has_default
+        self._default = default
+        self._length = length
+        self._name = name
+        self._null = null
+        self._unsigned = unsigned
+        self._enum_values = enum_values
+        self._parsing_errors = parsing_errors
+        self._parsing_warnings = parsing_warnings
+        self._schema_errors = None
+        self._schema_warnings = None
+
+    @property
+    def name(self) -> str:
+        """ Returns the name of the column. """
+        return self._name
+
+    @property
+    def length(self) -> Union[str, int]:
+        """ Returns the length of the column as a string or int.
+
+        Some examples of the length for various column definitions:
+
+        ==================  ====================
+        Type                Length
+        ==================  ====================
+        INT(10) UNSIGNED    10
+        VARCHAR(255)        255
+        decimal(20,5)       20,5
+        date
+        ==================  ====================
+
+        :returns: The column length
+        """
+
+        return self._length
+
+    @property
+    def null(self) -> bool:
+        """ Returns True/False to denote if the column is allowed to be null """
+        return self._null
+
+    @property
+    def column_type(self) -> str:
+        """ Returns a string denoting the type of the column.  Always returns in uppercase
+
+        Some examples of the length for various column definitions:
+
+        ==================  ====================
+        Definition          Type
+        ==================  ====================
+        INT(10) UNSIGNED    INT
+        VARCHAR(255)        VARCHAR
+        decimal(20,5)       DECIMAL
+        date                DATE
+        ==================  ====================
+        """
+        return self._column_type.upper()
+
+    @property
+    def has_default(self) -> bool:
+        """ Returns True/False to denote if the column has a default value defined """
+        return self._has_default
+
+    @property
+    def default(self) -> Union[str, int]:
+        """ Returns the default value for the column as a string, or None for a default value of null
+
+        Returns None to represent a default value of null.
+        """
+        return self._default
+
+    @property
+    def unsigned(self) -> bool:
+        """ Returns True, False, or None to denote the status of the UNSIGNED property
+
+        ==================  ====================
+        Return Value        Meaning
+        ==================  ====================
+        True                The column is unsigned
+        False               The column is signed
+        None                UNSIGNED is not an applicable property for this column type
+        ==================  ====================
+        """
+        return self._unsigned
+
+    @property
+    def character_set(self) -> str:
+        """ Returns None or a value to denote the CHARACTER_SET property
+
+        :returns: string, or None
+        """
+        return None if self._character_set is None else self._character_set.upper()
+
+    @property
+    def collate(self) -> str:
+        """ Returns None or a value to denote the COLLATE property
+
+        :returns: string, or None
+        """
+        return None if self._collate is None else self._collate.upper()
+
+    @property
+    def auto_increment(self) -> bool:
+        """ Returns True, False, or None to denote the status of the AUTO_INCREMENT property
+
+        ==================  ====================
+        Return Value        Meaning
+        ==================  ====================
+        True                The column is an AUTO_INCREMENT column
+        False               The column is not an AUTO_INCREMENT column
+        None                AUTO_INCREMENT is not an applicable property for this column type
+        ==================  ====================
+        """
+        return self._auto_increment
+
+    @property
+    def enum_values(self) -> List[str]:
+        """ Returns the allowed values for the column (enum/set only)"""
+        return self._enum_values
+
+    @property
+    def schema_errors(self) -> List[str]:
+        """ Returns a list of schema errors """
+        if self._schema_errors is None:
+            self._check_for_schema_errors_and_warnings()
+        return self._schema_errors
+
+    @property
+    def schema_warnings(self) -> List[str]:
+        """ Returns a list of schema warnings """
+        if self._schema_errors is None:
+            self._check_for_schema_errors_and_warnings()
+        return self._schema_warnings
+
+    @property
+    def parsing_errors(self) -> List[str]:
+        """ Returns a list of parsing errors """
+        return self._parsing_errors if self._parsing_errors is not None else []
+
+    @property
+    def parsing_warnings(self) -> List[str]:
+        """ Returns a list of parsing warnings """
+        return self._parsing_warnings if self._parsing_warnings is not None else []
+
+    def _check_for_schema_errors_and_warnings(self):
+        """ Finds and stores any schema errors/warnings in the appropriate parameters """
+        self._schema_errors = []
+        self._schema_warnings = []
+
+        if not self.name:
+            self._schema_errors.append("Column does not have a name")
+        if not self.column_type:
+            self._schema_errors.append(f"Missing column_type for column {self.name}")
+
+        if self.default is None and not self.null and not self.auto_increment:
+            self._schema_warnings.append(
+                f'Column {self.name} does not allow null values and has no default: you should set a default to avoid warnings'
+            )
+
+    def __str__(self) -> str:
+        """ Returns the MySQL command that would create the column
+
+        i.e. column_name type(len) default ''
+
+        :returns: A partial MySQL command that could be used to generate the column
+        """
+        parts = []
+        parts.append('`%s`' % self.name)
+
+        type_string = self.column_type
+        if self.enum_values:
+            type_string += "('%s')" % ("', '".join(self.enum_values))
+        elif self.length is not None and self.length != '':
+            type_string += '(%s)' % self.length
+        parts.append(type_string)
+
+        if self.unsigned:
+            parts.append('UNSIGNED')
+
+        if not self.null:
+            parts.append('NOT NULL')
+
+        if self.default is not None:
+            if self.default == '':
+                parts.append("DEFAULT ''")
+            elif type(self.default) != str or self.default.isnumeric():
+                parts.append("DEFAULT %s" % self.default)
+            else:
+                parts.append("DEFAULT '%s'" % self.default)
+
+        if self.auto_increment:
+            parts.append('AUTO_INCREMENT')
+
+        return ' '.join(parts)
+
+    def is_really_the_same_as(self, column: Column) -> bool:
+        """ Takes care of a pesky false-positive when checking columns
+
+        :param column: The column to comprehensively check for a difference with
+        :returns: True if the column really is the same, even for apparent differences
+        """
+        # if any of these attributes change then it really isn't the same
+        for attr in ['name', 'length', 'null', 'column_type', 'unsigned']:
+            if getattr(self, attr) != getattr(column, attr):
+                return False
+
+        if self._default_none_mismatch(column):
+            return False
+        if not self._is_really_the_same_default(column):
+            return False
+
+        # if we got here then these columns are the same. Either all attributes have the same
+        # values, or collate and/or character_set differ, but they differ by one not having
+        # a value.  This is the false-positive we are trying to check for
+        # (see tests.formats.mysql.definitions.test_table_text_false_positives)
+        return True
+
+    def _default_none_mismatch(self, column: Column) -> bool:
+        if self.default is not None and column.default is None:
+            return True
+        if column.default is not None and self.default is None:
+            return True
+        return False
+
+    def _is_really_the_same_default(self, column: Column) -> bool:
+        return self.default == column.default
diff --git a/mygrations/core/definitions/columns/date.py b/mygrations/core/definitions/columns/date.py
new file mode 100644
index 0000000..720ee2f
--- /dev/null
+++ b/mygrations/core/definitions/columns/date.py
@@ -0,0 +1,65 @@
+from .column import Column
+from typing import List, Union
+class Date(Column):
+    _allowed_column_types = [
+        'DATE',
+        'DATETIME',
+        'TIMESTAMP',
+    ]
+
+    def __init__(
+        self,
+        name: str = '',
+        column_type: str = '',
+        length: Union[str, int] = None,
+        null: bool = True,
+        has_default: bool = False,
+        default: Union[str, int] = None,
+        unsigned: bool = None,
+        character_set: str = None,
+        collate: str = None,
+        auto_increment: bool = False,
+        enum_values: List[str] = None,
+        parsing_errors: List[str] = None,
+        parsing_warnings: List[str] = None,
+    ):
+        # it would be nice to just do `def __init__(**kwargs)` and then `super().__init__(**kwargs)`
+        # but then we would lose our type hints.  :shrug:
+        super().__init__(
+            name=name,
+            column_type=column_type,
+            length=length,
+            null=null,
+            has_default=has_default,
+            default=default,
+            unsigned=unsigned,
+            character_set=character_set,
+            collate=collate,
+            auto_increment=auto_increment,
+            enum_values=enum_values,
+            parsing_errors=parsing_errors,
+            parsing_warnings=parsing_warnings,
+        )
+
+    def _check_for_schema_errors_and_warnings(self):
+        super()._check_for_schema_errors_and_warnings()
+
+        if self.length:
+            self._schema_errors.append(f"Column '{self.name}' of type '{self.column_type}' cannot have a length")
+
+        if self.character_set is not None:
+            self._schema_errors.append(f"Column '{self.name}' of type '{self.column_type}' cannot have a character set")
+        if self.collate is not None:
+            self._schema_errors.append(f"Column '{self.name}' of type '{self.column_type}' cannot have a collate")
+
+        if self.auto_increment and self.column_type in no_auto_increment:
+            self._schema_errors.append(f"Column '{self.name}' of type '{self.column_type}' cannot be an AUTO_INCREMENT")
+
+        if self.values:
+            self._schema_errors.append(
+                "Column '%s' of type %s is not allowed to have a list of values for its length" %
+                (self.name, self.column_type)
+            )
+
+        if self.unsigned:
+            self._schema_errors.append("Column %s cannot be unsigned" % self._name)
diff --git a/mygrations/core/definitions/columns/enum.py b/mygrations/core/definitions/columns/enum.py
new file mode 100644
index 0000000..abc049f
--- /dev/null
+++ b/mygrations/core/definitions/columns/enum.py
@@ -0,0 +1,58 @@
+from .string import String
+from typing import List, Union
+class Enum(String):
+    _allowed_column_types = [
+        'ENUM',
+        'SET',
+    ]
+
+    def __init__(
+        self,
+        name: str = '',
+        column_type: str = '',
+        length: Union[str, int] = None,
+        null: bool = True,
+        has_default: bool = False,
+        default: Union[str, int] = None,
+        unsigned: bool = None,
+        character_set: str = None,
+        collate: str = None,
+        auto_increment: bool = False,
+        enum_values: List[str] = None,
+        parsing_errors: List[str] = None,
+        parsing_warnings: List[str] = None,
+    ):
+        # it would be nice to just do `def __init__(**kwargs)` and then `super().__init__(**kwargs)`
+        # but then we would lose our type hints.  :shrug:
+        super().__init__(
+            name=name,
+            column_type=column_type,
+            length=length,
+            null=null,
+            has_default=has_default,
+            default=default,
+            unsigned=unsigned,
+            character_set=character_set,
+            collate=collate,
+            auto_increment=auto_increment,
+            enum_values=enum_values,
+            parsing_errors=parsing_errors,
+            parsing_warnings=parsing_warnings,
+        )
+
+    def _check_for_schema_errors_and_warnings(self):
+        super()._check_for_schema_errors_and_warnings()
+
+        if not self.enum_values or type(self.enum_values) != list:
+            self._schema_errors.append(f"column '{self.name}' of type '{self.column_type}' must have a list of values")
+
+        if self.default and self.default not in self.enum_values:
+            self._schema_errors.append(
+                f"Default value for '{self.column_type}' column '{self.name}' is not in the list of allowed values"
+            )
+
+        if self.auto_increment and self.column_type in no_auto_increment:
+            self._schema_errors.append(f"Column '{self.name}' of type '{self.column_type}' cannot be an AUTO_INCREMENT")
+
+        if self.unsigned:
+            self._schema_errors.append("Column %s cannot be unsigned" % self._name)
diff --git a/mygrations/core/definitions/columns/numeric.py b/mygrations/core/definitions/columns/numeric.py
new file mode 100644
index 0000000..d8f8d24
--- /dev/null
+++ b/mygrations/core/definitions/columns/numeric.py
@@ -0,0 +1,126 @@
+from .column import Column
+from typing import List, Union
+class Numeric(Column):
+    _allowed_column_types = [
+        'INTEGER',
+        'INT',
+        'SMALLINT',
+        'TINYINT',
+        'MEDIUMINT',
+        'BIGINT',
+        'DECIMAL',
+        'NUMERIC',
+        'FLOAT',
+        'DOUBLE',
+        'BIT',
+    ]
+
+    def __init__(
+        self,
+        name: str = '',
+        column_type: str = '',
+        length: Union[str, int] = None,
+        null: bool = True,
+        has_default: bool = False,
+        default: Union[str, int] = None,
+        unsigned: bool = None,
+        character_set: str = None,
+        collate: str = None,
+        auto_increment: bool = False,
+        enum_values: List[str] = None,
+        parsing_errors: List[str] = None,
+        parsing_warnings: List[str] = None,
+    ):
+        # it would be nice to just do `def __init__(**kwargs)` and then `super().__init__(**kwargs)`
+        # but then we would lose our type hints.  :shrug:
+        super().__init__(
+            name=name,
+            column_type=column_type,
+            length=length,
+            null=null,
+            has_default=has_default,
+            default=default,
+            unsigned=unsigned,
+            character_set=character_set,
+            collate=collate,
+            auto_increment=auto_increment,
+            enum_values=enum_values,
+            parsing_errors=parsing_errors,
+            parsing_warnings=parsing_warnings,
+        )
+
+    def _check_for_schema_errors_and_warnings(self):
+        super()._check_for_schema_errors_and_warnings()
+
+        allow_float = ['DECIMAL', 'NUMERIC', 'FLOAT', 'DOUBLE']
+        no_length = ['FLOAT', 'DOUBLE', 'BIT']
+        no_auto_increment = ['FLOAT', 'DOUBLE', 'BIT']
+
+        # I should probably have the parser auto-convert the type based on quotes/whatever, but
+        # currently it doesn't and I'm apparently being lazy.  I'll probably regret this.
+        default_type = None
+        if type(self.default) == int:
+            default_type = 'int'
+        elif type(self.default) == float:
+            default_type = 'float'
+        elif type(self.default) == str:
+            default_type = 'str'
+            try:
+                int(self.default)
+                default_type = 'int'
+            except:
+                try:
+                    float(self.default)
+                    default_type = 'float'
+                except:
+                    pass
+
+        if default_type == 'str':
+            self._schema_errors.append(
+                f"Column '{self.name}' of type '{self.column_type}' cannot have a string value as a default"
+            )
+        else:
+            if default_type == 'float' and self.column_type not in allow_float:
+                self._schema_errors.append(
+                    f"Column '{self.name}' of type '{self.column_type}' must have an integer value as a default"
+                )
+            if self.column_type == 'BIT' and int(self.default) != 0 and int(self.default) != 1:
+                self._schema_errors.append(f"Column '{self.name}' of type 'BIT' must have a default of 1 or 0")
+
+        if self.length:
+            if self.column_type in no_length:
+                self._schema_errors.append(f"Column '{self.name}' of type '{self.column_type}' cannot have a length")
+            elif type(self.length) == str and ',' in self.length and self.column_type not in allow_float:
+                self._schema_errors.append(
+                    f"Column '{self.name}' of type '{self.column_type}' must have an integer value as its length"
+                )
+
+        if self.character_set is not None:
+            self._schema_errors.append(f"Column '{self.name}' of type '{self.column_type}' cannot have a character set")
+        if self.collate is not None:
+            self._schema_errors.append(f"Column '{self.name}' of type '{self.column_type}' cannot have a collate")
+
+        if self.auto_increment and self.column_type in no_auto_increment:
+            self._schema_errors.append(f"Column '{self.name}' of type '{self.column_type}' cannot be an AUTO_INCREMENT")
+
+        if self.enum_values:
+            self._schema_errors.append(
+                "Column '%s' of type %s is not allowed to have a list of values for its length" %
+                (self.name, self.column_type)
+            )
+
+    def _is_really_the_same_default(self, column: Column) -> bool:
+        if self.column_type != 'DECIMAL':
+            return float(self.default) == float(column.default)
+
+        # Default equality is mildly tricky for decimals because 0 and 0.000 are the same,
+        # and if there are 4 digits after the decimal than 0.0000 and 0.00001 are the same too
+        # This will come up if someone sets a default in an SQL file with too many (or too few) decimals,
+        # while MySQL will report it properly rounded to the exact number of decimal places
+        split = self.length.split(',')
+        if len(split) == 2:
+            ndecimals = int(split[1])
+            if round(float(self.default), ndecimals) == round(float(column.default), ndecimals):
+                return True
+
+        return self.default == column.default
diff --git a/mygrations/core/definitions/columns/string.py b/mygrations/core/definitions/columns/string.py
new file mode 100644
index 0000000..5992f8b
--- /dev/null
+++ b/mygrations/core/definitions/columns/string.py
@@ -0,0 +1,142 @@
+from .column import Column
+from typing import List, Union
+class String(Column):
+    _allowed_column_types = [
+        'CHAR',
+        'VARCHAR',
+        'BINARY',
+        'VARBINARY',
+        'TINYBLOB',
+        'BLOB',
+        'MEDIUMBLOB',
+        'LONGBLOB',
+        'TINYTEXT',
+        'TEXT',
+        'MEDIUMTEXT',
+        'LONGTEXT',
+        'JSON',
+    ]
+
+    _allowed_default = [
+        'CHAR',
+        'VARCHAR',
+        'BINARY',
+        'VARBINARY',
+        'ENUM',
+        'SET',
+    ]
+
+    _allowed_collation = [
+        'CHAR',
+        'VARCHAR',
+        'BINARY',
+        'VARBINARY',
+        'ENUM',
+        'SET',
+    ]
+
+    _allowed_length = [
+        'CHAR',
+        'VARCHAR',
+        'BINARY',
+        'VARBINARY',
+    ]
+
+    _allowed_default_list = [
+        'ENUM',
+        'SET',
+    ]
+
+    def __init__(
+        self,
+        name: str = '',
+        column_type: str = '',
+        length: Union[str, int] = None,
+        null: bool = True,
+        has_default: bool = False,
+        default: Union[str, int] = None,
+        unsigned: bool = None,
+        character_set: str = None,
+        collate: str = None,
+        auto_increment: bool = False,
+        enum_values: List[str] = None,
+        parsing_errors: List[str] = None,
+        parsing_warnings: List[str] = None,
+    ):
+        # it would be nice to just do `def __init__(**kwargs)` and then `super().__init__(**kwargs)`
+        # but then we would lose our type hints.  :shrug:
+        super().__init__(
+            name=name,
+            column_type=column_type,
+            length=length,
+            null=null,
+            has_default=has_default,
+            default=default,
+            unsigned=unsigned,
+            character_set=character_set,
+            collate=collate,
+            auto_increment=auto_increment,
+            enum_values=enum_values,
+            parsing_errors=parsing_errors,
+            parsing_warnings=parsing_warnings,
+        )
+
+    def __str__(self) -> str:
+        as_string = super(String, self).__str__()
+        if self.character_set:
+            as_string += f" CHARACTER SET '{self.character_set}'"
+
+        if self.collate:
+            as_string += f" COLLATE '{self.collate}'"
+
+        return as_string
+
+    def _check_for_schema_errors_and_warnings(self):
+        super()._check_for_schema_errors_and_warnings()
+
+        if self.has_default and self.column_type not in self._allowed_default:
+            self._schema_errors.append(f"Column '{self.name}' of type '{self.column_type}' cannot have a default")
+
+        if self.auto_increment:
+            self._schema_errors.append(
+                f"Column '{self.name}' of type '{self.column_type}' cannot be an AUTO_INCREMENT: only numeric columns can"
+            )
+
+        if (self.character_set or self.collate):
+            if self.column_type not in self._allowed_collation:
+                self._schema_errors.append(
+                    f"Column '{self.name}' of type '{self.column_type}' cannot have a collation/character set"
+                )
+            is_binary = self.column_type == 'BINARY' or self.column_type == 'VARBINARY'
+            binary_collation = 'BINARY' if self.collate is None else self.collate
+            binary_character_set = 'BINARY' if self.character_set is None else self.character_set
+            if is_binary and (binary_collation != 'BINARY' or binary_character_set != 'BINARY'):
+                self._schema_errors.append(
+                    f"Column '{self.name}' of type '{self.column_type}' can only have a collate/character set of BINARY"
+                )
+
+        if self.length and self.column_type not in self._allowed_length:
+            self._schema_errors.append(f'Column {self.name} of type {self.column_type} cannot have a length')
+
+        if self.enum_values and self.column_type not in self._allowed_default_list:
+            self._schema_errors.append(
+                "Column '%s' of type %s is not allowed to have a list of values for its length" %
+                (self.name, self.column_type)
+            )
+
+        if self.unsigned:
+            self._schema_errors.append("Column %s cannot be unsigned" % self._name)
+
+    def is_really_the_same_as(self, column: Column) -> bool:
+        if not super().is_really_the_same_as(column):
+            return False
+
+        # if collate or character_set are different and *both* have a value,
+        # then these aren't really the same
+        for attr in ['collate', 'character_set']:
+            my_val = getattr(self, attr)
+            that_val = getattr(column, attr)
+            if my_val and that_val and my_val != that_val:
+                return False
+
+        return True
diff --git a/mygrations/core/definitions/constraint.py b/mygrations/core/definitions/constraint.py
new file mode 100644
index 0000000..b42c269
--- /dev/null
+++ b/mygrations/core/definitions/constraint.py
@@ -0,0 +1,135 @@
+from __future__ import annotations
+from abc import ABC, abstractmethod
+from typing import Union, List, Tuple
+from .base import Base
+class Constraint(Base):
+    _column_name: str = ''
+    _errors: List[str] = None
+    _foreign_table_name: str = ''
+    _foreign_column_name: str = ''
+    _name: str = ''
+    _on_delete: str = ''
+    _on_update: str = ''
+    _warnings: List[str] = None
+
+    def __init__(
+        self,
+        name: str = '',
+        column_name: str = '',
+        foreign_table_name: str = '',
+        foreign_column_name: str = '',
+        on_delete: str = '',
+        on_update: str = ''
+    ):
+        self._column_name = column_name
+        self._foreign_table_name = foreign_table_name
+        self._foreign_column_name = foreign_column_name
+        self._name = name
+        self._on_delete = on_delete.upper()
+        self._on_update = on_update.upper()
+        if not self._on_delete:
+            self._on_delete = 'RESTRICT'
+        if not self._on_update:
+            self._on_update = 'RESTRICT'
+        self._schema_errors = None
+        self._schema_warnings = None
+        self._parsing_errors = None
+        self._parsing_warnings = None
+
+    @property
+    def name(self) -> str:
+        """ Public getter.  Returns the name of the constraint. """
+        return self._name
+
+    @property
+    def column_name(self) -> str:
+        """ Public getter.  Returns the name of the column this constraint is on """
+        return self._column_name
+
+    @property
+    def foreign_table_name(self) -> str:
+        """ Public getter.  Returns the name of the table this constraint is to """
+        return self._foreign_table_name
+
+    @property
+    def foreign_column_name(self) -> str:
+        """ Public getter.  Returns the name of the column in the foreign table this constraint is to """
+        return self._foreign_column_name
+
+    @property
+    def on_delete(self) -> str:
+        """ Public getter.  Returns the ON DELETE action for this constraint """
+        return self._on_delete
+
+    @property
+    def on_update(self) -> str:
+        """ Public getter.  Returns the ON UPDATE action for this constraint """
+        return self._on_update
+
+    @property
+    def schema_errors(self) -> List[str]:
+        """ Returns a list of schema errors """
+        if self._schema_errors is None:
+            self._check_for_schema_errors_and_warnings()
+        return self._schema_errors
+
+    @property
+    def schema_warnings(self) -> List[str]:
+        """ Returns a list of schema warnings """
+        if self._schema_errors is None:
+            self._check_for_schema_errors_and_warnings()
+        return self._schema_warnings
+
+    @property
+    def parsing_errors(self) -> List[str]:
+        """ Returns a list of parsing errors """
+        return self._parsing_errors if self._parsing_errors is not None else []
+
+    @property
+    def parsing_warnings(self) -> List[str]:
+        """ Returns a list of parsing/table warnings """
+        return self._parsing_warnings if self._parsing_warnings is not None else []
+
+    def _check_for_schema_errors_and_warnings(self) -> List[str]:
+        self._schema_errors = []
+        self._schema_warnings = []
+
+        allowed_actions = ['CASCADE', 'NO ACTION', 'RESTRICT', 'SET DEFAULT', 'SET NULL']
+        if not self.name:
+            self._schema_errors.append('Missing name for constraint')
+        elif len(self._name) > 64:
+            self._schema_errors.append(f"Key name '{self.name}' is too long")
+
+        if self.on_delete not in allowed_actions:
+            self._schema_errors.append(
+                f"ON DELETE action of '{self.on_delete}' for constraint '{self.name}' is not a valid ON DELETE action"
+            )
+
+        if self.on_update not in allowed_actions:
+            self._schema_errors.append(
+                f"ON UPDATE action of '{self.on_update}' for constraint '{self.name}' is not a valid ON UPDATE action"
+            )
+
+        for required in ['name', 'column_name', 'foreign_table_name', 'foreign_column_name']:
+            if not getattr(self, required):
+                self._schema_errors.append(f"Missing {required} for constraint '{self.name}'")
+
+    def __str__(self) -> str:
+        """ Returns the MySQL command that would create the constraint
+
+        i.e. CONSTRAINT `vendors_w9_fk` FOREIGN KEY (`w9_id`) REFERENCES `vendor_w9s` (`id`) ON UPDATE CASCADE
+
+        :returns: A partial MySQL command that could be used to generate the foreign key
+        :rtype: string
+        """
+        return ' '.join([
+            'CONSTRAINT',
+            f'`{self.name}`',
+            'FOREIGN KEY',
+            f'(`{self.column_name}`)',
+            'REFERENCES',
+            f'`{self.foreign_table_name}`',
+            f'(`{self.foreign_column_name}`)',
+            f'ON DELETE {self.on_delete}',
+            f'ON UPDATE {self.on_update}',
+        ])
diff --git a/mygrations/core/definitions/database.py b/mygrations/core/definitions/database.py
new file mode 100644
index 0000000..01178f2
--- /dev/null
+++ b/mygrations/core/definitions/database.py
@@ -0,0 +1,418 @@
+class Database:
+    _parsing_errors = None
+    _schema_errors = None
+    _parsing_warnings = None
+    _schema_warnings = None
+    _tables = None
+    _rows = None
+    _global_errors = None
+    _global_warnings = None
+
+    @property
+    def tables(self):
+        """ Public getter.  Returns a dict of table definitions, by table name
+
+        :returns: A dict of table definitions, by table name
+        :rtype: dict
+        """
+        return self._tables
+
+    @property
+    def rows(self):
+        """ Public getter.  Returns a dict of row definitions, by as lists organized by table name
+
+        :returns: A dict of row definitions, by table name
+        :rtype: dict[list[Row]]
+        """
+        return self._rows
+
+    @property
+    def errors(self):
+        """ Public getter.  Returns a list of errors
+
+        :returns: A list of errors
+        :rtype: list
+        """
+        global_errors = self._global_errors if self._global_errors is not None else []
+        schema_errors = self.find_schema_errors()
+        parsing_errors = self.find_parsing_errors()
+        return [*global_errors, *parsing_errors, *schema_errors]
+
+    @property
+    def warnings(self):
+        """ Public getter.  Returns a list of warnings
+
+        :returns: A list of warnings
+        :rtype: list[string]
+        """
+        global_warnings = self._global_warnings if self._global_warnings is not None else []
+        schema_warnings = self.find_schema_warnings()
+        parsing_warnings = self.find_parsing_warnings()
+        return [*global_warnings, *parsing_warnings, *schema_warnings]
+
+    def find_schema_warnings(self):
+        """ Returns a list of human-readable warning messages (strings) that give any warnings about the schema itself.
+        :return: List of warning messages
+        :rtype: list
+        """
+        if self._schema_warnings is not None:
+            return self._schema_warnings
+
+        self._schema_warnings = [
+            *self.find_all_row_schema_warnings(),
+            *self.find_all_table_schema_warnings(),
+        ]
+
+        return self._schema_warnings
+
+    def find_schema_errors(self):
+        """ Returns a list of human-readable error messages (strings) that give any errors about the schema itself.
+        :return: List of error messages
+        :rtype: list
+        """
+        if self._schema_errors is not None:
+            return self._schema_errors
+
+        self._schema_errors = [
+            *self.find_all_database_schema_errors(),
+            *self.find_all_table_schema_errors(),
+            *self.find_all_row_schema_errors(),
+        ]
+
+        return self._schema_errors
+
+    def find_all_row_schema_errors(self):
+        """ Returns a list of any schema error messages for the rows in this database
+
+        :return: A list of schema error messages
+        :rtype: list[string]
+        """
+        errors = []
+        for (table_name, rows) in self.rows.items():
+            for row in rows:
+                if hasattr(row, 'schema_errors'):
+                    errors.extend(row.schema_errors)
+
+                # verify the columns which the inserts reference make sense
+                if table_name not in self.tables:
+                    errors.append(
+                        f"Insert error: found an insert for table '{table_name}' but this table does not exist"
+                    )
+
+                elif row.num_explicit_columns:
+                    table = self.tables[table_name]
+                    for column_name in row.columns:
+                        if column_name not in table.columns:
+                            errors.append(
+                                f"Insert error: insert command attempts to set column '{column_name}' for table '{table_name}' but the column does not exist in the table."
+                            )
+                else:
+                    for row_values in row.raw_rows:
+                        if len(row_values) != len(table.columns):
+                            errors.append(
+                                f"Insert error for table '{table_name}': insert command does not explicitly set column names and has a different number of values than the number of columns in the table"
+                            )
+
+        return errors
+
+    def find_all_row_schema_warnings(self):
+        """ Returns a list of any schema warning messages for the rows in this database
+
+        :return: A list of schema warning messages
+        :rtype: list[string]
+        """
+        warnings = []
+        for rows in self.rows.values():
+            for row in rows:
+                if hasattr(row, 'schema_warnings'):
+                    warnings.extend(row.schema_warnings)
+        return warnings
+
+    def find_all_table_schema_errors(self):
+        """ Returns a list of any schema error messages for the tables in this database
+
+        :return: A list of schema error messages
+        :rtype: list[string]
+        """
+        errors = []
+        for table in self.tables.values():
+            if hasattr(table, 'schema_errors'):
+                errors.extend(table.schema_errors)
+        return errors
+
+    def find_all_table_schema_warnings(self):
+        """ Returns a list of any schema warnings messages for the tables in this database
+
+        :return: A list of schema warnings messages
+        :rtype: list[string]
+        """
+        warnings = []
+        for table in self.tables.values():
+            if hasattr(table, 'schema_warnings'):
+                warnings.extend(table.schema_warnings)
+        return warnings
+
+    def find_all_database_schema_errors(self):
+        """ Returns a list of any constraint error messages for this database
+
+        :return: A list of constraint error messages
+        :rtype: [string]
+        """
+        errors = []
+        constraint_tables = {}
+        for table in self.tables.values():
+            if not table.constraints:
+                continue
+
+            for constraint in table.constraints.values():
+                error = self.find_constraint_errors(table, constraint)
+                if error:
+                    errors.append(error)
+
+                if constraint.name in constraint_tables:
+                    errors.append(
+                        f"Duplicate foreign key: foreign key named '{constraint.name}' exists in tables '{constraint_tables[constraint.name]}' and '{table.name}'"
+                    )
+                else:
+                    constraint_tables[constraint.name] = table.name
+
+        return errors
+
+    def find_parsing_errors(self):
+        """ Returns a list of human-readable error messages (strings) that give any errors from the parsing process
+        :return: List of error messages
+        :rtype: list
+        """
+        if self._parsing_errors is not None:
+            return self._parsing_errors
+
+        self._parsing_errors = [
+            *self.find_all_table_parsing_errors(),
+            *self.find_all_row_parsing_errors(),
+        ]
+
+        return self._parsing_errors
+
+    def find_parsing_warnings(self):
+        """ Returns a list of human-readable error messages (strings) that give any errors from the parsing process
+        :return: List of error messages
+        :rtype: list
+        """
+        if self._parsing_warnings is not None:
+            return self._parsing_warnings
+
+        self._parsing_warnings = [
+            *self.find_all_table_parsing_warnings(),
+            *self.find_all_row_parsing_warnings(),
+        ]
+
+        return self._parsing_warnings
+
+    def find_all_table_parsing_errors(self):
+        """ Returns a list of human-readable error messages (strings) from the table parsing process
+        :return: List of error messages
+        :rtype: list
+        """
+        errors = []
+        for table in self.tables.values():
+            if hasattr(table, 'parsing_errors'):
+                errors.extend(table.parsing_errors)
+        return errors
+
+    def find_all_row_parsing_errors(self):
+        """ Returns a list of human-readable error messages (strings) from the row parsing process
+        :return: List of error messages
+        :rtype: list
+        """
+        errors = []
+        for (table_name, rows) in self.rows.items():
+            for row in rows:
+                if hasattr(row, 'parsing_errors'):
+                    errors.extend(row.parsing_errors)
+        return errors
+
+    def find_all_table_parsing_warnings(self):
+        """ Returns a list of human-readable warning messages (strings) from the table parsing process
+        :return: List of warning messages
+        :rtype: list
+        """
+        warnings = []
+        for table in self.tables.values():
+            if hasattr(table, 'parsing_warnings'):
+                warnings.extend(table.parsing_warnings)
+        return warnings
+
+    def find_all_row_parsing_warnings(self):
+        """ Returns a list of human-readable warning messages (strings) from the row parsing process
+        :return: List of warning messages
+        :rtype: list
+        """
+        warnings = []
+        for (table_name, rows) in self.rows.items():
+            for row in rows:
+                if hasattr(table, 'parsing_warnings'):
+                    warnings.extend(row.parsing_warnings)
+        return warnings
+
+    def find_constraint_errors(self, table, constraint):
+        """ Returns None or a string with an error message for the given table and constraint
+
+        :param table: The table being checked
+        :param constraint: The constraint to check the table against
+        :type table: mygrations.core.definitions.table
+        :type constraint: mygrations.core.definitions.constraint
+        :rtype: string|None
+        """
+
+        if constraint.foreign_table_name not in self.tables:
+            return "Constraint error for foreign key `%s`: `%s`.`%s` references `%s`.`%s`, but table `%s` does not exist" % (
+                constraint.name, table.name, constraint.column_name, constraint.foreign_table_name,
+                constraint.foreign_column_name, constraint.foreign_table_name
+            )
+        foreign_table = self.tables[constraint.foreign_table_name]
+        if constraint.foreign_column_name not in foreign_table.columns:
+            return "Constraint error for foreign key `%s`: `%s`.`%s` references `%s`.`%s`, but column `%s`.`%s` does not exist" % (
+                constraint.name, table.name, constraint.column_name, constraint.foreign_table_name,
+                constraint.foreign_column_name, constraint.foreign_table_name, constraint.foreign_column_name
+            )
+
+        # the column exists but we may still have a constraint error.  That can happen in a few ways
+        if constraint.column_name not in table.columns:
+            return "Constraint error for foreign key `%s`: sets constraint on column `%s`.`%s`, but this column does not exist" % (
+                constraint.name, table.name, constraint.column_name
+            )
+        table_column = table.columns[constraint.column_name]
+        foreign_column = foreign_table.columns[constraint.foreign_column_name]
+
+        if not table.column_is_indexed(table.columns[table_column.name]):
+            return "Constraint error for foreign key `%s`: missing index. `%s`.`%s` does not have an index and therefore cannot be used in a foreign key constraint" % (
+                constraint.name, table.name, table_column.name
+            )
+
+        # we have a few attributes that must must match exactly and have easy-to-produce errors
+        for attr in ['column_type', 'length', 'character_set', 'collate']:
+            table_value = getattr(table_column, attr)
+            foreign_value = getattr(foreign_column, attr)
+            if table_value != foreign_value:
+                return "Constraint error for foreign key `%s`: %s mismatch. `%s`.`%s` is '%s' but `%s`.`%s` is '%s'" % (
+                    constraint.name, attr.replace('_', ' '), table.name, constraint.column_name, table_value,
+                    foreign_table.name, foreign_column.name, foreign_value
+                )
+
+        # unsigned are separate because they get a slightly different message
+        if table_column.unsigned and not foreign_column.unsigned:
+            return "Constraint error for foreign key `%s`: unsigned mistmatch. `%s`.`%s` is unsigned but `%s`.`%s` is not" % (
+                constraint.name, table.name, table_column.name, foreign_table.name, foreign_column.name
+            )
+
+        if not table_column.unsigned and foreign_column.unsigned:
+            return "Constraint error for foreign key `%s`: unsigned mistmatch. `%s`.`%s` is unsigned but `%s`.`%s` is not" % (
+                constraint.name, foreign_table.name, foreign_column.name, table.name, table_column.name
+            )
+
+        # if the constraint has a SET NULL but the column cannot be null, then 1215
+        if (constraint.on_delete == 'SET NULL' or constraint.on_update == 'SET NULL') and not table_column.null:
+            message_parts = []
+            if constraint.on_delete == 'SET NULL':
+                message_parts.append('ON DELETE')
+            if constraint.on_update == 'SET NULL':
+                message_parts.append('ON UPDATE')
+            return "Constraint error for foreign key `%s`: invalid SET NULL. `%s`.`%s` is not allowed to be null but the foreign key attempts to set the value to null %s" % (
+                constraint.name, table.name, table_column.name, ' and '.join(message_parts)
+            )
+
+        # if the column the constraint is on doesn't have an index, then 1215
+        if not foreign_table.column_is_indexed(foreign_column):
+            return "Constraint error for foreign key `%s`: missing index. `%s`.`%s` references `%s`.`%s` but `%s`.`%s` does not have an index and therefore cannot be used in a foreign key constraint" % (
+                constraint.name, table.name, table_column.name, foreign_table.name, foreign_column.name,
+                foreign_table.name, foreign_column.name
+            )
+
+        return None
+
+    def unfulfilled_fks(self, table):
+        """ Returns a dictionary with information about all constraints in the table which are not fulfilled by this database
+
+        If all foreign keys are fulfilled by the database structure then an empty dict is returned
+
+        The returned dictionary contains a key with the name of every foreign key that cannot
+        be fulfilled.  The value in the dictionary will be another dictionary containing
+        'error' (an error message stating exactly what the problem is) and 'foreign_key'
+        (the actual foreign key definition that cannot be fulfilled).
+
+        This is similar to find_all_database_schema_errors but is used in a specific way by the migration process,
+        so gets its own method (although both rely heavily on the `find_constraint_errors` method.
+
+        :param table: The table to check
+        :type table: mygrations.formats.mysql.definitions.table
+        :return: Dictionary with information on all foreign keys that cannot be fulfilled
+        :rtype: dict
+        """
+        if not table.constraints:
+            return {}
+
+        unfulfilled = {}
+        for (constraint_name, constraint) in table.constraints.items():
+            error = self.find_constraint_errors(table, constraint)
+            if error:
+                unfulfilled[constraint_name] = {"error": error, "foreign_key": constraint}
+
+        return unfulfilled
+
+    def add_table(self, table):
+        """ Adds a table to the database
+
+        :param table: The table to add
+        :type table: mygrations.formats.mysql.definitions.table
+        """
+        if table.name in self._tables:
+            raise ValueError('Cannot add table %s to database because it already exists' % table.name)
+
+        self._schema_errors = None
+        self._schema_warnings = None
+        self._tables[table.name] = table
+
+    def remove_table(self, table):
+        """ Removes a table from the database
+
+        :param table: The table to remove
+        :type table: mygrations.formats.mysql.definitions.table
+        """
+        if not table.name in self._tables:
+            raise ValueError('Cannot remove table %s from database because it does not exist' % table.name)
+
+        self._schema_errors = None
+        self._schema_warnings = None
+        self._tables.pop(table.name, None)
+
+    def apply_operation(self, table_name, operation):
+        """ Updates the database object according to the operation
+
+        :param table_name: The table that the operation is being applied to
+        :param operation: The operation to apply
+        :type table: string|mygrations.formats.mysql.definitions.table
+        :type operation: mygrations.formats.mysql.mygration.operations.*
+        """
+        if type(table_name) != str:
+            table_name = table_name.name
+
+        if not table_name in self._tables:
+            raise ValueError('Cannot apply operation to table %s because that table does not exist' % table_name)
+
+        # the table applies the operation
+        self._schema_errors = None
+        self._schema_warnings = None
+        self._tables[table_name].apply_operation(operation)
+
+    def apply_to_source(self, operation):
+        """ Updates the actual source of the database object: usually the database or a .sql file
+
+        This method and :meth:`mygrations.formats.mysql.defintions.database.apply_operation` sound very similar.
+        The difference is that the latter operates on the in-memory structure store only, and makes
+        no actual changes.  Instead it is primarily used for internal purposes.  This method actually
+        makes changes.
+
+        :param operation: The operation to apply
+        :type operation: mygrations.formats.mysql.mygration.operations.*
+        """
+        raise NotImplementedError("Someone forgot to finish something")
diff --git a/mygrations/core/definitions/index.py b/mygrations/core/definitions/index.py
new file mode 100644
index 0000000..04c63a0
--- /dev/null
+++ b/mygrations/core/definitions/index.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+from abc import ABC, abstractmethod
+from typing import Union, List
+from .columns.column import Column
+class Index:
+    _name: str = ''
+    _index_type: str = ''
+    _columns: List[str] = None
+    _schema_errors: List[str] = None
+    _schema_warnings: List[str] = None
+    _parsing_errors: List[str] = None
+    _parsing_warnings: List[str] = None
+
+    def __init__(self, name: str = '', columns: List[str] = None, index_type: str = None):
+        """ Index constructor
+
+        :param name: The name of the index
+        :param columns: The name of the columns in the index
+        :param index_type: The type of the index (INDEX, UNIQUE, or PRIMARY)
+        """
+        self._name = name
+        self._columns = [*columns] if columns else []
+        self._schema_errors = None
+        self._schema_warnings = None
+        self._parsing_errors = None
+        self._parsing_warnings = None
+        # this is often set at the class level by a parser, so we only want to set it if provided
+        if index_type is not None:
+            self._index_type = index_type
+
+    @property
+    def name(self) -> str:
+        """ Public getter.  Returns the name of the column.
+
+        :returns: The index name
+        """
+        # if we don't have a name then assume it's name matches the name of our first column
+        if self._name:
+            return self._name
+        if len(self._columns) > 0:
+            return self._columns[0] if type(self._columns[0]) == str else self._columns[0].name
+        return ''
+
+    @property
+    def index_type(self) -> str:
+        """ Public getter.  Returns a string denoting the type of the index.  Always returns in uppercase
+
+        Index type can be 'INDEX', 'UNIQUE', or 'PRIMARY'
+
+        :returns: The index type
+        """
+        return self._index_type.upper()
+
+    def is_primary(self) -> bool:
+        """ Returns True/False to denote if the index is a primary index """
+        return self.index_type == 'PRIMARY'
+
+    @property
+    def columns(self) -> List[Column]:
+        """ Returns a list of the columns on the index. """
+        return self._columns
+
+    @property
+    def schema_errors(self) -> List[str]:
+        """ Returns a list of schema errors """
+        if self._schema_errors is None:
+            self._check_for_schema_errors_and_warnings()
+        return self._schema_errors
+
+    @property
+    def schema_warnings(self) -> List[str]:
+        """ Returns a list of schema warnings """
+        if self._schema_errors is None:
+            self._check_for_schema_errors_and_warnings()
+        return self._schema_warnings
+
+    @property
+    def parsing_errors(self) -> List[str]:
+        """ Returns a list of parsing errors """
+        return self._parsing_errors if self._parsing_errors is not None else []
+
+    @property
+    def parsing_warnings(self) -> List[str]:
+        """ Returns a list of parsing/table warnings """
+        return self._parsing_warnings if self._parsing_warnings is not None else []
+
+    def _check_for_schema_errors_and_warnings(self):
+        self._schema_errors = []
+        self._schema_warnings = []
+        for required in ['name', 'index_type', 'columns']:
+            if not getattr(self, required):
+                self._schema_errors.append(f"Missing {required} for index {self.name}")
+        if len(self.name) > 64:
+            self._schema_errors.append('Key name %s must be <=64 characters long' % (self._name))
+
+    def __str__(self):
+        """ Returns the MySQL command that would create the index
+
+        i.e. PRIMARY KEY `id` (`id`)'
+
+        :returns: A partial MySQL command that could be used to generate the column
+        :rtype: string
+        """
+        parts = []
+        if self.index_type == 'PRIMARY':
+            parts.append('PRIMARY')
+        elif self.index_type == 'UNIQUE':
+            parts.append('UNIQUE')
+        parts.append('KEY')
+
+        if self._name:
+            parts.append('`%s`' % self.name)
+        parts.append('(`%s`)' % ("`,`".join(self.columns)))
+
+        return ' '.join(parts)
diff --git a/mygrations/core/definitions/option.py b/mygrations/core/definitions/option.py
new file mode 100644
index 0000000..552c655
--- /dev/null
+++ b/mygrations/core/definitions/option.py
@@ -0,0 +1,65 @@
+from typing import Union, List
+class Option:
+    _name: str = ''
+    _value: str = ''
+    _schema_errors: List[str] = None
+    _schema_warnings: List[str] = None
+    _parsing_errors: List[str] = None
+    _parsing_warnings: List[str] = None
+
+    def __init__(self, name: str = '', value: str = ''):
+        self._name = name
+        self._value = value
+        self._schema_errors = None
+        self._schema_warnings = None
+        self._parsing_errors = None
+        self._parsing_warnings = None
+
+    @property
+    def name(self) -> str:
+        """ Public getter.  Returns the name of the option.
+
+        :returns: The option name
+        """
+        return self._name
+
+    @property
+    def value(self) -> str:
+        """ Public getter.  Returns the value of the option.
+
+        :returns: The option value
+        """
+        return self._value
+
+    @property
+    def schema_errors(self) -> List[str]:
+        """ Returns a list of schema errors """
+        if self._schema_errors is None:
+            self._check_for_schema_errors_and_warnings()
+        return self._schema_errors
+
+    @property
+    def schema_warnings(self) -> List[str]:
+        """ Returns a list of schema warnings """
+        if self._schema_errors is None:
+            self._check_for_schema_errors_and_warnings()
+        return self._schema_warnings
+
+    @property
+    def parsing_errors(self) -> List[str]:
+        """ Returns a list of parsing errors """
+        return self._parsing_errors if self._parsing_errors is not None else []
+
+    @property
+    def parsing_warnings(self) -> List[str]:
+        """ Returns a list of parsing/table warnings """
+        return self._parsing_warnings if self._parsing_warnings is not None else []
+
+    def _check_for_schema_errors_and_warnings(self):
+        self._schema_errors = []
+        self._schema_warnings = []
+        for required in ['name', 'value']:
+            if not getattr(self, required):
+                self._schema_errors.append(f"Missing {required} for option {self.name}")
+        if len(self.name) > 64:
+            self._schema_errors.append('Option name %s must be <=64 characters long' % (self._name))
diff --git a/mygrations/core/definitions/rows.py b/mygrations/core/definitions/rows.py
new file mode 100644
index 0000000..152aab3
--- /dev/null
+++ b/mygrations/core/definitions/rows.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+from typing import Union, List
+from .columns.column import Column
+class Rows:
+    _columns: List[Column] = None
+    _schema_errors: List[str] = None
+    _schema_warnings: List[str] = None
+    _parsing_errors: List[str] = None
+    _parsing_warnings: List[str] = None
+    _num_explicit_columns: int = 0
+    _raw_rows: List[List[str]] = ''
+    _table: str = ''
+    _warnings: List[str] = None
+
+    def __init__(
+        self, table: str = '', raw_rows: List[List[str]] = [], columns: List[Column] = None, num_explicit_columns=0
+    ):
+        self._columns = columns if columns is not None else []
+        self._schema_errors = []
+        self._schema_warnings = []
+        self._parsing_errors = []
+        self._parsing_warnings = []
+        self._num_explicit_columns = num_explicit_columns
+        self._raw_rows = raw_rows
+        self._table = table
+        self._warnings = []
+
+    @property
+    def table(self) -> str:
+        """ Returns the name of the table that records are being inserted for
+
+        :returns: The name of the table
+        """
+        return self._table
+
+    @property
+    def raw_rows(self) -> List[List[str]]:
+        """ Returns a list of insert values from the VALUES part of the query
+
+        :returns: A list of values for each row
+        """
+        return self._raw_rows
+
+    @property
+    def columns(self) -> List[Column]:
+        """ Returns the list of columns for the rows
+
+        :returns: A list of columns
+        """
+        return self._columns
+
+    @property
+    def num_explicit_columns(self) -> int:
+        """ Returns the number of columns specified in the insert query
+
+        Can be zero if none are specified, which happens for queries like:
+        INSERT INTO table (val1, val2, val3...);
+
+        :returns: The number of columns which have been explicitly defined
+        """
+        return self._num_explicit_columns
+
+    @property
+    def schema_errors(self) -> List[str]:
+        """ Returns a list of schema errors """
+        return [] if self._schema_errors is None else self._schema_errors
+
+    @property
+    def schema_warnings(self) -> List[str]:
+        """ Returns a list of schema warnings """
+        return [] if self._schema_warnings is None else self._schema_warnings
+
+    @property
+    def parsing_errors(self) -> List[str]:
+        """ Returns a list of parsing errors """
+        return [] if self._parsing_errors is None else self._parsing_errors
+
+    @property
+    def parsing_warnings(self) -> List[str]:
+        """ Returns a list of parsing warnings """
+        return [] if self._parsing_warnings is None else self._parsing_warnings
diff --git a/mygrations/core/definitions/table.py b/mygrations/core/definitions/table.py
new file mode 100644
index 0000000..5908cab
--- /dev/null
+++ b/mygrations/core/definitions/table.py
@@ -0,0 +1,650 @@
+from __future__ import annotations
+from typing import Any, Dict, List, Set, Union
+from collections import OrderedDict
+from .rows import Rows
+from .option import Option
+from .columns.column import Column
+from .index import Index
+from .constraint import Constraint
+from ..operations.create_table import CreateTable
+class Table:
+    _columns: Dict[str, Column] = None
+    _constraints: Dict[str, Constraint] = None
+    _indexes: Dict[str, Constraint] = None
+    _indexed_columns = None
+    _name: str = ''
+    _options: List[Option] = None
+    _primary: Index = None
+    _rows: Dict[int, List[Union[str, int]]] = None
+    _tracking_rows: bool = False
+    _schema_errors: List[str] = None
+    _schema_warnings: List[str] = None
+    _global_errors: List[str] = None
+    _global_warnings: List[str] = None
+
+    def __init__(
+        self,
+        name: str = '',
+        columns: List[Column] = None,
+        indexes: List[Index] = None,
+        constraints: List[Constraint] = None,
+        options: List[Option] = None
+    ):
+        self._name = name
+        self._options = [*options] if options else []
+        self._primary = None
+        self._constraints = {}
+        self._columns = {}
+        self._indexes = {}
+        self._parsing_errors = None
+        self._parsing_warnings = None
+        self._schema_errors = None
+        self._schema_warnings = None
+        self._global_errors = []
+        self._global_warnings = []
+        self._final_global_errors = None
+        self._final_global_warnings = None
+        self._raw_columns = columns if columns is not None else []
+        self._raw_constraints = constraints if constraints is not None else []
+        self._raw_indexes = indexes if indexes is not None else []
+
+        if columns is not None:
+            for column in columns:
+                self.add_column(column)
+        if constraints is not None:
+            for constraint in constraints:
+                self.add_constraint(constraint)
+        if indexes is not None:
+            for index in indexes:
+                self.add_index(index)
+
+    # Errors are no longer tracked live. Instead, I need to finish adjusting the flow so that if any
+    # of the error fetchers are called, then the errors are collected from all children and then the
+    # proper errors are returned.
+
+    @property
+    def global_errors(self):
+        """ Public getter.  Returns a list of schema errors
+
+        :returns: A list of schema errors
+        :rtype: list
+        """
+        if self._schema_errors is None:
+            self._find_all_errors()
+        return self._final_global_errors
+
+    @property
+    def global_warnings(self):
+        """ Public getter.  Returns a list of schema warnings
+
+        :returns: A list of schema warnings
+        :rtype: list
+        """
+        if self._schema_errors is None:
+            self._find_all_errors()
+        return self._final_global_warnings
+
+    @property
+    def schema_errors(self):
+        """ Public getter.  Returns a list of schema errors
+
+        :returns: A list of schema errors
+        :rtype: list
+        """
+        if self._schema_errors is None:
+            self._find_all_errors()
+        return self._schema_errors
+
+    @property
+    def schema_warnings(self):
+        """ Public getter.  Returns a list of schema warnings
+
+        :returns: A list of schema warnings
+        :rtype: list
+        """
+        if self._schema_errors is None:
+            self._find_all_errors()
+        return self._schema_warnings
+
+    @property
+    def parsing_errors(self):
+        """ Public getter.  Returns a list of schema errors
+
+        :returns: A list of schema errors
+        :rtype: list
+        """
+        if self._schema_errors is None:
+            self._find_all_errors()
+        return self._parsing_errors
+
+    @property
+    def parsing_warnings(self):
+        """ Public getter.  Returns a list of schema warnings
+
+        :returns: A list of schema warnings
+        :rtype: list
+        """
+        if self._schema_errors is None:
+            self._find_all_errors()
+        return self._parsing_warnings
+
+    @property
+    def name(self):
+        """ Public getter.  Returns the name of the table.
+
+        :returns: The table name
+        :rtype: string
+        """
+
+        return self._name
+
+    @property
+    def options(self):
+        """ Public getter.  Returns a list of table options
+
+        :returns: Table options
+        :rtype: list
+        """
+
+        return self._options
+
+    def _find_all_errors(self):
+        self._schema_errors = []
+        self._schema_warnings = []
+        self._parsing_errors = []
+        self._parsing_warnings = []
+        if self._global_errors is not None:
+            self._final_global_errors = [*self._global_errors]
+        if self._global_warnings is not None:
+            self._final_global_warnings = [*self._global_warnings]
+
+        if not self.name:
+            self._schema_errors.append("Table missing name")
+        if not len(self.columns):
+            self._schema_errors.append(f"Table '{self.name}' does not have any columns")
+
+        # start with errors from "children" and append the table name for context
+        for type_to_check in [self._columns, self._indexes, self._constraints]:
+            for to_check in type_to_check.values():
+                for error in to_check.schema_errors:
+                    self._schema_errors.append(f"{error} in table '{self.name}'")
+                for warning in to_check.schema_warnings:
+                    self._schema_warnings.append(f"{warning} in table '{self.name}'")
+                for error in to_check.parsing_errors:
+                    self._parsing_errors.append(f"{error} in table '{self.name}'")
+                for warning in to_check.parsing_warnings:
+                    self._parsing_warnings.append(f"{error} in table '{self.name}'")
+                if hasattr(to_check, 'global_errors'):
+                    for warning in to_check.global_errors:
+                        self._final_global_errors.append(f"{error} in table '{self.name}'")
+                if hasattr(to_check, 'global_warnings'):
+                    for warning in to_check.global_warnings:
+                        self._final_global_warnings.append(f"{error} in table '{self.name}'")
+
+        # duplicate names.  This shouldn't really be possible anymore because the add_ methods will
+        # throw an exception if we try to add a duplicate, but I'll leave this in just in case.
+        for (name, to_check) in {
+            'columns': self._columns,
+            'constraints': self._constraints,
+            'indexes': self._indexes
+        }.items():
+            if len(to_check) == len(getattr(self, name)):
+                continue
+            label = name.rstrip('es').rstrip('s')
+            found = {}
+            duplicates = {}
+            for item in to_check:
+                if item.name in found:
+                    duplicates[item.name] = True
+                    continue
+                found[item.name] = True
+            for key in duplicates.keys():
+                self._schema_errors.append(f"Duplicate {label} name found in table '{self.name}': '{key}'")
+
+        # more than one primary key
+        primaries = list(filter(lambda index: index.is_primary(), self._indexes.values()))
+        if len(primaries) > 1:
+            self._schema_errors.append(f"Table '{self.name}' has more than one PRIMARY index")
+
+        # auto increment checks
+        if self._columns:
+            auto_increment = list(filter(lambda column: column.auto_increment, self._columns.values()))
+            if len(auto_increment):
+                if len(auto_increment) > 1:
+                    self._schema_errors.append(f"Table '{self.name}' has more than one AUTO_INCREMENT column")
+                elif not primaries:
+                    self._schema_errors.append(
+                        f"Table '{self.name}' has an AUTO_INCREMENT column but is missing the PRIMARY index"
+                    )
+                elif primaries[0].columns[0] != auto_increment[0].name:
+                    self.schema_errors.append(
+                        "Mismatched indexes in table '%s': column '%s' is the AUTO_INCREMENT column but '%s' is the PRIMARY index column"
+                        % (self.name, auto_increment[0].name, primaries[0].columns[0])
+                    )
+
+        # indexes on non-existent columns
+        for index in self._indexes.values():
+            for column in index.columns:
+                if not column in self.columns:
+                    self._schema_errors.append(
+                        f"Table '{self.name}' has index '{index.name}' that references non-existent column '{column}'"
+                    )
+
+        # we don't bother checking the constraints to see if they are valid because these are
+        # best checked at the database level (since, by definition, foreign key constraints are *typically*
+        # against other tables, not within a single table.
+
+    def mark_tracking_rows(self):
+        """ Marks the table as having had its rows read, for bookeeping purposes
+
+        The reason this is here is because we keep track of whether or not we are "tracking" rows
+        for the current table.  This is for bookeeping purposes, largely as a safe-guard attempt
+        to try to identify any more subtle bugs that might creep in.  Normally self._tracking_rows
+        gets set to True when we add rows to a table, but if a table is empty then rows will never
+        get added, and instead this method must be called to mark the rows as "tracked".
+        """
+        self._tracking_rows = True
+        if self._rows is None:
+            self._rows = OrderedDict()
+
+    @property
+    def tracking_rows(self) -> bool:
+        """ Public getter.  Returns True/False to denote whether or not this table is tracking row records
+
+        To be clear on the distinction: just about any table might have rows.  However,
+        that doesn't mean that the mygration system should be syncing rows for that table.
+        self.tracking_rows == True denotes that the mygration system thinks that we
+        should probably be syncing rows for this table.
+
+        :returns: Whether or not the mygration system is tracking rows on the table
+        """
+        return self._tracking_rows
+
+    @property
+    def columns(self) -> Dict[str, Column]:
+        """ Public getter.  Returns an ordered dictionary of table columns """
+        return self._columns
+
+    @property
+    def indexes(self) -> Dict[str, Index]:
+        """ Public getter.  Returns an ordered dictionary of table indexes """
+        return self._indexes
+
+    @property
+    def constraints(self) -> Dict[str, Constraint]:
+        """ Public getter.  Returns an ordered dictionary of table constraints """
+        return self._constraints
+
+    @property
+    def primary(self) -> Index:
+        """ Public getter.  Returns the index object for the primary key """
+        return self._primary
+
+    @property
+    def rows(self) -> Dict[int, List[Union[str, int]]]:
+        """ Public getter.  Returns an ordered dictionary with row data by id """
+        return None if self._rows is None else self._rows
+
+    def add_rows(self, rows: Rows) -> Union[str, bool]:
+        """ Adds rows to the table
+
+        The rows object has some flexibility in terms of columns: it doesn't just
+        assume that a value is provided for every column in the table.  Rather,
+        there can be a list of columns and only those columns have values (which
+        supports the equivalent of MySQL INSERT queries which only specify values
+        for some columns).
+
+        Rows with errors will not be processed
+
+        :returns: An error string if an error is encountered, otherwise True/False
+        """
+        self._schema_errors = None
+        self._schema_warnings = None
+        if not isinstance(rows, Rows):
+            raise ValueError(
+                f"Only objects of class mygrations.core.definitions.rows can be added as rows to a table.  Instead I received an object of class '{rows.__class__.__name__}'"
+            )
+
+        if rows.parsing_errors:
+            self._global_errors.extend(rows.parsing_errors)
+        if rows.parsing_warnings:
+            self._global_warnings.extend(rows.parsing_warnings)
+
+        self._tracking_rows = True
+        if self._rows is None:
+            self._rows = OrderedDict()
+
+        # the rows object may have a list of columns.  If not use our own list of columns
+        # remember that self._columns is an OrderedDict so converting its keys to a list
+        # actually preserves the order (which is a requirement for us)
+        columns = rows.columns if rows.num_explicit_columns else list(self._columns.keys())
+        if 'id' not in columns:
+            self._global_errors.append(
+                "A column named 'id' is required to manage rows in the table, but the id column is missing in the rows for table %s"
+                % (self._name, )
+            )
+            return
+        id_index = columns.index('id')
+
+        for values in rows.raw_rows:
+            # rows without explicit columns must be checked for matching columns
+            if not rows.num_explicit_columns and len(values) != len(columns):
+                self._global_errors.append(
+                    'Insert values has wrong number of values for table %s and row %s' % (self._name, values)
+                )
+                continue
+
+            # we need to know the id of this record
+            row_id = str(values[id_index])
+            if not row_id:
+                self._global_errors.append(
+                    'Row is missing a value for the id column for table %s and row %s' % (self._name, values)
+                )
+                continue
+
+            if row_id in self._rows:
+                self._global_errors.append('Duplicate row id found for table %s and row %s' % (self.name, values))
+                continue
+
+            if not row_id:
+                self._global_errors.append('Invalid row id of %s found for table %s' % (row_id, self.name))
+                continue
+
+            self._rows[row_id] = OrderedDict(zip(columns, values))
+            self._rows[row_id]['id'] = row_id
+
+        return True
+
+    def add_raw_row(self, row: Dict[str, Union[str, int]]) -> Union[str, bool]:
+        """ Adds a row into the table as a dictionary instead of a row object
+
+        A bit of repetition here.  This is similar to what happens inside the main
+        loop of self.add_rows, but different enough that I'm not trying to combine
+        them right this second.
+
+        :returns: An error string if an error is encountered, otherwise True/False
+        """
+        self._schema_errors = None
+        self._schema_warnings = None
+        self._tracking_rows = True
+        if self._rows is None:
+            self._rows = OrderedDict()
+
+        row_id = str(row.get('id'))
+        if not row_id:
+            raise ValueError("Cannot manage records without an 'id' column and value")
+        if row_id in self._rows:
+            return 'Duplicate row id found for table %s and row %s' % (self._name, row)
+
+        # make sure we have a value for every column in the row, and build an OrderedDict
+        converted_row = OrderedDict()
+        for column in self._columns.keys():
+            if column in row:
+                converted_row[column] = row[column]
+            else:
+                converted_row[column] = self._columns[column].default
+        converted_row['id'] = row_id
+
+        self._rows[row_id] = converted_row
+
+    def column_before(self, column_name: str) -> Union[str, bool]:
+        """ Returns the name of the column that comes before a given row.
+
+        Returns true if the column is at the beginning of the table
+
+        :returns: The name of the column before the given column, or True if at the beginning
+        """
+        # this works because we used an OrderedDict
+        columns = [col for col in self.columns.keys()]
+
+        if not column_name in columns:
+            raise ValueError(
+                "Cannot return column before %s because %s does not exist in table %s" %
+                (column_name, column_name, self.name)
+            )
+
+        index = columns.index(column_name)
+        if index == 0:
+            return True
+
+        return columns[index - 1]
+
+    def column_is_indexed(self, column: Union[str, Column]) -> bool:
+        """ Returns True/False to denote whether or not the column has a useable index """
+        if type(column) != str:
+            column = column.name
+        if column not in self._columns:
+            return False
+
+        # best way to do this is with a set.  We'll keep a record of all indexed columns
+        # the column is indexed if an index has that column in the first position
+        if self._indexed_columns is None:
+            self._indexed_columns = set([index.columns[0] for index in self._indexes.values()])
+        return column in self._indexed_columns
+
+    def __str__(self) -> str:
+        return str(self.create())
+
+    def create(self, nice=False):
+        """ Returns a create table operation that can create this table
+
+        :param nice: Whether or not to return a nicely formatted CREATE TABLE command
+        :returns: A create table operation
+        """
+        return CreateTable(self, nice)
+
+    def nice(self) -> str:
+        return str(self.create(True))
+
+    def add_column(self, column: Column, position=False):
+        """ Adds a column to the table
+
+        The value of position matches self.position from mygrations.formats.mysql.mygration.operations.add_column
+
+        :param column: The column to add
+        :param position: Where to put the column
+        """
+        self._schema_errors = None
+        self._schema_warnings = None
+        if column.name in self._columns:
+            raise ValueError("Cannot add column %s because %s already exists" % (column.name, column.name))
+
+        # putting it at the end is easy
+        if not position:
+            self._columns[column.name] = column
+            return None
+
+        # beginning is also easy
+        if position == True:
+            self._columns[column.name] = column
+            self._columns.move_to_end(column.name, last=False)
+            return None
+
+        # now it is tricky
+        found = False
+        new_columns = OrderedDict()
+        for key, value in self._columns.items():
+            new_columns[key] = value
+            if key == position:
+                new_columns[column.name] = column
+                found = True
+
+        if not found:
+            raise ValueError(
+                "Cannot add column %s after %s because %s does not exist" % (column.name, position, position)
+            )
+
+        self._columns = new_columns
+        return None
+
+    def remove_column(self, column: Union[str, Column]):
+        """ Removes a column from the table """
+        self._schema_errors = None
+        self._schema_warnings = None
+        column_name = column if type(column) == str else column.name
+        if not column_name in self._columns:
+            raise ValueError("Cannot remove column %s because column %s does not exist" % (column_name, column_name))
+        self._columns.pop(column_name, None)
+
+    def change_column(self, new_column: Column):
+        """ Changes a column.  This does not currently support renaming columns """
+        self._schema_errors = None
+        self._schema_warnings = None
+        if not new_column.name in self._columns:
+            raise ValueError(
+                "Cannot modify column %s because column %s does not exist" % (new_column.name, new_column.name)
+            )
+        self._columns[new_column.name] = new_column
+
+    def add_index(self, index: Index):
+        """ Adds an index to the table """
+        self._schema_errors = None
+        self._schema_warnings = None
+        if index.name in self._indexes:
+            raise ValueError("Cannot add index %s because index %s already exists" % (index.name, index.name))
+        if index.index_type == 'PRIMARY':
+            self._primary = index
+        self._indexes[index.name] = index
+
+        if self._indexed_columns is not None:
+            self._indexed_columns.add(index.columns[0])
+
+    def remove_index(self, index: Union[str, Index]):
+        """ Removes an index from the table """
+        self._schema_errors = None
+        self._schema_warnings = None
+        index_name = index if type(index) == str else index.name
+        if index_name not in self._indexes:
+            raise ValueError("Cannot remove index %s because index %s does not exist" % (index_name, index_name))
+
+        indexed_column = self._indexes[index_name].columns[0]
+        self._indexes.pop(index_name, None)
+        if self._indexed_columns is not None:
+            self._indexed_columns.discard(indexed_column)
+
+    def change_index(self, new_index: Index):
+        """ Changes an index.  This does not currently support renaming """
+        self._schema_errors = None
+        self._schema_warnings = None
+        if not new_index.name in self._indexes:
+            raise ValueError(
+                "Cannot modify index %s because index %s does not exist" % (new_index.name, new_index.name)
+            )
+
+        if self._indexed_columns is not None:
+            self._indexed_columns.discard(self._indexes[new_index.name].columns[0])
+        self._indexes[new_index.name] = new_index
+
+        if self._indexed_columns is not None:
+            self._indexed_columns.add(new_index.columns[0])
+
+    def add_constraint(self, constraint: Constraint):
+        """ Adds a constraint to the table """
+        self._schema_errors = None
+        self._schema_warnings = None
+        if constraint.name in self._constraints:
+            raise ValueError(
+                "Cannot add constraint %s because constraint %s already exists" % (constraint.name, constraint.name)
+            )
+        self._constraints[constraint.name] = constraint
+
+    def remove_constraint(self, constraint: Union[str, Constraint]):
+        """ Removes an constraint from the table """
+        self._schema_errors = None
+        self._schema_warnings = None
+        if type(constraint) != str:
+            constraint = constraint.name
+        if constraint not in self._constraints:
+            raise ValueError(
+                "Cannot remove constraint %s because constraint %s does not exist" % (constraint, constraint)
+            )
+        self._constraints.pop(constraint, None)
+
+    def change_constraint(self, new_constraint):
+        """ Changes a constraint. This does not currently support renaming. """
+        self._schema_errors = None
+        self._schema_warnings = None
+        if not new_constraint.name in self._constraints:
+            raise ValueError(
+                "Cannot modify constraint %s because constraint %s does not exist" %
+                (new_constraint.name, new_constraint.name)
+            )
+        self._constraints[new_constraint.name] = new_constraint
+
+    def _loose_equal(self, val1: Union[str, int], val2: Union[str, int]) -> bool:
+        """ Performs a looser comparison, as values might have different types depending on whether they came out of a database or file
+
+        Returns true if the two values are equal, even if one is a string and the other an int.
+        """
+        # if we don't have a type mistmatch then this is easy
+        if type(val1) == type(val2):
+            return val1 == val2
+
+        # otherwise see if we can cheat and just convert to strings
+        return str(val1) == str(val2)
+
+    def to(self, comparison_table, split_operations=False):
+        """ Compares two tables to eachother and returns a list of operations which can bring the structure of the second in line with the first
+
+        In other words, this pseudo code will make table have the same structure as comparison_table
+
+        for operation in table.to( comparison_table ):
+            table.apply( operation )
+
+        if split_operations is True then a dict of migration operations will be returned to separate
+        foreign key operations from everything else.  The dict will have up to three keys: [ 'removed_fks', 'fks', 'kitchen_sink' ]
+
+        'fks' contains the alter statement operation needed to add/change/remove foreign keys
+        'kitchen_sink' contains everything else
+
+        If that division of labor seems arbitrary, it isn't: it is separated out that
+        way due to the needs of the overall algorithm.
+
+        If split_operations is false then a single alter table operation will be returned that encompasses all changes
+
+        :param comparison_table: A table to find differences with
+        :param split_operations: Whether to combine all operations in one alter table or separate them
+        :type comparison_table: mygrations.formats.mysql.definitions.table
+        :type split_operations: bool
+        :returns: A list of operations to apply to table
+        :rtype: list[mygrations.formats.mysql.mygrations.operations.*] | dict
+        """
+        raise NotImplementedError()
+
+    def to_rows(self, from_table=None):
+        """ Compares two tables to eachother and returns a list of operations which can bring the rows of this table in line with the other
+
+        It's important to note (and probably important to think through and change eventually)
+        that this method has the opposite meaning of `mygrations.formats.mysql.definitions.table.to()`
+        That method is called on the `from` table and operations on the (required) `to` table.
+        This method is called on the `to` table and can (optionally) be passed in a `from` table.
+
+        :param from_table: A table to find differences with (or None)
+        :type from_table: mygrations.formats.mysql.definitions.table
+        :returns: A list of operations to apply to table
+        :rtype: list[mygrations.formats.mysql.mygrations.operations.*]
+        """
+        raise NotImplementedError()
+
+    def _differences(self, from_list: Dict[str, Any], to_list: Dict[str, Any]):
+        """
+        Calculates the difference between two OrderedDicts.
+
+        https://codereview.stackexchange.com/a/176303/140581
+
+        :param from_list: OrderedDict
+        :param to_list: OrderedDict
+        :return: (added, removed, overlap)
+        """
+
+        return ([key for key in to_list if key not in from_list], [key for key in from_list if key not in to_list],
+                [key for key in from_list if key in to_list])
+
+    def apply_operation(self, operation):
+        """
+        Applies an operation to the table
+
+        :param operation: The operation to apply
+        :type operation: mygrations.formats.mysql.mygration.operations.*
+        """
+        operation.apply_to_table(self)
diff --git a/tests/core/__init__.py b/mygrations/core/operations/__init__.py
similarity index 100%
rename from tests/core/__init__.py
rename to mygrations/core/operations/__init__.py
diff --git a/mygrations/core/operations/add_column.py b/mygrations/core/operations/add_column.py
new file mode 100644
index 0000000..827e4c0
--- /dev/null
+++ b/mygrations/core/operations/add_column.py
@@ -0,0 +1,42 @@
+from typing import Union
+from ..definitions.columns.column import Column
+from ..definitions.table import Table
+class AddColumn:
+    _column: Column
+    _position: Union[str, bool] = None
+    """ Generates a partial SQL command to add a column to a table """
+    def __init__(self, column: Column, position: Union[str, bool] = None):
+        """ Create an add column operation
+
+        Position should be one of:
+
+        ==================  ====================
+        Value               Meaning
+        ==================  ====================
+        None                Put at the end of the table
+        False               Put at the end of the table
+        True                Put at the beginning of the table
+        Any string          position should be a column name: put the new column after that one
+        ==================  ====================
+
+        :param column: The column to add
+        :param position: Where to put the new column in the table
+        :type column: mygrations.formats.mysql.definitions.column
+        :type position: mixed:
+        """
+        self._column = column
+        self._position = position
+
+    def apply_to_table(self, table: Table):
+        """ Adds the column to the table """
+        table.add_column(self._column, self._position)
+
+    def __str__(self):
+        if self._position == True:
+            position = ' FIRST'
+        elif type(self._position) == str:
+            position = ' AFTER `%s`' % self._position
+        else:
+            position = ''
+
+        return 'ADD %s%s' % (str(self._column), position)
diff --git a/mygrations/core/operations/add_constraint.py b/mygrations/core/operations/add_constraint.py
new file mode 100644
index 0000000..552d1f5
--- /dev/null
+++ b/mygrations/core/operations/add_constraint.py
@@ -0,0 +1,15 @@
+from ..definitions.constraint import Constraint
+from ..definitions.table import Table
+class AddConstraint:
+    """ Generates a partial SQL command to add a FK to a table """
+    _constraint: Constraint = None
+
+    def __init__(self, constraint: Constraint):
+        self._constraint = constraint
+
+    def apply_to_table(self, table: Table):
+        """ Adds the constraint to the table """
+        table.add_constraint(self._constraint)
+
+    def __str__(self):
+        return 'ADD %s' % str(self._constraint)
diff --git a/mygrations/core/operations/add_index.py b/mygrations/core/operations/add_index.py
new file mode 100644
index 0000000..ae7f0a6
--- /dev/null
+++ b/mygrations/core/operations/add_index.py
@@ -0,0 +1,15 @@
+from ..definitions.index import Index
+from ..definitions.table import Table
+class AddIndex:
+    """ Generates a partial SQL command to add an index to a table """
+    _index: Index = None
+
+    def __init__(self, index: Index):
+        self._index = index
+
+    def apply_to_table(self, table: Table):
+        """ Adds the index to the table """
+        table.add_index(self._index)
+
+    def __str__(self):
+        return 'ADD %s' % str(self._index)
diff --git a/mygrations/core/operations/add_row.py b/mygrations/core/operations/add_row.py
new file mode 100644
index 0000000..87cbb68
--- /dev/null
+++ b/mygrations/core/operations/add_row.py
@@ -0,0 +1,23 @@
+from typing import Union, Dict
+from ..definitions.table import Table
+class AddRow:
+    """ Generates an SQL command to insert a record """
+    def __init__(self, table_name: Union[str, Table], data: Dict):
+        if type(table_name) != str:
+            self._table_name = table_name.name
+        else:
+            self._table_name = table_name
+        self._data = data
+
+    @property
+    def table_name(self) -> str:
+        """ Returns the name of the table. """
+        return self._table_name
+
+    def __str__(self):
+        cols = ', '.join(['`%s`' % val for val in self._data.keys()])
+        vals = ', '.join([
+            "'%s'" % str(val).replace('\\', '\\\\').replace("'", "\\'") if val is not None else 'NULL'
+            for val in self._data.values()
+        ])
+        return 'INSERT INTO `%s` (%s) VALUES (%s);' % (self._table_name, cols, vals)
diff --git a/tests/core/parse/__init__.py b/mygrations/core/operations/add_table.py
similarity index 100%
rename from tests/core/parse/__init__.py
rename to mygrations/core/operations/add_table.py
diff --git a/mygrations/core/operations/alter_table.py b/mygrations/core/operations/alter_table.py
new file mode 100644
index 0000000..03a29d8
--- /dev/null
+++ b/mygrations/core/operations/alter_table.py
@@ -0,0 +1,25 @@
+class AlterTable:
+    """ Generates an SQL command to alter a table """
+    def __init__(self, table_name: str):
+        self._table_name = table_name
+        self._operations = []
+
+    def add_operation(self, operation):
+        self._operations.append(operation)
+
+    @property
+    def table_name(self) -> str:
+        """ Returns the name of the table. """
+        return self._table_name
+
+    def __len__(self):
+        return len(self._operations)
+
+    def __bool__(self):
+        return True if len(self._operations) else False
+
+    def __str__(self):
+        return 'ALTER TABLE `%s` %s;' % (self.table_name, ', '.join([str(x) for x in self._operations]))
+
+    def __iter__(self):
+        return self._operations.__iter__()
diff --git a/mygrations/core/operations/change_column.py b/mygrations/core/operations/change_column.py
new file mode 100644
index 0000000..e50deb7
--- /dev/null
+++ b/mygrations/core/operations/change_column.py
@@ -0,0 +1,13 @@
+from ..definitions.columns.column import Column
+from ..definitions.table import Table
+class ChangeColumn:
+    """ Generates a partial SQL command to change a column in a table """
+    def __init__(self, column: Column):
+        self.column = column
+
+    def apply_to_table(self, table: Table):
+        """ Changes the column in the table """
+        table.change_column(self.column)
+
+    def __str__(self):
+        return 'CHANGE `%s` %s' % (self.column.name, str(self.column))
diff --git a/mygrations/core/operations/change_constraint.py b/mygrations/core/operations/change_constraint.py
new file mode 100644
index 0000000..f6eb646
--- /dev/null
+++ b/mygrations/core/operations/change_constraint.py
@@ -0,0 +1,13 @@
+from ..definitions.constraint import Constraint
+from ..definitions.table import Table
+class ChangeConstraint:
+    """ Generates a partial SQL command to change a constraint in a table """
+    def __init__(self, constraint: Constraint):
+        self.constraint = constraint
+
+    def apply_to_table(self, table: Table):
+        """ Changes the constraint in the table """
+        table.change_constraint(self.constraint)
+
+    def __str__(self):
+        return 'DROP FOREIGN KEY `%s`, ADD %s' % (self.constraint.name, str(self.constraint))
diff --git a/mygrations/core/operations/change_index.py b/mygrations/core/operations/change_index.py
new file mode 100644
index 0000000..df6b709
--- /dev/null
+++ b/mygrations/core/operations/change_index.py
@@ -0,0 +1,13 @@
+from ..definitions.index import Index
+from ..definitions.table import Table
+class ChangeIndex:
+    """ Generates a partial SQL command to change an index in a table """
+    def __init__(self, index: Index):
+        self.index = index
+
+    def apply_to_table(self, table: Table):
+        """ Changes the index in the table """
+        table.change_index(self.index)
+
+    def __str__(self):
+        return 'DROP KEY `%s`, ADD %s' % (self.index.name, str(self.index))
diff --git a/mygrations/core/operations/change_row.py b/mygrations/core/operations/change_row.py
new file mode 100644
index 0000000..7146653
--- /dev/null
+++ b/mygrations/core/operations/change_row.py
@@ -0,0 +1,24 @@
+from typing import Union, Dict
+from ..definitions.table import Table
+class ChangeRow:
+    """ Generates an SQL command to update a record """
+    def __init__(self, table_name: Union[str, Table], data: Dict):
+        if 'id' not in data:
+            raise KeyError('Missing `id` column needed for update')
+        if type(table_name) != str:
+            self._table_name = table_name.name
+        else:
+            self._table_name = table_name
+        self._data = data
+
+    @property
+    def table_name(self) -> str:
+        """ Public getter.  Returns the name of the table. """
+        return self._table_name
+
+    def __str__(self):
+        updates = ', '.join([
+            "`%s`='%s'" % (key, str(val).replace('\\', '\\\\')) if val is not None else ('`%s`=NULL' % key)
+            for (key, val) in self._data.items() if key != 'id'
+        ])
+        return "UPDATE `%s` SET %s WHERE id='%s';" % (self._table_name, updates, self._data['id'])
diff --git a/mygrations/core/operations/create_table.py b/mygrations/core/operations/create_table.py
new file mode 100644
index 0000000..4bb9cf6
--- /dev/null
+++ b/mygrations/core/operations/create_table.py
@@ -0,0 +1,31 @@
+class CreateTable:
+    """ Generates an SQL command to create a table """
+    _table = None
+    _nice: bool
+
+    def __init__(self, table, nice=False):
+        self._table = table
+        self._nice = nice
+
+    @property
+    def table_name(self) -> str:
+        """ Returns the name of the table. """
+        return self._table.name
+
+    def __str__(self):
+        newline = '\n' if self._nice else ''
+        padding = '  ' if self._nice else ''
+        body_separator = ',\n' if self._nice else ','
+        body = ['%s%s' % (padding, str(self._table.columns[col])) for col in self._table.columns]
+        body.extend(['%s%s' % (padding, str(self._table.indexes[index])) for index in self._table.indexes])
+        body.extend([
+            '%s%s' % (padding, str(self._table.constraints[constraint])) for constraint in self._table.constraints
+        ])
+        options = ['%s=%s' % (opt.name, opt.value) for opt in self._table.options]
+        if options:
+            options = ' %s' % ' '.join(options)
+        else:
+            options = ''
+        return 'CREATE TABLE `%s` (%s%s%s)%s;' % (
+            self._table.name, newline, body_separator.join(body), newline, options
+        )
diff --git a/mygrations/core/operations/disable_checks.py b/mygrations/core/operations/disable_checks.py
new file mode 100644
index 0000000..d9cb1f0
--- /dev/null
+++ b/mygrations/core/operations/disable_checks.py
@@ -0,0 +1,4 @@
+class DisableChecks:
+    """ Generates an SQL command to disable foreign key checks """
+    def __str__(self):
+        return 'SET FOREIGN_KEY_CHECKS=0;'
diff --git a/mygrations/core/operations/enable_checks.py b/mygrations/core/operations/enable_checks.py
new file mode 100644
index 0000000..0be09c5
--- /dev/null
+++ b/mygrations/core/operations/enable_checks.py
@@ -0,0 +1,4 @@
+class EnableChecks:
+    """ Generates an SQL command to re-enable foreign key checks """
+    def __str__(self):
+        return 'SET FOREIGN_KEY_CHECKS=1;'
diff --git a/mygrations/core/operations/remove_column.py b/mygrations/core/operations/remove_column.py
new file mode 100644
index 0000000..03172d2
--- /dev/null
+++ b/mygrations/core/operations/remove_column.py
@@ -0,0 +1,13 @@
+from ..definitions.columns.column import Column
+from ..definitions.table import Table
+class RemoveColumn:
+    """ Generates a partial SQL command to remove a column from a table """
+    def __init__(self, column: Column):
+        self._column = column
+
+    def apply_to_table(self, table: Table):
+        """ Removes the column from the table """
+        table.remove_column(self.column)
+
+    def __str__(self):
+        return 'DROP `%s`' % self._column.name
diff --git a/mygrations/core/operations/remove_constraint.py b/mygrations/core/operations/remove_constraint.py
new file mode 100644
index 0000000..11779b9
--- /dev/null
+++ b/mygrations/core/operations/remove_constraint.py
@@ -0,0 +1,13 @@
+from ..definitions.constraint import Constraint
+from ..definitions.table import Table
+class RemoveConstraint:
+    """ Generates a partial SQL command to drop a foreign key from a table """
+    def __init__(self, constraint: Constraint):
+        self._constraint = constraint
+
+    def apply_to_table(self, table: Table):
+        """ Removes the constraint from the table """
+        table.remove_constraint(self._constraint)
+
+    def __str__(self):
+        return 'DROP FOREIGN KEY `%s`' % (self._constraint.name)
diff --git a/mygrations/core/operations/remove_index.py b/mygrations/core/operations/remove_index.py
new file mode 100644
index 0000000..3a5d18a
--- /dev/null
+++ b/mygrations/core/operations/remove_index.py
@@ -0,0 +1,15 @@
+from ..definitions.index import Index
+from ..definitions.table import Table
+class RemoveIndex:
+    """ Generates a partial SQL command to drop an index from a table """
+    def __init__(self, index: Index):
+        self._index = index
+
+    def apply_to_table(self, table: Table):
+        """ Removes the index from the table """
+        table.remove_index(self._index)
+
+    def __str__(self):
+        if self._index.index_type == 'PRIMARY':
+            return 'DROP PRIMARY KEY'
+        return 'DROP KEY `%s`' % (self._index.name)
diff --git a/mygrations/core/operations/remove_row.py b/mygrations/core/operations/remove_row.py
new file mode 100644
index 0000000..138834e
--- /dev/null
+++ b/mygrations/core/operations/remove_row.py
@@ -0,0 +1,18 @@
+from typing import Union
+from ..definitions.table import Table
+class RemoveRow:
+    """ Generates an SQL command to delete a record """
+    def __init__(self, table_name: Union[str, Table], row_id: Union[int, str]):
+        if type(table_name) != str:
+            self._table_name = table_name.name
+        else:
+            self._table_name = table_name
+        self.row_id = row_id
+
+    @property
+    def table_name(self) -> str:
+        """ Returns the name of the table. """
+        return self._table_name
+
+    def __str__(self):
+        return "DELETE FROM `%s` WHERE id='%s';" % (self._table_name, self.row_id)
diff --git a/mygrations/core/operations/remove_table.py b/mygrations/core/operations/remove_table.py
new file mode 100644
index 0000000..31f781c
--- /dev/null
+++ b/mygrations/core/operations/remove_table.py
@@ -0,0 +1,15 @@
+class RemoveTable:
+    """ Generates an SQL command to drop a table """
+    def __init__(self, table_name):
+        if type(table_name) != str:
+            self._table_name = table_name.name
+        else:
+            self._table_name = table_name
+
+    @property
+    def table_name(self) -> str:
+        """ Returns the name of the table. """
+        return self._table_name
+
+    def __str__(self):
+        return 'DROP TABLE `%s`;' % self._table_name
diff --git a/mygrations/core/parse/__init__.py b/mygrations/core/parse/__init__.py
index 8b13789..e69de29 100644
--- a/mygrations/core/parse/__init__.py
+++ b/mygrations/core/parse/__init__.py
@@ -1,1 +0,0 @@
-
diff --git a/mygrations/core/parse/parser.py b/mygrations/core/parse/parser.py
index f280dce..7317d73 100644
--- a/mygrations/core/parse/parser.py
+++ b/mygrations/core/parse/parser.py
@@ -1,36 +1,31 @@
 import re
+from .rule_children import RuleChildren
+from .rule_delimited import RuleDelimited
+from .rule_literal import RuleLiteral
+from .rule_regexp import RuleRegexp
+class Parser:
+    rule_types = {'children': RuleChildren, 'delimited': RuleDelimited, 'literal': RuleLiteral, 'regexp': RuleRegexp}
+    _parsing_errors = None
+    _parsing_warnings = None
+    _values = None
+
+    @property
+    def parsing_errors(self):
+        """ Public getter.  Returns a list of parsing errors
+
+        :returns: A list of parsing errors
+        :rtype: list
+        """
+        return self._parsing_errors if self._parsing_errors is not None else []
 
-from .rule_children import rule_children
-from .rule_delimited import rule_delimited
-from .rule_literal import rule_literal
-from .rule_regexp import rule_regexp
-class parser(object):
-
-    rule_types = {
-        'children': rule_children,
-        'delimited': rule_delimited,
-        'literal': rule_literal,
-        'regexp': rule_regexp
-    }
-
-    def __init__(self, rules=[]):
-
-        self._values = {}
-
-        # rules should be defined by the subclass
-        if rules:
-            self.rules = rules
-
-        if not self.rules:
-            raise NotImplementedError("Cannot extend parser without providing rules in %s" % (self.__class__))
-
-        for (rule_index, rule) in enumerate(self.rules):
-
-            # we always need a type
-            if not 'type' in rule:
-                raise ValueError('Missing type for rule %s in %s' % (rule, self.__class__))
+    @property
+    def parsing_warnings(self):
+        """ Public getter.  Returns a list of parsing warnings
 
-        self.num_rules = len(self.rules)
+        :returns: A list of parsing warnings
+        :rtype: list
+        """
+        return self._parsing_warnings if self._parsing_warnings is not None else []
 
     def __getitem__(self, key):
 
@@ -71,11 +66,13 @@ def parse(self, string=''):
         # first thing first, some initial string cleaning.  Clean spaces
         # from start and end and replace any multi-spaces with a single space.
         string = re.sub('\s+', ' ', string).strip()
+        number_rules = len(self.rules)
+        self._values = {}
 
         for (rule_index, rule) in enumerate(self.rules):
 
             # do we have a next rule?
-            next_rule = self.rules[rule_index + 1] if rule_index < self.num_rules - 1 else False
+            next_rule = self.rules[rule_index + 1] if rule_index < number_rules - 1 else False
 
             # now we can parse
             rule_parser = self.get_rule_parser(rule, next_rule)
@@ -103,7 +100,7 @@ def parse(self, string=''):
         # rules left that haven't been matched, then we don't match.
 
         # did we check every required rule?
-        for check_index in range(rule_index + 1, self.num_rules):
+        for check_index in range(rule_index + 1, number_rules):
             rule = self.rules[check_index]
             if not 'optional' in rule or not rule['optional']:
                 self.matched = False
diff --git a/mygrations/core/parse/rule_base.py b/mygrations/core/parse/rule_base.py
index 8db9e3a..db931a4 100644
--- a/mygrations/core/parse/rule_base.py
+++ b/mygrations/core/parse/rule_base.py
@@ -1,4 +1,4 @@
-class rule_base(object):
+class RuleBase:
     """ rule = rule_base( parser, rule, next_rule )
 
     Base class for rule parsers: the smallest part of our rule based parsing.  Each rule parser
diff --git a/mygrations/core/parse/rule_children.py b/mygrations/core/parse/rule_children.py
index a95b116..2ebf7f9 100644
--- a/mygrations/core/parse/rule_children.py
+++ b/mygrations/core/parse/rule_children.py
@@ -1,5 +1,5 @@
-from .rule_base import rule_base
-class rule_children(rule_base):
+from .rule_base import RuleBase
+class RuleChildren(RuleBase):
     """ rule = rule_children( parser, rule, next_rule )
 
     rule_children constructor.  Pass in the parser object that the rule is for, the rule
diff --git a/mygrations/core/parse/rule_delimited.py b/mygrations/core/parse/rule_delimited.py
index d607f8e..71888d0 100644
--- a/mygrations/core/parse/rule_delimited.py
+++ b/mygrations/core/parse/rule_delimited.py
@@ -1,5 +1,5 @@
-from .rule_base import rule_base
-class rule_delimited(rule_base):
+from .rule_base import RuleBase
+class RuleDelimited(RuleBase):
     """ rule = rule_delimited( parser, rule, next_rule )
 
     rule_delimited constructor.  Pass in the parser object that the rule is for, the rule
diff --git a/mygrations/core/parse/rule_literal.py b/mygrations/core/parse/rule_literal.py
index 492848d..474b864 100644
--- a/mygrations/core/parse/rule_literal.py
+++ b/mygrations/core/parse/rule_literal.py
@@ -1,5 +1,5 @@
-from .rule_base import rule_base
-class rule_literal(rule_base):
+from .rule_base import RuleBase
+class RuleLiteral(RuleBase):
     """ rule = rule_children( parser, rule, next_rule )
 
     rule_literal constructor.  Pass in the parser object that the rule is for, the rule
diff --git a/mygrations/core/parse/rule_regexp.py b/mygrations/core/parse/rule_regexp.py
index 840fa35..76fdfd3 100644
--- a/mygrations/core/parse/rule_regexp.py
+++ b/mygrations/core/parse/rule_regexp.py
@@ -1,6 +1,6 @@
-from .rule_base import rule_base
+from .rule_base import RuleBase
 import re
-class rule_regexp(rule_base):
+class RuleRegexp(RuleBase):
     """ rule = rule_regexp( parser, rule, next_rule )
 
     rule_literal constructor.  Pass in the parser object that the rule is for, the rule
diff --git a/mygrations/drivers/__init__.py b/mygrations/drivers/__init__.py
index 8b13789..e69de29 100644
--- a/mygrations/drivers/__init__.py
+++ b/mygrations/drivers/__init__.py
@@ -1,1 +0,0 @@
-
diff --git a/mygrations/drivers/mysqldb/__init__.py b/mygrations/drivers/mysqldb/__init__.py
deleted file mode 100644
index 8b13789..0000000
--- a/mygrations/drivers/mysqldb/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-
diff --git a/tests/formats/__init__.py b/mygrations/drivers/pymysql/__init__.py
similarity index 100%
rename from tests/formats/__init__.py
rename to mygrations/drivers/pymysql/__init__.py
diff --git a/mygrations/drivers/mysqldb/mysqldb.py b/mygrations/drivers/pymysql/pymysql.py
similarity index 54%
rename from mygrations/drivers/mysqldb/mysqldb.py
rename to mygrations/drivers/pymysql/pymysql.py
index 0b4034c..1ff9b2f 100644
--- a/mygrations/drivers/mysqldb/mysqldb.py
+++ b/mygrations/drivers/pymysql/pymysql.py
@@ -1,24 +1,28 @@
-import MySQLdb
-import MySQLdb.cursors
-
+import pymysql as pymysql_driver    # alias to avoid confusion
 from collections import OrderedDict
-class mysqldb(object):
+class PyMySQL(object):
     """ High level driver for a MySQLdb connection """
     execute_cursor = None
 
-    def __init__(self, credentials):
-        """ Initialize the MySQLdb connection
-
-        Accepts either a dictionary with mysqldb connnection credentials
-        or any MySQL database connection which implements the Python DB API spec v2.0
+    def __init__(self, credentials=None, connection=None):
+        """ Initialize the PyMySQL connection
 
-        :param credentials: The database credentials to connect with or a database connection
-        :type credentials: dict|mygrations.helpers.db_credentials
+        Accepts either a dictionary with mysql connnection credentials or a PyMySQL connection object.
         """
-        if issubclass(type(credentials), dict):
-            self.conn = MySQLdb.connect(**credentials)
+        if credentials is not None:
+            self.connection = pymysql_driver.connect(
+                user=credentials['user'],
+                password=credentials['password'],
+                host=credentials['host'],
+                database=credentials['database'],
+                autocommit=False,
+                connect_timeout=2,
+                cursorclass=pymysql_driver.cursors.DictCursor
+            )
+        elif connection is not None:
+            self.connection = connection
         else:
-            self.conn = credentials
+            raise ValueError("Must provide either the database credentials or connection object")
 
     def tables(self):
         """ Returns the tables in the connected database
@@ -26,7 +30,7 @@ def tables(self):
         :returns: An ordered dict with table definitions by table name
         :rtype: OrderedDict
         """
-        cursor = self.conn.cursor()
+        cursor = self.connection.cursor()
 
         # not returning an iterator: just fetch everything.
         # I'm guessing this will be fine for any realistic database
@@ -34,8 +38,9 @@ def tables(self):
         # at the same time.
         cursor.execute('SHOW TABLES')
         table_names = []
-        for (table_name, ) in cursor:
-            table_names.append(table_name)
+        for result_data in cursor:
+            for table_name in result_data.values():
+                table_names.append(table_name)
 
         definitions = OrderedDict()
         for table_name in table_names:
@@ -43,8 +48,8 @@ def tables(self):
             if not cursor.rowcount:
                 raise ValueError("Failed to execute SHOW CREATE TABLE command on table %s" % table_name)
 
-            (tbl_name, create_table) = cursor.fetchone()
-            definitions[table_name] = create_table
+            result_data = cursor.fetchone()
+            definitions[table_name] = result_data['Create Table']
 
         cursor.close()
 
@@ -60,9 +65,9 @@ def rows(self, table_name):
         # still sticking to full caching client-side without
         # support for memory-preserving iterators.  Given our
         # use case, I think this will be fine.  Can always change later
-        cursor = self.conn.cursor(MySQLdb.cursors.DictCursor)
+        cursor = self.connection.cursor()
         cursor.execute('SELECT * FROM %s' % table_name)
-        rows = cursor.fetchall()
+        rows = [row for row in cursor]
         cursor.close()
 
         return rows
@@ -71,8 +76,8 @@ def execute(self, queries):
         if isinstance(queries, str):
             queries = [queries]
 
-        cursor = self.conn.cursor(MySQLdb.cursors.DictCursor)
+        cursor = self.connection.cursor()
         for query in queries:
             cursor.execute(query)
-        self.conn.commit()
+        self.connection.commit()
         cursor.close()
diff --git a/mygrations/formats/mysql/db_reader/database.py b/mygrations/formats/mysql/db_reader/database.py
index 29cef35..47a9a23 100644
--- a/mygrations/formats/mysql/db_reader/database.py
+++ b/mygrations/formats/mysql/db_reader/database.py
@@ -1,9 +1,8 @@
 import os
 import glob
-
-from ..file_reader.reader import reader as sql_reader
-from mygrations.formats.mysql.definitions.database import database as database_definition
-class database(database_definition):
+from ..file_reader.reader import Reader as Reader
+from mygrations.formats.mysql.definitions.database import Database as DatabaseDefinition
+class Database(DatabaseDefinition):
     def __init__(self, conn):
         """ Constructor.  Accepts a mygrations db wrapper
 
@@ -11,10 +10,10 @@ def __init__(self, conn):
         :type conn: mygrations.drivers.mysqldb.mysqldb
         """
         self.conn = conn
-        self._warnings = []
-        self._errors = []
         self._tables = {}
         self._rows = []
+        self._global_errors = []
+        self._global_warnings = []
 
         # _load_tables will get the party started
         self._load_tables(self.conn)
@@ -29,23 +28,15 @@ def _load_tables(self, conn):
         """
         for (table, create_table) in conn.tables().items():
             try:
-                reader = sql_reader()
+                reader = Reader()
                 reader.parse(create_table)
 
             except ValueError as e:
                 print("Error in table %s: %s" % (create_table, e))
 
-            # we shouldn't get any errors, of course, because
-            # this is coming out of MySQL.  However, it may still
-            # get some warnings (due to lint settings (maybe)).  Either way,
-            # keep around the errors just because, even if it is
-            # always empty.
-            self._errors.extend(reader.errors)
-            self._warnings.extend(reader.warnings)
-
             for (table_name, table) in reader.tables.items():
                 if table.name in self._tables:
-                    self.errors.append('Found two definitions for table %s' % table.name)
+                    self._global_errors.append('Found two definitions for table %s' % table.name)
 
                 # our reader will return table objects
                 # from the file_reader namespace.  These expect
diff --git a/mygrations/formats/mysql/definitions/__init__.py b/mygrations/formats/mysql/definitions/__init__.py
index 1e117bc..cba1bdc 100644
--- a/mygrations/formats/mysql/definitions/__init__.py
+++ b/mygrations/formats/mysql/definitions/__init__.py
@@ -1,6 +1,6 @@
-from .constraint import constraint
-from .index import index
-from .column import column
-from .table import table
+from .constraint import Constraint
+from .index import Index
+from .table import Table
+from . import columns
 
-__all__ = ['constraint', 'index', 'column', 'table']
+__all__ = ['Constraint', 'Index', 'Table', 'columns']
diff --git a/mygrations/formats/mysql/definitions/column.py b/mygrations/formats/mysql/definitions/column.py
deleted file mode 100644
index e90e789..0000000
--- a/mygrations/formats/mysql/definitions/column.py
+++ /dev/null
@@ -1,258 +0,0 @@
-class column(object):
-
-    definition_type = 'column'
-    _unsigned = None
-    _character_set = None
-    _collate = None
-    _auto_increment = None
-    _errors = None
-    _warnings = None
-
-    def __init__(
-        self,
-        name='',
-        length='255',
-        null=True,
-        column_type='VARCHAR',
-        default=None,
-        unsigned=None,
-        character_set='',
-        collate='',
-        auto_increment=False
-    ):
-        self._name = name
-        self._length = length
-        self._null = null
-        self._column_type = column_type
-        self._default = default
-        self._unsigned = unsigned
-        self._character_set = character_set
-        self._collate = collate
-        self._auto_increment = auto_increment
-
-    @property
-    def name(self):
-        """ Public getter.  Returns the name of the column.
-
-        :returns: The column name
-        :rtype: string
-        """
-
-        return self._name
-
-    @property
-    def length(self):
-        """ Public getter.  Returns the length of the column as a string.
-
-        Some examples of the length for various column definitions:
-
-        ==================  ====================
-        Type                Length
-        ==================  ====================
-        INT(10) UNSIGNED    10
-        VARCHAR(255)        255
-        decimal(20,5)       20,5
-        date
-        ==================  ====================
-
-        :returns: The column length
-        :rtype: string
-        """
-
-        return self._length
-
-    @property
-    def null(self):
-        """ Public getter.  Returns True/False to denote if the column is allowed to be null
-
-        :returns: Whether or not null is an allowed value for the column
-        :rtype: bool
-        """
-        return self._null
-
-    @property
-    def column_type(self):
-        """ Public getter.  Returns a string denoting the type of the column.  Always returns in uppercase
-
-        Some examples of the length for various column definitions:
-
-        ==================  ====================
-        Definition          Type
-        ==================  ====================
-        INT(10) UNSIGNED    INT
-        VARCHAR(255)        VARCHAR
-        decimal(20,5)       DECIMAL
-        date                DATE
-        ==================  ====================
-
-        :returns: The column type
-        :rtype: string
-        """
-        return self._column_type.upper()
-
-    @property
-    def default(self):
-        """ Public getter.  Returns the default value for the column as a string, or None for a default value of null
-
-        Returns None to represent a default value of null.
-
-        :returns: The default value
-        :rtype: string|None
-        """
-        return self._default
-
-    @property
-    def unsigned(self):
-        """ Public getter.  Returns True, False, or None to denote the status of the UNSIGNED property
-
-        ==================  ====================
-        Return Value        Meaning
-        ==================  ====================
-        True                The column is unsigned
-        False               The column is signed
-        None                UNSIGNED is not an applicable property for this column type
-        ==================  ====================
-
-        :returns: True, False, or None
-        :rtype: bool|None
-        """
-        return self._unsigned
-
-    @property
-    def character_set(self):
-        """ Public getter.  Returns None or a value to denote the CHARACTER_SET property
-
-        :returns: string, or None
-        :rtype: string|None
-        """
-        return None if self._character_set is None else self._character_set.upper()
-
-    @property
-    def collate(self):
-        """ Public getter.  Returns None or a value to denote the COLLATE property
-
-        :returns: string, or None
-        :rtype: string|None
-        """
-        return None if self._collate is None else self._collate.upper()
-
-    @property
-    def auto_increment(self):
-        """ Public getter.  Returns True, False, or None to denote the status of the AUTO_INCREMENT property
-
-        ==================  ====================
-        Return Value        Meaning
-        ==================  ====================
-        True                The column is an AUTO_INCREMENT column
-        False               The column is not an AUTO_INCREMENT column
-        None                AUTO_INCREMENT is not an applicable property for this column type
-        ==================  ====================
-
-        :returns: True, False, or None
-        :rtype: bool|None
-        """
-        return self._auto_increment
-
-    @property
-    def errors(self):
-        """ Public getter.  Returns a list of parsing errors
-
-        :returns: A list of parsing errors
-        :rtype: list
-        """
-        return [] if self._errors is None else self._errors
-
-    @property
-    def warnings(self):
-        """ Public getter.  Returns a list of parsing/table warnings
-
-        :returns: A list of parsing/table warnings
-        :rtype: list
-        """
-        return [] if self._warnings is None else self._warnings
-
-    def __str__(self):
-        """ Returns the MySQL command that would create the column
-
-        i.e. column_name type(len) default ''
-
-        :returns: A partial MySQL command that could be used to generate the column
-        :rtype: string
-        """
-        parts = []
-        parts.append('`%s`' % self.name)
-
-        type_string = self.column_type
-        if self.length:
-            if type(self.length) == type([]):
-                length = "'%s'" % ("','".join(self.length))
-            else:
-                length = self.length
-            type_string += '(%s)' % length
-        parts.append(type_string)
-
-        if self.unsigned:
-            parts.append('UNSIGNED')
-
-        if not self.null:
-            parts.append('NOT NULL')
-
-        if self.default is not None:
-            if self.default == '':
-                parts.append("DEFAULT ''")
-            elif self.default.isnumeric():
-                parts.append("DEFAULT %s" % self.default)
-            else:
-                parts.append("DEFAULT '%s'" % self.default)
-
-        if self.auto_increment:
-            parts.append('AUTO_INCREMENT')
-
-        if self.character_set:
-            parts.append("CHARACTER SET '%s'" % self.character_set)
-
-        if self.collate:
-            parts.append("COLLATE '%s'" % self.collate)
-
-        return ' '.join(parts)
-
-    def is_really_the_same_as(self, column):
-        """ Takes care of a pesky false-positive when checking columns
-
-        :param column: The column to comprehensively check for a difference with
-        :type column: mygrations.formats.mysql.definitions.column
-        :returns: True if the column really is the same, even for apparent differences
-        :rtype: bool
-        """
-        # if any of these attributes change then it really isn't the same
-        for attr in ['name', 'length', 'null', 'column_type', 'unsigned']:
-            if getattr(self, attr) != getattr(column, attr):
-                return False
-
-        # default needs a special check because it can run into issues for decimal columns
-        if self.column_type == 'DECIMAL':
-            split = self.length.split(',')
-            if len(split) == 2:
-                ndecimals = int(split[1])
-                if (self.default is None
-                    and column.default is not None) or (self.default is not None and column.default is None):
-                    return False
-                if round(float(self.default), ndecimals) != round(float(column.default), ndecimals):
-                    return False
-        else:
-            if self.default != column.default:
-                return False
-
-        # if collate or character_set are different and *both* have a value,
-        # then these aren't really the same
-        for attr in ['collate', 'character_set']:
-            my_val = getattr(self, attr)
-            that_val = getattr(column, attr)
-            if my_val and that_val and my_val != that_val:
-                return False
-
-        # if we got here then these columns are the same. Either all attributes have the same
-        # values, or collate and/or character_set differ, but they differ by one not having
-        # a value.  This is the false-positive we are trying to check for
-        # (see tests.formats.mysql.definitions.test_table_text_false_positives)
-        return True
diff --git a/mygrations/formats/mysql/definitions/columns/__init__.py b/mygrations/formats/mysql/definitions/columns/__init__.py
new file mode 100644
index 0000000..f645128
--- /dev/null
+++ b/mygrations/formats/mysql/definitions/columns/__init__.py
@@ -0,0 +1,6 @@
+from .date import Date
+from .enum import Enum
+from .numeric import Numeric
+from .string import String
+
+__all__ = ['Date', 'Enum', 'Numeric', 'String']
diff --git a/mygrations/formats/mysql/definitions/columns/date.py b/mygrations/formats/mysql/definitions/columns/date.py
new file mode 100644
index 0000000..819ae80
--- /dev/null
+++ b/mygrations/formats/mysql/definitions/columns/date.py
@@ -0,0 +1,36 @@
+from mygrations.core.definitions.columns.date import Date as DateBase
+from typing import List, Union
+class Date(DateBase):
+    def __init__(
+        self,
+        name: str = '',
+        column_type: str = '',
+        length: Union[str, int] = None,
+        null: bool = True,
+        has_default: bool = False,
+        default: Union[str, int] = None,
+        unsigned: bool = None,
+        character_set: str = None,
+        collate: str = None,
+        auto_increment: bool = False,
+        enum_values: List[str] = None,
+        parsing_errors: List[str] = None,
+        parsing_warnings: List[str] = None,
+    ):
+        # it would be nice to just do `def __init__(**kwargs)` and then `super().__init__(**kwargs)`
+        # but then we would lose our type hints.  :shrug:
+        super().__init__(
+            name=name,
+            column_type=column_type,
+            length=length,
+            null=null,
+            has_default=has_default,
+            default=default,
+            character_set=character_set,
+            collate=collate,
+            auto_increment=auto_increment,
+            enum_values=enum_values,
+            parsing_errors=parsing_errors,
+            parsing_warnings=parsing_warnings,
+            unsigned=unsigned,
+        )
diff --git a/mygrations/formats/mysql/definitions/columns/enum.py b/mygrations/formats/mysql/definitions/columns/enum.py
new file mode 100644
index 0000000..f6a60c7
--- /dev/null
+++ b/mygrations/formats/mysql/definitions/columns/enum.py
@@ -0,0 +1,36 @@
+from mygrations.core.definitions.columns.enum import Enum as EnumBase
+from typing import List, Union
+class Enum(EnumBase):
+    def __init__(
+        self,
+        name: str = '',
+        column_type: str = '',
+        length: Union[str, int] = None,
+        null: bool = True,
+        has_default: bool = False,
+        default: Union[str, int] = None,
+        unsigned: bool = None,
+        character_set: str = None,
+        collate: str = None,
+        auto_increment: bool = False,
+        enum_values: List[str] = None,
+        parsing_errors: List[str] = None,
+        parsing_warnings: List[str] = None,
+    ):
+        # it would be nice to just do `def __init__(**kwargs)` and then `super().__init__(**kwargs)`
+        # but then we would lose our type hints.  :shrug:
+        super().__init__(
+            name=name,
+            column_type=column_type,
+            length=length,
+            null=null,
+            has_default=has_default,
+            default=default,
+            character_set=character_set,
+            collate=collate,
+            auto_increment=auto_increment,
+            enum_values=enum_values,
+            parsing_errors=parsing_errors,
+            parsing_warnings=parsing_warnings,
+            unsigned=unsigned,
+        )
diff --git a/mygrations/formats/mysql/definitions/columns/numeric.py b/mygrations/formats/mysql/definitions/columns/numeric.py
new file mode 100644
index 0000000..a380cd0
--- /dev/null
+++ b/mygrations/formats/mysql/definitions/columns/numeric.py
@@ -0,0 +1,36 @@
+from mygrations.core.definitions.columns.numeric import Numeric as NumericBase
+from typing import List, Union
+class Numeric(NumericBase):
+    def __init__(
+        self,
+        name: str = '',
+        column_type: str = '',
+        length: Union[str, int] = None,
+        null: bool = True,
+        has_default: bool = False,
+        default: Union[str, int] = None,
+        unsigned: bool = None,
+        character_set: str = None,
+        collate: str = None,
+        auto_increment: bool = False,
+        enum_values: List[str] = None,
+        parsing_errors: List[str] = None,
+        parsing_warnings: List[str] = None,
+    ):
+        # it would be nice to just do `def __init__(**kwargs)` and then `super().__init__(**kwargs)`
+        # but then we would lose our type hints.  :shrug:
+        super().__init__(
+            name=name,
+            column_type=column_type,
+            length=length,
+            null=null,
+            has_default=has_default,
+            default=default,
+            character_set=character_set,
+            collate=collate,
+            auto_increment=auto_increment,
+            enum_values=enum_values,
+            parsing_errors=parsing_errors,
+            parsing_warnings=parsing_warnings,
+            unsigned=unsigned,
+        )
diff --git a/mygrations/formats/mysql/definitions/columns/string.py b/mygrations/formats/mysql/definitions/columns/string.py
new file mode 100644
index 0000000..f7654b5
--- /dev/null
+++ b/mygrations/formats/mysql/definitions/columns/string.py
@@ -0,0 +1,36 @@
+from mygrations.core.definitions.columns.string import String as StringBase
+from typing import List, Union
+class String(StringBase):
+    def __init__(
+        self,
+        name: str = '',
+        column_type: str = '',
+        length: Union[str, int] = None,
+        null: bool = True,
+        has_default: bool = False,
+        default: Union[str, int] = None,
+        unsigned: bool = None,
+        character_set: str = None,
+        collate: str = None,
+        auto_increment: bool = False,
+        enum_values: List[str] = None,
+        parsing_errors: List[str] = None,
+        parsing_warnings: List[str] = None,
+    ):
+        # it would be nice to just do `def __init__(**kwargs)` and then `super().__init__(**kwargs)`
+        # but then we would lose our type hints.  :shrug:
+        super().__init__(
+            name=name,
+            column_type=column_type,
+            length=length,
+            null=null,
+            has_default=has_default,
+            default=default,
+            character_set=character_set,
+            collate=collate,
+            auto_increment=auto_increment,
+            enum_values=enum_values,
+            parsing_errors=parsing_errors,
+            parsing_warnings=parsing_warnings,
+            unsigned=unsigned,
+        )
diff --git a/mygrations/formats/mysql/definitions/constraint.py b/mygrations/formats/mysql/definitions/constraint.py
index 96027d9..bfefebe 100644
--- a/mygrations/formats/mysql/definitions/constraint.py
+++ b/mygrations/formats/mysql/definitions/constraint.py
@@ -1,117 +1,13 @@
-class constraint(object):
-
-    _errors = None
-    _warnings = None
-    _name = ''
-    _column = ''
-    _foreign_table = ''
-    _foreign_column = ''
-    _on_delete = ''
-    _on_update = ''
-
-    def __init__(self, name='', column='', foreign_table='', foreign_column='', on_delete='', on_update=''):
-        self._name = name
-        self._column = column
-        self._foreign_table = foreign_table
-        self._foreign_column = foreign_column
-        self._on_delete = on_delete
-        self._on_update = on_update
-
-    @property
-    def name(self):
-        """ Public getter.  Returns the name of the column.
-
-        :returns: The index name
-        :rtype: string
-        """
-
-        return self._name
-
-    @property
-    def column(self):
-        """ Public getter.  Returns the name of the column this constraint is on
-
-        :returns: The column name
-        :rtype: string
-        """
-
-        return self._column
-
-    @property
-    def foreign_table(self):
-        """ Public getter.  Returns the name of the table this constraint is to
-
-        :returns: The table name
-        :rtype: string
-        """
-
-        return self._foreign_table
-
-    @property
-    def foreign_column(self):
-        """ Public getter.  Returns the name of the column in the foreign table this constraint is to
-
-        :returns: The column name
-        :rtype: string
-        """
-
-        return self._foreign_column
-
-    @property
-    def on_delete(self):
-        """ Public getter.  Returns the ON DELETE action for this constraint
-
-        :returns: The ON DELETE action
-        :rtype: string
-        """
-
-        return self._on_delete
-
-    @property
-    def on_update(self):
-        """ Public getter.  Returns the ON UPDATE action for this constraint
-
-        :returns: The ON UPDATE action
-        :rtype: string
-        """
-
-        return self._on_update
-
-    @property
-    def errors(self):
-        """ Public getter.  Returns a list of parsing errors
-
-        :returns: A list of parsing errors
-        :rtype: list
-        """
-        return [] if self._errors is None else self._errors
-
-    @property
-    def warnings(self):
-        """ Public getter.  Returns a list of parsing/table warnings
-
-        :returns: A list of parsing/table warnings
-        :rtype: list
-        """
-        return [] if self._warnings is None else self._warnings
-
-    def __str__(self):
-        """ Returns the MySQL command that would create the constraint
-
-        i.e. CONSTRAINT `vendors_w9_fk` FOREIGN KEY (`w9_id`) REFERENCES `vendor_w9s` (`id`) ON UPDATE CASCADE
-
-        :returns: A partial MySQL command that could be used to generate the foreign key
-        :rtype: string
-        """
-        parts = ['CONSTRAINT']
-
-        parts.append('`%s`' % self.name)
-        parts.append('FOREIGN KEY')
-        parts.append('(`%s`)' % (self.column))
-        parts.append('REFERENCES')
-        parts.append('`%s`' % self.foreign_table)
-        parts.append('(`%s`)' % (self.foreign_column))
-        parts.append('ON DELETE %s' % self.on_delete)
-        parts.append('ON UPDATE %s' % self.on_update)
-
-        return ' '.join(parts)
+from mygrations.core.definitions.constraint import Constraint as BaseConstraint
+class Constraint(BaseConstraint):
+    def __init__(
+        self, name='', column_name='', foreign_table_name='', foreign_column_name='', on_delete='', on_update=''
+    ):
+        super().__init__(
+            name=name,
+            column_name=column_name,
+            foreign_table_name=foreign_table_name,
+            foreign_column_name=foreign_column_name,
+            on_delete=on_delete,
+            on_update=on_update
+        )
diff --git a/mygrations/formats/mysql/definitions/database.py b/mygrations/formats/mysql/definitions/database.py
index e42bcf7..7ee8c06 100644
--- a/mygrations/formats/mysql/definitions/database.py
+++ b/mygrations/formats/mysql/definitions/database.py
@@ -1,239 +1,3 @@
-class database(object):
-
-    _errors = None
-    _warnings = None
-    _tables = None
-    _rows = None
-    _errors_1215 = None
-
-    def __init__(self):
-        self._warnings = []
-        self._errors = []
-        self._tables = {}
-        self._rows = []
-        self._errors_1215 = None
-
-    @property
-    def tables(self):
-        """ Public getter.  Returns a dict of table definitions, by table name
-
-        :returns: A dict of table definitions, by table name
-        :rtype: dict
-        """
-        return self._tables
-
-    @property
-    def errors(self):
-        """ Public getter.  Returns a list of parsing errors
-
-        :returns: A list of parsing errors
-        :rtype: list
-        """
-        return self._errors
-
-    @property
-    def warnings(self):
-        """ Public getter.  Returns a list of parsing/table warnings
-
-        :returns: A list of parsing/table warnings
-        :rtype: list
-        """
-        return self._warnings
-
-    @property
-    def errors_1215(self):
-        """ Public getter.  Returns a list of 1215 errors found for the database
-
-        :returns: A list of MySQL 1215 error messages
-        :rtype: [string]
-        """
-        if self._errors_1215 is None:
-            self._errors_1215 = self._find_all_1215_errors()
-        return self._errors_1215
-
-    def store_rows_with_tables(self):
-        """ Processes table rows and adds them to the appropriate tables
-
-        Table rows are stored with tables for comparison purposes, but might
-        come in through their own separate files.  This method puts the two
-        together.
-        """
-        for rows in self._rows:
-            if not rows.table in self._tables:
-                self._errors.append('Found rows for table %s but that table does not have a definition' % rows.table)
-                continue
-
-            returned = self._tables[rows.table].add_rows(rows)
-            if isinstance(returned, str):
-                self._errors.append(returned)
-
-    def unfulfilled_fks(self, table):
-        """ Returns a dictionary with information about all constraints in the table which are not fulfilled by this database
-
-        If all foreign keys are fulfilled by the database structure then an empty dict is returned
-
-        The returned dictionary contains a key with the name of every foreign key that cannot
-        be fulfilled.  The value in the dictionary will be another dictionary containing
-        'error' (an error message stating exactly what the problem is) and 'foreign_key'
-        (the actual foreign key definition that cannot be fulfilled)
-
-        :param table: The table to check
-        :type table: mygrations.formats.mysql.definitions.table
-        :return: Dictionary with information on all foreign keys that cannot be fulfilled
-        :rtype: dict
-        """
-        if not table.constraints:
-            return {}
-
-        unfulfilled = {}
-        for (constraint_name, constraint) in table.constraints.items():
-            error = self.find_1215_errors(table, constraint)
-            if error:
-                unfulfilled[constraint_name] = {"error": error, "foreign_key": constraint}
-
-        return unfulfilled
-
-    def _find_all_1215_errors(self):
-        """ Returns a list of any 1215 error messages for this database
-
-        :return: A list of 1215 error messages
-        :rtype: [string]
-        """
-        errors = []
-        for table in self.tables.values():
-            if not table.constraints:
-                continue
-
-            for constraint in table.constraints.values():
-                error = self.find_1215_errors(table, constraint)
-                if error:
-                    errors.append(error)
-
-        return errors
-
-    def find_1215_errors(self, table, constraint):
-        """ Returns None or a string describing a 1215 error message found for the given table and constraint
-
-        :param table: The table being checked
-        :param constraint: The constraint to check the table against
-        :type table: mygrations.formats.mysql.definitions.table
-        :type constraint: mygrations.formats.mysql.definitions.constraint
-        :rtype: string|None
-        """
-
-        if constraint.foreign_table not in self.tables:
-            return "MySQL 1215 error for foreign key `%s`: `%s`.`%s` references `%s`.`%s`, but table `%s` does not exist" % (
-                constraint.name, table.name, constraint.column, constraint.foreign_table, constraint.foreign_column,
-                constraint.foreign_table
-            )
-        foreign_table = self.tables[constraint.foreign_table]
-        if constraint.foreign_column not in foreign_table.columns:
-            return "MySQL 1215 error for foreign key `%s`: `%s`.`%s` references `%s`.`%s`, but column `%s`.`%s` does not exist" % (
-                constraint.name, table.name, constraint.column, constraint.foreign_table, constraint.foreign_column,
-                constraint.foreign_table, constraint.foreign_column
-            )
-
-        # the column exists but we may still have a 1215 error.  That can happen in a few ways
-        table_column = table.columns[constraint.column]
-        foreign_column = foreign_table.columns[constraint.foreign_column]
-
-        if not table.column_is_indexed(table.columns[table_column.name]):
-            return "MySQL 1215 error for foreign key `%s`: missing index. `%s`.`%s` does not have an index and therefore cannot be used in a foreign key constraint" % (
-                constraint.name, table.name, table_column.name
-            )
-
-        # we have a few attributes that must must match exactly and have easy-to-produce errors
-        for attr in ['column_type', 'length', 'character_set', 'collate']:
-            table_value = getattr(table_column, attr)
-            foreign_value = getattr(foreign_column, attr)
-            if table_value != foreign_value:
-                return "MySQL 1215 error for foreign key `%s`: %s mismatch. `%s`.`%s` is '%s' but `%s`.`%s` is '%s'" % (
-                    constraint.name, attr.replace('_', ' '), table.name, constraint.column, table_value,
-                    foreign_table.name, foreign_column.name, foreign_value
-                )
-
-        # unsigned are separate because they get a slightly different message
-        if table_column.unsigned and not foreign_column.unsigned:
-            return "MySQL 1215 error for foreign key `%s`: unsigned mistmatch. `%s`.`%s` is unsigned but `%s`.`%s` is not" % (
-                constraint.name, table.name, table_column.name, foreign_table.name, foreign_column.name
-            )
-
-        if not table_column.unsigned and foreign_column.unsigned:
-            return "MySQL 1215 error for foreign key `%s`: unsigned mistmatch. `%s`.`%s` is unsigned but `%s`.`%s` is not" % (
-                constraint.name, foreign_table.name, foreign_column.name, table.name, table_column.name
-            )
-
-        # if the constraint has a SET NULL but the column cannot be null, then 1215
-        if (constraint.on_delete == 'SET NULL' or constraint.on_update == 'SET NULL') and not table_column.null:
-            message_parts = []
-            if constraint.on_delete == 'SET NULL':
-                message_parts.append('ON DELETE')
-            if constraint.on_update == 'SET NULL':
-                message_parts.append('ON UPDATE')
-            return "MySQL 1215 error for foreign key `%s`: invalid SET NULL. `%s`.`%s` is not allowed to be null but the foreign key attempts to set the value to null %s" % (
-                constraint.name, table.name, table_column.name, ' and '.join(message_parts)
-            )
-
-        # if the column the constraint is on doesn't have an index, then 1215
-        if not foreign_table.column_is_indexed(foreign_column):
-            return "MySQL 1215 error for foreign key `%s`: missing index. `%s`.`%s` references `%s`.`%s` but `%s`.`%s` does not have an index and therefore cannot be used in a foreign key constraint" % (
-                constraint.name, table.name, table_column.name, foreign_table.name, foreign_column.name,
-                foreign_table.name, foreign_column.name
-            )
-
-        return None
-
-    def add_table(self, table):
-        """ Adds a table to the database
-
-        :param table: The table to add
-        :type table: mygrations.formats.mysql.definitions.table
-        """
-        if table.name in self._tables:
-            raise ValueError('Cannot add table %s to database because it already exists' % table.name)
-
-        self._errors_1215 = None
-        self._tables[table.name] = table
-
-    def remove_table(self, table):
-        """ Removes a table from the database
-
-        :param table: The table to remove
-        :type table: mygrations.formats.mysql.definitions.table
-        """
-        if not table.name in self._tables:
-            raise ValueError('Cannot remove table %s from database because it does not exist' % table.name)
-
-        self._errors_1215 = None
-        self._tables.pop(table.name, None)
-
-    def apply_operation(self, table_name, operation):
-        """ Updates the database object according to the operation
-
-        :param table_name: The table that the operation is being applied to
-        :param operation: The operation to apply
-        :type table: string|mygrations.formats.mysql.definitions.table
-        :type operation: mygrations.formats.mysql.mygration.operations.*
-        """
-        if type(table_name) != str:
-            table_name = table_name.name
-
-        if not table_name in self._tables:
-            raise ValueError('Cannot apply operation to table %s because that table does not exist' % table_name)
-
-        # the table applies the operation
-        self._errors_1215 = None
-        self._tables[table_name].apply_operation(operation)
-
-    def apply_to_source(self, operation):
-        """ Updates the actual source of the database object: usually the database or a .sql file
-
-        This method and :meth:`mygrations.formats.mysql.defintions.database.apply_operation` sound very similar.
-        The difference is that the latter operates on the in-memory structure store only, and makes
-        no actual changes.  Instead it is primarily used for internal purposes.  This method actually
-        makes changes.
-
-        :param operation: The operation to apply
-        :type operation: mygrations.formats.mysql.mygration.operations.*
-        """
-        raise NotImplementedError("Someone forgot to finish something")
+from mygrations.core.definitions.database import Database as BaseDatabase
+class Database(BaseDatabase):
+    pass
diff --git a/mygrations/formats/mysql/definitions/index.py b/mygrations/formats/mysql/definitions/index.py
index 8d593f2..f8f10c9 100644
--- a/mygrations/formats/mysql/definitions/index.py
+++ b/mygrations/formats/mysql/definitions/index.py
@@ -1,91 +1,3 @@
-class index(object):
-
-    _name = ''
-    _index_type = ''
-    _columns = None
-    _errors = None
-    _warnings = None
-
-    def __init__(self, name, columns, index_type='INDEX'):
-        """ Index constructor
-
-        :param name: The name of the index
-        :param columns: The columns in the index
-        :param index_type: The type of the index (INDEX, UNIQUE, or PRIMARY)
-        :type name: string
-        :type columns: [string]
-        :type index_type: string
-        """
-        self._name = name
-        self._index_type = index_type
-        self._columns = columns
-
-    @property
-    def name(self):
-        """ Public getter.  Returns the name of the column.
-
-        :returns: The index name
-        :rtype: string
-        """
-
-        return self._name
-
-    @property
-    def index_type(self):
-        """ Public getter.  Returns a string denoting the type of the index.  Always returns in uppercase
-
-        Index type can be 'INDEX', 'UNIQUE', or 'PRIMARY'
-
-        :returns: The index type
-        :rtype: string
-        """
-        return self._index_type.upper()
-
-    @property
-    def columns(self):
-        """ Public getter.  Returns a list of the column names on the index.
-
-        :returns: The column length
-        :rtype: list
-        """
-
-        return self._columns
-
-    @property
-    def errors(self):
-        """ Public getter.  Returns a list of parsing errors
-
-        :returns: A list of parsing errors
-        :rtype: list
-        """
-        return [] if self._errors is None else self._errors
-
-    @property
-    def warnings(self):
-        """ Public getter.  Returns a list of parsing/table warnings
-
-        :returns: A list of parsing/table warnings
-        :rtype: list
-        """
-        return [] if self._warnings is None else self._warnings
-
-    def __str__(self):
-        """ Returns the MySQL command that would create the column
-
-        i.e. column_name type(len) default ''
-
-        :returns: A partial MySQL command that could be used to generate the column
-        :rtype: string
-        """
-        parts = []
-        if self.index_type == 'PRIMARY':
-            parts.append('PRIMARY')
-        elif self.index_type == 'UNIQUE':
-            parts.append('UNIQUE')
-        parts.append('KEY')
-
-        if self.name:
-            parts.append('`%s`' % self.name)
-        parts.append('(`%s`)' % ("`,`".join(self.columns)))
-
-        return ' '.join(parts)
+from mygrations.core.definitions.index import Index as BaseIndex
+class Index(BaseIndex):
+    pass
diff --git a/mygrations/formats/mysql/definitions/rows.py b/mygrations/formats/mysql/definitions/rows.py
index 1066b45..2e74907 100644
--- a/mygrations/formats/mysql/definitions/rows.py
+++ b/mygrations/formats/mysql/definitions/rows.py
@@ -1,66 +1,3 @@
-class rows(object):
-
-    _table = ''
-    _raw_rows = ''
-    _columns = None
-    _errors = None
-    _warnings = None
-
-    @property
-    def table(self):
-        """ Public getter.  Returns the name of the table that records are being inserted for
-
-        :returns: The name of the table
-        :rtype: list
-        """
-
-        return self._table
-
-    @property
-    def raw_rows(self):
-        """ Public getter.  Returns a list of insert values from the VALUES part of the query
-
-        :returns: A list of values for each row
-        :rtype: list[list]
-        """
-
-        return self._raw_rows
-
-    @property
-    def columns(self):
-        """ Public getter.  Returns the list of columns for the rows
-
-        :returns: A list of columns
-        :rtype: list
-        """
-        return self._columns
-
-    @property
-    def num_explicit_columns(self):
-        """ Public getter.  Returns the number of columns specified in the insert query
-
-        Can be zero if none are specified, which happens for queries like:
-        INSERT INTO table (val1, val2, val3...);
-
-        :returns: The number of columns which have been explicitly defined
-        :rtype: integer
-        """
-        return self._num_explicit_columns
-
-    @property
-    def errors(self):
-        """ Public getter.  Returns a list of parsing errors
-
-        :returns: A list of parsing errors
-        :rtype: list
-        """
-        return [] if self._errors is None else self._errors
-
-    @property
-    def warnings(self):
-        """ Public getter.  Returns a list of parsing/table warnings
-
-        :returns: A list of parsing/table warnings
-        :rtype: list
-        """
-        return [] if self._warnings is None else self._warnings
+from mygrations.core.definitions.rows import Rows as RowsBase
+class Rows(RowsBase):
+    pass
diff --git a/mygrations/formats/mysql/definitions/table.py b/mygrations/formats/mysql/definitions/table.py
index 0865559..90c89c6 100644
--- a/mygrations/formats/mysql/definitions/table.py
+++ b/mygrations/formats/mysql/definitions/table.py
@@ -1,308 +1,30 @@
 from collections import OrderedDict
-from .rows import rows as rows_definition
-
-from ..mygrations.operations.alter_table import alter_table
-from ..mygrations.operations.add_column import add_column
-from ..mygrations.operations.change_column import change_column
-from ..mygrations.operations.remove_column import remove_column
-from ..mygrations.operations.add_key import add_key
-from ..mygrations.operations.change_key import change_key
-from ..mygrations.operations.remove_key import remove_key
-from ..mygrations.operations.add_constraint import add_constraint
-from ..mygrations.operations.change_constraint import change_constraint
-from ..mygrations.operations.remove_constraint import remove_constraint
-from ..mygrations.operations.create_table import create_table
-from ..mygrations.operations.row_delete import row_delete
-from ..mygrations.operations.row_insert import row_insert
-from ..mygrations.operations.row_update import row_update
-class table(object):
-
-    _name = ''
-    _options = None
-    _columns = None
-    _indexes = None
-    _constraints = None
-    _primary = None
-    _rows = None
-    _errors = None
-    _warnings = None
-    _auto_increment = 1
-    _indexed_columns = None
-    _tracking_rows = False
-
-    def __init__(self, name):
-        self._name = name
-        self._options = []
-        self._columns = OrderedDict()
-        self._indexes = OrderedDict()
-        self._constraints = OrderedDict()
-        self._errors = []
-        self._warnings = []
-        self._primary = ''
-
-    @property
-    def name(self):
-        """ Public getter.  Returns the name of the table.
-
-        :returns: The table name
-        :rtype: string
-        """
-
-        return self._name
-
-    @property
-    def options(self):
-        """ Public getter.  Returns a list of table options
-
-        :returns: Table options
-        :rtype: list
-        """
-
-        return self._options
-
-    def mark_tracking_rows(self):
-        """ Marks the table as having had its rows read, for bookeeping purposes
-
-        The reason this is here is because we keep track of whether or not we are "tracking" rows
-        for the current table.  This is for bookeeping purposes, largely as a safe-guard attempt
-        to try to identify any more subtle bugs that might creep in.  Normally self._tracking_rows
-        gets set to True when we add rows to a table, but if a table is empty then rows will never
-        get added, and instead this method must be called to mark the rows as "tracked".
-        """
-        self._tracking_rows = True
-        if self._rows is None:
-            self._rows = OrderedDict()
-
-    @property
-    def tracking_rows(self):
-        """ Public getter.  Returns True/False to denote whether or not this table is tracking row records
-
-        To be clear on the distinction: just about any table might have rows.  However,
-        that doesn't mean that the mygration system should be syncing rows for that table.
-        self.tracking_rows == True denotes that the mygration system thinks that we
-        should probably be syncing rows for this table.
-
-        :returns: Whether or not the mygration system is tracking rows on the table
-        :rtype: bool
-        """
-
-        return self._tracking_rows
-
-    @property
-    def rows(self):
-        """ Public getter.  Returns an ordered dictionary of rows by id
-
-        :returns: Table rows
-        :rtype: OrderedDict
-        """
-
-        return self._rows
-
-    @property
-    def columns(self):
-        """ Public getter.  Returns an ordered dictionary of table columns
-
-        :returns: Table columns
-        :rtype: OrderedDict
-        """
-
-        return self._columns
-
-    @property
-    def indexes(self):
-        """ Public getter.  Returns an ordered dictionary of table indexes
-
-        :returns: Table indexes
-        :rtype: OrderedDict
-        """
-
-        return self._indexes
-
-    @property
-    def constraints(self):
-        """ Public getter.  Returns an ordered dictionary of table constraints
-
-        :returns: Table constraints
-        :rtype: OrderedDict
-        """
-
-        return self._constraints
-
-    @property
-    def primary(self):
-        """ Public getter.  Returns the index object for the primary key
-
-        :returns: The index object of the primary key column
-        :rtype: isinstance(formats.mysql.definitions.index)
-        """
-
-        return self._primary
-
-    @property
-    def errors(self):
-        """ Public getter.  Returns a list of parsing errors
-
-        :returns: A list of parsing errors
-        :rtype: list
-        """
-        return [] if self._errors is None else self._errors
-
-    @property
-    def warnings(self):
-        """ Public getter.  Returns a list of parsing/table warnings
-
-        :returns: A list of parsing/table warnings
-        :rtype: list
-        """
-        return [] if self._warnings is None else self._warnings
-
-    @property
-    def auto_increment(self):
-        """ Public getter.  Returns the autoincrement for the table
-
-        :returns: The auto increment
-        :rtype: int
-        """
-        return self._auto_increment
-
-    @property
-    def rows(self):
-        """ Public getter.  Returns an ordered dictionary with row data by id
-
-        :returns: An ordered dictionary with row data by id
-        :rtype: OrderedDict
-        """
-        return [] if self._rows is None else self._rows
-
-    def add_rows(self, rows):
-        """ Adds a mygrations.formats.mysql.definitions.rows object to the table
-
-        The rows object has some flexibility in terms of columns: it doesn't just
-        assume that a value is provided for every column in the table.  Rather,
-        there can be a list of columns and only those columns have values (which
-        supports the equivalent of MySQL INSERT queries which only specify values
-        for some columns).
-
-        Rows with errors will not be processed
-
-        :param rows: The rows to process
-        :type rows: mygrations.formats.mysql.definitions.rows
-        :returns: An error string if an error is encountered, otherwise True/False
-        :rtype: bool | string
-        """
-        if not isinstance(rows, rows_definition):
-            raise ValueError(
-                'Only objects of class mygrations.formats.mysql.definitions.rows can be added as rows to a table'
-            )
-
-        # we can't process guys with errors
-        if rows._errors:
-            return False
-
-        self._tracking_rows = True
-        if self._rows is None:
-            self._rows = OrderedDict()
-
-        # the rows object may have a list of columns.  If not use our own list of columns
-        # remember that self._columns is an OrderedDict so converting its keys to a list
-        # actually preserves the order (which is a requirement for us)
-        columns = rows.columns if rows.num_explicit_columns else list(self._columns.keys())
-
-        for values in rows.raw_rows:
-            # rows without explicit columns must be checked for matching columns
-            if not rows.num_explicit_columns and len(values) != len(columns):
-                return 'Insert values has wrong number of values for table %s and row %s' % (self._name, values)
-
-            # we need to know the id of this record, which means we need
-            # to know where in the list of values the id column lives
-            try:
-                id_index = columns.index('id')
-                row_id = int(values[id_index])
-            except ValueError:
-                row_id = self._auto_increment
-
-            self._auto_increment = max(self._auto_increment, row_id + 1)
-            if row_id in self._rows:
-                return 'Duplicate row id found for table %s and row %s' % (self.name, values)
-
-            if not row_id:
-                return 'Invalid row id of %s found for table %s' % (row_id, self.name)
-
-            self._rows[row_id] = OrderedDict(zip(columns, values))
-
-        return True
-
-    def add_raw_row(self, row):
-        """ Adds a row into the table as a dictionary instead of a row object
-
-        A bit of repetition here.  This is similar to what happens inside the main
-        loop of self.add_rows, but different enough that I'm not trying to combine
-        them right this second.
-
-        :param row: The row to add
-        :type row: A dictionary
-        :returns: An error string if an error is encountered, otherwise True/False
-        :rtype: bool | string
-        """
-        self._tracking_rows = True
-        if self._rows is None:
-            self._rows = OrderedDict()
-
-        row_id = row['id'] if 'id' in row else self._auto_increment
-        self._auto_increment = max(self._auto_increment, row_id + 1)
-        if row_id in self._rows:
-            return 'Duplicate row id found for table %s and row %s' % (self._name, row)
-
-        # make sure we have a value for every column in the row, and build an OrderedDict
-        converted_row = OrderedDict()
-        for column in self._columns.keys():
-            if column in row:
-                converted_row[column] = row[column]
-            else:
-                converted_row[column] = self._columns[column].default
-
-        self._rows[row_id] = converted_row
-
-    def column_before(self, column_name):
-        """ Returns the name of the column that comes before a given row.
-
-        Returns true if the column is at the beginning of the table
-
-        :param column_name: The name of the column to find the position for
-        :type column_name: str
-        :returns: The name of the column before the given column, or True if at the beginning
-        :rtype: string|True
-        """
-        # this works because we used an OrderedDict
-        columns = [col for col in self.columns.keys()]
-
-        if not column_name in columns:
-            raise ValueError(
-                "Cannot return column before %s because %s does not exist in table %s" %
-                (column_name, column_name, self.name)
-            )
-
-        index = columns.index(column_name)
-        if index == 0:
-            return True
-
-        return columns[index - 1]
-
-    def __str__(self):
-        return str(self.create())
-
-    def nice(self):
-        return str(self.create(True))
-
+from .rows import Rows as RowsDefinition
+from ..mygrations.operations.alter_table import AlterTable
+from ..mygrations.operations.add_column import AddColumn
+from ..mygrations.operations.change_column import ChangeColumn
+from ..mygrations.operations.remove_column import RemoveColumn
+from ..mygrations.operations.add_key import AddKey
+from ..mygrations.operations.change_key import ChangeKey
+from ..mygrations.operations.remove_key import RemoveKey
+from ..mygrations.operations.add_constraint import AddConstraint
+from ..mygrations.operations.change_constraint import ChangeConstraint
+from ..mygrations.operations.remove_constraint import RemoveConstraint
+from ..mygrations.operations.create_table import CreateTable
+from ..mygrations.operations.row_delete import RowDelete
+from ..mygrations.operations.row_insert import RowInsert
+from ..mygrations.operations.row_update import RowUpdate
+from mygrations.core.definitions.table import Table as BaseTable
+class Table(BaseTable):
     def create(self, nice=False):
         """ Returns a create table operation that can create this table
 
         :param nice: Whether or not to return a nicely formatted CREATE TABLE command
         :type nice: bool
         :returns: A create table operation
-        :rtype: mygrations.operations.create_table
+        :rtype: mygrations.operations.CreateTable
         """
-        return create_table(self, nice)
+        return CreateTable(self, nice)
 
     def to(self, comparison_table, split_operations=False):
         """ Compares two tables to eachother and returns a list of operations which can bring the structure of the second in line with the first
@@ -342,10 +64,10 @@ def to(self, comparison_table, split_operations=False):
         # 1. Adding columns, changing columns, adding keys, changing keys, removing keys, removing foreign keys
         # 2. Adding foreign keys, changing foreign keys
         # 3. Removing columns
-        primary_alter = alter_table(self.name)
+        primary_alter = AlterTable(self.name)
         for new_column in added_columns:
             primary_alter.add_operation(
-                add_column(comparison_table.columns[new_column], comparison_table.column_before(new_column))
+                AddColumn(comparison_table.columns[new_column], comparison_table.column_before(new_column))
             )
 
         for overlap_column in overlap_columns:
@@ -359,30 +81,30 @@ def to(self, comparison_table, split_operations=False):
             if self.columns[overlap_column].is_really_the_same_as(comparison_table.columns[overlap_column]):
                 continue
 
-            primary_alter.add_operation(change_column(comparison_table.columns[overlap_column]))
+            primary_alter.add_operation(ChangeColumn(comparison_table.columns[overlap_column]))
 
         for removed_column in removed_columns:
-            primary_alter.add_operation(remove_column(self.columns[removed_column]))
+            primary_alter.add_operation(RemoveColumn(self.columns[removed_column]))
 
         # indexes also go in that first alter table
         for new_key in added_keys:
-            primary_alter.add_operation(add_key(comparison_table.indexes[new_key]))
+            primary_alter.add_operation(AddKey(comparison_table.indexes[new_key]))
         for removed_key in removed_keys:
-            primary_alter.add_operation(remove_key(self.indexes[removed_key]))
+            primary_alter.add_operation(RemoveKey(self.indexes[removed_key]))
         for overlap_key in overlap_keys:
             if str(self.indexes[overlap_key]) == str(comparison_table.indexes[overlap_key]):
                 continue
-            primary_alter.add_operation(change_key(comparison_table.indexes[overlap_key]))
+            primary_alter.add_operation(ChangeKey(comparison_table.indexes[overlap_key]))
 
         # removed foreign key constraints get their own alter because that should always happen first
-        removed_constraints_alter = alter_table(self.name)
+        removed_constraints_alter = AlterTable(self.name)
         for removed_constraint in removed_constraints:
-            removed_constraints_alter.add_operation(remove_constraint(self.constraints[removed_constraint]))
+            removed_constraints_alter.add_operation(RemoveConstraint(self.constraints[removed_constraint]))
 
         # adding/changing/removing foreign keys gets their own alter
-        constraints = alter_table(self.name)
+        constraints = AlterTable(self.name)
         for added_constraint in added_constraints:
-            constraints.add_operation(add_constraint(comparison_table.constraints[added_constraint]))
+            constraints.add_operation(AddConstraint(comparison_table.constraints[added_constraint]))
         for overlap_constraint in overlap_constraints:
             if str(self.constraints[overlap_constraint]) == str(comparison_table.constraints[overlap_constraint]):
                 continue
@@ -390,8 +112,8 @@ def to(self, comparison_table, split_operations=False):
             # foreign key constraints are modified by first dropping the constraint and
             # then adding the new one.  However, these two operations cannot happen in the
             # same alter command.  Kinda a pain.  Oh well.
-            removed_constraints_alter.add_operation(remove_constraint(self.constraints[overlap_constraint]))
-            constraints.add_operation(add_constraint(comparison_table.constraints[overlap_constraint]))
+            removed_constraints_alter.add_operation(RemoveConstraint(self.constraints[overlap_constraint]))
+            constraints.add_operation(AddConstraint(comparison_table.constraints[overlap_constraint]))
 
         # now put it all together
         if split_operations:
@@ -436,10 +158,10 @@ def to_rows(self, from_table=None):
 
         operations = []
         for row_id in inserted_ids:
-            operations.append(row_insert(self.name, self.rows[row_id]))
+            operations.append(RowInsert(self.name, self.rows[row_id]))
 
         for row_id in deleted_ids:
-            operations.append(row_delete(self.name, row_id))
+            operations.append(RowDelete(self.name, row_id))
 
         for row_id in updated_ids:
             # try to be smart and not update if we don't have to
@@ -451,228 +173,6 @@ def to_rows(self, from_table=None):
                     break
 
             if differences:
-                operations.append(row_update(self.name, self.rows[row_id]))
+                operations.append(RowUpdate(self.name, self.rows[row_id]))
 
         return operations
-
-    def column_is_indexed(self, column):
-        """ Returns True/False to denote whether or not the column has a useable index
-
-        :param column: The column to check
-        :type column: string|mygrations.formats.mysql.definitions.column
-        """
-        if type(column) != str:
-            column = column.name
-
-        if column not in self._columns:
-            return False
-
-        # best way to do this is with a set.  We'll keep a record of all indexed columns
-        # the column is indexed if an index has that column in the first position
-        if self._indexed_columns is None:
-            self._indexed_columns = set([index.columns[0] for index in self._indexes.values()])
-
-        return column in self._indexed_columns
-
-    def _differences(self, from_list, to_list):
-        """
-        Calculates the difference between two OrderedDicts.
-
-        https://codereview.stackexchange.com/a/176303/140581
-
-        :param from_list: OrderedDict
-        :param to_list: OrderedDict
-        :return: (added, removed, overlap)
-        """
-
-        return ([key for key in to_list if key not in from_list], [key for key in from_list if key not in to_list],
-                [key for key in from_list if key in to_list])
-
-    def apply_operation(self, operation):
-        """
-        Applies an operation to the table
-
-        :param operation: The operation to apply
-        :type operation: mygrations.formats.mysql.mygration.operations.*
-        """
-        operation.apply_to_table(self)
-
-    def add_column(self, column, position=False):
-        """ Adds a column to the table
-
-        The value of position matches self.position from mygrations.formats.mysql.mygration.operations.add_column
-
-        :param column: The column to add
-        :param position: Where to put the column
-        :type column: mygrations.formats.mysql.definitions.column
-        :type position: See mygrations.formats.mysql.mygration.operations.add_column
-        """
-        # putting it at the end is easy
-        if column.name in self._columns:
-            raise ValueError("Cannot add column %s because %s already exists" % (column.name, column.name))
-
-        if not position:
-            self._columns[column.name] = column
-            return True
-
-        # end is also easy
-        if position == True:
-            self._columns[column.name] = column
-            self._columns.move_to_end(column.name, last=False)
-            return True
-
-        # now it is tricky
-        found = False
-        new_columns = OrderedDict()
-        for key, value in self._columns.items():
-            new_columns[key] = value
-            if key == position:
-                new_columns[column.name] = column
-                found = True
-
-        if not found:
-            raise ValueError(
-                "Cannot add column %s after %s because %s does not exist" % (column.name, position, position)
-            )
-
-        self._columns = new_columns
-        return True
-
-    def remove_column(self, column_name):
-        """ Removes a column from the table
-
-        :param column_name: The column to remove
-        :type column_name: string|mygrations.formats.mysql.definitions.column
-        """
-        if type(column_name) != str:
-            column_name = column_name.name
-
-        if not column_name in self._columns:
-            raise ValueError("Cannot remove column %s because column %s does not exist" % (column_name, column_name))
-
-        self._columns.pop(column_name, None)
-
-    def change_column(self, new_column):
-        """ Changes a column
-
-        This does not currently support renaming
-
-        :param new_column: The new column definition
-        :type new_column: mygrations.formats.mysql.definitions.column
-        """
-        if not new_column.name in self._columns:
-            raise ValueError(
-                "Cannot modify column %s because column %s does not exist" % (new_column.name, new_column.name)
-            )
-        self._columns[new_column.name] = new_column
-
-    def add_key(self, key):
-        """ Adds an key to the table
-
-        :param key: The key to add
-        :type key: mygrations.formats.mysql.definitions.key
-        """
-        if key.name in self._indexes:
-            raise ValueError("Cannot add key %s because key %s already exists" % (key.name, key.name))
-        self._indexes[key.name] = key
-
-        if self._indexed_columns is not None:
-            self._indexed_columns.add(key.columns[0])
-
-    def remove_key(self, key):
-        """ Removes an key from the table
-
-        :param key: The key to remove
-        :type key: string|mygrations.formats.mysql.definitions.key
-        """
-        if type(key) != str:
-            key = key.name
-
-        if key not in self._indexes:
-            raise ValueError("Cannot remove key %s because key %s does not exist" % (key, key))
-
-        indexed_column = self._indexes[key].columns[0]
-        self._indexes.pop(key, None)
-
-        if self._indexed_columns is not None:
-            self._indexed_columns.discard(indexed_column)
-
-    def change_key(self, new_key):
-        """ Changes a key
-
-        This does not currently support renaming
-
-        :param new_key: The new key definition
-        :type new_key: mygrations.formats.mysql.definitions.key
-        """
-        if not new_key.name in self._indexes:
-            raise ValueError("Cannot modify key %s because key %s does not exist" % (new_key.name, new_key.name))
-
-        if self._indexed_columns is not None:
-            self._indexed_columns.discard(self._indexes[new_key.name].columns[0])
-
-        self._indexes[new_key.name] = new_key
-
-        if self._indexed_columns is not None:
-            self._indexed_columns.add(new_key.columns[0])
-
-    def add_constraint(self, constraint):
-        """ Adds a constraint to the table
-
-        :param constraint: The constraint to add
-        :type constraint: mygrations.formats.mysql.definitions.constraint
-        """
-        if constraint.name in self._constraints:
-            raise ValueError(
-                "Cannot add constraint %s because constraint %s already exists" % (constraint.name, constraint.name)
-            )
-        self._constraints[constraint.name] = constraint
-
-    def remove_constraint(self, constraint):
-        """ Removes an constraint from the table
-
-        :param constraint: The constraint to remove
-        :type constraint: string|mygrations.formats.mysql.definitions.constraint
-        """
-        if type(constraint) != str:
-            constraint = constraint.name
-
-        if constraint not in self._constraints:
-            raise ValueError(
-                "Cannot remove constraint %s because constraint %s does not exist" % (constraint, constraint)
-            )
-        self._constraints.pop(constraint, None)
-
-    def change_constraint(self, new_constraint):
-        """ Changes a constraint
-
-        This does not currently support renaming
-
-        :param new_constraint: The new constraint definition
-        :type new_constraint: mygrations.formats.mysql.definitions.constraint
-        """
-        if not new_constraint.name in self._constraints:
-            raise ValueError(
-                "Cannot modify constraint %s because constraint %s does not exist" %
-                (new_constraint.name, new_constraint.name)
-            )
-        self._constraints[new_constraint.name] = new_constraint
-
-    def _loose_equal(self, val1, val2):
-        """ Performs a looser comparison, as values might have different types depending on whether they came out of a database or file
-
-        Returns true if the two values are equal, even if one is a string and the other an int.
-
-        :param val1: The first value to compare
-        :param val2: The second value to compare
-        :type val1: string|int
-        :type val2: string|int
-        :return: Whether or not they are considered a match
-        :rtype: bool
-        """
-        # if we don't have a type mistmatch then this is easy
-        if type(val1) == type(val2):
-            return val1 == val2
-
-        # otherwise see if we can cheat and just convert to strings
-        return str(val1) == str(val2)
diff --git a/mygrations/formats/mysql/file_reader/__init__.py b/mygrations/formats/mysql/file_reader/__init__.py
index 1e4f330..d95a23e 100644
--- a/mygrations/formats/mysql/file_reader/__init__.py
+++ b/mygrations/formats/mysql/file_reader/__init__.py
@@ -1,1 +1,4 @@
-from .reader import reader
+from .reader import Reader
+from .database import Database
+def parse(strings):
+    return Database(strings).as_core_database()
diff --git a/mygrations/formats/mysql/file_reader/comment_parser.py b/mygrations/formats/mysql/file_reader/comment_parser.py
index 4a029e7..8527380 100644
--- a/mygrations/formats/mysql/file_reader/comment_parser.py
+++ b/mygrations/formats/mysql/file_reader/comment_parser.py
@@ -1,5 +1,5 @@
-from mygrations.core.parse.parser import parser
-class comment_parser(parser):
+from mygrations.core.parse.parser import Parser
+class CommentParser(Parser):
 
     rules = ['nope']
     sql = ''
@@ -8,9 +8,11 @@ def __init__(self, rules=[]):
 
         # we're not yet a rules-based parser (I may do that eventually), so
         # I don't want to run the normal parser __init__()
-        self.errors = []
-        self.warnings = []
         self.matched = False
+        self._parsing_errors = []
+        self._parsing_warnings = []
+        self._schema_errors = []
+        self._schema_warnings = []
 
     def parse(self, sql):
         """ res = paser.parse()
@@ -61,7 +63,7 @@ def parse(self, sql):
             raise ValueError('SQL passed to comment parser did not start with a comment')
 
         if not self.sql.count('*/'):
-            self.errors.append('Could not find closing comment indicator, */')
+            self._parsing_errors.append('Could not find closing comment indicator, */')
             return self.sql
 
         # otherwise this is very straight-forward
diff --git a/mygrations/formats/mysql/file_reader/create_parser.py b/mygrations/formats/mysql/file_reader/create_parser.py
index a6da4af..4784a9b 100644
--- a/mygrations/formats/mysql/file_reader/create_parser.py
+++ b/mygrations/formats/mysql/file_reader/create_parser.py
@@ -1,13 +1,12 @@
 from collections import OrderedDict
 
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.constraint import constraint
-from mygrations.formats.mysql.definitions.index import index
-from mygrations.formats.mysql.definitions.column import column
-from mygrations.formats.mysql.definitions.table import table
+from mygrations.core.parse.parser import Parser
+from mygrations.formats.mysql.definitions.constraint import Constraint
+from mygrations.formats.mysql.definitions.index import Index
+from mygrations.formats.mysql.definitions.table import Table
 
 from .parsers import *
-class create_parser(parser, table):
+class CreateParser(Table, Parser):
 
     # this defines the rules for the parsing engine.  Yes, I decided to try to build
     # a parsing engine to parse the MySQL.  Seems like a reasonable choice at the
@@ -31,30 +30,29 @@ class create_parser(parser, table):
     }, {
         'type': 'literal',
         'value': '('
-    },
-             {
-                 'type':
-                 'children',
-                 'name':
-                 'definitions',
-                 'classes': [
-                     index_primary, index_key, index_unique, constraint_foreign, type_character, type_numeric,
-                     type_decimal, type_text, type_enum, type_plain
-                 ]
-             }, {
-                 'type': 'literal',
-                 'value': ')'
-             }, {
-                 'type': 'children',
-                 'name': 'table_options',
-                 'classes': [table_option],
-                 'optional': True
-             }, {
-                 'type': 'literal',
-                 'value': ';',
-                 'optional': True,
-                 'name': 'closing_semicolon'
-             }]
+    }, {
+        'type':
+        'children',
+        'name':
+        'definitions',
+        'classes': [
+            IndexPrimary, IndexKey, IndexUnique, ConstraintForeign, TypeCharacter, TypeNumeric, TypeDecimal, TypeText,
+            TypeEnum, TypePlain
+        ]
+    }, {
+        'type': 'literal',
+        'value': ')'
+    }, {
+        'type': 'children',
+        'name': 'table_options',
+        'classes': [TableOption],
+        'optional': True
+    }, {
+        'type': 'literal',
+        'value': ';',
+        'optional': True,
+        'name': 'closing_semicolon'
+    }]
 
     def process(self):
 
@@ -65,40 +63,33 @@ def process(self):
         self._columns = OrderedDict()
         self._indexes = OrderedDict()
         self._constraints = OrderedDict()
-        self._errors = []
-        self._warnings = []
         self._primary = ''
 
         # ignore the AUTO_INCREMENT option: there is no reason for us to ever manage that
         self._options = [opt for opt in self._options if opt.name != 'AUTO_INCREMENT']
 
         for definition in self._definitions:
-            if isinstance(definition, column):
-                self._columns[definition.name] = definition
-            elif isinstance(definition, index):
-                self._indexes[definition.name] = definition
-
-                if definition.index_type == 'PRIMARY':
-                    if self._primary:
-                        self._errors.append('Found more than one primary column for table %s' % (self._name))
-                    else:
-                        self._primary = definition
-            elif isinstance(definition, constraint):
-                self._constraints[definition.name] = definition
+            if hasattr(definition, 'as_definition'):
+                if definition._name in self._columns:
+                    self._global_errors.append(f"Table '{self._name}' has two columns named '{definition._name}'")
+                    continue
 
-            if definition.errors:
-                for error in definition.errors:
-                    self._errors.append('%s in table %s' % (error, self._name))
+                self.add_column(definition.as_definition())    # see notes on definition.as_definition()
+            elif isinstance(definition, Index):
+                if definition._name in self._indexes:
+                    self._global_errors.append(f"Table '{self._name}' has two indexes named '{definition._name}'")
+                    continue
 
-            if definition.warnings:
-                for warning in definition.warnings:
-                    self._warnings.append('%s in table %s' % (warning, self._name))
+                self.add_index(definition)
+            elif isinstance(definition, Constraint):
+                if definition._name in self._constraints:
+                    self._global_errors.append(f"Table '{self._name}' has two constraints named '{definition._name}'")
+                    continue
 
-        if not self._name:
-            self._errors.append('Table name is required')
+                self.add_constraint(definition)
 
-        if not len(self._columns):
-            self._errors.append("Table %s has no columns" % self._name)
+            else:
+                raise ValueError("Found unknown definition of type ".definition.__class__)
 
         if not self.semicolon:
-            self._errors.append("Missing ending semicolon for table %s" % self._name)
+            self._global_errors.append("Missing ending semicolon for table %s" % self._name)
diff --git a/mygrations/formats/mysql/file_reader/database.py b/mygrations/formats/mysql/file_reader/database.py
index 75c3dac..58fa0ed 100644
--- a/mygrations/formats/mysql/file_reader/database.py
+++ b/mygrations/formats/mysql/file_reader/database.py
@@ -1,9 +1,8 @@
 import os
 import glob
-
-from .reader import reader as sql_reader
-from mygrations.formats.mysql.definitions.database import database as database_definition
-class database(database_definition):
+from .reader import Reader
+from mygrations.formats.mysql.definitions.database import Database as BaseDatabase
+class Database(BaseDatabase):
     def __init__(self, strings):
         """ Constructor.  Accepts a string or list of strings with different possible contents
 
@@ -22,10 +21,10 @@ def __init__(self, strings):
         :type strings: string|list
         """
 
-        self._warnings = []
-        self._errors = []
         self._tables = {}
-        self._rows = []
+        self._rows = {}
+        self._global_errors = []
+        self._global_warnings = []
 
         if isinstance(strings, str):
             strings = [strings]
@@ -33,7 +32,7 @@ def __init__(self, strings):
         for string in strings:
             self.process(string)
 
-        self.store_rows_with_tables()
+        self._combine_tables_and_rows()
 
     def process(self, string):
         """ Processes a string.
@@ -80,21 +79,32 @@ def _read(self, contents):
         """
 
         try:
-            reader = sql_reader()
+            reader = Reader()
             reader.parse(contents)
 
         except ValueError as e:
             print("Error in file %s: %s" % (contents, e))
 
-        # pull in all errors and warnings
-        self._errors.extend(reader.errors)
-        self._warnings.extend(reader.warnings)
-
         # keep rows and tables separate while we are reading
         for (table_name, table) in reader.tables.items():
             if table.name in self._tables:
                 self.errors.append('Found two definitions for table %s' % table.name)
             self._tables[table.name] = table
 
-        for (table_name, rows) in reader.rows.items():
-            self._rows.extend(rows)
+        self._global_errors.extend(reader._global_errors)
+        self._global_warnings.extend(reader._global_warnings)
+
+        for rows_list in reader.rows.values():
+            for rows in rows_list:
+                if rows.table not in self._rows:
+                    self._rows[rows.table] = []
+                self._rows[rows.table].append(rows)
+
+    def _combine_tables_and_rows(self):
+        for (table_name, rows_list) in self._rows.items():
+            if table_name not in self._tables:
+                self._global_errors.extend(
+                    f"Found rows for table {table_name}, but this table does not exist in the database"
+                )
+            for rows in rows_list:
+                self._tables[table_name].add_rows(rows)
diff --git a/mygrations/formats/mysql/file_reader/insert_parser.py b/mygrations/formats/mysql/file_reader/insert_parser.py
index e72a095..1f407fa 100644
--- a/mygrations/formats/mysql/file_reader/insert_parser.py
+++ b/mygrations/formats/mysql/file_reader/insert_parser.py
@@ -1,8 +1,7 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.rows import rows
-
-from .parsers.insert_values import insert_values
-class insert_parser(parser, rows):
+from mygrations.core.parse.parser import Parser
+from mygrations.formats.mysql.definitions.rows import Rows
+from .parsers.insert_values import InsertValues
+class InsertParser(Parser, Rows):
 
     rules = [{
         'type': 'literal',
@@ -28,7 +27,7 @@ class insert_parser(parser, rows):
     }, {
         'type': 'children',
         'name': 'inserts',
-        'classes': [insert_values]
+        'classes': [InsertValues]
     }, {
         'type': 'literal',
         'value': ';',
@@ -36,31 +35,26 @@ class insert_parser(parser, rows):
         'name': 'closing_semicolon'
     }]
 
-    def __init__(self, rules=[]):
-
-        super().__init__(rules)
-
-        self._columns = []
-        self._raw_rows = []
-
     def process(self):
 
         self._table = self._values['table'].strip('`')
         self._columns = self._values['columns']
         self._raw_rows = []
         self._num_explicit_columns = len(self._columns) if self._columns else 0
-        self._errors = []
-        self._warnings = []
+        self._schema_errors = []
+        self._schema_warnings = []
+        self._parsing_errors = []
+        self._parsing_warnings = []
         self.has_semicolon = True if 'closing_semicolon' in self._values else False
 
         had_comma = True
         for row in self._values['inserts']:
 
             if not had_comma:
-                self._warnings.append('Missing comma between insert value groups')
+                self._parsing_warnings.append('Missing comma between insert value groups')
 
             if self._num_explicit_columns and len(row.values) != self._num_explicit_columns:
-                self._errors.append('Insert values has wrong number of values for %s' % (row))
+                self._parsing_errors.append('Insert values has different number of columns and values for %s' % (row))
                 continue
 
             self._raw_rows.append(row.values)
diff --git a/mygrations/formats/mysql/file_reader/parsers/__init__.py b/mygrations/formats/mysql/file_reader/parsers/__init__.py
index f55ecdb..ff8ecf5 100644
--- a/mygrations/formats/mysql/file_reader/parsers/__init__.py
+++ b/mygrations/formats/mysql/file_reader/parsers/__init__.py
@@ -1,17 +1,17 @@
-from .constraint_foreign import constraint_foreign
-from .index_key import index_key
-from .index_primary import index_primary
-from .index_unique import index_unique
-from .type_character import type_character
-from .type_decimal import type_decimal
-from .type_enum import type_enum
-from .type_numeric import type_numeric
-from .type_plain import type_plain
-from .type_text import type_text
-from .table_option import table_option
-from .insert_values import insert_values
+from .constraint_foreign import ConstraintForeign
+from .index_key import IndexKey
+from .index_primary import IndexPrimary
+from .index_unique import IndexUnique
+from .type_character import TypeCharacter
+from .type_decimal import TypeDecimal
+from .type_enum import TypeEnum
+from .type_numeric import TypeNumeric
+from .type_plain import TypePlain
+from .type_text import TypeText
+from .table_option import TableOption
+from .insert_values import InsertValues
 
 __all__ = [
-    "constraint_foreign", "index_key", "index_primary", "index_unique", "type_character", "type_decimal", "type_enum",
-    "type_numeric", "type_plain", "type_text", "table_option", "insert_values"
+    "ConstraintForeign", "IndexKey", "IndexPrimary", "IndexUnique", "TypeCharacter", "TypeDecimal", "TypeEnum",
+    "TypeNumeric", "TypePlain", "TypeText", "TableOption", "InsertValues"
 ]
diff --git a/mygrations/formats/mysql/file_reader/parsers/constraint_foreign.py b/mygrations/formats/mysql/file_reader/parsers/constraint_foreign.py
index da3ee47..719436b 100644
--- a/mygrations/formats/mysql/file_reader/parsers/constraint_foreign.py
+++ b/mygrations/formats/mysql/file_reader/parsers/constraint_foreign.py
@@ -1,6 +1,6 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.constraint import constraint
-class constraint_foreign(parser, constraint):
+from mygrations.core.parse.parser import Parser
+from mygrations.formats.mysql.definitions.constraint import Constraint
+class ConstraintForeign(Parser, Constraint):
 
     has_comma = False
 
@@ -17,21 +17,21 @@ class constraint_foreign(parser, constraint):
         'value': 'FOREIGN KEY ('
     }, {
         'type': 'regexp',
-        'name': 'column',
+        'name': 'column_name',
         'value': '[^\(\s\)]+'
     }, {
         'type': 'literal',
         'value': ') REFERENCES'
     }, {
         'type': 'regexp',
-        'name': 'foreign_table',
+        'name': 'foreign_table_name',
         'value': '[^\(]+'
     }, {
         'type': 'literal',
         'value': '('
     }, {
         'type': 'regexp',
-        'name': 'foreign_column',
+        'name': 'foreign_column_name',
         'value': '[^\)]+'
     }, {
         'type': 'literal',
@@ -85,12 +85,12 @@ class constraint_foreign(parser, constraint):
 
     def process(self):
 
-        self._errors = []
-        self._warnings = []
+        self._parsing_errors = []
+        self._parsing_warnings = []
         self._name = self._values['name'].strip().strip('`')
-        self._column = self._values['column'].strip().strip('`')
-        self._foreign_table = self._values['foreign_table'].strip().strip('`')
-        self._foreign_column = self._values['foreign_column'].strip().strip('`')
+        self._column_name = self._values['column_name'].strip().strip('`')
+        self._foreign_table_name = self._values['foreign_table_name'].strip().strip('`')
+        self._foreign_column_name = self._values['foreign_column_name'].strip().strip('`')
 
         # figure out what our rules are
         self._on_delete = self.find_action('DELETE')
@@ -98,11 +98,7 @@ def process(self):
 
         self.has_comma = True if 'ending_comma' in self._values else False
 
-        if len(self._name) > 64:
-            self._errors.append('Key name %s is too long' % (self._name))
-
     def find_action(self, update_type):
-
         # watch for more than one action for this type
         found = ''
         for action in ['CASCADE', 'NO ACTION', 'RESTRICT', 'SET DEFAULT', 'SET NULL']:
@@ -110,7 +106,7 @@ def find_action(self, update_type):
                 continue
 
             if found:
-                self._errors.append('Found contradictory rules in foreign constraint for ON %s' % update_type)
+                self._parsing_errors.append('Found contradictory rules in foreign constraint for ON %s' % update_type)
             else:
                 found = action
 
diff --git a/mygrations/formats/mysql/file_reader/parsers/index_key.py b/mygrations/formats/mysql/file_reader/parsers/index_key.py
index e01b590..39609b7 100644
--- a/mygrations/formats/mysql/file_reader/parsers/index_key.py
+++ b/mygrations/formats/mysql/file_reader/parsers/index_key.py
@@ -1,7 +1,6 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.index import index
-class index_key(parser, index):
-
+from mygrations.core.parse.parser import Parser
+from mygrations.formats.mysql.definitions.index import Index
+class IndexKey(Parser, Index):
     _index_type = 'index'
     has_comma = False
 
@@ -32,19 +31,7 @@ class index_key(parser, index):
         'name': 'ending_comma'
     }]
 
-    def __init__(self, rules=[]):
-
-        super().__init__(rules)
-
-        self._errors = []
-        self._warnings = []
-        self._columns = []
-
     def process(self):
-
         self._name = self._values['name'].strip().strip('`')
         self._columns = self._values['columns']
         self.has_comma = True if 'ending_comma' in self._values else False
-
-        if len(self.name) > 64:
-            self._errors.append('Key name %s is too long' % (self.name))
diff --git a/mygrations/formats/mysql/file_reader/parsers/index_primary.py b/mygrations/formats/mysql/file_reader/parsers/index_primary.py
index 2cb1bb5..fcb9f14 100644
--- a/mygrations/formats/mysql/file_reader/parsers/index_primary.py
+++ b/mygrations/formats/mysql/file_reader/parsers/index_primary.py
@@ -1,6 +1,6 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.index import index
-class index_primary(parser, index):
+from mygrations.core.parse.parser import Parser
+from mygrations.formats.mysql.definitions.index import Index
+class IndexPrimary(Parser, Index):
 
     _index_type = 'primary'
     has_comma = False
@@ -27,14 +27,6 @@ class index_primary(parser, index):
         'name': 'ending_comma'
     }]
 
-    def __init__(self, rules=[]):
-
-        super().__init__(rules)
-
-        self._errors = []
-        self._warnings = []
-        self._columns = []
-
     def process(self):
 
         self._name = ''
diff --git a/mygrations/formats/mysql/file_reader/parsers/index_unique.py b/mygrations/formats/mysql/file_reader/parsers/index_unique.py
index 40f1ad0..9cf0ce0 100644
--- a/mygrations/formats/mysql/file_reader/parsers/index_unique.py
+++ b/mygrations/formats/mysql/file_reader/parsers/index_unique.py
@@ -1,6 +1,6 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.index import index
-class index_unique(parser, index):
+from mygrations.core.parse.parser import Parser
+from mygrations.formats.mysql.definitions.index import Index
+class IndexUnique(Parser, Index):
 
     _index_type = 'unique'
     has_comma = False
@@ -35,19 +35,8 @@ class index_unique(parser, index):
         'name': 'ending_comma'
     }]
 
-    def __init__(self, rules=[]):
-
-        super().__init__(rules)
-
-        self._errors = []
-        self._warnings = []
-        self._columns = []
-
     def process(self):
 
         self._name = self._values['name'].strip().strip('`')
         self._columns = self._values['columns']
         self.has_comma = True if 'ending_comma' in self._values else False
-
-        if len(self._name) > 64:
-            self._errors.append('Key name %s is too long' % (self._name))
diff --git a/mygrations/formats/mysql/file_reader/parsers/insert_values.py b/mygrations/formats/mysql/file_reader/parsers/insert_values.py
index 945f0c3..a39b19b 100644
--- a/mygrations/formats/mysql/file_reader/parsers/insert_values.py
+++ b/mygrations/formats/mysql/file_reader/parsers/insert_values.py
@@ -1,5 +1,5 @@
-from mygrations.core.parse.parser import parser
-class insert_values(parser):
+from mygrations.core.parse.parser import Parser
+class InsertValues(Parser):
 
     has_comma = False
     values = []
@@ -22,14 +22,6 @@ class insert_values(parser):
         'name': 'ending_comma'
     }]
 
-    def __init__(self, rules=[]):
-
-        super().__init__(rules)
-
-        self._errors = []
-        self._warnings = []
-        self.values = []
-
     def process(self):
 
         self.values = [
diff --git a/mygrations/formats/mysql/file_reader/parsers/table_option.py b/mygrations/formats/mysql/file_reader/parsers/table_option.py
index 52efe64..4e45a14 100644
--- a/mygrations/formats/mysql/file_reader/parsers/table_option.py
+++ b/mygrations/formats/mysql/file_reader/parsers/table_option.py
@@ -1,5 +1,5 @@
-from mygrations.core.parse.parser import parser
-class table_option(parser):
+from mygrations.core.parse.parser import Parser
+class TableOption(Parser):
 
     name = ''
     value = ''
@@ -19,8 +19,13 @@ class table_option(parser):
     }]
 
     def process(self):
+        self._name = self._values['name'].strip()
+        self._value = self._values['value'].strip()
 
-        self._errors = []
-        self._warnings = []
-        self.name = self._values['name'].strip()
-        self.value = self._values['value'].strip()
+    @property
+    def name(self):
+        return self._name
+
+    @property
+    def value(self):
+        return self._value
diff --git a/mygrations/formats/mysql/file_reader/parsers/type.py b/mygrations/formats/mysql/file_reader/parsers/type.py
new file mode 100644
index 0000000..0d4963b
--- /dev/null
+++ b/mygrations/formats/mysql/file_reader/parsers/type.py
@@ -0,0 +1,66 @@
+from mygrations.formats.mysql.definitions import columns
+from mygrations.core.definitions.columns.column import Column
+class Type:
+    column_type_map = {
+        'INTEGER': columns.Numeric,
+        'INT': columns.Numeric,
+        'SMALLINT': columns.Numeric,
+        'TINYINT': columns.Numeric,
+        'MEDIUMINT': columns.Numeric,
+        'BIGINT': columns.Numeric,
+        'DECIMAL': columns.Numeric,
+        'NUMERIC': columns.Numeric,
+        'FLOAT': columns.Numeric,
+        'DOUBLE': columns.Numeric,
+        'BIT': columns.Numeric,
+        'DATE': columns.Date,
+        'DATETIME': columns.Date,
+        'TIMESTAMP': columns.Date,
+        'TIME': columns.Date,
+        'YEAR': columns.Date,
+        'CHAR': columns.String,
+        'VARCHAR': columns.String,
+        'BINARY': columns.String,
+        'VARBINARY': columns.String,
+        'TINYBLOB': columns.String,
+        'BLOB': columns.String,
+        'MEDIUMBLOB': columns.String,
+        'LONGBLOB': columns.String,
+        'TINYTEXT': columns.String,
+        'TEXT': columns.String,
+        'MEDIUMTEXT': columns.String,
+        'LONGTEXT': columns.String,
+        'JSON': columns.String,
+        'ENUM': columns.Enum,
+        'SET': columns.Enum,
+    }
+
+    def as_definition(self) -> Column:
+        """
+        Returns the parsed column as a MySQL Column Definition
+
+        This is here because a single parser class can end up working for columns of completely different
+        types.  Therefore, we can't put the column definition directly in our inheritence tree and have
+        to build and return a new one based on the type we have.
+        """
+        column_type = self._column_type.upper()
+        if column_type not in self.column_type_map:
+            raise ValueError(
+                f"Found an unknown column type, '{column_type}' that does not have a corresponding column class"
+            )
+        column_class = self.column_type_map[column_type]
+        return column_class(
+            name=self._name,
+            column_type=column_type,
+            length=getattr(self, '_length', None),
+            null=getattr(self, '_null', None),
+            has_default=getattr(self, '_has_default', False),
+            default=getattr(self, '_default', None),
+            unsigned=getattr(self, '_unsigned', None),
+            character_set=getattr(self, '_character_set', None),
+            collate=getattr(self, '_collate', None),
+            auto_increment=getattr(self, '_auto_increment', None),
+            enum_values=getattr(self, 'enum_values', None),
+            parsing_errors=getattr(self, '_parsing_errors', None),
+            parsing_warnings=getattr(self, '_parsing_warnings', None),
+        )
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_character.py b/mygrations/formats/mysql/file_reader/parsers/type_character.py
index 717e080..2d5e4ee 100644
--- a/mygrations/formats/mysql/file_reader/parsers/type_character.py
+++ b/mygrations/formats/mysql/file_reader/parsers/type_character.py
@@ -1,6 +1,6 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.column import column
-class type_character(parser, column):
+from mygrations.core.parse.parser import Parser
+from .type import Type
+class TypeCharacter(Parser, Type):
 
     allowed_collation_types = {'char': True, 'varchar': True}
 
@@ -107,12 +107,15 @@ def process(self):
 
         self.has_comma = True if 'ending_comma' in self._values else False
 
-        self._errors = []
-        self._warnings = []
+        self._parsing_errors = []
+        self._parsing_warnings = []
+        self._schema_errors = []
+        self._schema_warnings = []
         self._name = self._values['name'].strip('`')
         self._column_type = self._values['type']
         self._length = self._values['length']
         self._null = False if 'NOT NULL' in self._values else True
+        self._has_default = 'default' in self._values
         self._default = self._values['default'] if 'default' in self._values else None
         self._character_set = self._values['character_set'] if 'character_set' in self._values else None
         self._collate = self._values['collate'] if 'collate' in self._values else None
@@ -124,7 +127,7 @@ def process(self):
             if self._default.lower() == 'null':
                 self._default = None
             elif not self._default.isdigit():
-                self._warnings.append(
+                self._parsing_warnings.append(
                     'Default value of "%s" should have quotes for field %s' % (self._default, self._name)
                 )
 
@@ -135,24 +138,6 @@ def process(self):
         if self._collate and len(self._collate) >= 2 and self._collate[0] == "'" and self._collate[-1] == "'":
             self._collate = self._collate.strip("'")
 
-        if self._character_set or self._collate:
-            if not self._column_type.lower() in self.allowed_collation_types:
-                self._errors.append(
-                    'Column of type %s is not allowed to have a collation or character set for column %s' %
-                    (self._column_type, self._name)
-                )
-
-        if self._default is None and not self._null:
-            self._warnings.append(
-                'Column %s is not null and has no default: you should set a default to avoid MySQL warnings' %
-                (self._name)
-            )
-
-        if self._column_type.lower() in self.disallowed_types:
-            self._errors.append(
-                'Column of type %s is not allowed to have a length for column %s' % (self._column_type, self._name)
-            )
-
         self._attributes = {}
         if self._character_set:
             self._attributes['CHARACTER SET'] = self._character_set
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_decimal.py b/mygrations/formats/mysql/file_reader/parsers/type_decimal.py
index 09c6bad..4803788 100644
--- a/mygrations/formats/mysql/file_reader/parsers/type_decimal.py
+++ b/mygrations/formats/mysql/file_reader/parsers/type_decimal.py
@@ -1,6 +1,6 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.column import column
-class type_decimal(parser, column):
+from mygrations.core.parse.parser import Parser
+from .type import Type
+class TypeDecimal(Parser, Type):
 
     allowed_types = {'real': True, 'double': True, 'float': True, 'decimal': True, 'numeric': True}
 
@@ -58,32 +58,24 @@ def process(self):
 
         self.has_comma = True if 'ending_comma' in self._values else False
 
-        self._errors = []
-        self._warnings = []
+        self._parsing_errors = []
+        self._parsing_warnings = []
+        self._schema_errors = []
+        self._schema_warnings = []
         self._name = self._values['name'].strip('`')
         self._column_type = self._values['type']
         self.decimals = self._values['decimals']
         self._length = '%s,%s' % (self._values['length'], self._values['decimals'])
         self._unsigned = True if 'UNSIGNED' in self._values else False
+        self._has_default = 'default' in self._values
         self._null = False if 'NOT NULL' in self._values else True
         self._default = self._values['default'] if 'default' in self._values else None
 
         # make sense of the default
         if self._default and len(self._default) >= 2 and self._default[0] == "'" and self._default[-1] == "'":
             self._default = self._default.strip("'")
-            self._warnings.append('Default value for numeric column %s does not need to be quoted' % self._name)
+            self._parsing_warnings.append(
+                "Column '%s' has a numeric type but its default value is a string" % self._name
+            )
         elif self._default and self._default.lower() == 'null':
             self._default = None
-
-        if self._default is None and not self._null:
-            self._warnings.append(
-                'Column %s is not null and has no default: you should set a default to avoid MySQL warnings' %
-                (self._name)
-            )
-
-        # only a few types of field are allowed to have decimals
-        if not self._column_type.lower() in self.allowed_types:
-            self._errors.append(
-                'Column of type %s is not allowed to have a decimal length for column %s' %
-                (self._column_type, self._name)
-            )
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_enum.py b/mygrations/formats/mysql/file_reader/parsers/type_enum.py
index 16b9b28..424b2bf 100644
--- a/mygrations/formats/mysql/file_reader/parsers/type_enum.py
+++ b/mygrations/formats/mysql/file_reader/parsers/type_enum.py
@@ -1,6 +1,6 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.column import column
-class type_enum(parser, column):
+from mygrations.core.parse.parser import Parser
+from .type import Type
+class TypeEnum(Parser, Type):
 
     allowed_types = {'set': True, 'enum': True}
 
@@ -21,7 +21,7 @@ class type_enum(parser, column):
         'value': '('
     }, {
         'type': 'delimited',
-        'name': 'values',
+        'name': 'enum_values',
         'quote': "'",
         'separator': ','
     }, {
@@ -53,23 +53,20 @@ class type_enum(parser, column):
         'name': 'ending_comma'
     }]
 
-    def __init__(self, rules=[]):
-
-        super().__init__(rules)
-
-        self._errors = []
-        self._warnings = []
-        self.values = []
-
     def process(self):
 
         self.has_comma = True if 'ending_comma' in self._values else False
 
+        self._parsing_errors = []
+        self._parsing_warnings = []
+        self._schema_errors = []
+        self._schema_warnings = []
         self._name = self._values['name'].strip('`')
         self._column_type = self._values['type']
-        self.values = self._values['values']
-        self._length = self.values
+        self._enum_values = self._values['enum_values']
+        self._length = self._enum_values
         self._null = False if 'NOT NULL' in self._values else True
+        self._has_default = 'default' in self._values
         self._default = self._values['default'] if 'default' in self._values else None
         self._character_set = self._values['character_set'] if 'character_set' in self._values else None
         self._collate = self._values['collate'] if 'collate' in self._values else None
@@ -88,24 +85,6 @@ def process(self):
             if self._default.lower() == 'null':
                 self._default = None
             else:
-                self._warnings.append(
+                self._parsing_warnings.append(
                     'Default value of "%s" should have quotes for field %s' % (self._default, self._name)
                 )
-
-        if self._default and not (self._default in self.values):
-            self._errors.append(
-                "Column %s has default value of %s but this is not an allowed value" % (self._name, self._default)
-            )
-
-        if self._default is None and not self._null:
-            self._warnings.append(
-                'Column %s is not null and has no default: you should set a default to avoid MySQL warnings' %
-                (self._name)
-            )
-
-        # only a few types of field are allowed to use this
-        if not self._column_type.lower() in self.allowed_types:
-            self._errors.append(
-                'Column of type %s is not allowed to have a list of values for column %s' %
-                (self._column_type, self._name)
-            )
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_numeric.py b/mygrations/formats/mysql/file_reader/parsers/type_numeric.py
index cfd3ef0..3e23ba9 100644
--- a/mygrations/formats/mysql/file_reader/parsers/type_numeric.py
+++ b/mygrations/formats/mysql/file_reader/parsers/type_numeric.py
@@ -1,6 +1,6 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.column import column
-class type_numeric(parser, column):
+from mygrations.core.parse.parser import Parser
+from .type import Type
+class TypeNumeric(Parser, Type):
 
     allowed_types = {
         'bit': True,
@@ -64,47 +64,27 @@ class type_numeric(parser, column):
 
     def process(self):
 
-        self._errors = []
-        self._warnings = []
         self.has_comma = True if 'ending_comma' in self._values else False
 
+        self._parsing_errors = []
+        self._parsing_warnings = []
+        self._schema_errors = []
+        self._schema_warnings = []
         self._name = self._values['name'].strip('`')
         self._column_type = self._values['type']
         self._length = self._values['length']
         self._unsigned = True if 'UNSIGNED' in self._values else False
         self._null = False if 'NOT NULL' in self._values else True
+        self._has_default = 'default' in self._values
         self._default = self._values['default'] if 'default' in self._values else None
         self._auto_increment = True if 'AUTO_INCREMENT' in self._values else False
         self.is_char = self._column_type in ['char', 'varchar']
 
-        # double check unsigned
-        if self._unsigned and self.is_char:
-            self._errors.append("Column %s is a character type and cannot be unsigned" % self._name)
-
         # make sense of the default
         if self._default and len(self._default) >= 2 and self._default[0] == "'" and self._default[-1] == "'":
             self._default = self._default.strip("'")
-            if not self.is_char:
-                self._warnings.append('Default value for numeric column %s does not need to be quoted' % self._name)
         elif self._default:
             if self._default.lower() == 'null':
                 self._default = None
             elif self.is_char:
-                self._warnings.append('Default value for column %s should be quoted' % self._name)
-
-        if self._default is None and not self._null and not self._auto_increment:
-            self._warnings.append(
-                'Column %s is not null and has no default: you should set a default to avoid MySQL warnings' %
-                (self._name)
-            )
-
-        if self._auto_increment and self.is_char:
-            self._errors.append(
-                'Column %s is an auto increment character field: only numeric fields can auto increment' % (self._name)
-            )
-
-        # only a few types of field are allowed to have decimals
-        if not self._column_type.lower() in self.allowed_types:
-            self._errors.append(
-                'Column of type %s is not allowed to have a length for column %s' % (self._column_type, self._name)
-            )
+                self._parsing_warnings.append('Default value for column %s should be quoted' % self._name)
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_plain.py b/mygrations/formats/mysql/file_reader/parsers/type_plain.py
index 1e5460d..9ce8e0b 100644
--- a/mygrations/formats/mysql/file_reader/parsers/type_plain.py
+++ b/mygrations/formats/mysql/file_reader/parsers/type_plain.py
@@ -1,6 +1,6 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.column import column
-class type_plain(parser, column):
+from mygrations.core.parse.parser import Parser
+from .type import Type
+class TypePlain(Parser, Type):
 
     has_comma = False
 
@@ -33,20 +33,17 @@ def process(self):
 
         self.has_comma = True if 'ending_comma' in self._values else False
 
-        self._errors = []
-        self._warnings = []
+        self._parsing_errors = []
+        self._parsing_warnings = []
+        self._schema_errors = []
+        self._schema_warnings = []
         self._name = self._values['name'].strip('`')
         self._length = ''
         self._column_type = self._values['type']
+        self._has_default = 'default' in self._values
         self._default = self._values['default'].strip("'") if 'default' in self._values else None
         self._null = False if 'NOT NULL' in self._values else True
 
         # make sense of the default
         if self._default and self._default.lower() == 'null':
             self._default = None
-
-        if self._default is None and not self._null:
-            self._warnings.append(
-                'Column %s is not null and has no default: you should set a default to avoid MySQL warnings' %
-                (self._name)
-            )
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_text.py b/mygrations/formats/mysql/file_reader/parsers/type_text.py
index f50376d..246f227 100644
--- a/mygrations/formats/mysql/file_reader/parsers/type_text.py
+++ b/mygrations/formats/mysql/file_reader/parsers/type_text.py
@@ -1,6 +1,6 @@
-from mygrations.core.parse.parser import parser
-from mygrations.formats.mysql.definitions.column import column
-class type_text(parser, column):
+from mygrations.core.parse.parser import Parser
+from .type import Type
+class TypeText(Parser, Type):
 
     #***** Lint error if default is found for text field ******
     allowed_types = {
@@ -92,22 +92,26 @@ def process(self):
 
         self.has_comma = True if 'ending_comma' in self._values else False
 
-        self._errors = []
-        self._warnings = []
+        self._parsing_errors = []
+        self._parsing_warnings = []
+        self._schema_errors = []
+        self._schema_warnings = []
         self._name = self._values['name'].strip('`')
         self._length = ''
-        self._default = None
+        self._has_default = 'default' in self._values
+        self._default = self._values['default'] if 'default' in self._values else None
         self._column_type = self._values['type']
         self._null = False if 'NOT NULL' in self._values else True
         self._character_set = self._values['character_set'].strip("'") if 'character_set' in self._values else ''
         self._collate = self._values['collate'].strip("'") if 'collate' in self._values else ''
 
-        if self._column_type.lower() == 'datetime':
-            pass
-        elif not self._column_type.lower() in self.allowed_types:
-            self._errors.append('Column of type %s must have a length for column %s' % (self._column_type, self._name))
-        elif 'default' in self._values:
-            self._errors.append(
-                'Column of type %s is not allowed to have a default value for column %s' %
-                (self._column_type, self._name)
-            )
+        # slightly more work on the default
+        if self._default and len(self._default) >= 2 and self._default[0] == "'" and self._default[-1] == "'":
+            self._default = self._default.strip("'")
+        elif self._default:
+            if self._default.lower() == 'null':
+                self._default = None
+            elif not self._default.isdigit():
+                self._parsing_warnings.append(
+                    'Default value of "%s" should have quotes for field %s' % (self._default, self._name)
+                )
diff --git a/mygrations/formats/mysql/file_reader/reader.py b/mygrations/formats/mysql/file_reader/reader.py
index 393afd6..92d8291 100644
--- a/mygrations/formats/mysql/file_reader/reader.py
+++ b/mygrations/formats/mysql/file_reader/reader.py
@@ -1,14 +1,18 @@
 import io, os
-from .comment_parser import comment_parser
-from .create_parser import create_parser
-from .insert_parser import insert_parser
-class reader(object):
+from .comment_parser import CommentParser
+from .create_parser import CreateParser
+from .insert_parser import InsertParser
+class Reader:
     def __init__(self):
 
         self._tables = {}
         self._rows = {}
-        self._errors = []
-        self._warnings = []
+        self._global_errors = []
+        self._global_warnings = []
+        self._parsing_errors = []
+        self._parsing_warnings = []
+        self._schema_errors = []
+        self._schema_warnings = []
         self._matched = False
 
     @property
@@ -21,29 +25,65 @@ def matched(self):
         return self._matched
 
     @property
-    def errors(self):
+    def parsing_errors(self):
         """ Public getter.  Returns a list of parsing errors
 
         :returns: A list of parsing errors
         :rtype: list
         """
-        return [] if self._errors is None else self._errors
+        return [] if self._parsing_errors is None else self._parsing_errors
 
     @property
-    def warnings(self):
+    def parsing_warnings(self):
         """ Public getter.  Returns a list of parsing/table warnings
 
         :returns: A list of parsing/table warnings
         :rtype: list
         """
-        return [] if self._warnings is None else self._warnings
+        return [] if self._parsing_warnings is None else self._parsing_warnings
+
+    @property
+    def schema_errors(self):
+        """ Public getter.  Returns a list of schema errors
+
+        :returns: A list of schema errors
+        :rtype: list
+        """
+        return [] if self._schema_errors is None else self._schema_errors
+
+    @property
+    def schema_warnings(self):
+        """ Public getter.  Returns a list of schema warnings
+
+        :returns: A list of schema warnings
+        :rtype: list
+        """
+        return [] if self._schema_warnings is None else self._schema_warnings
+
+    @property
+    def global_errors(self):
+        """ Public getter.  Returns a list of schema errors
+
+        :returns: A list of schema errors
+        :rtype: list
+        """
+        return [] if self._global_errors is None else self._global_errors
+
+    @property
+    def global_warnings(self):
+        """ Public getter.  Returns a list of schema warnings
+
+        :returns: A list of schema warnings
+        :rtype: list
+        """
+        return [] if self._global_warnings is None else self._global_warnings
 
     @property
     def tables(self):
         """ Public getter.  Returns a list of table definitions
 
         :returns: A list of table definitions
-        :rtype: [mygrations.formats.mysql.definitions.table]
+        :rtype: dict[mygrations.formats.mysql.definitions.table]
         """
         return self._tables
 
@@ -127,16 +167,16 @@ def parse(self, filename):
             # now we are looking for one of three things:
             # comment, create, insert
             if data[:2] == '--' or data[:2] == '/*' or data[0] == '#':
-                parser = comment_parser()
+                parser = CommentParser()
                 data = parser.parse(data)
 
             elif data[:6].lower() == 'create':
-                parser = create_parser()
+                parser = CreateParser()
                 data = parser.parse(data)
                 self._tables[parser.name] = parser
 
             elif data[:6].lower() == 'insert':
-                parser = insert_parser()
+                parser = InsertParser()
                 data = parser.parse(data)
                 if not parser.table in self._rows:
                     self._rows[parser.table] = []
@@ -144,13 +184,10 @@ def parse(self, filename):
                 self._rows[parser.table].append(parser)
 
             else:
-                self._errors.append("Unrecognized MySQL command: %s%s" % (data, self._filename_notice()))
+                if self._global_errors is None:
+                    self._global_errors = []
+                self._global_errors.append("Unrecognized MySQL command: %s%s" % (data, self._filename_notice()))
                 return data
 
-            for error in parser.errors:
-                self._errors.append('%s%s' % (error, self._filename_notice()))
-            for warning in parser.warnings:
-                self._warnings.append('%s%s' % (warning, self._filename_notice()))
-
         self._matched = True
         return data
diff --git a/mygrations/formats/mysql/mygrations/__init__.py b/mygrations/formats/mysql/mygrations/__init__.py
index 8b13789..e69de29 100644
--- a/mygrations/formats/mysql/mygrations/__init__.py
+++ b/mygrations/formats/mysql/mygrations/__init__.py
@@ -1,1 +0,0 @@
-
diff --git a/mygrations/formats/mysql/mygrations/mygration.py b/mygrations/formats/mysql/mygrations/mygration.py
index b96ec55..0aaf321 100644
--- a/mygrations/formats/mysql/mygrations/mygration.py
+++ b/mygrations/formats/mysql/mygrations/mygration.py
@@ -1,12 +1,12 @@
 import copy
 
-from mygrations.formats.mysql.definitions.database import database
-from mygrations.formats.mysql.mygrations.operations.alter_table import alter_table
-from mygrations.formats.mysql.mygrations.operations.add_constraint import add_constraint
-from mygrations.formats.mysql.mygrations.operations.remove_table import remove_table
-from mygrations.formats.mysql.mygrations.operations.disable_checks import disable_checks
-from mygrations.formats.mysql.mygrations.operations.enable_checks import enable_checks
-class mygration:
+from mygrations.formats.mysql.definitions.database import Database
+from mygrations.formats.mysql.mygrations.operations.alter_table import AlterTable
+from mygrations.formats.mysql.mygrations.operations.add_constraint import AddConstraint
+from mygrations.formats.mysql.mygrations.operations.remove_table import RemoveTable
+from mygrations.formats.mysql.mygrations.operations.disable_checks import DisableChecks
+from mygrations.formats.mysql.mygrations.operations.enable_checks import EnableChecks
+class Mygration:
     """ Creates a migration plan to update a database to a given spec.
 
     If only one database is passed in, it treats it as a structure to migrate
@@ -28,7 +28,6 @@ class mygration:
     7. Apply all foreign key constraints that were previously ignored in steps 4 & 6
     8. Remove any tables that need to be removed
     """
-
     def __init__(self, db_to, db_from=None, disable_checks=True):
         """ Create a migration plan
 
@@ -46,10 +45,10 @@ def __init__(self, db_to, db_from=None, disable_checks=True):
 
         # first things first: stop if we have any FK errors in the database
         # we are migrating to
-        self._errors_1215 = []
-        self._errors_1215 = self.db_to.errors_1215
+        self._errors = []
+        self._errors = self.db_to.errors
 
-        if not self._errors_1215:
+        if not self._errors:
             self._operations = self._process()
         else:
             self._operations = None
@@ -73,13 +72,13 @@ def operations(self):
         return self._operations
 
     @property
-    def errors_1215(self):
+    def errors(self):
         """ Public getter.  Returns list of 1215 errors (as strings)
 
         :returns: A list of 1215 error messages
         :rtype: [string]
         """
-        return self._errors_1215
+        return self._errors
 
     def __len__(self):
         return len(self._operations)
@@ -123,7 +122,7 @@ def _process(self):
         """
         operations = []
         if self._disable_fk_checks:
-            operations.append(disable_checks())
+            operations.append(DisableChecks())
 
         # what tables have been added/changed/removed
         db_from_tables = self.db_from.tables if self.db_from else {}
@@ -138,10 +137,10 @@ def _process(self):
             operations.extend(source_table.to(target_table))
 
         for table_name in tables_to_remove:
-            operations.append(remove_table(table_name))
+            operations.append(RemoveTable(table_name))
 
         if self._disable_fk_checks:
-            operations.append(enable_checks())
+            operations.append(EnableChecks())
 
         return operations
 
@@ -170,7 +169,7 @@ def _old_process(self):
         # Our primary output is a list of operations, but there is more that we need
         # to make all of this happen.  We need a database to keep track of the
         # state of the database we are building after each operation is "applied"
-        tracking_db = copy.deepcopy(self.db_from) if self.db_from else database()
+        tracking_db = copy.deepcopy(self.db_from) if self.db_from else Database()
 
         # a little bit of extra processing will simplify our algorithm by a good chunk.
         # The situation is much more complicated when we have a database we are migrating
@@ -243,9 +242,9 @@ def _old_process(self):
             new_table = self.db_to.tables[table_to_add]
             bad_constraints = tracking_db.unfulfilled_fks(new_table)
             new_table_copy = copy.deepcopy(new_table)
-            create_fks = alter_table(table_to_add)
+            create_fks = AlterTable(table_to_add)
             for constraint in bad_constraints.values():
-                create_fks.add_operation(add_constraint(constraint['foreign_key']))
+                create_fks.add_operation(AddConstraint(constraint['foreign_key']))
                 new_table_copy.remove_constraint(constraint['foreign_key'])
             operations.append(new_table_copy.create())
             fk_operations.append(create_fks)
@@ -256,7 +255,7 @@ def _old_process(self):
 
         # finally remove any tables
         for table_to_remove in tables_to_remove:
-            operations.append(remove_table(table_to_remove))
+            operations.append(RemoveTable(table_to_remove))
             tracking_db.remove_table(table_to_remove)
 
         # all done!!!
diff --git a/mygrations/formats/mysql/mygrations/operations/__init__.py b/mygrations/formats/mysql/mygrations/operations/__init__.py
index 8b13789..e69de29 100644
--- a/mygrations/formats/mysql/mygrations/operations/__init__.py
+++ b/mygrations/formats/mysql/mygrations/operations/__init__.py
@@ -1,1 +0,0 @@
-
diff --git a/mygrations/formats/mysql/mygrations/operations/add_column.py b/mygrations/formats/mysql/mygrations/operations/add_column.py
index 2a5f860..28ffdb3 100644
--- a/mygrations/formats/mysql/mygrations/operations/add_column.py
+++ b/mygrations/formats/mysql/mygrations/operations/add_column.py
@@ -1,6 +1,5 @@
-class add_column:
+class AddColumn:
     """ Generates a partial SQL command to add a column to a table """
-
     def __init__(self, column, position=None):
         """ Create an add column operation
 
diff --git a/mygrations/formats/mysql/mygrations/operations/add_constraint.py b/mygrations/formats/mysql/mygrations/operations/add_constraint.py
index d822d51..25664c9 100644
--- a/mygrations/formats/mysql/mygrations/operations/add_constraint.py
+++ b/mygrations/formats/mysql/mygrations/operations/add_constraint.py
@@ -1,6 +1,5 @@
-class add_constraint:
+class AddConstraint:
     """ Generates a partial SQL command to add a FK to a table """
-
     def __init__(self, constraint):
         self.constraint = constraint
 
diff --git a/mygrations/formats/mysql/mygrations/operations/add_key.py b/mygrations/formats/mysql/mygrations/operations/add_key.py
index a782412..7b9cb21 100644
--- a/mygrations/formats/mysql/mygrations/operations/add_key.py
+++ b/mygrations/formats/mysql/mygrations/operations/add_key.py
@@ -1,6 +1,5 @@
-class add_key:
+class AddKey:
     """ Generates a partial SQL command to add a key to a table """
-
     def __init__(self, key):
         self.key = key
 
diff --git a/mygrations/formats/mysql/mygrations/operations/alter_table.py b/mygrations/formats/mysql/mygrations/operations/alter_table.py
index fa63f50..c5b36e6 100644
--- a/mygrations/formats/mysql/mygrations/operations/alter_table.py
+++ b/mygrations/formats/mysql/mygrations/operations/alter_table.py
@@ -1,6 +1,5 @@
-class alter_table:
+class AlterTable:
     """ Generates an SQL command to alter a table """
-
     def __init__(self, table):
         self._table = table
         self._operations = []
diff --git a/mygrations/formats/mysql/mygrations/operations/change_column.py b/mygrations/formats/mysql/mygrations/operations/change_column.py
index 7146217..1e60fcc 100644
--- a/mygrations/formats/mysql/mygrations/operations/change_column.py
+++ b/mygrations/formats/mysql/mygrations/operations/change_column.py
@@ -1,6 +1,5 @@
-class change_column:
+class ChangeColumn:
     """ Generates a partial SQL command to change a column in a table """
-
     def __init__(self, column):
         self.column = column
 
diff --git a/mygrations/formats/mysql/mygrations/operations/change_constraint.py b/mygrations/formats/mysql/mygrations/operations/change_constraint.py
index 6ea2e05..c704067 100644
--- a/mygrations/formats/mysql/mygrations/operations/change_constraint.py
+++ b/mygrations/formats/mysql/mygrations/operations/change_constraint.py
@@ -1,6 +1,5 @@
-class change_constraint:
+class ChangeConstraint:
     """ Generates a partial SQL command to change a foreign key in a table """
-
     def __init__(self, constraint):
         self.constraint = constraint
 
diff --git a/mygrations/formats/mysql/mygrations/operations/change_key.py b/mygrations/formats/mysql/mygrations/operations/change_key.py
index 404c532..c4647a7 100644
--- a/mygrations/formats/mysql/mygrations/operations/change_key.py
+++ b/mygrations/formats/mysql/mygrations/operations/change_key.py
@@ -1,6 +1,5 @@
-class change_key:
+class ChangeKey:
     """ Generates a partial SQL command to change a key in a table """
-
     def __init__(self, key):
         self.key = key
 
diff --git a/mygrations/formats/mysql/mygrations/operations/create_table.py b/mygrations/formats/mysql/mygrations/operations/create_table.py
index afe2082..ee1a083 100644
--- a/mygrations/formats/mysql/mygrations/operations/create_table.py
+++ b/mygrations/formats/mysql/mygrations/operations/create_table.py
@@ -1,6 +1,5 @@
-class create_table:
+class CreateTable:
     """ Generates an SQL command to create a table """
-
     def __init__(self, table, nice=False):
         """ Create table constructor
 
diff --git a/mygrations/formats/mysql/mygrations/operations/disable_checks.py b/mygrations/formats/mysql/mygrations/operations/disable_checks.py
index f4f9c40..d9cb1f0 100644
--- a/mygrations/formats/mysql/mygrations/operations/disable_checks.py
+++ b/mygrations/formats/mysql/mygrations/operations/disable_checks.py
@@ -1,5 +1,4 @@
-class disable_checks:
+class DisableChecks:
     """ Generates an SQL command to disable foreign key checks """
-
     def __str__(self):
         return 'SET FOREIGN_KEY_CHECKS=0;'
diff --git a/mygrations/formats/mysql/mygrations/operations/enable_checks.py b/mygrations/formats/mysql/mygrations/operations/enable_checks.py
index 3d43538..0be09c5 100644
--- a/mygrations/formats/mysql/mygrations/operations/enable_checks.py
+++ b/mygrations/formats/mysql/mygrations/operations/enable_checks.py
@@ -1,5 +1,4 @@
-class enable_checks:
+class EnableChecks:
     """ Generates an SQL command to re-enable foreign key checks """
-
     def __str__(self):
         return 'SET FOREIGN_KEY_CHECKS=1;'
diff --git a/mygrations/formats/mysql/mygrations/operations/remove_column.py b/mygrations/formats/mysql/mygrations/operations/remove_column.py
index d1d05b7..e34db66 100644
--- a/mygrations/formats/mysql/mygrations/operations/remove_column.py
+++ b/mygrations/formats/mysql/mygrations/operations/remove_column.py
@@ -1,6 +1,5 @@
-class remove_column:
+class RemoveColumn:
     """ Generates a partial SQL command to remove a column from a table """
-
     def __init__(self, column):
         self.column = column
 
diff --git a/mygrations/formats/mysql/mygrations/operations/remove_constraint.py b/mygrations/formats/mysql/mygrations/operations/remove_constraint.py
index 7361b50..8c34ccf 100644
--- a/mygrations/formats/mysql/mygrations/operations/remove_constraint.py
+++ b/mygrations/formats/mysql/mygrations/operations/remove_constraint.py
@@ -1,6 +1,5 @@
-class remove_constraint:
+class RemoveConstraint:
     """ Generates a partial SQL command to drop a foreign key from a table """
-
     def __init__(self, constraint):
         self.constraint = constraint
 
diff --git a/mygrations/formats/mysql/mygrations/operations/remove_key.py b/mygrations/formats/mysql/mygrations/operations/remove_key.py
index c4efef7..65ea169 100644
--- a/mygrations/formats/mysql/mygrations/operations/remove_key.py
+++ b/mygrations/formats/mysql/mygrations/operations/remove_key.py
@@ -1,6 +1,5 @@
-class remove_key:
+class RemoveKey:
     """ Generates a partial SQL command to drop a key from a table """
-
     def __init__(self, key):
         self.key = key
 
diff --git a/mygrations/formats/mysql/mygrations/operations/remove_table.py b/mygrations/formats/mysql/mygrations/operations/remove_table.py
index cc30e96..9599814 100644
--- a/mygrations/formats/mysql/mygrations/operations/remove_table.py
+++ b/mygrations/formats/mysql/mygrations/operations/remove_table.py
@@ -1,6 +1,5 @@
-class remove_table:
+class RemoveTable:
     """ Generates an SQL command to drop a table """
-
     def __init__(self, table_name):
         if type(table_name) != str:
             self._table_name = table_name.name
diff --git a/mygrations/formats/mysql/mygrations/operations/row_delete.py b/mygrations/formats/mysql/mygrations/operations/row_delete.py
index 65732fd..3bff403 100644
--- a/mygrations/formats/mysql/mygrations/operations/row_delete.py
+++ b/mygrations/formats/mysql/mygrations/operations/row_delete.py
@@ -1,6 +1,5 @@
-class row_delete:
+class RowDelete:
     """ Generates an SQL command to delete a record """
-
     def __init__(self, table_name, row_id):
         if type(table_name) != str:
             self._table_name = table_name.name
@@ -19,4 +18,4 @@ def table_name(self):
         return self._table_name
 
     def __str__(self):
-        return 'DELETE FROM `%s` WHERE id=%s;' % (self._table_name, self.row_id)
+        return "DELETE FROM `%s` WHERE id='%s';" % (self._table_name, self.row_id)
diff --git a/mygrations/formats/mysql/mygrations/operations/row_insert.py b/mygrations/formats/mysql/mygrations/operations/row_insert.py
index 24e4633..2ef1bf0 100644
--- a/mygrations/formats/mysql/mygrations/operations/row_insert.py
+++ b/mygrations/formats/mysql/mygrations/operations/row_insert.py
@@ -1,6 +1,5 @@
-class row_insert:
+class RowInsert:
     """ Generates an SQL command to insert a record """
-
     def __init__(self, table_name, data):
         if type(table_name) != str:
             self._table_name = table_name.name
diff --git a/mygrations/formats/mysql/mygrations/operations/row_update.py b/mygrations/formats/mysql/mygrations/operations/row_update.py
index 5f5a846..116a2a5 100644
--- a/mygrations/formats/mysql/mygrations/operations/row_update.py
+++ b/mygrations/formats/mysql/mygrations/operations/row_update.py
@@ -1,6 +1,5 @@
-class row_update:
+class RowUpdate:
     """ Generates an SQL command to update a record """
-
     def __init__(self, table_name, data):
         if 'id' not in data:
             raise KeyError('Missing `id` column needed for update')
@@ -26,4 +25,4 @@ def __str__(self):
             "`%s`='%s'" % (key, str(val).replace('\\', '\\\\')) if val is not None else ('`%s`=NULL' % key)
             for (key, val) in self.data.items() if key != 'id'
         ])
-        return 'UPDATE `%s` SET %s WHERE id=%s;' % (self._table_name, updates, self.data['id'])
+        return "UPDATE `%s` SET %s WHERE id='%s';" % (self._table_name, updates, self.data['id'])
diff --git a/mygrations/formats/mysql/mygrations/row_mygration.py b/mygrations/formats/mysql/mygrations/row_mygration.py
index 1dcfc01..19425a1 100644
--- a/mygrations/formats/mysql/mygrations/row_mygration.py
+++ b/mygrations/formats/mysql/mygrations/row_mygration.py
@@ -1,16 +1,15 @@
 import copy
 
-from mygrations.formats.mysql.definitions.database import database
-from mygrations.formats.mysql.mygrations.operations.row_insert import row_insert
-from mygrations.formats.mysql.mygrations.operations.row_delete import row_delete
-from mygrations.formats.mysql.mygrations.operations.row_update import row_update
-class row_mygration:
+from mygrations.formats.mysql.definitions.database import Database
+from mygrations.formats.mysql.mygrations.operations.row_insert import RowInsert
+from mygrations.formats.mysql.mygrations.operations.row_delete import RowDelete
+from mygrations.formats.mysql.mygrations.operations.row_update import RowUpdate
+class RowMygration:
     """ Creates a migration plan to sync the rows of a database for tables being tracked.
 
     This doesn't sync rows for all tables but only those tables marked as
     tracking_rows
     """
-
     def __init__(self, db_to, db_from=None):
         """ Create a migration plan
 
diff --git a/mygrations/helpers/db_credentials.py b/mygrations/helpers/db_credentials.py
index cbd4399..31358ac 100644
--- a/mygrations/helpers/db_credentials.py
+++ b/mygrations/helpers/db_credentials.py
@@ -1,13 +1,11 @@
 from .dotenv import dotenv
-class db_credentials(dict):
-
-    config = {}
-    env = {}
-
-    key_map = {'hostname_key': 'host', 'database_key': 'db', 'username_key': 'user', 'password_key': 'passwd'}
+class DbCredentials:
+    config = None
+    env = None
+    credentials = None
+    key_map = {'hostname_key': 'host', 'database_key': 'database', 'username_key': 'user', 'password_key': 'password'}
 
     def __init__(self, env, config):
-
         # Config represents the contents of the mygrate.conf file.
         # env represents the contents of the applications .env file.
         # Normally the config file will tell us which keys in the .env
@@ -21,13 +19,14 @@ def __init__(self, env, config):
                 raise ValueError("Cannot find required key %s in mygrate.conf" % key)
 
         # now make sure we have it in our .env
-        credentials = {}
+        self.credentials = {}
         for (key, val) in self.key_map.items():
 
             key_name = self.config[key]
             if not key_name in self.env:
                 raise ValueError("Failed to fulfill %s: key %s not found in .env" % (val, key_name))
 
-            credentials[val] = self.env[key_name]
+            self.credentials[val] = self.env[key_name]
 
-        super().__init__(credentials)
+    def __getitem__(self, key):
+        return self.credentials[key]
diff --git a/mygrations/mygrate.py b/mygrations/mygrate.py
index 402c110..cff0cee 100644
--- a/mygrations/mygrate.py
+++ b/mygrations/mygrate.py
@@ -1,12 +1,11 @@
-__ver__ = '0.1'
+__ver__ = '1.0'
 
 from .core import commands
-from .helpers.dotenv import dotenv
-class mygrate(object):
+class mygrate:
 
     command = ''
-    options = {}
-    config = {}
+    options = None
+    config = None
 
     def __init__(self, command, options={}):
 
diff --git a/setup.py b/setup.py
index 09d6761..443b842 100644
--- a/setup.py
+++ b/setup.py
@@ -18,7 +18,7 @@
 
 setup(
     name='mygrations',
-    version='0.13.0',
+    version='1.0.0',
     description='A general purpose migration tool for managing MySQL updates',
     long_description=long_description,
     long_description_content_type='text/markdown',
@@ -26,7 +26,7 @@
     author='Conor Mancone',
     author_email='cmancone@gmail.com',
     license='MIT',
-    download_url='https://github.com/cmancone/mygrations/archive/v0.13.tar.gz',
+    download_url='https://github.com/cmancone/mygrations/archive/v1.0.0.tar.gz',
 
     # See https://pypi.python.org/pypi?%3Aaction=list_classifiers
     classifiers=[
@@ -38,7 +38,10 @@
     ],
     keywords='setuptools development migrations mysql',
     packages=find_packages(exclude=['contrib', 'docs', 'tests']),
-
+    python_requires=">=3.6",
+    install_requires=[
+        'PyMySQL',
+    ],
     # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files
     # data_files=[('/usr/local/bin', ['mygrate.py'])],
 )


Test patch:
diff --git a/mygrations/core/definitions/columns/column_test.py b/mygrations/core/definitions/columns/column_test.py
new file mode 100644
index 0000000..708f082
--- /dev/null
+++ b/mygrations/core/definitions/columns/column_test.py
@@ -0,0 +1,64 @@
+import unittest
+from .column import Column
+class MockColumn(Column):
+    _allowed_column_types = ['INT']
+class TestColumn(unittest.TestCase):
+    def test_string_conversion(self):
+        column = MockColumn(
+            'test_column',
+            'INT',
+            length=25,
+            null=False,
+            default=0,
+            unsigned=True,
+        )
+
+        self.assertEquals('`test_column` INT(25) UNSIGNED NOT NULL DEFAULT 0', str(column))
+
+    def test_is_the_same(self):
+        attrs = {
+            'length': 5,
+            'null': False,
+            'unsigned': True,
+            'default': '5',
+        }
+        column1 = MockColumn('test_column', 'INT', **attrs)
+        column2 = MockColumn('test_column', 'INT', **attrs)
+
+        self.assertTrue(column1.is_really_the_same_as(column2))
+        self.assertTrue(column2.is_really_the_same_as(column1))
+
+        diff = {
+            'length': 6,
+            'null': None,
+            'unsigned': False,
+            'default': 5,
+        }
+        for (key, val) in diff.items():
+            column1 = MockColumn('test_column', 'INT', **attrs)
+            column2 = MockColumn('test_column', 'INT', **{**attrs, **{key: val}})
+            self.assertFalse(column1.is_really_the_same_as(column2))
+            self.assertFalse(column2.is_really_the_same_as(column1))
+
+        column1 = MockColumn('test_column', 'INT', **attrs)
+        column2 = MockColumn('test_column', 'INT', **{**attrs, 'default': None})
+        self.assertFalse(column1.is_really_the_same_as(column2))
+        self.assertFalse(column2.is_really_the_same_as(column1))
+
+    def test_not_null_needs_default(self):
+        column = MockColumn(
+            'test_column',
+            'INT',
+            length=25,
+            null=False,
+            default=None,
+        )
+        self.assertEquals(
+            [
+                'Column test_column does not allow null values and has no default: you should set a default to avoid warnings'
+            ],
+            column.schema_warnings,
+        )
+
+        column = MockColumn('test_column', 'INT')
+        self.assertEquals([], column.schema_warnings)
diff --git a/mygrations/core/definitions/columns/enum_test.py b/mygrations/core/definitions/columns/enum_test.py
new file mode 100644
index 0000000..c9ead62
--- /dev/null
+++ b/mygrations/core/definitions/columns/enum_test.py
@@ -0,0 +1,16 @@
+import unittest
+from .enum import Enum
+class TestEnum(unittest.TestCase):
+    def test_string_conversion(self):
+        column = Enum('test_column', 'ENUM', enum_values=['asdf', 'qwerty'], default='qwerty')
+
+        self.assertEquals("`test_column` ENUM('asdf', 'qwerty') DEFAULT \'qwerty\'", str(column))
+        self.assertEquals([], column.schema_errors)
+        self.assertEquals([], column.schema_warnings)
+
+    def test_default_errors(self):
+        column = Enum('test_column', 'ENUM', enum_values=['asdf', 'qwerty'], default='adf')
+        self.assertEquals(["Default value for 'ENUM' column 'test_column' is not in the list of allowed values"],
+                          column.schema_errors)
+
+        self.assertEquals([], Enum('test_column', 'ENUM', enum_values=['asdf', 'qwerty']).schema_errors)
diff --git a/mygrations/core/definitions/columns/numeric_test.py b/mygrations/core/definitions/columns/numeric_test.py
new file mode 100644
index 0000000..9087929
--- /dev/null
+++ b/mygrations/core/definitions/columns/numeric_test.py
@@ -0,0 +1,54 @@
+import unittest
+from .numeric import Numeric
+class NumericTest(unittest.TestCase):
+    def test_is_the_same(self):
+        attrs = {'length': '5,2', 'default': '0.00'}
+
+        column1 = Numeric('test', 'DECIMAL', **attrs)
+        column2 = Numeric('test', 'DECIMAL', **attrs)
+        self.assertTrue(column1.is_really_the_same_as(column2))
+        self.assertTrue(column2.is_really_the_same_as(column1))
+
+        column1 = Numeric('test', 'DECIMAL', **attrs)
+        column2 = Numeric('test', 'DECIMAL', **{**attrs, 'default': '0.02'})
+        self.assertFalse(column1.is_really_the_same_as(column2))
+        self.assertFalse(column2.is_really_the_same_as(column1))
+
+        column1 = Numeric('test', 'DECIMAL', **attrs)
+        column2 = Numeric('test', 'DECIMAL', **{**attrs, 'default': '0.001'})
+        self.assertTrue(column1.is_really_the_same_as(column2))
+        self.assertTrue(column2.is_really_the_same_as(column1))
+
+        column1 = Numeric('test', 'DECIMAL', **attrs)
+        column2 = Numeric('test', 'DECIMAL', **{**attrs, 'default': 0})
+        self.assertTrue(column1.is_really_the_same_as(column2))
+        self.assertTrue(column2.is_really_the_same_as(column1))
+
+    def test_default_errors(self):
+        self.assertEquals([f"Column 'test' of type 'DECIMAL' cannot have a string value as a default"],
+                          Numeric('test', 'DECIMAL', default='').schema_errors)
+        self.assertEquals([f"Column 'test' of type 'INT' must have an integer value as a default"],
+                          Numeric('test', 'INT', default=5.0).schema_errors)
+        self.assertEquals([f"Column 'test' of type 'BIT' must have a default of 1 or 0"],
+                          Numeric('test', 'BIT', default=5).schema_errors)
+        self.assertEquals([], Numeric('test', 'BIT', default=1).schema_errors)
+        self.assertEquals([], Numeric('test', 'INT', default=5).schema_errors)
+        self.assertEquals([], Numeric('test', 'FLOAT', default=5.0).schema_errors)
+
+    def test_length_errors(self):
+        self.assertEquals([f"Column 'test' of type 'FLOAT' cannot have a length"],
+                          Numeric('test', 'FLOAT', length=5).schema_errors)
+        self.assertEquals([f"Column 'test' of type 'INT' must have an integer value as its length"],
+                          Numeric('test', 'INT', length='5,2').schema_errors)
+
+    def test_character_set_errors(self):
+        self.assertEquals([f"Column 'test' of type 'INT' cannot have a character set"],
+                          Numeric('test', 'INT', character_set='UTF-8').schema_errors)
+        self.assertEquals([f"Column 'test' of type 'INT' cannot have a collate"],
+                          Numeric('test', 'INT', collate='UTF-8').schema_errors)
+
+    def test_auto_increment_errors(self):
+        self.assertEquals([f"Column 'test' of type 'FLOAT' cannot be an AUTO_INCREMENT"],
+                          Numeric('test', 'FLOAT', auto_increment=True).schema_errors)
+        self.assertEquals([], Numeric('test', 'FLOAT', auto_increment=False).schema_errors)
+        self.assertEquals([], Numeric('test', 'INT', auto_increment=True).schema_errors)
diff --git a/mygrations/core/definitions/columns/string_test.py b/mygrations/core/definitions/columns/string_test.py
new file mode 100644
index 0000000..25eec77
--- /dev/null
+++ b/mygrations/core/definitions/columns/string_test.py
@@ -0,0 +1,121 @@
+import unittest
+from .string import String
+class TestString(unittest.TestCase):
+    def test_string_conversion_with_collation(self):
+        column = String('test_column', 'VARCHAR', length=25, default='asdf', character_set='unicode', collate='sup')
+
+        self.assertEquals(
+            '`test_column` VARCHAR(25) DEFAULT \'asdf\' CHARACTER SET \'UNICODE\' COLLATE \'SUP\'', str(column)
+        )
+        self.assertEquals([], column.schema_errors)
+        self.assertEquals([], column.schema_warnings)
+
+    def test_is_the_same(self):
+        attrs = {
+            'length': 255,
+            'character_set': 'unicode',
+        }
+
+        column1 = String('test', 'VARCHAR', **attrs)
+        column2 = String('test', 'VARCHAR', **{**attrs, 'collate': 'hey'})
+
+        # since collate is only present for one they are the same
+        self.assertTrue(column1.is_really_the_same_as(column2))
+        self.assertTrue(column2.is_really_the_same_as(column1))
+
+        # but since they both have values but are different they are different
+        column3 = String('test', 'VARCHAR', **{**attrs, 'collate': 'sup'})
+        self.assertFalse(column3.is_really_the_same_as(column2))
+        self.assertFalse(column2.is_really_the_same_as(column3))
+
+    def test_no_default(self):
+        no_default = [
+            'TINYBLOB',
+            'BLOB',
+            'MEDIUMBLOB',
+            'LONGBLOB',
+            'TINYTEXT',
+            'TEXT',
+            'MEDIUMTEXT',
+            'LONGTEXT',
+            'JSON',
+        ]
+        for column_type in no_default:
+            column = String('test', column_type, default='Hey', has_default=True)
+            self.assertEquals(
+                [f"Column 'test' of type '{column_type}' cannot have a default"],
+                column.schema_errors,
+            )
+
+    def test_no_character_set_or_collate(self):
+        nope = [
+            'TINYBLOB',
+            'BLOB',
+            'MEDIUMBLOB',
+            'LONGBLOB',
+            'TINYTEXT',
+            'TEXT',
+            'MEDIUMTEXT',
+            'LONGTEXT',
+            'JSON',
+        ]
+        for column_type in nope:
+            column = String('test', column_type, character_set='Hey')
+            self.assertEquals(
+                [f"Column 'test' of type '{column_type}' cannot have a collation/character set"],
+                column.schema_errors,
+            )
+            column = String('test', column_type, collate='Hey')
+            self.assertEquals(
+                [f"Column 'test' of type '{column_type}' cannot have a collation/character set"],
+                column.schema_errors,
+            )
+
+    def test_no_length(self):
+        no_length = [
+            'TINYBLOB',
+            'BLOB',
+            'MEDIUMBLOB',
+            'LONGBLOB',
+            'TINYTEXT',
+            'TEXT',
+            'MEDIUMTEXT',
+            'LONGTEXT',
+            'JSON',
+        ]
+        for column_type in no_length:
+            column = String('test', column_type, length='28')
+            self.assertEquals(
+                [f'Column test of type {column_type} cannot have a length'],
+                column.schema_errors,
+            )
+
+    def test_binary_collation(self):
+        binary_only = [
+            'BINARY',
+            'VARBINARY',
+        ]
+        for column_type in binary_only:
+            column = String('test', column_type, character_set='Hey')
+            self.assertEquals(
+                [f"Column 'test' of type '{column_type}' can only have a collate/character set of BINARY"],
+                column.schema_errors,
+            )
+            column = String('test', column_type, collate='Hey')
+            self.assertEquals(
+                [f"Column 'test' of type '{column_type}' can only have a collate/character set of BINARY"],
+                column.schema_errors,
+            )
+            column = String('test', column_type, collate='binary')
+            self.assertEquals([], column.schema_errors)
+            column = String('test', column_type, character_set='binary')
+            self.assertEquals([], column.schema_errors)
+            column = String('test', column_type, character_set='binary', collate='binary')
+            self.assertEquals([], column.schema_errors)
+
+    def test_no_auto_increment(self):
+        column = String('test', 'VARCHAR', auto_increment=True)
+        self.assertEquals(
+            [f"Column 'test' of type 'VARCHAR' cannot be an AUTO_INCREMENT: only numeric columns can"],
+            column.schema_errors,
+        )
diff --git a/mygrations/core/definitions/constraint_test.py b/mygrations/core/definitions/constraint_test.py
new file mode 100644
index 0000000..1f1b076
--- /dev/null
+++ b/mygrations/core/definitions/constraint_test.py
@@ -0,0 +1,32 @@
+import unittest
+from .constraint import Constraint
+class TestConstraint(unittest.TestCase):
+    def test_can_create(self):
+        constraint = Constraint(
+            'user_id_fk',
+            'user_id',
+            'users',
+            'id',
+        )
+
+        self.assertEquals('user_id_fk', constraint.name)
+        self.assertEquals('user_id', constraint.column_name)
+        self.assertEquals('users', constraint.foreign_table_name)
+        self.assertEquals('id', constraint.foreign_column_name)
+        self.assertEquals('RESTRICT', constraint.on_delete)
+        self.assertEquals('RESTRICT', constraint.on_update)
+        self.assertEquals([], constraint.schema_errors)
+
+    def test_action(self):
+        constraint = Constraint(
+            'user_id_fk',
+            'user_id',
+            'users',
+            'id',
+            on_delete='GARBAGE',
+            on_update='whatever',
+        )
+        self.assertEquals([
+            "ON DELETE action of 'GARBAGE' for constraint 'user_id_fk' is not a valid ON DELETE action",
+            "ON UPDATE action of 'WHATEVER' for constraint 'user_id_fk' is not a valid ON UPDATE action",
+        ], constraint.schema_errors)
diff --git a/mygrations/core/definitions/index_test.py b/mygrations/core/definitions/index_test.py
new file mode 100644
index 0000000..74523af
--- /dev/null
+++ b/mygrations/core/definitions/index_test.py
@@ -0,0 +1,40 @@
+import unittest
+from .index import Index
+from .columns.string import String
+class TestIndex(unittest.TestCase):
+    def setUp(self):
+        self.columns = ['name', 'description']
+
+    def test_can_create(self):
+        name = String('name', 'VARCHAR')
+        description = String('description', 'VARCHAR')
+        index = Index('test_index', self.columns, index_type='unique')
+
+        self.assertEquals('test_index', index.name)
+        self.assertEquals('UNIQUE', index.index_type)
+        self.assertEquals(self.columns, index.columns)
+        self.assertEquals([], index.schema_errors)
+        self.assertEquals([], index.schema_warnings)
+        self.assertEquals([], index.parsing_errors)
+        self.assertEquals([], index.parsing_warnings)
+
+    def test_string_conversion(self):
+        index = Index(name='test_index', columns=self.columns, index_type='UNIQUE')
+
+        self.assertEquals('UNIQUE KEY `test_index` (`name`,`description`)', str(index))
+
+    def test_name_too_long(self):
+        too_long = '012345678901234567890123456789012345678901234567890123456789012345'
+        index = Index(too_long, self.columns, index_type='UNIQUE')
+        self.assertEquals([f'Key name {too_long} must be <=64 characters long'], index.schema_errors)
+
+    def test_missing_name_use_column(self):
+        index = Index(name='', columns=['hey'], index_type='sup')
+        self.assertEquals('hey', index.name)
+
+    def test_missing_things(self):
+        index = Index('blah', [], index_type='')
+        self.assertEquals([
+            'Missing index_type for index blah',
+            'Missing columns for index blah',
+        ], index.schema_errors)
diff --git a/mygrations/core/definitions/option_test.py b/mygrations/core/definitions/option_test.py
new file mode 100644
index 0000000..659cbf4
--- /dev/null
+++ b/mygrations/core/definitions/option_test.py
@@ -0,0 +1,13 @@
+import unittest
+from .option import Option
+class TestOption(unittest.TestCase):
+    def test_can_create(self):
+        option = Option('my_option', 'my_value')
+
+        self.assertEquals('my_option', option.name)
+        self.assertEquals('my_value', option.value)
+
+    def test_required_value(self):
+        option = Option('my_option', '')
+
+        self.assertEquals(['Missing value for option my_option'], option.schema_errors)
diff --git a/mygrations/core/definitions/table_test.py b/mygrations/core/definitions/table_test.py
new file mode 100644
index 0000000..5fb2ec9
--- /dev/null
+++ b/mygrations/core/definitions/table_test.py
@@ -0,0 +1,87 @@
+import unittest
+from collections import OrderedDict
+from .columns import Numeric, String
+from .constraint import Constraint
+from .index import Index
+from .option import Option
+from .table import Table
+class TestTable(unittest.TestCase):
+    def setUp(self):
+        self.id = Numeric('id', 'INT', length=10, unsigned=True, null=False, auto_increment=True)
+        self.user_id = Numeric('user_id', 'INT', length=10, unsigned=True, null=True)
+        self.age = Numeric('age', 'INT', length=10, unsigned=True, default=0)
+        self.name = String('name', 'VARCHAR', length=255, default='', null=False)
+        self.id_index = Index('id_index', ['id'], 'PRIMARY')
+        self.name_age_index = Index('name_age', ['name', 'age'], 'INDEX')
+        self.user_id_index = Index('user_id_index', ['user_id'], 'INDEX')
+        self.user_id_constraint = Constraint('user_id_fk', 'user_id', 'users', 'id', 'SET NULL', 'SET NULL')
+        self.engine = Option('ENGINE', 'InnoDB')
+
+    def test_can_create(self):
+        table = Table(
+            'registrations',
+            [self.id, self.user_id, self.age, self.name],
+            [self.id_index, self.name_age_index, self.user_id_index],
+            [self.user_id_constraint],
+            [self.engine],
+        )
+
+        self.assertEquals('registrations', table.name)
+        self.assertEquals(['id', 'user_id', 'age', 'name'], [column.name for column in table.columns.values()])
+        self.assertEquals(['user_id_fk'], [constraint.name for constraint in table.constraints.values()])
+        self.assertEquals(['id_index', 'name_age', 'user_id_index'], [index.name for index in table.indexes.values()])
+        self.assertEquals([self.engine], table.options)
+        self.assertEquals(self.id_index, table.primary)
+        self.assertEquals([], table.schema_errors)
+        self.assertEquals([], table.schema_warnings)
+
+    def test_require_basics(self):
+        table = Table('', [self.id], [self.id_index], [], [])
+        self.assertEquals(["Table missing name"], table.schema_errors)
+
+        table = Table('no_cols', [], [], [], [])
+        self.assertEquals(["Table 'no_cols' does not have any columns"], table.schema_errors)
+
+    def test_include_child_errors(self):
+        bad_column = String('text', 'TEXT', default='', has_default=True)
+        bad_index = Index('no_cols', [], 'INDEX')
+        bad_constraint = Constraint('user_id_fk', 'user_id', 'users', 'id', on_delete='CASCAD')
+        table = Table(
+            'errors',
+            [self.id, self.user_id, bad_column],
+            [self.id_index, bad_index],
+            [bad_constraint],
+            [self.engine],
+        )
+        self.assertEquals([
+            "Column 'text' of type 'TEXT' cannot have a default in table 'errors'",
+            "Missing columns for index no_cols in table 'errors'",
+            "ON DELETE action of 'CASCAD' for constraint 'user_id_fk' is not a valid ON DELETE action in table 'errors'",
+        ], table.schema_errors)
+
+    def test_check_primaries(self):
+        table = Table('errors', [self.id], [self.id_index, Index('id_primary', ['id'], 'PRIMARY')], [], [])
+        self.assertEquals(["Table 'errors' has more than one PRIMARY index"], table.schema_errors)
+
+        table = Table('errors', [self.id], [], [], [])
+        self.assertEquals(["Table 'errors' has an AUTO_INCREMENT column but is missing the PRIMARY index"],
+                          table.schema_errors)
+
+        table = Table(
+            'errors',
+            [self.id, self.user_id],
+            [Index('user_id', ['user_id'], 'PRIMARY')],
+            [],
+            [],
+        )
+        self.assertEquals([
+            "Mismatched indexes in table 'errors': column 'id' is the AUTO_INCREMENT column but 'user_id' is the PRIMARY index column"
+        ], table.schema_errors)
+
+    def test_check_missing_index_columns(self):
+        table = Table(
+            'errors', [self.id, self.user_id],
+            [self.id_index, Index('bad_index', ['non_column'], 'INDEX')], [], []
+        )
+        self.assertEquals(["Table 'errors' has index 'bad_index' that references non-existent column 'non_column'"],
+                          table.schema_errors)
diff --git a/mygrations/core/operations/add_column_test.py b/mygrations/core/operations/add_column_test.py
new file mode 100644
index 0000000..e87b43e
--- /dev/null
+++ b/mygrations/core/operations/add_column_test.py
@@ -0,0 +1,8 @@
+import unittest
+from .add_column import AddColumn
+from ..definitions.columns import String
+class AddColumnTest(unittest.TestCase):
+    def test_as_string(self):
+        string = String('name', 'VARCHAR', length=255, default='', null=False)
+        add_column = AddColumn(string, 'age')
+        self.assertEquals("ADD `name` VARCHAR(255) NOT NULL DEFAULT '' AFTER `age`", str(add_column))
diff --git a/mygrations/core/operations/add_constraint_test.py b/mygrations/core/operations/add_constraint_test.py
new file mode 100644
index 0000000..44d3c4d
--- /dev/null
+++ b/mygrations/core/operations/add_constraint_test.py
@@ -0,0 +1,11 @@
+import unittest
+from .add_constraint import AddConstraint
+from ..definitions.constraint import Constraint
+class AddConstraintTest(unittest.TestCase):
+    def test_as_string(self):
+        constraint = Constraint('user_id_fk', 'user_id', 'users', 'id')
+        add_constraint = AddConstraint(constraint)
+        self.assertEquals(
+            "ADD CONSTRAINT `user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT",
+            str(add_constraint)
+        )
diff --git a/mygrations/core/operations/add_index_test.py b/mygrations/core/operations/add_index_test.py
new file mode 100644
index 0000000..359d3d9
--- /dev/null
+++ b/mygrations/core/operations/add_index_test.py
@@ -0,0 +1,8 @@
+import unittest
+from .add_index import AddIndex
+from ..definitions.index import Index
+class AddIndexTest(unittest.TestCase):
+    def test_as_string(self):
+        index = Index('user_id', ['user_id', 'account_id'], 'index')
+        add_index = AddIndex(index)
+        self.assertEquals("ADD KEY `user_id` (`user_id`,`account_id`)", str(add_index))
diff --git a/mygrations/core/operations/add_row_test.py b/mygrations/core/operations/add_row_test.py
new file mode 100644
index 0000000..d217850
--- /dev/null
+++ b/mygrations/core/operations/add_row_test.py
@@ -0,0 +1,8 @@
+import unittest
+from .add_row import AddRow
+from collections import OrderedDict
+class AddRowTest(unittest.TestCase):
+    def test_as_string(self):
+        row = OrderedDict([('id', 10), ('age', '5'), ('dob', None)])
+        add_row = AddRow('test_table', row)
+        self.assertEquals("INSERT INTO `test_table` (`id`, `age`, `dob`) VALUES ('10', '5', NULL);", str(add_row))
diff --git a/mygrations/core/operations/alter_table_test.py b/mygrations/core/operations/alter_table_test.py
new file mode 100644
index 0000000..b76600f
--- /dev/null
+++ b/mygrations/core/operations/alter_table_test.py
@@ -0,0 +1,21 @@
+import unittest
+from .add_index import AddIndex
+from .alter_table import AlterTable
+from ..definitions.index import Index
+from ..definitions.table import Table
+from ..definitions.columns import Numeric
+class AlterTableTest(unittest.TestCase):
+    def test_as_string(self):
+        id_column = Numeric('id', 'INT', length=10, unsigned=True, null=False, auto_increment=True)
+        user_id_column = Numeric('user_id', 'INT', length=10, unsigned=True, null=False)
+        id_index = Index('id', ['id'], 'primary')
+        user_id_index = Index('user_id', ['user_id', 'account_id'], 'index')
+
+        alter_table = AlterTable('orders')
+        alter_table.add_operation(AddIndex(id_index))
+        alter_table.add_operation(AddIndex(user_id_index))
+
+        self.assertEquals(
+            "ALTER TABLE `orders` ADD PRIMARY KEY `id` (`id`), ADD KEY `user_id` (`user_id`,`account_id`);",
+            str(alter_table)
+        )
diff --git a/mygrations/core/operations/change_column_test.py b/mygrations/core/operations/change_column_test.py
new file mode 100644
index 0000000..a5c8c0f
--- /dev/null
+++ b/mygrations/core/operations/change_column_test.py
@@ -0,0 +1,7 @@
+import unittest
+from .change_column import ChangeColumn
+from ..definitions.columns import String
+class ChangeColumnTest(unittest.TestCase):
+    def test_as_string(self):
+        name = String('name', 'VARCHAR', length=255)
+        self.assertEquals("CHANGE `name` `name` VARCHAR(255)", str(ChangeColumn(name)))
diff --git a/mygrations/core/operations/change_constraint_test.py b/mygrations/core/operations/change_constraint_test.py
new file mode 100644
index 0000000..b084fb0
--- /dev/null
+++ b/mygrations/core/operations/change_constraint_test.py
@@ -0,0 +1,10 @@
+import unittest
+from .change_constraint import ChangeConstraint
+from ..definitions.constraint import Constraint
+class ChangeConstraintTest(unittest.TestCase):
+    def test_as_string(self):
+        constraint = Constraint('user_id_fk', 'user_id', 'users', 'id', on_update='cascade')
+        self.assertEquals(
+            "DROP FOREIGN KEY `user_id_fk`, ADD CONSTRAINT `user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE",
+            str(ChangeConstraint(constraint))
+        )
diff --git a/mygrations/core/operations/change_index_test.py b/mygrations/core/operations/change_index_test.py
new file mode 100644
index 0000000..0134d27
--- /dev/null
+++ b/mygrations/core/operations/change_index_test.py
@@ -0,0 +1,7 @@
+import unittest
+from .change_index import ChangeIndex
+from ..definitions.index import Index
+class ChangeIndexTest(unittest.TestCase):
+    def test_as_string(self):
+        index = Index('user_id', ['user_id', 'account_id'], 'index')
+        self.assertEquals("DROP KEY `user_id`, ADD KEY `user_id` (`user_id`,`account_id`)", str(ChangeIndex(index)))
diff --git a/mygrations/core/operations/change_row_test.py b/mygrations/core/operations/change_row_test.py
new file mode 100644
index 0000000..946bc85
--- /dev/null
+++ b/mygrations/core/operations/change_row_test.py
@@ -0,0 +1,8 @@
+import unittest
+from .change_row import ChangeRow
+from collections import OrderedDict
+class ChangeRowTest(unittest.TestCase):
+    def test_as_string(self):
+        row = OrderedDict([('id', 10), ('age', '5'), ('dob', None)])
+        change_row = ChangeRow('test_table', row)
+        self.assertEquals("UPDATE `test_table` SET `age`='5', `dob`=NULL WHERE id='10';", str(change_row))
diff --git a/mygrations/core/operations/create_table_test.py b/mygrations/core/operations/create_table_test.py
new file mode 100644
index 0000000..dfd097c
--- /dev/null
+++ b/mygrations/core/operations/create_table_test.py
@@ -0,0 +1,25 @@
+import unittest
+from .create_table import CreateTable
+from ..definitions.columns import String, Numeric
+from ..definitions.index import Index
+from ..definitions.table import Table
+from ..definitions.option import Option
+class CreateTableTest(unittest.TestCase):
+    def test_as_string(self):
+        table = Table(
+            'test_table', [
+                Numeric('id', 'INT', length=10, unsigned=True, auto_increment=True),
+                String('name', 'VARCHAR', length=255)
+            ], [Index('id', ['id'], 'PRIMARY')], [], [Option('ENGINE', 'InnoDB')]
+        )
+        self.assertEquals(
+            """CREATE TABLE `test_table` (
+  `id` INT(10) UNSIGNED AUTO_INCREMENT,
+  `name` VARCHAR(255),
+  PRIMARY KEY `id` (`id`)
+) ENGINE=InnoDB;""", str(CreateTable(table, True))
+        )
+        self.assertEquals(
+            """CREATE TABLE `test_table` (`id` INT(10) UNSIGNED AUTO_INCREMENT,`name` VARCHAR(255),PRIMARY KEY `id` (`id`)) ENGINE=InnoDB;""",
+            str(CreateTable(table, False))
+        )
diff --git a/mygrations/core/operations/disable_checks_test.py b/mygrations/core/operations/disable_checks_test.py
new file mode 100644
index 0000000..faf3009
--- /dev/null
+++ b/mygrations/core/operations/disable_checks_test.py
@@ -0,0 +1,5 @@
+import unittest
+from .disable_checks import DisableChecks
+class DisableChecksTest(unittest.TestCase):
+    def test_as_string(self):
+        self.assertEquals("SET FOREIGN_KEY_CHECKS=0;", str(DisableChecks()))
diff --git a/mygrations/core/operations/enable_checks_test.py b/mygrations/core/operations/enable_checks_test.py
new file mode 100644
index 0000000..79a4fe4
--- /dev/null
+++ b/mygrations/core/operations/enable_checks_test.py
@@ -0,0 +1,5 @@
+import unittest
+from .enable_checks import EnableChecks
+class EnableChecksTest(unittest.TestCase):
+    def test_as_string(self):
+        self.assertEquals("SET FOREIGN_KEY_CHECKS=1;", str(EnableChecks()))
diff --git a/mygrations/core/operations/remove_column_test.py b/mygrations/core/operations/remove_column_test.py
new file mode 100644
index 0000000..4bfc59d
--- /dev/null
+++ b/mygrations/core/operations/remove_column_test.py
@@ -0,0 +1,7 @@
+import unittest
+from .remove_column import RemoveColumn
+from ..definitions.columns import String
+class RemoveColumnTest(unittest.TestCase):
+    def test_as_string(self):
+        string = String('name', 'VARCHAR', length=255, default='', null=False)
+        self.assertEquals("DROP `name`", str(RemoveColumn(string)))
diff --git a/mygrations/core/operations/remove_constraint_test.py b/mygrations/core/operations/remove_constraint_test.py
new file mode 100644
index 0000000..3e8c155
--- /dev/null
+++ b/mygrations/core/operations/remove_constraint_test.py
@@ -0,0 +1,7 @@
+import unittest
+from .remove_constraint import RemoveConstraint
+from ..definitions.constraint import Constraint
+class RemoveConstraintTest(unittest.TestCase):
+    def test_as_string(self):
+        constraint = Constraint('user_id_fk', 'user_id', 'users', 'id')
+        self.assertEquals("DROP FOREIGN KEY `user_id_fk`", str(RemoveConstraint(constraint)))
diff --git a/mygrations/core/operations/remove_index_test.py b/mygrations/core/operations/remove_index_test.py
new file mode 100644
index 0000000..e28884f
--- /dev/null
+++ b/mygrations/core/operations/remove_index_test.py
@@ -0,0 +1,10 @@
+import unittest
+from .remove_index import RemoveIndex
+from ..definitions.index import Index
+class RemoveIndexTest(unittest.TestCase):
+    def test_as_string(self):
+        index = Index('user_id', ['user_id', 'account_id'], 'index')
+        self.assertEquals("DROP KEY `user_id`", str(RemoveIndex(index)))
+
+        index = Index('user_id', ['user_id'], 'primary')
+        self.assertEquals("DROP PRIMARY KEY", str(RemoveIndex(index)))
diff --git a/mygrations/core/operations/remove_row_test.py b/mygrations/core/operations/remove_row_test.py
new file mode 100644
index 0000000..4326c8f
--- /dev/null
+++ b/mygrations/core/operations/remove_row_test.py
@@ -0,0 +1,5 @@
+import unittest
+from .remove_row import RemoveRow
+class RemoveRowTest(unittest.TestCase):
+    def test_as_string(self):
+        self.assertEquals("DELETE FROM `test_table` WHERE id='10';", str(RemoveRow('test_table', 10)))
diff --git a/mygrations/core/operations/remove_table_test.py b/mygrations/core/operations/remove_table_test.py
new file mode 100644
index 0000000..8cd2d73
--- /dev/null
+++ b/mygrations/core/operations/remove_table_test.py
@@ -0,0 +1,5 @@
+import unittest
+from .remove_table import RemoveTable
+class RemoveTableTest(unittest.TestCase):
+    def test_as_string(self):
+        self.assertEquals("DROP TABLE `test_table`;", str(RemoveTable('test_table')))
diff --git a/tests/core/parse/test_parse_children.py b/mygrations/core/parse/rule_children_test.py
similarity index 60%
rename from tests/core/parse/test_parse_children.py
rename to mygrations/core/parse/rule_children_test.py
index 7e99929..25a19e4 100644
--- a/tests/core/parse/test_parse_children.py
+++ b/mygrations/core/parse/rule_children_test.py
@@ -1,26 +1,25 @@
 import unittest
-
-from mygrations.core.parse.rule_children import rule_children
-from mygrations.core.parse.parser import parser
-class test_rule(parser):
+from mygrations.core.parse.rule_children import RuleChildren
+from mygrations.core.parse.parser import Parser
+class TestRule(Parser):
 
     rules = [{'type': 'literal', 'value': 'bob'}, {'type': 'literal', 'value': 'greg'}]
-class test_rule_two(parser):
+class TestRuleTwo(Parser):
 
     rules = [{'type': 'regexp', 'name': 'test_regexp', 'value': '\d+'}, {'type': 'literal', 'value': 'sup'}]
-class test_rule_overlap(parser):
+class TestRuleOverlap(Parser):
 
     rules = [{'type': 'regexp', 'name': 'test_overlap', 'value': '\S+'}]
-class test_parse_children(unittest.TestCase):
+class RuleChildrenTest(unittest.TestCase):
     def get_rule(self, name, classes):
 
-        return rule_children(False, {'name': name, 'classes': classes}, {})
+        return RuleChildren(False, {'name': name, 'classes': classes}, {})
 
     def test_name_required(self):
 
         with self.assertRaises(ValueError):
 
-            self.get_rule('', [test_rule])
+            self.get_rule('', [TestRule])
 
     def test_classes_required(self):
 
@@ -31,7 +30,7 @@ def test_classes_required(self):
     # if nothing matches at the start then we get no match
     def test_match_start_only(self):
 
-        rule = self.get_rule('test', [test_rule, test_rule_two])
+        rule = self.get_rule('test', [TestRule, TestRuleTwo])
         self.assertFalse(rule.parse('okay bob greg'))
         self.assertEquals('', rule.result)
         self.assertEquals('okay bob greg', rule.leftovers)
@@ -40,32 +39,32 @@ def test_match_start_only(self):
     # as long as their parsers match, we will match them
     def test_match_any_order(self):
 
-        rule = self.get_rule('test', [test_rule, test_rule_two])
+        rule = self.get_rule('test', [TestRule, TestRuleTwo])
         self.assertTrue(rule.parse('999 sup bob greg bob greg 1234568 sup hey'))
         self.assertEquals('hey', rule.leftovers)
 
         self.assertEquals(4, len(rule.result))
-        self.assertTrue(rule.result[0].__class__ == test_rule_two)
-        self.assertTrue(rule.result[1].__class__ == test_rule)
-        self.assertTrue(rule.result[2].__class__ == test_rule)
-        self.assertTrue(rule.result[3].__class__ == test_rule_two)
+        self.assertTrue(rule.result[0].__class__ == TestRuleTwo)
+        self.assertTrue(rule.result[1].__class__ == TestRule)
+        self.assertTrue(rule.result[2].__class__ == TestRule)
+        self.assertTrue(rule.result[3].__class__ == TestRuleTwo)
 
     # if two rules both match a string the rule that matches
     # the largest portion of the string wins.
     def test_matches_most_wins(self):
 
-        rule = self.get_rule('test', [test_rule_overlap, test_rule])
+        rule = self.get_rule('test', [TestRuleOverlap, TestRule])
 
         self.assertTrue(rule.parse('bob greg'))
-        self.assertTrue(rule.result[0].__class__ == test_rule)
+        self.assertTrue(rule.result[0].__class__ == TestRule)
         self.assertEquals('', rule.leftovers)
 
     # if a child can't match all required params other
     # children can match
     def test_missing_required_cant_match(self):
 
-        rule = self.get_rule('test', [test_rule_overlap, test_rule])
+        rule = self.get_rule('test', [TestRuleOverlap, TestRule])
 
         self.assertTrue(rule.parse('bob'))
-        self.assertTrue(rule.result[0].__class__ == test_rule_overlap)
+        self.assertTrue(rule.result[0].__class__ == TestRuleOverlap)
         self.assertEquals('', rule.leftovers)
diff --git a/tests/core/parse/test_parse_delimited.py b/mygrations/core/parse/rule_delimited_test.py
similarity index 91%
rename from tests/core/parse/test_parse_delimited.py
rename to mygrations/core/parse/rule_delimited_test.py
index 0259f7b..e808b37 100644
--- a/tests/core/parse/test_parse_delimited.py
+++ b/mygrations/core/parse/rule_delimited_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.core.parse.rule_delimited import rule_delimited
-class test_parse_delimited(unittest.TestCase):
+from mygrations.core.parse.rule_delimited import RuleDelimited
+class RuleDelimitedTest(unittest.TestCase):
     def get_rule(self, name, separator, quote, literal):
 
-        return rule_delimited(
+        return RuleDelimited(
             False, {
                 'name': name,
                 'separator': separator,
@@ -43,7 +43,7 @@ def test_literal_required(self):
 
         with self.assertRaises(ValueError):
 
-            rule_delimited(False, {'name': 'bob', 'separator': ',', 'quote': '`'}, {})
+            RuleDelimited(False, {'name': 'bob', 'separator': ',', 'quote': '`'}, {})
 
     def test_can_init_with_name_and_separator(self):
 
diff --git a/tests/core/parse/test_parse_literal.py b/mygrations/core/parse/rule_literal_test.py
similarity index 88%
rename from tests/core/parse/test_parse_literal.py
rename to mygrations/core/parse/rule_literal_test.py
index 9af89ed..a135e24 100644
--- a/tests/core/parse/test_parse_literal.py
+++ b/mygrations/core/parse/rule_literal_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.core.parse.rule_literal import rule_literal
-class test_parse_regexp(unittest.TestCase):
+from mygrations.core.parse.rule_literal import RuleLiteral
+class RuleLiteralTest(unittest.TestCase):
     def get_rule(self, name, literal):
 
-        return rule_literal(False, {'name': name, 'value': literal}, {})
+        return RuleLiteral(False, {'name': name, 'value': literal}, {})
 
     def test_name_not_required(self):
 
diff --git a/tests/core/parse/test_parse_regexp.py b/mygrations/core/parse/rule_regexp_test.py
similarity index 90%
rename from tests/core/parse/test_parse_regexp.py
rename to mygrations/core/parse/rule_regexp_test.py
index c1c0812..11da09f 100644
--- a/tests/core/parse/test_parse_regexp.py
+++ b/mygrations/core/parse/rule_regexp_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.core.parse.rule_regexp import rule_regexp
-class test_parse_regexp(unittest.TestCase):
+from mygrations.core.parse.rule_regexp import RuleRegexp
+class RuleRegexpTest(unittest.TestCase):
     def get_rule(self, name, regexp):
 
-        return rule_regexp(False, {'name': name, 'value': regexp}, {})
+        return RuleRegexp(False, {'name': name, 'value': regexp}, {})
 
     def test_name_required(self):
 
diff --git a/mygrations/formats/mysql/definitions/constraint_test.py b/mygrations/formats/mysql/definitions/constraint_test.py
new file mode 100644
index 0000000..fa7ec6d
--- /dev/null
+++ b/mygrations/formats/mysql/definitions/constraint_test.py
@@ -0,0 +1,19 @@
+import unittest
+from .constraint import Constraint
+class TestConstraint(unittest.TestCase):
+    def test_string_conversion(self):
+        constraint = Constraint(
+            'user_id_fk',
+            'user_id',
+            'users',
+            'id',
+            on_delete='SET NULL',
+            on_update='CASCADE',
+        )
+
+        self.assertEquals(
+            'CONSTRAINT `user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE',
+            str(constraint)
+        )
+        self.assertEquals([], constraint.schema_errors)
+        self.assertEquals([], constraint.schema_warnings)
diff --git a/tests/formats/mysql/file_reader/parsers/test_create_parser.py b/mygrations/formats/mysql/file_reader/create_parser_test.py
similarity index 80%
rename from tests/formats/mysql/file_reader/parsers/test_create_parser.py
rename to mygrations/formats/mysql/file_reader/create_parser_test.py
index f3aa7e3..75817e8 100644
--- a/tests/formats/mysql/file_reader/parsers/test_create_parser.py
+++ b/mygrations/formats/mysql/file_reader/create_parser_test.py
@@ -1,11 +1,11 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-class test_constraint_foreign(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+class CreateParserTest(unittest.TestCase):
     def test_complicated_table_parses(self):
 
         # parse a typical foreign key constraint
-        parser = create_parser()
+        parser = CreateParser()
         returned = parser.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -22,12 +22,12 @@ def test_complicated_table_parses(self):
             `original_due_date` int(10) DEFAULT NULL,
             `assigned_to_id` int(10) unsigned DEFAULT NULL,
             `delegated_to_id` int(10) DEFAULT NULL,
-            `trust` tinyint(1) NOT NULL DEFAULT '0',
+            `trust` tinyint(1) NOT NULL DEFAULT 0,
             `description` text,
             `multiple_task_id` int(10) DEFAULT NULL,
             `completed_dt` int(10) DEFAULT NULL,
-            `duration` int(10) NOT NULL DEFAULT '0',
-            `number_comments` int(10) NOT NULL DEFAULT '0',
+            `duration` int(10) NOT NULL DEFAULT 0,
+            `number_comments` int(10) NOT NULL DEFAULT 0,
             `number_uploads` int(10) NOT NULL DEFAULT '0',
             `created` int(10) DEFAULT NULL,
             `updated` int(10) DEFAULT NULL,
@@ -55,13 +55,13 @@ def test_complicated_table_parses(self):
         self.assertEquals(3, len(parser.constraints))
         self.assertEquals(2, len(parser.options))
         self.assertTrue(parser.semicolon)
-        self.assertEquals(0, len(parser.errors))
+        self.assertEquals(0, len(parser.schema_errors))
         self.assertEquals(['id'], parser.primary.columns)
 
     def test_keeps_errors(self):
 
         # parse a typical foreign key constraint
-        parser = create_parser()
+        parser = CreateParser()
         returned = parser.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -75,5 +75,10 @@ def test_keeps_errors(self):
         self.assertTrue(parser.matched)
 
         # and we should have some errors
-        self.assertTrue('must have a length for column subject in table tasks' in parser.errors[0])
-        self.assertTrue('not allowed to have a default value for column task in table tasks' in parser.errors[1])
+        self.assertEquals(2, len(parser.schema_errors))
+        self.assertEquals(
+            "Column 'task' of type 'TEXT' cannot have a default in table 'tasks'", parser.schema_errors[0]
+        )
+        self.assertEquals(
+            "Table 'tasks' has an AUTO_INCREMENT column but is missing the PRIMARY index", parser.schema_errors[1]
+        )
diff --git a/mygrations/formats/mysql/file_reader/database_test.py b/mygrations/formats/mysql/file_reader/database_test.py
new file mode 100644
index 0000000..72bc39d
--- /dev/null
+++ b/mygrations/formats/mysql/file_reader/database_test.py
@@ -0,0 +1,182 @@
+import unittest
+
+from mygrations.formats.mysql.file_reader.database import Database
+class DatabaseTest(unittest.TestCase):
+    def test_simple(self):
+
+        strings = [
+            """
+            CREATE TABLE `logs` (
+                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+                `message` TEXT NOT NULL,
+                `traceback` text,
+                PRIMARY KEY (`id`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+        """, """
+            CREATE TABLE `more_logs` (
+                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+                `more_messages` TEXT NOT NULL,
+                `traceback` text,
+                PRIMARY KEY (`id`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+        """
+        ]
+        database = Database(strings)
+
+        # our parser should have a table!
+        self.assertTrue('logs' in database.tables)
+        self.assertTrue('more_logs' in database.tables)
+
+    def test_with_rows(self):
+
+        strings = [
+            """
+            CREATE TABLE `logs` (
+                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+                `message` TEXT NOT NULL,
+                `traceback` text,
+                PRIMARY KEY (`id`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+        """, """
+            INSERT INTO logs (id,message,traceback) VALUES (1,'hi','sup'),(2,'what','kay');
+        """
+        ]
+        database = Database(strings)
+
+        # our parser should have a table!
+        self.assertTrue('logs' in database.tables)
+
+        rows = database.tables['logs'].rows
+        self.assertEquals('hi', rows['1']['message'])
+        self.assertEquals('sup', rows['1']['traceback'])
+        self.assertEquals('what', rows['2']['message'])
+        self.assertEquals('kay', rows['2']['traceback'])
+
+    def test_keeps_errors(self):
+
+        strings = [
+            """
+            CREATE TABLE `logs` (
+                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+                `message` TEXT DEFAULT '',
+                `traceback` text,
+                PRIMARY KEY (`id`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+        """
+        ]
+        database = Database(strings)
+
+        self.assertEquals(1, len(database.errors))
+        self.assertTrue("Column 'message' of type 'TEXT' cannot have a default in table 'logs'" in database.errors[0])
+
+    def test_check_mismatched_default_value_and_column(self):
+        database = Database(
+            """
+CREATE TABLE `test_table` (
+  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+  `numeric_column` TINYINT(1) UNSIGNED NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`)
+);
+        """
+        )
+
+        self.assertTrue('test_table' in database.tables)
+        self.assertEqual(1, len(database.errors))
+        self.assertEqual(
+            "Column 'numeric_column' of type 'TINYINT' cannot have a string value as a default in table 'test_table'",
+            database.errors[0]
+        )
+
+    def test_duplicate_foreign_key_name(self):
+        database = Database(
+            """
+CREATE TABLE `roles` (
+  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+  `people_id` INT(10) UNSIGNED NOT NULL,
+  `name` varchar(255) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  KEY `role_people_id` (`people_id`),
+  CONSTRAINT `people_role_id_fk` FOREIGN KEY (`people_id`) REFERENCES `people` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
+
+CREATE TABLE `people` (
+  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+  `role_id` INT(10) UNSIGNED NOT NULL,
+  `name` varchar(255) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  KEY `people_role_id` (`role_id`),
+  CONSTRAINT `people_role_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
+        """
+        )
+        self.assertTrue('roles' in database.tables)
+        self.assertTrue('people' in database.tables)
+        self.assertEqual(1, len(database.errors))
+        self.assertEqual(
+            "Duplicate foreign key: foreign key named 'people_role_id_fk' exists in tables 'roles' and 'people'",
+            database.errors[0]
+        )
+
+    def test_foreign_key_non_column(self):
+        database = Database(
+            """
+CREATE TABLE `roles` (
+  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
+
+CREATE TABLE `people` (
+  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+  `role_id` INT(10) UNSIGNED NOT NULL,
+  `name` varchar(255) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  KEY `people_role_id` (`role_id`),
+  CONSTRAINT `people_role_id_fk` FOREIGN KEY (`roles_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
+        """
+        )
+        self.assertTrue('roles' in database.tables)
+        self.assertTrue('people' in database.tables)
+        self.assertEqual(1, len(database.errors))
+        self.assertEqual(
+            "Constraint error for foreign key `people_role_id_fk`: sets constraint on column `people`.`roles_id`, but this column does not exist",
+            database.errors[0]
+        )
+
+    def test_missing_key_column(self):
+        database = Database(
+            """
+CREATE TABLE `roles` (
+  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+  `name` VARCHAR(255) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  KEY `account_id_key` (`account_id`)
+);
+        """
+        )
+
+        self.assertTrue('roles' in database.tables)
+        self.assertEqual(1, len(database.errors))
+        self.assertEqual(
+            "Table 'roles' has index 'account_id_key' that references non-existent column 'account_id'",
+            database.errors[0]
+        )
+
+    def test_mismatched_column(self):
+        database = Database(
+            """
+CREATE TABLE `roles` (
+    `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+    `name` VARCHAR(255) NOT NULL DEFAULT '',
+    PRIMARY KEY (`id`)
+);
+INSERT INTO `roles` (`id`,`name`,`description`) VALUES (1,'asdf','more');
+        """
+        )
+
+        self.assertEqual(1, len(database.errors))
+        self.assertEquals(
+            "Insert error: insert command attempts to set column 'description' for table 'roles' but the column does not exist in the table.",
+            database.errors[0]
+        )
diff --git a/tests/formats/mysql/file_reader/test_insert_parser.py b/mygrations/formats/mysql/file_reader/insert_parser_test.py
similarity index 84%
rename from tests/formats/mysql/file_reader/test_insert_parser.py
rename to mygrations/formats/mysql/file_reader/insert_parser_test.py
index aea7e8f..3922dbf 100644
--- a/tests/formats/mysql/file_reader/test_insert_parser.py
+++ b/mygrations/formats/mysql/file_reader/insert_parser_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.insert_parser import insert_parser
+from mygrations.formats.mysql.file_reader.insert_parser import InsertParser
 class test_insert_parser(unittest.TestCase):
     def test_simple(self):
 
-        parser = insert_parser()
+        parser = InsertParser()
         returned = parser.parse("INSERT INTO test_table (`col1`,`col2`) VALUES ('val','val2');")
 
         # we should have matched
@@ -18,11 +18,11 @@ def test_simple(self):
         self.assertEquals(['col1', 'col2'], parser.columns)
         self.assertEquals([['val', 'val2']], parser.raw_rows)
         self.assertTrue(parser.has_semicolon)
-        self.assertEquals([], parser.errors)
+        self.assertEquals([], parser.parsing_errors)
 
     def test_multiple_values(self):
 
-        parser = insert_parser()
+        parser = InsertParser()
         returned = parser.parse("INSERT INTO test_table (`col1`,`col2`) VALUES ('val','val2'),('val3','val4')")
 
         # we should have matched
@@ -36,11 +36,11 @@ def test_multiple_values(self):
         self.assertEquals(['col1', 'col2'], parser.columns)
         self.assertEquals([['val', 'val2'], ['val3', 'val4']], parser.raw_rows)
         self.assertFalse(parser.has_semicolon)
-        self.assertEquals([], parser.errors)
+        self.assertEquals([], parser.parsing_errors)
 
     def test_missing_comma(self):
 
-        parser = insert_parser()
+        parser = InsertParser()
         returned = parser.parse("INSERT INTO test_table (`col1`,`col2`) VALUES ('val','val2')('val3','val4')")
 
         # we should have matched
@@ -54,4 +54,4 @@ def test_missing_comma(self):
         self.assertEquals(['col1', 'col2'], parser.columns)
         self.assertEquals([['val', 'val2'], ['val3', 'val4']], parser.raw_rows)
         self.assertFalse(parser.has_semicolon)
-        self.assertEquals(1, len(parser.warnings))
+        self.assertEquals(1, len(parser.parsing_warnings))
diff --git a/tests/formats/mysql/file_reader/parsers/test_constraint_foreign.py b/mygrations/formats/mysql/file_reader/parsers/constraint_foreign_test.py
similarity index 90%
rename from tests/formats/mysql/file_reader/parsers/test_constraint_foreign.py
rename to mygrations/formats/mysql/file_reader/parsers/constraint_foreign_test.py
index bb05e91..8a42cfe 100644
--- a/tests/formats/mysql/file_reader/parsers/test_constraint_foreign.py
+++ b/mygrations/formats/mysql/file_reader/parsers/constraint_foreign_test.py
@@ -1,11 +1,11 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.parsers.constraint_foreign import constraint_foreign
-class test_constraint_foreign(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.parsers.constraint_foreign import ConstraintForeign
+class TestConstraintForeign(unittest.TestCase):
     def test_simple(self):
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         returned = parser.parse(
             'CONSTRAINT `accounts_status_id_ref_account_statuses_id` FOREIGN KEY (`status_id`) REFERENCES `account_statuses` (`id`) ON DELETE CASCADE ON UPDATE SET NULL'
         )
@@ -18,9 +18,9 @@ def test_simple(self):
 
         # we should have lots of data now
         self.assertEquals('accounts_status_id_ref_account_statuses_id', parser.name)
-        self.assertEquals('status_id', parser.column)
-        self.assertEquals('account_statuses', parser.foreign_table)
-        self.assertEquals('id', parser.foreign_column)
+        self.assertEquals('status_id', parser.column_name)
+        self.assertEquals('account_statuses', parser.foreign_table_name)
+        self.assertEquals('id', parser.foreign_column_name)
         self.assertEquals('CASCADE', parser.on_delete)
         self.assertEquals('SET NULL', parser.on_update)
         self.assertFalse(parser.has_comma)
@@ -32,7 +32,7 @@ def test_simple(self):
     def test_all_deletes(self):
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE CASCADE ON UPDATE SET NULL')
         self.assertEquals('CASCADE', parser.on_delete)
         self.assertEquals(
@@ -41,7 +41,7 @@ def test_all_deletes(self):
         )
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE NO ACTION ON UPDATE SET NULL')
         self.assertEquals('NO ACTION', parser.on_delete)
         self.assertEquals(
@@ -50,7 +50,7 @@ def test_all_deletes(self):
         )
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE RESTRICT ON UPDATE SET NULL')
         self.assertEquals('RESTRICT', parser.on_delete)
         self.assertEquals(
@@ -59,7 +59,7 @@ def test_all_deletes(self):
         )
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE set default ON UPDATE SET NULL')
         self.assertEquals('SET DEFAULT', parser.on_delete)
         self.assertEquals(
@@ -68,7 +68,7 @@ def test_all_deletes(self):
         )
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE set null ON UPDATE SET NULL')
         self.assertEquals('SET NULL', parser.on_delete)
         self.assertEquals(
@@ -77,7 +77,7 @@ def test_all_deletes(self):
         )
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON UPDATE SET NULL')
         self.assertEquals('RESTRICT', parser.on_delete)
         self.assertEquals(
@@ -88,7 +88,7 @@ def test_all_deletes(self):
     def test_all_updates(self):
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE CASCADE ON UPDATE SET NULL')
         self.assertEquals('SET NULL', parser.on_update)
         self.assertEquals(
@@ -97,7 +97,7 @@ def test_all_updates(self):
         )
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse(
             'CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE NO ACTION ON UPDATE SET DEFAULT'
         )
@@ -108,7 +108,7 @@ def test_all_updates(self):
         )
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE RESTRICT ON UPDATE CASCADE')
         self.assertEquals('CASCADE', parser.on_update)
         self.assertEquals(
@@ -117,7 +117,7 @@ def test_all_updates(self):
         )
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse(
             'CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE set default ON UPDATE no action'
         )
@@ -128,7 +128,7 @@ def test_all_updates(self):
         )
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE set null ON UPDATE RESTRICT')
         self.assertEquals('RESTRICT', parser.on_update)
         self.assertEquals(
@@ -137,7 +137,7 @@ def test_all_updates(self):
         )
 
         # parse a typical foreign key constraint
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) ON DELETE SET NULL')
         self.assertEquals('RESTRICT', parser.on_update)
         self.assertEquals(
@@ -148,7 +148,7 @@ def test_all_updates(self):
     def test_action_optional(self):
 
         # all actions are optional, and default to RESTRICT
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         remaining = parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id)')
 
         self.assertTrue(parser.matched)
@@ -163,7 +163,7 @@ def test_action_optional(self):
     def test_leftovers(self):
 
         # anything that doesn't match should be returned as leftovers
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         remaining = parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id), sup')
 
         self.assertTrue(parser.matched)
@@ -175,7 +175,7 @@ def test_leftovers(self):
         )
 
         # even if the user forgets a comma
-        parser = constraint_foreign()
+        parser = ConstraintForeign()
         remaining = parser.parse('CONSTRAINT blah FOREIGN KEY (check) REFERENCES tbl (id) sup')
         self.assertEquals(
             'CONSTRAINT `blah` FOREIGN KEY (`check`) REFERENCES `tbl` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT',
diff --git a/tests/formats/mysql/file_reader/parsers/test_index_key.py b/mygrations/formats/mysql/file_reader/parsers/index_key_test.py
similarity index 91%
rename from tests/formats/mysql/file_reader/parsers/test_index_key.py
rename to mygrations/formats/mysql/file_reader/parsers/index_key_test.py
index 0e2e420..d6e9acd 100644
--- a/tests/formats/mysql/file_reader/parsers/test_index_key.py
+++ b/mygrations/formats/mysql/file_reader/parsers/index_key_test.py
@@ -1,11 +1,11 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.parsers.index_key import index_key
-class test_index_key(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.parsers.index_key import IndexKey
+class TestIndexKey(unittest.TestCase):
     def test_simple(self):
 
         # parse a typical KEY
-        parser = index_key()
+        parser = IndexKey()
         returned = parser.parse('KEY `users_email` (`email`),')
 
         # we should have matched
@@ -23,7 +23,7 @@ def test_simple(self):
     def test_optional_comma(self):
 
         # ending comma is optional
-        parser = index_key()
+        parser = IndexKey()
         returned = parser.parse('KEY `users_email` (`email`)')
 
         # we should have matched
@@ -34,7 +34,7 @@ def test_optional_comma(self):
     def test_optional_quotes(self):
 
         # key name quotes are optional
-        parser = index_key()
+        parser = IndexKey()
         returned = parser.parse('KEY users_email (`email`)')
 
         # we should have matched
@@ -45,7 +45,7 @@ def test_optional_quotes(self):
     def test_multiple_columns(self):
 
         # multi column index
-        parser = index_key()
+        parser = IndexKey()
         returned = parser.parse('KEY `users_email` (`email`,`username`,`password`),')
 
         # we should have matched
diff --git a/tests/formats/mysql/file_reader/parsers/test_index_primay.py b/mygrations/formats/mysql/file_reader/parsers/index_primary_test.py
similarity index 88%
rename from tests/formats/mysql/file_reader/parsers/test_index_primay.py
rename to mygrations/formats/mysql/file_reader/parsers/index_primary_test.py
index 89d7488..154043b 100644
--- a/tests/formats/mysql/file_reader/parsers/test_index_primay.py
+++ b/mygrations/formats/mysql/file_reader/parsers/index_primary_test.py
@@ -1,11 +1,11 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.parsers.index_primary import index_primary
-class test_index_primary(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.parsers.index_primary import IndexPrimary
+class TestIndexPrimary(unittest.TestCase):
     def test_simple(self):
 
         # parse a typical KEY
-        parser = index_primary()
+        parser = IndexPrimary()
         returned = parser.parse('PRIMARY KEY (`id`),')
 
         # we should have matched
@@ -23,7 +23,7 @@ def test_simple(self):
     def test_optional_comma(self):
 
         # ending comma is optional
-        parser = index_primary()
+        parser = IndexPrimary()
         returned = parser.parse('PRIMARY KEY (`id`)')
 
         # we should have matched
@@ -35,7 +35,7 @@ def test_optional_comma(self):
     def test_optional_quotes(self):
 
         # key name quotes are optional
-        parser = index_primary()
+        parser = IndexPrimary()
         returned = parser.parse('PRIMARY KEY (sup)')
 
         # we should have matched
diff --git a/tests/formats/mysql/file_reader/parsers/test_index_unique.py b/mygrations/formats/mysql/file_reader/parsers/index_unique_test.py
similarity index 91%
rename from tests/formats/mysql/file_reader/parsers/test_index_unique.py
rename to mygrations/formats/mysql/file_reader/parsers/index_unique_test.py
index 1eb37c0..e7191fe 100644
--- a/tests/formats/mysql/file_reader/parsers/test_index_unique.py
+++ b/mygrations/formats/mysql/file_reader/parsers/index_unique_test.py
@@ -1,11 +1,11 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.parsers.index_unique import index_unique
-class test_index_unique(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.parsers.index_unique import IndexUnique
+class TestIndexUnique(unittest.TestCase):
     def test_simple(self):
 
         # parse a typical UNIQUE KEY
-        parser = index_unique()
+        parser = IndexUnique()
         returned = parser.parse('UNIQUE KEY `users_email` (`email`),')
 
         # we should have matched
@@ -24,7 +24,7 @@ def test_simple(self):
     def test_optional_comma(self):
 
         # ending comma is optional
-        parser = index_unique()
+        parser = IndexUnique()
         returned = parser.parse('UNIQUE KEY `users_email` (`email`)')
 
         # we should have matched
@@ -35,7 +35,7 @@ def test_optional_comma(self):
     def test_optional_quotes(self):
 
         # key name quotes are optional
-        parser = index_unique()
+        parser = IndexUnique()
         returned = parser.parse('UNIQUE KEY users_email (`email`)')
 
         # we should have matched
@@ -46,7 +46,7 @@ def test_optional_quotes(self):
     def test_multiple_columns(self):
 
         # multi column index
-        parser = index_unique()
+        parser = IndexUnique()
         returned = parser.parse('UNIQUE KEY `users_email` (`email`,`username`,`password`),')
 
         # we should have matched
diff --git a/tests/formats/mysql/file_reader/parsers/test_insert_values.py b/mygrations/formats/mysql/file_reader/parsers/insert_values_test.py
similarity index 89%
rename from tests/formats/mysql/file_reader/parsers/test_insert_values.py
rename to mygrations/formats/mysql/file_reader/parsers/insert_values_test.py
index a3aefcd..79ca7ac 100644
--- a/tests/formats/mysql/file_reader/parsers/test_insert_values.py
+++ b/mygrations/formats/mysql/file_reader/parsers/insert_values_test.py
@@ -1,11 +1,11 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.parsers.insert_values import insert_values
-class test_insert_values(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.parsers.insert_values import InsertValues
+class TestInsertValues(unittest.TestCase):
     def test_simple(self):
 
         # parse typical insert values
-        parser = insert_values()
+        parser = InsertValues()
         returned = parser.parse("(      'name', 'bob', 'okay', 1),")
 
         # we should have matched
@@ -21,7 +21,7 @@ def test_simple(self):
     def test_optional_comma(self):
 
         # parse typical insert values
-        parser = insert_values()
+        parser = InsertValues()
         returned = parser.parse("('name', 'bob', 'okay', 1)")
 
         # we should have matched
@@ -37,7 +37,7 @@ def test_optional_comma(self):
     def test_return(self):
 
         # parse typical insert values
-        parser = insert_values()
+        parser = InsertValues()
         returned = parser.parse("('name','bob','okay',1), ('name','bob')")
 
         # we should have matched
diff --git a/tests/formats/mysql/file_reader/parsers/test_table_options.py b/mygrations/formats/mysql/file_reader/parsers/table_options_test.py
similarity index 90%
rename from tests/formats/mysql/file_reader/parsers/test_table_options.py
rename to mygrations/formats/mysql/file_reader/parsers/table_options_test.py
index 8a12291..f4f2c1c 100644
--- a/tests/formats/mysql/file_reader/parsers/test_table_options.py
+++ b/mygrations/formats/mysql/file_reader/parsers/table_options_test.py
@@ -1,11 +1,11 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.parsers.table_option import table_option
-class test_table_options(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.parsers.table_option import TableOption
+class TestTableOptions(unittest.TestCase):
     def test_simple(self):
 
         # parse typical insert values
-        parser = table_option()
+        parser = TableOption()
         returned = parser.parse("ENGINE=InnoDB")
 
         # we should have matched
@@ -21,7 +21,7 @@ def test_simple(self):
     def test_ignore_semicolon(self):
 
         # we don't handle the ending semi-colon
-        parser = table_option()
+        parser = TableOption()
         returned = parser.parse("COLLATE=utf8_general_ci;")
 
         # we should have matched
@@ -37,7 +37,7 @@ def test_ignore_semicolon(self):
     def test_ignore_spaces(self):
 
         # spaces are ignored as always
-        parser = table_option()
+        parser = TableOption()
         returned = parser.parse("COLLATE = utf8_general_ci")
 
         # we should have matched
@@ -53,7 +53,7 @@ def test_ignore_spaces(self):
     def test_ignore_spaces(self):
 
         # spaces are allowed in the name
-        parser = table_option()
+        parser = TableOption()
         returned = parser.parse("DEFAULT CHARSET=utf8")
 
         # we should have matched
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_character_test.py b/mygrations/formats/mysql/file_reader/parsers/type_character_test.py
new file mode 100644
index 0000000..3f83ea9
--- /dev/null
+++ b/mygrations/formats/mysql/file_reader/parsers/type_character_test.py
@@ -0,0 +1,79 @@
+import unittest
+
+from mygrations.formats.mysql.file_reader.parsers.type_character import TypeCharacter
+class TestTypeCharacter(unittest.TestCase):
+    def test_simple(self):
+
+        # parse typical insert values
+        parser = TypeCharacter()
+        returned = parser.parse("name varchar(255) not null default 'sup',")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('name', parser._name)
+        self.assertEquals('varchar', parser._column_type)
+        self.assertEquals('255', parser._length)
+        self.assertFalse(parser._null)
+        self.assertEquals('sup', parser._default)
+        self.assertTrue(parser.has_comma)
+        self.assertTrue(parser._character_set is None)
+        self.assertTrue(parser._collate is None)
+
+    def test_character_set(self):
+
+        # parse typical insert values
+        parser = TypeCharacter()
+        returned = parser.parse("name varchar(255) not null default 'sup' character set 'blah' collate 'boo',")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('name', parser._name)
+        self.assertEquals('varchar', parser._column_type)
+        self.assertEquals('255', parser._length)
+        self.assertFalse(parser._null)
+        self.assertEquals('sup', parser._default)
+        self.assertTrue(parser.has_comma)
+        self.assertEquals('blah', parser._character_set)
+        self.assertEquals('boo', parser._collate)
+
+    def test_optional_default(self):
+
+        # parse typical insert values
+        parser = TypeCharacter()
+        returned = parser.parse("name varchar(255)")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('name', parser._name)
+        self.assertEquals('varchar', parser._column_type)
+        self.assertEquals('255', parser._length)
+        self.assertTrue(parser._null)
+        self.assertEquals(None, parser._default)
+        self.assertFalse(parser.has_comma)
+        self.assertTrue(parser._character_set is None)
+        self.assertTrue(parser._collate is None)
+
+    def test_empty_default(self):
+
+        # parse typical insert values
+        parser = TypeCharacter()
+        returned = parser.parse("name varchar(255) default ''")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('', parser._default)
+
+    def test_strip_backticks(self):
+
+        # parse typical insert values
+        parser = TypeCharacter()
+        returned = parser.parse("`name` varchar(255)")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('name', parser._name)
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_decimal_test.py b/mygrations/formats/mysql/file_reader/parsers/type_decimal_test.py
new file mode 100644
index 0000000..7c46550
--- /dev/null
+++ b/mygrations/formats/mysql/file_reader/parsers/type_decimal_test.py
@@ -0,0 +1,62 @@
+import unittest
+
+from mygrations.formats.mysql.file_reader.parsers.type_decimal import TypeDecimal
+class TestTypeDecimal(unittest.TestCase):
+    def test_simple(self):
+
+        # parse typical insert values
+        parser = TypeDecimal()
+        returned = parser.parse("latitude float(10,7) not null default 0,")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('latitude', parser._name)
+        self.assertEquals('float', parser._column_type)
+        self.assertEquals('10,7', parser._length)
+        self.assertFalse(parser._unsigned)
+        self.assertFalse(parser._null)
+        self.assertEquals('0', parser._default)
+        self.assertTrue(parser.has_comma)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_optional_default(self):
+
+        # parse typical insert values
+        parser = TypeDecimal()
+        returned = parser.parse("latitude float(10,7) UNSIGNED")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('latitude', parser._name)
+        self.assertEquals('float', parser._column_type)
+        self.assertTrue(parser._unsigned)
+        self.assertEquals('10,7', parser._length)
+        self.assertTrue(parser._null)
+        self.assertEquals(None, parser._default)
+        self.assertFalse(parser.has_comma)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_strip_backticks(self):
+
+        # parse typical insert values
+        parser = TypeDecimal()
+        returned = parser.parse("`latitude` float(10,7)")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('latitude', parser._name)
+
+    def test_warning_for_string_default(self):
+
+        # parse typical insert values
+        parser = TypeDecimal()
+        returned = parser.parse("latitude float(10,7) default '0'")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+        self.assertTrue('has a numeric type but its default value is a string' in parser._parsing_warnings[0])
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_enum_test.py b/mygrations/formats/mysql/file_reader/parsers/type_enum_test.py
new file mode 100644
index 0000000..e96a8d1
--- /dev/null
+++ b/mygrations/formats/mysql/file_reader/parsers/type_enum_test.py
@@ -0,0 +1,59 @@
+import unittest
+
+from mygrations.formats.mysql.file_reader.parsers.type_enum import TypeEnum
+class TestTypeEnum(unittest.TestCase):
+    def test_simple(self):
+
+        # parse typical insert values
+        parser = TypeEnum()
+        returned = parser.parse("options enum('bob','joe','ray') not null default 'bob',")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('options', parser._name)
+        self.assertEquals('enum', parser._column_type)
+        self.assertEquals(['bob', 'joe', 'ray'], parser._enum_values)
+        self.assertFalse(parser._null)
+        self.assertEquals('bob', parser._default)
+        self.assertTrue(parser.has_comma)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_optional_default(self):
+
+        # parse typical insert values
+        parser = TypeEnum()
+        returned = parser.parse("options enum('bob','joe','ray')")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('options', parser._name)
+        self.assertEquals('enum', parser._column_type)
+        self.assertEquals(['bob', 'joe', 'ray'], parser._enum_values)
+        self.assertTrue(parser._null)
+        self.assertEquals(None, parser._default)
+        self.assertFalse(parser.has_comma)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_strip_backticks(self):
+
+        # parse typical insert values
+        parser = TypeEnum()
+        returned = parser.parse("`options` enum('bob','joe','ray')")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+        self.assertEquals('options', parser._name)
+
+    def test_warning_for_no_quote_default(self):
+
+        # parse typical insert values
+        parser = TypeEnum()
+        returned = parser.parse("options enum('bob','joe','ray') default bob")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+        self.assertTrue('should have quotes' in parser._parsing_warnings[0])
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_numeric_test.py b/mygrations/formats/mysql/file_reader/parsers/type_numeric_test.py
new file mode 100644
index 0000000..79fe8a9
--- /dev/null
+++ b/mygrations/formats/mysql/file_reader/parsers/type_numeric_test.py
@@ -0,0 +1,72 @@
+import unittest
+
+from mygrations.formats.mysql.file_reader.parsers.type_numeric import TypeNumeric
+class TestTypeNumeric(unittest.TestCase):
+    def test_simple(self):
+
+        # parse typical insert values
+        parser = TypeNumeric()
+        returned = parser.parse("created int(10) not null default 0,")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('created', parser._name)
+        self.assertEquals('int', parser._column_type)
+        self.assertEquals('10', parser._length)
+        self.assertFalse(parser._unsigned)
+        self.assertFalse(parser._null)
+        self.assertFalse(parser._auto_increment)
+        self.assertEquals('0', parser._default)
+        self.assertTrue(parser.has_comma)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_auto_increment(self):
+
+        # parse typical insert values
+        parser = TypeNumeric()
+        returned = parser.parse("created int(10) not null auto_increment,")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('created', parser._name)
+        self.assertEquals('int', parser._column_type)
+        self.assertEquals('10', parser._length)
+        self.assertTrue(parser._auto_increment)
+        self.assertFalse(parser._unsigned)
+        self.assertFalse(parser._null)
+        self.assertEquals(None, parser._default)
+        self.assertTrue(parser.has_comma)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_optional_default(self):
+
+        # parse typical insert values
+        parser = TypeNumeric()
+        returned = parser.parse("created int(10) UNSIGNED")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('created', parser._name)
+        self.assertEquals('int', parser._column_type)
+        self.assertTrue(parser._unsigned)
+        self.assertEquals('10', parser._length)
+        self.assertTrue(parser._null)
+        self.assertEquals(None, parser._default)
+        self.assertFalse(parser.has_comma)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_strip_backticks(self):
+
+        # parse typical insert values
+        parser = TypeNumeric()
+        returned = parser.parse("`created` int(10) UNSIGNED")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+        self.assertEquals('created', parser._name)
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_plain_test.py b/mygrations/formats/mysql/file_reader/parsers/type_plain_test.py
new file mode 100644
index 0000000..20cc3f3
--- /dev/null
+++ b/mygrations/formats/mysql/file_reader/parsers/type_plain_test.py
@@ -0,0 +1,47 @@
+import unittest
+
+from mygrations.formats.mysql.file_reader.parsers.type_plain import TypePlain
+class TestTypePlain(unittest.TestCase):
+    def test_simple(self):
+
+        # parse typical insert values
+        parser = TypePlain()
+        returned = parser.parse("created date NOT NULL DEFAULT 'bob',")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('created', parser._name)
+        self.assertEquals('date', parser._column_type)
+        self.assertFalse(parser._null)
+        self.assertEquals('bob', parser._default)
+        self.assertTrue(parser.has_comma)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_optional_default(self):
+
+        # parse typical insert values
+        parser = TypePlain()
+        returned = parser.parse("created date")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('created', parser._name)
+        self.assertEquals('date', parser._column_type)
+        self.assertTrue(parser._null)
+        self.assertEquals(None, parser._default)
+        self.assertFalse(parser.has_comma)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_strip_backticks(self):
+
+        # parse typical insert values
+        parser = TypePlain()
+        returned = parser.parse("`created` date")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+        self.assertEquals('created', parser._name)
diff --git a/mygrations/formats/mysql/file_reader/parsers/type_text_test.py b/mygrations/formats/mysql/file_reader/parsers/type_text_test.py
new file mode 100644
index 0000000..691e6f4
--- /dev/null
+++ b/mygrations/formats/mysql/file_reader/parsers/type_text_test.py
@@ -0,0 +1,49 @@
+import unittest
+
+from mygrations.formats.mysql.file_reader.parsers.type_text import TypeText
+class TestTypeText(unittest.TestCase):
+    def test_simple(self):
+
+        # parse typical insert values
+        parser = TypeText()
+        returned = parser.parse("name text not null,")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('name', parser._name)
+        self.assertEquals('text', parser._column_type)
+        self.assertFalse(parser._null)
+        self.assertTrue(parser.has_comma)
+        self.assertEquals('', parser._character_set)
+        self.assertEquals('', parser._collate)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_character_set(self):
+
+        # parse typical insert values
+        parser = TypeText()
+        returned = parser.parse("name text character set 'blah' collate 'boo',")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+
+        self.assertEquals('name', parser._name)
+        self.assertEquals('text', parser._column_type)
+        self.assertTrue(parser._null)
+        self.assertTrue(parser.has_comma)
+        self.assertEquals('blah', parser._character_set)
+        self.assertEquals('boo', parser._collate)
+        self.assertEquals(0, len(parser._parsing_errors))
+        self.assertEquals(0, len(parser._parsing_warnings))
+
+    def test_strip_backticks(self):
+
+        # parse typical insert values
+        parser = TypeText()
+        returned = parser.parse("`name` text not null")
+
+        self.assertTrue(parser.matched)
+        self.assertEquals('', returned)
+        self.assertEquals('name', parser._name)
diff --git a/tests/formats/mysql/file_reader/test_reader.py b/mygrations/formats/mysql/file_reader/reader_test.py
similarity index 90%
rename from tests/formats/mysql/file_reader/test_reader.py
rename to mygrations/formats/mysql/file_reader/reader_test.py
index 4f66040..11bf345 100644
--- a/tests/formats/mysql/file_reader/test_reader.py
+++ b/mygrations/formats/mysql/file_reader/reader_test.py
@@ -1,23 +1,21 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.reader import reader
-class test_reader(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.reader import Reader
+class ReaderTest(unittest.TestCase):
     def test_simple(self):
 
-        parser = reader()
+        parser = Reader()
         returned = parser.parse(
             """
             /* I should find and ignore comments */
             -- Of any variety
                # really
-
             CREATE TABLE `logs` (
                 `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
                 `message` TEXT NOT NULL,
                 `traceback` text,
                 PRIMARY KEY (`id`)
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
             INSERT INTO logs (`id`,`message`,`traceback`) VALUES (1,'blah','gah');
             INSERT INTO test (id,status) VALUES (1,'New'),(2,'Old');
         """
diff --git a/mygrations/helpers/dotenv_test.py b/mygrations/helpers/dotenv_test.py
new file mode 100644
index 0000000..b7093bd
--- /dev/null
+++ b/mygrations/helpers/dotenv_test.py
@@ -0,0 +1,252 @@
+import unittest
+
+from mygrations.helpers.dotenv import dotenv, DotEnvSyntaxError
+import io
+import tempfile
+class DotEnvTest(unittest.TestCase):
+
+    dotenv = None
+    test_string = 'test string'
+
+    def setUp(self):
+
+        self.dotenv = dotenv()
+
+    # get_contents should accept a number of parameters.
+    # It should accept a stringIO wrapper
+    def test_get_contents_stringIO(self):
+
+        self.assertEquals(self.test_string, self.dotenv.get_contents(io.StringIO(self.test_string)))
+
+    # it should also accept an actual string
+    def test_get_contents_string(self):
+
+        self.assertEquals(self.test_string, self.dotenv.get_contents(self.test_string))
+
+    # as well as a more general file pointer
+    def test_get_contents_fp(self):
+
+        fp = tempfile.TemporaryFile()
+        fp.write(self.test_string.encode(encoding='UTF-8'))
+        fp.seek(0)
+        self.assertEquals(self.test_string, self.dotenv.get_contents(fp))
+        fp.close()
+
+    # it should also accept a filename
+    def test_get_contents_filename(self):
+
+        filename = '%s/unit_mygrations_dotenv' % tempfile.gettempdir()
+        fp = open(filename, 'w')
+        fp.write(self.test_string)
+        fp.close()
+
+        self.assertEquals(self.test_string, self.dotenv.get_contents(filename))
+
+    def test_json(self):
+
+        # it should detect json and parse it as such
+        parsed = self.dotenv.parse('{ "key": "value",\n "name": "bob" }')
+
+        self.assertEquals({'key': 'value', 'name': 'bob'}, parsed)
+
+    def test_dont_interrupt_syntax_error(self):
+
+        # a syntax error from parse_line should not be stopped
+        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse, 'name')
+
+    def test_parse_each_line(self):
+
+        # otherwise each line should be parsed
+        parsed = self.dotenv.parse('key = value\nname = bob #sup')
+
+        self.assertEquals({'key': 'value', 'name': 'bob'}, parsed)
+
+    def test_parse_skip_empty(self):
+
+        # some whitespace checks and a few parts just because
+        parsed = self.dotenv.parse('key = value\n\n\n\n   name = "bob" # sup\n another = "test\\"value"#okay')
+
+        self.assertEquals({'key': 'value', 'name': 'bob', 'another': 'test"value'}, parsed)
+
+    def test_opening_comment(self):
+
+        self.assertEquals([False, False], self.dotenv.parse_line('# name = test', '#'))
+
+    def test_opening_comment_with_space(self):
+
+        # white space is stripped first, so it still starts with a comment if it has spaces
+        self.assertEquals([False, False], self.dotenv.parse_line(' # name = test', '#'))
+
+    def test_any_comment_charater(self):
+
+        # our comment character should be respected
+        self.assertEquals([False, False], self.dotenv.parse_line('; name = test', ';'))
+
+    def test_multi_character_comment(self):
+
+        # and it is allowed to be more than one character long
+        self.assertEquals([False, False], self.dotenv.parse_line('weirdcomment name = test', 'weirdcomment'))
+
+    def test_ignore_empty_lines(self):
+
+        # empty lines (which includes lines with all white space) are ignored
+        self.assertEquals([False, False], self.dotenv.parse_line('  ', '#'))
+
+    def test_invalid_format(self):
+
+        # supported format is something like name = value or name: value
+        # if neither of the separators is present then we should get a syntax error
+        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, 'sup', '#')
+
+    def test_empty_value_equal(self):
+
+        # empty values are allowed
+        self.assertEquals(['key', ''], self.dotenv.parse_line('key=', '#'))
+
+    def test_empty_value_colon(self):
+
+        # colons act exactly the same way
+        self.assertEquals(['key', ''], self.dotenv.parse_line('key:', '#'))
+
+    def test_empty_value_equal_spaces(self):
+
+        # and spaces are ignored
+        self.assertEquals(['key', ''], self.dotenv.parse_line(" key \t=\t  ", '#'))
+
+    def test_empty_value_equal_colon(self):
+
+        # and spaces are ignored
+        self.assertEquals(['key', ''], self.dotenv.parse_line(" key \t:\t  ", '#'))
+
+    def test_value_no_quotes_equal(self):
+
+        # a value without quotes should be easy
+        self.assertEquals(['key', 'asdf'], self.dotenv.parse_line("key=asdf", '#'))
+
+    def test_value_no_quotes_colon(self):
+
+        # a value without quotes should be easy
+        self.assertEquals(['key', 'asdf'], self.dotenv.parse_line("key:asdf", '#'))
+
+    def test_value_no_quotes_equal_spaces(self):
+
+        # spaces are still ignored at the beginning/end of a part
+        self.assertEquals(['key', 'asdf bob'], self.dotenv.parse_line("key= asdf bob  \t", '#'))
+
+    def test_value_no_quotes_equal_colon(self):
+
+        # spaces are still ignored at the beginning/end of a part
+        self.assertEquals(['key', 'asdf bob'], self.dotenv.parse_line("key : asdf bob  \t", '#'))
+
+    def test_value_no_quotes_equal_comment(self):
+
+        # and comments at the end are ignored (including spaces before comments)
+        self.assertEquals(['key', 'asdf bob'], self.dotenv.parse_line("key = asdf bob  \t# a comment", '#'))
+
+    def test_value_no_quotes_equal_colon(self):
+
+        # and comments at the end are ignored (including spaces before comments)
+        self.assertEquals(['key', 'asdf bob'], self.dotenv.parse_line("key : asdf bob  \t; a comment", ';'))
+
+    def test_no_lone_quotes_double(self):
+
+        # a quote character inside the value by itself is invalid
+        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, 'name = valu"e', '#')
+
+    def test_no_lone_quotes_single(self):
+
+        # a quote character inside the value by itself is invalid
+        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, "name = valu'e", '#')
+
+    def test_empty_single(self):
+
+        # easy
+        self.assertEquals(['db', ''], self.dotenv.parse_line("db = ''", '#'))
+
+    def test_empty_double(self):
+
+        # easy
+        self.assertEquals(['db', ''], self.dotenv.parse_line('db = ""', '#'))
+
+    def test_empty_single_comment(self):
+
+        # easy
+        self.assertEquals(['db', ''], self.dotenv.parse_line("db = '' # database name", '#'))
+
+    def test_empty_double_comment(self):
+
+        # easy
+        self.assertEquals(['db', ''], self.dotenv.parse_line('db = "" ; database name', ';'))
+
+    def test_single_quotes(self):
+
+        # I shouldn't get the quotes back.  Also white space should
+        # be ignored still
+        self.assertEquals(['db', 'dijere'], self.dotenv.parse_line("db = 'dijere' ", '#'))
+
+    def test_double_quotes(self):
+
+        # I shouldn't get the quotes back.  Also white space should
+        # be ignored still
+        self.assertEquals(['db', 'dijere'], self.dotenv.parse_line('db = "dijere" ', '#'))
+
+    def test_no_closing_quote_single(self):
+
+        # syntax error if we have an opening quote with no close
+        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, "name = 'test", '#')
+
+    def test_no_closing_quote_double(self):
+
+        # syntax error if we have an opening quote with no close
+        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, 'name = "test', '#')
+
+    def test_double_quotes_with_comment(self):
+
+        # comments after the quotes should be ignored (along with whitespace)
+        self.assertEquals(['db', 'bob'], self.dotenv.parse_line('db = "bob" ; database name', ';'))
+
+    def test_single_quotes_with_comment(self):
+
+        # comments after the quotes should be ignored (along with whitespace)
+        self.assertEquals(['db', 'bob'], self.dotenv.parse_line("db = 'bob' \t# database name", '#'))
+
+    def test_text_outside_of_double(self):
+
+        # anything outside of the quotes and before a comment results in a syntax error
+        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, 'name = "test" sup # hey', '#')
+
+    def test_text_outside_of_single(self):
+
+        # anything outside of the quotes and before a comment results in a syntax error
+        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, "name = 'test' sup ; hey", ';')
+
+    def test_allow_ending_semicolon(self):
+
+        # anything outside of the quotes and before a comment results in a syntax error
+        # except allow to end the line with a semi-colon.
+        self.assertEquals(['name', 'test'], self.dotenv.parse_line("name = 'test';", '#'))
+
+    def test_escaped_single_quote(self):
+
+        # quotes can be escaped inside quotes
+        self.assertEquals(['db', "asdf'qwerty'"], self.dotenv.parse_line("db = 'asdf\\'qwerty\\''", '#'))
+
+    def test_escaped_double_quote(self):
+
+        # quotes can be escaped inside quotes
+        self.assertEquals(['db', 'asdf"qwerty"'], self.dotenv.parse_line('db = "asdf\\"qwerty\\""', '#'))
+
+    def test_preserve_other_slashes(self):
+
+        # other slashes are left alone
+        self.assertEquals(['db', 'asdf\\bob'], self.dotenv.parse_line('db = "asdf\\bob"', '#'))
+
+    def test_double_quote_in_single_quote(self):
+
+        # double quote inside single quotes are just regular characters
+        self.assertEquals(['db', 'asdf"bob'], self.dotenv.parse_line("db = 'asdf\"bob'", '#'))
+
+    def test_single_quote_in_double_quote(self):
+
+        # single quote inside double quotes are just regular characters
+        self.assertEquals(['db', "asdf'bob"], self.dotenv.parse_line('db = "asdf\'bob"', '#'))
diff --git a/tests/formats/mysql/__init__.py b/mygrations/tests/__init__.py
similarity index 100%
rename from tests/formats/mysql/__init__.py
rename to mygrations/tests/__init__.py
diff --git a/tests/formats/mysql/definitions/__init__.py b/mygrations/tests/integration/__init__.py
similarity index 100%
rename from tests/formats/mysql/definitions/__init__.py
rename to mygrations/tests/integration/__init__.py
diff --git a/tests/formats/mysql/mygrations/test_add_conflicting_fks.py b/mygrations/tests/integration/add_conflicting_fks_test.py
similarity index 70%
rename from tests/formats/mysql/mygrations/test_add_conflicting_fks.py
rename to mygrations/tests/integration/add_conflicting_fks_test.py
index 366267a..560a600 100644
--- a/tests/formats/mysql/mygrations/test_add_conflicting_fks.py
+++ b/mygrations/tests/integration/add_conflicting_fks_test.py
@@ -1,9 +1,9 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.database import database as database_reader
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-from mygrations.formats.mysql.mygrations.mygration import mygration
-class test_add_conflicting_fks(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.database import Database as DatabaseReader
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+from mygrations.formats.mysql.mygrations.mygration import Mygration
+class AddConflictingForeignKeyTests(unittest.TestCase):
     def test_add_conflicting_separates_fks(self):
         """ 3 adds. """
 
@@ -29,13 +29,10 @@ def test_add_conflicting_separates_fks(self):
 `name` VARCHAR(255) NOT NULL DEFAULT '',
 PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
         tables = [table1, table2, table3]
-        db = database_reader(tables)
-        mygrate = mygration(db)
+        db = DatabaseReader(tables)
+        mygrate = Mygration(db)
 
-        ops = [str(op) for op in mygrate.operations]
-
-        self.assertEquals('SET FOREIGN_KEY_CHECKS=0;', ops[0])
-        self.assertTrue(table1 in ops)
-        self.assertTrue(table2 in ops)
-        self.assertTrue(table3 in ops)
-        self.assertEquals('SET FOREIGN_KEY_CHECKS=1;', ops[4])
+        self.assertEquals([
+            "Duplicate foreign key: foreign key named 'account_id_tasks_fk' exists in tables 'tasks' and 'repeating_tasks'"
+        ], mygrate.errors)
+        self.assertEquals(None, mygrate.operations)
diff --git a/tests/formats/mysql/mygrations/test_add_only.py b/mygrations/tests/integration/add_only_test.py
similarity index 71%
rename from tests/formats/mysql/mygrations/test_add_only.py
rename to mygrations/tests/integration/add_only_test.py
index 1a99e48..ad38f9e 100644
--- a/tests/formats/mysql/mygrations/test_add_only.py
+++ b/mygrations/tests/integration/add_only_test.py
@@ -1,9 +1,8 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.database import database as database_reader
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-from mygrations.formats.mysql.mygrations.mygration import mygration
-class test_add_only(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.database import Database
+from mygrations.formats.mysql.mygrations.mygration import Mygration
+class AddOnlyTest(unittest.TestCase):
     def test_add_one_only(self):
         """ mygration can accept only one table, in which case it just figures out the proper add order
 
@@ -13,8 +12,8 @@ def test_add_one_only(self):
         table1 = "CREATE TABLE `zlogs` (`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,\n`message` TEXT NOT NULL,\n`traceback` TEXT,\nPRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;"
         table2 = "CREATE TABLE `people` (`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,\n`first_name` VARCHAR(255) NOT NULL DEFAULT '',\n`last_name` VARCHAR(255) NOT NULL DEFAULT '',\nPRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;"
         tables = [table1, table2]
-        db = database_reader(tables)
-        mygrate = mygration(db)
+        mygrate = Mygration(Database(tables))
+        self.assertEquals([], mygrate.errors)
         ops = [str(op) for op in mygrate.operations]
 
         self.assertTrue(table1 in ops)
diff --git a/tests/formats/mysql/mygrations/test_backslashes.py b/mygrations/tests/integration/backslashes_test.py
similarity index 69%
rename from tests/formats/mysql/mygrations/test_backslashes.py
rename to mygrations/tests/integration/backslashes_test.py
index c4e4135..688b68b 100644
--- a/tests/formats/mysql/mygrations/test_backslashes.py
+++ b/mygrations/tests/integration/backslashes_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.formats.mysql.db_reader.database import database as database_reader
-from mygrations.formats.mysql.file_reader.database import database as file_reader
-from mygrations.drivers.mysqldb.mysqldb import mysqldb
-from tests.mocks.db.mysql.db_structure import db_structure
-from mygrations.formats.mysql.mygrations.row_mygration import row_mygration
+from mygrations.formats.mysql.db_reader.database import Database as DatabaseReader
+from mygrations.formats.mysql.file_reader.database import Database as FileReader
+from mygrations.drivers.pymysql.pymysql import PyMySQL
+from mygrations.tests.mocks.db.mysql.db_structure import DbStructure
+from mygrations.formats.mysql.mygrations.row_mygration import RowMygration
 class test_backslashes(unittest.TestCase):
     def test_diffs_with_quotes(self):
         """ Things that need backslashes can cause trouble """
@@ -33,7 +33,7 @@ def test_diffs_with_quotes(self):
             })
         }
 
-        db_db = database_reader(mysqldb(db_structure(tables, rows)))
+        db_db = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows)))
         db_db.read_rows('logs')
 
         # and one from a file
@@ -47,9 +47,9 @@ def test_diffs_with_quotes(self):
         INSERT INTO logs (id,message,traceback) VALUES (1,'test\\sup', 'never');
         INSERT INTO logs (id,message,traceback) VALUES (2,'bob\\test', 'forever');
         """
-        files_db = file_reader([table1])
+        files_db = FileReader([table1])
 
-        mygrate = row_mygration(files_db, db_db)
+        mygrate = RowMygration(files_db, db_db)
         ops = [str(op) for op in mygrate]
         self.assertEquals(1, len(ops))
-        self.assertTrue("`message`='bob\\\\test'" in ops[0])
+        self.assertEquals("UPDATE `logs` SET `message`='bob\\\\test', `traceback`='forever' WHERE id='2';", ops[0])
diff --git a/tests/formats/mysql/definitions/test_database_unfulfilled_fks.py b/mygrations/tests/integration/database_unfulfilled_fks_test.py
similarity index 91%
rename from tests/formats/mysql/definitions/test_database_unfulfilled_fks.py
rename to mygrations/tests/integration/database_unfulfilled_fks_test.py
index 2ca6e6a..50a9cd3 100644
--- a/tests/formats/mysql/definitions/test_database_unfulfilled_fks.py
+++ b/mygrations/tests/integration/database_unfulfilled_fks_test.py
@@ -1,8 +1,8 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.database import database as database_reader
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-class test_database_unfulfilled_fks(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.database import Database as DatabaseReader
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+class DatabaseUnfulfilledFKsTest(unittest.TestCase):
     def test_can_fulfill(self):
         """ If we have all necessary columns in all necessary tables, we can fulfill the FKs """
 
@@ -22,9 +22,9 @@ def test_can_fulfill(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -55,9 +55,9 @@ def test_can_not_fulfill(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -81,7 +81,7 @@ def test_can_not_fulfill(self):
             str(errors_1215['log_changes_type_id_fk']['foreign_key'])
         )
         self.assertEquals(
-            'MySQL 1215 error for foreign key `log_changes_type_id_fk`: `log_changes`.`type_id` references `types`.`id`, but table `types` does not exist',
+            'Constraint error for foreign key `log_changes_type_id_fk`: `log_changes`.`type_id` references `types`.`id`, but table `types` does not exist',
             errors_1215['log_changes_type_id_fk']['error']
         )
 
@@ -98,9 +98,9 @@ def test_missing_column(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -124,7 +124,7 @@ def test_missing_column(self):
             str(errors_1215['log_changes_type_id_fk']['foreign_key'])
         )
         self.assertEquals(
-            'MySQL 1215 error for foreign key `log_changes_type_id_fk`: `log_changes`.`type_id` references `logs`.`bob`, but column `logs`.`bob` does not exist',
+            'Constraint error for foreign key `log_changes_type_id_fk`: `log_changes`.`type_id` references `logs`.`bob`, but column `logs`.`bob` does not exist',
             errors_1215['log_changes_type_id_fk']['error']
         )
 
@@ -140,9 +140,9 @@ def test_1215_wrong_type(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -163,7 +163,7 @@ def test_1215_wrong_type(self):
             str(errors_1215['log_changes_log_id_fk']['foreign_key'])
         )
         self.assertEquals(
-            "MySQL 1215 error for foreign key `log_changes_log_id_fk`: column type mismatch. `log_changes`.`log_id` is 'BIGINT' but `logs`.`id` is 'INT'",
+            "Constraint error for foreign key `log_changes_log_id_fk`: column type mismatch. `log_changes`.`log_id` is 'BIGINT' but `logs`.`id` is 'INT'",
             errors_1215['log_changes_log_id_fk']['error']
         )
 
@@ -179,9 +179,9 @@ def test_1215_wrong_length(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -202,7 +202,7 @@ def test_1215_wrong_length(self):
             str(errors_1215['log_changes_log_id_fk']['foreign_key'])
         )
         self.assertEquals(
-            "MySQL 1215 error for foreign key `log_changes_log_id_fk`: length mismatch. `log_changes`.`log_id` is '11' but `logs`.`id` is '10'",
+            "Constraint error for foreign key `log_changes_log_id_fk`: length mismatch. `log_changes`.`log_id` is '11' but `logs`.`id` is '10'",
             errors_1215['log_changes_log_id_fk']['error']
         )
 
@@ -218,9 +218,9 @@ def test_1215_wrong_unsigned(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) NOT NULL AUTO_INCREMENT,
@@ -241,7 +241,7 @@ def test_1215_wrong_unsigned(self):
             str(errors_1215['log_changes_log_id_fk']['foreign_key'])
         )
         self.assertEquals(
-            "MySQL 1215 error for foreign key `log_changes_log_id_fk`: unsigned mistmatch. `logs`.`id` is unsigned but `log_changes`.`log_id` is not",
+            "Constraint error for foreign key `log_changes_log_id_fk`: unsigned mistmatch. `logs`.`id` is unsigned but `log_changes`.`log_id` is not",
             errors_1215['log_changes_log_id_fk']['error']
         )
 
@@ -257,9 +257,9 @@ def test_1215_wrong_unsigned_reversed(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -280,7 +280,7 @@ def test_1215_wrong_unsigned_reversed(self):
             str(errors_1215['log_changes_log_id_fk']['foreign_key'])
         )
         self.assertEquals(
-            "MySQL 1215 error for foreign key `log_changes_log_id_fk`: unsigned mistmatch. `log_changes`.`log_id` is unsigned but `logs`.`id` is not",
+            "Constraint error for foreign key `log_changes_log_id_fk`: unsigned mistmatch. `log_changes`.`log_id` is unsigned but `logs`.`id` is not",
             errors_1215['log_changes_log_id_fk']['error']
         )
 
@@ -296,9 +296,9 @@ def test_1215_on_delete_set_null_not_nullable(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -319,7 +319,7 @@ def test_1215_on_delete_set_null_not_nullable(self):
             str(errors_1215['log_changes_log_id_fk']['foreign_key'])
         )
         self.assertEquals(
-            "MySQL 1215 error for foreign key `log_changes_log_id_fk`: invalid SET NULL. `log_changes`.`log_id` is not allowed to be null but the foreign key attempts to set the value to null ON DELETE",
+            "Constraint error for foreign key `log_changes_log_id_fk`: invalid SET NULL. `log_changes`.`log_id` is not allowed to be null but the foreign key attempts to set the value to null ON DELETE",
             errors_1215['log_changes_log_id_fk']['error']
         )
 
@@ -335,9 +335,9 @@ def test_1215_on_update_set_null_not_nullable(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -358,7 +358,7 @@ def test_1215_on_update_set_null_not_nullable(self):
             str(errors_1215['log_changes_log_id_fk']['foreign_key'])
         )
         self.assertEquals(
-            "MySQL 1215 error for foreign key `log_changes_log_id_fk`: invalid SET NULL. `log_changes`.`log_id` is not allowed to be null but the foreign key attempts to set the value to null ON UPDATE",
+            "Constraint error for foreign key `log_changes_log_id_fk`: invalid SET NULL. `log_changes`.`log_id` is not allowed to be null but the foreign key attempts to set the value to null ON UPDATE",
             errors_1215['log_changes_log_id_fk']['error']
         )
 
@@ -374,9 +374,9 @@ def test_1215_on_update_on_delete_set_null_not_nullable(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -397,7 +397,7 @@ def test_1215_on_update_on_delete_set_null_not_nullable(self):
             str(errors_1215['log_changes_log_id_fk']['foreign_key'])
         )
         self.assertEquals(
-            "MySQL 1215 error for foreign key `log_changes_log_id_fk`: invalid SET NULL. `log_changes`.`log_id` is not allowed to be null but the foreign key attempts to set the value to null ON DELETE and ON UPDATE",
+            "Constraint error for foreign key `log_changes_log_id_fk`: invalid SET NULL. `log_changes`.`log_id` is not allowed to be null but the foreign key attempts to set the value to null ON DELETE and ON UPDATE",
             errors_1215['log_changes_log_id_fk']['error']
         )
 
@@ -419,9 +419,9 @@ def test_missing_index(self):
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
         """
         ]
-        db = database_reader(strings)
+        db = DatabaseReader(strings)
 
-        new_table = create_parser()
+        new_table = CreateParser()
         new_table.parse(
             """CREATE TABLE `log_changes` (
             `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -445,6 +445,6 @@ def test_missing_index(self):
             str(errors_1215['log_changes_log_id_fk']['foreign_key'])
         )
         self.assertEquals(
-            "MySQL 1215 error for foreign key `log_changes_log_id_fk`: missing index. `log_changes`.`log_id` references `logs`.`id` but `logs`.`id` does not have an index and therefore cannot be used in a foreign key constraint",
+            "Constraint error for foreign key `log_changes_log_id_fk`: missing index. `log_changes`.`log_id` references `logs`.`id` but `logs`.`id` does not have an index and therefore cannot be used in a foreign key constraint",
             errors_1215['log_changes_log_id_fk']['error']
         )
diff --git a/tests/formats/mysql/mygrations/test_mygrate_nulls.py b/mygrations/tests/integration/mygrate_nulls_test.py
similarity index 75%
rename from tests/formats/mysql/mygrations/test_mygrate_nulls.py
rename to mygrations/tests/integration/mygrate_nulls_test.py
index 9b9893c..01f3d49 100644
--- a/tests/formats/mysql/mygrations/test_mygrate_nulls.py
+++ b/mygrations/tests/integration/mygrate_nulls_test.py
@@ -1,10 +1,10 @@
 import unittest
-from mygrations.formats.mysql.db_reader.database import database as database_reader
-from mygrations.formats.mysql.file_reader.database import database as file_reader
-from mygrations.drivers.mysqldb.mysqldb import mysqldb
-from tests.mocks.db.mysql.db_structure import db_structure
-from mygrations.formats.mysql.mygrations.row_mygration import row_mygration
-class test_mygrate_null(unittest.TestCase):
+from mygrations.formats.mysql.db_reader.database import Database as DatabaseReader
+from mygrations.formats.mysql.file_reader.database import Database as FileReader
+from mygrations.drivers.pymysql.pymysql import PyMySQL
+from mygrations.tests.mocks.db.mysql.db_structure import DbStructure
+from mygrations.formats.mysql.mygrations.row_mygration import RowMygration
+class MygrateNullsTest(unittest.TestCase):
     def test_diff_with_null(self):
         """ NULL should be allowed and should result in a MySQL NULL in the database
 
@@ -42,7 +42,7 @@ def test_diff_with_null(self):
             )
         }
 
-        db_db = database_reader(mysqldb(db_structure(tables, rows)))
+        db_db = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows)))
         db_db.read_rows('logs')
 
         # and one from a file
@@ -58,11 +58,11 @@ def test_diff_with_null(self):
         INSERT INTO logs (id,message,traceback) VALUES (3,'from null to null', NULL);
         INSERT INTO logs (id,message,traceback) VALUES (4,'Insert Null',NULL);
         """
-        files_db = file_reader([table1])
+        files_db = FileReader([table1])
 
-        mygrate = row_mygration(files_db, db_db)
+        mygrate = RowMygration(files_db, db_db)
         ops = [str(op) for op in mygrate]
         self.assertEquals(3, len(ops))
         self.assertTrue("INSERT INTO `logs` (`id`, `message`, `traceback`) VALUES ('4', 'Insert Null', NULL);" in ops)
-        self.assertTrue("UPDATE `logs` SET `message`='from null to value', `traceback`='HEY' WHERE id=1;" in ops)
-        self.assertTrue("UPDATE `logs` SET `message`='from value to null', `traceback`=NULL WHERE id=2;" in ops)
+        self.assertTrue("UPDATE `logs` SET `message`='from null to value', `traceback`='HEY' WHERE id='1';" in ops)
+        self.assertTrue("UPDATE `logs` SET `message`='from value to null', `traceback`=NULL WHERE id='2';" in ops)
diff --git a/tests/formats/mysql/mygrations/test_mygrate_rows_different_types.py b/mygrations/tests/integration/mygrate_rows_different_types_test.py
similarity index 67%
rename from tests/formats/mysql/mygrations/test_mygrate_rows_different_types.py
rename to mygrations/tests/integration/mygrate_rows_different_types_test.py
index 4e8a1fb..9f2a14e 100644
--- a/tests/formats/mysql/mygrations/test_mygrate_rows_different_types.py
+++ b/mygrations/tests/integration/mygrate_rows_different_types_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.formats.mysql.db_reader.database import database as database_reader
-from mygrations.drivers.mysqldb.mysqldb import mysqldb
-from tests.mocks.db.mysql.db_structure import db_structure
-from mygrations.formats.mysql.mygrations.row_mygration import row_mygration
-class test_mygrate_rows_different_types(unittest.TestCase):
+from mygrations.formats.mysql.db_reader.database import Database as DatabaseReader
+from mygrations.drivers.pymysql.pymysql import PyMySQL
+from mygrations.tests.mocks.db.mysql.db_structure import DbStructure
+from mygrations.formats.mysql.mygrations.row_mygration import RowMygration
+class MygrateRowsDifferentTypesTest(unittest.TestCase):
     def _get_tables(self):
 
         return {
@@ -25,7 +25,7 @@ def test_modify_rows(self):
         tables = self._get_tables()
         rows = {'logs': ({'id': 1, 'account_id': 1, 'message': 'hey'}, {'id': 2, 'account_id': 1, 'message': 'sup'})}
 
-        database = database_reader(mysqldb(db_structure(tables, rows)))
+        database = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows)))
         database.read_rows('logs')
 
         rows_from = {
@@ -39,8 +39,8 @@ def test_modify_rows(self):
                 'message': 'sup'
             })
         }
-        database_from = database_reader(mysqldb(db_structure(tables, rows_from)))
+        database_from = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows_from)))
         database_from.read_rows('logs')
 
-        operations = [str(op) for op in row_mygration(database, database_from).operations]
+        operations = [str(op) for op in RowMygration(database, database_from).operations]
         self.assertEquals(0, len(operations))
diff --git a/tests/formats/mysql/mygrations/test_mygrate_rows_simple.py b/mygrations/tests/integration/mygrate_rows_simple_test.py
similarity index 73%
rename from tests/formats/mysql/mygrations/test_mygrate_rows_simple.py
rename to mygrations/tests/integration/mygrate_rows_simple_test.py
index 089d667..c6e5395 100644
--- a/tests/formats/mysql/mygrations/test_mygrate_rows_simple.py
+++ b/mygrations/tests/integration/mygrate_rows_simple_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.formats.mysql.db_reader.database import database as database_reader
-from mygrations.drivers.mysqldb.mysqldb import mysqldb
-from tests.mocks.db.mysql.db_structure import db_structure
-from mygrations.formats.mysql.mygrations.row_mygration import row_mygration
-class test_mygrate_rows_simple(unittest.TestCase):
+from mygrations.formats.mysql.db_reader.database import Database as DatabaseReader
+from mygrations.drivers.pymysql.pymysql import PyMySQL
+from mygrations.tests.mocks.db.mysql.db_structure import DbStructure
+from mygrations.formats.mysql.mygrations.row_mygration import RowMygration
+class MygrateRowsSimpleTest(unittest.TestCase):
     def _get_tables(self):
 
         return {
@@ -42,10 +42,10 @@ def test_add_rows(self):
             })
         }
 
-        database = database_reader(mysqldb(db_structure(tables, rows)))
+        database = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows)))
         database.read_rows('logs')
 
-        operations = [str(op) for op in row_mygration(database).operations]
+        operations = [str(op) for op in RowMygration(database).operations]
         self.assertEquals(2, len(operations))
         self.assertEquals(
             "INSERT INTO `logs` (`id`, `message`, `traceback`) VALUES ('1', 'hey', 'never');", operations[0]
@@ -70,7 +70,7 @@ def test_modify_rows(self):
             })
         }
 
-        database = database_reader(mysqldb(db_structure(tables, rows)))
+        database = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows)))
         database.read_rows('logs')
 
         rows_from = {
@@ -84,12 +84,12 @@ def test_modify_rows(self):
                 'traceback': 'forever'
             })
         }
-        database_from = database_reader(mysqldb(db_structure(tables, rows_from)))
+        database_from = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows_from)))
         database_from.read_rows('logs')
 
-        operations = [str(op) for op in row_mygration(database, database_from).operations]
+        operations = [str(op) for op in RowMygration(database, database_from).operations]
         self.assertEquals(1, len(operations))
-        self.assertEquals("UPDATE `logs` SET `message`='hey', `traceback`='never' WHERE id=1;", operations[0])
+        self.assertEquals("UPDATE `logs` SET `message`='hey', `traceback`='never' WHERE id='1';", operations[0])
 
     def test_delete_rows(self):
 
@@ -97,7 +97,7 @@ def test_delete_rows(self):
         tables = self._get_tables()
         rows = {'logs': ({'id': 2, 'message': 'sup', 'traceback': 'forever'}, )}
 
-        database = database_reader(mysqldb(db_structure(tables, rows)))
+        database = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows)))
         database.read_rows('logs')
 
         rows_from = {
@@ -111,12 +111,12 @@ def test_delete_rows(self):
                 'traceback': 'forever'
             })
         }
-        database_from = database_reader(mysqldb(db_structure(tables, rows_from)))
+        database_from = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows_from)))
         database_from.read_rows('logs')
 
-        operations = [str(op) for op in row_mygration(database, database_from).operations]
+        operations = [str(op) for op in RowMygration(database, database_from).operations]
         self.assertEquals(1, len(operations))
-        self.assertEquals("DELETE FROM `logs` WHERE id=1;", operations[0])
+        self.assertEquals("DELETE FROM `logs` WHERE id='1';", operations[0])
 
     def test_all(self):
 
@@ -134,7 +134,7 @@ def test_all(self):
             })
         }
 
-        database = database_reader(mysqldb(db_structure(tables, rows)))
+        database = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows)))
         database.read_rows('logs')
 
         rows_from = {
@@ -148,14 +148,14 @@ def test_all(self):
                 'traceback': 'forever'
             })
         }
-        database_from = database_reader(mysqldb(db_structure(tables, rows_from)))
+        database_from = DatabaseReader(PyMySQL(connection=DbStructure(tables, rows_from)))
         database_from.read_rows('logs')
 
-        operations = [str(op) for op in row_mygration(database, database_from).operations]
+        operations = [str(op) for op in RowMygration(database, database_from).operations]
         # don't be picky about the order
         self.assertEquals(3, len(operations))
         self.assertTrue(
             "INSERT INTO `logs` (`id`, `message`, `traceback`) VALUES ('3', 'okay', 'always');" in operations
         )
-        self.assertTrue("DELETE FROM `logs` WHERE id=1;" in operations)
-        self.assertTrue("UPDATE `logs` SET `message`='sup', `traceback`='whatever' WHERE id=2;" in operations)
+        self.assertTrue("DELETE FROM `logs` WHERE id='1';" in operations)
+        self.assertTrue("UPDATE `logs` SET `message`='sup', `traceback`='whatever' WHERE id='2';" in operations)
diff --git a/tests/formats/mysql/file_reader/test_regressions.py b/mygrations/tests/integration/regression_test.py
similarity index 85%
rename from tests/formats/mysql/file_reader/test_regressions.py
rename to mygrations/tests/integration/regression_test.py
index 59b4d7e..b84b581 100644
--- a/tests/formats/mysql/file_reader/test_regressions.py
+++ b/mygrations/tests/integration/regression_test.py
@@ -1,13 +1,12 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.reader import reader
-from mygrations.formats.mysql.file_reader.database import database
-class test_regressions(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.reader import Reader
+from mygrations.formats.mysql.file_reader.database import Database
+class RegressionTest(unittest.TestCase):
     """ Some tests based off failing CREATE TABLE commands from our database when things were first being finished """
-
     def test_payment_request_types(self):
 
-        parser = reader()
+        parser = Reader()
         returned = parser.parse(
             """CREATE TABLE `payment_request_types` (
   `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -28,11 +27,12 @@ def test_payment_request_types(self):
         self.assertEquals('', returned)
 
         # we should not have any errors
-        self.assertEquals([], parser.errors)
+        self.assertEquals([], parser.schema_errors)
+        self.assertEquals([], parser.parsing_errors)
 
     def test_employee_contract_list(self):
 
-        db = database(
+        db = Database(
             """CREATE TABLE `employee_contract_list` (
  `id` int(10) NOT NULL AUTO_INCREMENT,
  `account_id` int(10) unsigned NOT NULL,
@@ -61,4 +61,6 @@ def test_employee_contract_list(self):
 (12, 1, 'fed_i9', 'Federal I-9', 'employee_fed_i9s', 'fed_i9_id', 'hr/tax_forms/complete_fed_i9');"""
         )
 
-        self.assertEquals([], db.errors)
+        self.assertEquals([
+            'Constraint error for foreign key `employee_contact_list_account_id_ref_accounts_id`: `employee_contract_list`.`account_id` references `accounts`.`id`, but table `accounts` does not exist'
+        ], db.errors)
diff --git a/tests/formats/mysql/mygrations/test_simple_modify.py b/mygrations/tests/integration/simple_modify_test.py
similarity index 89%
rename from tests/formats/mysql/mygrations/test_simple_modify.py
rename to mygrations/tests/integration/simple_modify_test.py
index 14f94b8..fe4918d 100644
--- a/tests/formats/mysql/mygrations/test_simple_modify.py
+++ b/mygrations/tests/integration/simple_modify_test.py
@@ -1,7 +1,7 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.database import database as database_reader
-from mygrations.formats.mysql.mygrations.mygration import mygration
+from mygrations.formats.mysql.file_reader.database import Database as DatabaseReader
+from mygrations.formats.mysql.mygrations.mygration import Mygration
 class test_add_conflicting_fks(unittest.TestCase):
 
     accounts_table = """CREATE TABLE `accounts` (`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -19,7 +19,7 @@ def _get_db1(self):
             KEY `repeating_task_id_tasks` (`repeating_task_id`),
             CONSTRAINT `account_id_tasks_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
-        return database_reader([table1, self.accounts_table])
+        return DatabaseReader([table1, self.accounts_table])
 
     def test_simple_add_and_modify(self):
         """ Migrate to a database that has one extra table and column """
@@ -37,10 +37,10 @@ def test_simple_add_and_modify(self):
         table3 = """CREATE TABLE `histories` (`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
 `name` VARCHAR(255) NOT NULL DEFAULT '',
 PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
-        db2 = database_reader([table1, self.accounts_table, table3])
+        db2 = DatabaseReader([table1, self.accounts_table, table3])
         db1 = self._get_db1()
 
-        mygrate = mygration(db2, db1)
+        mygrate = Mygration(db2, db1)
 
         ops = [str(op) for op in mygrate.operations]
         self.assertEquals('SET FOREIGN_KEY_CHECKS=0;', ops[0])
@@ -67,11 +67,11 @@ def test_add_column_and_mutually_dependent_fk(self):
 PRIMARY KEY (`id`),
 KEY `account_id_rts` (`account_id`),
 KEY `task_id_rts` (`task_id`),
-CONSTRAINT `account_id_tasks_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+CONSTRAINT `account_id_repeating_tasks_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 CONSTRAINT `task_id_rts` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE ON UPDATE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
-        db2 = database_reader([table1, table2, self.accounts_table])
+        db2 = DatabaseReader([table1, table2, self.accounts_table])
 
-        mygrate = mygration(db2, self._get_db1())
+        mygrate = Mygration(db2, self._get_db1())
         ops = [str(op) for op in mygrate.operations]
 
         self.assertEquals('SET FOREIGN_KEY_CHECKS=0;', ops[0])
@@ -96,10 +96,10 @@ def test_all_key_adjustments(self):
             KEY `cool_key` (`name`),
             CONSTRAINT `account_id_tasks_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
-        db2 = database_reader([table1, self.accounts_table])
+        db2 = DatabaseReader([table1, self.accounts_table])
         db1 = self._get_db1()
 
-        mygrate = mygration(db2, db1)
+        mygrate = Mygration(db2, db1)
         ops = [str(op) for op in mygrate.operations]
 
         self.assertEquals('SET FOREIGN_KEY_CHECKS=0;', ops[0])
@@ -120,10 +120,10 @@ def test_all_column_adjustments(self):
             KEY `account_id_tasks` (`account_id`),
             CONSTRAINT `account_id_tasks_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
-        db2 = database_reader([table1, self.accounts_table])
+        db2 = DatabaseReader([table1, self.accounts_table])
         db1 = self._get_db1()
 
-        mygrate = mygration(db2, db1)
+        mygrate = Mygration(db2, db1)
         ops = [str(op) for op in mygrate.operations]
 
         self.assertEquals('SET FOREIGN_KEY_CHECKS=0;', ops[0])
@@ -148,7 +148,7 @@ def test_all_constraint_adjustments(self):
             CONSTRAINT `account_id_tasks_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
             CONSTRAINT `task_id_fk` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
-        db1 = database_reader([table1, self.accounts_table])
+        db1 = DatabaseReader([table1, self.accounts_table])
 
         table1 = """CREATE TABLE `tasks` (`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
             `account_id` INT(10) UNSIGNED NOT NULL,
@@ -162,9 +162,9 @@ def test_all_constraint_adjustments(self):
             CONSTRAINT `account_id_tasks_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
             CONSTRAINT `task_id_2_fk` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
-        db2 = database_reader([table1, self.accounts_table])
+        db2 = DatabaseReader([table1, self.accounts_table])
 
-        mygrate = mygration(db2, db1)
+        mygrate = Mygration(db2, db1)
         ops = [str(op) for op in mygrate.operations]
 
         self.assertEquals('SET FOREIGN_KEY_CHECKS=0;', ops[0])
@@ -193,10 +193,10 @@ def test_no_operations_on_1215(self):
             CONSTRAINT `account_id_tasks_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
 
-        db = database_reader([table1, self.accounts_table])
-        mygrate = mygration(db)
+        db = DatabaseReader([table1, self.accounts_table])
+        mygrate = Mygration(db)
 
         self.assertEquals([
-            'MySQL 1215 error for foreign key `account_id_tasks_fk`: unsigned mistmatch. `accounts`.`id` is unsigned but `tasks`.`account_id` is not'
-        ], mygrate.errors_1215)
+            'Constraint error for foreign key `account_id_tasks_fk`: unsigned mistmatch. `accounts`.`id` is unsigned but `tasks`.`account_id` is not'
+        ], mygrate.errors)
         self.assertEquals(None, mygrate.operations)
diff --git a/tests/formats/mysql/definitions/test_table_1215_regressions.py b/mygrations/tests/integration/table_constraint_regressions_test.py
similarity index 89%
rename from tests/formats/mysql/definitions/test_table_1215_regressions.py
rename to mygrations/tests/integration/table_constraint_regressions_test.py
index c6bd7b0..ff53f7f 100644
--- a/tests/formats/mysql/definitions/test_table_1215_regressions.py
+++ b/mygrations/tests/integration/table_constraint_regressions_test.py
@@ -1,11 +1,11 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.database import database as database_reader
+from mygrations.formats.mysql.file_reader.database import Database as DatabaseReader
 class test_table_1215_regressions(unittest.TestCase):
     def test_foreign_key_without_index(self):
         """ Discovered that the system was not raising an error for a foreign key that didn't have an index for the table it was attached to """
 
-        db = database_reader([
+        db = DatabaseReader([
             """CREATE TABLE `vendors` (`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`));""",
             """CREATE TABLE `payment_requests_external` (
   `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -40,8 +40,8 @@ def test_foreign_key_without_index(self):
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;"""
         ])
 
-        self.assertEquals(1, len(db.errors_1215))
+        self.assertEquals(1, len(db.errors))
         self.assertEquals(
-            'MySQL 1215 error for foreign key `vendor_id_pr_external_fk`: missing index. `payment_requests_external`.`vendor_id` does not have an index and therefore cannot be used in a foreign key constraint',
-            db.errors_1215[0]
+            'Constraint error for foreign key `vendor_id_pr_external_fk`: missing index. `payment_requests_external`.`vendor_id` does not have an index and therefore cannot be used in a foreign key constraint',
+            db.errors[0]
         )
diff --git a/tests/formats/mysql/definitions/test_table_decimal_false_positives.py b/mygrations/tests/integration/table_decimal_false_positives_test.py
similarity index 86%
rename from tests/formats/mysql/definitions/test_table_decimal_false_positives.py
rename to mygrations/tests/integration/table_decimal_false_positives_test.py
index f974067..fab4094 100644
--- a/tests/formats/mysql/definitions/test_table_decimal_false_positives.py
+++ b/mygrations/tests/integration/table_decimal_false_positives_test.py
@@ -1,7 +1,7 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-class test_table_decimal_false_positives(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+class TableDecimalFalsePositivesTest(unittest.TestCase):
     def test_decimal_false_positives(self):
         """ false positives for change on default for decimal columns
 
@@ -10,7 +10,7 @@ def test_decimal_false_positives(self):
         the default to `0` mysql will return a default of '0.00', leading to a false
         detection of a column change and unnecessary database changes.
         """
-        from_table = create_parser()
+        from_table = CreateParser()
         from_table.parse(
             """CREATE TABLE `decimal_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -20,7 +20,7 @@ def test_decimal_false_positives(self):
         """
         )
 
-        to_table = create_parser()
+        to_table = CreateParser()
         to_table.parse(
             """CREATE TABLE `decimal_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -34,7 +34,7 @@ def test_decimal_false_positives(self):
 
     def test_decimal_false_positives_just_because(self):
         """ This probably isn't a realistic test, but let's go for it while we're here """
-        from_table = create_parser()
+        from_table = CreateParser()
         from_table.parse(
             """CREATE TABLE `decimal_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -44,7 +44,7 @@ def test_decimal_false_positives_just_because(self):
         """
         )
 
-        to_table = create_parser()
+        to_table = CreateParser()
         to_table.parse(
             """CREATE TABLE `decimal_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -57,7 +57,7 @@ def test_decimal_false_positives_just_because(self):
         self.assertEquals(0, len(from_table.to(to_table)))
 
     def test_decimal_real_positives(self):
-        from_table = create_parser()
+        from_table = CreateParser()
         from_table.parse(
             """CREATE TABLE `decimal_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -67,7 +67,7 @@ def test_decimal_real_positives(self):
         """
         )
 
-        to_table = create_parser()
+        to_table = CreateParser()
         to_table.parse(
             """CREATE TABLE `decimal_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -80,7 +80,7 @@ def test_decimal_real_positives(self):
         self.assertEquals(1, len(from_table.to(to_table)))
 
     def test_float_false_positives(self):
-        from_table = create_parser()
+        from_table = CreateParser()
         from_table.parse(
             """CREATE TABLE `decimal_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -90,7 +90,7 @@ def test_float_false_positives(self):
         """
         )
 
-        to_table = create_parser()
+        to_table = CreateParser()
         to_table.parse(
             """CREATE TABLE `decimal_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
diff --git a/tests/formats/mysql/definitions/test_table_difference_columns.py b/mygrations/tests/integration/table_difference_columns_test.py
similarity index 92%
rename from tests/formats/mysql/definitions/test_table_difference_columns.py
rename to mygrations/tests/integration/table_difference_columns_test.py
index 05bc6b8..9768ec6 100644
--- a/tests/formats/mysql/definitions/test_table_difference_columns.py
+++ b/mygrations/tests/integration/table_difference_columns_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-class test_table_difference(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+class TableDifferenceColumnsTest(unittest.TestCase):
     def test_drop_columns(self):
 
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -14,7 +14,7 @@ def test_drop_columns(self):
         """
         )
 
-        b = create_parser()
+        b = CreateParser()
         b.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -33,7 +33,7 @@ def test_drop_columns(self):
 
     def test_add_columns(self):
 
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -43,7 +43,7 @@ def test_add_columns(self):
         """
         )
 
-        b = create_parser()
+        b = CreateParser()
         b.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -65,7 +65,7 @@ def test_add_columns(self):
 
     def test_add_remove_change_columns(self):
 
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -75,7 +75,7 @@ def test_add_remove_change_columns(self):
         """
         )
 
-        b = create_parser()
+        b = CreateParser()
         b.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -96,7 +96,7 @@ def test_add_remove_change_columns(self):
 
     def test_split(self):
 
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -106,7 +106,7 @@ def test_split(self):
         """
         )
 
-        b = create_parser()
+        b = CreateParser()
         b.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
diff --git a/tests/formats/mysql/definitions/test_table_difference_keys.py b/mygrations/tests/integration/table_difference_keys_test.py
similarity index 93%
rename from tests/formats/mysql/definitions/test_table_difference_keys.py
rename to mygrations/tests/integration/table_difference_keys_test.py
index 0be1f8e..1f1a8f8 100644
--- a/tests/formats/mysql/definitions/test_table_difference_keys.py
+++ b/mygrations/tests/integration/table_difference_keys_test.py
@@ -1,9 +1,9 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-class test_table_difference(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+class TableDifferenceKeysTest(unittest.TestCase):
     def _get_parsers(self):
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -14,7 +14,7 @@ def _get_parsers(self):
         """
         )
 
-        b = create_parser()
+        b = CreateParser()
         b.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -49,7 +49,7 @@ def test_add_keys(self):
         )
 
     def test_change_keys(self):
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -61,7 +61,7 @@ def test_change_keys(self):
         """
         )
 
-        b = create_parser()
+        b = CreateParser()
         b.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -82,7 +82,7 @@ def test_change_keys(self):
         )
 
     def test_add_remove_change(self):
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -96,7 +96,7 @@ def test_add_remove_change(self):
         """
         )
 
-        b = create_parser()
+        b = CreateParser()
         b.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -119,7 +119,7 @@ def test_add_remove_change(self):
         )
 
     def test_unique_keys(self):
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -133,7 +133,7 @@ def test_unique_keys(self):
         """
         )
 
-        b = create_parser()
+        b = CreateParser()
         b.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -155,7 +155,7 @@ def test_unique_keys(self):
         )
 
     def test_primary_key(self):
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -165,7 +165,7 @@ def test_primary_key(self):
         """
         )
 
-        b = create_parser()
+        b = CreateParser()
         b.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
diff --git a/tests/formats/mysql/definitions/test_table_operations_column.py b/mygrations/tests/integration/table_operations_column_test.py
similarity index 87%
rename from tests/formats/mysql/definitions/test_table_operations_column.py
rename to mygrations/tests/integration/table_operations_column_test.py
index 0ed7e9c..eddb74f 100644
--- a/tests/formats/mysql/definitions/test_table_operations_column.py
+++ b/mygrations/tests/integration/table_operations_column_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-from mygrations.formats.mysql.definitions.column import column
-class test_table_operations_column(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+from mygrations.core.definitions.columns.column import Column
+class TableOperationsColumnTest(unittest.TestCase):
     def _get_default_table(self):
-        table = create_parser()
+        table = CreateParser()
         table.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -21,7 +21,7 @@ def _get_default_table(self):
         return table
 
     def _get_new_column(self):
-        return column(**{"name": "description", "column_type": "TEXT", "length": ""})
+        return Column(**{"name": "description", "column_type": "TEXT", "length": ""})
 
     def test_add_column_before(self):
         table = self._get_default_table()
@@ -62,7 +62,7 @@ def test_remove_column(self):
 
     def test_change_column(self):
         table = self._get_default_table()
-        new_subject = column(**{"name": "subject", "column_type": "TEXT", "length": ""})
+        new_subject = Column(**{"name": "subject", "column_type": "TEXT", "length": ""})
         table.change_column(new_subject)
 
         self.assertEquals('`subject` TEXT', str(table.columns['subject']))
diff --git a/tests/formats/mysql/definitions/test_table_operations_constraints.py b/mygrations/tests/integration/table_operations_constraints_test.py
similarity index 81%
rename from tests/formats/mysql/definitions/test_table_operations_constraints.py
rename to mygrations/tests/integration/table_operations_constraints_test.py
index 8ec6f11..a3d1f4c 100644
--- a/tests/formats/mysql/definitions/test_table_operations_constraints.py
+++ b/mygrations/tests/integration/table_operations_constraints_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-from mygrations.formats.mysql.definitions.constraint import constraint
-class test_table_operations_constraints(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+from mygrations.formats.mysql.definitions.constraint import Constraint
+class TableOperationsConstraintsTest(unittest.TestCase):
     def _get_default_table(self):
-        table = create_parser()
+        table = CreateParser()
         table.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -23,12 +23,12 @@ def _get_default_table(self):
         return table
 
     def _get_new_constraint(self):
-        return constraint(
+        return Constraint(
             **{
                 "name": "status_id_fk",
-                "column": "status_id",
-                "foreign_table": "task_statuses",
-                "foreign_column": "id",
+                "column_name": "status_id",
+                "foreign_table_name": "task_statuses",
+                "foreign_column_name": "id",
                 "on_delete": "RESTRICT",
                 "on_update": "CASCADE"
             }
@@ -58,12 +58,12 @@ def test_cannot_remove_missing_constraint(self):
     def test_change_constraint(self):
         table = self._get_default_table()
         table.change_constraint(
-            constraint(
+            Constraint(
                 **{
                     "name": "tasks_account_id_fk",
-                    "column": "account_id",
-                    "foreign_table": "task_accounts",
-                    "foreign_column": "id",
+                    "column_name": "account_id",
+                    "foreign_table_name": "task_accounts",
+                    "foreign_column_name": "id",
                     "on_delete": "RESTRICT",
                     "on_update": "SET NULL"
                 }
diff --git a/tests/formats/mysql/definitions/test_table_operations_key.py b/mygrations/tests/integration/table_operations_key_test.py
similarity index 71%
rename from tests/formats/mysql/definitions/test_table_operations_key.py
rename to mygrations/tests/integration/table_operations_key_test.py
index 204fbab..fccb75e 100644
--- a/tests/formats/mysql/definitions/test_table_operations_key.py
+++ b/mygrations/tests/integration/table_operations_key_test.py
@@ -1,10 +1,10 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-from mygrations.formats.mysql.definitions.index import index
-class test_table_operations_key(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+from mygrations.formats.mysql.definitions.index import Index
+class TableOperationsKeyTest(unittest.TestCase):
     def _get_default_table(self):
-        table = create_parser()
+        table = CreateParser()
         table.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -20,41 +20,41 @@ def _get_default_table(self):
 
         return table
 
-    def test_add_key(self):
+    def test_add_index(self):
         table = self._get_default_table()
         self.assertTrue(table.column_is_indexed('account_id'))
 
-        table.add_key(index('task_tasks', ['task', 'subject']))
+        table.add_index(Index('task_tasks', ['task', 'subject']))
         self.assertTrue('task_tasks' in table.indexes)
         self.assertTrue(table.column_is_indexed('task'))
 
     def test_cannot_add_duplicate_key(self):
         table = self._get_default_table()
         with self.assertRaises(ValueError):
-            table.add_key(index('tasks_account_id', ['task', 'subject']))
+            table.add_index(Index('tasks_account_id', ['task', 'subject']))
 
-    def test_remove_key(self):
+    def test_remove_index(self):
         table = self._get_default_table()
         self.assertTrue(table.column_is_indexed('account_id'))
 
-        table.remove_key('tasks_account_id')
+        table.remove_index('tasks_account_id')
         self.assertFalse('tasks_account_id' in table.indexes)
         self.assertFalse(table.column_is_indexed('account_id'))
 
     def test_cannot_remove_missing_key(self):
         table = self._get_default_table()
         with self.assertRaises(ValueError):
-            table.remove_key('asdf')
+            table.remove_index('asdf')
 
-    def test_change_key(self):
+    def test_change_index(self):
         table = self._get_default_table()
         self.assertTrue(table.column_is_indexed('account_id'))
 
-        table.change_key(index('tasks_account_id', ['account_id', 'task']))
+        table.change_index(Index('tasks_account_id', ['account_id', 'task']))
         self.assertEquals('KEY `tasks_account_id` (`account_id`,`task`)', str(table.indexes['tasks_account_id']))
         self.assertTrue(table.column_is_indexed('account_id'))
 
     def test_cannot_change_missing_key(self):
         table = self._get_default_table()
         with self.assertRaises(ValueError):
-            table.change_key(index('asdf', ['account_id', 'task']))
+            table.change_index(Index('asdf', ['account_id', 'task']))
diff --git a/tests/formats/mysql/definitions/test_table_return_create.py b/mygrations/tests/integration/table_return_create_test.py
similarity index 89%
rename from tests/formats/mysql/definitions/test_table_return_create.py
rename to mygrations/tests/integration/table_return_create_test.py
index 9b76393..de3c4a4 100644
--- a/tests/formats/mysql/definitions/test_table_return_create.py
+++ b/mygrations/tests/integration/table_return_create_test.py
@@ -1,9 +1,9 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-class test_table_difference(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+class TableDifferenceTest(unittest.TestCase):
     def test_simple_create(self):
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -20,7 +20,7 @@ def test_simple_create(self):
         )
 
     def test_with_constraints(self):
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -37,7 +37,7 @@ def test_with_constraints(self):
         )
 
     def test_with_options(self):
-        a = create_parser()
+        a = CreateParser()
         a.parse(
             """CREATE TABLE `tasks` (
             `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
diff --git a/tests/formats/mysql/definitions/test_table_text_false_positives.py b/mygrations/tests/integration/table_text_false_positives_test.py
similarity index 94%
rename from tests/formats/mysql/definitions/test_table_text_false_positives.py
rename to mygrations/tests/integration/table_text_false_positives_test.py
index 419b303..010d825 100644
--- a/tests/formats/mysql/definitions/test_table_text_false_positives.py
+++ b/mygrations/tests/integration/table_text_false_positives_test.py
@@ -1,7 +1,7 @@
 import unittest
 
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-class test_table_text_false_positives(unittest.TestCase):
+from mygrations.formats.mysql.file_reader.create_parser import CreateParser
+class TableTextFalsePositives(unittest.TestCase):
     def test_row_false_positives(self):
         """ Complicated false positives for changes on text columns
 
@@ -30,7 +30,7 @@ def test_row_false_positives(self):
         alter this table every time we migrate, with no affect.  This test makes sure
         we don't all into this false-positive trap.
         """
-        from_table = create_parser()
+        from_table = CreateParser()
         from_table.parse(
             """CREATE TABLE `collate_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -40,7 +40,7 @@ def test_row_false_positives(self):
         """
         )
 
-        to_table = create_parser()
+        to_table = CreateParser()
         to_table.parse(
             """CREATE TABLE `collate_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -55,7 +55,7 @@ def test_row_false_positives(self):
     def test_false_positive_didnt_break_real_positives(self):
         """ Make sure that the above false-positive correction didn't break real-positive detections """
 
-        from_table = create_parser()
+        from_table = CreateParser()
         from_table.parse(
             """CREATE TABLE `collate_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -65,7 +65,7 @@ def test_false_positive_didnt_break_real_positives(self):
         """
         )
 
-        to_table = create_parser()
+        to_table = CreateParser()
         to_table.parse(
             """CREATE TABLE `collate_false_positive` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
diff --git a/tests/formats/mysql/file_reader/__init__.py b/mygrations/tests/mocks/__init__.py
similarity index 100%
rename from tests/formats/mysql/file_reader/__init__.py
rename to mygrations/tests/mocks/__init__.py
diff --git a/tests/formats/mysql/file_reader/parsers/__init__.py b/mygrations/tests/mocks/db/__init__.py
similarity index 100%
rename from tests/formats/mysql/file_reader/parsers/__init__.py
rename to mygrations/tests/mocks/db/__init__.py
diff --git a/tests/helpers/__init__.py b/mygrations/tests/mocks/db/mysql/__init__.py
similarity index 100%
rename from tests/helpers/__init__.py
rename to mygrations/tests/mocks/db/mysql/__init__.py
diff --git a/tests/mocks/db/mysql/db_structure.py b/mygrations/tests/mocks/db/mysql/db_structure.py
similarity index 93%
rename from tests/mocks/db/mysql/db_structure.py
rename to mygrations/tests/mocks/db/mysql/db_structure.py
index 6fd86e2..7d92020 100644
--- a/tests/mocks/db/mysql/db_structure.py
+++ b/mygrations/tests/mocks/db/mysql/db_structure.py
@@ -1,11 +1,10 @@
 import re
-class db_structure:
+class DbStructure:
     """ Mock class used when working with classes which read the database structure/rows
 
     This handles just a few simple queries: SHOW TABLES, SHOW CREATE TABLE,
     and SELECT * FROM
     """
-
     def __init__(self, tables, table_rows):
 
         self.tables = tables
@@ -48,8 +47,8 @@ def _load_show_tables_result(self):
         tables = [val for val in self.tables.keys()]
         tables.sort()
 
-        # results needs to be a list of tuples
-        self.results = [(val, ) for val in tables]
+        # results needs to be a list with a dictionary of table names as values (the key is ignored)
+        self.results = [{table: table for table in tables}]
 
     def _load_show_create_result(self, query):
         """ Loads up a result set internally to act as if a SHOW CREATE TABLE query was executed
@@ -58,12 +57,12 @@ def _load_show_create_result(self, query):
         :type query: string
         """
         table_name = query.split()[-1]
-        if not table_name in self.tables:
+        if table_name not in self.tables:
             raise ValueError(
                 "Cannot mock cursor.execute for query %s because table %s was not set" % (query, table_name)
             )
 
-        self.results = [(table_name, self.tables[table_name])]
+        self.results = [{'Create Table': self.tables[table_name]}]
 
     def _load_select_all_result(self, query):
         """ Loads up a result set internally to act as if a SELECT * FROM query was executed
diff --git a/test.py b/test.py
index 655456a..94a1b8c 100755
--- a/test.py
+++ b/test.py
@@ -1,10 +1,9 @@
 #!/usr/bin/env python3
 import unittest
-
 import sys, os
-sys.path.append(os.path.dirname(os.path.realpath(__file__)))
 
-glob = 'test*.py'
+sys.path.append(os.path.dirname(os.path.realpath(__file__)))
+glob = '?*test.py'
 if len(sys.argv) > 1:
     path = sys.argv[1]
     # generally expecting a directory.  If it is a file then find the parent directory
@@ -17,7 +16,7 @@
     # convert to python speak
     path = path.replace('/', '.').strip('.')
 else:
-    path = 'tests'
+    path = '.'
 
 tests = unittest.TestLoader().discover(path, pattern=glob)
 testRunner = unittest.runner.TextTestRunner()
diff --git a/tests/formats/mysql/db_reader/__init__.py b/tests/formats/mysql/db_reader/__init__.py
deleted file mode 100644
index 8b13789..0000000
--- a/tests/formats/mysql/db_reader/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-
diff --git a/tests/formats/mysql/db_reader/test_database.py b/tests/formats/mysql/db_reader/test_database.py
deleted file mode 100644
index 2f0437d..0000000
--- a/tests/formats/mysql/db_reader/test_database.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import unittest
-
-from mygrations.formats.mysql.db_reader.database import database as database_reader
-from mygrations.drivers.mysqldb.mysqldb import mysqldb
-from tests.mocks.db.mysql.db_structure import db_structure
-class test_database(unittest.TestCase):
-    def _get_tables(self):
-
-        return {
-            'logs':
-            """
-                CREATE TABLE `logs` (
-                    `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                    `message` TEXT NOT NULL,
-                    `traceback` text,
-                    PRIMARY KEY (`id`)
-                ) ENGINE=InnoDB DEFAULT CHARSET=utf8;""",
-            'more_logs':
-            """
-                CREATE TABLE `more_logs` (
-                    `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                    `more_messages` TEXT NOT NULL,
-                    `traceback` text,
-                    PRIMARY KEY (`id`)
-                ) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
-        }
-
-    def test_simple(self):
-
-        tables = self._get_tables()
-
-        mock_db = db_structure(tables, {})
-        database = database_reader(mysqldb(mock_db))
-
-        # our parser should have a table!
-        self.assertTrue('logs' in database.tables)
-        self.assertTrue('more_logs' in database.tables)
-
-    def test_read_rows(self):
-
-        tables = self._get_tables()
-        rows = {
-            'logs': ({
-                'id': 1,
-                'message': 'hey',
-                'traceback': 'never'
-            }, {
-                'id': 2,
-                'message': 'sup',
-                'traceback': 'forever'
-            })
-        }
-
-        database = database_reader(mysqldb(db_structure(tables, rows)))
-
-        # quick double check
-        self.assertTrue('logs' in database.tables)
-        self.assertTrue('more_logs' in database.tables)
-
-        database.read_rows('logs')
-
-        self.assertTrue(database.tables['logs'].tracking_rows)
-        rows = database.tables['logs'].rows
-        self.assertEquals(1, rows[1]['id'])
-        self.assertEquals('hey', rows[1]['message'])
-        self.assertEquals('never', rows[1]['traceback'])
-
-        self.assertEquals(2, rows[2]['id'])
-        self.assertEquals('sup', rows[2]['message'])
-        self.assertEquals('forever', rows[2]['traceback'])
diff --git a/tests/formats/mysql/definitions/test_database.py b/tests/formats/mysql/definitions/test_database.py
deleted file mode 100644
index 84dd439..0000000
--- a/tests/formats/mysql/definitions/test_database.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import unittest
-
-from mygrations.formats.mysql.file_reader.database import database as database_reader
-from mygrations.formats.mysql.file_reader.create_parser import create_parser
-class test_database(unittest.TestCase):
-    def _get_sample_db(self):
-
-        strings = [
-            """
-            CREATE TABLE `logs` (
-                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                `message` TEXT NOT NULL,
-                `traceback` text,
-                PRIMARY KEY (`id`)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-        """, """
-            CREATE TABLE `more_logs` (
-                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                `more_messages` TEXT NOT NULL,
-                `traceback` text,
-                PRIMARY KEY (`id`)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-        """
-        ]
-        return database_reader(strings)
-
-    def test_simple(self):
-
-        db1 = self._get_sample_db()
-        strings = [
-            """
-            CREATE TABLE `logs` (
-                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                `message` TEXT NOT NULL,
-                `traceback` text,
-                PRIMARY KEY (`id`)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-        """, """
-            CREATE TABLE `less_logs` (
-                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                `more_messages` TEXT NOT NULL,
-                `traceback` text,
-                PRIMARY KEY (`id`)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-        """
-        ]
-        db2 = database_reader(strings)
-
-        #differences = db2 - db1
-        #self.assertEquals( [], differences )
-
-    def test_add_table(self):
-        db = self._get_sample_db()
-
-        new_table = create_parser()
-        new_table.parse(
-            """CREATE TABLE `log_changes` (
-            `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
-            `log_id` INT(10) UNSIGNED NOT NULL,
-            `type_id` INT(10) UNSIGNED NOT NULL,
-            `change` VARCHAR(255),
-            PRIMARY KEY (id),
-            KEY `log_changes_log_id` (`log_id`),
-            KEY `log_changes_type_id` (`type_id`)
-            );
-        """
-        )
-
-        db.add_table(new_table)
-
-        self.assertEquals(3, len(db.tables))
-        self.assertTrue('log_changes' in db.tables)
-        self.assertEquals(new_table, db.tables['log_changes'])
-
-    def test_remove_table(self):
-
-        db1 = self._get_sample_db()
-        db1.remove_table(db1.tables['more_logs'])
-
-        self.assertEquals(1, len(db1.tables))
-        self.assertTrue('logs' in db1.tables)
-        self.assertFalse('more_logs' in db1.tables)
-
-    def test_exception_on_remove_invalid_table(self):
-
-        db1 = self._get_sample_db()
-
-        new_table = create_parser()
-        new_table.parse(
-            """CREATE TABLE `log_changes` (
-            `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
-            `log_id` INT(10) UNSIGNED NOT NULL,
-            `type_id` INT(10) UNSIGNED NOT NULL,
-            `change` VARCHAR(255),
-            PRIMARY KEY (id),
-            KEY `log_changes_log_id` (`log_id`),
-            KEY `log_changes_type_id` (`type_id`)
-            );
-        """
-        )
-
-        with self.assertRaises(ValueError):
-            db1.remove_table(new_table)
diff --git a/tests/formats/mysql/file_reader/parsers/test_type_character.py b/tests/formats/mysql/file_reader/parsers/test_type_character.py
deleted file mode 100644
index 11b79cb..0000000
--- a/tests/formats/mysql/file_reader/parsers/test_type_character.py
+++ /dev/null
@@ -1,132 +0,0 @@
-import unittest
-
-from mygrations.formats.mysql.file_reader.parsers.type_character import type_character
-class test_type_character(unittest.TestCase):
-    def test_simple(self):
-
-        # parse typical insert values
-        parser = type_character()
-        returned = parser.parse("name varchar(255) not null default 'sup',")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('name', parser.name)
-        self.assertEquals('VARCHAR', parser.column_type)
-        self.assertEquals('255', parser.length)
-        self.assertFalse(parser.null)
-        self.assertEquals('sup', parser.default)
-        self.assertTrue(parser.has_comma)
-        self.assertTrue(parser.character_set is None)
-        self.assertTrue(parser.collate is None)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals("`name` VARCHAR(255) NOT NULL DEFAULT 'sup'", str(parser))
-
-    def test_character_set(self):
-
-        # parse typical insert values
-        parser = type_character()
-        returned = parser.parse("name varchar(255) not null default 'sup' character set 'blah' collate 'boo',")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('name', parser.name)
-        self.assertEquals('VARCHAR', parser.column_type)
-        self.assertEquals('255', parser.length)
-        self.assertFalse(parser.null)
-        self.assertEquals('sup', parser.default)
-        self.assertTrue(parser.has_comma)
-        self.assertEquals('BLAH', parser.character_set)
-        self.assertEquals('BOO', parser.collate)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals("`name` VARCHAR(255) NOT NULL DEFAULT 'sup' CHARACTER SET 'BLAH' COLLATE 'BOO'", str(parser))
-
-    def test_optional_default(self):
-
-        # parse typical insert values
-        parser = type_character()
-        returned = parser.parse("name varchar(255)")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('name', parser.name)
-        self.assertEquals('VARCHAR', parser.column_type)
-        self.assertEquals('255', parser.length)
-        self.assertTrue(parser.null)
-        self.assertEquals(None, parser.default)
-        self.assertFalse(parser.has_comma)
-        self.assertTrue(parser.character_set is None)
-        self.assertTrue(parser.collate is None)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals("`name` VARCHAR(255)", str(parser))
-
-    def test_empty_default(self):
-
-        # parse typical insert values
-        parser = type_character()
-        returned = parser.parse("name varchar(255) default ''")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('', parser.default)
-        self.assertEquals("`name` VARCHAR(255) DEFAULT ''", str(parser))
-
-    def test_strip_backticks(self):
-
-        # parse typical insert values
-        parser = type_character()
-        returned = parser.parse("`name` varchar(255)")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('name', parser.name)
-        self.assertEquals("`name` VARCHAR(255)", str(parser))
-
-    def test_not_null_needs_default(self):
-
-        # parse typical insert values
-        parser = type_character()
-        returned = parser.parse("name varchar(255) NOT NULL")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertTrue('is not null and has no default' in parser.warnings[0])
-
-    # only char and varchar can have collate
-    def test_disallowed_collate(self):
-
-        # parse typical insert values
-        parser = type_character()
-        returned = parser.parse("name int(10) collate 'blah'")
-        self.assertTrue('is not allowed to have a collation or character set' in parser.errors[0])
-
-    def test_disallowed_character_set(self):
-
-        # parse typical insert values
-        parser = type_character()
-        returned = parser.parse("name int(10) character set 'blah'")
-        self.assertTrue('is not allowed to have a collation or character set' in parser.errors[0])
-
-    # some types are not allowed to have a length
-    def test_invalid_column_type(self):
-
-        for coltype in [
-            'date', 'year', 'tinyblob', 'blob', 'mediumblob', 'longblob', 'tinytext', 'text', 'mediumtext', 'longtext',
-            'json'
-        ]:
-
-            parser = type_character()
-            returned = parser.parse('name %s(10)' % coltype)
-
-            self.assertTrue(parser.matched)
-            self.assertEquals('', returned)
-            self.assertTrue('not allowed to have a length' in parser.errors[0])
diff --git a/tests/formats/mysql/file_reader/parsers/test_type_decimal.py b/tests/formats/mysql/file_reader/parsers/test_type_decimal.py
deleted file mode 100644
index b32c6c8..0000000
--- a/tests/formats/mysql/file_reader/parsers/test_type_decimal.py
+++ /dev/null
@@ -1,108 +0,0 @@
-import unittest
-
-from mygrations.formats.mysql.file_reader.parsers.type_decimal import type_decimal
-class test_type_decimal(unittest.TestCase):
-    def test_simple(self):
-
-        # parse typical insert values
-        parser = type_decimal()
-        returned = parser.parse("latitude float(10,7) not null default 0,")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('latitude', parser.name)
-        self.assertEquals('FLOAT', parser.column_type)
-        self.assertEquals('10,7', parser.length)
-        self.assertFalse(parser.unsigned)
-        self.assertFalse(parser.null)
-        self.assertEquals('0', parser.default)
-        self.assertTrue(parser.has_comma)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals(0, len(parser.warnings))
-        self.assertTrue(parser.character_set is None)
-        self.assertTrue(parser.collate is None)
-        self.assertEquals("`latitude` FLOAT(10,7) NOT NULL DEFAULT 0", str(parser))
-
-    def test_optional_default(self):
-
-        # parse typical insert values
-        parser = type_decimal()
-        returned = parser.parse("latitude float(10,7) UNSIGNED")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('latitude', parser.name)
-        self.assertEquals('FLOAT', parser.column_type)
-        self.assertTrue(parser.unsigned)
-        self.assertEquals('10,7', parser.length)
-        self.assertTrue(parser.null)
-        self.assertEquals(None, parser.default)
-        self.assertFalse(parser.has_comma)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals(0, len(parser.warnings))
-        self.assertEquals("`latitude` FLOAT(10,7) UNSIGNED", str(parser))
-
-    def test_strip_backticks(self):
-
-        # parse typical insert values
-        parser = type_decimal()
-        returned = parser.parse("`latitude` float(10,7)")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('latitude', parser.name)
-        self.assertEquals("`latitude` FLOAT(10,7)", str(parser))
-
-    def test_warning_for_string_default(self):
-
-        # parse typical insert values
-        parser = type_decimal()
-        returned = parser.parse("latitude float(10,7) default '0'")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('does not need to be quoted' in parser.warnings[0])
-
-    def test_not_null_needs_default(self):
-
-        # parse typical insert values
-        parser = type_decimal()
-        returned = parser.parse("name float(10,7) NOT NULL")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertTrue('is not null and has no default' in parser.warnings[0])
-
-    # only some can have decimals
-    def test_disallowed_collate(self):
-
-        # parse typical insert values
-        for coltype in ['real', 'double', 'float', 'decimal', 'numeric']:
-            parser = type_decimal()
-            returned = parser.parse("name %s(10,7)" % coltype)
-            self.assertEquals(0, len(parser.errors), 'Type %s should be allowed to have decimals but is not' % coltype)
-
-    # everything else is not allowed to have a length
-    def test_invalid_column_type(self):
-
-        for coltype in [
-            'date', 'year', 'tinyblob', 'blob', 'mediumblob', 'longblob', 'tinytext', 'text', 'mediumtext', 'longtext',
-            'json', 'char', 'varchar', 'biejfiejrei'
-        ]:
-
-            parser = type_decimal()
-            returned = parser.parse('name %s(10,7)' % coltype)
-
-            self.assertTrue(parser.matched)
-            self.assertEquals('', returned)
-            self.assertTrue(
-                'not allowed to have a decimal length' in parser.errors[0],
-                'Type %s should not be allowed to have decimals but is' % coltype
-            )
diff --git a/tests/formats/mysql/file_reader/parsers/test_type_enum.py b/tests/formats/mysql/file_reader/parsers/test_type_enum.py
deleted file mode 100644
index 1ead482..0000000
--- a/tests/formats/mysql/file_reader/parsers/test_type_enum.py
+++ /dev/null
@@ -1,112 +0,0 @@
-import unittest
-
-from mygrations.formats.mysql.file_reader.parsers.type_enum import type_enum
-class test_type_enum(unittest.TestCase):
-    def test_simple(self):
-
-        # parse typical insert values
-        parser = type_enum()
-        returned = parser.parse("options enum('bob','joe','ray') not null default 'bob',")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('options', parser.name)
-        self.assertEquals('ENUM', parser.column_type)
-        self.assertEquals(['bob', 'joe', 'ray'], parser.values)
-        self.assertFalse(parser.null)
-        self.assertEquals('bob', parser.default)
-        self.assertTrue(parser.has_comma)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals(0, len(parser.warnings))
-        self.assertEquals("`options` ENUM('bob','joe','ray') NOT NULL DEFAULT 'bob'", str(parser))
-
-    def test_optional_default(self):
-
-        # parse typical insert values
-        parser = type_enum()
-        returned = parser.parse("options enum('bob','joe','ray')")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('options', parser.name)
-        self.assertEquals('ENUM', parser.column_type)
-        self.assertEquals(['bob', 'joe', 'ray'], parser.values)
-        self.assertTrue(parser.null)
-        self.assertEquals(None, parser.default)
-        self.assertFalse(parser.has_comma)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals(0, len(parser.warnings))
-        self.assertEquals("`options` ENUM('bob','joe','ray')", str(parser))
-
-    def test_strip_backticks(self):
-
-        # parse typical insert values
-        parser = type_enum()
-        returned = parser.parse("`options` enum('bob','joe','ray')")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertEquals('options', parser.name)
-
-    def test_warning_for_no_quote_default(self):
-
-        # parse typical insert values
-        parser = type_enum()
-        returned = parser.parse("options enum('bob','joe','ray') default bob")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('should have quotes' in parser.warnings[0])
-        self.assertEquals("`options` ENUM('bob','joe','ray') DEFAULT 'bob'", str(parser))
-
-    def test_error_for_invalid_default(self):
-
-        # parse typical insert values
-        parser = type_enum()
-        returned = parser.parse("options enum('bob','joe','ray') default asdf")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('this is not an allowed value' in parser.errors[0])
-
-    def test_not_null_needs_default(self):
-
-        # parse typical insert values
-        parser = type_enum()
-        returned = parser.parse("options enum('bob','joe','ray') not null")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertTrue('is not null and has no default' in parser.warnings[0])
-
-    # only some can match this type
-    def test_allowed_types(self):
-
-        # parse typical insert values
-        for coltype in ['set', 'enum']:
-            parser = type_enum()
-            returned = parser.parse("name %s('bob','joe','ray')" % coltype)
-            self.assertEquals(
-                0, len(parser.errors), 'Type %s should be allowed to use set/enum values but is not' % coltype
-            )
-
-    # everything else is not allowed to have a length
-    def test_invalid_column_type(self):
-
-        for coltype in [
-            'date', 'year', 'tinyblob', 'blob', 'mediumblob', 'longblob', 'tinytext', 'text', 'mediumtext', 'longtext',
-            'json', 'char', 'varchar', 'biejfiejrei'
-        ]:
-
-            parser = type_enum()
-            returned = parser.parse("name %s('bob','joe','ray')" % coltype)
-
-            self.assertTrue(parser.matched)
-            self.assertEquals('', returned)
-            self.assertTrue(
-                'not allowed to have a list of values' in parser.errors[0],
-                'Type %s should not be allowed to use set/enum but is' % coltype
-            )
diff --git a/tests/formats/mysql/file_reader/parsers/test_type_numeric.py b/tests/formats/mysql/file_reader/parsers/test_type_numeric.py
deleted file mode 100644
index 15acd3b..0000000
--- a/tests/formats/mysql/file_reader/parsers/test_type_numeric.py
+++ /dev/null
@@ -1,170 +0,0 @@
-import unittest
-
-from mygrations.formats.mysql.file_reader.parsers.type_numeric import type_numeric
-class test_type_numeric(unittest.TestCase):
-    def test_simple(self):
-
-        # parse typical insert values
-        parser = type_numeric()
-        returned = parser.parse("created int(10) not null default 0,")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('created', parser.name)
-        self.assertEquals('INT', parser.column_type)
-        self.assertEquals('10', parser.length)
-        self.assertFalse(parser.unsigned)
-        self.assertFalse(parser.null)
-        self.assertFalse(parser.auto_increment)
-        self.assertEquals('0', parser.default)
-        self.assertTrue(parser.has_comma)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals(0, len(parser.warnings))
-        self.assertEquals("`created` INT(10) NOT NULL DEFAULT 0", str(parser))
-
-    def test_auto_increment(self):
-
-        # parse typical insert values
-        parser = type_numeric()
-        returned = parser.parse("created int(10) not null auto_increment,")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('created', parser.name)
-        self.assertEquals('INT', parser.column_type)
-        self.assertEquals('10', parser.length)
-        self.assertTrue(parser.auto_increment)
-        self.assertFalse(parser.unsigned)
-        self.assertFalse(parser.null)
-        self.assertEquals(None, parser.default)
-        self.assertTrue(parser.has_comma)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals(0, len(parser.warnings))
-        self.assertEquals("`created` INT(10) NOT NULL AUTO_INCREMENT", str(parser))
-
-    def test_optional_default(self):
-
-        # parse typical insert values
-        parser = type_numeric()
-        returned = parser.parse("created int(10) UNSIGNED")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('created', parser.name)
-        self.assertEquals('INT', parser.column_type)
-        self.assertTrue(parser.unsigned)
-        self.assertEquals('10', parser.length)
-        self.assertTrue(parser.null)
-        self.assertEquals(None, parser.default)
-        self.assertFalse(parser.has_comma)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals(0, len(parser.warnings))
-        self.assertEquals("`created` INT(10) UNSIGNED", str(parser))
-
-    def test_strip_backticks(self):
-
-        # parse typical insert values
-        parser = type_numeric()
-        returned = parser.parse("`created` int(10) UNSIGNED")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertEquals('created', parser.name)
-        self.assertEquals("`created` INT(10) UNSIGNED", str(parser))
-
-    def test_warning_for_string_default(self):
-
-        # parse typical insert values
-        parser = type_numeric()
-        returned = parser.parse("created int(10) default '0'")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('does not need to be quoted' in parser.warnings[0])
-        self.assertEquals("`created` INT(10) DEFAULT 0", str(parser))
-
-    def test_not_null_needs_default(self):
-
-        # parse typical insert values
-        parser = type_numeric()
-        returned = parser.parse("name int(10) NOT NULL")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('is not null and has no default' in parser.warnings[0])
-
-    def test_char_field_defaults_should_be_quoted(self):
-
-        parser = type_numeric()
-        returned = parser.parse("name varchar(255) DEFAULT asdf")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('should be quoted' in parser.warnings[0])
-
-    def test_char_fields_should_not_be_unsigned(self):
-
-        parser = type_numeric()
-        returned = parser.parse("name varchar(255) UNSIGNED NOT NULL DEFAULT 'asdf'")
-
-        self.assertTrue(parser.is_char)
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('character type and cannot be unsigned' in parser.errors[0])
-
-    def test_char_fields_should_not_auto_increment(self):
-
-        parser = type_numeric()
-        returned = parser.parse("name varchar(255) NOT NULL AUTO_INCREMENT")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('only numeric fields can auto increment' in parser.errors[0])
-
-    def test_autoincrement_can_be_not_null_and_no_default(self):
-
-        parser = type_numeric()
-        returned = parser.parse("name int(10) UNSIGNED NOT NULL AUTO_INCREMENT")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertEquals(0, len(parser.warnings))
-
-    def test_number_field_defaults_should_not_be_quoted(self):
-
-        parser = type_numeric()
-        returned = parser.parse("name int(10) DEFAULT '0'")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('does not need to be quoted' in parser.warnings[0])
-
-    # not for everyone
-    def test_disallowed_types(self):
-
-        # anything not on the whitelist (this is not a complete list)
-        for coltype in ['date', 'bob', 'difjerejrie']:
-            parser = type_numeric()
-            returned = parser.parse("name %s(10)" % coltype)
-            self.assertTrue(
-                'not allowed to have a length' in parser.errors[0],
-                'Type %s should not be allowed to be numeric but is' % coltype
-            )
-
-    # everything on the white list is allowed to be numeric
-    def test_valid_column_type(self):
-
-        for coltype in [
-            'bit', 'tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'decimal', 'numeric', 'char',
-            'varchar'
-        ]:
-
-            parser = type_numeric()
-            returned = parser.parse('name %s(10)' % coltype)
-
-            self.assertTrue(parser.matched)
-            self.assertEquals('', returned)
-            self.assertEquals(0, len(parser.errors), 'Type %s should not be allowed to have decimals but is' % coltype)
diff --git a/tests/formats/mysql/file_reader/parsers/test_type_plain.py b/tests/formats/mysql/file_reader/parsers/test_type_plain.py
deleted file mode 100644
index abc133d..0000000
--- a/tests/formats/mysql/file_reader/parsers/test_type_plain.py
+++ /dev/null
@@ -1,63 +0,0 @@
-import unittest
-
-from mygrations.formats.mysql.file_reader.parsers.type_plain import type_plain
-class test_type_plain(unittest.TestCase):
-    def test_simple(self):
-
-        # parse typical insert values
-        parser = type_plain()
-        returned = parser.parse("created date NOT NULL DEFAULT 'bob',")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('created', parser.name)
-        self.assertEquals('DATE', parser.column_type)
-        self.assertFalse(parser.null)
-        self.assertEquals('bob', parser.default)
-        self.assertTrue(parser.has_comma)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals(0, len(parser.warnings))
-        self.assertEquals("`created` DATE NOT NULL DEFAULT 'bob'", str(parser))
-
-    def test_optional_default(self):
-
-        # parse typical insert values
-        parser = type_plain()
-        returned = parser.parse("created date")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('created', parser.name)
-        self.assertEquals('DATE', parser.column_type)
-        self.assertTrue(parser.null)
-        self.assertEquals(None, parser.default)
-        self.assertFalse(parser.has_comma)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals(0, len(parser.warnings))
-        self.assertEquals("`created` DATE", str(parser))
-
-    def test_strip_backticks(self):
-
-        # parse typical insert values
-        parser = type_plain()
-        returned = parser.parse("`created` date")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('created', parser.name)
-        self.assertEquals("`created` DATE", str(parser))
-
-    def test_not_null_needs_default(self):
-
-        # parse typical insert values
-        parser = type_plain()
-        returned = parser.parse("created date NOT NULL")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('is not null and has no default' in parser.warnings[0])
diff --git a/tests/formats/mysql/file_reader/parsers/test_type_text.py b/tests/formats/mysql/file_reader/parsers/test_type_text.py
deleted file mode 100644
index a2dad12..0000000
--- a/tests/formats/mysql/file_reader/parsers/test_type_text.py
+++ /dev/null
@@ -1,87 +0,0 @@
-import unittest
-
-from mygrations.formats.mysql.file_reader.parsers.type_text import type_text
-class test_type_text(unittest.TestCase):
-    def test_simple(self):
-
-        # parse typical insert values
-        parser = type_text()
-        returned = parser.parse("name text not null,")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('name', parser.name)
-        self.assertEquals('TEXT', parser.column_type)
-        self.assertFalse(parser.null)
-        self.assertTrue(parser.has_comma)
-        self.assertEquals('', parser.character_set)
-        self.assertEquals('', parser.collate)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals("`name` TEXT NOT NULL", str(parser))
-
-    def test_character_set(self):
-
-        # parse typical insert values
-        parser = type_text()
-        returned = parser.parse("name text character set 'blah' collate 'boo',")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('name', parser.name)
-        self.assertEquals('TEXT', parser.column_type)
-        self.assertTrue(parser.null)
-        self.assertTrue(parser.has_comma)
-        self.assertEquals('BLAH', parser.character_set)
-        self.assertEquals('BOO', parser.collate)
-        self.assertEquals(0, len(parser.errors))
-        self.assertEquals("`name` TEXT CHARACTER SET 'BLAH' COLLATE 'BOO'", str(parser))
-
-    def test_strip_backticks(self):
-
-        # parse typical insert values
-        parser = type_text()
-        returned = parser.parse("`name` text not null")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertEquals('column', parser.definition_type)
-        self.assertEquals('name', parser.name)
-        self.assertEquals("`name` TEXT NOT NULL", str(parser))
-
-    def test_no_default(self):
-
-        # parse typical insert values
-        parser = type_text()
-        returned = parser.parse("name text default 'bob'")
-
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('not allowed to have a default' in parser.errors[0])
-
-    # some types must have a length
-    def test_invalid_column_type(self):
-
-        for coltype in ['integer', 'varchar', 'afijeirjeri']:
-
-            parser = type_text()
-            returned = parser.parse('name %s' % coltype)
-
-            self.assertTrue(parser.matched)
-            self.assertEquals('', returned)
-            self.assertTrue('must have a length' in parser.errors[0])
-
-    # only some types can not have a length
-    def test_valid_column_type(self):
-
-        for coltype in ['tinyblob', 'blob', 'mediumblob', 'longblob', 'tinytext', 'text', 'mediumtext', 'longtext']:
-
-            parser = type_text()
-            returned = parser.parse('name %s' % coltype)
-
-            self.assertTrue(parser.matched)
-            self.assertEquals('', returned)
-            self.assertEquals(0, len(parser.errors))
diff --git a/tests/formats/mysql/file_reader/test_database.py b/tests/formats/mysql/file_reader/test_database.py
deleted file mode 100644
index 37f1ef5..0000000
--- a/tests/formats/mysql/file_reader/test_database.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import unittest
-
-from mygrations.formats.mysql.file_reader.database import database as database_reader
-class test_database(unittest.TestCase):
-    def test_simple(self):
-
-        strings = [
-            """
-            CREATE TABLE `logs` (
-                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                `message` TEXT NOT NULL,
-                `traceback` text,
-                PRIMARY KEY (`id`)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-        """, """
-            CREATE TABLE `more_logs` (
-                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                `more_messages` TEXT NOT NULL,
-                `traceback` text,
-                PRIMARY KEY (`id`)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-        """
-        ]
-        database = database_reader(strings)
-
-        # our parser should have a table!
-        self.assertTrue('logs' in database.tables)
-        self.assertTrue('more_logs' in database.tables)
-
-    def test_with_rows(self):
-
-        strings = [
-            """
-            CREATE TABLE `logs` (
-                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                `message` TEXT NOT NULL,
-                `traceback` text,
-                PRIMARY KEY (`id`)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-        """, """
-            INSERT INTO logs (id,message,traceback) VALUES (1,'hi','sup'),(2,'what','kay');
-        """
-        ]
-        database = database_reader(strings)
-
-        # our parser should have a table!
-        self.assertTrue('logs' in database.tables)
-        self.assertEquals(3, database.tables['logs'].auto_increment)
-
-        rows = database.tables['logs'].rows
-        self.assertEquals('hi', rows[1]['message'])
-        self.assertEquals('sup', rows[1]['traceback'])
-        self.assertEquals('what', rows[2]['message'])
-        self.assertEquals('kay', rows[2]['traceback'])
-
-    def test_keeps_errors(self):
-
-        strings = [
-            """
-            CREATE TABLE `logs` (
-                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                `message` TEXT DEFAULT '',
-                `traceback` text,
-                PRIMARY KEY (`id`)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-        """
-        ]
-        database = database_reader(strings)
-
-        self.assertTrue('not allowed to have a default value for column message in table logs' in database.errors[0])
diff --git a/tests/formats/mysql/file_reader/test_index_key_parsing.py b/tests/formats/mysql/file_reader/test_index_key_parsing.py
deleted file mode 100644
index 3da0fc1..0000000
--- a/tests/formats/mysql/file_reader/test_index_key_parsing.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import unittest
-
-from mygrations.formats.mysql.file_reader.reader import reader
-class test_index_key_parsing(unittest.TestCase):
-    def test_simple(self):
-        parser = reader()
-        returned = parser.parse(
-            """
-            CREATE TABLE `logs` (
-                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                `message` TEXT NOT NULL,
-                `traceback` VARCHAR(255) NOT NULL DEFAULT '',
-                KEY `logs_message` (`message`),
-                INDEX `logs_traceback` (`traceback`),
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-        """
-        )
-
-        # we should have matched
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('logs' in parser.tables)
-
-        self.assertTrue('logs_message' in parser.tables['logs'].indexes)
-        self.assertTrue('logs_traceback' in parser.tables['logs'].indexes)
-        self.assertEquals(['message'], parser.tables['logs'].indexes['logs_message'].columns)
-        self.assertEquals(['traceback'], parser.tables['logs'].indexes['logs_traceback'].columns)
-
-    def test_unique(self):
-        parser = reader()
-        returned = parser.parse(
-            """
-            CREATE TABLE `logs` (
-                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-                `message` TEXT NOT NULL,
-                `traceback` VARCHAR(255) NOT NULL DEFAULT '',
-                UNIQUE KEY `logs_message` (`message`),
-                UNIQUE INDEX `logs_traceback` (`traceback`),
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-        """
-        )
-
-        # we should have matched
-        self.assertTrue(parser.matched)
-        self.assertEquals('', returned)
-        self.assertTrue('logs' in parser.tables)
-
-        self.assertTrue('logs_message' in parser.tables['logs'].indexes)
-        self.assertTrue('logs_traceback' in parser.tables['logs'].indexes)
-        self.assertEquals('UNIQUE', parser.tables['logs'].indexes['logs_message'].index_type)
-        self.assertEquals('UNIQUE', parser.tables['logs'].indexes['logs_traceback'].index_type)
diff --git a/tests/formats/mysql/mygrations/__init__.py b/tests/formats/mysql/mygrations/__init__.py
deleted file mode 100644
index 8b13789..0000000
--- a/tests/formats/mysql/mygrations/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-
diff --git a/tests/formats/mysql/mygrations/operations/__init__.py b/tests/formats/mysql/mygrations/operations/__init__.py
deleted file mode 100644
index 8b13789..0000000
--- a/tests/formats/mysql/mygrations/operations/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-
diff --git a/tests/formats/mysql/mygrations/operations/test_row_delete.py b/tests/formats/mysql/mygrations/operations/test_row_delete.py
deleted file mode 100644
index ee2b845..0000000
--- a/tests/formats/mysql/mygrations/operations/test_row_delete.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import unittest
-from mygrations.formats.mysql.mygrations.operations.row_delete import row_delete
-from collections import OrderedDict
-class test_row_delete(unittest.TestCase):
-    def test_simple(self):
-        op = row_delete('a_table', 7)
-
-        self.assertEquals("DELETE FROM `a_table` WHERE id=7;", str(op))
-        self.assertEquals('a_table', op.table_name)
diff --git a/tests/formats/mysql/mygrations/operations/test_row_insert.py b/tests/formats/mysql/mygrations/operations/test_row_insert.py
deleted file mode 100644
index 09f9597..0000000
--- a/tests/formats/mysql/mygrations/operations/test_row_insert.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import unittest
-from mygrations.formats.mysql.mygrations.operations.row_insert import row_insert
-from collections import OrderedDict
-class test_row_insert(unittest.TestCase):
-    def test_simple(self):
-        op = row_insert('a_table', OrderedDict([('id', 5), ('name', 'bob'), ('age', 2)]))
-
-        self.assertEquals("INSERT INTO `a_table` (`id`, `name`, `age`) VALUES ('5', 'bob', '2');", str(op))
-        self.assertEquals('a_table', op.table_name)
diff --git a/tests/formats/mysql/mygrations/operations/test_row_update.py b/tests/formats/mysql/mygrations/operations/test_row_update.py
deleted file mode 100644
index a61e02d..0000000
--- a/tests/formats/mysql/mygrations/operations/test_row_update.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import unittest
-from mygrations.formats.mysql.mygrations.operations.row_update import row_update
-from collections import OrderedDict
-class test_row_update(unittest.TestCase):
-    def test_simple(self):
-        op = row_update('a_table', OrderedDict([('id', 5), ('name', 'bob'), ('age', 2)]))
-
-        self.assertEquals("UPDATE `a_table` SET `name`='bob', `age`='2' WHERE id=5;", str(op))
-        self.assertEquals('a_table', op.table_name)
-
-    def test_id_required(self):
-        with self.assertRaises(KeyError):
-            op = row_update('a_table', OrderedDict([('name', 'bob'), ('age', 2)]))
diff --git a/tests/helpers/test_dotenv_get_contents.py b/tests/helpers/test_dotenv_get_contents.py
deleted file mode 100644
index 9ef7dea..0000000
--- a/tests/helpers/test_dotenv_get_contents.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import unittest
-
-from mygrations.helpers.dotenv import dotenv
-import io
-import tempfile
-class test_dotenv_get_contents(unittest.TestCase):
-
-    dotenv = None
-    test_string = 'test string'
-
-    def setUp(self):
-
-        self.dotenv = dotenv()
-
-    # get_contents should accept a number of parameters.
-    # It should accept a stringIO wrapper
-    def test_get_contents_stringIO(self):
-
-        self.assertEquals(self.test_string, self.dotenv.get_contents(io.StringIO(self.test_string)))
-
-    # it should also accept an actual string
-    def test_get_contents_string(self):
-
-        self.assertEquals(self.test_string, self.dotenv.get_contents(self.test_string))
-
-    # as well as a more general file pointer
-    def test_get_contents_fp(self):
-
-        fp = tempfile.TemporaryFile()
-        fp.write(self.test_string.encode(encoding='UTF-8'))
-        fp.seek(0)
-        self.assertEquals(self.test_string, self.dotenv.get_contents(fp))
-        fp.close()
-
-    # it should also accept a filename
-    def test_get_contents_filename(self):
-
-        filename = '%s/unit_mygrations_dotenv' % tempfile.gettempdir()
-        fp = open(filename, 'w')
-        fp.write(self.test_string)
-        fp.close()
-
-        self.assertEquals(self.test_string, self.dotenv.get_contents(filename))
diff --git a/tests/helpers/test_dotenv_parse.py b/tests/helpers/test_dotenv_parse.py
deleted file mode 100644
index d5fd363..0000000
--- a/tests/helpers/test_dotenv_parse.py
+++ /dev/null
@@ -1,38 +0,0 @@
-import unittest
-
-from mygrations.helpers.dotenv import dotenv, DotEnvSyntaxError
-# this does very little really, other than  run .parse_line on each line.
-# parse_line is thoroughly tested, so we don't need to do much.
-class test_dotenv_parse(unittest.TestCase):
-
-    dotenv = None
-
-    def setUp(self):
-
-        self.dotenv = dotenv()
-
-    def test_json(self):
-
-        # it should detect json and parse it as such
-        parsed = self.dotenv.parse('{ "key": "value",\n "name": "bob" }')
-
-        self.assertEquals({'key': 'value', 'name': 'bob'}, parsed)
-
-    def test_dont_interrupt_syntax_error(self):
-
-        # a syntax error from parse_line should not be stopped
-        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse, 'name')
-
-    def test_parse_each_line(self):
-
-        # otherwise each line should be parsed
-        parsed = self.dotenv.parse('key = value\nname = bob #sup')
-
-        self.assertEquals({'key': 'value', 'name': 'bob'}, parsed)
-
-    def test_parse_skip_empty(self):
-
-        # some whitespace checks and a few parts just because
-        parsed = self.dotenv.parse('key = value\n\n\n\n   name = "bob" # sup\n another = "test\\"value"#okay')
-
-        self.assertEquals({'key': 'value', 'name': 'bob', 'another': 'test"value'}, parsed)
diff --git a/tests/helpers/test_dotenv_parse_line.py b/tests/helpers/test_dotenv_parse_line.py
deleted file mode 100644
index 0ad2bfa..0000000
--- a/tests/helpers/test_dotenv_parse_line.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import unittest
-
-from mygrations.helpers.dotenv import dotenv, DotEnvSyntaxError
-class test_dotenv_parse_line(unittest.TestCase):
-
-    dotenv = None
-
-    def setUp(self):
-
-        self.dotenv = dotenv()
-
-    def test_opening_comment(self):
-
-        self.assertEquals([False, False], self.dotenv.parse_line('# name = test', '#'))
-
-    def test_opening_comment_with_space(self):
-
-        # white space is stripped first, so it still starts with a comment if it has spaces
-        self.assertEquals([False, False], self.dotenv.parse_line(' # name = test', '#'))
-
-    def test_any_comment_charater(self):
-
-        # our comment character should be respected
-        self.assertEquals([False, False], self.dotenv.parse_line('; name = test', ';'))
-
-    def test_multi_character_comment(self):
-
-        # and it is allowed to be more than one character long
-        self.assertEquals([False, False], self.dotenv.parse_line('weirdcomment name = test', 'weirdcomment'))
-
-    def test_ignore_empty_lines(self):
-
-        # empty lines (which includes lines with all white space) are ignored
-        self.assertEquals([False, False], self.dotenv.parse_line('  ', '#'))
-
-    def test_invalid_format(self):
-
-        # supported format is something like name = value or name: value
-        # if neither of the separators is present then we should get a syntax error
-        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, 'sup', '#')
-
-    def test_empty_value_equal(self):
-
-        # empty values are allowed
-        self.assertEquals(['key', ''], self.dotenv.parse_line('key=', '#'))
-
-    def test_empty_value_colon(self):
-
-        # colons act exactly the same way
-        self.assertEquals(['key', ''], self.dotenv.parse_line('key:', '#'))
-
-    def test_empty_value_equal_spaces(self):
-
-        # and spaces are ignored
-        self.assertEquals(['key', ''], self.dotenv.parse_line(" key \t=\t  ", '#'))
-
-    def test_empty_value_equal_colon(self):
-
-        # and spaces are ignored
-        self.assertEquals(['key', ''], self.dotenv.parse_line(" key \t:\t  ", '#'))
-
-    def test_value_no_quotes_equal(self):
-
-        # a value without quotes should be easy
-        self.assertEquals(['key', 'asdf'], self.dotenv.parse_line("key=asdf", '#'))
-
-    def test_value_no_quotes_colon(self):
-
-        # a value without quotes should be easy
-        self.assertEquals(['key', 'asdf'], self.dotenv.parse_line("key:asdf", '#'))
-
-    def test_value_no_quotes_equal_spaces(self):
-
-        # spaces are still ignored at the beginning/end of a part
-        self.assertEquals(['key', 'asdf bob'], self.dotenv.parse_line("key= asdf bob  \t", '#'))
-
-    def test_value_no_quotes_equal_colon(self):
-
-        # spaces are still ignored at the beginning/end of a part
-        self.assertEquals(['key', 'asdf bob'], self.dotenv.parse_line("key : asdf bob  \t", '#'))
-
-    def test_value_no_quotes_equal_comment(self):
-
-        # and comments at the end are ignored (including spaces before comments)
-        self.assertEquals(['key', 'asdf bob'], self.dotenv.parse_line("key = asdf bob  \t# a comment", '#'))
-
-    def test_value_no_quotes_equal_colon(self):
-
-        # and comments at the end are ignored (including spaces before comments)
-        self.assertEquals(['key', 'asdf bob'], self.dotenv.parse_line("key : asdf bob  \t; a comment", ';'))
diff --git a/tests/helpers/test_dotenv_parse_line_with_quotes.py b/tests/helpers/test_dotenv_parse_line_with_quotes.py
deleted file mode 100644
index 0d92d96..0000000
--- a/tests/helpers/test_dotenv_parse_line_with_quotes.py
+++ /dev/null
@@ -1,117 +0,0 @@
-import unittest
-
-from mygrations.helpers.dotenv import dotenv, DotEnvSyntaxError
-# mainly just breakning the tests up a little bit.
-# the ability to recognize and parse around quotes inside
-# the values does make this a bit more complicated, so it
-# makes sense to be in another file anyway
-class test_dotenv_parse_line_with_quotes(unittest.TestCase):
-
-    dotenv = None
-
-    def setUp(self):
-
-        self.dotenv = dotenv()
-
-    def test_no_lone_quotes_double(self):
-
-        # a quote character inside the value by itself is invalid
-        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, 'name = valu"e', '#')
-
-    def test_no_lone_quotes_single(self):
-
-        # a quote character inside the value by itself is invalid
-        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, "name = valu'e", '#')
-
-    def test_empty_single(self):
-
-        # easy
-        self.assertEquals(['db', ''], self.dotenv.parse_line("db = ''", '#'))
-
-    def test_empty_double(self):
-
-        # easy
-        self.assertEquals(['db', ''], self.dotenv.parse_line('db = ""', '#'))
-
-    def test_empty_single_comment(self):
-
-        # easy
-        self.assertEquals(['db', ''], self.dotenv.parse_line("db = '' # database name", '#'))
-
-    def test_empty_double_comment(self):
-
-        # easy
-        self.assertEquals(['db', ''], self.dotenv.parse_line('db = "" ; database name', ';'))
-
-    def test_single_quotes(self):
-
-        # I shouldn't get the quotes back.  Also white space should
-        # be ignored still
-        self.assertEquals(['db', 'dijere'], self.dotenv.parse_line("db = 'dijere' ", '#'))
-
-    def test_double_quotes(self):
-
-        # I shouldn't get the quotes back.  Also white space should
-        # be ignored still
-        self.assertEquals(['db', 'dijere'], self.dotenv.parse_line('db = "dijere" ', '#'))
-
-    def test_no_closing_quote_single(self):
-
-        # syntax error if we have an opening quote with no close
-        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, "name = 'test", '#')
-
-    def test_no_closing_quote_double(self):
-
-        # syntax error if we have an opening quote with no close
-        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, 'name = "test', '#')
-
-    def test_double_quotes_with_comment(self):
-
-        # comments after the quotes should be ignored (along with whitespace)
-        self.assertEquals(['db', 'bob'], self.dotenv.parse_line('db = "bob" ; database name', ';'))
-
-    def test_single_quotes_with_comment(self):
-
-        # comments after the quotes should be ignored (along with whitespace)
-        self.assertEquals(['db', 'bob'], self.dotenv.parse_line("db = 'bob' \t# database name", '#'))
-
-    def test_text_outside_of_double(self):
-
-        # anything outside of the quotes and before a comment results in a syntax error
-        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, 'name = "test" sup # hey', '#')
-
-    def test_text_outside_of_single(self):
-
-        # anything outside of the quotes and before a comment results in a syntax error
-        self.assertRaises(DotEnvSyntaxError, self.dotenv.parse_line, "name = 'test' sup ; hey", ';')
-
-    def test_allow_ending_semicolon(self):
-
-        # anything outside of the quotes and before a comment results in a syntax error
-        # except allow to end the line with a semi-colon.
-        self.assertEquals(['name', 'test'], self.dotenv.parse_line("name = 'test';", '#'))
-
-    def test_escaped_single_quote(self):
-
-        # quotes can be escaped inside quotes
-        self.assertEquals(['db', "asdf'qwerty'"], self.dotenv.parse_line("db = 'asdf\\'qwerty\\''", '#'))
-
-    def test_escaped_double_quote(self):
-
-        # quotes can be escaped inside quotes
-        self.assertEquals(['db', 'asdf"qwerty"'], self.dotenv.parse_line('db = "asdf\\"qwerty\\""', '#'))
-
-    def test_preserve_other_slashes(self):
-
-        # other slashes are left alone
-        self.assertEquals(['db', 'asdf\\bob'], self.dotenv.parse_line('db = "asdf\\bob"', '#'))
-
-    def test_double_quote_in_single_quote(self):
-
-        # double quote inside single quotes are just regular characters
-        self.assertEquals(['db', 'asdf"bob'], self.dotenv.parse_line("db = 'asdf\"bob'", '#'))
-
-    def test_single_quote_in_double_quote(self):
-
-        # single quote inside double quotes are just regular characters
-        self.assertEquals(['db', "asdf'bob"], self.dotenv.parse_line('db = "asdf\'bob"', '#'))
diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/mocks/db/__init__.py b/tests/mocks/db/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/mocks/db/mysql/__init__.py b/tests/mocks/db/mysql/__init__.py
deleted file mode 100644
index e69de29..0000000