def test_migrate(self): runner = CliRunner() runner.invoke(setup) result = runner.invoke(migrate) self.assertEqual(result.exit_code, 0) self.assertEqual( result.output, "Migrating users model\nMigrated users successfully!\nMigrating sessions model\nMigrated sessions successfully!\n" ) DB().table('dragonfly_testing').custom_sql("DROP TABLE sessions") DB().table('dragonfly_testing').custom_sql("DROP TABLE users")
def test_drop(self): runner = CliRunner() runner.invoke(setup) runner.invoke(migrate) result = runner.invoke(drop) self.assertEqual(result.exit_code, 0) self.assertEqual( result.output, "Deleting sessions model\nDeleted sessions successfully!\nDeleting users model\nDeleted users successfully!\n" ) with self.assertRaises(OperationalError): DB().table('dragonfly_testing').custom_sql("DROP TABLE sessions") DB().table('dragonfly_testing').custom_sql("DROP TABLE users")
def setUpClass(cls): cls.model = Article() cls.db = DB().table('articles') cls.db.custom_sql( "CREATE TABLE articles (\ntitle VARCHAR(50) UNIQUE,\ntext TEXT,\nid INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,\ncreated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\nupdated_at TIMESTAMP ON UPDATE NOW() DEFAULT NOW()\n)" ) for i in range(0, 10): cls.model.create({ 'title': f"Article {i}", 'text': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' })
def delayed_init(self, values, meta): self.__index = 0 # Store target model before it is initialised self.__target_pre_init = self.__target = getattr(importlib.import_module(f"models.{self.target_name}"), self.target_name.capitalize()) self.__target = self.__target_pre_init() target_table = self.__target.meta['table_name'] target_pk = self.__target.primary_key[0] # Generate keys if not passed in by user if self._target_key is None: self._target_key = target_table[:-1] + '_id' if self._this_key is None: self._this_key = meta['table_name'][:-1] + '_id' # Generate name of pivot table if self.__table is None: table_names = [meta['table_name'], target_table] self.__table = "_".join(sorted(table_names)) # List of all columns in the target model table. columns = [f"{target_table}.{column}" for column in self.__target.fields_keys] # Select all the columns in the target model table where the primary key is equal to one of the keys in the # pivot table where the other key is equal to the id of this model. res = DB().custom_sql( f"SELECT {', '.join(columns)} FROM {target_table} INNER JOIN {self.__table} ON {target_table}.{target_pk} " f"= {self.__table}.{self._target_key} WHERE {self.__table}.{self._this_key} = {values[meta['primary_key'][0]]}") self.values = [self.__target_pre_init(data=row) for row in res] return self
def __init__(self, data=None): """ By providing data to the constructor the class is converted from an object that represents the Model's table to a row in the Model's table. :param data: The data to initialise the class with if it represents a row in the database. """ # If this value is true then any data returned from the database will not have all the model fields. As such a # model instance cannot be created from that data. This field is thus needed to determine when this has happened # and to return a dictionary of data instead of attempting (and failing) to instantiate a model for it. self.__has_select = False # By default it is not a row self.__is_row = False # Retrieve the types that were defined in the user defined model and set the primary key for the table self.fields = {} self.primary_key = [] self.__composite = False self._relationships = {} for (key, value) in self.__class__.__dict__.items(): # Make sure not an inbuilt method or in the 'Meta' subclass if not key.startswith("__") and not key == 'Meta': # Make sure not a function if not callable(value): # Must be a field type so add to types dictionary self.fields[key] = value # If the field has the 'primary_key' attribute set to true then add to list of primary keys if value.default_parameters['primary_key']: self.primary_key.append(key) # Retrieve all data from the 'Meta' subclass (if present) self.meta = {} try: for (key, value) in self.__class__.__dict__['Meta'].__dict__.items(): if not key.startswith("__"): self.meta[key] = value # Retrieve any defined primary keys in 'Meta' subclass. Note that this will override any field defined primary keys if isinstance(value, PrimaryKey): self.primary_key = list(value.fields) except KeyError: pass try: # If the 'id' bool is defined and set to 'True' in 'Meta' class add id field if self.meta['id'] is True: self.fields['id'] = IntField(unsigned=True, auto_increment=True, primary_key=True) self.primary_key.append('id') except KeyError: # If 'id' bool is not present in 'Meta' class add id field (assuming id field is wanted by default) self.fields['id'] = IntField(unsigned=True, auto_increment=True, primary_key=True) self.primary_key.append('id') # The same as a above but for 'created_at' and 'updated_at' timestamps try: if self.meta['timestamps'] is True: self.fields['created_at'] = TimestampField( default='CURRENT_TIMESTAMP', null=False) self.fields['updated_at'] = TimestampField(default='NOW()', on="UPDATE NOW()", null=False) except KeyError: self.fields['created_at'] = TimestampField( default='CURRENT_TIMESTAMP', null=False) self.fields['updated_at'] = TimestampField(default='NOW()', on="UPDATE NOW()", null=False) if len(self.primary_key) > 1: self.__composite = True # Generate the table name if it has not been defined if 'table_name' not in self.meta.keys(): # TODO split at capital and add underscore self.meta['table_name'] = self.__class__.__name__.lower() + 's' self.meta['primary_key'] = self.primary_key # Shortcut self.__fields_keys = self.fields.keys() # Only used if class is a row self._database_values = {} self.__new_values = {} # Default DB attributes to None for key in self.__fields_keys: setattr(self, key, None) # Shortcut to interact with DB self.__db = DB().table(self.meta['table_name']) # Set the class attributes to the given data (if present), converting the class to a row if data is not None: self.__is_row = True self.__data_to_attributes(data)
class Model(object): """A way to easily interact with rows in a table.""" def __init__(self, data=None): """ By providing data to the constructor the class is converted from an object that represents the Model's table to a row in the Model's table. :param data: The data to initialise the class with if it represents a row in the database. """ # If this value is true then any data returned from the database will not have all the model fields. As such a # model instance cannot be created from that data. This field is thus needed to determine when this has happened # and to return a dictionary of data instead of attempting (and failing) to instantiate a model for it. self.__has_select = False # By default it is not a row self.__is_row = False # Retrieve the types that were defined in the user defined model and set the primary key for the table self.fields = {} self.primary_key = [] self.__composite = False self._relationships = {} for (key, value) in self.__class__.__dict__.items(): # Make sure not an inbuilt method or in the 'Meta' subclass if not key.startswith("__") and not key == 'Meta': # Make sure not a function if not callable(value): # Must be a field type so add to types dictionary self.fields[key] = value # If the field has the 'primary_key' attribute set to true then add to list of primary keys if value.default_parameters['primary_key']: self.primary_key.append(key) # Retrieve all data from the 'Meta' subclass (if present) self.meta = {} try: for (key, value) in self.__class__.__dict__['Meta'].__dict__.items(): if not key.startswith("__"): self.meta[key] = value # Retrieve any defined primary keys in 'Meta' subclass. Note that this will override any field defined primary keys if isinstance(value, PrimaryKey): self.primary_key = list(value.fields) except KeyError: pass try: # If the 'id' bool is defined and set to 'True' in 'Meta' class add id field if self.meta['id'] is True: self.fields['id'] = IntField(unsigned=True, auto_increment=True, primary_key=True) self.primary_key.append('id') except KeyError: # If 'id' bool is not present in 'Meta' class add id field (assuming id field is wanted by default) self.fields['id'] = IntField(unsigned=True, auto_increment=True, primary_key=True) self.primary_key.append('id') # The same as a above but for 'created_at' and 'updated_at' timestamps try: if self.meta['timestamps'] is True: self.fields['created_at'] = TimestampField( default='CURRENT_TIMESTAMP', null=False) self.fields['updated_at'] = TimestampField(default='NOW()', on="UPDATE NOW()", null=False) except KeyError: self.fields['created_at'] = TimestampField( default='CURRENT_TIMESTAMP', null=False) self.fields['updated_at'] = TimestampField(default='NOW()', on="UPDATE NOW()", null=False) if len(self.primary_key) > 1: self.__composite = True # Generate the table name if it has not been defined if 'table_name' not in self.meta.keys(): # TODO split at capital and add underscore self.meta['table_name'] = self.__class__.__name__.lower() + 's' self.meta['primary_key'] = self.primary_key # Shortcut self.__fields_keys = self.fields.keys() # Only used if class is a row self._database_values = {} self.__new_values = {} # Default DB attributes to None for key in self.__fields_keys: setattr(self, key, None) # Shortcut to interact with DB self.__db = DB().table(self.meta['table_name']) # Set the class attributes to the given data (if present), converting the class to a row if data is not None: self.__is_row = True self.__data_to_attributes(data) def __str__(self): if self.__is_row: string = f"{self.__class__.__name__}\n" for key in self.__fields_keys: string += f"{key}: {self.__dict__[key]} \n" return string else: return f"{self.__class__.__name__} instance. No data is bound" def __bool__(self): """ Returns 'True' if the class is a row and 'False' if not. :return: If the Model is a row """ if self.__is_row: return True return False def create(self, create_dict): """ Creates a new row in the table and returns a model representation of this row. :param create_dict: The values to create the new row with :type create_dict: dict :return: A list of object models :rtype: list """ self.__db.insert(create_dict) # Composite keys have not been implemented if not self.__composite: # Get id of new row to update timestamp data. create_dict[self.primary_key[0]] = self.__db.last_insert_id # It is not possible to be certain that a timestamp generated in Python will be the same as the one generated in # the table. Thus the timestamp fields are retrieved from the table and a new model class instantiated with them # along with the given 'create_dict' data. try: if self.meta['timestamps'] is True: timestamp_values = self.__db.custom_sql( f"SELECT created_at, updated_at FROM {self.meta['table_name']} WHERE {self.primary_key[0]} = {self.__db.last_insert_id}" )[0] create_dict['created_at'] = timestamp_values['created_at'] create_dict['updated_at'] = timestamp_values['updated_at'] except KeyError: timestamp_values = self.__db.custom_sql( f"SELECT created_at, updated_at FROM {self.meta['table_name']} WHERE {self.primary_key[0]} = {self.__db.last_insert_id}" )[0] create_dict['created_at'] = timestamp_values['created_at'] create_dict['updated_at'] = timestamp_values['updated_at'] return self.__data_to_model([create_dict])[0] def first(self): """ Get the first row in the table. :return: An object model :rtype: :class:`Model <dragonfly.db.models.model.Model>` """ return self.__data_to_model([self.__db.first()])[0] def get(self): """ Get all rows in the table. :return: An object model :rtype: list """ return self.__data_to_model(self.__db.get()) def all(self): """ Get all rows in the table . :return: A list object models :rtype: list """ return self.__data_to_model(self.__db.get()) def find(self, primary_key): """ Find a row by passing in the value of the row's primary key. Note that if you would like to find a model with more than one key pass through a dictionary containing the column and value. :example: ``Article().find({'id': 1, 'author': 1})`` :param primary_key: The value of the primary key to find. :type primary_key: int :return: An object model that has the given value as its primary key :rtype: :class:`Model <dragonfly.db.models.model.Model>` """ if isinstance(primary_key, dict): self.__db.multiple_where(primary_key) else: self.__db.where(self.primary_key[0], '=', primary_key) return self.first() def select(self, *args): """ Same as the :class:`DB <dragonfly.db.database.DB>` class :meth:`select<dragonfly.db.database.DB.select>` method. Note that a dictionary will be returned as a model cannot be represented with incomplete data. :param args: A list of columns to select :return: This model object :rtype: :class:`Model <dragonfly.db.models.model.Model>` """ self.__db.select(*args) self.__has_select = True return self def where(self, column, comparator, value): """ Same as the :class:`DB <dragonfly.db.database.DB>` class :meth:`where <dragonfly.db.database.DB.where>` method. :param column: The column in the database to check the value for :type column: str :param comparator: The comparison operator e.g. = :type comparator: str :param value: The value to test for :return: This model object :rtype: :class:`Model <dragonfly.db.models.model.Model>` """ self.__db.where(column, comparator, value) return self def multiple_where(self, where_dict): """ Select a row from the table using multiple values/columns :param where_dict: A dictionary where the key is the column in the table and the value is the value in the table. :type where_dict: dict :return This model object :rtype: :class:`Model <dragonfly.db.models.model.Model>` """ self.__db.multiple_where(where_dict) return self def paginate(self, size, to_json=False): """ Paginates the data in the table by the given size. Note that if ``to_json`` is ``True`` a :class:`Response <dragonfly.response.Response>` will be returned containing the appropriate JSON. Otherwise a list of rows that correspond to the page requested will be returned (the page number is known from the request object). :param size: The number of rows on each page :type size: int :param to_json: If the function should return a JSON response, default is False :type to_json: bool :return: A tuple containing a dictionary if results and a dictionary contaning meta information :rtype: tuple """ if not bool(request.get_data()): page_number = 1 else: page_number = request.get_data()['page'][0] result, meta = self.__db.chunk(int(page_number), size) if result is None: return None, None result = self.__data_to_model(result) if to_json: meta['data'] = [row.__get_field_values() for row in result] return Response(content=json.dumps(meta, default=default), content_type='application/json') return result, meta def update(self, update_dict): """ Update this model with the given values. :param update_dict: Update the given columns (key) with the given values :type update_dict: dict :return: This model object :rtype: :class:`Model <dragonfly.db.models.model.Model>` """ # Check to see if any given keys in the dictionary do not match the model fields/columns if not all(item in self.__fields_keys for item in update_dict.keys()): raise Exception( "Mismatch between model defined columns and given columns") for key in update_dict.keys(): self.__dict__[key] = update_dict[key] self.save() return self def save(self): """ Permeate the changes made to the Python model to the database. """ # Iterate over each new attribute (check if changed) and validate using Field class. Then save to DB self.__new_values = {} for key in self.__fields_keys: if self.__dict__[key] is not None: self.__new_values[key] = self.__dict__[key] self._database_values = { k: v for k, v in self._database_values.items() if v is not None } self.__db.multiple_where(self._database_values).update( self.__new_values) def delete(self): """Delete this row from the database .""" self.__db.multiple_where(self._database_values).delete() def to_dict(self): """ Return a dictionary equivalent of this row. :return: A dictionary equivalent of this row :rtype: dict """ if not self.__is_row: raise Exception( "A non data bound class cannot be converted to a dictionary") return self._database_values def __data_to_model(self, data): """Starts the process of converting data from the database to model instances. :param data: The data to convert :type: list :return: A list of instantiated model objects :rtype: list """ # Return a dict if true and thus not all fields present. if self.__has_select: self.__has_select = False return data if self.__is_row: raise Exception( "A data bound class cannot be rebound to more data") # Create new class instances passing in the given data collection = [self.__class__(row) for row in data] return collection def __data_to_attributes(self, data): """Converts the given data to class attributes in the class instance. The given data (from the database) is first converted to its equivalent python type and then assigned to the class dictionary as well as a ``database_values`` dictionary that is used to retrieve the correct row from the database when updating the model. :param data: The data to assign to the model instance """ if not self.__is_row: raise Exception( "A non data bound class cannot be bound to data without being converted" ) for k, v in self.fields.items(): if v.default_parameters['null']: if k not in data: data[k] = None # Ensure that the retrieved data and the defined model match if not data.keys() == self.__fields_keys: raise Exception( "Mismatch between model defined columns and database columns") for key, value in self.fields.items(): # In the future the to_python_type() method on each field class can be uses to convert to the correct type # when the given DB package (e.g mysqldb) does not do it by default value = data[key] # Assign the given data values to a class attribute (using __dict__) self.__dict__[key] = value # Store a back up of the data values so the row can be found when updating the model self._database_values[key] = value def __get_field_values(self): """ Returns the values of the model fields. :return: Dictionary containing the fields of the model and the values. :rtype: dict """ attributes = {} for key in self.__fields_keys: attributes[key] = self.__dict__[key] return attributes def add_relationship(self, relationship_class, update=False): """ Adds a relationship to another model to this model instance using the relationship classes. Note that the method will cache the values of a relationship unless update is set to true. :param relationship_class: An instantiated relationship class. :type relationship_class: :class:`Relationship <dragonfly.db.models.relationships.Relationship>` :param update: If the method should retrieve fresh data from the database. :type update: bool :return: The retrieved, related, model(s). :rtype: :class:`Relationship <dragonfly.db.models.relationships.Relationship>` """ target_name = relationship_class.target_name if update: self._relationships[target_name] = relationship_class.delayed_init( self._database_values, self.meta) return self._relationships[target_name] try: return self._relationships[target_name] except KeyError: self._relationships[target_name] = relationship_class.delayed_init( self._database_values, self.meta) return self._relationships[target_name]
def setUp(self): self.database = DB().table('testing') self.database.custom_sql("CREATE TABLE testing (id INT AUTO_INCREMENT PRIMARY KEY, string VARCHAR(255))") self.database.custom_sql( 'INSERT INTO testing (string) VALUES ("Test"), ("Test 1"), ("Test 2"), ("Test 3"), ("Test 4")')
class TestDB(TestCase): def setUp(self): self.database = DB().table('testing') self.database.custom_sql("CREATE TABLE testing (id INT AUTO_INCREMENT PRIMARY KEY, string VARCHAR(255))") self.database.custom_sql( 'INSERT INTO testing (string) VALUES ("Test"), ("Test 1"), ("Test 2"), ("Test 3"), ("Test 4")') def tearDown(self): self.database.custom_sql("DROP TABLE testing") def test_select(self): self.assertEqual(self.database.select('id').first(), {'id': 1}) def test_where(self): self.assertEqual(self.database.where('string', '=', 'Test').first(), {'id': 1, 'string': 'Test'}) def test_multiple_where(self): self.assertEqual(self.database.multiple_where({'string': 'Test', 'id': 1}).first(), {'id': 1, 'string': 'Test'}) def test_get(self): self.assertEqual(self.database.get(), ( {'id': 1, 'string': 'Test'}, {'id': 2, 'string': 'Test 1'}, {'id': 3, 'string': 'Test 2'}, {'id': 4, 'string': 'Test 3'}, {'id': 5, 'string': 'Test 4'})) def test_first(self): self.assertEqual(self.database.first(), {'id': 1, 'string': 'Test'}) def test_chunk(self): self.assertEqual(self.database.chunk(2, 2), ((({'id': 3, 'string': 'Test 2'}, {'id': 4, 'string': 'Test 3'}), {'current_page': 2, 'from': 3, 'last_page': 3, 'per_page': 2, 'to': 4, 'total': 5}))) def test_update(self): self.database.where('id', '=', 1).update({'string': 'Updated'}) self.assertEqual(self.database.where('id', '=', 1).first(), {'id': 1, 'string': 'Updated'}) def test_delete(self): self.database.where('id', '=', 1).delete() self.assertEqual(self.database.where('id', '=', 1).first(), None) def test_insert(self): self.database.insert({'string': 'Testing 5'}) self.assertEqual(self.database.where('id', '=', 6).first(), {'id': 6, 'string': 'Testing 5'}) # Erroneous def test_missing_clause(self): with self.assertRaises(MissingClause): self.database.update({'string': 'Testing'}) # Erroneous def test_no_table(self): with self.assertRaises(MissingTable): DB().where('string', '=', 'Testing').get() def test_erroneous_sql(self): # As all methods will eventually call 'custom_sql' or 'execute_sql' we only need to check those two methods with self.assertRaises(ProgrammingError): self.database.custom_sql("CREATE TABLE_ERROR error (id INT PRIMARY KEY)") with self.assertRaises(OperationalError): self.database.where('id', '=', 1).update({'invalid_column': 'invalid'}) def test_erroneous_comparison_operator(self): with self.assertRaises(InvalidOperator): self.database.where('id', '==', 1).get() def test_erroneous_chunk(self): with self.assertRaises(ChunkOutOfRange): self.database.chunk(200, 20)
def test_no_table(self): with self.assertRaises(MissingTable): DB().where('string', '=', 'Testing').get()