code: def join(self, table1, table2, key_filter=True):
          
          get_keys = lambda t: (f.name for f in self.relations[t] if f.key)
          keys = set(get_keys(table1)).intersection(get_keys(table2))
          if not keys:
              raise ItsdbError(
                  'Cannot join tables "{}" and "{}"; no shared key exists.'
                  .format(table1, table2)
              )
          key = keys.pop()
          # this join method stores the whole of table2 in memory, but it is
          # MUCH faster than a nested loop method. Most profiles will fit in
          # memory anyway, so it's a decent tradeoff
          table2_data = defaultdict(list)
          for row in self.read_table(table2, key_filter=key_filter):
              table2_data[row[key]].append(row)
          for row1 in self.read_table(table1, key_filter=key_filter):
              for row2 in table2_data.get(row1[key], []):
                  joinedrow = OrderedDict(
                      [('{}:{}'.format(table1, k), v)
                       for k, v in row1.items()] +
                      [('{}:{}'.format(table2, k), v)
                       for k, v in row2.items()]
                  )
                  yield joinedrow
docs: Yield rows from a table built by joining *table1* and *table2*.
          The column names in the rows have the original table name
          prepended and separated by a colon. For example, joining tables
          'item' and 'parse' will result in column names like
          'item:i-input' and 'parse:parse-id'.