Attempts to update records in the provided table from the database. If none are found,
        inserts new record that would match all the conditions. Returns updated or inserted record
        as list, dict, str, dataframe or series depending on the value of `astype`.

        Parameters
        ----------
        tname : str
            Table to update or insert records into.
        where : dict or None (default `None`)
            Dictionary of <column, value> where value can be of str type for exact match or a
            compiled regex expression for more advanced matching.
        where_not : dict or None (default `None`)
            Identical to `where` but for negative-matching.
        columns: list of str, str or None (default `None`)
            Column(s) to return for the updated or inserted records.
        astype: str, type or None (default `None`)
            Type to cast the output to. Possible values are: `nonetype`, `dataframe`, `series`,
            `str`, `dict`, `json`. If this is `None`, falls back to the type provided to the
            constructor. If a type was provided to the constructor but the user wants to avoid any
            casting, "nonetype" should be passed as the value.

        Returns
        -------
        records : list, dict, str, dataframe or series
            Updated or inserted records. Output type depends on `astype` parameter.

        Examples
        --------
        >>> db = PandasDatabase("test")
        >>> db.upsert("test", record={"Name": "John", "Color": "Blue"})
            Color                                     Blue
            Name                                      John
            __id__    a8f31bdd-8e57-4fa7-96f6-e6b20bf7a9dc
            dtype: object
        >>> db.upsert("test", where={"Name": "Jane", "Color": "Red"})
            Color                                      Red
            Name                                      Jane
            __id__    65c3bc2b-020c-48f0-b448-5fdb4e548abe
            dtype: object
        >>> db.upsert("test", record={"Color": "Yellow"}, where={"Name": "John"})
                                             __id__  Name   Color
            0  a8f31bdd-8e57-4fa7-96f6-e6b20bf7a9dc  John  Yellow

def upsert(self, tname, record=None, where=None, where_not=None, columns=None, astype=None):
        '''
        Attempts to update records in the provided table from the database. If none are found,
        inserts new record that would match all the conditions. Returns updated or inserted record
        as list, dict, str, dataframe or series depending on the value of `astype`.

        Parameters
        ----------
        tname : str
            Table to update or insert records into.
        where : dict or None (default `None`)
            Dictionary of <column, value> where value can be of str type for exact match or a
            compiled regex expression for more advanced matching.
        where_not : dict or None (default `None`)
            Identical to `where` but for negative-matching.
        columns: list of str, str or None (default `None`)
            Column(s) to return for the updated or inserted records.
        astype: str, type or None (default `None`)
            Type to cast the output to. Possible values are: `nonetype`, `dataframe`, `series`,
            `str`, `dict`, `json`. If this is `None`, falls back to the type provided to the
            constructor. If a type was provided to the constructor but the user wants to avoid any
            casting, "nonetype" should be passed as the value.

        Returns
        -------
        records : list, dict, str, dataframe or series
            Updated or inserted records. Output type depends on `astype` parameter.

        Examples
        --------
        >>> db = PandasDatabase("test")
        >>> db.upsert("test", record={"Name": "John", "Color": "Blue"})
            Color                                     Blue
            Name                                      John
            __id__    a8f31bdd-8e57-4fa7-96f6-e6b20bf7a9dc
            dtype: object
        >>> db.upsert("test", where={"Name": "Jane", "Color": "Red"})
            Color                                      Red
            Name                                      Jane
            __id__    65c3bc2b-020c-48f0-b448-5fdb4e548abe
            dtype: object
        >>> db.upsert("test", record={"Color": "Yellow"}, where={"Name": "John"})
                                             __id__  Name   Color
            0  a8f31bdd-8e57-4fa7-96f6-e6b20bf7a9dc  John  Yellow
        '''
        tname = self._check_tname(tname)
        where = PandasDatabase._check_conditions(where)
        where_not = PandasDatabase._check_conditions(where_not)
        columns = PandasDatabase._check_type_iter(str, columns)
        record = PandasDatabase._check_dict_type(str, str, record, cast=self.auto_cast)

        # Attempt search only if where conditions are given
        if (where is not None and len(where) > 0) \
                or (where_not is not None and len(where_not) > 0):
            ixs = self.find(tname, where=where, where_not=where_not, astype='dataframe').index

            # If no records matched the where conditions, default to insert
            if len(ixs) == 0:
                self._print(
                    'Warning: No records in "%s" matched the conditions %s' % (tname, where))

                # Add all the key-value pairs from the where condition
                for cond_key, cond_value in where.items():
                    record[cond_key] = cond_value[0] if len(cond_value) > 0 else None

                # Create a new record
                record_new = self.insert(tname, record=record, columns=columns, astype='series')

                # If the default value of the column provided in where_not conflicts, error out
                if where_not is not None and any([record_new[cond_key] in cond_value
                                                  for cond_key, cond_value in where_not.items()]):
                    _id = PandasDatabase._id_colname
                    self.delete(tname, where={_id: record_new[_id]})
                    raise ValueError('Cannot insert new record because default values conflict '
                                     'with conditions provided: %s' % where_not)

                # Otherwise return created record
                return self._output(record_new, astype=astype)

            # If existing record(s) must be updated
            elif len(ixs) > 0:
                self._print('Updating %d record(s) in "%s" where %r and where not %r'
                            % (len(ixs), tname, where, where_not))

                with self._lock:
                    self._update_schema(tname, record.keys())
                    for field_key, field_val in record.items():
                        self._db[tname].loc[ixs, field_key] = field_val

            # Save the changes to disk if required
            if self.auto_save:
                self.save()

            # Return updated records
            rows = self._db[tname].loc[ixs]
            if len(columns) > 0:
                rows = rows[columns]
            return self._output(rows, astype=astype)

        # Insert if no where conditions are given
        else:

            # Return the new record
            new_record = self.insert(tname, record=record, columns=columns, astype='series')
            return self._output(new_record, astype=astype)