Module xelo2.api.backend

Expand source code
from logging import getLogger
from pathlib import Path

from numpy import (
    array,
    character,
    empty,
    floating,
    isnan,
    NaN,
    issubdtype,
    )
from PyQt5.QtSql import QSqlQuery
from PyQt5.QtCore import QVariant
import sip

from .utils import (
    collect_columns,
    get_dtypes,
    out_date,
    out_datetime,
    )

lg = getLogger(__name__)


class Table():
    """General class to handle one row in a SQL table. End users should not
    use this class but only its subclasses.

    Parameters
    ----------
    db : instance of QSqlDatabase
        currently open database
    id : int
        row index for an unspecified table
    """
    db = None  # instance of database
    t = ''
    columns = {}

    def __init__(self, db, id):
        self.db = db
        self.id = id

        # check if it exists at all
        query = QSqlQuery(self.db['db'])
        query.prepare(f'SELECT id FROM {self.t}s WHERE id = :id')
        query.bindValue(':id', id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())
        if not query.next():
            raise ValueError(f'Could not find id = {id} in table {self.t}s')

        self.columns = collect_columns(self.db, self.t)

    def __str__(self):
        return f'<{self.t} (#{self.id})>'

    def __repr__(self):
        return f'{self.t.capitalize()}(db, id={self.id})'

    def __eq__(self, other):
        """So that we can compare instances very easily with set"""
        return self.t == other.t and self.id == other.id

    def __hash__(self):
        """So that we can compare instances very easily with set"""
        return hash(self.__str__())

    def delete(self):
        """Delete current item / this row from this table. It does not delete
        the python object.
        """
        query = QSqlQuery(self.db['db'])
        query.prepare(f"DELETE FROM {self.t}s WHERE id = :id")
        query.bindValue(':id', self.id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        self.id = None

    def __getattr__(self, key):

        if key not in self.columns:
            raise ValueError(f'{key} is not stored in this {self.t}')

        table_name = self.columns[key]
        id_name = 'id'
        if table_name != (self.t + 's'):  # for subtables, use foreign key
            id_name = f'{self.t}_id'

        query = QSqlQuery(self.db['db'])
        query.prepare(f"SELECT {key} FROM {table_name} WHERE {id_name} = :id")
        query.bindValue(':id', self.id)

        if not query.exec():
            raise SyntaxError(query.lastError().text())

        # we need to use QVariant, because QMYSQL in PyQt5 does not distinguish between null and 0.0
        # see https://www.riverbankcomputing.com/static/Docs/PyQt5/pyqt_qvariant.html
        autoconversion = sip.enableautoconversion(QVariant, False)
        if query.next():
            out = query.value(key)

            if out.isNull():
                out = None

            elif self.db['tables'][table_name][key]['type'] == 'QDateTime':
                out = out_datetime(self.db['db'].driverName(), out.value())

            elif self.db['tables'][table_name][key]['type'] == 'QDate':
                out = out_date(self.db['db'].driverName(), out.value())

            else:
                out = out.value()
        else:
            lg.warning(f"Could not get {key} from {table_name} for id = '{self.id}'")
            out = None

        sip.enableautoconversion(QVariant, autoconversion)
        return out

    def __setattr__(self, key, value):
        """Set a value for a key at this row.
        Note that __setattr__ has precedence over all other attributes, so we need
        to make sure that important attributes are handled correctly by the
        subclasses.

        Notes
        -----
        Order in python:
        1. __getattribute__ and __setattr__
        2. Data descriptors, like property
        3. Instance variables from the object's __dict__ (when setting an attribute, the search ends here)
        4. Non-Data descriptors (like methods) and other class variables
        5. __getattr__
        """
        BUILTINS = (
            'db',
            'id',
            't',
            'columns',
            'experimenters',
            'codes',
            'subject',
            'session',
            'run',
            'events',
            'data',
            'intendedfor',
            '_tb_data',
            '__class__',
            )

        if key in BUILTINS:
            """__setattr__ comes first: https://stackoverflow.com/a/15751159"""
            super().__setattr__(key, value)
            return

        if key not in self.columns:
            raise ValueError(f'{key} is not stored in this {self.t}')

        table_name = self.columns[key]
        id_name = 'id'
        if table_name != (self.t + 's'):  # for subtables, use foreign key
            id_name = f'{self.t}_id'

        if self.db['tables'][table_name][key]['type'] == 'QDate':
            value = _date(value)
        elif self.db['tables'][table_name][key]['type'] == 'QDateTime':
            value = _datetime(value)
        else:
            value = _null(value)

        query = QSqlQuery(self.db['db'])
        query.prepare(f"UPDATE {table_name} SET `{key}` = {value} WHERE {id_name} = :id")
        query.bindValue(':id', self.id)

        if not query.exec():
            print(value)
            raise ValueError(query.lastError().text())


class Table_with_files(Table):
    """This class (which should be used by end-users) is useful when handling
    objects which might be associated with files.
    """
    def list_files(self):
        """List all the files associated with this object
        """
        query = QSqlQuery(self.db['db'])
        query.prepare(f"SELECT file_id FROM {self.t}s_files WHERE {self.t}_id = :id")
        query.bindValue(':id', self.id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        out = []
        while query.next():
            out.append(File(self.db, query.value('file_id')))
        return out

    def add_file(self, format, path):
        """Add a file to this object.

        Parameters
        ----------
        format : str
            type of file (list of acceptable formats is stored in "allowed_values"
        path : str or Path
            path of the file (it does not need to exist)
        """
        path = Path(path).resolve()

        query = QSqlQuery(self.db['db'])
        query.prepare("SELECT id, format FROM files WHERE path = :path")
        query.bindValue(':path', str(path))
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        if query.next():
            file_id = query.value('id')
            format_in_table = query.value('format')

            if format != format_in_table:
                raise ValueError(f'Input format "{format}" does not match the format "{format_in_table}" in the table for {path}')

        else:
            query = QSqlQuery(self.db['db'])
            query.prepare("INSERT INTO files (`format`, `path`) VALUES (:format, :path)")
            query.bindValue(':format', format)
            query.bindValue(':path', str(path))
            if not query.exec():
                raise SyntaxError(query.lastError().text())

            file_id = query.lastInsertId()

        query = QSqlQuery(self.db['db'])
        query.prepare(f"INSERT INTO {self.t}s_files (`{self.t}_id`, `file_id`) VALUES (:id, :file_id)")
        query.bindValue(':id', self.id)
        query.bindValue(':file_id', file_id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        return File(db=self.db, id=file_id)

    def delete_file(self, file):
        """There should be a trigger that deletes the file when there are no pointers anymore
        """
        query = QSqlQuery(self.db['db'])
        query.prepare(f"DELETE FROM {self.t}s_files WHERE {self.t}_id = :id AND file_id = :file_id")
        query.bindValue(':id', self.id)
        query.bindValue(':file_id', file.id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())


class NumpyTable(Table_with_files):
    """Note that self.id points to the ID of the group
    """

    def __init__(self, db, id):
        super().__init__(db, id)
        self._tb_data = self.t.split('_')[0] + 's'

    @property
    def data(self):
        dtypes = get_dtypes(self.db['tables'][self._tb_data])
        query_str = ", ".join(f"`{col}`" for col in dtypes.names)
        query = QSqlQuery(self.db['db'])
        query.prepare(f"SELECT {query_str} FROM {self._tb_data} WHERE {self.t}_id = :id")
        query.bindValue(':id', self.id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        autoconversion = sip.enableautoconversion(QVariant, False)
        values = []
        while query.next():
            row = []
            for name in dtypes.names:
                v = query.value(name)
                if issubdtype(dtypes[name].type, floating) and v.isNull():
                    row.append(NaN)
                else:
                    row.append(v.value())

            values.append(tuple(row))

        sip.enableautoconversion(QVariant, autoconversion)
        return array(values, dtype=dtypes)

    @data.setter
    def data(self, values):
        """If values is None, it deletes all the events.
        """
        query = QSqlQuery(self.db['db'])
        query.prepare(f"DELETE FROM {self._tb_data} WHERE {self.t}_id = :id")
        query.bindValue(':id', self.id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        if values is None:
            return

        for row in values:
            column_str, values_str = _create_query(row)
            query = QSqlQuery(self.db['db'])  # column_str depends on values as well (no column when value is NaN)
            sql_cmd = f"""\
                INSERT INTO {self._tb_data} (`{self.t}_id`, {column_str})
                VALUES ('{self.id}', {values_str})
                """
            if not query.exec(sql_cmd):
                raise ValueError(query.lastError().text())

    def empty(self, n_rows):
        """convenience function to get an empty array with empty values if
        necessary"""
        dtypes = get_dtypes(self.db['tables'][self._tb_data])

        values = empty(n_rows, dtype=dtypes)
        for name in values.dtype.names:
            if issubdtype(dtypes[name].type, floating):
                values[name].fill(NaN)

        return values


class File(Table):
    t = 'file'

    def __init__(self, db, id):
        super().__init__(db, id)

    @property
    def path(self):
        return Path(self.__getattr__('path')).resolve()


def _null(s):
    if s is None:
        return 'null'
    else:
        s = str(s).replace("'", '"')
        s = s.replace('\\', '"')
        return f"'{s}'"


def _date(s):
    if s is None:
        return 'null'
    else:
        return f'"{s:%Y-%m-%d}"'


def _datetime(s):
    if s is None:
        return 'null'
    else:
        return '"' + f'{s:%Y-%m-%dT%H:%M:%S.%f}'[:-3] + '"'


def _create_query(row):
    """discard nan and create query strings"""
    dtypes = row.dtype
    columns = []
    values = []
    for name in dtypes.names:
        if issubdtype(dtypes[name].type, floating):
            if not isnan(row[name]):
                columns.append(name)
                values.append(f"'{row[name]}'")
        elif issubdtype(dtypes[name].type, character):
            if row[name] != '':
                columns.append(name)
                values.append(f"'{row[name]}'")
        else:
            raise ValueError(f'Unknown dtype {dtypes[name]}')

        assert 'name' in columns

    columns_str = ', '.join([f'`{x}`' for x in columns])
    values_str = ', '.join(values)

    return columns_str, values_str

Classes

class File (db, id)

General class to handle one row in a SQL table. End users should not use this class but only its subclasses.

Parameters

db : instance of QSqlDatabase
currently open database
id : int
row index for an unspecified table
Expand source code
class File(Table):
    t = 'file'

    def __init__(self, db, id):
        super().__init__(db, id)

    @property
    def path(self):
        return Path(self.__getattr__('path')).resolve()

Ancestors

Class variables

var t

Instance variables

var path
Expand source code
@property
def path(self):
    return Path(self.__getattr__('path')).resolve()

Inherited members

class NumpyTable (db, id)

Note that self.id points to the ID of the group

Expand source code
class NumpyTable(Table_with_files):
    """Note that self.id points to the ID of the group
    """

    def __init__(self, db, id):
        super().__init__(db, id)
        self._tb_data = self.t.split('_')[0] + 's'

    @property
    def data(self):
        dtypes = get_dtypes(self.db['tables'][self._tb_data])
        query_str = ", ".join(f"`{col}`" for col in dtypes.names)
        query = QSqlQuery(self.db['db'])
        query.prepare(f"SELECT {query_str} FROM {self._tb_data} WHERE {self.t}_id = :id")
        query.bindValue(':id', self.id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        autoconversion = sip.enableautoconversion(QVariant, False)
        values = []
        while query.next():
            row = []
            for name in dtypes.names:
                v = query.value(name)
                if issubdtype(dtypes[name].type, floating) and v.isNull():
                    row.append(NaN)
                else:
                    row.append(v.value())

            values.append(tuple(row))

        sip.enableautoconversion(QVariant, autoconversion)
        return array(values, dtype=dtypes)

    @data.setter
    def data(self, values):
        """If values is None, it deletes all the events.
        """
        query = QSqlQuery(self.db['db'])
        query.prepare(f"DELETE FROM {self._tb_data} WHERE {self.t}_id = :id")
        query.bindValue(':id', self.id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        if values is None:
            return

        for row in values:
            column_str, values_str = _create_query(row)
            query = QSqlQuery(self.db['db'])  # column_str depends on values as well (no column when value is NaN)
            sql_cmd = f"""\
                INSERT INTO {self._tb_data} (`{self.t}_id`, {column_str})
                VALUES ('{self.id}', {values_str})
                """
            if not query.exec(sql_cmd):
                raise ValueError(query.lastError().text())

    def empty(self, n_rows):
        """convenience function to get an empty array with empty values if
        necessary"""
        dtypes = get_dtypes(self.db['tables'][self._tb_data])

        values = empty(n_rows, dtype=dtypes)
        for name in values.dtype.names:
            if issubdtype(dtypes[name].type, floating):
                values[name].fill(NaN)

        return values

Ancestors

Subclasses

Instance variables

var data
Expand source code
@property
def data(self):
    dtypes = get_dtypes(self.db['tables'][self._tb_data])
    query_str = ", ".join(f"`{col}`" for col in dtypes.names)
    query = QSqlQuery(self.db['db'])
    query.prepare(f"SELECT {query_str} FROM {self._tb_data} WHERE {self.t}_id = :id")
    query.bindValue(':id', self.id)
    if not query.exec():
        raise SyntaxError(query.lastError().text())

    autoconversion = sip.enableautoconversion(QVariant, False)
    values = []
    while query.next():
        row = []
        for name in dtypes.names:
            v = query.value(name)
            if issubdtype(dtypes[name].type, floating) and v.isNull():
                row.append(NaN)
            else:
                row.append(v.value())

        values.append(tuple(row))

    sip.enableautoconversion(QVariant, autoconversion)
    return array(values, dtype=dtypes)

Methods

def empty(self, n_rows)

convenience function to get an empty array with empty values if necessary

Expand source code
def empty(self, n_rows):
    """convenience function to get an empty array with empty values if
    necessary"""
    dtypes = get_dtypes(self.db['tables'][self._tb_data])

    values = empty(n_rows, dtype=dtypes)
    for name in values.dtype.names:
        if issubdtype(dtypes[name].type, floating):
            values[name].fill(NaN)

    return values

Inherited members

class Table (db, id)

General class to handle one row in a SQL table. End users should not use this class but only its subclasses.

Parameters

db : instance of QSqlDatabase
currently open database
id : int
row index for an unspecified table
Expand source code
class Table():
    """General class to handle one row in a SQL table. End users should not
    use this class but only its subclasses.

    Parameters
    ----------
    db : instance of QSqlDatabase
        currently open database
    id : int
        row index for an unspecified table
    """
    db = None  # instance of database
    t = ''
    columns = {}

    def __init__(self, db, id):
        self.db = db
        self.id = id

        # check if it exists at all
        query = QSqlQuery(self.db['db'])
        query.prepare(f'SELECT id FROM {self.t}s WHERE id = :id')
        query.bindValue(':id', id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())
        if not query.next():
            raise ValueError(f'Could not find id = {id} in table {self.t}s')

        self.columns = collect_columns(self.db, self.t)

    def __str__(self):
        return f'<{self.t} (#{self.id})>'

    def __repr__(self):
        return f'{self.t.capitalize()}(db, id={self.id})'

    def __eq__(self, other):
        """So that we can compare instances very easily with set"""
        return self.t == other.t and self.id == other.id

    def __hash__(self):
        """So that we can compare instances very easily with set"""
        return hash(self.__str__())

    def delete(self):
        """Delete current item / this row from this table. It does not delete
        the python object.
        """
        query = QSqlQuery(self.db['db'])
        query.prepare(f"DELETE FROM {self.t}s WHERE id = :id")
        query.bindValue(':id', self.id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        self.id = None

    def __getattr__(self, key):

        if key not in self.columns:
            raise ValueError(f'{key} is not stored in this {self.t}')

        table_name = self.columns[key]
        id_name = 'id'
        if table_name != (self.t + 's'):  # for subtables, use foreign key
            id_name = f'{self.t}_id'

        query = QSqlQuery(self.db['db'])
        query.prepare(f"SELECT {key} FROM {table_name} WHERE {id_name} = :id")
        query.bindValue(':id', self.id)

        if not query.exec():
            raise SyntaxError(query.lastError().text())

        # we need to use QVariant, because QMYSQL in PyQt5 does not distinguish between null and 0.0
        # see https://www.riverbankcomputing.com/static/Docs/PyQt5/pyqt_qvariant.html
        autoconversion = sip.enableautoconversion(QVariant, False)
        if query.next():
            out = query.value(key)

            if out.isNull():
                out = None

            elif self.db['tables'][table_name][key]['type'] == 'QDateTime':
                out = out_datetime(self.db['db'].driverName(), out.value())

            elif self.db['tables'][table_name][key]['type'] == 'QDate':
                out = out_date(self.db['db'].driverName(), out.value())

            else:
                out = out.value()
        else:
            lg.warning(f"Could not get {key} from {table_name} for id = '{self.id}'")
            out = None

        sip.enableautoconversion(QVariant, autoconversion)
        return out

    def __setattr__(self, key, value):
        """Set a value for a key at this row.
        Note that __setattr__ has precedence over all other attributes, so we need
        to make sure that important attributes are handled correctly by the
        subclasses.

        Notes
        -----
        Order in python:
        1. __getattribute__ and __setattr__
        2. Data descriptors, like property
        3. Instance variables from the object's __dict__ (when setting an attribute, the search ends here)
        4. Non-Data descriptors (like methods) and other class variables
        5. __getattr__
        """
        BUILTINS = (
            'db',
            'id',
            't',
            'columns',
            'experimenters',
            'codes',
            'subject',
            'session',
            'run',
            'events',
            'data',
            'intendedfor',
            '_tb_data',
            '__class__',
            )

        if key in BUILTINS:
            """__setattr__ comes first: https://stackoverflow.com/a/15751159"""
            super().__setattr__(key, value)
            return

        if key not in self.columns:
            raise ValueError(f'{key} is not stored in this {self.t}')

        table_name = self.columns[key]
        id_name = 'id'
        if table_name != (self.t + 's'):  # for subtables, use foreign key
            id_name = f'{self.t}_id'

        if self.db['tables'][table_name][key]['type'] == 'QDate':
            value = _date(value)
        elif self.db['tables'][table_name][key]['type'] == 'QDateTime':
            value = _datetime(value)
        else:
            value = _null(value)

        query = QSqlQuery(self.db['db'])
        query.prepare(f"UPDATE {table_name} SET `{key}` = {value} WHERE {id_name} = :id")
        query.bindValue(':id', self.id)

        if not query.exec():
            print(value)
            raise ValueError(query.lastError().text())

Subclasses

Class variables

var columns
var db
var t

Methods

def delete(self)

Delete current item / this row from this table. It does not delete the python object.

Expand source code
def delete(self):
    """Delete current item / this row from this table. It does not delete
    the python object.
    """
    query = QSqlQuery(self.db['db'])
    query.prepare(f"DELETE FROM {self.t}s WHERE id = :id")
    query.bindValue(':id', self.id)
    if not query.exec():
        raise SyntaxError(query.lastError().text())

    self.id = None
class Table_with_files (db, id)

This class (which should be used by end-users) is useful when handling objects which might be associated with files.

Expand source code
class Table_with_files(Table):
    """This class (which should be used by end-users) is useful when handling
    objects which might be associated with files.
    """
    def list_files(self):
        """List all the files associated with this object
        """
        query = QSqlQuery(self.db['db'])
        query.prepare(f"SELECT file_id FROM {self.t}s_files WHERE {self.t}_id = :id")
        query.bindValue(':id', self.id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        out = []
        while query.next():
            out.append(File(self.db, query.value('file_id')))
        return out

    def add_file(self, format, path):
        """Add a file to this object.

        Parameters
        ----------
        format : str
            type of file (list of acceptable formats is stored in "allowed_values"
        path : str or Path
            path of the file (it does not need to exist)
        """
        path = Path(path).resolve()

        query = QSqlQuery(self.db['db'])
        query.prepare("SELECT id, format FROM files WHERE path = :path")
        query.bindValue(':path', str(path))
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        if query.next():
            file_id = query.value('id')
            format_in_table = query.value('format')

            if format != format_in_table:
                raise ValueError(f'Input format "{format}" does not match the format "{format_in_table}" in the table for {path}')

        else:
            query = QSqlQuery(self.db['db'])
            query.prepare("INSERT INTO files (`format`, `path`) VALUES (:format, :path)")
            query.bindValue(':format', format)
            query.bindValue(':path', str(path))
            if not query.exec():
                raise SyntaxError(query.lastError().text())

            file_id = query.lastInsertId()

        query = QSqlQuery(self.db['db'])
        query.prepare(f"INSERT INTO {self.t}s_files (`{self.t}_id`, `file_id`) VALUES (:id, :file_id)")
        query.bindValue(':id', self.id)
        query.bindValue(':file_id', file_id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        return File(db=self.db, id=file_id)

    def delete_file(self, file):
        """There should be a trigger that deletes the file when there are no pointers anymore
        """
        query = QSqlQuery(self.db['db'])
        query.prepare(f"DELETE FROM {self.t}s_files WHERE {self.t}_id = :id AND file_id = :file_id")
        query.bindValue(':id', self.id)
        query.bindValue(':file_id', file.id)
        if not query.exec():
            raise SyntaxError(query.lastError().text())

Ancestors

Subclasses

Methods

def add_file(self, format, path)

Add a file to this object.

Parameters

format : str
type of file (list of acceptable formats is stored in "allowed_values"
path : str or Path
path of the file (it does not need to exist)
Expand source code
def add_file(self, format, path):
    """Add a file to this object.

    Parameters
    ----------
    format : str
        type of file (list of acceptable formats is stored in "allowed_values"
    path : str or Path
        path of the file (it does not need to exist)
    """
    path = Path(path).resolve()

    query = QSqlQuery(self.db['db'])
    query.prepare("SELECT id, format FROM files WHERE path = :path")
    query.bindValue(':path', str(path))
    if not query.exec():
        raise SyntaxError(query.lastError().text())

    if query.next():
        file_id = query.value('id')
        format_in_table = query.value('format')

        if format != format_in_table:
            raise ValueError(f'Input format "{format}" does not match the format "{format_in_table}" in the table for {path}')

    else:
        query = QSqlQuery(self.db['db'])
        query.prepare("INSERT INTO files (`format`, `path`) VALUES (:format, :path)")
        query.bindValue(':format', format)
        query.bindValue(':path', str(path))
        if not query.exec():
            raise SyntaxError(query.lastError().text())

        file_id = query.lastInsertId()

    query = QSqlQuery(self.db['db'])
    query.prepare(f"INSERT INTO {self.t}s_files (`{self.t}_id`, `file_id`) VALUES (:id, :file_id)")
    query.bindValue(':id', self.id)
    query.bindValue(':file_id', file_id)
    if not query.exec():
        raise SyntaxError(query.lastError().text())

    return File(db=self.db, id=file_id)
def delete_file(self, file)

There should be a trigger that deletes the file when there are no pointers anymore

Expand source code
def delete_file(self, file):
    """There should be a trigger that deletes the file when there are no pointers anymore
    """
    query = QSqlQuery(self.db['db'])
    query.prepare(f"DELETE FROM {self.t}s_files WHERE {self.t}_id = :id AND file_id = :file_id")
    query.bindValue(':id', self.id)
    query.bindValue(':file_id', file.id)
    if not query.exec():
        raise SyntaxError(query.lastError().text())
def list_files(self)

List all the files associated with this object

Expand source code
def list_files(self):
    """List all the files associated with this object
    """
    query = QSqlQuery(self.db['db'])
    query.prepare(f"SELECT file_id FROM {self.t}s_files WHERE {self.t}_id = :id")
    query.bindValue(':id', self.id)
    if not query.exec():
        raise SyntaxError(query.lastError().text())

    out = []
    while query.next():
        out.append(File(self.db, query.value('file_id')))
    return out

Inherited members