docs: def merge_tables(target, tables, columns=None, drop_intersection=True):
      """
      Merge a number of tables onto a target table. Tables must have
      registered merge rules via the `broadcast` function.
  
      Parameters
      ----------
      target : str, DataFrameWrapper, or TableFuncWrapper
          Name of the table (or wrapped table) onto which tables will be merged.
      tables : list of `DataFrameWrapper`, `TableFuncWrapper`, or str
          All of the tables to merge. Should include the target table.
      columns : list of str, optional
          If given, columns will be mapped to `tables` and only those columns
          will be requested from each table. The final merged table will have
          only these columns. By default all columns are used from every
          table.
      drop_intersection : bool
          If True, keep the left most occurence of any column name if it occurs
          on more than one table.  This prevents getting back the same column
          with suffixes applied by pd.merge.  If false, columns names will be
          suffixed with the table names - e.g. zone_id_buildings and
          zone_id_parcels.
  
      Returns
      -------
      merged : pandas.DataFrame
  
      """
      # allow target to be string or table wrapper
      if isinstance(target, (DataFrameWrapper, TableFuncWrapper)):
          target = target.name
  
      # allow tables to be strings or table wrappers
      tables = [get_table(t)
                if not isinstance(t, (DataFrameWrapper, TableFuncWrapper)) else t
                for t in tables]
  
      merges = {t.name: {} for t in tables}
      tables = {t.name: t for t in tables}
      casts = _get_broadcasts(tables.keys())
      logger.debug(
          'attempting to merge tables {} to target table {}'.format(
              tables.keys(), target))
  
      # relate all the tables by registered broadcasts
      for table, onto in casts:
          merges[onto][table] = merges[table]
      merges = {target: merges[target]}
  
      # verify that all the tables can be merged to the target
      all_tables = set(_all_reachable_tables(merges))
  
      if all_tables != set(tables.keys()):
          raise RuntimeError(
              ('Not all tables can be merged to target "{}". Unlinked tables: {}'
               ).format(target, list(set(tables.keys()) - all_tables)))
  
      # add any columns necessary for indexing into other tables
      # during merges
      if columns:
          columns = list(columns)
          for c in casts.values():
              if c.onto_on:
                  columns.append(c.onto_on)
              if c.cast_on:
                  columns.append(c.cast_on)
  
      # get column map for which columns go with which table
      colmap = column_map(tables.values(), columns)
  
      # get frames
      frames = {name: t.to_frame(columns=colmap[name])
                for name, t in tables.items()}
  
      past_intersections = set()
  
      # perform merges until there's only one table left
      while merges[target]:
          nm = _next_merge(merges)
          onto = tz.first(nm)
          onto_table = frames[onto]
  
          # loop over all the tables that can be broadcast onto
          # the onto_table and merge them all in.
          for cast in nm[onto]:
              cast_table = frames[cast]
              bc = casts[(cast, onto)]
  
              with log_start_finish(
                      'merge tables {} and {}'.format(onto, cast), logger):
  
                  intersection = set(onto_table.columns).\
                      intersection(cast_table.columns)
                  # intersection is ok if it's the join key
                  intersection.discard(bc.onto_on)
                  intersection.discard(bc.cast_on)
                  # otherwise drop so as not to create conflicts
                  if drop_intersection:
                      cast_table = cast_table.drop(intersection, axis=1)
                  else:
                      # add suffix to past intersections which wouldn't get
                      # picked up by the merge - these we have to rename by hand
                      renames = dict(zip(
                          past_intersections,
                          [c+'_'+onto for c in past_intersections]
                      ))
                      onto_table = onto_table.rename(columns=renames)
  
                  # keep track of past intersections in case there's an odd
                  # number of intersections
                  past_intersections = past_intersections.union(intersection)
  
                  onto_table = pd.merge(
                      onto_table, cast_table,
                      suffixes=['_'+onto, '_'+cast],
                      left_on=bc.onto_on, right_on=bc.cast_on,
                      left_index=bc.onto_index, right_index=bc.cast_index)
  
          # replace the existing table with the merged one
          frames[onto] = onto_table
  
          # free up space by dropping the cast table
          del frames[cast]
  
          # mark the onto table as having no more things to broadcast
          # onto it.
          _recursive_getitem(merges, onto)[onto] = {}
  
      logger.debug('finished merge')
      return frames[target]