def merge_db(base_class: Type,
             src_engine: Engine,
             dst_session: Session,
             allow_missing_src_tables: bool = True,
             allow_missing_src_columns: bool = True,
             translate_fn: Callable[[TranslationContext], None] = None,
             skip_tables: List[TableIdentity] = None,
             only_tables: List[TableIdentity] = None,
             tables_to_keep_pks_for: List[TableIdentity] = None,
             extra_table_dependencies: List[TableDependency] = None,
             dummy_run: bool = False,
             info_only: bool = False,
             report_every: int = 1000,
             flush_per_table: bool = True,
             flush_per_record: bool = False,
             commit_with_flush: bool = False,
             commit_at_end: bool = True,
             prevent_eager_load: bool = True,
             trcon_info: Dict[str, Any] = None) -> None:
    """
    Copies an entire database as far as it is described by ``metadata`` and
    ``base_class``, from SQLAlchemy ORM session ``src_session`` to
    ``dst_session``, and in the process:

    - creates new primary keys at the destination, or raises an error if it
      doesn't know how (typically something like: ``Field 'name' doesn't have a
      default value``)

    - maintains relationships, or raises an error if it doesn't know how

    Basic method:

    - Examines the metadata for the SQLAlchemy ORM base class you provide.

    - Assumes that the tables exist (in the destination).

    - For each table/ORM class found in the metadata:

      - Queries (via the ORM) from the source.

      - For each ORM instance retrieved:

        - Writes information to the destination SQLAlchemy session.

        - If that ORM object has relationships, process them too.

    If a table is missing in the source, then that's OK if and only if
    ``allow_missing_src_tables`` is set. (Similarly with columns and
    ``allow_missing_src_columns``; we ask the ORM to perform a partial load,
    of a subset of attributes only.)

    Args:
        base_class:
            your ORM base class, e.g. from ``Base = declarative_base()``

        src_engine:
            SQLALchemy :class:`Engine` for the source database

        dst_session:
            SQLAlchemy :class:`Session` for the destination database

        allow_missing_src_tables:
            proceed if tables are missing from the source (allowing you to
            import from older, incomplete databases)

        allow_missing_src_columns:
            proceed if columns are missing from the source (allowing you to
            import from older, incomplete databases)

        translate_fn:
            optional function called with each instance, so you can modify
            instances in the pipeline. Signature:

            .. code-block:: python

                def my_translate_fn(trcon: TranslationContext) -> None:
                    # We can modify trcon.newobj, or replace it (including
                    # setting trcon.newobj = None to omit this object).
                    pass

        skip_tables:
            tables to skip (specified as a list of :class:`TableIdentity`)

        only_tables:
            tables to restrict the processor to (specified as a list of
            :class:`TableIdentity`)

        tables_to_keep_pks_for:
            tables for which PKs are guaranteed to be safe to insert into the
            destination database, without modification (specified as a list of
            :class:`TableIdentity`)

        extra_table_dependencies:
            optional list of :class:`TableDependency` objects (q.v.)

        dummy_run:
            don't alter the destination database

        info_only:
            show info, then stop

        report_every:
            provide a progress report every *n* records

        flush_per_table:
            flush the session after every table (reasonable)

        flush_per_record:
            flush the session after every instance (AVOID this if tables may
            refer to themselves)

        commit_with_flush:
            ``COMMIT`` with each flush?

        commit_at_end:
            ``COMMIT`` when finished?

        prevent_eager_load:
            disable any eager loading (use lazy loading instead)

        trcon_info:
            additional dictionary passed to ``TranslationContext.info``
            (see :class:`.TranslationContext`)
    """

    log.info("merge_db(): starting")
    if dummy_run:
        log.warning("Dummy run only; destination will not be changed")

    # Check parameters before we modify them
    if only_tables is not None and not only_tables:
        log.warning("... only_tables == []; nothing to do")
        return

    # Finalize parameters
    skip_tables = skip_tables or []  # type: List[TableIdentity]
    only_tables = only_tables or []  # type: List[TableIdentity]
    tables_to_keep_pks_for = tables_to_keep_pks_for or []  # type: List[TableIdentity]  # noqa
    extra_table_dependencies = extra_table_dependencies or []  # type: List[TableDependency]  # noqa
    trcon_info = trcon_info or {}  # type: Dict[str, Any]

    # We need both Core and ORM for the source.
    # noinspection PyUnresolvedReferences
    metadata = base_class.metadata  # type: MetaData
    src_session = sessionmaker(bind=src_engine)()  # type: Session
    dst_engine = get_engine_from_session(dst_session)
    tablename_to_ormclass = get_orm_classes_by_table_name_from_base(base_class)

    # Tell all TableIdentity objects about their metadata
    for tilist in [skip_tables, only_tables, tables_to_keep_pks_for]:
        for ti in tilist:
            ti.set_metadata_if_none(metadata)
    for td in extra_table_dependencies:
        td.set_metadata_if_none(metadata)

    # Get all lists of tables as their names
    skip_table_names = [ti.tablename for ti in skip_tables]
    only_table_names = [ti.tablename for ti in only_tables]
    tables_to_keep_pks_for = [ti.tablename for ti in tables_to_keep_pks_for]  # type: List[str]  # noqa
    # ... now all are of type List[str]

    # Safety check: this is an imperfect check for source == destination, but
    # it is fairly easy to pass in the wrong URL, so let's try our best:
    _src_url = get_safe_url_from_engine(src_engine)
    _dst_url = get_safe_url_from_session(dst_session)
    assert _src_url != _dst_url or _src_url == SQLITE_MEMORY_URL, (
        "Source and destination databases are the same!"
    )

    # Check the right tables are present.
    src_tables = sorted(get_table_names(src_engine))
    dst_tables = sorted(list(tablename_to_ormclass.keys()))
    log.debug("Source tables: {!r}", src_tables)
    log.debug("Destination tables: {!r}", dst_tables)
    if not allow_missing_src_tables:
        missing_tables = sorted(
            d for d in dst_tables
            if d not in src_tables and d not in skip_table_names
        )
        if missing_tables:
            raise RuntimeError("The following tables are missing from the "
                               "source database: " + repr(missing_tables))

    table_num = 0
    overall_record_num = 0

    tables = list(metadata.tables.values())  # type: List[Table]
    # Very helpfully, MetaData.sorted_tables produces tables in order of
    # relationship dependency ("each table is preceded by all tables which
    # it references");
    # http://docs.sqlalchemy.org/en/latest/core/metadata.html
    # HOWEVER, it only works if you specify ForeignKey relationships
    # explicitly.
    # We can also add in user-specified dependencies, and therefore can do the
    # sorting in one step with sqlalchemy.schema.sort_tables:
    ordered_tables = sort_tables(
        tables,
        extra_dependencies=[td.sqla_tuple() for td in extra_table_dependencies]
    )
    # Note that the ordering is NOT NECESSARILY CONSISTENT, though (in that
    # the order of stuff it doesn't care about varies across runs).
    all_dependencies = get_all_dependencies(metadata, extra_table_dependencies)
    dep_classifications = classify_tables_by_dependency_type(
        metadata, extra_table_dependencies)
    circular = [tdc for tdc in dep_classifications if tdc.circular]
    assert not circular, "Circular dependencies! {!r}".format(circular)
    log.debug("All table dependencies: {}",
              "; ".join(str(td) for td in all_dependencies))
    log.debug("Table dependency classifications: {}",
              "; ".join(str(c) for c in dep_classifications))
    log.info("Processing tables in the order: {!r}",
             [table.name for table in ordered_tables])

    objmap = {}

    def flush() -> None:
        if not dummy_run:
            log.debug("Flushing session")
            dst_session.flush()
            if commit_with_flush:
                log.debug("Committing...")
                dst_session.commit()

    def translate(oldobj_: object, newobj_: object) -> object:
        if translate_fn is None:
            return newobj_
        tc = TranslationContext(oldobj=oldobj_,
                                newobj=newobj_,
                                objmap=objmap,
                                table=table,
                                tablename=tablename,
                                src_session=src_session,
                                dst_session=dst_session,
                                src_engine=src_engine,
                                dst_engine=dst_engine,
                                missing_src_columns=missing_columns,
                                src_table_names=src_tables,
                                info=trcon_info)
        translate_fn(tc)
        if tc.newobj is None:
            log.debug("Instance skipped by user-supplied translate_fn")
        return tc.newobj

    # -------------------------------------------------------------------------
    # Now, per table/ORM class...
    # -------------------------------------------------------------------------
    for table in ordered_tables:
        tablename = table.name

        if tablename in skip_table_names:
            log.info("... skipping table {!r} (as per skip_tables)", tablename)
            continue
        if only_table_names and tablename not in only_table_names:
            log.info("... ignoring table {!r} (as per only_tables)", tablename)
            continue
        if allow_missing_src_tables and tablename not in src_tables:
            log.info("... ignoring table {!r} (not in source database)",
                     tablename)
            continue
        table_num += 1
        table_record_num = 0

        src_columns = sorted(get_column_names(src_engine, tablename))
        dst_columns = sorted([column.name for column in table.columns])
        missing_columns = sorted(list(set(dst_columns) - set(src_columns)))

        if not allow_missing_src_columns:
            if missing_columns:
                raise RuntimeError(
                    "The following columns are missing from source table "
                    "{!r}: {!r}".format(tablename, missing_columns))

        orm_class = tablename_to_ormclass[tablename]
        pk_attrs = get_pk_attrnames(orm_class)
        c2a = colname_to_attrname_dict(orm_class)
        missing_attrs = map_keys_to_values(missing_columns, c2a)
        tdc = [tdc for tdc in dep_classifications if tdc.table == table][0]

        log.info("Processing table {!r} via ORM class {!r}",
                 tablename, orm_class)
        log.debug("PK attributes: {!r}", pk_attrs)
        log.debug("Table: {!r}", table)
        log.debug("Dependencies: parents = {!r}; children = {!r}",
                  tdc.parent_names, tdc.child_names)

        if info_only:
            log.debug("info_only; skipping table contents")
            continue

        def wipe_primary_key(inst: object) -> None:
            for attrname in pk_attrs:
                setattr(inst, attrname, None)

        query = src_session.query(orm_class)

        if allow_missing_src_columns and missing_columns:
            src_attrs = map_keys_to_values(src_columns, c2a)
            log.info("Table {} is missing columns {} in the source",
                     tablename, missing_columns)
            log.debug("... using only columns {} via attributes {}",
                      src_columns, src_attrs)
            query = query.options(load_only(*src_attrs))
            # PROBLEM: it will not ignore the PK.

        if prevent_eager_load:
            query = query.options(lazyload("*"))

        wipe_pk = tablename not in tables_to_keep_pks_for

        # How best to deal with relationships?
        #
        # This doesn't work:
        # - process tables in order of dependencies, eager-loading
        #   relationships with
        #       for relationship in insp.mapper.relationships:  # type: RelationshipProperty  # noqa
        #           related_col = getattr(orm_class, relationship.key)
        #           query = query.options(joinedload(related_col))
        # - expunge from old session / make_transient / wipe_primary_key/ add
        #   to new session
        # ... get errors like
        #       sqlalchemy.exc.InvalidRequestError: Object '<Parent at
        #       0x7f99492440b8>' is already attached to session '7' (this is
        #       '6')
        #
        # ... at the point of dst_session.add(instance)
        # ... when adding the object on the child side of the relationship
        # ... I suspect that we move the Parent from session S to session D,
        #     but when we eager-load the Parent from the Child, that makes
        #     another in session S, so when we add the Child to session D, its
        #     parent is in session S, which is wrong.
        #
        # We must, therefore, take a more interventional approach, in which we
        # maintain a copy of the old object, make a copy using
        # copy_sqla_object, and re-assign relationships accordingly.

        for instance in query.all():
            # log.debug("Source instance: {!r}", instance)
            table_record_num += 1
            overall_record_num += 1
            if table_record_num % report_every == 0:
                log.info("... progress{}: on table {} ({}); record {} this "
                         "table; overall record {}",
                         " (DUMMY RUN)" if dummy_run else "",
                         table_num, tablename,
                         table_record_num, overall_record_num)

            if tdc.standalone:
                # Our table has neither parents nor children. We can therefore
                # simply move the instance from one session to the other,
                # blanking primary keys.

                # https://stackoverflow.com/questions/14636192/sqlalchemy-modification-of-detached-object  # noqa
                src_session.expunge(instance)
                make_transient(instance)
                if wipe_pk:
                    wipe_primary_key(instance)

                instance = translate(instance, instance)
                if not instance:
                    continue  # translate_fn elected to skip it

                if not dummy_run:
                    dst_session.add(instance)
                    # new PK will be created when session is flushed

            else:
                # Our table has either parents or children. We therefore make
                # a copy and place the COPY in the destination session. If
                # this object may be a parent, we maintain a log (in objmap)
                # of the old-to-new mapping. If this object is a child, we
                # re-assign its relationships based on the old-to-new mapping
                # (since we will have processed the parent table first, having
                # carefully ordered them in advance).

                oldobj = instance  # rename for clarity
                newobj = copy_sqla_object(
                    oldobj, omit_pk=wipe_pk, omit_fk=True,
                    omit_attrs=missing_attrs, debug=False
                )

                rewrite_relationships(oldobj, newobj, objmap, debug=False,
                                      skip_table_names=skip_table_names)

                newobj = translate(oldobj, newobj)
                if not newobj:
                    continue  # translate_fn elected to skip it

                if not dummy_run:
                    dst_session.add(newobj)
                    # new PK will be created when session is flushed

                if tdc.is_parent:
                    objmap[oldobj] = newobj  # for its children's benefit

            if flush_per_record:
                flush()

        if flush_per_table:
            flush()

    flush()
    if commit_at_end:
        log.debug("Committing...")
        dst_session.commit()
    log.info("merge_db(): finished")