From 9f6fcb11ebe2948167e982aa9ca9713b54a0e47d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 8 Nov 2012 21:28:44 -0800 Subject: [PATCH 0001/4200] Initial commit --- .gitignore | 27 +++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 29 insertions(+) create mode 100644 .gitignore create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..f24cd9952d --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +*.py[co] + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox + +#Translations +*.mo + +#Mr Developer +.mr.developer.cfg diff --git a/README.md b/README.md new file mode 100644 index 0000000000..869495c22b --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +cassandraengine +=============== \ No newline at end of file From 4890726ad57ee388b205acf6749e93b4f8679132 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 8 Nov 2012 22:26:06 -0800 Subject: [PATCH 0002/4200] initial research --- cassandraengine/__init__.py | 0 cassandraengine/columns.py | 0 cassandraengine/connection.py | 19 +++++++++++++++++++ cassandraengine/document.py | 0 cassandraengine/tests/__init__.py | 0 requirements.txt | 0 6 files changed, 19 insertions(+) create mode 100644 cassandraengine/__init__.py create mode 100644 cassandraengine/columns.py create mode 100644 cassandraengine/connection.py create mode 100644 cassandraengine/document.py create mode 100644 cassandraengine/tests/__init__.py create mode 100644 requirements.txt diff --git a/cassandraengine/__init__.py b/cassandraengine/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cassandraengine/columns.py b/cassandraengine/columns.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cassandraengine/connection.py b/cassandraengine/connection.py new file mode 100644 index 0000000000..f4d6c36c62 --- /dev/null +++ b/cassandraengine/connection.py @@ -0,0 +1,19 @@ +#http://pypi.python.org/pypi/cql/1.0.4 +#http://code.google.com/a/apache-extras.org/p/cassandra-dbapi2/ + +import cql + +_keyspace = 'cse_test' +_col_fam = 'colfam' + +conn = cql.connect('127.0.0.1', 9160) +cur = conn.cursor() + +#cli examples here: +#http://wiki.apache.org/cassandra/CassandraCli +cur.execute('create keyspace {};'.format(_keyspace)) + +#http://stackoverflow.com/questions/10871595/how-do-you-create-a-counter-columnfamily-with-cql3-in-cassandra +cur.execute('create column family {};'.format(_col_fam)) + +cur.execute('insert into colfam (obj_id, content) values (:obj_id, :content)', dict(obj_id=str(uuid.uuid4()), content='yo!')) diff --git a/cassandraengine/document.py b/cassandraengine/document.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cassandraengine/tests/__init__.py b/cassandraengine/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..e69de29bb2 From bc3c26a1ef73d64d9a7835a1cd3d3dcd4f76dffd Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 9 Nov 2012 07:17:08 -0800 Subject: [PATCH 0003/4200] adding example cql and crud method stubs --- cassandraengine/connection.py | 56 ++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/cassandraengine/connection.py b/cassandraengine/connection.py index f4d6c36c62..27482829ff 100644 --- a/cassandraengine/connection.py +++ b/cassandraengine/connection.py @@ -1,5 +1,6 @@ #http://pypi.python.org/pypi/cql/1.0.4 #http://code.google.com/a/apache-extras.org/p/cassandra-dbapi2/ +#http://cassandra.apache.org/doc/cql/CQL.html import cql @@ -7,13 +8,60 @@ _col_fam = 'colfam' conn = cql.connect('127.0.0.1', 9160) -cur = conn.cursor() #cli examples here: #http://wiki.apache.org/cassandra/CassandraCli -cur.execute('create keyspace {};'.format(_keyspace)) +try: + conn.set_initial_keyspace(_keyspace) +except cql.ProgrammingError: + #http://www.datastax.com/docs/1.0/references/cql/CREATE_KEYSPACE + cur = conn.cursor() + cur.execute("create keyspace {} with strategy_class = 'SimpleStrategy' and strategy_options:replication_factor=1;".format(_keyspace)) + conn.set_initial_keyspace(_keyspace) +cur = conn.cursor() +#http://www.datastax.com/docs/1.0/dml/using_cql #http://stackoverflow.com/questions/10871595/how-do-you-create-a-counter-columnfamily-with-cql3-in-cassandra -cur.execute('create column family {};'.format(_col_fam)) +try: + cur.execute("""create table colfam (id int PRIMARY KEY);""") +except cql.ProgrammingError: + #cur.execute('drop table colfam;') + pass + +#http://www.datastax.com/docs/1.0/references/cql/INSERT +#updates/inserts do the same thing +cur.execute('insert into colfam (id, content) values (:id, :content)', dict(id=1, content='yo!')) + +cur.execute('select * from colfam WHERE id=1;') +cur.fetchone() + +cur.execute("update colfam set content='hey' where id=1;") +cur.execute('select * from colfam WHERE id=1;') +cur.fetchone() + +cur.execute('delete from colfam WHERE id=1;') +cur.execute('delete from colfam WHERE id in (1);') + +#TODO: Add alter altering existing schema functionality +#http://www.datastax.com/docs/1.0/references/cql/ALTER_COLUMNFAMILY + +def create_column_family(name): + """ + """ + +def select_row(column_family, **kwargs): + """ + """ + +def create_row(column_family, **kwargs): + """ + """ + +def update_row(column_family, **kwargs): + """ + """ + +def delete_row(column_family, **kwargs): + """ + """ -cur.execute('insert into colfam (obj_id, content) values (:obj_id, :content)', dict(obj_id=str(uuid.uuid4()), content='yo!')) From fb36faa0a6b8188ec6bb2d929fd91ae28349e4aa Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 10 Nov 2012 10:43:28 -0800 Subject: [PATCH 0004/4200] adding initial column classes, document class stubs and an exception file --- cassandraengine/columns.py | 142 ++++++++++++++++++++++++++++++++++ cassandraengine/document.py | 18 +++++ cassandraengine/exceptions.py | 2 + 3 files changed, 162 insertions(+) create mode 100644 cassandraengine/exceptions.py diff --git a/cassandraengine/columns.py b/cassandraengine/columns.py index e69de29bb2..6bdda146ea 100644 --- a/cassandraengine/columns.py +++ b/cassandraengine/columns.py @@ -0,0 +1,142 @@ +#column field types +import re +from uuid import uuid1, uuid4 + +class BaseColumn(object): + """ + The base column + """ + + #the cassandra type this column maps to + db_type = None + + def __init__(self, primary_key=False, db_field=None, default=None, null=False): + """ + :param primary_key: bool flag, there can be only one primary key per doc + :param db_field: the fieldname this field will map to in the database + :param default: the default value, can be a value or a callable (no args) + :param null: bool, is the field nullable? + """ + self.primary_key = primary_key + self.db_field = db_field + self.default = default + self.null = null + + def validate(self, value): + if not self.has_default: + if not self.null and value is None: + raise ValidationError('null values are not allowed') + + def to_python(self, value): + """ + Converts data from the database into python values + raises a ValidationError if the value can't be converted + """ + return value + + @property + def has_default(self): + return bool(self.default) + + def get_default(self): + if self.has_default: + if callable(self.default): + return self.default() + else: + return self.default + + def to_database(self, value): + """ + Converts python value into database value + """ + if value is None and self.has_default: + return self.get_default() + return value + + def get_column_def(self): + """ + Returns a column definition for CQL table definition + """ + dterms = [self.db_field, self.db_type] + if self.primary_key: + dterms.append('PRIMARY KEY') + return ' '.join(dterms) + + def set_db_name(self, name): + """ + Sets the column name during document class construction + This value will be ignored if db_field is set in __init__ + """ + self.db_field = self.db_field or name + +class Bytes(BaseColumn): + db_type = 'blob' + +class Ascii(BaseColumn): + db_type = 'ascii' + +class Text(BaseColumn): + db_type = 'text' + +class Integer(BaseColumn): + db_type = 'int' + + def validate(self, value): + super(Integer, self).validate(value) + try: + long(value) + except (TypeError, ValueError): + raise ValidationError("{} can't be converted to integral value".format(value)) + + def to_python(self, value): + self.validate(value) + return long(value) + + def to_database(self, value): + self.validate() + return value + +class DateTime(BaseColumn): + db_type = 'timestamp' + +class UUID(BaseColumn): + """ + Type 1 or 4 UUID + """ + db_type = 'uuid' + + re_uuid = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') + + def __init__(self, **kwargs): + super(UUID, self).__init__(**kwargs) + if 'default' not in kwargs: + self.default = uuid4 + + def validate(self, value): + super(UUID, self).validate(value) + if not self.re_uuid.match(value): + raise ValidationError("{} is not a valid uuid".format(value)) + return value + +class Boolean(BaseColumn): + db_type = 'boolean' + + def to_python(self, value): + return bool(value) + + def to_database(self, value): + return bool(value) + +class Float(BaseColumn): + db_type = 'double' + + def to_python(self, value): + return float(value) + + def to_database(self, value): + return float(value) + +class Decimal(BaseColumn): + db_type = 'decimal' + #TODO: this + diff --git a/cassandraengine/document.py b/cassandraengine/document.py index e69de29bb2..d8d72f04b7 100644 --- a/cassandraengine/document.py +++ b/cassandraengine/document.py @@ -0,0 +1,18 @@ + +class BaseDocument(object): + pass + +class DocumentMetaClass(type): + + def __new__(cls, name, bases, attrs): + """ + """ + #TODO:assert primary key exists + #TODO:get column family name + #TODO:check that all resolved column family names are unique + return super(DocumentMetaClass, cls).__new__(cls, name, bases, attrs) + +class Document(BaseDocument): + """ + """ + __metaclass__ = DocumentMetaClass diff --git a/cassandraengine/exceptions.py b/cassandraengine/exceptions.py new file mode 100644 index 0000000000..8265b0ceab --- /dev/null +++ b/cassandraengine/exceptions.py @@ -0,0 +1,2 @@ + +class ValidationError(BaseException): pass From f3ca4843b0fb8495a81c9d26d897aea579120e4f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 10 Nov 2012 17:26:27 -0800 Subject: [PATCH 0005/4200] Renamed document.py to models.py defined a base model class, as well as it's metaclass --- cassandraengine/document.py | 18 ----- cassandraengine/models.py | 127 ++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 18 deletions(-) delete mode 100644 cassandraengine/document.py create mode 100644 cassandraengine/models.py diff --git a/cassandraengine/document.py b/cassandraengine/document.py deleted file mode 100644 index d8d72f04b7..0000000000 --- a/cassandraengine/document.py +++ /dev/null @@ -1,18 +0,0 @@ - -class BaseDocument(object): - pass - -class DocumentMetaClass(type): - - def __new__(cls, name, bases, attrs): - """ - """ - #TODO:assert primary key exists - #TODO:get column family name - #TODO:check that all resolved column family names are unique - return super(DocumentMetaClass, cls).__new__(cls, name, bases, attrs) - -class Document(BaseDocument): - """ - """ - __metaclass__ = DocumentMetaClass diff --git a/cassandraengine/models.py b/cassandraengine/models.py new file mode 100644 index 0000000000..c3fce738f1 --- /dev/null +++ b/cassandraengine/models.py @@ -0,0 +1,127 @@ + +from cassandraengine import columns +from cassandraengine.exceptions import ColumnFamilyException +from cassandraengine.manager import Manager + +class BaseModel(object): + """ + The base model class, don't inherit from this, inherit from Model, defined below + """ + + db_name = None + + def __init__(self, **values): + #set columns from values + for k,v in values.items(): + if k in self._columns: + setattr(self, k, v) + else: + self._dynamic_columns[k] = v + + #set excluded columns to None + for k in self._columns.keys(): + if k not in values: + setattr(self, k, None) + + @classmethod + def _column_family_definition(cls): + pass + + @classmethod + def find(cls, pk): + """ Loads a document by it's primary key """ + cls.objects.find(pk) + + @property + def pk(self): + """ Returns the object's primary key, regardless of it's name """ + return getattr(self, self._pk_name) + + def validate(self): + """ Cleans and validates the field values """ + for name, col in self._columns.items(): + val = col.validate(getattr(self, name)) + setattr(self, name, val) + + def as_dict(self): + """ Returns a map of column names to cleaned values """ + values = {} + for name, col in self._columns.items(): + values[name] = col.to_database(getattr(self, name, None)) + + #TODO: merge in dynamic columns + return values + + def save(self): + is_new = self.pk is None + self.validate() + self.objects._save_instance(self) + return self + + def delete(self): + pass + + +class ModelMetaClass(type): + + def __new__(cls, name, bases, attrs): + """ + """ + #move column definitions into _columns dict + #and set default column names + _columns = {} + pk_name = None + for k,v in attrs.items(): + if isinstance(v, columns.BaseColumn): + if v.is_primary_key: + if pk_name: + raise ColumnFamilyException("More than one primary key defined for {}".format(name)) + pk_name = k + _columns[k] = attrs.pop(k) + _columns[k].set_db_name(k) + + #set primary key if it's not already defined + if not pk_name: + _columns['id'] = columns.UUID(primary_key=True) + _columns['id'].set_db_name('id') + pk_name = 'id' + + #setup pk shortcut + if pk_name != 'pk': + pk_get = lambda self: getattr(self, pk_name) + pk_set = lambda self, val: setattr(self, pk_name, val) + attrs['pk'] = property(pk_get, pk_set) + + #check for duplicate column names + col_names = set() + for k,v in _columns.items(): + if v.db_field in col_names: + raise ColumnFamilyException("{} defines the column {} more than once".format(name, v.db_field)) + col_names.add(k) + + #get column family name + cf_name = attrs.pop('db_name', None) or name + + #create db_name -> model name map for loading + db_map = {} + for name, col in _columns.items(): + db_map[col.db_field] = name + + attrs['_columns'] = _columns + attrs['_db_map'] = db_map + attrs['_pk_name'] = pk_name + attrs['_dynamic_columns'] = {} + + klass = super(ModelMetaClass, cls).__new__(cls, name, bases, attrs) + klass.objects = Manager(klass) + return klass + + +class Model(BaseModel): + """ + the db name for the column family can be set as the attribute db_name, or + it will be genertaed from the class name + """ + __metaclass__ = ModelMetaClass + + From 44c1232f8ad8567b94e8c4217cd8474b4ebe010b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 10 Nov 2012 17:27:10 -0800 Subject: [PATCH 0006/4200] added validation to columns starting to clean up connection.py adding exceptions --- cassandraengine/columns.py | 51 +++++++++++++++++++---------- cassandraengine/connection.py | 60 +++++++++++++++-------------------- cassandraengine/exceptions.py | 4 ++- 3 files changed, 62 insertions(+), 53 deletions(-) diff --git a/cassandraengine/columns.py b/cassandraengine/columns.py index 6bdda146ea..7c90f5584c 100644 --- a/cassandraengine/columns.py +++ b/cassandraengine/columns.py @@ -2,6 +2,8 @@ import re from uuid import uuid1, uuid4 +from cassandraengine.exceptions import ValidationError + class BaseColumn(object): """ The base column @@ -23,9 +25,16 @@ def __init__(self, primary_key=False, db_field=None, default=None, null=False): self.null = null def validate(self, value): - if not self.has_default: - if not self.null and value is None: + """ + Returns a cleaned and validated value. Raises a ValidationError + if there's a problem + """ + if value is None: + if self.has_default: + return self.get_default() + elif not self.null: raise ValidationError('null values are not allowed') + return value def to_python(self, value): """ @@ -38,6 +47,10 @@ def to_python(self, value): def has_default(self): return bool(self.default) + @property + def is_primary_key(self): + return self.primary_key + def get_default(self): if self.has_default: if callable(self.default): @@ -82,19 +95,17 @@ class Integer(BaseColumn): db_type = 'int' def validate(self, value): - super(Integer, self).validate(value) + val = super(Integer, self).validate(value) try: - long(value) + return long(val) except (TypeError, ValueError): raise ValidationError("{} can't be converted to integral value".format(value)) def to_python(self, value): - self.validate(value) - return long(value) + return self.validate(value) def to_database(self, value): - self.validate() - return value + return self.validate(value) class DateTime(BaseColumn): db_type = 'timestamp' @@ -107,16 +118,16 @@ class UUID(BaseColumn): re_uuid = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') - def __init__(self, **kwargs): - super(UUID, self).__init__(**kwargs) - if 'default' not in kwargs: - self.default = uuid4 + def __init__(self, default=lambda:uuid4(), **kwargs): + super(UUID, self).__init__(default=default, **kwargs) def validate(self, value): - super(UUID, self).validate(value) - if not self.re_uuid.match(value): + val = super(UUID, self).validate(value) + from uuid import UUID as _UUID + if isinstance(val, _UUID): return val + if not self.re_uuid.match(val): raise ValidationError("{} is not a valid uuid".format(value)) - return value + return _UUID(val) class Boolean(BaseColumn): db_type = 'boolean' @@ -130,11 +141,17 @@ def to_database(self, value): class Float(BaseColumn): db_type = 'double' + def validate(self, value): + try: + return float(value) + except (TypeError, ValueError): + raise ValidationError("{} is not a valid float".format(value)) + def to_python(self, value): - return float(value) + return self.validate(value) def to_database(self, value): - return float(value) + return self.validate(value) class Decimal(BaseColumn): db_type = 'decimal' diff --git a/cassandraengine/connection.py b/cassandraengine/connection.py index 27482829ff..3cebc3acbf 100644 --- a/cassandraengine/connection.py +++ b/cassandraengine/connection.py @@ -1,35 +1,44 @@ #http://pypi.python.org/pypi/cql/1.0.4 -#http://code.google.com/a/apache-extras.org/p/cassandra-dbapi2/ +#http://code.google.com/a/apache-extras.org/p/cassandra-dbapi2 / #http://cassandra.apache.org/doc/cql/CQL.html import cql -_keyspace = 'cse_test' -_col_fam = 'colfam' - -conn = cql.connect('127.0.0.1', 9160) +_keyspace = 'cassengine_test' + +_conn = None +def get_connection(): + global _conn + if _conn is None: + _conn = cql.connect('127.0.0.1', 9160) + _conn.set_cql_version('3.0.0') + try: + _conn.set_initial_keyspace(_keyspace) + except cql.ProgrammingError, e: + #http://www.datastax.com/docs/1.0/references/cql/CREATE_KEYSPACE + cur = _conn.cursor() + cur.execute("""create keyspace {} + with strategy_class = 'SimpleStrategy' + and strategy_options:replication_factor=1;""".format(_keyspace)) + _conn.set_initial_keyspace(_keyspace) + return _conn #cli examples here: #http://wiki.apache.org/cassandra/CassandraCli -try: - conn.set_initial_keyspace(_keyspace) -except cql.ProgrammingError: - #http://www.datastax.com/docs/1.0/references/cql/CREATE_KEYSPACE - cur = conn.cursor() - cur.execute("create keyspace {} with strategy_class = 'SimpleStrategy' and strategy_options:replication_factor=1;".format(_keyspace)) - conn.set_initial_keyspace(_keyspace) -cur = conn.cursor() - #http://www.datastax.com/docs/1.0/dml/using_cql #http://stackoverflow.com/questions/10871595/how-do-you-create-a-counter-columnfamily-with-cql3-in-cassandra +""" try: - cur.execute("""create table colfam (id int PRIMARY KEY);""") + cur.execute("create table colfam (id int PRIMARY KEY);") except cql.ProgrammingError: #cur.execute('drop table colfam;') pass +""" #http://www.datastax.com/docs/1.0/references/cql/INSERT #updates/inserts do the same thing + +""" cur.execute('insert into colfam (id, content) values (:id, :content)', dict(id=1, content='yo!')) cur.execute('select * from colfam WHERE id=1;') @@ -41,27 +50,8 @@ cur.execute('delete from colfam WHERE id=1;') cur.execute('delete from colfam WHERE id in (1);') +""" #TODO: Add alter altering existing schema functionality #http://www.datastax.com/docs/1.0/references/cql/ALTER_COLUMNFAMILY -def create_column_family(name): - """ - """ - -def select_row(column_family, **kwargs): - """ - """ - -def create_row(column_family, **kwargs): - """ - """ - -def update_row(column_family, **kwargs): - """ - """ - -def delete_row(column_family, **kwargs): - """ - """ - diff --git a/cassandraengine/exceptions.py b/cassandraengine/exceptions.py index 8265b0ceab..a68518875e 100644 --- a/cassandraengine/exceptions.py +++ b/cassandraengine/exceptions.py @@ -1,2 +1,4 @@ - +#cassandraengine exceptions +class ColumnFamilyException(BaseException): pass class ValidationError(BaseException): pass + From ec1a3b02f4482f674fb91faf3a5359249ed2113a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 10 Nov 2012 17:27:54 -0800 Subject: [PATCH 0007/4200] adding object manager adding queryset class with insert, select, and table create and drop --- cassandraengine/manager.py | 80 ++++++++++++++++++++++ cassandraengine/query.py | 137 +++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 cassandraengine/manager.py create mode 100644 cassandraengine/query.py diff --git a/cassandraengine/manager.py b/cassandraengine/manager.py new file mode 100644 index 0000000000..9dc501b244 --- /dev/null +++ b/cassandraengine/manager.py @@ -0,0 +1,80 @@ +#manager class + +from cassandraengine.query import QuerySet + +class Manager(object): + + def __init__(self, model): + super(Manager, self).__init__() + self.model = model + + @property + def column_family_name(self): + """ + Returns the column family name if it's been defined + otherwise, it creates it from the module and class name + """ + if self.model.db_name: + return self.model.db_name + cf_name = self.model.__module__ + '.' + self.model.__name__ + cf_name = cf_name.replace('.', '_') + #trim to less than 48 characters or cassandra will complain + cf_name = cf_name[-48:] + return cf_name + + def column_family_definition(self): + """ + Generates a definition used for tale creation + """ + + def find(self, pk): + """ + Returns the row corresponding to the primary key value given + """ + values = QuerySet(self.model).find(pk) + #change the column names to model names + #in case they are different + field_dict = {} + db_map = self.model._db_map + for key, val in values.items(): + if key in db_map: + field_dict[db_map[key]] = val + else: + field_dict[key] = val + return self.model(**field_dict) + + def all(self): + return QuerySet(self.model).all() + + def filter(self, **kwargs): + return QuerySet(self.model).filter(**kwargs) + + def exclude(self, **kwargs): + return QuerySet(self.model).exclude(**kwargs) + + def create(self, **kwargs): + return self.model(**kwargs).save() + + def _save_instance(self, instance): + """ + The business end of save, this is called by the models + save method and calls the Query save method. This should + only be called by the model saving itself + """ + QuerySet(self.model).save(instance) + + def _create_column_family(self): + QuerySet(self.model)._create_column_family() + + def _delete_column_family(self): + QuerySet(self.model)._delete_column_family() + + def delete(self, **kwargs): + pass + + def __call__(self, **kwargs): + """ + filter shortcut + """ + return self.filter(**kwargs) + diff --git a/cassandraengine/query.py b/cassandraengine/query.py new file mode 100644 index 0000000000..bb0532d86b --- /dev/null +++ b/cassandraengine/query.py @@ -0,0 +1,137 @@ +from cassandraengine.connection import get_connection + +class QuerySet(object): + #TODO: querysets should be immutable + #TODO: querysets should be executed lazily + #TODO: conflicting filter args should raise exception unless a force kwarg is supplied + + def __init__(self, model, query={}): + super(QuerySet, self).__init__() + self.model = model + self.column_family_name = self.model.objects.column_family_name + + self._cursor = None + + #----query generation / execution---- + def _execute_query(self): + pass + + def _generate_querystring(self): + pass + + @property + def cursor(self): + if self._cursor is None: + self._cursor = self._execute_query() + return self._cursor + + #----Reads------ + def __iter__(self): + pass + + def next(self): + pass + + def first(self): + conn = get_connection() + cur = conn.cursor() + pass + + def all(self): + pass + + def filter(self, **kwargs): + pass + + def exclude(self, **kwargs): + pass + + def find(self, pk): + """ + loads one document identified by it's primary key + """ + qs = 'SELECT * FROM {column_family} WHERE {pk_name}=:{pk_name}' + qs = qs.format(column_family=self.column_family_name, + pk_name=self.model._pk_name) + conn = get_connection() + cur = conn.cursor() + cur.execute(qs, {self.model._pk_name:pk}) + values = cur.fetchone() + names = [i[0] for i in cur.description] + value_dict = dict(zip(names, values)) + return value_dict + + + #----writes---- + def save(self, instance): + """ + Creates / updates a row. + This is a blind insert call. + All validation and cleaning needs to happen + prior to calling this. + """ + assert type(instance) == self.model + #organize data + value_pairs = [] + + #get pk + col = self.model._columns[self.model._pk_name] + values = instance.as_dict() + value_pairs += [(col.db_field, values.get(self.model._pk_name))] + + #get defined fields and their column names + for name, col in self.model._columns.items(): + if col.is_primary_key: continue + value_pairs += [(col.db_field, values.get(name))] + + #add dynamic fields + for key, val in values.items(): + if key in self.model._columns: continue + value_pairs += [(key, val)] + + #construct query string + field_names = zip(*value_pairs)[0] + field_values = dict(value_pairs) + qs = ["INSERT INTO {}".format(self.column_family_name)] + qs += ["({})".format(', '.join(field_names))] + qs += ['VALUES'] + qs += ["({})".format(', '.join([':'+f for f in field_names]))] + qs = ' '.join(qs) + + conn = get_connection() + cur = conn.cursor() + cur.execute(qs, field_values) + + def _create_column_family(self): + #construct query string + qs = ['CREATE TABLE {}'.format(self.column_family_name)] + + #add column types + qtypes = [] + def add_column(col): + s = '{} {}'.format(col.db_field, col.db_type) + if col.primary_key: s += ' PRIMARY KEY' + qtypes.append(s) + add_column(self.model._columns[self.model._pk_name]) + for name, col in self.model._columns.items(): + if col.primary_key: continue + add_column(col) + + qs += ['({})'.format(', '.join(qtypes))] + qs = ' '.join(qs) + + #add primary key + conn = get_connection() + cur = conn.cursor() + try: + cur.execute(qs) + except BaseException, e: + if 'Cannot add already existing column family' not in e.message: + raise + + def _delete_column_family(self): + conn = get_connection() + cur = conn.cursor() + cur.execute('drop table {};'.format(self.column_family_name)) + + From 2aaf1650f32a6e4511c5b0cdcf95e25ebcf0b175 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 10 Nov 2012 17:28:55 -0800 Subject: [PATCH 0008/4200] added some unit tests, updated the readme and added requirements.txt --- README.md | 16 ++++++++- cassandraengine/tests/base.py | 11 +++++++ cassandraengine/tests/columns/__init__.py | 0 cassandraengine/tests/model/__init__.py | 0 .../tests/model/test_class_construction.py | 33 +++++++++++++++++++ cassandraengine/tests/model/test_model_io.py | 26 +++++++++++++++ requirements.txt | 1 + 7 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 cassandraengine/tests/base.py create mode 100644 cassandraengine/tests/columns/__init__.py create mode 100644 cassandraengine/tests/model/__init__.py create mode 100644 cassandraengine/tests/model/test_class_construction.py create mode 100644 cassandraengine/tests/model/test_model_io.py diff --git a/README.md b/README.md index 869495c22b..0e174ce9ad 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,16 @@ cassandraengine -=============== \ No newline at end of file +=============== + +Django ORM / Mongoengine style ORM for Cassandra + +In it's current state you can define column families, create and delete column families +based on your model definiteions, save models and retrieve models by their primary keys. + +That's about it. Also, there are only 2 tests and the CQL stuff is very simplistic at this point. + +##TODO +* dynamic column support +* return None when row isn't found in find() +* tests +* query functionality +* nice column and model class __repr__ diff --git a/cassandraengine/tests/base.py b/cassandraengine/tests/base.py new file mode 100644 index 0000000000..ac1f31d8f1 --- /dev/null +++ b/cassandraengine/tests/base.py @@ -0,0 +1,11 @@ +from unittest import TestCase + +class BaseCassEngTestCase(TestCase): + + def assertHasAttr(self, obj, attr): + self.assertTrue(hasattr(obj, attr), + "{} doesn't have attribute: {}".format(obj, attr)) + + def assertNotHasAttr(self, obj, attr): + self.assertFalse(hasattr(obj, attr), + "{} shouldn't have the attribute: {}".format(obj, attr)) diff --git a/cassandraengine/tests/columns/__init__.py b/cassandraengine/tests/columns/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cassandraengine/tests/model/__init__.py b/cassandraengine/tests/model/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cassandraengine/tests/model/test_class_construction.py b/cassandraengine/tests/model/test_class_construction.py new file mode 100644 index 0000000000..c02fc4e2bd --- /dev/null +++ b/cassandraengine/tests/model/test_class_construction.py @@ -0,0 +1,33 @@ +from cassandraengine.tests.base import BaseCassEngTestCase + +from cassandraengine.models import Model +from cassandraengine import columns + +class TestModelClassFunction(BaseCassEngTestCase): + + def test_column_attributes_handled_correctly(self): + """ + Tests that column attributes are moved to a _columns dict + and replaced with simple value attributes + """ + + class TestModel(Model): + text = columns.Text() + + self.assertHasAttr(TestModel, '_columns') + self.assertNotHasAttr(TestModel, 'id') + self.assertNotHasAttr(TestModel, 'text') + + inst = TestModel() + self.assertHasAttr(inst, 'id') + self.assertHasAttr(inst, 'text') + self.assertIsNone(inst.id) + self.assertIsNone(inst.text) + + +class TestModelValidation(BaseCassEngTestCase): + pass + +class TestModelSerialization(BaseCassEngTestCase): + pass + diff --git a/cassandraengine/tests/model/test_model_io.py b/cassandraengine/tests/model/test_model_io.py new file mode 100644 index 0000000000..4b73c26e0c --- /dev/null +++ b/cassandraengine/tests/model/test_model_io.py @@ -0,0 +1,26 @@ +from cassandraengine.tests.base import BaseCassEngTestCase + +from cassandraengine.models import Model +from cassandraengine import columns + +class TestModel(Model): + count = columns.Integer() + text = columns.Text() + +class TestModelIO(BaseCassEngTestCase): + + def setUp(self): + super(TestModelIO, self).setUp() + TestModel.objects._create_column_family() + + def tearDown(self): + super(TestModelIO, self).tearDown() + TestModel.objects._delete_column_family() + + def test_model_save_and_load(self): + tm = TestModel.objects.create(count=8, text='123456789') + tm2 = TestModel.objects.find(tm.pk) + + for cname in tm._columns.keys(): + self.assertEquals(getattr(tm, cname), getattr(tm2, cname)) + diff --git a/requirements.txt b/requirements.txt index e69de29bb2..b6734c8844 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1 @@ +cql==1.2.0 From 18f52886e7b570ee7c1fe0dbc6782d73abe7664a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 10 Nov 2012 19:33:22 -0800 Subject: [PATCH 0009/4200] renamed some exceptions --- cassandraengine/__init__.py | 3 +++ cassandraengine/columns.py | 3 --- cassandraengine/exceptions.py | 2 +- cassandraengine/models.py | 10 ++++++---- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cassandraengine/__init__.py b/cassandraengine/__init__.py index e69de29bb2..af95d0b003 100644 --- a/cassandraengine/__init__.py +++ b/cassandraengine/__init__.py @@ -0,0 +1,3 @@ +from cassandraengine.columns import * +from cassandraengine.models import Model + diff --git a/cassandraengine/columns.py b/cassandraengine/columns.py index 7c90f5584c..747cfba97c 100644 --- a/cassandraengine/columns.py +++ b/cassandraengine/columns.py @@ -5,9 +5,6 @@ from cassandraengine.exceptions import ValidationError class BaseColumn(object): - """ - The base column - """ #the cassandra type this column maps to db_type = None diff --git a/cassandraengine/exceptions.py b/cassandraengine/exceptions.py index a68518875e..4b20f7a334 100644 --- a/cassandraengine/exceptions.py +++ b/cassandraengine/exceptions.py @@ -1,4 +1,4 @@ #cassandraengine exceptions -class ColumnFamilyException(BaseException): pass +class ModelException(BaseException): pass class ValidationError(BaseException): pass diff --git a/cassandraengine/models.py b/cassandraengine/models.py index c3fce738f1..4e5bd9d3ec 100644 --- a/cassandraengine/models.py +++ b/cassandraengine/models.py @@ -1,6 +1,6 @@ from cassandraengine import columns -from cassandraengine.exceptions import ColumnFamilyException +from cassandraengine.exceptions import ModelException from cassandraengine.manager import Manager class BaseModel(object): @@ -8,6 +8,8 @@ class BaseModel(object): The base model class, don't inherit from this, inherit from Model, defined below """ + #table names will be generated automatically from it's model name and package + #however, you can alse define them manually here db_name = None def __init__(self, **values): @@ -75,7 +77,7 @@ def __new__(cls, name, bases, attrs): if isinstance(v, columns.BaseColumn): if v.is_primary_key: if pk_name: - raise ColumnFamilyException("More than one primary key defined for {}".format(name)) + raise ModelException("More than one primary key defined for {}".format(name)) pk_name = k _columns[k] = attrs.pop(k) _columns[k].set_db_name(k) @@ -96,8 +98,8 @@ def __new__(cls, name, bases, attrs): col_names = set() for k,v in _columns.items(): if v.db_field in col_names: - raise ColumnFamilyException("{} defines the column {} more than once".format(name, v.db_field)) - col_names.add(k) + raise ModelException("{} defines the column {} more than once".format(name, v.db_field)) + col_names.add(v.db_field) #get column family name cf_name = attrs.pop('db_name', None) or name From cfebd5a32d3b0628c461d96e09c13d297c42da2d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 10 Nov 2012 19:34:16 -0800 Subject: [PATCH 0010/4200] added more tests around the metaclass behavior --- .../tests/model/test_class_construction.py | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/cassandraengine/tests/model/test_class_construction.py b/cassandraengine/tests/model/test_class_construction.py index c02fc4e2bd..cc24a8c08b 100644 --- a/cassandraengine/tests/model/test_class_construction.py +++ b/cassandraengine/tests/model/test_class_construction.py @@ -1,9 +1,13 @@ from cassandraengine.tests.base import BaseCassEngTestCase +from cassandraengine.exceptions import ModelException from cassandraengine.models import Model from cassandraengine import columns class TestModelClassFunction(BaseCassEngTestCase): + """ + Tests verifying the behavior of the Model metaclass + """ def test_column_attributes_handled_correctly(self): """ @@ -24,10 +28,33 @@ class TestModel(Model): self.assertIsNone(inst.id) self.assertIsNone(inst.text) + def test_multiple_primary_keys_fail(self): + """Test attempting to define multiple primary keys fails""" + with self.assertRaises(ModelException): + class MultiPK(Model): + id1 = columns.Integer(primary_key=True) + id2 = columns.Integer(primary_key=True) -class TestModelValidation(BaseCassEngTestCase): - pass + def test_db_map(self): + """ + Tests that the db_map is properly defined + -the db_map allows columns + """ + class WildDBNames(Model): + content = columns.Text(db_field='words_and_whatnot') + numbers = columns.Integer(db_field='integers_etc') + + db_map = WildDBNames._db_map + self.assertEquals(db_map['words_and_whatnot'], 'content') + self.assertEquals(db_map['integers_etc'], 'numbers') + + def test_attempting_to_make_duplicate_column_names_fails(self): + """ + Tests that trying to create conflicting db column names will fail + """ -class TestModelSerialization(BaseCassEngTestCase): - pass + with self.assertRaises(ModelException): + class BadNames(Model): + words = columns.Text() + content = columns.Text(db_field='words') From b6042ac57ad62d7f73600588061cb194e114ba83 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 10 Nov 2012 21:12:47 -0800 Subject: [PATCH 0011/4200] adding dynamic columns to models (which aren't working yet) working on the QuerySet class adding additional tests around model saving and loading --- cassandraengine/columns.py | 2 +- cassandraengine/models.py | 18 ++++--- cassandraengine/query.py | 54 ++++++++++++++----- .../tests/columns/test_validation.py | 16 ++++++ cassandraengine/tests/model/test_model_io.py | 45 ++++++++++++++-- .../tests/model/test_validation.py | 0 6 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 cassandraengine/tests/columns/test_validation.py create mode 100644 cassandraengine/tests/model/test_validation.py diff --git a/cassandraengine/columns.py b/cassandraengine/columns.py index 747cfba97c..aa7a4b355b 100644 --- a/cassandraengine/columns.py +++ b/cassandraengine/columns.py @@ -14,7 +14,7 @@ def __init__(self, primary_key=False, db_field=None, default=None, null=False): :param primary_key: bool flag, there can be only one primary key per doc :param db_field: the fieldname this field will map to in the database :param default: the default value, can be a value or a callable (no args) - :param null: bool, is the field nullable? + :param null: boolean, is the field nullable? """ self.primary_key = primary_key self.db_field = db_field diff --git a/cassandraengine/models.py b/cassandraengine/models.py index 4e5bd9d3ec..115008960f 100644 --- a/cassandraengine/models.py +++ b/cassandraengine/models.py @@ -25,10 +25,6 @@ def __init__(self, **values): if k not in values: setattr(self, k, None) - @classmethod - def _column_family_definition(cls): - pass - @classmethod def find(cls, pk): """ Loads a document by it's primary key """ @@ -39,6 +35,16 @@ def pk(self): """ Returns the object's primary key, regardless of it's name """ return getattr(self, self._pk_name) + #dynamic column methods + def __getitem__(self, key): + return self._dynamic_columns[key] + + def __setitem__(self, key, val): + self._dynamic_columns[key] = val + + def __delitem__(self, key): + del self._dynamic_columns[key] + def validate(self): """ Cleans and validates the field values """ for name, col in self._columns.items(): @@ -47,11 +53,9 @@ def validate(self): def as_dict(self): """ Returns a map of column names to cleaned values """ - values = {} + values = self._dynamic_columns or {} for name, col in self._columns.items(): values[name] = col.to_database(getattr(self, name, None)) - - #TODO: merge in dynamic columns return values def save(self): diff --git a/cassandraengine/query.py b/cassandraengine/query.py index bb0532d86b..76149f633a 100644 --- a/cassandraengine/query.py +++ b/cassandraengine/query.py @@ -1,3 +1,5 @@ +import copy + from cassandraengine.connection import get_connection class QuerySet(object): @@ -5,16 +7,18 @@ class QuerySet(object): #TODO: querysets should be executed lazily #TODO: conflicting filter args should raise exception unless a force kwarg is supplied - def __init__(self, model, query={}): + def __init__(self, model, query_args={}): super(QuerySet, self).__init__() self.model = model + self.query_args = query_args self.column_family_name = self.model.objects.column_family_name self._cursor = None #----query generation / execution---- def _execute_query(self): - pass + conn = get_connection() + self._cursor = conn.cursor() def _generate_querystring(self): pass @@ -27,39 +31,61 @@ def cursor(self): #----Reads------ def __iter__(self): - pass + if self._cursor is None: + self._execute_query() + return self + + def _get_next(self): + """ + Gets the next cursor result + Returns a db_field->value dict + """ + cur = self._cursor + values = cur.fetchone() + if values is None: return None + names = [i[0] for i in cur.description] + value_dict = dict(zip(names, values)) + return value_dict def next(self): - pass + values = self._get_next() + if values is None: raise StopIteration + return values def first(self): - conn = get_connection() - cur = conn.cursor() pass def all(self): - pass + return QuerySet(self.model) def filter(self, **kwargs): - pass + qargs = copy.deepcopy(self.query_args) + qargs.update(kwargs) + return QuerySet(self.model, query_args=qargs) def exclude(self, **kwargs): + """ + Need to invert the logic for all kwargs + """ pass + def count(self): + """ + Returns the number of rows matched by this query + """ + def find(self, pk): """ loads one document identified by it's primary key """ + #TODO: make this a convenience wrapper of the filter method qs = 'SELECT * FROM {column_family} WHERE {pk_name}=:{pk_name}' qs = qs.format(column_family=self.column_family_name, pk_name=self.model._pk_name) conn = get_connection() - cur = conn.cursor() - cur.execute(qs, {self.model._pk_name:pk}) - values = cur.fetchone() - names = [i[0] for i in cur.description] - value_dict = dict(zip(names, values)) - return value_dict + self._cursor = conn.cursor() + self._cursor.execute(qs, {self.model._pk_name:pk}) + return self._get_next() #----writes---- diff --git a/cassandraengine/tests/columns/test_validation.py b/cassandraengine/tests/columns/test_validation.py new file mode 100644 index 0000000000..e42519cc3b --- /dev/null +++ b/cassandraengine/tests/columns/test_validation.py @@ -0,0 +1,16 @@ +#tests the behavior of the column classes + +from cassandraengine.tests.base import BaseCassEngTestCase + +from cassandraengine.columns import BaseColumn +from cassandraengine.columns import Bytes +from cassandraengine.columns import Ascii +from cassandraengine.columns import Text +from cassandraengine.columns import Integer +from cassandraengine.columns import DateTime +from cassandraengine.columns import UUID +from cassandraengine.columns import Boolean +from cassandraengine.columns import Float +from cassandraengine.columns import Decimal + + diff --git a/cassandraengine/tests/model/test_model_io.py b/cassandraengine/tests/model/test_model_io.py index 4b73c26e0c..7680a7897d 100644 --- a/cassandraengine/tests/model/test_model_io.py +++ b/cassandraengine/tests/model/test_model_io.py @@ -1,3 +1,4 @@ +from unittest import skip from cassandraengine.tests.base import BaseCassEngTestCase from cassandraengine.models import Model @@ -7,20 +8,56 @@ class TestModel(Model): count = columns.Integer() text = columns.Text() +#class TestModel2(Model): + class TestModelIO(BaseCassEngTestCase): def setUp(self): super(TestModelIO, self).setUp() TestModel.objects._create_column_family() - def tearDown(self): - super(TestModelIO, self).tearDown() - TestModel.objects._delete_column_family() - def test_model_save_and_load(self): + """ + Tests that models can be saved and retrieved + """ tm = TestModel.objects.create(count=8, text='123456789') tm2 = TestModel.objects.find(tm.pk) for cname in tm._columns.keys(): self.assertEquals(getattr(tm, cname), getattr(tm2, cname)) + def test_model_updating_works_properly(self): + """ + Tests that subsequent saves after initial model creation work + """ + tm = TestModel.objects.create(count=8, text='123456789') + + tm.count = 100 + tm.save() + + tm2 = TestModel.objects.find(tm.pk) + self.assertEquals(tm.count, tm2.count) + + def test_nullable_columns_are_saved_properly(self): + """ + Tests that nullable columns save without any trouble + """ + + @skip + def test_dynamic_columns(self): + """ + Tests that items put into dynamic columns are saved and retrieved properly + + Note: seems I've misunderstood how arbitrary column names work in Cassandra + skipping for now + """ + #TODO:Fix this + tm = TestModel(count=8, text='123456789') + tm['other'] = 'something' + tm['number'] = 5 + tm.save() + + tm2 = TestModel.objects.find(tm.pk) + self.assertEquals(tm['other'], tm2['other']) + self.assertEquals(tm['number'], tm2['number']) + diff --git a/cassandraengine/tests/model/test_validation.py b/cassandraengine/tests/model/test_validation.py new file mode 100644 index 0000000000..e69de29bb2 From 4764b492eb44b1a2c3bcdc38b80b71704c39bb58 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 11 Nov 2012 11:43:05 -0800 Subject: [PATCH 0012/4200] adding single instance delete method and supporting unit test adding column type stubs updating readme --- README.md | 12 ++++++-- cassandraengine/columns.py | 24 +++++++++++++++- cassandraengine/manager.py | 30 +++++++++++--------- cassandraengine/models.py | 3 +- cassandraengine/query.py | 28 +++++++++++++----- cassandraengine/tests/model/test_model_io.py | 9 ++++++ 6 files changed, 81 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0e174ce9ad..136523416c 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ cassandraengine =============== -Django ORM / Mongoengine style ORM for Cassandra +Python Cassandra ORM in the style of django / mongoengine In it's current state you can define column families, create and delete column families based on your model definiteions, save models and retrieve models by their primary keys. -That's about it. Also, there are only 2 tests and the CQL stuff is very simplistic at this point. +That's about it. Also, the CQL stuff is pretty simple at this point. ##TODO +* Complex queries (class Q(object)) +* Match column names to mongoengine field names? +* mongoengine fields? URLField, EmbeddedDocument, ListField, DictField +* column ttl? +* ForeignKey/DBRef fields? * dynamic column support -* return None when row isn't found in find() * tests * query functionality * nice column and model class __repr__ + + diff --git a/cassandraengine/columns.py b/cassandraengine/columns.py index aa7a4b355b..67f697ea25 100644 --- a/cassandraengine/columns.py +++ b/cassandraengine/columns.py @@ -106,6 +106,9 @@ def to_database(self, value): class DateTime(BaseColumn): db_type = 'timestamp' + def __init__(self, **kwargs): + super(DateTime, self).__init__(**kwargs) + raise NotImplementedError class UUID(BaseColumn): """ @@ -153,4 +156,23 @@ def to_database(self, value): class Decimal(BaseColumn): db_type = 'decimal' #TODO: this - + def __init__(self, **kwargs): + super(DateTime, self).__init__(**kwargs) + raise NotImplementedError + +class Counter(BaseColumn): + def __init__(self, **kwargs): + super(DateTime, self).__init__(**kwargs) + raise NotImplementedError + +#TODO: research supercolumns +#http://wiki.apache.org/cassandra/DataModel +class List(BaseColumn): + def __init__(self, **kwargs): + super(DateTime, self).__init__(**kwargs) + raise NotImplementedError + +class Dict(BaseColumn): + def __init__(self, **kwargs): + super(DateTime, self).__init__(**kwargs) + raise NotImplementedError diff --git a/cassandraengine/manager.py b/cassandraengine/manager.py index 9dc501b244..3f7ec7f66e 100644 --- a/cassandraengine/manager.py +++ b/cassandraengine/manager.py @@ -21,17 +21,20 @@ def column_family_name(self): #trim to less than 48 characters or cassandra will complain cf_name = cf_name[-48:] return cf_name - - def column_family_definition(self): + + def __call__(self, **kwargs): """ - Generates a definition used for tale creation + filter shortcut """ + return self.filter(**kwargs) def find(self, pk): """ Returns the row corresponding to the primary key value given """ values = QuerySet(self.model).find(pk) + if values is None: return + #change the column names to model names #in case they are different field_dict = {} @@ -55,6 +58,10 @@ def exclude(self, **kwargs): def create(self, **kwargs): return self.model(**kwargs).save() + def delete(self, **kwargs): + pass + + #----single instance methods---- def _save_instance(self, instance): """ The business end of save, this is called by the models @@ -63,18 +70,15 @@ def _save_instance(self, instance): """ QuerySet(self.model).save(instance) + def _delete_instance(self, instance): + """ + Deletes a single instance + """ + QuerySet(self.model).delete_instance(instance) + + #----column family create/delete---- def _create_column_family(self): QuerySet(self.model)._create_column_family() def _delete_column_family(self): QuerySet(self.model)._delete_column_family() - - def delete(self, **kwargs): - pass - - def __call__(self, **kwargs): - """ - filter shortcut - """ - return self.filter(**kwargs) - diff --git a/cassandraengine/models.py b/cassandraengine/models.py index 115008960f..040682d4f8 100644 --- a/cassandraengine/models.py +++ b/cassandraengine/models.py @@ -65,7 +65,8 @@ def save(self): return self def delete(self): - pass + """ Deletes this instance """ + self.objects._delete_instance(self) class ModelMetaClass(type): diff --git a/cassandraengine/query.py b/cassandraengine/query.py index 76149f633a..025d135ab6 100644 --- a/cassandraengine/query.py +++ b/cassandraengine/query.py @@ -42,7 +42,7 @@ def _get_next(self): """ cur = self._cursor values = cur.fetchone() - if values is None: return None + if values is None: return names = [i[0] for i in cur.description] value_dict = dict(zip(names, values)) return value_dict @@ -64,15 +64,12 @@ def filter(self, **kwargs): return QuerySet(self.model, query_args=qargs) def exclude(self, **kwargs): - """ - Need to invert the logic for all kwargs - """ + """ Need to invert the logic for all kwargs """ pass def count(self): - """ - Returns the number of rows matched by this query - """ + """ Returns the number of rows matched by this query """ + qs = 'SELECT COUNT(*) FROM {}'.format(self.column_family_name) def find(self, pk): """ @@ -128,6 +125,23 @@ def save(self, instance): cur = conn.cursor() cur.execute(qs, field_values) + #----delete--- + def delete(self): + """ + Deletes the contents of a query + """ + + def delete_instance(self, instance): + """ Deletes one instance """ + pk_name = self.model._pk_name + qs = ['DELETE FROM {}'.format(self.column_family_name)] + qs += ['WHERE {0}=:{0}'.format(pk_name)] + qs = ' '.join(qs) + + conn = get_connection() + cur = conn.cursor() + cur.execute(qs, {pk_name:instance.pk}) + def _create_column_family(self): #construct query string qs = ['CREATE TABLE {}'.format(self.column_family_name)] diff --git a/cassandraengine/tests/model/test_model_io.py b/cassandraengine/tests/model/test_model_io.py index 7680a7897d..8adf46913c 100644 --- a/cassandraengine/tests/model/test_model_io.py +++ b/cassandraengine/tests/model/test_model_io.py @@ -38,6 +38,15 @@ def test_model_updating_works_properly(self): tm2 = TestModel.objects.find(tm.pk) self.assertEquals(tm.count, tm2.count) + def test_model_deleting_works_properly(self): + """ + Tests that an instance's delete method deletes the instance + """ + tm = TestModel.objects.create(count=8, text='123456789') + tm.delete() + tm2 = TestModel.objects.find(tm.pk) + self.assertIsNone(tm2) + def test_nullable_columns_are_saved_properly(self): """ Tests that nullable columns save without any trouble From 142ceb184cbcea853182e6a20511a8c8b17cc6aa Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 11 Nov 2012 16:37:26 -0800 Subject: [PATCH 0013/4200] modified model metaclass to replace column definitions with properties that modify the columns value member instead of removing them until instantiation --- cassandraengine/columns.py | 45 +++++++++++++++---- cassandraengine/models.py | 27 ++++++----- .../tests/model/test_class_construction.py | 4 +- 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/cassandraengine/columns.py b/cassandraengine/columns.py index 67f697ea25..20f54ba8d7 100644 --- a/cassandraengine/columns.py +++ b/cassandraengine/columns.py @@ -21,6 +21,8 @@ def __init__(self, primary_key=False, db_field=None, default=None, null=False): self.default = default self.null = null + self.value = None + def validate(self, value): """ Returns a cleaned and validated value. Raises a ValidationError @@ -40,6 +42,14 @@ def to_python(self, value): """ return value + def to_database(self, value): + """ + Converts python value into database value + """ + if value is None and self.has_default: + return self.get_default() + return value + @property def has_default(self): return bool(self.default) @@ -48,6 +58,33 @@ def has_default(self): def is_primary_key(self): return self.primary_key + #methods for replacing column definitions with properties that interact + #with a column's value member + #this will allow putting logic behind value access (lazy loading, etc) + def _getval(self): + """ This columns value getter """ + return self.value + + def _setval(self, val): + """ This columns value setter """ + self.value = val + + def _delval(self): + """ This columns value deleter """ + raise NotImplementedError + + def get_property(self, allow_delete=True): + """ + Returns the property object that will set and get this + column's value and be assigned to this column's model attribute + """ + getval = lambda slf: self._getval() + setval = lambda slf, val: self._setval(val) + delval = lambda slf: self._delval() + if not allow_delete: + return property(getval, setval) + return property(getval, setval, delval) + def get_default(self): if self.has_default: if callable(self.default): @@ -55,14 +92,6 @@ def get_default(self): else: return self.default - def to_database(self, value): - """ - Converts python value into database value - """ - if value is None and self.has_default: - return self.get_default() - return value - def get_column_def(self): """ Returns a column definition for CQL table definition diff --git a/cassandraengine/models.py b/cassandraengine/models.py index 040682d4f8..3d1388f946 100644 --- a/cassandraengine/models.py +++ b/cassandraengine/models.py @@ -32,7 +32,7 @@ def find(cls, pk): @property def pk(self): - """ Returns the object's primary key, regardless of it's name """ + """ Returns the object's primary key """ return getattr(self, self._pk_name) #dynamic column methods @@ -78,26 +78,31 @@ def __new__(cls, name, bases, attrs): #and set default column names _columns = {} pk_name = None + + def _transform_column(col_name, col_obj): + _columns[col_name] = col_obj + col_obj.set_db_name(col_name) + allow_delete = not col_obj.primary_key + attrs[col_name] = col_obj.get_property(allow_delete=allow_delete) + + #transform column definitions for k,v in attrs.items(): if isinstance(v, columns.BaseColumn): if v.is_primary_key: if pk_name: raise ModelException("More than one primary key defined for {}".format(name)) pk_name = k - _columns[k] = attrs.pop(k) - _columns[k].set_db_name(k) + _transform_column(k,v) #set primary key if it's not already defined if not pk_name: - _columns['id'] = columns.UUID(primary_key=True) - _columns['id'].set_db_name('id') - pk_name = 'id' + k,v = 'id', columns.UUID(primary_key=True) + _transform_column(k,v) + pk_name = k - #setup pk shortcut + #setup primary key shortcut if pk_name != 'pk': - pk_get = lambda self: getattr(self, pk_name) - pk_set = lambda self, val: setattr(self, pk_name, val) - attrs['pk'] = property(pk_get, pk_set) + attrs['pk'] = _columns[pk_name].get_property(allow_delete=False) #check for duplicate column names col_names = set() @@ -107,7 +112,7 @@ def __new__(cls, name, bases, attrs): col_names.add(v.db_field) #get column family name - cf_name = attrs.pop('db_name', None) or name + cf_name = attrs.pop('db_name', name) #create db_name -> model name map for loading db_map = {} diff --git a/cassandraengine/tests/model/test_class_construction.py b/cassandraengine/tests/model/test_class_construction.py index cc24a8c08b..5e3dbe1968 100644 --- a/cassandraengine/tests/model/test_class_construction.py +++ b/cassandraengine/tests/model/test_class_construction.py @@ -19,8 +19,8 @@ class TestModel(Model): text = columns.Text() self.assertHasAttr(TestModel, '_columns') - self.assertNotHasAttr(TestModel, 'id') - self.assertNotHasAttr(TestModel, 'text') + self.assertHasAttr(TestModel, 'id') + self.assertHasAttr(TestModel, 'text') inst = TestModel() self.assertHasAttr(inst, 'id') From 83cf23da7c9f0dfc3bc36884c69ab9e334ea8d12 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 11 Nov 2012 22:14:40 -0800 Subject: [PATCH 0014/4200] cleaning things up a bit --- README.md | 9 ++++----- cassandraengine/columns.py | 5 +++++ cassandraengine/manager.py | 8 ++------ cassandraengine/models.py | 2 ++ cassandraengine/query.py | 11 +++++++++++ 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 136523416c..b3349e0d80 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,21 @@ cassandraengine =============== -Python Cassandra ORM in the style of django / mongoengine +Cassandra ORM for Python in the style of the Django orm and mongoengine In it's current state you can define column families, create and delete column families based on your model definiteions, save models and retrieve models by their primary keys. -That's about it. Also, the CQL stuff is pretty simple at this point. +That's about it. Also, the CQL stuff is very basic at this point. ##TODO -* Complex queries (class Q(object)) -* Match column names to mongoengine field names? +* Real querying * mongoengine fields? URLField, EmbeddedDocument, ListField, DictField * column ttl? * ForeignKey/DBRef fields? * dynamic column support -* tests * query functionality +* Match column names to mongoengine field names? * nice column and model class __repr__ diff --git a/cassandraengine/columns.py b/cassandraengine/columns.py index 20f54ba8d7..22a533c6d4 100644 --- a/cassandraengine/columns.py +++ b/cassandraengine/columns.py @@ -197,11 +197,16 @@ def __init__(self, **kwargs): #TODO: research supercolumns #http://wiki.apache.org/cassandra/DataModel class List(BaseColumn): + #checkout cql.cqltypes.ListType def __init__(self, **kwargs): super(DateTime, self).__init__(**kwargs) raise NotImplementedError class Dict(BaseColumn): + #checkout cql.cqltypes.MapType def __init__(self, **kwargs): super(DateTime, self).__init__(**kwargs) raise NotImplementedError + +#checkout cql.cqltypes.SetType +#checkout cql.cqltypes.CompositeType diff --git a/cassandraengine/manager.py b/cassandraengine/manager.py index 3f7ec7f66e..de061ce0fb 100644 --- a/cassandraengine/manager.py +++ b/cassandraengine/manager.py @@ -23,9 +23,7 @@ def column_family_name(self): return cf_name def __call__(self, **kwargs): - """ - filter shortcut - """ + """ filter shortcut """ return self.filter(**kwargs) def find(self, pk): @@ -71,9 +69,7 @@ def _save_instance(self, instance): QuerySet(self.model).save(instance) def _delete_instance(self, instance): - """ - Deletes a single instance - """ + """ Deletes a single instance """ QuerySet(self.model).delete_instance(instance) #----column family create/delete---- diff --git a/cassandraengine/models.py b/cassandraengine/models.py index 3d1388f946..fdc965581d 100644 --- a/cassandraengine/models.py +++ b/cassandraengine/models.py @@ -119,11 +119,13 @@ def _transform_column(col_name, col_obj): for name, col in _columns.items(): db_map[col.db_field] = name + #add management members to the class attrs['_columns'] = _columns attrs['_db_map'] = db_map attrs['_pk_name'] = pk_name attrs['_dynamic_columns'] = {} + #create the class and add a manager to it klass = super(ModelMetaClass, cls).__new__(cls, name, bases, attrs) klass.objects = Manager(klass) return klass diff --git a/cassandraengine/query.py b/cassandraengine/query.py index 025d135ab6..817b03960e 100644 --- a/cassandraengine/query.py +++ b/cassandraengine/query.py @@ -2,11 +2,22 @@ from cassandraengine.connection import get_connection +#CQL 3 reference: +#http://www.datastax.com/docs/1.1/references/cql/index + +class Query(object): + + pass + class QuerySet(object): #TODO: querysets should be immutable #TODO: querysets should be executed lazily #TODO: conflicting filter args should raise exception unless a force kwarg is supplied + #CQL supports ==, >, >=, <, <=, IN (a,b,c,..n) + #REVERSE, LIMIT + #ORDER BY + def __init__(self, model, query_args={}): super(QuerySet, self).__init__() self.model = model From b8103dd33bd29c83572cdf013759a865bf227437 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 12 Nov 2012 21:40:34 -0800 Subject: [PATCH 0015/4200] removed restriction on multiple primary keys reworked metaclass to preserve column definition order adjusted primary key declaration in table definition cql removed dynamic columns --- README.md | 3 -- cassandraengine/columns.py | 12 +++++- cassandraengine/models.py | 39 +++++++------------ cassandraengine/query.py | 8 ++-- .../tests/model/test_class_construction.py | 19 +++++---- cassandraengine/tests/model/test_model_io.py | 18 --------- 6 files changed, 41 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index b3349e0d80..cc2d2fc976 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,8 @@ based on your model definiteions, save models and retrieve models by their prima That's about it. Also, the CQL stuff is very basic at this point. ##TODO -* Real querying -* mongoengine fields? URLField, EmbeddedDocument, ListField, DictField * column ttl? * ForeignKey/DBRef fields? -* dynamic column support * query functionality * Match column names to mongoengine field names? * nice column and model class __repr__ diff --git a/cassandraengine/columns.py b/cassandraengine/columns.py index 22a533c6d4..bcbf39e7f5 100644 --- a/cassandraengine/columns.py +++ b/cassandraengine/columns.py @@ -9,6 +9,8 @@ class BaseColumn(object): #the cassandra type this column maps to db_type = None + instance_counter = 0 + def __init__(self, primary_key=False, db_field=None, default=None, null=False): """ :param primary_key: bool flag, there can be only one primary key per doc @@ -23,6 +25,10 @@ def __init__(self, primary_key=False, db_field=None, default=None, null=False): self.value = None + #keep track of instantiation order + self.position = BaseColumn.instance_counter + BaseColumn.instance_counter += 1 + def validate(self, value): """ Returns a cleaned and validated value. Raises a ValidationError @@ -97,8 +103,8 @@ def get_column_def(self): Returns a column definition for CQL table definition """ dterms = [self.db_field, self.db_type] - if self.primary_key: - dterms.append('PRIMARY KEY') + #if self.primary_key: + #dterms.append('PRIMARY KEY') return ' '.join(dterms) def set_db_name(self, name): @@ -196,6 +202,8 @@ def __init__(self, **kwargs): #TODO: research supercolumns #http://wiki.apache.org/cassandra/DataModel +#checkout composite columns: +#http://www.datastax.com/dev/blog/introduction-to-composite-columns-part-1 class List(BaseColumn): #checkout cql.cqltypes.ListType def __init__(self, **kwargs): diff --git a/cassandraengine/models.py b/cassandraengine/models.py index fdc965581d..76f3d99cd6 100644 --- a/cassandraengine/models.py +++ b/cassandraengine/models.py @@ -1,3 +1,4 @@ +from collections import OrderedDict from cassandraengine import columns from cassandraengine.exceptions import ModelException @@ -17,8 +18,6 @@ def __init__(self, **values): for k,v in values.items(): if k in self._columns: setattr(self, k, v) - else: - self._dynamic_columns[k] = v #set excluded columns to None for k in self._columns.keys(): @@ -35,16 +34,6 @@ def pk(self): """ Returns the object's primary key """ return getattr(self, self._pk_name) - #dynamic column methods - def __getitem__(self, key): - return self._dynamic_columns[key] - - def __setitem__(self, key, val): - self._dynamic_columns[key] = val - - def __delitem__(self, key): - del self._dynamic_columns[key] - def validate(self): """ Cleans and validates the field values """ for name, col in self._columns.items(): @@ -76,7 +65,7 @@ def __new__(cls, name, bases, attrs): """ #move column definitions into _columns dict #and set default column names - _columns = {} + _columns = OrderedDict() pk_name = None def _transform_column(col_name, col_obj): @@ -85,20 +74,20 @@ def _transform_column(col_name, col_obj): allow_delete = not col_obj.primary_key attrs[col_name] = col_obj.get_property(allow_delete=allow_delete) - #transform column definitions - for k,v in attrs.items(): - if isinstance(v, columns.BaseColumn): - if v.is_primary_key: - if pk_name: - raise ModelException("More than one primary key defined for {}".format(name)) - pk_name = k - _transform_column(k,v) - - #set primary key if it's not already defined - if not pk_name: + #import ipdb; ipdb.set_trace() + column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.BaseColumn)] + column_definitions = sorted(column_definitions, lambda x,y: cmp(x[1].position, y[1].position)) + + #prepend primary key if none has been defined + if not any([v.primary_key for k,v in column_definitions]): k,v = 'id', columns.UUID(primary_key=True) + column_definitions = [(k,v)] + column_definitions + + #transform column definitions + for k,v in column_definitions: + if pk_name is None and v.primary_key: + pk_name = k _transform_column(k,v) - pk_name = k #setup primary key shortcut if pk_name != 'pk': diff --git a/cassandraengine/query.py b/cassandraengine/query.py index 817b03960e..8cd7328519 100644 --- a/cassandraengine/query.py +++ b/cassandraengine/query.py @@ -158,16 +158,18 @@ def _create_column_family(self): qs = ['CREATE TABLE {}'.format(self.column_family_name)] #add column types + pkeys = [] qtypes = [] def add_column(col): s = '{} {}'.format(col.db_field, col.db_type) - if col.primary_key: s += ' PRIMARY KEY' + if col.primary_key: pkeys.append(col.db_field) qtypes.append(s) - add_column(self.model._columns[self.model._pk_name]) + #add_column(self.model._columns[self.model._pk_name]) for name, col in self.model._columns.items(): - if col.primary_key: continue add_column(col) + qtypes.append('PRIMARY KEY ({})'.format(', '.join(pkeys))) + qs += ['({})'.format(', '.join(qtypes))] qs = ' '.join(qs) diff --git a/cassandraengine/tests/model/test_class_construction.py b/cassandraengine/tests/model/test_class_construction.py index 5e3dbe1968..7da6f1e92a 100644 --- a/cassandraengine/tests/model/test_class_construction.py +++ b/cassandraengine/tests/model/test_class_construction.py @@ -28,13 +28,6 @@ class TestModel(Model): self.assertIsNone(inst.id) self.assertIsNone(inst.text) - def test_multiple_primary_keys_fail(self): - """Test attempting to define multiple primary keys fails""" - with self.assertRaises(ModelException): - class MultiPK(Model): - id1 = columns.Integer(primary_key=True) - id2 = columns.Integer(primary_key=True) - def test_db_map(self): """ Tests that the db_map is properly defined @@ -58,3 +51,15 @@ class BadNames(Model): words = columns.Text() content = columns.Text(db_field='words') + def test_column_ordering_is_preserved(self): + """ + Tests that the _columns dics retains the ordering of the class definition + """ + + class Stuff(Model): + words = columns.Text() + content = columns.Text() + numbers = columns.Integer() + + self.assertEquals(Stuff._columns.keys(), ['id', 'words', 'content', 'numbers']) + diff --git a/cassandraengine/tests/model/test_model_io.py b/cassandraengine/tests/model/test_model_io.py index 8adf46913c..788b36bb92 100644 --- a/cassandraengine/tests/model/test_model_io.py +++ b/cassandraengine/tests/model/test_model_io.py @@ -52,21 +52,3 @@ def test_nullable_columns_are_saved_properly(self): Tests that nullable columns save without any trouble """ - @skip - def test_dynamic_columns(self): - """ - Tests that items put into dynamic columns are saved and retrieved properly - - Note: seems I've misunderstood how arbitrary column names work in Cassandra - skipping for now - """ - #TODO:Fix this - tm = TestModel(count=8, text='123456789') - tm['other'] = 'something' - tm['number'] = 5 - tm.save() - - tm2 = TestModel.objects.find(tm.pk) - self.assertEquals(tm['other'], tm2['other']) - self.assertEquals(tm['number'], tm2['number']) - From fc1de7259604dbb973903fd58fba332790ad9c43 Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Mon, 12 Nov 2012 22:04:12 -0800 Subject: [PATCH 0016/4200] Add Vagrantfile - Add complete virtual environment with Python 2.7 and Cassandra 1.1.6 for testing. --- Vagrantfile | 46 ++++++++++++++++ manifests/default.pp | 66 +++++++++++++++++++++++ modules/cassandra/files/cassandra.upstart | 18 +++++++ 3 files changed, 130 insertions(+) create mode 100644 Vagrantfile create mode 100644 manifests/default.pp create mode 100644 modules/cassandra/files/cassandra.upstart diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000000..6d8ccc8ad1 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,46 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +Vagrant::Config.run do |config| + # All Vagrant configuration is done here. The most common configuration + # options are documented and commented below. For a complete reference, + # please see the online documentation at vagrantup.com. + + # Every Vagrant virtual environment requires a box to build off of. + config.vm.box = "precise" + + # The url from where the 'config.vm.box' box will be fetched if it + # doesn't already exist on the user's system. + config.vm.box_url = "https://s3-us-west-2.amazonaws.com/graphplatform.swmirror/precise64.box" + + # Boot with a GUI so you can see the screen. (Default is headless) + # config.vm.boot_mode = :gui + + # Assign this VM to a host-only network IP, allowing you to access it + # via the IP. Host-only networks can talk to the host machine as well as + # any other machines on the same network, but cannot be accessed (through this + # network interface) by any external networks. + config.vm.network :hostonly, "192.168.33.10" + + config.vm.customize ["modifyvm", :id, "--memory", "2048"] + + # Forward a port from the guest to the host, which allows for outside + # computers to access the VM, whereas host only networking does not. + config.vm.forward_port 80, 8080 + + # Share an additional folder to the guest VM. The first argument is + # an identifier, the second is the path on the guest to mount the + # folder, and the third is the path on the host to the actual folder. + # config.vm.share_folder "v-data", "/vagrant_data", "../data" + #config.vm.share_folder "v-root" "/vagrant", ".", :nfs => true + + # Provision with puppet + config.vm.provision :shell, :inline => "apt-get update" + + config.vm.provision :puppet, :options => ['--verbose', '--debug'] do |puppet| + puppet.facter = {'hostname' => 'cassandraengine'} + # puppet.manifests_path = "puppet/manifests" + # puppet.manifest_file = "site.pp" + puppet.module_path = "modules" + end +end \ No newline at end of file diff --git a/manifests/default.pp b/manifests/default.pp new file mode 100644 index 0000000000..1cf748cb71 --- /dev/null +++ b/manifests/default.pp @@ -0,0 +1,66 @@ +# Basic virtualbox configuration +Exec { path => "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } + +node basenode { + package{["build-essential", "git-core", "vim"]: + ensure => installed + } +} + +class xfstools { + package{['lvm2', 'xfsprogs']: + ensure => installed + } +} +class java { + package {['openjdk-7-jre-headless']: + ensure => installed + } +} + +class cassandra { + include xfstools + include java + + package {"wget": + ensure => latest + } + + file {"/etc/init/cassandra.conf": + source => "puppet:///modules/cassandra/cassandra.upstart", + owner => root + } + + exec {"download-cassandra": + cwd => "/tmp", + command => "wget http://download.nextag.com/apache/cassandra/1.1.6/apache-cassandra-1.1.6-bin.tar.gz", + creates => "/tmp/apache-cassandra-1.1.6-bin.tar.gz", + require => [Package["wget"], File["/etc/init/cassandra.conf"]] + } + + exec {"install-cassandra": + cwd => "/tmp", + command => "tar -xzf apache-cassandra-1.1.6-bin.tar.gz; mv apache-cassandra-1.1.6 /usr/local/cassandra", + require => Exec["download-cassandra"], + creates => "/usr/local/cassandra/bin/cassandra" + } + + service {"cassandra": + ensure => running, + require => Exec["install-cassandra"] + } +} + +node cassandraengine inherits basenode { + include cassandra + + package {["python-pip", "python-dev"]: + ensure => installed + } + + exec {"install-requirements": + cwd => "/vagrant", + command => "pip install -r requirements.txt", + require => [Package["python-pip"], Package["python-dev"]] + } +} diff --git a/modules/cassandra/files/cassandra.upstart b/modules/cassandra/files/cassandra.upstart new file mode 100644 index 0000000000..05278cad04 --- /dev/null +++ b/modules/cassandra/files/cassandra.upstart @@ -0,0 +1,18 @@ +# Cassandra Upstart +# + +description "Cassandra" + +start on runlevel [2345] +stop on runlevel [!2345] + +respawn + +exec /usr/local/cassandra/bin/cassandra -f + + + +pre-stop script + kill `cat /tmp/cassandra.pid` + sleep 3 +end script \ No newline at end of file From fd881636ea01d58673d12389fe98df925bad6eb1 Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Mon, 12 Nov 2012 22:04:41 -0800 Subject: [PATCH 0017/4200] Add Ignore For Vagrant - Add ignore for .vagrant file --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f24cd9952d..878f8ebd3c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ var sdist develop-eggs .installed.cfg - +.vagrant # Installer logs pip-log.txt From 718003362102c078d608afbc6ccb3fb740d79e0b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 13 Nov 2012 21:17:21 -0800 Subject: [PATCH 0018/4200] fixed a problem with the value management of instances --- cassandraengine/models.py | 7 +++--- .../tests/model/test_class_construction.py | 22 ++++++++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/cassandraengine/models.py b/cassandraengine/models.py index 76f3d99cd6..2b425bbc8e 100644 --- a/cassandraengine/models.py +++ b/cassandraengine/models.py @@ -71,10 +71,7 @@ def __new__(cls, name, bases, attrs): def _transform_column(col_name, col_obj): _columns[col_name] = col_obj col_obj.set_db_name(col_name) - allow_delete = not col_obj.primary_key - attrs[col_name] = col_obj.get_property(allow_delete=allow_delete) - #import ipdb; ipdb.set_trace() column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.BaseColumn)] column_definitions = sorted(column_definitions, lambda x,y: cmp(x[1].position, y[1].position)) @@ -91,7 +88,9 @@ def _transform_column(col_name, col_obj): #setup primary key shortcut if pk_name != 'pk': - attrs['pk'] = _columns[pk_name].get_property(allow_delete=False) + pk_get = lambda self: getattr(self, pk_name) + pk_set = lambda self, val: setattr(self, pk_name, val) + attrs['pk'] = property(pk_get, pk_set) #check for duplicate column names col_names = set() diff --git a/cassandraengine/tests/model/test_class_construction.py b/cassandraengine/tests/model/test_class_construction.py index 7da6f1e92a..b1659fa122 100644 --- a/cassandraengine/tests/model/test_class_construction.py +++ b/cassandraengine/tests/model/test_class_construction.py @@ -18,10 +18,12 @@ def test_column_attributes_handled_correctly(self): class TestModel(Model): text = columns.Text() + #check class attributes self.assertHasAttr(TestModel, '_columns') - self.assertHasAttr(TestModel, 'id') + self.assertNotHasAttr(TestModel, 'id') self.assertHasAttr(TestModel, 'text') + #check instance attributes inst = TestModel() self.assertHasAttr(inst, 'id') self.assertHasAttr(inst, 'text') @@ -63,3 +65,21 @@ class Stuff(Model): self.assertEquals(Stuff._columns.keys(), ['id', 'words', 'content', 'numbers']) + def test_value_managers_are_keeping_model_instances_isolated(self): + """ + Tests that instance value managers are isolated from other instances + """ + class Stuff(Model): + num = columns.Integer() + + inst1 = Stuff(num=5) + inst2 = Stuff(num=7) + + self.assertNotEquals(inst1.num, inst2.num) + self.assertEquals(inst1.num, 5) + self.assertEquals(inst2.num, 7) + + def test_meta_data_is_not_inherited(self): + """ + Test that metadata defined in one class, is not inherited by subclasses + """ From eab79f6900c3d6bb69702e04c58bb535519c2640 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 18 Nov 2012 17:34:35 -0800 Subject: [PATCH 0019/4200] changing name to cqlengine --- cassandraengine/__init__.py | 3 -- .../tests/columns/test_validation.py | 16 -------- cqlengine/__init__.py | 3 ++ {cassandraengine => cqlengine}/columns.py | 37 ++++++++++++++++++- {cassandraengine => cqlengine}/connection.py | 0 {cassandraengine => cqlengine}/exceptions.py | 2 +- {cassandraengine => cqlengine}/manager.py | 2 +- {cassandraengine => cqlengine}/models.py | 22 +++++------ {cassandraengine => cqlengine}/query.py | 2 +- .../tests/__init__.py | 0 {cassandraengine => cqlengine}/tests/base.py | 0 .../tests/columns/__init__.py | 0 cqlengine/tests/columns/test_validation.py | 16 ++++++++ .../tests/model/__init__.py | 0 .../tests/model/test_class_construction.py | 8 ++-- .../tests/model/test_model_io.py | 6 +-- .../tests/model/test_validation.py | 0 17 files changed, 74 insertions(+), 43 deletions(-) delete mode 100644 cassandraengine/__init__.py delete mode 100644 cassandraengine/tests/columns/test_validation.py create mode 100644 cqlengine/__init__.py rename {cassandraengine => cqlengine}/columns.py (87%) rename {cassandraengine => cqlengine}/connection.py (100%) rename {cassandraengine => cqlengine}/exceptions.py (75%) rename {cassandraengine => cqlengine}/manager.py (98%) rename {cassandraengine => cqlengine}/models.py (89%) rename {cassandraengine => cqlengine}/query.py (99%) rename {cassandraengine => cqlengine}/tests/__init__.py (100%) rename {cassandraengine => cqlengine}/tests/base.py (100%) rename {cassandraengine => cqlengine}/tests/columns/__init__.py (100%) create mode 100644 cqlengine/tests/columns/test_validation.py rename {cassandraengine => cqlengine}/tests/model/__init__.py (100%) rename {cassandraengine => cqlengine}/tests/model/test_class_construction.py (93%) rename {cassandraengine => cqlengine}/tests/model/test_model_io.py (91%) rename {cassandraengine => cqlengine}/tests/model/test_validation.py (100%) diff --git a/cassandraengine/__init__.py b/cassandraengine/__init__.py deleted file mode 100644 index af95d0b003..0000000000 --- a/cassandraengine/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from cassandraengine.columns import * -from cassandraengine.models import Model - diff --git a/cassandraengine/tests/columns/test_validation.py b/cassandraengine/tests/columns/test_validation.py deleted file mode 100644 index e42519cc3b..0000000000 --- a/cassandraengine/tests/columns/test_validation.py +++ /dev/null @@ -1,16 +0,0 @@ -#tests the behavior of the column classes - -from cassandraengine.tests.base import BaseCassEngTestCase - -from cassandraengine.columns import BaseColumn -from cassandraengine.columns import Bytes -from cassandraengine.columns import Ascii -from cassandraengine.columns import Text -from cassandraengine.columns import Integer -from cassandraengine.columns import DateTime -from cassandraengine.columns import UUID -from cassandraengine.columns import Boolean -from cassandraengine.columns import Float -from cassandraengine.columns import Decimal - - diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py new file mode 100644 index 0000000000..1af8944a10 --- /dev/null +++ b/cqlengine/__init__.py @@ -0,0 +1,3 @@ +from cqlengine.columns import * +from cqlengine.models import Model + diff --git a/cassandraengine/columns.py b/cqlengine/columns.py similarity index 87% rename from cassandraengine/columns.py rename to cqlengine/columns.py index bcbf39e7f5..25a6f59b34 100644 --- a/cassandraengine/columns.py +++ b/cqlengine/columns.py @@ -2,12 +2,43 @@ import re from uuid import uuid1, uuid4 -from cassandraengine.exceptions import ValidationError +from cqlengine.exceptions import ValidationError + +class BaseValueManager(object): + + def __init__(self, instance, column, value): + self.instance = instance + self.column = column + self.initial_value = value + self.value = value + + def deleted(self): + return self.value is None and self.initial_value is not None + + def getval(self): + return self.value + + def setval(self, val): + self.value = val + + def delval(self): + self.value = None + + def get_property(self): + _get = lambda slf: self.getval() + _set = lambda slf, val: self.setval(val) + _del = lambda slf: self.delval() + + if self.column.can_delete: + return property(_get, _set, _del) + else: + return property(_get, _set) class BaseColumn(object): #the cassandra type this column maps to db_type = None + value_manager = BaseValueManager instance_counter = 0 @@ -64,6 +95,10 @@ def has_default(self): def is_primary_key(self): return self.primary_key + @property + def can_delete(self): + return not self.primary_key + #methods for replacing column definitions with properties that interact #with a column's value member #this will allow putting logic behind value access (lazy loading, etc) diff --git a/cassandraengine/connection.py b/cqlengine/connection.py similarity index 100% rename from cassandraengine/connection.py rename to cqlengine/connection.py diff --git a/cassandraengine/exceptions.py b/cqlengine/exceptions.py similarity index 75% rename from cassandraengine/exceptions.py rename to cqlengine/exceptions.py index 4b20f7a334..3bb026f214 100644 --- a/cassandraengine/exceptions.py +++ b/cqlengine/exceptions.py @@ -1,4 +1,4 @@ -#cassandraengine exceptions +#cqlengine exceptions class ModelException(BaseException): pass class ValidationError(BaseException): pass diff --git a/cassandraengine/manager.py b/cqlengine/manager.py similarity index 98% rename from cassandraengine/manager.py rename to cqlengine/manager.py index de061ce0fb..4d4cbb73bc 100644 --- a/cassandraengine/manager.py +++ b/cqlengine/manager.py @@ -1,6 +1,6 @@ #manager class -from cassandraengine.query import QuerySet +from cqlengine.query import QuerySet class Manager(object): diff --git a/cassandraengine/models.py b/cqlengine/models.py similarity index 89% rename from cassandraengine/models.py rename to cqlengine/models.py index 2b425bbc8e..b44cdebe76 100644 --- a/cassandraengine/models.py +++ b/cqlengine/models.py @@ -1,8 +1,8 @@ from collections import OrderedDict -from cassandraengine import columns -from cassandraengine.exceptions import ModelException -from cassandraengine.manager import Manager +from cqlengine import columns +from cqlengine.exceptions import ModelException +from cqlengine.manager import Manager class BaseModel(object): """ @@ -11,18 +11,14 @@ class BaseModel(object): #table names will be generated automatically from it's model name and package #however, you can alse define them manually here - db_name = None + db_name = None def __init__(self, **values): - #set columns from values - for k,v in values.items(): - if k in self._columns: - setattr(self, k, v) - - #set excluded columns to None - for k in self._columns.keys(): - if k not in values: - setattr(self, k, None) + self._values = {} + for name, column in self._columns.items(): + value_mngr = column.value_manager(self, column, values.get(name, None)) + self._values[name] = value_mngr + setattr(self, name, value_mngr.get_property()) @classmethod def find(cls, pk): diff --git a/cassandraengine/query.py b/cqlengine/query.py similarity index 99% rename from cassandraengine/query.py rename to cqlengine/query.py index 8cd7328519..70343d6bce 100644 --- a/cassandraengine/query.py +++ b/cqlengine/query.py @@ -1,6 +1,6 @@ import copy -from cassandraengine.connection import get_connection +from cqlengine.connection import get_connection #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index diff --git a/cassandraengine/tests/__init__.py b/cqlengine/tests/__init__.py similarity index 100% rename from cassandraengine/tests/__init__.py rename to cqlengine/tests/__init__.py diff --git a/cassandraengine/tests/base.py b/cqlengine/tests/base.py similarity index 100% rename from cassandraengine/tests/base.py rename to cqlengine/tests/base.py diff --git a/cassandraengine/tests/columns/__init__.py b/cqlengine/tests/columns/__init__.py similarity index 100% rename from cassandraengine/tests/columns/__init__.py rename to cqlengine/tests/columns/__init__.py diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py new file mode 100644 index 0000000000..f916fb0ebe --- /dev/null +++ b/cqlengine/tests/columns/test_validation.py @@ -0,0 +1,16 @@ +#tests the behavior of the column classes + +from cqlengine.tests.base import BaseCassEngTestCase + +from cqlengine.columns import BaseColumn +from cqlengine.columns import Bytes +from cqlengine.columns import Ascii +from cqlengine.columns import Text +from cqlengine.columns import Integer +from cqlengine.columns import DateTime +from cqlengine.columns import UUID +from cqlengine.columns import Boolean +from cqlengine.columns import Float +from cqlengine.columns import Decimal + + diff --git a/cassandraengine/tests/model/__init__.py b/cqlengine/tests/model/__init__.py similarity index 100% rename from cassandraengine/tests/model/__init__.py rename to cqlengine/tests/model/__init__.py diff --git a/cassandraengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py similarity index 93% rename from cassandraengine/tests/model/test_class_construction.py rename to cqlengine/tests/model/test_class_construction.py index b1659fa122..bf6f3522d7 100644 --- a/cassandraengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -1,8 +1,8 @@ -from cassandraengine.tests.base import BaseCassEngTestCase +from cqlengine.tests.base import BaseCassEngTestCase -from cassandraengine.exceptions import ModelException -from cassandraengine.models import Model -from cassandraengine import columns +from cqlengine.exceptions import ModelException +from cqlengine.models import Model +from cqlengine import columns class TestModelClassFunction(BaseCassEngTestCase): """ diff --git a/cassandraengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py similarity index 91% rename from cassandraengine/tests/model/test_model_io.py rename to cqlengine/tests/model/test_model_io.py index 788b36bb92..a080726ba6 100644 --- a/cassandraengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -1,8 +1,8 @@ from unittest import skip -from cassandraengine.tests.base import BaseCassEngTestCase +from cqlengine.tests.base import BaseCassEngTestCase -from cassandraengine.models import Model -from cassandraengine import columns +from cqlengine.models import Model +from cqlengine import columns class TestModel(Model): count = columns.Integer() diff --git a/cassandraengine/tests/model/test_validation.py b/cqlengine/tests/model/test_validation.py similarity index 100% rename from cassandraengine/tests/model/test_validation.py rename to cqlengine/tests/model/test_validation.py From 39176b9660b0c722ad9f0a97e6a1711294455efa Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 18 Nov 2012 17:48:21 -0800 Subject: [PATCH 0020/4200] adding valuemanager class and setup to model metaclass --- cqlengine/models.py | 14 ++++++++++---- cqlengine/tests/model/test_class_construction.py | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index b44cdebe76..3fa7c6bfcd 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -18,7 +18,6 @@ def __init__(self, **values): for name, column in self._columns.items(): value_mngr = column.value_manager(self, column, values.get(name, None)) self._values[name] = value_mngr - setattr(self, name, value_mngr.get_property()) @classmethod def find(cls, pk): @@ -67,6 +66,15 @@ def __new__(cls, name, bases, attrs): def _transform_column(col_name, col_obj): _columns[col_name] = col_obj col_obj.set_db_name(col_name) + #set properties + _get = lambda self: self._values[col_name].getval() + _set = lambda self, val: self._values[col_name].setval(val) + _del = lambda self: self._values[col_name].delval() + if col_obj.can_delete: + attrs[col_name] = property(_get, _set) + else: + attrs[col_name] = property(_get, _set, _del) + column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.BaseColumn)] column_definitions = sorted(column_definitions, lambda x,y: cmp(x[1].position, y[1].position)) @@ -84,9 +92,7 @@ def _transform_column(col_name, col_obj): #setup primary key shortcut if pk_name != 'pk': - pk_get = lambda self: getattr(self, pk_name) - pk_set = lambda self, val: setattr(self, pk_name, val) - attrs['pk'] = property(pk_get, pk_set) + attrs['pk'] = attrs[pk_name] #check for duplicate column names col_names = set() diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index bf6f3522d7..949e40fa77 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -20,7 +20,7 @@ class TestModel(Model): #check class attributes self.assertHasAttr(TestModel, '_columns') - self.assertNotHasAttr(TestModel, 'id') + self.assertHasAttr(TestModel, 'id') self.assertHasAttr(TestModel, 'text') #check instance attributes From 4152a37e80cc2fcad18af5d1f7f7672a258bf935 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 20 Nov 2012 21:48:41 -0800 Subject: [PATCH 0021/4200] adding nosetests to vagrant node --- manifests/default.pp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifests/default.pp b/manifests/default.pp index 1cf748cb71..3c5ad95642 100644 --- a/manifests/default.pp +++ b/manifests/default.pp @@ -54,7 +54,7 @@ node cassandraengine inherits basenode { include cassandra - package {["python-pip", "python-dev"]: + package {["python-pip", "python-dev", "python-nose"]: ensure => installed } From 61177eb3401ab151ed3072368f8cfb29e289b436 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 20 Nov 2012 23:22:58 -0800 Subject: [PATCH 0022/4200] starting to write the QuerySet class --- cqlengine/exceptions.py | 6 +- cqlengine/query.py | 134 ++++++++++++++++++++++--- cqlengine/tests/query/__init__.py | 0 cqlengine/tests/query/test_queryset.py | 52 ++++++++++ 4 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 cqlengine/tests/query/__init__.py create mode 100644 cqlengine/tests/query/test_queryset.py diff --git a/cqlengine/exceptions.py b/cqlengine/exceptions.py index 3bb026f214..3a5444e9b1 100644 --- a/cqlengine/exceptions.py +++ b/cqlengine/exceptions.py @@ -1,4 +1,6 @@ #cqlengine exceptions -class ModelException(BaseException): pass -class ValidationError(BaseException): pass +class CQLEngineException(BaseException): pass +class ModelException(CQLEngineException): pass +class ValidationError(CQLEngineException): pass +class QueryException(CQLEngineException): pass diff --git a/cqlengine/query.py b/cqlengine/query.py index 70343d6bce..f410391097 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -1,18 +1,58 @@ +from collections import namedtuple import copy from cqlengine.connection import get_connection +from cqlengine.exceptions import QueryException #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index -class Query(object): +WhereFilter = namedtuple('WhereFilter', ['column', 'operator', 'value']) - pass +class QueryOperatorException(QueryException): pass + +class QueryOperator(object): + symbol = None + + @classmethod + def get_operator(cls, symbol): + if not hasattr(cls, 'opmap'): + QueryOperator.opmap = {} + def _recurse(klass): + if klass.symbol: + QueryOperator.opmap[klass.symbol.upper()] = klass + for subklass in klass.__subclasses__(): + _recurse(subklass) + pass + _recurse(QueryOperator) + try: + return QueryOperator.opmap[symbol.upper()] + except KeyError: + raise QueryOperatorException("{} doesn't map to a QueryOperator".format(symbol)) + +class EqualsOperator(QueryOperator): + symbol = 'EQ' + +class InOperator(QueryOperator): + symbol = 'IN' + +class GreaterThanOperator(QueryOperator): + symbol = "GT" + +class GreaterThanOrEqualOperator(QueryOperator): + symbol = "GTE" + +class LessThanOperator(QueryOperator): + symbol = "LT" + +class LessThanOrEqualOperator(QueryOperator): + symbol = "LTE" class QuerySet(object): #TODO: querysets should be immutable #TODO: querysets should be executed lazily - #TODO: conflicting filter args should raise exception unless a force kwarg is supplied + #TODO: support specifying offset and limit (use slice) (maybe return a mutated queryset) + #TODO: support specifying columns to exclude or select only #CQL supports ==, >, >=, <, <=, IN (a,b,c,..n) #REVERSE, LIMIT @@ -21,9 +61,22 @@ class QuerySet(object): def __init__(self, model, query_args={}): super(QuerySet, self).__init__() self.model = model - self.query_args = query_args self.column_family_name = self.model.objects.column_family_name + #Where clause filters + self._where = [] + + #ordering arguments + self._order = [] + + #subset selection + self._limit = None + self._start = None + + #see the defer and only methods + self._defer_fields = [] + self._only_fields = [] + self._cursor = None #----query generation / execution---- @@ -31,7 +84,16 @@ def _execute_query(self): conn = get_connection() self._cursor = conn.cursor() - def _generate_querystring(self): + def _where_clause(self): + """ + Returns a where clause based on the given filter args + """ + pass + + def _select_query(self): + """ + Returns a select clause based on the given filter args + """ pass @property @@ -67,16 +129,36 @@ def first(self): pass def all(self): - return QuerySet(self.model) + clone = copy.deepcopy(self) + clone._where = [] + return clone + + def _parse_filter_arg(self, arg, val): + statement = arg.split('__') + if len(statement) == 1: + return WhereFilter(arg, None, val) + elif len(statement) == 2: + return WhereFilter(statement[0], statement[1], val) + else: + raise QueryException("Can't parse '{}'".format(arg)) def filter(self, **kwargs): - qargs = copy.deepcopy(self.query_args) - qargs.update(kwargs) - return QuerySet(self.model, query_args=qargs) + #add arguments to the where clause filters + clone = copy.deepcopy(self) + for arg, val in kwargs.items(): + raw_statement = self._parse_filter_arg(arg, val) + #resolve column and operator + try: + column = self.model._columns[raw_statement.column] + except KeyError: + raise QueryException("Can't resolve column name: '{}'".format(raw_statement.column)) - def exclude(self, **kwargs): - """ Need to invert the logic for all kwargs """ - pass + operator = QueryOperator.get_operator(raw_statement.operator) + + statement = WhereFilter(column, operator, val) + clone._where.append(statement) + + return clone def count(self): """ Returns the number of rows matched by this query """ @@ -95,6 +177,32 @@ def find(self, pk): self._cursor.execute(qs, {self.model._pk_name:pk}) return self._get_next() + def _only_or_defer(self, action, fields): + clone = copy.deepcopy(self) + if clone._defer_fields or clone._only_fields: + raise QueryException("QuerySet alread has only or defer fields defined") + + #check for strange fields + missing_fields = [f for f in fields if f not in self.model._columns.keys()] + if missing_fields: + raise QueryException("Can't resolve fields {} in {}".format(', '.join(missing_fields), self.model.__name__)) + + if action == 'defer': + clone._defer_fields = fields + elif action == 'only': + clone._only_fields = fields + else: + raise ValueError + + return clone + + def only(self, fields): + """ Load only these fields for the returned query """ + return self._only_or_defer('only', fields) + + def defer(self, fields): + """ Don't load these fields for the returned query """ + return self._only_or_defer('defer', fields) #----writes---- def save(self, instance): @@ -137,7 +245,7 @@ def save(self, instance): cur.execute(qs, field_values) #----delete--- - def delete(self): + def delete(self, columns=[]): """ Deletes the contents of a query """ diff --git a/cqlengine/tests/query/__init__.py b/cqlengine/tests/query/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py new file mode 100644 index 0000000000..ef1bd9c3f2 --- /dev/null +++ b/cqlengine/tests/query/test_queryset.py @@ -0,0 +1,52 @@ +from cqlengine.tests.base import BaseCassEngTestCase + +from cqlengine.exceptions import ModelException +from cqlengine.models import Model +from cqlengine import columns + +class TestQuerySet(BaseCassEngTestCase): + + def test_query_filter_parsing(self): + """ + Tests the queryset filter method + """ + + def test_where_clause_generation(self): + """ + Tests the where clause creation + """ + + def test_querystring_generation(self): + """ + Tests the select querystring creation + """ + + def test_queryset_is_immutable(self): + """ + Tests that calling a queryset function that changes it's state returns a new queryset + """ + + def test_queryset_slicing(self): + """ + Check that the limit and start is implemented as iterator slices + """ + + def test_proper_delete_behavior(self): + """ + Tests that deleting the contents of a queryset works properly + """ + + def test_the_all_method_clears_where_filter(self): + """ + Tests that calling all on a queryset with previously defined filters returns a queryset with no filters + """ + + def test_defining_only_and_defer_fails(self): + """ + Tests that trying to add fields to either only or defer, or doing so more than once fails + """ + + def test_defining_only_or_defer_fields_fails(self): + """ + Tests that setting only or defer fields that don't exist raises an exception + """ From 24fb184f711c22ce2147e6bfd680af4201206e7b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 21 Nov 2012 07:39:13 -0800 Subject: [PATCH 0023/4200] removing some outdated property stuff --- cqlengine/columns.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 25a6f59b34..6ff219cfe9 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -99,33 +99,6 @@ def is_primary_key(self): def can_delete(self): return not self.primary_key - #methods for replacing column definitions with properties that interact - #with a column's value member - #this will allow putting logic behind value access (lazy loading, etc) - def _getval(self): - """ This columns value getter """ - return self.value - - def _setval(self, val): - """ This columns value setter """ - self.value = val - - def _delval(self): - """ This columns value deleter """ - raise NotImplementedError - - def get_property(self, allow_delete=True): - """ - Returns the property object that will set and get this - column's value and be assigned to this column's model attribute - """ - getval = lambda slf: self._getval() - setval = lambda slf, val: self._setval(val) - delval = lambda slf: self._delval() - if not allow_delete: - return property(getval, setval) - return property(getval, setval, delval) - def get_default(self): if self.has_default: if callable(self.default): From dee0ace2d76e0101c6d31180e1787ce25fd97821 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 21 Nov 2012 07:40:09 -0800 Subject: [PATCH 0024/4200] working out queryoperator logic and structure updating filter arg parsing and storage adding where clause creation --- cqlengine/query.py | 108 ++++++++++++++++++++++--- cqlengine/tests/query/test_queryset.py | 5 ++ 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index f410391097..56a27e6192 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -1,5 +1,7 @@ from collections import namedtuple import copy +from hashlib import md5 +from time import time from cqlengine.connection import get_connection from cqlengine.exceptions import QueryException @@ -7,13 +9,65 @@ #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index -WhereFilter = namedtuple('WhereFilter', ['column', 'operator', 'value']) - class QueryOperatorException(QueryException): pass class QueryOperator(object): + # The symbol that identifies this operator in filter kwargs + # ie: colname__ symbol = None + # The comparator symbol this operator + # uses in cql + cql_symbol = None + + def __init__(self, column, value): + self.column = column + self.value = value + + #the identifier is a unique key that will be used in string + #replacement on query strings, it's created from a hash + #of this object's id and the time + self.identifier = md5(str(id(self)) + str(time())).hexdigest() + + #perform validation on this operator + self.validate_operator() + self.validate_value() + + @property + def cql(self): + """ + Returns this operator's portion of the WHERE clause + :param valname: the dict key that this operator's compare value will be found in + """ + return '{} {} :{}'.format(self.column.db_field, self.cql_symbol, self.identifier) + + def validate_operator(self): + """ + Checks that this operator can be used on the column provided + """ + if self.symbol is None: + raise QueryOperatorException("{} is not a valid operator, use one with 'symbol' defined".format(self.__class__.__name__)) + if self.cql_symbol is None: + raise QueryOperatorException("{} is not a valid operator, use one with 'cql_symbol' defined".format(self.__class__.__name__)) + + def validate_value(self): + """ + Checks that the compare value works with this operator + + *doesn't do anything by default + """ + pass + + def get_dict(self): + """ + Returns this operators contribution to the cql.query arg dictionanry + + ie: if this column's name is colname, and the identifier is colval, + this should return the dict: {'colval':} + SELECT * FROM column_family WHERE colname=:colval + """ + return {self.identifier: self.value} + @classmethod def get_operator(cls, symbol): if not hasattr(cls, 'opmap'): @@ -26,33 +80,40 @@ def _recurse(klass): pass _recurse(QueryOperator) try: - return QueryOperator.opmap[symbol.upper()] + return QueryOperator.opmap[symbol.upper()](column) except KeyError: raise QueryOperatorException("{} doesn't map to a QueryOperator".format(symbol)) class EqualsOperator(QueryOperator): symbol = 'EQ' + cql_symbol = '=' class InOperator(QueryOperator): symbol = 'IN' + cql_symbol = 'IN' class GreaterThanOperator(QueryOperator): symbol = "GT" + cql_symbol = '>' class GreaterThanOrEqualOperator(QueryOperator): symbol = "GTE" + cql_symbol = '>=' class LessThanOperator(QueryOperator): symbol = "LT" + cql_symbol = '<' class LessThanOrEqualOperator(QueryOperator): symbol = "LTE" + cql_symbol = '<=' class QuerySet(object): #TODO: querysets should be immutable #TODO: querysets should be executed lazily #TODO: support specifying offset and limit (use slice) (maybe return a mutated queryset) #TODO: support specifying columns to exclude or select only + #TODO: cache results in this instance, but don't copy them on deepcopy #CQL supports ==, >, >=, <, <=, IN (a,b,c,..n) #REVERSE, LIMIT @@ -84,11 +145,26 @@ def _execute_query(self): conn = get_connection() self._cursor = conn.cursor() + def _validate_where_syntax(self): + """ + Checks that a filterset will not create invalid cql + """ + #TODO: check that there's either a = or IN relationship with a primary key or indexed field + def _where_clause(self): """ Returns a where clause based on the given filter args """ - pass + self._validate_where_syntax() + return ' AND '.join([f.cql for f in self._where]) + + def _where_values(self): + """ + Returns the value dict to be passed to the cql query + """ + values = {} + for where in self._where: values.update(where.get_dict()) + return values def _select_query(self): """ @@ -133,12 +209,17 @@ def all(self): clone._where = [] return clone - def _parse_filter_arg(self, arg, val): + def _parse_filter_arg(self, arg): + """ + Parses a filter arg in the format: + __ + :returns: colname, op tuple + """ statement = arg.split('__') if len(statement) == 1: - return WhereFilter(arg, None, val) + return arg, None elif len(statement) == 2: - return WhereFilter(statement[0], statement[1], val) + return statement[0], statement[1] else: raise QueryException("Can't parse '{}'".format(arg)) @@ -146,17 +227,18 @@ def filter(self, **kwargs): #add arguments to the where clause filters clone = copy.deepcopy(self) for arg, val in kwargs.items(): - raw_statement = self._parse_filter_arg(arg, val) + col_name, col_op = self._parse_filter_arg(arg) #resolve column and operator try: - column = self.model._columns[raw_statement.column] + column = self.model._columns[col_name] except KeyError: - raise QueryException("Can't resolve column name: '{}'".format(raw_statement.column)) + raise QueryException("Can't resolve column name: '{}'".format(col_name)) - operator = QueryOperator.get_operator(raw_statement.operator) + #get query operator, or use equals if not supplied + operator_class = QueryOperator.get_operator(col_op or 'EQ') + operator = operator_class(column, val) - statement = WhereFilter(column, operator, val) - clone._where.append(statement) + clone._where.append(operator) return clone diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index ef1bd9c3f2..7f6de601b8 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -11,6 +11,11 @@ def test_query_filter_parsing(self): Tests the queryset filter method """ + def test_using_invalid_column_names_in_filter_kwargs_raises_error(self): + """ + Tests that using invalid or nonexistant column names for filter args raises an error + """ + def test_where_clause_generation(self): """ Tests the where clause creation From 1fcf80909510872af1e60044d625ca6129683ea6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 22 Nov 2012 12:39:22 -0800 Subject: [PATCH 0025/4200] adding select querystring generation moving the manager functionality into the queryset and modifying the model and query classes to work with it --- cqlengine/columns.py | 6 +- cqlengine/manager.py | 23 +---- cqlengine/models.py | 29 +++++- cqlengine/query.py | 93 ++++++++++++------- .../tests/model/test_class_construction.py | 7 +- cqlengine/tests/model/test_model_io.py | 4 + cqlengine/tests/query/test_queryoperators.py | 0 cqlengine/tests/query/test_queryset.py | 52 ++++++++++- requirements.txt | 2 + 9 files changed, 153 insertions(+), 63 deletions(-) create mode 100644 cqlengine/tests/query/test_queryoperators.py diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 6ff219cfe9..e81e893005 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -42,9 +42,11 @@ class BaseColumn(object): instance_counter = 0 - def __init__(self, primary_key=False, db_field=None, default=None, null=False): + def __init__(self, primary_key=False, index=False, db_field=None, default=None, null=False): """ - :param primary_key: bool flag, there can be only one primary key per doc + :param primary_key: bool flag, indicates this column is a primary key. The first primary key defined + on a model is the partition key, all others are cluster keys + :param index: bool flag, indicates an index should be created for this column :param db_field: the fieldname this field will map to in the database :param default: the default value, can be a value or a callable (no args) :param null: boolean, is the field nullable? diff --git a/cqlengine/manager.py b/cqlengine/manager.py index 4d4cbb73bc..b2c2ee0b52 100644 --- a/cqlengine/manager.py +++ b/cqlengine/manager.py @@ -2,6 +2,7 @@ from cqlengine.query import QuerySet +#TODO: refactor this into the QuerySet class Manager(object): def __init__(self, model): @@ -27,22 +28,9 @@ def __call__(self, **kwargs): return self.filter(**kwargs) def find(self, pk): - """ - Returns the row corresponding to the primary key value given - """ - values = QuerySet(self.model).find(pk) - if values is None: return - - #change the column names to model names - #in case they are different - field_dict = {} - db_map = self.model._db_map - for key, val in values.items(): - if key in db_map: - field_dict[db_map[key]] = val - else: - field_dict[key] = val - return self.model(**field_dict) + """ Returns the row corresponding to the primary key set given """ + #TODO: rework this to work with multiple primary keys + return QuerySet(self.model).find(pk) def all(self): return QuerySet(self.model).all() @@ -50,9 +38,6 @@ def all(self): def filter(self, **kwargs): return QuerySet(self.model).filter(**kwargs) - def exclude(self, **kwargs): - return QuerySet(self.model).exclude(**kwargs) - def create(self, **kwargs): return self.model(**kwargs).save() diff --git a/cqlengine/models.py b/cqlengine/models.py index 3fa7c6bfcd..ca51a21e8c 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -2,7 +2,7 @@ from cqlengine import columns from cqlengine.exceptions import ModelException -from cqlengine.manager import Manager +from cqlengine.query import QuerySet class BaseModel(object): """ @@ -19,11 +19,28 @@ def __init__(self, **values): value_mngr = column.value_manager(self, column, values.get(name, None)) self._values[name] = value_mngr + #TODO: note any deferred or only fields so they're not deleted + @classmethod def find(cls, pk): """ Loads a document by it's primary key """ + #TODO: rework this to work with multiple primary keys cls.objects.find(pk) + @classmethod + def column_family_name(cls): + """ + Returns the column family name if it's been defined + otherwise, it creates it from the module and class name + """ + if cls.db_name: + return cls.db_name + cf_name = cls.__module__ + '.' + cls.__name__ + cf_name = cf_name.replace('.', '_') + #trim to less than 48 characters or cassandra will complain + cf_name = cf_name[-48:] + return cf_name + @property def pk(self): """ Returns the object's primary key """ @@ -45,12 +62,14 @@ def as_dict(self): def save(self): is_new = self.pk is None self.validate() - self.objects._save_instance(self) + #self.objects._save_instance(self) + self.objects.save(self) return self def delete(self): """ Deletes this instance """ - self.objects._delete_instance(self) + #self.objects._delete_instance(self) + self.objects.delete_instance(self) class ModelMetaClass(type): @@ -115,9 +134,9 @@ def _transform_column(col_name, col_obj): attrs['_pk_name'] = pk_name attrs['_dynamic_columns'] = {} - #create the class and add a manager to it + #create the class and add a QuerySet to it klass = super(ModelMetaClass, cls).__new__(cls, name, bases, attrs) - klass.objects = Manager(klass) + klass.objects = QuerySet(klass) return klass diff --git a/cqlengine/query.py b/cqlengine/query.py index 56a27e6192..7b6c91b0cf 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -80,7 +80,7 @@ def _recurse(klass): pass _recurse(QueryOperator) try: - return QueryOperator.opmap[symbol.upper()](column) + return QueryOperator.opmap[symbol.upper()] except KeyError: raise QueryOperatorException("{} doesn't map to a QueryOperator".format(symbol)) @@ -119,10 +119,10 @@ class QuerySet(object): #REVERSE, LIMIT #ORDER BY - def __init__(self, model, query_args={}): + def __init__(self, model): super(QuerySet, self).__init__() self.model = model - self.column_family_name = self.model.objects.column_family_name + self.column_family_name = self.model.column_family_name() #Where clause filters self._where = [] @@ -141,65 +141,82 @@ def __init__(self, model, query_args={}): self._cursor = None #----query generation / execution---- - def _execute_query(self): - conn = get_connection() - self._cursor = conn.cursor() def _validate_where_syntax(self): - """ - Checks that a filterset will not create invalid cql - """ + """ Checks that a filterset will not create invalid cql """ #TODO: check that there's either a = or IN relationship with a primary key or indexed field + #TODO: abuse this to see if we can get cql to raise an exception def _where_clause(self): - """ - Returns a where clause based on the given filter args - """ + """ Returns a where clause based on the given filter args """ self._validate_where_syntax() return ' AND '.join([f.cql for f in self._where]) def _where_values(self): - """ - Returns the value dict to be passed to the cql query - """ + """ Returns the value dict to be passed to the cql query """ values = {} - for where in self._where: values.update(where.get_dict()) + for where in self._where: + values.update(where.get_dict()) return values - def _select_query(self): + def _select_query(self, count=False): """ Returns a select clause based on the given filter args + + :param count: indicates this should return a count query only """ - pass + qs = [] + if count: + qs += ['SELECT COUNT(*)'] + else: + fields = self.models._columns.keys() + if self._defer_fields: + fields = [f for f in fields if f not in self._defer_fields] + elif self._only_fields: + fields = [f for f in fields if f in self._only_fields] + db_fields = [self.model._columns[f].db_fields for f in fields] + qs += ['SELECT {}'.format(', '.join(db_fields))] - @property - def cursor(self): - if self._cursor is None: - self._cursor = self._execute_query() - return self._cursor + qs += ['FROM {}'.format(self.column_family_name)] + + if self._where: + qs += ['WHERE {}'.format(self._where_clause())] + + #TODO: add support for limit, start, order by, and reverse + return ' '.join(qs) #----Reads------ def __iter__(self): if self._cursor is None: - self._execute_query() + conn = get_connection() + self._cursor = conn.cursor() + self._cursor.execute(self._select_query(), self._where_values()) return self + def _construct_instance(self, values): + #translate column names to model names + field_dict = {} + db_map = self.model._db_map + for key, val in values.items(): + if key in db_map: + field_dict[db_map[key]] = val + else: + field_dict[key] = val + return self.model(**field_dict) + def _get_next(self): - """ - Gets the next cursor result - Returns a db_field->value dict - """ + """ Gets the next cursor result """ cur = self._cursor values = cur.fetchone() if values is None: return names = [i[0] for i in cur.description] value_dict = dict(zip(names, values)) - return value_dict + return self._construct_instance(value_dict) def next(self): - values = self._get_next() - if values is None: raise StopIteration - return values + instance = self._get_next() + if instance is None: raise StopIteration + return instance def first(self): pass @@ -242,15 +259,22 @@ def filter(self, **kwargs): return clone + def __call__(self, **kwargs): + return self.filter(**kwargs) + def count(self): """ Returns the number of rows matched by this query """ - qs = 'SELECT COUNT(*) FROM {}'.format(self.column_family_name) + con = get_connection() + cur = con.cursor() + cur.execute(self._select_query(count=True), self._where_values()) + return cur.fetchone() def find(self, pk): """ loads one document identified by it's primary key """ #TODO: make this a convenience wrapper of the filter method + #TODO: rework this to work with multiple primary keys qs = 'SELECT * FROM {column_family} WHERE {pk_name}=:{pk_name}' qs = qs.format(column_family=self.column_family_name, pk_name=self.model._pk_name) @@ -326,6 +350,9 @@ def save(self, instance): cur = conn.cursor() cur.execute(qs, field_values) + def create(self, **kwargs): + return self.model(**kwargs).save() + #----delete--- def delete(self, columns=[]): """ diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 949e40fa77..9b8f4f359f 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -18,7 +18,7 @@ def test_column_attributes_handled_correctly(self): class TestModel(Model): text = columns.Text() - #check class attributes + #check class attibutes self.assertHasAttr(TestModel, '_columns') self.assertHasAttr(TestModel, 'id') self.assertHasAttr(TestModel, 'text') @@ -79,6 +79,11 @@ class Stuff(Model): self.assertEquals(inst1.num, 5) self.assertEquals(inst2.num, 7) + def test_normal_fields_can_be_defined_between_primary_keys(self): + """ + Tests tha non primary key fields can be defined between primary key fields + """ + def test_meta_data_is_not_inherited(self): """ Test that metadata defined in one class, is not inherited by subclasses diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index a080726ba6..5d0824203d 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -52,3 +52,7 @@ def test_nullable_columns_are_saved_properly(self): Tests that nullable columns save without any trouble """ + def test_column_deleting_works_properly(self): + """ + """ + diff --git a/cqlengine/tests/query/test_queryoperators.py b/cqlengine/tests/query/test_queryoperators.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 7f6de601b8..fe58dc725e 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -3,25 +3,58 @@ from cqlengine.exceptions import ModelException from cqlengine.models import Model from cqlengine import columns +from cqlengine import query + +class TestModel(Model): + test_id = columns.Integer(primary_key=True) + attempt_id = columns.Integer(primary_key=True) + descriptions = columns.Text() + expected_result = columns.Integer() + test_result = columns.Integer(index=True) class TestQuerySet(BaseCassEngTestCase): def test_query_filter_parsing(self): """ - Tests the queryset filter method + Tests the queryset filter method parses it's kwargs properly """ + query1 = TestModel.objects(test_id=5) + assert len(query1._where) == 1 + + op = query1._where[0] + assert isinstance(op, query.EqualsOperator) + assert op.value == 5 + + query2 = query1.filter(expected_result__gte=1) + assert len(query2._where) == 2 + + op = query2._where[1] + assert isinstance(op, query.GreaterThanOrEqualOperator) + assert op.value == 1 def test_using_invalid_column_names_in_filter_kwargs_raises_error(self): """ Tests that using invalid or nonexistant column names for filter args raises an error """ + with self.assertRaises(query.QueryException): + query0 = TestModel.objects(nonsense=5) def test_where_clause_generation(self): """ Tests the where clause creation """ + query1 = TestModel.objects(test_id=5) + ids = [o.identifier for o in query1._where] + where = query1._where_clause() + assert where == 'test_id = :{}'.format(*ids) + + query2 = query1.filter(expected_result__gte=1) + ids = [o.identifier for o in query2._where] + where = query2._where_clause() + assert where == 'test_id = :{} AND expected_result >= :{}'.format(*ids) - def test_querystring_generation(self): + + def test_querystring_construction(self): """ Tests the select querystring creation """ @@ -30,6 +63,11 @@ def test_queryset_is_immutable(self): """ Tests that calling a queryset function that changes it's state returns a new queryset """ + query1 = TestModel.objects(test_id=5) + assert len(query1._where) == 1 + + query2 = query1.filter(expected_result__gte=1) + assert len(query2._where) == 2 def test_queryset_slicing(self): """ @@ -45,13 +83,21 @@ def test_the_all_method_clears_where_filter(self): """ Tests that calling all on a queryset with previously defined filters returns a queryset with no filters """ + query1 = TestModel.objects(test_id=5) + assert len(query1._where) == 1 + + query2 = query1.filter(expected_result__gte=1) + assert len(query2._where) == 2 + + query3 = query2.all() + assert len(query3._where) == 0 def test_defining_only_and_defer_fails(self): """ Tests that trying to add fields to either only or defer, or doing so more than once fails """ - def test_defining_only_or_defer_fields_fails(self): + def test_defining_only_or_defer_on_nonexistant_fields_fails(self): """ Tests that setting only or defer fields that don't exist raises an exception """ diff --git a/requirements.txt b/requirements.txt index b6734c8844..8411d39556 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ cql==1.2.0 +ipython==0.13.1 +ipdb==0.7 From b7d91fe869fef8fcdebafc83b771db907df73f16 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 22 Nov 2012 23:16:49 -0800 Subject: [PATCH 0026/4200] writing query delete method added queryset usage tests refactoring how column names are stored and calculated --- cqlengine/columns.py | 13 ++++-- cqlengine/models.py | 13 ++---- cqlengine/query.py | 54 ++++++++++++++-------- cqlengine/tests/query/test_queryset.py | 63 ++++++++++++++++++++++++-- 4 files changed, 111 insertions(+), 32 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index e81e893005..0eeb8051d0 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -56,6 +56,9 @@ def __init__(self, primary_key=False, index=False, db_field=None, default=None, self.default = default self.null = null + #the column name in the model definition + self.column_name = None + self.value = None #keep track of instantiation order @@ -112,17 +115,21 @@ def get_column_def(self): """ Returns a column definition for CQL table definition """ - dterms = [self.db_field, self.db_type] + dterms = [self.db_field_name, self.db_type] #if self.primary_key: #dterms.append('PRIMARY KEY') return ' '.join(dterms) - def set_db_name(self, name): + def set_column_name(self, name): """ Sets the column name during document class construction This value will be ignored if db_field is set in __init__ """ - self.db_field = self.db_field or name + self.column_name = name + + @property + def db_field_name(self): + return self.db_field or self.column_name class Bytes(BaseColumn): db_type = 'blob' diff --git a/cqlengine/models.py b/cqlengine/models.py index ca51a21e8c..2d9de4ed5a 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -62,13 +62,11 @@ def as_dict(self): def save(self): is_new = self.pk is None self.validate() - #self.objects._save_instance(self) self.objects.save(self) return self def delete(self): """ Deletes this instance """ - #self.objects._delete_instance(self) self.objects.delete_instance(self) @@ -84,7 +82,7 @@ def __new__(cls, name, bases, attrs): def _transform_column(col_name, col_obj): _columns[col_name] = col_obj - col_obj.set_db_name(col_name) + col_obj.set_column_name(col_name) #set properties _get = lambda self: self._values[col_name].getval() _set = lambda self, val: self._values[col_name].setval(val) @@ -94,7 +92,6 @@ def _transform_column(col_name, col_obj): else: attrs[col_name] = property(_get, _set, _del) - column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.BaseColumn)] column_definitions = sorted(column_definitions, lambda x,y: cmp(x[1].position, y[1].position)) @@ -116,9 +113,9 @@ def _transform_column(col_name, col_obj): #check for duplicate column names col_names = set() for k,v in _columns.items(): - if v.db_field in col_names: - raise ModelException("{} defines the column {} more than once".format(name, v.db_field)) - col_names.add(v.db_field) + if v.db_field_name in col_names: + raise ModelException("{} defines the column {} more than once".format(name, v.db_field_name)) + col_names.add(v.db_field_name) #get column family name cf_name = attrs.pop('db_name', name) @@ -126,7 +123,7 @@ def _transform_column(col_name, col_obj): #create db_name -> model name map for loading db_map = {} for name, col in _columns.items(): - db_map[col.db_field] = name + db_map[col.db_field_name] = name #add management members to the class attrs['_columns'] = _columns diff --git a/cqlengine/query.py b/cqlengine/query.py index 7b6c91b0cf..b2c8c9e89a 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -39,7 +39,7 @@ def cql(self): Returns this operator's portion of the WHERE clause :param valname: the dict key that this operator's compare value will be found in """ - return '{} {} :{}'.format(self.column.db_field, self.cql_symbol, self.identifier) + return '{} {} :{}'.format(self.column.db_field_name, self.cql_symbol, self.identifier) def validate_operator(self): """ @@ -140,6 +140,12 @@ def __init__(self, model): self._cursor = None + def __unicode__(self): + return self._select_query() + + def __str__(self): + return str(self.__unicode__()) + #----query generation / execution---- def _validate_where_syntax(self): @@ -169,12 +175,12 @@ def _select_query(self, count=False): if count: qs += ['SELECT COUNT(*)'] else: - fields = self.models._columns.keys() + fields = self.model._columns.keys() if self._defer_fields: fields = [f for f in fields if f not in self._defer_fields] elif self._only_fields: fields = [f for f in fields if f in self._only_fields] - db_fields = [self.model._columns[f].db_fields for f in fields] + db_fields = [self.model._columns[f].db_field_name for f in fields] qs += ['SELECT {}'.format(', '.join(db_fields))] qs += ['FROM {}'.format(self.column_family_name)] @@ -182,7 +188,10 @@ def _select_query(self, count=False): if self._where: qs += ['WHERE {}'.format(self._where_clause())] - #TODO: add support for limit, start, order by, and reverse + if not count: + #TODO: add support for limit, start, order by, and reverse + pass + return ' '.join(qs) #----Reads------ @@ -191,6 +200,7 @@ def __iter__(self): conn = get_connection() self._cursor = conn.cursor() self._cursor.execute(self._select_query(), self._where_values()) + self._rowcount = self._cursor.rowcount return self def _construct_instance(self, values): @@ -267,7 +277,10 @@ def count(self): con = get_connection() cur = con.cursor() cur.execute(self._select_query(count=True), self._where_values()) - return cur.fetchone() + return cur.fetchone()[0] + + def __len__(self): + return self.count() def find(self, pk): """ @@ -319,23 +332,14 @@ def save(self, instance): prior to calling this. """ assert type(instance) == self.model + #organize data value_pairs = [] - - #get pk - col = self.model._columns[self.model._pk_name] values = instance.as_dict() - value_pairs += [(col.db_field, values.get(self.model._pk_name))] #get defined fields and their column names for name, col in self.model._columns.items(): - if col.is_primary_key: continue - value_pairs += [(col.db_field, values.get(name))] - - #add dynamic fields - for key, val in values.items(): - if key in self.model._columns: continue - value_pairs += [(key, val)] + value_pairs += [(col.db_field_name, values.get(name))] #construct query string field_names = zip(*value_pairs)[0] @@ -357,7 +361,21 @@ def create(self, **kwargs): def delete(self, columns=[]): """ Deletes the contents of a query + + :returns: number of rows deleted """ + qs = ['DELETE FROM {}'.format(self.column_family_name)] + if self._where: + qs += ['WHERE {}'.format(self._where_clause())] + qs = ' '.join(qs) + + #TODO: Return number of rows deleted + con = get_connection() + cur = con.cursor() + cur.execute(qs, self._where_values()) + return cur.fetchone() + + def delete_instance(self, instance): """ Deletes one instance """ @@ -378,8 +396,8 @@ def _create_column_family(self): pkeys = [] qtypes = [] def add_column(col): - s = '{} {}'.format(col.db_field, col.db_type) - if col.primary_key: pkeys.append(col.db_field) + s = '{} {}'.format(col.db_field_name, col.db_type) + if col.primary_key: pkeys.append(col.db_field_name) qtypes.append(s) #add_column(self.model._columns[self.model._pk_name]) for name, col in self.model._columns.items(): diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index fe58dc725e..9ac767b51f 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -8,11 +8,11 @@ class TestModel(Model): test_id = columns.Integer(primary_key=True) attempt_id = columns.Integer(primary_key=True) - descriptions = columns.Text() + description = columns.Text() expected_result = columns.Integer() test_result = columns.Integer(index=True) -class TestQuerySet(BaseCassEngTestCase): +class TestQuerySetOperation(BaseCassEngTestCase): def test_query_filter_parsing(self): """ @@ -54,7 +54,7 @@ def test_where_clause_generation(self): assert where == 'test_id = :{} AND expected_result >= :{}'.format(*ids) - def test_querystring_construction(self): + def test_querystring_generation(self): """ Tests the select querystring creation """ @@ -101,3 +101,60 @@ def test_defining_only_or_defer_on_nonexistant_fields_fails(self): """ Tests that setting only or defer fields that don't exist raises an exception """ + +class TestQuerySetUsage(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestQuerySetUsage, cls).setUpClass() + try: TestModel.objects._delete_column_family() + except: pass + TestModel.objects._create_column_family() + + TestModel.objects.create(test_id=0, attempt_id=0, description='try1', expected_result=5, test_result=30) + TestModel.objects.create(test_id=0, attempt_id=1, description='try2', expected_result=10, test_result=30) + TestModel.objects.create(test_id=0, attempt_id=2, description='try3', expected_result=15, test_result=30) + TestModel.objects.create(test_id=0, attempt_id=3, description='try4', expected_result=20, test_result=25) + + TestModel.objects.create(test_id=1, attempt_id=0, description='try5', expected_result=5, test_result=25) + TestModel.objects.create(test_id=1, attempt_id=1, description='try6', expected_result=10, test_result=25) + TestModel.objects.create(test_id=1, attempt_id=2, description='try7', expected_result=15, test_result=25) + TestModel.objects.create(test_id=1, attempt_id=3, description='try8', expected_result=20, test_result=20) + + TestModel.objects.create(test_id=2, attempt_id=0, description='try9', expected_result=50, test_result=40) + TestModel.objects.create(test_id=2, attempt_id=1, description='try10', expected_result=60, test_result=40) + TestModel.objects.create(test_id=2, attempt_id=2, description='try11', expected_result=70, test_result=45) + TestModel.objects.create(test_id=2, attempt_id=3, description='try12', expected_result=75, test_result=45) + + @classmethod + def tearDownClass(cls): + super(TestQuerySetUsage, cls).tearDownClass() + TestModel.objects._delete_column_family() + + def test_count(self): + q = TestModel.objects(test_id=0) + assert q.count() == 4 + + def test_iteration(self): + q = TestModel.objects(test_id=0) + #tuple of expected attempt_id, expected_result values + import ipdb; ipdb.set_trace() + compare_set = set([(0,5), (1,10), (2,15), (3,20)]) + for t in q: + val = t.attempt_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 + + q = TestModel.objects(attempt_id=3) + assert len(q) == 3 + #tuple of expected test_id, expected_result values + compare_set = set([(0,20), (1,20), (2,75)]) + for t in q: + val = t.test_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 + + + From 277f1496e08117d0fd0721305bbb125e11227891 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 23 Nov 2012 13:37:45 -0800 Subject: [PATCH 0027/4200] made keyspace a model attribute made keyspace a required parameter for get_connection moved create/delete column family and keyspaces into a management module --- cqlengine/connection.py | 46 +++++++++----- cqlengine/management.py | 50 +++++++++++++++ cqlengine/models.py | 5 +- cqlengine/query.py | 62 ++++++------------- .../tests/model/test_class_construction.py | 5 ++ cqlengine/tests/model/test_model_io.py | 15 ++++- cqlengine/tests/query/test_queryset.py | 10 +-- 7 files changed, 125 insertions(+), 68 deletions(-) create mode 100644 cqlengine/management.py diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 3cebc3acbf..19a767ac45 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -3,25 +3,39 @@ #http://cassandra.apache.org/doc/cql/CQL.html import cql +from cqlengine.exceptions import CQLEngineException + +class CQLConnectionError(CQLEngineException): pass _keyspace = 'cassengine_test' -_conn = None -def get_connection(): - global _conn - if _conn is None: - _conn = cql.connect('127.0.0.1', 9160) - _conn.set_cql_version('3.0.0') - try: - _conn.set_initial_keyspace(_keyspace) - except cql.ProgrammingError, e: - #http://www.datastax.com/docs/1.0/references/cql/CREATE_KEYSPACE - cur = _conn.cursor() - cur.execute("""create keyspace {} - with strategy_class = 'SimpleStrategy' - and strategy_options:replication_factor=1;""".format(_keyspace)) - _conn.set_initial_keyspace(_keyspace) - return _conn +#TODO: look into the cql connection pool class +_conn = {} +def get_connection(keyspace, create_missing_keyspace=True): + con = _conn.get(keyspace) + if con is None: + con = cql.connect('127.0.0.1', 9160) + con.set_cql_version('3.0.0') + + if keyspace: + try: + con.set_initial_keyspace(keyspace) + except cql.ProgrammingError, e: + if create_missing_keyspace: + from cqlengine.management import create_keyspace + create_keyspace(keyspace) + #http://www.datastax.com/docs/1.0/references/cql/CREATE_KEYSPACE + cur = con.cursor() + cur.execute("""create keyspace {} + with strategy_class = 'SimpleStrategy' + and strategy_options:replication_factor=1;""".format(keyspace)) + con.set_initial_keyspace(keyspace) + else: + raise CQLConnectionError('"{}" is not an existing keyspace'.format(keyspace)) + + _conn[keyspace] = con + + return con #cli examples here: #http://wiki.apache.org/cassandra/CassandraCli diff --git a/cqlengine/management.py b/cqlengine/management.py new file mode 100644 index 0000000000..07c24acd6e --- /dev/null +++ b/cqlengine/management.py @@ -0,0 +1,50 @@ +from cqlengine.connection import get_connection + + +def create_keyspace(name): + con = get_connection(None) + cur = con.cursor() + cur.execute("""create keyspace {} + with strategy_class = 'SimpleStrategy' + and strategy_options:replication_factor=1;""".format(name)) + +def delete_keyspace(name): + pass + +def create_column_family(model): + #TODO: check for existing column family + #construct query string + qs = ['CREATE TABLE {}'.format(model.column_family_name())] + + #add column types + pkeys = [] + qtypes = [] + def add_column(col): + s = '{} {}'.format(col.db_field_name, col.db_type) + if col.primary_key: pkeys.append(col.db_field_name) + qtypes.append(s) + for name, col in model._columns.items(): + add_column(col) + + qtypes.append('PRIMARY KEY ({})'.format(', '.join(pkeys))) + + qs += ['({})'.format(', '.join(qtypes))] + qs = ' '.join(qs) + + #add primary key + conn = get_connection(model.keyspace) + cur = conn.cursor() + try: + cur.execute(qs) + except BaseException, e: + if 'Cannot add already existing column family' not in e.message: + raise + + +def delete_column_family(model): + #TODO: check that model exists + conn = get_connection(model.keyspace) + cur = conn.cursor() + cur.execute('drop table {};'.format(model.column_family_name())) + + pass diff --git a/cqlengine/models.py b/cqlengine/models.py index 2d9de4ed5a..7b2fa8ead1 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -9,10 +9,13 @@ class BaseModel(object): The base model class, don't inherit from this, inherit from Model, defined below """ - #table names will be generated automatically from it's model name and package + #table names will be generated automatically from it's model and package name #however, you can alse define them manually here db_name = None + #the keyspace for this model + keyspace = 'cqlengine' + def __init__(self, **values): self._values = {} for name, column in self._columns.items(): diff --git a/cqlengine/query.py b/cqlengine/query.py index b2c8c9e89a..71853fe169 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -46,15 +46,23 @@ def validate_operator(self): Checks that this operator can be used on the column provided """ if self.symbol is None: - raise QueryOperatorException("{} is not a valid operator, use one with 'symbol' defined".format(self.__class__.__name__)) + raise QueryOperatorException( + "{} is not a valid operator, use one with 'symbol' defined".format( + self.__class__.__name__ + ) + ) if self.cql_symbol is None: - raise QueryOperatorException("{} is not a valid operator, use one with 'cql_symbol' defined".format(self.__class__.__name__)) + raise QueryOperatorException( + "{} is not a valid operator, use one with 'cql_symbol' defined".format( + self.__class__.__name__ + ) + ) def validate_value(self): """ Checks that the compare value works with this operator - *doesn't do anything by default + Doesn't do anything by default """ pass @@ -109,7 +117,6 @@ class LessThanOrEqualOperator(QueryOperator): cql_symbol = '<=' class QuerySet(object): - #TODO: querysets should be immutable #TODO: querysets should be executed lazily #TODO: support specifying offset and limit (use slice) (maybe return a mutated queryset) #TODO: support specifying columns to exclude or select only @@ -195,9 +202,10 @@ def _select_query(self, count=False): return ' '.join(qs) #----Reads------ + def __iter__(self): if self._cursor is None: - conn = get_connection() + conn = get_connection(self.model.keyspace) self._cursor = conn.cursor() self._cursor.execute(self._select_query(), self._where_values()) self._rowcount = self._cursor.rowcount @@ -274,7 +282,7 @@ def __call__(self, **kwargs): def count(self): """ Returns the number of rows matched by this query """ - con = get_connection() + con = get_connection(self.model.keyspace) cur = con.cursor() cur.execute(self._select_query(count=True), self._where_values()) return cur.fetchone()[0] @@ -291,7 +299,7 @@ def find(self, pk): qs = 'SELECT * FROM {column_family} WHERE {pk_name}=:{pk_name}' qs = qs.format(column_family=self.column_family_name, pk_name=self.model._pk_name) - conn = get_connection() + conn = get_connection(self.model.keyspace) self._cursor = conn.cursor() self._cursor.execute(qs, {self.model._pk_name:pk}) return self._get_next() @@ -350,7 +358,7 @@ def save(self, instance): qs += ["({})".format(', '.join([':'+f for f in field_names]))] qs = ' '.join(qs) - conn = get_connection() + conn = get_connection(self.model.keyspace) cur = conn.cursor() cur.execute(qs, field_values) @@ -370,7 +378,7 @@ def delete(self, columns=[]): qs = ' '.join(qs) #TODO: Return number of rows deleted - con = get_connection() + con = get_connection(self.model.keyspace) cur = con.cursor() cur.execute(qs, self._where_values()) return cur.fetchone() @@ -384,42 +392,8 @@ def delete_instance(self, instance): qs += ['WHERE {0}=:{0}'.format(pk_name)] qs = ' '.join(qs) - conn = get_connection() + conn = get_connection(self.model.keyspace) cur = conn.cursor() cur.execute(qs, {pk_name:instance.pk}) - def _create_column_family(self): - #construct query string - qs = ['CREATE TABLE {}'.format(self.column_family_name)] - - #add column types - pkeys = [] - qtypes = [] - def add_column(col): - s = '{} {}'.format(col.db_field_name, col.db_type) - if col.primary_key: pkeys.append(col.db_field_name) - qtypes.append(s) - #add_column(self.model._columns[self.model._pk_name]) - for name, col in self.model._columns.items(): - add_column(col) - - qtypes.append('PRIMARY KEY ({})'.format(', '.join(pkeys))) - - qs += ['({})'.format(', '.join(qtypes))] - qs = ' '.join(qs) - - #add primary key - conn = get_connection() - cur = conn.cursor() - try: - cur.execute(qs) - except BaseException, e: - if 'Cannot add already existing column family' not in e.message: - raise - - def _delete_column_family(self): - conn = get_connection() - cur = conn.cursor() - cur.execute('drop table {};'.format(self.column_family_name)) - diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 9b8f4f359f..45b83d3d5c 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -84,6 +84,11 @@ def test_normal_fields_can_be_defined_between_primary_keys(self): Tests tha non primary key fields can be defined between primary key fields """ + def test_model_keyspace_attribute_must_be_a_string(self): + """ + Tests that users can't set the keyspace to None, or something else + """ + def test_meta_data_is_not_inherited(self): """ Test that metadata defined in one class, is not inherited by subclasses diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 5d0824203d..fe558eb936 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -1,6 +1,9 @@ from unittest import skip from cqlengine.tests.base import BaseCassEngTestCase +from cqlengine.management import create_column_family +from cqlengine.management import delete_column_family +from cqlengine.models import Model from cqlengine.models import Model from cqlengine import columns @@ -12,9 +15,15 @@ class TestModel(Model): class TestModelIO(BaseCassEngTestCase): - def setUp(self): - super(TestModelIO, self).setUp() - TestModel.objects._create_column_family() + @classmethod + def setUpClass(cls): + super(TestModelIO, cls).setUpClass() + create_column_family(TestModel) + + @classmethod + def tearDownClass(cls): + super(TestModelIO, cls).tearDownClass() + delete_column_family(TestModel) def test_model_save_and_load(self): """ diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 9ac767b51f..b59f9ab7ee 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -1,6 +1,8 @@ from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.exceptions import ModelException +from cqlengine.management import create_column_family +from cqlengine.management import delete_column_family from cqlengine.models import Model from cqlengine import columns from cqlengine import query @@ -107,9 +109,9 @@ class TestQuerySetUsage(BaseCassEngTestCase): @classmethod def setUpClass(cls): super(TestQuerySetUsage, cls).setUpClass() - try: TestModel.objects._delete_column_family() + try: delete_column_family(TestModel) except: pass - TestModel.objects._create_column_family() + create_column_family(TestModel) TestModel.objects.create(test_id=0, attempt_id=0, description='try1', expected_result=5, test_result=30) TestModel.objects.create(test_id=0, attempt_id=1, description='try2', expected_result=10, test_result=30) @@ -129,7 +131,8 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): super(TestQuerySetUsage, cls).tearDownClass() - TestModel.objects._delete_column_family() + #TestModel.objects._delete_column_family() + delete_column_family(TestModel) def test_count(self): q = TestModel.objects(test_id=0) @@ -138,7 +141,6 @@ def test_count(self): def test_iteration(self): q = TestModel.objects(test_id=0) #tuple of expected attempt_id, expected_result values - import ipdb; ipdb.set_trace() compare_set = set([(0,5), (1,10), (2,15), (3,20)]) for t in q: val = t.attempt_id, t.expected_result From d67006919a723d831cd5c6d68b6bf5128a2cdb7e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 23 Nov 2012 14:19:40 -0800 Subject: [PATCH 0028/4200] refactoring exceptions --- cqlengine/exceptions.py | 1 - cqlengine/query.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/exceptions.py b/cqlengine/exceptions.py index 3a5444e9b1..0b1ff55f1e 100644 --- a/cqlengine/exceptions.py +++ b/cqlengine/exceptions.py @@ -3,4 +3,3 @@ class CQLEngineException(BaseException): pass class ModelException(CQLEngineException): pass class ValidationError(CQLEngineException): pass -class QueryException(CQLEngineException): pass diff --git a/cqlengine/query.py b/cqlengine/query.py index 71853fe169..86837e93f9 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -4,11 +4,12 @@ from time import time from cqlengine.connection import get_connection -from cqlengine.exceptions import QueryException +from cqlengine.exceptions import CQLEngineException #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index +class QueryException(CQLEngineException): pass class QueryOperatorException(QueryException): pass class QueryOperator(object): From fdae4779048e00f80173d0f94a3bd7bdb178710e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 23 Nov 2012 16:17:26 -0800 Subject: [PATCH 0029/4200] adding column indexing support adding some where clause validation added supporting tests --- cqlengine/columns.py | 7 ++ cqlengine/management.py | 75 +++++++++------- cqlengine/models.py | 17 +++- cqlengine/query.py | 8 +- .../tests/model/test_class_construction.py | 5 ++ cqlengine/tests/query/test_queryset.py | 87 +++++++++++++++++-- 6 files changed, 157 insertions(+), 42 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 0eeb8051d0..9ce1bb6bf1 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -52,6 +52,7 @@ def __init__(self, primary_key=False, index=False, db_field=None, default=None, :param null: boolean, is the field nullable? """ self.primary_key = primary_key + self.index = index self.db_field = db_field self.default = default self.null = null @@ -129,8 +130,14 @@ def set_column_name(self, name): @property def db_field_name(self): + """ Returns the name of the cql name of this column """ return self.db_field or self.column_name + @property + def db_index_name(self): + """ Returns the name of the cql index """ + return 'index_{}'.format(self.db_field_name) + class Bytes(BaseColumn): db_type = 'blob' diff --git a/cqlengine/management.py b/cqlengine/management.py index 07c24acd6e..29fe02ba66 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,50 +1,65 @@ from cqlengine.connection import get_connection - def create_keyspace(name): con = get_connection(None) cur = con.cursor() - cur.execute("""create keyspace {} - with strategy_class = 'SimpleStrategy' - and strategy_options:replication_factor=1;""".format(name)) + cur.execute("""CREATE KEYSPACE {} + WITH strategy_class = 'SimpleStrategy' + AND strategy_options:replication_factor=1;""".format(name)) def delete_keyspace(name): - pass + con = get_connection(None) + cur = con.cursor() + cur.execute("DROP KEYSPACE {}".format(name)) def create_column_family(model): - #TODO: check for existing column family #construct query string - qs = ['CREATE TABLE {}'.format(model.column_family_name())] + cf_name = model.column_family_name() - #add column types - pkeys = [] - qtypes = [] - def add_column(col): - s = '{} {}'.format(col.db_field_name, col.db_type) - if col.primary_key: pkeys.append(col.db_field_name) - qtypes.append(s) - for name, col in model._columns.items(): - add_column(col) + conn = get_connection(model.keyspace) + cur = conn.cursor() - qtypes.append('PRIMARY KEY ({})'.format(', '.join(pkeys))) + #check for an existing column family + ks_info = conn.client.describe_keyspace(model.keyspace) + if not any([cf_name == cf.name for cf in ks_info.cf_defs]): + qs = ['CREATE TABLE {}'.format(cf_name)] - qs += ['({})'.format(', '.join(qtypes))] - qs = ' '.join(qs) + #add column types + pkeys = [] + qtypes = [] + def add_column(col): + s = '{} {}'.format(col.db_field_name, col.db_type) + if col.primary_key: pkeys.append(col.db_field_name) + qtypes.append(s) + for name, col in model._columns.items(): + add_column(col) + + qtypes.append('PRIMARY KEY ({})'.format(', '.join(pkeys))) + + qs += ['({})'.format(', '.join(qtypes))] + qs = ' '.join(qs) - #add primary key - conn = get_connection(model.keyspace) - cur = conn.cursor() - try: cur.execute(qs) - except BaseException, e: - if 'Cannot add already existing column family' not in e.message: - raise + + indexes = [c for n,c in model._columns.items() if c.index] + if indexes: + import ipdb; ipdb.set_trace() + for column in indexes: + #TODO: check for existing index... + #can that be determined from the connection client? + qs = ['CREATE INDEX {}'.format(column.db_index_name)] + qs += ['ON {}'.format(cf_name)] + qs += ['({})'.format(column.db_field_name)] + qs = ' '.join(qs) + cur.execute(qs) def delete_column_family(model): - #TODO: check that model exists + #check that model exists + cf_name = model.column_family_name() conn = get_connection(model.keyspace) - cur = conn.cursor() - cur.execute('drop table {};'.format(model.column_family_name())) + ks_info = conn.client.describe_keyspace(model.keyspace) + if any([cf_name == cf.name for cf in ks_info.cf_defs]): + cur = conn.cursor() + cur.execute('drop table {};'.format(cf_name)) - pass diff --git a/cqlengine/models.py b/cqlengine/models.py index 7b2fa8ead1..862f8e340c 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -4,6 +4,8 @@ from cqlengine.exceptions import ModelException from cqlengine.query import QuerySet +class ModelDefinitionException(ModelException): pass + class BaseModel(object): """ The base model class, don't inherit from this, inherit from Model, defined below @@ -37,12 +39,12 @@ def column_family_name(cls): otherwise, it creates it from the module and class name """ if cls.db_name: - return cls.db_name + return cls.db_name.lower() cf_name = cls.__module__ + '.' + cls.__name__ cf_name = cf_name.replace('.', '_') #trim to less than 48 characters or cassandra will complain cf_name = cf_name[-48:] - return cf_name + return cf_name.lower() @property def pk(self): @@ -66,6 +68,7 @@ def save(self): is_new = self.pk is None self.validate() self.objects.save(self) + #delete any fields that have been deleted / set to none return self def delete(self): @@ -120,13 +123,19 @@ def _transform_column(col_name, col_obj): raise ModelException("{} defines the column {} more than once".format(name, v.db_field_name)) col_names.add(v.db_field_name) + #check for indexes on models with multiple primary keys + if len([1 for k,v in column_definitions if v.primary_key]) > 1: + if len([1 for k,v in column_definitions if v.index]) > 0: + raise ModelDefinitionException( + 'Indexes on models with multiple primary keys is not supported') + #get column family name cf_name = attrs.pop('db_name', name) #create db_name -> model name map for loading db_map = {} - for name, col in _columns.items(): - db_map[col.db_field_name] = name + for field_name, col in _columns.items(): + db_map[col.db_field_name] = field_name #add management members to the class attrs['_columns'] = _columns diff --git a/cqlengine/query.py b/cqlengine/query.py index 86837e93f9..83f78ef7f2 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -97,7 +97,7 @@ class EqualsOperator(QueryOperator): symbol = 'EQ' cql_symbol = '=' -class InOperator(QueryOperator): +class InOperator(EqualsOperator): symbol = 'IN' cql_symbol = 'IN' @@ -158,7 +158,11 @@ def __str__(self): def _validate_where_syntax(self): """ Checks that a filterset will not create invalid cql """ - #TODO: check that there's either a = or IN relationship with a primary key or indexed field + + #check that there's either a = or IN relationship with a primary key or indexed field + equal_ops = [w for w in self._where if isinstance(w, EqualsOperator)] + if not any([w.column.primary_key or w.column.index for w in equal_ops]): + raise QueryException('Where clauses require either a "=" or "IN" comparison with either a primary key or indexed field') #TODO: abuse this to see if we can get cql to raise an exception def _where_clause(self): diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 45b83d3d5c..d2d88a0d98 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -89,6 +89,11 @@ def test_model_keyspace_attribute_must_be_a_string(self): Tests that users can't set the keyspace to None, or something else """ + def test_indexes_arent_allowed_on_models_with_multiple_primary_keys(self): + """ + Tests that attempting to define an index on a model with multiple primary keys fails + """ + def test_meta_data_is_not_inherited(self): """ Test that metadata defined in one class, is not inherited by subclasses diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index b59f9ab7ee..03fdb39674 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -12,6 +12,13 @@ class TestModel(Model): attempt_id = columns.Integer(primary_key=True) description = columns.Text() expected_result = columns.Integer() + test_result = columns.Integer() + +class IndexedTestModel(Model): + test_id = columns.Integer(primary_key=True) + attempt_id = columns.Integer(index=True) + description = columns.Text() + expected_result = columns.Integer() test_result = columns.Integer(index=True) class TestQuerySetOperation(BaseCassEngTestCase): @@ -104,14 +111,15 @@ def test_defining_only_or_defer_on_nonexistant_fields_fails(self): Tests that setting only or defer fields that don't exist raises an exception """ -class TestQuerySetUsage(BaseCassEngTestCase): +class BaseQuerySetUsage(BaseCassEngTestCase): @classmethod def setUpClass(cls): - super(TestQuerySetUsage, cls).setUpClass() - try: delete_column_family(TestModel) - except: pass + super(BaseQuerySetUsage, cls).setUpClass() + delete_column_family(TestModel) + delete_column_family(IndexedTestModel) create_column_family(TestModel) + create_column_family(IndexedTestModel) TestModel.objects.create(test_id=0, attempt_id=0, description='try1', expected_result=5, test_result=30) TestModel.objects.create(test_id=0, attempt_id=1, description='try2', expected_result=10, test_result=30) @@ -128,13 +136,32 @@ def setUpClass(cls): TestModel.objects.create(test_id=2, attempt_id=2, description='try11', expected_result=70, test_result=45) TestModel.objects.create(test_id=2, attempt_id=3, description='try12', expected_result=75, test_result=45) + IndexedTestModel.objects.create(test_id=0, attempt_id=0, description='try1', expected_result=5, test_result=30) + IndexedTestModel.objects.create(test_id=1, attempt_id=1, description='try2', expected_result=10, test_result=30) + IndexedTestModel.objects.create(test_id=2, attempt_id=2, description='try3', expected_result=15, test_result=30) + IndexedTestModel.objects.create(test_id=3, attempt_id=3, description='try4', expected_result=20, test_result=25) + + IndexedTestModel.objects.create(test_id=4, attempt_id=0, description='try5', expected_result=5, test_result=25) + IndexedTestModel.objects.create(test_id=5, attempt_id=1, description='try6', expected_result=10, test_result=25) + IndexedTestModel.objects.create(test_id=6, attempt_id=2, description='try7', expected_result=15, test_result=25) + IndexedTestModel.objects.create(test_id=7, attempt_id=3, description='try8', expected_result=20, test_result=20) + + IndexedTestModel.objects.create(test_id=8, attempt_id=0, description='try9', expected_result=50, test_result=40) + IndexedTestModel.objects.create(test_id=9, attempt_id=1, description='try10', expected_result=60, test_result=40) + IndexedTestModel.objects.create(test_id=10, attempt_id=2, description='try11', expected_result=70, test_result=45) + IndexedTestModel.objects.create(test_id=11, attempt_id=3, description='try12', expected_result=75, test_result=45) + @classmethod def tearDownClass(cls): - super(TestQuerySetUsage, cls).tearDownClass() - #TestModel.objects._delete_column_family() + super(BaseQuerySetUsage, cls).tearDownClass() delete_column_family(TestModel) + delete_column_family(IndexedTestModel) + +class TestQuerySetCountAndSelection(BaseQuerySetUsage): def test_count(self): + assert TestModel.objects.count() == 12 + q = TestModel.objects(test_id=0) assert q.count() == 4 @@ -157,6 +184,54 @@ def test_iteration(self): assert val in compare_set compare_set.remove(val) assert len(compare_set) == 0 + + def test_delete(self): + TestModel.objects.create(test_id=3, attempt_id=0, description='try9', expected_result=50, test_result=40) + TestModel.objects.create(test_id=3, attempt_id=1, description='try10', expected_result=60, test_result=40) + TestModel.objects.create(test_id=3, attempt_id=2, description='try11', expected_result=70, test_result=45) + TestModel.objects.create(test_id=3, attempt_id=3, description='try12', expected_result=75, test_result=45) + + assert TestModel.objects.count() == 16 + assert TestModel.objects(test_id=3).count() == 4 + + TestModel.objects(test_id=3).delete() + + assert TestModel.objects.count() == 12 + assert TestModel.objects(test_id=3).count() == 0 + +class TestQuerySetValidation(BaseQuerySetUsage): + + def test_primary_key_or_index_must_be_specified(self): + """ + Tests that queries that don't have an equals relation to a primary key or indexed field fail + """ + with self.assertRaises(query.QueryException): + q = TestModel.objects(test_result=25) + iter(q) + + def test_primary_key_or_index_must_have_equal_relation_filter(self): + """ + Tests that queries that don't have non equal (>,<, etc) relation to a primary key or indexed field fail + """ + with self.assertRaises(query.QueryException): + q = TestModel.objects(test_id__gt=0) + iter(q) + + + def test_indexed_field_can_be_queried(self): + """ + Tests that queries on an indexed field will work without any primary key relations specified + """ + q = IndexedTestModel.objects(test_result=25) + count = q.count() + assert q.count() == 4 + + + + + + + From aa6b151d068f17218d9e83279a9521f1c70bdccf Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 23 Nov 2012 16:18:14 -0800 Subject: [PATCH 0030/4200] removing the manager file --- cqlengine/manager.py | 65 -------------------------------------------- 1 file changed, 65 deletions(-) delete mode 100644 cqlengine/manager.py diff --git a/cqlengine/manager.py b/cqlengine/manager.py deleted file mode 100644 index b2c2ee0b52..0000000000 --- a/cqlengine/manager.py +++ /dev/null @@ -1,65 +0,0 @@ -#manager class - -from cqlengine.query import QuerySet - -#TODO: refactor this into the QuerySet -class Manager(object): - - def __init__(self, model): - super(Manager, self).__init__() - self.model = model - - @property - def column_family_name(self): - """ - Returns the column family name if it's been defined - otherwise, it creates it from the module and class name - """ - if self.model.db_name: - return self.model.db_name - cf_name = self.model.__module__ + '.' + self.model.__name__ - cf_name = cf_name.replace('.', '_') - #trim to less than 48 characters or cassandra will complain - cf_name = cf_name[-48:] - return cf_name - - def __call__(self, **kwargs): - """ filter shortcut """ - return self.filter(**kwargs) - - def find(self, pk): - """ Returns the row corresponding to the primary key set given """ - #TODO: rework this to work with multiple primary keys - return QuerySet(self.model).find(pk) - - def all(self): - return QuerySet(self.model).all() - - def filter(self, **kwargs): - return QuerySet(self.model).filter(**kwargs) - - def create(self, **kwargs): - return self.model(**kwargs).save() - - def delete(self, **kwargs): - pass - - #----single instance methods---- - def _save_instance(self, instance): - """ - The business end of save, this is called by the models - save method and calls the Query save method. This should - only be called by the model saving itself - """ - QuerySet(self.model).save(instance) - - def _delete_instance(self, instance): - """ Deletes a single instance """ - QuerySet(self.model).delete_instance(instance) - - #----column family create/delete---- - def _create_column_family(self): - QuerySet(self.model)._create_column_family() - - def _delete_column_family(self): - QuerySet(self.model)._delete_column_family() From f75442456f8ebbfad3510cb1c42d4672671f677f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 23 Nov 2012 17:40:15 -0800 Subject: [PATCH 0031/4200] cleaning up --- cqlengine/columns.py | 21 ++------------------- cqlengine/connection.py | 38 -------------------------------------- cqlengine/management.py | 1 - cqlengine/query.py | 6 +----- 4 files changed, 3 insertions(+), 63 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 9ce1bb6bf1..bf27a87460 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -221,24 +221,7 @@ def __init__(self, **kwargs): class Counter(BaseColumn): def __init__(self, **kwargs): - super(DateTime, self).__init__(**kwargs) - raise NotImplementedError - -#TODO: research supercolumns -#http://wiki.apache.org/cassandra/DataModel -#checkout composite columns: -#http://www.datastax.com/dev/blog/introduction-to-composite-columns-part-1 -class List(BaseColumn): - #checkout cql.cqltypes.ListType - def __init__(self, **kwargs): - super(DateTime, self).__init__(**kwargs) - raise NotImplementedError - -class Dict(BaseColumn): - #checkout cql.cqltypes.MapType - def __init__(self, **kwargs): - super(DateTime, self).__init__(**kwargs) + super(Counter, self).__init__(**kwargs) raise NotImplementedError -#checkout cql.cqltypes.SetType -#checkout cql.cqltypes.CompositeType +#TODO: Foreign key fields diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 19a767ac45..dc1a7467fb 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -24,12 +24,6 @@ def get_connection(keyspace, create_missing_keyspace=True): if create_missing_keyspace: from cqlengine.management import create_keyspace create_keyspace(keyspace) - #http://www.datastax.com/docs/1.0/references/cql/CREATE_KEYSPACE - cur = con.cursor() - cur.execute("""create keyspace {} - with strategy_class = 'SimpleStrategy' - and strategy_options:replication_factor=1;""".format(keyspace)) - con.set_initial_keyspace(keyspace) else: raise CQLConnectionError('"{}" is not an existing keyspace'.format(keyspace)) @@ -37,35 +31,3 @@ def get_connection(keyspace, create_missing_keyspace=True): return con -#cli examples here: -#http://wiki.apache.org/cassandra/CassandraCli -#http://www.datastax.com/docs/1.0/dml/using_cql -#http://stackoverflow.com/questions/10871595/how-do-you-create-a-counter-columnfamily-with-cql3-in-cassandra -""" -try: - cur.execute("create table colfam (id int PRIMARY KEY);") -except cql.ProgrammingError: - #cur.execute('drop table colfam;') - pass -""" - -#http://www.datastax.com/docs/1.0/references/cql/INSERT -#updates/inserts do the same thing - -""" -cur.execute('insert into colfam (id, content) values (:id, :content)', dict(id=1, content='yo!')) - -cur.execute('select * from colfam WHERE id=1;') -cur.fetchone() - -cur.execute("update colfam set content='hey' where id=1;") -cur.execute('select * from colfam WHERE id=1;') -cur.fetchone() - -cur.execute('delete from colfam WHERE id=1;') -cur.execute('delete from colfam WHERE id in (1);') -""" - -#TODO: Add alter altering existing schema functionality -#http://www.datastax.com/docs/1.0/references/cql/ALTER_COLUMNFAMILY - diff --git a/cqlengine/management.py b/cqlengine/management.py index 29fe02ba66..5b2ad6aa42 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -43,7 +43,6 @@ def add_column(col): indexes = [c for n,c in model._columns.items() if c.index] if indexes: - import ipdb; ipdb.set_trace() for column in indexes: #TODO: check for existing index... #can that be determined from the connection client? diff --git a/cqlengine/query.py b/cqlengine/query.py index 83f78ef7f2..ce6a859087 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -118,15 +118,11 @@ class LessThanOrEqualOperator(QueryOperator): cql_symbol = '<=' class QuerySet(object): - #TODO: querysets should be executed lazily #TODO: support specifying offset and limit (use slice) (maybe return a mutated queryset) #TODO: support specifying columns to exclude or select only + #TODO: support ORDER BY #TODO: cache results in this instance, but don't copy them on deepcopy - #CQL supports ==, >, >=, <, <=, IN (a,b,c,..n) - #REVERSE, LIMIT - #ORDER BY - def __init__(self, model): super(QuerySet, self).__init__() self.model = model From e0530a883ad500628c19bf5fd051e668a54bfd40 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 23 Nov 2012 21:19:46 -0800 Subject: [PATCH 0032/4200] removing the find method adding initial support for cached queryset results added a dirty fix for iterating over a queryset more than once --- cqlengine/models.py | 6 --- cqlengine/query.py | 52 ++++++++++++++------------ cqlengine/tests/model/test_model_io.py | 6 +-- cqlengine/tests/query/test_queryset.py | 23 ++++++++++++ 4 files changed, 55 insertions(+), 32 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 862f8e340c..9111b7596c 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -26,12 +26,6 @@ def __init__(self, **values): #TODO: note any deferred or only fields so they're not deleted - @classmethod - def find(cls, pk): - """ Loads a document by it's primary key """ - #TODO: rework this to work with multiple primary keys - cls.objects.find(pk) - @classmethod def column_family_name(cls): """ diff --git a/cqlengine/query.py b/cqlengine/query.py index ce6a859087..8ee3d6653d 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -142,6 +142,9 @@ def __init__(self, model): self._defer_fields = [] self._only_fields = [] + #results cache + self._result_cache = None + self._cursor = None def __unicode__(self): @@ -150,6 +153,22 @@ def __unicode__(self): def __str__(self): return str(self.__unicode__()) + def __call__(self, **kwargs): + return self.filter(**kwargs) + + def __deepcopy__(self, memo): + clone = self.__class__(self.model) + for k,v in self.__dict__.items(): + if k in ['_result_cache']: + clone.__dict__[k] = None + else: + clone.__dict__[k] = copy.deepcopy(v, memo) + + return clone + + def __len__(self): + return self.count() + #----query generation / execution---- def _validate_where_syntax(self): @@ -205,6 +224,7 @@ def _select_query(self, count=False): #----Reads------ def __iter__(self): + #TODO: cache results if self._cursor is None: conn = get_connection(self.model.keyspace) self._cursor = conn.cursor() @@ -234,11 +254,14 @@ def _get_next(self): def next(self): instance = self._get_next() - if instance is None: raise StopIteration + if instance is None: + #TODO: this is inefficient, we should be caching the results + self._cursor = None + raise StopIteration return instance def first(self): - pass + return iter(self)._get_next() def all(self): clone = copy.deepcopy(self) @@ -278,11 +301,9 @@ def filter(self, **kwargs): return clone - def __call__(self, **kwargs): - return self.filter(**kwargs) - def count(self): """ Returns the number of rows matched by this query """ + #TODO: check for previous query execution and return row count if it exists con = get_connection(self.model.keyspace) cur = con.cursor() cur.execute(self._select_query(count=True), self._where_values()) @@ -291,20 +312,6 @@ def count(self): def __len__(self): return self.count() - def find(self, pk): - """ - loads one document identified by it's primary key - """ - #TODO: make this a convenience wrapper of the filter method - #TODO: rework this to work with multiple primary keys - qs = 'SELECT * FROM {column_family} WHERE {pk_name}=:{pk_name}' - qs = qs.format(column_family=self.column_family_name, - pk_name=self.model._pk_name) - conn = get_connection(self.model.keyspace) - self._cursor = conn.cursor() - self._cursor.execute(qs, {self.model._pk_name:pk}) - return self._get_next() - def _only_or_defer(self, action, fields): clone = copy.deepcopy(self) if clone._defer_fields or clone._only_fields: @@ -313,7 +320,9 @@ def _only_or_defer(self, action, fields): #check for strange fields missing_fields = [f for f in fields if f not in self.model._columns.keys()] if missing_fields: - raise QueryException("Can't resolve fields {} in {}".format(', '.join(missing_fields), self.model.__name__)) + raise QueryException( + "Can't resolve fields {} in {}".format( + ', '.join(missing_fields), self.model.__name__)) if action == 'defer': clone._defer_fields = fields @@ -378,12 +387,9 @@ def delete(self, columns=[]): qs += ['WHERE {}'.format(self._where_clause())] qs = ' '.join(qs) - #TODO: Return number of rows deleted con = get_connection(self.model.keyspace) cur = con.cursor() cur.execute(qs, self._where_values()) - return cur.fetchone() - def delete_instance(self, instance): diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index fe558eb936..f02a61ec43 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -30,7 +30,7 @@ def test_model_save_and_load(self): Tests that models can be saved and retrieved """ tm = TestModel.objects.create(count=8, text='123456789') - tm2 = TestModel.objects.find(tm.pk) + tm2 = TestModel.objects(id=tm.pk).first() for cname in tm._columns.keys(): self.assertEquals(getattr(tm, cname), getattr(tm2, cname)) @@ -44,7 +44,7 @@ def test_model_updating_works_properly(self): tm.count = 100 tm.save() - tm2 = TestModel.objects.find(tm.pk) + tm2 = TestModel.objects(id=tm.pk).first() self.assertEquals(tm.count, tm2.count) def test_model_deleting_works_properly(self): @@ -53,7 +53,7 @@ def test_model_deleting_works_properly(self): """ tm = TestModel.objects.create(count=8, text='123456789') tm.delete() - tm2 = TestModel.objects.find(tm.pk) + tm2 = TestModel.objects(id=tm.pk).first() self.assertIsNone(tm2) def test_nullable_columns_are_saved_properly(self): diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 03fdb39674..8d91d2da60 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -199,6 +199,29 @@ def test_delete(self): assert TestModel.objects.count() == 12 assert TestModel.objects(test_id=3).count() == 0 +class TestQuerySetIterator(BaseQuerySetUsage): + + def test_multiple_iterations_work_properly(self): + """ Tests that iterating over a query set more than once works """ + q = TestModel.objects(test_id=0) + #tuple of expected attempt_id, expected_result values + compare_set = set([(0,5), (1,10), (2,15), (3,20)]) + for t in q: + val = t.attempt_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 + + #try it again + compare_set = set([(0,5), (1,10), (2,15), (3,20)]) + for t in q: + val = t.attempt_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 + + + class TestQuerySetValidation(BaseQuerySetUsage): def test_primary_key_or_index_must_be_specified(self): From 854de8d375ae25168b7da633b631dbf5a68d8bd3 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 23 Nov 2012 21:33:43 -0800 Subject: [PATCH 0033/4200] adding some todos --- cqlengine/columns.py | 17 +++++++++++------ cqlengine/query.py | 7 +++---- cqlengine/tests/model/test_model_io.py | 2 -- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index bf27a87460..a1ba0a3c57 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -55,6 +55,8 @@ def __init__(self, primary_key=False, index=False, db_field=None, default=None, self.index = index self.db_field = db_field self.default = default + + #TODO: make this required, since fields won't actually be nulled, but deleted self.null = null #the column name in the model definition @@ -116,10 +118,7 @@ def get_column_def(self): """ Returns a column definition for CQL table definition """ - dterms = [self.db_field_name, self.db_type] - #if self.primary_key: - #dterms.append('PRIMARY KEY') - return ' '.join(dterms) + return '{} {}'.format(self.db_field_name, self.db_type) def set_column_name(self, name): """ @@ -214,14 +213,20 @@ def to_database(self, value): class Decimal(BaseColumn): db_type = 'decimal' - #TODO: this + #TODO: decimal field def __init__(self, **kwargs): super(DateTime, self).__init__(**kwargs) raise NotImplementedError class Counter(BaseColumn): + #TODO: counter field def __init__(self, **kwargs): super(Counter, self).__init__(**kwargs) raise NotImplementedError -#TODO: Foreign key fields +class ForeignKey(BaseColumn): + #TODO: Foreign key field + def __init__(self, **kwargs): + super(ForeignKey, self).__init__(**kwargs) + raise NotImplementedError + diff --git a/cqlengine/query.py b/cqlengine/query.py index 8ee3d6653d..c9195c9465 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -17,8 +17,7 @@ class QueryOperator(object): # ie: colname__ symbol = None - # The comparator symbol this operator - # uses in cql + # The comparator symbol this operator uses in cql cql_symbol = None def __init__(self, column, value): @@ -372,6 +371,8 @@ def save(self, instance): cur = conn.cursor() cur.execute(qs, field_values) + #TODO: delete deleted / nulled fields + def create(self, **kwargs): return self.model(**kwargs).save() @@ -379,8 +380,6 @@ def create(self, **kwargs): def delete(self, columns=[]): """ Deletes the contents of a query - - :returns: number of rows deleted """ qs = ['DELETE FROM {}'.format(self.column_family_name)] if self._where: diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index f02a61ec43..cf43f96dc0 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -11,8 +11,6 @@ class TestModel(Model): count = columns.Integer() text = columns.Text() -#class TestModel2(Model): - class TestModelIO(BaseCassEngTestCase): @classmethod From 394266ab983e53c7ce579072fa033ac2e95c97e5 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 24 Nov 2012 10:28:52 -0800 Subject: [PATCH 0034/4200] adding support for order_by --- cqlengine/query.py | 81 ++++++++++++++++++- .../tests/model/test_class_construction.py | 5 ++ cqlengine/tests/query/test_queryset.py | 28 +++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index c9195c9465..46891787a5 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -119,7 +119,6 @@ class LessThanOrEqualOperator(QueryOperator): class QuerySet(object): #TODO: support specifying offset and limit (use slice) (maybe return a mutated queryset) #TODO: support specifying columns to exclude or select only - #TODO: support ORDER BY #TODO: cache results in this instance, but don't copy them on deepcopy def __init__(self, model): @@ -131,7 +130,7 @@ def __init__(self, model): self._where = [] #ordering arguments - self._order = [] + self._order = None #subset selection self._limit = None @@ -215,7 +214,9 @@ def _select_query(self, count=False): qs += ['WHERE {}'.format(self._where_clause())] if not count: - #TODO: add support for limit, start, order by, and reverse + if self._order: + qs += ['ORDER BY {}'.format(self._order)] + #TODO: add support for limit, start, and reverse pass return ' '.join(qs) @@ -231,6 +232,14 @@ def __iter__(self): self._rowcount = self._cursor.rowcount return self + def __getitem__(self, s): + + if isinstance(s, slice): + pass + else: + s = long(s) + pass + def _construct_instance(self, values): #translate column names to model names field_dict = {} @@ -300,6 +309,39 @@ def filter(self, **kwargs): return clone + def order_by(self, colname): + """ + orders the result set. + ordering can only select one column, and it must be the second column in a composite primary key + + Default order is ascending, prepend a '-' to the column name for descending + """ + if colname is None: + clone = copy.deepcopy(self) + clone._order = None + return clone + + order_type = 'DESC' if colname.startswith('-') else 'ASC' + colname = colname.replace('-', '') + + column = self.model._columns.get(colname) + if column is None: + raise QueryException("Can't resolve the column name: '{}'".format(colname)) + + #validate the column selection + if not column.primary_key: + raise QueryException( + "Can't order on '{}', can only order on (clustered) primary keys".format(colname)) + + pks = [v for k,v in self.model._columns.items() if v.primary_key] + if column == pks[0]: + raise QueryException( + "Can't order by the first primary key, clustering (secondary) keys only") + + clone = copy.deepcopy(self) + clone._order = '{} {}'.format(column.db_field_name, order_type) + return clone + def count(self): """ Returns the number of rows matched by this query """ #TODO: check for previous query execution and return row count if it exists @@ -311,6 +353,39 @@ def count(self): def __len__(self): return self.count() +# def set_limits(self, start, limit): +# """ Sets the start and limit parameters """ +# #validate +# if not (start is None or isinstance(start, (int, long))): +# raise QueryException("'start' must be None, or an int or long") +# if not (limit is None or isinstance(limit, (int, long))): +# raise QueryException("'limit' must be None, or an int or long") +# +# clone = copy.deepcopy(self) +# clone._start = start +# clone._limit = limit +# return clone +# +# def start(self, v): +# """ Sets the limit """ +# if not (v is None or isinstance(v, (int, long))): +# raise TypeError +# if v == self._start: +# return self +# clone = copy.deepcopy(self) +# clone._start = v +# return clone +# +# def limit(self, v): +# """ Sets the limit """ +# if not (v is None or isinstance(v, (int, long))): +# raise TypeError +# if v == self._limit: +# return self +# clone = copy.deepcopy(self) +# clone._limit = v +# return clone + def _only_or_defer(self, action, fields): clone = copy.deepcopy(self) if clone._defer_fields or clone._only_fields: diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index d2d88a0d98..fd218e1a5c 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -84,6 +84,11 @@ def test_normal_fields_can_be_defined_between_primary_keys(self): Tests tha non primary key fields can be defined between primary key fields """ + def test_at_least_one_non_primary_key_column_is_required(self): + """ + Tests that an error is raised if a model doesn't contain at least one primary key field + """ + def test_model_keyspace_attribute_must_be_a_string(self): """ Tests that users can't set the keyspace to None, or something else diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 8d91d2da60..256e681697 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -220,7 +220,35 @@ def test_multiple_iterations_work_properly(self): compare_set.remove(val) assert len(compare_set) == 0 +class TestQuerySetOrdering(BaseQuerySetUsage): + def test_order_by_success_case(self): + + q = TestModel.objects(test_id=0).order_by('attempt_id') + expected_order = [0,1,2,3] + for model, expect in zip(q,expected_order): + assert model.attempt_id == expect + + q = q.order_by('-attempt_id') + expected_order.reverse() + for model, expect in zip(q,expected_order): + assert model.attempt_id == expect + + def test_ordering_by_non_second_primary_keys_fail(self): + + with self.assertRaises(query.QueryException): + q = TestModel.objects(test_id=0).order_by('test_id') + + def test_ordering_by_non_primary_keys_fails(self): + with self.assertRaises(query.QueryException): + q = TestModel.objects(test_id=0).order_by('description') + + def test_ordering_on_indexed_columns_fails(self): + with self.assertRaises(query.QueryException): + q = IndexedTestModel.objects(test_id=0).order_by('attempt_id') + +class TestQuerySetSlicing(BaseQuerySetUsage): + pass class TestQuerySetValidation(BaseQuerySetUsage): From 301aa5c1dfb22a4009a7fa7e1a48e4d7e59d69dd Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 24 Nov 2012 16:58:03 -0800 Subject: [PATCH 0035/4200] adding support for select LIMIT --- cqlengine/query.py | 108 ++++++++++++++++++++------------------------- 1 file changed, 47 insertions(+), 61 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 46891787a5..7efd1929a9 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -117,7 +117,7 @@ class LessThanOrEqualOperator(QueryOperator): cql_symbol = '<=' class QuerySet(object): - #TODO: support specifying offset and limit (use slice) (maybe return a mutated queryset) + #TODO: delete empty columns on save #TODO: support specifying columns to exclude or select only #TODO: cache results in this instance, but don't copy them on deepcopy @@ -132,9 +132,9 @@ def __init__(self, model): #ordering arguments self._order = None - #subset selection - self._limit = None - self._start = None + #CQL has a default limit of 10000, it's defined here + #because explicit is better than implicit + self._limit = 10000 #see the defer and only methods self._defer_fields = [] @@ -190,34 +190,28 @@ def _where_values(self): values.update(where.get_dict()) return values - def _select_query(self, count=False): + def _select_query(self): """ Returns a select clause based on the given filter args - - :param count: indicates this should return a count query only """ - qs = [] - if count: - qs += ['SELECT COUNT(*)'] - else: - fields = self.model._columns.keys() - if self._defer_fields: - fields = [f for f in fields if f not in self._defer_fields] - elif self._only_fields: - fields = [f for f in fields if f in self._only_fields] - db_fields = [self.model._columns[f].db_field_name for f in fields] - qs += ['SELECT {}'.format(', '.join(db_fields))] - + fields = self.model._columns.keys() + if self._defer_fields: + fields = [f for f in fields if f not in self._defer_fields] + elif self._only_fields: + fields = [f for f in fields if f in self._only_fields] + db_fields = [self.model._columns[f].db_field_name for f in fields] + + qs = ['SELECT {}'.format(', '.join(db_fields))] qs += ['FROM {}'.format(self.column_family_name)] if self._where: qs += ['WHERE {}'.format(self._where_clause())] - if not count: - if self._order: - qs += ['ORDER BY {}'.format(self._order)] - #TODO: add support for limit, start, and reverse - pass + if self._order: + qs += ['ORDER BY {}'.format(self._order)] + + if self._limit: + qs += ['LIMIT {}'.format(self._limit)] return ' '.join(qs) @@ -235,10 +229,15 @@ def __iter__(self): def __getitem__(self, s): if isinstance(s, slice): - pass + #return a new query with limit defined + #start and step are not supported + if s.start: raise QueryException('CQL does not support START') + if s.step: raise QueryException('step is not supported') + return self.limit(s.stop) else: + #return the object at this index s = long(s) - pass + raise NotImplementedError def _construct_instance(self, values): #translate column names to model names @@ -345,46 +344,33 @@ def order_by(self, colname): def count(self): """ Returns the number of rows matched by this query """ #TODO: check for previous query execution and return row count if it exists + qs = ['SELECT COUNT(*)'] + qs += ['FROM {}'.format(self.column_family_name)] + if self._where: + qs += ['WHERE {}'.format(self._where_clause())] + qs = ' '.join(qs) + con = get_connection(self.model.keyspace) cur = con.cursor() - cur.execute(self._select_query(count=True), self._where_values()) + cur.execute(qs, self._where_values()) return cur.fetchone()[0] - def __len__(self): - return self.count() + def limit(self, v): + """ + Sets the limit on the number of results returned + CQL has a default limit of 10,000 + """ + if not (v is None or isinstance(v, (int, long))): + raise TypeError + if v == self._limit: + return self + + if v < 0: + raise QueryException("Negative limit is not allowed") -# def set_limits(self, start, limit): -# """ Sets the start and limit parameters """ -# #validate -# if not (start is None or isinstance(start, (int, long))): -# raise QueryException("'start' must be None, or an int or long") -# if not (limit is None or isinstance(limit, (int, long))): -# raise QueryException("'limit' must be None, or an int or long") -# -# clone = copy.deepcopy(self) -# clone._start = start -# clone._limit = limit -# return clone -# -# def start(self, v): -# """ Sets the limit """ -# if not (v is None or isinstance(v, (int, long))): -# raise TypeError -# if v == self._start: -# return self -# clone = copy.deepcopy(self) -# clone._start = v -# return clone -# -# def limit(self, v): -# """ Sets the limit """ -# if not (v is None or isinstance(v, (int, long))): -# raise TypeError -# if v == self._limit: -# return self -# clone = copy.deepcopy(self) -# clone._limit = v -# return clone + clone = copy.deepcopy(self) + clone._limit = v + return clone def _only_or_defer(self, action, fields): clone = copy.deepcopy(self) From 2cbf582b0dfc3d05c8494506c54386efd68f1832 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 24 Nov 2012 17:33:14 -0800 Subject: [PATCH 0036/4200] adding column delete on save --- cqlengine/columns.py | 1 + cqlengine/models.py | 18 +++++++++++------- cqlengine/query.py | 20 +++++++++++++++++++- cqlengine/tests/model/test_model_io.py | 7 +------ cqlengine/tests/query/test_queryset.py | 10 ---------- 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index a1ba0a3c57..9236d44e2e 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -12,6 +12,7 @@ def __init__(self, instance, column, value): self.initial_value = value self.value = value + @property def deleted(self): return self.value is None and self.initial_value is not None diff --git a/cqlengine/models.py b/cqlengine/models.py index 9111b7596c..3893bb9e0a 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -24,7 +24,7 @@ def __init__(self, **values): value_mngr = column.value_manager(self, column, values.get(name, None)) self._values[name] = value_mngr - #TODO: note any deferred or only fields so they're not deleted + #TODO: note any absent fields so they're not deleted @classmethod def column_family_name(cls): @@ -75,13 +75,16 @@ class ModelMetaClass(type): def __new__(cls, name, bases, attrs): """ """ - #move column definitions into _columns dict + #move column definitions into columns dict #and set default column names - _columns = OrderedDict() + columns = OrderedDict() + primary_keys = OrderedDict() pk_name = None def _transform_column(col_name, col_obj): - _columns[col_name] = col_obj + columns[col_name] = col_obj + if col_obj.primary_key: + primary_keys[col_name] = col_obj col_obj.set_column_name(col_name) #set properties _get = lambda self: self._values[col_name].getval() @@ -112,7 +115,7 @@ def _transform_column(col_name, col_obj): #check for duplicate column names col_names = set() - for k,v in _columns.items(): + for v in columns.values(): if v.db_field_name in col_names: raise ModelException("{} defines the column {} more than once".format(name, v.db_field_name)) col_names.add(v.db_field_name) @@ -128,11 +131,12 @@ def _transform_column(col_name, col_obj): #create db_name -> model name map for loading db_map = {} - for field_name, col in _columns.items(): + for field_name, col in columns.items(): db_map[col.db_field_name] = field_name #add management members to the class - attrs['_columns'] = _columns + attrs['_columns'] = columns + attrs['_primary_keys'] = primary_keys attrs['_db_map'] = db_map attrs['_pk_name'] = pk_name attrs['_dynamic_columns'] = {} diff --git a/cqlengine/query.py b/cqlengine/query.py index 7efd1929a9..e6cccc9fc7 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -432,7 +432,25 @@ def save(self, instance): cur = conn.cursor() cur.execute(qs, field_values) - #TODO: delete deleted / nulled fields + #TODO: delete deleted / nulled columns + deleted = [k for k,v in instance._values.items() if v.deleted] + if deleted: + import ipdb; ipdb.set_trace() + del_fields = [self.model._columns[f] for f in deleted] + del_fields = [f.db_field_name for f in del_fields if not f.primary_key] + pks = self.model._primary_keys + qs = ['DELETE {}'.format(', '.join(del_fields))] + qs += ['FROM {}'.format(self.column_family_name)] + qs += ['WHERE'] + eq = lambda col: '{0} = :{0}'.format(v.db_field_name) + qs += [' AND '.join([eq(f) for f in pks.values()])] + qs = ' '.join(qs) + + pk_dict = dict([(v.db_field_name, getattr(instance, k)) for k,v in pks.items()]) + cur.execute(qs, pk_dict) + + + def create(self, **kwargs): return self.model(**kwargs).save() diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index cf43f96dc0..09a7280979 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -10,7 +10,7 @@ class TestModel(Model): count = columns.Integer() text = columns.Text() - + class TestModelIO(BaseCassEngTestCase): @classmethod @@ -54,11 +54,6 @@ def test_model_deleting_works_properly(self): tm2 = TestModel.objects(id=tm.pk).first() self.assertIsNone(tm2) - def test_nullable_columns_are_saved_properly(self): - """ - Tests that nullable columns save without any trouble - """ - def test_column_deleting_works_properly(self): """ """ diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 256e681697..b3c1449ad8 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -78,16 +78,6 @@ def test_queryset_is_immutable(self): query2 = query1.filter(expected_result__gte=1) assert len(query2._where) == 2 - def test_queryset_slicing(self): - """ - Check that the limit and start is implemented as iterator slices - """ - - def test_proper_delete_behavior(self): - """ - Tests that deleting the contents of a queryset works properly - """ - def test_the_all_method_clears_where_filter(self): """ Tests that calling all on a queryset with previously defined filters returns a queryset with no filters From 67dd9151a9abbde3aca24390eb5fceee2bf5b89e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 24 Nov 2012 17:37:57 -0800 Subject: [PATCH 0037/4200] fixing name conflict --- cqlengine/models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 3893bb9e0a..f51c5dd27e 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -77,12 +77,12 @@ def __new__(cls, name, bases, attrs): """ #move column definitions into columns dict #and set default column names - columns = OrderedDict() + column_dict = OrderedDict() primary_keys = OrderedDict() pk_name = None def _transform_column(col_name, col_obj): - columns[col_name] = col_obj + column_dict[col_name] = col_obj if col_obj.primary_key: primary_keys[col_name] = col_obj col_obj.set_column_name(col_name) @@ -115,7 +115,7 @@ def _transform_column(col_name, col_obj): #check for duplicate column names col_names = set() - for v in columns.values(): + for v in column_dict.values(): if v.db_field_name in col_names: raise ModelException("{} defines the column {} more than once".format(name, v.db_field_name)) col_names.add(v.db_field_name) @@ -131,11 +131,11 @@ def _transform_column(col_name, col_obj): #create db_name -> model name map for loading db_map = {} - for field_name, col in columns.items(): + for field_name, col in column_dict.items(): db_map[col.db_field_name] = field_name #add management members to the class - attrs['_columns'] = columns + attrs['_columns'] = column_dict attrs['_primary_keys'] = primary_keys attrs['_db_map'] = db_map attrs['_pk_name'] = pk_name From 405a0240ad651b3d5967028f27073a0b9fa8f6b2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 24 Nov 2012 17:38:19 -0800 Subject: [PATCH 0038/4200] refactoring column null arg to required --- cqlengine/columns.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 9236d44e2e..b62666001f 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -43,22 +43,20 @@ class BaseColumn(object): instance_counter = 0 - def __init__(self, primary_key=False, index=False, db_field=None, default=None, null=False): + def __init__(self, primary_key=False, index=False, db_field=None, default=None, required=True): """ :param primary_key: bool flag, indicates this column is a primary key. The first primary key defined on a model is the partition key, all others are cluster keys :param index: bool flag, indicates an index should be created for this column :param db_field: the fieldname this field will map to in the database :param default: the default value, can be a value or a callable (no args) - :param null: boolean, is the field nullable? + :param required: boolean, is the field required? """ self.primary_key = primary_key self.index = index self.db_field = db_field self.default = default - - #TODO: make this required, since fields won't actually be nulled, but deleted - self.null = null + self.required = required #the column name in the model definition self.column_name = None @@ -77,8 +75,8 @@ def validate(self, value): if value is None: if self.has_default: return self.get_default() - elif not self.null: - raise ValidationError('null values are not allowed') + elif self.required: + raise ValidationError('{} - None values are not allowed'.format(self.column_name or self.db_field)) return value def to_python(self, value): From 823e21fdee28b8b1fa284fcd475b98a48d179520 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 24 Nov 2012 17:48:40 -0800 Subject: [PATCH 0039/4200] testing and debugging column deleting --- cqlengine/query.py | 7 ++++--- cqlengine/tests/model/test_model_io.py | 10 +++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index e6cccc9fc7..a62b172d18 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -417,7 +417,9 @@ def save(self, instance): #get defined fields and their column names for name, col in self.model._columns.items(): - value_pairs += [(col.db_field_name, values.get(name))] + val = values.get(name) + if val is None: continue + value_pairs += [(col.db_field_name, val)] #construct query string field_names = zip(*value_pairs)[0] @@ -435,14 +437,13 @@ def save(self, instance): #TODO: delete deleted / nulled columns deleted = [k for k,v in instance._values.items() if v.deleted] if deleted: - import ipdb; ipdb.set_trace() del_fields = [self.model._columns[f] for f in deleted] del_fields = [f.db_field_name for f in del_fields if not f.primary_key] pks = self.model._primary_keys qs = ['DELETE {}'.format(', '.join(del_fields))] qs += ['FROM {}'.format(self.column_family_name)] qs += ['WHERE'] - eq = lambda col: '{0} = :{0}'.format(v.db_field_name) + eq = lambda col: '{0} = :{0}'.format(v.column.db_field_name) qs += [' AND '.join([eq(f) for f in pks.values()])] qs = ' '.join(qs) diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 09a7280979..f408e46ce9 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -9,7 +9,7 @@ class TestModel(Model): count = columns.Integer() - text = columns.Text() + text = columns.Text(required=False) class TestModelIO(BaseCassEngTestCase): @@ -57,4 +57,12 @@ def test_model_deleting_works_properly(self): def test_column_deleting_works_properly(self): """ """ + tm = TestModel.objects.create(count=8, text='123456789') + tm.text = None + tm.save() + + tm2 = TestModel.objects(id=tm.pk).first() + assert tm2.text is None + assert tm2._values['text'].initial_value is None + From 2db6426d0278b24b751b9271b2f92eb4199de383 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 24 Nov 2012 23:11:09 -0800 Subject: [PATCH 0040/4200] added fault tolerant connection manager, which will be easily extended to support connection pooling added support for multiple connections removed dependence on setting keyspace name on opening connection --- cqlengine/connection.py | 103 +++++++++++++++++- cqlengine/management.py | 92 ++++++++-------- cqlengine/models.py | 8 +- cqlengine/query.py | 33 +++--- cqlengine/tests/base.py | 7 ++ cqlengine/tests/management/__init__.py | 0 cqlengine/tests/management/test_management.py | 0 7 files changed, 169 insertions(+), 74 deletions(-) create mode 100644 cqlengine/tests/management/__init__.py create mode 100644 cqlengine/tests/management/test_management.py diff --git a/cqlengine/connection.py b/cqlengine/connection.py index dc1a7467fb..6cf5c1ff4a 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -2,17 +2,67 @@ #http://code.google.com/a/apache-extras.org/p/cassandra-dbapi2 / #http://cassandra.apache.org/doc/cql/CQL.html +from collections import namedtuple +import random + import cql + from cqlengine.exceptions import CQLEngineException +from thrift.transport.TTransport import TTransportException + + class CQLConnectionError(CQLEngineException): pass -_keyspace = 'cassengine_test' +Host = namedtuple('Host', ['name', 'port']) +_hosts = [] +_host_idx = 0 +_conn= None +_username = None +_password = None + +def _set_conn(host): + """ + """ + global _conn + _conn = cql.connect(host.name, host.port, user=_username, password=_password) + _conn.set_cql_version('3.0.0') + +def setup(hosts, username=None, password=None): + """ + Records the hosts and connects to one of them + + :param hosts: list of hosts, strings in the :, or just + """ + global _hosts + global _username + global _password + + _username = username + _password = password + + for host in hosts: + host = host.strip() + host = host.split(':') + if len(host) == 1: + _hosts.append(Host(host[0], 9160)) + elif len(host) == 2: + _hosts.append(Host(*host)) + else: + raise CQLConnectionError("Can't parse {}".format(''.join(host))) + + if not _hosts: + raise CQLConnectionError("At least one host required") + + random.shuffle(_hosts) + host = _hosts[_host_idx] + _set_conn(host) + #TODO: look into the cql connection pool class -_conn = {} -def get_connection(keyspace, create_missing_keyspace=True): - con = _conn.get(keyspace) +_old_conn = {} +def get_connection(keyspace=None, create_missing_keyspace=True): + con = _old_conn.get(keyspace) if con is None: con = cql.connect('127.0.0.1', 9160) con.set_cql_version('3.0.0') @@ -27,7 +77,50 @@ def get_connection(keyspace, create_missing_keyspace=True): else: raise CQLConnectionError('"{}" is not an existing keyspace'.format(keyspace)) - _conn[keyspace] = con + _old_conn[keyspace] = con return con +class connection_manager(object): + """ + Connection failure tolerant connection manager. Written to be used in a 'with' block for connection pooling + """ + def __init__(self): + if not _hosts: + raise CQLConnectionError("No connections have been configured, call cqlengine.connection.setup") + self.keyspace = None + self.con = _conn + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + pass + + def execute(self, query, params={}): + """ + Gets a connection from the pool and executes the given query, returns the cursor + + if there's a connection problem, this will silently create a new connection pool + from the available hosts, and remove the problematic host from the host list + """ + global _host_idx + + for i in range(len(_hosts)): + try: + cur = self.con.cursor() + cur.execute(query, params) + return cur + except TTransportException: + #TODO: check for other errors raised in the event of a connection / server problem + #move to the next connection and set the connection pool + self.con_pool.return_connection(self.con) + self.con = None + _host_idx += 1 + _host_idx %= len(_hosts) + host = _hosts[_host_idx] + _set_conn(host) + self.con = _conn + + raise CQLConnectionError("couldn't reach a Cassandra server") + diff --git a/cqlengine/management.py b/cqlengine/management.py index 5b2ad6aa42..ac967059b1 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,64 +1,62 @@ from cqlengine.connection import get_connection +from cqlengine.connection import connection_manager def create_keyspace(name): - con = get_connection(None) - cur = con.cursor() - cur.execute("""CREATE KEYSPACE {} - WITH strategy_class = 'SimpleStrategy' - AND strategy_options:replication_factor=1;""".format(name)) + with connection_manager() as con: + con.execute("""CREATE KEYSPACE {} + WITH strategy_class = 'SimpleStrategy' + AND strategy_options:replication_factor=1;""".format(name)) def delete_keyspace(name): - con = get_connection(None) - cur = con.cursor() - cur.execute("DROP KEYSPACE {}".format(name)) + with connection_manager() as con: + con.execute("DROP KEYSPACE {}".format(name)) def create_column_family(model): #construct query string cf_name = model.column_family_name() + raw_cf_name = model.column_family_name(include_keyspace=False) + + with connection_manager() as con: + #check for an existing column family + ks_info = con.con.client.describe_keyspace(model.keyspace) + if not any([raw_cf_name == cf.name for cf in ks_info.cf_defs]): + qs = ['CREATE TABLE {}'.format(cf_name)] + + #add column types + pkeys = [] + qtypes = [] + def add_column(col): + s = '{} {}'.format(col.db_field_name, col.db_type) + if col.primary_key: pkeys.append(col.db_field_name) + qtypes.append(s) + for name, col in model._columns.items(): + add_column(col) + + qtypes.append('PRIMARY KEY ({})'.format(', '.join(pkeys))) + + qs += ['({})'.format(', '.join(qtypes))] + qs = ' '.join(qs) - conn = get_connection(model.keyspace) - cur = conn.cursor() - - #check for an existing column family - ks_info = conn.client.describe_keyspace(model.keyspace) - if not any([cf_name == cf.name for cf in ks_info.cf_defs]): - qs = ['CREATE TABLE {}'.format(cf_name)] - - #add column types - pkeys = [] - qtypes = [] - def add_column(col): - s = '{} {}'.format(col.db_field_name, col.db_type) - if col.primary_key: pkeys.append(col.db_field_name) - qtypes.append(s) - for name, col in model._columns.items(): - add_column(col) - - qtypes.append('PRIMARY KEY ({})'.format(', '.join(pkeys))) - - qs += ['({})'.format(', '.join(qtypes))] - qs = ' '.join(qs) - - cur.execute(qs) + con.execute(qs) - indexes = [c for n,c in model._columns.items() if c.index] - if indexes: - for column in indexes: - #TODO: check for existing index... - #can that be determined from the connection client? - qs = ['CREATE INDEX {}'.format(column.db_index_name)] - qs += ['ON {}'.format(cf_name)] - qs += ['({})'.format(column.db_field_name)] - qs = ' '.join(qs) - cur.execute(qs) + indexes = [c for n,c in model._columns.items() if c.index] + if indexes: + for column in indexes: + #TODO: check for existing index... + #can that be determined from the connection client? + qs = ['CREATE INDEX {}'.format(column.db_index_name)] + qs += ['ON {}'.format(cf_name)] + qs += ['({})'.format(column.db_field_name)] + qs = ' '.join(qs) + con.execute(qs) def delete_column_family(model): #check that model exists cf_name = model.column_family_name() - conn = get_connection(model.keyspace) - ks_info = conn.client.describe_keyspace(model.keyspace) - if any([cf_name == cf.name for cf in ks_info.cf_defs]): - cur = conn.cursor() - cur.execute('drop table {};'.format(cf_name)) + raw_cf_name = model.column_family_name(include_keyspace=False) + with connection_manager() as con: + ks_info = con.con.client.describe_keyspace(model.keyspace) + if any([raw_cf_name == cf.name for cf in ks_info.cf_defs]): + con.execute('drop table {};'.format(cf_name)) diff --git a/cqlengine/models.py b/cqlengine/models.py index f51c5dd27e..b17df09fb3 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -24,10 +24,8 @@ def __init__(self, **values): value_mngr = column.value_manager(self, column, values.get(name, None)) self._values[name] = value_mngr - #TODO: note any absent fields so they're not deleted - @classmethod - def column_family_name(cls): + def column_family_name(cls, include_keyspace=True): """ Returns the column family name if it's been defined otherwise, it creates it from the module and class name @@ -38,7 +36,9 @@ def column_family_name(cls): cf_name = cf_name.replace('.', '_') #trim to less than 48 characters or cassandra will complain cf_name = cf_name[-48:] - return cf_name.lower() + cf_name = cf_name.lower() + if not include_keyspace: return cf_name + return '{}.{}'.format(cls.keyspace, cf_name) @property def pk(self): diff --git a/cqlengine/query.py b/cqlengine/query.py index a62b172d18..bf5c1464d8 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -4,6 +4,7 @@ from time import time from cqlengine.connection import get_connection +from cqlengine.connection import connection_manager from cqlengine.exceptions import CQLEngineException #CQL 3 reference: @@ -220,7 +221,7 @@ def _select_query(self): def __iter__(self): #TODO: cache results if self._cursor is None: - conn = get_connection(self.model.keyspace) + conn = get_connection() self._cursor = conn.cursor() self._cursor.execute(self._select_query(), self._where_values()) self._rowcount = self._cursor.rowcount @@ -350,10 +351,9 @@ def count(self): qs += ['WHERE {}'.format(self._where_clause())] qs = ' '.join(qs) - con = get_connection(self.model.keyspace) - cur = con.cursor() - cur.execute(qs, self._where_values()) - return cur.fetchone()[0] + with connection_manager() as con: + cur = con.execute(qs, self._where_values()) + return cur.fetchone()[0] def limit(self, v): """ @@ -430,11 +430,10 @@ def save(self, instance): qs += ["({})".format(', '.join([':'+f for f in field_names]))] qs = ' '.join(qs) - conn = get_connection(self.model.keyspace) - cur = conn.cursor() - cur.execute(qs, field_values) + with connection_manager() as con: + con.execute(qs, field_values) - #TODO: delete deleted / nulled columns + #delete deleted / nulled columns deleted = [k for k,v in instance._values.items() if v.deleted] if deleted: del_fields = [self.model._columns[f] for f in deleted] @@ -448,10 +447,10 @@ def save(self, instance): qs = ' '.join(qs) pk_dict = dict([(v.db_field_name, getattr(instance, k)) for k,v in pks.items()]) - cur.execute(qs, pk_dict) - - + with connection_manager() as con: + con.execute(qs, pk_dict) + def create(self, **kwargs): return self.model(**kwargs).save() @@ -466,9 +465,8 @@ def delete(self, columns=[]): qs += ['WHERE {}'.format(self._where_clause())] qs = ' '.join(qs) - con = get_connection(self.model.keyspace) - cur = con.cursor() - cur.execute(qs, self._where_values()) + with connection_manager() as con: + con.execute(qs, self._where_values()) def delete_instance(self, instance): @@ -478,8 +476,7 @@ def delete_instance(self, instance): qs += ['WHERE {0}=:{0}'.format(pk_name)] qs = ' '.join(qs) - conn = get_connection(self.model.keyspace) - cur = conn.cursor() - cur.execute(qs, {pk_name:instance.pk}) + with connection_manager() as con: + con.execute(qs, {pk_name:instance.pk}) diff --git a/cqlengine/tests/base.py b/cqlengine/tests/base.py index ac1f31d8f1..d94ea69b04 100644 --- a/cqlengine/tests/base.py +++ b/cqlengine/tests/base.py @@ -1,7 +1,14 @@ from unittest import TestCase +from cqlengine import connection class BaseCassEngTestCase(TestCase): + @classmethod + def setUpClass(cls): + super(BaseCassEngTestCase, cls).setUpClass() + if not connection._hosts: + connection.setup(['localhost']) + def assertHasAttr(self, obj, attr): self.assertTrue(hasattr(obj, attr), "{} doesn't have attribute: {}".format(obj, attr)) diff --git a/cqlengine/tests/management/__init__.py b/cqlengine/tests/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py new file mode 100644 index 0000000000..e69de29bb2 From 6816148ef5e1c9d3a173bfdc52043f94f338e827 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 10:47:32 -0800 Subject: [PATCH 0041/4200] removing and get_connection and editing files depending on it --- cqlengine/connection.py | 22 ---------------------- cqlengine/management.py | 1 - cqlengine/query.py | 7 +++---- 3 files changed, 3 insertions(+), 27 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 6cf5c1ff4a..84d6d1eb5b 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -59,28 +59,6 @@ def setup(hosts, username=None, password=None): _set_conn(host) -#TODO: look into the cql connection pool class -_old_conn = {} -def get_connection(keyspace=None, create_missing_keyspace=True): - con = _old_conn.get(keyspace) - if con is None: - con = cql.connect('127.0.0.1', 9160) - con.set_cql_version('3.0.0') - - if keyspace: - try: - con.set_initial_keyspace(keyspace) - except cql.ProgrammingError, e: - if create_missing_keyspace: - from cqlengine.management import create_keyspace - create_keyspace(keyspace) - else: - raise CQLConnectionError('"{}" is not an existing keyspace'.format(keyspace)) - - _old_conn[keyspace] = con - - return con - class connection_manager(object): """ Connection failure tolerant connection manager. Written to be used in a 'with' block for connection pooling diff --git a/cqlengine/management.py b/cqlengine/management.py index ac967059b1..97c565c12b 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,4 +1,3 @@ -from cqlengine.connection import get_connection from cqlengine.connection import connection_manager def create_keyspace(name): diff --git a/cqlengine/query.py b/cqlengine/query.py index bf5c1464d8..921e8d19af 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -3,7 +3,6 @@ from hashlib import md5 from time import time -from cqlengine.connection import get_connection from cqlengine.connection import connection_manager from cqlengine.exceptions import CQLEngineException @@ -221,9 +220,9 @@ def _select_query(self): def __iter__(self): #TODO: cache results if self._cursor is None: - conn = get_connection() - self._cursor = conn.cursor() - self._cursor.execute(self._select_query(), self._where_values()) + #TODO: the query and caching should happen in the same function + with connection_manager() as con: + self._cursor = con.execute(self._select_query(), self._where_values()) self._rowcount = self._cursor.rowcount return self From 3038afbfc17a02d3e105222689f6ddc2bcacc9e6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 11:00:42 -0800 Subject: [PATCH 0042/4200] adding BSD3 license and authors file --- AUTHORS | 8 ++++++++ LICENSE | 9 +++++++++ 2 files changed, 17 insertions(+) create mode 100644 AUTHORS create mode 100644 LICENSE diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000000..3a69691a3e --- /dev/null +++ b/AUTHORS @@ -0,0 +1,8 @@ +PRIMARY AUTHORS + +Blake Eggleston + +CONTRIBUTORS + +Eric Scrivner + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..3833bbf351 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +Copyright (c) 2012, Blake Eggleston and AUTHORS +All rights reserved. + +* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +* Neither the name of the cqlengine nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From f0686a50af396454ab06bf4b9a86091119111282 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 11:49:33 -0800 Subject: [PATCH 0043/4200] updating readme --- README.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index cc2d2fc976..7405b89433 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,73 @@ -cassandraengine +cqlengine =============== -Cassandra ORM for Python in the style of the Django orm and mongoengine +cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine -In it's current state you can define column families, create and delete column families -based on your model definiteions, save models and retrieve models by their primary keys. +[Documentation](https://github.com/bdeggleston/cqlengine/wiki/cqlengine-documentation) -That's about it. Also, the CQL stuff is very basic at this point. +[Report a Bug](https://github.com/bdeggleston/cqlengine/issues) -##TODO -* column ttl? -* ForeignKey/DBRef fields? -* query functionality -* Match column names to mongoengine field names? -* nice column and model class __repr__ +[Users Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-users) +[Dev Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-dev) +## Installation +TODO + +## Getting Started + +```python +#first, define a model +>>> from cqlengine import columns +>>> from cqlengine.models import Model + +>>> class ExampleModel(Model): +>>> example_id = columns.UUID(primary_key=True) +>>> example_type = columns.Integer(index=True) +>>> created_at = columns.DateTime() +>>> description = columns.Text(required=False) + +#next, setup the connection to your cassandra server(s)... +>>> from cqlengine import connection +>>> connection.setup(['127.0.0.1:9160']) + +#...and create your CQL table +>>> from cqlengine.management import create_column_family +>>> create_column_family(ExampleModel) + +#now we can create some rows: +>>> em1 = ExampleModel.objects.create(example_type=0, description="example1") +>>> em2 = ExampleModel.objects.create(example_type=0, description="example2") +>>> em3 = ExampleModel.objects.create(example_type=0, description="example3") +>>> em4 = ExampleModel.objects.create(example_type=0, description="example4") +>>> em5 = ExampleModel.objects.create(example_type=1, description="example5") +>>> em6 = ExampleModel.objects.create(example_type=1, description="example6") +>>> em7 = ExampleModel.objects.create(example_type=1, description="example7") +>>> em8 = ExampleModel.objects.create(example_type=1, description="example8") +# Note: the UUID and DateTime columns will create uuid4 and datetime.now +# values automatically if we don't specify them when creating new rows + +#and now we can run some queries against our table +>>> ExampleModel.objects.count() +8 +>>> q = ExampleModel.objects(example_type=1) +>>> q.count() +4 +>>> for instance in q: +>>> print q.description +example5 +example6 +example7 +example8 + +#here we are applying additional filtering to an existing query +#query objects are immutable, so calling filter returns a new +#query object +>>> q2 = q.filter(example_id=em5.example_id) + +>>> q2.count() +1 +>>> for instance in q2: +>>> print q.description +example5 +``` From 1038b6b13251b8784ddc302c524e5d6ddc0cf5ff Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 11:54:49 -0800 Subject: [PATCH 0044/4200] added automatic keyspace generation to create_column_family added checks for existing keyspaces in create_keyspace and delete_keyspace --- cqlengine/management.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 97c565c12b..de0e93a45c 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -2,19 +2,25 @@ def create_keyspace(name): with connection_manager() as con: - con.execute("""CREATE KEYSPACE {} - WITH strategy_class = 'SimpleStrategy' - AND strategy_options:replication_factor=1;""".format(name)) + if name not in [k.name for k in con.con.client.describe_keyspaces()]: + con.execute("""CREATE KEYSPACE {} + WITH strategy_class = 'SimpleStrategy' + AND strategy_options:replication_factor=1;""".format(name)) def delete_keyspace(name): with connection_manager() as con: - con.execute("DROP KEYSPACE {}".format(name)) + if name in [k.name for k in con.con.client.describe_keyspaces()]: + con.execute("DROP KEYSPACE {}".format(name)) -def create_column_family(model): +def create_column_family(model, create_missing_keyspace=True): #construct query string cf_name = model.column_family_name() raw_cf_name = model.column_family_name(include_keyspace=False) + #create missing keyspace + if create_missing_keyspace: + create_keyspace(model.keyspace) + with connection_manager() as con: #check for an existing column family ks_info = con.con.client.describe_keyspace(model.keyspace) From 382a63875b83ec7a2ab267559e60caec42abfe55 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 12:16:33 -0800 Subject: [PATCH 0045/4200] changing docs url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7405b89433..ba909d4c8c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ cqlengine cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine -[Documentation](https://github.com/bdeggleston/cqlengine/wiki/cqlengine-documentation) +[Documentation](https://github.com/bdeggleston/cqlengine/wiki/Documentation) [Report a Bug](https://github.com/bdeggleston/cqlengine/issues) From 11e217203c3d6523a03fbdd7fdff8d04494038e6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 14:44:05 -0800 Subject: [PATCH 0046/4200] refactoring BaseColumn name --- cqlengine/columns.py | 28 ++++++++++++++-------------- cqlengine/models.py | 1 + 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index b62666001f..98abb96511 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -35,7 +35,7 @@ def get_property(self): else: return property(_get, _set) -class BaseColumn(object): +class Column(object): #the cassandra type this column maps to db_type = None @@ -64,8 +64,8 @@ def __init__(self, primary_key=False, index=False, db_field=None, default=None, self.value = None #keep track of instantiation order - self.position = BaseColumn.instance_counter - BaseColumn.instance_counter += 1 + self.position = Column.instance_counter + Column.instance_counter += 1 def validate(self, value): """ @@ -136,16 +136,16 @@ def db_index_name(self): """ Returns the name of the cql index """ return 'index_{}'.format(self.db_field_name) -class Bytes(BaseColumn): +class Bytes(Column): db_type = 'blob' -class Ascii(BaseColumn): +class Ascii(Column): db_type = 'ascii' -class Text(BaseColumn): +class Text(Column): db_type = 'text' -class Integer(BaseColumn): +class Integer(Column): db_type = 'int' def validate(self, value): @@ -161,13 +161,13 @@ def to_python(self, value): def to_database(self, value): return self.validate(value) -class DateTime(BaseColumn): +class DateTime(Column): db_type = 'timestamp' def __init__(self, **kwargs): super(DateTime, self).__init__(**kwargs) raise NotImplementedError -class UUID(BaseColumn): +class UUID(Column): """ Type 1 or 4 UUID """ @@ -186,7 +186,7 @@ def validate(self, value): raise ValidationError("{} is not a valid uuid".format(value)) return _UUID(val) -class Boolean(BaseColumn): +class Boolean(Column): db_type = 'boolean' def to_python(self, value): @@ -195,7 +195,7 @@ def to_python(self, value): def to_database(self, value): return bool(value) -class Float(BaseColumn): +class Float(Column): db_type = 'double' def validate(self, value): @@ -210,20 +210,20 @@ def to_python(self, value): def to_database(self, value): return self.validate(value) -class Decimal(BaseColumn): +class Decimal(Column): db_type = 'decimal' #TODO: decimal field def __init__(self, **kwargs): super(DateTime, self).__init__(**kwargs) raise NotImplementedError -class Counter(BaseColumn): +class Counter(Column): #TODO: counter field def __init__(self, **kwargs): super(Counter, self).__init__(**kwargs) raise NotImplementedError -class ForeignKey(BaseColumn): +class ForeignKey(Column): #TODO: Foreign key field def __init__(self, **kwargs): super(ForeignKey, self).__init__(**kwargs) diff --git a/cqlengine/models.py b/cqlengine/models.py index b17df09fb3..7b5dc707de 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -103,6 +103,7 @@ def _transform_column(col_name, col_obj): k,v = 'id', columns.UUID(primary_key=True) column_definitions = [(k,v)] + column_definitions + #TODO: check that the defined columns don't conflict with any of the Model API's existing attributes/methods #transform column definitions for k,v in column_definitions: if pk_name is None and v.primary_key: From bf62e234ecd86e040eb74827d8c9d3cc5bd71f53 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 15:20:02 -0800 Subject: [PATCH 0047/4200] added double/single precision option to float field fixing some old references to BaseColumn --- cqlengine/columns.py | 4 ++++ cqlengine/models.py | 2 +- cqlengine/tests/columns/test_validation.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 98abb96511..df7a3a3480 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -198,6 +198,10 @@ def to_database(self, value): class Float(Column): db_type = 'double' + def __init__(self, double_precision=True, **kwargs): + self.db_type = 'double' if double_precision else 'float' + super(Float, self).__init__(**kwargs) + def validate(self, value): try: return float(value) diff --git a/cqlengine/models.py b/cqlengine/models.py index 7b5dc707de..d977742fbc 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -95,7 +95,7 @@ def _transform_column(col_name, col_obj): else: attrs[col_name] = property(_get, _set, _del) - column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.BaseColumn)] + column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.Column)] column_definitions = sorted(column_definitions, lambda x,y: cmp(x[1].position, y[1].position)) #prepend primary key if none has been defined diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index f916fb0ebe..4043dfe89a 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -2,7 +2,7 @@ from cqlengine.tests.base import BaseCassEngTestCase -from cqlengine.columns import BaseColumn +from cqlengine.columns import Column from cqlengine.columns import Bytes from cqlengine.columns import Ascii from cqlengine.columns import Text From f62cc9dab6b7f8a3a5c80c324216805881094eea Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 18:28:13 -0800 Subject: [PATCH 0048/4200] adding scary alpha warning --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ba909d4c8c..a218535d91 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and m [Dev Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-dev) +**NOTE: cqlengine is in alpha and under development, some features may change (hopefully with notice). Make sure to check the changelog and test your app before upgrading** + ## Installation TODO From bd940eff49e1877ffbb4c9487a67d451db2c8349 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 18:28:35 -0800 Subject: [PATCH 0049/4200] adding datetime and decimal columns and tests --- cqlengine/columns.py | 17 +++++--- cqlengine/models.py | 4 +- cqlengine/query.py | 7 +-- cqlengine/tests/columns/test_validation.py | 51 ++++++++++++++++++++++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index df7a3a3480..617b0d4b12 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -1,4 +1,5 @@ #column field types +from datetime import datetime import re from uuid import uuid1, uuid4 @@ -165,7 +166,17 @@ class DateTime(Column): db_type = 'timestamp' def __init__(self, **kwargs): super(DateTime, self).__init__(**kwargs) - raise NotImplementedError + + def to_python(self, value): + if isinstance(value, datetime): + return value + return datetime.fromtimestamp(value) + + def to_database(self, value): + value = super(DateTime, self).to_database(value) + if not isinstance(value, datetime): + raise ValidationError("'{}' is not a datetime object".format(value)) + return value.strftime('%Y-%m-%d %H:%M:%S') class UUID(Column): """ @@ -216,10 +227,6 @@ def to_database(self, value): class Decimal(Column): db_type = 'decimal' - #TODO: decimal field - def __init__(self, **kwargs): - super(DateTime, self).__init__(**kwargs) - raise NotImplementedError class Counter(Column): #TODO: counter field diff --git a/cqlengine/models.py b/cqlengine/models.py index d977742fbc..c629d2041f 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -21,7 +21,9 @@ class BaseModel(object): def __init__(self, **values): self._values = {} for name, column in self._columns.items(): - value_mngr = column.value_manager(self, column, values.get(name, None)) + value = values.get(name, None) + if value is not None: value = column.to_python(value) + value_mngr = column.value_manager(self, column, value) self._values[name] = value_mngr @classmethod diff --git a/cqlengine/query.py b/cqlengine/query.py index 921e8d19af..bb7a5e519f 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -117,9 +117,8 @@ class LessThanOrEqualOperator(QueryOperator): cql_symbol = '<=' class QuerySet(object): - #TODO: delete empty columns on save - #TODO: support specifying columns to exclude or select only #TODO: cache results in this instance, but don't copy them on deepcopy + #TODO: support multiple iterators def __init__(self, model): super(QuerySet, self).__init__() @@ -224,7 +223,9 @@ def __iter__(self): with connection_manager() as con: self._cursor = con.execute(self._select_query(), self._where_values()) self._rowcount = self._cursor.rowcount - return self + return self + else: + raise QueryException("QuerySet only supports a single iterator at a time, though this will be fixed shortly") def __getitem__(self, s): diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 4043dfe89a..3f70ab12cd 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -1,4 +1,6 @@ #tests the behavior of the column classes +from datetime import datetime +from decimal import Decimal as D from cqlengine.tests.base import BaseCassEngTestCase @@ -13,4 +15,53 @@ from cqlengine.columns import Float from cqlengine.columns import Decimal +from cqlengine.management import create_column_family, delete_column_family +from cqlengine.models import Model + +class TestDatetime(BaseCassEngTestCase): + class DatetimeTest(Model): + test_id = Integer(primary_key=True) + created_at = DateTime() + + @classmethod + def setUpClass(cls): + super(TestDatetime, cls).setUpClass() + create_column_family(cls.DatetimeTest) + + @classmethod + def tearDownClass(cls): + super(TestDatetime, cls).tearDownClass() + delete_column_family(cls.DatetimeTest) + + def test_datetime_io(self): + now = datetime.now() + dt = self.DatetimeTest.objects.create(test_id=0, created_at=now) + dt2 = self.DatetimeTest.objects(test_id=0).first() + assert dt2.created_at.timetuple()[:6] == now.timetuple()[:6] + +class TestDecimal(BaseCassEngTestCase): + class DecimalTest(Model): + test_id = Integer(primary_key=True) + dec_val = Decimal() + + @classmethod + def setUpClass(cls): + super(TestDecimal, cls).setUpClass() + create_column_family(cls.DecimalTest) + + @classmethod + def tearDownClass(cls): + super(TestDecimal, cls).tearDownClass() + delete_column_family(cls.DecimalTest) + + def test_datetime_io(self): + dt = self.DecimalTest.objects.create(test_id=0, dec_val=D('0.00')) + dt2 = self.DecimalTest.objects(test_id=0).first() + assert dt2.dec_val == dt.dec_val + + dt = self.DecimalTest.objects.create(test_id=0, dec_val=5) + dt2 = self.DecimalTest.objects(test_id=0).first() + assert dt2.dec_val == D('5') + + From 168c1136023e81df93f7c1ea69e25ebfe24e0a61 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 18:56:38 -0800 Subject: [PATCH 0050/4200] adding setuptools stuff --- MANIFEST.in | 5 +++++ cqlengine/__init__.py | 2 ++ setup.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 MANIFEST.in create mode 100644 setup.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000..d7e1cb6199 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include AUTHORS +include LICENSE +include README.md +recursive-include cqlengine * + diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 1af8944a10..0a826654f8 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,3 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model +__version__ = '0.0.1-ALPHA' + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..afd921b17c --- /dev/null +++ b/setup.py @@ -0,0 +1,43 @@ +from setuptools import setup, find_packages + +version = '0.0.1-ALPHA' + +long_desc = """ +cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine + +[Documentation](https://github.com/bdeggleston/cqlengine/wiki/Documentation) + +[Report a Bug](https://github.com/bdeggleston/cqlengine/issues) + +[Users Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-users) + +[Dev Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-dev) + +**NOTE: cqlengine is in alpha and under development, some features may change (hopefully with notice). Make sure to check the changelog and test your app before upgrading** +""" + +setup( + name='cqlengine', + version=version, + description='Cassandra CQL ORM for Python in the style of the Django orm and mongoengine', + long_description=long_desc, + classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Web Environment", + "Environment :: Plugins", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + keywords='cassandra,cql,orm', + author='Blake Eggleston', + author_email='bdeggleston@gmail.com', + url='https://github.com/bdeggleston/cqlengine', + license='BSD', + packages=find_packages(), + include_package_data=True, +) + From f6c51ae55e23a3fdaeb0d9fc9b0bca61acaaf5e2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 19:04:28 -0800 Subject: [PATCH 0051/4200] adding cql dependency --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index afd921b17c..8fba346530 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ "Topic :: Software Development :: Libraries :: Python Modules", ], keywords='cassandra,cql,orm', + install_requires = ['cql'], author='Blake Eggleston', author_email='bdeggleston@gmail.com', url='https://github.com/bdeggleston/cqlengine', From 1aa7f4ea11a0a96da69ce5b8a7dffad7386ad794 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 21:17:14 -0800 Subject: [PATCH 0052/4200] adding github dependency link --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 8fba346530..fbe3451c70 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ name='cqlengine', version=version, description='Cassandra CQL ORM for Python in the style of the Django orm and mongoengine', + dependency_links = ['https://github.com/bdeggleston/cqlengine/archive/0.0.1-ALPHA.tar.gz#egg=cqlengine-0.0.1-ALPHA.tar.gz'], long_description=long_desc, classifiers = [ "Development Status :: 3 - Alpha", From 823ebbf8c7346b6d44908414d6e6318fa6dd7e55 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 21:49:24 -0800 Subject: [PATCH 0053/4200] removing old reference to con_pool from connection --- cqlengine/connection.py | 1 - setup.py | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 84d6d1eb5b..65147f6ac9 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -92,7 +92,6 @@ def execute(self, query, params={}): except TTransportException: #TODO: check for other errors raised in the event of a connection / server problem #move to the next connection and set the connection pool - self.con_pool.return_connection(self.con) self.con = None _host_idx += 1 _host_idx %= len(_hosts) diff --git a/setup.py b/setup.py index fbe3451c70..570fe6ac31 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,9 @@ from setuptools import setup, find_packages +#next time: +#python setup.py register +#python setup.py sdist upload + version = '0.0.1-ALPHA' long_desc = """ From 35b83d448577c0b90937cd1dcda994e2b3680de2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 21:56:25 -0800 Subject: [PATCH 0054/4200] updating version --- cqlengine/__init__.py | 2 +- setup.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 0a826654f8..6822d1af02 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.0.1-ALPHA' +__version__ = '0.0.2-ALPHA' diff --git a/setup.py b/setup.py index 570fe6ac31..e422ca0be6 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.0.1-ALPHA' +version = '0.0.2-ALPHA' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine @@ -24,7 +24,7 @@ name='cqlengine', version=version, description='Cassandra CQL ORM for Python in the style of the Django orm and mongoengine', - dependency_links = ['https://github.com/bdeggleston/cqlengine/archive/0.0.1-ALPHA.tar.gz#egg=cqlengine-0.0.1-ALPHA.tar.gz'], + dependency_links = ['https://github.com/bdeggleston/cqlengine/archive/0.0.2-ALPHA.tar.gz#egg=cqlengine-0.0.2-ALPHA'], long_description=long_desc, classifiers = [ "Development Status :: 3 - Alpha", From 69546acca86118e848bd72b7bddc74cd67595326 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 25 Nov 2012 22:02:01 -0800 Subject: [PATCH 0055/4200] adding pip instructions --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a218535d91..72780d257d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and m **NOTE: cqlengine is in alpha and under development, some features may change (hopefully with notice). Make sure to check the changelog and test your app before upgrading** ## Installation -TODO +``` +pip install cqlengine +``` ## Getting Started From d844c0ddeb669108195ce1b6a0a119a176002d4a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 26 Nov 2012 21:54:53 -0800 Subject: [PATCH 0056/4200] adding queryset result caching and array indexing / slicing of querysets --- cqlengine/query.py | 117 +++++++++++++++---------- cqlengine/tests/query/test_queryset.py | 49 ++++++++++- 2 files changed, 116 insertions(+), 50 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index bb7a5e519f..74e8810cc1 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -117,8 +117,6 @@ class LessThanOrEqualOperator(QueryOperator): cql_symbol = '<=' class QuerySet(object): - #TODO: cache results in this instance, but don't copy them on deepcopy - #TODO: support multiple iterators def __init__(self, model): super(QuerySet, self).__init__() @@ -140,9 +138,9 @@ def __init__(self, model): self._only_fields = [] #results cache - self._result_cache = None - self._cursor = None + self._result_cache = None + self._result_idx = None def __unicode__(self): return self._select_query() @@ -156,7 +154,7 @@ def __call__(self, **kwargs): def __deepcopy__(self, memo): clone = self.__class__(self.model) for k,v in self.__dict__.items(): - if k in ['_result_cache']: + if k in ['_cursor', '_result_cache', '_result_idx']: clone.__dict__[k] = None else: clone.__dict__[k] = copy.deepcopy(v, memo) @@ -216,29 +214,65 @@ def _select_query(self): #----Reads------ - def __iter__(self): - #TODO: cache results - if self._cursor is None: - #TODO: the query and caching should happen in the same function - with connection_manager() as con: - self._cursor = con.execute(self._select_query(), self._where_values()) - self._rowcount = self._cursor.rowcount - return self + def _execute_query(self): + with connection_manager() as con: + self._cursor = con.execute(self._select_query(), self._where_values()) + self._result_cache = [None]*self._cursor.rowcount + + def _fill_result_cache_to_idx(self, idx): + if self._result_cache is None: + self._execute_query() + if self._result_idx is None: + self._result_idx = -1 + + names = [i[0] for i in self._cursor.description] + qty = idx - self._result_idx + if qty < 1: + return else: - raise QueryException("QuerySet only supports a single iterator at a time, though this will be fixed shortly") + for values in self._cursor.fetchmany(qty): + value_dict = dict(zip(names, values)) + self._result_idx += 1 + self._result_cache[self._result_idx] = self._construct_instance(value_dict) + + def __iter__(self): + if self._result_cache is None: + self._execute_query() + + for idx in range(len(self._result_cache)): + instance = self._result_cache[idx] + if instance is None: + self._fill_result_cache_to_idx(idx) + yield self._result_cache[idx] def __getitem__(self, s): + if self._result_cache is None: + self._execute_query() + + num_results = len(self._result_cache) if isinstance(s, slice): - #return a new query with limit defined - #start and step are not supported - if s.start: raise QueryException('CQL does not support START') - if s.step: raise QueryException('step is not supported') - return self.limit(s.stop) + #calculate the amount of results that need to be loaded + end = num_results if s.step is None else s.step + if end < 0: + end += num_results + else: + end -= 1 + self._fill_result_cache_to_idx(end) + return self._result_cache[s.start:s.stop:s.step] else: #return the object at this index s = long(s) - raise NotImplementedError + + #handle negative indexing + if s < 0: s += num_results + + if s >= num_results: + raise IndexError + else: + self._fill_result_cache_to_idx(s) + return self._result_cache[s] + def _construct_instance(self, values): #translate column names to model names @@ -251,25 +285,11 @@ def _construct_instance(self, values): field_dict[key] = val return self.model(**field_dict) - def _get_next(self): - """ Gets the next cursor result """ - cur = self._cursor - values = cur.fetchone() - if values is None: return - names = [i[0] for i in cur.description] - value_dict = dict(zip(names, values)) - return self._construct_instance(value_dict) - - def next(self): - instance = self._get_next() - if instance is None: - #TODO: this is inefficient, we should be caching the results - self._cursor = None - raise StopIteration - return instance - def first(self): - return iter(self)._get_next() + try: + return iter(self).next() + except StopIteration: + return None def all(self): clone = copy.deepcopy(self) @@ -345,15 +365,18 @@ def order_by(self, colname): def count(self): """ Returns the number of rows matched by this query """ #TODO: check for previous query execution and return row count if it exists - qs = ['SELECT COUNT(*)'] - qs += ['FROM {}'.format(self.column_family_name)] - if self._where: - qs += ['WHERE {}'.format(self._where_clause())] - qs = ' '.join(qs) - - with connection_manager() as con: - cur = con.execute(qs, self._where_values()) - return cur.fetchone()[0] + if self._result_cache is None: + qs = ['SELECT COUNT(*)'] + qs += ['FROM {}'.format(self.column_family_name)] + if self._where: + qs += ['WHERE {}'.format(self._where_clause())] + qs = ' '.join(qs) + + with connection_manager() as con: + cur = con.execute(qs, self._where_values()) + return cur.fetchone()[0] + else: + return len(self._result_cache) def limit(self, v): """ diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index b3c1449ad8..b90888a002 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -210,6 +210,19 @@ def test_multiple_iterations_work_properly(self): compare_set.remove(val) assert len(compare_set) == 0 + def test_multiple_iterators_are_isolated(self): + """ + tests that the use of one iterator does not affect the behavior of another + """ + q = TestModel.objects(test_id=0).order_by('attempt_id') + expected_order = [0,1,2,3] + iter1 = iter(q) + iter2 = iter(q) + for attempt_id in expected_order: + assert iter1.next().attempt_id == attempt_id + assert iter2.next().attempt_id == attempt_id + + class TestQuerySetOrdering(BaseQuerySetUsage): def test_order_by_success_case(self): @@ -238,7 +251,37 @@ def test_ordering_on_indexed_columns_fails(self): q = IndexedTestModel.objects(test_id=0).order_by('attempt_id') class TestQuerySetSlicing(BaseQuerySetUsage): - pass + + def test_out_of_range_index_raises_error(self): + q = TestModel.objects(test_id=0).order_by('attempt_id') + with self.assertRaises(IndexError): + q[10] + + def test_array_indexing_works_properly(self): + q = TestModel.objects(test_id=0).order_by('attempt_id') + expected_order = [0,1,2,3] + for i in range(len(q)): + assert q[i].attempt_id == expected_order[i] + + def test_negative_indexing_works_properly(self): + q = TestModel.objects(test_id=0).order_by('attempt_id') + expected_order = [0,1,2,3] + assert q[-1].attempt_id == expected_order[-1] + assert q[-2].attempt_id == expected_order[-2] + + def test_slicing_works_properly(self): + q = TestModel.objects(test_id=0).order_by('attempt_id') + expected_order = [0,1,2,3] + for model, expect in zip(q[1:3], expected_order[1:3]): + assert model.attempt_id == expect + + def test_negative_slicing(self): + q = TestModel.objects(test_id=0).order_by('attempt_id') + expected_order = [0,1,2,3] + for model, expect in zip(q[-3:], expected_order[-3:]): + assert model.attempt_id == expect + for model, expect in zip(q[:-1], expected_order[:-1]): + assert model.attempt_id == expect class TestQuerySetValidation(BaseQuerySetUsage): @@ -248,7 +291,7 @@ def test_primary_key_or_index_must_be_specified(self): """ with self.assertRaises(query.QueryException): q = TestModel.objects(test_result=25) - iter(q) + [i for i in q] def test_primary_key_or_index_must_have_equal_relation_filter(self): """ @@ -256,7 +299,7 @@ def test_primary_key_or_index_must_have_equal_relation_filter(self): """ with self.assertRaises(query.QueryException): q = TestModel.objects(test_id__gt=0) - iter(q) + [i for i in q] def test_indexed_field_can_be_queried(self): From 74181e2f31545c47c0feb3bcc23bf04ca6e85501 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 26 Nov 2012 22:11:29 -0800 Subject: [PATCH 0057/4200] making the autogenerated table names a bit more readable --- cqlengine/models.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index c629d2041f..6afa240008 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -1,4 +1,5 @@ from collections import OrderedDict +import re from cqlengine import columns from cqlengine.exceptions import ModelException @@ -34,8 +35,16 @@ def column_family_name(cls, include_keyspace=True): """ if cls.db_name: return cls.db_name.lower() - cf_name = cls.__module__ + '.' + cls.__name__ - cf_name = cf_name.replace('.', '_') + + camelcase = re.compile(r'([a-z])([A-Z])') + ccase = lambda s: camelcase.sub(lambda v: '{}_{}'.format(v.group(1), v.group(2).lower()), s) + + cf_name = '' + module = cls.__module__.split('.') + if module: + cf_name = ccase(module[-1]) + '_' + + cf_name += ccase(cls.__name__) #trim to less than 48 characters or cassandra will complain cf_name = cf_name[-48:] cf_name = cf_name.lower() From 70bea617f41b86a89435e43a703aaba1f809f01b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 26 Nov 2012 22:13:21 -0800 Subject: [PATCH 0058/4200] updating version --- cqlengine/__init__.py | 2 +- setup.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 6822d1af02..90414d015b 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.0.2-ALPHA' +__version__ = '0.0.3-ALPHA' diff --git a/setup.py b/setup.py index e422ca0be6..8b7adfe59c 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.0.2-ALPHA' +version = '0.0.3-ALPHA' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine @@ -24,7 +24,7 @@ name='cqlengine', version=version, description='Cassandra CQL ORM for Python in the style of the Django orm and mongoengine', - dependency_links = ['https://github.com/bdeggleston/cqlengine/archive/0.0.2-ALPHA.tar.gz#egg=cqlengine-0.0.2-ALPHA'], + dependency_links = ['https://github.com/bdeggleston/cqlengine/archive/{0}.tar.gz#egg=cqlengine-{0}'.format(version)], long_description=long_desc, classifiers = [ "Development Status :: 3 - Alpha", From 6c006578302a91a33d6a26fb6ad2aa9ceac42b90 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 28 Nov 2012 21:58:56 -0800 Subject: [PATCH 0059/4200] added create shortcut classmethod on BaseModel --- README.md | 16 ++++++++-------- cqlengine/models.py | 4 ++++ cqlengine/tests/model/test_model_io.py | 6 +++--- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 72780d257d..5656f0e2ce 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,14 @@ pip install cqlengine >>> create_column_family(ExampleModel) #now we can create some rows: ->>> em1 = ExampleModel.objects.create(example_type=0, description="example1") ->>> em2 = ExampleModel.objects.create(example_type=0, description="example2") ->>> em3 = ExampleModel.objects.create(example_type=0, description="example3") ->>> em4 = ExampleModel.objects.create(example_type=0, description="example4") ->>> em5 = ExampleModel.objects.create(example_type=1, description="example5") ->>> em6 = ExampleModel.objects.create(example_type=1, description="example6") ->>> em7 = ExampleModel.objects.create(example_type=1, description="example7") ->>> em8 = ExampleModel.objects.create(example_type=1, description="example8") +>>> em1 = ExampleModel.create(example_type=0, description="example1") +>>> em2 = ExampleModel.create(example_type=0, description="example2") +>>> em3 = ExampleModel.create(example_type=0, description="example3") +>>> em4 = ExampleModel.create(example_type=0, description="example4") +>>> em5 = ExampleModel.create(example_type=1, description="example5") +>>> em6 = ExampleModel.create(example_type=1, description="example6") +>>> em7 = ExampleModel.create(example_type=1, description="example7") +>>> em8 = ExampleModel.create(example_type=1, description="example8") # Note: the UUID and DateTime columns will create uuid4 and datetime.now # values automatically if we don't specify them when creating new rows diff --git a/cqlengine/models.py b/cqlengine/models.py index 6afa240008..11f7e3f64a 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -69,6 +69,10 @@ def as_dict(self): values[name] = col.to_database(getattr(self, name, None)) return values + @classmethod + def create(cls, **kwargs): + return cls.objects.create(**kwargs) + def save(self): is_new = self.pk is None self.validate() diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index f408e46ce9..85100eeb13 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -27,7 +27,7 @@ def test_model_save_and_load(self): """ Tests that models can be saved and retrieved """ - tm = TestModel.objects.create(count=8, text='123456789') + tm = TestModel.create(count=8, text='123456789') tm2 = TestModel.objects(id=tm.pk).first() for cname in tm._columns.keys(): @@ -49,7 +49,7 @@ def test_model_deleting_works_properly(self): """ Tests that an instance's delete method deletes the instance """ - tm = TestModel.objects.create(count=8, text='123456789') + tm = TestModel.create(count=8, text='123456789') tm.delete() tm2 = TestModel.objects(id=tm.pk).first() self.assertIsNone(tm2) @@ -57,7 +57,7 @@ def test_model_deleting_works_properly(self): def test_column_deleting_works_properly(self): """ """ - tm = TestModel.objects.create(count=8, text='123456789') + tm = TestModel.create(count=8, text='123456789') tm.text = None tm.save() From 40dda4d839cb8b2f90aedcaabe5da82d4b587f83 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 28 Nov 2012 22:03:23 -0800 Subject: [PATCH 0060/4200] adding changelog --- changelog | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 changelog diff --git a/changelog b/changelog new file mode 100644 index 0000000000..9ab74a3b7b --- /dev/null +++ b/changelog @@ -0,0 +1,10 @@ +CHANGELOG + +0.0.4-ALPHA (in progress) +* added create method shortcut to the model class + +0.0.3-ALPHA +* added queryset result caching +* added queryset array index access and slicing +* updating table name generation (more readable) + From fd39fac6aae5de612895820af1fbeab766207604 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 29 Nov 2012 21:23:03 -0800 Subject: [PATCH 0061/4200] adding IDE stuff --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 878f8ebd3c..6290413ccc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,11 @@ develop-eggs # Installer logs pip-log.txt +# IDE +.project +.pydevproject +.settings/ + # Unit test / coverage reports .coverage .tox From ba7c8c87ee20c97ed05f3a05e12c70cf7c6a5312 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 29 Nov 2012 21:24:02 -0800 Subject: [PATCH 0062/4200] adding get method to QuerySet --- changelog | 1 + cqlengine/models.py | 5 +- cqlengine/query.py | 35 +++++++++--- cqlengine/tests/query/test_queryset.py | 76 ++++++++++++++++++++------ 4 files changed, 90 insertions(+), 27 deletions(-) diff --git a/changelog b/changelog index 9ab74a3b7b..911753f940 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,7 @@ CHANGELOG 0.0.4-ALPHA (in progress) +* added .get() method to QuerySet * added create method shortcut to the model class 0.0.3-ALPHA diff --git a/cqlengine/models.py b/cqlengine/models.py index 11f7e3f64a..0c201cdb6e 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -3,7 +3,7 @@ from cqlengine import columns from cqlengine.exceptions import ModelException -from cqlengine.query import QuerySet +from cqlengine.query import QuerySet, QueryException class ModelDefinitionException(ModelException): pass @@ -11,6 +11,9 @@ class BaseModel(object): """ The base model class, don't inherit from this, inherit from Model, defined below """ + + class DoesNotExist(QueryException): pass + class MultipleObjectsReturned(QueryException): pass #table names will be generated automatically from it's model and package name #however, you can alse define them manually here diff --git a/cqlengine/query.py b/cqlengine/query.py index 74e8810cc1..c85c6c68df 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -215,13 +215,13 @@ def _select_query(self): #----Reads------ def _execute_query(self): - with connection_manager() as con: - self._cursor = con.execute(self._select_query(), self._where_values()) - self._result_cache = [None]*self._cursor.rowcount + if self._result_cache is None: + with connection_manager() as con: + self._cursor = con.execute(self._select_query(), self._where_values()) + self._result_cache = [None]*self._cursor.rowcount def _fill_result_cache_to_idx(self, idx): - if self._result_cache is None: - self._execute_query() + self._execute_query() if self._result_idx is None: self._result_idx = -1 @@ -236,8 +236,7 @@ def _fill_result_cache_to_idx(self, idx): self._result_cache[self._result_idx] = self._construct_instance(value_dict) def __iter__(self): - if self._result_cache is None: - self._execute_query() + self._execute_query() for idx in range(len(self._result_cache)): instance = self._result_cache[idx] @@ -246,8 +245,7 @@ def __iter__(self): yield self._result_cache[idx] def __getitem__(self, s): - if self._result_cache is None: - self._execute_query() + self._execute_query() num_results = len(self._result_cache) @@ -329,6 +327,25 @@ def filter(self, **kwargs): return clone + def get(self, **kwargs): + """ + Returns a single instance matching this query, optionally with additional filter kwargs. + + A DoesNotExistError will be raised if there are no rows matching the query + A MultipleObjectsFoundError will be raised if there is more than one row matching the queyr + """ + if kwargs: return self.filter(**kwargs).get() + self._execute_query() + if len(self._result_cache) == 0: + raise self.model.DoesNotExist + elif len(self._result_cache) > 1: + raise self.model.MultipleObjectsReturned( + '{} objects found'.format(len(self._result_cache))) + else: + return self[0] + + + def order_by(self, colname): """ orders the result set. diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index b90888a002..326bdc9b11 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -147,7 +147,7 @@ def tearDownClass(cls): delete_column_family(TestModel) delete_column_family(IndexedTestModel) -class TestQuerySetCountAndSelection(BaseQuerySetUsage): +class TestQuerySetCountSelectionAndIteration(BaseQuerySetUsage): def test_count(self): assert TestModel.objects.count() == 12 @@ -175,22 +175,6 @@ def test_iteration(self): compare_set.remove(val) assert len(compare_set) == 0 - def test_delete(self): - TestModel.objects.create(test_id=3, attempt_id=0, description='try9', expected_result=50, test_result=40) - TestModel.objects.create(test_id=3, attempt_id=1, description='try10', expected_result=60, test_result=40) - TestModel.objects.create(test_id=3, attempt_id=2, description='try11', expected_result=70, test_result=45) - TestModel.objects.create(test_id=3, attempt_id=3, description='try12', expected_result=75, test_result=45) - - assert TestModel.objects.count() == 16 - assert TestModel.objects(test_id=3).count() == 4 - - TestModel.objects(test_id=3).delete() - - assert TestModel.objects.count() == 12 - assert TestModel.objects(test_id=3).count() == 0 - -class TestQuerySetIterator(BaseQuerySetUsage): - def test_multiple_iterations_work_properly(self): """ Tests that iterating over a query set more than once works """ q = TestModel.objects(test_id=0) @@ -222,6 +206,41 @@ def test_multiple_iterators_are_isolated(self): assert iter1.next().attempt_id == attempt_id assert iter2.next().attempt_id == attempt_id + def test_get_success_case(self): + """ + Tests that the .get() method works on new and existing querysets + """ + m = TestModel.objects.get(test_id=0, attempt_id=0) + assert isinstance(m, TestModel) + assert m.test_id == 0 + assert m.attempt_id == 0 + + q = TestModel.objects(test_id=0, attempt_id=0) + m = q.get() + assert isinstance(m, TestModel) + assert m.test_id == 0 + assert m.attempt_id == 0 + + q = TestModel.objects(test_id=0) + m = q.get(attempt_id=0) + assert isinstance(m, TestModel) + assert m.test_id == 0 + assert m.attempt_id == 0 + + def test_get_doesnotexist_exception(self): + """ + Tests that get calls that don't return a result raises a DoesNotExist error + """ + with self.assertRaises(TestModel.DoesNotExist): + TestModel.objects.get(test_id=100) + + def test_get_multipleobjects_exception(self): + """ + Tests that get calls that return multiple results raise a MultipleObjectsReturned error + """ + with self.assertRaises(TestModel.MultipleObjectsReturned): + TestModel.objects.get(test_id=1) + class TestQuerySetOrdering(BaseQuerySetUsage): @@ -310,6 +329,29 @@ def test_indexed_field_can_be_queried(self): count = q.count() assert q.count() == 4 +class TestQuerySetDelete(BaseQuerySetUsage): + + def test_delete(self): + TestModel.objects.create(test_id=3, attempt_id=0, description='try9', expected_result=50, test_result=40) + TestModel.objects.create(test_id=3, attempt_id=1, description='try10', expected_result=60, test_result=40) + TestModel.objects.create(test_id=3, attempt_id=2, description='try11', expected_result=70, test_result=45) + TestModel.objects.create(test_id=3, attempt_id=3, description='try12', expected_result=75, test_result=45) + + assert TestModel.objects.count() == 16 + assert TestModel.objects(test_id=3).count() == 4 + + TestModel.objects(test_id=3).delete() + + assert TestModel.objects.count() == 12 + assert TestModel.objects(test_id=3).count() == 0 + + def test_delete_without_partition_key(self): + + pass + + def test_delete_without_any_where_args(self): + pass + From 10d753be8def8a3cd10bd5cd67ee7d0068882923 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 29 Nov 2012 21:41:24 -0800 Subject: [PATCH 0063/4200] adding partition key validation to delete method --- changelog | 1 + cqlengine/query.py | 7 +++++-- cqlengine/tests/query/test_queryset.py | 9 ++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/changelog b/changelog index 911753f940..6d805ec337 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,7 @@ CHANGELOG 0.0.4-ALPHA (in progress) +* added partition key validation to QuerySet delete method * added .get() method to QuerySet * added create method shortcut to the model class diff --git a/cqlengine/query.py b/cqlengine/query.py index c85c6c68df..3904ae86ee 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -500,9 +500,12 @@ def delete(self, columns=[]): """ Deletes the contents of a query """ + #validate where clause + partition_key = self.model._primary_keys.values()[0] + if not any([c.column == partition_key for c in self._where]): + raise QueryException("The partition key must be defined on delete queries") qs = ['DELETE FROM {}'.format(self.column_family_name)] - if self._where: - qs += ['WHERE {}'.format(self._where_clause())] + qs += ['WHERE {}'.format(self._where_clause())] qs = ' '.join(qs) with connection_manager() as con: diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 326bdc9b11..842d978a2f 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -346,11 +346,14 @@ def test_delete(self): assert TestModel.objects(test_id=3).count() == 0 def test_delete_without_partition_key(self): - - pass + """ Tests that attempting to delete a model without defining a partition key fails """ + with self.assertRaises(query.QueryException): + TestModel.objects(attempt_id=0).delete() def test_delete_without_any_where_args(self): - pass + """ Tests that attempting to delete a whole table without any arguments will fail """ + with self.assertRaises(query.QueryException): + TestModel.objects(attempt_id=0).delete() From 1cd37d65b0162250a2b68f84304262aaa3b79e71 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 1 Dec 2012 09:43:09 -0800 Subject: [PATCH 0064/4200] removing foreign key stub --- cqlengine/columns.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 617b0d4b12..387aaf3d8f 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -234,9 +234,3 @@ def __init__(self, **kwargs): super(Counter, self).__init__(**kwargs) raise NotImplementedError -class ForeignKey(Column): - #TODO: Foreign key field - def __init__(self, **kwargs): - super(ForeignKey, self).__init__(**kwargs) - raise NotImplementedError - From 798686cd9d7fc570a82dff48ddbafbdb8a2b57e2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 2 Dec 2012 10:00:38 -0800 Subject: [PATCH 0065/4200] changing the create_column_family and delete_column_family management functions to create_table and delete_table, respectively --- README.md | 4 ++-- changelog | 2 ++ cqlengine/management.py | 4 ++-- cqlengine/tests/columns/test_validation.py | 10 +++++----- cqlengine/tests/model/test_model_io.py | 8 ++++---- cqlengine/tests/query/test_queryset.py | 16 ++++++++-------- 6 files changed, 23 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 5656f0e2ce..313003ea0e 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ pip install cqlengine >>> connection.setup(['127.0.0.1:9160']) #...and create your CQL table ->>> from cqlengine.management import create_column_family ->>> create_column_family(ExampleModel) +>>> from cqlengine.management import create_table +>>> create_table(ExampleModel) #now we can create some rows: >>> em1 = ExampleModel.create(example_type=0, description="example1") diff --git a/changelog b/changelog index 6d805ec337..4f4db4ff88 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,8 @@ CHANGELOG 0.0.4-ALPHA (in progress) +* changing create_column_family management function to create_table +* changing delete_column_family management function to delete_table * added partition key validation to QuerySet delete method * added .get() method to QuerySet * added create method shortcut to the model class diff --git a/cqlengine/management.py b/cqlengine/management.py index de0e93a45c..481e30364d 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -12,7 +12,7 @@ def delete_keyspace(name): if name in [k.name for k in con.con.client.describe_keyspaces()]: con.execute("DROP KEYSPACE {}".format(name)) -def create_column_family(model, create_missing_keyspace=True): +def create_table(model, create_missing_keyspace=True): #construct query string cf_name = model.column_family_name() raw_cf_name = model.column_family_name(include_keyspace=False) @@ -56,7 +56,7 @@ def add_column(col): con.execute(qs) -def delete_column_family(model): +def delete_table(model): #check that model exists cf_name = model.column_family_name() raw_cf_name = model.column_family_name(include_keyspace=False) diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 3f70ab12cd..0c2b6c7da2 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -15,7 +15,7 @@ from cqlengine.columns import Float from cqlengine.columns import Decimal -from cqlengine.management import create_column_family, delete_column_family +from cqlengine.management import create_table, delete_table from cqlengine.models import Model class TestDatetime(BaseCassEngTestCase): @@ -26,12 +26,12 @@ class DatetimeTest(Model): @classmethod def setUpClass(cls): super(TestDatetime, cls).setUpClass() - create_column_family(cls.DatetimeTest) + create_table(cls.DatetimeTest) @classmethod def tearDownClass(cls): super(TestDatetime, cls).tearDownClass() - delete_column_family(cls.DatetimeTest) + delete_table(cls.DatetimeTest) def test_datetime_io(self): now = datetime.now() @@ -47,12 +47,12 @@ class DecimalTest(Model): @classmethod def setUpClass(cls): super(TestDecimal, cls).setUpClass() - create_column_family(cls.DecimalTest) + create_table(cls.DecimalTest) @classmethod def tearDownClass(cls): super(TestDecimal, cls).tearDownClass() - delete_column_family(cls.DecimalTest) + delete_table(cls.DecimalTest) def test_datetime_io(self): dt = self.DecimalTest.objects.create(test_id=0, dec_val=D('0.00')) diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 85100eeb13..f65b44efd2 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -1,8 +1,8 @@ from unittest import skip from cqlengine.tests.base import BaseCassEngTestCase -from cqlengine.management import create_column_family -from cqlengine.management import delete_column_family +from cqlengine.management import create_table +from cqlengine.management import delete_table from cqlengine.models import Model from cqlengine.models import Model from cqlengine import columns @@ -16,12 +16,12 @@ class TestModelIO(BaseCassEngTestCase): @classmethod def setUpClass(cls): super(TestModelIO, cls).setUpClass() - create_column_family(TestModel) + create_table(TestModel) @classmethod def tearDownClass(cls): super(TestModelIO, cls).tearDownClass() - delete_column_family(TestModel) + delete_table(TestModel) def test_model_save_and_load(self): """ diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 842d978a2f..ad3cb73e15 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -1,8 +1,8 @@ from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.exceptions import ModelException -from cqlengine.management import create_column_family -from cqlengine.management import delete_column_family +from cqlengine.management import create_table +from cqlengine.management import delete_table from cqlengine.models import Model from cqlengine import columns from cqlengine import query @@ -106,10 +106,10 @@ class BaseQuerySetUsage(BaseCassEngTestCase): @classmethod def setUpClass(cls): super(BaseQuerySetUsage, cls).setUpClass() - delete_column_family(TestModel) - delete_column_family(IndexedTestModel) - create_column_family(TestModel) - create_column_family(IndexedTestModel) + delete_table(TestModel) + delete_table(IndexedTestModel) + create_table(TestModel) + create_table(IndexedTestModel) TestModel.objects.create(test_id=0, attempt_id=0, description='try1', expected_result=5, test_result=30) TestModel.objects.create(test_id=0, attempt_id=1, description='try2', expected_result=10, test_result=30) @@ -144,8 +144,8 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): super(BaseQuerySetUsage, cls).tearDownClass() - delete_column_family(TestModel) - delete_column_family(IndexedTestModel) + delete_table(TestModel) + delete_table(IndexedTestModel) class TestQuerySetCountSelectionAndIteration(BaseQuerySetUsage): From 112da2a6a89ea2a920fe4faa189c2b20bb575c26 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 2 Dec 2012 10:44:49 -0800 Subject: [PATCH 0066/4200] Adding sphinx documentation --- .gitignore | 3 + changelog | 1 + docs/Makefile | 153 +++++++++++++++++++++ docs/conf.py | 242 +++++++++++++++++++++++++++++++++ docs/index.rst | 100 ++++++++++++++ docs/make.bat | 190 ++++++++++++++++++++++++++ docs/topics/columns.rst | 100 ++++++++++++++ docs/topics/connection.rst | 19 +++ docs/topics/manage_schemas.rst | 43 ++++++ docs/topics/models.rst | 136 ++++++++++++++++++ docs/topics/queryset.rst | 225 ++++++++++++++++++++++++++++++ requirements.txt | 1 + 12 files changed, 1213 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/topics/columns.rst create mode 100644 docs/topics/connection.rst create mode 100644 docs/topics/manage_schemas.rst create mode 100644 docs/topics/models.rst create mode 100644 docs/topics/queryset.rst diff --git a/.gitignore b/.gitignore index 6290413ccc..bd169dfade 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ develop-eggs # Installer logs pip-log.txt +# Docs +html/ + # IDE .project .pydevproject diff --git a/changelog b/changelog index 4f4db4ff88..a75b17c221 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,7 @@ CHANGELOG 0.0.4-ALPHA (in progress) +* added Sphinx docs * changing create_column_family management function to create_table * changing delete_column_family management function to delete_table * added partition key validation to QuerySet delete method diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000000..ca198684e1 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/cqlengine.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/cqlengine.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/cqlengine" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/cqlengine" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000000..e41747b73e --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# +# cqlengine documentation build configuration file, created by +# sphinx-quickstart on Sat Dec 1 09:50:49 2012. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.pngmath', 'sphinx.ext.mathjax'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'cqlengine' +copyright = u'2012, Blake Eggleston' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.0.3' +# The full version, including alpha/beta/rc tags. +release = '0.0.3' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'cqlenginedoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'cqlengine.tex', u'cqlengine Documentation', + u'Blake Eggleston', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'cqlengine', u'cqlengine Documentation', + [u'Blake Eggleston'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'cqlengine', u'cqlengine Documentation', + u'Blake Eggleston', 'cqlengine', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000000..2559a1006e --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,100 @@ + +.. _index: + +======================= +cqlengine documentation +======================= + +cqlengine is a Cassandra CQL ORM for Python with an interface similar to the Django orm and mongoengine + +:ref:`getting-started` + +Contents: + +.. toctree:: + :maxdepth: 2 + + topics/models + topics/queryset + topics/columns + topics/connection + topics/manage_schemas + +.. _getting-started: + +Getting Started +=============== + + .. code-block:: python + + #first, define a model + >>> from cqlengine import columns + >>> from cqlengine import Model + + >>> class ExampleModel(Model): + >>> example_id = columns.UUID(primary_key=True) + >>> example_type = columns.Integer(index=True) + >>> created_at = columns.DateTime() + >>> description = columns.Text(required=False) + + #next, setup the connection to your cassandra server(s)... + >>> from cqlengine import connection + >>> connection.setup(['127.0.0.1:9160']) + + #...and create your CQL table + >>> from cqlengine.management import create_table + >>> create_table(ExampleModel) + + #now we can create some rows: + >>> em1 = ExampleModel.create(example_type=0, description="example1") + >>> em2 = ExampleModel.create(example_type=0, description="example2") + >>> em3 = ExampleModel.create(example_type=0, description="example3") + >>> em4 = ExampleModel.create(example_type=0, description="example4") + >>> em5 = ExampleModel.create(example_type=1, description="example5") + >>> em6 = ExampleModel.create(example_type=1, description="example6") + >>> em7 = ExampleModel.create(example_type=1, description="example7") + >>> em8 = ExampleModel.create(example_type=1, description="example8") + # Note: the UUID and DateTime columns will create uuid4 and datetime.now + # values automatically if we don't specify them when creating new rows + + #and now we can run some queries against our table + >>> ExampleModel.objects.count() + 8 + >>> q = ExampleModel.objects(example_type=1) + >>> q.count() + 4 + >>> for instance in q: + >>> print q.description + example5 + example6 + example7 + example8 + + #here we are applying additional filtering to an existing query + #query objects are immutable, so calling filter returns a new + #query object + >>> q2 = q.filter(example_id=em5.example_id) + + >>> q2.count() + 1 + >>> for instance in q2: + >>> print q.description + example5 + + +`Report a Bug `_ + +`Users Mailing List `_ + +`Dev Mailing List `_ + +**NOTE: cqlengine is in alpha and under development, some features may change. Make sure to check the changelog and test your app before upgrading** + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000000..6be2277f78 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,190 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\cqlengine.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\cqlengine.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst new file mode 100644 index 0000000000..dc87f8cbdf --- /dev/null +++ b/docs/topics/columns.rst @@ -0,0 +1,100 @@ +======= +Columns +======= + +.. module:: cqlengine.columns + +.. class:: Bytes() + + Stores arbitrary bytes (no validation), expressed as hexadecimal :: + + columns.Bytes() + + +.. class:: Ascii() + + Stores a US-ASCII character string :: + + columns.Ascii() + + +.. class:: Text() + + Stores a UTF-8 encoded string :: + + columns.Text() + +.. class:: Integer() + + Stores an integer value :: + + columns.Integer() + +.. class:: DateTime() + + Stores a datetime value. + + Python's datetime.now callable is set as the default value for this column :: + + columns.DateTime() + +.. class:: UUID() + + Stores a type 1 or type 4 UUID. + + Python's uuid.uuid4 callable is set as the default value for this column. :: + + columns.UUID() + +.. class:: Boolean() + + Stores a boolean True or False value :: + + columns.Boolean() + +.. class:: Float() + + Stores a floating point value :: + + columns.Float() + + **options** + + :attr:`~columns.Float.double_precision` + If True, stores a double precision float value, otherwise single precision. Defaults to True. + +.. class:: Decimal() + + Stores a variable precision decimal value :: + + columns.Decimal() + +Column Options +============== + + Each column can be defined with optional arguments to modify the way they behave. While some column types may define additional column options, these are the options that are available on all columns: + + .. attribute:: BaseColumn.primary_key + + If True, this column is created as a primary key field. A model can have multiple primary keys. Defaults to False. + + *In CQL, there are 2 types of primary keys: partition keys and clustering keys. As with CQL, the first primary key is the partition key, and all others are clustering keys.* + + .. attribute:: BaseColumn.index + + If True, an index will be created for this column. Defaults to False. + + *Note: Indexes can only be created on models with one primary key* + + .. attribute:: BaseColumn.db_field + + Explicitly sets the name of the column in the database table. If this is left blank, the column name will be the same as the name of the column attribute. Defaults to None. + + .. attribute:: BaseColumn.default + + The default value for this column. If a model instance is saved without a value for this column having been defined, the default value will be used. This can be either a value or a callable object (ie: datetime.now is a valid default argument). + + .. attribute:: BaseColumn.required + + If True, this model cannot be saved without a value defined for this column. Defaults to True. Primary key fields cannot have their required fields set to False. + diff --git a/docs/topics/connection.rst b/docs/topics/connection.rst new file mode 100644 index 0000000000..14bc2341c3 --- /dev/null +++ b/docs/topics/connection.rst @@ -0,0 +1,19 @@ +============== +Connection +============== + +.. module:: cqlengine.connection + +The setup function in `cqlengine.connection` records the Cassandra servers to connect to. +If there is a problem with one of the servers, cqlengine will try to connect to each of the other connections before failing. + +.. function:: setup(hosts [, username=None, password=None]) + + :param hosts: list of hosts, strings in the :, or just + :type hosts: list + + Records the hosts and connects to one of them + +See the example at :ref:`getting-started` + + diff --git a/docs/topics/manage_schemas.rst b/docs/topics/manage_schemas.rst new file mode 100644 index 0000000000..2174e56f6a --- /dev/null +++ b/docs/topics/manage_schemas.rst @@ -0,0 +1,43 @@ +=============== +Managing Schmas +=============== + +.. module:: cqlengine.management + +Once a connection has been made to Cassandra, you can use the functions in ``cqlengine.management`` to create and delete keyspaces, as well as create and delete tables for defined models + +.. function:: create_keyspace(name) + + :param name: the keyspace name to create + :type name: string + + creates a keyspace with the given name + +.. function:: delete_keyspace(name) + + :param name: the keyspace name to delete + :type name: string + + deletes the keyspace with the given name + +.. function:: create_table(model [, create_missing_keyspace=True]) + + :param model: the :class:`~cqlengine.model.Model` class to make a table with + :type model: :class:`~cqlengine.model.Model` + :param create_missing_keyspace: *Optional* If True, the model's keyspace will be created if it does not already exist. Defaults to ``True`` + :type create_missing_keyspace: bool + + creates a CQL table for the given model + +.. function:: delete_table(model) + + :param model: the :class:`~cqlengine.model.Model` class to delete a column family for + :type model: :class:`~cqlengine.model.Model` + + deletes the CQL table for the given model + + + +See the example at :ref:`getting-started` + + diff --git a/docs/topics/models.rst b/docs/topics/models.rst new file mode 100644 index 0000000000..3ddefc37e4 --- /dev/null +++ b/docs/topics/models.rst @@ -0,0 +1,136 @@ +====== +Models +====== + +.. module:: cqlengine.models + +A model is a python class representing a CQL table. + +Example +======= + +This example defines a Person table, with the columns ``first_name`` and ``last_name`` + +.. code-block:: python + + from cqlengine import columns + from cqlengine.models import Model + + class Person(Model): + first_name = columns.Text() + last_name = columns.Text() + + +The Person model would create this CQL table: + +.. code-block:: sql + + CREATE TABLE cqlengine.person ( + id uuid, + first_name text, + last_name text, + PRIMARY KEY (id) + ) + +Columns +======= + + Columns in your models map to columns in your CQL table. You define CQL columns by defining column attributes on your model classes. For a model to be valid it needs at least one primary key column (defined automatically if you don't define one) and one non-primary key column. + + Just as in CQL, the order you define your columns in is important, and is the same order they are defined in on a model's corresponding table. + +Column Types +============ + + Each column on your model definitions needs to an instance of a Column class. The column types that are included with cqlengine as of this writing are: + + * :class:`~cqlengine.columns.Bytes` + * :class:`~cqlengine.columns.Ascii` + * :class:`~cqlengine.columns.Text` + * :class:`~cqlengine.columns.Integer` + * :class:`~cqlengine.columns.DateTime` + * :class:`~cqlengine.columns.UUID` + * :class:`~cqlengine.columns.Boolean` + * :class:`~cqlengine.columns.Float` + * :class:`~cqlengine.columns.Decimal` + + A time uuid field is in the works. + +Column Options +-------------- + + Each column can be defined with optional arguments to modify the way they behave. While some column types may define additional column options, these are the options that are available on all columns: + + :attr:`~cqlengine.columns.BaseColumn.primary_key` + If True, this column is created as a primary key field. A model can have multiple primary keys. Defaults to False. + + *In CQL, there are 2 types of primary keys: partition keys and clustering keys. As with CQL, the first primary key is the partition key, and all others are clustering keys.* + + :attr:`~cqlengine.columns.BaseColumn.index` + If True, an index will be created for this column. Defaults to False. + + *Note: Indexes can only be created on models with one primary key* + + :attr:`~cqlengine.columns.BaseColumn.db_field` + Explicitly sets the name of the column in the database table. If this is left blank, the column name will be the same as the name of the column attribute. Defaults to None. + + :attr:`~cqlengine.columns.BaseColumn.default` + The default value for this column. If a model instance is saved without a value for this column having been defined, the default value will be used. This can be either a value or a callable object (ie: datetime.now is a valid default argument). + + :attr:`~cqlengine.columns.BaseColumn.required` + If True, this model cannot be saved without a value defined for this column. Defaults to True. Primary key fields cannot have their required fields set to False. + +Model Methods +============= + Below are the methods that can be called on model instances. + +.. class:: Model(\*\*values) + + Creates an instance of the model. Pass in keyword arguments for columns you've defined on the model. + + *Example* + + .. code-block:: python + + #using the person model from earlier: + class Person(Model): + first_name = columns.Text() + last_name = columns.Text() + + person = Person(first_name='Blake', last_name='Eggleston') + person.first_name #returns 'Blake' + person.last_name #returns 'Eggleston' + + + .. method:: save() + + Saves an object to the database + + *Example* + + .. code-block:: python + + #create a person instance + person = Person(first_name='Kimberly', last_name='Eggleston') + #saves it to Cassandra + person.save() + + + .. method:: delete() + + Deletes the object from the database. + +Model Attributes +================ + + .. attribute:: Model.db_name + + *Optional.* Sets the name of the CQL table for this model. If left blank, the table name will be the name of the model, with it's module name as it's prefix + + .. attribute:: Model.keyspace + + *Optional.* Sets the name of the keyspace used by this model. Defaulst to cqlengine + +Automatic Primary Keys +====================== + CQL requires that all tables define at least one primary key. If a model definition does not include a primary key column, cqlengine will automatically add a uuid primary key column named ``id``. diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst new file mode 100644 index 0000000000..63b76d03a6 --- /dev/null +++ b/docs/topics/queryset.rst @@ -0,0 +1,225 @@ +============== +Making Queries +============== + +.. module:: cqlengine.query + +Retrieving objects +================== + Once you've populated Cassandra with data, you'll probably want to retrieve some of it. This is accomplished with QuerySet objects. This section will describe how to use QuerySet objects to retrieve the data you're looking for. + +Retrieving all objects +---------------------- + The simplest query you can make is to return all objects from a table. + + This is accomplished with the ``.all()`` method, which returns a QuerySet of all objects in a table + + Using the Person example model, we would get all Person objects like this: + + .. code-block:: python + + all_objects = Person.objects.all() + +.. _retrieving-objects-with-filters: + +Retrieving objects with filters +---------------------------------------- + Typically, you'll want to query only a subset of the records in your database. + + That can be accomplished with the QuerySet's ``.filter(\*\*)`` method. + + For example, given the model definition: + + .. code-block:: python + + class Automobile(Model): + manufacturer = columns.Text(primary_key=True) + year = columns.Integer(primary_key=True) + model = columns.Text() + price = columns.Decimal() + + ...and assuming the Automobile table contains a record of every car model manufactured in the last 20 years or so, we can retrieve only the cars made by a single manufacturer like this: + + + .. code-block:: python + + q = Automobile.objects.filter(manufacturer='Tesla') + + We can then further filter our query with another call to **.filter** + + .. code-block:: python + + q = q.filter(year=2012) + + *Note: all queries involving any filtering MUST define either an '=' or an 'in' relation to either a primary key column, or an indexed column.* + +Accessing objects in a QuerySet +=============================== + + There are several methods for getting objects out of a queryset + + * iterating over the queryset + .. code-block:: python + + for car in Automobile.objects.all(): + #...do something to the car instance + pass + + * list index + .. code-block:: python + + q = Automobile.objects.all() + q[0] #returns the first result + q[1] #returns the second result + + + * list slicing + .. code-block:: python + + q = Automobile.objects.all() + q[1:] #returns all results except the first + q[1:9] #returns a slice of the results + + *Note: CQL does not support specifying a start position in it's queries. Therefore, accessing elements using array indexing / slicing will load every result up to the index value requested* + + * calling :attr:`get() ` on the queryset + .. code-block:: python + + q = Automobile.objects.filter(manufacturer='Tesla') + q = q.filter(year=2012) + car = q.get() + + this returns the object matching the queryset + + * calling :attr:`first() ` on the queryset + .. code-block:: python + + q = Automobile.objects.filter(manufacturer='Tesla') + q = q.filter(year=2012) + car = q.first() + + this returns the first value in the queryset + +.. _query-filtering-operators: + +Filtering Operators +=================== + + :attr:`Equal To ` + + The default filtering operator. + + .. code-block:: python + + q = Automobile.objects.filter(manufacturer='Tesla') + q = q.filter(year=2012) #year == 2012 + + In addition to simple equal to queries, cqlengine also supports querying with other operators by appending a ``__`` to the field name on the filtering call + + :attr:`in (__in) ` + + .. code-block:: python + + q = Automobile.objects.filter(manufacturer='Tesla') + q = q.filter(year__in=[2011, 2012]) + + :attr:`> (__gt) ` + + .. code-block:: python + + q = Automobile.objects.filter(manufacturer='Tesla') + q = q.filter(year__gt=2010) # year > 2010 + + :attr:`>= (__gte) ` + + .. code-block:: python + + q = Automobile.objects.filter(manufacturer='Tesla') + q = q.filter(year__gte=2010) # year >= 2010 + + :attr:`< (__lt) ` + + .. code-block:: python + + q = Automobile.objects.filter(manufacturer='Tesla') + q = q.filter(year__lt=2012) # year < 2012 + + :attr:`<= (__lte) ` + + .. code-block:: python + + q = Automobile.objects.filter(manufacturer='Tesla') + q = q.filter(year__lte=2012) # year <= 2012 + +QuerySets are imutable +====================== + + When calling any method that changes a queryset, the method does not actually change the queryset object it's called on, but returns a new queryset object with the attributes of the original queryset, plus the attributes added in the method call. + + *Example* + + .. code-block:: python + + #this produces 3 different querysets + #q does not change after it's initial definition + q = Automobiles.objects.filter(year=2012) + tesla2012 = q.filter(manufacturer='Tesla') + honda2012 = q.filter(manufacturer='Honda') + +Ordering QuerySets +================== + + Since Cassandra is essentially a distributed hash table on steroids, the order you get records back in will not be particularly predictable. + + However, you can set a column to order on with the ``.order_by(column_name)`` method. + + *Example* + + .. code-block:: python + + #sort ascending + q = Automobiles.objects.all().order_by('year') + #sort descending + q = Automobiles.objects.all().order_by('-year') + + *Note: Cassandra only supports ordering on a clustering key. In other words, to support ordering results, your model must have more than one primary key, and you must order on a primary key, excluding the first one.* + + *For instance, given our Automobile model, year is the only column we can order on.* + +QuerySet method reference +========================= + +.. class:: QuerySet + + .. method:: all() + + Returns a queryset matching all rows + + .. method:: count() + + Returns the number of matching rows in your QuerySet + + .. method:: filter(\*\*values) + + :param values: See :ref:`retrieving-objects-with-filters` + + Returns a QuerySet filtered on the keyword arguments + + .. method:: get(\*\*values) + + :param values: See :ref:`retrieving-objects-with-filters` + + Returns a single object matching the QuerySet. If no objects are matched, a :attr:`~models.Model.DoesNotExist` exception is raised. If more than one object is found, a :attr:`~models.Model.MultipleObjectsReturned` exception is raised. + + .. method:: limit(num) + + Limits the number of results returned by Cassandra. + + *Note that CQL's default limit is 10,000, so all queries without a limit set explicitly will have an implicit limit of 10,000* + + .. method:: order_by(field_name) + + :param field_name: the name of the field to order on. *Note: the field_name must be a clustering key* + :type field_name: string + + Sets the field to order on. diff --git a/requirements.txt b/requirements.txt index 8411d39556..a16d2a9cc7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ cql==1.2.0 ipython==0.13.1 ipdb==0.7 +Sphinx==1.1.3 From db3a7751fb08f2041219d9900bc7ad3e73f8cd35 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 2 Dec 2012 10:51:42 -0800 Subject: [PATCH 0067/4200] updating documentation link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 313003ea0e..4eceed1fb6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ cqlengine cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine -[Documentation](https://github.com/bdeggleston/cqlengine/wiki/Documentation) +[Documentation](https://cqlengine.readthedocs.org/en/latest/) [Report a Bug](https://github.com/bdeggleston/cqlengine/issues) From 7afe9300ff37c788536434fd966c274e6611700f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 2 Dec 2012 11:02:09 -0800 Subject: [PATCH 0068/4200] updating version# --- changelog | 2 +- cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/changelog b/changelog index a75b17c221..652dda1d7c 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,6 @@ CHANGELOG -0.0.4-ALPHA (in progress) +0.0.4-ALPHA * added Sphinx docs * changing create_column_family management function to create_table * changing delete_column_family management function to delete_table diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 90414d015b..8fed278532 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.0.3-ALPHA' +__version__ = '0.0.4-ALPHA' diff --git a/docs/conf.py b/docs/conf.py index e41747b73e..e15b8f3ec1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.0.3' +version = '0.0.4' # The full version, including alpha/beta/rc tags. -release = '0.0.3' +release = '0.0.4-ALPHA' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 8b7adfe59c..de11c701bf 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.0.3-ALPHA' +version = '0.0.4-ALPHA' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine From 192f1914b3ddfe4ca647908481afdf977ea6fb79 Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Mon, 3 Dec 2012 20:22:42 -0800 Subject: [PATCH 0069/4200] Add Connection Pooling --- cqlengine/connection.py | 88 +++++++++++++++++-- cqlengine/tests/management/test_management.py | 37 ++++++++ requirements.txt | 1 + 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 65147f6ac9..52063d1b51 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -3,6 +3,7 @@ #http://cassandra.apache.org/doc/cql/CQL.html from collections import namedtuple +import Queue import random import cql @@ -20,6 +21,7 @@ class CQLConnectionError(CQLEngineException): pass _conn= None _username = None _password = None +_max_connections = 10 def _set_conn(host): """ @@ -28,7 +30,7 @@ def _set_conn(host): _conn = cql.connect(host.name, host.port, user=_username, password=_password) _conn.set_cql_version('3.0.0') -def setup(hosts, username=None, password=None): +def setup(hosts, username=None, password=None, max_connections=10): """ Records the hosts and connects to one of them @@ -37,10 +39,11 @@ def setup(hosts, username=None, password=None): global _hosts global _username global _password - + global _max_connections _username = username _password = password - + _max_connections = max_connections + for host in hosts: host = host.strip() host = host.split(':') @@ -57,7 +60,82 @@ def setup(hosts, username=None, password=None): random.shuffle(_hosts) host = _hosts[_host_idx] _set_conn(host) + + +class ConnectionPool(object): + """Handles pooling of database connections.""" + + # Connection pool queue + _queue = None + + @classmethod + def clear(cls): + """ + Force the connection pool to be cleared. Will close all internal + connections. + """ + try: + while not cls._queue.empty(): + cls._queue.get().close() + except: + pass + @classmethod + def get(cls): + """ + Returns a usable database connection. Uses the internal queue to + determine whether to return an existing connection or to create + a new one. + """ + try: + if cls._queue.empty(): + return cls._create_connection() + return cls._queue.get() + except CQLConnectionError as cqle: + raise cqle + except: + if not cls._queue: + cls._queue = Queue.Queue(maxsize=_max_connections) + return cls._create_connection() + + @classmethod + def put(cls, conn): + """ + Returns a connection to the queue freeing it up for other queries to + use. + + :param conn: The connection to be released + :type conn: connection + """ + try: + if cls._queue.full(): + conn.close() + else: + cls._queue.put(conn) + except: + if not cls._queue: + cls._queue = Queue.Queue(maxsize=_max_connections) + cls._queue.put(conn) + + @classmethod + def _create_connection(cls): + """ + Creates a new connection for the connection pool. + """ + global _hosts + global _username + global _password + + if not _hosts: + raise CQLConnectionError("At least one host required") + + random.shuffle(_hosts) + host = _hosts[_host_idx] + + new_conn = cql.connect(host.name, host.port, user=_username, password=_password) + new_conn.set_cql_version('3.0.0') + return new_conn + class connection_manager(object): """ @@ -67,13 +145,13 @@ def __init__(self): if not _hosts: raise CQLConnectionError("No connections have been configured, call cqlengine.connection.setup") self.keyspace = None - self.con = _conn + self.con = ConnectionPool.get() def __enter__(self): return self def __exit__(self, type, value, traceback): - pass + ConnectionPool.put(self.con) def execute(self, query, params={}): """ diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index e69de29bb2..e491d615a0 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -0,0 +1,37 @@ +from cqlengine.tests.base import BaseCassEngTestCase + +from cqlengine.connection import ConnectionPool + +from mock import Mock + + +class ConnectionPoolTestCase(BaseCassEngTestCase): + """Test cassandra connection pooling.""" + + def setUp(self): + ConnectionPool.clear() + + def test_should_create_single_connection_on_request(self): + """Should create a single connection on first request""" + result = ConnectionPool.get() + self.assertIsNotNone(result) + self.assertEquals(0, ConnectionPool._queue.qsize()) + ConnectionPool._queue.put(result) + self.assertEquals(1, ConnectionPool._queue.qsize()) + + def test_should_close_connection_if_queue_is_full(self): + """Should close additional connections if queue is full""" + connections = [ConnectionPool.get() for x in range(10)] + for conn in connections: + ConnectionPool.put(conn) + fake_conn = Mock() + ConnectionPool.put(fake_conn) + fake_conn.close.assert_called_once_with() + + def test_should_pop_connections_from_queue(self): + """Should pull existing connections off of the queue""" + conn = ConnectionPool.get() + ConnectionPool.put(conn) + self.assertEquals(1, ConnectionPool._queue.qsize()) + self.assertEquals(conn, ConnectionPool.get()) + self.assertEquals(0, ConnectionPool._queue.qsize()) diff --git a/requirements.txt b/requirements.txt index a16d2a9cc7..2415255265 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ cql==1.2.0 ipython==0.13.1 ipdb==0.7 Sphinx==1.1.3 +mock==1.0.1 From 5118c36de91f424a1b63839020fa6cab88734818 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 4 Dec 2012 22:29:50 -0800 Subject: [PATCH 0070/4200] expanding the connection manager a bit --- cqlengine/connection.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 52063d1b51..8e1f70709d 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -23,13 +23,6 @@ class CQLConnectionError(CQLEngineException): pass _password = None _max_connections = 10 -def _set_conn(host): - """ - """ - global _conn - _conn = cql.connect(host.name, host.port, user=_username, password=_password) - _conn.set_cql_version('3.0.0') - def setup(hosts, username=None, password=None, max_connections=10): """ Records the hosts and connects to one of them @@ -58,8 +51,9 @@ def setup(hosts, username=None, password=None, max_connections=10): raise CQLConnectionError("At least one host required") random.shuffle(_hosts) - host = _hosts[_host_idx] - _set_conn(host) + + con = ConnectionPool.get() + ConnectionPool.put(con) class ConnectionPool(object): @@ -129,7 +123,6 @@ def _create_connection(cls): if not _hosts: raise CQLConnectionError("At least one host required") - random.shuffle(_hosts) host = _hosts[_host_idx] new_conn = cql.connect(host.name, host.port, user=_username, password=_password) @@ -146,12 +139,17 @@ def __init__(self): raise CQLConnectionError("No connections have been configured, call cqlengine.connection.setup") self.keyspace = None self.con = ConnectionPool.get() + self.cur = None + + def close(self): + if self.cur: self.cur.close() + ConnectionPool.put(self.con) def __enter__(self): return self def __exit__(self, type, value, traceback): - ConnectionPool.put(self.con) + self.close() def execute(self, query, params={}): """ @@ -164,18 +162,17 @@ def execute(self, query, params={}): for i in range(len(_hosts)): try: - cur = self.con.cursor() - cur.execute(query, params) - return cur + self.cur = self.con.cursor() + self.cur.execute(query, params) + return self.cur except TTransportException: #TODO: check for other errors raised in the event of a connection / server problem #move to the next connection and set the connection pool self.con = None _host_idx += 1 _host_idx %= len(_hosts) - host = _hosts[_host_idx] - _set_conn(host) - self.con = _conn + self.con.close() + self.con = ConnectionPool._create_connection() raise CQLConnectionError("couldn't reach a Cassandra server") From 43cbca139d53067a0884aea4f669bc38967726cb Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 4 Dec 2012 22:30:07 -0800 Subject: [PATCH 0071/4200] adding connection pool support to the queryset --- cqlengine/query.py | 27 +++++++++++++++++------- cqlengine/tests/query/test_queryset.py | 29 ++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 3904ae86ee..e75f563382 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -138,7 +138,8 @@ def __init__(self, model): self._only_fields = [] #results cache - self._cursor = None + self._con = None + self._cur = None self._result_cache = None self._result_idx = None @@ -154,7 +155,7 @@ def __call__(self, **kwargs): def __deepcopy__(self, memo): clone = self.__class__(self.model) for k,v in self.__dict__.items(): - if k in ['_cursor', '_result_cache', '_result_idx']: + if k in ['_con', '_cur', '_result_cache', '_result_idx']: clone.__dict__[k] = None else: clone.__dict__[k] = copy.deepcopy(v, memo) @@ -163,6 +164,12 @@ def __deepcopy__(self, memo): def __len__(self): return self.count() + + def __del__(self): + if self._con: + self._con.close() + self._con = None + self._cur = None #----query generation / execution---- @@ -216,24 +223,30 @@ def _select_query(self): def _execute_query(self): if self._result_cache is None: - with connection_manager() as con: - self._cursor = con.execute(self._select_query(), self._where_values()) - self._result_cache = [None]*self._cursor.rowcount + self._con = connection_manager() + self._cur = self._con.execute(self._select_query(), self._where_values()) + self._result_cache = [None]*self._cur.rowcount def _fill_result_cache_to_idx(self, idx): self._execute_query() if self._result_idx is None: self._result_idx = -1 - names = [i[0] for i in self._cursor.description] qty = idx - self._result_idx if qty < 1: return else: - for values in self._cursor.fetchmany(qty): + names = [i[0] for i in self._cur.description] + for values in self._cur.fetchmany(qty): value_dict = dict(zip(names, values)) self._result_idx += 1 self._result_cache[self._result_idx] = self._construct_instance(value_dict) + + #return the connection to the connection pool if we have all objects + if self._result_cache and self._result_cache[-1] is not None: + self._con.close() + self._con = None + self._cur = None def __iter__(self): self._execute_query() diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index ad3cb73e15..31178b16c5 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -355,8 +355,33 @@ def test_delete_without_any_where_args(self): with self.assertRaises(query.QueryException): TestModel.objects(attempt_id=0).delete() - - +class TestQuerySetConnectionHandling(BaseQuerySetUsage): + + def test_conn_is_returned_after_filling_cache(self): + """ + Tests that the queryset returns it's connection after it's fetched all of it's results + """ + q = TestModel.objects(test_id=0) + #tuple of expected attempt_id, expected_result values + compare_set = set([(0,5), (1,10), (2,15), (3,20)]) + for t in q: + val = t.attempt_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + + assert q._con is None + assert q._cur is None + + def test_conn_is_returned_after_queryset_is_garbage_collected(self): + """ Tests that the connection is returned to the connection pool after the queryset is gc'd """ + from cqlengine.connection import ConnectionPool + assert ConnectionPool._queue.qsize() == 1 + q = TestModel.objects(test_id=0) + v = q[0] + assert ConnectionPool._queue.qsize() == 0 + + del q + assert ConnectionPool._queue.qsize() == 1 From eb28b92dcf455307b9eb4fcb85ddd945443244be Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 4 Dec 2012 22:46:44 -0800 Subject: [PATCH 0072/4200] adding convenience filter methods --- cqlengine/models.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cqlengine/models.py b/cqlengine/models.py index 0c201cdb6e..6e75c9acc3 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -75,6 +75,18 @@ def as_dict(self): @classmethod def create(cls, **kwargs): return cls.objects.create(**kwargs) + + @classmethod + def all(cls): + return cls.objects.all() + + @classmethod + def filter(cls, **kwargs): + return cls.objects.filter(**kwargs) + + @classmethod + def get(cls, **kwargs): + return cls.objects.get(**kwargs) def save(self): is_new = self.pk is None From 1935bd1a0b5cca3f010d2c0a07a8de3bd2602b3f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 4 Dec 2012 22:47:47 -0800 Subject: [PATCH 0073/4200] updating version, changelog, etc --- AUTHORS | 3 ++- changelog | 4 ++++ cqlengine/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index 3a69691a3e..b361bf0de4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -4,5 +4,6 @@ Blake Eggleston CONTRIBUTORS -Eric Scrivner +Eric Scrivner - test environment, connection pooling +Jon Haddad - helped hash out some of the architecture diff --git a/changelog b/changelog index 652dda1d7c..d1d9469f68 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,9 @@ CHANGELOG +0.0.5 +* added connection pooling +* adding a few convenience query classmethods to the model class + 0.0.4-ALPHA * added Sphinx docs * changing create_column_family management function to create_table diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 8fed278532..f2c0cbcb0a 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.0.4-ALPHA' +__version__ = '0.0.5' diff --git a/setup.py b/setup.py index de11c701bf..4471f9c807 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.0.4-ALPHA' +version = '0.0.5' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine From 57359a87ad3a6a4609b8017b75bfa7ef0be0b975 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 5 Dec 2012 11:16:01 -0800 Subject: [PATCH 0074/4200] added timeuuid column --- cqlengine/columns.py | 11 ++++++++ cqlengine/tests/columns/test_validation.py | 33 +++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 387aaf3d8f..b276b5d75b 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -196,6 +196,17 @@ def validate(self, value): if not self.re_uuid.match(val): raise ValidationError("{} is not a valid uuid".format(value)) return _UUID(val) + +class TimeUUID(UUID): + """ + UUID containing timestamp + """ + + db_type = 'timeuuid' + + def __init__(self, **kwargs): + kwargs.setdefault('default', lambda: uuid1()) + super(TimeUUID, self).__init__(**kwargs) class Boolean(Column): db_type = 'boolean' diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 0c2b6c7da2..a94f87a093 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -4,7 +4,7 @@ from cqlengine.tests.base import BaseCassEngTestCase -from cqlengine.columns import Column +from cqlengine.columns import Column, TimeUUID from cqlengine.columns import Bytes from cqlengine.columns import Ascii from cqlengine.columns import Text @@ -63,5 +63,36 @@ def test_datetime_io(self): dt2 = self.DecimalTest.objects(test_id=0).first() assert dt2.dec_val == D('5') +class TestTimeUUID(BaseCassEngTestCase): + class TimeUUIDTest(Model): + test_id = Integer(primary_key=True) + timeuuid = TimeUUID() + + @classmethod + def setUpClass(cls): + super(TestTimeUUID, cls).setUpClass() + create_table(cls.TimeUUIDTest) + + @classmethod + def tearDownClass(cls): + super(TestTimeUUID, cls).tearDownClass() + delete_table(cls.TimeUUIDTest) + + def test_timeuuid_io(self): + t0 = self.TimeUUIDTest.create(test_id=0) + t1 = self.TimeUUIDTest.get(test_id=0) + + assert t1.timeuuid.time == t1.timeuuid.time + + + + + + + + + + + From d38a71ea788b004c78928ee4752c8ecc18e74bfb Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 5 Dec 2012 11:16:37 -0800 Subject: [PATCH 0075/4200] updating version# --- changelog | 3 +++ cqlengine/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/changelog b/changelog index d1d9469f68..ad4f5792c6 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.0.6 +* added TimeUUID column + 0.0.5 * added connection pooling * adding a few convenience query classmethods to the model class diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index f2c0cbcb0a..6742297c6f 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.0.5' +__version__ = '0.0.6' diff --git a/setup.py b/setup.py index 4471f9c807..f79eb17e51 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.0.5' +version = '0.0.6' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine From 119c0ac001f3f09c57de1d5a293f0f2e32020d88 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 5 Dec 2012 15:30:09 -0800 Subject: [PATCH 0076/4200] updating docs version --- docs/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index e15b8f3ec1..fe149b8571 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.0.4' +version = '0.0.6' # The full version, including alpha/beta/rc tags. -release = '0.0.4-ALPHA' +release = '0.0.6' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. From 454f634b3410297ed1b2a7f67f46a044c3a3ee50 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 5 Dec 2012 16:43:58 -0800 Subject: [PATCH 0077/4200] updating setup docs link --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index f79eb17e51..927db4f509 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine -[Documentation](https://github.com/bdeggleston/cqlengine/wiki/Documentation) +[Documentation](https://cqlengine.readthedocs.org/en/latest/) [Report a Bug](https://github.com/bdeggleston/cqlengine/issues) @@ -17,7 +17,7 @@ [Dev Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-dev) -**NOTE: cqlengine is in alpha and under development, some features may change (hopefully with notice). Make sure to check the changelog and test your app before upgrading** +**NOTE: cqlengine is in alpha and under development, some features may change. Make sure to check the changelog and test your app before upgrading** """ setup( From e53a5031ae6ba8bf319e24a6617906d0c1067f65 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 7 Dec 2012 15:10:42 -0800 Subject: [PATCH 0078/4200] fixed manual table name bugs --- changelog | 4 +++ cqlengine/models.py | 33 +++++++++---------- .../tests/model/test_class_construction.py | 25 ++++++++++++++ docs/topics/models.rst | 2 +- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/changelog b/changelog index ad4f5792c6..2546c9da0b 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,9 @@ CHANGELOG +0.0.7 +* fixed manual table name bug +* changed model level db_name field to table_name + 0.0.6 * added TimeUUID column diff --git a/cqlengine/models.py b/cqlengine/models.py index 6e75c9acc3..7891fe1325 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -17,7 +17,7 @@ class MultipleObjectsReturned(QueryException): pass #table names will be generated automatically from it's model and package name #however, you can alse define them manually here - db_name = None + table_name = None #the keyspace for this model keyspace = 'cqlengine' @@ -36,21 +36,21 @@ def column_family_name(cls, include_keyspace=True): Returns the column family name if it's been defined otherwise, it creates it from the module and class name """ - if cls.db_name: - return cls.db_name.lower() - - camelcase = re.compile(r'([a-z])([A-Z])') - ccase = lambda s: camelcase.sub(lambda v: '{}_{}'.format(v.group(1), v.group(2).lower()), s) - cf_name = '' - module = cls.__module__.split('.') - if module: - cf_name = ccase(module[-1]) + '_' - - cf_name += ccase(cls.__name__) - #trim to less than 48 characters or cassandra will complain - cf_name = cf_name[-48:] - cf_name = cf_name.lower() + if cls.table_name: + cf_name = cls.table_name.lower() + else: + camelcase = re.compile(r'([a-z])([A-Z])') + ccase = lambda s: camelcase.sub(lambda v: '{}_{}'.format(v.group(1), v.group(2).lower()), s) + + module = cls.__module__.split('.') + if module: + cf_name = ccase(module[-1]) + '_' + + cf_name += ccase(cls.__name__) + #trim to less than 48 characters or cassandra will complain + cf_name = cf_name[-48:] + cf_name = cf_name.lower() if not include_keyspace: return cf_name return '{}.{}'.format(cls.keyspace, cf_name) @@ -157,9 +157,6 @@ def _transform_column(col_name, col_obj): raise ModelDefinitionException( 'Indexes on models with multiple primary keys is not supported') - #get column family name - cf_name = attrs.pop('db_name', name) - #create db_name -> model name map for loading db_map = {} for field_name, col in column_dict.items(): diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index fd218e1a5c..d01011ec42 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -3,6 +3,7 @@ from cqlengine.exceptions import ModelException from cqlengine.models import Model from cqlengine import columns +import cqlengine class TestModelClassFunction(BaseCassEngTestCase): """ @@ -103,3 +104,27 @@ def test_meta_data_is_not_inherited(self): """ Test that metadata defined in one class, is not inherited by subclasses """ + +class TestManualTableNaming(BaseCassEngTestCase): + + class RenamedTest(cqlengine.Model): + keyspace = 'whatever' + table_name = 'manual_name' + + id = cqlengine.UUID(primary_key=True) + data = cqlengine.Text() + + def test_proper_table_naming(self): + assert self.RenamedTest.column_family_name(include_keyspace=False) == 'manual_name' + assert self.RenamedTest.column_family_name(include_keyspace=True) == 'whatever.manual_name' + + + + + + + + + + + diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 3ddefc37e4..86bd08d437 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -123,7 +123,7 @@ Model Methods Model Attributes ================ - .. attribute:: Model.db_name + .. attribute:: Model.table_name *Optional.* Sets the name of the CQL table for this model. If left blank, the table name will be the name of the model, with it's module name as it's prefix From eae926b6710cc8e74cc9a726f7db6a7628b31227 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 7 Dec 2012 15:13:00 -0800 Subject: [PATCH 0079/4200] changing version number --- cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 6742297c6f..5a99e64c07 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.0.6' +__version__ = '0.0.7' diff --git a/docs/conf.py b/docs/conf.py index fe149b8571..a45c245c9d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.0.6' +version = '0.0.7' # The full version, including alpha/beta/rc tags. -release = '0.0.6' +release = '0.0.7' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 927db4f509..a9658c482f 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.0.6' +version = '0.0.7' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine From 6b202c7e76ad182c1a9290fb0c0cfdd999cdae89 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 11 Dec 2012 18:53:14 -0800 Subject: [PATCH 0080/4200] added ability to specifiy replication factor and replication strategy --- cqlengine/management.py | 11 +++++++---- cqlengine/models.py | 1 + cqlengine/tests/management/test_management.py | 10 ++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 481e30364d..4e764b55de 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,11 +1,11 @@ from cqlengine.connection import connection_manager -def create_keyspace(name): +def create_keyspace(name, strategy_class = 'SimpleStrategy', replication_factor=3): with connection_manager() as con: if name not in [k.name for k in con.con.client.describe_keyspaces()]: con.execute("""CREATE KEYSPACE {} - WITH strategy_class = 'SimpleStrategy' - AND strategy_options:replication_factor=1;""".format(name)) + WITH strategy_class = '{}' + AND strategy_options:replication_factor={};""".format(name, strategy_class, replication_factor)) def delete_keyspace(name): with connection_manager() as con: @@ -38,8 +38,11 @@ def add_column(col): add_column(col) qtypes.append('PRIMARY KEY ({})'.format(', '.join(pkeys))) - + qs += ['({})'.format(', '.join(qtypes))] + + # add read_repair_chance + qs += ["WITH read_repair_chance = {}".format(model.read_repair_chance)] qs = ' '.join(qs) con.execute(qs) diff --git a/cqlengine/models.py b/cqlengine/models.py index 7891fe1325..e8a270459a 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -21,6 +21,7 @@ class MultipleObjectsReturned(QueryException): pass #the keyspace for this model keyspace = 'cqlengine' + read_repair_chance = 0.1 def __init__(self, **values): self._values = {} diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index e491d615a0..f3862a9527 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -3,6 +3,7 @@ from cqlengine.connection import ConnectionPool from mock import Mock +from cqlengine import management class ConnectionPoolTestCase(BaseCassEngTestCase): @@ -35,3 +36,12 @@ def test_should_pop_connections_from_queue(self): self.assertEquals(1, ConnectionPool._queue.qsize()) self.assertEquals(conn, ConnectionPool.get()) self.assertEquals(0, ConnectionPool._queue.qsize()) + + +class CreateKeyspaceTest(BaseCassEngTestCase): + def test_create_succeeeds(self): + management.create_keyspace('test_keyspace') + management.delete_keyspace('test_keyspace') + + + \ No newline at end of file From fd347707b29d63977a5a37c934e6481c17c0e0f9 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 12 Dec 2012 11:58:02 -0800 Subject: [PATCH 0081/4200] updating version# and change log --- changelog | 4 ++++ cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/changelog b/changelog index 2546c9da0b..d8ee94baee 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,9 @@ CHANGELOG +0.0.8 +* added configurable read repair chance to model definitions +* added configurable keyspace strategy class and replication factor to keyspace creator + 0.0.7 * fixed manual table name bug * changed model level db_name field to table_name diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 5a99e64c07..6f020f7620 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.0.7' +__version__ = '0.0.8' diff --git a/docs/conf.py b/docs/conf.py index a45c245c9d..2068f2641e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.0.7' +version = '0.0.8' # The full version, including alpha/beta/rc tags. -release = '0.0.7' +release = '0.0.8' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index a9658c482f..45619393e9 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.0.7' +version = '0.0.8' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine From 27e5069db961a23c85fe84e8a12aed9b2d36042f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 12 Dec 2012 12:31:21 -0800 Subject: [PATCH 0082/4200] updated readme --- README.md | 61 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 4eceed1fb6..2a61f645af 100644 --- a/README.md +++ b/README.md @@ -22,43 +22,48 @@ pip install cqlengine ```python #first, define a model ->>> from cqlengine import columns ->>> from cqlengine.models import Model +from cqlengine import columns +from cqlengine.models import Model ->>> class ExampleModel(Model): ->>> example_id = columns.UUID(primary_key=True) ->>> example_type = columns.Integer(index=True) ->>> created_at = columns.DateTime() ->>> description = columns.Text(required=False) +class ExampleModel(Model): + read_repair_chance = 0.05 # optional - defaults to 0.1 + + example_id = columns.UUID(primary_key=True) + example_type = columns.Integer(index=True) + created_at = columns.DateTime() + description = columns.Text(required=False) #next, setup the connection to your cassandra server(s)... ->>> from cqlengine import connection ->>> connection.setup(['127.0.0.1:9160']) +from cqlengine import connection +connection.setup(['127.0.0.1:9160']) #...and create your CQL table ->>> from cqlengine.management import create_table ->>> create_table(ExampleModel) +from cqlengine.management import create_table +create_table(ExampleModel) #now we can create some rows: ->>> em1 = ExampleModel.create(example_type=0, description="example1") ->>> em2 = ExampleModel.create(example_type=0, description="example2") ->>> em3 = ExampleModel.create(example_type=0, description="example3") ->>> em4 = ExampleModel.create(example_type=0, description="example4") ->>> em5 = ExampleModel.create(example_type=1, description="example5") ->>> em6 = ExampleModel.create(example_type=1, description="example6") ->>> em7 = ExampleModel.create(example_type=1, description="example7") ->>> em8 = ExampleModel.create(example_type=1, description="example8") +em1 = ExampleModel.create(example_type=0, description="example1") +em2 = ExampleModel.create(example_type=0, description="example2") +em3 = ExampleModel.create(example_type=0, description="example3") +em4 = ExampleModel.create(example_type=0, description="example4") +em5 = ExampleModel.create(example_type=1, description="example5") +em6 = ExampleModel.create(example_type=1, description="example6") +em7 = ExampleModel.create(example_type=1, description="example7") +em8 = ExampleModel.create(example_type=1, description="example8") # Note: the UUID and DateTime columns will create uuid4 and datetime.now # values automatically if we don't specify them when creating new rows +# alternative syntax for creating new objects +ExampleModel(example_type=0, description="example9").save() + #and now we can run some queries against our table ->>> ExampleModel.objects.count() +ExampleModel.objects.count() 8 ->>> q = ExampleModel.objects(example_type=1) ->>> q.count() +q = ExampleModel.objects(example_type=1) +q.count() 4 ->>> for instance in q: ->>> print q.description +for instance in q: + print q.description example5 example6 example7 @@ -67,11 +72,11 @@ example8 #here we are applying additional filtering to an existing query #query objects are immutable, so calling filter returns a new #query object ->>> q2 = q.filter(example_id=em5.example_id) +q2 = q.filter(example_id=em5.example_id) ->>> q2.count() +q2.count() 1 ->>> for instance in q2: ->>> print q.description +for instance in q2: + print q.description example5 ``` From 3c5b7df847130366fae1c99f0f401a2840531720 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 12 Dec 2012 12:35:26 -0800 Subject: [PATCH 0083/4200] readme improvements --- README.md | 48 ++++++++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2a61f645af..d70353999a 100644 --- a/README.md +++ b/README.md @@ -26,44 +26,40 @@ from cqlengine import columns from cqlengine.models import Model class ExampleModel(Model): - read_repair_chance = 0.05 # optional - defaults to 0.1 - + read_repair_chance = 0.05 # optional - defaults to 0.1 example_id = columns.UUID(primary_key=True) example_type = columns.Integer(index=True) created_at = columns.DateTime() description = columns.Text(required=False) #next, setup the connection to your cassandra server(s)... -from cqlengine import connection -connection.setup(['127.0.0.1:9160']) +>>> from cqlengine import connection +>>> connection.setup(['127.0.0.1:9160']) #...and create your CQL table -from cqlengine.management import create_table -create_table(ExampleModel) +>>> from cqlengine.management import create_table +>>> create_table(ExampleModel) #now we can create some rows: -em1 = ExampleModel.create(example_type=0, description="example1") -em2 = ExampleModel.create(example_type=0, description="example2") -em3 = ExampleModel.create(example_type=0, description="example3") -em4 = ExampleModel.create(example_type=0, description="example4") -em5 = ExampleModel.create(example_type=1, description="example5") -em6 = ExampleModel.create(example_type=1, description="example6") -em7 = ExampleModel.create(example_type=1, description="example7") -em8 = ExampleModel.create(example_type=1, description="example8") +>>> em1 = ExampleModel.create(example_type=0, description="example1") +>>> em2 = ExampleModel.create(example_type=0, description="example2") +>>> em3 = ExampleModel.create(example_type=0, description="example3") +>>> em4 = ExampleModel.create(example_type=0, description="example4") +>>> em5 = ExampleModel.create(example_type=1, description="example5") +>>> em6 = ExampleModel.create(example_type=1, description="example6") +>>> em7 = ExampleModel.create(example_type=1, description="example7") +>>> em8 = ExampleModel.create(example_type=1, description="example8") # Note: the UUID and DateTime columns will create uuid4 and datetime.now # values automatically if we don't specify them when creating new rows -# alternative syntax for creating new objects -ExampleModel(example_type=0, description="example9").save() - #and now we can run some queries against our table -ExampleModel.objects.count() +>>> ExampleModel.objects.count() 8 -q = ExampleModel.objects(example_type=1) -q.count() +>>> q = ExampleModel.objects(example_type=1) +>>> q.count() 4 -for instance in q: - print q.description +>>> for instance in q: +>>> print q.description example5 example6 example7 @@ -72,11 +68,11 @@ example8 #here we are applying additional filtering to an existing query #query objects are immutable, so calling filter returns a new #query object -q2 = q.filter(example_id=em5.example_id) +>>> q2 = q.filter(example_id=em5.example_id) -q2.count() +>>> q2.count() 1 -for instance in q2: - print q.description +>>> for instance in q2: +>>> print q.description example5 ``` From b1c9ad6f8e55876b776099b240f1872a3f26a551 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 13 Dec 2012 09:32:52 -0800 Subject: [PATCH 0084/4200] updating docs/readme --- README.md | 4 +--- docs/index.rst | 58 +++++++++++++++++++++++++------------------------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 4eceed1fb6..56422d9fce 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ cqlengine =============== -cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine +cqlengine is a Cassandra CQL 3 ORM for Python with an interface similar to the Django orm and mongoengine [Documentation](https://cqlengine.readthedocs.org/en/latest/) @@ -11,8 +11,6 @@ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and m [Dev Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-dev) -**NOTE: cqlengine is in alpha and under development, some features may change (hopefully with notice). Make sure to check the changelog and test your app before upgrading** - ## Installation ``` pip install cqlengine diff --git a/docs/index.rst b/docs/index.rst index 2559a1006e..c556dfd12b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,7 +5,7 @@ cqlengine documentation ======================= -cqlengine is a Cassandra CQL ORM for Python with an interface similar to the Django orm and mongoengine +cqlengine is a Cassandra CQL 3 ORM for Python with an interface similar to the Django orm and mongoengine :ref:`getting-started` @@ -28,43 +28,43 @@ Getting Started .. code-block:: python #first, define a model - >>> from cqlengine import columns - >>> from cqlengine import Model + from cqlengine import columns + from cqlengine import Model - >>> class ExampleModel(Model): - >>> example_id = columns.UUID(primary_key=True) - >>> example_type = columns.Integer(index=True) - >>> created_at = columns.DateTime() - >>> description = columns.Text(required=False) + class ExampleModel(Model): + example_id = columns.UUID(primary_key=True) + example_type = columns.Integer(index=True) + created_at = columns.DateTime() + description = columns.Text(required=False) #next, setup the connection to your cassandra server(s)... - >>> from cqlengine import connection - >>> connection.setup(['127.0.0.1:9160']) + from cqlengine import connection + connection.setup(['127.0.0.1:9160']) #...and create your CQL table - >>> from cqlengine.management import create_table - >>> create_table(ExampleModel) + from cqlengine.management import create_table + create_table(ExampleModel) #now we can create some rows: - >>> em1 = ExampleModel.create(example_type=0, description="example1") - >>> em2 = ExampleModel.create(example_type=0, description="example2") - >>> em3 = ExampleModel.create(example_type=0, description="example3") - >>> em4 = ExampleModel.create(example_type=0, description="example4") - >>> em5 = ExampleModel.create(example_type=1, description="example5") - >>> em6 = ExampleModel.create(example_type=1, description="example6") - >>> em7 = ExampleModel.create(example_type=1, description="example7") - >>> em8 = ExampleModel.create(example_type=1, description="example8") + em1 = ExampleModel.create(example_type=0, description="example1") + em2 = ExampleModel.create(example_type=0, description="example2") + em3 = ExampleModel.create(example_type=0, description="example3") + em4 = ExampleModel.create(example_type=0, description="example4") + em5 = ExampleModel.create(example_type=1, description="example5") + em6 = ExampleModel.create(example_type=1, description="example6") + em7 = ExampleModel.create(example_type=1, description="example7") + em8 = ExampleModel.create(example_type=1, description="example8") # Note: the UUID and DateTime columns will create uuid4 and datetime.now # values automatically if we don't specify them when creating new rows #and now we can run some queries against our table - >>> ExampleModel.objects.count() + ExampleModel.objects.count() 8 - >>> q = ExampleModel.objects(example_type=1) - >>> q.count() + q = ExampleModel.objects(example_type=1) + q.count() 4 - >>> for instance in q: - >>> print q.description + for instance in q: + print q.description example5 example6 example7 @@ -73,12 +73,12 @@ Getting Started #here we are applying additional filtering to an existing query #query objects are immutable, so calling filter returns a new #query object - >>> q2 = q.filter(example_id=em5.example_id) + q2 = q.filter(example_id=em5.example_id) - >>> q2.count() + q2.count() 1 - >>> for instance in q2: - >>> print q.description + for instance in q2: + print q.description example5 From 96a213b4b142af07e055a2d280c47a0a141f0e27 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 13 Dec 2012 09:38:35 -0800 Subject: [PATCH 0085/4200] editing doc index --- docs/index.rst | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index c556dfd12b..44b5b9b9fe 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -38,33 +38,33 @@ Getting Started description = columns.Text(required=False) #next, setup the connection to your cassandra server(s)... - from cqlengine import connection - connection.setup(['127.0.0.1:9160']) + >>> from cqlengine import connection + >>> connection.setup(['127.0.0.1:9160']) #...and create your CQL table - from cqlengine.management import create_table - create_table(ExampleModel) + >>> from cqlengine.management import create_table + >>> create_table(ExampleModel) #now we can create some rows: - em1 = ExampleModel.create(example_type=0, description="example1") - em2 = ExampleModel.create(example_type=0, description="example2") - em3 = ExampleModel.create(example_type=0, description="example3") - em4 = ExampleModel.create(example_type=0, description="example4") - em5 = ExampleModel.create(example_type=1, description="example5") - em6 = ExampleModel.create(example_type=1, description="example6") - em7 = ExampleModel.create(example_type=1, description="example7") - em8 = ExampleModel.create(example_type=1, description="example8") + >>> em1 = ExampleModel.create(example_type=0, description="example1") + >>> em2 = ExampleModel.create(example_type=0, description="example2") + >>> em3 = ExampleModel.create(example_type=0, description="example3") + >>> em4 = ExampleModel.create(example_type=0, description="example4") + >>> em5 = ExampleModel.create(example_type=1, description="example5") + >>> em6 = ExampleModel.create(example_type=1, description="example6") + >>> em7 = ExampleModel.create(example_type=1, description="example7") + >>> em8 = ExampleModel.create(example_type=1, description="example8") # Note: the UUID and DateTime columns will create uuid4 and datetime.now # values automatically if we don't specify them when creating new rows #and now we can run some queries against our table - ExampleModel.objects.count() + >>> ExampleModel.objects.count() 8 - q = ExampleModel.objects(example_type=1) - q.count() + >>> q = ExampleModel.objects(example_type=1) + >>> q.count() 4 - for instance in q: - print q.description + >>> for instance in q: + >>> print q.description example5 example6 example7 @@ -73,12 +73,12 @@ Getting Started #here we are applying additional filtering to an existing query #query objects are immutable, so calling filter returns a new #query object - q2 = q.filter(example_id=em5.example_id) + >>> q2 = q.filter(example_id=em5.example_id) - q2.count() + >>> q2.count() 1 - for instance in q2: - print q.description + >>> for instance in q2: + >>> print q.description example5 From 585c75a5bb4e0d0d482b9805c7dd40da715dfe7a Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Thu, 13 Dec 2012 20:58:20 -0800 Subject: [PATCH 0086/4200] Fix Vagrant Box URL --- Vagrantfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Vagrantfile b/Vagrantfile index 6d8ccc8ad1..191c5e8cdc 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -11,7 +11,7 @@ Vagrant::Config.run do |config| # The url from where the 'config.vm.box' box will be fetched if it # doesn't already exist on the user's system. - config.vm.box_url = "https://s3-us-west-2.amazonaws.com/graphplatform.swmirror/precise64.box" + config.vm.box_url = "http://files.vagrantup.com/precise64.box" # Boot with a GUI so you can see the screen. (Default is headless) # config.vm.boot_mode = :gui @@ -43,4 +43,4 @@ Vagrant::Config.run do |config| # puppet.manifest_file = "site.pp" puppet.module_path = "modules" end -end \ No newline at end of file +end From dc902163455f36c8ba34337373b24814829b028e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 22 Dec 2012 21:41:09 -0800 Subject: [PATCH 0087/4200] adding IDEA project files --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index bd169dfade..16029f08ec 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,11 @@ html/ .project .pydevproject .settings/ +.idea/ +*.iml + +.DS_Store + # Unit test / coverage reports .coverage From cd4f9afbed749dd735880785b2061e93657032e9 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 22 Dec 2012 21:41:47 -0800 Subject: [PATCH 0088/4200] fixing column inheritance and short circuiting table name inheritance --- cqlengine/models.py | 22 ++++++++++++++++--- .../tests/model/test_class_construction.py | 17 ++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index e8a270459a..255b7ce808 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -16,8 +16,8 @@ class DoesNotExist(QueryException): pass class MultipleObjectsReturned(QueryException): pass #table names will be generated automatically from it's model and package name - #however, you can alse define them manually here - table_name = None + #however, you can also define them manually here + table_name = None #the keyspace for this model keyspace = 'cqlengine' @@ -112,6 +112,12 @@ def __new__(cls, name, bases, attrs): primary_keys = OrderedDict() pk_name = None + #get inherited properties + inherited_columns = OrderedDict() + for base in bases: + for k,v in getattr(base, '_defined_columns', {}).items(): + inherited_columns.setdefault(k,v) + def _transform_column(col_name, col_obj): column_dict[col_name] = col_obj if col_obj.primary_key: @@ -129,7 +135,13 @@ def _transform_column(col_name, col_obj): column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.Column)] column_definitions = sorted(column_definitions, lambda x,y: cmp(x[1].position, y[1].position)) - #prepend primary key if none has been defined + column_definitions = inherited_columns.items() + column_definitions + + #columns defined on model, excludes automatically + #defined columns + defined_columns = OrderedDict(column_definitions) + + #prepend primary key if one hasn't been defined if not any([v.primary_key for k,v in column_definitions]): k,v = 'id', columns.UUID(primary_key=True) column_definitions = [(k,v)] + column_definitions @@ -163,9 +175,13 @@ def _transform_column(col_name, col_obj): for field_name, col in column_dict.items(): db_map[col.db_field_name] = field_name + #short circuit table_name inheritance + attrs['table_name'] = attrs.get('table_name') + #add management members to the class attrs['_columns'] = column_dict attrs['_primary_keys'] = primary_keys + attrs['_defined_columns'] = defined_columns attrs['_db_map'] = db_map attrs['_pk_name'] = pk_name attrs['_dynamic_columns'] = {} diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index d01011ec42..77b1d73775 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -80,6 +80,19 @@ class Stuff(Model): self.assertEquals(inst1.num, 5) self.assertEquals(inst2.num, 7) + def test_superclass_fields_are_inherited(self): + """ + Tests that fields defined on the super class are inherited properly + """ + class TestModel(Model): + text = columns.Text() + + class InheritedModel(TestModel): + numbers = columns.Integer() + + assert 'text' in InheritedModel._columns + assert 'numbers' in InheritedModel._columns + def test_normal_fields_can_be_defined_between_primary_keys(self): """ Tests tha non primary key fields can be defined between primary key fields @@ -118,6 +131,10 @@ def test_proper_table_naming(self): assert self.RenamedTest.column_family_name(include_keyspace=False) == 'manual_name' assert self.RenamedTest.column_family_name(include_keyspace=True) == 'whatever.manual_name' + def test_manual_table_name_is_not_inherited(self): + class InheritedTest(self.RenamedTest): pass + assert InheritedTest.table_name is None + From 3e1f96c4f0d77fdadb81bb1d7578b1aa36546a98 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 22 Dec 2012 21:45:42 -0800 Subject: [PATCH 0089/4200] adding not about short circuited table_name inheritance --- docs/topics/models.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 86bd08d437..86788a2530 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -125,7 +125,7 @@ Model Attributes .. attribute:: Model.table_name - *Optional.* Sets the name of the CQL table for this model. If left blank, the table name will be the name of the model, with it's module name as it's prefix + *Optional.* Sets the name of the CQL table for this model. If left blank, the table name will be the name of the model, with it's module name as it's prefix. Manually defined table names are not inherited. .. attribute:: Model.keyspace From 31633bb1a46f3d26b1d9390fefc9232de49f9b6e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 22 Dec 2012 22:06:06 -0800 Subject: [PATCH 0090/4200] updating version number and change log --- changelog | 4 ++++ cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/changelog b/changelog index d8ee94baee..a724be7dfa 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,9 @@ CHANGELOG +0.0.9 +* fixed column inheritance bug +* manually defined table names are no longer inherited by subclasses + 0.0.8 * added configurable read repair chance to model definitions * added configurable keyspace strategy class and replication factor to keyspace creator diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 6f020f7620..a3f227db2b 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.0.8' +__version__ = '0.0.9' diff --git a/docs/conf.py b/docs/conf.py index 2068f2641e..d4defc9b6e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.0.8' +version = '0.0.9' # The full version, including alpha/beta/rc tags. -release = '0.0.8' +release = '0.0.9' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 45619393e9..470df3540c 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.0.8' +version = '0.0.9' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine From e7d4f9bf9f260e50f70045da55a13e60c06a1c67 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 6 Jan 2013 09:31:37 -0800 Subject: [PATCH 0091/4200] Changing base exception from BaseException to Exception --- cqlengine/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/exceptions.py b/cqlengine/exceptions.py index 0b1ff55f1e..87b5cdc4c5 100644 --- a/cqlengine/exceptions.py +++ b/cqlengine/exceptions.py @@ -1,5 +1,5 @@ #cqlengine exceptions -class CQLEngineException(BaseException): pass +class CQLEngineException(Exception): pass class ModelException(CQLEngineException): pass class ValidationError(CQLEngineException): pass From 79900278450364f8d064dcaaa7ac712a349c95bf Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 6 Jan 2013 09:37:20 -0800 Subject: [PATCH 0092/4200] fixed bug where default values that evaluate to false were ignored --- cqlengine/columns.py | 2 +- cqlengine/tests/columns/test_validation.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index b276b5d75b..df01bef72e 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -97,7 +97,7 @@ def to_database(self, value): @property def has_default(self): - return bool(self.default) + return self.default is not None @property def is_primary_key(self): diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index a94f87a093..00a4097b18 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -84,6 +84,15 @@ def test_timeuuid_io(self): assert t1.timeuuid.time == t1.timeuuid.time +class TestInteger(BaseCassEngTestCase): + class IntegerTest(Model): + test_id = UUID(primary_key=True) + value = Integer(default=0) + + def test_default_zero_fields_validate(self): + """ Tests that integer columns with a default value of 0 validate """ + it = self.IntegerTest() + it.validate() From e529f33dbed84f5efd74dffc8e0192e95aa08afe Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 6 Jan 2013 10:13:04 -0800 Subject: [PATCH 0093/4200] adding magic methods for model instance equality comparisons --- cqlengine/models.py | 6 +++ .../tests/model/test_equality_operations.py | 52 +++++++++++++++++++ cqlengine/tests/model/test_model_io.py | 1 - 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 cqlengine/tests/model/test_equality_operations.py diff --git a/cqlengine/models.py b/cqlengine/models.py index 255b7ce808..bf9d157aca 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -31,6 +31,12 @@ def __init__(self, **values): value_mngr = column.value_manager(self, column, value) self._values[name] = value_mngr + def __eq__(self, other): + return self.as_dict() == other.as_dict() + + def __ne__(self, other): + return not self.__eq__(other) + @classmethod def column_family_name(cls, include_keyspace=True): """ diff --git a/cqlengine/tests/model/test_equality_operations.py b/cqlengine/tests/model/test_equality_operations.py new file mode 100644 index 0000000000..4d5b95219b --- /dev/null +++ b/cqlengine/tests/model/test_equality_operations.py @@ -0,0 +1,52 @@ +from unittest import skip +from cqlengine.tests.base import BaseCassEngTestCase + +from cqlengine.management import create_table +from cqlengine.management import delete_table +from cqlengine.models import Model +from cqlengine import columns + +class TestModel(Model): + count = columns.Integer() + text = columns.Text(required=False) + +class TestEqualityOperators(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestEqualityOperators, cls).setUpClass() + create_table(TestModel) + + def setUp(self): + super(TestEqualityOperators, self).setUp() + self.t0 = TestModel.create(count=5, text='words') + self.t1 = TestModel.create(count=5, text='words') + + @classmethod + def tearDownClass(cls): + super(TestEqualityOperators, cls).tearDownClass() + delete_table(TestModel) + + def test_an_instance_evaluates_as_equal_to_itself(self): + """ + """ + assert self.t0 == self.t0 + + def test_two_instances_referencing_the_same_rows_and_different_values_evaluate_not_equal(self): + """ + """ + t0 = TestModel.get(id=self.t0.id) + t0.text = 'bleh' + assert t0 != self.t0 + + def test_two_instances_referencing_the_same_rows_and_values_evaluate_equal(self): + """ + """ + t0 = TestModel.get(id=self.t0.id) + assert t0 == self.t0 + + def test_two_instances_referencing_different_rows_evaluate_to_not_equal(self): + """ + """ + assert self.t0 != self.t1 + diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index f65b44efd2..73eb1e2496 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -4,7 +4,6 @@ from cqlengine.management import create_table from cqlengine.management import delete_table from cqlengine.models import Model -from cqlengine.models import Model from cqlengine import columns class TestModel(Model): From 16e4a6293288d272c0e8d02f9ae6e682bebca761 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 6 Jan 2013 10:28:07 -0800 Subject: [PATCH 0094/4200] adding min & max length validation to the Text column type --- cqlengine/columns.py | 17 +++++++++ cqlengine/tests/columns/test_validation.py | 43 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index df01bef72e..2f6ee4f4ac 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -146,6 +146,23 @@ class Ascii(Column): class Text(Column): db_type = 'text' + def __init__(self, *args, **kwargs): + self.min_length = kwargs.pop('min_length', 1 if kwargs.get('required', True) else None) + self.max_length = kwargs.pop('max_length', None) + super(Text, self).__init__(*args, **kwargs) + + def validate(self, value): + value = super(Text, self).validate(value) + if not isinstance(value, (basestring, bytearray)) and value is not None: + raise ValidationError('{} is not a string'.format(type(value))) + if self.max_length: + if len(value) > self.max_length: + raise ValidationError('{} is longer than {} characters'.format(self.column_name, self.max_length)) + if self.min_length: + if len(value) < self.min_length: + raise ValidationError('{} is shorter than {} characters'.format(self.column_name, self.min_length)) + return value + class Integer(Column): db_type = 'int' diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 00a4097b18..4921b67256 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -1,6 +1,7 @@ #tests the behavior of the column classes from datetime import datetime from decimal import Decimal as D +from cqlengine import ValidationError from cqlengine.tests.base import BaseCassEngTestCase @@ -94,6 +95,48 @@ def test_default_zero_fields_validate(self): it = self.IntegerTest() it.validate() +class TestText(BaseCassEngTestCase): + + def test_min_length(self): + #min len defaults to 1 + col = Text() + + with self.assertRaises(ValidationError): + col.validate('') + + col.validate('b') + + #test not required defaults to 0 + Text(required=False).validate('') + + #test arbitrary lengths + Text(min_length=0).validate('') + Text(min_length=5).validate('blake') + Text(min_length=5).validate('blaketastic') + with self.assertRaises(ValidationError): + Text(min_length=6).validate('blake') + + def test_max_length(self): + + Text(max_length=5).validate('blake') + with self.assertRaises(ValidationError): + Text(max_length=5).validate('blaketastic') + + def test_type_checking(self): + Text().validate('string') + Text().validate(u'unicode') + Text().validate(bytearray('bytearray')) + + with self.assertRaises(ValidationError): + Text().validate(None) + + with self.assertRaises(ValidationError): + Text().validate(5) + + with self.assertRaises(ValidationError): + Text().validate(True) + + From d0ac0cfcd99cb2619401e886c5fed7f058edfa5f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 6 Jan 2013 10:38:10 -0800 Subject: [PATCH 0095/4200] updating version number, documentation, and changelog --- changelog | 5 +++++ cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- docs/topics/columns.rst | 8 ++++++++ setup.py | 2 +- 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/changelog b/changelog index a724be7dfa..8763c4ad70 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,10 @@ CHANGELOG +0.1 +* added min_length and max_length validators to the Text column +* added == and != equality operators to model class +* fixed bug with default values that would evaluate to False (ie: 0, '') + 0.0.9 * fixed column inheritance bug * manually defined table names are no longer inherited by subclasses diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index a3f227db2b..33cf4f1397 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.0.9' +__version__ = '0.1' diff --git a/docs/conf.py b/docs/conf.py index d4defc9b6e..f2d45ab7a1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.0.9' +version = '0.1' # The full version, including alpha/beta/rc tags. -release = '0.0.9' +release = '0.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index dc87f8cbdf..9090b0bc0f 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -24,6 +24,14 @@ Columns columns.Text() + **options** + + :attr:`~columns.Text.min_length` + Sets the minimum length of this string. If this field is not set , and the column is not a required field, it defaults to 0, otherwise 1. + + :attr:`~columns.Text.max_length` + Sets the maximum length of this string. Defaults to None + .. class:: Integer() Stores an integer value :: diff --git a/setup.py b/setup.py index 470df3540c..65c4806c53 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.0.9' +version = '0.1' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine From 9386d55513ae9233e2281c8f0ffbabf37ce656f9 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 8 Feb 2013 07:04:49 -0800 Subject: [PATCH 0096/4200] fixed a bug in table auto naming, which would fail if the generated name started with an _ --- cqlengine/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cqlengine/models.py b/cqlengine/models.py index 255b7ce808..f979a84e48 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -52,6 +52,7 @@ def column_family_name(cls, include_keyspace=True): #trim to less than 48 characters or cassandra will complain cf_name = cf_name[-48:] cf_name = cf_name.lower() + cf_name = re.sub(r'^_+', '', cf_name) if not include_keyspace: return cf_name return '{}.{}'.format(cls.keyspace, cf_name) From 2aa17094e3af71bb3cad7b3508f703376973e3d4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 8 Feb 2013 07:28:19 -0800 Subject: [PATCH 0097/4200] adding check for existing indexes --- cqlengine/management.py | 9 +++++++-- cqlengine/tests/model/test_model_io.py | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 4e764b55de..7031b8585c 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -47,11 +47,16 @@ def add_column(col): con.execute(qs) + #get existing index names + ks_info = con.con.client.describe_keyspace(model.keyspace) + cf_def = [cf for cf in ks_info.cf_defs if cf.name == raw_cf_name][0] + idx_names = [i.index_name for i in cf_def.column_metadata] + idx_names = filter(None, idx_names) + indexes = [c for n,c in model._columns.items() if c.index] if indexes: for column in indexes: - #TODO: check for existing index... - #can that be determined from the connection client? + if column.db_index_name in idx_names: continue qs = ['CREATE INDEX {}'.format(column.db_index_name)] qs += ['ON {}'.format(cf_name)] qs += ['({})'.format(column.db_field_name)] diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index f65b44efd2..61e9e73996 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -66,3 +66,9 @@ def test_column_deleting_works_properly(self): assert tm2._values['text'].initial_value is None + def test_a_sensical_error_is_raised_if_you_try_to_create_a_table_twice(self): + """ + """ + create_table(TestModel) + create_table(TestModel) + From b8ba592ede1d59a2ee5b3d9ef213c8eb7b5ab6f8 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 8 Feb 2013 07:32:39 -0800 Subject: [PATCH 0098/4200] version number bump --- changelog | 3 +++ cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/changelog b/changelog index 8763c4ad70..153d5617cd 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.1.1 +* fixed a bug occurring when creating tables from the REPL + 0.1 * added min_length and max_length validators to the Text column * added == and != equality operators to model class diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 33cf4f1397..cb73106c62 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.1' +__version__ = '0.1.1' diff --git a/docs/conf.py b/docs/conf.py index f2d45ab7a1..d25ee3a0bb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.1' +version = '0.1.1' # The full version, including alpha/beta/rc tags. -release = '0.1' +release = '0.1.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 65c4806c53..f1878a24b3 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.1' +version = '0.1.1' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine From ba53c61b7b66cac2340b845e7cccdfa6911ce805 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 8 Feb 2013 09:11:29 -0800 Subject: [PATCH 0099/4200] fixing potential bug with column family discovery --- cqlengine/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 7031b8585c..65d6b0e2e3 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -49,8 +49,8 @@ def add_column(col): #get existing index names ks_info = con.con.client.describe_keyspace(model.keyspace) - cf_def = [cf for cf in ks_info.cf_defs if cf.name == raw_cf_name][0] - idx_names = [i.index_name for i in cf_def.column_metadata] + cf_defs = [cf for cf in ks_info.cf_defs if cf.name == raw_cf_name] + idx_names = [i.index_name for i in cf_defs[0].column_metadata] if cf_defs else [] idx_names = filter(None, idx_names) indexes = [c for n,c in model._columns.items() if c.index] From 7467f868976f61785229e0461b4a147b1a30d26c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 8 Feb 2013 09:12:12 -0800 Subject: [PATCH 0100/4200] version number bump --- cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index cb73106c62..1f8c70ad36 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,5 +1,5 @@ from cqlengine.columns import * from cqlengine.models import Model -__version__ = '0.1.1' +__version__ = '0.1.2' diff --git a/docs/conf.py b/docs/conf.py index d25ee3a0bb..59c9a1239b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.1.1' +version = '0.1.2' # The full version, including alpha/beta/rc tags. -release = '0.1.1' +release = '0.1.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index f1878a24b3..4190fb384f 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.1.1' +version = '0.1.2' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine From b76244a2f4acffe6b2e0d5bab5f21f21a5111588 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 8 Feb 2013 09:44:13 -0800 Subject: [PATCH 0101/4200] updating docs --- README.md | 16 ++++++++-------- docs/index.rst | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8af88071ca..3e5b06bfa6 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,14 @@ class ExampleModel(Model): >>> create_table(ExampleModel) #now we can create some rows: ->>> em1 = ExampleModel.create(example_type=0, description="example1") ->>> em2 = ExampleModel.create(example_type=0, description="example2") ->>> em3 = ExampleModel.create(example_type=0, description="example3") ->>> em4 = ExampleModel.create(example_type=0, description="example4") ->>> em5 = ExampleModel.create(example_type=1, description="example5") ->>> em6 = ExampleModel.create(example_type=1, description="example6") ->>> em7 = ExampleModel.create(example_type=1, description="example7") ->>> em8 = ExampleModel.create(example_type=1, description="example8") +>>> em1 = ExampleModel.create(example_type=0, description="example1", created_at=datetime.now()) +>>> em2 = ExampleModel.create(example_type=0, description="example2", created_at=datetime.now()) +>>> em3 = ExampleModel.create(example_type=0, description="example3", created_at=datetime.now()) +>>> em4 = ExampleModel.create(example_type=0, description="example4", created_at=datetime.now()) +>>> em5 = ExampleModel.create(example_type=1, description="example5", created_at=datetime.now()) +>>> em6 = ExampleModel.create(example_type=1, description="example6", created_at=datetime.now()) +>>> em7 = ExampleModel.create(example_type=1, description="example7", created_at=datetime.now()) +>>> em8 = ExampleModel.create(example_type=1, description="example8", created_at=datetime.now()) # Note: the UUID and DateTime columns will create uuid4 and datetime.now # values automatically if we don't specify them when creating new rows diff --git a/docs/index.rst b/docs/index.rst index 44b5b9b9fe..dbc64e54f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -46,14 +46,14 @@ Getting Started >>> create_table(ExampleModel) #now we can create some rows: - >>> em1 = ExampleModel.create(example_type=0, description="example1") - >>> em2 = ExampleModel.create(example_type=0, description="example2") - >>> em3 = ExampleModel.create(example_type=0, description="example3") - >>> em4 = ExampleModel.create(example_type=0, description="example4") - >>> em5 = ExampleModel.create(example_type=1, description="example5") - >>> em6 = ExampleModel.create(example_type=1, description="example6") - >>> em7 = ExampleModel.create(example_type=1, description="example7") - >>> em8 = ExampleModel.create(example_type=1, description="example8") + >>> em1 = ExampleModel.create(example_type=0, description="example1", created_at=datetime.now()) + >>> em2 = ExampleModel.create(example_type=0, description="example2", created_at=datetime.now()) + >>> em3 = ExampleModel.create(example_type=0, description="example3", created_at=datetime.now()) + >>> em4 = ExampleModel.create(example_type=0, description="example4", created_at=datetime.now()) + >>> em5 = ExampleModel.create(example_type=1, description="example5", created_at=datetime.now()) + >>> em6 = ExampleModel.create(example_type=1, description="example6", created_at=datetime.now()) + >>> em7 = ExampleModel.create(example_type=1, description="example7", created_at=datetime.now()) + >>> em8 = ExampleModel.create(example_type=1, description="example8", created_at=datetime.now()) # Note: the UUID and DateTime columns will create uuid4 and datetime.now # values automatically if we don't specify them when creating new rows From 2d6aab505b6a387ede22b3f80da028306d8ed631 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 23 Feb 2013 10:01:54 -0800 Subject: [PATCH 0102/4200] adding support for 1.2 style keyspace creation --- cqlengine/connection.py | 2 ++ cqlengine/management.py | 46 ++++++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 8e1f70709d..52bc5cdef1 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -165,6 +165,8 @@ def execute(self, query, params={}): self.cur = self.con.cursor() self.cur.execute(query, params) return self.cur + except cql.ProgrammingError as ex: + raise CQLEngineException(unicode(ex)) except TTransportException: #TODO: check for other errors raised in the event of a connection / server problem #move to the next connection and set the connection pool diff --git a/cqlengine/management.py b/cqlengine/management.py index 65d6b0e2e3..7b4e52abe2 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,11 +1,43 @@ +import json + from cqlengine.connection import connection_manager +from cqlengine.exceptions import CQLEngineException + +def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, durable_writes=True, **replication_values): + """ + creates a keyspace -def create_keyspace(name, strategy_class = 'SimpleStrategy', replication_factor=3): + :param name: name of keyspace to create + :param strategy_class: keyspace replication strategy class + :param replication_factor: keyspace replication factor + :param durable_writes: 1.2 only, write log is bypassed if set to False + :param **replication_values: 1.2 only, additional values to ad to the replication data map + """ with connection_manager() as con: if name not in [k.name for k in con.con.client.describe_keyspaces()]: - con.execute("""CREATE KEYSPACE {} - WITH strategy_class = '{}' - AND strategy_options:replication_factor={};""".format(name, strategy_class, replication_factor)) + + try: + #Try the 1.1 method + con.execute("""CREATE KEYSPACE {} + WITH strategy_class = '{}' + AND strategy_options:replication_factor={};""".format(name, strategy_class, replication_factor)) + except CQLEngineException: + #try the 1.2 method + replication_map = { + 'class': strategy_class, + 'replication_factor':replication_factor + } + replication_map.update(replication_values) + + query = """ + CREATE KEYSPACE {} + WITH REPLICATION = {} + """.format(name, json.dumps(replication_map).replace('"', "'")) + + if strategy_class != 'SimpleStrategy': + query += " AND DURABLE_WRITES = {}".format('true' if durable_writes else 'false') + + con.execute(query) def delete_keyspace(name): with connection_manager() as con: @@ -31,8 +63,8 @@ def create_table(model, create_missing_keyspace=True): pkeys = [] qtypes = [] def add_column(col): - s = '{} {}'.format(col.db_field_name, col.db_type) - if col.primary_key: pkeys.append(col.db_field_name) + s = '"{}" {}'.format(col.db_field_name, col.db_type) + if col.primary_key: pkeys.append('"{}"'.format(col.db_field_name)) qtypes.append(s) for name, col in model._columns.items(): add_column(col) @@ -42,7 +74,7 @@ def add_column(col): qs += ['({})'.format(', '.join(qtypes))] # add read_repair_chance - qs += ["WITH read_repair_chance = {}".format(model.read_repair_chance)] + qs += ['WITH read_repair_chance = {}'.format(model.read_repair_chance)] qs = ' '.join(qs) con.execute(qs) From daadbeb364c59998fd543d7e789bd0f171202209 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 23 Feb 2013 10:12:34 -0800 Subject: [PATCH 0103/4200] adding workaround for detecting existing tables, since 1.2 won't return the column family names with the describe keyspace method --- cqlengine/management.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 7b4e52abe2..28da51c868 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -15,7 +15,6 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, """ with connection_manager() as con: if name not in [k.name for k in con.con.client.describe_keyspaces()]: - try: #Try the 1.1 method con.execute("""CREATE KEYSPACE {} @@ -77,7 +76,13 @@ def add_column(col): qs += ['WITH read_repair_chance = {}'.format(model.read_repair_chance)] qs = ' '.join(qs) - con.execute(qs) + try: + con.execute(qs) + except CQLEngineException as ex: + # 1.2 doesn't return cf names, so we have to examine the exception + # and ignore if it says the column family already exists + if "Cannot add already existing column family" not in unicode(ex): + raise #get existing index names ks_info = con.con.client.describe_keyspace(model.keyspace) From 7fce1cda23d8a402fef5767e6a166ae309ee3597 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 23 Feb 2013 11:03:59 -0800 Subject: [PATCH 0104/4200] fixing index creation to deal with broken schema discovery in cassandra 1.2 --- cqlengine/management.py | 13 ++++++++--- cqlengine/query.py | 10 ++++----- cqlengine/tests/model/test_model_io.py | 30 ++++++++++++++++++++++++++ cqlengine/tests/query/test_queryset.py | 4 ++-- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 28da51c868..2b45622129 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -84,7 +84,7 @@ def add_column(col): if "Cannot add already existing column family" not in unicode(ex): raise - #get existing index names + #get existing index names, skip ones that already exist ks_info = con.con.client.describe_keyspace(model.keyspace) cf_defs = [cf for cf in ks_info.cf_defs if cf.name == raw_cf_name] idx_names = [i.index_name for i in cf_defs[0].column_metadata] if cf_defs else [] @@ -96,9 +96,16 @@ def add_column(col): if column.db_index_name in idx_names: continue qs = ['CREATE INDEX {}'.format(column.db_index_name)] qs += ['ON {}'.format(cf_name)] - qs += ['({})'.format(column.db_field_name)] + qs += ['("{}")'.format(column.db_field_name)] qs = ' '.join(qs) - con.execute(qs) + + try: + con.execute(qs) + except CQLEngineException as ex: + # 1.2 doesn't return cf names, so we have to examine the exception + # and ignore if it says the index already exists + if "Index already exists" not in unicode(ex): + raise def delete_table(model): diff --git a/cqlengine/query.py b/cqlengine/query.py index e75f563382..0b1973f7ef 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -39,7 +39,7 @@ def cql(self): Returns this operator's portion of the WHERE clause :param valname: the dict key that this operator's compare value will be found in """ - return '{} {} :{}'.format(self.column.db_field_name, self.cql_symbol, self.identifier) + return '"{}" {} :{}'.format(self.column.db_field_name, self.cql_symbol, self.identifier) def validate_operator(self): """ @@ -205,7 +205,7 @@ def _select_query(self): fields = [f for f in fields if f in self._only_fields] db_fields = [self.model._columns[f].db_field_name for f in fields] - qs = ['SELECT {}'.format(', '.join(db_fields))] + qs = ['SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields]))] qs += ['FROM {}'.format(self.column_family_name)] if self._where: @@ -389,7 +389,7 @@ def order_by(self, colname): "Can't order by the first primary key, clustering (secondary) keys only") clone = copy.deepcopy(self) - clone._order = '{} {}'.format(column.db_field_name, order_type) + clone._order = '"{}" {}'.format(column.db_field_name, order_type) return clone def count(self): @@ -478,7 +478,7 @@ def save(self, instance): field_names = zip(*value_pairs)[0] field_values = dict(value_pairs) qs = ["INSERT INTO {}".format(self.column_family_name)] - qs += ["({})".format(', '.join(field_names))] + qs += ["({})".format(', '.join(['"{}"'.format(f) for f in field_names]))] qs += ['VALUES'] qs += ["({})".format(', '.join([':'+f for f in field_names]))] qs = ' '.join(qs) @@ -492,7 +492,7 @@ def save(self, instance): del_fields = [self.model._columns[f] for f in deleted] del_fields = [f.db_field_name for f in del_fields if not f.primary_key] pks = self.model._primary_keys - qs = ['DELETE {}'.format(', '.join(del_fields))] + qs = ['DELETE {}'.format(', '.join(['"{}"'.format(f) for f in del_fields]))] qs += ['FROM {}'.format(self.column_family_name)] qs += ['WHERE'] eq = lambda col: '{0} = :{0}'.format(v.column.db_field_name) diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 68dc18c340..e24da14175 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -71,3 +71,33 @@ def test_a_sensical_error_is_raised_if_you_try_to_create_a_table_twice(self): create_table(TestModel) create_table(TestModel) +class IndexDefinitionModel(Model): + key = columns.UUID(primary_key=True) + val = columns.Text(index=True) + +class TestIndexedColumnDefinition(BaseCassEngTestCase): + + def test_exception_isnt_raised_if_an_index_is_defined_more_than_once(self): + create_table(IndexDefinitionModel) + create_table(IndexDefinitionModel) + +class ReservedWordModel(Model): + token = columns.Text(primary_key=True) + insert = columns.Integer(index=True) + +class TestQueryQuoting(BaseCassEngTestCase): + + def test_reserved_cql_words_can_be_used_as_column_names(self): + """ + """ + create_table(ReservedWordModel) + + model1 = ReservedWordModel.create(token='1', insert=5) + + model2 = ReservedWordModel.filter(token=1) + + assert len(model2) == 1 + assert model1.token == model2[0].token + assert model1.insert == model2[0].insert + + diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 31178b16c5..c80a9386e9 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -55,12 +55,12 @@ def test_where_clause_generation(self): query1 = TestModel.objects(test_id=5) ids = [o.identifier for o in query1._where] where = query1._where_clause() - assert where == 'test_id = :{}'.format(*ids) + assert where == '"test_id" = :{}'.format(*ids) query2 = query1.filter(expected_result__gte=1) ids = [o.identifier for o in query2._where] where = query2._where_clause() - assert where == 'test_id = :{} AND expected_result >= :{}'.format(*ids) + assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) def test_querystring_generation(self): From f37ee97f2e06f97815c0fdd059798b5f3e121ac0 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 23 Feb 2013 12:51:46 -0800 Subject: [PATCH 0105/4200] adding support for the allow filtering flag in cassandra 1.2 --- cqlengine/columns.py | 3 +++ cqlengine/models.py | 4 ++++ cqlengine/query.py | 25 +++++++++++++++++++++++++ cqlengine/tests/query/test_queryset.py | 6 +++++- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 2f6ee4f4ac..b9d3d7c136 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -59,6 +59,9 @@ def __init__(self, primary_key=False, index=False, db_field=None, default=None, self.default = default self.required = required + #only the model meta class should touch this + self._partition_key = False + #the column name in the model definition self.column_name = None diff --git a/cqlengine/models.py b/cqlengine/models.py index 45475bfa03..07a7b47900 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -118,6 +118,7 @@ def __new__(cls, name, bases, attrs): column_dict = OrderedDict() primary_keys = OrderedDict() pk_name = None + primary_key = None #get inherited properties inherited_columns = OrderedDict() @@ -158,6 +159,8 @@ def _transform_column(col_name, col_obj): for k,v in column_definitions: if pk_name is None and v.primary_key: pk_name = k + primary_key = v + v._partition_key = True _transform_column(k,v) #setup primary key shortcut @@ -191,6 +194,7 @@ def _transform_column(col_name, col_obj): attrs['_defined_columns'] = defined_columns attrs['_db_map'] = db_map attrs['_pk_name'] = pk_name + attrs['_primary_key'] = primary_key attrs['_dynamic_columns'] = {} #create the class and add a QuerySet to it diff --git a/cqlengine/query.py b/cqlengine/query.py index 0b1973f7ef..d76e14c004 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -129,6 +129,8 @@ def __init__(self, model): #ordering arguments self._order = None + self._allow_filtering = False + #CQL has a default limit of 10000, it's defined here #because explicit is better than implicit self._limit = 10000 @@ -180,6 +182,14 @@ def _validate_where_syntax(self): equal_ops = [w for w in self._where if isinstance(w, EqualsOperator)] if not any([w.column.primary_key or w.column.index for w in equal_ops]): raise QueryException('Where clauses require either a "=" or "IN" comparison with either a primary key or indexed field') + + if not self._allow_filtering: + #if the query is not on an indexed field + if not any([w.column.index for w in equal_ops]): + if not any([w.column._partition_key for w in equal_ops]): + raise QueryException('Filtering on a clustering key without a partition key is not allowed unless allow_filtering() is called on the querset') + + #TODO: abuse this to see if we can get cql to raise an exception def _where_clause(self): @@ -217,6 +227,9 @@ def _select_query(self): if self._limit: qs += ['LIMIT {}'.format(self._limit)] + if self._allow_filtering: + qs += ['ALLOW FILTERING'] + return ' '.join(qs) #----Reads------ @@ -400,6 +413,9 @@ def count(self): qs += ['FROM {}'.format(self.column_family_name)] if self._where: qs += ['WHERE {}'.format(self._where_clause())] + if self._allow_filtering: + qs += ['ALLOW FILTERING'] + qs = ' '.join(qs) with connection_manager() as con: @@ -425,6 +441,15 @@ def limit(self, v): clone._limit = v return clone + def allow_filtering(self): + """ + Enables the unwise practive of querying on a clustering + key without also defining a partition key + """ + clone = copy.deepcopy(self) + clone._allow_filtering = True + return clone + def _only_or_defer(self, action, fields): clone = copy.deepcopy(self) if clone._defer_fields or clone._only_fields: diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index c80a9386e9..3eff054f7b 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -165,7 +165,7 @@ def test_iteration(self): compare_set.remove(val) assert len(compare_set) == 0 - q = TestModel.objects(attempt_id=3) + q = TestModel.objects(attempt_id=3).allow_filtering() assert len(q) == 3 #tuple of expected test_id, expected_result values compare_set = set([(0,20), (1,20), (2,75)]) @@ -241,6 +241,10 @@ def test_get_multipleobjects_exception(self): with self.assertRaises(TestModel.MultipleObjectsReturned): TestModel.objects.get(test_id=1) + def test_allow_filtering_flag(self): + """ + """ + class TestQuerySetOrdering(BaseQuerySetUsage): From 584c439434ebe93253fdc39ae098133c0f7db6ee Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 23 Feb 2013 13:35:32 -0800 Subject: [PATCH 0106/4200] updating delete table to work with Cassandra 1.2 --- cqlengine/management.py | 9 +++++---- cqlengine/tests/management/test_management.py | 16 +++++++++++++--- cqlengine/tests/query/test_datetime_queries.py | 1 + 3 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 cqlengine/tests/query/test_datetime_queries.py diff --git a/cqlengine/management.py b/cqlengine/management.py index 2b45622129..20ec5741f3 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -109,11 +109,12 @@ def add_column(col): def delete_table(model): - #check that model exists cf_name = model.column_family_name() - raw_cf_name = model.column_family_name(include_keyspace=False) with connection_manager() as con: - ks_info = con.con.client.describe_keyspace(model.keyspace) - if any([raw_cf_name == cf.name for cf in ks_info.cf_defs]): + try: con.execute('drop table {};'.format(cf_name)) + except CQLEngineException as ex: + #don't freak out if the table doesn't exist + if 'Cannot drop non existing column family' not in unicode(ex): + raise diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index f3862a9527..ac542bb8e6 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -1,9 +1,11 @@ +from cqlengine.management import create_table, delete_table from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.connection import ConnectionPool from mock import Mock from cqlengine import management +from cqlengine.tests.query.test_queryset import TestModel class ConnectionPoolTestCase(BaseCassEngTestCase): @@ -42,6 +44,14 @@ class CreateKeyspaceTest(BaseCassEngTestCase): def test_create_succeeeds(self): management.create_keyspace('test_keyspace') management.delete_keyspace('test_keyspace') - - - \ No newline at end of file + +class DeleteTableTest(BaseCassEngTestCase): + + def test_multiple_deletes_dont_fail(self): + """ + + """ + create_table(TestModel) + + delete_table(TestModel) + delete_table(TestModel) diff --git a/cqlengine/tests/query/test_datetime_queries.py b/cqlengine/tests/query/test_datetime_queries.py new file mode 100644 index 0000000000..f6150a6d76 --- /dev/null +++ b/cqlengine/tests/query/test_datetime_queries.py @@ -0,0 +1 @@ +__author__ = 'bdeggleston' From a20a26340787676847ef5a58e1d43602f6b89874 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 23 Feb 2013 13:35:59 -0800 Subject: [PATCH 0107/4200] fixing datetime query bug --- cqlengine/query.py | 2 +- .../tests/query/test_datetime_queries.py | 48 ++++++++++++++++++- cqlengine/tests/query/test_queryset.py | 2 - 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index d76e14c004..840d7b2fe0 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -74,7 +74,7 @@ def get_dict(self): this should return the dict: {'colval':} SELECT * FROM column_family WHERE colname=:colval """ - return {self.identifier: self.value} + return {self.identifier: self.column.to_database(self.value)} @classmethod def get_operator(cls, symbol): diff --git a/cqlengine/tests/query/test_datetime_queries.py b/cqlengine/tests/query/test_datetime_queries.py index f6150a6d76..044e12328c 100644 --- a/cqlengine/tests/query/test_datetime_queries.py +++ b/cqlengine/tests/query/test_datetime_queries.py @@ -1 +1,47 @@ -__author__ = 'bdeggleston' +from datetime import datetime, timedelta +from uuid import uuid4 + +from cqlengine.tests.base import BaseCassEngTestCase + +from cqlengine.exceptions import ModelException +from cqlengine.management import create_table +from cqlengine.management import delete_table +from cqlengine.models import Model +from cqlengine import columns +from cqlengine import query + +class DateTimeQueryTestModel(Model): + user = columns.Integer(primary_key=True) + day = columns.DateTime(primary_key=True) + data = columns.Text() + +class TestDateTimeQueries(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestDateTimeQueries, cls).setUpClass() + create_table(DateTimeQueryTestModel) + + cls.base_date = datetime.now() - timedelta(days=10) + for x in range(7): + for y in range(10): + DateTimeQueryTestModel.create( + user=x, + day=(cls.base_date+timedelta(days=y)), + data=str(uuid4()) + ) + + + @classmethod + def tearDownClass(cls): + super(TestDateTimeQueries, cls).tearDownClass() + delete_table(DateTimeQueryTestModel) + + def test_range_query(self): + """ Tests that loading from a range of dates works properly """ + start = datetime(*self.base_date.timetuple()[:3]) + end = start + timedelta(days=3) + + results = DateTimeQueryTestModel.filter(user=0, day__gte=start, day__lt=end) + assert len(results) == 3 + diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 3eff054f7b..cf4eb9d1b0 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -245,7 +245,6 @@ def test_allow_filtering_flag(self): """ """ - class TestQuerySetOrdering(BaseQuerySetUsage): def test_order_by_success_case(self): @@ -392,4 +391,3 @@ def test_conn_is_returned_after_queryset_is_garbage_collected(self): - From 64a68e9cf9e045cd64a97a00db2750be02971a1e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 24 Feb 2013 19:43:51 -0800 Subject: [PATCH 0108/4200] fixing date time precision, and using utc time when turning the cal timestamp into a datetime --- cqlengine/columns.py | 5 +++-- cqlengine/tests/query/test_datetime_queries.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index b9d3d7c136..15a02748d7 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -190,13 +190,14 @@ def __init__(self, **kwargs): def to_python(self, value): if isinstance(value, datetime): return value - return datetime.fromtimestamp(value) + return datetime.utcfromtimestamp(value) def to_database(self, value): value = super(DateTime, self).to_database(value) if not isinstance(value, datetime): raise ValidationError("'{}' is not a datetime object".format(value)) - return value.strftime('%Y-%m-%d %H:%M:%S') + epoch = datetime(1970, 1, 1) + return long((value - epoch).total_seconds() * 1000) class UUID(Column): """ diff --git a/cqlengine/tests/query/test_datetime_queries.py b/cqlengine/tests/query/test_datetime_queries.py index 044e12328c..e374bd7d18 100644 --- a/cqlengine/tests/query/test_datetime_queries.py +++ b/cqlengine/tests/query/test_datetime_queries.py @@ -45,3 +45,13 @@ def test_range_query(self): results = DateTimeQueryTestModel.filter(user=0, day__gte=start, day__lt=end) assert len(results) == 3 + def test_datetime_precision(self): + """ Tests that millisecond resolution is preserved when saving datetime objects """ + now = datetime.now() + pk = 1000 + obj = DateTimeQueryTestModel.create(user=pk, day=now, data='energy cheese') + load = DateTimeQueryTestModel.get(user=pk) + + assert abs(now - load.day).total_seconds() < 0.001 + obj.delete() + From b2e40c1b97b313e6d66a97bd075374bcda23f7b7 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 1 Mar 2013 07:36:28 -0800 Subject: [PATCH 0109/4200] updating changelog --- changelog | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/changelog b/changelog index 153d5617cd..aa066984d3 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,11 @@ CHANGELOG +0.1.2 (in progress) +* adding support for allow filtering flag +* updating management functions to work with cassandra 1.2 +* fixed a bug querying datetimes +* modifying datetime serialization to preserver millisecond accuracy + 0.1.1 * fixed a bug occurring when creating tables from the REPL From 320682c76de1db16545771e3ad3e7452c5d33906 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 1 Mar 2013 08:00:05 -0800 Subject: [PATCH 0110/4200] adding support for cal helper functions --- cqlengine/functions.py | 48 ++++++++++++++++++++ cqlengine/models.py | 1 + cqlengine/query.py | 6 ++- cqlengine/tests/query/test_queryoperators.py | 32 +++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 cqlengine/functions.py diff --git a/cqlengine/functions.py b/cqlengine/functions.py new file mode 100644 index 0000000000..f2f6166185 --- /dev/null +++ b/cqlengine/functions.py @@ -0,0 +1,48 @@ +from datetime import datetime + +from cqlengine.exceptions import ValidationError + +class BaseQueryFunction(object): + """ + Base class for filtering functions. Subclasses of these classes can + be passed into .filter() and will be translated into CQL functions in + the resulting query + """ + + _cql_string = None + + def __init__(self, value): + self.value = value + + def to_cql(self, value_id): + """ + Returns a function for cql with the value id as it's argument + """ + return self._cql_string.format(value_id) + +class MinTimeUUID(BaseQueryFunction): + + _cql_string = 'MinTimeUUID(:{})' + + def __init__(self, value): + """ + :param value: the time to create a maximum time uuid from + :type value: datetime + """ + if not isinstance(value, datetime): + raise ValidationError('datetime instance is required') + super(MinTimeUUID, self).__init__(value) + +class MaxTimeUUID(BaseQueryFunction): + + _cql_string = 'MaxTimeUUID(:{})' + + def __init__(self, value): + """ + :param value: the time to create a minimum time uuid from + :type value: datetime + """ + if not isinstance(value, datetime): + raise ValidationError('datetime instance is required') + super(MaxTimeUUID, self).__init__(value) + diff --git a/cqlengine/models.py b/cqlengine/models.py index 07a7b47900..a856c59f2f 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -3,6 +3,7 @@ from cqlengine import columns from cqlengine.exceptions import ModelException +from cqlengine.functions import BaseQueryFunction from cqlengine.query import QuerySet, QueryException class ModelDefinitionException(ModelException): pass diff --git a/cqlengine/query.py b/cqlengine/query.py index 840d7b2fe0..94c4544d5b 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -5,6 +5,7 @@ from cqlengine.connection import connection_manager from cqlengine.exceptions import CQLEngineException +from cqlengine.functions import BaseQueryFunction #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index @@ -39,7 +40,10 @@ def cql(self): Returns this operator's portion of the WHERE clause :param valname: the dict key that this operator's compare value will be found in """ - return '"{}" {} :{}'.format(self.column.db_field_name, self.cql_symbol, self.identifier) + if isinstance(self.value, BaseQueryFunction): + return '"{}" {} {}'.format(self.column.db_field_name, self.cql_symbol, self.value.to_cql(self.identifier)) + else: + return '"{}" {} :{}'.format(self.column.db_field_name, self.cql_symbol, self.identifier) def validate_operator(self): """ diff --git a/cqlengine/tests/query/test_queryoperators.py b/cqlengine/tests/query/test_queryoperators.py index e69de29bb2..62ffea9cb3 100644 --- a/cqlengine/tests/query/test_queryoperators.py +++ b/cqlengine/tests/query/test_queryoperators.py @@ -0,0 +1,32 @@ +from datetime import datetime + +from cqlengine.tests.base import BaseCassEngTestCase +from cqlengine import columns +from cqlengine import functions +from cqlengine import query + +class TestQuerySetOperation(BaseCassEngTestCase): + + def test_maxtimeuuid_function(self): + """ + Tests that queries with helper functions are generated properly + """ + now = datetime.now() + col = columns.DateTime() + col.set_column_name('time') + qry = query.EqualsOperator(col, functions.MaxTimeUUID(now)) + + assert qry.cql == '"time" = MaxTimeUUID(:{})'.format(qry.identifier) + + def test_mintimeuuid_function(self): + """ + Tests that queries with helper functions are generated properly + """ + now = datetime.now() + col = columns.DateTime() + col.set_column_name('time') + qry = query.EqualsOperator(col, functions.MinTimeUUID(now)) + + assert qry.cql == '"time" = MinTimeUUID(:{})'.format(qry.identifier) + + From b4e09a324c9ba89b142d2ff764a851c69da635dc Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 2 Mar 2013 17:13:51 -0800 Subject: [PATCH 0111/4200] testing min and max timeuuid functions with related debugging --- cqlengine/functions.py | 11 +++++ cqlengine/query.py | 5 +- cqlengine/tests/query/test_queryoperators.py | 4 +- cqlengine/tests/query/test_queryset.py | 50 ++++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/cqlengine/functions.py b/cqlengine/functions.py index f2f6166185..15e93d4659 100644 --- a/cqlengine/functions.py +++ b/cqlengine/functions.py @@ -20,6 +20,9 @@ def to_cql(self, value_id): """ return self._cql_string.format(value_id) + def get_value(self): + raise NotImplementedError + class MinTimeUUID(BaseQueryFunction): _cql_string = 'MinTimeUUID(:{})' @@ -33,6 +36,10 @@ def __init__(self, value): raise ValidationError('datetime instance is required') super(MinTimeUUID, self).__init__(value) + def get_value(self): + epoch = datetime(1970, 1, 1) + return long((self.value - epoch).total_seconds() * 1000) + class MaxTimeUUID(BaseQueryFunction): _cql_string = 'MaxTimeUUID(:{})' @@ -46,3 +53,7 @@ def __init__(self, value): raise ValidationError('datetime instance is required') super(MaxTimeUUID, self).__init__(value) + def get_value(self): + epoch = datetime(1970, 1, 1) + return long((self.value - epoch).total_seconds() * 1000) + diff --git a/cqlengine/query.py b/cqlengine/query.py index 94c4544d5b..7a9a74e77a 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -78,7 +78,10 @@ def get_dict(self): this should return the dict: {'colval':} SELECT * FROM column_family WHERE colname=:colval """ - return {self.identifier: self.column.to_database(self.value)} + if isinstance(self.value, BaseQueryFunction): + return {self.identifier: self.column.to_database(self.value.get_value())} + else: + return {self.identifier: self.column.to_database(self.value)} @classmethod def get_operator(cls, symbol): diff --git a/cqlengine/tests/query/test_queryoperators.py b/cqlengine/tests/query/test_queryoperators.py index 62ffea9cb3..5db4a299af 100644 --- a/cqlengine/tests/query/test_queryoperators.py +++ b/cqlengine/tests/query/test_queryoperators.py @@ -1,7 +1,8 @@ from datetime import datetime +import time from cqlengine.tests.base import BaseCassEngTestCase -from cqlengine import columns +from cqlengine import columns, Model from cqlengine import functions from cqlengine import query @@ -30,3 +31,4 @@ def test_mintimeuuid_function(self): assert qry.cql == '"time" = MinTimeUUID(:{})'.format(qry.identifier) + diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index cf4eb9d1b0..99f45afacd 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -1,6 +1,11 @@ +from datetime import datetime +import time +from uuid import uuid1, uuid4 + from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.exceptions import ModelException +from cqlengine import functions from cqlengine.management import create_table from cqlengine.management import delete_table from cqlengine.models import Model @@ -386,6 +391,51 @@ def test_conn_is_returned_after_queryset_is_garbage_collected(self): del q assert ConnectionPool._queue.qsize() == 1 +class TimeUUIDQueryModel(Model): + partition = columns.UUID(primary_key=True) + time = columns.TimeUUID(primary_key=True) + data = columns.Text(required=False) + +class TestMinMaxTimeUUIDFunctions(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestMinMaxTimeUUIDFunctions, cls).setUpClass() + create_table(TimeUUIDQueryModel) + + @classmethod + def tearDownClass(cls): + super(TestMinMaxTimeUUIDFunctions, cls).tearDownClass() + delete_table(TimeUUIDQueryModel) + + def test_success_case(self): + """ Test that the min and max time uuid functions work as expected """ + pk = uuid4() + TimeUUIDQueryModel.create(partition=pk, time=uuid1(), data='1') + time.sleep(0.2) + TimeUUIDQueryModel.create(partition=pk, time=uuid1(), data='2') + time.sleep(0.2) + midpoint = datetime.utcnow() + time.sleep(0.2) + TimeUUIDQueryModel.create(partition=pk, time=uuid1(), data='3') + time.sleep(0.2) + TimeUUIDQueryModel.create(partition=pk, time=uuid1(), data='4') + time.sleep(0.2) + + q = TimeUUIDQueryModel.filter(partition=pk, time__lte=functions.MaxTimeUUID(midpoint)) + q = [d for d in q] + assert len(q) == 2 + datas = [d.data for d in q] + assert '1' in datas + assert '2' in datas + + q = TimeUUIDQueryModel.filter(partition=pk, time__gte=functions.MinTimeUUID(midpoint)) + assert len(q) == 2 + datas = [d.data for d in q] + assert '3' in datas + assert '4' in datas + + From 684501de389f016fad9b9ee1fb352ea0a6c99abb Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 2 Mar 2013 19:31:11 -0800 Subject: [PATCH 0112/4200] updating changelog --- changelog | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index aa066984d3..c5f26914a5 100644 --- a/changelog +++ b/changelog @@ -4,7 +4,8 @@ CHANGELOG * adding support for allow filtering flag * updating management functions to work with cassandra 1.2 * fixed a bug querying datetimes -* modifying datetime serialization to preserver millisecond accuracy +* modifying datetime serialization to preserve millisecond accuracy +* adding cql function call generators MaxTimeUUID and MinTimeUUID 0.1.1 * fixed a bug occurring when creating tables from the REPL From 9aba6e469e27692fde5173d9d21c729236d22c9a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 5 Mar 2013 21:26:15 -0800 Subject: [PATCH 0113/4200] adding container columns --- cqlengine/columns.py | 169 +++++++++++++++++- cqlengine/management.py | 2 +- .../tests/columns/test_container_columns.py | 116 ++++++++++++ 3 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 cqlengine/tests/columns/test_container_columns.py diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 15a02748d7..fe7ad9e348 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -1,7 +1,9 @@ #column field types +from copy import copy from datetime import datetime import re from uuid import uuid1, uuid4 +from cql.query import cql_quote from cqlengine.exceptions import ValidationError @@ -10,7 +12,7 @@ class BaseValueManager(object): def __init__(self, instance, column, value): self.instance = instance self.column = column - self.initial_value = value + self.initial_value = copy(value) self.value = value @property @@ -121,7 +123,7 @@ def get_column_def(self): """ Returns a column definition for CQL table definition """ - return '{} {}'.format(self.db_field_name, self.db_type) + return '"{}" {}'.format(self.db_field_name, self.db_type) def set_column_name(self, name): """ @@ -156,6 +158,7 @@ def __init__(self, *args, **kwargs): def validate(self, value): value = super(Text, self).validate(value) + if value is None: return if not isinstance(value, (basestring, bytearray)) and value is not None: raise ValidationError('{} is not a string'.format(type(value))) if self.max_length: @@ -171,6 +174,7 @@ class Integer(Column): def validate(self, value): val = super(Integer, self).validate(value) + if val is None: return try: return long(val) except (TypeError, ValueError): @@ -212,6 +216,7 @@ def __init__(self, default=lambda:uuid4(), **kwargs): def validate(self, value): val = super(UUID, self).validate(value) + if val is None: return from uuid import UUID as _UUID if isinstance(val, _UUID): return val if not self.re_uuid.match(val): @@ -246,6 +251,8 @@ def __init__(self, double_precision=True, **kwargs): super(Float, self).__init__(**kwargs) def validate(self, value): + value = super(Float, self).validate(value) + if value is None: return try: return float(value) except (TypeError, ValueError): @@ -266,3 +273,161 @@ def __init__(self, **kwargs): super(Counter, self).__init__(**kwargs) raise NotImplementedError +class ContainerValueManager(BaseValueManager): + pass + +class ContainerQuoter(object): + """ + contains a single value, which will quote itself for CQL insertion statements + """ + def __init__(self, value): + self.value = value + + def __str__(self): + raise NotImplementedError + +class BaseContainerColumn(Column): + """ + Base Container type + """ + + def __init__(self, value_type, **kwargs): + """ + :param value_type: a column class indicating the types of the value + """ + if not issubclass(value_type, Column): + raise ValidationError('value_type must be a column class') + if issubclass(value_type, BaseContainerColumn): + raise ValidationError('container types cannot be nested') + if value_type.db_type is None: + raise ValidationError('value_type cannot be an abstract column type') + + self.value_type = value_type + self.value_col = self.value_type() + super(BaseContainerColumn, self).__init__(**kwargs) + + def get_column_def(self): + """ + Returns a column definition for CQL table definition + """ + db_type = self.db_type.format(self.value_type.db_type) + return '{} {}'.format(self.db_field_name, db_type) + +class Set(BaseContainerColumn): + """ + Stores a set of unordered, unique values + + http://www.datastax.com/docs/1.2/cql_cli/using/collections + """ + db_type = 'set<{}>' + + class Quoter(ContainerQuoter): + + def __str__(self): + cq = cql_quote + return '{' + ', '.join([cq(v) for v in self.value]) + '}' + + def __init__(self, value_type, strict=True, **kwargs): + """ + :param value_type: a column class indicating the types of the value + :param strict: sets whether non set values will be coerced to set + type on validation, or raise a validation error, defaults to True + """ + self.strict = strict + super(Set, self).__init__(value_type, **kwargs) + + def validate(self, value): + val = super(Set, self).validate(value) + if val is None: return + types = (set,) if self.strict else (set, list, tuple) + if not isinstance(val, types): + if self.strict: + raise ValidationError('{} is not a set object'.format(val)) + else: + raise ValidationError('{} cannot be coerced to a set object'.format(val)) + + return {self.value_col.validate(v) for v in val} + + def to_database(self, value): + return self.Quoter({self.value_col.to_database(v) for v in value}) + +class List(BaseContainerColumn): + """ + Stores a list of ordered values + + http://www.datastax.com/docs/1.2/cql_cli/using/collections_list + """ + db_type = 'list<{}>' + + class Quoter(ContainerQuoter): + + def __str__(self): + cq = cql_quote + return '[' + ', '.join([cq(v) for v in self.value]) + ']' + + def validate(self, value): + val = super(List, self).validate(value) + if val is None: return + if not isinstance(val, (set, list, tuple)): + raise ValidationError('{} is not a list object'.format(val)) + return [self.value_col.validate(v) for v in val] + + def to_database(self, value): + return self.Quoter([self.value_col.to_database(v) for v in value]) + +class Map(BaseContainerColumn): + """ + Stores a key -> value map (dictionary) + + http://www.datastax.com/docs/1.2/cql_cli/using/collections_map + """ + + db_type = 'map<{}, {}>' + + class Quoter(ContainerQuoter): + + def __str__(self): + cq = cql_quote + return '{' + ', '.join([cq(k) + ':' + cq(v) for k,v in self.value.items()]) + '}' + + def __init__(self, key_type, value_type, **kwargs): + """ + :param key_type: a column class indicating the types of the key + :param value_type: a column class indicating the types of the value + """ + if not issubclass(value_type, Column): + raise ValidationError('key_type must be a column class') + if issubclass(value_type, BaseContainerColumn): + raise ValidationError('container types cannot be nested') + if key_type.db_type is None: + raise ValidationError('key_type cannot be an abstract column type') + + self.key_type = key_type + self.key_col = self.key_type() + super(Map, self).__init__(value_type, **kwargs) + + def get_column_def(self): + """ + Returns a column definition for CQL table definition + """ + db_type = self.db_type.format( + self.key_type.db_type, + self.value_type.db_type + ) + return '{} {}'.format(self.db_field_name, db_type) + + def validate(self, value): + val = super(Map, self).validate(value) + if val is None: return + if not isinstance(val, dict): + raise ValidationError('{} is not a dict object'.format(val)) + return {self.key_col.validate(k):self.value_col.validate(v) for k,v in val.items()} + + def to_python(self, value): + if value is not None: + return {self.key_col.to_python(k):self.value_col.to_python(v) for k,v in value.items()} + + def to_database(self, value): + return self.Quoter({self.key_col.to_database(k):self.value_col.to_database(v) for k,v in value.items()}) + + diff --git a/cqlengine/management.py b/cqlengine/management.py index 20ec5741f3..ba8b8145f7 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -62,7 +62,7 @@ def create_table(model, create_missing_keyspace=True): pkeys = [] qtypes = [] def add_column(col): - s = '"{}" {}'.format(col.db_field_name, col.db_type) + s = col.get_column_def() if col.primary_key: pkeys.append('"{}"'.format(col.db_field_name)) qtypes.append(s) for name, col in model._columns.items(): diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py new file mode 100644 index 0000000000..064a7cc32d --- /dev/null +++ b/cqlengine/tests/columns/test_container_columns.py @@ -0,0 +1,116 @@ +from datetime import datetime, timedelta +from uuid import uuid4 + +from cqlengine import Model +from cqlengine import columns +from cqlengine.management import create_table, delete_table +from cqlengine.tests.base import BaseCassEngTestCase + +class TestSetModel(Model): + partition = columns.UUID(primary_key=True, default=uuid4) + int_set = columns.Set(columns.Integer, required=False) + text_set = columns.Set(columns.Text, required=False) + +class TestSetColumn(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestSetColumn, cls).setUpClass() + delete_table(TestSetModel) + create_table(TestSetModel) + + @classmethod + def tearDownClass(cls): + super(TestSetColumn, cls).tearDownClass() + delete_table(TestSetModel) + + def test_io_success(self): + """ Tests that a basic usage works as expected """ + m1 = TestSetModel.create(int_set={1,2}, text_set={'kai', 'andreas'}) + m2 = TestSetModel.get(partition=m1.partition) + + assert isinstance(m2.int_set, set) + assert isinstance(m2.text_set, set) + + assert 1 in m2.int_set + assert 2 in m2.int_set + + assert 'kai' in m2.text_set + assert 'andreas' in m2.text_set + + +class TestListModel(Model): + partition = columns.UUID(primary_key=True, default=uuid4) + int_list = columns.List(columns.Integer, required=False) + text_list = columns.List(columns.Text, required=False) + +class TestListColumn(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestListColumn, cls).setUpClass() + delete_table(TestListModel) + create_table(TestListModel) + + @classmethod + def tearDownClass(cls): + super(TestListColumn, cls).tearDownClass() + delete_table(TestListModel) + + def test_io_success(self): + """ Tests that a basic usage works as expected """ + m1 = TestListModel.create(int_list=[1,2], text_list=['kai', 'andreas']) + m2 = TestListModel.get(partition=m1.partition) + + assert isinstance(m2.int_list, tuple) + assert isinstance(m2.text_list, tuple) + + assert len(m2.int_list) == 2 + assert len(m2.text_list) == 2 + + assert m2.int_list[0] == 1 + assert m2.int_list[1] == 2 + + assert m2.text_list[0] == 'kai' + assert m2.text_list[1] == 'andreas' + + +class TestMapModel(Model): + partition = columns.UUID(primary_key=True, default=uuid4) + int_map = columns.Map(columns.Integer, columns.UUID, required=False) + text_map = columns.Map(columns.Text, columns.DateTime, required=False) + +class TestMapColumn(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestMapColumn, cls).setUpClass() + delete_table(TestMapModel) + create_table(TestMapModel) + + @classmethod + def tearDownClass(cls): + super(TestMapColumn, cls).tearDownClass() + delete_table(TestMapModel) + + def test_io_success(self): + """ Tests that a basic usage works as expected """ + k1 = uuid4() + k2 = uuid4() + now = datetime.now() + then = now + timedelta(days=1) + m1 = TestMapModel.create(int_map={1:k1,2:k2}, text_map={'now':now, 'then':then}) + m2 = TestMapModel.get(partition=m1.partition) + + assert isinstance(m2.int_map, dict) + assert isinstance(m2.text_map, dict) + + assert 1 in m2.int_map + assert 2 in m2.int_map + assert m2.int_map[1] == k1 + assert m2.int_map[2] == k2 + + assert 'now' in m2.text_map + assert 'then' in m2.text_map + assert (now - m2.text_map['now']).total_seconds() < 0.001 + assert (then - m2.text_map['then']).total_seconds() < 0.001 From 7ab2585cb7e4786f56f378674ece0ac0b708de76 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 5 Mar 2013 21:47:15 -0800 Subject: [PATCH 0114/4200] adding tests around container column type validation --- .../tests/columns/test_container_columns.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 064a7cc32d..4a1721e078 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from uuid import uuid4 -from cqlengine import Model +from cqlengine import Model, ValidationError from cqlengine import columns from cqlengine.management import create_table, delete_table from cqlengine.tests.base import BaseCassEngTestCase @@ -38,6 +38,13 @@ def test_io_success(self): assert 'kai' in m2.text_set assert 'andreas' in m2.text_set + def test_type_validation(self): + """ + Tests that attempting to use the wrong types will raise an exception + """ + with self.assertRaises(ValidationError): + TestSetModel.create(int_set={'string', True}, text_set={1, 3.0}) + class TestListModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) @@ -74,6 +81,13 @@ def test_io_success(self): assert m2.text_list[0] == 'kai' assert m2.text_list[1] == 'andreas' + def test_type_validation(self): + """ + Tests that attempting to use the wrong types will raise an exception + """ + with self.assertRaises(ValidationError): + TestListModel.create(int_list=['string', True], text_list=[1, 3.0]) + class TestMapModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) @@ -114,3 +128,10 @@ def test_io_success(self): assert 'then' in m2.text_map assert (now - m2.text_map['now']).total_seconds() < 0.001 assert (then - m2.text_map['then']).total_seconds() < 0.001 + + def test_type_validation(self): + """ + Tests that attempting to use the wrong types will raise an exception + """ + with self.assertRaises(ValidationError): + TestMapModel.create(int_map={'key':2,uuid4():'val'}, text_map={2:5}) From 412412bfb8c1ed68095ac43557f6e8ba27f1e09d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 5 Mar 2013 21:47:57 -0800 Subject: [PATCH 0115/4200] fixing failing unit test --- cqlengine/tests/model/test_model_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index e24da14175..f94b5964c2 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -94,7 +94,7 @@ def test_reserved_cql_words_can_be_used_as_column_names(self): model1 = ReservedWordModel.create(token='1', insert=5) - model2 = ReservedWordModel.filter(token=1) + model2 = ReservedWordModel.filter(token='1') assert len(model2) == 1 assert model1.token == model2[0].token From eb2fe8c21fa12e279124689d4f3bcb274be3ea65 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 5 Mar 2013 21:53:22 -0800 Subject: [PATCH 0116/4200] adding collection types to changelog --- changelog | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog b/changelog index c5f26914a5..a109cfcf0f 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,7 @@ CHANGELOG 0.1.2 (in progress) +* adding set, list, and map collection types * adding support for allow filtering flag * updating management functions to work with cassandra 1.2 * fixed a bug querying datetimes From f1119db1c24bdad5237987059c11646404ba7d7a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 5 Mar 2013 22:24:11 -0800 Subject: [PATCH 0117/4200] adding functions to the base module --- cqlengine/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 1f8c70ad36..d5e7b74fcc 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,4 +1,5 @@ from cqlengine.columns import * +from cqlengine.functions import * from cqlengine.models import Model __version__ = '0.1.2' From 61235126bf84b86aa71655d5383228355b0b53df Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 5 Mar 2013 22:24:26 -0800 Subject: [PATCH 0118/4200] adding new features to the documentation --- docs/topics/columns.rst | 59 ++++++++++++++++++++++++++++++++++++++++ docs/topics/models.rst | 6 ++-- docs/topics/queryset.rst | 31 +++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index 9090b0bc0f..0537e3bccb 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -77,6 +77,65 @@ Columns columns.Decimal() +Collection Type Columns +---------------------------- + + CQLEngine also supports container column types. Each container column requires a column class argument to specify what type of objects it will hold. The Map column requires 2, one for the key, and the other for the value + + *Example* + + .. code-block:: python + + class Person(Model): + first_name = columns.Text() + last_name = columns.Text() + + friends = columns.Set(columns.Text) + enemies = columns.Set(columns.Text) + todo_list = columns.List(columns.Text) + birthdays = columns.Map(columns.Text, columns.DateTime) + + + +.. class:: Set() + + Stores a set of unordered, unique values. Available only with Cassandra 1.2 and above :: + + columns.Set(value_type) + + **options** + + :attr:`~columns.Set.value_type` + The type of objects the set will contain + + :attr:`~columns.Set.strict` + If True, adding this column will raise an exception during save if the value is not a python `set` instance. If False, it will attempt to coerce the value to a set. Defaults to True. + +.. class:: List() + + Stores a list of ordered values. Available only with Cassandra 1.2 and above :: + + columns.List(value_type) + + **options** + + :attr:`~columns.List.value_type` + The type of objects the set will contain + +.. class:: Map() + + Stores a map (dictionary) collection, available only with Cassandra 1.2 and above :: + + columns.Map(key_type, value_type) + + **options** + + :attr:`~columns.Map.key_type` + The type of the map keys + + :attr:`~columns.Map.value_type` + The type of the map values + Column Options ============== diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 86788a2530..0dd2c19cf8 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -50,11 +50,13 @@ Column Types * :class:`~cqlengine.columns.Integer` * :class:`~cqlengine.columns.DateTime` * :class:`~cqlengine.columns.UUID` + * :class:`~cqlengine.columns.TimeUUID` * :class:`~cqlengine.columns.Boolean` * :class:`~cqlengine.columns.Float` * :class:`~cqlengine.columns.Decimal` - - A time uuid field is in the works. + * :class:`~cqlengine.columns.Set` + * :class:`~cqlengine.columns.List` + * :class:`~cqlengine.columns.Map` Column Options -------------- diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index 63b76d03a6..ce3a2c818c 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -151,6 +151,33 @@ Filtering Operators q = Automobile.objects.filter(manufacturer='Tesla') q = q.filter(year__lte=2012) # year <= 2012 + +TimeUUID Functions +================== + + In addition to querying using regular values, there are two functions you can pass in when querying TimeUUID columns to help make filtering by them easier. Note that these functions don't actually return a value, but instruct the cql interpreter to use the functions in it's query. + + .. class:: MinTimeUUID(datetime) + + returns the minimum time uuid value possible for the given datetime + + .. class:: MaxTimeUUID(datetime) + + returns the maximum time uuid value possible for the given datetime + + *Example* + + .. code-block:: python + + class DataStream(Model): + time = cqlengine.TimeUUID(primary_key=True) + data = cqlengine.Bytes() + + min_time = datetime(1982, 1, 1) + max_time = datetime(1982, 3, 9) + + DataStream.filter(time__gt=cqlengine.MinTimeUUID(min_time), time__lt=cqlengine.MaxTimeUUID(max_time)) + QuerySets are imutable ====================== @@ -223,3 +250,7 @@ QuerySet method reference :type field_name: string Sets the field to order on. + + .. method:: allow_filtering() + + Enables the (usually) unwise practive of querying on a clustering key without also defining a partition key From cb5945f96cc76d71f81cc878a6d20a54f47cd511 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 6 Mar 2013 20:41:13 -0800 Subject: [PATCH 0119/4200] renaming the value manager's initial_value field to previous_value --- cqlengine/columns.py | 14 ++++++++++++-- cqlengine/tests/model/test_model_io.py | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index fe7ad9e348..67d9212af2 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -12,12 +12,22 @@ class BaseValueManager(object): def __init__(self, instance, column, value): self.instance = instance self.column = column - self.initial_value = copy(value) + self.previous_value = copy(value) self.value = value @property def deleted(self): - return self.value is None and self.initial_value is not None + return self.value is None and self.previous_value is not None + + @property + def changed(self): + """ + Indicates whether or not this value has changed. + + :rtype: boolean + + """ + return self.value != self.previous_value def getval(self): return self.value diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index f94b5964c2..4c40e2beb3 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -62,7 +62,7 @@ def test_column_deleting_works_properly(self): tm2 = TestModel.objects(id=tm.pk).first() assert tm2.text is None - assert tm2._values['text'].initial_value is None + assert tm2._values['text'].previous_value is None def test_a_sensical_error_is_raised_if_you_try_to_create_a_table_twice(self): From da3895aeb35b934494a3a5be528c7e4125aa3180 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 6 Mar 2013 21:13:19 -0800 Subject: [PATCH 0120/4200] adding flags for checking if an object has been persisted and if it can be persisted with an update --- cqlengine/models.py | 23 +++++++++++++- cqlengine/query.py | 4 ++- cqlengine/tests/model/test_model_io.py | 42 ++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index a856c59f2f..101a328b25 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -32,6 +32,21 @@ def __init__(self, **values): value_mngr = column.value_manager(self, column, value) self._values[name] = value_mngr + # a flag set by the deserializer to indicate + # that update should be used when persisting changes + self._is_persisted = False + + def _can_update(self): + """ + Called by the save function to check if this should be + persisted with update or insert + + :return: + """ + if not self._is_persisted: return False + pks = self._primary_keys.keys() + return all([not self._values[k].changed for k in self._primary_keys]) + def __eq__(self, other): return self.as_dict() == other.as_dict() @@ -101,7 +116,13 @@ def save(self): is_new = self.pk is None self.validate() self.objects.save(self) - #delete any fields that have been deleted / set to none + + #reset the value managers + for v in self._values.values(): + v.previous_value = v.value + + self._is_persisted = True + return self def delete(self): diff --git a/cqlengine/query.py b/cqlengine/query.py index 7a9a74e77a..e4a1e84dff 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -314,7 +314,9 @@ def _construct_instance(self, values): field_dict[db_map[key]] = val else: field_dict[key] = val - return self.model(**field_dict) + instance = self.model(**field_dict) + instance._is_persisted = True + return instance def first(self): try: diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 4c40e2beb3..707e6736c3 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -1,4 +1,5 @@ from unittest import skip +from uuid import uuid4 from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.management import create_table @@ -71,6 +72,47 @@ def test_a_sensical_error_is_raised_if_you_try_to_create_a_table_twice(self): create_table(TestModel) create_table(TestModel) +class TestCanUpdate(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestCanUpdate, cls).setUpClass() + delete_table(TestModel) + create_table(TestModel) + + @classmethod + def tearDownClass(cls): + super(TestCanUpdate, cls).tearDownClass() + delete_table(TestModel) + + def test_success_case(self): + tm = TestModel(count=8, text='123456789') + + # object hasn't been saved, + # shouldn't be able to update + assert not tm._is_persisted + assert not tm._can_update() + + tm.save() + + # object has been saved, + # should be able to update + assert tm._is_persisted + assert tm._can_update() + + tm.count = 200 + + # primary keys haven't changed, + # should still be able to update + assert tm._can_update() + + tm.id = uuid4() + + # primary keys have changed, + # should not be able to update + assert not tm._can_update() + + class IndexDefinitionModel(Model): key = columns.UUID(primary_key=True) val = columns.Text(index=True) From e77c056f98f638e02e459fc32ea8290110a0a5f9 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 6 Mar 2013 22:12:52 -0800 Subject: [PATCH 0121/4200] adding support for either insert or update statements on object persistence --- cqlengine/query.py | 45 +++++++++++++++++++---- cqlengine/tests/model/test_model_io.py | 49 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index e4a1e84dff..da95798472 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -511,14 +511,45 @@ def save(self, instance): #construct query string field_names = zip(*value_pairs)[0] field_values = dict(value_pairs) - qs = ["INSERT INTO {}".format(self.column_family_name)] - qs += ["({})".format(', '.join(['"{}"'.format(f) for f in field_names]))] - qs += ['VALUES'] - qs += ["({})".format(', '.join([':'+f for f in field_names]))] + + qs = [] + if instance._can_update(): + qs += ["UPDATE {}".format(self.column_family_name)] + qs += ["SET"] + + set_statements = [] + #get defined fields and their column names + for name, col in self.model._columns.items(): + if not col.is_primary_key: + val = values.get(name) + if val is None: continue + set_statements += ['"{0}" = :{0}'.format(col.db_field_name)] + qs += [', '.join(set_statements)] + + qs += ['WHERE'] + + where_statements = [] + for name, col in self.model._primary_keys.items(): + where_statements += ['"{0}" = :{0}'.format(col.db_field_name)] + + qs += [' AND '.join(where_statements)] + + # clear the qs if there are not set statements + if not set_statements: qs = [] + + else: + qs += ["INSERT INTO {}".format(self.column_family_name)] + qs += ["({})".format(', '.join(['"{}"'.format(f) for f in field_names]))] + qs += ['VALUES'] + qs += ["({})".format(', '.join([':'+f for f in field_names]))] + qs = ' '.join(qs) - with connection_manager() as con: - con.execute(qs, field_values) + # skip query execution if it's empty + # caused by pointless update queries + if qs: + with connection_manager() as con: + con.execute(qs, field_values) #delete deleted / nulled columns deleted = [k for k,v in instance._values.items() if v.deleted] @@ -529,7 +560,7 @@ def save(self, instance): qs = ['DELETE {}'.format(', '.join(['"{}"'.format(f) for f in del_fields]))] qs += ['FROM {}'.format(self.column_family_name)] qs += ['WHERE'] - eq = lambda col: '{0} = :{0}'.format(v.column.db_field_name) + eq = lambda col: '{0} = :{0}'.format(col.db_field_name) qs += [' AND '.join([eq(f) for f in pks.values()])] qs = ' '.join(qs) diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 707e6736c3..5ced331fb3 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -1,5 +1,6 @@ from unittest import skip from uuid import uuid4 +import random from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.management import create_table @@ -72,6 +73,53 @@ def test_a_sensical_error_is_raised_if_you_try_to_create_a_table_twice(self): create_table(TestModel) create_table(TestModel) +class TestMultiKeyModel(Model): + partition = columns.Integer(primary_key=True) + cluster = columns.Integer(primary_key=True) + count = columns.Integer(required=False) + text = columns.Text(required=False) + +class TestUpdating(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestUpdating, cls).setUpClass() + delete_table(TestMultiKeyModel) + create_table(TestMultiKeyModel) + + @classmethod + def tearDownClass(cls): + super(TestUpdating, cls).tearDownClass() + delete_table(TestMultiKeyModel) + + def setUp(self): + super(TestUpdating, self).setUp() + self.instance = TestMultiKeyModel.create( + partition=random.randint(0, 1000), + cluster=random.randint(0, 1000), + count=0, + text='happy' + ) + + def test_vanilla_update(self): + self.instance.count = 5 + self.instance.save() + + check = TestMultiKeyModel.get(partition=self.instance.partition, cluster=self.instance.cluster) + assert check.count == 5 + assert check.text == 'happy' + + def test_deleting_only(self): + self.instance.count = None + self.instance.text = None + self.instance.save() + + check = TestMultiKeyModel.get(partition=self.instance.partition, cluster=self.instance.cluster) + assert check.count is None + assert check.text is None + + + class TestCanUpdate(BaseCassEngTestCase): @classmethod @@ -105,6 +153,7 @@ def test_success_case(self): # primary keys haven't changed, # should still be able to update assert tm._can_update() + tm.save() tm.id = uuid4() From fcae60188e8b6ce7e58ced825bfd56bc5820df3d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 6 Mar 2013 22:14:16 -0800 Subject: [PATCH 0122/4200] updating changelog --- changelog | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog b/changelog index a109cfcf0f..00dc02bced 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,7 @@ CHANGELOG 0.1.2 (in progress) +* expanding internal save function to use update where appropriate * adding set, list, and map collection types * adding support for allow filtering flag * updating management functions to work with cassandra 1.2 From 54d4b804c4f10926a10c8e7bf9c2703917aead84 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 7 Mar 2013 21:59:55 -0800 Subject: [PATCH 0123/4200] adding test around deleting multi key models --- cqlengine/tests/model/test_model_io.py | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 5ced331fb3..7f708cf214 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -73,12 +73,40 @@ def test_a_sensical_error_is_raised_if_you_try_to_create_a_table_twice(self): create_table(TestModel) create_table(TestModel) + class TestMultiKeyModel(Model): partition = columns.Integer(primary_key=True) cluster = columns.Integer(primary_key=True) count = columns.Integer(required=False) text = columns.Text(required=False) +class TestDeleting(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestDeleting, cls).setUpClass() + delete_table(TestMultiKeyModel) + create_table(TestMultiKeyModel) + + @classmethod + def tearDownClass(cls): + super(TestDeleting, cls).tearDownClass() + delete_table(TestMultiKeyModel) + + def test_deleting_only_deletes_one_object(self): + partition = random.randint(0,1000) + for i in range(5): + TestMultiKeyModel.create(partition=partition, cluster=i, count=i, text=str(i)) + + assert TestMultiKeyModel.filter(partition=partition).count() == 5 + + TestMultiKeyModel.get(partition=partition, cluster=0).delete() + + assert TestMultiKeyModel.filter(partition=partition).count() == 4 + + TestMultiKeyModel.filter(partition=partition).delete() + + class TestUpdating(BaseCassEngTestCase): @classmethod From dc6031ae9234b9c7333b89ddb0fc95923140df00 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 7 Mar 2013 22:43:42 -0800 Subject: [PATCH 0124/4200] Implementing batch queries (not tested yet) --- cqlengine/models.py | 16 ++- cqlengine/query.py | 209 +++++++++++++++++++++------- cqlengine/tests/test_batch_query.py | 0 3 files changed, 173 insertions(+), 52 deletions(-) create mode 100644 cqlengine/tests/test_batch_query.py diff --git a/cqlengine/models.py b/cqlengine/models.py index 101a328b25..ca9ee77a30 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -4,7 +4,7 @@ from cqlengine import columns from cqlengine.exceptions import ModelException from cqlengine.functions import BaseQueryFunction -from cqlengine.query import QuerySet, QueryException +from cqlengine.query import QuerySet, QueryException, DMLQuery class ModelDefinitionException(ModelException): pass @@ -112,10 +112,13 @@ def filter(cls, **kwargs): def get(cls, **kwargs): return cls.objects.get(**kwargs) - def save(self): + def save(self, batch_obj=None): is_new = self.pk is None self.validate() - self.objects.save(self) + if batch_obj: + DMLQuery(self.__class__, self).batch(batch_obj).save() + else: + DMLQuery(self.__class__, self).save() #reset the value managers for v in self._values.values(): @@ -127,8 +130,13 @@ def save(self): def delete(self): """ Deletes this instance """ - self.objects.delete_instance(self) + DMLQuery(self.__class__, self).delete() + def batch(self, batch_obj): + """ + Returns a batched DML query + """ + return DMLQuery(self.__class__, self).batch(batch_obj) class ModelMetaClass(type): diff --git a/cqlengine/query.py b/cqlengine/query.py index da95798472..3405f0518e 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -1,7 +1,9 @@ from collections import namedtuple import copy +from datetime import datetime from hashlib import md5 from time import time +from uuid import uuid1 from cqlengine.connection import connection_manager from cqlengine.exceptions import CQLEngineException @@ -28,7 +30,7 @@ def __init__(self, column, value): #the identifier is a unique key that will be used in string #replacement on query strings, it's created from a hash #of this object's id and the time - self.identifier = md5(str(id(self)) + str(time())).hexdigest() + self.identifier = uuid1().hex #perform validation on this operator self.validate_operator() @@ -123,6 +125,59 @@ class LessThanOrEqualOperator(QueryOperator): symbol = "LTE" cql_symbol = '<=' +class Consistency(object): + ANY = 'ANY' + ONE = 'ONE' + QUORUM = 'QUORUM' + LOCAL_QUORUM = 'LOCAL_QUORUM' + EACH_QUORUM = 'EACH_QUORUM' + ALL = 'ALL' + +class BatchQuery(object): + """ + Handles the batching of queries + """ + + def __init__(self, consistency=Consistency.ONE, timestamp=None): + self.queries = [] + self.consistency = consistency + if timestamp is not None and not isinstance(timestamp, datetime): + raise CQLEngineException('timestamp object must be an instance of datetime') + self.timestamp = timestamp + + def add_query(self, query, params): + self.queries.append((query, params)) + + def execute(self): + query_list = [] + parameters = {} + + opener = 'BEGIN BATCH USING CONSISTENCY {}'.format(self.consistency) + if self.timestamp: + epoch = datetime(1970, 1, 1) + ts = long((self.timestamp - epoch).total_seconds() * 1000) + opener += ' TIMESTAMP {}'.format(ts) + + query_list = [opener] + for query, params in self.queries: + query_list.append(query) + parameters.update(params) + + query_list.append('APPLY BATCH;') + + with connection_manager() as con: + con.execute('\n'.join(query_list), parameters) + + self.queries = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + #don't execute if there was an exception + if exc_type is not None: return + self.execute() + class QuerySet(object): def __init__(self, model): @@ -152,6 +207,8 @@ def __init__(self, model): self._result_cache = None self._result_idx = None + self._batch = None + def __unicode__(self): return self._select_query() @@ -242,6 +299,8 @@ def _select_query(self): #----Reads------ def _execute_query(self): + if self._batch: + raise CQLEngineException("Only inserts, updates, and deletes are available in batch mode") if self._result_cache is None: self._con = connection_manager() self._cur = self._con.execute(self._select_query(), self._where_values()) @@ -318,6 +377,18 @@ def _construct_instance(self, values): instance._is_persisted = True return instance + def batch(self, batch_obj): + """ + Adds a batch query to the mix + :param batch_obj: + :return: + """ + if not isinstance(batch_obj, BatchQuery): + raise CQLEngineException('batch_obj must be a BatchQuery instance') + clone = copy.deepcopy(self) + clone._batch = batch_obj + return clone + def first(self): try: return iter(self).next() @@ -379,8 +450,6 @@ def get(self, **kwargs): else: return self[0] - - def order_by(self, colname): """ orders the result set. @@ -416,6 +485,8 @@ def order_by(self, colname): def count(self): """ Returns the number of rows matched by this query """ + if self._batch: + raise CQLEngineException("Only inserts, updates, and deletes are available in batch mode") #TODO: check for previous query execution and return row count if it exists if self._result_cache is None: qs = ['SELECT COUNT(*)'] @@ -426,7 +497,7 @@ def count(self): qs += ['ALLOW FILTERING'] qs = ' '.join(qs) - + with connection_manager() as con: cur = con.execute(qs, self._where_values()) return cur.fetchone()[0] @@ -435,7 +506,7 @@ def count(self): def limit(self, v): """ - Sets the limit on the number of results returned + Sets the limit on the number of results returned CQL has a default limit of 10,000 """ if not (v is None or isinstance(v, (int, long))): @@ -488,19 +559,64 @@ def defer(self, fields): """ Don't load these fields for the returned query """ return self._only_or_defer('defer', fields) - #----writes---- - def save(self, instance): + def create(self, **kwargs): + return self.model(**kwargs).save(batch_obj=self._batch) + + #----delete--- + def delete(self, columns=[]): + """ + Deletes the contents of a query + """ + #validate where clause + partition_key = self.model._primary_keys.values()[0] + if not any([c.column == partition_key for c in self._where]): + raise QueryException("The partition key must be defined on delete queries") + qs = ['DELETE FROM {}'.format(self.column_family_name)] + qs += ['WHERE {}'.format(self._where_clause())] + qs = ' '.join(qs) + + if self._batch: + self._batch.add_query(qs, self._where_values()) + else: + with connection_manager() as con: + con.execute(qs, self._where_values()) + +class DMLQuery(object): + """ + A query object used for queries performing inserts, updates, or deletes + + this is usually instantiated by the model instance to be modified + + unlike the read query object, this is mutable + """ + + def __init__(self, model, instance=None): + self.model = model + self.column_family_name = self.model.column_family_name() + self.instance = instance + self.batch = None + pass + + def batch(self, batch_obj): + if not isinstance(batch_obj, BatchQuery): + raise CQLEngineException('batch_obj must be a BatchQuery instance') + self.batch = batch_obj + return self + + def save(self): """ Creates / updates a row. This is a blind insert call. - All validation and cleaning needs to happen + All validation and cleaning needs to happen prior to calling this. """ - assert type(instance) == self.model + if self.instance is None: + raise CQLEngineException("DML Query intance attribute is None") + assert type(self.instance) == self.model #organize data value_pairs = [] - values = instance.as_dict() + values = self.instance.as_dict() #get defined fields and their column names for name, col in self.model._columns.items(): @@ -510,10 +626,12 @@ def save(self, instance): #construct query string field_names = zip(*value_pairs)[0] + field_ids = {n:uuid1().hex for n in field_names} field_values = dict(value_pairs) + query_values = {field_ids[n]:field_values[n] for n in field_names} qs = [] - if instance._can_update(): + if self.instance._can_update(): qs += ["UPDATE {}".format(self.column_family_name)] qs += ["SET"] @@ -523,14 +641,14 @@ def save(self, instance): if not col.is_primary_key: val = values.get(name) if val is None: continue - set_statements += ['"{0}" = :{0}'.format(col.db_field_name)] + set_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] qs += [', '.join(set_statements)] qs += ['WHERE'] where_statements = [] for name, col in self.model._primary_keys.items(): - where_statements += ['"{0}" = :{0}'.format(col.db_field_name)] + where_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] qs += [' AND '.join(where_statements)] @@ -541,18 +659,21 @@ def save(self, instance): qs += ["INSERT INTO {}".format(self.column_family_name)] qs += ["({})".format(', '.join(['"{}"'.format(f) for f in field_names]))] qs += ['VALUES'] - qs += ["({})".format(', '.join([':'+f for f in field_names]))] + qs += ["({})".format(', '.join([':'+field_ids[f] for f in field_names]))] qs = ' '.join(qs) # skip query execution if it's empty # caused by pointless update queries if qs: - with connection_manager() as con: - con.execute(qs, field_values) + if self.batch: + self.batch.add_query(qs, query_values) + else: + with connection_manager() as con: + con.execute(qs, query_values) #delete deleted / nulled columns - deleted = [k for k,v in instance._values.items() if v.deleted] + deleted = [k for k,v in self.instance._values.items() if v.deleted] if deleted: del_fields = [self.model._columns[f] for f in deleted] del_fields = [f.db_field_name for f in del_fields if not f.primary_key] @@ -560,44 +681,36 @@ def save(self, instance): qs = ['DELETE {}'.format(', '.join(['"{}"'.format(f) for f in del_fields]))] qs += ['FROM {}'.format(self.column_family_name)] qs += ['WHERE'] - eq = lambda col: '{0} = :{0}'.format(col.db_field_name) + eq = lambda col: '"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name]) qs += [' AND '.join([eq(f) for f in pks.values()])] qs = ' '.join(qs) - pk_dict = dict([(v.db_field_name, getattr(instance, k)) for k,v in pks.items()]) - - with connection_manager() as con: - con.execute(qs, pk_dict) - - - def create(self, **kwargs): - return self.model(**kwargs).save() - - #----delete--- - def delete(self, columns=[]): - """ - Deletes the contents of a query - """ - #validate where clause - partition_key = self.model._primary_keys.values()[0] - if not any([c.column == partition_key for c in self._where]): - raise QueryException("The partition key must be defined on delete queries") - qs = ['DELETE FROM {}'.format(self.column_family_name)] - qs += ['WHERE {}'.format(self._where_clause())] - qs = ' '.join(qs) - - with connection_manager() as con: - con.execute(qs, self._where_values()) - + if self.batch: + self.batch.add_query(qs, query_values) + else: + with connection_manager() as con: + con.execute(qs, query_values) - def delete_instance(self, instance): + def delete(self): """ Deletes one instance """ - pk_name = self.model._pk_name + if self.instance is None: + raise CQLEngineException("DML Query intance attribute is None") + field_values = {} qs = ['DELETE FROM {}'.format(self.column_family_name)] - qs += ['WHERE {0}=:{0}'.format(pk_name)] + qs += ['WHERE'] + where_statements = [] + for name, col in self.model._primary_keys.items(): + field_id = uuid1().hex + field_values[field_id] = col.to_database(getattr(self.instance, name)) + where_statements += ['"{}" = :{}'.format(col.db_field_name, field_id)] + + qs += [' AND '.join(where_statements)] qs = ' '.join(qs) - with connection_manager() as con: - con.execute(qs, {pk_name:instance.pk}) + if self.batch: + self.batch.add_query(qs, field_values) + else: + with connection_manager() as con: + con.execute(qs, field_values) diff --git a/cqlengine/tests/test_batch_query.py b/cqlengine/tests/test_batch_query.py new file mode 100644 index 0000000000..e69de29bb2 From 241c942334a049cb9d6d3ceac700ea8afad6e4f4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 9 Mar 2013 17:43:10 -0800 Subject: [PATCH 0125/4200] restructuring some of the batch query internals, putting some unit tests around batch querying --- cqlengine/models.py | 42 +++++++++---- cqlengine/query.py | 18 +++--- cqlengine/tests/test_batch_query.py | 93 +++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 20 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index ca9ee77a30..da4e18fc13 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -8,6 +8,22 @@ class ModelDefinitionException(ModelException): pass +class hybrid_classmethod(object): + """ + Allows a method to behave as both a class method and + normal instance method depending on how it's called + """ + + def __init__(self, clsmethod, instmethod): + self.clsmethod = clsmethod + self.instmethod = instmethod + + def __get__(self, instance, owner): + if instance is None: + return self.clsmethod.__get__(owner, owner) + else: + return self.instmethod.__get__(instance, owner) + class BaseModel(object): """ The base model class, don't inherit from this, inherit from Model, defined below @@ -35,6 +51,7 @@ def __init__(self, **values): # a flag set by the deserializer to indicate # that update should be used when persisting changes self._is_persisted = False + self._batch = None def _can_update(self): """ @@ -112,13 +129,10 @@ def filter(cls, **kwargs): def get(cls, **kwargs): return cls.objects.get(**kwargs) - def save(self, batch_obj=None): + def save(self): is_new = self.pk is None self.validate() - if batch_obj: - DMLQuery(self.__class__, self).batch(batch_obj).save() - else: - DMLQuery(self.__class__, self).save() + DMLQuery(self.__class__, self, batch=self._batch).save() #reset the value managers for v in self._values.values(): @@ -130,13 +144,19 @@ def save(self, batch_obj=None): def delete(self): """ Deletes this instance """ - DMLQuery(self.__class__, self).delete() + DMLQuery(self.__class__, self, batch=self._batch).delete() + + @classmethod + def _class_batch(cls, batch): + return cls.objects.batch(batch) + + def _inst_batch(self, batch): + self._batch = batch + return self + + batch = hybrid_classmethod(_class_batch, _inst_batch) + - def batch(self, batch_obj): - """ - Returns a batched DML query - """ - return DMLQuery(self.__class__, self).batch(batch_obj) class ModelMetaClass(type): diff --git a/cqlengine/query.py b/cqlengine/query.py index 3405f0518e..0b60539981 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -138,7 +138,7 @@ class BatchQuery(object): Handles the batching of queries """ - def __init__(self, consistency=Consistency.ONE, timestamp=None): + def __init__(self, consistency=None, timestamp=None): self.queries = [] self.consistency = consistency if timestamp is not None and not isinstance(timestamp, datetime): @@ -149,18 +149,18 @@ def add_query(self, query, params): self.queries.append((query, params)) def execute(self): - query_list = [] - parameters = {} - - opener = 'BEGIN BATCH USING CONSISTENCY {}'.format(self.consistency) + opener = 'BEGIN BATCH' + if self.consistency: + opener += ' USING CONSISTENCY {}'.format(self.consistency) if self.timestamp: epoch = datetime(1970, 1, 1) ts = long((self.timestamp - epoch).total_seconds() * 1000) opener += ' TIMESTAMP {}'.format(ts) query_list = [opener] + parameters = {} for query, params in self.queries: - query_list.append(query) + query_list.append(' ' + query) parameters.update(params) query_list.append('APPLY BATCH;') @@ -560,7 +560,7 @@ def defer(self, fields): return self._only_or_defer('defer', fields) def create(self, **kwargs): - return self.model(**kwargs).save(batch_obj=self._batch) + return self.model(**kwargs).batch(self._batch).save() #----delete--- def delete(self, columns=[]): @@ -590,11 +590,11 @@ class DMLQuery(object): unlike the read query object, this is mutable """ - def __init__(self, model, instance=None): + def __init__(self, model, instance=None, batch=None): self.model = model self.column_family_name = self.model.column_family_name() self.instance = instance - self.batch = None + self.batch = batch pass def batch(self, batch_obj): diff --git a/cqlengine/tests/test_batch_query.py b/cqlengine/tests/test_batch_query.py index e69de29bb2..c6d509ebad 100644 --- a/cqlengine/tests/test_batch_query.py +++ b/cqlengine/tests/test_batch_query.py @@ -0,0 +1,93 @@ +from unittest import skip +from uuid import uuid4 +import random +from cqlengine import Model, columns +from cqlengine.management import delete_table, create_table +from cqlengine.query import BatchQuery, Consistency +from cqlengine.tests.base import BaseCassEngTestCase + +class TestMultiKeyModel(Model): + partition = columns.Integer(primary_key=True) + cluster = columns.Integer(primary_key=True) + count = columns.Integer(required=False) + text = columns.Text(required=False) + +class BatchQueryTests(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(BatchQueryTests, cls).setUpClass() + delete_table(TestMultiKeyModel) + create_table(TestMultiKeyModel) + + @classmethod + def tearDownClass(cls): + super(BatchQueryTests, cls).tearDownClass() + delete_table(TestMultiKeyModel) + + def setUp(self): + super(BatchQueryTests, self).setUp() + self.pkey = 1 + for obj in TestMultiKeyModel.filter(partition=self.pkey): + obj.delete() + + + + def test_insert_success_case(self): + + b = BatchQuery() + inst = TestMultiKeyModel.batch(b).create(partition=self.pkey, cluster=2, count=3, text='4') + + with self.assertRaises(TestMultiKeyModel.DoesNotExist): + TestMultiKeyModel.get(partition=self.pkey, cluster=2) + + b.execute() + + TestMultiKeyModel.get(partition=self.pkey, cluster=2) + + def test_update_success_case(self): + + inst = TestMultiKeyModel.create(partition=self.pkey, cluster=2, count=3, text='4') + + b = BatchQuery() + + inst.count = 4 + inst.batch(b).save() + + inst2 = TestMultiKeyModel.get(partition=self.pkey, cluster=2) + assert inst2.count == 3 + + b.execute() + + inst3 = TestMultiKeyModel.get(partition=self.pkey, cluster=2) + assert inst3.count == 4 + + def test_delete_success_case(self): + + inst = TestMultiKeyModel.create(partition=self.pkey, cluster=2, count=3, text='4') + + b = BatchQuery() + + inst.batch(b).delete() + + TestMultiKeyModel.get(partition=self.pkey, cluster=2) + + b.execute() + + with self.assertRaises(TestMultiKeyModel.DoesNotExist): + TestMultiKeyModel.get(partition=self.pkey, cluster=2) + + def test_context_manager(self): + + with BatchQuery() as b: + for i in range(5): + TestMultiKeyModel.batch(b).create(partition=self.pkey, cluster=i, count=3, text='4') + + for i in range(5): + with self.assertRaises(TestMultiKeyModel.DoesNotExist): + TestMultiKeyModel.get(partition=self.pkey, cluster=i) + + for i in range(5): + TestMultiKeyModel.get(partition=self.pkey, cluster=i) + + From 66ca79f7779b6ade94e2ee89641fd934eaeee8ed Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 9 Mar 2013 18:18:20 -0800 Subject: [PATCH 0126/4200] adding documentation for batch queries --- cqlengine/__init__.py | 1 + docs/topics/queryset.rst | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index d5e7b74fcc..184a4c8a38 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,6 +1,7 @@ from cqlengine.columns import * from cqlengine.functions import * from cqlengine.models import Model +from cqlengine.query import BatchQuery __version__ = '0.1.2' diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index ce3a2c818c..6733080cfe 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -213,6 +213,34 @@ Ordering QuerySets *For instance, given our Automobile model, year is the only column we can order on.* +Batch Queries +=============== + + cqlengine now supports batch queries using the BatchQuery class. Batch queries can be started and stopped manually, or within a context manager. To add queries to the batch object, you just need to precede the create/save/delete call with a call to batch, and pass in the batch object. + + You can only create, update, and delete rows with a batch query, attempting to read rows out of the database with a batch query will fail. + + .. code-block:: python + + from cqlengine import BatchQuery + + #using a context manager + with BatchQuery() as b: + now = datetime.now() + em1 = ExampleModel.batch(b).create(example_type=0, description="1", created_at=now) + em2 = ExampleModel.batch(b).create(example_type=0, description="2", created_at=now) + em3 = ExampleModel.batch(b).create(example_type=0, description="3", created_at=now) + + # -- or -- + + #manually + b = BatchQuery() + now = datetime.now() + em1 = ExampleModel.batch(b).create(example_type=0, description="1", created_at=now) + em2 = ExampleModel.batch(b).create(example_type=0, description="2", created_at=now) + em3 = ExampleModel.batch(b).create(example_type=0, description="3", created_at=now) + b.execute() + QuerySet method reference ========================= From af50ced2df9ddd3d0dbcfc41910d79080a65dff0 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 10 Mar 2013 14:11:16 -0700 Subject: [PATCH 0127/4200] adding support for partial updates for set columns --- cqlengine/columns.py | 68 ++++++++++++++++++- cqlengine/models.py | 3 +- cqlengine/query.py | 11 ++- .../tests/columns/test_container_columns.py | 26 +++++++ 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 67d9212af2..84b245da0c 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -29,6 +29,9 @@ def changed(self): """ return self.value != self.previous_value + def reset_previous_value(self): + self.previous_value = copy(self.value) + def getval(self): return self.value @@ -283,9 +286,6 @@ def __init__(self, **kwargs): super(Counter, self).__init__(**kwargs) raise NotImplementedError -class ContainerValueManager(BaseValueManager): - pass - class ContainerQuoter(object): """ contains a single value, which will quote itself for CQL insertion statements @@ -323,6 +323,12 @@ def get_column_def(self): db_type = self.db_type.format(self.value_type.db_type) return '{} {}'.format(self.db_field_name, db_type) + def get_update_statement(self, val, prev, ctx): + """ + Used to add partial update statements + """ + raise NotImplementedError + class Set(BaseContainerColumn): """ Stores a set of unordered, unique values @@ -359,8 +365,50 @@ def validate(self, value): return {self.value_col.validate(v) for v in val} def to_database(self, value): + if value is None: return None return self.Quoter({self.value_col.to_database(v) for v in value}) + def get_update_statement(self, val, prev, ctx): + """ + Returns statements that will be added to an object's update statement + also updates the query context + + :param val: the current column value + :param prev: the previous column value + :param ctx: the values that will be passed to the query + :rtype: list + """ + + # remove from Quoter containers, if applicable + if isinstance(val, self.Quoter): val = val.value + if isinstance(prev, self.Quoter): prev = prev.value + + if val is None or val == prev: + # don't return anything if the new value is the same as + # the old one, or if the new value is none + return [] + elif prev is None or not any({v in prev for v in val}): + field = uuid1().hex + ctx[field] = self.Quoter(val) + return ['"{}" = :{}'.format(self.db_field_name, field)] + else: + # partial update time + to_create = val - prev + to_delete = prev - val + statements = [] + + if to_create: + field_id = uuid1().hex + ctx[field_id] = self.Quoter(to_create) + statements += ['"{0}" = "{0}" + :{1}'.format(self.db_field_name, field_id)] + + if to_delete: + field_id = uuid1().hex + ctx[field_id] = self.Quoter(to_delete) + statements += ['"{0}" = "{0}" - :{1}'.format(self.db_field_name, field_id)] + + return statements + class List(BaseContainerColumn): """ Stores a list of ordered values @@ -383,8 +431,15 @@ def validate(self, value): return [self.value_col.validate(v) for v in val] def to_database(self, value): + if value is None: return None return self.Quoter([self.value_col.to_database(v) for v in value]) + def get_update_statement(self, val, prev, values): + """ + http://en.wikipedia.org/wiki/Boyer%E2%80%93Moore_string_search_algorithm + """ + pass + class Map(BaseContainerColumn): """ Stores a key -> value map (dictionary) @@ -438,6 +493,13 @@ def to_python(self, value): return {self.key_col.to_python(k):self.value_col.to_python(v) for k,v in value.items()} def to_database(self, value): + if value is None: return None return self.Quoter({self.key_col.to_database(k):self.value_col.to_database(v) for k,v in value.items()}) + def get_update_statement(self, val, prev, ctx): + """ + http://www.datastax.com/docs/1.2/cql_cli/using/collections_map#deletion + """ + pass + diff --git a/cqlengine/models.py b/cqlengine/models.py index da4e18fc13..9b6ec3b084 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -136,8 +136,7 @@ def save(self): #reset the value managers for v in self._values.values(): - v.previous_value = v.value - + v.reset_previous_value() self._is_persisted = True return self diff --git a/cqlengine/query.py b/cqlengine/query.py index 0b60539981..fd91e45add 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -4,6 +4,7 @@ from hashlib import md5 from time import time from uuid import uuid1 +from cqlengine import BaseContainerColumn from cqlengine.connection import connection_manager from cqlengine.exceptions import CQLEngineException @@ -641,7 +642,15 @@ def save(self): if not col.is_primary_key: val = values.get(name) if val is None: continue - set_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] + if isinstance(col, BaseContainerColumn): + #remove value from query values, the column will handle it + query_values.pop(field_ids.get(name), None) + + val_mgr = self.instance._values[name] + set_statements += col.get_update_statement(val, val_mgr.previous_value, query_values) + pass + else: + set_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] qs += [', '.join(set_statements)] qs += ['WHERE'] diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 4a1721e078..15f8919288 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -45,6 +45,32 @@ def test_type_validation(self): with self.assertRaises(ValidationError): TestSetModel.create(int_set={'string', True}, text_set={1, 3.0}) + def test_partial_updates(self): + """ Tests that partial udpates work as expected """ + m1 = TestSetModel.create(int_set={1,2,3,4}) + + m1.int_set.add(5) + m1.int_set.remove(1) + assert m1.int_set == {2,3,4,5} + + m1.save() + + m2 = TestSetModel.get(partition=m1.partition) + assert m2.int_set == {2,3,4,5} + + def test_partial_update_creation(self): + """ + Tests that proper update statements are created for a partial set update + :return: + """ + ctx = {} + col = columns.Set(columns.Integer, db_field="TEST") + statements = col.get_update_statement({1,2,3,4}, {2,3,4,5}, ctx) + + assert len([v for v in ctx.values() if {1} == v.value]) == 1 + assert len([v for v in ctx.values() if {5} == v.value]) == 1 + assert len([s for s in statements if '"TEST" = "TEST" -' in s]) == 1 + assert len([s for s in statements if '"TEST" = "TEST" +' in s]) == 1 class TestListModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) From cdb1dc964287442dab13eb2300a2f4140e1d5ffb Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 10 Mar 2013 19:13:07 -0700 Subject: [PATCH 0128/4200] adding support for partial updates for list columns --- cqlengine/columns.py | 62 ++++++++++++++++++- .../tests/columns/test_container_columns.py | 28 +++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 84b245da0c..6144d6c129 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -436,9 +436,67 @@ def to_database(self, value): def get_update_statement(self, val, prev, values): """ - http://en.wikipedia.org/wiki/Boyer%E2%80%93Moore_string_search_algorithm + Returns statements that will be added to an object's update statement + also updates the query context """ - pass + # remove from Quoter containers, if applicable + if isinstance(val, self.Quoter): val = val.value + if isinstance(prev, self.Quoter): prev = prev.value + + def _insert(): + field_id = uuid1().hex + values[field_id] = self.Quoter(val) + return ['"{}" = :{}'.format(self.db_field_name, field_id)] + + if val is None or val == prev: + return [] + elif prev is None: + return _insert() + elif len(val) < len(prev): + return _insert() + else: + # the prepend and append lists, + # if both of these are still None after looking + # at both lists, an insert statement will be returned + prepend = None + append = None + + # the max start idx we want to compare + search_space = len(val) - max(0, len(prev)-1) + + # the size of the sub lists we want to look at + search_size = len(prev) + + for i in range(search_space): + #slice boundary + j = i + search_size + sub = val[i:j] + idx_cmp = lambda idx: prev[idx] == sub[idx] + if idx_cmp(0) and idx_cmp(-1) and prev == sub: + prepend = val[:i] + append = val[j:] + break + + # create update statements + if prepend is append is None: + return _insert() + + statements = [] + if prepend: + field_id = uuid1().hex + # CQL seems to prepend element at a time, starting + # with the element at idx 0, we can either reverse + # it here, or have it inserted in reverse + prepend.reverse() + values[field_id] = self.Quoter(prepend) + statements += ['"{0}" = :{1} + "{0}"'.format(self.db_field_name, field_id)] + + if append: + field_id = uuid1().hex + values[field_id] = self.Quoter(append) + statements += ['"{0}" = "{0}" + :{1}'.format(self.db_field_name, field_id)] + + return statements class Map(BaseContainerColumn): """ diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 15f8919288..11a0af001e 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -114,6 +114,34 @@ def test_type_validation(self): with self.assertRaises(ValidationError): TestListModel.create(int_list=['string', True], text_list=[1, 3.0]) + def test_partial_updates(self): + """ Tests that partial udpates work as expected """ + final = range(10) + initial = final[3:7] + m1 = TestListModel.create(int_list=initial) + + m1.int_list = final + m1.save() + + m2 = TestListModel.get(partition=m1.partition) + assert list(m2.int_list) == final + + def test_partial_update_creation(self): + """ + Tests that proper update statements are created for a partial list update + :return: + """ + final = range(10) + initial = final[3:7] + + ctx = {} + col = columns.List(columns.Integer, db_field="TEST") + statements = col.get_update_statement(final, initial, ctx) + + assert len([v for v in ctx.values() if [0,1,2] == v.value]) == 1 + assert len([v for v in ctx.values() if [7,8,9] == v.value]) == 1 + assert len([s for s in statements if '"TEST" = "TEST" +' in s]) == 1 + assert len([s for s in statements if '+ "TEST"' in s]) == 1 class TestMapModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) From a6332f12a0447458a97a49a5c12533311ea36efb Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 10 Mar 2013 19:53:22 -0700 Subject: [PATCH 0129/4200] adding support for partial updates for map columns --- cqlengine/columns.py | 51 ++++++++++++++++++- cqlengine/query.py | 35 +++++++++---- .../tests/columns/test_container_columns.py | 37 ++++++++++++++ 3 files changed, 112 insertions(+), 11 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 6144d6c129..fd5fc7c9ca 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -366,6 +366,7 @@ def validate(self, value): def to_database(self, value): if value is None: return None + if isinstance(value, self.Quoter): return value return self.Quoter({self.value_col.to_database(v) for v in value}) def get_update_statement(self, val, prev, ctx): @@ -380,6 +381,8 @@ def get_update_statement(self, val, prev, ctx): """ # remove from Quoter containers, if applicable + val = self.to_database(val) + prev = self.to_database(prev) if isinstance(val, self.Quoter): val = val.value if isinstance(prev, self.Quoter): prev = prev.value @@ -432,6 +435,7 @@ def validate(self, value): def to_database(self, value): if value is None: return None + if isinstance(value, self.Quoter): return value return self.Quoter([self.value_col.to_database(v) for v in value]) def get_update_statement(self, val, prev, values): @@ -440,6 +444,8 @@ def get_update_statement(self, val, prev, values): also updates the query context """ # remove from Quoter containers, if applicable + val = self.to_database(val) + prev = self.to_database(prev) if isinstance(val, self.Quoter): val = val.value if isinstance(prev, self.Quoter): prev = prev.value @@ -552,12 +558,55 @@ def to_python(self, value): def to_database(self, value): if value is None: return None + if isinstance(value, self.Quoter): return value return self.Quoter({self.key_col.to_database(k):self.value_col.to_database(v) for k,v in value.items()}) def get_update_statement(self, val, prev, ctx): """ http://www.datastax.com/docs/1.2/cql_cli/using/collections_map#deletion """ - pass + # remove from Quoter containers, if applicable + val = self.to_database(val) + prev = self.to_database(prev) + if isinstance(val, self.Quoter): val = val.value + if isinstance(prev, self.Quoter): prev = prev.value + + #get the updated map + update = {k:v for k,v in val.items() if v != prev.get(k)} + + statements = [] + for k,v in update.items(): + key_id = uuid1().hex + val_id = uuid1().hex + ctx[key_id] = k + ctx[val_id] = v + statements += ['"{}"[:{}] = :{}'.format(self.db_field_name, key_id, val_id)] + + return statements + + def get_delete_statement(self, val, prev, ctx): + """ + Returns statements that will be added to an object's delete statement + also updates the query context, used for removing keys from a map + """ + if val is prev is None: + return [] + + val = self.to_database(val) + prev = self.to_database(prev) + if isinstance(val, self.Quoter): val = val.value + if isinstance(prev, self.Quoter): prev = prev.value + + old_keys = set(prev.keys()) + new_keys = set(val.keys()) + del_keys = old_keys - new_keys + + del_statements = [] + for key in del_keys: + field_id = uuid1().hex + ctx[field_id] = key + del_statements += ['"{}"[:{}]'.format(self.db_field_name, field_id)] + + return del_statements diff --git a/cqlengine/query.py b/cqlengine/query.py index fd91e45add..29ab13962b 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -4,7 +4,7 @@ from hashlib import md5 from time import time from uuid import uuid1 -from cqlengine import BaseContainerColumn +from cqlengine import BaseContainerColumn, BaseValueManager, Map from cqlengine.connection import connection_manager from cqlengine.exceptions import CQLEngineException @@ -681,17 +681,32 @@ def save(self): with connection_manager() as con: con.execute(qs, query_values) - #delete deleted / nulled columns - deleted = [k for k,v in self.instance._values.items() if v.deleted] - if deleted: - del_fields = [self.model._columns[f] for f in deleted] - del_fields = [f.db_field_name for f in del_fields if not f.primary_key] - pks = self.model._primary_keys - qs = ['DELETE {}'.format(', '.join(['"{}"'.format(f) for f in del_fields]))] + + # delete nulled columns and removed map keys + qs = ['DELETE'] + query_values = {} + + del_statements = [] + for k,v in self.instance._values.items(): + col = v.column + if v.deleted: + del_statements += ['"{}"'.format(col.db_field_name)] + elif isinstance(col, Map): + del_statements += col.get_delete_statement(v.value, v.previous_value, query_values) + + if del_statements: + qs += [', '.join(del_statements)] + qs += ['FROM {}'.format(self.column_family_name)] + qs += ['WHERE'] - eq = lambda col: '"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name]) - qs += [' AND '.join([eq(f) for f in pks.values()])] + where_statements = [] + for name, col in self.model._primary_keys.items(): + field_id = uuid1().hex + query_values[field_id] = field_values[name] + where_statements += ['"{}" = :{}'.format(col.db_field_name, field_id)] + qs += [' AND '.join(where_statements)] + qs = ' '.join(qs) if self.batch: diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 11a0af001e..28dca78366 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -189,3 +189,40 @@ def test_type_validation(self): """ with self.assertRaises(ValidationError): TestMapModel.create(int_map={'key':2,uuid4():'val'}, text_map={2:5}) + + def test_partial_updates(self): + """ Tests that partial udpates work as expected """ + now = datetime.now() + #derez it a bit + now = datetime(*now.timetuple()[:-3]) + early = now - timedelta(minutes=30) + earlier = early - timedelta(minutes=30) + later = now + timedelta(minutes=30) + + initial = {'now':now, 'early':earlier} + final = {'later':later, 'early':early} + + m1 = TestMapModel.create(text_map=initial) + + m1.text_map = final + m1.save() + + m2 = TestMapModel.get(partition=m1.partition) + assert m2.text_map == final + +# def test_partial_update_creation(self): +# """ +# Tests that proper update statements are created for a partial list update +# :return: +# """ +# final = range(10) +# initial = final[3:7] +# +# ctx = {} +# col = columns.List(columns.Integer, db_field="TEST") +# statements = col.get_update_statement(final, initial, ctx) +# +# assert len([v for v in ctx.values() if [0,1,2] == v.value]) == 1 +# assert len([v for v in ctx.values() if [7,8,9] == v.value]) == 1 +# assert len([s for s in statements if '"TEST" = "TEST" +' in s]) == 1 +# assert len([s for s in statements if '+ "TEST"' in s]) == 1 From bd7c5c080a648e3e9d2e500a208a6aa432159765 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 10 Mar 2013 20:00:39 -0700 Subject: [PATCH 0130/4200] fixing failing unit test --- cqlengine/tests/columns/test_container_columns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 28dca78366..914c7479a8 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -138,7 +138,7 @@ def test_partial_update_creation(self): col = columns.List(columns.Integer, db_field="TEST") statements = col.get_update_statement(final, initial, ctx) - assert len([v for v in ctx.values() if [0,1,2] == v.value]) == 1 + assert len([v for v in ctx.values() if [2,1,0] == v.value]) == 1 assert len([v for v in ctx.values() if [7,8,9] == v.value]) == 1 assert len([s for s in statements if '"TEST" = "TEST" +' in s]) == 1 assert len([s for s in statements if '+ "TEST"' in s]) == 1 From 7f1d0c9e80e692c3244ebfd61a00b1bce0e6a846 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 11 Mar 2013 18:59:27 -0700 Subject: [PATCH 0131/4200] adding user configurable keyspace --- cqlengine/connection.py | 8 ++++++-- cqlengine/management.py | 9 +++++---- cqlengine/models.py | 11 +++++++++-- cqlengine/query.py | 5 ++++- cqlengine/tests/base.py | 2 +- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 52bc5cdef1..9be7922579 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -23,7 +23,7 @@ class CQLConnectionError(CQLEngineException): pass _password = None _max_connections = 10 -def setup(hosts, username=None, password=None, max_connections=10): +def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None): """ Records the hosts and connects to one of them @@ -36,7 +36,11 @@ def setup(hosts, username=None, password=None, max_connections=10): _username = username _password = password _max_connections = max_connections - + + if default_keyspace: + from cqlengine import models + models.DEFAULT_KEYSPACE = default_keyspace + for host in hosts: host = host.strip() host = host.split(':') diff --git a/cqlengine/management.py b/cqlengine/management.py index ba8b8145f7..5ef536cf7d 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -14,7 +14,8 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, :param **replication_values: 1.2 only, additional values to ad to the replication data map """ with connection_manager() as con: - if name not in [k.name for k in con.con.client.describe_keyspaces()]: + if not any([name == k.name for k in con.con.client.describe_keyspaces()]): +# if name not in [k.name for k in con.con.client.describe_keyspaces()]: try: #Try the 1.1 method con.execute("""CREATE KEYSPACE {} @@ -50,11 +51,11 @@ def create_table(model, create_missing_keyspace=True): #create missing keyspace if create_missing_keyspace: - create_keyspace(model.keyspace) + create_keyspace(model._get_keyspace()) with connection_manager() as con: #check for an existing column family - ks_info = con.con.client.describe_keyspace(model.keyspace) + ks_info = con.con.client.describe_keyspace(model._get_keyspace()) if not any([raw_cf_name == cf.name for cf in ks_info.cf_defs]): qs = ['CREATE TABLE {}'.format(cf_name)] @@ -85,7 +86,7 @@ def add_column(col): raise #get existing index names, skip ones that already exist - ks_info = con.con.client.describe_keyspace(model.keyspace) + ks_info = con.con.client.describe_keyspace(model._get_keyspace()) cf_defs = [cf for cf in ks_info.cf_defs if cf.name == raw_cf_name] idx_names = [i.index_name for i in cf_defs[0].column_metadata] if cf_defs else [] idx_names = filter(None, idx_names) diff --git a/cqlengine/models.py b/cqlengine/models.py index 9b6ec3b084..b9801bf0bf 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -8,6 +8,8 @@ class ModelDefinitionException(ModelException): pass +DEFAULT_KEYSPACE = 'cqlengine' + class hybrid_classmethod(object): """ Allows a method to behave as both a class method and @@ -37,7 +39,7 @@ class MultipleObjectsReturned(QueryException): pass table_name = None #the keyspace for this model - keyspace = 'cqlengine' + keyspace = None read_repair_chance = 0.1 def __init__(self, **values): @@ -64,6 +66,11 @@ def _can_update(self): pks = self._primary_keys.keys() return all([not self._values[k].changed for k in self._primary_keys]) + @classmethod + def _get_keyspace(cls): + """ Returns the manual keyspace, if set, otherwise the default keyspace """ + return cls.keyspace or DEFAULT_KEYSPACE + def __eq__(self, other): return self.as_dict() == other.as_dict() @@ -93,7 +100,7 @@ def column_family_name(cls, include_keyspace=True): cf_name = cf_name.lower() cf_name = re.sub(r'^_+', '', cf_name) if not include_keyspace: return cf_name - return '{}.{}'.format(cls.keyspace, cf_name) + return '{}.{}'.format(cls._get_keyspace(), cf_name) @property def pk(self): diff --git a/cqlengine/query.py b/cqlengine/query.py index 29ab13962b..36909f1e44 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -184,7 +184,6 @@ class QuerySet(object): def __init__(self, model): super(QuerySet, self).__init__() self.model = model - self.column_family_name = self.model.column_family_name() #Where clause filters self._where = [] @@ -210,6 +209,10 @@ def __init__(self, model): self._batch = None + @property + def column_family_name(self): + return self.model.column_family_name() + def __unicode__(self): return self._select_query() diff --git a/cqlengine/tests/base.py b/cqlengine/tests/base.py index d94ea69b04..0b450cdd8b 100644 --- a/cqlengine/tests/base.py +++ b/cqlengine/tests/base.py @@ -7,7 +7,7 @@ class BaseCassEngTestCase(TestCase): def setUpClass(cls): super(BaseCassEngTestCase, cls).setUpClass() if not connection._hosts: - connection.setup(['localhost']) + connection.setup(['localhost'], default_keyspace='cqlengine_test') def assertHasAttr(self, obj, attr): self.assertTrue(hasattr(obj, attr), From f04c831ec0b69f4eb44bf6f08b1592dcc4c86b1b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 12 Mar 2013 10:11:55 -0700 Subject: [PATCH 0132/4200] fixing bug caused by batch objects on querysets being copied --- cqlengine/query.py | 6 ++++++ cqlengine/tests/test_batch_query.py | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 36909f1e44..c148f8e44e 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -227,6 +227,12 @@ def __deepcopy__(self, memo): for k,v in self.__dict__.items(): if k in ['_con', '_cur', '_result_cache', '_result_idx']: clone.__dict__[k] = None + elif k == '_batch': + # we need to keep the same batch instance across + # all queryset clones, otherwise the batched queries + # fly off into other batch instances which are never + # executed, thx @dokai + clone.__dict__[k] = self._batch else: clone.__dict__[k] = copy.deepcopy(v, memo) diff --git a/cqlengine/tests/test_batch_query.py b/cqlengine/tests/test_batch_query.py index c6d509ebad..fb4aab4a72 100644 --- a/cqlengine/tests/test_batch_query.py +++ b/cqlengine/tests/test_batch_query.py @@ -31,8 +31,6 @@ def setUp(self): for obj in TestMultiKeyModel.filter(partition=self.pkey): obj.delete() - - def test_insert_success_case(self): b = BatchQuery() @@ -90,4 +88,18 @@ def test_context_manager(self): for i in range(5): TestMultiKeyModel.get(partition=self.pkey, cluster=i) + def test_bulk_delete_success_case(self): + + for i in range(1): + for j in range(5): + TestMultiKeyModel.create(partition=i, cluster=j, count=i*j, text='{}:{}'.format(i,j)) + + with BatchQuery() as b: + TestMultiKeyModel.objects.batch(b).filter(partition=0).delete() + assert TestMultiKeyModel.filter(partition=0).count() == 5 + + assert TestMultiKeyModel.filter(partition=0).count() == 0 + #cleanup + for m in TestMultiKeyModel.all(): + m.delete() From 1dd34e0029068a1eb9dcff75f6ac639e69c5b2ee Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 12 Mar 2013 21:57:23 -0700 Subject: [PATCH 0133/4200] updating batch query to work with 1.2 --- cqlengine/query.py | 22 +++++++++------------- cqlengine/tests/test_batch_query.py | 2 +- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index c148f8e44e..2355edeadf 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -126,22 +126,20 @@ class LessThanOrEqualOperator(QueryOperator): symbol = "LTE" cql_symbol = '<=' -class Consistency(object): - ANY = 'ANY' - ONE = 'ONE' - QUORUM = 'QUORUM' - LOCAL_QUORUM = 'LOCAL_QUORUM' - EACH_QUORUM = 'EACH_QUORUM' - ALL = 'ALL' +class BatchType(object): + Unlogged = 'UNLOGGED' + Counter = 'COUNTER' class BatchQuery(object): """ Handles the batching of queries + + http://www.datastax.com/docs/1.2/cql_cli/cql/BATCH """ - def __init__(self, consistency=None, timestamp=None): + def __init__(self, batch_type=None, timestamp=None): self.queries = [] - self.consistency = consistency + self.batch_type = batch_type if timestamp is not None and not isinstance(timestamp, datetime): raise CQLEngineException('timestamp object must be an instance of datetime') self.timestamp = timestamp @@ -150,13 +148,11 @@ def add_query(self, query, params): self.queries.append((query, params)) def execute(self): - opener = 'BEGIN BATCH' - if self.consistency: - opener += ' USING CONSISTENCY {}'.format(self.consistency) + opener = 'BEGIN ' + (self.batch_type + ' ' if self.batch_type else '') + ' BATCH' if self.timestamp: epoch = datetime(1970, 1, 1) ts = long((self.timestamp - epoch).total_seconds() * 1000) - opener += ' TIMESTAMP {}'.format(ts) + opener += ' USING TIMESTAMP {}'.format(ts) query_list = [opener] parameters = {} diff --git a/cqlengine/tests/test_batch_query.py b/cqlengine/tests/test_batch_query.py index fb4aab4a72..6ba608778c 100644 --- a/cqlengine/tests/test_batch_query.py +++ b/cqlengine/tests/test_batch_query.py @@ -3,7 +3,7 @@ import random from cqlengine import Model, columns from cqlengine.management import delete_table, create_table -from cqlengine.query import BatchQuery, Consistency +from cqlengine.query import BatchQuery from cqlengine.tests.base import BaseCassEngTestCase class TestMultiKeyModel(Model): From fb99a4d70e63f6a2541f54175b89b20b0b9ad865 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 12 Mar 2013 21:57:23 -0700 Subject: [PATCH 0134/4200] updating batch query to work with 1.2 --- cqlengine/query.py | 22 ++--- cqlengine/tests/query/test_batch_query.py | 106 ++++++++++++++++++++++ cqlengine/tests/test_batch_query.py | 2 +- 3 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 cqlengine/tests/query/test_batch_query.py diff --git a/cqlengine/query.py b/cqlengine/query.py index c148f8e44e..2355edeadf 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -126,22 +126,20 @@ class LessThanOrEqualOperator(QueryOperator): symbol = "LTE" cql_symbol = '<=' -class Consistency(object): - ANY = 'ANY' - ONE = 'ONE' - QUORUM = 'QUORUM' - LOCAL_QUORUM = 'LOCAL_QUORUM' - EACH_QUORUM = 'EACH_QUORUM' - ALL = 'ALL' +class BatchType(object): + Unlogged = 'UNLOGGED' + Counter = 'COUNTER' class BatchQuery(object): """ Handles the batching of queries + + http://www.datastax.com/docs/1.2/cql_cli/cql/BATCH """ - def __init__(self, consistency=None, timestamp=None): + def __init__(self, batch_type=None, timestamp=None): self.queries = [] - self.consistency = consistency + self.batch_type = batch_type if timestamp is not None and not isinstance(timestamp, datetime): raise CQLEngineException('timestamp object must be an instance of datetime') self.timestamp = timestamp @@ -150,13 +148,11 @@ def add_query(self, query, params): self.queries.append((query, params)) def execute(self): - opener = 'BEGIN BATCH' - if self.consistency: - opener += ' USING CONSISTENCY {}'.format(self.consistency) + opener = 'BEGIN ' + (self.batch_type + ' ' if self.batch_type else '') + ' BATCH' if self.timestamp: epoch = datetime(1970, 1, 1) ts = long((self.timestamp - epoch).total_seconds() * 1000) - opener += ' TIMESTAMP {}'.format(ts) + opener += ' USING TIMESTAMP {}'.format(ts) query_list = [opener] parameters = {} diff --git a/cqlengine/tests/query/test_batch_query.py b/cqlengine/tests/query/test_batch_query.py new file mode 100644 index 0000000000..5b31826a39 --- /dev/null +++ b/cqlengine/tests/query/test_batch_query.py @@ -0,0 +1,106 @@ +from datetime import datetime +from unittest import skip +from uuid import uuid4 +import random +from cqlengine import Model, columns +from cqlengine.management import delete_table, create_table +from cqlengine.query import BatchQuery +from cqlengine.tests.base import BaseCassEngTestCase + +class TestMultiKeyModel(Model): + partition = columns.Integer(primary_key=True) + cluster = columns.Integer(primary_key=True) + count = columns.Integer(required=False) + text = columns.Text(required=False) + +class BatchQueryTests(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(BatchQueryTests, cls).setUpClass() + delete_table(TestMultiKeyModel) + create_table(TestMultiKeyModel) + + @classmethod + def tearDownClass(cls): + super(BatchQueryTests, cls).tearDownClass() + delete_table(TestMultiKeyModel) + + def setUp(self): + super(BatchQueryTests, self).setUp() + self.pkey = 1 + for obj in TestMultiKeyModel.filter(partition=self.pkey): + obj.delete() + + def test_insert_success_case(self): + + b = BatchQuery() + inst = TestMultiKeyModel.batch(b).create(partition=self.pkey, cluster=2, count=3, text='4') + + with self.assertRaises(TestMultiKeyModel.DoesNotExist): + TestMultiKeyModel.get(partition=self.pkey, cluster=2) + + b.execute() + + TestMultiKeyModel.get(partition=self.pkey, cluster=2) + + def test_update_success_case(self): + + inst = TestMultiKeyModel.create(partition=self.pkey, cluster=2, count=3, text='4') + + b = BatchQuery() + + inst.count = 4 + inst.batch(b).save() + + inst2 = TestMultiKeyModel.get(partition=self.pkey, cluster=2) + assert inst2.count == 3 + + b.execute() + + inst3 = TestMultiKeyModel.get(partition=self.pkey, cluster=2) + assert inst3.count == 4 + + def test_delete_success_case(self): + + inst = TestMultiKeyModel.create(partition=self.pkey, cluster=2, count=3, text='4') + + b = BatchQuery() + + inst.batch(b).delete() + + TestMultiKeyModel.get(partition=self.pkey, cluster=2) + + b.execute() + + with self.assertRaises(TestMultiKeyModel.DoesNotExist): + TestMultiKeyModel.get(partition=self.pkey, cluster=2) + + def test_context_manager(self): + + with BatchQuery() as b: + for i in range(5): + TestMultiKeyModel.batch(b).create(partition=self.pkey, cluster=i, count=3, text='4') + + for i in range(5): + with self.assertRaises(TestMultiKeyModel.DoesNotExist): + TestMultiKeyModel.get(partition=self.pkey, cluster=i) + + for i in range(5): + TestMultiKeyModel.get(partition=self.pkey, cluster=i) + + def test_bulk_delete_success_case(self): + + for i in range(1): + for j in range(5): + TestMultiKeyModel.create(partition=i, cluster=j, count=i*j, text='{}:{}'.format(i,j)) + + with BatchQuery() as b: + TestMultiKeyModel.objects.batch(b).filter(partition=0).delete() + assert TestMultiKeyModel.filter(partition=0).count() == 5 + + assert TestMultiKeyModel.filter(partition=0).count() == 0 + #cleanup + for m in TestMultiKeyModel.all(): + m.delete() + diff --git a/cqlengine/tests/test_batch_query.py b/cqlengine/tests/test_batch_query.py index fb4aab4a72..6ba608778c 100644 --- a/cqlengine/tests/test_batch_query.py +++ b/cqlengine/tests/test_batch_query.py @@ -3,7 +3,7 @@ import random from cqlengine import Model, columns from cqlengine.management import delete_table, create_table -from cqlengine.query import BatchQuery, Consistency +from cqlengine.query import BatchQuery from cqlengine.tests.base import BaseCassEngTestCase class TestMultiKeyModel(Model): From 827bf18d6b87640ca37ae60ffd4b636d42a79b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20Kry=C5=84ski?= Date: Wed, 13 Mar 2013 23:15:31 +0100 Subject: [PATCH 0135/4200] include cf name in auto generated index name --- cqlengine/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 5ef536cf7d..67c34f6841 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -95,7 +95,7 @@ def add_column(col): if indexes: for column in indexes: if column.db_index_name in idx_names: continue - qs = ['CREATE INDEX {}'.format(column.db_index_name)] + qs = ['CREATE INDEX index_{}_{}'.format(raw_cf_name, column.db_field_name)] qs += ['ON {}'.format(cf_name)] qs += ['("{}")'.format(column.db_field_name)] qs = ' '.join(qs) From fa5547573d478ee57ac0e29ac9bc9c89dc2e596f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20Kry=C5=84ski?= Date: Wed, 13 Mar 2013 23:23:17 +0100 Subject: [PATCH 0136/4200] enable indexes on models with multiple primary keys (supported since cassandra 1.2) --- cqlengine/models.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index b9801bf0bf..aec06b4f89 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -230,12 +230,6 @@ def _transform_column(col_name, col_obj): raise ModelException("{} defines the column {} more than once".format(name, v.db_field_name)) col_names.add(v.db_field_name) - #check for indexes on models with multiple primary keys - if len([1 for k,v in column_definitions if v.primary_key]) > 1: - if len([1 for k,v in column_definitions if v.index]) > 0: - raise ModelDefinitionException( - 'Indexes on models with multiple primary keys is not supported') - #create db_name -> model name map for loading db_map = {} for field_name, col in column_dict.items(): From 1481391fa6ee3c7f993a1057beaaad7926c5dba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20Kry=C5=84ski?= Date: Fri, 15 Mar 2013 13:55:05 +0100 Subject: [PATCH 0137/4200] connection.setup 'lazy' parameter --- cqlengine/connection.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 9be7922579..e0afffdb8c 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -23,7 +23,7 @@ class CQLConnectionError(CQLEngineException): pass _password = None _max_connections = 10 -def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None): +def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None, lazy=False): """ Records the hosts and connects to one of them @@ -55,9 +55,10 @@ def setup(hosts, username=None, password=None, max_connections=10, default_keysp raise CQLConnectionError("At least one host required") random.shuffle(_hosts) - - con = ConnectionPool.get() - ConnectionPool.put(con) + + if not lazy: + con = ConnectionPool.get() + ConnectionPool.put(con) class ConnectionPool(object): From f94283f0335126178952c2fc9c09bb66d15ead4d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 20 Mar 2013 19:24:33 -0700 Subject: [PATCH 0138/4200] fixing in operator per https://github.com/bdeggleston/cqlengine/issues/34 --- cqlengine/query.py | 21 +++++++++++++++++++++ cqlengine/tests/query/test_queryset.py | 4 ++++ 2 files changed, 25 insertions(+) diff --git a/cqlengine/query.py b/cqlengine/query.py index 2355edeadf..0e2fd4e3a1 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -110,6 +110,27 @@ class InOperator(EqualsOperator): symbol = 'IN' cql_symbol = 'IN' + class Quoter(object): + """ + contains a single value, which will quote itself for CQL insertion statements + """ + def __init__(self, value): + self.value = value + + def __str__(self): + from cql.query import cql_quote as cq + return '(' + ', '.join([cq(v) for v in self.value]) + ')' + + def get_dict(self): + if isinstance(self.value, BaseQueryFunction): + return {self.identifier: self.column.to_database(self.value.get_value())} + else: + try: + values = [v for v in self.value] + except TypeError: + raise QueryException("in operator arguments must be iterable, {} found".format(self.value)) + return {self.identifier: self.Quoter([self.column.to_database(v) for v in self.value])} + class GreaterThanOperator(QueryOperator): symbol = "GT" cql_symbol = '>' diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 99f45afacd..cc003079e0 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -436,7 +436,11 @@ def test_success_case(self): assert '4' in datas +class TestInOperator(BaseQuerySetUsage): + def test_success_case(self): + q = TestModel.filter(test_id__in=[0,1]) + assert q.count() == 8 From e10237e8558e2058eee56a28396feea4cb25d50e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 20 Mar 2013 19:28:33 -0700 Subject: [PATCH 0139/4200] changing list column behavior to load lists, not tuples, from the database --- cqlengine/columns.py | 4 ++++ cqlengine/tests/columns/test_container_columns.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index fd5fc7c9ca..f63afff1de 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -433,6 +433,10 @@ def validate(self, value): raise ValidationError('{} is not a list object'.format(val)) return [self.value_col.validate(v) for v in val] + def to_python(self, value): + if value is None: return None + return list(value) + def to_database(self, value): if value is None: return None if isinstance(value, self.Quoter): return value diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 914c7479a8..b598da56bf 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -95,8 +95,8 @@ def test_io_success(self): m1 = TestListModel.create(int_list=[1,2], text_list=['kai', 'andreas']) m2 = TestListModel.get(partition=m1.partition) - assert isinstance(m2.int_list, tuple) - assert isinstance(m2.text_list, tuple) + assert isinstance(m2.int_list, list) + assert isinstance(m2.text_list, list) assert len(m2.int_list) == 2 assert len(m2.text_list) == 2 From cad39537efecb9847ddab2dda9bbe2add6fd0be6 Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Fri, 22 Mar 2013 09:52:18 +0200 Subject: [PATCH 0140/4200] Ignore empty batches instead of sending invalid CQL to Cassandra. --- cqlengine/query.py | 12 ++++++++---- cqlengine/tests/test_batch_query.py | 6 ++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 0e2fd4e3a1..3079986744 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -169,6 +169,10 @@ def add_query(self, query, params): self.queries.append((query, params)) def execute(self): + if len(self.queries) == 0: + # Empty batch is a no-op + return + opener = 'BEGIN ' + (self.batch_type + ' ' if self.batch_type else '') + ' BATCH' if self.timestamp: epoch = datetime(1970, 1, 1) @@ -257,7 +261,7 @@ def __deepcopy__(self, memo): def __len__(self): return self.count() - + def __del__(self): if self._con: self._con.close() @@ -347,7 +351,7 @@ def _fill_result_cache_to_idx(self, idx): value_dict = dict(zip(names, values)) self._result_idx += 1 self._result_cache[self._result_idx] = self._construct_instance(value_dict) - + #return the connection to the connection pool if we have all objects if self._result_cache and self._result_cache[-1] is not None: self._con.close() @@ -389,7 +393,7 @@ def __getitem__(self, s): else: self._fill_result_cache_to_idx(s) return self._result_cache[s] - + def _construct_instance(self, values): #translate column names to model names @@ -476,7 +480,7 @@ def get(self, **kwargs): '{} objects found'.format(len(self._result_cache))) else: return self[0] - + def order_by(self, colname): """ orders the result set. diff --git a/cqlengine/tests/test_batch_query.py b/cqlengine/tests/test_batch_query.py index 6ba608778c..7ee98b7084 100644 --- a/cqlengine/tests/test_batch_query.py +++ b/cqlengine/tests/test_batch_query.py @@ -103,3 +103,9 @@ def test_bulk_delete_success_case(self): for m in TestMultiKeyModel.all(): m.delete() + def test_empty_batch(self): + b = BatchQuery() + b.execute() + + with BatchQuery() as b: + pass From beb1f48760cb597f179fb7286b15def4cb4a79fe Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Fri, 22 Mar 2013 10:11:45 +0200 Subject: [PATCH 0141/4200] Allow multiple ORDER BY conditions to be set. Supports both .order_by('foo', 'bar') and chained .order_by() calls. --- cqlengine/query.py | 52 ++++++++++++++------------ cqlengine/tests/query/test_queryset.py | 38 +++++++++++++++---- 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 0e2fd4e3a1..7082bf9905 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -206,7 +206,7 @@ def __init__(self, model): self._where = [] #ordering arguments - self._order = None + self._order = [] self._allow_filtering = False @@ -257,7 +257,7 @@ def __deepcopy__(self, memo): def __len__(self): return self.count() - + def __del__(self): if self._con: self._con.close() @@ -313,7 +313,7 @@ def _select_query(self): qs += ['WHERE {}'.format(self._where_clause())] if self._order: - qs += ['ORDER BY {}'.format(self._order)] + qs += ['ORDER BY {}'.format(', '.join(self._order))] if self._limit: qs += ['LIMIT {}'.format(self._limit)] @@ -347,7 +347,7 @@ def _fill_result_cache_to_idx(self, idx): value_dict = dict(zip(names, values)) self._result_idx += 1 self._result_cache[self._result_idx] = self._construct_instance(value_dict) - + #return the connection to the connection pool if we have all objects if self._result_cache and self._result_cache[-1] is not None: self._con.close() @@ -389,7 +389,7 @@ def __getitem__(self, s): else: self._fill_result_cache_to_idx(s) return self._result_cache[s] - + def _construct_instance(self, values): #translate column names to model names @@ -476,38 +476,42 @@ def get(self, **kwargs): '{} objects found'.format(len(self._result_cache))) else: return self[0] - - def order_by(self, colname): + + def order_by(self, *colnames): """ orders the result set. - ordering can only select one column, and it must be the second column in a composite primary key + ordering can only use clustering columns. Default order is ascending, prepend a '-' to the column name for descending """ - if colname is None: + if len(colnames) == 0: clone = copy.deepcopy(self) - clone._order = None + clone._order = [] return clone - order_type = 'DESC' if colname.startswith('-') else 'ASC' - colname = colname.replace('-', '') + conditions = [] + for colname in colnames: + order_type = 'DESC' if colname.startswith('-') else 'ASC' + colname = colname.replace('-', '') - column = self.model._columns.get(colname) - if column is None: - raise QueryException("Can't resolve the column name: '{}'".format(colname)) + column = self.model._columns.get(colname) + if column is None: + raise QueryException("Can't resolve the column name: '{}'".format(colname)) - #validate the column selection - if not column.primary_key: - raise QueryException( - "Can't order on '{}', can only order on (clustered) primary keys".format(colname)) + #validate the column selection + if not column.primary_key: + raise QueryException( + "Can't order on '{}', can only order on (clustered) primary keys".format(colname)) - pks = [v for k,v in self.model._columns.items() if v.primary_key] - if column == pks[0]: - raise QueryException( - "Can't order by the first primary key, clustering (secondary) keys only") + pks = [v for k, v in self.model._columns.items() if v.primary_key] + if column == pks[0]: + raise QueryException( + "Can't order by the first primary key (partition key), clustering (secondary) keys only") + + conditions.append('"{}" {}'.format(column.db_field_name, order_type)) clone = copy.deepcopy(self) - clone._order = '"{}" {}'.format(column.db_field_name, order_type) + clone._order.extend(conditions) return clone def count(self): diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index cc003079e0..660fb8db7f 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -26,6 +26,12 @@ class IndexedTestModel(Model): expected_result = columns.Integer() test_result = columns.Integer(index=True) +class TestMultiClusteringModel(Model): + one = columns.Integer(primary_key=True) + two = columns.Integer(primary_key=True) + three = columns.Integer(primary_key=True) + + class TestQuerySetOperation(BaseCassEngTestCase): def test_query_filter_parsing(self): @@ -115,6 +121,7 @@ def setUpClass(cls): delete_table(IndexedTestModel) create_table(TestModel) create_table(IndexedTestModel) + create_table(TestMultiClusteringModel) TestModel.objects.create(test_id=0, attempt_id=0, description='try1', expected_result=5, test_result=30) TestModel.objects.create(test_id=0, attempt_id=1, description='try2', expected_result=10, test_result=30) @@ -151,6 +158,7 @@ def tearDownClass(cls): super(BaseQuerySetUsage, cls).tearDownClass() delete_table(TestModel) delete_table(IndexedTestModel) + delete_table(TestMultiClusteringModel) class TestQuerySetCountSelectionAndIteration(BaseQuerySetUsage): @@ -179,7 +187,7 @@ def test_iteration(self): assert val in compare_set compare_set.remove(val) assert len(compare_set) == 0 - + def test_multiple_iterations_work_properly(self): """ Tests that iterating over a query set more than once works """ q = TestModel.objects(test_id=0) @@ -277,6 +285,20 @@ def test_ordering_on_indexed_columns_fails(self): with self.assertRaises(query.QueryException): q = IndexedTestModel.objects(test_id=0).order_by('attempt_id') + def test_ordering_on_multiple_clustering_columns(self): + TestMultiClusteringModel.create(one=1, two=1, three=4) + TestMultiClusteringModel.create(one=1, two=1, three=2) + TestMultiClusteringModel.create(one=1, two=1, three=5) + TestMultiClusteringModel.create(one=1, two=1, three=1) + TestMultiClusteringModel.create(one=1, two=1, three=3) + + results = TestMultiClusteringModel.objects.filter(one=1, two=1).order_by('-two', '-three') + assert [r.three for r in results] == [5, 4, 3, 2, 1] + + results = TestMultiClusteringModel.objects.filter(one=1, two=1).order_by('two', 'three') + assert [r.three for r in results] == [1, 2, 3, 4, 5] + + class TestQuerySetSlicing(BaseQuerySetUsage): def test_out_of_range_index_raises_error(self): @@ -344,12 +366,12 @@ def test_delete(self): TestModel.objects.create(test_id=3, attempt_id=1, description='try10', expected_result=60, test_result=40) TestModel.objects.create(test_id=3, attempt_id=2, description='try11', expected_result=70, test_result=45) TestModel.objects.create(test_id=3, attempt_id=3, description='try12', expected_result=75, test_result=45) - + assert TestModel.objects.count() == 16 assert TestModel.objects(test_id=3).count() == 4 - + TestModel.objects(test_id=3).delete() - + assert TestModel.objects.count() == 12 assert TestModel.objects(test_id=3).count() == 0 @@ -364,7 +386,7 @@ def test_delete_without_any_where_args(self): TestModel.objects(attempt_id=0).delete() class TestQuerySetConnectionHandling(BaseQuerySetUsage): - + def test_conn_is_returned_after_filling_cache(self): """ Tests that the queryset returns it's connection after it's fetched all of it's results @@ -376,10 +398,10 @@ def test_conn_is_returned_after_filling_cache(self): val = t.attempt_id, t.expected_result assert val in compare_set compare_set.remove(val) - + assert q._con is None assert q._cur is None - + def test_conn_is_returned_after_queryset_is_garbage_collected(self): """ Tests that the connection is returned to the connection pool after the queryset is gc'd """ from cqlengine.connection import ConnectionPool @@ -387,7 +409,7 @@ def test_conn_is_returned_after_queryset_is_garbage_collected(self): q = TestModel.objects(test_id=0) v = q[0] assert ConnectionPool._queue.qsize() == 0 - + del q assert ConnectionPool._queue.qsize() == 1 From c4d4eb874e79250b855199c375c6f53dd4fc01ea Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Fri, 22 Mar 2013 10:20:03 +0200 Subject: [PATCH 0142/4200] Added a derived Date type. Internally it is stored as a timestamp but the value is exposed as a instance. --- cqlengine/columns.py | 32 ++++++++++++++-- cqlengine/tests/columns/test_validation.py | 43 +++++++++++++++++++--- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index f63afff1de..5cbe6a0946 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -1,6 +1,7 @@ #column field types from copy import copy from datetime import datetime +from datetime import date import re from uuid import uuid1, uuid4 from cql.query import cql_quote @@ -216,6 +217,31 @@ def to_database(self, value): epoch = datetime(1970, 1, 1) return long((value - epoch).total_seconds() * 1000) + +class Date(Column): + db_type = 'timestamp' + + def __init__(self, **kwargs): + super(Date, self).__init__(**kwargs) + + def to_python(self, value): + if isinstance(value, datetime): + return value.date() + elif isinstance(value, date): + return value + + return date.fromtimestamp(value) + + def to_database(self, value): + value = super(Date, self).to_database(value) + if isinstance(value, datetime): + value = value.date() + if not isinstance(value, date): + raise ValidationError("'{}' is not a date object".format(repr(value))) + + return long((value - date(1970, 1, 1)).total_seconds() * 1000) + + class UUID(Column): """ Type 1 or 4 UUID @@ -235,14 +261,14 @@ def validate(self, value): if not self.re_uuid.match(val): raise ValidationError("{} is not a valid uuid".format(value)) return _UUID(val) - + class TimeUUID(UUID): """ UUID containing timestamp """ - + db_type = 'timeuuid' - + def __init__(self, **kwargs): kwargs.setdefault('default', lambda: uuid1()) super(TimeUUID, self).__init__(**kwargs) diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 4921b67256..44903a4c85 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -1,5 +1,6 @@ #tests the behavior of the column classes from datetime import datetime +from datetime import date from decimal import Decimal as D from cqlengine import ValidationError @@ -11,6 +12,7 @@ from cqlengine.columns import Text from cqlengine.columns import Integer from cqlengine.columns import DateTime +from cqlengine.columns import Date from cqlengine.columns import UUID from cqlengine.columns import Boolean from cqlengine.columns import Float @@ -40,6 +42,37 @@ def test_datetime_io(self): dt2 = self.DatetimeTest.objects(test_id=0).first() assert dt2.created_at.timetuple()[:6] == now.timetuple()[:6] + +class TestDate(BaseCassEngTestCase): + class DateTest(Model): + test_id = Integer(primary_key=True) + created_at = Date() + + @classmethod + def setUpClass(cls): + super(TestDate, cls).setUpClass() + create_table(cls.DateTest) + + @classmethod + def tearDownClass(cls): + super(TestDate, cls).tearDownClass() + delete_table(cls.DateTest) + + def test_date_io(self): + today = date.today() + self.DateTest.objects.create(test_id=0, created_at=today) + dt2 = self.DateTest.objects(test_id=0).first() + assert dt2.created_at.isoformat() == today.isoformat() + + def test_date_io_using_datetime(self): + now = datetime.utcnow() + self.DateTest.objects.create(test_id=0, created_at=now) + dt2 = self.DateTest.objects(test_id=0).first() + assert not isinstance(dt2.created_at, datetime) + assert isinstance(dt2.created_at, date) + assert dt2.created_at.isoformat() == now.date().isoformat() + + class TestDecimal(BaseCassEngTestCase): class DecimalTest(Model): test_id = Integer(primary_key=True) @@ -63,26 +96,26 @@ def test_datetime_io(self): dt = self.DecimalTest.objects.create(test_id=0, dec_val=5) dt2 = self.DecimalTest.objects(test_id=0).first() assert dt2.dec_val == D('5') - + class TestTimeUUID(BaseCassEngTestCase): class TimeUUIDTest(Model): test_id = Integer(primary_key=True) timeuuid = TimeUUID() - + @classmethod def setUpClass(cls): super(TestTimeUUID, cls).setUpClass() create_table(cls.TimeUUIDTest) - + @classmethod def tearDownClass(cls): super(TestTimeUUID, cls).tearDownClass() delete_table(cls.TimeUUIDTest) - + def test_timeuuid_io(self): t0 = self.TimeUUIDTest.create(test_id=0) t1 = self.TimeUUIDTest.get(test_id=0) - + assert t1.timeuuid.time == t1.timeuuid.time class TestInteger(BaseCassEngTestCase): From 1cffc497e394f1d62b6d6f09d1217b013ea91f61 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 23 Mar 2013 21:54:54 -0700 Subject: [PATCH 0143/4200] adding test around chaining order_by statements --- cqlengine/tests/base.py | 2 +- cqlengine/tests/query/test_queryset.py | 3 +++ requirements.txt | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cqlengine/tests/base.py b/cqlengine/tests/base.py index 0b450cdd8b..e9fa8a9b19 100644 --- a/cqlengine/tests/base.py +++ b/cqlengine/tests/base.py @@ -7,7 +7,7 @@ class BaseCassEngTestCase(TestCase): def setUpClass(cls): super(BaseCassEngTestCase, cls).setUpClass() if not connection._hosts: - connection.setup(['localhost'], default_keyspace='cqlengine_test') + connection.setup(['localhost:9170'], default_keyspace='cqlengine_test') def assertHasAttr(self, obj, attr): self.assertTrue(hasattr(obj, attr), diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 660fb8db7f..d5e6fe16db 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -298,6 +298,9 @@ def test_ordering_on_multiple_clustering_columns(self): results = TestMultiClusteringModel.objects.filter(one=1, two=1).order_by('two', 'three') assert [r.three for r in results] == [1, 2, 3, 4, 5] + results = TestMultiClusteringModel.objects.filter(one=1, two=1).order_by('two').order_by('three') + assert [r.three for r in results] == [1, 2, 3, 4, 5] + class TestQuerySetSlicing(BaseQuerySetUsage): diff --git a/requirements.txt b/requirements.txt index 2415255265..04a6bc4465 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cql==1.2.0 +cql==1.4.0 ipython==0.13.1 ipdb==0.7 Sphinx==1.1.3 From 92e37c96c81211d57265661dc16b1bccaee40ed6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 23 Mar 2013 22:01:46 -0700 Subject: [PATCH 0144/4200] making date deserialize with utc timezone --- cqlengine/columns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 5cbe6a0946..76830d2c27 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -230,7 +230,7 @@ def to_python(self, value): elif isinstance(value, date): return value - return date.fromtimestamp(value) + return datetime.utcfromtimestamp(value).date() def to_database(self, value): value = super(Date, self).to_database(value) From 5a6c276a887d38da1cae8cfb90188c60bf298a38 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 23 Mar 2013 22:04:00 -0700 Subject: [PATCH 0145/4200] modifying pk comparison to look at the db_field_name, so it's not dependent on the ids being identical --- cqlengine/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index a0f758f18f..bb7d036f64 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -604,7 +604,7 @@ def delete(self, columns=[]): """ #validate where clause partition_key = self.model._primary_keys.values()[0] - if not any([c.column == partition_key for c in self._where]): + if not any([c.column.db_field_name == partition_key.db_field_name for c in self._where]): raise QueryException("The partition key must be defined on delete queries") qs = ['DELETE FROM {}'.format(self.column_family_name)] qs += ['WHERE {}'.format(self._where_clause())] From 2be6f3409c0b3fedc0e57bd99072e73571a30852 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 22 Apr 2013 16:02:31 -0700 Subject: [PATCH 0146/4200] Fixed issue with trying to call .close() on a None --- cqlengine/connection.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index e0afffdb8c..2a5ef989eb 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -78,7 +78,7 @@ def clear(cls): cls._queue.get().close() except: pass - + @classmethod def get(cls): """ @@ -124,7 +124,7 @@ def _create_connection(cls): global _hosts global _username global _password - + if not _hosts: raise CQLConnectionError("At least one host required") @@ -133,7 +133,7 @@ def _create_connection(cls): new_conn = cql.connect(host.name, host.port, user=_username, password=_password) new_conn.set_cql_version('3.0.0') return new_conn - + class connection_manager(object): """ @@ -145,7 +145,7 @@ def __init__(self): self.keyspace = None self.con = ConnectionPool.get() self.cur = None - + def close(self): if self.cur: self.cur.close() ConnectionPool.put(self.con) @@ -175,7 +175,6 @@ def execute(self, query, params={}): except TTransportException: #TODO: check for other errors raised in the event of a connection / server problem #move to the next connection and set the connection pool - self.con = None _host_idx += 1 _host_idx %= len(_hosts) self.con.close() From 8ec6fc38a43eea201e7086c455fdccb54d5df7de Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 2 May 2013 22:21:56 -0700 Subject: [PATCH 0147/4200] version number bump --- cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 184a4c8a38..ed5a820213 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.1.2' +__version__ = '0.2' diff --git a/docs/conf.py b/docs/conf.py index 59c9a1239b..96c7db6ed6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.1.2' +version = '0.2' # The full version, including alpha/beta/rc tags. -release = '0.1.2' +release = '0.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 4190fb384f..791c75df20 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.1.2' +version = '0.2' long_desc = """ cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine From b8584770c0cf2a0b6c2713a4760b2a4b82ce4546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20Kry=C5=84ski?= Date: Sat, 4 May 2013 19:53:46 +0200 Subject: [PATCH 0148/4200] Token function --- cqlengine/functions.py | 11 +++++++++++ cqlengine/query.py | 11 +++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cqlengine/functions.py b/cqlengine/functions.py index 15e93d4659..3f023e75fb 100644 --- a/cqlengine/functions.py +++ b/cqlengine/functions.py @@ -23,6 +23,9 @@ def to_cql(self, value_id): def get_value(self): raise NotImplementedError + def format_cql(self, field, operator, value_id): + return '"{}" {} {}'.format(field, operator, self.to_cql(value_id)) + class MinTimeUUID(BaseQueryFunction): _cql_string = 'MinTimeUUID(:{})' @@ -57,3 +60,11 @@ def get_value(self): epoch = datetime(1970, 1, 1) return long((self.value - epoch).total_seconds() * 1000) +class Token(BaseQueryFunction): + _cql_string = 'token(:{})' + + def format_cql(self, field, operator, value_id): + return 'token("{}") {} {}'.format(field, operator, self.to_cql(value_id)) + + def get_value(self): + return self.value diff --git a/cqlengine/query.py b/cqlengine/query.py index bb7d036f64..a2762d3af0 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -8,7 +8,7 @@ from cqlengine.connection import connection_manager from cqlengine.exceptions import CQLEngineException -from cqlengine.functions import BaseQueryFunction +from cqlengine.functions import BaseQueryFunction, Token #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index @@ -44,7 +44,7 @@ def cql(self): :param valname: the dict key that this operator's compare value will be found in """ if isinstance(self.value, BaseQueryFunction): - return '"{}" {} {}'.format(self.column.db_field_name, self.cql_symbol, self.value.to_cql(self.identifier)) + return self.value.format_cql(self.column.db_field_name, self.cql_symbol, self.identifier) else: return '"{}" {} :{}'.format(self.column.db_field_name, self.cql_symbol, self.identifier) @@ -275,14 +275,17 @@ def _validate_where_syntax(self): #check that there's either a = or IN relationship with a primary key or indexed field equal_ops = [w for w in self._where if isinstance(w, EqualsOperator)] - if not any([w.column.primary_key or w.column.index for w in equal_ops]): + token_ops = [w for w in self._where if isinstance(w.value, Token)] + if not any([w.column.primary_key or w.column.index for w in equal_ops]) and not token_ops: raise QueryException('Where clauses require either a "=" or "IN" comparison with either a primary key or indexed field') if not self._allow_filtering: #if the query is not on an indexed field if not any([w.column.index for w in equal_ops]): - if not any([w.column._partition_key for w in equal_ops]): + if not any([w.column._partition_key for w in equal_ops]) and not token_ops: raise QueryException('Filtering on a clustering key without a partition key is not allowed unless allow_filtering() is called on the querset') + if any(not w.column._partition_key for w in token_ops): + raise QueryException('The token() function is only supported on the partition key') #TODO: abuse this to see if we can get cql to raise an exception From 3cb08dd23a5c28ec573bb0fd328b513a8b4c4e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20Kry=C5=84ski?= Date: Sat, 4 May 2013 20:05:22 +0200 Subject: [PATCH 0149/4200] do not call count() in QuerySet.__len__ --- cqlengine/query.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index a2762d3af0..fe8680b4e1 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -260,7 +260,8 @@ def __deepcopy__(self, memo): return clone def __len__(self): - return self.count() + self._execute_query() + return len(self._result_cache) def __del__(self): if self._con: From bc4f7ed78b8d9ee9e6474517d9ab262ca08099ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20Kry=C5=84ski?= Date: Sat, 4 May 2013 22:30:27 +0200 Subject: [PATCH 0150/4200] QuerySet.values_list support --- cqlengine/query.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index fe8680b4e1..9ac1f948bb 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -222,6 +222,9 @@ def __init__(self, model): self._defer_fields = [] self._only_fields = [] + self._values_list = False + self._flat_values_list = False + #results cache self._con = None self._cur = None @@ -340,6 +343,9 @@ def _execute_query(self): self._con = connection_manager() self._cur = self._con.execute(self._select_query(), self._where_values()) self._result_cache = [None]*self._cur.rowcount + if self._cur.description: + names = [i[0] for i in self._cur.description] + self._construct_result = self._create_result_constructor(names) def _fill_result_cache_to_idx(self, idx): self._execute_query() @@ -350,14 +356,12 @@ def _fill_result_cache_to_idx(self, idx): if qty < 1: return else: - names = [i[0] for i in self._cur.description] for values in self._cur.fetchmany(qty): - value_dict = dict(zip(names, values)) self._result_idx += 1 - self._result_cache[self._result_idx] = self._construct_instance(value_dict) + self._result_cache[self._result_idx] = self._construct_result(values) #return the connection to the connection pool if we have all objects - if self._result_cache and self._result_cache[-1] is not None: + if self._result_cache and self._result_idx == (len(self._result_cache) - 1): self._con.close() self._con = None self._cur = None @@ -398,6 +402,17 @@ def __getitem__(self, s): self._fill_result_cache_to_idx(s) return self._result_cache[s] + def _create_result_constructor(self, names): + if not self._values_list: + return (lambda values: self._construct_instance(dict(zip(names, values)))) + + db_map = self.model._db_map + columns = [self.model._columns[db_map[name]] for name in names] + if self._flat_values_list: + return (lambda values: columns[0].to_python(values[0])) + else: + # result_cls = namedtuple("{}Tuple".format(self.model.__name__), names) + return (lambda values: map(lambda (c, v): c.to_python(v), zip(columns, values))) def _construct_instance(self, values): #translate column names to model names @@ -620,6 +635,18 @@ def delete(self, columns=[]): with connection_manager() as con: con.execute(qs, self._where_values()) + def values_list(self, *fields, **kwargs): + flat = kwargs.pop('flat', False) + if kwargs: + raise TypeError('Unexpected keyword arguments to values_list: %s' + % (kwargs.keys(),)) + if flat and len(fields) > 1: + raise TypeError("'flat' is not valid when values_list is called with more than one field.") + clone = self.only(fields) + clone._values_list = True + clone._flat_values_list = flat + return clone + class DMLQuery(object): """ A query object used for queries performing inserts, updates, or deletes From 49bd7415a82f1db81cc8696f804a1ca958787833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20Kry=C5=84ski?= Date: Tue, 7 May 2013 00:52:06 +0200 Subject: [PATCH 0151/4200] memory leak fix --- cqlengine/query.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 9ac1f948bb..a4690df04a 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -403,30 +403,23 @@ def __getitem__(self, s): return self._result_cache[s] def _create_result_constructor(self, names): + model = self.model + db_map = model._db_map if not self._values_list: - return (lambda values: self._construct_instance(dict(zip(names, values)))) - - db_map = self.model._db_map - columns = [self.model._columns[db_map[name]] for name in names] + def _construct_instance(values): + field_dict = dict((db_map.get(k, k), v) for k, v in zip(names, values)) + instance = model(**field_dict) + instance._is_persisted = True + return instance + return _construct_instance + + columns = [model._columns[db_map[name]] for name in names] if self._flat_values_list: return (lambda values: columns[0].to_python(values[0])) else: # result_cls = namedtuple("{}Tuple".format(self.model.__name__), names) return (lambda values: map(lambda (c, v): c.to_python(v), zip(columns, values))) - def _construct_instance(self, values): - #translate column names to model names - field_dict = {} - db_map = self.model._db_map - for key, val in values.items(): - if key in db_map: - field_dict[db_map[key]] = val - else: - field_dict[key] = val - instance = self.model(**field_dict) - instance._is_persisted = True - return instance - def batch(self, batch_obj): """ Adds a batch query to the mix From edaf83f3aec166b5a28676404b0ad03c95d42547 Mon Sep 17 00:00:00 2001 From: Pandu Rao Date: Wed, 8 May 2013 17:06:09 -0700 Subject: [PATCH 0152/4200] Added guards to ensure that prev and val exist before calling their keys function --- cqlengine/columns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 76830d2c27..8a2dd79ea6 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -627,8 +627,8 @@ def get_delete_statement(self, val, prev, ctx): if isinstance(val, self.Quoter): val = val.value if isinstance(prev, self.Quoter): prev = prev.value - old_keys = set(prev.keys()) - new_keys = set(val.keys()) + old_keys = set(prev.keys()) if prev else set() + new_keys = set(val.keys()) if val else set() del_keys = old_keys - new_keys del_statements = [] From 1bdd1a14b135a6c6c6d6d86118135de983225f79 Mon Sep 17 00:00:00 2001 From: "Gregory R. Doermann" Date: Thu, 9 May 2013 11:21:44 -0600 Subject: [PATCH 0153/4200] Timezone aware datetime fix. --- cqlengine/columns.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 76830d2c27..9c67a1641b 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -214,8 +214,12 @@ def to_database(self, value): value = super(DateTime, self).to_database(value) if not isinstance(value, datetime): raise ValidationError("'{}' is not a datetime object".format(value)) - epoch = datetime(1970, 1, 1) - return long((value - epoch).total_seconds() * 1000) + epoch = datetime(1970, 1, 1, tzinfo=value.tzinfo) + offset = 0 + if epoch.tzinfo: + offset_delta = epoch.tzinfo.utcoffset(epoch) + offset = offset_delta.days*24*3600 + offset_delta.seconds + return long(((value - epoch).total_seconds() - offset) * 1000) class Date(Column): From 29f5ef42acc0dbf73c6ea4a5ca312c3386656096 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 9 May 2013 10:40:05 -0700 Subject: [PATCH 0154/4200] fixing the base test connection --- cqlengine/tests/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/tests/base.py b/cqlengine/tests/base.py index e9fa8a9b19..64e6a3c2a9 100644 --- a/cqlengine/tests/base.py +++ b/cqlengine/tests/base.py @@ -7,7 +7,7 @@ class BaseCassEngTestCase(TestCase): def setUpClass(cls): super(BaseCassEngTestCase, cls).setUpClass() if not connection._hosts: - connection.setup(['localhost:9170'], default_keyspace='cqlengine_test') + connection.setup(['localhost:9160'], default_keyspace='cqlengine_test') def assertHasAttr(self, obj, attr): self.assertTrue(hasattr(obj, attr), From c7f9a6d9378ded459e65103a9f028ed13071a172 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 9 May 2013 10:56:57 -0700 Subject: [PATCH 0155/4200] adding check to update statement to avoid failure on update from None --- cqlengine/columns.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 8a2dd79ea6..e028c538c9 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -600,6 +600,8 @@ def get_update_statement(self, val, prev, ctx): prev = self.to_database(prev) if isinstance(val, self.Quoter): val = val.value if isinstance(prev, self.Quoter): prev = prev.value + val = val or {} + prev = prev or {} #get the updated map update = {k:v for k,v in val.items() if v != prev.get(k)} From 47a0099708c381969125b18a3cd0416a08a55a1d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 9 May 2013 10:57:50 -0700 Subject: [PATCH 0156/4200] adding additional tests around map column updates --- .../tests/columns/test_container_columns.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index b598da56bf..3533aad3ef 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -210,6 +210,26 @@ def test_partial_updates(self): m2 = TestMapModel.get(partition=m1.partition) assert m2.text_map == final + def test_updates_from_none(self): + """ Tests that updates from None work as expected """ + m = TestMapModel.create(int_map=None) + expected = {1:uuid4()} + m.int_map = expected + m.save() + + m2 = TestMapModel.get(partition=m.partition) + assert m2.int_map == expected + + + def test_updates_to_none(self): + """ Tests that setting the field to None works as expected """ + m = TestMapModel.create(int_map={1:uuid4()}) + m.int_map = None + m.save() + + m2 = TestMapModel.get(partition=m.partition) + assert m2.int_map is None + # def test_partial_update_creation(self): # """ # Tests that proper update statements are created for a partial list update From 558f6981b4ae3b064637fc4b561a1675866eea03 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 9 May 2013 11:59:10 -0700 Subject: [PATCH 0157/4200] added test for date time column with tzinfo --- cqlengine/tests/columns/test_validation.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 44903a4c85..e323c4653d 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -1,6 +1,7 @@ #tests the behavior of the column classes -from datetime import datetime +from datetime import datetime, timedelta from datetime import date +from datetime import tzinfo from decimal import Decimal as D from cqlengine import ValidationError @@ -42,6 +43,18 @@ def test_datetime_io(self): dt2 = self.DatetimeTest.objects(test_id=0).first() assert dt2.created_at.timetuple()[:6] == now.timetuple()[:6] + def test_datetime_tzinfo_io(self): + class TZ(tzinfo): + def utcoffset(self, date_time): + return timedelta(hours=-1) + def dst(self, date_time): + return None + + now = datetime(1982, 1, 1, tzinfo=TZ()) + dt = self.DatetimeTest.objects.create(test_id=0, created_at=now) + dt2 = self.DatetimeTest.objects(test_id=0).first() + assert dt2.created_at.timetuple()[:6] == (now + timedelta(hours=1)).timetuple()[:6] + class TestDate(BaseCassEngTestCase): class DateTest(Model): From 8d23c7ba7adb05a8e6404bb676af67ad866398b6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 9 May 2013 12:03:20 -0700 Subject: [PATCH 0158/4200] updating pypi description --- setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.py b/setup.py index 791c75df20..af82216686 100644 --- a/setup.py +++ b/setup.py @@ -16,8 +16,6 @@ [Users Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-users) [Dev Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-dev) - -**NOTE: cqlengine is in alpha and under development, some features may change. Make sure to check the changelog and test your app before upgrading** """ setup( From 7316fa20c02044e573ee8c769420bebbeba4a097 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 9 May 2013 12:04:24 -0700 Subject: [PATCH 0159/4200] updating changelog --- changelog | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 00dc02bced..3edb19f0fa 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,10 @@ CHANGELOG -0.1.2 (in progress) +0.2.1 (in progress) +* adding support for datetimes with tzinfo (thanks @gdoermann) +* fixing bug in saving map updates (thanks @pandu-rao) + +0.2 * expanding internal save function to use update where appropriate * adding set, list, and map collection types * adding support for allow filtering flag From 2f8752353506cad8d806f362847517a8bb2475cb Mon Sep 17 00:00:00 2001 From: Silvio Tomatis Date: Mon, 13 May 2013 14:45:41 +0200 Subject: [PATCH 0160/4200] Add travis testing --- .travis.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..86b92c0ac8 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: python +python: + - "2.7" +services: + - cassandra +before_install: + - sudo sh -c "echo 'JVM_OPTS=\"\${JVM_OPTS} -Djava.net.preferIPv4Stack=false\"' >> /usr/local/cassandra/conf/cassandra-env.sh" + - sudo service cassandra start +install: + - "pip install -r requirements.txt --use-mirrors" + - "pip install pytest --use-mirrors" +script: + - while [ ! -f /var/run/cassandra.pid ] ; do sleep 1 ; done # wait until cassandra is ready + - "py.test cqlengine/tests/" From 4fee79b348a9d19b8cf09bf748762265866c6ce8 Mon Sep 17 00:00:00 2001 From: Silvio Tomatis Date: Mon, 13 May 2013 15:10:14 +0200 Subject: [PATCH 0161/4200] Make all tests pass when run alltogether with 'py.test cqlengine/tests' --- cqlengine/tests/query/test_queryset.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index d5e6fe16db..a77d750299 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -408,13 +408,15 @@ def test_conn_is_returned_after_filling_cache(self): def test_conn_is_returned_after_queryset_is_garbage_collected(self): """ Tests that the connection is returned to the connection pool after the queryset is gc'd """ from cqlengine.connection import ConnectionPool - assert ConnectionPool._queue.qsize() == 1 + # The queue size can be 1 if we just run this file's tests + # It will be 2 when we run 'em all + initial_size = ConnectionPool._queue.qsize() q = TestModel.objects(test_id=0) v = q[0] - assert ConnectionPool._queue.qsize() == 0 + assert ConnectionPool._queue.qsize() == initial_size - 1 del q - assert ConnectionPool._queue.qsize() == 1 + assert ConnectionPool._queue.qsize() == initial_size class TimeUUIDQueryModel(Model): partition = columns.UUID(primary_key=True) From 0022f21c975f1b47e8c8b9f25b73a35bc372a3bf Mon Sep 17 00:00:00 2001 From: Silvio Tomatis Date: Mon, 13 May 2013 15:48:44 +0200 Subject: [PATCH 0162/4200] Add test for boolean column in test_model_io --- cqlengine/tests/model/test_model_io.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 7f708cf214..08e77ded36 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -1,4 +1,3 @@ -from unittest import skip from uuid import uuid4 import random from cqlengine.tests.base import BaseCassEngTestCase @@ -11,6 +10,7 @@ class TestModel(Model): count = columns.Integer() text = columns.Text(required=False) + a_bool = columns.Boolean(default=False) class TestModelIO(BaseCassEngTestCase): @@ -41,10 +41,12 @@ def test_model_updating_works_properly(self): tm = TestModel.objects.create(count=8, text='123456789') tm.count = 100 + tm.a_bool = True tm.save() tm2 = TestModel.objects(id=tm.pk).first() self.assertEquals(tm.count, tm2.count) + self.assertEquals(tm.a_bool, tm2.a_bool) def test_model_deleting_works_properly(self): """ From 52f87303a4681521d6b93772a7d0a478005e30b1 Mon Sep 17 00:00:00 2001 From: "Gregory R. Doermann" Date: Wed, 15 May 2013 13:46:27 -0600 Subject: [PATCH 0163/4200] Basic (working) counter column. You can add and subtract values. You must use CounterModel when using a CounterColumn. --- cqlengine/__init__.py | 2 +- cqlengine/columns.py | 8 +++++--- cqlengine/models.py | 12 ++++++++++++ cqlengine/query.py | 15 +++++++++++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 184a4c8a38..e46d0e6d49 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,6 +1,6 @@ from cqlengine.columns import * from cqlengine.functions import * -from cqlengine.models import Model +from cqlengine.models import Model, CounterModel from cqlengine.query import BatchQuery __version__ = '0.1.2' diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 9c67a1641b..ce7ce9936c 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -310,11 +310,13 @@ def to_database(self, value): class Decimal(Column): db_type = 'decimal' -class Counter(Column): - #TODO: counter field +class Counter(Integer): + """ Validates like an integer, goes into the database as a counter + """ + db_type = 'counter' + def __init__(self, **kwargs): super(Counter, self).__init__(**kwargs) - raise NotImplementedError class ContainerQuoter(object): """ diff --git a/cqlengine/models.py b/cqlengine/models.py index aec06b4f89..372f3253bf 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -261,3 +261,15 @@ class Model(BaseModel): __metaclass__ = ModelMetaClass +class CounterBaseModel(BaseModel): + def _can_update(self): + # INSERT is not allowed for counter column families + return True + + +class CounterModel(CounterBaseModel): + """ + the db name for the column family can be set as the attribute db_name, or + it will be genertaed from the class name + """ + __metaclass__ = ModelMetaClass diff --git a/cqlengine/query.py b/cqlengine/query.py index bb7d036f64..db8d17a318 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -4,7 +4,7 @@ from hashlib import md5 from time import time from uuid import uuid1 -from cqlengine import BaseContainerColumn, BaseValueManager, Map +from cqlengine import BaseContainerColumn, BaseValueManager, Map, Counter from cqlengine.connection import connection_manager from cqlengine.exceptions import CQLEngineException @@ -676,7 +676,18 @@ def save(self): if not col.is_primary_key: val = values.get(name) if val is None: continue - if isinstance(col, BaseContainerColumn): + if isinstance(col, Counter): + field_ids.pop(name) + value = field_values.pop(name) + if value == 0: + # Don't increment that column + continue + elif value < 0: + sign = '-' + else: + sign = '+' + set_statements += ['{0} = {0} {1} {2}'.format(col.db_field_name, sign, abs(value))] + elif isinstance(col, BaseContainerColumn): #remove value from query values, the column will handle it query_values.pop(field_ids.get(name), None) From 1b72535064d6954b70d41cb2de236457ed711786 Mon Sep 17 00:00:00 2001 From: "Gregory R. Doermann" Date: Fri, 17 May 2013 14:12:14 -0600 Subject: [PATCH 0164/4200] Custom TTL and timestamp information can be passed in on save. --- cqlengine/connection.py | 4 +++- cqlengine/functions.py | 15 +++++++++++++++ cqlengine/models.py | 19 +++++++++++++++---- cqlengine/query.py | 26 ++++++++++++++++++++++---- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 2a5ef989eb..c56d08a928 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -156,13 +156,15 @@ def __enter__(self): def __exit__(self, type, value, traceback): self.close() - def execute(self, query, params={}): + def execute(self, query, params=None): """ Gets a connection from the pool and executes the given query, returns the cursor if there's a connection problem, this will silently create a new connection pool from the available hosts, and remove the problematic host from the host list """ + if params is None: + params = {} global _host_idx for i in range(len(_hosts)): diff --git a/cqlengine/functions.py b/cqlengine/functions.py index 15e93d4659..b2886c9ab4 100644 --- a/cqlengine/functions.py +++ b/cqlengine/functions.py @@ -57,3 +57,18 @@ def get_value(self): epoch = datetime(1970, 1, 1) return long((self.value - epoch).total_seconds() * 1000) + +class NotSet(object): + """ + Different from None, so you know when it just wasn't set compared to passing in None + """ + pass + + +def format_timestamp(timestamp): + if isinstance(timestamp, datetime): + epoch = datetime(1970, 1, 1) + ts = long((timestamp - epoch).total_seconds() * 1000) + else : + ts = long(timestamp) + return ts diff --git a/cqlengine/models.py b/cqlengine/models.py index 372f3253bf..33ccb56b92 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -3,9 +3,10 @@ from cqlengine import columns from cqlengine.exceptions import ModelException -from cqlengine.functions import BaseQueryFunction +from cqlengine.functions import BaseQueryFunction, NotSet from cqlengine.query import QuerySet, QueryException, DMLQuery + class ModelDefinitionException(ModelException): pass DEFAULT_KEYSPACE = 'cqlengine' @@ -38,12 +39,20 @@ class MultipleObjectsReturned(QueryException): pass #however, you can also define them manually here table_name = None + #DEFAULT_TTL must be an integer seconds for the default time to live on any insert on the table + #this can be overridden on any given query, but you can set a default on the model + DEFAULT_TTL = None + #the keyspace for this model keyspace = None read_repair_chance = 0.1 - def __init__(self, **values): + def __init__(self, ttl=NotSet, **values): self._values = {} + if ttl == NotSet: + self.ttl = self.DEFAULT_TTL + else: + self.ttl = ttl for name, column in self._columns.items(): value = values.get(name, None) if value is not None: value = column.to_python(value) @@ -136,10 +145,12 @@ def filter(cls, **kwargs): def get(cls, **kwargs): return cls.objects.get(**kwargs) - def save(self): + def save(self, ttl=NotSet, timestamp=None): + if ttl == NotSet: + ttl = self.ttl is_new = self.pk is None self.validate() - DMLQuery(self.__class__, self, batch=self._batch).save() + DMLQuery(self.__class__, self, batch=self._batch).save(ttl, timestamp) #reset the value managers for v in self._values.values(): diff --git a/cqlengine/query.py b/cqlengine/query.py index db8d17a318..eca930a07e 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -8,7 +8,7 @@ from cqlengine.connection import connection_manager from cqlengine.exceptions import CQLEngineException -from cqlengine.functions import BaseQueryFunction +from cqlengine.functions import BaseQueryFunction, format_timestamp #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index @@ -175,8 +175,7 @@ def execute(self): opener = 'BEGIN ' + (self.batch_type + ' ' if self.batch_type else '') + ' BATCH' if self.timestamp: - epoch = datetime(1970, 1, 1) - ts = long((self.timestamp - epoch).total_seconds() * 1000) + ts = format_timestamp(self.timestamp) opener += ' USING TIMESTAMP {}'.format(ts) query_list = [opener] @@ -638,7 +637,7 @@ def batch(self, batch_obj): self.batch = batch_obj return self - def save(self): + def save(self, ttl=None, timestamp=None): """ Creates / updates a row. This is a blind insert call. @@ -666,8 +665,25 @@ def save(self): query_values = {field_ids[n]:field_values[n] for n in field_names} qs = [] + + using = [] + if ttl is not None: + ttl = int(ttl) + using.append('TTL {} '.format(ttl)) + if timestamp: + ts = format_timestamp(timestamp) + using.append('TIMESTAMP {} '.format(ts)) + + usings = '' + if using: + using = 'AND '.join(using).strip() + usings = ' USING {}'.format(using) + + if self.instance._can_update(): qs += ["UPDATE {}".format(self.column_family_name)] + if usings: + qs += [usings] qs += ["SET"] set_statements = [] @@ -714,6 +730,8 @@ def save(self): qs += ["({})".format(', '.join(['"{}"'.format(f) for f in field_names]))] qs += ['VALUES'] qs += ["({})".format(', '.join([':'+field_ids[f] for f in field_names]))] + if usings: + qs += [usings] qs = ' '.join(qs) From ff67b994835bbd3db97b74602cd9b7efafcd161b Mon Sep 17 00:00:00 2001 From: Pandu Rao Date: Tue, 21 May 2013 17:59:04 -0700 Subject: [PATCH 0165/4200] Fixed #46 delete property only on can_delete --- cqlengine/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index aec06b4f89..1b1181ee83 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -192,9 +192,9 @@ def _transform_column(col_name, col_obj): _set = lambda self, val: self._values[col_name].setval(val) _del = lambda self: self._values[col_name].delval() if col_obj.can_delete: - attrs[col_name] = property(_get, _set) - else: attrs[col_name] = property(_get, _set, _del) + else: + attrs[col_name] = property(_get, _set) column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.Column)] column_definitions = sorted(column_definitions, lambda x,y: cmp(x[1].position, y[1].position)) From bdc0c0399ae4315dba3e4e3e0dfbd4fa267a4a0b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 22 May 2013 11:46:01 -0700 Subject: [PATCH 0166/4200] changing ORM (object row mapper) to Object Mapper, to avoid any confusion regarding support for object relationships --- README.md | 2 +- docs/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3e5b06bfa6..7b244ffeb6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ cqlengine =============== -cqlengine is a Cassandra CQL 3 ORM for Python with an interface similar to the Django orm and mongoengine +cqlengine is a Cassandra CQL 3 Object Mapper for Python with an interface similar to the Django orm and mongoengine [Documentation](https://cqlengine.readthedocs.org/en/latest/) diff --git a/docs/index.rst b/docs/index.rst index dbc64e54f9..6cea2f2c33 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,7 +5,7 @@ cqlengine documentation ======================= -cqlengine is a Cassandra CQL 3 ORM for Python with an interface similar to the Django orm and mongoengine +cqlengine is a Cassandra CQL 3 Object Mapper for Python with an interface similar to the Django orm and mongoengine :ref:`getting-started` From 7e56e25d2186c00f75f735d0b74f9aa17de3d483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20Kry=C5=84ski?= Date: Sat, 25 May 2013 16:00:49 +0200 Subject: [PATCH 0167/4200] changed .all() behaviour - it clones QuerySet as in Django ORM --- cqlengine/query.py | 24 +++++++++++++++++++++--- cqlengine/tests/query/test_queryset.py | 6 +++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index a4690df04a..809dedd85e 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -102,6 +102,19 @@ def _recurse(klass): except KeyError: raise QueryOperatorException("{} doesn't map to a QueryOperator".format(symbol)) + # equality operator, used by tests + + def __eq__(self, op): + return self.__class__ is op.__class__ and \ + self.column.db_field_name == op.column.db_field_name and \ + self.value == op.value + + def __ne__(self, op): + return not (self == op) + + def __hash__(self): + return hash(self.column.db_field_name) ^ hash(self.value) + class EqualsOperator(QueryOperator): symbol = 'EQ' cql_symbol = '=' @@ -439,9 +452,7 @@ def first(self): return None def all(self): - clone = copy.deepcopy(self) - clone._where = [] - return clone + return copy.deepcopy(self) def _parse_filter_arg(self, arg): """ @@ -640,6 +651,13 @@ def values_list(self, *fields, **kwargs): clone._flat_values_list = flat return clone + + def __eq__(self, q): + return set(self._where) == set(q._where) + + def __ne__(self, q): + return not (self != q) + class DMLQuery(object): """ A query object used for queries performing inserts, updates, or deletes diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index a77d750299..3c9c43cf9d 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -89,9 +89,9 @@ def test_queryset_is_immutable(self): query2 = query1.filter(expected_result__gte=1) assert len(query2._where) == 2 - def test_the_all_method_clears_where_filter(self): + def test_the_all_method_duplicates_queryset(self): """ - Tests that calling all on a queryset with previously defined filters returns a queryset with no filters + Tests that calling all on a queryset with previously defined filters duplicates queryset """ query1 = TestModel.objects(test_id=5) assert len(query1._where) == 1 @@ -100,7 +100,7 @@ def test_the_all_method_clears_where_filter(self): assert len(query2._where) == 2 query3 = query2.all() - assert len(query3._where) == 0 + assert query3 == query2 def test_defining_only_and_defer_fails(self): """ From ec10c687fb5e04dda360971626a31e20926109a4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 26 May 2013 08:10:10 -0700 Subject: [PATCH 0168/4200] removing alpha from pypi info --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index af82216686..860b6739e3 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,6 @@ dependency_links = ['https://github.com/bdeggleston/cqlengine/archive/{0}.tar.gz#egg=cqlengine-{0}'.format(version)], long_description=long_desc, classifiers = [ - "Development Status :: 3 - Alpha", "Environment :: Web Environment", "Environment :: Plugins", "License :: OSI Approved :: BSD License", From c151fbd8b1b55f7ffc0f65b09249e4eaa00f5fbe Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 26 May 2013 08:10:52 -0700 Subject: [PATCH 0169/4200] updating pypi description --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 860b6739e3..0f98baecbe 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ version = '0.2' long_desc = """ -cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine +cqlengine is a Cassandra CQL Object Mapper for Python in the style of the Django orm and mongoengine [Documentation](https://cqlengine.readthedocs.org/en/latest/) From d1e69bf01df7abdb1b1f2429138e51998d213f5b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 26 May 2013 08:16:21 -0700 Subject: [PATCH 0170/4200] adding test around deleting of model attributes --- cqlengine/tests/model/test_class_construction.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 77b1d73775..36aa11e90f 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -117,7 +117,18 @@ def test_meta_data_is_not_inherited(self): """ Test that metadata defined in one class, is not inherited by subclasses """ - + + def test_del_attribute_is_assigned_properly(self): + """ Tests that columns that can be deleted have the del attribute """ + class DelModel(Model): + key = columns.Integer(primary_key=True) + data = columns.Integer(required=False) + + model = DelModel(key=4, data=5) + del model.data + with self.assertRaises(AttributeError): + del model.key + class TestManualTableNaming(BaseCassEngTestCase): class RenamedTest(cqlengine.Model): From 786a21ad0d898dc225cd38dd0a23fb6503ef957c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 22 May 2013 11:46:01 -0700 Subject: [PATCH 0171/4200] changing ORM (object row mapper) to Object Mapper, to avoid any confusion regarding support for object relationships --- README.md | 2 +- docs/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3e5b06bfa6..7b244ffeb6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ cqlengine =============== -cqlengine is a Cassandra CQL 3 ORM for Python with an interface similar to the Django orm and mongoengine +cqlengine is a Cassandra CQL 3 Object Mapper for Python with an interface similar to the Django orm and mongoengine [Documentation](https://cqlengine.readthedocs.org/en/latest/) diff --git a/docs/index.rst b/docs/index.rst index dbc64e54f9..6cea2f2c33 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,7 +5,7 @@ cqlengine documentation ======================= -cqlengine is a Cassandra CQL 3 ORM for Python with an interface similar to the Django orm and mongoengine +cqlengine is a Cassandra CQL 3 Object Mapper for Python with an interface similar to the Django orm and mongoengine :ref:`getting-started` From 4826f384823bc85d51a601b7b72a5624c2096249 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 26 May 2013 08:10:10 -0700 Subject: [PATCH 0172/4200] removing alpha from pypi info --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index af82216686..860b6739e3 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,6 @@ dependency_links = ['https://github.com/bdeggleston/cqlengine/archive/{0}.tar.gz#egg=cqlengine-{0}'.format(version)], long_description=long_desc, classifiers = [ - "Development Status :: 3 - Alpha", "Environment :: Web Environment", "Environment :: Plugins", "License :: OSI Approved :: BSD License", From d6b984b95223ce538274b3e8d420ef2c441564b7 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 26 May 2013 08:10:52 -0700 Subject: [PATCH 0173/4200] updating pypi description --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 860b6739e3..0f98baecbe 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ version = '0.2' long_desc = """ -cqlengine is a Cassandra CQL ORM for Python in the style of the Django orm and mongoengine +cqlengine is a Cassandra CQL Object Mapper for Python in the style of the Django orm and mongoengine [Documentation](https://cqlengine.readthedocs.org/en/latest/) From bb3ea850f250f01cd68db2f77644fcd6c7b45fdc Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 26 May 2013 08:16:21 -0700 Subject: [PATCH 0174/4200] adding test around deleting of model attributes --- cqlengine/tests/model/test_class_construction.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 77b1d73775..36aa11e90f 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -117,7 +117,18 @@ def test_meta_data_is_not_inherited(self): """ Test that metadata defined in one class, is not inherited by subclasses """ - + + def test_del_attribute_is_assigned_properly(self): + """ Tests that columns that can be deleted have the del attribute """ + class DelModel(Model): + key = columns.Integer(primary_key=True) + data = columns.Integer(required=False) + + model = DelModel(key=4, data=5) + del model.data + with self.assertRaises(AttributeError): + del model.key + class TestManualTableNaming(BaseCassEngTestCase): class RenamedTest(cqlengine.Model): From 929d1736eac5e1f03bab6184db7360a8d3b2a339 Mon Sep 17 00:00:00 2001 From: Pandu Rao Date: Sun, 26 May 2013 10:18:31 -0700 Subject: [PATCH 0175/4200] Fixed error in sample code in documentation --- docs/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 6cea2f2c33..f93a8505c4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -64,7 +64,7 @@ Getting Started >>> q.count() 4 >>> for instance in q: - >>> print q.description + >>> print instance.description example5 example6 example7 @@ -78,7 +78,7 @@ Getting Started >>> q2.count() 1 >>> for instance in q2: - >>> print q.description + >>> print instance.description example5 From d9b0eb9bb5dacc00a1efa3c8fcbddb82fe66c147 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 26 May 2013 12:20:17 -0600 Subject: [PATCH 0176/4200] duplicate pandu-rao's doc fixes in the readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7b244ffeb6..b54711f116 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ class ExampleModel(Model): >>> q.count() 4 >>> for instance in q: ->>> print q.description +>>> print instance.description example5 example6 example7 @@ -71,6 +71,6 @@ example8 >>> q2.count() 1 >>> for instance in q2: ->>> print q.description +>>> print instance.description example5 ``` From cdcccbb99a9ead5a1aea67585d62ec42291ab964 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 26 May 2013 12:20:17 -0600 Subject: [PATCH 0177/4200] duplicate pandu-rao's doc fixes in the readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7b244ffeb6..b54711f116 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ class ExampleModel(Model): >>> q.count() 4 >>> for instance in q: ->>> print q.description +>>> print instance.description example5 example6 example7 @@ -71,6 +71,6 @@ example8 >>> q2.count() 1 >>> for instance in q2: ->>> print q.description +>>> print instance.description example5 ``` From 7c43b39237f7a1e0ee28da347705cc45eb89d83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20Kry=C5=84ski?= Date: Mon, 27 May 2013 00:34:53 +0200 Subject: [PATCH 0178/4200] clustering order, composite partition keys, Token func, docs & tests --- cqlengine/columns.py | 41 +++++++-- cqlengine/functions.py | 56 ++++++++----- cqlengine/management.py | 17 +++- cqlengine/models.py | 45 ++++++---- cqlengine/query.py | 84 ++++++++++--------- .../tests/model/test_class_construction.py | 24 +++++- .../tests/model/test_clustering_order.py | 35 ++++++++ cqlengine/tests/query/test_queryoperators.py | 20 ++++- cqlengine/tests/query/test_queryset.py | 11 ++- docs/topics/columns.rst | 11 ++- docs/topics/models.rst | 5 +- docs/topics/queryset.rst | 27 ++++++ 12 files changed, 284 insertions(+), 92 deletions(-) create mode 100644 cqlengine/tests/model/test_clustering_order.py diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 3c9bad45ba..ebf8cbc2ee 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -60,7 +60,7 @@ class Column(object): instance_counter = 0 - def __init__(self, primary_key=False, index=False, db_field=None, default=None, required=True): + def __init__(self, primary_key=False, partition_key=False, index=False, db_field=None, default=None, required=True, clustering_order=None): """ :param primary_key: bool flag, indicates this column is a primary key. The first primary key defined on a model is the partition key, all others are cluster keys @@ -69,15 +69,13 @@ def __init__(self, primary_key=False, index=False, db_field=None, default=None, :param default: the default value, can be a value or a callable (no args) :param required: boolean, is the field required? """ - self.primary_key = primary_key + self.partition_key = partition_key + self.primary_key = partition_key or primary_key self.index = index self.db_field = db_field self.default = default self.required = required - - #only the model meta class should touch this - self._partition_key = False - + self.clustering_order = clustering_order #the column name in the model definition self.column_name = None @@ -137,7 +135,7 @@ def get_column_def(self): """ Returns a column definition for CQL table definition """ - return '"{}" {}'.format(self.db_field_name, self.db_type) + return '{} {}'.format(self.cql, self.db_type) def set_column_name(self, name): """ @@ -156,6 +154,13 @@ def db_index_name(self): """ Returns the name of the cql index """ return 'index_{}'.format(self.db_field_name) + @property + def cql(self): + return self.get_cql() + + def get_cql(self): + return '"{}"'.format(self.db_field_name) + class Bytes(Column): db_type = 'blob' @@ -645,4 +650,26 @@ def get_delete_statement(self, val, prev, ctx): return del_statements +class _PartitionKeys(Column): + class value_manager(BaseValueManager): + pass + + def __init__(self, model): + self.model = model + +class _PartitionKeysToken(Column): + """ + virtual column representing token of partition columns. + Used by filter(pk__token=Token(...)) filters + """ + + def __init__(self, model): + self.partition_columns = model._partition_keys.values() + super(_PartitionKeysToken, self).__init__(partition_key=True) + + def to_database(self, value): + raise NotImplementedError + + def get_cql(self): + return "token({})".format(", ".join(c.cql for c in self.partition_columns)) diff --git a/cqlengine/functions.py b/cqlengine/functions.py index 3f023e75fb..947841808a 100644 --- a/cqlengine/functions.py +++ b/cqlengine/functions.py @@ -1,30 +1,39 @@ from datetime import datetime +from uuid import uuid1 from cqlengine.exceptions import ValidationError -class BaseQueryFunction(object): +class QueryValue(object): """ - Base class for filtering functions. Subclasses of these classes can - be passed into .filter() and will be translated into CQL functions in - the resulting query + Base class for query filter values. Subclasses of these classes can + be passed into .filter() keyword args """ - _cql_string = None + _cql_string = ':{}' - def __init__(self, value): + def __init__(self, value, identifier=None): self.value = value + self.identifier = uuid1().hex if identifier is None else identifier - def to_cql(self, value_id): - """ - Returns a function for cql with the value id as it's argument - """ - return self._cql_string.format(value_id) + def get_cql(self): + return self._cql_string.format(self.identifier) def get_value(self): - raise NotImplementedError + return self.value + + def get_dict(self, column): + return {self.identifier: column.to_database(self.get_value())} + + @property + def cql(self): + return self.get_cql() - def format_cql(self, field, operator, value_id): - return '"{}" {} {}'.format(field, operator, self.to_cql(value_id)) +class BaseQueryFunction(QueryValue): + """ + Base class for filtering functions. Subclasses of these classes can + be passed into .filter() and will be translated into CQL functions in + the resulting query + """ class MinTimeUUID(BaseQueryFunction): @@ -61,10 +70,19 @@ def get_value(self): return long((self.value - epoch).total_seconds() * 1000) class Token(BaseQueryFunction): - _cql_string = 'token(:{})' - def format_cql(self, field, operator, value_id): - return 'token("{}") {} {}'.format(field, operator, self.to_cql(value_id)) + def __init__(self, *values): + if len(values) == 1 and isinstance(values[0], (list, tuple)): + values = values[0] + super(Token, self).__init__(values, [uuid1().hex for i in values]) + + def get_dict(self, column): + items = zip(self.identifier, self.value, column.partition_columns) + return dict( + (id, col.to_database(val)) for id, val, col in items + ) + + def get_cql(self): + token_args = ', '.join(':{}'.format(id) for id in self.identifier) + return "token({})".format(token_args) - def get_value(self): - return self.value diff --git a/cqlengine/management.py b/cqlengine/management.py index 67c34f6841..f5f6a64923 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -61,20 +61,29 @@ def create_table(model, create_missing_keyspace=True): #add column types pkeys = [] + ckeys = [] qtypes = [] def add_column(col): s = col.get_column_def() - if col.primary_key: pkeys.append('"{}"'.format(col.db_field_name)) + if col.primary_key: + keys = (pkeys if col.partition_key else ckeys) + keys.append('"{}"'.format(col.db_field_name)) qtypes.append(s) for name, col in model._columns.items(): add_column(col) - qtypes.append('PRIMARY KEY ({})'.format(', '.join(pkeys))) + qtypes.append('PRIMARY KEY (({}){})'.format(', '.join(pkeys), ckeys and ', ' + ', '.join(ckeys) or '')) qs += ['({})'.format(', '.join(qtypes))] - + + with_qs = ['read_repair_chance = {}'.format(model.read_repair_chance)] + + _order = ["%s %s" % (c.db_field_name, c.clustering_order or 'ASC') for c in model._clustering_keys.values()] + if _order: + with_qs.append("clustering order by ({})".format(', '.join(_order))) + # add read_repair_chance - qs += ['WITH read_repair_chance = {}'.format(model.read_repair_chance)] + qs += ['WITH {}'.format(' AND '.join(with_qs))] qs = ' '.join(qs) try: diff --git a/cqlengine/models.py b/cqlengine/models.py index aec06b4f89..f06042d72e 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -102,10 +102,10 @@ def column_family_name(cls, include_keyspace=True): if not include_keyspace: return cf_name return '{}.{}'.format(cls._get_keyspace(), cf_name) - @property - def pk(self): - """ Returns the object's primary key """ - return getattr(self, self._pk_name) + #@property + #def pk(self): + # """ Returns the object's primary key """ + # return getattr(self, self._pk_name) def validate(self): """ Cleans and validates the field values """ @@ -174,7 +174,6 @@ def __new__(cls, name, bases, attrs): column_dict = OrderedDict() primary_keys = OrderedDict() pk_name = None - primary_key = None #get inherited properties inherited_columns = OrderedDict() @@ -210,24 +209,40 @@ def _transform_column(col_name, col_obj): k,v = 'id', columns.UUID(primary_key=True) column_definitions = [(k,v)] + column_definitions + has_partition_keys = any(v.partition_key for (k, v) in column_definitions) + #TODO: check that the defined columns don't conflict with any of the Model API's existing attributes/methods #transform column definitions for k,v in column_definitions: - if pk_name is None and v.primary_key: - pk_name = k - primary_key = v - v._partition_key = True + if not has_partition_keys and v.primary_key: + v.partition_key = True + has_partition_keys = True _transform_column(k,v) - - #setup primary key shortcut - if pk_name != 'pk': + + partition_keys = OrderedDict(k for k in primary_keys.items() if k[1].partition_key) + clustering_keys = OrderedDict(k for k in primary_keys.items() if not k[1].partition_key) + + #setup partition key shortcut + assert partition_keys + if len(partition_keys) == 1: + pk_name = partition_keys.keys()[0] attrs['pk'] = attrs[pk_name] + else: + # composite partition key case + _get = lambda self: tuple(self._values[c].getval() for c in partition_keys.keys()) + _set = lambda self, val: tuple(self._values[c].setval(v) for (c, v) in zip(partition_keys.keys(), val)) + attrs['pk'] = property(_get, _set) - #check for duplicate column names + # some validation col_names = set() for v in column_dict.values(): + # check for duplicate column names if v.db_field_name in col_names: raise ModelException("{} defines the column {} more than once".format(name, v.db_field_name)) + if v.clustering_order and not (v.primary_key and not v.partition_key): + raise ModelException("clustering_order may be specified only for clustering primary keys") + if v.clustering_order and v.clustering_order.lower() not in ('asc', 'desc'): + raise ModelException("invalid clustering order {} for column {}".format(repr(v.clustering_order), v.db_field_name)) col_names.add(v.db_field_name) #create db_name -> model name map for loading @@ -244,9 +259,11 @@ def _transform_column(col_name, col_obj): attrs['_defined_columns'] = defined_columns attrs['_db_map'] = db_map attrs['_pk_name'] = pk_name - attrs['_primary_key'] = primary_key attrs['_dynamic_columns'] = {} + attrs['_partition_keys'] = partition_keys + attrs['_clustering_keys'] = clustering_keys + #create the class and add a QuerySet to it klass = super(ModelMetaClass, cls).__new__(cls, name, bases, attrs) klass.objects = QuerySet(klass) diff --git a/cqlengine/query.py b/cqlengine/query.py index a4690df04a..19974eebbf 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -4,11 +4,11 @@ from hashlib import md5 from time import time from uuid import uuid1 -from cqlengine import BaseContainerColumn, BaseValueManager, Map +from cqlengine import BaseContainerColumn, BaseValueManager, Map, columns from cqlengine.connection import connection_manager from cqlengine.exceptions import CQLEngineException -from cqlengine.functions import BaseQueryFunction, Token +from cqlengine.functions import QueryValue, Token #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index @@ -24,14 +24,16 @@ class QueryOperator(object): # The comparator symbol this operator uses in cql cql_symbol = None + QUERY_VALUE_WRAPPER = QueryValue + def __init__(self, column, value): self.column = column self.value = value - #the identifier is a unique key that will be used in string - #replacement on query strings, it's created from a hash - #of this object's id and the time - self.identifier = uuid1().hex + if isinstance(value, QueryValue): + self.query_value = value + else: + self.query_value = self.QUERY_VALUE_WRAPPER(value) #perform validation on this operator self.validate_operator() @@ -41,12 +43,8 @@ def __init__(self, column, value): def cql(self): """ Returns this operator's portion of the WHERE clause - :param valname: the dict key that this operator's compare value will be found in """ - if isinstance(self.value, BaseQueryFunction): - return self.value.format_cql(self.column.db_field_name, self.cql_symbol, self.identifier) - else: - return '"{}" {} :{}'.format(self.column.db_field_name, self.cql_symbol, self.identifier) + return '{} {} {}'.format(self.column.cql, self.cql_symbol, self.query_value.cql) def validate_operator(self): """ @@ -81,10 +79,7 @@ def get_dict(self): this should return the dict: {'colval':} SELECT * FROM column_family WHERE colname=:colval """ - if isinstance(self.value, BaseQueryFunction): - return {self.identifier: self.column.to_database(self.value.get_value())} - else: - return {self.identifier: self.column.to_database(self.value)} + return self.query_value.get_dict(self.column) @classmethod def get_operator(cls, symbol): @@ -102,34 +97,34 @@ def _recurse(klass): except KeyError: raise QueryOperatorException("{} doesn't map to a QueryOperator".format(symbol)) + def __eq__(self, op): + return self.__class__ is op.__class__ and self.column == op.column and self.value == op.value + + def __ne__(self, op): + return not (self == op) + class EqualsOperator(QueryOperator): symbol = 'EQ' cql_symbol = '=' +class IterableQueryValue(QueryValue): + def __init__(self, value): + try: + super(IterableQueryValue, self).__init__(value, [uuid1().hex for i in value]) + except TypeError: + raise QueryException("in operator arguments must be iterable, {} found".format(value)) + + def get_dict(self, column): + return dict((i, column.to_database(v)) for (i, v) in zip(self.identifier, self.value)) + + def get_cql(self): + return '({})'.format(', '.join(':{}'.format(i) for i in self.identifier)) + class InOperator(EqualsOperator): symbol = 'IN' cql_symbol = 'IN' - class Quoter(object): - """ - contains a single value, which will quote itself for CQL insertion statements - """ - def __init__(self, value): - self.value = value - - def __str__(self): - from cql.query import cql_quote as cq - return '(' + ', '.join([cq(v) for v in self.value]) + ')' - - def get_dict(self): - if isinstance(self.value, BaseQueryFunction): - return {self.identifier: self.column.to_database(self.value.get_value())} - else: - try: - values = [v for v in self.value] - except TypeError: - raise QueryException("in operator arguments must be iterable, {} found".format(self.value)) - return {self.identifier: self.Quoter([self.column.to_database(v) for v in self.value])} + QUERY_VALUE_WRAPPER = IterableQueryValue class GreaterThanOperator(QueryOperator): symbol = "GT" @@ -286,9 +281,9 @@ def _validate_where_syntax(self): if not self._allow_filtering: #if the query is not on an indexed field if not any([w.column.index for w in equal_ops]): - if not any([w.column._partition_key for w in equal_ops]) and not token_ops: + if not any([w.column.partition_key for w in equal_ops]) and not token_ops: raise QueryException('Filtering on a clustering key without a partition key is not allowed unless allow_filtering() is called on the querset') - if any(not w.column._partition_key for w in token_ops): + if any(not w.column.partition_key for w in token_ops): raise QueryException('The token() function is only supported on the partition key') @@ -314,7 +309,7 @@ def _select_query(self): if self._defer_fields: fields = [f for f in fields if f not in self._defer_fields] elif self._only_fields: - fields = [f for f in fields if f in self._only_fields] + fields = self._only_fields db_fields = [self.model._columns[f].db_field_name for f in fields] qs = ['SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields]))] @@ -449,7 +444,7 @@ def _parse_filter_arg(self, arg): __ :returns: colname, op tuple """ - statement = arg.split('__') + statement = arg.rsplit('__', 1) if len(statement) == 1: return arg, None elif len(statement) == 2: @@ -466,7 +461,10 @@ def filter(self, **kwargs): try: column = self.model._columns[col_name] except KeyError: - raise QueryException("Can't resolve column name: '{}'".format(col_name)) + if col_name == 'pk__token': + column = columns._PartitionKeysToken(self.model) + else: + raise QueryException("Can't resolve column name: '{}'".format(col_name)) #get query operator, or use equals if not supplied operator_class = QueryOperator.get_operator(col_op or 'EQ') @@ -640,6 +638,12 @@ def values_list(self, *fields, **kwargs): clone._flat_values_list = flat return clone + def __eq__(self, q): + return self._where == q._where + + def __ne__(self, q): + return not (self != q) + class DMLQuery(object): """ A query object used for queries performing inserts, updates, or deletes diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 77b1d73775..02d51f5671 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -117,7 +117,29 @@ def test_meta_data_is_not_inherited(self): """ Test that metadata defined in one class, is not inherited by subclasses """ - + + def test_partition_keys(self): + """ + Test compound partition key definition + """ + class ModelWithPartitionKeys(cqlengine.Model): + c1 = cqlengine.Text(primary_key=True) + p1 = cqlengine.Text(partition_key=True) + p2 = cqlengine.Text(partition_key=True) + + cols = ModelWithPartitionKeys._columns + + self.assertTrue(cols['c1'].primary_key) + self.assertFalse(cols['c1'].partition_key) + + self.assertTrue(cols['p1'].primary_key) + self.assertTrue(cols['p1'].partition_key) + self.assertTrue(cols['p2'].primary_key) + self.assertTrue(cols['p2'].partition_key) + + obj = ModelWithPartitionKeys(p1='a', p2='b') + self.assertEquals(obj.pk, ('a', 'b')) + class TestManualTableNaming(BaseCassEngTestCase): class RenamedTest(cqlengine.Model): diff --git a/cqlengine/tests/model/test_clustering_order.py b/cqlengine/tests/model/test_clustering_order.py new file mode 100644 index 0000000000..92d8e067e8 --- /dev/null +++ b/cqlengine/tests/model/test_clustering_order.py @@ -0,0 +1,35 @@ +import random +from cqlengine.tests.base import BaseCassEngTestCase + +from cqlengine.management import create_table +from cqlengine.management import delete_table +from cqlengine.models import Model +from cqlengine import columns + +class TestModel(Model): + id = columns.Integer(primary_key=True) + clustering_key = columns.Integer(primary_key=True, clustering_order='desc') + +class TestClusteringOrder(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestClusteringOrder, cls).setUpClass() + create_table(TestModel) + + @classmethod + def tearDownClass(cls): + super(TestClusteringOrder, cls).tearDownClass() + delete_table(TestModel) + + def test_clustering_order(self): + """ + Tests that models can be saved and retrieved + """ + items = list(range(20)) + random.shuffle(items) + for i in items: + TestModel.create(id=1, clustering_key=i) + + values = list(TestModel.objects.values_list('clustering_key', flat=True)) + self.assertEquals(values, sorted(items, reverse=True)) diff --git a/cqlengine/tests/query/test_queryoperators.py b/cqlengine/tests/query/test_queryoperators.py index 5db4a299af..8f5243fbab 100644 --- a/cqlengine/tests/query/test_queryoperators.py +++ b/cqlengine/tests/query/test_queryoperators.py @@ -17,7 +17,7 @@ def test_maxtimeuuid_function(self): col.set_column_name('time') qry = query.EqualsOperator(col, functions.MaxTimeUUID(now)) - assert qry.cql == '"time" = MaxTimeUUID(:{})'.format(qry.identifier) + assert qry.cql == '"time" = MaxTimeUUID(:{})'.format(qry.value.identifier) def test_mintimeuuid_function(self): """ @@ -28,7 +28,23 @@ def test_mintimeuuid_function(self): col.set_column_name('time') qry = query.EqualsOperator(col, functions.MinTimeUUID(now)) - assert qry.cql == '"time" = MinTimeUUID(:{})'.format(qry.identifier) + assert qry.cql == '"time" = MinTimeUUID(:{})'.format(qry.value.identifier) + def test_token_function(self): + class TestModel(Model): + p1 = columns.Text(partition_key=True) + p2 = columns.Text(partition_key=True) + + func = functions.Token('a', 'b') + + q = TestModel.objects.filter(pk__token__gt=func) + self.assertEquals(q._where[0].cql, 'token("p1", "p2") > token(:{}, :{})'.format(*func.identifier)) + + # Token(tuple()) is also possible for convinience + # it (allows for Token(obj.pk) syntax) + func = functions.Token(('a', 'b')) + + q = TestModel.objects.filter(pk__token__gt=func) + self.assertEquals(q._where[0].cql, 'token("p1", "p2") > token(:{}, :{})'.format(*func.identifier)) diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index a77d750299..79ca5af0d3 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -64,12 +64,12 @@ def test_where_clause_generation(self): Tests the where clause creation """ query1 = TestModel.objects(test_id=5) - ids = [o.identifier for o in query1._where] + ids = [o.query_value.identifier for o in query1._where] where = query1._where_clause() assert where == '"test_id" = :{}'.format(*ids) query2 = query1.filter(expected_result__gte=1) - ids = [o.identifier for o in query2._where] + ids = [o.query_value.identifier for o in query2._where] where = query2._where_clause() assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) @@ -470,5 +470,12 @@ def test_success_case(self): assert q.count() == 8 +class TestValuesList(BaseQuerySetUsage): + def test_values_list(self): + q = TestModel.objects.filter(test_id=0, attempt_id=1) + item = q.values_list('test_id', 'attempt_id', 'description', 'expected_result', 'test_result').first() + assert item == [0, 1, 'try2', 10, 30] + item = q.values_list('expected_result', flat=True).first() + assert item == 10 diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index 0537e3bccb..0779c4333f 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -145,12 +145,16 @@ Column Options If True, this column is created as a primary key field. A model can have multiple primary keys. Defaults to False. - *In CQL, there are 2 types of primary keys: partition keys and clustering keys. As with CQL, the first primary key is the partition key, and all others are clustering keys.* + *In CQL, there are 2 types of primary keys: partition keys and clustering keys. As with CQL, the first primary key is the partition key, and all others are clustering keys, unless partition keys are specified manually using* :attr:`BaseColumn.partition_key` + + .. attribute:: BaseColumn.partition_key + + If True, this column is created as partition primary key. There may be many partition keys defined, forming *composite partition key* .. attribute:: BaseColumn.index If True, an index will be created for this column. Defaults to False. - + *Note: Indexes can only be created on models with one primary key* .. attribute:: BaseColumn.db_field @@ -165,3 +169,6 @@ Column Options If True, this model cannot be saved without a value defined for this column. Defaults to True. Primary key fields cannot have their required fields set to False. + .. attribute:: BaseColumn.clustering_order + + Defines CLUSTERING ORDER for this column (valid choices are "asc" (default) or "desc"). It may be specified only for clustering primary keys - more: http://www.datastax.com/docs/1.2/cql_cli/cql/CREATE_TABLE#using-clustering-order diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 0dd2c19cf8..52eb90613d 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -66,7 +66,10 @@ Column Options :attr:`~cqlengine.columns.BaseColumn.primary_key` If True, this column is created as a primary key field. A model can have multiple primary keys. Defaults to False. - *In CQL, there are 2 types of primary keys: partition keys and clustering keys. As with CQL, the first primary key is the partition key, and all others are clustering keys.* + *In CQL, there are 2 types of primary keys: partition keys and clustering keys. As with CQL, the first primary key is the partition key, and all others are clustering keys, unless partition keys are specified manually using* :attr:`~cqlengine.columns.BaseColumn.partition_key` + + :attr:`~cqlengine.columns.BaseColumn.partition_key` + If True, this column is created as partition primary key. There may be many partition keys defined, forming *composite partition key* :attr:`~cqlengine.columns.BaseColumn.index` If True, an index will be created for this column. Defaults to False. diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index 6733080cfe..8490ce3daf 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -178,6 +178,26 @@ TimeUUID Functions DataStream.filter(time__gt=cqlengine.MinTimeUUID(min_time), time__lt=cqlengine.MaxTimeUUID(max_time)) +Token Function +============== + + Token functon may be used only on special, virtual column pk__token, representing token of partition key (it also works for composite partition keys). + Cassandra orders returned items by value of partition key token, so using cqlengine.Token we can easy paginate through all table rows. + + *Example* + + .. code-block:: python + + class Items(Model): + id = cqlengine.Text(primary_key=True) + data = cqlengine.Bytes() + + query = Items.objects.all().limit(10) + + first_page = list(query); + last = first_page[-1] + next_page = list(query.filter(pk__token__gt=cqlengine.Token(last.pk))) + QuerySets are imutable ====================== @@ -213,6 +233,13 @@ Ordering QuerySets *For instance, given our Automobile model, year is the only column we can order on.* +Values Lists +============ + + There is a special QuerySet's method ``.values_list()`` - when called, QuerySet returns lists of values instead of model instances. It may significantly speedup things with lower memory footprint for large responses. + Each tuple contains the value from the respective field passed into the ``values_list()`` call — so the first item is the first field, etc. For example: + + Batch Queries =============== From 97d7e647a304dbfe4ee10539949e7aa12e9e7982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20Kry=C5=84ski?= Date: Mon, 27 May 2013 02:14:28 +0200 Subject: [PATCH 0179/4200] Fix for #41 --- cqlengine/models.py | 7 ++++++- cqlengine/tests/query/test_queryset.py | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 61d001578e..3753e7f78c 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -26,6 +26,10 @@ def __get__(self, instance, owner): else: return self.instmethod.__get__(instance, owner) +class QuerySetDescriptor(object): + def __get__(self, obj, model): + return QuerySet(model) + class BaseModel(object): """ The base model class, don't inherit from this, inherit from Model, defined below @@ -34,6 +38,8 @@ class BaseModel(object): class DoesNotExist(QueryException): pass class MultipleObjectsReturned(QueryException): pass + objects = QuerySetDescriptor() + #table names will be generated automatically from it's model and package name #however, you can also define them manually here table_name = None @@ -266,7 +272,6 @@ def _transform_column(col_name, col_obj): #create the class and add a QuerySet to it klass = super(ModelMetaClass, cls).__new__(cls, name, bases, attrs) - klass.objects = QuerySet(klass) return klass diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 5ce12b5ed5..1c3eb95b20 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -479,3 +479,11 @@ def test_values_list(self): item = q.values_list('expected_result', flat=True).first() assert item == 10 +class TestObjectsProperty(BaseQuerySetUsage): + + def test_objects_property_returns_fresh_queryset(self): + assert TestModel.objects._result_cache is None + len(TestModel.objects) # evaluate queryset + assert TestModel.objects._result_cache is None + + From f2a401d0541d3eebd4259827404d9827740e9adf Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Mon, 27 May 2013 10:39:26 +0300 Subject: [PATCH 0180/4200] Allow `datetime.date` values with `columns.DateTime`. Adds support for setting a `datetime.date` value to a `DateTime` column. The value will be transparently converted to a `datetime.datetime` with the time components set to zero. --- cqlengine/columns.py | 11 ++++++++++- cqlengine/tests/columns/test_validation.py | 6 ++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 3c9bad45ba..07a3842718 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -183,6 +183,7 @@ def validate(self, value): raise ValidationError('{} is shorter than {} characters'.format(self.column_name, self.min_length)) return value + class Integer(Column): db_type = 'int' @@ -200,20 +201,27 @@ def to_python(self, value): def to_database(self, value): return self.validate(value) + class DateTime(Column): db_type = 'timestamp' + def __init__(self, **kwargs): super(DateTime, self).__init__(**kwargs) def to_python(self, value): if isinstance(value, datetime): return value + elif isinstance(value, date): + return datetime(*(value.timetuple()[:6])) return datetime.utcfromtimestamp(value) def to_database(self, value): value = super(DateTime, self).to_database(value) if not isinstance(value, datetime): - raise ValidationError("'{}' is not a datetime object".format(value)) + if isinstance(value, date): + value = datetime(value.year, value.month, value.day) + else: + raise ValidationError("'{}' is not a datetime object".format(value)) epoch = datetime(1970, 1, 1, tzinfo=value.tzinfo) offset = 0 if epoch.tzinfo: @@ -326,6 +334,7 @@ def __init__(self, value): def __str__(self): raise NotImplementedError + class BaseContainerColumn(Column): """ Base Container type diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index e323c4653d..24c1739706 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -55,6 +55,12 @@ def dst(self, date_time): dt2 = self.DatetimeTest.objects(test_id=0).first() assert dt2.created_at.timetuple()[:6] == (now + timedelta(hours=1)).timetuple()[:6] + def test_datetime_date_support(self): + today = date.today() + self.DatetimeTest.objects.create(test_id=0, created_at=today) + dt2 = self.DatetimeTest.objects(test_id=0).first() + assert dt2.created_at.isoformat() == datetime(today.year, today.month, today.day).isoformat() + class TestDate(BaseCassEngTestCase): class DateTest(Model): From b1ce5f2f380dad3d45be52510f6d4075aac49e7d Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Mon, 27 May 2013 10:54:54 +0300 Subject: [PATCH 0181/4200] Log the CQL statements at DEBUG level. This logs the CQL statements emitted by cqlengine in the `cqlengine.cql` logger at DEBUG level. For debugging purposes you can enable that logger to help work out problems with your queries. The logger is not enabled by default by `cqlengine`. --- cqlengine/columns.py | 3 +++ cqlengine/connection.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 3c9bad45ba..7247f75ca5 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -326,6 +326,9 @@ def __init__(self, value): def __str__(self): raise NotImplementedError + def __repr__(self): + return self.__str__() + class BaseContainerColumn(Column): """ Base Container type diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 2a5ef989eb..c0c2b1710e 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -7,11 +7,13 @@ import random import cql +import logging from cqlengine.exceptions import CQLEngineException from thrift.transport.TTransport import TTransportException +LOG = logging.getLogger('cqlengine.cql') class CQLConnectionError(CQLEngineException): pass @@ -167,6 +169,7 @@ def execute(self, query, params={}): for i in range(len(_hosts)): try: + LOG.debug('{} {}'.format(query, repr(params))) self.cur = self.con.cursor() self.cur.execute(query, params) return self.cur From 26d68711469520791dd8a4659b0e245754490dcf Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 30 May 2013 14:28:47 -0700 Subject: [PATCH 0182/4200] ignoring noseids --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 16029f08ec..6d96b7f030 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ html/ #Mr Developer .mr.developer.cfg +.noseids From 3bb7314c60521a93bfbca14e532e257e6b6d1089 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 31 May 2013 09:34:25 -0700 Subject: [PATCH 0183/4200] adding some docstrings to the function classes --- cqlengine/functions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cqlengine/functions.py b/cqlengine/functions.py index 947841808a..5a8bfb548b 100644 --- a/cqlengine/functions.py +++ b/cqlengine/functions.py @@ -36,6 +36,11 @@ class BaseQueryFunction(QueryValue): """ class MinTimeUUID(BaseQueryFunction): + """ + return a fake timeuuid corresponding to the smallest possible timeuuid for the given timestamp + + http://cassandra.apache.org/doc/cql3/CQL.html#timeuuidFun + """ _cql_string = 'MinTimeUUID(:{})' @@ -53,6 +58,11 @@ def get_value(self): return long((self.value - epoch).total_seconds() * 1000) class MaxTimeUUID(BaseQueryFunction): + """ + return a fake timeuuid corresponding to the largest possible timeuuid for the given timestamp + + http://cassandra.apache.org/doc/cql3/CQL.html#timeuuidFun + """ _cql_string = 'MaxTimeUUID(:{})' @@ -70,6 +80,11 @@ def get_value(self): return long((self.value - epoch).total_seconds() * 1000) class Token(BaseQueryFunction): + """ + compute the token for a given partition key + + http://cassandra.apache.org/doc/cql3/CQL.html#tokenFun + """ def __init__(self, *values): if len(values) == 1 and isinstance(values[0], (list, tuple)): From 4a5a11fb5d1ff28a106dc2288d159643cf0d1e2a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 09:51:39 -0700 Subject: [PATCH 0184/4200] removed unused column class --- cqlengine/columns.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 284702dbce..91240e0ecd 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -661,13 +661,6 @@ def get_delete_statement(self, val, prev, ctx): return del_statements -class _PartitionKeys(Column): - class value_manager(BaseValueManager): - pass - - def __init__(self, model): - self.model = model - class _PartitionKeysToken(Column): """ virtual column representing token of partition columns. From 7ef3cde72722a81d5d9c5362b6b3273d293325e0 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 09:52:04 -0700 Subject: [PATCH 0185/4200] adding some docstrings and comments --- cqlengine/models.py | 4 +++- cqlengine/query.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 3753e7f78c..842a05edd1 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -220,6 +220,8 @@ def _transform_column(col_name, col_obj): #TODO: check that the defined columns don't conflict with any of the Model API's existing attributes/methods #transform column definitions for k,v in column_definitions: + # this will mark the first primary key column as a partition + # key, if one hasn't been set already if not has_partition_keys and v.primary_key: v.partition_key = True has_partition_keys = True @@ -234,7 +236,7 @@ def _transform_column(col_name, col_obj): pk_name = partition_keys.keys()[0] attrs['pk'] = attrs[pk_name] else: - # composite partition key case + # composite partition key case, get/set a tuple of values _get = lambda self: tuple(self._values[c].getval() for c in partition_keys.keys()) _set = lambda self, val: tuple(self._values[c].setval(v) for (c, v) in zip(partition_keys.keys(), val)) attrs['pk'] = property(_get, _set) diff --git a/cqlengine/query.py b/cqlengine/query.py index 160eab9b39..8aee97e747 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -405,6 +405,9 @@ def __getitem__(self, s): return self._result_cache[s] def _create_result_constructor(self, names): + """ + Returns a function that will be used to instantiate query results + """ model = self.model db_map = model._db_map if not self._values_list: @@ -632,6 +635,7 @@ def delete(self, columns=[]): con.execute(qs, self._where_values()) def values_list(self, *fields, **kwargs): + """ Instructs the query set to return tuples, not model instance """ flat = kwargs.pop('flat', False) if kwargs: raise TypeError('Unexpected keyword arguments to values_list: %s' From e75dfe5ef746323b99050fe30d19a267badfbd4a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 09:52:40 -0700 Subject: [PATCH 0186/4200] adding some todos --- cqlengine/management.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cqlengine/management.py b/cqlengine/management.py index f5f6a64923..25717aa4c6 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -14,6 +14,7 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, :param **replication_values: 1.2 only, additional values to ad to the replication data map """ with connection_manager() as con: + #TODO: check system tables instead of using cql thrifteries if not any([name == k.name for k in con.con.client.describe_keyspaces()]): # if name not in [k.name for k in con.con.client.describe_keyspaces()]: try: @@ -55,6 +56,7 @@ def create_table(model, create_missing_keyspace=True): with connection_manager() as con: #check for an existing column family + #TODO: check system tables instead of using cql thrifteries ks_info = con.con.client.describe_keyspace(model._get_keyspace()) if not any([raw_cf_name == cf.name for cf in ks_info.cf_defs]): qs = ['CREATE TABLE {}'.format(cf_name)] From 532f3275191506dab25ed4d5c8f921632c09c909 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 09:55:45 -0700 Subject: [PATCH 0187/4200] adding cassandra docs url to token function docs --- docs/topics/queryset.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index 8490ce3daf..48e26a7cba 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -184,6 +184,8 @@ Token Function Token functon may be used only on special, virtual column pk__token, representing token of partition key (it also works for composite partition keys). Cassandra orders returned items by value of partition key token, so using cqlengine.Token we can easy paginate through all table rows. + See http://cassandra.apache.org/doc/cql3/CQL.html#tokenFun + *Example* .. code-block:: python From 55f16442e36ffea5e35a80a5bb1b128c71f6226f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 10:00:15 -0700 Subject: [PATCH 0188/4200] removing commented out method --- cqlengine/models.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 842a05edd1..d0ea201dac 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -108,11 +108,6 @@ def column_family_name(cls, include_keyspace=True): if not include_keyspace: return cf_name return '{}.{}'.format(cls._get_keyspace(), cf_name) - #@property - #def pk(self): - # """ Returns the object's primary key """ - # return getattr(self, self._pk_name) - def validate(self): """ Cleans and validates the field values """ for name, col in self._columns.items(): From 24eba1bbc0aafc9e503747e688cacfdb88ad2fb8 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 10:11:38 -0700 Subject: [PATCH 0189/4200] adding contributor guidelines --- CONTRIBUTING.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..62f6d786bb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,12 @@ +### Contributing code to cqlengine + +Before submitting a pull request, please make sure that it follows these guidelines: + +* Limit yourself to one feature or bug fix per pull request. +* Include unittests that thoroughly test the feature/bug fix +* Write clear, descriptive commit messages. +* Many granular commits are preferred over large monolithic commits +* If you're adding or modifying features, please update the documentation + +If you're working on a big ticket item, please check in on [cqlengine-users](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-users). +We'd hate to have to steer you in a different direction after you've already put in a lot of hard work. From dc14e6927505bc949a00c60b7b543330660a5d6a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 10:23:37 -0700 Subject: [PATCH 0190/4200] adding contributor guidelines link to the readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b54711f116..b0037b9465 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,7 @@ example8 >>> print instance.description example5 ``` + +## Contributing + +If you'd like to contribute to cqlengine, please read the [contributor guidelines](https://github.com/bdeggleston/cqlengine/blob/master/CONTRIBUTING.md) From 286bd0e66af0c1c21a961a06fc4e3177c4ecf57c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 11:33:37 -0700 Subject: [PATCH 0191/4200] adding custom value quoter to boolean column for compatibility with Cassandra 1.2.5+ --- cqlengine/columns.py | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 91240e0ecd..8559795474 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -52,6 +52,20 @@ def get_property(self): else: return property(_get, _set) +class ValueQuoter(object): + """ + contains a single value, which will quote itself for CQL insertion statements + """ + def __init__(self, value): + self.value = value + + def __str__(self): + raise NotImplementedError + + def __repr__(self): + return self.__str__() + + class Column(object): #the cassandra type this column maps to @@ -293,11 +307,16 @@ def __init__(self, **kwargs): class Boolean(Column): db_type = 'boolean' + class Quoter(ValueQuoter): + """ Cassandra 1.2.5 is stricter about boolean values """ + def __str__(self): + return 'true' if self.value else 'false' + def to_python(self, value): return bool(value) def to_database(self, value): - return bool(value) + return self.Quoter(bool(value)) class Float(Column): db_type = 'double' @@ -329,19 +348,6 @@ def __init__(self, **kwargs): super(Counter, self).__init__(**kwargs) raise NotImplementedError -class ContainerQuoter(object): - """ - contains a single value, which will quote itself for CQL insertion statements - """ - def __init__(self, value): - self.value = value - - def __str__(self): - raise NotImplementedError - - def __repr__(self): - return self.__str__() - class BaseContainerColumn(Column): """ Base Container type @@ -383,7 +389,7 @@ class Set(BaseContainerColumn): """ db_type = 'set<{}>' - class Quoter(ContainerQuoter): + class Quoter(ValueQuoter): def __str__(self): cq = cql_quote @@ -466,7 +472,7 @@ class List(BaseContainerColumn): """ db_type = 'list<{}>' - class Quoter(ContainerQuoter): + class Quoter(ValueQuoter): def __str__(self): cq = cql_quote @@ -563,7 +569,7 @@ class Map(BaseContainerColumn): db_type = 'map<{}, {}>' - class Quoter(ContainerQuoter): + class Quoter(ValueQuoter): def __str__(self): cq = cql_quote From 24b207c18a785270b5ac90e93b7e774d3d80cc56 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 11:58:22 -0700 Subject: [PATCH 0192/4200] adding None handling to DateTime to_database --- cqlengine/columns.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 8559795474..69ad188a3c 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -236,6 +236,7 @@ def to_python(self, value): def to_database(self, value): value = super(DateTime, self).to_database(value) + if value is None: return if not isinstance(value, datetime): if isinstance(value, date): value = datetime(value.year, value.month, value.day) From 40bed98b90d58b980f4086e3901cdab5a4be7b05 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 13:24:53 -0700 Subject: [PATCH 0193/4200] making DoesNotExist & MultipleObjectsReturned distinct classes for each model, and inherit from parent model if any --- cqlengine/models.py | 25 ++++++++++++--- cqlengine/query.py | 4 +++ .../tests/model/test_class_construction.py | 31 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index d0ea201dac..bd4e65fabe 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -3,8 +3,9 @@ from cqlengine import columns from cqlengine.exceptions import ModelException -from cqlengine.functions import BaseQueryFunction -from cqlengine.query import QuerySet, QueryException, DMLQuery +from cqlengine.query import QuerySet, DMLQuery +from cqlengine.query import DoesNotExist as _DoesNotExist +from cqlengine.query import MultipleObjectsReturned as _MultipleObjectsReturned class ModelDefinitionException(ModelException): pass @@ -35,8 +36,8 @@ class BaseModel(object): The base model class, don't inherit from this, inherit from Model, defined below """ - class DoesNotExist(QueryException): pass - class MultipleObjectsReturned(QueryException): pass + class DoesNotExist(_DoesNotExist): pass + class MultipleObjectsReturned(_MultipleObjectsReturned): pass objects = QuerySetDescriptor() @@ -182,6 +183,7 @@ def __new__(cls, name, bases, attrs): for k,v in getattr(base, '_defined_columns', {}).items(): inherited_columns.setdefault(k,v) + def _transform_column(col_name, col_obj): column_dict[col_name] = col_obj if col_obj.primary_key: @@ -267,6 +269,21 @@ def _transform_column(col_name, col_obj): attrs['_partition_keys'] = partition_keys attrs['_clustering_keys'] = clustering_keys + #setup class exceptions + DoesNotExistBase = None + for base in bases: + DoesNotExistBase = getattr(base, 'DoesNotExist', None) + if DoesNotExistBase is not None: break + DoesNotExistBase = DoesNotExistBase or attrs.pop('DoesNotExist', BaseModel.DoesNotExist) + attrs['DoesNotExist'] = type('DoesNotExist', (DoesNotExistBase,), {}) + + MultipleObjectsReturnedBase = None + for base in bases: + MultipleObjectsReturnedBase = getattr(base, 'MultipleObjectsReturned', None) + if MultipleObjectsReturnedBase is not None: break + MultipleObjectsReturnedBase = DoesNotExistBase or attrs.pop('MultipleObjectsReturned', BaseModel.MultipleObjectsReturned) + attrs['MultipleObjectsReturned'] = type('MultipleObjectsReturned', (MultipleObjectsReturnedBase,), {}) + #create the class and add a QuerySet to it klass = super(ModelMetaClass, cls).__new__(cls, name, bases, attrs) return klass diff --git a/cqlengine/query.py b/cqlengine/query.py index 8aee97e747..3ab71129f0 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -14,6 +14,10 @@ #http://www.datastax.com/docs/1.1/references/cql/index class QueryException(CQLEngineException): pass +class DoesNotExist(QueryException): pass +class MultipleObjectsReturned(QueryException): pass + + class QueryOperatorException(QueryException): pass class QueryOperator(object): diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 36f986508f..c67fcbee4d 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -151,6 +151,37 @@ class DelModel(Model): with self.assertRaises(AttributeError): del model.key + def test_does_not_exist_exceptions_are_not_shared_between_model(self): + """ Tests that DoesNotExist exceptions are not the same exception between models """ + + class Model1(Model): + pass + class Model2(Model): + pass + + try: + raise Model1.DoesNotExist + except Model2.DoesNotExist: + assert False, "Model1 exception should not be caught by Model2" + except Model1.DoesNotExist: + #expected + pass + + def test_does_not_exist_inherits_from_superclass(self): + """ Tests that a DoesNotExist exception can be caught by it's parent class DoesNotExist """ + class Model1(Model): + pass + class Model2(Model1): + pass + + try: + raise Model2.DoesNotExist + except Model1.DoesNotExist: + #expected + pass + except Exception: + assert False, "Model2 exception should not be caught by Model1" + class TestManualTableNaming(BaseCassEngTestCase): class RenamedTest(cqlengine.Model): From 1301486d0e223d9feba7453b49fc44ab3a8707c3 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 13:28:26 -0700 Subject: [PATCH 0194/4200] updating changelog --- changelog | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 3edb19f0fa..05714651e4 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,15 @@ CHANGELOG -0.2.1 (in progress) +0.3 +* added support for Token function (thanks @mrk-its) +* added support for compound partition key (thanks @mrk-its)s +* added support for defining clustering key ordering (thanks @mrk-its) +* added values_list to Query class, bypassing object creation if desired (thanks @mrk-its) +* fixed bug with Model.objects caching values (thanks @mrk-its) +* fixed Cassandra 1.2.5 compatibility bug +* updated model exception inheritance + +0.2.1 * adding support for datetimes with tzinfo (thanks @gdoermann) * fixing bug in saving map updates (thanks @pandu-rao) From f2b9c14e2906fc2110fa19ae1ce852640c5216ad Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 13:37:14 -0700 Subject: [PATCH 0195/4200] version number bump --- cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index ed5a820213..c9dc969515 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.2' +__version__ = '0.3' diff --git a/docs/conf.py b/docs/conf.py index 96c7db6ed6..9b7e050a50 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.2' +version = '0.3' # The full version, including alpha/beta/rc tags. -release = '0.2' +release = '0.3' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 0f98baecbe..24545a9b7e 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.2' +version = '0.3' long_desc = """ cqlengine is a Cassandra CQL Object Mapper for Python in the style of the Django orm and mongoengine From 5eef9bd18d94ab0c3d898653799707268b4148e7 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 15:46:53 -0700 Subject: [PATCH 0196/4200] adding tests around io for all column types --- cqlengine/tests/columns/test_value_io.py | 136 +++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 cqlengine/tests/columns/test_value_io.py diff --git a/cqlengine/tests/columns/test_value_io.py b/cqlengine/tests/columns/test_value_io.py new file mode 100644 index 0000000000..273ff0d56b --- /dev/null +++ b/cqlengine/tests/columns/test_value_io.py @@ -0,0 +1,136 @@ +from datetime import datetime, timedelta +from decimal import Decimal +from uuid import uuid1, uuid4, UUID +from unittest import SkipTest +from cqlengine.tests.base import BaseCassEngTestCase + +from cqlengine.management import create_table +from cqlengine.management import delete_table +from cqlengine.models import Model +from cqlengine import columns + +class BaseColumnIOTest(BaseCassEngTestCase): + + TEST_MODEL = None + TEST_COLUMN = None + + @property + def PKEY_VAL(self): + raise NotImplementedError + + @property + def DATA_VAL(self): + raise NotImplementedError + + @classmethod + def setUpClass(cls): + super(BaseColumnIOTest, cls).setUpClass() + if not cls.TEST_COLUMN: return + class IOTestModel(Model): + table_name = cls.TEST_COLUMN.db_type + "_io_test_model_{}".format(uuid4().hex[:8]) + pkey = cls.TEST_COLUMN(primary_key=True) + data = cls.TEST_COLUMN() + + cls.TEST_MODEL = IOTestModel + create_table(cls.TEST_MODEL) + + #tupleify + if not isinstance(cls.PKEY_VAL, tuple): + cls.PKEY_VAL = cls.PKEY_VAL, + if not isinstance(cls.DATA_VAL, tuple): + cls.DATA_VAL = cls.DATA_VAL, + + @classmethod + def tearDownClass(cls): + super(BaseColumnIOTest, cls).tearDownClass() + if not cls.TEST_COLUMN: return + delete_table(cls.TEST_MODEL) + + def comparator_converter(self, val): + """ If you want to convert the original value used to compare the model vales """ + return val + + def test_column_io(self): + """ Tests the given models class creates and retrieves values as expected """ + if not self.TEST_COLUMN: return + for pkey, data in zip(self.PKEY_VAL, self.DATA_VAL): + #create + m1 = self.TEST_MODEL.create(pkey=pkey, data=data) + + #get + m2 = self.TEST_MODEL.get(pkey=pkey) + assert m1.pkey == m2.pkey == self.comparator_converter(pkey), self.TEST_COLUMN + assert m1.data == m2.data == self.comparator_converter(data), self.TEST_COLUMN + + #delete + self.TEST_MODEL.filter(pkey=pkey).delete() + +class TestTextIO(BaseColumnIOTest): + + TEST_COLUMN = columns.Text + PKEY_VAL = 'bacon' + DATA_VAL = 'monkey' + +class TestInteger(BaseColumnIOTest): + + TEST_COLUMN = columns.Integer + PKEY_VAL = 5 + DATA_VAL = 6 + +class TestDateTime(BaseColumnIOTest): + + TEST_COLUMN = columns.DateTime + now = datetime(*datetime.now().timetuple()[:6]) + PKEY_VAL = now + DATA_VAL = now + timedelta(days=1) + +class TestDate(BaseColumnIOTest): + + TEST_COLUMN = columns.Date + now = datetime.now().date() + PKEY_VAL = now + DATA_VAL = now + timedelta(days=1) + +class TestUUID(BaseColumnIOTest): + + TEST_COLUMN = columns.UUID + + PKEY_VAL = str(uuid4()), uuid4() + DATA_VAL = str(uuid4()), uuid4() + + def comparator_converter(self, val): + return val if isinstance(val, UUID) else UUID(val) + +class TestTimeUUID(BaseColumnIOTest): + + TEST_COLUMN = columns.TimeUUID + + PKEY_VAL = str(uuid1()), uuid1() + DATA_VAL = str(uuid1()), uuid1() + + def comparator_converter(self, val): + return val if isinstance(val, UUID) else UUID(val) + +class TestBooleanIO(BaseColumnIOTest): + + TEST_COLUMN = columns.Boolean + + PKEY_VAL = True + DATA_VAL = False + +class TestFloatIO(BaseColumnIOTest): + + TEST_COLUMN = columns.Float + + PKEY_VAL = 3.14 + DATA_VAL = -1982.11 + +class TestDecimalIO(BaseColumnIOTest): + + TEST_COLUMN = columns.Decimal + + PKEY_VAL = Decimal('1.35'), 5, '2.4' + DATA_VAL = Decimal('0.005'), 3.5, '8' + + def comparator_converter(self, val): + return Decimal(val) From 229be07bab4c74e13849f623984fd3fe602414f5 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 15:47:22 -0700 Subject: [PATCH 0197/4200] adding some conversion logic to Decimal and UUID columns --- cqlengine/columns.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 69ad188a3c..3f0806345d 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -294,6 +294,12 @@ def validate(self, value): raise ValidationError("{} is not a valid uuid".format(value)) return _UUID(val) + def to_python(self, value): + return self.validate(value) + + def to_database(self, value): + return self.validate(value) + class TimeUUID(UUID): """ UUID containing timestamp @@ -343,6 +349,22 @@ def to_database(self, value): class Decimal(Column): db_type = 'decimal' + def validate(self, value): + from decimal import Decimal as _Decimal + from decimal import InvalidOperation + val = super(Decimal, self).validate(value) + if val is None: return + try: + return _Decimal(val) + except InvalidOperation: + raise ValidationError("'{}' can't be coerced to decimal".format(val)) + + def to_python(self, value): + return self.validate(value) + + def to_database(self, value): + return self.validate(value) + class Counter(Column): #TODO: counter field def __init__(self, **kwargs): From 7a4fe9ae78090c2591f0980cf8614833fb924111 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 15:48:17 -0700 Subject: [PATCH 0198/4200] fixing the get_dict method on the time uuid functions --- cqlengine/functions.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cqlengine/functions.py b/cqlengine/functions.py index 5a8bfb548b..eee1fcab5e 100644 --- a/cqlengine/functions.py +++ b/cqlengine/functions.py @@ -57,6 +57,9 @@ def get_value(self): epoch = datetime(1970, 1, 1) return long((self.value - epoch).total_seconds() * 1000) + def get_dict(self, column): + return {self.identifier: self.get_value()} + class MaxTimeUUID(BaseQueryFunction): """ return a fake timeuuid corresponding to the largest possible timeuuid for the given timestamp @@ -79,6 +82,9 @@ def get_value(self): epoch = datetime(1970, 1, 1) return long((self.value - epoch).total_seconds() * 1000) + def get_dict(self, column): + return {self.identifier: self.get_value()} + class Token(BaseQueryFunction): """ compute the token for a given partition key From 45695a9f691357332a60c5ab4749420cce9797f0 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 15:49:15 -0700 Subject: [PATCH 0199/4200] version number bump --- cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index c9dc969515..25d51303d5 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.3' +__version__ = '0.3.1' diff --git a/docs/conf.py b/docs/conf.py index 9b7e050a50..36e5d084b0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.3' +version = '0.3.1' # The full version, including alpha/beta/rc tags. -release = '0.3' +release = '0.3.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 24545a9b7e..cec6865dca 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.3' +version = '0.3.1' long_desc = """ cqlengine is a Cassandra CQL Object Mapper for Python in the style of the Django orm and mongoengine From 8597bfac6c41f9f83b3c45b54a1536f6a7457ff1 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 3 Jun 2013 13:39:40 -0700 Subject: [PATCH 0200/4200] adding test files and starting to rework the pool --- cqlengine/connection.py | 2 ++ cqlengine/tests/connections/__init__.py | 0 cqlengine/tests/connections/test_connection_pool.py | 0 3 files changed, 2 insertions(+) create mode 100644 cqlengine/tests/connections/__init__.py create mode 100644 cqlengine/tests/connections/test_connection_pool.py diff --git a/cqlengine/connection.py b/cqlengine/connection.py index c0c2b1710e..1fb7b53bd1 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -122,6 +122,8 @@ def put(cls, conn): def _create_connection(cls): """ Creates a new connection for the connection pool. + + should only return a valid connection that it's actually connected to """ global _hosts global _username diff --git a/cqlengine/tests/connections/__init__.py b/cqlengine/tests/connections/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/connections/test_connection_pool.py b/cqlengine/tests/connections/test_connection_pool.py new file mode 100644 index 0000000000..e69de29bb2 From 55331ad07b39bf6f2f164cb12a20fa9379517065 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 3 Jun 2013 13:45:07 -0700 Subject: [PATCH 0201/4200] making ConnectionPool not use a global state --- cqlengine/connection.py | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 1fb7b53bd1..e4e2b42dee 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -69,38 +69,35 @@ class ConnectionPool(object): # Connection pool queue _queue = None - @classmethod - def clear(cls): + def __init__(self, hosts): + self._hosts = hosts + self._queue = Queue.Queue(maxsize=_max_connections) + + def clear(self): """ Force the connection pool to be cleared. Will close all internal connections. """ try: - while not cls._queue.empty(): - cls._queue.get().close() + while not self._queue.empty(): + self._queue.get().close() except: pass - @classmethod - def get(cls): + def get(self): """ Returns a usable database connection. Uses the internal queue to determine whether to return an existing connection or to create a new one. """ try: - if cls._queue.empty(): - return cls._create_connection() - return cls._queue.get() + if self._queue.empty(): + return self._create_connection() + return self._queue.get() except CQLConnectionError as cqle: raise cqle - except: - if not cls._queue: - cls._queue = Queue.Queue(maxsize=_max_connections) - return cls._create_connection() - @classmethod - def put(cls, conn): + def put(self, conn): """ Returns a connection to the queue freeing it up for other queries to use. @@ -109,17 +106,16 @@ def put(cls, conn): :type conn: connection """ try: - if cls._queue.full(): + if self._queue.full(): conn.close() else: - cls._queue.put(conn) + self._queue.put(conn) except: - if not cls._queue: - cls._queue = Queue.Queue(maxsize=_max_connections) - cls._queue.put(conn) + if not self._queue: + self._queue = + self._queue.put(conn) - @classmethod - def _create_connection(cls): + def _create_connection(self): """ Creates a new connection for the connection pool. From d742345796812722d94c71bab4bc2a423dc6cd62 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 3 Jun 2013 13:48:58 -0700 Subject: [PATCH 0202/4200] setup now creates a global connection pool --- cqlengine/connection.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index e4e2b42dee..e18c1b089b 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -18,25 +18,18 @@ class CQLConnectionError(CQLEngineException): pass Host = namedtuple('Host', ['name', 'port']) -_hosts = [] -_host_idx = 0 -_conn= None -_username = None -_password = None + _max_connections = 10 +_connection_pool = None -def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None, lazy=False): +def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None, lazy=True): """ Records the hosts and connects to one of them :param hosts: list of hosts, strings in the :, or just """ - global _hosts - global _username - global _password global _max_connections - _username = username - _password = password + global _connection_pool _max_connections = max_connections if default_keyspace: @@ -56,11 +49,11 @@ def setup(hosts, username=None, password=None, max_connections=10, default_keysp if not _hosts: raise CQLConnectionError("At least one host required") - random.shuffle(_hosts) + _connection_pool = ConnectionPool(_hosts) if not lazy: - con = ConnectionPool.get() - ConnectionPool.put(con) + con = _connection_pool.get() + _connection_pool.put(con) class ConnectionPool(object): @@ -69,8 +62,11 @@ class ConnectionPool(object): # Connection pool queue _queue = None - def __init__(self, hosts): + def __init__(self, hosts, username, password): self._hosts = hosts + self._username = username + self._password = password + self._queue = Queue.Queue(maxsize=_max_connections) def clear(self): From e6a7934fe30ba89187b30f3c696048105d905d02 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 3 Jun 2013 13:55:30 -0700 Subject: [PATCH 0203/4200] fixing references to removed vars --- cqlengine/connection.py | 25 +++++++------------------ cqlengine/tests/base.py | 6 +++--- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index e18c1b089b..a1a18df09b 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -22,7 +22,7 @@ class CQLConnectionError(CQLEngineException): pass _max_connections = 10 _connection_pool = None -def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None, lazy=True): +def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None): """ Records the hosts and connects to one of them @@ -36,6 +36,7 @@ def setup(hosts, username=None, password=None, max_connections=10, default_keysp from cqlengine import models models.DEFAULT_KEYSPACE = default_keyspace + _hosts = [] for host in hosts: host = host.strip() host = host.split(':') @@ -51,10 +52,6 @@ def setup(hosts, username=None, password=None, max_connections=10, default_keysp _connection_pool = ConnectionPool(_hosts) - if not lazy: - con = _connection_pool.get() - _connection_pool.put(con) - class ConnectionPool(object): """Handles pooling of database connections.""" @@ -101,14 +98,10 @@ def put(self, conn): :param conn: The connection to be released :type conn: connection """ - try: - if self._queue.full(): - conn.close() - else: - self._queue.put(conn) - except: - if not self._queue: - self._queue = + + if self._queue.full(): + conn.close() + else: self._queue.put(conn) def _create_connection(self): @@ -117,11 +110,7 @@ def _create_connection(self): should only return a valid connection that it's actually connected to """ - global _hosts - global _username - global _password - - if not _hosts: + if not self._hosts: raise CQLConnectionError("At least one host required") host = _hosts[_host_idx] diff --git a/cqlengine/tests/base.py b/cqlengine/tests/base.py index 64e6a3c2a9..a7090c627d 100644 --- a/cqlengine/tests/base.py +++ b/cqlengine/tests/base.py @@ -6,13 +6,13 @@ class BaseCassEngTestCase(TestCase): @classmethod def setUpClass(cls): super(BaseCassEngTestCase, cls).setUpClass() - if not connection._hosts: + if not connection._connection_pool: connection.setup(['localhost:9160'], default_keyspace='cqlengine_test') def assertHasAttr(self, obj, attr): - self.assertTrue(hasattr(obj, attr), + self.assertTrue(hasattr(obj, attr), "{} doesn't have attribute: {}".format(obj, attr)) def assertNotHasAttr(self, obj, attr): - self.assertFalse(hasattr(obj, attr), + self.assertFalse(hasattr(obj, attr), "{} shouldn't have the attribute: {}".format(obj, attr)) From 37259b963ee155a17d86ae597a2814e92f818991 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 3 Jun 2013 14:48:44 -0700 Subject: [PATCH 0204/4200] Getting connection pooling working with mocking library Verified throwing exception when no servers are available, but correctly recovering and hitting the next server when one is. fixing minor pooling tests ensure we get the right exception back when no servers are available working on tests for retry connections working despite failure Removed old connection_manager and replaced with a simple context manager that allows for easy access to clients within the main pool --- .gitignore | 3 + cqlengine/connection.py | 96 +++++------- cqlengine/management.py | 145 +++++++++--------- cqlengine/query.py | 27 ++-- cqlengine/tests/base.py | 6 +- cqlengine/tests/management/test_management.py | 67 ++++---- cqlengine/tests/query/test_queryset.py | 11 -- 7 files changed, 167 insertions(+), 188 deletions(-) diff --git a/.gitignore b/.gitignore index 6d96b7f030..1e4d8eb344 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ html/ #Mr Developer .mr.developer.cfg .noseids +/commitlog +/data + diff --git a/cqlengine/connection.py b/cqlengine/connection.py index a1a18df09b..a7b700afc3 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -9,8 +9,11 @@ import cql import logging +from copy import copy from cqlengine.exceptions import CQLEngineException +from contextlib import contextmanager + from thrift.transport.TTransport import TTransportException LOG = logging.getLogger('cqlengine.cql') @@ -20,7 +23,9 @@ class CQLConnectionError(CQLEngineException): pass Host = namedtuple('Host', ['name', 'port']) _max_connections = 10 -_connection_pool = None + +# global connection pool +connection_pool = None def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None): """ @@ -29,7 +34,7 @@ def setup(hosts, username=None, password=None, max_connections=10, default_keysp :param hosts: list of hosts, strings in the :, or just """ global _max_connections - global _connection_pool + global connection_pool _max_connections = max_connections if default_keyspace: @@ -50,16 +55,13 @@ def setup(hosts, username=None, password=None, max_connections=10, default_keysp if not _hosts: raise CQLConnectionError("At least one host required") - _connection_pool = ConnectionPool(_hosts) + connection_pool = ConnectionPool(_hosts, username, password) class ConnectionPool(object): """Handles pooling of database connections.""" - # Connection pool queue - _queue = None - - def __init__(self, hosts, username, password): + def __init__(self, hosts, username=None, password=None): self._hosts = hosts self._username = username self._password = password @@ -113,58 +115,40 @@ def _create_connection(self): if not self._hosts: raise CQLConnectionError("At least one host required") - host = _hosts[_host_idx] - - new_conn = cql.connect(host.name, host.port, user=_username, password=_password) - new_conn.set_cql_version('3.0.0') - return new_conn - - -class connection_manager(object): - """ - Connection failure tolerant connection manager. Written to be used in a 'with' block for connection pooling - """ - def __init__(self): - if not _hosts: - raise CQLConnectionError("No connections have been configured, call cqlengine.connection.setup") - self.keyspace = None - self.con = ConnectionPool.get() - self.cur = None + hosts = copy(self._hosts) + random.shuffle(hosts) - def close(self): - if self.cur: self.cur.close() - ConnectionPool.put(self.con) - - def __enter__(self): - return self + for host in hosts: + try: + new_conn = cql.connect(host.name, host.port, user=self._username, password=self._password) + new_conn.set_cql_version('3.0.0') + return new_conn + except Exception as e: + logging.debug("Could not establish connection to {}:{}".format(host.name, host.port)) + pass - def __exit__(self, type, value, traceback): - self.close() + raise CQLConnectionError("Could not connect to any server in cluster") - def execute(self, query, params={}): - """ - Gets a connection from the pool and executes the given query, returns the cursor + def execute(self, query, params): + try: + con = self.get() + cur = con.cursor() + cur.execute(query, params) + self.put(con) + return cur + except cql.ProgrammingError as ex: + raise CQLEngineException(unicode(ex)) + except TTransportException: + pass - if there's a connection problem, this will silently create a new connection pool - from the available hosts, and remove the problematic host from the host list - """ - global _host_idx + raise CQLEngineException("Could not execute query against the cluster") - for i in range(len(_hosts)): - try: - LOG.debug('{} {}'.format(query, repr(params))) - self.cur = self.con.cursor() - self.cur.execute(query, params) - return self.cur - except cql.ProgrammingError as ex: - raise CQLEngineException(unicode(ex)) - except TTransportException: - #TODO: check for other errors raised in the event of a connection / server problem - #move to the next connection and set the connection pool - _host_idx += 1 - _host_idx %= len(_hosts) - self.con.close() - self.con = ConnectionPool._create_connection() - - raise CQLConnectionError("couldn't reach a Cassandra server") +def execute(query, params={}): + return connection_pool.execute(query, params) +@contextmanager +def connection_manager(): + global connection_pool + tmp = connection_pool.get() + yield tmp + connection_pool.put(tmp) diff --git a/cqlengine/management.py b/cqlengine/management.py index 25717aa4c6..feddef2ff1 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,6 +1,6 @@ import json -from cqlengine.connection import connection_manager +from cqlengine.connection import connection_manager, execute from cqlengine.exceptions import CQLEngineException def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, durable_writes=True, **replication_values): @@ -15,11 +15,11 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, """ with connection_manager() as con: #TODO: check system tables instead of using cql thrifteries - if not any([name == k.name for k in con.con.client.describe_keyspaces()]): -# if name not in [k.name for k in con.con.client.describe_keyspaces()]: + if not any([name == k.name for k in con.client.describe_keyspaces()]): + # if name not in [k.name for k in con.con.client.describe_keyspaces()]: try: #Try the 1.1 method - con.execute("""CREATE KEYSPACE {} + execute("""CREATE KEYSPACE {} WITH strategy_class = '{}' AND strategy_options:replication_factor={};""".format(name, strategy_class, replication_factor)) except CQLEngineException: @@ -38,12 +38,12 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, if strategy_class != 'SimpleStrategy': query += " AND DURABLE_WRITES = {}".format('true' if durable_writes else 'false') - con.execute(query) + execute(query) def delete_keyspace(name): with connection_manager() as con: - if name in [k.name for k in con.con.client.describe_keyspaces()]: - con.execute("DROP KEYSPACE {}".format(name)) + if name in [k.name for k in con.client.describe_keyspaces()]: + execute("DROP KEYSPACE {}".format(name)) def create_table(model, create_missing_keyspace=True): #construct query string @@ -55,78 +55,81 @@ def create_table(model, create_missing_keyspace=True): create_keyspace(model._get_keyspace()) with connection_manager() as con: - #check for an existing column family - #TODO: check system tables instead of using cql thrifteries - ks_info = con.con.client.describe_keyspace(model._get_keyspace()) - if not any([raw_cf_name == cf.name for cf in ks_info.cf_defs]): - qs = ['CREATE TABLE {}'.format(cf_name)] - - #add column types - pkeys = [] - ckeys = [] - qtypes = [] - def add_column(col): - s = col.get_column_def() - if col.primary_key: - keys = (pkeys if col.partition_key else ckeys) - keys.append('"{}"'.format(col.db_field_name)) - qtypes.append(s) - for name, col in model._columns.items(): - add_column(col) - - qtypes.append('PRIMARY KEY (({}){})'.format(', '.join(pkeys), ckeys and ', ' + ', '.join(ckeys) or '')) - - qs += ['({})'.format(', '.join(qtypes))] - - with_qs = ['read_repair_chance = {}'.format(model.read_repair_chance)] - - _order = ["%s %s" % (c.db_field_name, c.clustering_order or 'ASC') for c in model._clustering_keys.values()] - if _order: - with_qs.append("clustering order by ({})".format(', '.join(_order))) - - # add read_repair_chance - qs += ['WITH {}'.format(' AND '.join(with_qs))] + ks_info = con.client.describe_keyspace(model._get_keyspace()) + + #check for an existing column family + #TODO: check system tables instead of using cql thrifteries + if not any([raw_cf_name == cf.name for cf in ks_info.cf_defs]): + qs = ['CREATE TABLE {}'.format(cf_name)] + + #add column types + pkeys = [] + ckeys = [] + qtypes = [] + def add_column(col): + s = col.get_column_def() + if col.primary_key: + keys = (pkeys if col.partition_key else ckeys) + keys.append('"{}"'.format(col.db_field_name)) + qtypes.append(s) + for name, col in model._columns.items(): + add_column(col) + + qtypes.append('PRIMARY KEY (({}){})'.format(', '.join(pkeys), ckeys and ', ' + ', '.join(ckeys) or '')) + + qs += ['({})'.format(', '.join(qtypes))] + + with_qs = ['read_repair_chance = {}'.format(model.read_repair_chance)] + + _order = ["%s %s" % (c.db_field_name, c.clustering_order or 'ASC') for c in model._clustering_keys.values()] + if _order: + with_qs.append("clustering order by ({})".format(', '.join(_order))) + + # add read_repair_chance + qs += ['WITH {}'.format(' AND '.join(with_qs))] + qs = ' '.join(qs) + + try: + execute(qs) + except CQLEngineException as ex: + # 1.2 doesn't return cf names, so we have to examine the exception + # and ignore if it says the column family already exists + if "Cannot add already existing column family" not in unicode(ex): + raise + + #get existing index names, skip ones that already exist + with connection_manager() as con: + ks_info = con.client.describe_keyspace(model._get_keyspace()) + + cf_defs = [cf for cf in ks_info.cf_defs if cf.name == raw_cf_name] + idx_names = [i.index_name for i in cf_defs[0].column_metadata] if cf_defs else [] + idx_names = filter(None, idx_names) + + indexes = [c for n,c in model._columns.items() if c.index] + if indexes: + for column in indexes: + if column.db_index_name in idx_names: continue + qs = ['CREATE INDEX index_{}_{}'.format(raw_cf_name, column.db_field_name)] + qs += ['ON {}'.format(cf_name)] + qs += ['("{}")'.format(column.db_field_name)] qs = ' '.join(qs) try: - con.execute(qs) + execute(qs) except CQLEngineException as ex: # 1.2 doesn't return cf names, so we have to examine the exception - # and ignore if it says the column family already exists - if "Cannot add already existing column family" not in unicode(ex): + # and ignore if it says the index already exists + if "Index already exists" not in unicode(ex): raise - #get existing index names, skip ones that already exist - ks_info = con.con.client.describe_keyspace(model._get_keyspace()) - cf_defs = [cf for cf in ks_info.cf_defs if cf.name == raw_cf_name] - idx_names = [i.index_name for i in cf_defs[0].column_metadata] if cf_defs else [] - idx_names = filter(None, idx_names) - - indexes = [c for n,c in model._columns.items() if c.index] - if indexes: - for column in indexes: - if column.db_index_name in idx_names: continue - qs = ['CREATE INDEX index_{}_{}'.format(raw_cf_name, column.db_field_name)] - qs += ['ON {}'.format(cf_name)] - qs += ['("{}")'.format(column.db_field_name)] - qs = ' '.join(qs) - - try: - con.execute(qs) - except CQLEngineException as ex: - # 1.2 doesn't return cf names, so we have to examine the exception - # and ignore if it says the index already exists - if "Index already exists" not in unicode(ex): - raise - def delete_table(model): cf_name = model.column_family_name() - with connection_manager() as con: - try: - con.execute('drop table {};'.format(cf_name)) - except CQLEngineException as ex: - #don't freak out if the table doesn't exist - if 'Cannot drop non existing column family' not in unicode(ex): - raise + + try: + execute('drop table {};'.format(cf_name)) + except CQLEngineException as ex: + #don't freak out if the table doesn't exist + if 'Cannot drop non existing column family' not in unicode(ex): + raise diff --git a/cqlengine/query.py b/cqlengine/query.py index 3ab71129f0..d3ae8352e0 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -6,7 +6,8 @@ from uuid import uuid1 from cqlengine import BaseContainerColumn, BaseValueManager, Map, columns -from cqlengine.connection import connection_manager +from cqlengine.connection import connection_pool, connection_manager, execute + from cqlengine.exceptions import CQLEngineException from cqlengine.functions import QueryValue, Token @@ -193,8 +194,7 @@ def execute(self): query_list.append('APPLY BATCH;') - with connection_manager() as con: - con.execute('\n'.join(query_list), parameters) + execute('\n'.join(query_list), parameters) self.queries = [] @@ -346,8 +346,7 @@ def _execute_query(self): if self._batch: raise CQLEngineException("Only inserts, updates, and deletes are available in batch mode") if self._result_cache is None: - self._con = connection_manager() - self._cur = self._con.execute(self._select_query(), self._where_values()) + self._cur = execute(self._select_query(), self._where_values()) self._result_cache = [None]*self._cur.rowcount if self._cur.description: names = [i[0] for i in self._cur.description] @@ -368,7 +367,6 @@ def _fill_result_cache_to_idx(self, idx): #return the connection to the connection pool if we have all objects if self._result_cache and self._result_idx == (len(self._result_cache) - 1): - self._con.close() self._con = None self._cur = None @@ -555,9 +553,8 @@ def count(self): qs = ' '.join(qs) - with connection_manager() as con: - cur = con.execute(qs, self._where_values()) - return cur.fetchone()[0] + cur = execute(qs, self._where_values()) + return cur.fetchone()[0] else: return len(self._result_cache) @@ -635,8 +632,7 @@ def delete(self, columns=[]): if self._batch: self._batch.add_query(qs, self._where_values()) else: - with connection_manager() as con: - con.execute(qs, self._where_values()) + execute(qs, self._where_values()) def values_list(self, *fields, **kwargs): """ Instructs the query set to return tuples, not model instance """ @@ -753,8 +749,7 @@ def save(self): if self.batch: self.batch.add_query(qs, query_values) else: - with connection_manager() as con: - con.execute(qs, query_values) + execute(qs, query_values) # delete nulled columns and removed map keys @@ -787,8 +782,7 @@ def save(self): if self.batch: self.batch.add_query(qs, query_values) else: - with connection_manager() as con: - con.execute(qs, query_values) + execute(qs, query_values) def delete(self): """ Deletes one instance """ @@ -809,7 +803,6 @@ def delete(self): if self.batch: self.batch.add_query(qs, field_values) else: - with connection_manager() as con: - con.execute(qs, field_values) + execute(qs, field_values) diff --git a/cqlengine/tests/base.py b/cqlengine/tests/base.py index a7090c627d..4400881111 100644 --- a/cqlengine/tests/base.py +++ b/cqlengine/tests/base.py @@ -1,13 +1,13 @@ from unittest import TestCase -from cqlengine import connection +from cqlengine import connection class BaseCassEngTestCase(TestCase): @classmethod def setUpClass(cls): super(BaseCassEngTestCase, cls).setUpClass() - if not connection._connection_pool: - connection.setup(['localhost:9160'], default_keyspace='cqlengine_test') + # todo fix + connection.setup(['localhost:9160'], default_keyspace='cqlengine_test') def assertHasAttr(self, obj, attr): self.assertTrue(hasattr(obj, attr), diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index ac542bb8e6..be49199ff6 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -1,44 +1,51 @@ +from cqlengine.exceptions import CQLEngineException from cqlengine.management import create_table, delete_table from cqlengine.tests.base import BaseCassEngTestCase -from cqlengine.connection import ConnectionPool +from cqlengine.connection import ConnectionPool, Host -from mock import Mock +from mock import Mock, MagicMock, MagicProxy, patch from cqlengine import management from cqlengine.tests.query.test_queryset import TestModel +from cql.thrifteries import ThriftConnection -class ConnectionPoolTestCase(BaseCassEngTestCase): +class ConnectionPoolFailoverTestCase(BaseCassEngTestCase): """Test cassandra connection pooling.""" def setUp(self): - ConnectionPool.clear() - - def test_should_create_single_connection_on_request(self): - """Should create a single connection on first request""" - result = ConnectionPool.get() - self.assertIsNotNone(result) - self.assertEquals(0, ConnectionPool._queue.qsize()) - ConnectionPool._queue.put(result) - self.assertEquals(1, ConnectionPool._queue.qsize()) - - def test_should_close_connection_if_queue_is_full(self): - """Should close additional connections if queue is full""" - connections = [ConnectionPool.get() for x in range(10)] - for conn in connections: - ConnectionPool.put(conn) - fake_conn = Mock() - ConnectionPool.put(fake_conn) - fake_conn.close.assert_called_once_with() - - def test_should_pop_connections_from_queue(self): - """Should pull existing connections off of the queue""" - conn = ConnectionPool.get() - ConnectionPool.put(conn) - self.assertEquals(1, ConnectionPool._queue.qsize()) - self.assertEquals(conn, ConnectionPool.get()) - self.assertEquals(0, ConnectionPool._queue.qsize()) - + self.host = Host('127.0.0.1', '9160') + self.pool = ConnectionPool([self.host]) + + def test_totally_dead_pool(self): + # kill the con + with patch('cqlengine.connection.cql.connect') as mock: + mock.side_effect=CQLEngineException + with self.assertRaises(CQLEngineException): + self.pool.execute("select * from system.peers", {}) + + def test_dead_node(self): + self.pool._hosts.append(self.host) + + # cursor mock needed so set_cql_version doesn't crap out + ok_cur = MagicMock() + + ok_conn = MagicMock() + ok_conn.return_value = ok_cur + + + returns = [CQLEngineException(), ok_conn] + + def side_effect(*args, **kwargs): + result = returns.pop(0) + if isinstance(result, Exception): + raise result + return result + + with patch('cqlengine.connection.cql.connect') as mock: + mock.side_effect = side_effect + conn = self.pool._create_connection() + class CreateKeyspaceTest(BaseCassEngTestCase): def test_create_succeeeds(self): diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 1c3eb95b20..17954d184a 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -405,18 +405,7 @@ def test_conn_is_returned_after_filling_cache(self): assert q._con is None assert q._cur is None - def test_conn_is_returned_after_queryset_is_garbage_collected(self): - """ Tests that the connection is returned to the connection pool after the queryset is gc'd """ - from cqlengine.connection import ConnectionPool - # The queue size can be 1 if we just run this file's tests - # It will be 2 when we run 'em all - initial_size = ConnectionPool._queue.qsize() - q = TestModel.objects(test_id=0) - v = q[0] - assert ConnectionPool._queue.qsize() == initial_size - 1 - del q - assert ConnectionPool._queue.qsize() == initial_size class TimeUUIDQueryModel(Model): partition = columns.UUID(primary_key=True) From 66548e7f243c75d5160d7bfdd9a2aa06ef6186ce Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 3 Jun 2013 17:30:57 -0700 Subject: [PATCH 0205/4200] test comment --- cqlengine/tests/management/test_management.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index be49199ff6..4e271c58ce 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -25,6 +25,9 @@ def test_totally_dead_pool(self): self.pool.execute("select * from system.peers", {}) def test_dead_node(self): + """ + tests that a single dead node doesn't mess up the pool + """ self.pool._hosts.append(self.host) # cursor mock needed so set_cql_version doesn't crap out From f94c61228ce7e1f329685b78f5a894e19f39c5df Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 18:09:11 -0700 Subject: [PATCH 0206/4200] clarifying how the column io tests work --- cqlengine/tests/columns/test_value_io.py | 37 +++++++++++++----------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/cqlengine/tests/columns/test_value_io.py b/cqlengine/tests/columns/test_value_io.py index 273ff0d56b..86621d65bf 100644 --- a/cqlengine/tests/columns/test_value_io.py +++ b/cqlengine/tests/columns/test_value_io.py @@ -1,7 +1,6 @@ from datetime import datetime, timedelta from decimal import Decimal from uuid import uuid1, uuid4, UUID -from unittest import SkipTest from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.management import create_table @@ -10,31 +9,35 @@ from cqlengine import columns class BaseColumnIOTest(BaseCassEngTestCase): + """ Tests that values are come out of cassandra in the format we expect """ - TEST_MODEL = None - TEST_COLUMN = None + # The generated test model is assigned here + _test_model = None - @property - def PKEY_VAL(self): - raise NotImplementedError + # the column we want to test + TEST_COLUMN = None - @property - def DATA_VAL(self): - raise NotImplementedError + # the values we want to test against, you can + # use a single value, or multiple comma separated values + PKEY_VAL = None + DATA_VAL = None @classmethod def setUpClass(cls): super(BaseColumnIOTest, cls).setUpClass() + + #if the test column hasn't been defined, bail out if not cls.TEST_COLUMN: return + + # create a table with the given column class IOTestModel(Model): table_name = cls.TEST_COLUMN.db_type + "_io_test_model_{}".format(uuid4().hex[:8]) pkey = cls.TEST_COLUMN(primary_key=True) data = cls.TEST_COLUMN() + cls._test_model = IOTestModel + create_table(cls._test_model) - cls.TEST_MODEL = IOTestModel - create_table(cls.TEST_MODEL) - - #tupleify + #tupleify the tested values if not isinstance(cls.PKEY_VAL, tuple): cls.PKEY_VAL = cls.PKEY_VAL, if not isinstance(cls.DATA_VAL, tuple): @@ -44,7 +47,7 @@ class IOTestModel(Model): def tearDownClass(cls): super(BaseColumnIOTest, cls).tearDownClass() if not cls.TEST_COLUMN: return - delete_table(cls.TEST_MODEL) + delete_table(cls._test_model) def comparator_converter(self, val): """ If you want to convert the original value used to compare the model vales """ @@ -55,15 +58,15 @@ def test_column_io(self): if not self.TEST_COLUMN: return for pkey, data in zip(self.PKEY_VAL, self.DATA_VAL): #create - m1 = self.TEST_MODEL.create(pkey=pkey, data=data) + m1 = self._test_model.create(pkey=pkey, data=data) #get - m2 = self.TEST_MODEL.get(pkey=pkey) + m2 = self._test_model.get(pkey=pkey) assert m1.pkey == m2.pkey == self.comparator_converter(pkey), self.TEST_COLUMN assert m1.data == m2.data == self.comparator_converter(data), self.TEST_COLUMN #delete - self.TEST_MODEL.filter(pkey=pkey).delete() + self._test_model.filter(pkey=pkey).delete() class TestTextIO(BaseColumnIOTest): From f4c7a5bde249dc5d08246a196fec7f664bfcbb5c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 3 Jun 2013 18:12:46 -0700 Subject: [PATCH 0207/4200] more clarification --- cqlengine/tests/columns/test_value_io.py | 95 +++++++++++++----------- 1 file changed, 51 insertions(+), 44 deletions(-) diff --git a/cqlengine/tests/columns/test_value_io.py b/cqlengine/tests/columns/test_value_io.py index 86621d65bf..910bf0bd2c 100644 --- a/cqlengine/tests/columns/test_value_io.py +++ b/cqlengine/tests/columns/test_value_io.py @@ -9,44 +9,49 @@ from cqlengine import columns class BaseColumnIOTest(BaseCassEngTestCase): - """ Tests that values are come out of cassandra in the format we expect """ + """ + Tests that values are come out of cassandra in the format we expect + + To test a column type, subclass this test, define the column, and the primary key + and data values you want to test + """ # The generated test model is assigned here _test_model = None # the column we want to test - TEST_COLUMN = None + test_column = None # the values we want to test against, you can # use a single value, or multiple comma separated values - PKEY_VAL = None - DATA_VAL = None + pkey_val = None + data_val = None @classmethod def setUpClass(cls): super(BaseColumnIOTest, cls).setUpClass() #if the test column hasn't been defined, bail out - if not cls.TEST_COLUMN: return + if not cls.test_column: return # create a table with the given column class IOTestModel(Model): - table_name = cls.TEST_COLUMN.db_type + "_io_test_model_{}".format(uuid4().hex[:8]) - pkey = cls.TEST_COLUMN(primary_key=True) - data = cls.TEST_COLUMN() + table_name = cls.test_column.db_type + "_io_test_model_{}".format(uuid4().hex[:8]) + pkey = cls.test_column(primary_key=True) + data = cls.test_column() cls._test_model = IOTestModel create_table(cls._test_model) #tupleify the tested values - if not isinstance(cls.PKEY_VAL, tuple): - cls.PKEY_VAL = cls.PKEY_VAL, - if not isinstance(cls.DATA_VAL, tuple): - cls.DATA_VAL = cls.DATA_VAL, + if not isinstance(cls.pkey_val, tuple): + cls.pkey_val = cls.pkey_val, + if not isinstance(cls.data_val, tuple): + cls.data_val = cls.data_val, @classmethod def tearDownClass(cls): super(BaseColumnIOTest, cls).tearDownClass() - if not cls.TEST_COLUMN: return + if not cls.test_column: return delete_table(cls._test_model) def comparator_converter(self, val): @@ -55,85 +60,87 @@ def comparator_converter(self, val): def test_column_io(self): """ Tests the given models class creates and retrieves values as expected """ - if not self.TEST_COLUMN: return - for pkey, data in zip(self.PKEY_VAL, self.DATA_VAL): + if not self.test_column: return + for pkey, data in zip(self.pkey_val, self.data_val): #create m1 = self._test_model.create(pkey=pkey, data=data) #get m2 = self._test_model.get(pkey=pkey) - assert m1.pkey == m2.pkey == self.comparator_converter(pkey), self.TEST_COLUMN - assert m1.data == m2.data == self.comparator_converter(data), self.TEST_COLUMN + assert m1.pkey == m2.pkey == self.comparator_converter(pkey), self.test_column + assert m1.data == m2.data == self.comparator_converter(data), self.test_column #delete self._test_model.filter(pkey=pkey).delete() class TestTextIO(BaseColumnIOTest): - TEST_COLUMN = columns.Text - PKEY_VAL = 'bacon' - DATA_VAL = 'monkey' + test_column = columns.Text + pkey_val = 'bacon' + data_val = 'monkey' class TestInteger(BaseColumnIOTest): - TEST_COLUMN = columns.Integer - PKEY_VAL = 5 - DATA_VAL = 6 + test_column = columns.Integer + pkey_val = 5 + data_val = 6 class TestDateTime(BaseColumnIOTest): - TEST_COLUMN = columns.DateTime + test_column = columns.DateTime + now = datetime(*datetime.now().timetuple()[:6]) - PKEY_VAL = now - DATA_VAL = now + timedelta(days=1) + pkey_val = now + data_val = now + timedelta(days=1) class TestDate(BaseColumnIOTest): - TEST_COLUMN = columns.Date + test_column = columns.Date + now = datetime.now().date() - PKEY_VAL = now - DATA_VAL = now + timedelta(days=1) + pkey_val = now + data_val = now + timedelta(days=1) class TestUUID(BaseColumnIOTest): - TEST_COLUMN = columns.UUID + test_column = columns.UUID - PKEY_VAL = str(uuid4()), uuid4() - DATA_VAL = str(uuid4()), uuid4() + pkey_val = str(uuid4()), uuid4() + data_val = str(uuid4()), uuid4() def comparator_converter(self, val): return val if isinstance(val, UUID) else UUID(val) class TestTimeUUID(BaseColumnIOTest): - TEST_COLUMN = columns.TimeUUID + test_column = columns.TimeUUID - PKEY_VAL = str(uuid1()), uuid1() - DATA_VAL = str(uuid1()), uuid1() + pkey_val = str(uuid1()), uuid1() + data_val = str(uuid1()), uuid1() def comparator_converter(self, val): return val if isinstance(val, UUID) else UUID(val) class TestBooleanIO(BaseColumnIOTest): - TEST_COLUMN = columns.Boolean + test_column = columns.Boolean - PKEY_VAL = True - DATA_VAL = False + pkey_val = True + data_val = False class TestFloatIO(BaseColumnIOTest): - TEST_COLUMN = columns.Float + test_column = columns.Float - PKEY_VAL = 3.14 - DATA_VAL = -1982.11 + pkey_val = 3.14 + data_val = -1982.11 class TestDecimalIO(BaseColumnIOTest): - TEST_COLUMN = columns.Decimal + test_column = columns.Decimal - PKEY_VAL = Decimal('1.35'), 5, '2.4' - DATA_VAL = Decimal('0.005'), 3.5, '8' + pkey_val = Decimal('1.35'), 5, '2.4' + data_val = Decimal('0.005'), 3.5, '8' def comparator_converter(self, val): return Decimal(val) From 20ba2fcc78e1eecd718190bdc2cef78ed80ac93e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 4 Jun 2013 13:21:12 -0700 Subject: [PATCH 0208/4200] fixing some attribute names that were upsetting py.test reflection --- cqlengine/tests/columns/test_value_io.py | 51 ++++++++++++------------ 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/cqlengine/tests/columns/test_value_io.py b/cqlengine/tests/columns/test_value_io.py index 910bf0bd2c..fda632e965 100644 --- a/cqlengine/tests/columns/test_value_io.py +++ b/cqlengine/tests/columns/test_value_io.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from decimal import Decimal from uuid import uuid1, uuid4, UUID + from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.management import create_table @@ -17,10 +18,10 @@ class BaseColumnIOTest(BaseCassEngTestCase): """ # The generated test model is assigned here - _test_model = None + _generated_model = None # the column we want to test - test_column = None + column = None # the values we want to test against, you can # use a single value, or multiple comma separated values @@ -32,15 +33,15 @@ def setUpClass(cls): super(BaseColumnIOTest, cls).setUpClass() #if the test column hasn't been defined, bail out - if not cls.test_column: return + if not cls.column: return # create a table with the given column class IOTestModel(Model): - table_name = cls.test_column.db_type + "_io_test_model_{}".format(uuid4().hex[:8]) - pkey = cls.test_column(primary_key=True) - data = cls.test_column() - cls._test_model = IOTestModel - create_table(cls._test_model) + table_name = cls.column.db_type + "_io_test_model_{}".format(uuid4().hex[:8]) + pkey = cls.column(primary_key=True) + data = cls.column() + cls._generated_model = IOTestModel + create_table(cls._generated_model) #tupleify the tested values if not isinstance(cls.pkey_val, tuple): @@ -51,8 +52,8 @@ class IOTestModel(Model): @classmethod def tearDownClass(cls): super(BaseColumnIOTest, cls).tearDownClass() - if not cls.test_column: return - delete_table(cls._test_model) + if not cls.column: return + delete_table(cls._generated_model) def comparator_converter(self, val): """ If you want to convert the original value used to compare the model vales """ @@ -60,34 +61,34 @@ def comparator_converter(self, val): def test_column_io(self): """ Tests the given models class creates and retrieves values as expected """ - if not self.test_column: return + if not self.column: return for pkey, data in zip(self.pkey_val, self.data_val): #create - m1 = self._test_model.create(pkey=pkey, data=data) + m1 = self._generated_model.create(pkey=pkey, data=data) #get - m2 = self._test_model.get(pkey=pkey) - assert m1.pkey == m2.pkey == self.comparator_converter(pkey), self.test_column - assert m1.data == m2.data == self.comparator_converter(data), self.test_column + m2 = self._generated_model.get(pkey=pkey) + assert m1.pkey == m2.pkey == self.comparator_converter(pkey), self.column + assert m1.data == m2.data == self.comparator_converter(data), self.column #delete - self._test_model.filter(pkey=pkey).delete() + self._generated_model.filter(pkey=pkey).delete() class TestTextIO(BaseColumnIOTest): - test_column = columns.Text + column = columns.Text pkey_val = 'bacon' data_val = 'monkey' class TestInteger(BaseColumnIOTest): - test_column = columns.Integer + column = columns.Integer pkey_val = 5 data_val = 6 class TestDateTime(BaseColumnIOTest): - test_column = columns.DateTime + column = columns.DateTime now = datetime(*datetime.now().timetuple()[:6]) pkey_val = now @@ -95,7 +96,7 @@ class TestDateTime(BaseColumnIOTest): class TestDate(BaseColumnIOTest): - test_column = columns.Date + column = columns.Date now = datetime.now().date() pkey_val = now @@ -103,7 +104,7 @@ class TestDate(BaseColumnIOTest): class TestUUID(BaseColumnIOTest): - test_column = columns.UUID + column = columns.UUID pkey_val = str(uuid4()), uuid4() data_val = str(uuid4()), uuid4() @@ -113,7 +114,7 @@ def comparator_converter(self, val): class TestTimeUUID(BaseColumnIOTest): - test_column = columns.TimeUUID + column = columns.TimeUUID pkey_val = str(uuid1()), uuid1() data_val = str(uuid1()), uuid1() @@ -123,21 +124,21 @@ def comparator_converter(self, val): class TestBooleanIO(BaseColumnIOTest): - test_column = columns.Boolean + column = columns.Boolean pkey_val = True data_val = False class TestFloatIO(BaseColumnIOTest): - test_column = columns.Float + column = columns.Float pkey_val = 3.14 data_val = -1982.11 class TestDecimalIO(BaseColumnIOTest): - test_column = columns.Decimal + column = columns.Decimal pkey_val = Decimal('1.35'), 5, '2.4' data_val = Decimal('0.005'), 3.5, '8' From 67055e006e1125446c56c05e07c1bd60afb2b1c8 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 4 Jun 2013 13:40:19 -0700 Subject: [PATCH 0209/4200] fixing bug in uuid string validation --- cqlengine/columns.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 3f0806345d..4f93b34ca9 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -290,9 +290,9 @@ def validate(self, value): if val is None: return from uuid import UUID as _UUID if isinstance(val, _UUID): return val - if not self.re_uuid.match(val): - raise ValidationError("{} is not a valid uuid".format(value)) - return _UUID(val) + if isinstance(val, basestring) and self.re_uuid.match(val): + return _UUID(val) + raise ValidationError("{} is not a valid uuid".format(value)) def to_python(self, value): return self.validate(value) From 750c58c27c71e176b27aba8462c18d36e5b764a4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 4 Jun 2013 13:43:58 -0700 Subject: [PATCH 0210/4200] version bump --- cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 25d51303d5..a593cdce5c 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.3.1' +__version__ = '0.3.2' diff --git a/docs/conf.py b/docs/conf.py index 36e5d084b0..ecb08cc150 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.3.1' +version = '0.3.2' # The full version, including alpha/beta/rc tags. -release = '0.3.1' +release = '0.3.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index cec6865dca..7692b9f853 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.3.1' +version = '0.3.2' long_desc = """ cqlengine is a Cassandra CQL Object Mapper for Python in the style of the Django orm and mongoengine From 634c0e9ae0c8cbf2daa5fed1f85f4d00f41f1da0 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 5 Jun 2013 14:21:58 -0700 Subject: [PATCH 0211/4200] adding support for abstract base classes --- cqlengine/management.py | 4 + cqlengine/models.py | 13 +++- .../tests/model/test_class_construction.py | 74 ++++++++++++++++++- docs/topics/models.rst | 4 + 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index feddef2ff1..90005d2bc2 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -46,6 +46,10 @@ def delete_keyspace(name): execute("DROP KEYSPACE {}".format(name)) def create_table(model, create_missing_keyspace=True): + + if model.__abstract__: + raise CQLEngineException("cannot create table from abstract model") + #construct query string cf_name = model.column_family_name() raw_cf_name = model.column_family_name(include_keyspace=False) diff --git a/cqlengine/models.py b/cqlengine/models.py index bd4e65fabe..99a6c780e0 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -2,7 +2,7 @@ import re from cqlengine import columns -from cqlengine.exceptions import ModelException +from cqlengine.exceptions import ModelException, CQLEngineException from cqlengine.query import QuerySet, DMLQuery from cqlengine.query import DoesNotExist as _DoesNotExist from cqlengine.query import MultipleObjectsReturned as _MultipleObjectsReturned @@ -29,6 +29,8 @@ def __get__(self, instance, owner): class QuerySetDescriptor(object): def __get__(self, obj, model): + if model.__abstract__: + raise CQLEngineException('cannot execute queries against abstract models') return QuerySet(model) class BaseModel(object): @@ -183,6 +185,8 @@ def __new__(cls, name, bases, attrs): for k,v in getattr(base, '_defined_columns', {}).items(): inherited_columns.setdefault(k,v) + #short circuit __abstract__ inheritance + is_abstract = attrs['__abstract__'] = attrs.get('__abstract__', False) def _transform_column(col_name, col_obj): column_dict[col_name] = col_obj @@ -208,7 +212,7 @@ def _transform_column(col_name, col_obj): defined_columns = OrderedDict(column_definitions) #prepend primary key if one hasn't been defined - if not any([v.primary_key for k,v in column_definitions]): + if not is_abstract and not any([v.primary_key for k,v in column_definitions]): k,v = 'id', columns.UUID(primary_key=True) column_definitions = [(k,v)] + column_definitions @@ -228,7 +232,9 @@ def _transform_column(col_name, col_obj): clustering_keys = OrderedDict(k for k in primary_keys.items() if not k[1].partition_key) #setup partition key shortcut - assert partition_keys + if len(partition_keys) == 0: + if not is_abstract: + raise ModelException("at least one partition key must be defined") if len(partition_keys) == 1: pk_name = partition_keys.keys()[0] attrs['pk'] = attrs[pk_name] @@ -294,6 +300,7 @@ class Model(BaseModel): the db name for the column family can be set as the attribute db_name, or it will be genertaed from the class name """ + __abstract__ = True __metaclass__ = ModelMetaClass diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index c67fcbee4d..a37b6baa3c 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -1,6 +1,7 @@ +from cqlengine.query import QueryException from cqlengine.tests.base import BaseCassEngTestCase -from cqlengine.exceptions import ModelException +from cqlengine.exceptions import ModelException, CQLEngineException from cqlengine.models import Model from cqlengine import columns import cqlengine @@ -199,8 +200,79 @@ def test_manual_table_name_is_not_inherited(self): class InheritedTest(self.RenamedTest): pass assert InheritedTest.table_name is None +class AbstractModel(Model): + __abstract__ = True +class ConcreteModel(AbstractModel): + pkey = columns.Integer(primary_key=True) + data = columns.Integer() +class AbstractModelWithCol(Model): + __abstract__ = True + pkey = columns.Integer(primary_key=True) + +class ConcreteModelWithCol(AbstractModelWithCol): + data = columns.Integer() + +class AbstractModelWithFullCols(Model): + __abstract__ = True + pkey = columns.Integer(primary_key=True) + data = columns.Integer() + +class TestAbstractModelClasses(BaseCassEngTestCase): + + def test_id_field_is_not_created(self): + """ Tests that an id field is not automatically generated on abstract classes """ + assert not hasattr(AbstractModel, 'id') + assert not hasattr(AbstractModelWithCol, 'id') + + def test_id_field_is_not_created_on_subclass(self): + assert not hasattr(ConcreteModel, 'id') + + def test_abstract_attribute_is_not_inherited(self): + """ Tests that __abstract__ attribute is not inherited """ + assert not ConcreteModel.__abstract__ + assert not ConcreteModelWithCol.__abstract__ + + def test_attempting_to_save_abstract_model_fails(self): + """ Attempting to save a model from an abstract model should fail """ + with self.assertRaises(CQLEngineException): + AbstractModelWithFullCols.create(pkey=1, data=2) + + def test_attempting_to_create_abstract_table_fails(self): + """ Attempting to create a table from an abstract model should fail """ + from cqlengine.management import create_table + with self.assertRaises(CQLEngineException): + create_table(AbstractModelWithFullCols) + + def test_attempting_query_on_abstract_model_fails(self): + """ Tests attempting to execute query with an abstract model fails """ + with self.assertRaises(CQLEngineException): + iter(AbstractModelWithFullCols.objects(pkey=5)).next() + + def test_abstract_columns_are_inherited(self): + """ Tests that columns defined in the abstract class are inherited into the concrete class """ + assert hasattr(ConcreteModelWithCol, 'pkey') + assert isinstance(ConcreteModelWithCol.pkey, property) + assert isinstance(ConcreteModelWithCol._columns['pkey'], columns.Column) + + def test_concrete_class_table_creation_cycle(self): + """ Tests that models with inherited abstract classes can be created, and have io performed """ + from cqlengine.management import create_table, delete_table + create_table(ConcreteModelWithCol) + + w1 = ConcreteModelWithCol.create(pkey=5, data=6) + w2 = ConcreteModelWithCol.create(pkey=6, data=7) + + r1 = ConcreteModelWithCol.get(pkey=5) + r2 = ConcreteModelWithCol.get(pkey=6) + + assert w1.pkey == r1.pkey + assert w1.data == r1.data + assert w2.pkey == r2.pkey + assert w2.data == r2.data + + delete_table(ConcreteModelWithCol) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 52eb90613d..5dc322f61b 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -128,6 +128,10 @@ Model Methods Model Attributes ================ + .. attribute:: Model.__abstract__ + + *Optional.* Indicates that this model is only intended to be used as a base class for other models. You can't create tables for abstract models, but checks around schema validity are skipped during class construction. + .. attribute:: Model.table_name *Optional.* Sets the name of the CQL table for this model. If left blank, the table name will be the name of the model, with it's module name as it's prefix. Manually defined table names are not inherited. From fd27adf80ebe64bafb7972d0a674d6ce5fc2f9e3 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 5 Jun 2013 14:25:33 -0700 Subject: [PATCH 0212/4200] updating changelog --- changelog | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/changelog b/changelog index 05714651e4..8111f51f61 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,11 @@ CHANGELOG +0.3.3 +* added abstract base class models + +0.3.2 +* comprehesive rewrite of connection management (thanks @rustyrazorblade) + 0.3 * added support for Token function (thanks @mrk-its) * added support for compound partition key (thanks @mrk-its)s From d6707038df441e6b0e05d7eedcafaa0a59b407f3 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 5 Jun 2013 14:25:40 -0700 Subject: [PATCH 0213/4200] version bump --- cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index a593cdce5c..d103644c90 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.3.2' +__version__ = '0.3.3' diff --git a/docs/conf.py b/docs/conf.py index ecb08cc150..5d797fee3e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.3.2' +version = '0.3.3' # The full version, including alpha/beta/rc tags. -release = '0.3.2' +release = '0.3.3' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 7692b9f853..512d55e902 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.3.2' +version = '0.3.3' long_desc = """ cqlengine is a Cassandra CQL Object Mapper for Python in the style of the Django orm and mongoengine From f8654657578cfc09273c8d45c9c829699756b059 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 7 Jun 2013 16:18:59 -0700 Subject: [PATCH 0214/4200] making connection consistency more configurable --- cqlengine/connection.py | 15 +++++++++++---- cqlengine/tests/base.py | 2 +- docs/topics/connection.rst | 11 ++++++++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index a7b700afc3..5010aebc1a 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -27,7 +27,7 @@ class CQLConnectionError(CQLEngineException): pass # global connection pool connection_pool = None -def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None): +def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None, consistency='ONE'): """ Records the hosts and connects to one of them @@ -55,16 +55,17 @@ def setup(hosts, username=None, password=None, max_connections=10, default_keysp if not _hosts: raise CQLConnectionError("At least one host required") - connection_pool = ConnectionPool(_hosts, username, password) + connection_pool = ConnectionPool(_hosts, username, password, consistency) class ConnectionPool(object): """Handles pooling of database connections.""" - def __init__(self, hosts, username=None, password=None): + def __init__(self, hosts, username=None, password=None, consistency=None): self._hosts = hosts self._username = username self._password = password + self._consistency = consistency self._queue = Queue.Queue(maxsize=_max_connections) @@ -120,7 +121,13 @@ def _create_connection(self): for host in hosts: try: - new_conn = cql.connect(host.name, host.port, user=self._username, password=self._password) + new_conn = cql.connect( + host.name, + host.port, + user=self._username, + password=self._password, + consistency_level=self._consistency + ) new_conn.set_cql_version('3.0.0') return new_conn except Exception as e: diff --git a/cqlengine/tests/base.py b/cqlengine/tests/base.py index 4400881111..126049a860 100644 --- a/cqlengine/tests/base.py +++ b/cqlengine/tests/base.py @@ -1,5 +1,5 @@ from unittest import TestCase -from cqlengine import connection +from cqlengine import connection class BaseCassEngTestCase(TestCase): diff --git a/docs/topics/connection.rst b/docs/topics/connection.rst index 14bc2341c3..48558f9c46 100644 --- a/docs/topics/connection.rst +++ b/docs/topics/connection.rst @@ -7,11 +7,20 @@ Connection The setup function in `cqlengine.connection` records the Cassandra servers to connect to. If there is a problem with one of the servers, cqlengine will try to connect to each of the other connections before failing. -.. function:: setup(hosts [, username=None, password=None]) +.. function:: setup(hosts [, username=None, password=None, consistency='ONE']) :param hosts: list of hosts, strings in the :, or just :type hosts: list + :param username: a username, if required + :type username: str + + :param password: a password, if required + :type password: str + + :param consistency: the consistency level of the connection, defaults to 'ONE' + :type consistency: str + Records the hosts and connects to one of them See the example at :ref:`getting-started` From 226e900fdf5ff85ebd16bb72c4ef37f666f1ad72 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 7 Jun 2013 16:29:28 -0700 Subject: [PATCH 0215/4200] adding some IDE hints to batch and query set descriptors --- cqlengine/models.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cqlengine/models.py b/cqlengine/models.py index 99a6c780e0..64faf6a4d1 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -27,12 +27,25 @@ def __get__(self, instance, owner): else: return self.instmethod.__get__(instance, owner) + def __call__(self, *args, **kwargs): + """ Just a hint to IDEs that it's ok to call this """ + raise NotImplementedError + class QuerySetDescriptor(object): + """ + returns a fresh queryset for the given model + it's declared on everytime it's accessed + """ + def __get__(self, obj, model): if model.__abstract__: raise CQLEngineException('cannot execute queries against abstract models') return QuerySet(model) + def __call__(self, *args, **kwargs): + """ Just a hint to IDEs that it's ok to call this """ + raise NotImplementedError + class BaseModel(object): """ The base model class, don't inherit from this, inherit from Model, defined below From 35811d1fab64266655ff335c8afbdd5969c332f6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 7 Jun 2013 17:42:12 -0700 Subject: [PATCH 0216/4200] expanding column documentation --- cqlengine/columns.py | 18 +++++++++++++++--- docs/topics/columns.rst | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 4f93b34ca9..d864780cfc 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -74,14 +74,26 @@ class Column(object): instance_counter = 0 - def __init__(self, primary_key=False, partition_key=False, index=False, db_field=None, default=None, required=True, clustering_order=None): + def __init__(self, + primary_key=False, + partition_key=False, + index=False, + db_field=None, + default=None, + required=True, + clustering_order=None): """ :param primary_key: bool flag, indicates this column is a primary key. The first primary key defined - on a model is the partition key, all others are cluster keys + on a model is the partition key (unless partition keys are set), all others are cluster keys + :param partition_key: indicates that this column should be the partition key, defining + more than one partition key column creates a compound partition key :param index: bool flag, indicates an index should be created for this column :param db_field: the fieldname this field will map to in the database :param default: the default value, can be a value or a callable (no args) - :param required: boolean, is the field required? + :param required: boolean, is the field required? Model validation will raise and + exception if required is set to True and there is a None value assigned + :param clustering_order: only applicable on clustering keys (primary keys that are not partition keys) + determines the order that the clustering keys are sorted on disk """ self.partition_key = partition_key self.primary_key = partition_key or primary_key diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index 0779c4333f..61252e7f97 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -149,7 +149,7 @@ Column Options .. attribute:: BaseColumn.partition_key - If True, this column is created as partition primary key. There may be many partition keys defined, forming *composite partition key* + If True, this column is created as partition primary key. There may be many partition keys defined, forming a *composite partition key* .. attribute:: BaseColumn.index From f6fd5b100cf4d91bd0ba15a6c733183a208eeef7 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 14 Jun 2013 15:47:26 -0700 Subject: [PATCH 0217/4200] beginning work on query builder --- cqlengine/query.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/cqlengine/query.py b/cqlengine/query.py index d3ae8352e0..dd511d5939 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -154,6 +154,66 @@ class LessThanOrEqualOperator(QueryOperator): symbol = "LTE" cql_symbol = '<=' +class AbstractColumnDescriptor(object): + """ + exposes cql query operators through pythons + builtin comparator symbols + """ + + def _get_column(self): + raise NotImplementedError + + def __eq__(self, other): + return EqualsOperator(self._get_column(), other) + + def __contains__(self, item): + return InOperator(self._get_column(), item) + + def __gt__(self, other): + return GreaterThanOperator(self._get_column(), other) + + def __ge__(self, other): + return GreaterThanOrEqualOperator(self._get_column(), other) + + def __lt__(self, other): + return LessThanOperator(self._get_column(), other) + + def __le__(self, other): + return LessThanOrEqualOperator(self._get_column(), other) + + +class NamedColumnDescriptor(AbstractColumnDescriptor): + """ describes a named cql column """ + + def __init__(self, name): + self.name = name + +C = NamedColumnDescriptor + +class TableDescriptor(object): + """ describes a cql table """ + + def __init__(self, keyspace, name): + self.keyspace = keyspace + self.name = name + +T = TableDescriptor + +class KeyspaceDescriptor(object): + """ Describes a cql keyspace """ + + def __init__(self, name): + self.name = name + + def table(self, name): + """ + returns a table descriptor with the given + name that belongs to this keyspace + """ + return TableDescriptor(self.name, name) + +K = KeyspaceDescriptor + class BatchType(object): Unlogged = 'UNLOGGED' Counter = 'COUNTER' From 4a977005f3569d1a946d7021486c1ba465b14a08 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 14 Jun 2013 17:52:13 -0700 Subject: [PATCH 0218/4200] fixed #67 (validation on keyname typos) #66 (removed defaults on columns) and #57 and removed autoid --- changelog | 4 +++ cqlengine/columns.py | 12 ------- cqlengine/models.py | 24 ++++++++------ cqlengine/tests/columns/test_validation.py | 17 ++++++++-- .../tests/model/test_class_construction.py | 31 ++++++++++++++----- .../tests/model/test_equality_operations.py | 2 ++ cqlengine/tests/model/test_model_io.py | 10 ++++++ 7 files changed, 68 insertions(+), 32 deletions(-) diff --git a/changelog b/changelog index 8111f51f61..3d94f5fb91 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,9 @@ CHANGELOG +0.4.0 +* removed default values from all column types +* explicit primary key is required (automatic id removed) + 0.3.3 * added abstract base class models diff --git a/cqlengine/columns.py b/cqlengine/columns.py index d864780cfc..67112eb059 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -236,9 +236,6 @@ def to_database(self, value): class DateTime(Column): db_type = 'timestamp' - def __init__(self, **kwargs): - super(DateTime, self).__init__(**kwargs) - def to_python(self, value): if isinstance(value, datetime): return value @@ -265,8 +262,6 @@ def to_database(self, value): class Date(Column): db_type = 'timestamp' - def __init__(self, **kwargs): - super(Date, self).__init__(**kwargs) def to_python(self, value): if isinstance(value, datetime): @@ -294,9 +289,6 @@ class UUID(Column): re_uuid = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') - def __init__(self, default=lambda:uuid4(), **kwargs): - super(UUID, self).__init__(default=default, **kwargs) - def validate(self, value): val = super(UUID, self).validate(value) if val is None: return @@ -319,10 +311,6 @@ class TimeUUID(UUID): db_type = 'timeuuid' - def __init__(self, **kwargs): - kwargs.setdefault('default', lambda: uuid1()) - super(TimeUUID, self).__init__(**kwargs) - class Boolean(Column): db_type = 'boolean' diff --git a/cqlengine/models.py b/cqlengine/models.py index 64faf6a4d1..7240bf16ca 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -2,7 +2,7 @@ import re from cqlengine import columns -from cqlengine.exceptions import ModelException, CQLEngineException +from cqlengine.exceptions import ModelException, CQLEngineException, ValidationError from cqlengine.query import QuerySet, DMLQuery from cqlengine.query import DoesNotExist as _DoesNotExist from cqlengine.query import MultipleObjectsReturned as _MultipleObjectsReturned @@ -50,7 +50,7 @@ class BaseModel(object): """ The base model class, don't inherit from this, inherit from Model, defined below """ - + class DoesNotExist(_DoesNotExist): pass class MultipleObjectsReturned(_MultipleObjectsReturned): pass @@ -60,12 +60,17 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass #however, you can also define them manually here table_name = None - #the keyspace for this model + #the keyspace for this model keyspace = None read_repair_chance = 0.1 def __init__(self, **values): self._values = {} + + extra_columns = set(values.keys()) - set(self._columns.keys()) + if extra_columns: + raise ValidationError("Incorrect columns passed: {}".format(extra_columns)) + for name, column in self._columns.items(): value = values.get(name, None) if value is not None: value = column.to_python(value) @@ -111,11 +116,11 @@ def column_family_name(cls, include_keyspace=True): else: camelcase = re.compile(r'([a-z])([A-Z])') ccase = lambda s: camelcase.sub(lambda v: '{}_{}'.format(v.group(1), v.group(2).lower()), s) - + module = cls.__module__.split('.') if module: cf_name = ccase(module[-1]) + '_' - + cf_name += ccase(cls.__name__) #trim to less than 48 characters or cassandra will complain cf_name = cf_name[-48:] @@ -140,15 +145,15 @@ def as_dict(self): @classmethod def create(cls, **kwargs): return cls.objects.create(**kwargs) - + @classmethod def all(cls): return cls.objects.all() - + @classmethod def filter(cls, **kwargs): return cls.objects.filter(**kwargs) - + @classmethod def get(cls, **kwargs): return cls.objects.get(**kwargs) @@ -226,8 +231,7 @@ def _transform_column(col_name, col_obj): #prepend primary key if one hasn't been defined if not is_abstract and not any([v.primary_key for k,v in column_definitions]): - k,v = 'id', columns.UUID(primary_key=True) - column_definitions = [(k,v)] + column_definitions + raise ModelDefinitionException("At least 1 primary key is required.") has_partition_keys = any(v.partition_key for (k, v) in column_definitions) diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 24c1739706..acbf9099a9 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -3,6 +3,7 @@ from datetime import date from datetime import tzinfo from decimal import Decimal as D +from uuid import uuid4, uuid1 from cqlengine import ValidationError from cqlengine.tests.base import BaseCassEngTestCase @@ -119,7 +120,7 @@ def test_datetime_io(self): class TestTimeUUID(BaseCassEngTestCase): class TimeUUIDTest(Model): test_id = Integer(primary_key=True) - timeuuid = TimeUUID() + timeuuid = TimeUUID(default=uuid1()) @classmethod def setUpClass(cls): @@ -132,6 +133,10 @@ def tearDownClass(cls): delete_table(cls.TimeUUIDTest) def test_timeuuid_io(self): + """ + ensures that + :return: + """ t0 = self.TimeUUIDTest.create(test_id=0) t1 = self.TimeUUIDTest.get(test_id=0) @@ -139,8 +144,8 @@ def test_timeuuid_io(self): class TestInteger(BaseCassEngTestCase): class IntegerTest(Model): - test_id = UUID(primary_key=True) - value = Integer(default=0) + test_id = UUID(primary_key=True, default=lambda:uuid4()) + value = Integer(default=0, required=True) def test_default_zero_fields_validate(self): """ Tests that integer columns with a default value of 0 validate """ @@ -190,7 +195,13 @@ def test_type_checking(self): +class TestExtraFieldsRaiseException(BaseCassEngTestCase): + class TestModel(Model): + id = UUID(primary_key=True, default=uuid4) + def test_extra_field(self): + with self.assertRaises(ValidationError): + self.TestModel.create(bacon=5000) diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index a37b6baa3c..3e249be2e4 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -1,8 +1,9 @@ +from uuid import uuid4 from cqlengine.query import QueryException from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.exceptions import ModelException, CQLEngineException -from cqlengine.models import Model +from cqlengine.models import Model, ModelDefinitionException from cqlengine import columns import cqlengine @@ -18,6 +19,7 @@ def test_column_attributes_handled_correctly(self): """ class TestModel(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) text = columns.Text() #check class attibutes @@ -38,6 +40,7 @@ def test_db_map(self): -the db_map allows columns """ class WildDBNames(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) content = columns.Text(db_field='words_and_whatnot') numbers = columns.Integer(db_field='integers_etc') @@ -61,17 +64,26 @@ def test_column_ordering_is_preserved(self): """ class Stuff(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) words = columns.Text() content = columns.Text() numbers = columns.Integer() self.assertEquals(Stuff._columns.keys(), ['id', 'words', 'content', 'numbers']) + def test_exception_raised_when_creating_class_without_pk(self): + with self.assertRaises(ModelDefinitionException): + class TestModel(Model): + count = columns.Integer() + text = columns.Text(required=False) + + def test_value_managers_are_keeping_model_instances_isolated(self): """ Tests that instance value managers are isolated from other instances """ class Stuff(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) num = columns.Integer() inst1 = Stuff(num=5) @@ -86,6 +98,7 @@ def test_superclass_fields_are_inherited(self): Tests that fields defined on the super class are inherited properly """ class TestModel(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) text = columns.Text() class InheritedModel(TestModel): @@ -124,6 +137,7 @@ def test_partition_keys(self): Test compound partition key definition """ class ModelWithPartitionKeys(cqlengine.Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) c1 = cqlengine.Text(primary_key=True) p1 = cqlengine.Text(partition_key=True) p2 = cqlengine.Text(partition_key=True) @@ -144,6 +158,7 @@ class ModelWithPartitionKeys(cqlengine.Model): def test_del_attribute_is_assigned_properly(self): """ Tests that columns that can be deleted have the del attribute """ class DelModel(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) key = columns.Integer(primary_key=True) data = columns.Integer(required=False) @@ -156,9 +171,10 @@ def test_does_not_exist_exceptions_are_not_shared_between_model(self): """ Tests that DoesNotExist exceptions are not the same exception between models """ class Model1(Model): - pass + id = columns.UUID(primary_key=True, default=lambda:uuid4()) + class Model2(Model): - pass + id = columns.UUID(primary_key=True, default=lambda:uuid4()) try: raise Model1.DoesNotExist @@ -171,7 +187,8 @@ class Model2(Model): def test_does_not_exist_inherits_from_superclass(self): """ Tests that a DoesNotExist exception can be caught by it's parent class DoesNotExist """ class Model1(Model): - pass + id = columns.UUID(primary_key=True, default=lambda:uuid4()) + class Model2(Model1): pass @@ -184,14 +201,14 @@ class Model2(Model1): assert False, "Model2 exception should not be caught by Model1" class TestManualTableNaming(BaseCassEngTestCase): - + class RenamedTest(cqlengine.Model): keyspace = 'whatever' table_name = 'manual_name' - + id = cqlengine.UUID(primary_key=True) data = cqlengine.Text() - + def test_proper_table_naming(self): assert self.RenamedTest.column_family_name(include_keyspace=False) == 'manual_name' assert self.RenamedTest.column_family_name(include_keyspace=True) == 'whatever.manual_name' diff --git a/cqlengine/tests/model/test_equality_operations.py b/cqlengine/tests/model/test_equality_operations.py index 4d5b95219b..a3e592b3dd 100644 --- a/cqlengine/tests/model/test_equality_operations.py +++ b/cqlengine/tests/model/test_equality_operations.py @@ -1,4 +1,5 @@ from unittest import skip +from uuid import uuid4 from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.management import create_table @@ -7,6 +8,7 @@ from cqlengine import columns class TestModel(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) count = columns.Integer() text = columns.Text(required=False) diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 08e77ded36..08d41d8b27 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -8,10 +8,18 @@ from cqlengine import columns class TestModel(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) count = columns.Integer() text = columns.Text(required=False) a_bool = columns.Boolean(default=False) +class TestModel(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) + count = columns.Integer() + text = columns.Text(required=False) + a_bool = columns.Boolean(default=False) + + class TestModelIO(BaseCassEngTestCase): @classmethod @@ -34,6 +42,8 @@ def test_model_save_and_load(self): for cname in tm._columns.keys(): self.assertEquals(getattr(tm, cname), getattr(tm2, cname)) + + def test_model_updating_works_properly(self): """ Tests that subsequent saves after initial model creation work From f254f4da7a4e93e7b757da272a79c59eb01b64ab Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 14 Jun 2013 17:53:00 -0700 Subject: [PATCH 0219/4200] updated changelog --- changelog | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog b/changelog index 3d94f5fb91..f17057b083 100644 --- a/changelog +++ b/changelog @@ -3,6 +3,7 @@ CHANGELOG 0.4.0 * removed default values from all column types * explicit primary key is required (automatic id removed) +* added validation on keyname types on .create() 0.3.3 * added abstract base class models From 23a59e85a2c2c1717417e7ce4f8ff29df117e82d Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 14 Jun 2013 17:53:15 -0700 Subject: [PATCH 0220/4200] changelog update --- changelog | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog b/changelog index f17057b083..f16994fa22 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,6 @@ CHANGELOG -0.4.0 +0.4.0 (in progress) * removed default values from all column types * explicit primary key is required (automatic id removed) * added validation on keyname types on .create() From 30c59e5e8fa7e1cdc70cf0109690a46f44ed9074 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 14 Jun 2013 17:57:42 -0700 Subject: [PATCH 0221/4200] required is false by default #61 --- cqlengine/columns.py | 2 +- cqlengine/tests/columns/test_validation.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 67112eb059..941d2419aa 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -80,7 +80,7 @@ def __init__(self, index=False, db_field=None, default=None, - required=True, + required=False, clustering_order=None): """ :param primary_key: bool flag, indicates this column is a primary key. The first primary key defined diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index acbf9099a9..627ccbd3e6 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -185,7 +185,7 @@ def test_type_checking(self): Text().validate(bytearray('bytearray')) with self.assertRaises(ValidationError): - Text().validate(None) + Text(required=True).validate(None) with self.assertRaises(ValidationError): Text().validate(5) From 0476a97a67c433843c0ad8dd2d713f1495648ac2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 10:06:55 -0700 Subject: [PATCH 0222/4200] adding query method, and expanding the column descriptor --- cqlengine/query.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cqlengine/query.py b/cqlengine/query.py index dd511d5939..88d16aa42a 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -188,6 +188,13 @@ class NamedColumnDescriptor(AbstractColumnDescriptor): def __init__(self, name): self.name = name + @property + def cql(self): + return self.name + + def to_database(self, val): + return val + C = NamedColumnDescriptor class TableDescriptor(object): @@ -544,6 +551,12 @@ def filter(self, **kwargs): return clone + def query(self, *args): + """ + Same end result as filter, but uses the new comparator style args + ie: Model.column == val + """ + def get(self, **kwargs): """ Returns a single instance matching this query, optionally with additional filter kwargs. From c90da56021b2c047e3a294fbc17d211a2aa8c567 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 10:30:02 -0700 Subject: [PATCH 0223/4200] replacing property stuff with ColumnDescriptor instances --- cqlengine/models.py | 62 ++++++++++++++++--- .../tests/model/test_class_construction.py | 2 +- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 64faf6a4d1..57629260b9 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -1,9 +1,10 @@ from collections import OrderedDict import re +import cqlengine from cqlengine import columns from cqlengine.exceptions import ModelException, CQLEngineException -from cqlengine.query import QuerySet, DMLQuery +from cqlengine.query import QuerySet, DMLQuery, AbstractColumnDescriptor from cqlengine.query import DoesNotExist as _DoesNotExist from cqlengine.query import MultipleObjectsReturned as _MultipleObjectsReturned @@ -46,6 +47,56 @@ def __call__(self, *args, **kwargs): """ Just a hint to IDEs that it's ok to call this """ raise NotImplementedError +class ColumnDescriptor(AbstractColumnDescriptor): + """ + Handles the reading and writing of column values to and from + a model instance's value manager, as well as creating + comparator queries + """ + + def __init__(self, column): + """ + :param column: + :type column: columns.Column + :return: + """ + self.column = column + + def __get__(self, instance, owner): + """ + Returns either the value or column, depending + on if an instance is provided or not + + :param instance: the model instance + :type instance: Model + """ + + if instance: + return instance._values[self.column.column_name].getval() + else: + return self.column + + def __set__(self, instance, value): + """ + Sets the value on an instance, raises an exception with classes + TODO: use None instance to create update statements + """ + if instance: + return instance._values[self.column.column_name].setval(value) + else: + raise AttributeError('cannot reassign column values') + + def __delete__(self, instance): + """ + Sets the column value to None, if possible + """ + if instance: + if self.column.can_delete: + instance._values[self.column.column_name].delval() + else: + raise AttributeError('cannot delete {} columns'.format(self.column.column_name)) + + class BaseModel(object): """ The base model class, don't inherit from this, inherit from Model, defined below @@ -180,7 +231,6 @@ def _inst_batch(self, batch): batch = hybrid_classmethod(_class_batch, _inst_batch) - class ModelMetaClass(type): def __new__(cls, name, bases, attrs): @@ -207,13 +257,7 @@ def _transform_column(col_name, col_obj): primary_keys[col_name] = col_obj col_obj.set_column_name(col_name) #set properties - _get = lambda self: self._values[col_name].getval() - _set = lambda self, val: self._values[col_name].setval(val) - _del = lambda self: self._values[col_name].delval() - if col_obj.can_delete: - attrs[col_name] = property(_get, _set, _del) - else: - attrs[col_name] = property(_get, _set) + attrs[col_name] = ColumnDescriptor(col_obj) column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.Column)] column_definitions = sorted(column_definitions, lambda x,y: cmp(x[1].position, y[1].position)) diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index a37b6baa3c..0739fe467f 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -253,7 +253,7 @@ def test_attempting_query_on_abstract_model_fails(self): def test_abstract_columns_are_inherited(self): """ Tests that columns defined in the abstract class are inherited into the concrete class """ assert hasattr(ConcreteModelWithCol, 'pkey') - assert isinstance(ConcreteModelWithCol.pkey, property) + assert isinstance(ConcreteModelWithCol.pkey, columns.Column) assert isinstance(ConcreteModelWithCol._columns['pkey'], columns.Column) def test_concrete_class_table_creation_cycle(self): From 36082ee04a20fb250ad9e0217c507d8e1186eb89 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 10:34:36 -0700 Subject: [PATCH 0224/4200] updating changelog --- changelog | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog b/changelog index f16994fa22..a697b88734 100644 --- a/changelog +++ b/changelog @@ -4,6 +4,7 @@ CHANGELOG * removed default values from all column types * explicit primary key is required (automatic id removed) * added validation on keyname types on .create() +* changed internal implementation of model value get/set 0.3.3 * added abstract base class models From 4d21458264e239501c4aa284584050a8dc79cb58 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 11:22:12 -0700 Subject: [PATCH 0225/4200] expanding descriptor documentation --- cqlengine/models.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 4875d0b3c2..cf070cd644 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -29,7 +29,9 @@ def __get__(self, instance, owner): return self.instmethod.__get__(instance, owner) def __call__(self, *args, **kwargs): - """ Just a hint to IDEs that it's ok to call this """ + """ + Just a hint to IDEs that it's ok to call this + """ raise NotImplementedError class QuerySetDescriptor(object): @@ -39,12 +41,18 @@ class QuerySetDescriptor(object): """ def __get__(self, obj, model): + """ :rtype: QuerySet """ if model.__abstract__: raise CQLEngineException('cannot execute queries against abstract models') return QuerySet(model) def __call__(self, *args, **kwargs): - """ Just a hint to IDEs that it's ok to call this """ + """ + Just a hint to IDEs that it's ok to call this + + :rtype: QuerySet + """ + raise NotImplementedError raise NotImplementedError class ColumnDescriptor(AbstractColumnDescriptor): From a9c19d3120d2752985aad4ae7f83e6e8dcbc1829 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 11:26:27 -0700 Subject: [PATCH 0226/4200] implementing query method, and mode query method descriptor --- cqlengine/models.py | 19 +++++++++++++++++++ cqlengine/query.py | 16 ++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/cqlengine/models.py b/cqlengine/models.py index cf070cd644..bfa66b871c 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -53,6 +53,25 @@ def __call__(self, *args, **kwargs): :rtype: QuerySet """ raise NotImplementedError + +class QueryExpressionDescriptor(object): + """ + returns a fresh queryset /query method for the given model + it's declared on everytime it's accessed + """ + + def __get__(self, obj, model): + """ :rtype: QuerySet """ + if model.__abstract__: + raise CQLEngineException('cannot execute queries against abstract models') + return QuerySet(model).query + + def __call__(self, *args, **kwargs): + """ + Just a hint to IDEs that it's ok to call this + + :rtype: QuerySet + """ raise NotImplementedError class ColumnDescriptor(AbstractColumnDescriptor): diff --git a/cqlengine/query.py b/cqlengine/query.py index 88d16aa42a..95b6fbc35b 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -530,6 +530,13 @@ def _parse_filter_arg(self, arg): raise QueryException("Can't parse '{}'".format(arg)) def filter(self, **kwargs): + """ + Adds WHERE arguments to the queryset, returning a new queryset + + #TODO: show examples + + :rtype: QuerySet + """ #add arguments to the where clause filters clone = copy.deepcopy(self) for arg, val in kwargs.items(): @@ -555,7 +562,16 @@ def query(self, *args): """ Same end result as filter, but uses the new comparator style args ie: Model.column == val + + #TODO: show examples + + :rtype: QuerySet """ + clone = copy.deepcopy(self) + for operator in args: + clone._where.append(operator) + + return clone def get(self, **kwargs): """ From 1e6531afda1ba055de09388e0a2a0bfb725dd57d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 11:27:36 -0700 Subject: [PATCH 0227/4200] moving column query expression evaluator out of column descriptor --- cqlengine/models.py | 14 ++++++++++++-- cqlengine/tests/model/test_class_construction.py | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index bfa66b871c..808938e067 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -74,7 +74,15 @@ def __call__(self, *args, **kwargs): """ raise NotImplementedError -class ColumnDescriptor(AbstractColumnDescriptor): +class ColumnQueryEvaluator(AbstractColumnDescriptor): + + def __init__(self, column): + self.column = column + + def _get_column(self): + return self.column + +class ColumnDescriptor(object): """ Handles the reading and writing of column values to and from a model instance's value manager, as well as creating @@ -88,6 +96,7 @@ def __init__(self, column): :return: """ self.column = column + self.query_evaluator = ColumnQueryEvaluator(self.column) def __get__(self, instance, owner): """ @@ -101,7 +110,7 @@ def __get__(self, instance, owner): if instance: return instance._values[self.column.column_name].getval() else: - return self.column + return self.query_evaluator def __set__(self, instance, value): """ @@ -133,6 +142,7 @@ class DoesNotExist(_DoesNotExist): pass class MultipleObjectsReturned(_MultipleObjectsReturned): pass objects = QuerySetDescriptor() + query = QueryExpressionDescriptor() #table names will be generated automatically from it's model and package name #however, you can also define them manually here diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 155fc17e1d..25cd590604 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -3,7 +3,7 @@ from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.exceptions import ModelException, CQLEngineException -from cqlengine.models import Model, ModelDefinitionException +from cqlengine.models import Model, ModelDefinitionException, ColumnQueryEvaluator from cqlengine import columns import cqlengine @@ -270,7 +270,7 @@ def test_attempting_query_on_abstract_model_fails(self): def test_abstract_columns_are_inherited(self): """ Tests that columns defined in the abstract class are inherited into the concrete class """ assert hasattr(ConcreteModelWithCol, 'pkey') - assert isinstance(ConcreteModelWithCol.pkey, columns.Column) + assert isinstance(ConcreteModelWithCol.pkey, ColumnQueryEvaluator) assert isinstance(ConcreteModelWithCol._columns['pkey'], columns.Column) def test_concrete_class_table_creation_cycle(self): From c3c21fa1d4b5459617292710dfb083ac38584cda Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 11:27:56 -0700 Subject: [PATCH 0228/4200] adding test around query expressions --- cqlengine/tests/query/test_queryset.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 17954d184a..549eec6869 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -52,6 +52,22 @@ def test_query_filter_parsing(self): assert isinstance(op, query.GreaterThanOrEqualOperator) assert op.value == 1 + def test_query_expression_parsing(self): + """ Tests that query experessions are evaluated properly """ + query1 = TestModel.query(TestModel.test_id == 5) + assert len(query1._where) == 1 + + op = query1._where[0] + assert isinstance(op, query.EqualsOperator) + assert op.value == 5 + + query2 = query1.query(TestModel.expected_result >= 1) + assert len(query2._where) == 2 + + op = query2._where[1] + assert isinstance(op, query.GreaterThanOrEqualOperator) + assert op.value == 1 + def test_using_invalid_column_names_in_filter_kwargs_raises_error(self): """ Tests that using invalid or nonexistant column names for filter args raises an error From 0d251d170a1a2be5d8d76a0746602601de3f938a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 11:33:02 -0700 Subject: [PATCH 0229/4200] adding query method argument validation and supporting tests --- cqlengine/query.py | 2 ++ cqlengine/tests/query/test_queryset.py | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 95b6fbc35b..efeb40e0ca 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -569,6 +569,8 @@ def query(self, *args): """ clone = copy.deepcopy(self) for operator in args: + if not isinstance(operator, QueryOperator): + raise QueryException('{} is not a valid query operator'.format(operator)) clone._where.append(operator) return clone diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 549eec6869..7208075ea2 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -73,7 +73,21 @@ def test_using_invalid_column_names_in_filter_kwargs_raises_error(self): Tests that using invalid or nonexistant column names for filter args raises an error """ with self.assertRaises(query.QueryException): - query0 = TestModel.objects(nonsense=5) + TestModel.objects(nonsense=5) + + def test_using_nonexistant_column_names_in_query_args_raises_error(self): + """ + Tests that using invalid or nonexistant columns for query args raises an error + """ + with self.assertRaises(AttributeError): + TestModel.query(TestModel.nonsense == 5) + + def test_using_non_query_operators_in_query_args_raises_error(self): + """ + Tests that providing query args that are not query operator instances raises an error + """ + with self.assertRaises(query.QueryException): + TestModel.query(5) def test_where_clause_generation(self): """ From 286da25d9aa8d7528217a4c7497cc3dbcd4111d1 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 11:36:43 -0700 Subject: [PATCH 0230/4200] adding more tests around query method behavior --- cqlengine/tests/query/test_queryset.py | 27 +++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 7208075ea2..3d9cca8abb 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -89,7 +89,7 @@ def test_using_non_query_operators_in_query_args_raises_error(self): with self.assertRaises(query.QueryException): TestModel.query(5) - def test_where_clause_generation(self): + def test_filter_method_where_clause_generation(self): """ Tests the where clause creation """ @@ -103,6 +103,19 @@ def test_where_clause_generation(self): where = query2._where_clause() assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) + def test_query_method_where_clause_generation(self): + """ + Tests the where clause creation + """ + query1 = TestModel.query(TestModel.test_id == 5) + ids = [o.query_value.identifier for o in query1._where] + where = query1._where_clause() + assert where == '"test_id" = :{}'.format(*ids) + + query2 = query1.query(TestModel.expected_result >= 1) + ids = [o.query_value.identifier for o in query2._where] + where = query2._where_clause() + assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) def test_querystring_generation(self): """ @@ -118,6 +131,18 @@ def test_queryset_is_immutable(self): query2 = query1.filter(expected_result__gte=1) assert len(query2._where) == 2 + assert len(query1._where) == 1 + + def test_querymethod_queryset_is_immutable(self): + """ + Tests that calling a queryset function that changes it's state returns a new queryset + """ + query1 = TestModel.query(TestModel.test_id == 5) + assert len(query1._where) == 1 + + query2 = query1.query(TestModel.expected_result >= 1) + assert len(query2._where) == 2 + assert len(query1._where) == 1 def test_the_all_method_duplicates_queryset(self): """ From 83d904f58f76dd628f1c97392dd353bd3c7cdc22 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 11:45:11 -0700 Subject: [PATCH 0231/4200] adding additional tests around query method --- cqlengine/tests/query/test_queryset.py | 63 ++++++++++++++++++-------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 3d9cca8abb..230c1dd06f 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -218,12 +218,21 @@ def tearDownClass(cls): class TestQuerySetCountSelectionAndIteration(BaseQuerySetUsage): def test_count(self): + """ Tests that adding filtering statements affects the count query as expected """ assert TestModel.objects.count() == 12 q = TestModel.objects(test_id=0) assert q.count() == 4 + def test_query_method_count(self): + """ Tests that adding query statements affects the count query as expected """ + assert TestModel.objects.count() == 12 + + q = TestModel.query(TestModel.test_id == 0) + assert q.count() == 4 + def test_iteration(self): + """ Tests that iterating over a query set pulls back all of the expected results """ q = TestModel.objects(test_id=0) #tuple of expected attempt_id, expected_result values compare_set = set([(0,5), (1,10), (2,15), (3,20)]) @@ -233,6 +242,7 @@ def test_iteration(self): compare_set.remove(val) assert len(compare_set) == 0 + # test with regular filtering q = TestModel.objects(attempt_id=3).allow_filtering() assert len(q) == 3 #tuple of expected test_id, expected_result values @@ -243,36 +253,49 @@ def test_iteration(self): compare_set.remove(val) assert len(compare_set) == 0 - def test_multiple_iterations_work_properly(self): - """ Tests that iterating over a query set more than once works """ - q = TestModel.objects(test_id=0) - #tuple of expected attempt_id, expected_result values - compare_set = set([(0,5), (1,10), (2,15), (3,20)]) + # test with query method + q = TestModel.query(TestModel.attempt_id == 3).allow_filtering() + assert len(q) == 3 + #tuple of expected test_id, expected_result values + compare_set = set([(0,20), (1,20), (2,75)]) for t in q: - val = t.attempt_id, t.expected_result + val = t.test_id, t.expected_result assert val in compare_set compare_set.remove(val) assert len(compare_set) == 0 - #try it again - compare_set = set([(0,5), (1,10), (2,15), (3,20)]) - for t in q: - val = t.attempt_id, t.expected_result - assert val in compare_set - compare_set.remove(val) - assert len(compare_set) == 0 + def test_multiple_iterations_work_properly(self): + """ Tests that iterating over a query set more than once works """ + # test with both the filtering method and the query method + for q in (TestModel.objects(test_id=0), TestModel.query(TestModel.test_id==0)): + #tuple of expected attempt_id, expected_result values + compare_set = set([(0,5), (1,10), (2,15), (3,20)]) + for t in q: + val = t.attempt_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 + + #try it again + compare_set = set([(0,5), (1,10), (2,15), (3,20)]) + for t in q: + val = t.attempt_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 def test_multiple_iterators_are_isolated(self): """ tests that the use of one iterator does not affect the behavior of another """ - q = TestModel.objects(test_id=0).order_by('attempt_id') - expected_order = [0,1,2,3] - iter1 = iter(q) - iter2 = iter(q) - for attempt_id in expected_order: - assert iter1.next().attempt_id == attempt_id - assert iter2.next().attempt_id == attempt_id + for q in (TestModel.objects(test_id=0), TestModel.query(TestModel.test_id==0)): + q = q.order_by('attempt_id') + expected_order = [0,1,2,3] + iter1 = iter(q) + iter2 = iter(q) + for attempt_id in expected_order: + assert iter1.next().attempt_id == attempt_id + assert iter2.next().attempt_id == attempt_id def test_get_success_case(self): """ From b6701db99ed17e15c3e26dcd82fb061ca3dfbf29 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 11:54:23 -0700 Subject: [PATCH 0232/4200] rolling all query types into the filter method and removing the query method --- cqlengine/models.py | 29 ++++------------------- cqlengine/query.py | 32 ++++++++------------------ cqlengine/tests/query/test_queryset.py | 24 +++++++++---------- 3 files changed, 27 insertions(+), 58 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 808938e067..afc2678ddd 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -34,6 +34,7 @@ def __call__(self, *args, **kwargs): """ raise NotImplementedError + class QuerySetDescriptor(object): """ returns a fresh queryset for the given model @@ -54,25 +55,6 @@ def __call__(self, *args, **kwargs): """ raise NotImplementedError -class QueryExpressionDescriptor(object): - """ - returns a fresh queryset /query method for the given model - it's declared on everytime it's accessed - """ - - def __get__(self, obj, model): - """ :rtype: QuerySet """ - if model.__abstract__: - raise CQLEngineException('cannot execute queries against abstract models') - return QuerySet(model).query - - def __call__(self, *args, **kwargs): - """ - Just a hint to IDEs that it's ok to call this - - :rtype: QuerySet - """ - raise NotImplementedError class ColumnQueryEvaluator(AbstractColumnDescriptor): @@ -142,7 +124,6 @@ class DoesNotExist(_DoesNotExist): pass class MultipleObjectsReturned(_MultipleObjectsReturned): pass objects = QuerySetDescriptor() - query = QueryExpressionDescriptor() #table names will be generated automatically from it's model and package name #however, you can also define them manually here @@ -239,12 +220,12 @@ def all(cls): return cls.objects.all() @classmethod - def filter(cls, **kwargs): - return cls.objects.filter(**kwargs) + def filter(cls, *args, **kwargs): + return cls.objects.filter(*args, **kwargs) @classmethod - def get(cls, **kwargs): - return cls.objects.get(**kwargs) + def get(cls, *args, **kwargs): + return cls.objects.get(*args, **kwargs) def save(self): is_new = self.pk is None diff --git a/cqlengine/query.py b/cqlengine/query.py index efeb40e0ca..47eabf9ade 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -316,8 +316,8 @@ def __unicode__(self): def __str__(self): return str(self.__unicode__()) - def __call__(self, **kwargs): - return self.filter(**kwargs) + def __call__(self, *args, **kwargs): + return self.filter(*args, **kwargs) def __deepcopy__(self, memo): clone = self.__class__(self.model) @@ -529,7 +529,7 @@ def _parse_filter_arg(self, arg): else: raise QueryException("Can't parse '{}'".format(arg)) - def filter(self, **kwargs): + def filter(self, *args, **kwargs): """ Adds WHERE arguments to the queryset, returning a new queryset @@ -539,6 +539,11 @@ def filter(self, **kwargs): """ #add arguments to the where clause filters clone = copy.deepcopy(self) + for operator in args: + if not isinstance(operator, QueryOperator): + raise QueryException('{} is not a valid query operator'.format(operator)) + clone._where.append(operator) + for arg, val in kwargs.items(): col_name, col_op = self._parse_filter_arg(arg) #resolve column and operator @@ -558,31 +563,14 @@ def filter(self, **kwargs): return clone - def query(self, *args): - """ - Same end result as filter, but uses the new comparator style args - ie: Model.column == val - - #TODO: show examples - - :rtype: QuerySet - """ - clone = copy.deepcopy(self) - for operator in args: - if not isinstance(operator, QueryOperator): - raise QueryException('{} is not a valid query operator'.format(operator)) - clone._where.append(operator) - - return clone - - def get(self, **kwargs): + def get(self, *args, **kwargs): """ Returns a single instance matching this query, optionally with additional filter kwargs. A DoesNotExistError will be raised if there are no rows matching the query A MultipleObjectsFoundError will be raised if there is more than one row matching the queyr """ - if kwargs: return self.filter(**kwargs).get() + if kwargs: return self.filter(*args, **kwargs).get() self._execute_query() if len(self._result_cache) == 0: raise self.model.DoesNotExist diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 230c1dd06f..0b68617869 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -54,14 +54,14 @@ def test_query_filter_parsing(self): def test_query_expression_parsing(self): """ Tests that query experessions are evaluated properly """ - query1 = TestModel.query(TestModel.test_id == 5) + query1 = TestModel.filter(TestModel.test_id == 5) assert len(query1._where) == 1 op = query1._where[0] assert isinstance(op, query.EqualsOperator) assert op.value == 5 - query2 = query1.query(TestModel.expected_result >= 1) + query2 = query1.filter(TestModel.expected_result >= 1) assert len(query2._where) == 2 op = query2._where[1] @@ -80,14 +80,14 @@ def test_using_nonexistant_column_names_in_query_args_raises_error(self): Tests that using invalid or nonexistant columns for query args raises an error """ with self.assertRaises(AttributeError): - TestModel.query(TestModel.nonsense == 5) + TestModel.objects(TestModel.nonsense == 5) def test_using_non_query_operators_in_query_args_raises_error(self): """ Tests that providing query args that are not query operator instances raises an error """ with self.assertRaises(query.QueryException): - TestModel.query(5) + TestModel.objects(5) def test_filter_method_where_clause_generation(self): """ @@ -107,12 +107,12 @@ def test_query_method_where_clause_generation(self): """ Tests the where clause creation """ - query1 = TestModel.query(TestModel.test_id == 5) + query1 = TestModel.objects(TestModel.test_id == 5) ids = [o.query_value.identifier for o in query1._where] where = query1._where_clause() assert where == '"test_id" = :{}'.format(*ids) - query2 = query1.query(TestModel.expected_result >= 1) + query2 = query1.filter(TestModel.expected_result >= 1) ids = [o.query_value.identifier for o in query2._where] where = query2._where_clause() assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) @@ -137,10 +137,10 @@ def test_querymethod_queryset_is_immutable(self): """ Tests that calling a queryset function that changes it's state returns a new queryset """ - query1 = TestModel.query(TestModel.test_id == 5) + query1 = TestModel.objects(TestModel.test_id == 5) assert len(query1._where) == 1 - query2 = query1.query(TestModel.expected_result >= 1) + query2 = query1.filter(TestModel.expected_result >= 1) assert len(query2._where) == 2 assert len(query1._where) == 1 @@ -228,7 +228,7 @@ def test_query_method_count(self): """ Tests that adding query statements affects the count query as expected """ assert TestModel.objects.count() == 12 - q = TestModel.query(TestModel.test_id == 0) + q = TestModel.objects(TestModel.test_id == 0) assert q.count() == 4 def test_iteration(self): @@ -254,7 +254,7 @@ def test_iteration(self): assert len(compare_set) == 0 # test with query method - q = TestModel.query(TestModel.attempt_id == 3).allow_filtering() + q = TestModel.objects(TestModel.attempt_id == 3).allow_filtering() assert len(q) == 3 #tuple of expected test_id, expected_result values compare_set = set([(0,20), (1,20), (2,75)]) @@ -267,7 +267,7 @@ def test_iteration(self): def test_multiple_iterations_work_properly(self): """ Tests that iterating over a query set more than once works """ # test with both the filtering method and the query method - for q in (TestModel.objects(test_id=0), TestModel.query(TestModel.test_id==0)): + for q in (TestModel.objects(test_id=0), TestModel.objects(TestModel.test_id==0)): #tuple of expected attempt_id, expected_result values compare_set = set([(0,5), (1,10), (2,15), (3,20)]) for t in q: @@ -288,7 +288,7 @@ def test_multiple_iterators_are_isolated(self): """ tests that the use of one iterator does not affect the behavior of another """ - for q in (TestModel.objects(test_id=0), TestModel.query(TestModel.test_id==0)): + for q in (TestModel.objects(test_id=0), TestModel.objects(TestModel.test_id==0)): q = q.order_by('attempt_id') expected_order = [0,1,2,3] iter1 = iter(q) From 264fcaeb7b277573fb6e057cb40c3bf1ba8a9416 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 12:37:36 -0700 Subject: [PATCH 0233/4200] updating the get methods to work with query expressions --- cqlengine/query.py | 4 ++- cqlengine/tests/query/test_queryset.py | 40 ++++++++++++++++---------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 47eabf9ade..a56a8a33cc 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -570,7 +570,9 @@ def get(self, *args, **kwargs): A DoesNotExistError will be raised if there are no rows matching the query A MultipleObjectsFoundError will be raised if there is more than one row matching the queyr """ - if kwargs: return self.filter(*args, **kwargs).get() + if args or kwargs: + return self.filter(*args, **kwargs).get() + self._execute_query() if len(self._result_cache) == 0: raise self.model.DoesNotExist diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 0b68617869..921da1b559 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -103,7 +103,7 @@ def test_filter_method_where_clause_generation(self): where = query2._where_clause() assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) - def test_query_method_where_clause_generation(self): + def test_query_expression_where_clause_generation(self): """ Tests the where clause creation """ @@ -133,17 +133,6 @@ def test_queryset_is_immutable(self): assert len(query2._where) == 2 assert len(query1._where) == 1 - def test_querymethod_queryset_is_immutable(self): - """ - Tests that calling a queryset function that changes it's state returns a new queryset - """ - query1 = TestModel.objects(TestModel.test_id == 5) - assert len(query1._where) == 1 - - query2 = query1.filter(TestModel.expected_result >= 1) - assert len(query2._where) == 2 - assert len(query1._where) == 1 - def test_the_all_method_duplicates_queryset(self): """ Tests that calling all on a queryset with previously defined filters duplicates queryset @@ -224,7 +213,7 @@ def test_count(self): q = TestModel.objects(test_id=0) assert q.count() == 4 - def test_query_method_count(self): + def test_query_expression_count(self): """ Tests that adding query statements affects the count query as expected """ assert TestModel.objects.count() == 12 @@ -267,7 +256,7 @@ def test_iteration(self): def test_multiple_iterations_work_properly(self): """ Tests that iterating over a query set more than once works """ # test with both the filtering method and the query method - for q in (TestModel.objects(test_id=0), TestModel.objects(TestModel.test_id==0)): + for q in (TestModel.objects(test_id=0), TestModel.objects(TestModel.test_id == 0)): #tuple of expected attempt_id, expected_result values compare_set = set([(0,5), (1,10), (2,15), (3,20)]) for t in q: @@ -288,7 +277,7 @@ def test_multiple_iterators_are_isolated(self): """ tests that the use of one iterator does not affect the behavior of another """ - for q in (TestModel.objects(test_id=0), TestModel.objects(TestModel.test_id==0)): + for q in (TestModel.objects(test_id=0), TestModel.objects(TestModel.test_id == 0)): q = q.order_by('attempt_id') expected_order = [0,1,2,3] iter1 = iter(q) @@ -318,6 +307,27 @@ def test_get_success_case(self): assert m.test_id == 0 assert m.attempt_id == 0 + def test_query_expression_get_success_case(self): + """ + Tests that the .get() method works on new and existing querysets + """ + m = TestModel.get(TestModel.test_id == 0, TestModel.attempt_id == 0) + assert isinstance(m, TestModel) + assert m.test_id == 0 + assert m.attempt_id == 0 + + q = TestModel.objects(TestModel.test_id == 0, TestModel.attempt_id == 0) + m = q.get() + assert isinstance(m, TestModel) + assert m.test_id == 0 + assert m.attempt_id == 0 + + q = TestModel.objects(TestModel.test_id == 0) + m = q.get(TestModel.attempt_id == 0) + assert isinstance(m, TestModel) + assert m.test_id == 0 + assert m.attempt_id == 0 + def test_get_doesnotexist_exception(self): """ Tests that get calls that don't return a result raises a DoesNotExist error From f8c4317098ebb710570f79207044067c2fd1afe6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 12:45:36 -0700 Subject: [PATCH 0234/4200] adding additional tests around the query expression queries --- cqlengine/tests/query/test_queryset.py | 40 ++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 921da1b559..943efb2093 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -361,10 +361,14 @@ def test_order_by_success_case(self): assert model.attempt_id == expect def test_ordering_by_non_second_primary_keys_fail(self): - + # kwarg filtering with self.assertRaises(query.QueryException): q = TestModel.objects(test_id=0).order_by('test_id') + # kwarg filtering + with self.assertRaises(query.QueryException): + q = TestModel.objects(TestModel.test_id == 0).order_by('test_id') + def test_ordering_by_non_primary_keys_fails(self): with self.assertRaises(query.QueryException): q = TestModel.objects(test_id=0).order_by('description') @@ -431,7 +435,7 @@ def test_primary_key_or_index_must_be_specified(self): """ with self.assertRaises(query.QueryException): q = TestModel.objects(test_result=25) - [i for i in q] + list([i for i in q]) def test_primary_key_or_index_must_have_equal_relation_filter(self): """ @@ -439,7 +443,7 @@ def test_primary_key_or_index_must_have_equal_relation_filter(self): """ with self.assertRaises(query.QueryException): q = TestModel.objects(test_id__gt=0) - [i for i in q] + list([i for i in q]) def test_indexed_field_can_be_queried(self): @@ -447,7 +451,6 @@ def test_indexed_field_can_be_queried(self): Tests that queries on an indexed field will work without any primary key relations specified """ q = IndexedTestModel.objects(test_result=25) - count = q.count() assert q.count() == 4 class TestQuerySetDelete(BaseQuerySetUsage): @@ -526,6 +529,7 @@ def test_success_case(self): TimeUUIDQueryModel.create(partition=pk, time=uuid1(), data='4') time.sleep(0.2) + # test kwarg filtering q = TimeUUIDQueryModel.filter(partition=pk, time__lte=functions.MaxTimeUUID(midpoint)) q = [d for d in q] assert len(q) == 2 @@ -539,13 +543,39 @@ def test_success_case(self): assert '3' in datas assert '4' in datas + # test query expression filtering + q = TimeUUIDQueryModel.filter( + TimeUUIDQueryModel.partition == pk, + TimeUUIDQueryModel.time <= functions.MaxTimeUUID(midpoint) + ) + q = [d for d in q] + assert len(q) == 2 + datas = [d.data for d in q] + assert '1' in datas + assert '2' in datas + + q = TimeUUIDQueryModel.filter( + TimeUUIDQueryModel.partition == pk, + TimeUUIDQueryModel.time >= functions.MinTimeUUID(midpoint) + ) + assert len(q) == 2 + datas = [d.data for d in q] + assert '3' in datas + assert '4' in datas + class TestInOperator(BaseQuerySetUsage): - def test_success_case(self): + def test_kwarg_success_case(self): + """ Tests the in operator works with the kwarg query method """ q = TestModel.filter(test_id__in=[0,1]) assert q.count() == 8 + def test_query_expression_success_case(self): + """ Tests the in operator works with the query expression query method """ + q = TestModel.filter(TestModel.test_id in [0, 1]) + assert q.count() == 8 + class TestValuesList(BaseQuerySetUsage): def test_values_list(self): From 9f94df4b8eb15a9cd27d411e4105124613be3c88 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Sat, 15 Jun 2013 12:50:12 -0700 Subject: [PATCH 0235/4200] #58 - renamed model configuration attributes to use __double_underscore__ style --- changelog | 1 + cqlengine/management.py | 2 +- cqlengine/models.py | 15 ++++++++------- cqlengine/tests/model/test_class_construction.py | 4 ++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/changelog b/changelog index f16994fa22..423f2783a7 100644 --- a/changelog +++ b/changelog @@ -4,6 +4,7 @@ CHANGELOG * removed default values from all column types * explicit primary key is required (automatic id removed) * added validation on keyname types on .create() +* changed table_name to __table_name__, read_repair_chance to __read_repair_chance__, keyspace to __keyspace__ 0.3.3 * added abstract base class models diff --git a/cqlengine/management.py b/cqlengine/management.py index 90005d2bc2..b3504b919e 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -83,7 +83,7 @@ def add_column(col): qs += ['({})'.format(', '.join(qtypes))] - with_qs = ['read_repair_chance = {}'.format(model.read_repair_chance)] + with_qs = ['read_repair_chance = {}'.format(model.__read_repair_chance__)] _order = ["%s %s" % (c.db_field_name, c.clustering_order or 'ASC') for c in model._clustering_keys.values()] if _order: diff --git a/cqlengine/models.py b/cqlengine/models.py index 7240bf16ca..5e8f6ef308 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -58,11 +58,12 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass #table names will be generated automatically from it's model and package name #however, you can also define them manually here - table_name = None + __table_name__ = None #the keyspace for this model - keyspace = None - read_repair_chance = 0.1 + __keyspace__ = None + + __read_repair_chance__ = 0.1 def __init__(self, **values): self._values = {} @@ -96,7 +97,7 @@ def _can_update(self): @classmethod def _get_keyspace(cls): """ Returns the manual keyspace, if set, otherwise the default keyspace """ - return cls.keyspace or DEFAULT_KEYSPACE + return cls.__keyspace__ or DEFAULT_KEYSPACE def __eq__(self, other): return self.as_dict() == other.as_dict() @@ -111,8 +112,8 @@ def column_family_name(cls, include_keyspace=True): otherwise, it creates it from the module and class name """ cf_name = '' - if cls.table_name: - cf_name = cls.table_name.lower() + if cls.__table_name__: + cf_name = cls.__table_name__.lower() else: camelcase = re.compile(r'([a-z])([A-Z])') ccase = lambda s: camelcase.sub(lambda v: '{}_{}'.format(v.group(1), v.group(2).lower()), s) @@ -279,7 +280,7 @@ def _transform_column(col_name, col_obj): db_map[col.db_field_name] = field_name #short circuit table_name inheritance - attrs['table_name'] = attrs.get('table_name') + attrs['table_name'] = attrs.get('__table_name__') #add management members to the class attrs['_columns'] = column_dict diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 3e249be2e4..a3dda94f7b 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -203,8 +203,8 @@ class Model2(Model1): class TestManualTableNaming(BaseCassEngTestCase): class RenamedTest(cqlengine.Model): - keyspace = 'whatever' - table_name = 'manual_name' + __keyspace__ = 'whatever' + __table_name__ = 'manual_name' id = cqlengine.UUID(primary_key=True) data = cqlengine.Text() From 09883d6b0408424e32b4190da41271e6673d8f85 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 16:46:34 -0700 Subject: [PATCH 0236/4200] changed __contains__ to in_() --- cqlengine/query.py | 14 ++++++++------ cqlengine/tests/query/test_queryset.py | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index a56a8a33cc..8d7a6392de 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -163,12 +163,17 @@ class AbstractColumnDescriptor(object): def _get_column(self): raise NotImplementedError - def __eq__(self, other): - return EqualsOperator(self._get_column(), other) + def in_(self, item): + """ + Returns an in operator - def __contains__(self, item): + used in where you'd typically want to use python's `in` operator + """ return InOperator(self._get_column(), item) + def __eq__(self, other): + return EqualsOperator(self._get_column(), other) + def __gt__(self, other): return GreaterThanOperator(self._get_column(), other) @@ -195,7 +200,6 @@ def cql(self): def to_database(self, val): return val -C = NamedColumnDescriptor class TableDescriptor(object): """ describes a cql table """ @@ -204,7 +208,6 @@ def __init__(self, keyspace, name): self.keyspace = keyspace self.name = name -T = TableDescriptor class KeyspaceDescriptor(object): """ Describes a cql keyspace """ @@ -219,7 +222,6 @@ def table(self, name): """ return TableDescriptor(self.name, name) -K = KeyspaceDescriptor class BatchType(object): Unlogged = 'UNLOGGED' diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 943efb2093..117c87c18b 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -573,7 +573,7 @@ def test_kwarg_success_case(self): def test_query_expression_success_case(self): """ Tests the in operator works with the query expression query method """ - q = TestModel.filter(TestModel.test_id in [0, 1]) + q = TestModel.filter(TestModel.test_id.in_([0, 1])) assert q.count() == 8 From d50fdfab8b4fbe6fbdb732d69fe54e91af4a0ecc Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 16:55:22 -0700 Subject: [PATCH 0237/4200] renaming queryable column --- cqlengine/models.py | 12 +++++++++--- cqlengine/query.py | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index afc2678ddd..55b03dc20a 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -1,10 +1,9 @@ from collections import OrderedDict import re -import cqlengine from cqlengine import columns from cqlengine.exceptions import ModelException, CQLEngineException, ValidationError -from cqlengine.query import QuerySet, DMLQuery, AbstractColumnDescriptor +from cqlengine.query import QuerySet, DMLQuery, AbstractQueryableColumn from cqlengine.query import DoesNotExist as _DoesNotExist from cqlengine.query import MultipleObjectsReturned as _MultipleObjectsReturned @@ -56,7 +55,14 @@ def __call__(self, *args, **kwargs): raise NotImplementedError -class ColumnQueryEvaluator(AbstractColumnDescriptor): +class ColumnQueryEvaluator(AbstractQueryableColumn): + """ + Wraps a column and allows it to be used in comparator + expressions, returning query operators + + ie: + Model.column == 5 + """ def __init__(self, column): self.column = column diff --git a/cqlengine/query.py b/cqlengine/query.py index 8d7a6392de..af3a01ddfe 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -154,7 +154,7 @@ class LessThanOrEqualOperator(QueryOperator): symbol = "LTE" cql_symbol = '<=' -class AbstractColumnDescriptor(object): +class AbstractQueryableColumn(object): """ exposes cql query operators through pythons builtin comparator symbols @@ -187,7 +187,7 @@ def __le__(self, other): return LessThanOrEqualOperator(self._get_column(), other) -class NamedColumnDescriptor(AbstractColumnDescriptor): +class NamedColumnDescriptor(AbstractQueryableColumn): """ describes a named cql column """ def __init__(self, name): From 005129e6e1fd581962ccb1b9a7892613ed1e7eb7 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 17:37:04 -0700 Subject: [PATCH 0238/4200] moving named classes into their own file, and renaming them --- cqlengine/named.py | 41 +++++++++++++++++++++++++++++++++++++++++ cqlengine/query.py | 36 ------------------------------------ 2 files changed, 41 insertions(+), 36 deletions(-) create mode 100644 cqlengine/named.py diff --git a/cqlengine/named.py b/cqlengine/named.py new file mode 100644 index 0000000000..f56032486b --- /dev/null +++ b/cqlengine/named.py @@ -0,0 +1,41 @@ +from cqlengine.query import AbstractQueryableColumn + + +class NamedColumn(AbstractQueryableColumn): + """ describes a named cql column """ + + def __init__(self, name): + self.name = name + + @property + def cql(self): + return self.name + + def to_database(self, val): + return val + + +class NamedTable(object): + """ describes a cql table """ + + def __init__(self, keyspace, name): + self.keyspace = keyspace + self.name = name + + def column(self, name): + return NamedColumn(name) + + +class NamedKeyspace(object): + """ Describes a cql keyspace """ + + def __init__(self, name): + self.name = name + + def table(self, name): + """ + returns a table descriptor with the given + name that belongs to this keyspace + """ + return NamedTable(self.name, name) + diff --git a/cqlengine/query.py b/cqlengine/query.py index af3a01ddfe..a626a817ed 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -187,42 +187,6 @@ def __le__(self, other): return LessThanOrEqualOperator(self._get_column(), other) -class NamedColumnDescriptor(AbstractQueryableColumn): - """ describes a named cql column """ - - def __init__(self, name): - self.name = name - - @property - def cql(self): - return self.name - - def to_database(self, val): - return val - - -class TableDescriptor(object): - """ describes a cql table """ - - def __init__(self, keyspace, name): - self.keyspace = keyspace - self.name = name - - -class KeyspaceDescriptor(object): - """ Describes a cql keyspace """ - - def __init__(self, name): - self.name = name - - def table(self, name): - """ - returns a table descriptor with the given - name that belongs to this keyspace - """ - return TableDescriptor(self.name, name) - - class BatchType(object): Unlogged = 'UNLOGGED' Counter = 'COUNTER' From 13075bc8a56fbbed36e416c3fda850a373be3357 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 17:40:18 -0700 Subject: [PATCH 0239/4200] adding tests around name object query creation --- cqlengine/tests/query/test_named.py | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 cqlengine/tests/query/test_named.py diff --git a/cqlengine/tests/query/test_named.py b/cqlengine/tests/query/test_named.py new file mode 100644 index 0000000000..a7379b0aaa --- /dev/null +++ b/cqlengine/tests/query/test_named.py @@ -0,0 +1,47 @@ +from cqlengine import query +from cqlengine.named import NamedKeyspace +from cqlengine.tests.query.test_queryset import TestModel +from cqlengine.tests.base import BaseCassEngTestCase + + +class TestQuerySetOperation(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestQuerySetOperation, cls).setUpClass() + cls.keyspace = NamedKeyspace('cqlengine_test') + cls.table = cls.keyspace.table('test_model') + + def test_query_filter_parsing(self): + """ + Tests the queryset filter method parses it's kwargs properly + """ + query1 = self.table.objects(test_id=5) + assert len(query1._where) == 1 + + op = query1._where[0] + assert isinstance(op, query.EqualsOperator) + assert op.value == 5 + + query2 = query1.filter(expected_result__gte=1) + assert len(query2._where) == 2 + + op = query2._where[1] + assert isinstance(op, query.GreaterThanOrEqualOperator) + assert op.value == 1 + + def test_query_expression_parsing(self): + """ Tests that query experessions are evaluated properly """ + query1 = self.table.filter(self.table.column('test_id') == 5) + assert len(query1._where) == 1 + + op = query1._where[0] + assert isinstance(op, query.EqualsOperator) + assert op.value == 5 + + query2 = query1.filter(self.table.column('expected_result') >= 1) + assert len(query2._where) == 2 + + op = query2._where[1] + assert isinstance(op, query.GreaterThanOrEqualOperator) + assert op.value == 1 From 50506f9a970966ea5544396f08385ffa0a2d0a0f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 17:42:40 -0700 Subject: [PATCH 0240/4200] adding methods to mimic regular models --- cqlengine/named.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cqlengine/named.py b/cqlengine/named.py index f56032486b..f2ab1d04d1 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -1,3 +1,4 @@ +from cqlengine.models import QuerySetDescriptor from cqlengine.query import AbstractQueryableColumn @@ -7,6 +8,9 @@ class NamedColumn(AbstractQueryableColumn): def __init__(self, name): self.name = name + def _get_column(self): + return self + @property def cql(self): return self.name @@ -25,6 +29,25 @@ def __init__(self, keyspace, name): def column(self, name): return NamedColumn(name) + __abstract__ = False + objects = QuerySetDescriptor() + + @classmethod + def create(cls, **kwargs): + return cls.objects.create(**kwargs) + + @classmethod + def all(cls): + return cls.objects.all() + + @classmethod + def filter(cls, *args, **kwargs): + return cls.objects.filter(*args, **kwargs) + + @classmethod + def get(cls, *args, **kwargs): + return cls.objects.get(*args, **kwargs) + class NamedKeyspace(object): """ Describes a cql keyspace """ From 58e38bca76169291aded8b73042c952929425683 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 17:51:14 -0700 Subject: [PATCH 0241/4200] adding fake _column --- cqlengine/named.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/cqlengine/named.py b/cqlengine/named.py index f2ab1d04d1..abc19ac997 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -1,3 +1,5 @@ +from collections import defaultdict, namedtuple + from cqlengine.models import QuerySetDescriptor from cqlengine.query import AbstractQueryableColumn @@ -22,16 +24,25 @@ def to_database(self, val): class NamedTable(object): """ describes a cql table """ + __abstract__ = False + + class ColumnContainer(dict): + def __missing__(self, name): + column = NamedColumn(name) + self[name] = column + return column + _columns = ColumnContainer() + + objects = QuerySetDescriptor() + def __init__(self, keyspace, name): self.keyspace = keyspace self.name = name - def column(self, name): + @classmethod + def column(cls, name): return NamedColumn(name) - __abstract__ = False - objects = QuerySetDescriptor() - @classmethod def create(cls, **kwargs): return cls.objects.create(**kwargs) From df2a769c6aca4410c4efcf3e4082468f8997d0a5 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 15 Jun 2013 17:51:32 -0700 Subject: [PATCH 0242/4200] commenting out named table create, for now --- cqlengine/named.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cqlengine/named.py b/cqlengine/named.py index abc19ac997..0072c5318a 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -43,9 +43,9 @@ def __init__(self, keyspace, name): def column(cls, name): return NamedColumn(name) - @classmethod - def create(cls, **kwargs): - return cls.objects.create(**kwargs) + # @classmethod + # def create(cls, **kwargs): + # return cls.objects.create(**kwargs) @classmethod def all(cls): From e6644239bb61981f7728cf37495967ed6a26ce73 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Sat, 15 Jun 2013 22:56:57 -0700 Subject: [PATCH 0243/4200] updated docs --- docs/topics/queryset.rst | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index 48e26a7cba..c237ed6b59 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -29,9 +29,9 @@ Retrieving objects with filters That can be accomplished with the QuerySet's ``.filter(\*\*)`` method. For example, given the model definition: - + .. code-block:: python - + class Automobile(Model): manufacturer = columns.Text(primary_key=True) year = columns.Integer(primary_key=True) @@ -40,11 +40,17 @@ Retrieving objects with filters ...and assuming the Automobile table contains a record of every car model manufactured in the last 20 years or so, we can retrieve only the cars made by a single manufacturer like this: - + .. code-block:: python q = Automobile.objects.filter(manufacturer='Tesla') + You can also use the more convenient syntax: + + .. code-block:: python + + q = Automobile.objects(Automobile.manufacturer == 'Tesla') + We can then further filter our query with another call to **.filter** .. code-block:: python @@ -123,6 +129,7 @@ Filtering Operators q = Automobile.objects.filter(manufacturer='Tesla') q = q.filter(year__in=[2011, 2012]) + :attr:`> (__gt) ` .. code-block:: python @@ -130,6 +137,10 @@ Filtering Operators q = Automobile.objects.filter(manufacturer='Tesla') q = q.filter(year__gt=2010) # year > 2010 + # or the nicer syntax + + q.filter(Automobile.year > 2010) + :attr:`>= (__gte) ` .. code-block:: python @@ -137,6 +148,10 @@ Filtering Operators q = Automobile.objects.filter(manufacturer='Tesla') q = q.filter(year__gte=2010) # year >= 2010 + # or the nicer syntax + + Automobile.objects.filter(Automobile.manufacturer == 'Tesla') + :attr:`< (__lt) ` .. code-block:: python @@ -144,6 +159,10 @@ Filtering Operators q = Automobile.objects.filter(manufacturer='Tesla') q = q.filter(year__lt=2012) # year < 2012 + # or... + + q.filter(Automobile.year < 2012) + :attr:`<= (__lte) ` .. code-block:: python @@ -151,6 +170,8 @@ Filtering Operators q = Automobile.objects.filter(manufacturer='Tesla') q = q.filter(year__lte=2012) # year <= 2012 + q.filter(Automobile.year <= 2012) + TimeUUID Functions ================== @@ -220,7 +241,7 @@ Ordering QuerySets Since Cassandra is essentially a distributed hash table on steroids, the order you get records back in will not be particularly predictable. - However, you can set a column to order on with the ``.order_by(column_name)`` method. + However, you can set a column to order on with the ``.order_by(column_name)`` method. *Example* @@ -245,12 +266,12 @@ Values Lists Batch Queries =============== - cqlengine now supports batch queries using the BatchQuery class. Batch queries can be started and stopped manually, or within a context manager. To add queries to the batch object, you just need to precede the create/save/delete call with a call to batch, and pass in the batch object. - + cqlengine now supports batch queries using the BatchQuery class. Batch queries can be started and stopped manually, or within a context manager. To add queries to the batch object, you just need to precede the create/save/delete call with a call to batch, and pass in the batch object. + You can only create, update, and delete rows with a batch query, attempting to read rows out of the database with a batch query will fail. .. code-block:: python - + from cqlengine import BatchQuery #using a context manager @@ -298,7 +319,7 @@ QuerySet method reference .. method:: limit(num) Limits the number of results returned by Cassandra. - + *Note that CQL's default limit is 10,000, so all queries without a limit set explicitly will have an implicit limit of 10,000* .. method:: order_by(field_name) @@ -306,7 +327,7 @@ QuerySet method reference :param field_name: the name of the field to order on. *Note: the field_name must be a clustering key* :type field_name: string - Sets the field to order on. + Sets the field to order on. .. method:: allow_filtering() From fedb622fe426080d30aa58906aeae7ea9fad7cdb Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 08:18:53 -0700 Subject: [PATCH 0244/4200] adding tests around where clause generation --- cqlengine/tests/query/test_named.py | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cqlengine/tests/query/test_named.py b/cqlengine/tests/query/test_named.py index a7379b0aaa..561083d4db 100644 --- a/cqlengine/tests/query/test_named.py +++ b/cqlengine/tests/query/test_named.py @@ -45,3 +45,34 @@ def test_query_expression_parsing(self): op = query2._where[1] assert isinstance(op, query.GreaterThanOrEqualOperator) assert op.value == 1 + + def test_filter_method_where_clause_generation(self): + """ + Tests the where clause creation + """ + query1 = self.table.objects(test_id=5) + ids = [o.query_value.identifier for o in query1._where] + where = query1._where_clause() + assert where == '"test_id" = :{}'.format(*ids) + + query2 = query1.filter(expected_result__gte=1) + ids = [o.query_value.identifier for o in query2._where] + where = query2._where_clause() + assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) + + def test_query_expression_where_clause_generation(self): + """ + Tests the where clause creation + """ + query1 = self.table.objects(self.table.column('test_id') == 5) + ids = [o.query_value.identifier for o in query1._where] + where = query1._where_clause() + assert where == '"test_id" = :{}'.format(*ids) + + query2 = query1.filter(self.table.column('expected_result') >= 1) + ids = [o.query_value.identifier for o in query2._where] + where = query2._where_clause() + assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) + + + From e87d806e5b7ce022ecb64e2dd1d242e6aa194b06 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 08:21:04 -0700 Subject: [PATCH 0245/4200] renaming queryset --- cqlengine/models.py | 8 ++++---- cqlengine/query.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 55b03dc20a..c3ae852050 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -3,7 +3,7 @@ from cqlengine import columns from cqlengine.exceptions import ModelException, CQLEngineException, ValidationError -from cqlengine.query import QuerySet, DMLQuery, AbstractQueryableColumn +from cqlengine.query import ModelQuerySet, DMLQuery, AbstractQueryableColumn from cqlengine.query import DoesNotExist as _DoesNotExist from cqlengine.query import MultipleObjectsReturned as _MultipleObjectsReturned @@ -41,16 +41,16 @@ class QuerySetDescriptor(object): """ def __get__(self, obj, model): - """ :rtype: QuerySet """ + """ :rtype: ModelQuerySet """ if model.__abstract__: raise CQLEngineException('cannot execute queries against abstract models') - return QuerySet(model) + return ModelQuerySet(model) def __call__(self, *args, **kwargs): """ Just a hint to IDEs that it's ok to call this - :rtype: QuerySet + :rtype: ModelQuerySet """ raise NotImplementedError diff --git a/cqlengine/query.py b/cqlengine/query.py index a626a817ed..1a9fa7817a 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -239,10 +239,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is not None: return self.execute() -class QuerySet(object): +class ModelQuerySet(object): def __init__(self, model): - super(QuerySet, self).__init__() + super(ModelQuerySet, self).__init__() self.model = model #Where clause filters @@ -501,7 +501,7 @@ def filter(self, *args, **kwargs): #TODO: show examples - :rtype: QuerySet + :rtype: ModelQuerySet """ #add arguments to the where clause filters clone = copy.deepcopy(self) From 86202721ada6d1e5e8c153a9b8b038e4898363c6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 08:24:19 -0700 Subject: [PATCH 0246/4200] creating simple query set --- cqlengine/query.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 1a9fa7817a..cc735f6d88 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -239,10 +239,11 @@ def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is not None: return self.execute() -class ModelQuerySet(object): + +class SimpleQuerySet(object): def __init__(self, model): - super(ModelQuerySet, self).__init__() + super(SimpleQuerySet, self).__init__() self.model = model #Where clause filters @@ -501,7 +502,7 @@ def filter(self, *args, **kwargs): #TODO: show examples - :rtype: ModelQuerySet + :rtype: SimpleQuerySet """ #add arguments to the where clause filters clone = copy.deepcopy(self) @@ -700,6 +701,13 @@ def __eq__(self, q): def __ne__(self, q): return not (self != q) + +class ModelQuerySet(SimpleQuerySet): + """ + + """ + + class DMLQuery(object): """ A query object used for queries performing inserts, updates, or deletes From 8aaae66254c82cadfc3f2042be8ab6171b88b43f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 08:27:02 -0700 Subject: [PATCH 0247/4200] moving where clause validation off of SimpleQuerySet --- cqlengine/query.py | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index cc735f6d88..9aef5a48a6 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -314,29 +314,8 @@ def __del__(self): #----query generation / execution---- - def _validate_where_syntax(self): - """ Checks that a filterset will not create invalid cql """ - - #check that there's either a = or IN relationship with a primary key or indexed field - equal_ops = [w for w in self._where if isinstance(w, EqualsOperator)] - token_ops = [w for w in self._where if isinstance(w.value, Token)] - if not any([w.column.primary_key or w.column.index for w in equal_ops]) and not token_ops: - raise QueryException('Where clauses require either a "=" or "IN" comparison with either a primary key or indexed field') - - if not self._allow_filtering: - #if the query is not on an indexed field - if not any([w.column.index for w in equal_ops]): - if not any([w.column.partition_key for w in equal_ops]) and not token_ops: - raise QueryException('Filtering on a clustering key without a partition key is not allowed unless allow_filtering() is called on the querset') - if any(not w.column.partition_key for w in token_ops): - raise QueryException('The token() function is only supported on the partition key') - - - #TODO: abuse this to see if we can get cql to raise an exception - def _where_clause(self): """ Returns a where clause based on the given filter args """ - self._validate_where_syntax() return ' AND '.join([f.cql for f in self._where]) def _where_values(self): @@ -706,6 +685,30 @@ class ModelQuerySet(SimpleQuerySet): """ """ + def _validate_where_syntax(self): + """ Checks that a filterset will not create invalid cql """ + + #check that there's either a = or IN relationship with a primary key or indexed field + equal_ops = [w for w in self._where if isinstance(w, EqualsOperator)] + token_ops = [w for w in self._where if isinstance(w.value, Token)] + if not any([w.column.primary_key or w.column.index for w in equal_ops]) and not token_ops: + raise QueryException('Where clauses require either a "=" or "IN" comparison with either a primary key or indexed field') + + if not self._allow_filtering: + #if the query is not on an indexed field + if not any([w.column.index for w in equal_ops]): + if not any([w.column.partition_key for w in equal_ops]) and not token_ops: + raise QueryException('Filtering on a clustering key without a partition key is not allowed unless allow_filtering() is called on the querset') + if any(not w.column.partition_key for w in token_ops): + raise QueryException('The token() function is only supported on the partition key') + + + #TODO: abuse this to see if we can get cql to raise an exception + + def _where_clause(self): + """ Returns a where clause based on the given filter args """ + self._validate_where_syntax() + return super(ModelQuerySet, self)._where_clause() class DMLQuery(object): From 83c11cb735dda575617e3875bffda207fab0e963 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 08:32:27 -0700 Subject: [PATCH 0248/4200] encapsulating queryset column retrieval --- cqlengine/models.py | 11 +++++++++++ cqlengine/query.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index c3ae852050..30a9261745 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -173,6 +173,17 @@ def _get_keyspace(cls): """ Returns the manual keyspace, if set, otherwise the default keyspace """ return cls.keyspace or DEFAULT_KEYSPACE + @classmethod + def _get_column(cls, name): + """ + Returns the column matching the given name, raising a key error if + it doesn't exist + + :param name: the name of the column to return + :rtype: Column + """ + return cls._columns[name] + def __eq__(self, other): return self.as_dict() == other.as_dict() diff --git a/cqlengine/query.py b/cqlengine/query.py index 9aef5a48a6..a846c8c0c6 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -494,7 +494,7 @@ def filter(self, *args, **kwargs): col_name, col_op = self._parse_filter_arg(arg) #resolve column and operator try: - column = self.model._columns[col_name] + column = self.model._get_column(col_name) except KeyError: if col_name == 'pk__token': column = columns._PartitionKeysToken(self.model) From 9c56f3181a10f79d7c8449dd58b949f67e1d98b0 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 08:35:48 -0700 Subject: [PATCH 0249/4200] switching named columns from model queryset to simple queryset --- cqlengine/named.py | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/cqlengine/named.py b/cqlengine/named.py index 0072c5318a..fc0eb0e01f 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -1,7 +1,26 @@ -from collections import defaultdict, namedtuple +from cqlengine.exceptions import CQLEngineException +from cqlengine.query import AbstractQueryableColumn, SimpleQuerySet -from cqlengine.models import QuerySetDescriptor -from cqlengine.query import AbstractQueryableColumn + +class QuerySetDescriptor(object): + """ + returns a fresh queryset for the given model + it's declared on everytime it's accessed + """ + + def __get__(self, obj, model): + """ :rtype: ModelQuerySet """ + if model.__abstract__: + raise CQLEngineException('cannot execute queries against abstract models') + return SimpleQuerySet(model) + + def __call__(self, *args, **kwargs): + """ + Just a hint to IDEs that it's ok to call this + + :rtype: ModelQuerySet + """ + raise NotImplementedError class NamedColumn(AbstractQueryableColumn): @@ -26,13 +45,6 @@ class NamedTable(object): __abstract__ = False - class ColumnContainer(dict): - def __missing__(self, name): - column = NamedColumn(name) - self[name] = column - return column - _columns = ColumnContainer() - objects = QuerySetDescriptor() def __init__(self, keyspace, name): @@ -43,6 +55,15 @@ def __init__(self, keyspace, name): def column(cls, name): return NamedColumn(name) + @classmethod + def _get_column(cls, name): + """ + Returns the column matching the given name + + :rtype: Column + """ + return cls.column(name) + # @classmethod # def create(cls, **kwargs): # return cls.objects.create(**kwargs) From 23ec346b82a873f9919e6fb7f4602d1e77072960 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 08:37:03 -0700 Subject: [PATCH 0250/4200] updating the named object doc strings --- cqlengine/named.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cqlengine/named.py b/cqlengine/named.py index fc0eb0e01f..5e092156bf 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -24,7 +24,9 @@ def __call__(self, *args, **kwargs): class NamedColumn(AbstractQueryableColumn): - """ describes a named cql column """ + """ + A column that is not coupled to a model class, or type + """ def __init__(self, name): self.name = name @@ -41,7 +43,9 @@ def to_database(self, val): class NamedTable(object): - """ describes a cql table """ + """ + A Table that is not coupled to a model class + """ __abstract__ = False @@ -82,7 +86,9 @@ def get(cls, *args, **kwargs): class NamedKeyspace(object): - """ Describes a cql keyspace """ + """ + A keyspace + """ def __init__(self, name): self.name = name From 6d42ab38ed821bdf317fd42d15f4f4d1a5d20f1d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 08:40:36 -0700 Subject: [PATCH 0251/4200] expanding cql methods on named column --- cqlengine/named.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cqlengine/named.py b/cqlengine/named.py index 5e092156bf..94a3c05e90 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -36,7 +36,10 @@ def _get_column(self): @property def cql(self): - return self.name + return self.get_cql() + + def get_cql(self): + return '"{}"'.format(self.name) def to_database(self, val): return val From e9d951b671d6b7035bd68bec4f1e999cde943ba9 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 09:14:37 -0700 Subject: [PATCH 0252/4200] making named table operations instance specific --- cqlengine/named.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cqlengine/named.py b/cqlengine/named.py index 94a3c05e90..9b62efcb39 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -12,7 +12,7 @@ def __get__(self, obj, model): """ :rtype: ModelQuerySet """ if model.__abstract__: raise CQLEngineException('cannot execute queries against abstract models') - return SimpleQuerySet(model) + return SimpleQuerySet(obj) def __call__(self, *args, **kwargs): """ @@ -58,11 +58,19 @@ def __init__(self, keyspace, name): self.keyspace = keyspace self.name = name - @classmethod def column(cls, name): return NamedColumn(name) - @classmethod + def column_family_name(self, include_keyspace=True): + """ + Returns the column family name if it's been defined + otherwise, it creates it from the module and class name + """ + if include_keyspace: + return '{}.{}'.format(self.keyspace, self.name) + else: + return self.name + def _get_column(cls, name): """ Returns the column matching the given name @@ -75,15 +83,12 @@ def _get_column(cls, name): # def create(cls, **kwargs): # return cls.objects.create(**kwargs) - @classmethod def all(cls): return cls.objects.all() - @classmethod def filter(cls, *args, **kwargs): return cls.objects.filter(*args, **kwargs) - @classmethod def get(cls, *args, **kwargs): return cls.objects.get(*args, **kwargs) From cc3b08bdd6e6a7d45bd8b4f895212dd2f1284345 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 09:17:13 -0700 Subject: [PATCH 0253/4200] renaming cls to self --- cqlengine/named.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/cqlengine/named.py b/cqlengine/named.py index 9b62efcb39..f2301bbb03 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -58,7 +58,7 @@ def __init__(self, keyspace, name): self.keyspace = keyspace self.name = name - def column(cls, name): + def column(self, name): return NamedColumn(name) def column_family_name(self, include_keyspace=True): @@ -71,26 +71,25 @@ def column_family_name(self, include_keyspace=True): else: return self.name - def _get_column(cls, name): + def _get_column(self, name): """ Returns the column matching the given name :rtype: Column """ - return cls.column(name) + return self.column(name) - # @classmethod - # def create(cls, **kwargs): - # return cls.objects.create(**kwargs) + # def create(self, **kwargs): + # return self.objects.create(**kwargs) - def all(cls): - return cls.objects.all() + def all(self): + return self.objects.all() - def filter(cls, *args, **kwargs): - return cls.objects.filter(*args, **kwargs) + def filter(self, *args, **kwargs): + return self.objects.filter(*args, **kwargs) - def get(cls, *args, **kwargs): - return cls.objects.get(*args, **kwargs) + def get(self, *args, **kwargs): + return self.objects.get(*args, **kwargs) class NamedKeyspace(object): From def580706533dd3814d4d108a12cbc3b95b53b22 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 09:22:59 -0700 Subject: [PATCH 0254/4200] =?UTF-8?q?adding=20tests=20around=20named=20tab?= =?UTF-8?q?le=20queries=E2=80=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cqlengine/tests/query/test_named.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/query/test_named.py b/cqlengine/tests/query/test_named.py index 561083d4db..6f1327f094 100644 --- a/cqlengine/tests/query/test_named.py +++ b/cqlengine/tests/query/test_named.py @@ -1,6 +1,6 @@ from cqlengine import query from cqlengine.named import NamedKeyspace -from cqlengine.tests.query.test_queryset import TestModel +from cqlengine.tests.query.test_queryset import TestModel, BaseQuerySetUsage from cqlengine.tests.base import BaseCassEngTestCase @@ -75,4 +75,28 @@ def test_query_expression_where_clause_generation(self): assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) +class TestQuerySetCountSelectionAndIteration(BaseQuerySetUsage): + + @classmethod + def setUpClass(cls): + super(TestQuerySetCountSelectionAndIteration, cls).setUpClass() + ks,tn = TestModel.column_family_name().split('.') + cls.keyspace = NamedKeyspace(ks) + cls.table = cls.keyspace.table(tn) + + + def test_count(self): + """ Tests that adding filtering statements affects the count query as expected """ + assert self.table.objects.count() == 12 + + q = self.table.objects(test_id=0) + assert q.count() == 4 + + def test_query_expression_count(self): + """ Tests that adding query statements affects the count query as expected """ + assert self.table.objects.count() == 12 + + q = self.table.objects(self.table.column('test_id') == 0) + assert q.count() == 4 + From cc40938b592a8d95922e3e321adb07a3a9f92723 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 09:27:41 -0700 Subject: [PATCH 0255/4200] splitting the simple queryset into an abstract and simple queryset --- cqlengine/query.py | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index a846c8c0c6..b54fcbf30a 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -240,10 +240,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.execute() -class SimpleQuerySet(object): +class AbstractQuerySet(object): def __init__(self, model): - super(SimpleQuerySet, self).__init__() + super(AbstractQuerySet, self).__init__() self.model = model #Where clause filters @@ -325,16 +325,15 @@ def _where_values(self): values.update(where.get_dict()) return values + def _get_select_fields(self): + """ Returns the fields to be returned by the select query """ + raise NotImplementedError + def _select_query(self): """ Returns a select clause based on the given filter args """ - fields = self.model._columns.keys() - if self._defer_fields: - fields = [f for f in fields if f not in self._defer_fields] - elif self._only_fields: - fields = self._only_fields - db_fields = [self.model._columns[f].db_field_name for f in fields] + db_fields = self._get_select_fields() qs = ['SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields]))] qs += ['FROM {}'.format(self.column_family_name)] @@ -481,7 +480,7 @@ def filter(self, *args, **kwargs): #TODO: show examples - :rtype: SimpleQuerySet + :rtype: AbstractQuerySet """ #add arguments to the where clause filters clone = copy.deepcopy(self) @@ -680,8 +679,17 @@ def __eq__(self, q): def __ne__(self, q): return not (self != q) +class SimpleQuerySet(AbstractQuerySet): + """ + + """ + + def _get_select_fields(self): + """ Returns the fields to be returned by the select query """ + return ['*'] + -class ModelQuerySet(SimpleQuerySet): +class ModelQuerySet(AbstractQuerySet): """ """ @@ -710,6 +718,15 @@ def _where_clause(self): self._validate_where_syntax() return super(ModelQuerySet, self)._where_clause() + def _get_select_fields(self): + """ Returns the fields to be returned by the select query """ + fields = self.model._columns.keys() + if self._defer_fields: + fields = [f for f in fields if f not in self._defer_fields] + elif self._only_fields: + fields = self._only_fields + return [self.model._columns[f].db_field_name for f in fields] + class DMLQuery(object): """ From df964ec3a5543d670f775b96159e520d5505e572 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 09:37:42 -0700 Subject: [PATCH 0256/4200] moving the select statement into it's own method --- cqlengine/query.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index b54fcbf30a..5a87d94965 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -325,17 +325,15 @@ def _where_values(self): values.update(where.get_dict()) return values - def _get_select_fields(self): - """ Returns the fields to be returned by the select query """ + def _get_select_statement(self): + """ returns the select portion of this queryset's cql statement """ raise NotImplementedError def _select_query(self): """ Returns a select clause based on the given filter args """ - db_fields = self._get_select_fields() - - qs = ['SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields]))] + qs = [self._get_select_statement()] qs += ['FROM {}'.format(self.column_family_name)] if self._where: @@ -684,9 +682,9 @@ class SimpleQuerySet(AbstractQuerySet): """ - def _get_select_fields(self): + def _get_select_statement(self): """ Returns the fields to be returned by the select query """ - return ['*'] + return 'SELECT *' class ModelQuerySet(AbstractQuerySet): @@ -718,14 +716,15 @@ def _where_clause(self): self._validate_where_syntax() return super(ModelQuerySet, self)._where_clause() - def _get_select_fields(self): + def _get_select_statement(self): """ Returns the fields to be returned by the select query """ fields = self.model._columns.keys() if self._defer_fields: fields = [f for f in fields if f not in self._defer_fields] elif self._only_fields: fields = self._only_fields - return [self.model._columns[f].db_field_name for f in fields] + db_fields = [self.model._columns[f].db_field_name for f in fields] + return 'SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields])) class DMLQuery(object): From fbe3294554a1c1e2b498de4298829fd6d6d33cfa Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 09:48:07 -0700 Subject: [PATCH 0257/4200] creating different result constructor factory methods for each queryset type --- cqlengine/query.py | 71 +++++++++++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 5a87d94965..06d2f2bfe4 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -420,22 +420,7 @@ def _create_result_constructor(self, names): """ Returns a function that will be used to instantiate query results """ - model = self.model - db_map = model._db_map - if not self._values_list: - def _construct_instance(values): - field_dict = dict((db_map.get(k, k), v) for k, v in zip(names, values)) - instance = model(**field_dict) - instance._is_persisted = True - return instance - return _construct_instance - - columns = [model._columns[db_map[name]] for name in names] - if self._flat_values_list: - return (lambda values: columns[0].to_python(values[0])) - else: - # result_cls = namedtuple("{}Tuple".format(self.model.__name__), names) - return (lambda values: map(lambda (c, v): c.to_python(v), zip(columns, values))) + raise NotImplementedError def batch(self, batch_obj): """ @@ -658,19 +643,6 @@ def delete(self, columns=[]): else: execute(qs, self._where_values()) - def values_list(self, *fields, **kwargs): - """ Instructs the query set to return tuples, not model instance """ - flat = kwargs.pop('flat', False) - if kwargs: - raise TypeError('Unexpected keyword arguments to values_list: %s' - % (kwargs.keys(),)) - if flat and len(fields) > 1: - raise TypeError("'flat' is not valid when values_list is called with more than one field.") - clone = self.only(fields) - clone._values_list = True - clone._flat_values_list = flat - return clone - def __eq__(self, q): return set(self._where) == set(q._where) @@ -686,6 +658,13 @@ def _get_select_statement(self): """ Returns the fields to be returned by the select query """ return 'SELECT *' + def _create_result_constructor(self, names): + """ + Returns a function that will be used to instantiate query results + """ + def _construct_instance(values): + return dict(zip(names, values)) + return _construct_instance class ModelQuerySet(AbstractQuerySet): """ @@ -726,6 +705,40 @@ def _get_select_statement(self): db_fields = [self.model._columns[f].db_field_name for f in fields] return 'SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields])) + def _create_result_constructor(self, names): + """ + Returns a function that will be used to instantiate query results + """ + model = self.model + db_map = model._db_map + if not self._values_list: + def _construct_instance(values): + field_dict = dict((db_map.get(k, k), v) for k, v in zip(names, values)) + instance = model(**field_dict) + instance._is_persisted = True + return instance + return _construct_instance + + columns = [model._columns[db_map[name]] for name in names] + if self._flat_values_list: + return (lambda values: columns[0].to_python(values[0])) + else: + # result_cls = namedtuple("{}Tuple".format(self.model.__name__), names) + return (lambda values: map(lambda (c, v): c.to_python(v), zip(columns, values))) + + def values_list(self, *fields, **kwargs): + """ Instructs the query set to return tuples, not model instance """ + flat = kwargs.pop('flat', False) + if kwargs: + raise TypeError('Unexpected keyword arguments to values_list: %s' + % (kwargs.keys(),)) + if flat and len(fields) > 1: + raise TypeError("'flat' is not valid when values_list is called with more than one field.") + clone = self.only(fields) + clone._values_list = True + clone._flat_values_list = flat + return clone + class DMLQuery(object): """ From d741790ec78b90dd66a19c328670aec84b60c01c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 09:52:37 -0700 Subject: [PATCH 0258/4200] changing named query results to Result Object instance --- cqlengine/query.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 06d2f2bfe4..d3b9d01023 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -649,6 +649,17 @@ def __eq__(self, q): def __ne__(self, q): return not (self != q) +class ResultObject(dict): + """ + adds attribute access to a dictionary + """ + + def __getattr__(self, item): + try: + return self[item] + except KeyError: + raise AttributeError + class SimpleQuerySet(AbstractQuerySet): """ @@ -663,7 +674,7 @@ def _create_result_constructor(self, names): Returns a function that will be used to instantiate query results """ def _construct_instance(values): - return dict(zip(names, values)) + return ResultObject(zip(names, values)) return _construct_instance class ModelQuerySet(AbstractQuerySet): From 5c1e203f099470d8d66ec44be8446b04d8461169 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 09:58:56 -0700 Subject: [PATCH 0259/4200] adding does not exist and multiple objects returns exceptions to named table --- cqlengine/named.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cqlengine/named.py b/cqlengine/named.py index f2301bbb03..c1d1263a82 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -1,6 +1,8 @@ from cqlengine.exceptions import CQLEngineException from cqlengine.query import AbstractQueryableColumn, SimpleQuerySet +from cqlengine.query import DoesNotExist as _DoesNotExist +from cqlengine.query import MultipleObjectsReturned as _MultipleObjectsReturned class QuerySetDescriptor(object): """ @@ -54,6 +56,9 @@ class NamedTable(object): objects = QuerySetDescriptor() + class DoesNotExist(_DoesNotExist): pass + class MultipleObjectsReturned(_MultipleObjectsReturned): pass + def __init__(self, keyspace, name): self.keyspace = keyspace self.name = name From 6ccc32084c208d612eca7f577962b79f5cf7714e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 10:07:57 -0700 Subject: [PATCH 0260/4200] moving order conditions into their own method and adding extra validation to the model queryset order condition method --- cqlengine/query.py | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index d3b9d01023..b01e1a9ea3 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -510,6 +510,12 @@ def get(self, *args, **kwargs): else: return self[0] + def _get_ordering_condition(self, colname): + order_type = 'DESC' if colname.startswith('-') else 'ASC' + colname = colname.replace('-', '') + + return colname, order_type + def order_by(self, *colnames): """ orders the result set. @@ -524,24 +530,7 @@ def order_by(self, *colnames): conditions = [] for colname in colnames: - order_type = 'DESC' if colname.startswith('-') else 'ASC' - colname = colname.replace('-', '') - - column = self.model._columns.get(colname) - if column is None: - raise QueryException("Can't resolve the column name: '{}'".format(colname)) - - #validate the column selection - if not column.primary_key: - raise QueryException( - "Can't order on '{}', can only order on (clustered) primary keys".format(colname)) - - pks = [v for k, v in self.model._columns.items() if v.primary_key] - if column == pks[0]: - raise QueryException( - "Can't order by the first primary key (partition key), clustering (secondary) keys only") - - conditions.append('"{}" {}'.format(column.db_field_name, order_type)) + conditions.append('"{}" {}'.format(*self._get_ordering_condition(colname))) clone = copy.deepcopy(self) clone._order.extend(conditions) @@ -737,6 +726,25 @@ def _construct_instance(values): # result_cls = namedtuple("{}Tuple".format(self.model.__name__), names) return (lambda values: map(lambda (c, v): c.to_python(v), zip(columns, values))) + def _get_ordering_condition(self, colname): + colname, order_type = super(ModelQuerySet, self)._get_ordering_condition(colname) + + column = self.model._columns.get(colname) + if column is None: + raise QueryException("Can't resolve the column name: '{}'".format(colname)) + + #validate the column selection + if not column.primary_key: + raise QueryException( + "Can't order on '{}', can only order on (clustered) primary keys".format(colname)) + + pks = [v for k, v in self.model._columns.items() if v.primary_key] + if column == pks[0]: + raise QueryException( + "Can't order by the first primary key (partition key), clustering (secondary) keys only") + + return column.db_field_name, order_type + def values_list(self, *fields, **kwargs): """ Instructs the query set to return tuples, not model instance """ flat = kwargs.pop('flat', False) From d7a7d13f54661b9af781219ce2a1e6de65378ab8 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 10:08:15 -0700 Subject: [PATCH 0261/4200] adding additional tests around named table queries --- cqlengine/tests/query/test_named.py | 128 +++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/query/test_named.py b/cqlengine/tests/query/test_named.py index 6f1327f094..cba46dcb59 100644 --- a/cqlengine/tests/query/test_named.py +++ b/cqlengine/tests/query/test_named.py @@ -1,6 +1,7 @@ from cqlengine import query from cqlengine.named import NamedKeyspace -from cqlengine.tests.query.test_queryset import TestModel, BaseQuerySetUsage +from cqlengine.query import ResultObject +from cqlengine.tests.query.test_queryset import BaseQuerySetUsage from cqlengine.tests.base import BaseCassEngTestCase @@ -80,6 +81,9 @@ class TestQuerySetCountSelectionAndIteration(BaseQuerySetUsage): @classmethod def setUpClass(cls): super(TestQuerySetCountSelectionAndIteration, cls).setUpClass() + + from cqlengine.tests.query.test_queryset import TestModel + ks,tn = TestModel.column_family_name().split('.') cls.keyspace = NamedKeyspace(ks) cls.table = cls.keyspace.table(tn) @@ -99,4 +103,126 @@ def test_query_expression_count(self): q = self.table.objects(self.table.column('test_id') == 0) assert q.count() == 4 + def test_iteration(self): + """ Tests that iterating over a query set pulls back all of the expected results """ + q = self.table.objects(test_id=0) + #tuple of expected attempt_id, expected_result values + compare_set = set([(0,5), (1,10), (2,15), (3,20)]) + for t in q: + val = t.attempt_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 + + # test with regular filtering + q = self.table.objects(attempt_id=3).allow_filtering() + assert len(q) == 3 + #tuple of expected test_id, expected_result values + compare_set = set([(0,20), (1,20), (2,75)]) + for t in q: + val = t.test_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 + + # test with query method + q = self.table.objects(self.table.column('attempt_id') == 3).allow_filtering() + assert len(q) == 3 + #tuple of expected test_id, expected_result values + compare_set = set([(0,20), (1,20), (2,75)]) + for t in q: + val = t.test_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 + + def test_multiple_iterations_work_properly(self): + """ Tests that iterating over a query set more than once works """ + # test with both the filtering method and the query method + for q in (self.table.objects(test_id=0), self.table.objects(self.table.column('test_id') == 0)): + #tuple of expected attempt_id, expected_result values + compare_set = set([(0,5), (1,10), (2,15), (3,20)]) + for t in q: + val = t.attempt_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 + + #try it again + compare_set = set([(0,5), (1,10), (2,15), (3,20)]) + for t in q: + val = t.attempt_id, t.expected_result + assert val in compare_set + compare_set.remove(val) + assert len(compare_set) == 0 + + def test_multiple_iterators_are_isolated(self): + """ + tests that the use of one iterator does not affect the behavior of another + """ + for q in (self.table.objects(test_id=0), self.table.objects(self.table.column('test_id') == 0)): + q = q.order_by('attempt_id') + expected_order = [0,1,2,3] + iter1 = iter(q) + iter2 = iter(q) + for attempt_id in expected_order: + assert iter1.next().attempt_id == attempt_id + assert iter2.next().attempt_id == attempt_id + + def test_get_success_case(self): + """ + Tests that the .get() method works on new and existing querysets + """ + m = self.table.objects.get(test_id=0, attempt_id=0) + assert isinstance(m, ResultObject) + assert m.test_id == 0 + assert m.attempt_id == 0 + + q = self.table.objects(test_id=0, attempt_id=0) + m = q.get() + assert isinstance(m, ResultObject) + assert m.test_id == 0 + assert m.attempt_id == 0 + + q = self.table.objects(test_id=0) + m = q.get(attempt_id=0) + assert isinstance(m, ResultObject) + assert m.test_id == 0 + assert m.attempt_id == 0 + + def test_query_expression_get_success_case(self): + """ + Tests that the .get() method works on new and existing querysets + """ + m = self.table.get(self.table.column('test_id') == 0, self.table.column('attempt_id') == 0) + assert isinstance(m, ResultObject) + assert m.test_id == 0 + assert m.attempt_id == 0 + + q = self.table.objects(self.table.column('test_id') == 0, self.table.column('attempt_id') == 0) + m = q.get() + assert isinstance(m, ResultObject) + assert m.test_id == 0 + assert m.attempt_id == 0 + + q = self.table.objects(self.table.column('test_id') == 0) + m = q.get(self.table.column('attempt_id') == 0) + assert isinstance(m, ResultObject) + assert m.test_id == 0 + assert m.attempt_id == 0 + + def test_get_doesnotexist_exception(self): + """ + Tests that get calls that don't return a result raises a DoesNotExist error + """ + with self.assertRaises(self.table.DoesNotExist): + self.table.objects.get(test_id=100) + + def test_get_multipleobjects_exception(self): + """ + Tests that get calls that return multiple results raise a MultipleObjectsReturned error + """ + with self.assertRaises(self.table.MultipleObjectsReturned): + self.table.objects.get(test_id=1) + From 423e26e6b4d53e48a6b0ad65e945be06d6546dc4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 16 Jun 2013 10:27:42 -0700 Subject: [PATCH 0262/4200] removing module name from auto column family name generation --- cqlengine/models.py | 4 ---- cqlengine/tests/model/test_class_construction.py | 8 ++++++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 3dca106428..6b9536e960 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -187,10 +187,6 @@ def column_family_name(cls, include_keyspace=True): camelcase = re.compile(r'([a-z])([A-Z])') ccase = lambda s: camelcase.sub(lambda v: '{}_{}'.format(v.group(1), v.group(2).lower()), s) - module = cls.__module__.split('.') - if module: - cf_name = ccase(module[-1]) + '_' - cf_name += ccase(cls.__name__) #trim to less than 48 characters or cassandra will complain cf_name = cf_name[-48:] diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 05e77bde9a..f0344c6bd3 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -107,6 +107,14 @@ class InheritedModel(TestModel): assert 'text' in InheritedModel._columns assert 'numbers' in InheritedModel._columns + def test_column_family_name_generation(self): + """ Tests that auto column family name generation works as expected """ + class TestModel(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) + text = columns.Text() + + assert TestModel.column_family_name(include_keyspace=False) == 'test_model' + def test_normal_fields_can_be_defined_between_primary_keys(self): """ Tests tha non primary key fields can be defined between primary key fields From 61de63acc8863c0b2a46df0a18f871fc94fbb4bf Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 17 Jun 2013 11:45:37 -0700 Subject: [PATCH 0263/4200] updating changelog --- changelog | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog b/changelog index bb830d8efb..9084cdfc55 100644 --- a/changelog +++ b/changelog @@ -5,6 +5,7 @@ CHANGELOG * explicit primary key is required (automatic id removed) * added validation on keyname types on .create() * changed table_name to __table_name__, read_repair_chance to __read_repair_chance__, keyspace to __keyspace__ +* modified table name auto generator to ignore module name * changed internal implementation of model value get/set 0.3.3 From d9491aa5f5acc7c806dfdc7809d0a9aefa14e62d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 17 Jun 2013 11:48:58 -0700 Subject: [PATCH 0264/4200] adding breaking change link to readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b0037b9465..10531060e5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ cqlengine =============== -cqlengine is a Cassandra CQL 3 Object Mapper for Python with an interface similar to the Django orm and mongoengine +cqlengine is a Cassandra CQL 3 Object Mapper for Python + +**Users of versions < 0.4, please read this post: [Breaking Changes](https://groups.google.com/forum/?fromgroups#!topic/cqlengine-users/erkSNe1JwuU)** [Documentation](https://cqlengine.readthedocs.org/en/latest/) From fec88a847e8b52b4a0b46bc5dada002f4c6c4d14 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 17 Jun 2013 16:37:03 -0700 Subject: [PATCH 0265/4200] create uuid1 from datetime #65 --- cqlengine/columns.py | 48 ++++++++++++++++++++++ cqlengine/tests/columns/test_validation.py | 13 +++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 941d2419aa..0838195ddb 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -304,6 +304,9 @@ def to_python(self, value): def to_database(self, value): return self.validate(value) +_last_timestamp = None +from uuid import UUID as pyUUID, getnode + class TimeUUID(UUID): """ UUID containing timestamp @@ -311,6 +314,50 @@ class TimeUUID(UUID): db_type = 'timeuuid' + @classmethod + def from_datetime(self, dt): + """ + generates a UUID for a given datetime + + :param dt: datetime + :type dt: datetime + :return: + """ + global _last_timestamp + + epoch = datetime(1970, 1, 1, tzinfo=dt.tzinfo) + + offset = 0 + if epoch.tzinfo: + offset_delta = epoch.tzinfo.utcoffset(epoch) + offset = offset_delta.days*24*3600 + offset_delta.seconds + + timestamp = (dt - epoch).total_seconds() - offset + + node = None + clock_seq = None + + nanoseconds = int(timestamp * 1e9) + timestamp = int(nanoseconds // 100) + 0x01b21dd213814000L + + if _last_timestamp is not None and timestamp <= _last_timestamp: + timestamp = _last_timestamp + 1 + _last_timestamp = timestamp + if clock_seq is None: + import random + clock_seq = random.randrange(1 << 14L) # instead of stable storage + time_low = timestamp & 0xffffffffL + time_mid = (timestamp >> 32L) & 0xffffL + time_hi_version = (timestamp >> 48L) & 0x0fffL + clock_seq_low = clock_seq & 0xffL + clock_seq_hi_variant = (clock_seq >> 8L) & 0x3fL + if node is None: + node = getnode() + return pyUUID(fields=(time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node), version=1) + + + class Boolean(Column): db_type = 'boolean' @@ -325,6 +372,7 @@ def to_python(self, value): def to_database(self, value): return self.Quoter(bool(value)) + class Float(Column): db_type = 'double' diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 627ccbd3e6..a68958bb23 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -3,6 +3,7 @@ from datetime import date from datetime import tzinfo from decimal import Decimal as D +from unittest import TestCase from uuid import uuid4, uuid1 from cqlengine import ValidationError @@ -204,10 +205,18 @@ def test_extra_field(self): self.TestModel.create(bacon=5000) +class TestTimeUUIDFromDatetime(TestCase): + def test_conversion_specific_date(self): + dt = datetime(1981, 7, 11, microsecond=555000) + uuid = TimeUUID.from_datetime(dt) + from uuid import UUID + assert isinstance(uuid, UUID) + ts = (uuid.time - 0x01b21dd213814000) / 1e7 # back to a timestamp + new_dt = datetime.utcfromtimestamp(ts) - - + # checks that we created a UUID1 with the proper timestamp + assert new_dt == dt From fed38658460871eaa1ff86b036c577406f20dc7b Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 17 Jun 2013 16:39:26 -0700 Subject: [PATCH 0266/4200] updated changelog --- changelog | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog b/changelog index 9084cdfc55..5f52491b32 100644 --- a/changelog +++ b/changelog @@ -7,6 +7,8 @@ CHANGELOG * changed table_name to __table_name__, read_repair_chance to __read_repair_chance__, keyspace to __keyspace__ * modified table name auto generator to ignore module name * changed internal implementation of model value get/set +* added TimeUUID.from_datetime(), used for generating UUID1's for a specific +time 0.3.3 * added abstract base class models From 071b54a76bafa1f62344a226f41c93fe2ab22142 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 18 Jun 2013 11:50:22 -0700 Subject: [PATCH 0267/4200] updating setup --- setup.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 512d55e902..fb2fc15630 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ version = '0.3.3' long_desc = """ -cqlengine is a Cassandra CQL Object Mapper for Python in the style of the Django orm and mongoengine +Cassandra CQL 3 Object Mapper for Python [Documentation](https://cqlengine.readthedocs.org/en/latest/) @@ -21,8 +21,7 @@ setup( name='cqlengine', version=version, - description='Cassandra CQL ORM for Python in the style of the Django orm and mongoengine', - dependency_links = ['https://github.com/bdeggleston/cqlengine/archive/{0}.tar.gz#egg=cqlengine-{0}'.format(version)], + description='Cassandra CQL 3 Object Mapper for Python', long_description=long_desc, classifiers = [ "Environment :: Web Environment", @@ -38,7 +37,7 @@ install_requires = ['cql'], author='Blake Eggleston', author_email='bdeggleston@gmail.com', - url='https://github.com/bdeggleston/cqlengine', + url='https://github.com/cqlengine/cqlengine', license='BSD', packages=find_packages(), include_package_data=True, From 474bfb4c098a6240edf01c917a529a3cc3ca0e34 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 18 Jun 2013 11:51:21 -0700 Subject: [PATCH 0268/4200] version number bump --- cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index d103644c90..3e2d00b097 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.3.3' +__version__ = '0.4' diff --git a/docs/conf.py b/docs/conf.py index 5d797fee3e..50f1bd0129 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.3.3' +version = '0.4' # The full version, including alpha/beta/rc tags. -release = '0.3.3' +release = '0.4' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index fb2fc15630..1b6b8b8d5e 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.3.3' +version = '0.4' long_desc = """ Cassandra CQL 3 Object Mapper for Python From b5d0ddad8bcf8eef4da51348a49fe128b3fd49de Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 18 Jun 2013 11:57:35 -0700 Subject: [PATCH 0269/4200] removing alpha warning --- docs/index.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index f93a8505c4..2901f2aff4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -88,8 +88,6 @@ Getting Started `Dev Mailing List `_ -**NOTE: cqlengine is in alpha and under development, some features may change. Make sure to check the changelog and test your app before upgrading** - Indices and tables ================== From cc1af16c1c80d4acb631e4769903c4a2d78b3d98 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 18 Jun 2013 17:50:30 -0700 Subject: [PATCH 0270/4200] fixed TS issue --- cqlengine/columns.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 0838195ddb..0210e8dbb8 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -304,7 +304,6 @@ def to_python(self, value): def to_database(self, value): return self.validate(value) -_last_timestamp = None from uuid import UUID as pyUUID, getnode class TimeUUID(UUID): @@ -340,9 +339,6 @@ def from_datetime(self, dt): nanoseconds = int(timestamp * 1e9) timestamp = int(nanoseconds // 100) + 0x01b21dd213814000L - if _last_timestamp is not None and timestamp <= _last_timestamp: - timestamp = _last_timestamp + 1 - _last_timestamp = timestamp if clock_seq is None: import random clock_seq = random.randrange(1 << 14L) # instead of stable storage From 1d4e9b0957070092e02eb4a659370255a896fb10 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 18 Jun 2013 17:53:53 -0700 Subject: [PATCH 0271/4200] bugfix for timeuuids --- cqlengine/__init__.py | 2 +- docs/conf.py | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 3e2d00b097..df941b0fff 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.4' +__version__ = '0.4.1' diff --git a/docs/conf.py b/docs/conf.py index 50f1bd0129..5aff68540e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,7 +50,7 @@ # The short X.Y version. version = '0.4' # The full version, including alpha/beta/rc tags. -release = '0.4' +release = '0.4.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 1b6b8b8d5e..ff3dcddc4b 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.4' +version = '0.4.1' long_desc = """ Cassandra CQL 3 Object Mapper for Python From dad5effe7b79d86f8c39444cc0c2b93ac9ed33ec Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 19 Jun 2013 09:55:54 -0700 Subject: [PATCH 0272/4200] adding change warning to all doc pages --- docs/index.rst | 4 ++++ docs/topics/columns.rst | 4 ++++ docs/topics/connection.rst | 4 ++++ docs/topics/manage_schemas.rst | 6 ++++++ docs/topics/models.rst | 6 ++++++ docs/topics/queryset.rst | 6 ++++++ 6 files changed, 30 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index 2901f2aff4..9dff72fd56 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,6 +5,10 @@ cqlengine documentation ======================= +**Users of versions < 0.4, please read this post before upgrading:** `Breaking Changes`_ + +.. _Breaking Changes: https://groups.google.com/forum/?fromgroups#!topic/cqlengine-users/erkSNe1JwuU + cqlengine is a Cassandra CQL 3 Object Mapper for Python with an interface similar to the Django orm and mongoengine :ref:`getting-started` diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index 61252e7f97..987d7d7c4f 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -2,6 +2,10 @@ Columns ======= +**Users of versions < 0.4, please read this post before upgrading:** `Breaking Changes`_ + +.. _Breaking Changes: https://groups.google.com/forum/?fromgroups#!topic/cqlengine-users/erkSNe1JwuU + .. module:: cqlengine.columns .. class:: Bytes() diff --git a/docs/topics/connection.rst b/docs/topics/connection.rst index 48558f9c46..88f601fbd0 100644 --- a/docs/topics/connection.rst +++ b/docs/topics/connection.rst @@ -2,6 +2,10 @@ Connection ============== +**Users of versions < 0.4, please read this post before upgrading:** `Breaking Changes`_ + +.. _Breaking Changes: https://groups.google.com/forum/?fromgroups#!topic/cqlengine-users/erkSNe1JwuU + .. module:: cqlengine.connection The setup function in `cqlengine.connection` records the Cassandra servers to connect to. diff --git a/docs/topics/manage_schemas.rst b/docs/topics/manage_schemas.rst index 2174e56f6a..413b53c481 100644 --- a/docs/topics/manage_schemas.rst +++ b/docs/topics/manage_schemas.rst @@ -2,6 +2,12 @@ Managing Schmas =============== +**Users of versions < 0.4, please read this post before upgrading:** `Breaking Changes`_ + +.. _Breaking Changes: https://groups.google.com/forum/?fromgroups#!topic/cqlengine-users/erkSNe1JwuU + +.. module:: cqlengine.connection + .. module:: cqlengine.management Once a connection has been made to Cassandra, you can use the functions in ``cqlengine.management`` to create and delete keyspaces, as well as create and delete tables for defined models diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 5dc322f61b..f599f2cfb7 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -2,6 +2,12 @@ Models ====== +**Users of versions < 0.4, please read this post before upgrading:** `Breaking Changes`_ + +.. _Breaking Changes: https://groups.google.com/forum/?fromgroups#!topic/cqlengine-users/erkSNe1JwuU + +.. module:: cqlengine.connection + .. module:: cqlengine.models A model is a python class representing a CQL table. diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index c237ed6b59..0a481fbe9f 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -2,6 +2,12 @@ Making Queries ============== +**Users of versions < 0.4, please read this post before upgrading:** `Breaking Changes`_ + +.. _Breaking Changes: https://groups.google.com/forum/?fromgroups#!topic/cqlengine-users/erkSNe1JwuU + +.. module:: cqlengine.connection + .. module:: cqlengine.query Retrieving objects From 147158eb99c16530e8cf40d8324ba8059eb234cc Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 19 Jun 2013 14:50:43 -0700 Subject: [PATCH 0273/4200] updating changelog --- changelog | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 5f52491b32..29a8b3f208 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,12 @@ CHANGELOG -0.4.0 (in progress) +0.4.2 +* added support for instantiating container columns with column instances + +0.4.1 +* fixed bug in TimeUUID from datetime method + +0.4.0 * removed default values from all column types * explicit primary key is required (automatic id removed) * added validation on keyname types on .create() From fb5aba9ba69318b90057f6bfad434a2d4459bae2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 19 Jun 2013 14:57:33 -0700 Subject: [PATCH 0274/4200] adding support for instantiating container columns with column instances, as well as classes --- cqlengine/columns.py | 14 +++++++++---- .../tests/columns/test_container_columns.py | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 0210e8dbb8..bc71e51ab4 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -424,15 +424,21 @@ def __init__(self, value_type, **kwargs): """ :param value_type: a column class indicating the types of the value """ - if not issubclass(value_type, Column): + inheritance_comparator = issubclass if isinstance(value_type, type) else isinstance + if not inheritance_comparator(value_type, Column): raise ValidationError('value_type must be a column class') - if issubclass(value_type, BaseContainerColumn): + if inheritance_comparator(value_type, BaseContainerColumn): raise ValidationError('container types cannot be nested') if value_type.db_type is None: raise ValidationError('value_type cannot be an abstract column type') - self.value_type = value_type - self.value_col = self.value_type() + if isinstance(value_type, type): + self.value_type = value_type + self.value_col = self.value_type() + else: + self.value_col = value_type + self.value_type = self.value_col.__class__ + super(BaseContainerColumn, self).__init__(**kwargs) def get_column_def(self): diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 3533aad3ef..989978ac2a 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -6,6 +6,27 @@ from cqlengine.management import create_table, delete_table from cqlengine.tests.base import BaseCassEngTestCase +class TestClassConstruction(BaseCassEngTestCase): + """ + Tests around the instantiation of container columns + """ + + def test_instantiation_with_column_class(self): + """ + Tests that columns instantiated with a column class work properly + and that the class is instantiated in the constructor + """ + column = columns.List(columns.Text) + assert isinstance(column.value_col, columns.Text) + + def test_instantiation_with_column_instance(self): + """ + Tests that columns instantiated with a column instance work properly + """ + column = columns.List(columns.Text(min_length=100)) + assert isinstance(column.value_col, columns.Text) + + class TestSetModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) int_set = columns.Set(columns.Integer, required=False) From c81d01d0f765087bc6ed97d4f860f33492d920c9 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 19 Jun 2013 15:02:24 -0700 Subject: [PATCH 0275/4200] adding column instance support to map keys as well --- cqlengine/columns.py | 13 ++-- .../tests/columns/test_container_columns.py | 61 ++++++++++++++----- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index bc71e51ab4..2c74ed1028 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -653,15 +653,20 @@ def __init__(self, key_type, value_type, **kwargs): :param key_type: a column class indicating the types of the key :param value_type: a column class indicating the types of the value """ - if not issubclass(value_type, Column): + inheritance_comparator = issubclass if isinstance(key_type, type) else isinstance + if not inheritance_comparator(key_type, Column): raise ValidationError('key_type must be a column class') - if issubclass(value_type, BaseContainerColumn): + if inheritance_comparator(key_type, BaseContainerColumn): raise ValidationError('container types cannot be nested') if key_type.db_type is None: raise ValidationError('key_type cannot be an abstract column type') - self.key_type = key_type - self.key_col = self.key_type() + if isinstance(key_type, type): + self.key_type = key_type + self.key_col = self.key_type() + else: + self.key_col = key_type + self.key_type = self.key_col.__class__ super(Map, self).__init__(value_type, **kwargs) def get_column_def(self): diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 989978ac2a..6ba261d907 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -11,20 +11,6 @@ class TestClassConstruction(BaseCassEngTestCase): Tests around the instantiation of container columns """ - def test_instantiation_with_column_class(self): - """ - Tests that columns instantiated with a column class work properly - and that the class is instantiated in the constructor - """ - column = columns.List(columns.Text) - assert isinstance(column.value_col, columns.Text) - - def test_instantiation_with_column_instance(self): - """ - Tests that columns instantiated with a column instance work properly - """ - column = columns.List(columns.Text(min_length=100)) - assert isinstance(column.value_col, columns.Text) class TestSetModel(Model): @@ -93,6 +79,21 @@ def test_partial_update_creation(self): assert len([s for s in statements if '"TEST" = "TEST" -' in s]) == 1 assert len([s for s in statements if '"TEST" = "TEST" +' in s]) == 1 + def test_instantiation_with_column_class(self): + """ + Tests that columns instantiated with a column class work properly + and that the class is instantiated in the constructor + """ + column = columns.Set(columns.Text) + assert isinstance(column.value_col, columns.Text) + + def test_instantiation_with_column_instance(self): + """ + Tests that columns instantiated with a column instance work properly + """ + column = columns.Set(columns.Text(min_length=100)) + assert isinstance(column.value_col, columns.Text) + class TestListModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) int_list = columns.List(columns.Integer, required=False) @@ -164,6 +165,21 @@ def test_partial_update_creation(self): assert len([s for s in statements if '"TEST" = "TEST" +' in s]) == 1 assert len([s for s in statements if '+ "TEST"' in s]) == 1 + def test_instantiation_with_column_class(self): + """ + Tests that columns instantiated with a column class work properly + and that the class is instantiated in the constructor + """ + column = columns.List(columns.Text) + assert isinstance(column.value_col, columns.Text) + + def test_instantiation_with_column_instance(self): + """ + Tests that columns instantiated with a column instance work properly + """ + column = columns.List(columns.Text(min_length=100)) + assert isinstance(column.value_col, columns.Text) + class TestMapModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) int_map = columns.Map(columns.Integer, columns.UUID, required=False) @@ -251,6 +267,23 @@ def test_updates_to_none(self): m2 = TestMapModel.get(partition=m.partition) assert m2.int_map is None + def test_instantiation_with_column_class(self): + """ + Tests that columns instantiated with a column class work properly + and that the class is instantiated in the constructor + """ + column = columns.Map(columns.Text, columns.Integer) + assert isinstance(column.key_col, columns.Text) + assert isinstance(column.value_col, columns.Integer) + + def test_instantiation_with_column_instance(self): + """ + Tests that columns instantiated with a column instance work properly + """ + column = columns.Map(columns.Text(min_length=100), columns.Integer()) + assert isinstance(column.key_col, columns.Text) + assert isinstance(column.value_col, columns.Integer) + # def test_partial_update_creation(self): # """ # Tests that proper update statements are created for a partial list update From d78a452087dc1bf4a960420949e14e78dc58ba5b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 19 Jun 2013 15:03:10 -0700 Subject: [PATCH 0276/4200] removing now empty test class --- cqlengine/tests/columns/test_container_columns.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 6ba261d907..c27c03289d 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -6,18 +6,13 @@ from cqlengine.management import create_table, delete_table from cqlengine.tests.base import BaseCassEngTestCase -class TestClassConstruction(BaseCassEngTestCase): - """ - Tests around the instantiation of container columns - """ - - class TestSetModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) int_set = columns.Set(columns.Integer, required=False) text_set = columns.Set(columns.Text, required=False) + class TestSetColumn(BaseCassEngTestCase): @classmethod @@ -94,11 +89,13 @@ def test_instantiation_with_column_instance(self): column = columns.Set(columns.Text(min_length=100)) assert isinstance(column.value_col, columns.Text) + class TestListModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) int_list = columns.List(columns.Integer, required=False) text_list = columns.List(columns.Text, required=False) + class TestListColumn(BaseCassEngTestCase): @classmethod @@ -180,11 +177,13 @@ def test_instantiation_with_column_instance(self): column = columns.List(columns.Text(min_length=100)) assert isinstance(column.value_col, columns.Text) + class TestMapModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) int_map = columns.Map(columns.Integer, columns.UUID, required=False) text_map = columns.Map(columns.Text, columns.DateTime, required=False) + class TestMapColumn(BaseCassEngTestCase): @classmethod From 01cf5bf0b00622f8849d9289920edacab4436e28 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 19 Jun 2013 15:04:24 -0700 Subject: [PATCH 0277/4200] version number bump --- cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index df941b0fff..1bdc2ce5eb 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.4.1' +__version__ = '0.4.2' diff --git a/docs/conf.py b/docs/conf.py index 5aff68540e..8dc1ccd58c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.4' +version = '0.4.2' # The full version, including alpha/beta/rc tags. -release = '0.4.1' +release = '0.4.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index ff3dcddc4b..494f2d7947 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.4.1' +version = '0.4.2' long_desc = """ Cassandra CQL 3 Object Mapper for Python From 43b6eb3c8dca2d3d372ecd308ebe024b7777d707 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 20 Jun 2013 10:30:06 -0700 Subject: [PATCH 0278/4200] fixing text min length calculation to work with new required=False default --- cqlengine/columns.py | 2 +- cqlengine/tests/columns/test_validation.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 2c74ed1028..8f52e3f885 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -197,7 +197,7 @@ class Text(Column): db_type = 'text' def __init__(self, *args, **kwargs): - self.min_length = kwargs.pop('min_length', 1 if kwargs.get('required', True) else None) + self.min_length = kwargs.pop('min_length', 1 if kwargs.get('required', False) else None) self.max_length = kwargs.pop('max_length', None) super(Text, self).__init__(*args, **kwargs) diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index a68958bb23..46e8516364 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -194,6 +194,12 @@ def test_type_checking(self): with self.assertRaises(ValidationError): Text().validate(True) + def test_non_required_validation(self): + """ Tests that validation is ok on none and blank values if required is False """ + Text().validate('') + Text().validate(None) + + class TestExtraFieldsRaiseException(BaseCassEngTestCase): From 6587d383c27ab05a5c4a2bd0fda8e06e1beb2530 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 20 Jun 2013 10:41:57 -0700 Subject: [PATCH 0279/4200] 0.4.3 version bump --- changelog | 3 +++ cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/changelog b/changelog index 29a8b3f208..1c147a6706 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.4.3 +* fixed bug with Text column validation + 0.4.2 * added support for instantiating container columns with column instances diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 1bdc2ce5eb..2ab73db5d1 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.4.2' +__version__ = '0.4.3' diff --git a/docs/conf.py b/docs/conf.py index 8dc1ccd58c..3de8e4dd9f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.4.2' +version = '0.4.3' # The full version, including alpha/beta/rc tags. -release = '0.4.2' +release = '0.4.3' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 494f2d7947..99fa7b3f69 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.4.2' +version = '0.4.3' long_desc = """ Cassandra CQL 3 Object Mapper for Python From 9634f47ea57c2122e9060ec6e94c445e1c941f45 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 20 Jun 2013 10:48:10 -0700 Subject: [PATCH 0280/4200] updating failing test --- cqlengine/tests/columns/test_validation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 46e8516364..53cd738c02 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -158,9 +158,7 @@ class TestText(BaseCassEngTestCase): def test_min_length(self): #min len defaults to 1 col = Text() - - with self.assertRaises(ValidationError): - col.validate('') + col.validate('') col.validate('b') From 174aa4aa1b946f7958ac479821ed32879890f88f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 21 Jun 2013 10:41:34 -0700 Subject: [PATCH 0281/4200] updated batch docs --- docs/topics/queryset.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index c237ed6b59..47fc4ef3e0 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -291,6 +291,15 @@ Batch Queries em3 = ExampleModel.batch(b).create(example_type=0, description="3", created_at=now) b.execute() + # updating in a batch + + b = BatchQuery() + em1.description = "new description" + em1.batch(b).save() + em2.description = "another new description" + em2.batch(b).save() + b.execute() + QuerySet method reference ========================= From 7d62430cc849d1f38a7069e518574f3ec04133ec Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Mon, 24 Jun 2013 10:49:24 +0300 Subject: [PATCH 0282/4200] Restore CQL query logging at DEBUG level. --- cqlengine/connection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 5010aebc1a..4119d7f9bc 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -141,6 +141,7 @@ def execute(self, query, params): con = self.get() cur = con.cursor() cur.execute(query, params) + LOG.debug('{} {}'.format(query, repr(params))) self.put(con) return cur except cql.ProgrammingError as ex: From ad6d7b834ef228f522d2c80eee689ecac45eee93 Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Fri, 7 Jun 2013 23:17:31 +0300 Subject: [PATCH 0283/4200] Make ValueQuoter objects equal if their repr() are equal. This makes model instances which have quoted values to equal in an intuitive manner. --- cqlengine/columns.py | 5 +++++ cqlengine/tests/columns/test_value_io.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 8f52e3f885..8191c2abcc 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -65,6 +65,11 @@ def __str__(self): def __repr__(self): return self.__str__() + def __eq__(self, other): + if isinstance(other, self.__class__): + return repr(self) == repr(other) + return False + class Column(object): diff --git a/cqlengine/tests/columns/test_value_io.py b/cqlengine/tests/columns/test_value_io.py index fda632e965..64054279cc 100644 --- a/cqlengine/tests/columns/test_value_io.py +++ b/cqlengine/tests/columns/test_value_io.py @@ -7,7 +7,10 @@ from cqlengine.management import create_table from cqlengine.management import delete_table from cqlengine.models import Model +from cqlengine.columns import ValueQuoter from cqlengine import columns +import unittest + class BaseColumnIOTest(BaseCassEngTestCase): """ @@ -145,3 +148,11 @@ class TestDecimalIO(BaseColumnIOTest): def comparator_converter(self, val): return Decimal(val) + +class TestQuoter(unittest.TestCase): + + def test_equals(self): + assert ValueQuoter(False) == ValueQuoter(False) + assert ValueQuoter(1) == ValueQuoter(1) + assert ValueQuoter("foo") == ValueQuoter("foo") + assert ValueQuoter(1.55) == ValueQuoter(1.55) From ed46c0c5ab62f84a36e511326daacd88979174c3 Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Fri, 7 Jun 2013 19:18:07 +0300 Subject: [PATCH 0284/4200] Fixed a bug when NetworkTopologyStrategy is used. Although the Cassandra documentation implies that the `replication_factor` parameter would be ignored in this case its presence will cause an error when creating a keyspace using NetworkTopologyStrategy. --- cqlengine/management.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cqlengine/management.py b/cqlengine/management.py index b3504b919e..e8a692e0be 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -30,6 +30,12 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, } replication_map.update(replication_values) + if strategy_class.lower() != 'simplestrategy': + # Although the Cassandra documentation states for `replication_factor` + # that it is "Required if class is SimpleStrategy; otherwise, + # not used." we get an error if it is present. + replication_map.pop('replication_factor', None) + query = """ CREATE KEYSPACE {} WITH REPLICATION = {} From 934c8b34f31d2b5d031def97ae9fa4c15c664240 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 13:39:06 -0700 Subject: [PATCH 0285/4200] fixing bug that would cause failures when updating a model with an empty list --- cqlengine/columns.py | 10 ++++++ .../tests/columns/test_container_columns.py | 31 ++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 8f52e3f885..9c5199866f 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -585,10 +585,20 @@ def _insert(): if val is None or val == prev: return [] + elif prev is None: return _insert() + elif len(val) < len(prev): + # if elements have been removed, + # rewrite the whole list return _insert() + + elif len(prev) == 0: + # if we're updating from an empty + # list, do a complete insert + return _insert() + else: # the prepend and append lists, # if both of these are still None after looking diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index c27c03289d..03b152c3aa 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -146,10 +146,7 @@ def test_partial_updates(self): assert list(m2.int_list) == final def test_partial_update_creation(self): - """ - Tests that proper update statements are created for a partial list update - :return: - """ + """ Tests that proper update statements are created for a partial list update """ final = range(10) initial = final[3:7] @@ -162,6 +159,32 @@ def test_partial_update_creation(self): assert len([s for s in statements if '"TEST" = "TEST" +' in s]) == 1 assert len([s for s in statements if '+ "TEST"' in s]) == 1 + def test_update_from_none(self): + """ Tests that updating an 'None' list creates a straight insert statement """ + ctx = {} + col = columns.List(columns.Integer, db_field="TEST") + statements = col.get_update_statement([1, 2, 3], None, ctx) + + #only one variable /statement should be generated + assert len(ctx) == 1 + assert len(statements) == 1 + + assert str(ctx.values()[0]) == str([1, 2, 3]) + assert statements[0] == '"TEST" = :{}'.format(ctx.keys()[0]) + + def test_update_from_empty(self): + """ Tests that updating an empty list creates a straight insert statement """ + ctx = {} + col = columns.List(columns.Integer, db_field="TEST") + statements = col.get_update_statement([1, 2, 3], [], ctx) + + #only one variable /statement should be generated + assert len(ctx) == 1 + assert len(statements) == 1 + + assert str(ctx.values()[0]) == str([1,2,3]) + assert statements[0] == '"TEST" = :{}'.format(ctx.keys()[0]) + def test_instantiation_with_column_class(self): """ Tests that columns instantiated with a column class work properly From 27ba94a03a5df562eccd695dd235031d4d2490dc Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 13:44:26 -0700 Subject: [PATCH 0286/4200] adding tests around updating empty and null sets --- .../tests/columns/test_container_columns.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 03b152c3aa..e14adc6971 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -74,6 +74,32 @@ def test_partial_update_creation(self): assert len([s for s in statements if '"TEST" = "TEST" -' in s]) == 1 assert len([s for s in statements if '"TEST" = "TEST" +' in s]) == 1 + def test_update_from_none(self): + """ Tests that updating an 'None' list creates a straight insert statement """ + ctx = {} + col = columns.Set(columns.Integer, db_field="TEST") + statements = col.get_update_statement({1,2,3,4}, None, ctx) + + #only one variable /statement should be generated + assert len(ctx) == 1 + assert len(statements) == 1 + + assert ctx.values()[0].value == {1,2,3,4} + assert statements[0] == '"TEST" = :{}'.format(ctx.keys()[0]) + + def test_update_from_empty(self): + """ Tests that updating an empty list creates a straight insert statement """ + ctx = {} + col = columns.Set(columns.Integer, db_field="TEST") + statements = col.get_update_statement({1,2,3,4}, set(), ctx) + + #only one variable /statement should be generated + assert len(ctx) == 1 + assert len(statements) == 1 + + assert ctx.values()[0].value == {1,2,3,4} + assert statements[0] == '"TEST" = :{}'.format(ctx.keys()[0]) + def test_instantiation_with_column_class(self): """ Tests that columns instantiated with a column class work properly @@ -169,7 +195,7 @@ def test_update_from_none(self): assert len(ctx) == 1 assert len(statements) == 1 - assert str(ctx.values()[0]) == str([1, 2, 3]) + assert ctx.values()[0].value == [1, 2, 3] assert statements[0] == '"TEST" = :{}'.format(ctx.keys()[0]) def test_update_from_empty(self): @@ -182,7 +208,7 @@ def test_update_from_empty(self): assert len(ctx) == 1 assert len(statements) == 1 - assert str(ctx.values()[0]) == str([1,2,3]) + assert ctx.values()[0].value == [1,2,3] assert statements[0] == '"TEST" = :{}'.format(ctx.keys()[0]) def test_instantiation_with_column_class(self): From 1aacad2673f5848bf9da0c955d4da343b705e06d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 13:45:14 -0700 Subject: [PATCH 0287/4200] reformatting --- .../tests/columns/test_container_columns.py | 65 +++++++++---------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index e14adc6971..227502b12a 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -8,13 +8,12 @@ class TestSetModel(Model): - partition = columns.UUID(primary_key=True, default=uuid4) - int_set = columns.Set(columns.Integer, required=False) - text_set = columns.Set(columns.Text, required=False) + partition = columns.UUID(primary_key=True, default=uuid4) + int_set = columns.Set(columns.Integer, required=False) + text_set = columns.Set(columns.Text, required=False) class TestSetColumn(BaseCassEngTestCase): - @classmethod def setUpClass(cls): super(TestSetColumn, cls).setUpClass() @@ -28,7 +27,7 @@ def tearDownClass(cls): def test_io_success(self): """ Tests that a basic usage works as expected """ - m1 = TestSetModel.create(int_set={1,2}, text_set={'kai', 'andreas'}) + m1 = TestSetModel.create(int_set={1, 2}, text_set={'kai', 'andreas'}) m2 = TestSetModel.get(partition=m1.partition) assert isinstance(m2.int_set, set) @@ -49,16 +48,16 @@ def test_type_validation(self): def test_partial_updates(self): """ Tests that partial udpates work as expected """ - m1 = TestSetModel.create(int_set={1,2,3,4}) + m1 = TestSetModel.create(int_set={1, 2, 3, 4}) m1.int_set.add(5) m1.int_set.remove(1) - assert m1.int_set == {2,3,4,5} + assert m1.int_set == {2, 3, 4, 5} m1.save() - m2 = TestSetModel.get(partition=m1.partition) - assert m2.int_set == {2,3,4,5} + m2 = TestSetModel.get(partition=m1.partition) + assert m2.int_set == {2, 3, 4, 5} def test_partial_update_creation(self): """ @@ -67,7 +66,7 @@ def test_partial_update_creation(self): """ ctx = {} col = columns.Set(columns.Integer, db_field="TEST") - statements = col.get_update_statement({1,2,3,4}, {2,3,4,5}, ctx) + statements = col.get_update_statement({1, 2, 3, 4}, {2, 3, 4, 5}, ctx) assert len([v for v in ctx.values() if {1} == v.value]) == 1 assert len([v for v in ctx.values() if {5} == v.value]) == 1 @@ -78,26 +77,26 @@ def test_update_from_none(self): """ Tests that updating an 'None' list creates a straight insert statement """ ctx = {} col = columns.Set(columns.Integer, db_field="TEST") - statements = col.get_update_statement({1,2,3,4}, None, ctx) + statements = col.get_update_statement({1, 2, 3, 4}, None, ctx) #only one variable /statement should be generated assert len(ctx) == 1 assert len(statements) == 1 - assert ctx.values()[0].value == {1,2,3,4} + assert ctx.values()[0].value == {1, 2, 3, 4} assert statements[0] == '"TEST" = :{}'.format(ctx.keys()[0]) def test_update_from_empty(self): """ Tests that updating an empty list creates a straight insert statement """ ctx = {} col = columns.Set(columns.Integer, db_field="TEST") - statements = col.get_update_statement({1,2,3,4}, set(), ctx) + statements = col.get_update_statement({1, 2, 3, 4}, set(), ctx) #only one variable /statement should be generated assert len(ctx) == 1 assert len(statements) == 1 - assert ctx.values()[0].value == {1,2,3,4} + assert ctx.values()[0].value == {1, 2, 3, 4} assert statements[0] == '"TEST" = :{}'.format(ctx.keys()[0]) def test_instantiation_with_column_class(self): @@ -117,13 +116,12 @@ def test_instantiation_with_column_instance(self): class TestListModel(Model): - partition = columns.UUID(primary_key=True, default=uuid4) - int_list = columns.List(columns.Integer, required=False) - text_list = columns.List(columns.Text, required=False) + partition = columns.UUID(primary_key=True, default=uuid4) + int_list = columns.List(columns.Integer, required=False) + text_list = columns.List(columns.Text, required=False) class TestListColumn(BaseCassEngTestCase): - @classmethod def setUpClass(cls): super(TestListColumn, cls).setUpClass() @@ -137,7 +135,7 @@ def tearDownClass(cls): def test_io_success(self): """ Tests that a basic usage works as expected """ - m1 = TestListModel.create(int_list=[1,2], text_list=['kai', 'andreas']) + m1 = TestListModel.create(int_list=[1, 2], text_list=['kai', 'andreas']) m2 = TestListModel.get(partition=m1.partition) assert isinstance(m2.int_list, list) @@ -168,7 +166,7 @@ def test_partial_updates(self): m1.int_list = final m1.save() - m2 = TestListModel.get(partition=m1.partition) + m2 = TestListModel.get(partition=m1.partition) assert list(m2.int_list) == final def test_partial_update_creation(self): @@ -180,8 +178,8 @@ def test_partial_update_creation(self): col = columns.List(columns.Integer, db_field="TEST") statements = col.get_update_statement(final, initial, ctx) - assert len([v for v in ctx.values() if [2,1,0] == v.value]) == 1 - assert len([v for v in ctx.values() if [7,8,9] == v.value]) == 1 + assert len([v for v in ctx.values() if [2, 1, 0] == v.value]) == 1 + assert len([v for v in ctx.values() if [7, 8, 9] == v.value]) == 1 assert len([s for s in statements if '"TEST" = "TEST" +' in s]) == 1 assert len([s for s in statements if '+ "TEST"' in s]) == 1 @@ -208,7 +206,7 @@ def test_update_from_empty(self): assert len(ctx) == 1 assert len(statements) == 1 - assert ctx.values()[0].value == [1,2,3] + assert ctx.values()[0].value == [1, 2, 3] assert statements[0] == '"TEST" = :{}'.format(ctx.keys()[0]) def test_instantiation_with_column_class(self): @@ -228,13 +226,12 @@ def test_instantiation_with_column_instance(self): class TestMapModel(Model): - partition = columns.UUID(primary_key=True, default=uuid4) - int_map = columns.Map(columns.Integer, columns.UUID, required=False) - text_map = columns.Map(columns.Text, columns.DateTime, required=False) + partition = columns.UUID(primary_key=True, default=uuid4) + int_map = columns.Map(columns.Integer, columns.UUID, required=False) + text_map = columns.Map(columns.Text, columns.DateTime, required=False) class TestMapColumn(BaseCassEngTestCase): - @classmethod def setUpClass(cls): super(TestMapColumn, cls).setUpClass() @@ -252,7 +249,7 @@ def test_io_success(self): k2 = uuid4() now = datetime.now() then = now + timedelta(days=1) - m1 = TestMapModel.create(int_map={1:k1,2:k2}, text_map={'now':now, 'then':then}) + m1 = TestMapModel.create(int_map={1: k1, 2: k2}, text_map={'now': now, 'then': then}) m2 = TestMapModel.get(partition=m1.partition) assert isinstance(m2.int_map, dict) @@ -273,7 +270,7 @@ def test_type_validation(self): Tests that attempting to use the wrong types will raise an exception """ with self.assertRaises(ValidationError): - TestMapModel.create(int_map={'key':2,uuid4():'val'}, text_map={2:5}) + TestMapModel.create(int_map={'key': 2, uuid4(): 'val'}, text_map={2: 5}) def test_partial_updates(self): """ Tests that partial udpates work as expected """ @@ -284,21 +281,21 @@ def test_partial_updates(self): earlier = early - timedelta(minutes=30) later = now + timedelta(minutes=30) - initial = {'now':now, 'early':earlier} - final = {'later':later, 'early':early} + initial = {'now': now, 'early': earlier} + final = {'later': later, 'early': early} m1 = TestMapModel.create(text_map=initial) m1.text_map = final m1.save() - m2 = TestMapModel.get(partition=m1.partition) + m2 = TestMapModel.get(partition=m1.partition) assert m2.text_map == final def test_updates_from_none(self): """ Tests that updates from None work as expected """ m = TestMapModel.create(int_map=None) - expected = {1:uuid4()} + expected = {1: uuid4()} m.int_map = expected m.save() @@ -308,7 +305,7 @@ def test_updates_from_none(self): def test_updates_to_none(self): """ Tests that setting the field to None works as expected """ - m = TestMapModel.create(int_map={1:uuid4()}) + m = TestMapModel.create(int_map={1: uuid4()}) m.int_map = None m.save() From f65b39ee2b6a679f1360498d9e7e07562b4d6162 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 13:48:21 -0700 Subject: [PATCH 0288/4200] changelog update and version bump --- changelog | 4 ++++ cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/changelog b/changelog index 1c147a6706..5c70db60b6 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,9 @@ CHANGELOG +0.4.4 +* adding query logging back +* fixed bug updating an empty list column + 0.4.3 * fixed bug with Text column validation diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 2ab73db5d1..98c57ebebd 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.4.3' +__version__ = '0.4.4' diff --git a/docs/conf.py b/docs/conf.py index 3de8e4dd9f..8bbd4c3575 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.4.3' +version = '0.4.4' # The full version, including alpha/beta/rc tags. -release = '0.4.3' +release = '0.4.4' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 99fa7b3f69..bedf371d88 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.4.3' +version = '0.4.4' long_desc = """ Cassandra CQL 3 Object Mapper for Python From 4b97781a7bdd15d172adb8231d41f5412ff33c61 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 15:00:44 -0700 Subject: [PATCH 0289/4200] adding to_python call to value column in container column to_python methods --- cqlengine/columns.py | 9 +++- .../tests/columns/test_container_columns.py | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 9c5199866f..a4dc26d09f 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -489,6 +489,10 @@ def validate(self, value): return {self.value_col.validate(v) for v in val} + def to_python(self, value): + if value is None: return None + return {self.value_col.to_python(v) for v in value} + def to_database(self, value): if value is None: return None if isinstance(value, self.Quoter): return value @@ -560,7 +564,7 @@ def validate(self, value): def to_python(self, value): if value is None: return None - return list(value) + return [self.value_col.to_python(v) for v in value] def to_database(self, value): if value is None: return None @@ -643,6 +647,7 @@ def _insert(): return statements + class Map(BaseContainerColumn): """ Stores a key -> value map (dictionary) @@ -698,7 +703,7 @@ def validate(self, value): def to_python(self, value): if value is not None: - return {self.key_col.to_python(k):self.value_col.to_python(v) for k,v in value.items()} + return {self.key_col.to_python(k): self.value_col.to_python(v) for k,v in value.items()} def to_database(self, value): if value is None: return None diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 227502b12a..2134764dde 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +import json from uuid import uuid4 from cqlengine import Model, ValidationError @@ -13,6 +14,20 @@ class TestSetModel(Model): text_set = columns.Set(columns.Text, required=False) +class JsonTestColumn(columns.Column): + db_type = 'text' + + def to_python(self, value): + if value is None: return + if isinstance(value, basestring): + return json.loads(value) + else: + return value + + def to_database(self, value): + if value is None: return + return json.dumps(value) + class TestSetColumn(BaseCassEngTestCase): @classmethod def setUpClass(cls): @@ -114,6 +129,15 @@ def test_instantiation_with_column_instance(self): column = columns.Set(columns.Text(min_length=100)) assert isinstance(column.value_col, columns.Text) + def test_to_python(self): + """ Tests that to_python of value column is called """ + column = columns.Set(JsonTestColumn) + val = {1, 2, 3} + db_val = column.to_database(val) + assert db_val.value == {json.dumps(v) for v in val} + py_val = column.to_python(db_val.value) + assert py_val == val + class TestListModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) @@ -224,6 +248,14 @@ def test_instantiation_with_column_instance(self): column = columns.List(columns.Text(min_length=100)) assert isinstance(column.value_col, columns.Text) + def test_to_python(self): + """ Tests that to_python of value column is called """ + column = columns.List(JsonTestColumn) + val = [1, 2, 3] + db_val = column.to_database(val) + assert db_val.value == [json.dumps(v) for v in val] + py_val = column.to_python(db_val.value) + assert py_val == val class TestMapModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) @@ -329,6 +361,15 @@ def test_instantiation_with_column_instance(self): assert isinstance(column.key_col, columns.Text) assert isinstance(column.value_col, columns.Integer) + def test_to_python(self): + """ Tests that to_python of value column is called """ + column = columns.Map(JsonTestColumn, JsonTestColumn) + val = {1: 2, 3: 4, 5: 6} + db_val = column.to_database(val) + assert db_val.value == {json.dumps(k):json.dumps(v) for k,v in val.items()} + py_val = column.to_python(db_val.value) + assert py_val == val + # def test_partial_update_creation(self): # """ # Tests that proper update statements are created for a partial list update From 479224df322104776dda3c6f586f7339a8747ffe Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 15:02:33 -0700 Subject: [PATCH 0290/4200] updating changelog and version --- changelog | 3 +++ cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/changelog b/changelog index 5c70db60b6..7a68e4f84b 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.4.5 +* fixed bug where container columns would not call their child to_python method, this only really affected columns with special to_python logic + 0.4.4 * adding query logging back * fixed bug updating an empty list column diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 98c57ebebd..f246c6a70a 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.4.4' +__version__ = '0.4.5' diff --git a/docs/conf.py b/docs/conf.py index 8bbd4c3575..cadd3038d6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.4.4' +version = '0.4.5' # The full version, including alpha/beta/rc tags. -release = '0.4.4' +release = '0.4.5' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index bedf371d88..326913033d 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.4.4' +version = '0.4.5' long_desc = """ Cassandra CQL 3 Object Mapper for Python From 044e9fb933a6e8f0af5b7dc583719ed6f6140f3f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 15:30:48 -0700 Subject: [PATCH 0291/4200] updating value quoter equal function to compare values not repr --- cqlengine/columns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 0c4e62467b..fe205ae884 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -67,7 +67,7 @@ def __repr__(self): def __eq__(self, other): if isinstance(other, self.__class__): - return repr(self) == repr(other) + return self.value == other.value return False From e722b505464e3f8c8a76fd564653aed97b69ebce Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 15:34:35 -0700 Subject: [PATCH 0292/4200] updating equality operations to stop relying on the to_database dictionary conversion --- cqlengine/models.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index a0c41be1ce..609e055933 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -186,7 +186,21 @@ def _get_column(cls, name): return cls._columns[name] def __eq__(self, other): - return self.as_dict() == other.as_dict() + if self.__class__ != other.__class__: + return False + + # check attribute keys + keys = set(self._columns.keys()) + other_keys = set(self._columns.keys()) + if keys != other_keys: + return False + + # check that all of the attributes match + for key in other_keys: + if getattr(self, key, None) != getattr(other, key, None): + return False + + return True def __ne__(self, other): return not self.__eq__(other) From 7a4c7235006da305273b49c6a28fe83497b9dec1 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 15:46:38 -0700 Subject: [PATCH 0293/4200] renaming as_dict to _as_dict --- cqlengine/models.py | 2 +- cqlengine/query.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 609e055933..8849324943 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -232,7 +232,7 @@ def validate(self): val = col.validate(getattr(self, name)) setattr(self, name, val) - def as_dict(self): + def _as_dict(self): """ Returns a map of column names to cleaned values """ values = self._dynamic_columns or {} for name, col in self._columns.items(): diff --git a/cqlengine/query.py b/cqlengine/query.py index b01e1a9ea3..b8b4926dea 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -794,7 +794,7 @@ def save(self): #organize data value_pairs = [] - values = self.instance.as_dict() + values = self.instance._as_dict() #get defined fields and their column names for name, col in self.model._columns.items(): From ed0bb798962205b699da5c0ddf4e830ad6afcb2a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 15:47:41 -0700 Subject: [PATCH 0294/4200] updating changelog and version --- changelog | 3 +++ cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/changelog b/changelog index 7a68e4f84b..35981af32e 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.4.6 +* fixing the way models are compared + 0.4.5 * fixed bug where container columns would not call their child to_python method, this only really affected columns with special to_python logic diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index f246c6a70a..a07df1f2e7 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.4.5' +__version__ = '0.4.6' diff --git a/docs/conf.py b/docs/conf.py index cadd3038d6..880cbeb40b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.4.5' +version = '0.4.6' # The full version, including alpha/beta/rc tags. -release = '0.4.5' +release = '0.4.6' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 326913033d..c00d3ab982 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.4.5' +version = '0.4.6' long_desc = """ Cassandra CQL 3 Object Mapper for Python From f90cfb046a7e727a160e4e331d799064d1588c06 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 27 Jun 2013 13:10:16 -0700 Subject: [PATCH 0295/4200] adding support for setting a query's batch object to None --- cqlengine/query.py | 24 +++++++++++------------ cqlengine/tests/query/test_batch_query.py | 21 +++++++++++++++++++- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index b8b4926dea..d86b504bab 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -428,8 +428,8 @@ def batch(self, batch_obj): :param batch_obj: :return: """ - if not isinstance(batch_obj, BatchQuery): - raise CQLEngineException('batch_obj must be a BatchQuery instance') + if batch_obj is not None and not isinstance(batch_obj, BatchQuery): + raise CQLEngineException('batch_obj must be a BatchQuery instance or None') clone = copy.deepcopy(self) clone._batch = batch_obj return clone @@ -772,13 +772,13 @@ def __init__(self, model, instance=None, batch=None): self.model = model self.column_family_name = self.model.column_family_name() self.instance = instance - self.batch = batch + self._batch = batch pass def batch(self, batch_obj): - if not isinstance(batch_obj, BatchQuery): - raise CQLEngineException('batch_obj must be a BatchQuery instance') - self.batch = batch_obj + if batch_obj is not None and not isinstance(batch_obj, BatchQuery): + raise CQLEngineException('batch_obj must be a BatchQuery instance or None') + self._batch = batch_obj return self def save(self): @@ -852,8 +852,8 @@ def save(self): # skip query execution if it's empty # caused by pointless update queries if qs: - if self.batch: - self.batch.add_query(qs, query_values) + if self._batch: + self._batch.add_query(qs, query_values) else: execute(qs, query_values) @@ -885,8 +885,8 @@ def save(self): qs = ' '.join(qs) - if self.batch: - self.batch.add_query(qs, query_values) + if self._batch: + self._batch.add_query(qs, query_values) else: execute(qs, query_values) @@ -906,8 +906,8 @@ def delete(self): qs += [' AND '.join(where_statements)] qs = ' '.join(qs) - if self.batch: - self.batch.add_query(qs, field_values) + if self._batch: + self._batch.add_query(qs, field_values) else: execute(qs, field_values) diff --git a/cqlengine/tests/query/test_batch_query.py b/cqlengine/tests/query/test_batch_query.py index 5b31826a39..37e8d28d56 100644 --- a/cqlengine/tests/query/test_batch_query.py +++ b/cqlengine/tests/query/test_batch_query.py @@ -4,7 +4,7 @@ import random from cqlengine import Model, columns from cqlengine.management import delete_table, create_table -from cqlengine.query import BatchQuery +from cqlengine.query import BatchQuery, DMLQuery from cqlengine.tests.base import BaseCassEngTestCase class TestMultiKeyModel(Model): @@ -104,3 +104,22 @@ def test_bulk_delete_success_case(self): for m in TestMultiKeyModel.all(): m.delete() + def test_none_success_case(self): + """ Tests that passing None into the batch call clears any batch object """ + b = BatchQuery() + + q = TestMultiKeyModel.objects.batch(b) + assert q._batch == b + + q = q.batch(None) + assert q._batch is None + + def test_dml_none_success_case(self): + """ Tests that passing None into the batch call clears any batch object """ + b = BatchQuery() + + q = DMLQuery(TestMultiKeyModel, batch=b) + assert q._batch == b + + q.batch(None) + assert q._batch is None From 01e9747411452e889f2aebeda26c6c8ea2727930 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 27 Jun 2013 13:11:50 -0700 Subject: [PATCH 0296/4200] changelog and version update --- changelog | 3 +++ cqlengine/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/changelog b/changelog index 35981af32e..6d49d52889 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.4.7 +* adding support for passing None into query batch methods to clear any batch objects + 0.4.6 * fixing the way models are compared diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index a07df1f2e7..d9763109b4 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,5 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.4.6' +__version__ = '0.4.7' diff --git a/docs/conf.py b/docs/conf.py index 880cbeb40b..2b29bbfe6d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.4.6' +version = '0.4.7' # The full version, including alpha/beta/rc tags. -release = '0.4.6' +release = '0.4.7' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index c00d3ab982..9865237f08 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.4.6' +version = '0.4.7' long_desc = """ Cassandra CQL 3 Object Mapper for Python From 75d20b69200e99f8ff209530c6c824505b2f93f1 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 27 Jun 2013 14:22:47 -0700 Subject: [PATCH 0297/4200] fixed empty sets, maps, lists --- cqlengine/columns.py | 15 +++++++++----- .../tests/columns/test_container_columns.py | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index fe205ae884..f8d262dfe5 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -473,14 +473,15 @@ def __str__(self): cq = cql_quote return '{' + ', '.join([cq(v) for v in self.value]) + '}' - def __init__(self, value_type, strict=True, **kwargs): + def __init__(self, value_type, strict=True, default=set, **kwargs): """ :param value_type: a column class indicating the types of the value :param strict: sets whether non set values will be coerced to set type on validation, or raise a validation error, defaults to True """ self.strict = strict - super(Set, self).__init__(value_type, **kwargs) + + super(Set, self).__init__(value_type, default=default, **kwargs) def validate(self, value): val = super(Set, self).validate(value) @@ -495,11 +496,12 @@ def validate(self, value): return {self.value_col.validate(v) for v in val} def to_python(self, value): - if value is None: return None + if value is None: return set() return {self.value_col.to_python(v) for v in value} def to_database(self, value): if value is None: return None + if isinstance(value, self.Quoter): return value return self.Quoter({self.value_col.to_database(v) for v in value}) @@ -560,6 +562,9 @@ def __str__(self): cq = cql_quote return '[' + ', '.join([cq(v) for v in self.value]) + ']' + def __init__(self, value_type, default=set, **kwargs): + return super(List, self).__init__(value_type=value_type, default=default, **kwargs) + def validate(self, value): val = super(List, self).validate(value) if val is None: return @@ -668,7 +673,7 @@ def __str__(self): cq = cql_quote return '{' + ', '.join([cq(k) + ':' + cq(v) for k,v in self.value.items()]) + '}' - def __init__(self, key_type, value_type, **kwargs): + def __init__(self, key_type, value_type, default=dict, **kwargs): """ :param key_type: a column class indicating the types of the key :param value_type: a column class indicating the types of the value @@ -687,7 +692,7 @@ def __init__(self, key_type, value_type, **kwargs): else: self.key_col = key_type self.key_type = self.key_col.__class__ - super(Map, self).__init__(value_type, **kwargs) + super(Map, self).__init__(value_type, default=default, **kwargs) def get_column_def(self): """ diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 2134764dde..180378dec0 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -40,6 +40,16 @@ def tearDownClass(cls): super(TestSetColumn, cls).tearDownClass() delete_table(TestSetModel) + + def test_empty_set_initial(self): + """ + tests that sets are set() by default, should never be none + :return: + """ + m = TestSetModel.create() + m.int_set.add(5) + m.save() + def test_io_success(self): """ Tests that a basic usage works as expected """ m1 = TestSetModel.create(int_set={1, 2}, text_set={'kai', 'andreas'}) @@ -129,6 +139,8 @@ def test_instantiation_with_column_instance(self): column = columns.Set(columns.Text(min_length=100)) assert isinstance(column.value_col, columns.Text) + + def test_to_python(self): """ Tests that to_python of value column is called """ column = columns.Set(JsonTestColumn) @@ -157,6 +169,10 @@ def tearDownClass(cls): super(TestListColumn, cls).tearDownClass() delete_table(TestListModel) + def test_initial(self): + tmp = TestListModel.create() + tmp.int_list.append(1) + def test_io_success(self): """ Tests that a basic usage works as expected """ m1 = TestListModel.create(int_list=[1, 2], text_list=['kai', 'andreas']) @@ -275,6 +291,10 @@ def tearDownClass(cls): super(TestMapColumn, cls).tearDownClass() delete_table(TestMapModel) + def test_empty_default(self): + tmp = TestMapModel.create() + tmp.int_map['blah'] = 1 + def test_io_success(self): """ Tests that a basic usage works as expected """ k1 = uuid4() From 5cc63e67cfb8aec15ff1d0a4fc8240cafd64cd71 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 27 Jun 2013 14:58:32 -0700 Subject: [PATCH 0298/4200] CQL version file instead of manually updating --- cqlengine/VERSION | 1 + cqlengine/__init__.py | 4 +++- docs/conf.py | 7 +++++-- setup.py | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 cqlengine/VERSION diff --git a/cqlengine/VERSION b/cqlengine/VERSION new file mode 100644 index 0000000000..cb498ab2c8 --- /dev/null +++ b/cqlengine/VERSION @@ -0,0 +1 @@ +0.4.8 diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index d9763109b4..bdcaa6e420 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -3,5 +3,7 @@ from cqlengine.models import Model from cqlengine.query import BatchQuery -__version__ = '0.4.7' +__cqlengine_version_path__ = os.path.realpath(__file__ + '/../VERSION') +__version__ = open(__cqlengine_version_path__, 'r').readline().strip() + diff --git a/docs/conf.py b/docs/conf.py index 2b29bbfe6d..847ef50dce 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,10 +47,13 @@ # |version| and |release|, also used in various other places throughout the # built documents. # +__cqlengine_version_path__ = os.path.realpath(__file__ + '/../VERSION') # The short X.Y version. -version = '0.4.7' +version = open(__cqlengine_version_path__, 'r').readline().strip() # The full version, including alpha/beta/rc tags. -release = '0.4.7' +release = version + + # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index 9865237f08..5fd441428a 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ #python setup.py register #python setup.py sdist upload -version = '0.4.7' +version = open('cqlengine/VERSION', 'r').readline().strip() long_desc = """ Cassandra CQL 3 Object Mapper for Python From 7ab977b59c540c2746cd1c2fe09e84b1b2016870 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 27 Jun 2013 15:01:15 -0700 Subject: [PATCH 0299/4200] fixed import --- cqlengine/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index bdcaa6e420..27ef399572 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -1,3 +1,5 @@ +import os + from cqlengine.columns import * from cqlengine.functions import * from cqlengine.models import Model From e2dafaa25caea0f137ad78bd4c33af8d65756ed5 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 27 Jun 2013 15:30:02 -0700 Subject: [PATCH 0300/4200] fixes for pulling data out of DB with empty sets, lists, dict --- cqlengine/columns.py | 4 +++- cqlengine/models.py | 3 ++- .../tests/columns/test_container_columns.py | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index f8d262dfe5..3b7a22d06e 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -573,7 +573,7 @@ def validate(self, value): return [self.value_col.validate(v) for v in val] def to_python(self, value): - if value is None: return None + if value is None: return [] return [self.value_col.to_python(v) for v in value] def to_database(self, value): @@ -712,6 +712,8 @@ def validate(self, value): return {self.key_col.validate(k):self.value_col.validate(v) for k,v in val.items()} def to_python(self, value): + if value is None: + return {} if value is not None: return {self.key_col.to_python(k): self.value_col.to_python(v) for k,v in value.items()} diff --git a/cqlengine/models.py b/cqlengine/models.py index 8849324943..a2d9b6abce 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -149,7 +149,8 @@ def __init__(self, **values): for name, column in self._columns.items(): value = values.get(name, None) - if value is not None: value = column.to_python(value) + if value is not None or isinstance(column, columns.BaseContainerColumn): + value = column.to_python(value) value_mngr = column.value_manager(self, column, value) self._values[name] = value_mngr diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 180378dec0..6cf218299c 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -50,6 +50,11 @@ def test_empty_set_initial(self): m.int_set.add(5) m.save() + def test_empty_set_retrieval(self): + m = TestSetModel.create() + m2 = TestSetModel.get(partition=m.partition) + m2.int_set.add(3) + def test_io_success(self): """ Tests that a basic usage works as expected """ m1 = TestSetModel.create(int_set={1, 2}, text_set={'kai', 'andreas'}) @@ -173,6 +178,11 @@ def test_initial(self): tmp = TestListModel.create() tmp.int_list.append(1) + def test_initial(self): + tmp = TestListModel.create() + tmp2 = TestListModel.get(partition=tmp.partition) + tmp2.int_list.append(1) + def test_io_success(self): """ Tests that a basic usage works as expected """ m1 = TestListModel.create(int_list=[1, 2], text_list=['kai', 'andreas']) @@ -295,6 +305,13 @@ def test_empty_default(self): tmp = TestMapModel.create() tmp.int_map['blah'] = 1 + def test_empty_retrieve(self): + tmp = TestMapModel.create() + tmp2 = TestMapModel.get(partition=tmp.partition) + tmp2.int_map['blah'] = 1 + + + def test_io_success(self): """ Tests that a basic usage works as expected """ k1 = uuid4() @@ -362,7 +379,7 @@ def test_updates_to_none(self): m.save() m2 = TestMapModel.get(partition=m.partition) - assert m2.int_map is None + assert m2.int_map == {} def test_instantiation_with_column_class(self): """ From 62fdaa5542d80be291f1826f108ebac1a4b426bc Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 27 Jun 2013 15:30:52 -0700 Subject: [PATCH 0301/4200] version bump --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index cb498ab2c8..76914ddc02 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.4.8 +0.4.9 From db60c151817794dc11b3eb309ba5a22341b64eb2 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 27 Jun 2013 16:03:12 -0700 Subject: [PATCH 0302/4200] batch docs for deletion --- docs/topics/queryset.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index 9248e1acb7..cb1fdbb836 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -306,6 +306,12 @@ Batch Queries em2.batch(b).save() b.execute() + # deleting in a batch + b = BatchQuery() + ExampleModel.objects(id=some_id).batch(b).delete() + ExampleModel.objects(id=some_id2).batch(b).delete() + b.execute() + QuerySet method reference ========================= From cd7a36665f94ee0b03bac8d9048f8f1e6f5a70f1 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 30 Jun 2013 18:25:13 -0700 Subject: [PATCH 0303/4200] updating field placeholders to use uuid4 instead of uuid1 due to collision0 which appear in some Virtualbox systems fixes #82 --- cqlengine/query.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index d86b504bab..16f6e806b3 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -1,9 +1,6 @@ -from collections import namedtuple import copy from datetime import datetime -from hashlib import md5 -from time import time -from uuid import uuid1 +from uuid import uuid4 from cqlengine import BaseContainerColumn, BaseValueManager, Map, columns from cqlengine.connection import connection_pool, connection_manager, execute @@ -122,7 +119,7 @@ class EqualsOperator(QueryOperator): class IterableQueryValue(QueryValue): def __init__(self, value): try: - super(IterableQueryValue, self).__init__(value, [uuid1().hex for i in value]) + super(IterableQueryValue, self).__init__(value, [uuid4().hex for i in value]) except TypeError: raise QueryException("in operator arguments must be iterable, {} found".format(value)) @@ -804,7 +801,7 @@ def save(self): #construct query string field_names = zip(*value_pairs)[0] - field_ids = {n:uuid1().hex for n in field_names} + field_ids = {n:uuid4().hex for n in field_names} field_values = dict(value_pairs) query_values = {field_ids[n]:field_values[n] for n in field_names} @@ -878,7 +875,7 @@ def save(self): qs += ['WHERE'] where_statements = [] for name, col in self.model._primary_keys.items(): - field_id = uuid1().hex + field_id = uuid4().hex query_values[field_id] = field_values[name] where_statements += ['"{}" = :{}'.format(col.db_field_name, field_id)] qs += [' AND '.join(where_statements)] @@ -899,7 +896,7 @@ def delete(self): qs += ['WHERE'] where_statements = [] for name, col in self.model._primary_keys.items(): - field_id = uuid1().hex + field_id = uuid4().hex field_values[field_id] = col.to_database(getattr(self.instance, name)) where_statements += ['"{}" = :{}'.format(col.db_field_name, field_id)] From 49acd4b271bc9510201c518904e4b14dec527865 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 30 Jun 2013 18:27:33 -0700 Subject: [PATCH 0304/4200] updating changelog and version --- changelog | 3 +++ cqlengine/VERSION | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 6d49d52889..d090b319c6 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.4.10 +* changing query parameter placeholders from uuid1 to uuid4 + 0.4.7 * adding support for passing None into query batch methods to clear any batch objects diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 76914ddc02..e8423da873 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.4.9 +0.4.10 From d417e09eb508e1b81133a0b28cbac9c4715a9fde Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 1 Jul 2013 16:01:32 -0700 Subject: [PATCH 0305/4200] modifying execute function to just return the connection pool Conflicts: cqlengine/connection.py --- cqlengine/connection.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 4119d7f9bc..03810c57ee 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -156,7 +156,8 @@ def execute(query, params={}): @contextmanager def connection_manager(): + """ :rtype: ConnectionPool """ global connection_pool - tmp = connection_pool.get() - yield tmp - connection_pool.put(tmp) + # tmp = connection_pool.get() + yield connection_pool + # connection_pool.put(tmp) From 85d75512537f3154d45c01a666d68885baf90374 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 1 Jul 2013 16:02:56 -0700 Subject: [PATCH 0306/4200] updating row serialization Conflicts: cqlengine/connection.py cqlengine/query.py --- cqlengine/connection.py | 11 +++++++++++ cqlengine/management.py | 2 ++ cqlengine/query.py | 16 +++++++--------- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 03810c57ee..cca31b118b 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -27,6 +27,17 @@ class CQLConnectionError(CQLEngineException): pass # global connection pool connection_pool = None + +class CQLConnectionError(CQLEngineException): pass + + +class RowResult(tuple): pass + + +def _column_tuple_factory(colnames, values): + return tuple(colnames), [RowResult(v) for v in values] + + def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None, consistency='ONE'): """ Records the hosts and connects to one of them diff --git a/cqlengine/management.py b/cqlengine/management.py index b3504b919e..dbe6a497ae 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -40,11 +40,13 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, execute(query) + def delete_keyspace(name): with connection_manager() as con: if name in [k.name for k in con.client.describe_keyspaces()]: execute("DROP KEYSPACE {}".format(name)) + def create_table(model, create_missing_keyspace=True): if model.__abstract__: diff --git a/cqlengine/query.py b/cqlengine/query.py index 16f6e806b3..4cf1a86711 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -3,7 +3,7 @@ from uuid import uuid4 from cqlengine import BaseContainerColumn, BaseValueManager, Map, columns -from cqlengine.connection import connection_pool, connection_manager, execute +from cqlengine.connection import connection_manager, execute, RowResult from cqlengine.exceptions import CQLEngineException from cqlengine.functions import QueryValue, Token @@ -353,11 +353,8 @@ def _execute_query(self): if self._batch: raise CQLEngineException("Only inserts, updates, and deletes are available in batch mode") if self._result_cache is None: - self._cur = execute(self._select_query(), self._where_values()) - self._result_cache = [None]*self._cur.rowcount - if self._cur.description: - names = [i[0] for i in self._cur.description] - self._construct_result = self._create_result_constructor(names) + columns, self._result_cache = execute(self._select_query(), self._where_values()) + self._construct_result = self._create_result_constructor(columns) def _fill_result_cache_to_idx(self, idx): self._execute_query() @@ -368,7 +365,9 @@ def _fill_result_cache_to_idx(self, idx): if qty < 1: return else: - for values in self._cur.fetchmany(qty): + start = max(0, self._result_idx) + stop = start + qty + for values in self._result_cache[start:stop]: self._result_idx += 1 self._result_cache[self._result_idx] = self._construct_result(values) @@ -382,7 +381,7 @@ def __iter__(self): for idx in range(len(self._result_cache)): instance = self._result_cache[idx] - if instance is None: + if isinstance(instance, RowResult): self._fill_result_cache_to_idx(idx) yield self._result_cache[idx] @@ -716,7 +715,6 @@ def _construct_instance(values): return instance return _construct_instance - columns = [model._columns[db_map[name]] for name in names] if self._flat_values_list: return (lambda values: columns[0].to_python(values[0])) else: From 0d1ae6e6ca28554e6305ff33ad7c4779683f5413 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 26 Jun 2013 08:04:15 -0700 Subject: [PATCH 0307/4200] fixing a problem with the result cache filler --- cqlengine/connection.py | 3 ++- cqlengine/query.py | 6 ++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index cca31b118b..8b3a8f0479 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -31,7 +31,8 @@ class CQLConnectionError(CQLEngineException): pass class CQLConnectionError(CQLEngineException): pass -class RowResult(tuple): pass +class RowResult(tuple): + pass def _column_tuple_factory(colnames, values): diff --git a/cqlengine/query.py b/cqlengine/query.py index 4cf1a86711..ea81376f7c 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -365,11 +365,9 @@ def _fill_result_cache_to_idx(self, idx): if qty < 1: return else: - start = max(0, self._result_idx) - stop = start + qty - for values in self._result_cache[start:stop]: + for idx in range(qty): self._result_idx += 1 - self._result_cache[self._result_idx] = self._construct_result(values) + self._result_cache[self._result_idx] = self._construct_result(self._result_cache[self._result_idx]) #return the connection to the connection pool if we have all objects if self._result_cache and self._result_idx == (len(self._result_cache) - 1): From d3137dc321fa82aa848a968e406e12f51fdb8f52 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 1 Jul 2013 16:04:15 -0700 Subject: [PATCH 0308/4200] fixing count method Conflicts: cqlengine/query.py --- cqlengine/query.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index ea81376f7c..7431bd8cb9 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -1,6 +1,9 @@ import copy from datetime import datetime from uuid import uuid4 +from hashlib import md5 +from time import time +from uuid import uuid1 from cqlengine import BaseContainerColumn, BaseValueManager, Map, columns from cqlengine.connection import connection_manager, execute, RowResult @@ -545,8 +548,8 @@ def count(self): qs = ' '.join(qs) - cur = execute(qs, self._where_values()) - return cur.fetchone()[0] + result = execute(qs, self._where_values(), row_factory=decoder.tuple_factory) + return result[0][0] else: return len(self._result_cache) From 533d70b1be65ac5348720c24e7401871112397d4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 25 Jun 2013 10:12:12 -0700 Subject: [PATCH 0309/4200] updating management module to work with the native driver --- cqlengine/management.py | 83 ++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 46 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index dbe6a497ae..23f32795c6 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -14,36 +14,30 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, :param **replication_values: 1.2 only, additional values to ad to the replication data map """ with connection_manager() as con: - #TODO: check system tables instead of using cql thrifteries - if not any([name == k.name for k in con.client.describe_keyspaces()]): - # if name not in [k.name for k in con.con.client.describe_keyspaces()]: - try: - #Try the 1.1 method - execute("""CREATE KEYSPACE {} - WITH strategy_class = '{}' - AND strategy_options:replication_factor={};""".format(name, strategy_class, replication_factor)) - except CQLEngineException: - #try the 1.2 method - replication_map = { - 'class': strategy_class, - 'replication_factor':replication_factor - } - replication_map.update(replication_values) - - query = """ - CREATE KEYSPACE {} - WITH REPLICATION = {} - """.format(name, json.dumps(replication_map).replace('"', "'")) - - if strategy_class != 'SimpleStrategy': - query += " AND DURABLE_WRITES = {}".format('true' if durable_writes else 'false') - - execute(query) + keyspaces = con.execute("""SELECT keyspace_name FROM system.schema_keyspaces""", {}) + if name not in [r['keyspace_name'] for r in keyspaces]: + #try the 1.2 method + replication_map = { + 'class': strategy_class, + 'replication_factor':replication_factor + } + replication_map.update(replication_values) + + query = """ + CREATE KEYSPACE {} + WITH REPLICATION = {} + """.format(name, json.dumps(replication_map).replace('"', "'")) + + if strategy_class != 'SimpleStrategy': + query += " AND DURABLE_WRITES = {}".format('true' if durable_writes else 'false') + + execute(query) def delete_keyspace(name): with connection_manager() as con: - if name in [k.name for k in con.client.describe_keyspaces()]: + keyspaces = con.execute("""SELECT keyspace_name FROM system.schema_keyspaces""", {}) + if name in [r['keyspace_name'] for r in keyspaces]: execute("DROP KEYSPACE {}".format(name)) @@ -56,16 +50,17 @@ def create_table(model, create_missing_keyspace=True): cf_name = model.column_family_name() raw_cf_name = model.column_family_name(include_keyspace=False) + ks_name = model._get_keyspace() #create missing keyspace if create_missing_keyspace: - create_keyspace(model._get_keyspace()) + create_keyspace(ks_name) with connection_manager() as con: - ks_info = con.client.describe_keyspace(model._get_keyspace()) + tables = con.execute("SELECT columnfamily_name from system.schema_columnfamilies WHERE keyspace_name = %s", [ks_name]) #check for an existing column family #TODO: check system tables instead of using cql thrifteries - if not any([raw_cf_name == cf.name for cf in ks_info.cf_defs]): + if raw_cf_name not in tables: qs = ['CREATE TABLE {}'.format(cf_name)] #add column types @@ -105,10 +100,9 @@ def add_column(col): #get existing index names, skip ones that already exist with connection_manager() as con: - ks_info = con.client.describe_keyspace(model._get_keyspace()) + idx_names = con.execute("SELECT index_name from system.\"IndexInfo\" WHERE table_name=%s", [raw_cf_name]) - cf_defs = [cf for cf in ks_info.cf_defs if cf.name == raw_cf_name] - idx_names = [i.index_name for i in cf_defs[0].column_metadata] if cf_defs else [] + idx_names = [i['index_name'] for i in idx_names] idx_names = filter(None, idx_names) indexes = [c for n,c in model._columns.items() if c.index] @@ -120,22 +114,19 @@ def add_column(col): qs += ['("{}")'.format(column.db_field_name)] qs = ' '.join(qs) - try: - execute(qs) - except CQLEngineException as ex: - # 1.2 doesn't return cf names, so we have to examine the exception - # and ignore if it says the index already exists - if "Index already exists" not in unicode(ex): - raise + execute(qs) def delete_table(model): - cf_name = model.column_family_name() - try: - execute('drop table {};'.format(cf_name)) - except CQLEngineException as ex: - #don't freak out if the table doesn't exist - if 'Cannot drop non existing column family' not in unicode(ex): - raise + # don't try to delete non existant tables + ks_name = model._get_keyspace() + with connection_manager() as con: + tables = con.execute("SELECT columnfamily_name from system.schema_columnfamilies WHERE keyspace_name = %s", [ks_name]) + raw_cf_name = model.column_family_name(include_keyspace=False) + if raw_cf_name not in [t['columnfamily_name'] for t in tables]: + return + + cf_name = model.column_family_name() + execute('drop table {};'.format(cf_name)) From 5c6e8601ec0b73b14db76ab713f1b5fb0d8e8614 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 1 Jul 2013 16:09:03 -0700 Subject: [PATCH 0310/4200] updating management functions to work with the cql driver --- cqlengine/management.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 23f32795c6..d66e808422 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -56,7 +56,10 @@ def create_table(model, create_missing_keyspace=True): create_keyspace(ks_name) with connection_manager() as con: - tables = con.execute("SELECT columnfamily_name from system.schema_columnfamilies WHERE keyspace_name = %s", [ks_name]) + tables = con.execute( + "SELECT columnfamily_name from system.schema_columnfamilies WHERE keyspace_name = :ks_name", + {'ks_name': ks_name} + ) #check for an existing column family #TODO: check system tables instead of using cql thrifteries @@ -100,7 +103,10 @@ def add_column(col): #get existing index names, skip ones that already exist with connection_manager() as con: - idx_names = con.execute("SELECT index_name from system.\"IndexInfo\" WHERE table_name=%s", [raw_cf_name]) + idx_names = con.execute( + "SELECT index_name from system.\"IndexInfo\" WHERE table_name=:table_name", + {'table_name': raw_cf_name} + ) idx_names = [i['index_name'] for i in idx_names] idx_names = filter(None, idx_names) @@ -122,7 +128,10 @@ def delete_table(model): # don't try to delete non existant tables ks_name = model._get_keyspace() with connection_manager() as con: - tables = con.execute("SELECT columnfamily_name from system.schema_columnfamilies WHERE keyspace_name = %s", [ks_name]) + tables = con.execute( + "SELECT columnfamily_name from system.schema_columnfamilies WHERE keyspace_name = :ks_name", + {'ks_name': ks_name} + ) raw_cf_name = model.column_family_name(include_keyspace=False) if raw_cf_name not in [t['columnfamily_name'] for t in tables]: return From 8aa4b04374539b95cbc4b744c77ea79d5f87704c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 1 Jul 2013 16:18:38 -0700 Subject: [PATCH 0311/4200] updating connection to work with the cql driver --- cqlengine/connection.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 8b3a8f0479..588390b398 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -153,9 +153,11 @@ def execute(self, query, params): con = self.get() cur = con.cursor() cur.execute(query, params) + columns = [i[0] for i in cur.description or []] + results = [RowResult(r) for r in cur.fetchall()] LOG.debug('{} {}'.format(query, repr(params))) self.put(con) - return cur + return columns, results except cql.ProgrammingError as ex: raise CQLEngineException(unicode(ex)) except TTransportException: @@ -163,9 +165,11 @@ def execute(self, query, params): raise CQLEngineException("Could not execute query against the cluster") + def execute(query, params={}): return connection_pool.execute(query, params) + @contextmanager def connection_manager(): """ :rtype: ConnectionPool """ From 0590c5e0f6d7457bdafbff6f7f3a0122e938c804 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 1 Jul 2013 16:19:07 -0700 Subject: [PATCH 0312/4200] updating management to work with with the cql driver --- cqlengine/management.py | 12 ++++++------ cqlengine/query.py | 6 ------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index d66e808422..293e2ff4c7 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -14,8 +14,8 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, :param **replication_values: 1.2 only, additional values to ad to the replication data map """ with connection_manager() as con: - keyspaces = con.execute("""SELECT keyspace_name FROM system.schema_keyspaces""", {}) - if name not in [r['keyspace_name'] for r in keyspaces]: + _, keyspaces = con.execute("""SELECT keyspace_name FROM system.schema_keyspaces""", {}) + if name not in [r[0] for r in keyspaces]: #try the 1.2 method replication_map = { 'class': strategy_class, @@ -103,12 +103,12 @@ def add_column(col): #get existing index names, skip ones that already exist with connection_manager() as con: - idx_names = con.execute( + _, idx_names = con.execute( "SELECT index_name from system.\"IndexInfo\" WHERE table_name=:table_name", {'table_name': raw_cf_name} ) - idx_names = [i['index_name'] for i in idx_names] + idx_names = [i[0] for i in idx_names] idx_names = filter(None, idx_names) indexes = [c for n,c in model._columns.items() if c.index] @@ -128,12 +128,12 @@ def delete_table(model): # don't try to delete non existant tables ks_name = model._get_keyspace() with connection_manager() as con: - tables = con.execute( + _, tables = con.execute( "SELECT columnfamily_name from system.schema_columnfamilies WHERE keyspace_name = :ks_name", {'ks_name': ks_name} ) raw_cf_name = model.column_family_name(include_keyspace=False) - if raw_cf_name not in [t['columnfamily_name'] for t in tables]: + if raw_cf_name not in [t[0] for t in tables]: return cf_name = model.column_family_name() diff --git a/cqlengine/query.py b/cqlengine/query.py index 7431bd8cb9..b8b05f2536 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -306,12 +306,6 @@ def __len__(self): self._execute_query() return len(self._result_cache) - def __del__(self): - if self._con: - self._con.close() - self._con = None - self._cur = None - #----query generation / execution---- def _where_clause(self): From 92ef6fce17c766ee8d3a3b8390ed473ac31810c4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 1 Jul 2013 16:26:02 -0700 Subject: [PATCH 0313/4200] fixing some problems with the eager result loading --- cqlengine/management.py | 10 +++++++--- cqlengine/query.py | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 293e2ff4c7..46676fe31e 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -36,8 +36,8 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, def delete_keyspace(name): with connection_manager() as con: - keyspaces = con.execute("""SELECT keyspace_name FROM system.schema_keyspaces""", {}) - if name in [r['keyspace_name'] for r in keyspaces]: + _, keyspaces = con.execute("""SELECT keyspace_name FROM system.schema_keyspaces""", {}) + if name in [r[0] for r in keyspaces]: execute("DROP KEYSPACE {}".format(name)) @@ -120,7 +120,11 @@ def add_column(col): qs += ['("{}")'.format(column.db_field_name)] qs = ' '.join(qs) - execute(qs) + try: + execute(qs) + except CQLEngineException: + # index already exists + pass def delete_table(model): diff --git a/cqlengine/query.py b/cqlengine/query.py index b8b05f2536..a64a0fd8e8 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -542,7 +542,7 @@ def count(self): qs = ' '.join(qs) - result = execute(qs, self._where_values(), row_factory=decoder.tuple_factory) + _, result = execute(qs, self._where_values()) return result[0][0] else: return len(self._result_cache) @@ -710,6 +710,7 @@ def _construct_instance(values): return instance return _construct_instance + columns = [model._columns[n] for n in names] if self._flat_values_list: return (lambda values: columns[0].to_python(values[0])) else: From e9b055452af5c26b7e33e3fa006c2fbc09d69bc2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 1 Jul 2013 17:39:21 -0700 Subject: [PATCH 0314/4200] wrapping query results in a named tuple --- cqlengine/connection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 588390b398..97c96d4fab 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -34,6 +34,8 @@ class CQLConnectionError(CQLEngineException): pass class RowResult(tuple): pass +QueryResult = namedtuple('RowResult', ('columns', 'results')) + def _column_tuple_factory(colnames, values): return tuple(colnames), [RowResult(v) for v in values] @@ -157,7 +159,7 @@ def execute(self, query, params): results = [RowResult(r) for r in cur.fetchall()] LOG.debug('{} {}'.format(query, repr(params))) self.put(con) - return columns, results + return QueryResult(columns, results) except cql.ProgrammingError as ex: raise CQLEngineException(unicode(ex)) except TTransportException: From 779a960edb054dcc01f19c308d0fdfc0507e5fbe Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 1 Jul 2013 17:39:33 -0700 Subject: [PATCH 0315/4200] updating changelog and version --- changelog | 5 +++++ cqlengine/VERSION | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index d090b319c6..69582a2cd9 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,10 @@ CHANGELOG +0.5 +* eagerly loading results into the query result cache, the cql driver does this anyway, + and pulling them from the cursor was causing some problems with gevented queries, + this will cause some breaking changes for users calling execute directly + 0.4.10 * changing query parameter placeholders from uuid1 to uuid4 diff --git a/cqlengine/VERSION b/cqlengine/VERSION index e8423da873..2eb3c4fe4e 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.4.10 +0.5 From 8091278d6ab4a427da6582f02a4feec8f9194eb4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 5 Jul 2013 17:44:25 -0700 Subject: [PATCH 0316/4200] removing auto column from docs --- docs/topics/models.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index f599f2cfb7..16d95db76d 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -146,6 +146,3 @@ Model Attributes *Optional.* Sets the name of the keyspace used by this model. Defaulst to cqlengine -Automatic Primary Keys -====================== - CQL requires that all tables define at least one primary key. If a model definition does not include a primary key column, cqlengine will automatically add a uuid primary key column named ``id``. From 6ea6d178836291950ba9b39dd9a564f9fe7478e2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 6 Jul 2013 08:28:40 -0700 Subject: [PATCH 0317/4200] fixing doc version --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 847ef50dce..37422f20a5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,7 +47,7 @@ # |version| and |release|, also used in various other places throughout the # built documents. # -__cqlengine_version_path__ = os.path.realpath(__file__ + '/../VERSION') +__cqlengine_version_path__ = os.path.realpath(__file__ + '/../../cqlengine/VERSION') # The short X.Y version. version = open(__cqlengine_version_path__, 'r').readline().strip() # The full version, including alpha/beta/rc tags. From 714f1e0214620f9a491dd08508ee34cacea24b86 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 6 Jul 2013 08:28:50 -0700 Subject: [PATCH 0318/4200] updating columns docs --- docs/topics/columns.rst | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index 987d7d7c4f..d323ac216a 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -46,18 +46,30 @@ Columns Stores a datetime value. - Python's datetime.now callable is set as the default value for this column :: - columns.DateTime() .. class:: UUID() Stores a type 1 or type 4 UUID. - Python's uuid.uuid4 callable is set as the default value for this column. :: - columns.UUID() +.. class:: TimeUUID() + + Stores a UUID value as the cql type 'timeuuid' :: + + columns.TimeUUID() + + .. classmethod:: from_datetime(dt) + + generates a TimeUUID for the given datetime + + :param dt: the datetime to create a time uuid from + :type dt: datetime.datetime + + :returns: a time uuid created from the given datetime + :rtype: uuid1 + .. class:: Boolean() Stores a boolean True or False value :: @@ -91,6 +103,7 @@ Collection Type Columns .. code-block:: python class Person(Model): + id = columns.UUID(primary_key=True, default=uuid.uuid4) first_name = columns.Text() last_name = columns.Text() From 2a779643bd0534360e1a194aca4480c360214582 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 6 Jul 2013 08:32:23 -0700 Subject: [PATCH 0319/4200] fixing the default value on the example partition key --- README.md | 2 +- docs/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 10531060e5..23516e78fe 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ from cqlengine.models import Model class ExampleModel(Model): read_repair_chance = 0.05 # optional - defaults to 0.1 - example_id = columns.UUID(primary_key=True) + example_id = columns.UUID(primary_key=True, default=uuid.uuid4) example_type = columns.Integer(index=True) created_at = columns.DateTime() description = columns.Text(required=False) diff --git a/docs/index.rst b/docs/index.rst index 9dff72fd56..2053e35934 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -36,7 +36,7 @@ Getting Started from cqlengine import Model class ExampleModel(Model): - example_id = columns.UUID(primary_key=True) + example_id = columns.UUID(primary_key=True, default=uuid.uuid4) example_type = columns.Integer(index=True) created_at = columns.DateTime() description = columns.Text(required=False) From 8e0cccf8f57c74808fbca6d7c2eecc0b682ff603 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 6 Jul 2013 08:34:49 -0700 Subject: [PATCH 0320/4200] removing other references to auto defaults and ids from the docs --- README.md | 2 -- docs/index.rst | 2 -- docs/topics/models.rst | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index 23516e78fe..09e2014241 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,6 @@ class ExampleModel(Model): >>> em6 = ExampleModel.create(example_type=1, description="example6", created_at=datetime.now()) >>> em7 = ExampleModel.create(example_type=1, description="example7", created_at=datetime.now()) >>> em8 = ExampleModel.create(example_type=1, description="example8", created_at=datetime.now()) -# Note: the UUID and DateTime columns will create uuid4 and datetime.now -# values automatically if we don't specify them when creating new rows #and now we can run some queries against our table >>> ExampleModel.objects.count() diff --git a/docs/index.rst b/docs/index.rst index 2053e35934..243c365313 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -58,8 +58,6 @@ Getting Started >>> em6 = ExampleModel.create(example_type=1, description="example6", created_at=datetime.now()) >>> em7 = ExampleModel.create(example_type=1, description="example7", created_at=datetime.now()) >>> em8 = ExampleModel.create(example_type=1, description="example8", created_at=datetime.now()) - # Note: the UUID and DateTime columns will create uuid4 and datetime.now - # values automatically if we don't specify them when creating new rows #and now we can run some queries against our table >>> ExampleModel.objects.count() diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 16d95db76d..3360ba7fee 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -41,7 +41,7 @@ The Person model would create this CQL table: Columns ======= - Columns in your models map to columns in your CQL table. You define CQL columns by defining column attributes on your model classes. For a model to be valid it needs at least one primary key column (defined automatically if you don't define one) and one non-primary key column. + Columns in your models map to columns in your CQL table. You define CQL columns by defining column attributes on your model classes. For a model to be valid it needs at least one primary key column and one non-primary key column. Just as in CQL, the order you define your columns in is important, and is the same order they are defined in on a model's corresponding table. From 600c9175cd648cff965254b2c9aa317363851fa3 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 6 Jul 2013 08:45:51 -0700 Subject: [PATCH 0321/4200] updating description --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 243c365313..1122599cca 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,7 +9,7 @@ cqlengine documentation .. _Breaking Changes: https://groups.google.com/forum/?fromgroups#!topic/cqlengine-users/erkSNe1JwuU -cqlengine is a Cassandra CQL 3 Object Mapper for Python with an interface similar to the Django orm and mongoengine +cqlengine is a Cassandra CQL 3 Object Mapper for Python :ref:`getting-started` From ce5da33e57f697922d430ec89e4d68a2c0295334 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 9 Jul 2013 21:25:07 -0700 Subject: [PATCH 0322/4200] added a retry until no servers can be contacted --- cqlengine/connection.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 97c96d4fab..dde5e3da6f 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -151,21 +151,23 @@ def _create_connection(self): raise CQLConnectionError("Could not connect to any server in cluster") def execute(self, query, params): - try: - con = self.get() - cur = con.cursor() - cur.execute(query, params) - columns = [i[0] for i in cur.description or []] - results = [RowResult(r) for r in cur.fetchall()] - LOG.debug('{} {}'.format(query, repr(params))) - self.put(con) - return QueryResult(columns, results) - except cql.ProgrammingError as ex: - raise CQLEngineException(unicode(ex)) - except TTransportException: - pass + while True: + try: + con = self.get() + cur = con.cursor() + cur.execute(query, params) + columns = [i[0] for i in cur.description or []] + results = [RowResult(r) for r in cur.fetchall()] + LOG.debug('{} {}'.format(query, repr(params))) + self.put(con) + return QueryResult(columns, results) + except CQLConnectionError as ex: + raise CQLEngineException("Could not execute query against the cluster") + except cql.ProgrammingError as ex: + raise CQLEngineException(unicode(ex)) + except TTransportException: + raise CQLEngineException("Could not execute query against the cluster") - raise CQLEngineException("Could not execute query against the cluster") def execute(query, params={}): From 21bb32c045ecd484986c324298483e3a5acfffbb Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 13 Jul 2013 11:57:52 -0700 Subject: [PATCH 0323/4200] quoting clustering order column, fixes #88 --- cqlengine/management.py | 4 ++-- cqlengine/tests/management/test_management.py | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 46676fe31e..be96b79f80 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -85,9 +85,9 @@ def add_column(col): with_qs = ['read_repair_chance = {}'.format(model.__read_repair_chance__)] - _order = ["%s %s" % (c.db_field_name, c.clustering_order or 'ASC') for c in model._clustering_keys.values()] + _order = ['"{}" {}'.format(c.db_field_name, c.clustering_order or 'ASC') for c in model._clustering_keys.values()] if _order: - with_qs.append("clustering order by ({})".format(', '.join(_order))) + with_qs.append('clustering order by ({})'.format(', '.join(_order))) # add read_repair_chance qs += ['WITH {}'.format(' AND '.join(with_qs))] diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index 4e271c58ce..5e00bb977d 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -7,6 +7,8 @@ from mock import Mock, MagicMock, MagicProxy, patch from cqlengine import management from cqlengine.tests.query.test_queryset import TestModel +from cqlengine.models import Model +from cqlengine import columns from cql.thrifteries import ThriftConnection @@ -65,3 +67,23 @@ def test_multiple_deletes_dont_fail(self): delete_table(TestModel) delete_table(TestModel) + +class LowercaseKeyModel(Model): + first_key = columns.Integer(primary_key=True) + second_key = columns.Integer(primary_key=True) + some_data = columns.Text() + +class CapitalizedKeyModel(Model): + firstKey = columns.Integer(primary_key=True) + secondKey = columns.Integer(primary_key=True) + someData = columns.Text() + +class CapitalizedKeyTest(BaseCassEngTestCase): + + def test_table_definition(self): + """ Tests that creating a table with capitalized column names succeedso """ + create_table(LowercaseKeyModel) + create_table(CapitalizedKeyModel) + + delete_table(LowercaseKeyModel) + delete_table(CapitalizedKeyModel) From 019f1435746a073cd799870cbab7ae4fcf7585b0 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 13 Jul 2013 12:04:04 -0700 Subject: [PATCH 0324/4200] updating changelog and version# --- changelog | 4 ++++ cqlengine/VERSION | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 69582a2cd9..752a1eaa48 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,9 @@ CHANGELOG +0.5.1 +* improving connection pooling +* fixing bug with clustering order columns not being quoted + 0.5 * eagerly loading results into the query result cache, the cql driver does this anyway, and pulling them from the cursor was causing some problems with gevented queries, diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 2eb3c4fe4e..4b9fcbec10 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.5 +0.5.1 From e11a0b0d7c93366a4875e2ef5b1b998941017014 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 13 Jul 2013 12:13:48 -0700 Subject: [PATCH 0325/4200] adding hex conversion to Bytes column --- cqlengine/columns.py | 5 +++++ cqlengine/tests/columns/test_value_io.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 3b7a22d06e..f82e468fe7 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -195,6 +195,11 @@ def get_cql(self): class Bytes(Column): db_type = 'blob' + def to_database(self, value): + val = super(Bytes, self).to_database(value) + if val is None: return + return val.encode('hex') + class Ascii(Column): db_type = 'ascii' diff --git a/cqlengine/tests/columns/test_value_io.py b/cqlengine/tests/columns/test_value_io.py index 64054279cc..1c822a9e5d 100644 --- a/cqlengine/tests/columns/test_value_io.py +++ b/cqlengine/tests/columns/test_value_io.py @@ -77,6 +77,12 @@ def test_column_io(self): #delete self._generated_model.filter(pkey=pkey).delete() +class TestBlobIO(BaseColumnIOTest): + + column = columns.Bytes + pkey_val = 'blake', uuid4().bytes + data_val = 'eggleston', uuid4().bytes + class TestTextIO(BaseColumnIOTest): column = columns.Text From 69c288019733a8dd3fad95c627803ead81b995a2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 13 Jul 2013 12:14:15 -0700 Subject: [PATCH 0326/4200] updating version and changelog --- changelog | 3 +++ cqlengine/VERSION | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 752a1eaa48..0da8f3c7e9 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.5.2 +* adding hex conversion to Bytes column + 0.5.1 * improving connection pooling * fixing bug with clustering order columns not being quoted diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 4b9fcbec10..cb0c939a93 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.5.1 +0.5.2 From 49a6e16d27ba5eb6fc4fccd2259eba665505c764 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 23 Jul 2013 12:38:26 -0700 Subject: [PATCH 0327/4200] fixing a mutable kwarg --- cqlengine/connection.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index dde5e3da6f..b2d62023f3 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -169,11 +169,10 @@ def execute(self, query, params): raise CQLEngineException("Could not execute query against the cluster") - -def execute(query, params={}): +def execute(query, params=None): + params = params or {} return connection_pool.execute(query, params) - @contextmanager def connection_manager(): """ :rtype: ConnectionPool """ From c99903aa9aa8084d04d7d477263536b803d4a6c1 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 23 Jul 2013 12:48:06 -0700 Subject: [PATCH 0328/4200] adding counter column --- cqlengine/columns.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index f82e468fe7..cd01cf40ee 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -243,6 +243,24 @@ def to_database(self, value): return self.validate(value) +class Counter(Integer): + db_type = 'counter' + + def get_update_statement(self, val, prev, ctx): + val = self.to_database(val) + prev = self.to_database(prev or 0) + + delta = val - prev + if delta == 0: + return [] + + field_id = uuid4().hex + sign = '+' if delta > 0 else '-' + delta = abs(delta) + ctx[field_id] = delta + return ['"{0}" = "{0}" {1} {2}'.format(self.db_field_name, sign, delta)] + + class DateTime(Column): db_type = 'timestamp' @@ -363,7 +381,6 @@ def from_datetime(self, dt): clock_seq_hi_variant, clock_seq_low, node), version=1) - class Boolean(Column): db_type = 'boolean' @@ -419,11 +436,6 @@ def to_python(self, value): def to_database(self, value): return self.validate(value) -class Counter(Column): - #TODO: counter field - def __init__(self, **kwargs): - super(Counter, self).__init__(**kwargs) - raise NotImplementedError class BaseContainerColumn(Column): """ From ceb487af88507aaa0df82cefbbf528d63c5248de Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 23 Jul 2013 12:48:34 -0700 Subject: [PATCH 0329/4200] adding counter support to update statement generator --- cqlengine/query.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index a64a0fd8e8..4327ff1155 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -5,6 +5,7 @@ from time import time from uuid import uuid1 from cqlengine import BaseContainerColumn, BaseValueManager, Map, columns +from cqlengine.columns import Counter from cqlengine.connection import connection_manager, execute, RowResult @@ -809,8 +810,9 @@ def save(self): for name, col in self.model._columns.items(): if not col.is_primary_key: val = values.get(name) - if val is None: continue - if isinstance(col, BaseContainerColumn): + if val is None: + continue + if isinstance(col, (BaseContainerColumn, Counter)): #remove value from query values, the column will handle it query_values.pop(field_ids.get(name), None) From 17b77ea9e4b03a6caa4611db3966610dfc491a42 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 23 Jul 2013 12:53:00 -0700 Subject: [PATCH 0330/4200] adding support for using set statements for counter columns if there is no previous value, reformatting --- cqlengine/columns.py | 18 ++++++++++++++++-- .../tests/columns/test_container_columns.py | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index cd01cf40ee..0f505ccc02 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -192,6 +192,7 @@ def cql(self): def get_cql(self): return '"{}"'.format(self.db_field_name) + class Bytes(Column): db_type = 'blob' @@ -200,9 +201,11 @@ def to_database(self, value): if val is None: return return val.encode('hex') + class Ascii(Column): db_type = 'ascii' + class Text(Column): db_type = 'text' @@ -248,13 +251,19 @@ class Counter(Integer): def get_update_statement(self, val, prev, ctx): val = self.to_database(val) - prev = self.to_database(prev or 0) + prev = self.to_database(prev) + field_id = uuid4().hex + + # use a set statement if there is no + # previous value to compute a delta from + if prev is None: + ctx[field_id] = val + return ['"{}" = :{}'.format(self.db_field_name, field_id)] delta = val - prev if delta == 0: return [] - field_id = uuid4().hex sign = '+' if delta > 0 else '-' delta = abs(delta) ctx[field_id] = delta @@ -334,6 +343,7 @@ def to_database(self, value): from uuid import UUID as pyUUID, getnode + class TimeUUID(UUID): """ UUID containing timestamp @@ -417,6 +427,7 @@ def to_python(self, value): def to_database(self, value): return self.validate(value) + class Decimal(Column): db_type = 'decimal' @@ -476,6 +487,7 @@ def get_update_statement(self, val, prev, ctx): """ raise NotImplementedError + class Set(BaseContainerColumn): """ Stores a set of unordered, unique values @@ -565,6 +577,7 @@ def get_update_statement(self, val, prev, ctx): return statements + class List(BaseContainerColumn): """ Stores a list of ordered values @@ -789,6 +802,7 @@ def get_delete_statement(self, val, prev, ctx): return del_statements + class _PartitionKeysToken(Column): """ virtual column representing token of partition columns. diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 6cf218299c..cd11fb5abb 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -28,7 +28,9 @@ def to_database(self, value): if value is None: return return json.dumps(value) + class TestSetColumn(BaseCassEngTestCase): + @classmethod def setUpClass(cls): super(TestSetColumn, cls).setUpClass() @@ -40,7 +42,6 @@ def tearDownClass(cls): super(TestSetColumn, cls).tearDownClass() delete_table(TestSetModel) - def test_empty_set_initial(self): """ tests that sets are set() by default, should never be none From 19d6984e727d79af79c466b0c3842de7a4a46207 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 24 Jul 2013 09:49:15 -0700 Subject: [PATCH 0331/4200] updating the model and query classes to handle models with counter columns correctly --- cqlengine/columns.py | 26 +++++++++++++++----------- cqlengine/models.py | 10 ++++++++-- cqlengine/query.py | 9 +++++---- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 0f505ccc02..56221c2dd1 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -249,22 +249,26 @@ def to_database(self, value): class Counter(Integer): db_type = 'counter' + def __init__(self, + index=False, + db_field=None, + required=False): + super(Counter, self).__init__( + primary_key=False, + partition_key=False, + index=index, + db_field=db_field, + default=0, + required=required, + ) + def get_update_statement(self, val, prev, ctx): val = self.to_database(val) - prev = self.to_database(prev) + prev = self.to_database(prev or 0) field_id = uuid4().hex - # use a set statement if there is no - # previous value to compute a delta from - if prev is None: - ctx[field_id] = val - return ['"{}" = :{}'.format(self.db_field_name, field_id)] - delta = val - prev - if delta == 0: - return [] - - sign = '+' if delta > 0 else '-' + sign = '-' if delta < 0 else '+' delta = abs(delta) ctx[field_id] = delta return ['"{0}" = "{0}" {1} {2}'.format(self.db_field_name, sign, delta)] diff --git a/cqlengine/models.py b/cqlengine/models.py index a2d9b6abce..640e023bd5 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -70,6 +70,7 @@ def __init__(self, column): def _get_column(self): return self.column + class ColumnDescriptor(object): """ Handles the reading and writing of column values to and from @@ -127,6 +128,7 @@ class BaseModel(object): """ class DoesNotExist(_DoesNotExist): pass + class MultipleObjectsReturned(_MultipleObjectsReturned): pass objects = QuerySetDescriptor() @@ -316,14 +318,17 @@ def _transform_column(col_name, col_obj): column_definitions = inherited_columns.items() + column_definitions - #columns defined on model, excludes automatically - #defined columns defined_columns = OrderedDict(column_definitions) #prepend primary key if one hasn't been defined if not is_abstract and not any([v.primary_key for k,v in column_definitions]): raise ModelDefinitionException("At least 1 primary key is required.") + counter_columns = [c for c in defined_columns.values() if isinstance(c, columns.Counter)] + data_columns = [c for c in defined_columns.values() if not c.primary_key and not isinstance(c, columns.Counter)] + if counter_columns and data_columns: + raise ModelDefinitionException('counter models may not have data columns') + has_partition_keys = any(v.partition_key for (k, v) in column_definitions) #TODO: check that the defined columns don't conflict with any of the Model API's existing attributes/methods @@ -382,6 +387,7 @@ def _transform_column(col_name, col_obj): attrs['_partition_keys'] = partition_keys attrs['_clustering_keys'] = clustering_keys + attrs['_has_counter'] = len(counter_columns) > 0 #setup class exceptions DoesNotExistBase = None diff --git a/cqlengine/query.py b/cqlengine/query.py index 4327ff1155..194b4c9330 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -801,7 +801,7 @@ def save(self): query_values = {field_ids[n]:field_values[n] for n in field_names} qs = [] - if self.instance._can_update(): + if self.instance._has_counter or self.instance._can_update(): qs += ["UPDATE {}".format(self.column_family_name)] qs += ["SET"] @@ -818,7 +818,7 @@ def save(self): val_mgr = self.instance._values[name] set_statements += col.get_update_statement(val, val_mgr.previous_value, query_values) - pass + else: set_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] qs += [', '.join(set_statements)] @@ -831,8 +831,9 @@ def save(self): qs += [' AND '.join(where_statements)] - # clear the qs if there are not set statements - if not set_statements: qs = [] + # clear the qs if there are no set statements and this is not a counter model + if not set_statements and not self.instance._has_counter: + qs = [] else: qs += ["INSERT INTO {}".format(self.column_family_name)] From 3c24995481d29d145f99217290a2ca3f1cea4be6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 24 Jul 2013 09:49:41 -0700 Subject: [PATCH 0332/4200] adding tests around counter columns --- .../tests/columns/test_counter_column.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 cqlengine/tests/columns/test_counter_column.py diff --git a/cqlengine/tests/columns/test_counter_column.py b/cqlengine/tests/columns/test_counter_column.py new file mode 100644 index 0000000000..f92cb4f418 --- /dev/null +++ b/cqlengine/tests/columns/test_counter_column.py @@ -0,0 +1,64 @@ +from uuid import uuid4 + +from cqlengine import Model, ValidationError +from cqlengine import columns +from cqlengine.management import create_table, delete_table +from cqlengine.tests.base import BaseCassEngTestCase + + +class TestCounterModel(Model): + partition = columns.UUID(primary_key=True, default=uuid4) + cluster = columns.UUID(primary_key=True, default=uuid4) + counter = columns.Counter() + + +class TestClassConstruction(BaseCassEngTestCase): + + def test_defining_a_non_counter_column_fails(self): + """ Tests that defining a non counter column field fails """ + + def test_defining_a_primary_key_counter_column_fails(self): + """ Tests that defining primary keys on counter columns fails """ + + +class TestCounterColumn(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestCounterColumn, cls).setUpClass() + delete_table(TestCounterModel) + create_table(TestCounterModel) + + @classmethod + def tearDownClass(cls): + super(TestCounterColumn, cls).tearDownClass() + delete_table(TestCounterModel) + + def test_updates(self): + """ Tests that counter updates work as intended """ + instance = TestCounterModel.create() + instance.counter += 5 + instance.save() + + actual = TestCounterModel.get(partition=instance.partition) + assert actual.counter == 5 + + def test_concurrent_updates(self): + """ Tests updates from multiple queries reaches the correct value """ + instance = TestCounterModel.create() + new1 = TestCounterModel.get(partition=instance.partition) + new2 = TestCounterModel.get(partition=instance.partition) + + new1.counter += 5 + new1.save() + new2.counter += 5 + new2.save() + + actual = TestCounterModel.get(partition=instance.partition) + assert actual.counter == 10 + + def test_update_from_none(self): + """ Tests that updating from None uses a create statement """ + + def test_multiple_inserts(self): + """ Tests inserting over existing data works as expected """ From 58223eab242d23f43cc67ee7a09ebe6ffbff184f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 31 Jul 2013 12:35:49 -0700 Subject: [PATCH 0333/4200] Only adds new columns. Will not remove non existing. --- cqlengine/connection.py | 2 + cqlengine/management.py | 41 ++++++++++++++-- cqlengine/tests/management/test_management.py | 48 +++++++++++++++++-- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index dde5e3da6f..12d37c8ea7 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -154,6 +154,8 @@ def execute(self, query, params): while True: try: con = self.get() + if not con: + raise CQLEngineException("Error calling execute without calling setup.") cur = con.cursor() cur.execute(query, params) columns = [i[0] for i in cur.description or []] diff --git a/cqlengine/management.py b/cqlengine/management.py index be96b79f80..ce72e778b9 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -3,6 +3,13 @@ from cqlengine.connection import connection_manager, execute from cqlengine.exceptions import CQLEngineException +import logging +from collections import namedtuple +Field = namedtuple('Field', ['name', 'type']) + +logger = logging.getLogger(__name__) + + def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, durable_writes=True, **replication_values): """ creates a keyspace @@ -60,6 +67,7 @@ def create_table(model, create_missing_keyspace=True): "SELECT columnfamily_name from system.schema_columnfamilies WHERE keyspace_name = :ks_name", {'ks_name': ks_name} ) + tables = [x[0] for x in tables.results] #check for an existing column family #TODO: check system tables instead of using cql thrifteries @@ -67,9 +75,9 @@ def create_table(model, create_missing_keyspace=True): qs = ['CREATE TABLE {}'.format(cf_name)] #add column types - pkeys = [] - ckeys = [] - qtypes = [] + pkeys = [] # primary keys + ckeys = [] # clustering keys + qtypes = [] # field types def add_column(col): s = col.get_column_def() if col.primary_key: @@ -100,6 +108,19 @@ def add_column(col): # and ignore if it says the column family already exists if "Cannot add already existing column family" not in unicode(ex): raise + else: + # see if we're missing any columns + fields = get_fields(model) + field_names = [x.name for x in fields] + for name, col in model._columns.items(): + if col.primary_key or col.partition_key: continue # we can't mess with the PK + if name in field_names: continue # skip columns already defined + + # add missing column using the column def + query = "ALTER TABLE {} add {}".format(cf_name, col.get_column_def()) + logger.debug(query) + execute(query) + #get existing index names, skip ones that already exist with connection_manager() as con: @@ -126,6 +147,20 @@ def add_column(col): # index already exists pass +def get_fields(model): + # returns all fields that aren't part of the PK + ks_name = model._get_keyspace() + col_family = model.column_family_name(include_keyspace=False) + + with connection_manager() as con: + query = "SELECT column_name, validator FROM system.schema_columns \ + WHERE keyspace_name = :ks_name AND columnfamily_name = :col_family" + + logger.debug("get_fields %s %s", ks_name, col_family) + + tmp = con.execute(query, {'ks_name':ks_name, 'col_family':col_family}) + return [Field(x[0], x[1]) for x in tmp.results] + # convert to Field named tuples def delete_table(model): diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index 5e00bb977d..b99bd5a922 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -1,16 +1,15 @@ from cqlengine.exceptions import CQLEngineException -from cqlengine.management import create_table, delete_table +from cqlengine.management import create_table, delete_table, get_fields from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.connection import ConnectionPool, Host -from mock import Mock, MagicMock, MagicProxy, patch +from mock import MagicMock, patch from cqlengine import management from cqlengine.tests.query.test_queryset import TestModel from cqlengine.models import Model from cqlengine import columns -from cql.thrifteries import ThriftConnection class ConnectionPoolFailoverTestCase(BaseCassEngTestCase): """Test cassandra connection pooling.""" @@ -87,3 +86,46 @@ def test_table_definition(self): delete_table(LowercaseKeyModel) delete_table(CapitalizedKeyModel) + + +class FirstModel(Model): + __table_name__ = 'first_model' + first_key = columns.UUID(primary_key=True) + second_key = columns.UUID() + third_key = columns.Text() + +class SecondModel(Model): + __table_name__ = 'first_model' + first_key = columns.UUID(primary_key=True) + second_key = columns.UUID() + third_key = columns.Text() + fourth_key = columns.Text() + +class ThirdModel(Model): + __table_name__ = 'first_model' + first_key = columns.UUID(primary_key=True) + second_key = columns.UUID() + third_key = columns.Text() + # removed fourth key, but it should stay in the DB + blah = columns.Map(columns.Text, columns.Text) + +class AddColumnTest(BaseCassEngTestCase): + def setUp(self): + delete_table(FirstModel) + + def test_add_column(self): + create_table(FirstModel) + fields = get_fields(FirstModel) + + # this should contain the second key + self.assertEqual(len(fields), 2) + # get schema + create_table(SecondModel) + + fields = get_fields(FirstModel) + self.assertEqual(len(fields), 3) + + create_table(ThirdModel) + fields = get_fields(FirstModel) + self.assertEqual(len(fields), 4) + From 121c04d9758c38c63e5f6f91393d0f8127a26e49 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 31 Jul 2013 15:50:46 -0700 Subject: [PATCH 0334/4200] version bump --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index cb0c939a93..be14282b7f 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.5.2 +0.5.3 From 1b4430fa85b2b24d647d2c9380e6f6e3fba312fa Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 31 Jul 2013 16:56:03 -0700 Subject: [PATCH 0335/4200] fixed db field name issue --- cqlengine/management.py | 2 +- cqlengine/tests/management/test_management.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index ce72e778b9..a06a10fb97 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -114,7 +114,7 @@ def add_column(col): field_names = [x.name for x in fields] for name, col in model._columns.items(): if col.primary_key or col.partition_key: continue # we can't mess with the PK - if name in field_names: continue # skip columns already defined + if col.db_field_name in field_names: continue # skip columns already defined # add missing column using the column def query = "ALTER TABLE {} add {}".format(cf_name, col.get_column_def()) diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index b99bd5a922..9df6a31507 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -109,6 +109,14 @@ class ThirdModel(Model): # removed fourth key, but it should stay in the DB blah = columns.Map(columns.Text, columns.Text) +class FourthModel(Model): + __table_name__ = 'first_model' + first_key = columns.UUID(primary_key=True) + second_key = columns.UUID() + third_key = columns.Text() + # removed fourth key, but it should stay in the DB + renamed = columns.Map(columns.Text, columns.Text, db_field='blah') + class AddColumnTest(BaseCassEngTestCase): def setUp(self): delete_table(FirstModel) @@ -129,3 +137,7 @@ def test_add_column(self): fields = get_fields(FirstModel) self.assertEqual(len(fields), 4) + create_table(FourthModel) + fields = get_fields(FirstModel) + self.assertEqual(len(fields), 4) + From 368bfb642a7015bea8841d65554851f6957c8ca5 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 31 Jul 2013 16:57:13 -0700 Subject: [PATCH 0336/4200] fixed bug in alter --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index be14282b7f..a918a2aa18 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.5.3 +0.6.0 From 3ee88ecf93ffaad8eb753021b1e811a27c8d513f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 14 Aug 2013 15:48:24 -0700 Subject: [PATCH 0337/4200] setting counter column default instantiation value to 0 (not None) --- cqlengine/columns.py | 9 +++++++++ cqlengine/tests/columns/test_counter_column.py | 13 +++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 56221c2dd1..9940f1aa18 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -246,9 +246,18 @@ def to_database(self, value): return self.validate(value) +class CounterValueManager(BaseValueManager): + def __init__(self, instance, column, value): + super(CounterValueManager, self).__init__(instance, column, value) + self.value = self.value or 0 + self.previous_value = self.previous_value or 0 + + class Counter(Integer): db_type = 'counter' + value_manager = CounterValueManager + def __init__(self, index=False, db_field=None, diff --git a/cqlengine/tests/columns/test_counter_column.py b/cqlengine/tests/columns/test_counter_column.py index f92cb4f418..f8dc0096e1 100644 --- a/cqlengine/tests/columns/test_counter_column.py +++ b/cqlengine/tests/columns/test_counter_column.py @@ -59,6 +59,15 @@ def test_concurrent_updates(self): def test_update_from_none(self): """ Tests that updating from None uses a create statement """ + instance = TestCounterModel() + instance.counter += 1 + instance.save() + + new = TestCounterModel.get(partition=instance.partition) + assert new.counter == 1 + + def test_new_instance_defaults_to_zero(self): + """ Tests that instantiating a new model instance will set the counter column to zero """ + instance = TestCounterModel() + assert instance.counter == 0 - def test_multiple_inserts(self): - """ Tests inserting over existing data works as expected """ From a13d6cf88ba099f615555f5c085aabc8f10a283d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 14 Aug 2013 15:56:23 -0700 Subject: [PATCH 0338/4200] adding tests and checks to model definition constraints --- cqlengine/models.py | 4 ++++ .../tests/columns/test_counter_column.py | 24 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 640e023bd5..d03a00415d 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -334,6 +334,10 @@ def _transform_column(col_name, col_obj): #TODO: check that the defined columns don't conflict with any of the Model API's existing attributes/methods #transform column definitions for k,v in column_definitions: + # counter column primary keys are not allowed + if (v.primary_key or v.partition_key) and isinstance(v, (columns.Counter, columns.BaseContainerColumn)): + raise ModelDefinitionException('counter columns and container columns cannot be used as primary keys') + # this will mark the first primary key column as a partition # key, if one hasn't been set already if not has_partition_keys and v.primary_key: diff --git a/cqlengine/tests/columns/test_counter_column.py b/cqlengine/tests/columns/test_counter_column.py index f8dc0096e1..c260f38ed0 100644 --- a/cqlengine/tests/columns/test_counter_column.py +++ b/cqlengine/tests/columns/test_counter_column.py @@ -1,8 +1,9 @@ from uuid import uuid4 -from cqlengine import Model, ValidationError +from cqlengine import Model from cqlengine import columns from cqlengine.management import create_table, delete_table +from cqlengine.models import ModelDefinitionException from cqlengine.tests.base import BaseCassEngTestCase @@ -15,10 +16,29 @@ class TestCounterModel(Model): class TestClassConstruction(BaseCassEngTestCase): def test_defining_a_non_counter_column_fails(self): - """ Tests that defining a non counter column field fails """ + """ Tests that defining a non counter column field in a model with a counter column fails """ + with self.assertRaises(ModelDefinitionException): + class model(Model): + partition = columns.UUID(primary_key=True, default=uuid4) + counter = columns.Counter() + text = columns.Text() + def test_defining_a_primary_key_counter_column_fails(self): """ Tests that defining primary keys on counter columns fails """ + with self.assertRaises(TypeError): + class model(Model): + partition = columns.UUID(primary_key=True, default=uuid4) + cluster = columns.Counter(primary_ley=True) + counter = columns.Counter() + + # force it + with self.assertRaises(ModelDefinitionException): + class model(Model): + partition = columns.UUID(primary_key=True, default=uuid4) + cluster = columns.Counter() + cluster.primary_key = True + counter = columns.Counter() class TestCounterColumn(BaseCassEngTestCase): From 7a3b6a1bbabfacf77e7e09c6ab4da640d36d3d36 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 14 Aug 2013 18:07:30 -0700 Subject: [PATCH 0339/4200] renamed create_table to sync_table (added an alias), getting compaction started --- cqlengine/__init__.py | 3 +++ cqlengine/management.py | 4 +++- cqlengine/models.py | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 27ef399572..639b56b0bd 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -8,4 +8,7 @@ __cqlengine_version_path__ = os.path.realpath(__file__ + '/../VERSION') __version__ = open(__cqlengine_version_path__, 'r').readline().strip() +# compaction +SizeTieredCompactionStrategy = "SizeTieredCompactionStrategy" +LeveledCompactionStrategy = "LeveledCompactionStrategy" diff --git a/cqlengine/management.py b/cqlengine/management.py index a06a10fb97..740f78e335 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -47,8 +47,10 @@ def delete_keyspace(name): if name in [r[0] for r in keyspaces]: execute("DROP KEYSPACE {}".format(name)) - def create_table(model, create_missing_keyspace=True): + sync_table(model, create_missing_keyspace) + +def sync_table(model, create_missing_keyspace=True): if model.__abstract__: raise CQLEngineException("cannot create table from abstract model") diff --git a/cqlengine/models.py b/cqlengine/models.py index d03a00415d..7eb88e1728 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -140,6 +140,8 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass #the keyspace for this model __keyspace__ = None + + __compaction__ = None __read_repair_chance__ = 0.1 def __init__(self, **values): From 23afb880d976cfb47c4b04d2a6436b5f068ef734 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 15 Aug 2013 13:20:22 -0700 Subject: [PATCH 0340/4200] test classes for different scenarios in place, working to test failure cases --- cqlengine/management.py | 32 +++++++++++ cqlengine/models.py | 16 ++++++ cqlengine/tests/management/test_management.py | 57 ++++++++++++++++++- 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 740f78e335..d2ecbca9b9 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,4 +1,5 @@ import json +from cqlengine import SizeTieredCompactionStrategy from cqlengine.connection import connection_manager, execute from cqlengine.exceptions import CQLEngineException @@ -99,6 +100,7 @@ def add_column(col): if _order: with_qs.append('clustering order by ({})'.format(', '.join(_order))) + # add read_repair_chance qs += ['WITH {}'.format(' AND '.join(with_qs))] qs = ' '.join(qs) @@ -149,6 +151,36 @@ def add_column(col): # index already exists pass +def get_compaction_options(model): + """ + Generates dictionary (later converted to a string) for creating and altering + tables with compaction strategy + + :param model: + :return: + """ + if not model.__compaction__: + return None + + result = {'class':model.__compaction__} + + def setter(key, limited_to_strategy = None): + mkey = "__compaction_{}__".format(key) + tmp = getattr(model, mkey) + if tmp and limited_to_strategy and limited_to_strategy != model.__compaction__: + raise CQLEngineException("{} is limited to {}".format(key, limited_to_strategy)) + + if tmp: + result[key] = tmp + + setter('min_threshold') + setter('tombstone_compaction_interval') + setter('bucket_high', SizeTieredCompactionStrategy) + setter('bucket_low', SizeTieredCompactionStrategy) + + return result + + def get_fields(model): # returns all fields that aren't part of the PK ks_name = model._get_keyspace() diff --git a/cqlengine/models.py b/cqlengine/models.py index 7eb88e1728..3abf1abaf1 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -141,7 +141,23 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass __keyspace__ = None + # compaction options __compaction__ = None + __compaction_tombstone_compaction_interval__ = None + __compaction_tombstone_threshold = None + + # compaction - size tiered options + __compaction_bucket_high__ = None + __compaction_bucket_low__ = None + __compaction_max_threshold__ = None + __compaction_min_threshold__ = None + __compaction_min_sstable_size__ = None + + # compaction - leveled options + __compaction_sstable_size_in_mb__ = None # only works with Leveled + + # end compaction + __read_repair_chance__ = 0.1 def __init__(self, **values): diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index 9df6a31507..02973c0485 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -1,11 +1,11 @@ from cqlengine.exceptions import CQLEngineException -from cqlengine.management import create_table, delete_table, get_fields +from cqlengine.management import create_table, delete_table, get_fields, get_compaction_options from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.connection import ConnectionPool, Host from mock import MagicMock, patch -from cqlengine import management +from cqlengine import management, SizeTieredCompactionStrategy, LeveledCompactionStrategy from cqlengine.tests.query.test_queryset import TestModel from cqlengine.models import Model from cqlengine import columns @@ -141,3 +141,56 @@ def test_add_column(self): fields = get_fields(FirstModel) self.assertEqual(len(fields), 4) +class CompactionModel(Model): + __compaction__ = None + cid = columns.UUID(primary_key=True) + name = columns.Text() + +class CompactionSizeTieredModel(Model): + __compaction__ = SizeTieredCompactionStrategy + cid = columns.UUID(primary_key=True) + name = columns.Text() + +class CompactionLeveledStrategyModel(Model): + __compaction__ = LeveledCompactionStrategy + cid = columns.UUID(primary_key=True) + name = columns.Text() + +import copy + +class EmptyCompactionTest(BaseCassEngTestCase): + def test_empty_compaction(self): + self.model = copy.deepcopy(CompactionModel) + result = get_compaction_options(self.model) + self.assertIsNone(result) + + +class SizeTieredCompactionTest(BaseCassEngTestCase): + + def setUp(self): + self.model = copy.deepcopy(CompactionModel) + self.model.__compaction__ = SizeTieredCompactionStrategy + + def test_size_tiered(self): + result = get_compaction_options(self.model) + assert result['class'] == SizeTieredCompactionStrategy + + def test_min_threshold(self): + self.model.__compaction_min_threshold__ = 2 + + result = get_compaction_options(self.model) + assert result['min_threshold'] == 2 + +class LeveledCompactionTest(BaseCassEngTestCase): + def setUp(self): + self.model = copy.deepcopy(CompactionLeveledStrategyModel) + + def test_simple_leveled(self): + result = get_compaction_options(self.model) + assert result['class'] == LeveledCompactionStrategy + + def test_bucket_high_fails(self): + with patch.object(self.model, '__compaction_bucket_high__', 10), \ + self.assertRaises(CQLEngineException): + result = get_compaction_options(self.model) + From 15f6e613c382f72b8e9333b21d10ca0154a27260 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 15 Aug 2013 13:27:53 -0700 Subject: [PATCH 0341/4200] more size tiered options --- cqlengine/management.py | 4 ++++ cqlengine/tests/management/test_management.py | 24 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index d2ecbca9b9..f6b5f176ef 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -175,8 +175,12 @@ def setter(key, limited_to_strategy = None): setter('min_threshold') setter('tombstone_compaction_interval') + setter('bucket_high', SizeTieredCompactionStrategy) setter('bucket_low', SizeTieredCompactionStrategy) + setter('max_threshold', SizeTieredCompactionStrategy) + setter('min_threshold', SizeTieredCompactionStrategy) + setter('min_sstable_size', SizeTieredCompactionStrategy) return result diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index 02973c0485..5caa337a52 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -185,12 +185,30 @@ class LeveledCompactionTest(BaseCassEngTestCase): def setUp(self): self.model = copy.deepcopy(CompactionLeveledStrategyModel) + def assert_option_fails(self, key): + # key is a normal_key, converted to + # __compaction_key__ + key = "__compaction_{}__".format(key) + + with patch.object(self.model, key, 10), \ + self.assertRaises(CQLEngineException): + get_compaction_options(self.model) + def test_simple_leveled(self): result = get_compaction_options(self.model) assert result['class'] == LeveledCompactionStrategy def test_bucket_high_fails(self): - with patch.object(self.model, '__compaction_bucket_high__', 10), \ - self.assertRaises(CQLEngineException): - result = get_compaction_options(self.model) + self.assert_option_fails('bucket_high') + + def test_bucket_low_fails(self): + self.assert_option_fails('bucket_low') + + def test_max_threshold_fails(self): + self.assert_option_fails('max_threshold') + + def test_min_threshold_fails(self): + self.assert_option_fails('min_threshold') + def test_min_sstable_size_fails(self): + self.assert_option_fails('min_sstable_size') From 8c38d7a6eb08ba76e7d014e33aaffbda99484634 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 15 Aug 2013 14:02:48 -0700 Subject: [PATCH 0342/4200] finishing out available options --- cqlengine/management.py | 4 ++- cqlengine/tests/management/test_management.py | 25 ++++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index f6b5f176ef..b0c24b829c 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,5 +1,5 @@ import json -from cqlengine import SizeTieredCompactionStrategy +from cqlengine import SizeTieredCompactionStrategy, LeveledCompactionStrategy from cqlengine.connection import connection_manager, execute from cqlengine.exceptions import CQLEngineException @@ -182,6 +182,8 @@ def setter(key, limited_to_strategy = None): setter('min_threshold', SizeTieredCompactionStrategy) setter('min_sstable_size', SizeTieredCompactionStrategy) + setter("sstable_size_in_mb", LeveledCompactionStrategy) + return result diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index 5caa337a52..028fe9a5cb 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -164,8 +164,18 @@ def test_empty_compaction(self): result = get_compaction_options(self.model) self.assertIsNone(result) +class BaseCompactionTest(BaseCassEngTestCase): + def assert_option_fails(self, key): + # key is a normal_key, converted to + # __compaction_key__ + + key = "__compaction_{}__".format(key) -class SizeTieredCompactionTest(BaseCassEngTestCase): + with patch.object(self.model, key, 10), \ + self.assertRaises(CQLEngineException): + get_compaction_options(self.model) + +class SizeTieredCompactionTest(BaseCompactionTest): def setUp(self): self.model = copy.deepcopy(CompactionModel) @@ -177,23 +187,13 @@ def test_size_tiered(self): def test_min_threshold(self): self.model.__compaction_min_threshold__ = 2 - result = get_compaction_options(self.model) assert result['min_threshold'] == 2 -class LeveledCompactionTest(BaseCassEngTestCase): +class LeveledCompactionTest(BaseCompactionTest): def setUp(self): self.model = copy.deepcopy(CompactionLeveledStrategyModel) - def assert_option_fails(self, key): - # key is a normal_key, converted to - # __compaction_key__ - key = "__compaction_{}__".format(key) - - with patch.object(self.model, key, 10), \ - self.assertRaises(CQLEngineException): - get_compaction_options(self.model) - def test_simple_leveled(self): result = get_compaction_options(self.model) assert result['class'] == LeveledCompactionStrategy @@ -212,3 +212,4 @@ def test_min_threshold_fails(self): def test_min_sstable_size_fails(self): self.assert_option_fails('min_sstable_size') + From 95f851863b409cdc2c3185ce339ccaf8dc5cd667 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 15 Aug 2013 14:05:05 -0700 Subject: [PATCH 0343/4200] check for sstable size in MB ok in leveled compaction --- cqlengine/tests/management/test_management.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index 028fe9a5cb..d308bb5038 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -213,3 +213,7 @@ def test_min_threshold_fails(self): def test_min_sstable_size_fails(self): self.assert_option_fails('min_sstable_size') + def test_sstable_size_in_mb(self): + with patch.object(self.model, '__compaction_sstable_size_in_mb__', 32): + result = get_compaction_options(self.model) + assert result['sstable_size_in_mb'] == 32 From a9de7843cfd788efc8f4736413ec492134d03e29 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 15 Aug 2013 14:49:16 -0700 Subject: [PATCH 0344/4200] breaking apart sync table --- cqlengine/management.py | 64 ++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index b0c24b829c..306d330cf3 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -75,35 +75,7 @@ def sync_table(model, create_missing_keyspace=True): #check for an existing column family #TODO: check system tables instead of using cql thrifteries if raw_cf_name not in tables: - qs = ['CREATE TABLE {}'.format(cf_name)] - - #add column types - pkeys = [] # primary keys - ckeys = [] # clustering keys - qtypes = [] # field types - def add_column(col): - s = col.get_column_def() - if col.primary_key: - keys = (pkeys if col.partition_key else ckeys) - keys.append('"{}"'.format(col.db_field_name)) - qtypes.append(s) - for name, col in model._columns.items(): - add_column(col) - - qtypes.append('PRIMARY KEY (({}){})'.format(', '.join(pkeys), ckeys and ', ' + ', '.join(ckeys) or '')) - - qs += ['({})'.format(', '.join(qtypes))] - - with_qs = ['read_repair_chance = {}'.format(model.__read_repair_chance__)] - - _order = ['"{}" {}'.format(c.db_field_name, c.clustering_order or 'ASC') for c in model._clustering_keys.values()] - if _order: - with_qs.append('clustering order by ({})'.format(', '.join(_order))) - - - # add read_repair_chance - qs += ['WITH {}'.format(' AND '.join(with_qs))] - qs = ' '.join(qs) + qs = get_create_table(model) try: execute(qs) @@ -151,6 +123,40 @@ def add_column(col): # index already exists pass +def get_create_table(model): + cf_name = model.column_family_name() + qs = ['CREATE TABLE {}'.format(cf_name)] + + #add column types + pkeys = [] # primary keys + ckeys = [] # clustering keys + qtypes = [] # field types + def add_column(col): + s = col.get_column_def() + if col.primary_key: + keys = (pkeys if col.partition_key else ckeys) + keys.append('"{}"'.format(col.db_field_name)) + qtypes.append(s) + for name, col in model._columns.items(): + add_column(col) + + qtypes.append('PRIMARY KEY (({}){})'.format(', '.join(pkeys), ckeys and ', ' + ', '.join(ckeys) or '')) + + qs += ['({})'.format(', '.join(qtypes))] + + with_qs = ['read_repair_chance = {}'.format(model.__read_repair_chance__)] + + _order = ['"{}" {}'.format(c.db_field_name, c.clustering_order or 'ASC') for c in model._clustering_keys.values()] + if _order: + with_qs.append('clustering order by ({})'.format(', '.join(_order))) + + + # add read_repair_chance + qs += ['WITH {}'.format(' AND '.join(with_qs))] + qs = ' '.join(qs) + return qs + + def get_compaction_options(model): """ Generates dictionary (later converted to a string) for creating and altering From 9ea1c7859e507d5767e33297b2bab31c4bc2fbfd Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 15 Aug 2013 15:47:52 -0700 Subject: [PATCH 0345/4200] options added to create string --- cqlengine/management.py | 8 ++++++++ cqlengine/tests/management/test_management.py | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 306d330cf3..5c9350a326 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -147,12 +147,20 @@ def add_column(col): with_qs = ['read_repair_chance = {}'.format(model.__read_repair_chance__)] _order = ['"{}" {}'.format(c.db_field_name, c.clustering_order or 'ASC') for c in model._clustering_keys.values()] + if _order: with_qs.append('clustering order by ({})'.format(', '.join(_order))) + compaction_options = get_compaction_options(model) + + if compaction_options: + compaction_options = json.dumps(compaction_options).replace('"', "'") + with_qs.append("compaction = {}".format(compaction_options)) # add read_repair_chance qs += ['WITH {}'.format(' AND '.join(with_qs))] + + qs = ' '.join(qs) return qs diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index d308bb5038..ab27b132cc 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -1,5 +1,5 @@ from cqlengine.exceptions import CQLEngineException -from cqlengine.management import create_table, delete_table, get_fields, get_compaction_options +from cqlengine.management import create_table, delete_table, get_fields, get_compaction_options, get_create_table from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.connection import ConnectionPool, Host @@ -216,4 +216,8 @@ def test_min_sstable_size_fails(self): def test_sstable_size_in_mb(self): with patch.object(self.model, '__compaction_sstable_size_in_mb__', 32): result = get_compaction_options(self.model) + assert result['sstable_size_in_mb'] == 32 + + + From f7b9953e0a035bf7f21222c35f6d02f61c01a84b Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 15 Aug 2013 16:11:18 -0700 Subject: [PATCH 0346/4200] test for leveled compaction working --- changelog | 9 +++++++++ cqlengine/management.py | 6 ++++++ cqlengine/tests/management/test_management.py | 11 ++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 0da8f3c7e9..2ed482986a 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,14 @@ CHANGELOG +0.7.0 +* added counter columns +* added support for compaction settings at the model level +* deprecated delete_table in favor of drop_table +* deprecated create_table in favor of sync_table + +0.6.0 +* added table sync + 0.5.2 * adding hex conversion to Bytes column diff --git a/cqlengine/management.py b/cqlengine/management.py index 5c9350a326..eae327a6ae 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,4 +1,5 @@ import json +import warnings from cqlengine import SizeTieredCompactionStrategy, LeveledCompactionStrategy from cqlengine.connection import connection_manager, execute @@ -49,6 +50,7 @@ def delete_keyspace(name): execute("DROP KEYSPACE {}".format(name)) def create_table(model, create_missing_keyspace=True): + warnings.warn("create_table has been deprecated in favor of sync_table and will be removed in a future release", DeprecationWarning) sync_table(model, create_missing_keyspace) def sync_table(model, create_missing_keyspace=True): @@ -217,6 +219,10 @@ def get_fields(model): # convert to Field named tuples def delete_table(model): + warnings.warn("delete_table has been deprecated in favor of drop_table()", DeprecationWarning) + return drop_table(model) + +def drop_table(model): # don't try to delete non existant tables ks_name = model._get_keyspace() diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index ab27b132cc..a89c0b4084 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -1,5 +1,5 @@ from cqlengine.exceptions import CQLEngineException -from cqlengine.management import create_table, delete_table, get_fields, get_compaction_options, get_create_table +from cqlengine.management import create_table, delete_table, get_fields, get_compaction_options, get_create_table, sync_table, drop_table from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.connection import ConnectionPool, Host @@ -219,5 +219,14 @@ def test_sstable_size_in_mb(self): assert result['sstable_size_in_mb'] == 32 + def test_create_table(self): + class LeveledcompactionTestTable(Model): + __compaction__ = LeveledCompactionStrategy + __compaction_sstable_size_in_mb__ = 64 + user_id = columns.UUID(primary_key=True) + name = columns.Text() + + drop_table(LeveledcompactionTestTable) + sync_table(LeveledcompactionTestTable) From 6148b9edbf0c77d4d77e75d6654033d356e2d6a8 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 15 Aug 2013 16:44:31 -0700 Subject: [PATCH 0347/4200] working on alters --- cqlengine/management.py | 15 ++++++++++++++- cqlengine/tests/management/test_management.py | 5 +++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index eae327a6ae..ddcbdaa213 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -75,7 +75,6 @@ def sync_table(model, create_missing_keyspace=True): tables = [x[0] for x in tables.results] #check for an existing column family - #TODO: check system tables instead of using cql thrifteries if raw_cf_name not in tables: qs = get_create_table(model) @@ -218,6 +217,20 @@ def get_fields(model): return [Field(x[0], x[1]) for x in tmp.results] # convert to Field named tuples +def get_compaction_settings(model): + # returns a dictionary of compaction settings in an existing table + ks_name = model._get_keyspace() + col_family = model.column_family_name(include_keyspace=False) + with connection_manager() as con: + query = "SELECT , validator FROM system.schema_columns \ + WHERE keyspace_name = :ks_name AND columnfamily_name = :col_family" + + logger.debug("get_fields %s %s", ks_name, col_family) + + tmp = con.execute(query, {'ks_name':ks_name, 'col_family':col_family}) + import ipdb; ipdb.set_trace() + + def delete_table(model): warnings.warn("delete_table has been deprecated in favor of drop_table()", DeprecationWarning) return drop_table(model) diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index a89c0b4084..6f1a833749 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -229,4 +229,9 @@ class LeveledcompactionTestTable(Model): drop_table(LeveledcompactionTestTable) sync_table(LeveledcompactionTestTable) + LeveledcompactionTestTable.__compaction__ = SizeTieredCompactionStrategy + LeveledcompactionTestTable.__compaction_sstable_size_in_mb__ = None + + sync_table(LeveledcompactionTestTable) + From a78e4ee54ffb592c92c938b4d82297ca5e2288bb Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 15 Aug 2013 18:54:56 -0700 Subject: [PATCH 0348/4200] moved compaction settings to it's own test module --- cqlengine/management.py | 23 ++-- .../management/test_compaction_settings.py | 104 ++++++++++++++++++ cqlengine/tests/management/test_management.py | 102 +---------------- 3 files changed, 123 insertions(+), 106 deletions(-) create mode 100644 cqlengine/tests/management/test_compaction_settings.py diff --git a/cqlengine/management.py b/cqlengine/management.py index ddcbdaa213..0267a719ca 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,6 +1,7 @@ import json import warnings from cqlengine import SizeTieredCompactionStrategy, LeveledCompactionStrategy +from cqlengine.named import NamedTable from cqlengine.connection import connection_manager, execute from cqlengine.exceptions import CQLEngineException @@ -12,6 +13,10 @@ logger = logging.getLogger(__name__) +# system keyspaces +schema_columnfamilies = NamedTable('system', 'schema_columnfamilies') + + def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, durable_writes=True, **replication_values): """ creates a keyspace @@ -98,6 +103,10 @@ def sync_table(model, create_missing_keyspace=True): logger.debug(query) execute(query) + update_compaction(model) + # update compaction + + #get existing index names, skip ones that already exist with connection_manager() as con: @@ -217,18 +226,16 @@ def get_fields(model): return [Field(x[0], x[1]) for x in tmp.results] # convert to Field named tuples -def get_compaction_settings(model): - # returns a dictionary of compaction settings in an existing table + +def update_compaction(model): ks_name = model._get_keyspace() col_family = model.column_family_name(include_keyspace=False) - with connection_manager() as con: - query = "SELECT , validator FROM system.schema_columns \ - WHERE keyspace_name = :ks_name AND columnfamily_name = :col_family" - logger.debug("get_fields %s %s", ks_name, col_family) + row = schema_columnfamilies.get(keyspace_name=ks_name, + columnfamily_name=col_family) + # check compaction_strategy_class + # check compaction_strategy_options - tmp = con.execute(query, {'ks_name':ks_name, 'col_family':col_family}) - import ipdb; ipdb.set_trace() def delete_table(model): diff --git a/cqlengine/tests/management/test_compaction_settings.py b/cqlengine/tests/management/test_compaction_settings.py new file mode 100644 index 0000000000..6242be654c --- /dev/null +++ b/cqlengine/tests/management/test_compaction_settings.py @@ -0,0 +1,104 @@ +import copy +from mock import patch +from cqlengine import Model, columns, SizeTieredCompactionStrategy, LeveledCompactionStrategy +from cqlengine.exceptions import CQLEngineException +from cqlengine.management import get_compaction_options, drop_table, sync_table +from cqlengine.tests.base import BaseCassEngTestCase + + +class CompactionModel(Model): + __compaction__ = None + cid = columns.UUID(primary_key=True) + name = columns.Text() + + +class BaseCompactionTest(BaseCassEngTestCase): + def assert_option_fails(self, key): + # key is a normal_key, converted to + # __compaction_key__ + + key = "__compaction_{}__".format(key) + + with patch.object(self.model, key, 10), \ + self.assertRaises(CQLEngineException): + get_compaction_options(self.model) + + +class SizeTieredCompactionTest(BaseCompactionTest): + + def setUp(self): + self.model = copy.deepcopy(CompactionModel) + self.model.__compaction__ = SizeTieredCompactionStrategy + + def test_size_tiered(self): + result = get_compaction_options(self.model) + assert result['class'] == SizeTieredCompactionStrategy + + def test_min_threshold(self): + self.model.__compaction_min_threshold__ = 2 + result = get_compaction_options(self.model) + assert result['min_threshold'] == 2 + + +class LeveledCompactionTest(BaseCompactionTest): + def setUp(self): + self.model = copy.deepcopy(CompactionLeveledStrategyModel) + + def test_simple_leveled(self): + result = get_compaction_options(self.model) + assert result['class'] == LeveledCompactionStrategy + + def test_bucket_high_fails(self): + self.assert_option_fails('bucket_high') + + def test_bucket_low_fails(self): + self.assert_option_fails('bucket_low') + + def test_max_threshold_fails(self): + self.assert_option_fails('max_threshold') + + def test_min_threshold_fails(self): + self.assert_option_fails('min_threshold') + + def test_min_sstable_size_fails(self): + self.assert_option_fails('min_sstable_size') + + def test_sstable_size_in_mb(self): + with patch.object(self.model, '__compaction_sstable_size_in_mb__', 32): + result = get_compaction_options(self.model) + + assert result['sstable_size_in_mb'] == 32 + + def test_create_table(self): + class LeveledcompactionTestTable(Model): + __compaction__ = LeveledCompactionStrategy + __compaction_sstable_size_in_mb__ = 64 + user_id = columns.UUID(primary_key=True) + name = columns.Text() + + drop_table(LeveledcompactionTestTable) + sync_table(LeveledcompactionTestTable) + + LeveledcompactionTestTable.__compaction__ = SizeTieredCompactionStrategy + LeveledcompactionTestTable.__compaction_sstable_size_in_mb__ = None + + sync_table(LeveledcompactionTestTable) + + +class EmptyCompactionTest(BaseCassEngTestCase): + def test_empty_compaction(self): + self.model = copy.deepcopy(CompactionModel) + result = get_compaction_options(self.model) + self.assertIsNone(result) + + +class CompactionLeveledStrategyModel(Model): + __compaction__ = LeveledCompactionStrategy + cid = columns.UUID(primary_key=True) + name = columns.Text() + + +class CompactionSizeTieredModel(Model): + __compaction__ = SizeTieredCompactionStrategy + cid = columns.UUID(primary_key=True) + name = columns.Text() diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index 6f1a833749..88405b1e40 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -1,11 +1,10 @@ +from mock import MagicMock, patch + from cqlengine.exceptions import CQLEngineException -from cqlengine.management import create_table, delete_table, get_fields, get_compaction_options, get_create_table, sync_table, drop_table +from cqlengine.management import create_table, delete_table, get_fields from cqlengine.tests.base import BaseCassEngTestCase - from cqlengine.connection import ConnectionPool, Host - -from mock import MagicMock, patch -from cqlengine import management, SizeTieredCompactionStrategy, LeveledCompactionStrategy +from cqlengine import management from cqlengine.tests.query.test_queryset import TestModel from cqlengine.models import Model from cqlengine import columns @@ -141,97 +140,4 @@ def test_add_column(self): fields = get_fields(FirstModel) self.assertEqual(len(fields), 4) -class CompactionModel(Model): - __compaction__ = None - cid = columns.UUID(primary_key=True) - name = columns.Text() - -class CompactionSizeTieredModel(Model): - __compaction__ = SizeTieredCompactionStrategy - cid = columns.UUID(primary_key=True) - name = columns.Text() - -class CompactionLeveledStrategyModel(Model): - __compaction__ = LeveledCompactionStrategy - cid = columns.UUID(primary_key=True) - name = columns.Text() - -import copy - -class EmptyCompactionTest(BaseCassEngTestCase): - def test_empty_compaction(self): - self.model = copy.deepcopy(CompactionModel) - result = get_compaction_options(self.model) - self.assertIsNone(result) - -class BaseCompactionTest(BaseCassEngTestCase): - def assert_option_fails(self, key): - # key is a normal_key, converted to - # __compaction_key__ - - key = "__compaction_{}__".format(key) - - with patch.object(self.model, key, 10), \ - self.assertRaises(CQLEngineException): - get_compaction_options(self.model) - -class SizeTieredCompactionTest(BaseCompactionTest): - - def setUp(self): - self.model = copy.deepcopy(CompactionModel) - self.model.__compaction__ = SizeTieredCompactionStrategy - - def test_size_tiered(self): - result = get_compaction_options(self.model) - assert result['class'] == SizeTieredCompactionStrategy - - def test_min_threshold(self): - self.model.__compaction_min_threshold__ = 2 - result = get_compaction_options(self.model) - assert result['min_threshold'] == 2 - -class LeveledCompactionTest(BaseCompactionTest): - def setUp(self): - self.model = copy.deepcopy(CompactionLeveledStrategyModel) - - def test_simple_leveled(self): - result = get_compaction_options(self.model) - assert result['class'] == LeveledCompactionStrategy - - def test_bucket_high_fails(self): - self.assert_option_fails('bucket_high') - - def test_bucket_low_fails(self): - self.assert_option_fails('bucket_low') - - def test_max_threshold_fails(self): - self.assert_option_fails('max_threshold') - - def test_min_threshold_fails(self): - self.assert_option_fails('min_threshold') - - def test_min_sstable_size_fails(self): - self.assert_option_fails('min_sstable_size') - - def test_sstable_size_in_mb(self): - with patch.object(self.model, '__compaction_sstable_size_in_mb__', 32): - result = get_compaction_options(self.model) - - assert result['sstable_size_in_mb'] == 32 - - def test_create_table(self): - class LeveledcompactionTestTable(Model): - __compaction__ = LeveledCompactionStrategy - __compaction_sstable_size_in_mb__ = 64 - user_id = columns.UUID(primary_key=True) - name = columns.Text() - - drop_table(LeveledcompactionTestTable) - sync_table(LeveledcompactionTestTable) - - LeveledcompactionTestTable.__compaction__ = SizeTieredCompactionStrategy - LeveledcompactionTestTable.__compaction_sstable_size_in_mb__ = None - - sync_table(LeveledcompactionTestTable) - From f3c6d32d08b5b394604ba0bbd83f30d8c6faa93d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 16 Aug 2013 08:29:02 -0700 Subject: [PATCH 0349/4200] fixing tablename and keyspace name class args --- docs/topics/models.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 3360ba7fee..c030c76445 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -138,11 +138,11 @@ Model Attributes *Optional.* Indicates that this model is only intended to be used as a base class for other models. You can't create tables for abstract models, but checks around schema validity are skipped during class construction. - .. attribute:: Model.table_name + .. attribute:: Model.__table_name__ *Optional.* Sets the name of the CQL table for this model. If left blank, the table name will be the name of the model, with it's module name as it's prefix. Manually defined table names are not inherited. - .. attribute:: Model.keyspace + .. attribute:: Model.__keyspace__ *Optional.* Sets the name of the keyspace used by this model. Defaulst to cqlengine From f33627c9791a53aa5efeb9a330d761fc8ae2e7eb Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 16 Aug 2013 11:26:06 -0700 Subject: [PATCH 0350/4200] making sure update table is called --- cqlengine/management.py | 5 ++-- .../management/test_compaction_settings.py | 27 ++++++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 0267a719ca..820d8236e2 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -103,9 +103,7 @@ def sync_table(model, create_missing_keyspace=True): logger.debug(query) execute(query) - update_compaction(model) - # update compaction - + update_compaction(model) #get existing index names, skip ones that already exist @@ -228,6 +226,7 @@ def get_fields(model): def update_compaction(model): + logger.debug("Checking %s for compaction differences", model) ks_name = model._get_keyspace() col_family = model.column_family_name(include_keyspace=False) diff --git a/cqlengine/tests/management/test_compaction_settings.py b/cqlengine/tests/management/test_compaction_settings.py index 6242be654c..4ef3cc170c 100644 --- a/cqlengine/tests/management/test_compaction_settings.py +++ b/cqlengine/tests/management/test_compaction_settings.py @@ -1,5 +1,6 @@ import copy -from mock import patch +from time import sleep +from mock import patch, MagicMock from cqlengine import Model, columns, SizeTieredCompactionStrategy, LeveledCompactionStrategy from cqlengine.exceptions import CQLEngineException from cqlengine.management import get_compaction_options, drop_table, sync_table @@ -69,20 +70,26 @@ def test_sstable_size_in_mb(self): assert result['sstable_size_in_mb'] == 32 - def test_create_table(self): - class LeveledcompactionTestTable(Model): - __compaction__ = LeveledCompactionStrategy - __compaction_sstable_size_in_mb__ = 64 - user_id = columns.UUID(primary_key=True) - name = columns.Text() +class LeveledcompactionTestTable(Model): + __compaction__ = LeveledCompactionStrategy + __compaction_sstable_size_in_mb__ = 64 + + user_id = columns.UUID(primary_key=True) + name = columns.Text() + + +class AlterTableTest(BaseCassEngTestCase): + + def test_alter_is_called_table(self): drop_table(LeveledcompactionTestTable) sync_table(LeveledcompactionTestTable) + with patch('cqlengine.management.update_compaction') as mock: + mock.return_value = True + sync_table(LeveledcompactionTestTable) + assert mock.called == 1 - LeveledcompactionTestTable.__compaction__ = SizeTieredCompactionStrategy - LeveledcompactionTestTable.__compaction_sstable_size_in_mb__ = None - sync_table(LeveledcompactionTestTable) class EmptyCompactionTest(BaseCassEngTestCase): From edf0dc06b7b18df61ad3a778c7e059eaceefc1b1 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 16 Aug 2013 11:26:58 -0700 Subject: [PATCH 0351/4200] removed unnecessary return value --- cqlengine/tests/management/test_compaction_settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cqlengine/tests/management/test_compaction_settings.py b/cqlengine/tests/management/test_compaction_settings.py index 4ef3cc170c..cc568201f0 100644 --- a/cqlengine/tests/management/test_compaction_settings.py +++ b/cqlengine/tests/management/test_compaction_settings.py @@ -85,7 +85,6 @@ def test_alter_is_called_table(self): drop_table(LeveledcompactionTestTable) sync_table(LeveledcompactionTestTable) with patch('cqlengine.management.update_compaction') as mock: - mock.return_value = True sync_table(LeveledcompactionTestTable) assert mock.called == 1 From d43bf1c9559420a7ca04511764ba98692c4166fa Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 16 Aug 2013 12:07:29 -0700 Subject: [PATCH 0352/4200] working on updating compaction strategy --- cqlengine/management.py | 8 +++++++- .../tests/management/test_compaction_settings.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 820d8236e2..8fb447c2aa 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -187,6 +187,12 @@ def get_compaction_options(model): result = {'class':model.__compaction__} def setter(key, limited_to_strategy = None): + """ + sets key in result, checking if the key is limited to either SizeTiered or Leveled + :param key: one of the compaction options, like "bucket_high" + :param limited_to_strategy: SizeTieredCompactionStrategy, LeveledCompactionStrategy + :return: + """ mkey = "__compaction_{}__".format(key) tmp = getattr(model, mkey) if tmp and limited_to_strategy and limited_to_strategy != model.__compaction__: @@ -234,7 +240,7 @@ def update_compaction(model): columnfamily_name=col_family) # check compaction_strategy_class # check compaction_strategy_options - + return True def delete_table(model): diff --git a/cqlengine/tests/management/test_compaction_settings.py b/cqlengine/tests/management/test_compaction_settings.py index cc568201f0..d6d96ad53c 100644 --- a/cqlengine/tests/management/test_compaction_settings.py +++ b/cqlengine/tests/management/test_compaction_settings.py @@ -78,6 +78,7 @@ class LeveledcompactionTestTable(Model): user_id = columns.UUID(primary_key=True) name = columns.Text() +from cqlengine.management import schema_columnfamilies class AlterTableTest(BaseCassEngTestCase): @@ -88,6 +89,18 @@ def test_alter_is_called_table(self): sync_table(LeveledcompactionTestTable) assert mock.called == 1 + def test_alter_actually_alters(self): + tmp = copy.deepcopy(LeveledcompactionTestTable) + drop_table(tmp) + sync_table(tmp) + tmp.__compaction__ = SizeTieredCompactionStrategy + tmp.__compaction_sstable_size_in_mb__ = None + sync_table(tmp) + + table_settings = schema_columnfamilies.get(keyspace_name=tmp._get_keyspace(), + columnfamily_name=tmp.column_family_name(include_keyspace=False)) + self.assertRegexpMatches(table_settings['compaction_strategy_class'], '.*SizeTieredCompactionStrategy$') + From b5d69d0906b7080f1933df22f36fed75ccab6d4d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 16 Aug 2013 16:00:06 -0700 Subject: [PATCH 0353/4200] updating column option docs --- docs/topics/models.rst | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index c030c76445..86e0840d73 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -67,15 +67,19 @@ Column Types Column Options -------------- - Each column can be defined with optional arguments to modify the way they behave. While some column types may define additional column options, these are the options that are available on all columns: + Each column can be defined with optional arguments to modify the way they behave. While some column types may + define additional column options, these are the options that are available on all columns: :attr:`~cqlengine.columns.BaseColumn.primary_key` If True, this column is created as a primary key field. A model can have multiple primary keys. Defaults to False. - *In CQL, there are 2 types of primary keys: partition keys and clustering keys. As with CQL, the first primary key is the partition key, and all others are clustering keys, unless partition keys are specified manually using* :attr:`~cqlengine.columns.BaseColumn.partition_key` + *In CQL, there are 2 types of primary keys: partition keys and clustering keys. As with CQL, the first + primary key is the partition key, and all others are clustering keys, unless partition keys are specified + manually using* :attr:`~cqlengine.columns.BaseColumn.partition_key` :attr:`~cqlengine.columns.BaseColumn.partition_key` - If True, this column is created as partition primary key. There may be many partition keys defined, forming *composite partition key* + If True, this column is created as partition primary key. There may be many partition keys defined, + forming a *composite partition key* :attr:`~cqlengine.columns.BaseColumn.index` If True, an index will be created for this column. Defaults to False. @@ -83,13 +87,16 @@ Column Options *Note: Indexes can only be created on models with one primary key* :attr:`~cqlengine.columns.BaseColumn.db_field` - Explicitly sets the name of the column in the database table. If this is left blank, the column name will be the same as the name of the column attribute. Defaults to None. + Explicitly sets the name of the column in the database table. If this is left blank, the column name will be + the same as the name of the column attribute. Defaults to None. :attr:`~cqlengine.columns.BaseColumn.default` - The default value for this column. If a model instance is saved without a value for this column having been defined, the default value will be used. This can be either a value or a callable object (ie: datetime.now is a valid default argument). + The default value for this column. If a model instance is saved without a value for this column having been + defined, the default value will be used. This can be either a value or a callable object (ie: datetime.now is a valid default argument). + Callable defaults will be called each time a default is assigned to a None value :attr:`~cqlengine.columns.BaseColumn.required` - If True, this model cannot be saved without a value defined for this column. Defaults to True. Primary key fields cannot have their required fields set to False. + If True, this model cannot be saved without a value defined for this column. Defaults to False. Primary key fields always require values. Model Methods ============= From b2eace8bd49825421d12291dfb441fa32542e1f2 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 19 Aug 2013 12:02:51 -0700 Subject: [PATCH 0354/4200] altering correctly on incorrect compaction strategy --- cqlengine/management.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 8fb447c2aa..3ac4dd1f04 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -239,8 +239,14 @@ def update_compaction(model): row = schema_columnfamilies.get(keyspace_name=ks_name, columnfamily_name=col_family) # check compaction_strategy_class + do_update = not row['compaction_strategy_class'].endswith(model.__compaction__) + # check compaction_strategy_options - return True + if do_update: + options = get_compaction_options(model) + options = json.dumps(options).replace('"', "'") + cf_name = model.column_family_name() + execute("ALTER TABLE {} with compaction = {}".format(cf_name, options)) def delete_table(model): From cb925d342cb8cd3db92f4fc2bf47c67c37e40ad0 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 19 Aug 2013 13:12:19 -0700 Subject: [PATCH 0355/4200] verifying alter table with compaction" --- cqlengine/management.py | 20 +++++++++++++++---- .../management/test_compaction_settings.py | 15 +++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 3ac4dd1f04..8aeb55276c 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -182,7 +182,7 @@ def get_compaction_options(model): :return: """ if not model.__compaction__: - return None + return {} result = {'class':model.__compaction__} @@ -239,12 +239,24 @@ def update_compaction(model): row = schema_columnfamilies.get(keyspace_name=ks_name, columnfamily_name=col_family) # check compaction_strategy_class - do_update = not row['compaction_strategy_class'].endswith(model.__compaction__) + if model.__compaction__: + do_update = not row['compaction_strategy_class'].endswith(model.__compaction__) + else: + do_update = False + + existing_options = row['compaction_strategy_options'] + existing_options = json.loads(existing_options) + + desired_options = get_compaction_options(model) + desired_options.pop('class', None) + + for k,v in desired_options.items(): + if existing_options[k] != v: + do_update = True # check compaction_strategy_options if do_update: - options = get_compaction_options(model) - options = json.dumps(options).replace('"', "'") + options = json.dumps(get_compaction_options(model)).replace('"', "'") cf_name = model.column_family_name() execute("ALTER TABLE {} with compaction = {}".format(cf_name, options)) diff --git a/cqlengine/tests/management/test_compaction_settings.py b/cqlengine/tests/management/test_compaction_settings.py index d6d96ad53c..4a958f901d 100644 --- a/cqlengine/tests/management/test_compaction_settings.py +++ b/cqlengine/tests/management/test_compaction_settings.py @@ -101,6 +101,19 @@ def test_alter_actually_alters(self): columnfamily_name=tmp.column_family_name(include_keyspace=False)) self.assertRegexpMatches(table_settings['compaction_strategy_class'], '.*SizeTieredCompactionStrategy$') + def test_alter_options(self): + + class AlterTable(Model): + __compaction__ = LeveledCompactionStrategy + __compaction_sstable_size_in_mb__ = 64 + + user_id = columns.UUID(primary_key=True) + name = columns.Text() + + drop_table(AlterTable) + sync_table(AlterTable) + AlterTable.__compaction_sstable_size_in_mb__ = 128 + sync_table(AlterTable) @@ -108,7 +121,7 @@ class EmptyCompactionTest(BaseCassEngTestCase): def test_empty_compaction(self): self.model = copy.deepcopy(CompactionModel) result = get_compaction_options(self.model) - self.assertIsNone(result) + self.assertEqual({}, result) class CompactionLeveledStrategyModel(Model): From 32b005edb1883d179ebc2bf9ef3c6e89760b1167 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 19 Aug 2013 13:45:32 -0700 Subject: [PATCH 0356/4200] fixed broken test due to weird copying issue --- cqlengine/tests/management/test_compaction_settings.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cqlengine/tests/management/test_compaction_settings.py b/cqlengine/tests/management/test_compaction_settings.py index 4a958f901d..07b72a8170 100644 --- a/cqlengine/tests/management/test_compaction_settings.py +++ b/cqlengine/tests/management/test_compaction_settings.py @@ -119,8 +119,12 @@ class AlterTable(Model): class EmptyCompactionTest(BaseCassEngTestCase): def test_empty_compaction(self): - self.model = copy.deepcopy(CompactionModel) - result = get_compaction_options(self.model) + class EmptyCompactionModel(Model): + __compaction__ = None + cid = columns.UUID(primary_key=True) + name = columns.Text() + + result = get_compaction_options(EmptyCompactionModel) self.assertEqual({}, result) From e7c83a0d69933d418b01c8202cc8c9e87b52f7c5 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 19 Aug 2013 15:49:26 -0700 Subject: [PATCH 0357/4200] adding support for using different queryset and dmlquery classes on a model --- cqlengine/models.py | 12 ++++-- .../tests/model/test_class_construction.py | 38 ++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index d03a00415d..2c011b8e62 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -7,10 +7,12 @@ from cqlengine.query import DoesNotExist as _DoesNotExist from cqlengine.query import MultipleObjectsReturned as _MultipleObjectsReturned + class ModelDefinitionException(ModelException): pass DEFAULT_KEYSPACE = 'cqlengine' + class hybrid_classmethod(object): """ Allows a method to behave as both a class method and @@ -44,7 +46,7 @@ def __get__(self, obj, model): """ :rtype: ModelQuerySet """ if model.__abstract__: raise CQLEngineException('cannot execute queries against abstract models') - return ModelQuerySet(model) + return model.__queryset__(model) def __call__(self, *args, **kwargs): """ @@ -140,6 +142,10 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass #the keyspace for this model __keyspace__ = None + # the queryset class used for this class + __queryset__ = ModelQuerySet + __dmlquery__ = DMLQuery + __read_repair_chance__ = 0.1 def __init__(self, **values): @@ -261,7 +267,7 @@ def get(cls, *args, **kwargs): def save(self): is_new = self.pk is None self.validate() - DMLQuery(self.__class__, self, batch=self._batch).save() + self.__dmlquery__(self.__class__, self, batch=self._batch).save() #reset the value managers for v in self._values.values(): @@ -272,7 +278,7 @@ def save(self): def delete(self): """ Deletes this instance """ - DMLQuery(self.__class__, self, batch=self._batch).delete() + self.__dmlquery__(self.__class__, self, batch=self._batch).delete() @classmethod def _class_batch(cls, batch): diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index f0344c6bd3..6653946bea 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -1,5 +1,5 @@ from uuid import uuid4 -from cqlengine.query import QueryException +from cqlengine.query import QueryException, ModelQuerySet, DMLQuery from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.exceptions import ModelException, CQLEngineException @@ -300,6 +300,42 @@ def test_concrete_class_table_creation_cycle(self): delete_table(ConcreteModelWithCol) +class TestCustomQuerySet(BaseCassEngTestCase): + """ Tests overriding the default queryset class """ + + class TestException(Exception): pass + + def test_overriding_queryset(self): + + class QSet(ModelQuerySet): + def create(iself, **kwargs): + raise self.TestException + + class CQModel(Model): + __queryset__ = QSet + part = columns.UUID(primary_key=True) + data = columns.Text() + + with self.assertRaises(self.TestException): + CQModel.create(part=uuid4(), data='s') + + def test_overriding_dmlqueryset(self): + + class DMLQ(DMLQuery): + def save(iself): + raise self.TestException + + class CDQModel(Model): + __dmlquery__ = DMLQ + part = columns.UUID(primary_key=True) + data = columns.Text() + + with self.assertRaises(self.TestException): + CDQModel().save() + + + + From 29c8561049ab6333684ce56c398cbe95b849d3dc Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 20 Aug 2013 11:43:41 -0700 Subject: [PATCH 0358/4200] updated docs with options, added tests for compaction options --- cqlengine/management.py | 32 ++++++----- .../management/test_compaction_settings.py | 54 +++++++++++++++++-- docs/topics/models.rst | 50 ++++++++++++++--- 3 files changed, 114 insertions(+), 22 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 8aeb55276c..e05c7fc265 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -201,7 +201,6 @@ def setter(key, limited_to_strategy = None): if tmp: result[key] = tmp - setter('min_threshold') setter('tombstone_compaction_interval') setter('bucket_high', SizeTieredCompactionStrategy) @@ -231,18 +230,19 @@ def get_fields(model): # convert to Field named tuples +def get_table_settings(model): + return schema_columnfamilies.get(keyspace_name=model._get_keyspace(), + columnfamily_name=model.column_family_name(include_keyspace=False)) + + def update_compaction(model): logger.debug("Checking %s for compaction differences", model) - ks_name = model._get_keyspace() - col_family = model.column_family_name(include_keyspace=False) - - row = schema_columnfamilies.get(keyspace_name=ks_name, - columnfamily_name=col_family) + row = get_table_settings(model) # check compaction_strategy_class - if model.__compaction__: - do_update = not row['compaction_strategy_class'].endswith(model.__compaction__) - else: - do_update = False + if not model.__compaction__: + return + + do_update = not row['compaction_strategy_class'].endswith(model.__compaction__) existing_options = row['compaction_strategy_options'] existing_options = json.loads(existing_options) @@ -251,14 +251,19 @@ def update_compaction(model): desired_options.pop('class', None) for k,v in desired_options.items(): - if existing_options[k] != v: + val = existing_options.pop(k, None) + if val != v: do_update = True # check compaction_strategy_options if do_update: - options = json.dumps(get_compaction_options(model)).replace('"', "'") + options = get_compaction_options(model) + # jsonify + options = json.dumps(options).replace('"', "'") cf_name = model.column_family_name() - execute("ALTER TABLE {} with compaction = {}".format(cf_name, options)) + query = "ALTER TABLE {} with compaction = {}".format(cf_name, options) + logger.debug(query) + execute(query) def delete_table(model): @@ -281,3 +286,4 @@ def drop_table(model): cf_name = model.column_family_name() execute('drop table {};'.format(cf_name)) + diff --git a/cqlengine/tests/management/test_compaction_settings.py b/cqlengine/tests/management/test_compaction_settings.py index 07b72a8170..014abcb2db 100644 --- a/cqlengine/tests/management/test_compaction_settings.py +++ b/cqlengine/tests/management/test_compaction_settings.py @@ -1,9 +1,10 @@ import copy +import json from time import sleep from mock import patch, MagicMock from cqlengine import Model, columns, SizeTieredCompactionStrategy, LeveledCompactionStrategy from cqlengine.exceptions import CQLEngineException -from cqlengine.management import get_compaction_options, drop_table, sync_table +from cqlengine.management import get_compaction_options, drop_table, sync_table, get_table_settings from cqlengine.tests.base import BaseCassEngTestCase @@ -97,10 +98,11 @@ def test_alter_actually_alters(self): tmp.__compaction_sstable_size_in_mb__ = None sync_table(tmp) - table_settings = schema_columnfamilies.get(keyspace_name=tmp._get_keyspace(), - columnfamily_name=tmp.column_family_name(include_keyspace=False)) + table_settings = get_table_settings(tmp) + self.assertRegexpMatches(table_settings['compaction_strategy_class'], '.*SizeTieredCompactionStrategy$') + def test_alter_options(self): class AlterTable(Model): @@ -138,3 +140,49 @@ class CompactionSizeTieredModel(Model): __compaction__ = SizeTieredCompactionStrategy cid = columns.UUID(primary_key=True) name = columns.Text() + + + +class OptionsTest(BaseCassEngTestCase): + + def test_all_size_tiered_options(self): + class AllSizeTieredOptionsModel(Model): + __compaction__ = SizeTieredCompactionStrategy + __compaction_bucket_low__ = .3 + __compaction_bucket_high__ = 2 + __compaction_min_threshold__ = 2 + __compaction_max_threshold__ = 64 + __compaction_tombstone_compaction_interval__ = 86400 + + cid = columns.UUID(primary_key=True) + name = columns.Text() + + drop_table(AllSizeTieredOptionsModel) + sync_table(AllSizeTieredOptionsModel) + + settings = get_table_settings(AllSizeTieredOptionsModel) + options = json.loads(settings['compaction_strategy_options']) + expected = {u'min_threshold': u'2', + u'bucket_low': u'0.3', + u'tombstone_compaction_interval': u'86400', + u'bucket_high': u'2', + u'max_threshold': u'64'} + self.assertDictEqual(options, expected) + + + def test_all_leveled_options(self): + + class AllLeveledOptionsModel(Model): + __compaction__ = LeveledCompactionStrategy + __compaction_sstable_size_in_mb__ = 64 + + cid = columns.UUID(primary_key=True) + name = columns.Text() + + drop_table(AllLeveledOptionsModel) + sync_table(AllLeveledOptionsModel) + + settings = get_table_settings(AllLeveledOptionsModel) + options = json.loads(settings['compaction_strategy_options']) + self.assertDictEqual(options, {u'sstable_size_in_mb': u'64'}) + diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 86e0840d73..4d9ab98a55 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -21,11 +21,11 @@ This example defines a Person table, with the columns ``first_name`` and ``last_ from cqlengine import columns from cqlengine.models import Model - + class Person(Model): first_name = columns.Text() last_name = columns.Text() - + The Person model would create this CQL table: @@ -83,7 +83,7 @@ Column Options :attr:`~cqlengine.columns.BaseColumn.index` If True, an index will be created for this column. Defaults to False. - + *Note: Indexes can only be created on models with one primary key* :attr:`~cqlengine.columns.BaseColumn.db_field` @@ -109,7 +109,7 @@ Model Methods *Example* .. code-block:: python - + #using the person model from earlier: class Person(Model): first_name = columns.Text() @@ -118,7 +118,7 @@ Model Methods person = Person(first_name='Blake', last_name='Eggleston') person.first_name #returns 'Blake' person.last_name #returns 'Eggleston' - + .. method:: save() @@ -135,7 +135,7 @@ Model Methods .. method:: delete() - + Deletes the object from the database. Model Attributes @@ -153,3 +153,41 @@ Model Attributes *Optional.* Sets the name of the keyspace used by this model. Defaulst to cqlengine + +Compaction Options +==================== + + As of cqlengine 0.6 we've added support for specifying compaction options. cqlengine will only use your compaction options if you have a strategy set. When a table is synced, it will be altered to match the compaction options set on your table. This means that if you are changing settings manually they will be changed back on resync. Do not use the compaction settings of cqlengine if you want to manage your compaction settings manually. + + cqlengine supports all compaction options as of Cassandra 1.2.8. + + Tables may either be + .. attribute:: Model.__compaction_bucket_high__ + + .. attribute:: Model.__compaction_bucket_low__ + + .. attribute:: Model.__compaction_max_compaction_threshold__ + + .. attribute:: Model.__compaction_min_compaction_threshold__ + + .. attribute:: Model.__compaction_min_sstable_size__ + + .. attribute:: Model.__compaction_sstable_size_in_mb__ + + .. attribute:: Model.__compaction_tombstone_compaction_interval__ + + .. attribute:: Model.__compaction_tombstone_threshold__ + + For example: + + .. code-block::python + + class User(Model): + __compaction__ = LeveledCompactionStrategy + __compaction_sstable_size_in_mb__ = 64 + __compaction_tombstone_threshold__ = .2 + + user_id = columns.UUID(primary_key=True) + name = columns.Text() + + From 4c53e3e2e40697e390d8b6d5ce73249d85687708 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 20 Aug 2013 13:23:52 -0700 Subject: [PATCH 0359/4200] updated version --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index a918a2aa18..faef31a435 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.6.0 +0.7.0 From f2f9230b5821990330003d5ed0757fdba8ccbb66 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 20 Aug 2013 13:32:55 -0700 Subject: [PATCH 0360/4200] fixed docs --- docs/topics/models.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 4d9ab98a55..093f1cdd95 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -157,7 +157,7 @@ Model Attributes Compaction Options ==================== - As of cqlengine 0.6 we've added support for specifying compaction options. cqlengine will only use your compaction options if you have a strategy set. When a table is synced, it will be altered to match the compaction options set on your table. This means that if you are changing settings manually they will be changed back on resync. Do not use the compaction settings of cqlengine if you want to manage your compaction settings manually. + As of cqlengine 0.7 we've added support for specifying compaction options. cqlengine will only use your compaction options if you have a strategy set. When a table is synced, it will be altered to match the compaction options set on your table. This means that if you are changing settings manually they will be changed back on resync. Do not use the compaction settings of cqlengine if you want to manage your compaction settings manually. cqlengine supports all compaction options as of Cassandra 1.2.8. @@ -183,7 +183,7 @@ Compaction Options .. code-block::python class User(Model): - __compaction__ = LeveledCompactionStrategy + __compaction__ = cqlengine.LeveledCompactionStrategy __compaction_sstable_size_in_mb__ = 64 __compaction_tombstone_threshold__ = .2 @@ -191,3 +191,4 @@ Compaction Options name = columns.Text() + Tables may use LeveledCompactionStrategy or SizeTieredCompactionStrategy. Both options are available in the top level cqlengine module. From 705b18968f6d02c066b4126b0b8ef6a62fd2efb6 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 20 Aug 2013 13:35:08 -0700 Subject: [PATCH 0361/4200] fixing more docs errors --- docs/topics/models.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 093f1cdd95..551056b55d 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -180,7 +180,7 @@ Compaction Options For example: - .. code-block::python + .. code-block:: python class User(Model): __compaction__ = cqlengine.LeveledCompactionStrategy From 2abdbb52454013c3a6ef7795eedbf252da97a0f1 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 20 Aug 2013 13:38:41 -0700 Subject: [PATCH 0362/4200] improving docs --- docs/topics/models.rst | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 551056b55d..17e7cbdcfc 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -190,5 +190,16 @@ Compaction Options user_id = columns.UUID(primary_key=True) name = columns.Text() + or for SizeTieredCompaction: - Tables may use LeveledCompactionStrategy or SizeTieredCompactionStrategy. Both options are available in the top level cqlengine module. + .. code-block:: python + + class TimeData(Model): + __compaction__ = SizeTieredCompactionStrategy + __compaction_bucket_low__ = .3 + __compaction_bucket_high__ = 2 + __compaction_min_threshold__ = 2 + __compaction_max_threshold__ = 64 + __compaction_tombstone_compaction_interval__ = 86400 + + Tables may use `LeveledCompactionStrategy` or `SizeTieredCompactionStrategy`. Both options are available in the top level cqlengine module. To reiterate, you will need to set your `__compaction__` option explicitly in order for cqlengine to handle any of your settings. From dc869c4916d636d93aebf3863dee13f13672f106 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 20 Aug 2013 13:40:37 -0700 Subject: [PATCH 0363/4200] updated readme and index to use sync_table rather than create_table --- README.md | 4 ++-- docs/index.rst | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 09e2014241..a1242e34fc 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ class ExampleModel(Model): >>> connection.setup(['127.0.0.1:9160']) #...and create your CQL table ->>> from cqlengine.management import create_table ->>> create_table(ExampleModel) +>>> from cqlengine.management import sync_table +>>> sync_table(ExampleModel) #now we can create some rows: >>> em1 = ExampleModel.create(example_type=0, description="example1", created_at=datetime.now()) diff --git a/docs/index.rst b/docs/index.rst index 1122599cca..b97360ebe6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,7 +17,7 @@ Contents: .. toctree:: :maxdepth: 2 - + topics/models topics/queryset topics/columns @@ -46,8 +46,8 @@ Getting Started >>> connection.setup(['127.0.0.1:9160']) #...and create your CQL table - >>> from cqlengine.management import create_table - >>> create_table(ExampleModel) + >>> from cqlengine.management import sync_table + >>> sync_table(ExampleModel) #now we can create some rows: >>> em1 = ExampleModel.create(example_type=0, description="example1", created_at=datetime.now()) From 66930a00d96c0a9fee624efc00dee82036c3f4bb Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 20 Aug 2013 13:44:15 -0700 Subject: [PATCH 0364/4200] fixed author --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5fd441428a..944d41191f 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ ], keywords='cassandra,cql,orm', install_requires = ['cql'], - author='Blake Eggleston', + author='Blake Eggleston, Jon Haddad', author_email='bdeggleston@gmail.com', url='https://github.com/cqlengine/cqlengine', license='BSD', From 9c5a0104f56beacee91e953173e246d1710f4c2b Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 20 Aug 2013 14:25:58 -0700 Subject: [PATCH 0365/4200] fixing docs --- docs/topics/models.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 17e7cbdcfc..961a866be9 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -161,7 +161,8 @@ Compaction Options cqlengine supports all compaction options as of Cassandra 1.2.8. - Tables may either be + Available Options: + .. attribute:: Model.__compaction_bucket_high__ .. attribute:: Model.__compaction_bucket_low__ From e6cb306771defd290ca1f21562f27fce29b1d0e4 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 20 Aug 2013 22:16:45 -0700 Subject: [PATCH 0366/4200] column docs --- docs/topics/columns.rst | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index d323ac216a..daffa28a50 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -18,9 +18,9 @@ Columns .. class:: Ascii() Stores a US-ASCII character string :: - + columns.Ascii() - + .. class:: Text() @@ -93,15 +93,24 @@ Columns columns.Decimal() +.. class:: Counter() + + Counters can be incremented and decremented. + + ..code:: python + + columns.Counter() + + Collection Type Columns ---------------------------- CQLEngine also supports container column types. Each container column requires a column class argument to specify what type of objects it will hold. The Map column requires 2, one for the key, and the other for the value - + *Example* .. code-block:: python - + class Person(Model): id = columns.UUID(primary_key=True, default=uuid.uuid4) first_name = columns.Text() @@ -111,7 +120,7 @@ Collection Type Columns enemies = columns.Set(columns.Text) todo_list = columns.List(columns.Text) birthdays = columns.Map(columns.Text, columns.DateTime) - + .. class:: Set() From 9cbe8d7268797fd7c34495eaf9e9aec2fb0e2384 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 20 Aug 2013 22:17:34 -0700 Subject: [PATCH 0367/4200] typo --- docs/topics/columns.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index daffa28a50..5ee9a18686 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -95,11 +95,9 @@ Columns .. class:: Counter() - Counters can be incremented and decremented. + Counters can be incremented and decremented :: - ..code:: python - - columns.Counter() + columns.Counter() Collection Type Columns From 7354b5d646b3eb31dfc9a7173250c76472fdec3c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 22 Aug 2013 15:26:41 -0700 Subject: [PATCH 0368/4200] adding dev requirements file --- requirements-dev.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000000..2773a99fe4 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +nose +nose-progressive +profilestats +pycallgraph +ipdbplugin==1.2 +ipdb==0.7 From 70f2c95c90e03ed04e5380190e394e9c8b8791c1 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 22 Aug 2013 15:29:29 -0700 Subject: [PATCH 0369/4200] splitting up instance constructor methods to make defining custom instantiation methods easier --- cqlengine/query.py | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 194b4c9330..4ad557d59a 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -352,7 +352,7 @@ def _execute_query(self): raise CQLEngineException("Only inserts, updates, and deletes are available in batch mode") if self._result_cache is None: columns, self._result_cache = execute(self._select_query(), self._where_values()) - self._construct_result = self._create_result_constructor(columns) + self._construct_result = self._get_result_constructor(columns) def _fill_result_cache_to_idx(self, idx): self._execute_query() @@ -408,7 +408,7 @@ def __getitem__(self, s): self._fill_result_cache_to_idx(s) return self._result_cache[s] - def _create_result_constructor(self, names): + def _get_result_constructor(self, names): """ Returns a function that will be used to instantiate query results """ @@ -650,7 +650,7 @@ def _get_select_statement(self): """ Returns the fields to be returned by the select query """ return 'SELECT *' - def _create_result_constructor(self, names): + def _get_result_constructor(self, names): """ Returns a function that will be used to instantiate query results """ @@ -697,26 +697,27 @@ def _get_select_statement(self): db_fields = [self.model._columns[f].db_field_name for f in fields] return 'SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields])) - def _create_result_constructor(self, names): - """ - Returns a function that will be used to instantiate query results - """ + def _get_instance_constructor(self, names): + """ returns a function used to construct model instances """ model = self.model db_map = model._db_map + def _construct_instance(values): + field_dict = dict((db_map.get(k, k), v) for k, v in zip(names, values)) + instance = model(**field_dict) + instance._is_persisted = True + return instance + return _construct_instance + + def _get_result_constructor(self, names): + """ Returns a function that will be used to instantiate query results """ if not self._values_list: - def _construct_instance(values): - field_dict = dict((db_map.get(k, k), v) for k, v in zip(names, values)) - instance = model(**field_dict) - instance._is_persisted = True - return instance - return _construct_instance - - columns = [model._columns[n] for n in names] - if self._flat_values_list: - return (lambda values: columns[0].to_python(values[0])) + return self._get_instance_constructor(names) else: - # result_cls = namedtuple("{}Tuple".format(self.model.__name__), names) - return (lambda values: map(lambda (c, v): c.to_python(v), zip(columns, values))) + columns = [self.model._columns[n] for n in names] + if self._flat_values_list: + return lambda values: columns[0].to_python(values[0]) + else: + return lambda values: map(lambda (c, v): c.to_python(v), zip(columns, values)) def _get_ordering_condition(self, colname): colname, order_type = super(ModelQuerySet, self)._get_ordering_condition(colname) From f70973a90a07b007c8038756dad32446b5439eef Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 22 Aug 2013 15:32:23 -0700 Subject: [PATCH 0370/4200] updating --- changelog | 3 +++ cqlengine/VERSION | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 6a72c4bd18..8dd2fe8608 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.7.1 +* refactoring query class to make defining custom model instantiation logic easier + 0.7.0 * added counter columns * added support for compaction settings at the model level diff --git a/cqlengine/VERSION b/cqlengine/VERSION index faef31a435..39e898a4f9 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.7.0 +0.7.1 From d2b86bb31aefc23a37ab310c7e79e17943beb573 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 22 Aug 2013 15:44:18 -0700 Subject: [PATCH 0371/4200] removing table_name inheritance short circuit, which wasn't working anyway --- cqlengine/models.py | 3 --- cqlengine/tests/model/test_class_construction.py | 4 ---- 2 files changed, 7 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 18a49f7f7b..3212d67244 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -401,9 +401,6 @@ def _transform_column(col_name, col_obj): for field_name, col in column_dict.items(): db_map[col.db_field_name] = field_name - #short circuit table_name inheritance - attrs['table_name'] = attrs.get('__table_name__') - #add management members to the class attrs['_columns'] = column_dict attrs['_primary_keys'] = primary_keys diff --git a/cqlengine/tests/model/test_class_construction.py b/cqlengine/tests/model/test_class_construction.py index 6653946bea..6ae46753ec 100644 --- a/cqlengine/tests/model/test_class_construction.py +++ b/cqlengine/tests/model/test_class_construction.py @@ -221,10 +221,6 @@ def test_proper_table_naming(self): assert self.RenamedTest.column_family_name(include_keyspace=False) == 'manual_name' assert self.RenamedTest.column_family_name(include_keyspace=True) == 'whatever.manual_name' - def test_manual_table_name_is_not_inherited(self): - class InheritedTest(self.RenamedTest): pass - assert InheritedTest.table_name is None - class AbstractModel(Model): __abstract__ = True From 4c0648343c161a54d0133968328d18087c3b927b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 30 Aug 2013 15:22:34 -0700 Subject: [PATCH 0372/4200] adding polymorphic_key arg to base column --- cqlengine/columns.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 9940f1aa18..8834b1db7f 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -86,7 +86,8 @@ def __init__(self, db_field=None, default=None, required=False, - clustering_order=None): + clustering_order=None, + polymorphic_key=False): """ :param primary_key: bool flag, indicates this column is a primary key. The first primary key defined on a model is the partition key (unless partition keys are set), all others are cluster keys @@ -99,6 +100,8 @@ def __init__(self, exception if required is set to True and there is a None value assigned :param clustering_order: only applicable on clustering keys (primary keys that are not partition keys) determines the order that the clustering keys are sorted on disk + :param polymorphic_key: boolean, if set to True, this column will be used for saving and loading instances + of polymorphic tables """ self.partition_key = partition_key self.primary_key = partition_key or primary_key @@ -107,6 +110,7 @@ def __init__(self, self.default = default self.required = required self.clustering_order = clustering_order + self.polymorphic_key = polymorphic_key #the column name in the model definition self.column_name = None From 54d610ec20d867f19c61918c315ca6fee421e625 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 10:25:53 -0700 Subject: [PATCH 0373/4200] adding polymorphic config to model metaclass --- cqlengine/models.py | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 3212d67244..f43f107428 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -10,6 +10,9 @@ class ModelDefinitionException(ModelException): pass + +class PolyMorphicModelException(ModelException): pass + DEFAULT_KEYSPACE = 'cqlengine' @@ -142,6 +145,8 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass #the keyspace for this model __keyspace__ = None + #polymorphism options + __polymorphic_key__ = None # compaction options __compaction__ = None @@ -282,6 +287,14 @@ def get(cls, *args, **kwargs): return cls.objects.get(*args, **kwargs) def save(self): + + # handle polymorphic models + if self._is_polymorphic: + if self._is_polymorphic_base: + raise PolyMorphicModelException('cannot save polymorphic base model') + else: + setattr(self, self._polymorphic_column_name, self.__polymorphic_key__) + is_new = self.pk is None self.validate() self.__dmlquery__(self.__class__, self, batch=self._batch).save() @@ -328,6 +341,9 @@ def __new__(cls, name, bases, attrs): #short circuit __abstract__ inheritance is_abstract = attrs['__abstract__'] = attrs.get('__abstract__', False) + #short circuit __polymorphic_key__ inheritance + attrs['__polymorphic_key__'] = attrs.get('__polymorphic_key__', None) + def _transform_column(col_name, col_obj): column_dict[col_name] = col_obj if col_obj.primary_key: @@ -339,11 +355,20 @@ def _transform_column(col_name, col_obj): column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.Column)] column_definitions = sorted(column_definitions, lambda x,y: cmp(x[1].position, y[1].position)) + polymorphic_base = any([c[1].polymorphic_key for c in column_definitions]) + column_definitions = inherited_columns.items() + column_definitions + polymorphic_columns = [c for c in column_definitions if c[1].polymorphic_key] + is_polymorphic = len(polymorphic_columns) > 0 + if len(polymorphic_columns) > 1: + raise ModelDefinitionException('only one polymorphic_key can be defined in a model, {} found'.format(len(polymorphic_columns))) + + polymorphic_column_name, polymorphic_column = polymorphic_columns[0] if polymorphic_columns else (None, None) + defined_columns = OrderedDict(column_definitions) - #prepend primary key if one hasn't been defined + # check for primary key if not is_abstract and not any([v.primary_key for k,v in column_definitions]): raise ModelDefinitionException("At least 1 primary key is required.") @@ -356,7 +381,7 @@ def _transform_column(col_name, col_obj): #TODO: check that the defined columns don't conflict with any of the Model API's existing attributes/methods #transform column definitions - for k,v in column_definitions: + for k, v in column_definitions: # counter column primary keys are not allowed if (v.primary_key or v.partition_key) and isinstance(v, (columns.Counter, columns.BaseContainerColumn)): raise ModelDefinitionException('counter columns and container columns cannot be used as primary keys') @@ -413,6 +438,12 @@ def _transform_column(col_name, col_obj): attrs['_clustering_keys'] = clustering_keys attrs['_has_counter'] = len(counter_columns) > 0 + # add polymorphic management attributes + attrs['_is_polymorphic_base'] = polymorphic_base + attrs['_is_polymorphic'] = is_polymorphic + attrs['_polymorphic_column'] = polymorphic_column + attrs['_polymorphic_column_name'] = polymorphic_column_name + #setup class exceptions DoesNotExistBase = None for base in bases: From 0c380402954df642d4462749bb6dfb5cfbce38ab Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 10:26:05 -0700 Subject: [PATCH 0374/4200] adding tests around polymorphic models --- cqlengine/tests/model/test_polymorphism.py | 110 +++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 cqlengine/tests/model/test_polymorphism.py diff --git a/cqlengine/tests/model/test_polymorphism.py b/cqlengine/tests/model/test_polymorphism.py new file mode 100644 index 0000000000..2b42f556f5 --- /dev/null +++ b/cqlengine/tests/model/test_polymorphism.py @@ -0,0 +1,110 @@ +import uuid + +from cqlengine import columns +from cqlengine import models +from cqlengine.tests.base import BaseCassEngTestCase +from cqlengine import management + + +class TestPolymorphicClassConstruction(BaseCassEngTestCase): + + def test_multiple_polymorphic_key_failure(self): + """ Tests that defining a model with more than one polymorphic key fails """ + with self.assertRaises(models.ModelDefinitionException): + class M(models.Model): + partition = columns.Integer(primary_key=True) + type1 = columns.Integer(polymorphic_key=True) + type2 = columns.Integer(polymorphic_key=True) + + def test_polymorphic_key_inheritance(self): + """ Tests that polymorphic_key attribute is not inherited """ + class Base(models.Model): + partition = columns.Integer(primary_key=True) + type1 = columns.Integer(polymorphic_key=True) + + class M1(Base): + __polymorphic_key__ = 1 + + class M2(M1): + pass + + assert M2.__polymorphic_key__ is None + + def test_polymorphic_metaclass(self): + """ Tests that the model meta class configures polymorphic models properly """ + class Base(models.Model): + partition = columns.Integer(primary_key=True) + type1 = columns.Integer(polymorphic_key=True) + + class M1(Base): + __polymorphic_key__ = 1 + + assert Base._is_polymorphic + assert M1._is_polymorphic + + assert Base._is_polymorphic_base + assert not M1._is_polymorphic_base + + assert Base._polymorphic_column == Base.type1 + assert M1._polymorphic_column == M1.type1 + + assert Base._polymorphic_column_name == 'type1' + assert M1._polymorphic_column_name == 'type1' + + +class PolyBase(models.Model): + partition = columns.UUID(primary_key=True, default=uuid.uuid4) + row_type = columns.Integer(polymorphic_key=True) + + +class Poly1(PolyBase): + __polymorphic_key__ = 1 + data1 = columns.Text() + + +class Poly2(PolyBase): + __polymorphic_key__ = 2 + data2 = columns.Text() + + +class TestPolymorphicModel(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestPolymorphicModel, cls).setUpClass() + management.sync_table(Poly1) + management.sync_table(Poly2) + + @classmethod + def tearDownClass(cls): + super(TestPolymorphicModel, cls).tearDownClass() + management.drop_table(Poly1) + management.drop_table(Poly2) + + def test_saving_base_model_fails(self): + with self.assertRaises(models.PolyMorphicModelException): + PolyBase.create(partition=1) + + def test_saving_subclass_saves_poly_key(self): + pass + + +class TestIndexedPolymorphicQuery(BaseCassEngTestCase): + + def test_success_case(self): + pass + + def test_polymorphic_key_is_added_to_queries(self): + pass + + +class TestUnindexedPolymorphicQuery(BaseCassEngTestCase): + + def test_non_conflicting_type_results_work(self): + pass + + def test_conflicting_type_results(self): + pass + + def test_allow_filtering_filters_types(self): + pass From 94c7578b0b2c1dc180a5041fa4b847df55fa4ede Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 12:00:11 -0700 Subject: [PATCH 0375/4200] moving instance construction onto the model --- cqlengine/models.py | 29 +++++++++++++++++++++++++++++ cqlengine/query.py | 16 +--------------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index f43f107428..7d84a3c889 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -189,6 +189,21 @@ def __init__(self, **values): self._is_persisted = False self._batch = None + @classmethod + def _construct_instance(cls, names, values): + """ + method used to construct instances from query results + + :param cls: + :param names: + :param values: + :return: + """ + field_dict = dict((cls._db_map.get(k, k), v) for k, v in zip(names, values)) + instance = cls(**field_dict) + instance._is_persisted = True + return instance + def _can_update(self): """ Called by the save function to check if this should be @@ -216,6 +231,16 @@ def _get_column(cls, name): """ return cls._columns[name] + @classmethod + def _get_polymorphic_base(cls): + if cls._is_polymorphic: + if cls._is_polymorphic_base: + return cls + for base in cls.__bases__: + klass = base._get_polymorphic_base() + if klass is not None: + return klass + def __eq__(self, other): if self.__class__ != other.__class__: return False @@ -246,6 +271,10 @@ def column_family_name(cls, include_keyspace=True): if cls.__table_name__: cf_name = cls.__table_name__.lower() else: + # get polymorphic base table names if model is polymorphic + if cls._is_polymorphic and not cls._is_polymorphic_base: + return cls._get_polymorphic_base().column_family_name(include_keyspace=include_keyspace) + camelcase = re.compile(r'([a-z])([A-Z])') ccase = lambda s: camelcase.sub(lambda v: '{}_{}'.format(v.group(1), v.group(2).lower()), s) diff --git a/cqlengine/query.py b/cqlengine/query.py index 4ad557d59a..9bb1a31a33 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -679,9 +679,6 @@ def _validate_where_syntax(self): if any(not w.column.partition_key for w in token_ops): raise QueryException('The token() function is only supported on the partition key') - - #TODO: abuse this to see if we can get cql to raise an exception - def _where_clause(self): """ Returns a where clause based on the given filter args """ self._validate_where_syntax() @@ -697,21 +694,10 @@ def _get_select_statement(self): db_fields = [self.model._columns[f].db_field_name for f in fields] return 'SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields])) - def _get_instance_constructor(self, names): - """ returns a function used to construct model instances """ - model = self.model - db_map = model._db_map - def _construct_instance(values): - field_dict = dict((db_map.get(k, k), v) for k, v in zip(names, values)) - instance = model(**field_dict) - instance._is_persisted = True - return instance - return _construct_instance - def _get_result_constructor(self, names): """ Returns a function that will be used to instantiate query results """ if not self._values_list: - return self._get_instance_constructor(names) + return lambda values: self.model._construct_instance(names, values) else: columns = [self.model._columns[n] for n in names] if self._flat_values_list: From ba476f8d6dce244d2f0cb5581c027140582f48ce Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 12:09:17 -0700 Subject: [PATCH 0376/4200] moving polymorphic base discovery into model meta class --- cqlengine/models.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 7d84a3c889..70ecf05a2c 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -231,16 +231,6 @@ def _get_column(cls, name): """ return cls._columns[name] - @classmethod - def _get_polymorphic_base(cls): - if cls._is_polymorphic: - if cls._is_polymorphic_base: - return cls - for base in cls.__bases__: - klass = base._get_polymorphic_base() - if klass is not None: - return klass - def __eq__(self, other): if self.__class__ != other.__class__: return False @@ -273,7 +263,7 @@ def column_family_name(cls, include_keyspace=True): else: # get polymorphic base table names if model is polymorphic if cls._is_polymorphic and not cls._is_polymorphic_base: - return cls._get_polymorphic_base().column_family_name(include_keyspace=include_keyspace) + return cls._polymorphic_base.column_family_name(include_keyspace=include_keyspace) camelcase = re.compile(r'([a-z])([A-Z])') ccase = lambda s: camelcase.sub(lambda v: '{}_{}'.format(v.group(1), v.group(2).lower()), s) @@ -384,7 +374,7 @@ def _transform_column(col_name, col_obj): column_definitions = [(k,v) for k,v in attrs.items() if isinstance(v, columns.Column)] column_definitions = sorted(column_definitions, lambda x,y: cmp(x[1].position, y[1].position)) - polymorphic_base = any([c[1].polymorphic_key for c in column_definitions]) + is_polymorphic_base = any([c[1].polymorphic_key for c in column_definitions]) column_definitions = inherited_columns.items() + column_definitions @@ -395,6 +385,18 @@ def _transform_column(col_name, col_obj): polymorphic_column_name, polymorphic_column = polymorphic_columns[0] if polymorphic_columns else (None, None) + # find polymorphic base class + polymorphic_base = None + if is_polymorphic and not is_polymorphic_base: + def _get_polymorphic_base(bases): + for base in bases: + if getattr(base, '_is_polymorphic_base', False): + return base + klass = _get_polymorphic_base(base.__bases__) + if klass: + return klass + polymorphic_base = _get_polymorphic_base(bases) + defined_columns = OrderedDict(column_definitions) # check for primary key @@ -468,8 +470,9 @@ def _transform_column(col_name, col_obj): attrs['_has_counter'] = len(counter_columns) > 0 # add polymorphic management attributes - attrs['_is_polymorphic_base'] = polymorphic_base + attrs['_is_polymorphic_base'] = is_polymorphic_base attrs['_is_polymorphic'] = is_polymorphic + attrs['_polymorphic_base'] = polymorphic_base attrs['_polymorphic_column'] = polymorphic_column attrs['_polymorphic_column_name'] = polymorphic_column_name From 2fc5350289c87b6db8e9ea313d505c238c348181 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 12:25:06 -0700 Subject: [PATCH 0377/4200] adding support for polymorphic model deserialization --- cqlengine/models.py | 53 +++++++++++++++++++--- cqlengine/tests/model/test_polymorphism.py | 28 +++++++++++- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 70ecf05a2c..57f922ed41 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -189,18 +189,58 @@ def __init__(self, **values): self._is_persisted = False self._batch = None + @classmethod + def _discover_polymorphic_submodels(cls): + if not cls._is_polymorphic_base: + raise ModelException('_discover_polymorphic_submodels can only be called on polymorphic base classes') + def _discover(klass): + if not klass._is_polymorphic_base and klass.__polymorphic_key__ is not None: + cls._polymorphic_map[klass.__polymorphic_key__] = klass + for subklass in klass.__subclasses__(): + _discover(subklass) + _discover(cls) + + @classmethod + def _get_model_by_polymorphic_key(cls, key): + if not cls._is_polymorphic_base: + raise ModelException('_get_model_by_polymorphic_key can only be called on polymorphic base classes') + return cls._polymorphic_map.get(key) + @classmethod def _construct_instance(cls, names, values): """ method used to construct instances from query results - - :param cls: - :param names: - :param values: - :return: + this is where polymorphic deserialization occurs """ field_dict = dict((cls._db_map.get(k, k), v) for k, v in zip(names, values)) - instance = cls(**field_dict) + if cls._is_polymorphic: + poly_key = field_dict.get(cls._polymorphic_column_name) + + if poly_key is None: + raise PolyMorphicModelException('polymorphic key was not found in values') + + poly_base = cls if cls._is_polymorphic_base else cls._polymorphic_base + + klass = poly_base._get_model_by_polymorphic_key(poly_key) + if klass is None: + poly_base._discover_polymorphic_submodels() + klass = poly_base._get_model_by_polymorphic_key(poly_key) + if klass is None: + raise PolyMorphicModelException( + 'unrecognized polymorphic key {} for class {}'.format(poly_key, poly_base.__name__) + ) + + if not issubclass(klass, poly_base): + raise PolyMorphicModelException( + '{} is not a subclass of {}'.format(klass.__name__, poly_base.__name__) + ) + + field_dict = {k: v for k, v in field_dict.items() if k in klass._columns.keys()} + + else: + klass = cls + + instance = klass(**field_dict) instance._is_persisted = True return instance @@ -475,6 +515,7 @@ def _get_polymorphic_base(bases): attrs['_polymorphic_base'] = polymorphic_base attrs['_polymorphic_column'] = polymorphic_column attrs['_polymorphic_column_name'] = polymorphic_column_name + attrs['_polymorphic_map'] = {} if is_polymorphic_base else None #setup class exceptions DoesNotExistBase = None diff --git a/cqlengine/tests/model/test_polymorphism.py b/cqlengine/tests/model/test_polymorphism.py index 2b42f556f5..7fc5275ae3 100644 --- a/cqlengine/tests/model/test_polymorphism.py +++ b/cqlengine/tests/model/test_polymorphism.py @@ -51,6 +51,16 @@ class M1(Base): assert Base._polymorphic_column_name == 'type1' assert M1._polymorphic_column_name == 'type1' + def test_table_names_are_inherited_from_poly_base(self): + class Base(models.Model): + partition = columns.Integer(primary_key=True) + type1 = columns.Integer(polymorphic_key=True) + + class M1(Base): + __polymorphic_key__ = 1 + + assert Base.column_family_name() == M1.column_family_name() + class PolyBase(models.Model): partition = columns.UUID(primary_key=True, default=uuid.uuid4) @@ -83,10 +93,24 @@ def tearDownClass(cls): def test_saving_base_model_fails(self): with self.assertRaises(models.PolyMorphicModelException): - PolyBase.create(partition=1) + PolyBase.create() def test_saving_subclass_saves_poly_key(self): - pass + p1 = Poly1.create(data1='pickle') + p2 = Poly2.create(data2='bacon') + + assert p1.row_type == Poly1.__polymorphic_key__ + assert p2.row_type == Poly2.__polymorphic_key__ + + def test_query_deserialization(self): + p1 = Poly1.create(data1='pickle') + p2 = Poly2.create(data2='bacon') + + p1r = PolyBase.get(partition=p1.partition) + p2r = PolyBase.get(partition=p2.partition) + + assert isinstance(p1r, Poly1) + assert isinstance(p2r, Poly2) class TestIndexedPolymorphicQuery(BaseCassEngTestCase): From 2984ba71634e5c3d4b23bb42a977401ca60ffc01 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 15:35:28 -0700 Subject: [PATCH 0378/4200] adding tests around unindexed polymorphic columns --- cqlengine/models.py | 2 +- cqlengine/tests/model/test_polymorphism.py | 65 +++++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 57f922ed41..8a7d58f8ea 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -230,7 +230,7 @@ def _construct_instance(cls, names, values): 'unrecognized polymorphic key {} for class {}'.format(poly_key, poly_base.__name__) ) - if not issubclass(klass, poly_base): + if not issubclass(klass, cls): raise PolyMorphicModelException( '{} is not a subclass of {}'.format(klass.__name__, poly_base.__name__) ) diff --git a/cqlengine/tests/model/test_polymorphism.py b/cqlengine/tests/model/test_polymorphism.py index 7fc5275ae3..6dece75fd8 100644 --- a/cqlengine/tests/model/test_polymorphism.py +++ b/cqlengine/tests/model/test_polymorphism.py @@ -113,22 +113,73 @@ def test_query_deserialization(self): assert isinstance(p2r, Poly2) -class TestIndexedPolymorphicQuery(BaseCassEngTestCase): +class UnindexedPolyBase(models.Model): + partition = columns.UUID(primary_key=True, default=uuid.uuid4) + cluster = columns.UUID(primary_key=True, default=uuid.uuid4) + row_type = columns.Integer(polymorphic_key=True) - def test_success_case(self): - pass - def test_polymorphic_key_is_added_to_queries(self): - pass +class UnindexedPoly1(UnindexedPolyBase): + __polymorphic_key__ = 1 + data1 = columns.Text() + + +class UnindexedPoly2(UnindexedPolyBase): + __polymorphic_key__ = 2 + data2 = columns.Text() class TestUnindexedPolymorphicQuery(BaseCassEngTestCase): + @classmethod + def setUpClass(cls): + super(TestUnindexedPolymorphicQuery, cls).setUpClass() + management.sync_table(UnindexedPoly1) + management.sync_table(UnindexedPoly2) + + cls.p1 = UnindexedPoly1.create(data1='pickle') + cls.p2 = UnindexedPoly2.create(partition=cls.p1.partition, data2='bacon') + + @classmethod + def tearDownClass(cls): + super(TestUnindexedPolymorphicQuery, cls).tearDownClass() + management.drop_table(UnindexedPoly1) + management.drop_table(UnindexedPoly2) + def test_non_conflicting_type_results_work(self): - pass + assert len([p for p in UnindexedPoly1.objects(partition=self.p1.partition, cluster=self.p1.cluster)]) == 1 + assert len([p for p in UnindexedPoly2.objects(partition=self.p1partition, cluster=self.p2.cluster)]) == 1 def test_conflicting_type_results(self): - pass + with self.assertRaises(models.PolyMorphicModelException): + [p for p in UnindexedPoly1.objects(partition=self.p1.partition)] + with self.assertRaises(models.PolyMorphicModelException): + [p for p in UnindexedPoly2.objects(partition=self.p1.partition)] def test_allow_filtering_filters_types(self): pass + + +class IndexedPolyBase(models.Model): + partition = columns.UUID(primary_key=True, default=uuid.uuid4) + row_type = columns.Integer(polymorphic_key=True, index=True) + + +class IndexedPoly1(IndexedPolyBase): + __polymorphic_key__ = 1 + data1 = columns.Text() + + +class IndexedPoly2(IndexedPolyBase): + __polymorphic_key__ = 2 + data2 = columns.Text() + + +class TestIndexedPolymorphicQuery(BaseCassEngTestCase): + + def test_success_case(self): + pass + + def test_polymorphic_key_is_added_to_queries(self): + pass + From 5dc9e971267c2072c60ae9271c6e230813f72e15 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 16:29:38 -0700 Subject: [PATCH 0379/4200] finishing up the unindexed polymorphic key tests --- cqlengine/models.py | 2 +- cqlengine/tests/model/test_polymorphism.py | 32 +++++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 8a7d58f8ea..0d94e08cbf 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -232,7 +232,7 @@ def _construct_instance(cls, names, values): if not issubclass(klass, cls): raise PolyMorphicModelException( - '{} is not a subclass of {}'.format(klass.__name__, poly_base.__name__) + '{} is not a subclass of {}'.format(klass.__name__, cls.__name__) ) field_dict = {k: v for k, v in field_dict.items() if k in klass._columns.keys()} diff --git a/cqlengine/tests/model/test_polymorphism.py b/cqlengine/tests/model/test_polymorphism.py index 6dece75fd8..d24ac34044 100644 --- a/cqlengine/tests/model/test_polymorphism.py +++ b/cqlengine/tests/model/test_polymorphism.py @@ -61,6 +61,9 @@ class M1(Base): assert Base.column_family_name() == M1.column_family_name() + def test_collection_columns_cant_be_polymorphic_keys(self): + pass + class PolyBase(models.Model): partition = columns.UUID(primary_key=True, default=uuid.uuid4) @@ -129,6 +132,11 @@ class UnindexedPoly2(UnindexedPolyBase): data2 = columns.Text() +class UnindexedPoly3(UnindexedPoly2): + __polymorphic_key__ = 3 + data3 = columns.Text() + + class TestUnindexedPolymorphicQuery(BaseCassEngTestCase): @classmethod @@ -136,28 +144,33 @@ def setUpClass(cls): super(TestUnindexedPolymorphicQuery, cls).setUpClass() management.sync_table(UnindexedPoly1) management.sync_table(UnindexedPoly2) + management.sync_table(UnindexedPoly3) cls.p1 = UnindexedPoly1.create(data1='pickle') cls.p2 = UnindexedPoly2.create(partition=cls.p1.partition, data2='bacon') + cls.p3 = UnindexedPoly3.create(partition=cls.p1.partition, data3='bacon') @classmethod def tearDownClass(cls): super(TestUnindexedPolymorphicQuery, cls).tearDownClass() management.drop_table(UnindexedPoly1) management.drop_table(UnindexedPoly2) + management.drop_table(UnindexedPoly3) def test_non_conflicting_type_results_work(self): - assert len([p for p in UnindexedPoly1.objects(partition=self.p1.partition, cluster=self.p1.cluster)]) == 1 - assert len([p for p in UnindexedPoly2.objects(partition=self.p1partition, cluster=self.p2.cluster)]) == 1 + p1, p2, p3 = self.p1, self.p2, self.p3 + assert len(list(UnindexedPoly1.objects(partition=p1.partition, cluster=p1.cluster))) == 1 + assert len(list(UnindexedPoly2.objects(partition=p1.partition, cluster=p2.cluster))) == 1 + + def test_subclassed_model_results_work_properly(self): + p1, p2, p3 = self.p1, self.p2, self.p3 + assert len(list(UnindexedPoly2.objects(partition=p1.partition, cluster__in=[p2.cluster, p3.cluster]))) == 2 def test_conflicting_type_results(self): with self.assertRaises(models.PolyMorphicModelException): - [p for p in UnindexedPoly1.objects(partition=self.p1.partition)] + list(UnindexedPoly1.objects(partition=self.p1.partition)) with self.assertRaises(models.PolyMorphicModelException): - [p for p in UnindexedPoly2.objects(partition=self.p1.partition)] - - def test_allow_filtering_filters_types(self): - pass + list(UnindexedPoly2.objects(partition=self.p1.partition)) class IndexedPolyBase(models.Model): @@ -175,6 +188,11 @@ class IndexedPoly2(IndexedPolyBase): data2 = columns.Text() +class IndexedPoly3(IndexedPoly2): + __polymorphic_key__ = 3 + data3 = columns.Text() + + class TestIndexedPolymorphicQuery(BaseCassEngTestCase): def test_success_case(self): From 8aa59edb36deb7f985dea556251cf66c9242be1b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 16:33:22 -0700 Subject: [PATCH 0380/4200] switching select query to all columns when defer and only fields aren't defined --- cqlengine/query.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 9bb1a31a33..02a5d7d577 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -686,13 +686,16 @@ def _where_clause(self): def _get_select_statement(self): """ Returns the fields to be returned by the select query """ - fields = self.model._columns.keys() - if self._defer_fields: - fields = [f for f in fields if f not in self._defer_fields] - elif self._only_fields: - fields = self._only_fields - db_fields = [self.model._columns[f].db_field_name for f in fields] - return 'SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields])) + if self._defer_fields or self._only_fields: + fields = self.model._columns.keys() + if self._defer_fields: + fields = [f for f in fields if f not in self._defer_fields] + elif self._only_fields: + fields = self._only_fields + db_fields = [self.model._columns[f].db_field_name for f in fields] + return 'SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields])) + else: + return 'SELECT *' def _get_result_constructor(self, names): """ Returns a function that will be used to instantiate query results """ From 880600924ae33998b7dddc6b36de00618b089511 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 16:53:59 -0700 Subject: [PATCH 0381/4200] adding support for auto filtering on polymorphic subclass models with and indexed polymorphic key --- cqlengine/models.py | 13 ++++++++++- cqlengine/tests/model/test_polymorphism.py | 26 +++++++++++++++------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 0d94e08cbf..e55ed5ae45 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -49,7 +49,18 @@ def __get__(self, obj, model): """ :rtype: ModelQuerySet """ if model.__abstract__: raise CQLEngineException('cannot execute queries against abstract models') - return model.__queryset__(model) + queryset = model.__queryset__(model) + + # if this is a concrete polymorphic model, and the polymorphic + # key is an indexed column, add a filter clause to only return + # logical rows of the proper type + if model._is_polymorphic and not model._is_polymorphic_base: + name, column = model._polymorphic_column_name, model._polymorphic_column + if column.partition_key or column.index: + # look for existing poly types + return queryset.filter(**{name: model.__polymorphic_key__}) + + return queryset def __call__(self, *args, **kwargs): """ diff --git a/cqlengine/tests/model/test_polymorphism.py b/cqlengine/tests/model/test_polymorphism.py index d24ac34044..d608aae1b7 100644 --- a/cqlengine/tests/model/test_polymorphism.py +++ b/cqlengine/tests/model/test_polymorphism.py @@ -148,7 +148,7 @@ def setUpClass(cls): cls.p1 = UnindexedPoly1.create(data1='pickle') cls.p2 = UnindexedPoly2.create(partition=cls.p1.partition, data2='bacon') - cls.p3 = UnindexedPoly3.create(partition=cls.p1.partition, data3='bacon') + cls.p3 = UnindexedPoly3.create(partition=cls.p1.partition, data3='turkey') @classmethod def tearDownClass(cls): @@ -175,6 +175,7 @@ def test_conflicting_type_results(self): class IndexedPolyBase(models.Model): partition = columns.UUID(primary_key=True, default=uuid.uuid4) + cluster = columns.UUID(primary_key=True, default=uuid.uuid4) row_type = columns.Integer(polymorphic_key=True, index=True) @@ -188,16 +189,25 @@ class IndexedPoly2(IndexedPolyBase): data2 = columns.Text() -class IndexedPoly3(IndexedPoly2): - __polymorphic_key__ = 3 - data3 = columns.Text() +class TestIndexedPolymorphicQuery(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestIndexedPolymorphicQuery, cls).setUpClass() + management.sync_table(IndexedPoly1) + management.sync_table(IndexedPoly2) + cls.p1 = IndexedPoly1.create(data1='pickle') + cls.p2 = IndexedPoly2.create(partition=cls.p1.partition, data2='bacon') -class TestIndexedPolymorphicQuery(BaseCassEngTestCase): + @classmethod + def tearDownClass(cls): + super(TestIndexedPolymorphicQuery, cls).tearDownClass() + management.drop_table(IndexedPoly1) + management.drop_table(IndexedPoly2) def test_success_case(self): - pass + assert len(list(IndexedPoly1.objects(partition=self.p1.partition))) == 1 + assert len(list(IndexedPoly2.objects(partition=self.p1.partition))) == 1 - def test_polymorphic_key_is_added_to_queries(self): - pass From f0b6966d41f16a608bfcd25c30d10cbab57919c2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 17:00:15 -0700 Subject: [PATCH 0382/4200] adding restriction around defining counter or container columns as polymorphic keys --- cqlengine/models.py | 3 +++ cqlengine/tests/model/test_polymorphism.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index e55ed5ae45..d01208ea09 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -436,6 +436,9 @@ def _transform_column(col_name, col_obj): polymorphic_column_name, polymorphic_column = polymorphic_columns[0] if polymorphic_columns else (None, None) + if isinstance(polymorphic_column, (columns.BaseContainerColumn, columns.Counter)): + raise ModelDefinitionException('counter and container columns cannot be used for polymorphic keys') + # find polymorphic base class polymorphic_base = None if is_polymorphic and not is_polymorphic_base: diff --git a/cqlengine/tests/model/test_polymorphism.py b/cqlengine/tests/model/test_polymorphism.py index d608aae1b7..00078bcb50 100644 --- a/cqlengine/tests/model/test_polymorphism.py +++ b/cqlengine/tests/model/test_polymorphism.py @@ -62,7 +62,10 @@ class M1(Base): assert Base.column_family_name() == M1.column_family_name() def test_collection_columns_cant_be_polymorphic_keys(self): - pass + with self.assertRaises(models.ModelDefinitionException): + class Base(models.Model): + partition = columns.Integer(primary_key=True) + type1 = columns.Set(columns.Integer, polymorphic_key=True) class PolyBase(models.Model): From cdb88e4fa4897769011c0181ef48c0ef6cdd4621 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 3 Sep 2013 18:32:06 -0700 Subject: [PATCH 0383/4200] adding custom validation --- cqlengine/models.py | 6 ++++ cqlengine/tests/model/test_validation.py | 46 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/cqlengine/models.py b/cqlengine/models.py index d01208ea09..202234af5e 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -331,6 +331,12 @@ def validate(self): """ Cleans and validates the field values """ for name, col in self._columns.items(): val = col.validate(getattr(self, name)) + + # handle custom validation + validation_function = getattr(self, 'validate_{}'.format(name), None) + if validation_function: + val = validation_function(val) + setattr(self, name, val) def _as_dict(self): diff --git a/cqlengine/tests/model/test_validation.py b/cqlengine/tests/model/test_validation.py index e69de29bb2..9a59872731 100644 --- a/cqlengine/tests/model/test_validation.py +++ b/cqlengine/tests/model/test_validation.py @@ -0,0 +1,46 @@ +from uuid import uuid4 + +from cqlengine.tests.base import BaseCassEngTestCase +from cqlengine.management import sync_table +from cqlengine.management import drop_table +from cqlengine.models import Model +from cqlengine import columns +from cqlengine import ValidationError + + +class CustomValidationTestModel(Model): + id = columns.UUID(primary_key=True, default=lambda: uuid4()) + count = columns.Integer() + text = columns.Text(required=False) + a_bool = columns.Boolean(default=False) + + def validate_a_bool(self, val): + if val: + raise ValidationError('False only!') + return val + + def validate_text(self, val): + if val.lower() == 'jon': + raise ValidationError('no one likes jon') + return val + + +class TestCustomValidation(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestCustomValidation, cls).setUpClass() + sync_table(CustomValidationTestModel) + + @classmethod + def tearDownClass(cls): + super(TestCustomValidation, cls).tearDownClass() + drop_table(CustomValidationTestModel) + + def test_custom_validation(self): + # sanity check + CustomValidationTestModel.create(count=5, text='txt', a_bool=False) + + with self.assertRaises(ValidationError): + CustomValidationTestModel.create(count=5, text='txt', a_bool=True) + From 3b3d00051816f567348a57e1d11cf8e0db15dd97 Mon Sep 17 00:00:00 2001 From: Tommaso Barbugli Date: Wed, 4 Sep 2013 15:23:51 +0200 Subject: [PATCH 0384/4200] get cassandra host from the environment (CASSANDRA_TEST_HOST) or default to localhost --- cqlengine/tests/base.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cqlengine/tests/base.py b/cqlengine/tests/base.py index 126049a860..98e34eaa7e 100644 --- a/cqlengine/tests/base.py +++ b/cqlengine/tests/base.py @@ -1,13 +1,20 @@ from unittest import TestCase from cqlengine import connection +import os + + +if os.environ.get('CASSANDRA_TEST_HOST'): + CASSANDRA_TEST_HOST = os.environ['CASSANDRA_TEST_HOST'] +else: + CASSANDRA_TEST_HOST = 'localhost:9160' + class BaseCassEngTestCase(TestCase): @classmethod def setUpClass(cls): super(BaseCassEngTestCase, cls).setUpClass() - # todo fix - connection.setup(['localhost:9160'], default_keyspace='cqlengine_test') + connection.setup([CASSANDRA_TEST_HOST], default_keyspace='cqlengine_test') def assertHasAttr(self, obj, attr): self.assertTrue(hasattr(obj, attr), From fb6543c973b8e445a96c110780fdecd0d8ebf7e1 Mon Sep 17 00:00:00 2001 From: Tommaso Barbugli Date: Wed, 4 Sep 2013 15:56:09 +0200 Subject: [PATCH 0385/4200] add VarInt column --- cqlengine/columns.py | 20 ++++++++++++++++ cqlengine/tests/columns/test_validation.py | 28 +++++++++++++++++++++- docs/topics/columns.rst | 6 +++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 9940f1aa18..be3e193110 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -246,6 +246,26 @@ def to_database(self, value): return self.validate(value) +class VarInt(Column): + db_type = 'varint' + + def validate(self, value): + val = super(VarInt, self).validate(value) + if val is None: + return + try: + return long(val) + except (TypeError, ValueError): + raise ValidationError( + "{} can't be converted to integral value".format(value)) + + def to_python(self, value): + return self.validate(value) + + def to_database(self, value): + return self.validate(value) + + class CounterValueManager(BaseValueManager): def __init__(self, instance, column, value): super(CounterValueManager, self).__init__(instance, column, value) diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 53cd738c02..e2393d5b34 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -14,6 +14,7 @@ from cqlengine.columns import Ascii from cqlengine.columns import Text from cqlengine.columns import Integer +from cqlengine.columns import VarInt from cqlengine.columns import DateTime from cqlengine.columns import Date from cqlengine.columns import UUID @@ -24,6 +25,9 @@ from cqlengine.management import create_table, delete_table from cqlengine.models import Model +import sys + + class TestDatetime(BaseCassEngTestCase): class DatetimeTest(Model): test_id = Integer(primary_key=True) @@ -64,6 +68,28 @@ def test_datetime_date_support(self): assert dt2.created_at.isoformat() == datetime(today.year, today.month, today.day).isoformat() +class TestVarInt(BaseCassEngTestCase): + class VarIntTest(Model): + test_id = Integer(primary_key=True) + bignum = VarInt(primary_key=True) + + @classmethod + def setUpClass(cls): + super(TestVarInt, cls).setUpClass() + create_table(cls.VarIntTest) + + @classmethod + def tearDownClass(cls): + super(TestVarInt, cls).tearDownClass() + delete_table(cls.VarIntTest) + + def test_varint_io(self): + long_int = sys.maxint + 1 + int1 = self.VarIntTest.objects.create(test_id=0, bignum=long_int) + int2 = self.VarIntTest.objects(test_id=0).first() + assert int1.bignum == int2.bignum + + class TestDate(BaseCassEngTestCase): class DateTest(Model): test_id = Integer(primary_key=True) @@ -109,7 +135,7 @@ def tearDownClass(cls): super(TestDecimal, cls).tearDownClass() delete_table(cls.DecimalTest) - def test_datetime_io(self): + def test_decimal_io(self): dt = self.DecimalTest.objects.create(test_id=0, dec_val=D('0.00')) dt2 = self.DecimalTest.objects(test_id=0).first() assert dt2.dec_val == dt.dec_val diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index 5ee9a18686..0418147f18 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -42,6 +42,12 @@ Columns columns.Integer() +.. class:: VarInt() + + Stores an arbitrary-precision integer :: + + columns.VarInt() + .. class:: DateTime() Stores a datetime value. From b35f915044fd697f2ad9a1f9acbd4aec520b0d66 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 4 Sep 2013 11:19:06 -0700 Subject: [PATCH 0386/4200] reverting custom validation changes --- cqlengine/models.py | 6 ---- cqlengine/tests/model/test_validation.py | 45 ------------------------ 2 files changed, 51 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 202234af5e..d01208ea09 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -331,12 +331,6 @@ def validate(self): """ Cleans and validates the field values """ for name, col in self._columns.items(): val = col.validate(getattr(self, name)) - - # handle custom validation - validation_function = getattr(self, 'validate_{}'.format(name), None) - if validation_function: - val = validation_function(val) - setattr(self, name, val) def _as_dict(self): diff --git a/cqlengine/tests/model/test_validation.py b/cqlengine/tests/model/test_validation.py index 9a59872731..8b13789179 100644 --- a/cqlengine/tests/model/test_validation.py +++ b/cqlengine/tests/model/test_validation.py @@ -1,46 +1 @@ -from uuid import uuid4 - -from cqlengine.tests.base import BaseCassEngTestCase -from cqlengine.management import sync_table -from cqlengine.management import drop_table -from cqlengine.models import Model -from cqlengine import columns -from cqlengine import ValidationError - - -class CustomValidationTestModel(Model): - id = columns.UUID(primary_key=True, default=lambda: uuid4()) - count = columns.Integer() - text = columns.Text(required=False) - a_bool = columns.Boolean(default=False) - - def validate_a_bool(self, val): - if val: - raise ValidationError('False only!') - return val - - def validate_text(self, val): - if val.lower() == 'jon': - raise ValidationError('no one likes jon') - return val - - -class TestCustomValidation(BaseCassEngTestCase): - - @classmethod - def setUpClass(cls): - super(TestCustomValidation, cls).setUpClass() - sync_table(CustomValidationTestModel) - - @classmethod - def tearDownClass(cls): - super(TestCustomValidation, cls).tearDownClass() - drop_table(CustomValidationTestModel) - - def test_custom_validation(self): - # sanity check - CustomValidationTestModel.create(count=5, text='txt', a_bool=False) - - with self.assertRaises(ValidationError): - CustomValidationTestModel.create(count=5, text='txt', a_bool=True) From 031ea5e8ad1540aed356f597dc72935c591f551b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 4 Sep 2013 14:05:34 -0700 Subject: [PATCH 0387/4200] adding docs for polymorphic models and user defined validation --- docs/topics/models.rst | 86 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 961a866be9..183fa27b12 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -154,6 +154,92 @@ Model Attributes *Optional.* Sets the name of the keyspace used by this model. Defaulst to cqlengine +Table Polymorphism +================== + + As of cqlengine 0.8, it is possible to save and load different model classes using a single CQL table. + This is useful in situations where you have different object types that you want to store in a single cassandra row. + + For instance, suppose you want a table that stores rows of pets owned by an owner: + + .. code-block:: python + + class Pet(Model): + __table_name__ = 'pet' + owner_id = UUID(primary_key=True) + pet_id = UUID(primary_key=True) + pet_type = Text(polymorphic_key=True) + name = Text() + + def eat(self, food): + pass + + def sleep(self, time): + pass + + class Cat(Pet): + __polymorphic_key__ = 'cat' + cuteness = Float() + + def tear_up_couch(self): + pass + + class Dog(Pet): + __polymorphic_key__ = 'dog' + fierceness = Float() + + def bark_all_night(self): + pass + + After calling ``sync_table`` on each of these tables, the columns defined in each model will be added to the + ``pet`` table. Additionally, saving ``Cat`` and ``Dog`` models will save the meta data needed to identify each row + as either a cat or dog. + + To setup a polymorphic model structure, follow these steps + + 1. Create a base model with a column set as the polymorphic_key (set ``polymorphic_key=True`` in the column definition) + 2. Create subclass models, and define a unique ``__polymorphic_key__`` value on each + 3. Run ``sync_table`` on each of the sub tables + + **About the polymorphic key** + + The polymorphic key is what cqlengine uses under the covers to map logical cql rows to the appropriate model type. The + base model maintains a map of polymorphic keys to subclasses. When a polymorphic model is saved, this value is automatically + saved into the polymorphic key column. You can set the polymorphic key column to any column type that you like, with + the exception of container and counter columns, although ``Integer`` columns make the most sense. Additionally, if you + set ``index=True`` on your polymorphic key column, you can execute queries against polymorphic subclasses, and a + ``WHERE`` clause will be automatically added to your query, returning only rows of that type. Note that you must + define a unique ``__polymorphic_key__`` value to each subclass, and that you can only assign a single polymorphic + key column per model + + +Extending Model Validation +========================== + + Each time you save a model instance in cqlengine, the data in the model is validated against the schema you've defined + for your model. Most of the validation is fairly straightforward, it basically checks that you're not trying to do + something like save text into an integer column, and it enforces the ``required`` flag set on column definitions. + It also performs any transformations needed to save the data properly. + + However, there are often additional constraints or transformations you want to impose on your data, beyond simply + making sure that Cassandra won't complain when you try to insert it. To define additional validation on a model, + extend the model's validation method: + + .. code-block:: python + + class Member(Model): + person_id = UUID(primary_key=True) + name = Text(required=True) + + def validate(self): + super(Member, self).validate() + if self.name == 'jon': + raise ValidationError('no jon\'s allowed') + + *Note*: while not required, the convention is to raise a ``ValidationError`` (``from cqlengine import ValidationError``) + if validation fails + + Compaction Options ==================== From 712814432ab013d753e7e68f534e71a8886e6386 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 4 Sep 2013 17:14:26 -0700 Subject: [PATCH 0388/4200] fixing typo in docs --- docs/topics/models.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 183fa27b12..06b720d4de 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -151,7 +151,7 @@ Model Attributes .. attribute:: Model.__keyspace__ - *Optional.* Sets the name of the keyspace used by this model. Defaulst to cqlengine + *Optional.* Sets the name of the keyspace used by this model. Defaults to cqlengine Table Polymorphism From 95246fc13421ab01758a13094878ed3339436346 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 4 Sep 2013 17:18:08 -0700 Subject: [PATCH 0389/4200] updating the doc index with download links --- docs/index.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index b97360ebe6..e4e8c394fd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,7 +13,15 @@ cqlengine is a Cassandra CQL 3 Object Mapper for Python :ref:`getting-started` +Download +======== + +`Github `_ + +`PyPi `_ + Contents: +========= .. toctree:: :maxdepth: 2 From 41b9284c7129fad9f8d28c3f7cfbee2e01330f98 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 4 Sep 2013 21:04:34 -0700 Subject: [PATCH 0390/4200] savage version bump --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 39e898a4f9..aec258df73 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.7.1 +0.8 From e72cde5f0e7de393e2b9494062245e0eaf1f7392 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 23 Sep 2013 17:53:26 -0700 Subject: [PATCH 0391/4200] validation around not throwing an exception when updating a keyspace --- cqlengine/models.py | 7 +++---- cqlengine/tests/columns/test_validation.py | 13 ++++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index d01208ea09..2969c228aa 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -184,10 +184,6 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass def __init__(self, **values): self._values = {} - extra_columns = set(values.keys()) - set(self._columns.keys()) - if extra_columns: - raise ValidationError("Incorrect columns passed: {}".format(extra_columns)) - for name, column in self._columns.items(): value = values.get(name, None) if value is not None or isinstance(column, columns.BaseContainerColumn): @@ -342,6 +338,9 @@ def _as_dict(self): @classmethod def create(cls, **kwargs): + extra_columns = set(kwargs.keys()) - set(cls._columns.keys()) + if extra_columns: + raise ValidationError("Incorrect columns passed: {}".format(extra_columns)) return cls.objects.create(**kwargs) @classmethod diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index e2393d5b34..6362375ed4 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -6,6 +6,7 @@ from unittest import TestCase from uuid import uuid4, uuid1 from cqlengine import ValidationError +from cqlengine.connection import execute from cqlengine.tests.base import BaseCassEngTestCase @@ -22,7 +23,7 @@ from cqlengine.columns import Float from cqlengine.columns import Decimal -from cqlengine.management import create_table, delete_table +from cqlengine.management import create_table, delete_table, sync_table from cqlengine.models import Model import sys @@ -234,6 +235,16 @@ def test_extra_field(self): with self.assertRaises(ValidationError): self.TestModel.create(bacon=5000) +class TestPythonDoesntDieWhenExtraFieldIsInCassandra(BaseCassEngTestCase): + class TestModel(Model): + __table_name__ = 'alter_doesnt_break_running_app' + id = UUID(primary_key=True, default=uuid4) + + def test_extra_field(self): + sync_table(self.TestModel) + self.TestModel.create() + execute("ALTER TABLE {} add blah int".format(self.TestModel.column_family_name(include_keyspace=True))) + self.TestModel.objects().all() class TestTimeUUIDFromDatetime(TestCase): def test_conversion_specific_date(self): From 3bd1ee58551d4faf21a69a7a82b56f41c94c9c9a Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 23 Sep 2013 17:57:39 -0700 Subject: [PATCH 0392/4200] removed python 2.6, fixed issue with model creation --- cqlengine/VERSION | 2 +- cqlengine/tests/columns/test_validation.py | 3 ++- setup.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 39e898a4f9..100435be13 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.7.1 +0.8.2 diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 6362375ed4..3c6c665614 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -23,7 +23,7 @@ from cqlengine.columns import Float from cqlengine.columns import Decimal -from cqlengine.management import create_table, delete_table, sync_table +from cqlengine.management import create_table, delete_table, sync_table, drop_table from cqlengine.models import Model import sys @@ -241,6 +241,7 @@ class TestModel(Model): id = UUID(primary_key=True, default=uuid4) def test_extra_field(self): + drop_table(self.TestModel) sync_table(self.TestModel) self.TestModel.create() execute("ALTER TABLE {} add blah int".format(self.TestModel.column_family_name(include_keyspace=True))) diff --git a/setup.py b/setup.py index 944d41191f..3bb09c9c2c 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,6 @@ "Environment :: Plugins", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", - "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules", From 96d4b45effcdf6cda5bfe7778f1347a384b5a260 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 23 Sep 2013 17:57:53 -0700 Subject: [PATCH 0393/4200] version bump --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 100435be13..6f4eebdf6f 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.8.2 +0.8.1 From 5c16b58a48c493322d515a98b774efb128a50320 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 24 Sep 2013 13:30:56 -0700 Subject: [PATCH 0394/4200] allow attempt to requery on connection failure --- cqlengine/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index fabf197a31..dc0d73a5a4 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -168,7 +168,7 @@ def execute(self, query, params): except cql.ProgrammingError as ex: raise CQLEngineException(unicode(ex)) except TTransportException: - raise CQLEngineException("Could not execute query against the cluster") + pass def execute(query, params=None): From 8c9487864f43df9d54b95cb22cf1ee8905271467 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 24 Sep 2013 13:32:53 -0700 Subject: [PATCH 0395/4200] version bumb --- changelog | 10 ++++++++++ cqlengine/VERSION | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 8dd2fe8608..75814f1386 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,15 @@ CHANGELOG +0.8.2 +* fix for connection failover + +0.8.1 +* fix for models not exactly matching schema + +0.8.0 +* support for table polymorphism +* var int type + 0.7.1 * refactoring query class to make defining custom model instantiation logic easier diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 6f4eebdf6f..100435be13 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.8.1 +0.8.2 From 639fef10cf659c573892aa00a022dfe7edb5d3f6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 25 Sep 2013 13:35:26 -0700 Subject: [PATCH 0396/4200] fixing documentation inconsistency --- docs/topics/columns.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index 0418147f18..6d04df6452 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -197,7 +197,7 @@ Column Options .. attribute:: BaseColumn.required - If True, this model cannot be saved without a value defined for this column. Defaults to True. Primary key fields cannot have their required fields set to False. + If True, this model cannot be saved without a value defined for this column. Defaults to False. Primary key fields cannot have their required fields set to False. .. attribute:: BaseColumn.clustering_order From 523d99364a47e1a396e99dc3ed856ec0ec4d0669 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 27 Sep 2013 13:35:19 -0700 Subject: [PATCH 0397/4200] better logging on operational errors --- cqlengine/connection.py | 6 ++++++ .../tests/connections/test_connection_pool.py | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index dc0d73a5a4..0092e1f538 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -12,6 +12,8 @@ from copy import copy from cqlengine.exceptions import CQLEngineException +from cql import OperationalError + from contextlib import contextmanager from thrift.transport.TTransport import TTransportException @@ -28,6 +30,7 @@ class CQLConnectionError(CQLEngineException): pass connection_pool = None + class CQLConnectionError(CQLEngineException): pass @@ -169,6 +172,9 @@ def execute(self, query, params): raise CQLEngineException(unicode(ex)) except TTransportException: pass + except OperationalError as ex: + LOG.exception("Operational Error %s on %s:%s", ex, con.host, con.port) + raise ex def execute(query, params=None): diff --git a/cqlengine/tests/connections/test_connection_pool.py b/cqlengine/tests/connections/test_connection_pool.py index e69de29bb2..29b3ea50a9 100644 --- a/cqlengine/tests/connections/test_connection_pool.py +++ b/cqlengine/tests/connections/test_connection_pool.py @@ -0,0 +1,21 @@ +from unittest import TestCase +from cql import OperationalError +from mock import MagicMock, patch, Mock + +from cqlengine.connection import ConnectionPool, Host + + +class OperationalErrorLoggingTest(TestCase): + def test_logging(self): + p = ConnectionPool([Host('127.0.0.1', '9160')]) + + class MockConnection(object): + host = 'localhost' + port = 6379 + def cursor(self): + raise OperationalError('test') + + + with patch.object(p, 'get', return_value=MockConnection()): + with self.assertRaises(OperationalError): + p.execute("select * from system.peers", {}) From f6e37e96ce90a35a7a38124c7a451bab3c4b3c9b Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 27 Sep 2013 13:36:00 -0700 Subject: [PATCH 0398/4200] version bump --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 100435be13..ee94dd834b 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.8.2 +0.8.3 From b8f9b336e8fba3e1404f59aa5d9c784f151db75a Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 27 Sep 2013 13:36:19 -0700 Subject: [PATCH 0399/4200] updated changelog --- changelog | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog b/changelog index 75814f1386..9c8be6538e 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.8.3 +* better logging for operational errors + 0.8.2 * fix for connection failover From 5003e608c6593e98b82479ae9db876fa0ef0eccc Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 2 Oct 2013 13:40:36 -0700 Subject: [PATCH 0400/4200] updating value manager to use deepcopy instead of copy --- changelog | 3 +++ cqlengine/VERSION | 2 +- cqlengine/columns.py | 8 +++++--- cqlengine/tests/columns/test_container_columns.py | 2 -- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/changelog b/changelog index 9c8be6538e..ac3d1a6486 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.8.4 +* changing value manager previous value copying to deepcopy + 0.8.3 * better logging for operational errors diff --git a/cqlengine/VERSION b/cqlengine/VERSION index ee94dd834b..b60d71966a 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.8.3 +0.8.4 diff --git a/cqlengine/columns.py b/cqlengine/columns.py index e40ddc28c0..10a5e1b956 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -1,5 +1,5 @@ #column field types -from copy import copy +from copy import deepcopy from datetime import datetime from datetime import date import re @@ -8,12 +8,13 @@ from cqlengine.exceptions import ValidationError + class BaseValueManager(object): def __init__(self, instance, column, value): self.instance = instance self.column = column - self.previous_value = copy(value) + self.previous_value = deepcopy(value) self.value = value @property @@ -31,7 +32,7 @@ def changed(self): return self.value != self.previous_value def reset_previous_value(self): - self.previous_value = copy(self.value) + self.previous_value = deepcopy(self.value) def getval(self): return self.value @@ -52,6 +53,7 @@ def get_property(self): else: return property(_get, _set) + class ValueQuoter(object): """ contains a single value, which will quote itself for CQL insertion statements diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index cd11fb5abb..ba4df808c8 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -145,8 +145,6 @@ def test_instantiation_with_column_instance(self): column = columns.Set(columns.Text(min_length=100)) assert isinstance(column.value_col, columns.Text) - - def test_to_python(self): """ Tests that to_python of value column is called """ column = columns.Set(JsonTestColumn) From 10e4d4290405083979ff7d1ce272ed97efb6f759 Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Thu, 3 Oct 2013 16:22:34 -0700 Subject: [PATCH 0401/4200] Transport Creation --- cqlengine/connection.py | 57 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 0092e1f538..f6e25e4e76 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -44,11 +44,32 @@ def _column_tuple_factory(colnames, values): return tuple(colnames), [RowResult(v) for v in values] -def setup(hosts, username=None, password=None, max_connections=10, default_keyspace=None, consistency='ONE'): +def setup( + hosts, + username=None, + password=None, + max_connections=10, + default_keyspace=None, + consistency='ONE', + timeout=None): """ Records the hosts and connects to one of them :param hosts: list of hosts, strings in the :, or just + :type hosts: list + :param username: The cassandra username + :type username: str + :param password: The cassandra password + :type password: str + :param max_connections: The maximum number of connections to service + :type max_connections: int or long + :param default_keyspace: The default keyspace to use + :type default_keyspace: str + :param consistency: The global consistency level + :type consistency: str + :param timeout: The connection timeout in milliseconds + :type timeout: int or long + """ global _max_connections global connection_pool @@ -72,17 +93,24 @@ def setup(hosts, username=None, password=None, max_connections=10, default_keysp if not _hosts: raise CQLConnectionError("At least one host required") - connection_pool = ConnectionPool(_hosts, username, password, consistency) + connection_pool = ConnectionPool(_hosts, username, password, consistency, timeout) class ConnectionPool(object): """Handles pooling of database connections.""" - def __init__(self, hosts, username=None, password=None, consistency=None): + def __init__( + self, + hosts, + username=None, + password=None, + consistency=None, + timeout=None): self._hosts = hosts self._username = username self._password = password self._consistency = consistency + self._timeout = timeout self._queue = Queue.Queue(maxsize=_max_connections) @@ -124,6 +152,25 @@ def put(self, conn): else: self._queue.put(conn) + def _create_transport(self, host): + """ + Create a new Thrift transport for the given host. + + :param host: The host object + :type host: Host + + :rtype: thrift.TTransport.* + + """ + from thrift.transport import TSocket, TTransport + + thrift_socket = TSocket.TSocket(host.name, host.port) + + if self._timeout is not None: + thrift_socket.setTimeout(self._timeout) + + return TTransport.TFramedTransport(thrift_socket) + def _create_connection(self): """ Creates a new connection for the connection pool. @@ -138,12 +185,14 @@ def _create_connection(self): for host in hosts: try: + transport = self._create_transport(host) new_conn = cql.connect( host.name, host.port, user=self._username, password=self._password, - consistency_level=self._consistency + consistency_level=self._consistency, + transport=transport ) new_conn.set_cql_version('3.0.0') return new_conn From f4bf649709f8daefc153e91d9ffba31e4f0f4777 Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Thu, 3 Oct 2013 16:29:31 -0700 Subject: [PATCH 0402/4200] Update documentation --- cqlengine/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index f6e25e4e76..ecd3edd82b 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -67,7 +67,7 @@ def setup( :type default_keyspace: str :param consistency: The global consistency level :type consistency: str - :param timeout: The connection timeout in milliseconds + :param timeout: The connection timeout in seconds :type timeout: int or long """ From f23d310767a6c506574cc124b9cbfc5312c6887d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 3 Oct 2013 16:46:23 -0700 Subject: [PATCH 0403/4200] updating version number and docs --- changelog | 3 +++ cqlengine/VERSION | 2 +- docs/topics/connection.rst | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index ac3d1a6486..21d31aa7c8 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,8 @@ CHANGELOG +0.8.5 +* adding support for timeouts + 0.8.4 * changing value manager previous value copying to deepcopy diff --git a/cqlengine/VERSION b/cqlengine/VERSION index b60d71966a..7ada0d303f 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.8.4 +0.8.5 diff --git a/docs/topics/connection.rst b/docs/topics/connection.rst index 88f601fbd0..c956b58fda 100644 --- a/docs/topics/connection.rst +++ b/docs/topics/connection.rst @@ -25,6 +25,9 @@ If there is a problem with one of the servers, cqlengine will try to connect to :param consistency: the consistency level of the connection, defaults to 'ONE' :type consistency: str + :param timeout: the connection timeout in seconds + :type timeout: int or long + Records the hosts and connects to one of them See the example at :ref:`getting-started` From d568185c3aa7ad4593d37f638a24cf1759ede6bd Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 3 Oct 2013 16:49:38 -0700 Subject: [PATCH 0404/4200] updating timeout docs --- cqlengine/connection.py | 2 +- docs/topics/connection.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index ecd3edd82b..f6e25e4e76 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -67,7 +67,7 @@ def setup( :type default_keyspace: str :param consistency: The global consistency level :type consistency: str - :param timeout: The connection timeout in seconds + :param timeout: The connection timeout in milliseconds :type timeout: int or long """ diff --git a/docs/topics/connection.rst b/docs/topics/connection.rst index c956b58fda..6140584704 100644 --- a/docs/topics/connection.rst +++ b/docs/topics/connection.rst @@ -25,7 +25,7 @@ If there is a problem with one of the servers, cqlengine will try to connect to :param consistency: the consistency level of the connection, defaults to 'ONE' :type consistency: str - :param timeout: the connection timeout in seconds + :param timeout: the connection timeout in milliseconds :type timeout: int or long Records the hosts and connects to one of them From 2b8424c74895c6a8dbd3d7f2cc789dd1027d6e89 Mon Sep 17 00:00:00 2001 From: Pandu Rao Date: Thu, 3 Oct 2013 18:34:32 -0700 Subject: [PATCH 0405/4200] Added guards to handle None value in Date and DateTime column to_python conversion --- cqlengine/columns.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 10a5e1b956..a361eb0d43 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -313,6 +313,7 @@ class DateTime(Column): db_type = 'timestamp' def to_python(self, value): + if value is None: return if isinstance(value, datetime): return value elif isinstance(value, date): @@ -340,6 +341,7 @@ class Date(Column): def to_python(self, value): + if value is None: return if isinstance(value, datetime): return value.date() elif isinstance(value, date): From 2a7a11dde4484bf4f0a77152e3ad1615f1235eaf Mon Sep 17 00:00:00 2001 From: Pandu Rao Date: Wed, 16 Oct 2013 18:30:47 -0700 Subject: [PATCH 0406/4200] Added guard to handle None value in Date column to_database conversion --- cqlengine/columns.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index a361eb0d43..d82dd636fc 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -351,6 +351,7 @@ def to_python(self, value): def to_database(self, value): value = super(Date, self).to_database(value) + if value is None: return if isinstance(value, datetime): value = value.date() if not isinstance(value, date): From c9358733f60353f9ef583a166875ccd608792119 Mon Sep 17 00:00:00 2001 From: Pandu Rao Date: Wed, 16 Oct 2013 18:32:05 -0700 Subject: [PATCH 0407/4200] Add testcases for guards for None in date, datetime conversion --- cqlengine/tests/columns/test_validation.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 3c6c665614..158bda4a52 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -68,6 +68,11 @@ def test_datetime_date_support(self): dt2 = self.DatetimeTest.objects(test_id=0).first() assert dt2.created_at.isoformat() == datetime(today.year, today.month, today.day).isoformat() + def test_datetime_none(self): + dt = self.DatetimeTest.objects.create(test_id=0, created_at=None) + dt2 = self.DatetimeTest.objects(test_id=0).first() + assert dt2.created_at is None + class TestVarInt(BaseCassEngTestCase): class VarIntTest(Model): @@ -120,6 +125,11 @@ def test_date_io_using_datetime(self): assert isinstance(dt2.created_at, date) assert dt2.created_at.isoformat() == now.date().isoformat() + def test_date_none(self): + dt = self.DateTest.objects.create(test_id=0, created_at=None) + dt2 = self.DateTest.objects(test_id=0).first() + assert dt2.created_at is None + class TestDecimal(BaseCassEngTestCase): class DecimalTest(Model): From b52e9a9b34411b67d264d4c0a6d4d2c3f2ef76d9 Mon Sep 17 00:00:00 2001 From: Michael Cyrulnik Date: Thu, 17 Oct 2013 11:17:22 -0400 Subject: [PATCH 0408/4200] add BigInt column --- cqlengine/columns.py | 4 ++++ cqlengine/tests/columns/test_validation.py | 11 +++++++++++ cqlengine/tests/columns/test_value_io.py | 6 ++++++ docs/topics/columns.rst | 8 +++++++- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 10a5e1b956..8781e62fc8 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -252,6 +252,10 @@ def to_database(self, value): return self.validate(value) +class BigInt(Integer): + db_type = 'bigint' + + class VarInt(Column): db_type = 'varint' diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 3c6c665614..b89e4a9172 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -15,6 +15,7 @@ from cqlengine.columns import Ascii from cqlengine.columns import Text from cqlengine.columns import Integer +from cqlengine.columns import BigInt from cqlengine.columns import VarInt from cqlengine.columns import DateTime from cqlengine.columns import Date @@ -180,6 +181,16 @@ def test_default_zero_fields_validate(self): it = self.IntegerTest() it.validate() +class TestBigInt(BaseCassEngTestCase): + class BigIntTest(Model): + test_id = UUID(primary_key=True, default=lambda:uuid4()) + value = BigInt(default=0, required=True) + + def test_default_zero_fields_validate(self): + """ Tests that bigint columns with a default value of 0 validate """ + it = self.BigIntTest() + it.validate() + class TestText(BaseCassEngTestCase): def test_min_length(self): diff --git a/cqlengine/tests/columns/test_value_io.py b/cqlengine/tests/columns/test_value_io.py index 1c822a9e5d..54d8f5d5c5 100644 --- a/cqlengine/tests/columns/test_value_io.py +++ b/cqlengine/tests/columns/test_value_io.py @@ -95,6 +95,12 @@ class TestInteger(BaseColumnIOTest): pkey_val = 5 data_val = 6 +class TestBigInt(BaseColumnIOTest): + + column = columns.BigInt + pkey_val = 6 + data_val = pow(2, 63) - 1 + class TestDateTime(BaseColumnIOTest): column = columns.DateTime diff --git a/docs/topics/columns.rst b/docs/topics/columns.rst index 6d04df6452..0f71e3c7ad 100644 --- a/docs/topics/columns.rst +++ b/docs/topics/columns.rst @@ -38,10 +38,16 @@ Columns .. class:: Integer() - Stores an integer value :: + Stores a 32-bit signed integer value :: columns.Integer() +.. class:: BigInt() + + Stores a 64-bit signed long value :: + + columns.BigInt() + .. class:: VarInt() Stores an arbitrary-precision integer :: From 6abf31f2cc0a9437807e7a6f4b585aa8f3a4f425 Mon Sep 17 00:00:00 2001 From: Pandu Rao Date: Thu, 17 Oct 2013 12:22:33 -0700 Subject: [PATCH 0409/4200] Fixed bug in test case and expanded test case to include values_list --- cqlengine/tests/columns/test_validation.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 158bda4a52..1a72046b2a 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -69,10 +69,13 @@ def test_datetime_date_support(self): assert dt2.created_at.isoformat() == datetime(today.year, today.month, today.day).isoformat() def test_datetime_none(self): - dt = self.DatetimeTest.objects.create(test_id=0, created_at=None) - dt2 = self.DatetimeTest.objects(test_id=0).first() + dt = self.DatetimeTest.objects.create(test_id=1, created_at=None) + dt2 = self.DatetimeTest.objects(test_id=1).first() assert dt2.created_at is None + dts = self.DatetimeTest.objects.filter(test_id=1).values_list('created_at') + assert dts[0][0] is None + class TestVarInt(BaseCassEngTestCase): class VarIntTest(Model): @@ -126,10 +129,13 @@ def test_date_io_using_datetime(self): assert dt2.created_at.isoformat() == now.date().isoformat() def test_date_none(self): - dt = self.DateTest.objects.create(test_id=0, created_at=None) - dt2 = self.DateTest.objects(test_id=0).first() + self.DateTest.objects.create(test_id=1, created_at=None) + dt2 = self.DateTest.objects(test_id=1).first() assert dt2.created_at is None + dts = self.DateTest.objects(test_id=1).values_list('created_at') + assert dts[0][0] is None + class TestDecimal(BaseCassEngTestCase): class DecimalTest(Model): From 442872f6a861bcc0686c21bb813f04e23ada6fbb Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Fri, 18 Oct 2013 15:40:12 +0100 Subject: [PATCH 0410/4200] Allow use of timezone aware datetimes in {Max,Min}TimeUUID Convert the given timezone aware datetimes to UTC in a similar manner as the DateTime column type does instead of resulting in an error. --- cqlengine/columns.py | 15 ++----- cqlengine/functions.py | 12 +++-- cqlengine/tests/query/test_queryset.py | 62 ++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 10a5e1b956..dd7b3ba325 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -328,11 +328,9 @@ def to_database(self, value): else: raise ValidationError("'{}' is not a datetime object".format(value)) epoch = datetime(1970, 1, 1, tzinfo=value.tzinfo) - offset = 0 - if epoch.tzinfo: - offset_delta = epoch.tzinfo.utcoffset(epoch) - offset = offset_delta.days*24*3600 + offset_delta.seconds - return long(((value - epoch).total_seconds() - offset) * 1000) + offset = epoch.tzinfo.utcoffset(epoch).total_seconds() if epoch.tzinfo else 0 + + return long(((value - epoch).total_seconds() - offset) * 1000) class Date(Column): @@ -402,12 +400,7 @@ def from_datetime(self, dt): global _last_timestamp epoch = datetime(1970, 1, 1, tzinfo=dt.tzinfo) - - offset = 0 - if epoch.tzinfo: - offset_delta = epoch.tzinfo.utcoffset(epoch) - offset = offset_delta.days*24*3600 + offset_delta.seconds - + offset = epoch.tzinfo.utcoffset(epoch).total_seconds() if epoch.tzinfo else 0 timestamp = (dt - epoch).total_seconds() - offset node = None diff --git a/cqlengine/functions.py b/cqlengine/functions.py index eee1fcab5e..136618453c 100644 --- a/cqlengine/functions.py +++ b/cqlengine/functions.py @@ -54,8 +54,10 @@ def __init__(self, value): super(MinTimeUUID, self).__init__(value) def get_value(self): - epoch = datetime(1970, 1, 1) - return long((self.value - epoch).total_seconds() * 1000) + epoch = datetime(1970, 1, 1, tzinfo=self.value.tzinfo) + offset = epoch.tzinfo.utcoffset(epoch).total_seconds() if epoch.tzinfo else 0 + + return long(((self.value - epoch).total_seconds() - offset) * 1000) def get_dict(self, column): return {self.identifier: self.get_value()} @@ -79,8 +81,10 @@ def __init__(self, value): super(MaxTimeUUID, self).__init__(value) def get_value(self): - epoch = datetime(1970, 1, 1) - return long((self.value - epoch).total_seconds() * 1000) + epoch = datetime(1970, 1, 1, tzinfo=self.value.tzinfo) + offset = epoch.tzinfo.utcoffset(epoch).total_seconds() if epoch.tzinfo else 0 + + return long(((self.value - epoch).total_seconds() - offset) * 1000) def get_dict(self, column): return {self.identifier: self.get_value()} diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 117c87c18b..9004a28bf1 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -11,6 +11,25 @@ from cqlengine.models import Model from cqlengine import columns from cqlengine import query +from datetime import timedelta +from datetime import tzinfo + + +class TzOffset(tzinfo): + """Minimal implementation of a timezone offset to help testing with timezone + aware datetimes. + """ + def __init__(self, offset): + self._offset = timedelta(hours=offset) + + def utcoffset(self, dt): + return self._offset + + def tzname(self, dt): + return 'TzOffset: {}'.format(self._offset.hours) + + def dst(self, dt): + return timedelta(0) class TestModel(Model): test_id = columns.Integer(primary_key=True) @@ -515,6 +534,49 @@ def tearDownClass(cls): super(TestMinMaxTimeUUIDFunctions, cls).tearDownClass() delete_table(TimeUUIDQueryModel) + def test_tzaware_datetime_support(self): + """Test that using timezone aware datetime instances works with the + MinTimeUUID/MaxTimeUUID functions. + """ + pk = uuid4() + midpoint_utc = datetime.utcnow().replace(tzinfo=TzOffset(0)) + midpoint_helsinki = midpoint_utc.astimezone(TzOffset(3)) + + # Assert pre-condition that we have the same logical point in time + assert midpoint_utc.utctimetuple() == midpoint_helsinki.utctimetuple() + assert midpoint_utc.timetuple() != midpoint_helsinki.timetuple() + + TimeUUIDQueryModel.create( + partition=pk, + time=columns.TimeUUID.from_datetime(midpoint_utc - timedelta(minutes=1)), + data='1') + + TimeUUIDQueryModel.create( + partition=pk, + time=columns.TimeUUID.from_datetime(midpoint_utc), + data='2') + + TimeUUIDQueryModel.create( + partition=pk, + time=columns.TimeUUID.from_datetime(midpoint_utc + timedelta(minutes=1)), + data='3') + + assert ['1', '2'] == [o.data for o in TimeUUIDQueryModel.filter( + TimeUUIDQueryModel.partition == pk, + TimeUUIDQueryModel.time <= functions.MaxTimeUUID(midpoint_utc))] + + assert ['1', '2'] == [o.data for o in TimeUUIDQueryModel.filter( + TimeUUIDQueryModel.partition == pk, + TimeUUIDQueryModel.time <= functions.MaxTimeUUID(midpoint_helsinki))] + + assert ['2', '3'] == [o.data for o in TimeUUIDQueryModel.filter( + TimeUUIDQueryModel.partition == pk, + TimeUUIDQueryModel.time >= functions.MinTimeUUID(midpoint_utc))] + + assert ['2', '3'] == [o.data for o in TimeUUIDQueryModel.filter( + TimeUUIDQueryModel.partition == pk, + TimeUUIDQueryModel.time >= functions.MinTimeUUID(midpoint_helsinki))] + def test_success_case(self): """ Test that the min and max time uuid functions work as expected """ pk = uuid4() From 72959da7db8a1fd479fbe8d1bea406ca49f1c8f1 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 23 Oct 2013 16:49:58 -0700 Subject: [PATCH 0411/4200] fixed authors --- AUTHORS | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index b361bf0de4..a220cb26d6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,9 +1,10 @@ PRIMARY AUTHORS Blake Eggleston +Jon Haddad CONTRIBUTORS Eric Scrivner - test environment, connection pooling -Jon Haddad - helped hash out some of the architecture + From 284a69d7f27ade34074a98f582cf92a8088d7d9c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 11:55:15 -0700 Subject: [PATCH 0412/4200] adding stubbed ttl query tests --- cqlengine/tests/test_ttl.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 cqlengine/tests/test_ttl.py diff --git a/cqlengine/tests/test_ttl.py b/cqlengine/tests/test_ttl.py new file mode 100644 index 0000000000..2274cf34c5 --- /dev/null +++ b/cqlengine/tests/test_ttl.py @@ -0,0 +1,16 @@ +from cqlengine.tests.base import BaseCassEngTestCase + + +class TTLQueryTests(BaseCassEngTestCase): + + def test_update_queryset_ttl_success_case(self): + """ tests that ttls on querysets work as expected """ + + def test_select_ttl_failure(self): + """ tests that ttls on select queries raise an exception """ + + +class TTLModelTests(BaseCassEngTestCase): + + def test_model_ttl_success_case(self): + """ tests that ttls on models work as expected """ \ No newline at end of file From 7b34285ec296b298d674943899415c4a9dad3b11 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 11:56:20 -0700 Subject: [PATCH 0413/4200] adding batch and ttl to docs --- docs/topics/models.rst | 8 ++++++++ docs/topics/queryset.rst | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 06b720d4de..e63c5dc791 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -138,6 +138,14 @@ Model Methods Deletes the object from the database. + .. method:: batch(batch_object) + + Sets the batch object to run instance updates and inserts queries with. + + .. method:: ttl(batch_object) + + Sets the ttl values to run instance updates and inserts queries with. + Model Attributes ================ diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index cb1fdbb836..08c46a2796 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -353,3 +353,12 @@ QuerySet method reference .. method:: allow_filtering() Enables the (usually) unwise practive of querying on a clustering key without also defining a partition key + + .. method:: batch(batch_object) + + Sets the batch object to run the query on. Note that running a select query with a batch object will raise an exception + + .. method:: ttl(batch_object) + + Sets the ttl to run the query query with. Note that running a select query with a ttl value will raise an exception + From c62b7519fd613042c1a46041926c176bedd52f6b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 12:17:17 -0700 Subject: [PATCH 0414/4200] adding failing tests for empty container column saving --- .../tests/columns/test_container_columns.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index ba4df808c8..2e1d7cb844 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -154,6 +154,10 @@ def test_to_python(self): py_val = column.to_python(db_val.value) assert py_val == val + def test_default_empty_container_saving(self): + """ tests that the default empty container is not saved if it hasn't been updated """ + self.fail("implement") + class TestListModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) @@ -282,6 +286,11 @@ def test_to_python(self): py_val = column.to_python(db_val.value) assert py_val == val + def test_default_empty_container_saving(self): + """ tests that the default empty container is not saved if it hasn't been updated """ + self.fail("implement") + + class TestMapModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) int_map = columns.Map(columns.Integer, columns.UUID, required=False) @@ -309,8 +318,6 @@ def test_empty_retrieve(self): tmp2 = TestMapModel.get(partition=tmp.partition) tmp2.int_map['blah'] = 1 - - def test_io_success(self): """ Tests that a basic usage works as expected """ k1 = uuid4() @@ -370,7 +377,6 @@ def test_updates_from_none(self): m2 = TestMapModel.get(partition=m.partition) assert m2.int_map == expected - def test_updates_to_none(self): """ Tests that setting the field to None works as expected """ m = TestMapModel.create(int_map={1: uuid4()}) @@ -406,6 +412,10 @@ def test_to_python(self): py_val = column.to_python(db_val.value) assert py_val == val + def test_default_empty_container_saving(self): + """ tests that the default empty container is not saved if it hasn't been updated """ + self.fail("implement") + # def test_partial_update_creation(self): # """ # Tests that proper update statements are created for a partial list update From 52e29a57597d9b5bcb1f0ccdd8de3ad0fd589308 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 12:17:52 -0700 Subject: [PATCH 0415/4200] adding update method to model docs --- docs/topics/models.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 06b720d4de..e665e6c5ca 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -138,6 +138,13 @@ Model Methods Deletes the object from the database. + -- method:: update(**values) + + Performs an update on the model instance. You can pass in values to set on the model + for updating, or you can call without values to execute an update against any modified + fields. If no fields on the model have been modified since loading, no query will be + performed. Model validation is performed normally. + Model Attributes ================ From a57d3730f3ea5180a130c56141f308a28c919492 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 13:08:52 -0700 Subject: [PATCH 0416/4200] separating inserts, updates, column deletes and query value logic --- cqlengine/query.py | 197 +++++++++++++++++++++++++++++---------------- 1 file changed, 129 insertions(+), 68 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 02a5d7d577..00e9db0c83 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -22,6 +22,7 @@ class MultipleObjectsReturned(QueryException): pass class QueryOperatorException(QueryException): pass + class QueryOperator(object): # The symbol that identifies this operator in filter kwargs # ie: colname__ @@ -116,10 +117,12 @@ def __ne__(self, op): def __hash__(self): return hash(self.column.db_field_name) ^ hash(self.value) + class EqualsOperator(QueryOperator): symbol = 'EQ' cql_symbol = '=' + class IterableQueryValue(QueryValue): def __init__(self, value): try: @@ -133,28 +136,34 @@ def get_dict(self, column): def get_cql(self): return '({})'.format(', '.join(':{}'.format(i) for i in self.identifier)) + class InOperator(EqualsOperator): symbol = 'IN' cql_symbol = 'IN' QUERY_VALUE_WRAPPER = IterableQueryValue + class GreaterThanOperator(QueryOperator): symbol = "GT" cql_symbol = '>' + class GreaterThanOrEqualOperator(QueryOperator): symbol = "GTE" cql_symbol = '>=' + class LessThanOperator(QueryOperator): symbol = "LT" cql_symbol = '<' + class LessThanOrEqualOperator(QueryOperator): symbol = "LTE" cql_symbol = '<=' + class AbstractQueryableColumn(object): """ exposes cql query operators through pythons @@ -192,6 +201,7 @@ class BatchType(object): Unlogged = 'UNLOGGED' Counter = 'COUNTER' + class BatchQuery(object): """ Handles the batching of queries @@ -624,12 +634,20 @@ def delete(self, columns=[]): else: execute(qs, self._where_values()) + def update(self, **values): + """ + updates the contents of the query + """ + qs = ['UPDATE {}'.format(self.column_family_name)] + qs += ['SET'] + def __eq__(self, q): return set(self._where) == set(q._where) def __ne__(self, q): return not (self != q) + class ResultObject(dict): """ adds attribute access to a dictionary @@ -641,6 +659,7 @@ def __getattr__(self, item): except KeyError: raise AttributeError + class SimpleQuerySet(AbstractQuerySet): """ @@ -658,6 +677,7 @@ def _construct_instance(values): return ResultObject(zip(names, values)) return _construct_instance + class ModelQuerySet(AbstractQuerySet): """ @@ -763,10 +783,48 @@ def batch(self, batch_obj): self._batch = batch_obj return self - def save(self): + def _delete_null_columns(self): """ - Creates / updates a row. - This is a blind insert call. + executes a delete query to remove null columns + """ + values, field_names, field_ids, field_values, query_values = self._get_query_values() + + # delete nulled columns and removed map keys + qs = ['DELETE'] + query_values = {} + + del_statements = [] + for k,v in self.instance._values.items(): + col = v.column + if v.deleted: + del_statements += ['"{}"'.format(col.db_field_name)] + elif isinstance(col, Map): + del_statements += col.get_delete_statement(v.value, v.previous_value, query_values) + + if del_statements: + qs += [', '.join(del_statements)] + + qs += ['FROM {}'.format(self.column_family_name)] + + qs += ['WHERE'] + where_statements = [] + for name, col in self.model._primary_keys.items(): + field_id = uuid4().hex + query_values[field_id] = field_values[name] + where_statements += ['"{}" = :{}'.format(col.db_field_name, field_id)] + qs += [' AND '.join(where_statements)] + + qs = ' '.join(qs) + + if self._batch: + self._batch.add_query(qs, query_values) + else: + execute(qs, query_values) + + def update(self): + """ + updates a row. + This is a blind update call. All validation and cleaning needs to happen prior to calling this. """ @@ -774,6 +832,57 @@ def save(self): raise CQLEngineException("DML Query intance attribute is None") assert type(self.instance) == self.model + values, field_names, field_ids, field_values, query_values = self._get_query_values() + + qs = [] + qs += ["UPDATE {}".format(self.column_family_name)] + qs += ["SET"] + + set_statements = [] + #get defined fields and their column names + for name, col in self.model._columns.items(): + if not col.is_primary_key: + val = values.get(name) + if val is None: + continue + if isinstance(col, (BaseContainerColumn, Counter)): + #remove value from query values, the column will handle it + query_values.pop(field_ids.get(name), None) + + val_mgr = self.instance._values[name] + set_statements += col.get_update_statement(val, val_mgr.previous_value, query_values) + + else: + set_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] + qs += [', '.join(set_statements)] + + qs += ['WHERE'] + + where_statements = [] + for name, col in self.model._primary_keys.items(): + where_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] + + qs += [' AND '.join(where_statements)] + + # clear the qs if there are no set statements and this is not a counter model + if not set_statements and not self.instance._has_counter: + qs = [] + + qs = ' '.join(qs) + # skip query execution if it's empty + # caused by pointless update queries + if qs: + if self._batch: + self._batch.add_query(qs, query_values) + else: + execute(qs, query_values) + + self._delete_null_columns() + + def _get_query_values(self): + """ + returns all the data needed to do queries + """ #organize data value_pairs = [] values = self.instance._as_dict() @@ -789,42 +898,24 @@ def save(self): field_ids = {n:uuid4().hex for n in field_names} field_values = dict(value_pairs) query_values = {field_ids[n]:field_values[n] for n in field_names} + return values, field_names, field_ids, field_values, query_values - qs = [] - if self.instance._has_counter or self.instance._can_update(): - qs += ["UPDATE {}".format(self.column_family_name)] - qs += ["SET"] - - set_statements = [] - #get defined fields and their column names - for name, col in self.model._columns.items(): - if not col.is_primary_key: - val = values.get(name) - if val is None: - continue - if isinstance(col, (BaseContainerColumn, Counter)): - #remove value from query values, the column will handle it - query_values.pop(field_ids.get(name), None) - - val_mgr = self.instance._values[name] - set_statements += col.get_update_statement(val, val_mgr.previous_value, query_values) - - else: - set_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] - qs += [', '.join(set_statements)] - - qs += ['WHERE'] - - where_statements = [] - for name, col in self.model._primary_keys.items(): - where_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] - - qs += [' AND '.join(where_statements)] + def save(self): + """ + Creates / updates a row. + This is a blind insert call. + All validation and cleaning needs to happen + prior to calling this. + """ + if self.instance is None: + raise CQLEngineException("DML Query intance attribute is None") + assert type(self.instance) == self.model - # clear the qs if there are no set statements and this is not a counter model - if not set_statements and not self.instance._has_counter: - qs = [] + values, field_names, field_ids, field_values, query_values = self._get_query_values() + qs = [] + if self.instance._has_counter or self.instance._can_update(): + return self.update() else: qs += ["INSERT INTO {}".format(self.column_family_name)] qs += ["({})".format(', '.join(['"{}"'.format(f) for f in field_names]))] @@ -841,38 +932,8 @@ def save(self): else: execute(qs, query_values) - - # delete nulled columns and removed map keys - qs = ['DELETE'] - query_values = {} - - del_statements = [] - for k,v in self.instance._values.items(): - col = v.column - if v.deleted: - del_statements += ['"{}"'.format(col.db_field_name)] - elif isinstance(col, Map): - del_statements += col.get_delete_statement(v.value, v.previous_value, query_values) - - if del_statements: - qs += [', '.join(del_statements)] - - qs += ['FROM {}'.format(self.column_family_name)] - - qs += ['WHERE'] - where_statements = [] - for name, col in self.model._primary_keys.items(): - field_id = uuid4().hex - query_values[field_id] = field_values[name] - where_statements += ['"{}" = :{}'.format(col.db_field_name, field_id)] - qs += [' AND '.join(where_statements)] - - qs = ' '.join(qs) - - if self._batch: - self._batch.add_query(qs, query_values) - else: - execute(qs, query_values) + # delete any nulled columns + self._delete_null_columns() def delete(self): """ Deletes one instance """ From dcd5a5a1acd80d1f21e9a6c0b8b89bb2e3138993 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 13:17:50 -0700 Subject: [PATCH 0417/4200] ttls are working off the Class and instance --- cqlengine/models.py | 50 ++++++++++++++++++++++++ cqlengine/query.py | 6 ++- cqlengine/tests/query/test_queryset.py | 8 ++-- cqlengine/tests/test_ttl.py | 54 ++++++++++++++++++++++++-- 4 files changed, 110 insertions(+), 8 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 2969c228aa..006e0b855f 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -70,6 +70,51 @@ def __call__(self, *args, **kwargs): """ raise NotImplementedError +class TTLDescriptor(object): + """ + returns a query set descriptor + """ + def __get__(self, instance, model): + if instance: + def ttl_setter(ts): + instance._ttl = ts + return instance + return ttl_setter + + qs = model.__queryset__(model) + + def ttl_setter(ts): + qs._ttl = ts + return qs + + return ttl_setter + + def __call__(self, *args, **kwargs): + raise NotImplementedError + + +class ConsistencyDescriptor(object): + """ + returns a query set descriptor if called on Class, instance if it was an instance call + """ + def __get__(self, instance, model): + if instance: + def consistency_setter(consistency): + instance._consistency = consistency + return instance + return consistency_setter + + qs = model.__queryset__(model) + + def consistency_setter(ts): + qs._consistency = ts + return qs + + return consistency_setter + + def __call__(self, *args, **kwargs): + raise NotImplementedError + class ColumnQueryEvaluator(AbstractQueryableColumn): """ @@ -148,6 +193,8 @@ class DoesNotExist(_DoesNotExist): pass class MultipleObjectsReturned(_MultipleObjectsReturned): pass objects = QuerySetDescriptor() + ttl = TTLDescriptor() + consistency = ConsistencyDescriptor() #table names will be generated automatically from it's model and package name #however, you can also define them manually here @@ -179,10 +226,13 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass __queryset__ = ModelQuerySet __dmlquery__ = DMLQuery + __ttl__ = None + __read_repair_chance__ = 0.1 def __init__(self, **values): self._values = {} + self._ttl = None for name, column in self._columns.items(): value = values.get(name, None) diff --git a/cqlengine/query.py b/cqlengine/query.py index 02a5d7d577..804650fa13 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -273,6 +273,7 @@ def __init__(self, model): self._result_idx = None self._batch = None + self._ttl = None @property def column_family_name(self): @@ -727,6 +728,10 @@ def _get_ordering_condition(self, colname): return column.db_field_name, order_type + def _get_ttl_statement(self): + return "" + + def values_list(self, *fields, **kwargs): """ Instructs the query set to return tuples, not model instance """ flat = kwargs.pop('flat', False) @@ -755,7 +760,6 @@ def __init__(self, model, instance=None, batch=None): self.column_family_name = self.model.column_family_name() self.instance = instance self._batch = batch - pass def batch(self, batch_obj): if batch_obj is not None and not isinstance(batch_obj, BatchQuery): diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 117c87c18b..cda6831c2e 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -6,7 +6,7 @@ from cqlengine.exceptions import ModelException from cqlengine import functions -from cqlengine.management import create_table +from cqlengine.management import create_table, drop_table from cqlengine.management import delete_table from cqlengine.models import Model from cqlengine import columns @@ -200,9 +200,9 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): super(BaseQuerySetUsage, cls).tearDownClass() - delete_table(TestModel) - delete_table(IndexedTestModel) - delete_table(TestMultiClusteringModel) + drop_table(TestModel) + drop_table(IndexedTestModel) + drop_table(TestMultiClusteringModel) class TestQuerySetCountSelectionAndIteration(BaseQuerySetUsage): diff --git a/cqlengine/tests/test_ttl.py b/cqlengine/tests/test_ttl.py index 2274cf34c5..a0c0a6dfb9 100644 --- a/cqlengine/tests/test_ttl.py +++ b/cqlengine/tests/test_ttl.py @@ -1,7 +1,31 @@ +from cqlengine.management import sync_table, drop_table from cqlengine.tests.base import BaseCassEngTestCase +from cqlengine.models import Model +from uuid import uuid4 +from cqlengine import columns -class TTLQueryTests(BaseCassEngTestCase): +class TestTTLModel(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) + count = columns.Integer() + text = columns.Text(required=False) + + +class BaseTTLTest(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(BaseTTLTest, cls).setUpClass() + sync_table(TestTTLModel) + + @classmethod + def tearDownClass(cls): + super(BaseTTLTest, cls).tearDownClass() + drop_table(TestTTLModel) + + + +class TTLQueryTests(BaseTTLTest): def test_update_queryset_ttl_success_case(self): """ tests that ttls on querysets work as expected """ @@ -10,7 +34,31 @@ def test_select_ttl_failure(self): """ tests that ttls on select queries raise an exception """ -class TTLModelTests(BaseCassEngTestCase): +class TTLModelTests(BaseTTLTest): def test_model_ttl_success_case(self): - """ tests that ttls on models work as expected """ \ No newline at end of file + """ tests that ttls on models work as expected """ + + def test_queryset_is_returned_on_class(self): + """ + ensures we get a queryset descriptor back + """ + qs = TestTTLModel.ttl(60) + self.assertTrue(isinstance(qs, TestTTLModel.__queryset__), type(qs)) + +class TTLInstanceTest(BaseTTLTest): + def test_instance_is_returned(self): + """ + ensures that we properly handle the instance.ttl(60).save() scenario + :return: + """ + o = TestTTLModel.create(text="whatever") + o.text = "new stuff" + o.ttl(60) + self.assertEqual(60, o._ttl) + o.save() + + + +class QuerySetTTLFragmentTest(BaseTTLTest): + pass From 982031672e7af39320018d64805e4c30c4e6e1e8 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 13:20:17 -0700 Subject: [PATCH 0418/4200] ttl statement fragment --- cqlengine/query.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 804650fa13..8ff33a6ef1 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -729,8 +729,9 @@ def _get_ordering_condition(self, colname): return column.db_field_name, order_type def _get_ttl_statement(self): - return "" - + if not self._ttl: + return "" + return "USING TTL {}".format(self._ttl) def values_list(self, *fields, **kwargs): """ Instructs the query set to return tuples, not model instance """ From 308a5be608e4ecdd51a4b03b5ae67174bfb8b77c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 13:25:07 -0700 Subject: [PATCH 0419/4200] updating update method to only issue set statements for modified rows and counters --- cqlengine/query.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cqlengine/query.py b/cqlengine/query.py index 00e9db0c83..dc7a18724d 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -843,8 +843,16 @@ def update(self): for name, col in self.model._columns.items(): if not col.is_primary_key: val = values.get(name) + + # don't update something that is null if val is None: continue + + # don't update something if it hasn't changed + if not self.instance._values[name].changed and not isinstance(col, Counter): + continue + + # add the update statements if isinstance(col, (BaseContainerColumn, Counter)): #remove value from query values, the column will handle it query_values.pop(field_ids.get(name), None) From f68a5924a6dceeb3ff854bb63bdd70f5cbfe0cfd Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 13:36:43 -0700 Subject: [PATCH 0420/4200] fixing container column bug that would write insert empty container columns --- cqlengine/columns.py | 22 +++++++++++++++++++--- cqlengine/query.py | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 10a5e1b956..adccf8f90f 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -198,6 +198,10 @@ def cql(self): def get_cql(self): return '"{}"'.format(self.db_field_name) + def _val_is_null(self, val): + """ determines if the given value equates to a null value for the given column type """ + return val is None + class Bytes(Column): db_type = 'blob' @@ -526,6 +530,15 @@ def get_update_statement(self, val, prev, ctx): """ raise NotImplementedError + def _val_is_null(self, val): + return not val + + +class BaseContainerQuoter(ValueQuoter): + + def __nonzero__(self): + return bool(self.value) + class Set(BaseContainerColumn): """ @@ -535,7 +548,7 @@ class Set(BaseContainerColumn): """ db_type = 'set<{}>' - class Quoter(ValueQuoter): + class Quoter(BaseContainerQuoter): def __str__(self): cq = cql_quote @@ -625,12 +638,15 @@ class List(BaseContainerColumn): """ db_type = 'list<{}>' - class Quoter(ValueQuoter): + class Quoter(BaseContainerQuoter): def __str__(self): cq = cql_quote return '[' + ', '.join([cq(v) for v in self.value]) + ']' + def __nonzero__(self): + return bool(self.value) + def __init__(self, value_type, default=set, **kwargs): return super(List, self).__init__(value_type=value_type, default=default, **kwargs) @@ -736,7 +752,7 @@ class Map(BaseContainerColumn): db_type = 'map<{}, {}>' - class Quoter(ValueQuoter): + class Quoter(BaseContainerQuoter): def __str__(self): cq = cql_quote diff --git a/cqlengine/query.py b/cqlengine/query.py index dc7a18724d..377accb4fd 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -898,7 +898,7 @@ def _get_query_values(self): #get defined fields and their column names for name, col in self.model._columns.items(): val = values.get(name) - if val is None: continue + if col._val_is_null(val): continue value_pairs += [(col.db_field_name, val)] #construct query string From 38df187625d07dd45fca299b0e0f07c68901f9e2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 13:42:20 -0700 Subject: [PATCH 0421/4200] adding tests around empty container insert bug fix --- .../tests/columns/test_container_columns.py | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 2e1d7cb844..0d3dc524b2 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -156,7 +156,14 @@ def test_to_python(self): def test_default_empty_container_saving(self): """ tests that the default empty container is not saved if it hasn't been updated """ - self.fail("implement") + pkey = uuid4() + # create a row with set data + TestSetModel.create(partition=pkey, int_set={3, 4}) + # create another with no set data + TestSetModel.create(partition=pkey) + + m = TestSetModel.get(partition=pkey) + self.assertEqual(m.int_set, {3, 4}) class TestListModel(Model): @@ -288,7 +295,14 @@ def test_to_python(self): def test_default_empty_container_saving(self): """ tests that the default empty container is not saved if it hasn't been updated """ - self.fail("implement") + pkey = uuid4() + # create a row with list data + TestListModel.create(partition=pkey, int_list=[1,2,3,4]) + # create another with no list data + TestListModel.create(partition=pkey) + + m = TestListModel.get(partition=pkey) + self.assertEqual(m.int_list, [1,2,3,4]) class TestMapModel(Model): @@ -414,7 +428,15 @@ def test_to_python(self): def test_default_empty_container_saving(self): """ tests that the default empty container is not saved if it hasn't been updated """ - self.fail("implement") + pkey = uuid4() + tmap = {1: uuid4(), 2: uuid4()} + # create a row with set data + TestMapModel.create(partition=pkey, int_map=tmap) + # create another with no set data + TestMapModel.create(partition=pkey) + + m = TestMapModel.get(partition=pkey) + self.assertEqual(m.int_map, tmap) # def test_partial_update_creation(self): # """ From 783f77222c27ec5b4268e5fdb7875b98e44c1754 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 13:48:38 -0700 Subject: [PATCH 0422/4200] added consistency constants to top level --- cqlengine/__init__.py | 10 ++++++++++ cqlengine/models.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cqlengine/__init__.py b/cqlengine/__init__.py index 639b56b0bd..51b3fc2f22 100644 --- a/cqlengine/__init__.py +++ b/cqlengine/__init__.py @@ -12,3 +12,13 @@ SizeTieredCompactionStrategy = "SizeTieredCompactionStrategy" LeveledCompactionStrategy = "LeveledCompactionStrategy" + +ANY = "ANY" +ONE = "ONE" +TWO = "TWO" +THREE = "THREE" +QUORUM = "QUORUM" +LOCAL_QUORUM = "LOCAL_QUORUM" +EACH_QUORUM = "EACH_QUORUM" +ALL = "ALL" + diff --git a/cqlengine/models.py b/cqlengine/models.py index 006e0b855f..5c365c9a56 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -7,7 +7,6 @@ from cqlengine.query import DoesNotExist as _DoesNotExist from cqlengine.query import MultipleObjectsReturned as _MultipleObjectsReturned - class ModelDefinitionException(ModelException): pass @@ -76,6 +75,7 @@ class TTLDescriptor(object): """ def __get__(self, instance, model): if instance: + # instance method def ttl_setter(ts): instance._ttl = ts return instance From 8355e3bb5f276af6c6d0b9f6c5517e2b1dabfd70 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 14:14:31 -0700 Subject: [PATCH 0423/4200] test that ensures TTL is called on update --- cqlengine/query.py | 5 ++++- cqlengine/tests/test_ttl.py | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 19b90cd3b8..1e32acda0a 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -284,6 +284,7 @@ def __init__(self, model): self._batch = None self._ttl = None + self._consistency = None @property def column_family_name(self): @@ -615,7 +616,7 @@ def defer(self, fields): return self._only_or_defer('defer', fields) def create(self, **kwargs): - return self.model(**kwargs).batch(self._batch).save() + return self.model(**kwargs).batch(self._batch).ttl(self._ttl).consistency(self._consistency).save() #----delete--- def delete(self, columns=[]): @@ -935,8 +936,10 @@ def save(self): qs += ['VALUES'] qs += ["({})".format(', '.join([':'+field_ids[f] for f in field_names]))] + qs = ' '.join(qs) + # skip query execution if it's empty # caused by pointless update queries if qs: diff --git a/cqlengine/tests/test_ttl.py b/cqlengine/tests/test_ttl.py index a0c0a6dfb9..92313b96d8 100644 --- a/cqlengine/tests/test_ttl.py +++ b/cqlengine/tests/test_ttl.py @@ -3,7 +3,8 @@ from cqlengine.models import Model from uuid import uuid4 from cqlengine import columns - +import mock +from cqlengine.connection import ConnectionPool class TestTTLModel(Model): id = columns.UUID(primary_key=True, default=lambda:uuid4()) @@ -56,7 +57,16 @@ def test_instance_is_returned(self): o.text = "new stuff" o.ttl(60) self.assertEqual(60, o._ttl) - o.save() + + def test_ttl_is_include_with_query(self): + o = TestTTLModel.create(text="whatever") + o.text = "new stuff" + o.ttl(60) + + with mock.patch.object(ConnectionPool, 'execute') as m: + o.save() + query = m.call_args[0][0] + self.assertIn("USING TTL", query) From 343367dbf7a57af85e6ea9eaecc4202bdb626bdb Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 14:19:41 -0700 Subject: [PATCH 0424/4200] adding update method to model --- cqlengine/models.py | 32 +++++++++++++++++++++++++++++++- cqlengine/query.py | 2 +- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 2969c228aa..c1730b3cc0 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -356,7 +356,6 @@ def get(cls, *args, **kwargs): return cls.objects.get(*args, **kwargs) def save(self): - # handle polymorphic models if self._is_polymorphic: if self._is_polymorphic_base: @@ -375,6 +374,37 @@ def save(self): return self + def update(self, **values): + for k, v in values.items(): + col = self._columns.get(k) + + # check for nonexistant columns + if col is None: + raise ValidationError("{}.{} has no column named: {}".format(self.__module__, self.__name__, k)) + + # check for primary key update attempts + if col.is_primary_key: + raise ValidationError("Cannot apply update to primary key '{}' for {}.{}".format(k, self.__module__, self.__name__)) + + setattr(self, k, v) + + # handle polymorphic models + if self._is_polymorphic: + if self._is_polymorphic_base: + raise PolyMorphicModelException('cannot update polymorphic base model') + else: + setattr(self, self._polymorphic_column_name, self.__polymorphic_key__) + + self.validate() + self.__dmlquery__(self.__class__, self, batch=self._batch).update() + + #reset the value managers + for v in self._values.values(): + v.reset_previous_value() + self._is_persisted = True + + return self + def delete(self): """ Deletes this instance """ self.__dmlquery__(self.__class__, self, batch=self._batch).delete() diff --git a/cqlengine/query.py b/cqlengine/query.py index 377accb4fd..75d2b7a848 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -785,7 +785,7 @@ def batch(self, batch_obj): def _delete_null_columns(self): """ - executes a delete query to remove null columns + executes a delete query to remove columns that have changed to null """ values, field_names, field_ids, field_values, query_values = self._get_query_values() From eea1e7e1898ce1cdb5d6c19bb0d7d45cf7482958 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 14:49:25 -0700 Subject: [PATCH 0425/4200] TTL working for save on an update --- cqlengine/models.py | 2 +- cqlengine/query.py | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 5c365c9a56..cfeb724efc 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -416,7 +416,7 @@ def save(self): is_new = self.pk is None self.validate() - self.__dmlquery__(self.__class__, self, batch=self._batch).save() + self.__dmlquery__(self.__class__, self, batch=self._batch, ttl=self._ttl).save() #reset the value managers for v in self._values.values(): diff --git a/cqlengine/query.py b/cqlengine/query.py index 1e32acda0a..a2ce6d20d8 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -776,12 +776,14 @@ class DMLQuery(object): unlike the read query object, this is mutable """ + _ttl = None - def __init__(self, model, instance=None, batch=None): + def __init__(self, model, instance=None, batch=None, ttl=None): self.model = model self.column_family_name = self.model.column_family_name() self.instance = instance self._batch = batch + self._ttl = ttl def batch(self, batch_obj): if batch_obj is not None and not isinstance(batch_obj, BatchQuery): @@ -878,6 +880,9 @@ def update(self): qs += [' AND '.join(where_statements)] + if self._ttl: + qs += ["USING TTL {}".format(self._ttl)] + # clear the qs if there are no set statements and this is not a counter model if not set_statements and not self.instance._has_counter: qs = [] @@ -936,7 +941,10 @@ def save(self): qs += ['VALUES'] qs += ["({})".format(', '.join([':'+field_ids[f] for f in field_names]))] + if self._ttl: + qs += ["USING TTL {}".format(self._ttl)] + qs += [] qs = ' '.join(qs) From cbc07a9fc88fb28200da8d4a69aed5cb2bf8a2e9 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 14:50:05 -0700 Subject: [PATCH 0426/4200] updating changelog --- changelog | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/changelog b/changelog index 21d31aa7c8..4e7d121ec1 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,9 @@ CHANGELOG +0.9 +* adding update method +* only saving collection fields on insert if they've been modified + 0.8.5 * adding support for timeouts From 4996f05563814312101ce2fe8905615f841f33f6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 14:50:30 -0700 Subject: [PATCH 0427/4200] fixing update errors --- cqlengine/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index c1730b3cc0..93a9af984e 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -380,11 +380,11 @@ def update(self, **values): # check for nonexistant columns if col is None: - raise ValidationError("{}.{} has no column named: {}".format(self.__module__, self.__name__, k)) + raise ValidationError("{}.{} has no column named: {}".format(self.__module__, self.__class__.__name__, k)) # check for primary key update attempts if col.is_primary_key: - raise ValidationError("Cannot apply update to primary key '{}' for {}.{}".format(k, self.__module__, self.__name__)) + raise ValidationError("Cannot apply update to primary key '{}' for {}.{}".format(k, self.__module__, self.__class__.__name__)) setattr(self, k, v) From c906c77773c1f726f56d8315d593c14c945eec2b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 14:50:42 -0700 Subject: [PATCH 0428/4200] adding tests around model updates --- cqlengine/tests/model/test_updates.py | 91 +++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 cqlengine/tests/model/test_updates.py diff --git a/cqlengine/tests/model/test_updates.py b/cqlengine/tests/model/test_updates.py new file mode 100644 index 0000000000..c9ab45d9de --- /dev/null +++ b/cqlengine/tests/model/test_updates.py @@ -0,0 +1,91 @@ +from uuid import uuid4 + +from mock import patch +from cqlengine.exceptions import ValidationError + +from cqlengine.tests.base import BaseCassEngTestCase +from cqlengine.models import Model +from cqlengine import columns +from cqlengine.management import sync_table, drop_table +from cqlengine.connection import ConnectionPool + + +class TestUpdateModel(Model): + partition = columns.UUID(primary_key=True, default=uuid4) + cluster = columns.UUID(primary_key=True, default=uuid4) + count = columns.Integer(required=False) + text = columns.Text(required=False, index=True) + + +class ModelUpdateTests(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(ModelUpdateTests, cls).setUpClass() + sync_table(TestUpdateModel) + + @classmethod + def tearDownClass(cls): + super(ModelUpdateTests, cls).tearDownClass() + drop_table(TestUpdateModel) + + def test_update_model(self): + """ tests calling udpate on models with no values passed in """ + m0 = TestUpdateModel.create(count=5, text='monkey') + + # independently save over a new count value, unknown to original instance + m1 = TestUpdateModel.get(partition=m0.partition, cluster=m0.cluster) + m1.count = 6 + m1.save() + + # update the text, and call update + m0.text = 'monkey land' + m0.update() + + # database should reflect both updates + m2 = TestUpdateModel.get(partition=m0.partition, cluster=m0.cluster) + self.assertEqual(m2.count, m1.count) + self.assertEqual(m2.text, m0.text) + + def test_update_values(self): + """ tests calling update on models with values passed in """ + m0 = TestUpdateModel.create(count=5, text='monkey') + + # independently save over a new count value, unknown to original instance + m1 = TestUpdateModel.get(partition=m0.partition, cluster=m0.cluster) + m1.count = 6 + m1.save() + + # update the text, and call update + m0.update(text='monkey land') + self.assertEqual(m0.text, 'monkey land') + + # database should reflect both updates + m2 = TestUpdateModel.get(partition=m0.partition, cluster=m0.cluster) + self.assertEqual(m2.count, m1.count) + self.assertEqual(m2.text, m0.text) + + def test_noop_model_update(self): + """ tests that calling update on a model with no changes will do nothing. """ + m0 = TestUpdateModel.create(count=5, text='monkey') + + with patch.object(ConnectionPool, 'execute') as execute: + m0.update() + assert execute.call_count == 0 + + with patch.object(ConnectionPool, 'execute') as execute: + m0.update(count=5) + assert execute.call_count == 0 + + def test_invalid_update_kwarg(self): + """ tests that passing in a kwarg to the update method that isn't a column will fail """ + m0 = TestUpdateModel.create(count=5, text='monkey') + with self.assertRaises(ValidationError): + m0.update(numbers=20) + + def test_primary_key_update_failure(self): + """ tests that attempting to update the value of a primary key will fail """ + m0 = TestUpdateModel.create(count=5, text='monkey') + with self.assertRaises(ValidationError): + m0.update(partition=uuid4()) + From 2601354448f95348f35e0374717c430780cc7bbe Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 14:52:38 -0700 Subject: [PATCH 0429/4200] TTL working on inserts --- cqlengine/tests/test_ttl.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cqlengine/tests/test_ttl.py b/cqlengine/tests/test_ttl.py index 92313b96d8..6093b76545 100644 --- a/cqlengine/tests/test_ttl.py +++ b/cqlengine/tests/test_ttl.py @@ -37,8 +37,13 @@ def test_select_ttl_failure(self): class TTLModelTests(BaseTTLTest): - def test_model_ttl_success_case(self): + def test_ttl_included_on_create(self): """ tests that ttls on models work as expected """ + with mock.patch.object(ConnectionPool, 'execute') as m: + TestTTLModel.ttl(60).create(text="hello blake") + + query = m.call_args[0][0] + self.assertIn("USING TTL", query) def test_queryset_is_returned_on_class(self): """ @@ -47,6 +52,8 @@ def test_queryset_is_returned_on_class(self): qs = TestTTLModel.ttl(60) self.assertTrue(isinstance(qs, TestTTLModel.__queryset__), type(qs)) + + class TTLInstanceTest(BaseTTLTest): def test_instance_is_returned(self): """ @@ -58,7 +65,7 @@ def test_instance_is_returned(self): o.ttl(60) self.assertEqual(60, o._ttl) - def test_ttl_is_include_with_query(self): + def test_ttl_is_include_with_query_on_update(self): o = TestTTLModel.create(text="whatever") o.text = "new stuff" o.ttl(60) From ca67541c5a689e7aac4670e11e4727ea1f986fe2 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 14:55:44 -0700 Subject: [PATCH 0430/4200] test for updates --- cqlengine/tests/test_ttl.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cqlengine/tests/test_ttl.py b/cqlengine/tests/test_ttl.py index 6093b76545..11a868a32e 100644 --- a/cqlengine/tests/test_ttl.py +++ b/cqlengine/tests/test_ttl.py @@ -53,6 +53,17 @@ def test_queryset_is_returned_on_class(self): self.assertTrue(isinstance(qs, TestTTLModel.__queryset__), type(qs)) +class TTLInstanceUpdateTest(BaseTTLTest): + def test_update_includes_ttl(self): + model = TestTTLModel.create(text="goodbye blake") + with mock.patch.object(ConnectionPool, 'execute') as m: + model.ttl(60).update(text="goodbye forever") + + query = m.call_args[0][0] + self.assertIn("USING TTL", query) + + + class TTLInstanceTest(BaseTTLTest): def test_instance_is_returned(self): From e22dea631dc1541b7ebc3cde34f94f36c16ecf37 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 14:57:55 -0700 Subject: [PATCH 0431/4200] update with TTL work --- cqlengine/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 88abd85a51..16d7b01eb2 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -446,7 +446,7 @@ def update(self, **values): setattr(self, self._polymorphic_column_name, self.__polymorphic_key__) self.validate() - self.__dmlquery__(self.__class__, self, batch=self._batch).update() + self.__dmlquery__(self.__class__, self, batch=self._batch, ttl=self._ttl).update() #reset the value managers for v in self._values.values(): From f1fb9da47c867f4edd57327de0f4a3a773fd0068 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 15:36:20 -0700 Subject: [PATCH 0432/4200] ensure consistency is called with the right param --- cqlengine/connection.py | 11 +++++---- cqlengine/tests/test_consistency.py | 35 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 cqlengine/tests/test_consistency.py diff --git a/cqlengine/connection.py b/cqlengine/connection.py index f6e25e4e76..df89e430f2 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -165,10 +165,10 @@ def _create_transport(self, host): from thrift.transport import TSocket, TTransport thrift_socket = TSocket.TSocket(host.name, host.port) - + if self._timeout is not None: thrift_socket.setTimeout(self._timeout) - + return TTransport.TFramedTransport(thrift_socket) def _create_connection(self): @@ -202,14 +202,17 @@ def _create_connection(self): raise CQLConnectionError("Could not connect to any server in cluster") - def execute(self, query, params): + def execute(self, query, params, consistency_level=None): + if not consistency_level: + consistency_level = self._consistency + while True: try: con = self.get() if not con: raise CQLEngineException("Error calling execute without calling setup.") cur = con.cursor() - cur.execute(query, params) + cur.execute(query, params, consistency_level=consistency_level) columns = [i[0] for i in cur.description or []] results = [RowResult(r) for r in cur.fetchall()] LOG.debug('{} {}'.format(query, repr(params))) diff --git a/cqlengine/tests/test_consistency.py b/cqlengine/tests/test_consistency.py new file mode 100644 index 0000000000..cf1673ed9c --- /dev/null +++ b/cqlengine/tests/test_consistency.py @@ -0,0 +1,35 @@ +from cqlengine.management import sync_table, drop_table +from cqlengine.tests.base import BaseCassEngTestCase +from cqlengine.models import Model +from uuid import uuid4 +from cqlengine import columns +import mock +from cqlengine.connection import ConnectionPool +from cqlengine import ALL + +class TestConsistencyModel(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) + count = columns.Integer() + text = columns.Text(required=False) + +class BaseConsistencyTest(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(BaseConsistencyTest, cls).setUpClass() + sync_table(TestConsistencyModel) + + @classmethod + def tearDownClass(cls): + super(BaseConsistencyTest, cls).tearDownClass() + drop_table(TestConsistencyModel) + + +class TestConsistency(BaseConsistencyTest): + def test_create_uses_consistency(self): + + with mock.patch.object(ConnectionPool, 'execute') as m: + TestConsistencyModel.consistency(ALL).create(text="i am not fault tolerant this way") + + args = m.call_args + self.assertEqual(ALL, args[2]) From a7c7a85a3522f38ae6184a4c524bd2487b22dbfc Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 15:58:14 -0700 Subject: [PATCH 0433/4200] adding queryset updates and supporting tests --- cqlengine/query.py | 57 +++++++++++-- cqlengine/tests/query/test_updates.py | 118 ++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 cqlengine/tests/query/test_updates.py diff --git a/cqlengine/query.py b/cqlengine/query.py index 75d2b7a848..b24c67a06b 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -9,7 +9,7 @@ from cqlengine.connection import connection_manager, execute, RowResult -from cqlengine.exceptions import CQLEngineException +from cqlengine.exceptions import CQLEngineException, ValidationError from cqlengine.functions import QueryValue, Token #CQL 3 reference: @@ -634,13 +634,6 @@ def delete(self, columns=[]): else: execute(qs, self._where_values()) - def update(self, **values): - """ - updates the contents of the query - """ - qs = ['UPDATE {}'.format(self.column_family_name)] - qs += ['SET'] - def __eq__(self, q): return set(self._where) == set(q._where) @@ -760,6 +753,54 @@ def values_list(self, *fields, **kwargs): clone._flat_values_list = flat return clone + def update(self, **values): + """ Updates the rows in this queryset """ + if not values: + raise ValidationError("At least one column needs to be updated") + + set_statements = [] + ctx = {} + nulled_columns = set() + for name, val in values.items(): + col = self.model._columns.get(name) + # check for nonexistant columns + if col is None: + raise ValidationError("{}.{} has no column named: {}".format(self.__module__, self.model.__name__, name)) + # check for primary key update attempts + if col.is_primary_key: + raise ValidationError("Cannot apply update to primary key '{}' for {}.{}".format(name, self.__module__, self.model.__name__)) + + val = col.validate(val) + if val is None: + nulled_columns.add(name) + continue + # add the update statements + if isinstance(col, (BaseContainerColumn, Counter)): + val_mgr = self.instance._values[name] + set_statements += col.get_update_statement(val, val_mgr.previous_value, ctx) + + else: + field_id = uuid4().hex + set_statements += ['"{}" = :{}'.format(col.db_field_name, field_id)] + ctx[field_id] = val + + if set_statements: + qs = "UPDATE {} SET {} WHERE {}".format( + self.column_family_name, + ', '.join(set_statements), + self._where_clause() + ) + ctx.update(self._where_values()) + execute(qs, ctx) + + if nulled_columns: + qs = "DELETE {} FROM {} WHERE {}".format( + ', '.join(nulled_columns), + self.column_family_name, + self._where_clause() + ) + execute(qs, self._where_values()) + class DMLQuery(object): """ diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py new file mode 100644 index 0000000000..56d5cd5ef0 --- /dev/null +++ b/cqlengine/tests/query/test_updates.py @@ -0,0 +1,118 @@ +from uuid import uuid4 +from cqlengine.exceptions import ValidationError + +from cqlengine.tests.base import BaseCassEngTestCase +from cqlengine.models import Model +from cqlengine.management import sync_table, drop_table +from cqlengine import columns + + +class TestQueryUpdateModel(Model): + partition = columns.UUID(primary_key=True, default=uuid4) + cluster = columns.Integer(primary_key=True) + count = columns.Integer(required=False) + text = columns.Text(required=False, index=True) + + +class QueryUpdateTests(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(QueryUpdateTests, cls).setUpClass() + sync_table(TestQueryUpdateModel) + + @classmethod + def tearDownClass(cls): + super(QueryUpdateTests, cls).tearDownClass() + drop_table(TestQueryUpdateModel) + + def test_update_values(self): + """ tests calling udpate on a queryset """ + partition = uuid4() + for i in range(5): + TestQueryUpdateModel.create(partition=partition, cluster=i, count=i, text=str(i)) + + # sanity check + for i, row in enumerate(TestQueryUpdateModel.objects(partition=partition)): + assert row.cluster == i + assert row.count == i + assert row.text == str(i) + + # perform update + TestQueryUpdateModel.objects(partition=partition, cluster=3).update(count=6) + + for i, row in enumerate(TestQueryUpdateModel.objects(partition=partition)): + assert row.cluster == i + assert row.count == (6 if i == 3 else i) + assert row.text == str(i) + + def test_update_values_validation(self): + """ tests calling udpate on models with values passed in """ + partition = uuid4() + for i in range(5): + TestQueryUpdateModel.create(partition=partition, cluster=i, count=i, text=str(i)) + + # sanity check + for i, row in enumerate(TestQueryUpdateModel.objects(partition=partition)): + assert row.cluster == i + assert row.count == i + assert row.text == str(i) + + # perform update + with self.assertRaises(ValidationError): + TestQueryUpdateModel.objects(partition=partition, cluster=3).update(count='asdf') + + def test_update_with_no_values_failure(self): + """ tests calling update on models with no values passed in """ + with self.assertRaises(ValidationError): + TestQueryUpdateModel.objects(partition=uuid4(), cluster=3).update() + + def test_invalid_update_kwarg(self): + """ tests that passing in a kwarg to the update method that isn't a column will fail """ + with self.assertRaises(ValidationError): + TestQueryUpdateModel.objects(partition=uuid4(), cluster=3).update(bacon=5000) + + def test_primary_key_update_failure(self): + """ tests that attempting to update the value of a primary key will fail """ + with self.assertRaises(ValidationError): + TestQueryUpdateModel.objects(partition=uuid4(), cluster=3).update(cluster=5000) + + def test_null_update_deletes_column(self): + """ setting a field to null in the update should issue a delete statement """ + partition = uuid4() + for i in range(5): + TestQueryUpdateModel.create(partition=partition, cluster=i, count=i, text=str(i)) + + # sanity check + for i, row in enumerate(TestQueryUpdateModel.objects(partition=partition)): + assert row.cluster == i + assert row.count == i + assert row.text == str(i) + + # perform update + TestQueryUpdateModel.objects(partition=partition, cluster=3).update(text=None) + + for i, row in enumerate(TestQueryUpdateModel.objects(partition=partition)): + assert row.cluster == i + assert row.count == i + assert row.text == (None if i == 3 else str(i)) + + def test_mixed_value_and_null_update(self): + """ tests that updating a columns value, and removing another works properly """ + partition = uuid4() + for i in range(5): + TestQueryUpdateModel.create(partition=partition, cluster=i, count=i, text=str(i)) + + # sanity check + for i, row in enumerate(TestQueryUpdateModel.objects(partition=partition)): + assert row.cluster == i + assert row.count == i + assert row.text == str(i) + + # perform update + TestQueryUpdateModel.objects(partition=partition, cluster=3).update(count=6, text=None) + + for i, row in enumerate(TestQueryUpdateModel.objects(partition=partition)): + assert row.cluster == i + assert row.count == (6 if i == 3 else i) + assert row.text == (None if i == 3 else str(i)) From 1aa65768f066bfa6e22f422d0be7aa3b54b2861d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 17:14:00 -0700 Subject: [PATCH 0434/4200] removing empty update exception --- cqlengine/query.py | 2 +- cqlengine/tests/query/test_updates.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index b24c67a06b..6619c5a2bb 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -756,7 +756,7 @@ def values_list(self, *fields, **kwargs): def update(self, **values): """ Updates the rows in this queryset """ if not values: - raise ValidationError("At least one column needs to be updated") + return set_statements = [] ctx = {} diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py index 56d5cd5ef0..0c9c88cf21 100644 --- a/cqlengine/tests/query/test_updates.py +++ b/cqlengine/tests/query/test_updates.py @@ -1,5 +1,6 @@ from uuid import uuid4 from cqlengine.exceptions import ValidationError +from cqlengine.query import QueryException from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.models import Model @@ -62,11 +63,6 @@ def test_update_values_validation(self): with self.assertRaises(ValidationError): TestQueryUpdateModel.objects(partition=partition, cluster=3).update(count='asdf') - def test_update_with_no_values_failure(self): - """ tests calling update on models with no values passed in """ - with self.assertRaises(ValidationError): - TestQueryUpdateModel.objects(partition=uuid4(), cluster=3).update() - def test_invalid_update_kwarg(self): """ tests that passing in a kwarg to the update method that isn't a column will fail """ with self.assertRaises(ValidationError): From 3ef6c10ac670e8c866c959ab8443065f820b687a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 17:14:34 -0700 Subject: [PATCH 0435/4200] adding docs for queryset update --- docs/topics/models.rst | 2 +- docs/topics/queryset.rst | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index e665e6c5ca..42d447f9d7 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -137,9 +137,9 @@ Model Methods .. method:: delete() Deletes the object from the database. - -- method:: update(**values) + Performs an update on the model instance. You can pass in values to set on the model for updating, or you can call without values to execute an update against any modified fields. If no fields on the model have been modified since loading, no query will be diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index cb1fdbb836..5632e41043 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -353,3 +353,16 @@ QuerySet method reference .. method:: allow_filtering() Enables the (usually) unwise practive of querying on a clustering key without also defining a partition key + + -- method:: update(**values) + + Performs an update on the row selected by the queryset. Include values to update in the + update like so: + + .. code-block:: python + Model.objects(key=n).update(value='x') + + Passing in updates for columns which are not part of the model will raise a ValidationError. + Per column validation will be performed, but instance level validation will not + (`Model.validate` is not called). + From bfe2ebd31ba8ddee6eb95b13b73d29bc469ed57d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 17:23:25 -0700 Subject: [PATCH 0436/4200] adding BigInt to changelog --- changelog | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog b/changelog index 4e7d121ec1..9ab05e8e79 100644 --- a/changelog +++ b/changelog @@ -2,6 +2,7 @@ CHANGELOG 0.9 * adding update method +* adding BigInt column (thanks @Lifto) * only saving collection fields on insert if they've been modified 0.8.5 From 73756b247682f299bf8c3aa2511e10cc36195d84 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 17:28:45 -0700 Subject: [PATCH 0437/4200] adding dokai's time uuid update to changelog --- changelog | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog b/changelog index 9ab05e8e79..57f6fc2c6d 100644 --- a/changelog +++ b/changelog @@ -3,6 +3,7 @@ CHANGELOG 0.9 * adding update method * adding BigInt column (thanks @Lifto) +* adding support for timezone aware time uuid functions (thanks @dokai) * only saving collection fields on insert if they've been modified 0.8.5 From 8b238734e66137f465cb12dc0a9faff15d54a107 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 17:33:52 -0700 Subject: [PATCH 0438/4200] making sure we send the consistency_level explicitly --- cqlengine/connection.py | 6 ++++-- cqlengine/models.py | 10 +++++++--- cqlengine/query.py | 12 +++++++----- cqlengine/tests/test_consistency.py | 8 +++++++- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index df89e430f2..6fec0d3d64 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -229,9 +229,11 @@ def execute(self, query, params, consistency_level=None): raise ex -def execute(query, params=None): +def execute(query, params=None, consistency_level=None): params = params or {} - return connection_pool.execute(query, params) + if consistency_level is None: + consistency_level = connection_pool._consistency + return connection_pool.execute(query, params, consistency_level) @contextmanager def connection_manager(): diff --git a/cqlengine/models.py b/cqlengine/models.py index 16d7b01eb2..143320a31b 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -106,8 +106,8 @@ def consistency_setter(consistency): qs = model.__queryset__(model) - def consistency_setter(ts): - qs._consistency = ts + def consistency_setter(consistency): + qs._consistency = consistency return qs return consistency_setter @@ -227,6 +227,7 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass __dmlquery__ = DMLQuery __ttl__ = None + __consistency__ = None # can be set per query __read_repair_chance__ = 0.1 @@ -446,7 +447,10 @@ def update(self, **values): setattr(self, self._polymorphic_column_name, self.__polymorphic_key__) self.validate() - self.__dmlquery__(self.__class__, self, batch=self._batch, ttl=self._ttl).update() + self.__dmlquery__(self.__class__, self, + batch=self._batch, + ttl=self._ttl, + consistency=self.consistency).update() #reset the value managers for v in self._values.values(): diff --git a/cqlengine/query.py b/cqlengine/query.py index 361f42ef44..d67b45d4a9 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -363,7 +363,7 @@ def _execute_query(self): if self._batch: raise CQLEngineException("Only inserts, updates, and deletes are available in batch mode") if self._result_cache is None: - columns, self._result_cache = execute(self._select_query(), self._where_values()) + columns, self._result_cache = execute(self._select_query(), self._where_values(), self._consistency) self._construct_result = self._get_result_constructor(columns) def _fill_result_cache_to_idx(self, idx): @@ -777,13 +777,15 @@ class DMLQuery(object): unlike the read query object, this is mutable """ _ttl = None + _consistency = None - def __init__(self, model, instance=None, batch=None, ttl=None): + def __init__(self, model, instance=None, batch=None, ttl=None, consistency=None): self.model = model self.column_family_name = self.model.column_family_name() self.instance = instance self._batch = batch self._ttl = ttl + self._consistency = consistency def batch(self, batch_obj): if batch_obj is not None and not isinstance(batch_obj, BatchQuery): @@ -894,7 +896,7 @@ def update(self): if self._batch: self._batch.add_query(qs, query_values) else: - execute(qs, query_values) + execute(qs, query_values, consistency_level=self._consistency) self._delete_null_columns() @@ -954,7 +956,7 @@ def save(self): if self._batch: self._batch.add_query(qs, query_values) else: - execute(qs, query_values) + execute(qs, query_values, self._consistency) # delete any nulled columns self._delete_null_columns() @@ -978,6 +980,6 @@ def delete(self): if self._batch: self._batch.add_query(qs, field_values) else: - execute(qs, field_values) + execute(qs, field_values, self._consistency) diff --git a/cqlengine/tests/test_consistency.py b/cqlengine/tests/test_consistency.py index cf1673ed9c..7ab49d7867 100644 --- a/cqlengine/tests/test_consistency.py +++ b/cqlengine/tests/test_consistency.py @@ -32,4 +32,10 @@ def test_create_uses_consistency(self): TestConsistencyModel.consistency(ALL).create(text="i am not fault tolerant this way") args = m.call_args - self.assertEqual(ALL, args[2]) + self.assertEqual(ALL, args[0][2]) + + def test_queryset_is_returned_on_create(self): + qs = TestConsistencyModel.consistency(ALL) + self.assertTrue(isinstance(qs, TestConsistencyModel.__queryset__), type(qs)) + + From da58f8b2fbb83b535751b6503ba5dd37a8b0da2a Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 17:54:57 -0700 Subject: [PATCH 0439/4200] updates can use consistency levels --- cqlengine/models.py | 9 ++++++--- cqlengine/tests/test_consistency.py | 13 ++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 143320a31b..ced3a3fa08 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -100,7 +100,7 @@ class ConsistencyDescriptor(object): def __get__(self, instance, model): if instance: def consistency_setter(consistency): - instance._consistency = consistency + instance.__consistency__ = consistency return instance return consistency_setter @@ -416,7 +416,10 @@ def save(self): is_new = self.pk is None self.validate() - self.__dmlquery__(self.__class__, self, batch=self._batch, ttl=self._ttl).save() + self.__dmlquery__(self.__class__, self, + batch=self._batch, + ttl=self._ttl, + consistency=self.__consistency__).save() #reset the value managers for v in self._values.values(): @@ -450,7 +453,7 @@ def update(self, **values): self.__dmlquery__(self.__class__, self, batch=self._batch, ttl=self._ttl, - consistency=self.consistency).update() + consistency=self.__consistency__).update() #reset the value managers for v in self._values.values(): diff --git a/cqlengine/tests/test_consistency.py b/cqlengine/tests/test_consistency.py index 7ab49d7867..ec60450420 100644 --- a/cqlengine/tests/test_consistency.py +++ b/cqlengine/tests/test_consistency.py @@ -28,8 +28,9 @@ def tearDownClass(cls): class TestConsistency(BaseConsistencyTest): def test_create_uses_consistency(self): + qs = TestConsistencyModel.consistency(ALL) with mock.patch.object(ConnectionPool, 'execute') as m: - TestConsistencyModel.consistency(ALL).create(text="i am not fault tolerant this way") + qs.create(text="i am not fault tolerant this way") args = m.call_args self.assertEqual(ALL, args[0][2]) @@ -38,4 +39,14 @@ def test_queryset_is_returned_on_create(self): qs = TestConsistencyModel.consistency(ALL) self.assertTrue(isinstance(qs, TestConsistencyModel.__queryset__), type(qs)) + def test_update_uses_consistency(self): + t = TestConsistencyModel.create(text="bacon and eggs") + t.text = "ham sandwich" + + with mock.patch.object(ConnectionPool, 'execute') as m: + t.consistency(ALL).save() + + args = m.call_args + self.assertEqual(ALL, args[0][2]) + From 3f6d293af21a37bf4c3d116d70e9f65dfdb62c55 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 17:56:46 -0700 Subject: [PATCH 0440/4200] update test works --- cqlengine/tests/test_consistency.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/tests/test_consistency.py b/cqlengine/tests/test_consistency.py index ec60450420..27c455ef7d 100644 --- a/cqlengine/tests/test_consistency.py +++ b/cqlengine/tests/test_consistency.py @@ -45,7 +45,7 @@ def test_update_uses_consistency(self): with mock.patch.object(ConnectionPool, 'execute') as m: t.consistency(ALL).save() - + args = m.call_args self.assertEqual(ALL, args[0][2]) From 6b1391749edaccbbbd83767a16b8cacb8903a92c Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 18:19:04 -0700 Subject: [PATCH 0441/4200] consistency with batch verified --- cqlengine/query.py | 9 +++++++-- cqlengine/tests/test_consistency.py | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index d67b45d4a9..0e9395de6a 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -208,17 +208,22 @@ class BatchQuery(object): http://www.datastax.com/docs/1.2/cql_cli/cql/BATCH """ + _consistency = None - def __init__(self, batch_type=None, timestamp=None): + def __init__(self, batch_type=None, timestamp=None, consistency=None): self.queries = [] self.batch_type = batch_type if timestamp is not None and not isinstance(timestamp, datetime): raise CQLEngineException('timestamp object must be an instance of datetime') self.timestamp = timestamp + self._consistency = consistency def add_query(self, query, params): self.queries.append((query, params)) + def consistency(self, consistency): + self._consistency = consistency + def execute(self): if len(self.queries) == 0: # Empty batch is a no-op @@ -238,7 +243,7 @@ def execute(self): query_list.append('APPLY BATCH;') - execute('\n'.join(query_list), parameters) + execute('\n'.join(query_list), parameters, self._consistency) self.queries = [] diff --git a/cqlengine/tests/test_consistency.py b/cqlengine/tests/test_consistency.py index 27c455ef7d..f4c5986b80 100644 --- a/cqlengine/tests/test_consistency.py +++ b/cqlengine/tests/test_consistency.py @@ -5,7 +5,7 @@ from cqlengine import columns import mock from cqlengine.connection import ConnectionPool -from cqlengine import ALL +from cqlengine import ALL, BatchQuery class TestConsistencyModel(Model): id = columns.UUID(primary_key=True, default=lambda:uuid4()) @@ -50,3 +50,18 @@ def test_update_uses_consistency(self): self.assertEqual(ALL, args[0][2]) + def test_batch_consistency(self): + + with mock.patch.object(ConnectionPool, 'execute') as m: + with BatchQuery(consistency=ALL) as b: + TestConsistencyModel.batch(b).create(text="monkey") + + args = m.call_args + self.assertEqual(ALL, args[0][2]) + + with mock.patch.object(ConnectionPool, 'execute') as m: + with BatchQuery() as b: + TestConsistencyModel.batch(b).create(text="monkey") + + args = m.call_args + self.assertNotEqual(ALL, args[0][2]) From 691ddb31718d03b13c38f85797cec062b1294c5c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 18:20:30 -0700 Subject: [PATCH 0442/4200] beginning queryset refactor with some basic statement and operator classes and a bunch of empty test files --- cqlengine/operators.py | 71 +++++++++++++++++++ cqlengine/statements.py | 51 +++++++++++++ cqlengine/tests/operators/__init__.py | 1 + .../operators/test_assignment_operators.py | 0 .../tests/operators/test_base_operator.py | 9 +++ .../tests/operators/test_where_operators.py | 30 ++++++++ cqlengine/tests/statements/__init__.py | 1 + .../tests/statements/test_base_statement.py | 0 .../tests/statements/test_delete_statement.py | 0 .../tests/statements/test_dml_statement.py | 0 .../tests/statements/test_insert_statement.py | 0 .../tests/statements/test_select_statement.py | 0 .../tests/statements/test_update_statement.py | 0 .../tests/statements/test_where_clause.py | 13 ++++ 14 files changed, 176 insertions(+) create mode 100644 cqlengine/operators.py create mode 100644 cqlengine/statements.py create mode 100644 cqlengine/tests/operators/__init__.py create mode 100644 cqlengine/tests/operators/test_assignment_operators.py create mode 100644 cqlengine/tests/operators/test_base_operator.py create mode 100644 cqlengine/tests/operators/test_where_operators.py create mode 100644 cqlengine/tests/statements/__init__.py create mode 100644 cqlengine/tests/statements/test_base_statement.py create mode 100644 cqlengine/tests/statements/test_delete_statement.py create mode 100644 cqlengine/tests/statements/test_dml_statement.py create mode 100644 cqlengine/tests/statements/test_insert_statement.py create mode 100644 cqlengine/tests/statements/test_select_statement.py create mode 100644 cqlengine/tests/statements/test_update_statement.py create mode 100644 cqlengine/tests/statements/test_where_clause.py diff --git a/cqlengine/operators.py b/cqlengine/operators.py new file mode 100644 index 0000000000..dae52ee4e5 --- /dev/null +++ b/cqlengine/operators.py @@ -0,0 +1,71 @@ +class QueryOperatorException(Exception): pass + + +class BaseQueryOperator(object): + # The symbol that identifies this operator in kwargs + # ie: colname__ + symbol = None + + # The comparator symbol this operator uses in cql + cql_symbol = None + + def __unicode__(self): + if self.cql_symbol is None: + raise QueryOperatorException("cql symbol is None") + return self.cql_symbol + + @classmethod + def get_operator(cls, symbol): + if cls == BaseQueryOperator: + raise QueryOperatorException("get_operator can only be called from a BaseQueryOperator subclass") + if not hasattr(cls, 'opmap'): + cls.opmap = {} + def _recurse(klass): + if klass.symbol: + cls.opmap[klass.symbol.upper()] = klass + for subklass in klass.__subclasses__(): + _recurse(subklass) + pass + _recurse(cls) + try: + return cls.opmap[symbol.upper()] + except KeyError: + raise QueryOperatorException("{} doesn't map to a QueryOperator".format(symbol)) + + +class BaseWhereOperator(BaseQueryOperator): + """ base operator used for where clauses """ + + +class EqualsOperator(BaseWhereOperator): + symbol = 'EQ' + cql_symbol = '=' + + +class InOperator(EqualsOperator): + symbol = 'IN' + cql_symbol = 'IN' + + +class GreaterThanOperator(BaseWhereOperator): + symbol = "GT" + cql_symbol = '>' + + +class GreaterThanOrEqualOperator(BaseWhereOperator): + symbol = "GTE" + cql_symbol = '>=' + + +class LessThanOperator(BaseWhereOperator): + symbol = "LT" + cql_symbol = '<' + + +class LessThanOrEqualOperator(BaseWhereOperator): + symbol = "LTE" + cql_symbol = '<=' + + +class BaseAssignmentOperator(BaseQueryOperator): + """ base operator used for insert and delete statements """ \ No newline at end of file diff --git a/cqlengine/statements.py b/cqlengine/statements.py new file mode 100644 index 0000000000..e1f5c2deee --- /dev/null +++ b/cqlengine/statements.py @@ -0,0 +1,51 @@ +from cqlengine.operators import BaseWhereOperator + + +class StatementException(Exception): pass + + +class WhereClause(object): + """ a single where statement used in queries """ + + def __init__(self, field, operator, value): + super(WhereClause, self).__init__() + if not isinstance(operator, BaseWhereOperator): + raise StatementException( + "operator must be of type {}, got {}".format(BaseWhereOperator, type(operator)) + ) + + self.field = field + self.operator = operator + self.value = value + + +class BaseCQLStatement(object): + """ The base cql statement class """ + + def __init__(self, table, consistency=None): + super(BaseCQLStatement, self).__init__() + self.table = table + self.consistency = None + self.where_clauses = [] + + + + +class SelectStatement(BaseCQLStatement): + """ a cql select statement """ + + +class DMLStatement(BaseCQLStatement): + """ mutation statements """ + + +class InsertStatement(BaseCQLStatement): + """ an cql insert select statement """ + + +class UpdateStatement(BaseCQLStatement): + """ an cql update select statement """ + + +class DeleteStatement(BaseCQLStatement): + """ a cql delete statement """ diff --git a/cqlengine/tests/operators/__init__.py b/cqlengine/tests/operators/__init__.py new file mode 100644 index 0000000000..f6150a6d76 --- /dev/null +++ b/cqlengine/tests/operators/__init__.py @@ -0,0 +1 @@ +__author__ = 'bdeggleston' diff --git a/cqlengine/tests/operators/test_assignment_operators.py b/cqlengine/tests/operators/test_assignment_operators.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/operators/test_base_operator.py b/cqlengine/tests/operators/test_base_operator.py new file mode 100644 index 0000000000..af13fdb115 --- /dev/null +++ b/cqlengine/tests/operators/test_base_operator.py @@ -0,0 +1,9 @@ +from unittest import TestCase +from cqlengine.operators import BaseQueryOperator, QueryOperatorException + + +class BaseOperatorTest(TestCase): + + def test_get_operator_cannot_be_called_from_base_class(self): + with self.assertRaises(QueryOperatorException): + BaseQueryOperator.get_operator('*') \ No newline at end of file diff --git a/cqlengine/tests/operators/test_where_operators.py b/cqlengine/tests/operators/test_where_operators.py new file mode 100644 index 0000000000..f8f0e8fad8 --- /dev/null +++ b/cqlengine/tests/operators/test_where_operators.py @@ -0,0 +1,30 @@ +from unittest import TestCase +from cqlengine.operators import * + + +class TestWhereOperators(TestCase): + + def test_symbol_lookup(self): + """ tests where symbols are looked up properly """ + + def check_lookup(symbol, expected): + op = BaseWhereOperator.get_operator(symbol) + self.assertEqual(op, expected) + + check_lookup('EQ', EqualsOperator) + check_lookup('IN', InOperator) + check_lookup('GT', GreaterThanOperator) + check_lookup('GTE', GreaterThanOrEqualOperator) + check_lookup('LT', LessThanOperator) + check_lookup('LTE', LessThanOrEqualOperator) + + def test_operator_rendering(self): + """ tests symbols are rendered properly """ + self.assertEqual("=", unicode(EqualsOperator())) + self.assertEqual("IN", unicode(InOperator())) + self.assertEqual(">", unicode(GreaterThanOperator())) + self.assertEqual(">=", unicode(GreaterThanOrEqualOperator())) + self.assertEqual("<", unicode(LessThanOperator())) + self.assertEqual("<=", unicode(LessThanOrEqualOperator())) + + diff --git a/cqlengine/tests/statements/__init__.py b/cqlengine/tests/statements/__init__.py new file mode 100644 index 0000000000..f6150a6d76 --- /dev/null +++ b/cqlengine/tests/statements/__init__.py @@ -0,0 +1 @@ +__author__ = 'bdeggleston' diff --git a/cqlengine/tests/statements/test_base_statement.py b/cqlengine/tests/statements/test_base_statement.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/statements/test_delete_statement.py b/cqlengine/tests/statements/test_delete_statement.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/statements/test_dml_statement.py b/cqlengine/tests/statements/test_dml_statement.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/statements/test_insert_statement.py b/cqlengine/tests/statements/test_insert_statement.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/statements/test_update_statement.py b/cqlengine/tests/statements/test_update_statement.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/statements/test_where_clause.py b/cqlengine/tests/statements/test_where_clause.py new file mode 100644 index 0000000000..65300a97aa --- /dev/null +++ b/cqlengine/tests/statements/test_where_clause.py @@ -0,0 +1,13 @@ +from unittest import TestCase +from cqlengine.statements import StatementException, WhereClause + + +class TestWhereClause(TestCase): + + def test_operator_check(self): + """ tests that creating a where statement with a non BaseWhereOperator object fails """ + with self.assertRaises(StatementException): + WhereClause('a', 'b', 'c') + + def test_where_clause_rendering(self): + """ tests that where clauses are rendered properly """ \ No newline at end of file From 308fd2fbd92c14386bc9026da83335d5c8a49d12 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 18:25:22 -0700 Subject: [PATCH 0443/4200] adding where clause rendering --- cqlengine/operators.py | 3 +++ cqlengine/statements.py | 6 ++++++ cqlengine/tests/statements/test_where_clause.py | 6 +++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cqlengine/operators.py b/cqlengine/operators.py index dae52ee4e5..c5ad4a5669 100644 --- a/cqlengine/operators.py +++ b/cqlengine/operators.py @@ -14,6 +14,9 @@ def __unicode__(self): raise QueryOperatorException("cql symbol is None") return self.cql_symbol + def __str__(self): + return str(unicode(self)) + @classmethod def get_operator(cls, symbol): if cls == BaseQueryOperator: diff --git a/cqlengine/statements.py b/cqlengine/statements.py index e1f5c2deee..e3ab61bb73 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -18,6 +18,12 @@ def __init__(self, field, operator, value): self.operator = operator self.value = value + def __unicode__(self): + return u"{} {} {}".format(self.field, self.operator, self.value) + + def __str__(self): + return str(unicode(self)) + class BaseCQLStatement(object): """ The base cql statement class """ diff --git a/cqlengine/tests/statements/test_where_clause.py b/cqlengine/tests/statements/test_where_clause.py index 65300a97aa..a455203642 100644 --- a/cqlengine/tests/statements/test_where_clause.py +++ b/cqlengine/tests/statements/test_where_clause.py @@ -1,4 +1,5 @@ from unittest import TestCase +from cqlengine.operators import EqualsOperator from cqlengine.statements import StatementException, WhereClause @@ -10,4 +11,7 @@ def test_operator_check(self): WhereClause('a', 'b', 'c') def test_where_clause_rendering(self): - """ tests that where clauses are rendered properly """ \ No newline at end of file + """ tests that where clauses are rendered properly """ + wc = WhereClause('a', EqualsOperator(), 'c') + self.assertEqual("a = c", unicode(wc)) + self.assertEqual("a = c", str(wc)) From 3bbf307ca9f7ddb928c67cb547dbbe0aa5d38148 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 18:28:56 -0700 Subject: [PATCH 0444/4200] adding field listification --- cqlengine/statements.py | 8 ++++++-- cqlengine/tests/statements/test_select_statement.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index e3ab61bb73..f448f7ae87 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -35,11 +35,15 @@ def __init__(self, table, consistency=None): self.where_clauses = [] - - class SelectStatement(BaseCQLStatement): """ a cql select statement """ + def __init__(self, table, fields, consistency=None): + super(SelectStatement, self).__init__(table, consistency) + if isinstance(fields, basestring): + fields = [fields] + self.fields = fields + class DMLStatement(BaseCQLStatement): """ mutation statements """ diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index e69de29bb2..81f800d328 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -0,0 +1,10 @@ +from unittest import TestCase +from cqlengine.statements import SelectStatement + + +class SelectStatementTests(TestCase): + + def test_single_field_is_listified(self): + """ tests that passing a string field into the constructor puts it into a list """ + ss = SelectStatement('table', 'field') + self.assertEqual(ss.fields, ['field']) \ No newline at end of file From c3cf18bbdb5d28b4db1fc2988293a2a20685c3ed Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 24 Oct 2013 18:32:29 -0700 Subject: [PATCH 0445/4200] test for blind updates --- cqlengine/tests/test_consistency.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cqlengine/tests/test_consistency.py b/cqlengine/tests/test_consistency.py index f4c5986b80..8eadd9c8d6 100644 --- a/cqlengine/tests/test_consistency.py +++ b/cqlengine/tests/test_consistency.py @@ -65,3 +65,14 @@ def test_batch_consistency(self): args = m.call_args self.assertNotEqual(ALL, args[0][2]) + + def test_blind_update(self): + t = TestConsistencyModel.create(text="bacon and eggs") + t.text = "ham sandwich" + uid = t.id + + with mock.patch.object(ConnectionPool, 'execute') as m: + TestConsistencyModel.objects(id=uid).update(text="grilled cheese") + + args = m.call_args + self.assertEqual(ALL, args[0][2]) From 0d708730bbd9750e5608c053d5337bcce1255b67 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 18:34:16 -0700 Subject: [PATCH 0446/4200] adding select field rendering --- cqlengine/statements.py | 7 ++++++- cqlengine/tests/statements/test_select_statement.py | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index f448f7ae87..b68afcd42b 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -31,7 +31,7 @@ class BaseCQLStatement(object): def __init__(self, table, consistency=None): super(BaseCQLStatement, self).__init__() self.table = table - self.consistency = None + self.consistency = consistency self.where_clauses = [] @@ -44,6 +44,11 @@ def __init__(self, table, fields, consistency=None): fields = [fields] self.fields = fields + def __unicode__(self): + qs = ['SELECT'] + qs += [', '.join(self.fields) if self.fields else '*'] + return ' '.join(qs) + class DMLStatement(BaseCQLStatement): """ mutation statements """ diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index 81f800d328..9cf1e9db1e 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -7,4 +7,14 @@ class SelectStatementTests(TestCase): def test_single_field_is_listified(self): """ tests that passing a string field into the constructor puts it into a list """ ss = SelectStatement('table', 'field') - self.assertEqual(ss.fields, ['field']) \ No newline at end of file + self.assertEqual(ss.fields, ['field']) + + def test_field_rendering(self): + """ tests that fields are properly added to the select statement """ + ss = SelectStatement('table', ['f1', 'f2']) + self.assertTrue(unicode(ss).startswith('SELECT f1, f2')) + + def test_none_fields_rendering(self): + """ tests that a '*' is added if no fields are passed in """ + ss = SelectStatement('table', None) + self.assertTrue(unicode(ss).startswith('SELECT *')) From 39e5c08880eeb156ba2c8115b0895208a1768c3b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 19:05:09 -0700 Subject: [PATCH 0447/4200] adding more select rendering and beginning dml statements --- cqlengine/statements.py | 75 ++++++++++++++++--- .../tests/statements/test_insert_statement.py | 11 +++ .../tests/statements/test_select_statement.py | 26 ++++++- 3 files changed, 99 insertions(+), 13 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index b68afcd42b..96581599e3 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -19,7 +19,7 @@ def __init__(self, field, operator, value): self.value = value def __unicode__(self): - return u"{} {} {}".format(self.field, self.operator, self.value) + return u'"{}" {} {}'.format(self.field, self.operator, self.value) def __str__(self): return str(unicode(self)) @@ -28,39 +28,92 @@ def __str__(self): class BaseCQLStatement(object): """ The base cql statement class """ - def __init__(self, table, consistency=None): + def __init__(self, table, consistency=None, where=None): super(BaseCQLStatement, self).__init__() self.table = table self.consistency = consistency - self.where_clauses = [] + self.where_clauses = where or [] + + def add_where_clause(self, clause): + if not isinstance(clause, WhereClause): + raise StatementException("only instances of WhereClause can be added to statements") + self.where_clauses.append(clause) class SelectStatement(BaseCQLStatement): """ a cql select statement """ - def __init__(self, table, fields, consistency=None): - super(SelectStatement, self).__init__(table, consistency) - if isinstance(fields, basestring): - fields = [fields] - self.fields = fields + def __init__(self, + table, + fields, + consistency=None, + where=None, + order_by=None, + limit=None, + allow_filtering=False + ): + super(SelectStatement, self).__init__( + table, + consistency=consistency, + where=where + ) + + self.fields = [fields] if isinstance(fields, basestring) else fields + self.order_by = [order_by] if isinstance(order_by, basestring) else order_by + self.limit = limit + self.allow_filtering = allow_filtering def __unicode__(self): qs = ['SELECT'] - qs += [', '.join(self.fields) if self.fields else '*'] + qs += [', '.join(['"{}"'.format(f) for f in self.fields]) if self.fields else '*'] + qs += ['FROM', self.table] + + if self.where_clauses: + qs += ['WHERE', ' AND '.join([unicode(c) for c in self.where_clauses])] + + if self.order_by: + qs += ['ORDER BY {}'.format(', '.join(unicode(o) for o in self.order_by))] + + if self.limit: + qs += ['LIMIT {}'.format(self.limit)] + + if self.allow_filtering: + qs += ['ALLOW FILTERING'] + return ' '.join(qs) class DMLStatement(BaseCQLStatement): """ mutation statements """ + def __init__(self, table, consistency=None, where=None, ttl=None): + super(DMLStatement, self).__init__( + table, + consistency=consistency, + where=where) + self.ttl = ttl + -class InsertStatement(BaseCQLStatement): +class InsertStatement(DMLStatement): """ an cql insert select statement """ + def __init__(self, table, values, consistency=None): + super(InsertStatement, self).__init__( + table, + consistency=consistency, + where=None + ) + + def add_where_clause(self, clause): + raise StatementException("Cannot add where clauses to insert statements") -class UpdateStatement(BaseCQLStatement): + +class UpdateStatement(DMLStatement): """ an cql update select statement """ + def __init__(self, table, consistency=None, where=None): + super(UpdateStatement, self).__init__(table, consistency, where) + class DeleteStatement(BaseCQLStatement): """ a cql delete statement """ diff --git a/cqlengine/tests/statements/test_insert_statement.py b/cqlengine/tests/statements/test_insert_statement.py index e69de29bb2..6b88ef343c 100644 --- a/cqlengine/tests/statements/test_insert_statement.py +++ b/cqlengine/tests/statements/test_insert_statement.py @@ -0,0 +1,11 @@ +from unittest import TestCase +from cqlengine.statements import InsertStatement, StatementException + + +class InsertStatementTests(TestCase): + + def test_where_clause_failure(self): + """ tests that where clauses cannot be added to Insert statements """ + ist = InsertStatement('table') + with self.assertRaises(StatementException): + ist.add_where_clause('s') \ No newline at end of file diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index 9cf1e9db1e..bdaa3d8471 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -1,5 +1,6 @@ from unittest import TestCase -from cqlengine.statements import SelectStatement +from cqlengine.statements import SelectStatement, WhereClause +from cqlengine.operators import * class SelectStatementTests(TestCase): @@ -12,9 +13,30 @@ def test_single_field_is_listified(self): def test_field_rendering(self): """ tests that fields are properly added to the select statement """ ss = SelectStatement('table', ['f1', 'f2']) - self.assertTrue(unicode(ss).startswith('SELECT f1, f2')) + self.assertTrue(unicode(ss).startswith('SELECT "f1", "f2"')) + self.assertTrue(str(ss).startswith('SELECT "f1", "f2"')) def test_none_fields_rendering(self): """ tests that a '*' is added if no fields are passed in """ ss = SelectStatement('table', None) self.assertTrue(unicode(ss).startswith('SELECT *')) + self.assertTrue(str(ss).startswith('SELECT *')) + + def test_table_rendering(self): + ss = SelectStatement('table', None) + self.assertTrue(unicode(ss).startswith('SELECT * FROM table')) + self.assertTrue(str(ss).startswith('SELECT * FROM table')) + + def test_where_clause_rendering(self): + ss = SelectStatement('table', None) + ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) + self.assertEqual(unicode(ss), 'SELECT * FROM table WHERE "a" = b') + + def test_order_by_rendering(self): + pass + + def test_limit_rendering(self): + pass + + def test_allow_filtering_rendering(self): + pass From 136339d9d3ce1bdb180a2881bd1d686197a76bc8 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 19:10:46 -0700 Subject: [PATCH 0448/4200] moving where rendering into it's own property --- cqlengine/statements.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 96581599e3..6dc9a75ed9 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -39,6 +39,10 @@ def add_where_clause(self, clause): raise StatementException("only instances of WhereClause can be added to statements") self.where_clauses.append(clause) + @property + def _where(self): + return 'WHERE {}'.format(' AND '.join([unicode(c) for c in self.where_clauses)) + class SelectStatement(BaseCQLStatement): """ a cql select statement """ @@ -69,7 +73,7 @@ def __unicode__(self): qs += ['FROM', self.table] if self.where_clauses: - qs += ['WHERE', ' AND '.join([unicode(c) for c in self.where_clauses])] + qs += [self._where] if self.order_by: qs += ['ORDER BY {}'.format(', '.join(unicode(o) for o in self.order_by))] @@ -103,6 +107,7 @@ def __init__(self, table, values, consistency=None): consistency=consistency, where=None ) + self.values = values def add_where_clause(self, clause): raise StatementException("Cannot add where clauses to insert statements") From b23cb13ad8751cc34fcf51ef1a5be496ce8a71f2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 21:21:47 -0700 Subject: [PATCH 0449/4200] fixing str tests --- cqlengine/statements.py | 5 ++++- .../tests/statements/test_select_statement.py | 14 +++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 6dc9a75ed9..ee289b44d0 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -39,9 +39,12 @@ def add_where_clause(self, clause): raise StatementException("only instances of WhereClause can be added to statements") self.where_clauses.append(clause) + def __str__(self): + return str(unicode(self)) + @property def _where(self): - return 'WHERE {}'.format(' AND '.join([unicode(c) for c in self.where_clauses)) + return 'WHERE {}'.format(' AND '.join([unicode(c) for c in self.where_clauses])) class SelectStatement(BaseCQLStatement): diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index bdaa3d8471..d69f77bad8 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -13,24 +13,24 @@ def test_single_field_is_listified(self): def test_field_rendering(self): """ tests that fields are properly added to the select statement """ ss = SelectStatement('table', ['f1', 'f2']) - self.assertTrue(unicode(ss).startswith('SELECT "f1", "f2"')) - self.assertTrue(str(ss).startswith('SELECT "f1", "f2"')) + self.assertTrue(unicode(ss).startswith('SELECT "f1", "f2"'), unicode(ss)) + self.assertTrue(str(ss).startswith('SELECT "f1", "f2"'), str(ss)) def test_none_fields_rendering(self): """ tests that a '*' is added if no fields are passed in """ ss = SelectStatement('table', None) - self.assertTrue(unicode(ss).startswith('SELECT *')) - self.assertTrue(str(ss).startswith('SELECT *')) + self.assertTrue(unicode(ss).startswith('SELECT *'), unicode(ss)) + self.assertTrue(str(ss).startswith('SELECT *'), str(ss)) def test_table_rendering(self): ss = SelectStatement('table', None) - self.assertTrue(unicode(ss).startswith('SELECT * FROM table')) - self.assertTrue(str(ss).startswith('SELECT * FROM table')) + self.assertTrue(unicode(ss).startswith('SELECT * FROM table'), unicode(ss)) + self.assertTrue(str(ss).startswith('SELECT * FROM table'), str(ss)) def test_where_clause_rendering(self): ss = SelectStatement('table', None) ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) - self.assertEqual(unicode(ss), 'SELECT * FROM table WHERE "a" = b') + self.assertEqual(unicode(ss), 'SELECT * FROM table WHERE "a" = b', unicode(ss)) def test_order_by_rendering(self): pass From 1207c5fea5e41ef5b92875b30b161223bf5fa3ad Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 21:23:15 -0700 Subject: [PATCH 0450/4200] removing the dev mailing list from the docs --- README.md | 2 -- docs/index.rst | 2 -- setup.py | 2 -- 3 files changed, 6 deletions(-) diff --git a/README.md b/README.md index a1242e34fc..15c591cb8b 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,6 @@ cqlengine is a Cassandra CQL 3 Object Mapper for Python [Users Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-users) -[Dev Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-dev) - ## Installation ``` pip install cqlengine diff --git a/docs/index.rst b/docs/index.rst index e4e8c394fd..d748f39fd1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -96,8 +96,6 @@ Getting Started `Users Mailing List `_ -`Dev Mailing List `_ - Indices and tables ================== diff --git a/setup.py b/setup.py index 3bb09c9c2c..2965a6d73a 100644 --- a/setup.py +++ b/setup.py @@ -14,8 +14,6 @@ [Report a Bug](https://github.com/bdeggleston/cqlengine/issues) [Users Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-users) - -[Dev Mailing List](https://groups.google.com/forum/?fromgroups#!forum/cqlengine-dev) """ setup( From 5733a05684cce03915dcbea7d157cb3560b49257 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 21:33:50 -0700 Subject: [PATCH 0451/4200] adding tests around misc select statement rendering --- .../tests/statements/test_select_statement.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index d69f77bad8..ec7673cd29 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -32,11 +32,15 @@ def test_where_clause_rendering(self): ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) self.assertEqual(unicode(ss), 'SELECT * FROM table WHERE "a" = b', unicode(ss)) - def test_order_by_rendering(self): - pass - - def test_limit_rendering(self): - pass - - def test_allow_filtering_rendering(self): - pass + def test_additional_rendering(self): + ss = SelectStatement( + 'table', + None, + order_by=['x', 'y'], + limit=15, + allow_filtering=True + ) + qstr = unicode(ss) + self.assertIn('LIMIT 15', qstr) + self.assertIn('ORDER BY x, y', qstr) + self.assertIn('ALLOW FILTERING', qstr) From e59d75977f683ca135d2a02f9907cdfc0cb9b95c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 21:34:51 -0700 Subject: [PATCH 0452/4200] fixing statement tests --- cqlengine/tests/statements/test_insert_statement.py | 2 +- cqlengine/tests/statements/test_where_clause.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cqlengine/tests/statements/test_insert_statement.py b/cqlengine/tests/statements/test_insert_statement.py index 6b88ef343c..f81d8ea68d 100644 --- a/cqlengine/tests/statements/test_insert_statement.py +++ b/cqlengine/tests/statements/test_insert_statement.py @@ -6,6 +6,6 @@ class InsertStatementTests(TestCase): def test_where_clause_failure(self): """ tests that where clauses cannot be added to Insert statements """ - ist = InsertStatement('table') + ist = InsertStatement('table', {}) with self.assertRaises(StatementException): ist.add_where_clause('s') \ No newline at end of file diff --git a/cqlengine/tests/statements/test_where_clause.py b/cqlengine/tests/statements/test_where_clause.py index a455203642..353e506fd7 100644 --- a/cqlengine/tests/statements/test_where_clause.py +++ b/cqlengine/tests/statements/test_where_clause.py @@ -13,5 +13,5 @@ def test_operator_check(self): def test_where_clause_rendering(self): """ tests that where clauses are rendered properly """ wc = WhereClause('a', EqualsOperator(), 'c') - self.assertEqual("a = c", unicode(wc)) - self.assertEqual("a = c", str(wc)) + self.assertEqual('"a" = c', unicode(wc)) + self.assertEqual('"a" = c', str(wc)) From e6a411e3fc7e2920a62e0fd04e4e2b472879ecf7 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 21:42:20 -0700 Subject: [PATCH 0453/4200] adding a set clause --- cqlengine/statements.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index ee289b44d0..4535cc151c 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -1,19 +1,12 @@ -from cqlengine.operators import BaseWhereOperator +from cqlengine.operators import BaseWhereOperator, BaseAssignmentOperator class StatementException(Exception): pass -class WhereClause(object): - """ a single where statement used in queries """ +class BaseClause(object): def __init__(self, field, operator, value): - super(WhereClause, self).__init__() - if not isinstance(operator, BaseWhereOperator): - raise StatementException( - "operator must be of type {}, got {}".format(BaseWhereOperator, type(operator)) - ) - self.field = field self.operator = operator self.value = value @@ -25,6 +18,28 @@ def __str__(self): return str(unicode(self)) +class WhereClause(BaseClause): + """ a single where statement used in queries """ + + def __init__(self, field, operator, value): + if not isinstance(operator, BaseWhereOperator): + raise StatementException( + "operator must be of type {}, got {}".format(BaseWhereOperator, type(operator)) + ) + super(WhereClause, self).__init__(field, operator, value) + + +class SetClause(BaseClause): + """ a single variable st statement """ + + def __init__(self, field, operator, value): + if not isinstance(operator, BaseAssignmentOperator): + raise StatementException( + "operator must be of type {}, got {}".format(BaseAssignmentOperator, type(operator)) + ) + super(SetClause, self).__init__(field, operator, value) + + class BaseCQLStatement(object): """ The base cql statement class """ From 1d816dd07c5099898cf1be40523269adcd2332d4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 21:50:45 -0700 Subject: [PATCH 0454/4200] adding assignment statement base class --- cqlengine/statements.py | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 4535cc151c..d308d6dc72 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -29,7 +29,7 @@ def __init__(self, field, operator, value): super(WhereClause, self).__init__(field, operator, value) -class SetClause(BaseClause): +class AssignmentClause(BaseClause): """ a single variable st statement """ def __init__(self, field, operator, value): @@ -37,7 +37,7 @@ def __init__(self, field, operator, value): raise StatementException( "operator must be of type {}, got {}".format(BaseAssignmentOperator, type(operator)) ) - super(SetClause, self).__init__(field, operator, value) + super(AssignmentClause, self).__init__(field, operator, value) class BaseCQLStatement(object): @@ -72,8 +72,8 @@ def __init__(self, where=None, order_by=None, limit=None, - allow_filtering=False - ): + allow_filtering=False): + super(SelectStatement, self).__init__( table, consistency=consistency, @@ -106,7 +106,7 @@ def __unicode__(self): class DMLStatement(BaseCQLStatement): - """ mutation statements """ + """ mutation statements with ttls """ def __init__(self, table, consistency=None, where=None, ttl=None): super(DMLStatement, self).__init__( @@ -116,7 +116,30 @@ def __init__(self, table, consistency=None, where=None, ttl=None): self.ttl = ttl -class InsertStatement(DMLStatement): +class AssignmentStatement(DMLStatement): + """ value assignment statements """ + + def __init__(self, + table, + assignments, + consistency=None, + where=None, + ttl=None): + super(AssignmentStatement, self).__init__( + table, + consistency=consistency, + where=where, + ttl=ttl + ) + self.assignments = assignments or [] + + def add_assignment_clause(self, clause): + if not isinstance(clause, AssignmentClause): + raise StatementException("only instances of AssignmentClause can be added to statements") + self.assignments.append(clause) + + +class InsertStatement(AssignmentStatement): """ an cql insert select statement """ def __init__(self, table, values, consistency=None): @@ -131,12 +154,12 @@ def add_where_clause(self, clause): raise StatementException("Cannot add where clauses to insert statements") -class UpdateStatement(DMLStatement): +class UpdateStatement(AssignmentStatement): """ an cql update select statement """ def __init__(self, table, consistency=None, where=None): super(UpdateStatement, self).__init__(table, consistency, where) -class DeleteStatement(BaseCQLStatement): +class DeleteStatement(DMLStatement): """ a cql delete statement """ From 822f6f3c2d47c76ec9e2b7c7eb195a85a0a45171 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 24 Oct 2013 21:58:02 -0700 Subject: [PATCH 0455/4200] setting up constructors for other statements --- cqlengine/statements.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index d308d6dc72..45f8a49b00 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -80,7 +80,7 @@ def __init__(self, where=where ) - self.fields = [fields] if isinstance(fields, basestring) else fields + self.fields = [fields] if isinstance(fields, basestring) else (fields or []) self.order_by = [order_by] if isinstance(order_by, basestring) else order_by self.limit = limit self.allow_filtering = allow_filtering @@ -157,9 +157,26 @@ def add_where_clause(self, clause): class UpdateStatement(AssignmentStatement): """ an cql update select statement """ - def __init__(self, table, consistency=None, where=None): - super(UpdateStatement, self).__init__(table, consistency, where) + def __init__(self, table, assignments, consistency=None, where=None, ttl=None): + super(UpdateStatement, self).__init__( + table, + assignments, + consistency=consistency, + where=where, + ttl=ttl + ) class DeleteStatement(DMLStatement): """ a cql delete statement """ + + def __init__(self, table, fields, consistency=None, where=None, ttl=None): + super(DeleteStatement, self).__init__( + table, + consistency=consistency, + where=where, + ttl=ttl + ) + self.fields = [fields] if isinstance(fields, basestring) else (fields or []) + + From 58253b4ed73945b8ad5d6b81b5f5fb541ab82665 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 07:06:40 -0700 Subject: [PATCH 0456/4200] adding deterministic context ids --- cqlengine/operators.py | 10 +++++++++- cqlengine/statements.py | 32 +++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/cqlengine/operators.py b/cqlengine/operators.py index c5ad4a5669..cbbfe4ad66 100644 --- a/cqlengine/operators.py +++ b/cqlengine/operators.py @@ -71,4 +71,12 @@ class LessThanOrEqualOperator(BaseWhereOperator): class BaseAssignmentOperator(BaseQueryOperator): - """ base operator used for insert and delete statements """ \ No newline at end of file + """ base operator used for insert and delete statements """ + + +class AssignmentOperator(BaseAssignmentOperator): + cql_symbol = "=" + + +class AddSymbol(BaseAssignmentOperator): + cql_symbol = "+" \ No newline at end of file diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 45f8a49b00..1a7593d7df 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -10,13 +10,27 @@ def __init__(self, field, operator, value): self.field = field self.operator = operator self.value = value + self.context_id = None def __unicode__(self): - return u'"{}" {} {}'.format(self.field, self.operator, self.value) + return u'"{}" {} {}'.format(self.field, self.operator, self.context_id) def __str__(self): return str(unicode(self)) + def get_context_size(self): + """ returns the number of entries this clause will add to the query context """ + return 1 + + def set_context_id(self, i): + """ sets the value placeholder that will be used in the query """ + self.context_id = i + + def update_context(self, ctx): + """ updates the query context with this clauses values """ + assert isinstance(ctx, dict) + ctx[self.context_id] = self.value + class WhereClause(BaseClause): """ a single where statement used in queries """ @@ -47,11 +61,17 @@ def __init__(self, table, consistency=None, where=None): super(BaseCQLStatement, self).__init__() self.table = table self.consistency = consistency - self.where_clauses = where or [] + self.context_counter = 0 + + self.where_clauses = [] + for clause in where or []: + self.add_where_clause(clause) def add_where_clause(self, clause): if not isinstance(clause, WhereClause): raise StatementException("only instances of WhereClause can be added to statements") + clause.set_context_id(self.context_counter) + self.context_counter += clause.get_context_size() self.where_clauses.append(clause) def __str__(self): @@ -131,11 +151,17 @@ def __init__(self, where=where, ttl=ttl ) - self.assignments = assignments or [] + + # add assignments + self.assignments = [] + for assignment in assignments or []: + self.add_assignment_clause(assignment) def add_assignment_clause(self, clause): if not isinstance(clause, AssignmentClause): raise StatementException("only instances of AssignmentClause can be added to statements") + clause.set_context_id(self.context_counter) + self.context_counter += clause.get_context_size() self.assignments.append(clause) From 317b68815583c9e795a1fbddfa41366c7921f789 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 07:10:36 -0700 Subject: [PATCH 0457/4200] adding test around adding assignments --- .../tests/statements/test_assignment_statement.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 cqlengine/tests/statements/test_assignment_statement.py diff --git a/cqlengine/tests/statements/test_assignment_statement.py b/cqlengine/tests/statements/test_assignment_statement.py new file mode 100644 index 0000000000..695c7cebc8 --- /dev/null +++ b/cqlengine/tests/statements/test_assignment_statement.py @@ -0,0 +1,11 @@ +from unittest import TestCase +from cqlengine.statements import AssignmentStatement, StatementException + + +class AssignmentStatementTest(TestCase): + + def test_add_assignment_type_checking(self): + """ tests that only assignment clauses can be added to queries """ + stmt = AssignmentStatement('table', []) + with self.assertRaises(StatementException): + stmt.add_assignment_clause('x=5') \ No newline at end of file From 6bfd7536acfde03594c53a115a44a45ecc2922e3 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 07:12:11 -0700 Subject: [PATCH 0458/4200] adding test around adding where clauses --- cqlengine/tests/statements/test_base_statement.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cqlengine/tests/statements/test_base_statement.py b/cqlengine/tests/statements/test_base_statement.py index e69de29bb2..4acc1b926f 100644 --- a/cqlengine/tests/statements/test_base_statement.py +++ b/cqlengine/tests/statements/test_base_statement.py @@ -0,0 +1,11 @@ +from unittest import TestCase +from cqlengine.statements import BaseCQLStatement, StatementException + + +class BaseStatementTest(TestCase): + + def test_where_clause_type_checking(self): + """ tests that only assignment clauses can be added to queries """ + stmt = BaseCQLStatement('table', []) + with self.assertRaises(StatementException): + stmt.add_where_clause('x=5') From 66c625027c62f2ba3d5d6df3ceecb2bafc7beff4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 07:19:51 -0700 Subject: [PATCH 0459/4200] adding initial unicode rendering for insert statement --- cqlengine/statements.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 1a7593d7df..cbdd55bec4 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -53,6 +53,9 @@ def __init__(self, field, operator, value): ) super(AssignmentClause, self).__init__(field, operator, value) + def insert_tuple(self): + return self.field, self.context_id + class BaseCQLStatement(object): """ The base cql statement class """ @@ -179,6 +182,19 @@ def __init__(self, table, values, consistency=None): def add_where_clause(self, clause): raise StatementException("Cannot add where clauses to insert statements") + def __unicode__(self): + qs = ['INSERT INTO {}'.format(self.table)] + + # get column names and context placeholders + fields = [a.insert_tuple() for a in self.assignments] + columns, values = zip(*fields) + + qs += ["({})".format(', '.join(['"{}"'.format(c) for c in columns]))] + qs += ['VALUES'] + qs += ["({})".format(', '.join([':{}'.format(v) for v in values]))] + + return ' '.join(qs) + class UpdateStatement(AssignmentStatement): """ an cql update select statement """ From edcb766893b6e93f417fabef2aca5598d4cf970c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 07:27:31 -0700 Subject: [PATCH 0460/4200] adding test around clause context updating --- cqlengine/tests/statements/test_base_clause.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 cqlengine/tests/statements/test_base_clause.py diff --git a/cqlengine/tests/statements/test_base_clause.py b/cqlengine/tests/statements/test_base_clause.py new file mode 100644 index 0000000000..04d7d52845 --- /dev/null +++ b/cqlengine/tests/statements/test_base_clause.py @@ -0,0 +1,16 @@ +from unittest import TestCase +from cqlengine.statements import BaseClause + + +class BaseClauseTests(TestCase): + + def test_context_updating(self): + ss = BaseClause('a', 'b') + assert ss.get_context_size() == 1 + + ctx = {} + ss.set_context_id(10) + ss.update_context(ctx) + assert ctx == {10: 'b'} + + From 188fdd54aa20442beb74850a69df9c8aa2e5c91e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 07:30:13 -0700 Subject: [PATCH 0461/4200] reworkign clause construction and test around assignment clauses --- cqlengine/statements.py | 19 ++++++------------- .../statements/test_assignment_clause.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 13 deletions(-) create mode 100644 cqlengine/tests/statements/test_assignment_clause.py diff --git a/cqlengine/statements.py b/cqlengine/statements.py index cbdd55bec4..3c49127cc7 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -6,15 +6,11 @@ class StatementException(Exception): pass class BaseClause(object): - def __init__(self, field, operator, value): + def __init__(self, field, value): self.field = field - self.operator = operator self.value = value self.context_id = None - def __unicode__(self): - return u'"{}" {} {}'.format(self.field, self.operator, self.context_id) - def __str__(self): return str(unicode(self)) @@ -40,19 +36,16 @@ def __init__(self, field, operator, value): raise StatementException( "operator must be of type {}, got {}".format(BaseWhereOperator, type(operator)) ) - super(WhereClause, self).__init__(field, operator, value) + super(WhereClause, self).__init__(field, value) + self.operator = operator + + def __unicode__(self): + return u'"{}" {} {}'.format(self.field, self.operator, self.context_id) class AssignmentClause(BaseClause): """ a single variable st statement """ - def __init__(self, field, operator, value): - if not isinstance(operator, BaseAssignmentOperator): - raise StatementException( - "operator must be of type {}, got {}".format(BaseAssignmentOperator, type(operator)) - ) - super(AssignmentClause, self).__init__(field, operator, value) - def insert_tuple(self): return self.field, self.context_id diff --git a/cqlengine/tests/statements/test_assignment_clause.py b/cqlengine/tests/statements/test_assignment_clause.py new file mode 100644 index 0000000000..b778d4b4a8 --- /dev/null +++ b/cqlengine/tests/statements/test_assignment_clause.py @@ -0,0 +1,13 @@ +from unittest import TestCase +from cqlengine.statements import AssignmentClause + + +class AssignmentClauseTests(TestCase): + + def test_rendering(self): + pass + + def test_insert_tuple(self): + ac = AssignmentClause('a', 'b') + ac.set_context_id(10) + self.assertEqual(ac.insert_tuple(), ('a', 10)) From 0a4eebfcd5447b91d55ed3b57dfa0b92dcaf294e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 07:41:15 -0700 Subject: [PATCH 0462/4200] adding tests around insert statements and fixing a few things --- cqlengine/statements.py | 4 ++-- .../tests/statements/test_insert_statement.py | 19 ++++++++++++++++--- .../tests/statements/test_select_statement.py | 3 +++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 3c49127cc7..70db919773 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -164,13 +164,13 @@ def add_assignment_clause(self, clause): class InsertStatement(AssignmentStatement): """ an cql insert select statement """ - def __init__(self, table, values, consistency=None): + def __init__(self, table, assignments, consistency=None): super(InsertStatement, self).__init__( table, + assignments, consistency=consistency, where=None ) - self.values = values def add_where_clause(self, clause): raise StatementException("Cannot add where clauses to insert statements") diff --git a/cqlengine/tests/statements/test_insert_statement.py b/cqlengine/tests/statements/test_insert_statement.py index f81d8ea68d..093e799923 100644 --- a/cqlengine/tests/statements/test_insert_statement.py +++ b/cqlengine/tests/statements/test_insert_statement.py @@ -1,11 +1,24 @@ from unittest import TestCase -from cqlengine.statements import InsertStatement, StatementException +from cqlengine.statements import InsertStatement, StatementException, AssignmentClause class InsertStatementTests(TestCase): def test_where_clause_failure(self): """ tests that where clauses cannot be added to Insert statements """ - ist = InsertStatement('table', {}) + ist = InsertStatement('table', None) with self.assertRaises(StatementException): - ist.add_where_clause('s') \ No newline at end of file + ist.add_where_clause('s') + + def test_statement(self): + ist = InsertStatement('table', None) + ist.add_assignment_clause(AssignmentClause('a', 'b')) + ist.add_assignment_clause(AssignmentClause('c', 'd')) + + self.assertEqual( + unicode(ist), + 'INSERT INTO table ("a", "c") VALUES (:0, :1)' + ) + + def test_additional_rendering(self): + self.fail("Implement ttl and consistency") diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index ec7673cd29..13ed3d5005 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -44,3 +44,6 @@ def test_additional_rendering(self): self.assertIn('LIMIT 15', qstr) self.assertIn('ORDER BY x, y', qstr) self.assertIn('ALLOW FILTERING', qstr) + + self.fail("Implement ttl and consistency") + From 9413595f10b04dd5d29aa90a5c62733f88c17b21 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 25 Oct 2013 10:57:53 -0700 Subject: [PATCH 0463/4200] fixed ttl bug on updates --- cqlengine/query.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 0e8d9ce1e0..a88c744be4 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -805,10 +805,12 @@ def update(self, **values): ctx[field_id] = val if set_statements: - qs = "UPDATE {} SET {} WHERE {}".format( + ttl_stmt = "USING TTL {}".format(self._ttl) if self._ttl else "" + qs = "UPDATE {} SET {} WHERE {} {}".format( self.column_family_name, ', '.join(set_statements), - self._where_clause() + self._where_clause(), + ttl_stmt ) ctx.update(self._where_values()) execute(qs, ctx, self._consistency) From 735b05f6ef7213c0f8e26e0959f6bfb0af0c5ff6 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 25 Oct 2013 11:53:08 -0700 Subject: [PATCH 0464/4200] clone instead of modify self --- cqlengine/query.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index a88c744be4..a542c303d2 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -307,7 +307,7 @@ def __call__(self, *args, **kwargs): def __deepcopy__(self, memo): clone = self.__class__(self.model) for k,v in self.__dict__.items(): - if k in ['_con', '_cur', '_result_cache', '_result_idx']: + if k in ['_con', '_cur', '_result_cache', '_result_idx']: # don't clone these clone.__dict__[k] = None elif k == '_batch': # we need to keep the same batch instance across @@ -766,12 +766,14 @@ def values_list(self, *fields, **kwargs): return clone def consistency(self, consistency): - self._consistency = consistency - return self + clone = copy.deepcopy(self) + clone._consistency = consistency + return clone def ttl(self, ttl): - self._ttl = ttl - return self + clone = copy.deepcopy(self) + clone._ttl = ttl + return clone def update(self, **values): """ Updates the rows in this queryset """ From de041cd08d5281b993f20981d14195e83900ed96 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 11:57:23 -0700 Subject: [PATCH 0465/4200] adding method for determining which column values have changed --- cqlengine/models.py | 4 ++++ cqlengine/tests/model/test_model_io.py | 12 +++++++++--- docs/topics/models.rst | 5 +++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 93a9af984e..7012fc4471 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -409,6 +409,10 @@ def delete(self): """ Deletes this instance """ self.__dmlquery__(self.__class__, self, batch=self._batch).delete() + def get_changed_columns(self): + """ returns a list of the columns that have been updated since instantiation or save """ + return [k for k,v in self._values.items() if v.changed] + @classmethod def _class_batch(cls, batch): return cls.objects.batch(batch) diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 08d41d8b27..91d4591246 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -42,8 +42,6 @@ def test_model_save_and_load(self): for cname in tm._columns.keys(): self.assertEquals(getattr(tm, cname), getattr(tm2, cname)) - - def test_model_updating_works_properly(self): """ Tests that subsequent saves after initial model creation work @@ -78,7 +76,6 @@ def test_column_deleting_works_properly(self): assert tm2.text is None assert tm2._values['text'].previous_value is None - def test_a_sensical_error_is_raised_if_you_try_to_create_a_table_twice(self): """ """ @@ -92,6 +89,7 @@ class TestMultiKeyModel(Model): count = columns.Integer(required=False) text = columns.Text(required=False) + class TestDeleting(BaseCassEngTestCase): @classmethod @@ -158,6 +156,14 @@ def test_deleting_only(self): assert check.count is None assert check.text is None + def test_get_changed_columns(self): + assert self.instance.get_changed_columns() == [] + self.instance.count = 1 + changes = self.instance.get_changed_columns() + assert len(changes) == 1 + assert changes == ['count'] + self.instance.save() + assert self.instance.get_changed_columns() == [] class TestCanUpdate(BaseCassEngTestCase): diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 42d447f9d7..3e776e5be6 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -137,6 +137,7 @@ Model Methods .. method:: delete() Deletes the object from the database. + -- method:: update(**values) @@ -145,6 +146,10 @@ Model Methods fields. If no fields on the model have been modified since loading, no query will be performed. Model validation is performed normally. + -- method:: get_changed_columns() + + Returns a list of column names that have changed since the model was instantiated or saved + Model Attributes ================ From dfb062ec85c555c5b0bea2d490c0d61bcf7c93be Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 12:01:39 -0700 Subject: [PATCH 0466/4200] updating changelog --- changelog | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog b/changelog index 57f6fc2c6d..4aad4980c0 100644 --- a/changelog +++ b/changelog @@ -2,9 +2,12 @@ CHANGELOG 0.9 * adding update method +* adding support for ttls +* adding support for per-query consistency * adding BigInt column (thanks @Lifto) * adding support for timezone aware time uuid functions (thanks @dokai) * only saving collection fields on insert if they've been modified +* adding model method that returns a list of modified columns 0.8.5 * adding support for timeouts From b0feecfed31c81d45a4f8b5b62669e304a2ba2cd Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 12:03:24 -0700 Subject: [PATCH 0467/4200] bumping version number --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 7ada0d303f..b63ba696b7 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.8.5 +0.9 From aa87e3a3a6686abfe2b0ffb11f366bc1c067c62f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 25 Oct 2013 12:44:32 -0700 Subject: [PATCH 0468/4200] ignore docs build folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1e4d8eb344..1b5006e6b8 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ html/ /commitlog /data +docs/_build From 6056f2fac1d4ec695949f19de6fdc44231533775 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 25 Oct 2013 13:14:55 -0700 Subject: [PATCH 0469/4200] docs fix --- docs/topics/queryset.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index 6caf910612..2311801219 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -362,7 +362,7 @@ QuerySet method reference Sets the ttl to run the query query with. Note that running a select query with a ttl value will raise an exception - -- method:: update(**values) + .. method:: update(**values) Performs an update on the row selected by the queryset. Include values to update in the update like so: From 03e869c10ad1c7398cc00f11fa3c80a965068793 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 25 Oct 2013 13:20:01 -0700 Subject: [PATCH 0470/4200] ttl docs --- docs/topics/queryset.rst | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index 2311801219..a73ed1d05f 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -321,6 +321,15 @@ QuerySet method reference Returns a queryset matching all rows + .. method:: batch(batch_object) + + Sets the batch object to run the query on. Note that running a select query with a batch object will raise an exception + + .. method:: consistency(consistency_setting) + + Sets the consistency level for the operation. Options may be imported from the top level :attr:`cqlengine` package. + + .. method:: count() Returns the number of matching rows in your QuerySet @@ -354,12 +363,12 @@ QuerySet method reference Enables the (usually) unwise practive of querying on a clustering key without also defining a partition key - .. method:: batch(batch_object) - - Sets the batch object to run the query on. Note that running a select query with a batch object will raise an exception .. method:: ttl(ttl_in_seconds) + :param ttl_in_seconds: time in seconds in which the saved values should expire + :type ttl_in_seconds: int + Sets the ttl to run the query query with. Note that running a select query with a ttl value will raise an exception .. method:: update(**values) From 1e6462366184120b05e0b07ffaef6307230c6a67 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 13:48:03 -0700 Subject: [PATCH 0471/4200] implementing delete statement and adding method to get statement context --- cqlengine/statements.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 70db919773..7278b8c434 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -70,6 +70,12 @@ def add_where_clause(self, clause): self.context_counter += clause.get_context_size() self.where_clauses.append(clause) + def get_context(self): + ctx = {} + for clause in self.where_clauses or []: + clause.update_context(ctx) + return ctx + def __str__(self): return str(unicode(self)) @@ -83,7 +89,7 @@ class SelectStatement(BaseCQLStatement): def __init__(self, table, - fields, + fields=None, consistency=None, where=None, order_by=None, @@ -121,18 +127,7 @@ def __unicode__(self): return ' '.join(qs) -class DMLStatement(BaseCQLStatement): - """ mutation statements with ttls """ - - def __init__(self, table, consistency=None, where=None, ttl=None): - super(DMLStatement, self).__init__( - table, - consistency=consistency, - where=where) - self.ttl = ttl - - -class AssignmentStatement(DMLStatement): +class AssignmentStatement(BaseCQLStatement): """ value assignment statements """ def __init__(self, @@ -145,8 +140,8 @@ def __init__(self, table, consistency=consistency, where=where, - ttl=ttl ) + self.ttl = ttl # add assignments self.assignments = [] @@ -202,16 +197,24 @@ def __init__(self, table, assignments, consistency=None, where=None, ttl=None): ) -class DeleteStatement(DMLStatement): +class DeleteStatement(BaseCQLStatement): """ a cql delete statement """ - def __init__(self, table, fields, consistency=None, where=None, ttl=None): + def __init__(self, table, fields=None, consistency=None, where=None): super(DeleteStatement, self).__init__( table, consistency=consistency, where=where, - ttl=ttl ) self.fields = [fields] if isinstance(fields, basestring) else (fields or []) + def __unicode__(self): + qs = ['DELETE'] + qs += [', '.join(['"{}"'.format(f) for f in self.fields]) if self.fields else '*'] + qs += ['FROM', self.table] + + if self.where_clauses: + qs += [self._where] + + return ' '.join(qs) From 7771ceabd72c862ac10f3df74836005f26bf3f38 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 13:48:31 -0700 Subject: [PATCH 0472/4200] adding test around delete statement --- .../tests/statements/test_delete_statement.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/cqlengine/tests/statements/test_delete_statement.py b/cqlengine/tests/statements/test_delete_statement.py index e69de29bb2..da24b410f7 100644 --- a/cqlengine/tests/statements/test_delete_statement.py +++ b/cqlengine/tests/statements/test_delete_statement.py @@ -0,0 +1,38 @@ +from unittest import TestCase +from cqlengine.statements import DeleteStatement, WhereClause +from cqlengine.operators import * + + +class DeleteStatementTests(TestCase): + + def test_single_field_is_listified(self): + """ tests that passing a string field into the constructor puts it into a list """ + ds = DeleteStatement('table', 'field') + self.assertEqual(ds.fields, ['field']) + + def test_field_rendering(self): + """ tests that fields are properly added to the select statement """ + ds = DeleteStatement('table', ['f1', 'f2']) + self.assertTrue(unicode(ds).startswith('DELETE "f1", "f2"'), unicode(ds)) + self.assertTrue(str(ds).startswith('DELETE "f1", "f2"'), str(ds)) + + def test_none_fields_rendering(self): + """ tests that a '*' is added if no fields are passed in """ + ds = DeleteStatement('table', None) + self.assertTrue(unicode(ds).startswith('DELETE *'), unicode(ds)) + self.assertTrue(str(ds).startswith('DELETE *'), str(ds)) + + def test_table_rendering(self): + ds = DeleteStatement('table', None) + self.assertTrue(unicode(ds).startswith('DELETE * FROM table'), unicode(ds)) + self.assertTrue(str(ds).startswith('DELETE * FROM table'), str(ds)) + + def test_where_clause_rendering(self): + ds = DeleteStatement('table', None) + ds.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) + self.assertEqual(unicode(ds), 'DELETE * FROM table WHERE "a" = 0', unicode(ds)) + + def test_context(self): + ds = DeleteStatement('table', None) + ds.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) + self.assertEqual(ds.get_context(), {0: 'b'}) From ff1184080ceacfab6b9b18a975df8a3ee0d8dca5 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 13:48:38 -0700 Subject: [PATCH 0473/4200] adding context test to select statement --- cqlengine/tests/statements/test_select_statement.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index 13ed3d5005..25563c795d 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -32,6 +32,11 @@ def test_where_clause_rendering(self): ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) self.assertEqual(unicode(ss), 'SELECT * FROM table WHERE "a" = b', unicode(ss)) + def test_context(self): + ss = SelectStatement('table', None) + ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) + self.assertEqual(ss.get_context(), {0: 'b'}) + def test_additional_rendering(self): ss = SelectStatement( 'table', From 2b7dd6f22011a45f8d5dec3a6fd6e6f516a608ee Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 13:54:20 -0700 Subject: [PATCH 0474/4200] adding methods to the base assignment class --- cqlengine/statements.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 7278b8c434..387b61d714 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -64,6 +64,11 @@ def __init__(self, table, consistency=None, where=None): self.add_where_clause(clause) def add_where_clause(self, clause): + """ + adds a where clause to this statement + :param clause: the clause to add + :type clause: WhereClause + """ if not isinstance(clause, WhereClause): raise StatementException("only instances of WhereClause can be added to statements") clause.set_context_id(self.context_counter) @@ -71,6 +76,10 @@ def add_where_clause(self, clause): self.where_clauses.append(clause) def get_context(self): + """ + returns the context dict for this statement + :rtype: dict + """ ctx = {} for clause in self.where_clauses or []: clause.update_context(ctx) @@ -132,7 +141,7 @@ class AssignmentStatement(BaseCQLStatement): def __init__(self, table, - assignments, + assignments=None, consistency=None, where=None, ttl=None): @@ -149,12 +158,23 @@ def __init__(self, self.add_assignment_clause(assignment) def add_assignment_clause(self, clause): + """ + adds an assignment clause to this statement + :param clause: the clause to add + :type clause: AssignmentClause + """ if not isinstance(clause, AssignmentClause): raise StatementException("only instances of AssignmentClause can be added to statements") clause.set_context_id(self.context_counter) self.context_counter += clause.get_context_size() self.assignments.append(clause) + def get_context(self): + ctx = super(AssignmentStatement, self).get_context() + for clause in self.assignments: + clause.update_context(ctx) + return ctx + class InsertStatement(AssignmentStatement): """ an cql insert select statement """ From cc1533f099fab5d1ddebfa8cb40cd4951b56bf41 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 14:16:44 -0700 Subject: [PATCH 0475/4200] implementing update statement and insert ttl --- cqlengine/statements.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 387b61d714..36ef3bb4c9 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -179,14 +179,6 @@ def get_context(self): class InsertStatement(AssignmentStatement): """ an cql insert select statement """ - def __init__(self, table, assignments, consistency=None): - super(InsertStatement, self).__init__( - table, - assignments, - consistency=consistency, - where=None - ) - def add_where_clause(self, clause): raise StatementException("Cannot add where clauses to insert statements") @@ -201,20 +193,27 @@ def __unicode__(self): qs += ['VALUES'] qs += ["({})".format(', '.join([':{}'.format(v) for v in values]))] + if self.ttl: + qs += ["USING TTL {}".format(self.ttl)] + return ' '.join(qs) class UpdateStatement(AssignmentStatement): """ an cql update select statement """ - def __init__(self, table, assignments, consistency=None, where=None, ttl=None): - super(UpdateStatement, self).__init__( - table, - assignments, - consistency=consistency, - where=where, - ttl=ttl - ) + def __unicode__(self): + qs = ['UPDATE', self.table] + qs += 'SET' + qs += ', '.join([unicode(c) for c in self.assignments]) + + if self.where_clauses: + qs += [self._where] + + if self.ttl: + qs += ["USING TTL {}".format(self.ttl)] + + return ' '.join(qs) class DeleteStatement(BaseCQLStatement): From 70e446631b903a835994f2715bc8c583e991acd1 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 14:16:55 -0700 Subject: [PATCH 0476/4200] adding test around insert ttl --- cqlengine/tests/statements/test_insert_statement.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/statements/test_insert_statement.py b/cqlengine/tests/statements/test_insert_statement.py index 093e799923..51280a314f 100644 --- a/cqlengine/tests/statements/test_insert_statement.py +++ b/cqlengine/tests/statements/test_insert_statement.py @@ -21,4 +21,7 @@ def test_statement(self): ) def test_additional_rendering(self): - self.fail("Implement ttl and consistency") + ist = InsertStatement('table', ttl=60) + ist.add_assignment_clause(AssignmentClause('a', 'b')) + ist.add_assignment_clause(AssignmentClause('c', 'd')) + self.assertIn('USING TTL 60', unicode(ist)) From 89c20ef6c5e1d7717144c953583f1f2359321641 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 14:25:30 -0700 Subject: [PATCH 0477/4200] adding tests around update and related debugging --- cqlengine/statements.py | 15 ++++++++--- .../tests/statements/test_update_statement.py | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 36ef3bb4c9..110fc94dc2 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -11,6 +11,9 @@ def __init__(self, field, value): self.value = value self.context_id = None + def __unicode__(self): + raise NotImplementedError + def __str__(self): return str(unicode(self)) @@ -40,12 +43,15 @@ def __init__(self, field, operator, value): self.operator = operator def __unicode__(self): - return u'"{}" {} {}'.format(self.field, self.operator, self.context_id) + return u'"{}" {} :{}'.format(self.field, self.operator, self.context_id) class AssignmentClause(BaseClause): """ a single variable st statement """ + def __unicode__(self): + return u'"{}" = :{}'.format(self.field, self.context_id) + def insert_tuple(self): return self.field, self.context_id @@ -85,6 +91,9 @@ def get_context(self): clause.update_context(ctx) return ctx + def __unicode__(self): + raise NotImplementedError + def __str__(self): return str(unicode(self)) @@ -204,8 +213,8 @@ class UpdateStatement(AssignmentStatement): def __unicode__(self): qs = ['UPDATE', self.table] - qs += 'SET' - qs += ', '.join([unicode(c) for c in self.assignments]) + qs += ['SET'] + qs += [', '.join([unicode(c) for c in self.assignments])] if self.where_clauses: qs += [self._where] diff --git a/cqlengine/tests/statements/test_update_statement.py b/cqlengine/tests/statements/test_update_statement.py index e69de29bb2..1dabbfe3c7 100644 --- a/cqlengine/tests/statements/test_update_statement.py +++ b/cqlengine/tests/statements/test_update_statement.py @@ -0,0 +1,26 @@ +from unittest import TestCase +from cqlengine.statements import UpdateStatement, WhereClause, AssignmentClause +from cqlengine.operators import * + + +class UpdateStatementTests(TestCase): + + def test_table_rendering(self): + """ tests that fields are properly added to the select statement """ + us = UpdateStatement('table') + self.assertTrue(unicode(us).startswith('UPDATE table SET'), unicode(us)) + self.assertTrue(str(us).startswith('UPDATE table SET'), str(us)) + + def test_rendering(self): + us = UpdateStatement('table') + us.add_assignment_clause(AssignmentClause('a', 'b')) + us.add_assignment_clause(AssignmentClause('c', 'd')) + us.add_where_clause(WhereClause('a', EqualsOperator(), 'x')) + self.assertEqual(unicode(us), 'UPDATE table SET "a" = :0, "c" = :1 WHERE "a" = :2', unicode(us)) + + def test_context(self): + us = UpdateStatement('table') + us.add_assignment_clause(AssignmentClause('a', 'b')) + us.add_assignment_clause(AssignmentClause('c', 'd')) + us.add_where_clause(WhereClause('a', EqualsOperator(), 'x')) + self.assertEqual(us.get_context(), {0: 'b', 1: 'd', 2: 'x'}) From a7c250561ff8acd10c96d523fa472972e3105da8 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 14:37:15 -0700 Subject: [PATCH 0478/4200] adding test around update statement ttls --- cqlengine/models.py | 1 + cqlengine/tests/statements/test_update_statement.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/cqlengine/models.py b/cqlengine/models.py index 21f78c2638..b6da73c8ba 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -69,6 +69,7 @@ def __call__(self, *args, **kwargs): """ raise NotImplementedError + class TTLDescriptor(object): """ returns a query set descriptor diff --git a/cqlengine/tests/statements/test_update_statement.py b/cqlengine/tests/statements/test_update_statement.py index 1dabbfe3c7..6c633852d1 100644 --- a/cqlengine/tests/statements/test_update_statement.py +++ b/cqlengine/tests/statements/test_update_statement.py @@ -24,3 +24,10 @@ def test_context(self): us.add_assignment_clause(AssignmentClause('c', 'd')) us.add_where_clause(WhereClause('a', EqualsOperator(), 'x')) self.assertEqual(us.get_context(), {0: 'b', 1: 'd', 2: 'x'}) + + def test_additional_rendering(self): + us = UpdateStatement('table', ttl=60) + us.add_assignment_clause(AssignmentClause('a', 'b')) + us.add_where_clause(WhereClause('a', EqualsOperator(), 'x')) + self.assertIn('USING TTL 60', unicode(us)) + From 1ea0f057fac7b398959dcabf9d551c0d1765c51a Mon Sep 17 00:00:00 2001 From: Dvir Volk Date: Sat, 26 Oct 2013 00:50:18 +0300 Subject: [PATCH 0479/4200] Added __repr__ to model instances for prettly logging --- cqlengine/models.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cqlengine/models.py b/cqlengine/models.py index 21f78c2638..be48ca20de 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -247,6 +247,17 @@ def __init__(self, **values): self._is_persisted = False self._batch = None + + def __repr__(self): + """ + Pretty printing of models by their primary key + """ + return '{} <{}>'.format(self.__class__.__name__, + ', '.join(('{}={}'.format(k, getattr(self, k)) for k,v in self._primary_keys.iteritems())) + ) + + + @classmethod def _discover_polymorphic_submodels(cls): if not cls._is_polymorphic_base: From 09d440052963b70fd4c4232551d3dbbeed773bd3 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 15:00:05 -0700 Subject: [PATCH 0480/4200] removing dml statement tests --- cqlengine/tests/statements/test_dml_statement.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 cqlengine/tests/statements/test_dml_statement.py diff --git a/cqlengine/tests/statements/test_dml_statement.py b/cqlengine/tests/statements/test_dml_statement.py deleted file mode 100644 index e69de29bb2..0000000000 From 1bc88c547c23ede7a5ac3808ee4840944fa77f62 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 25 Oct 2013 15:03:27 -0700 Subject: [PATCH 0481/4200] version bump --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index b63ba696b7..f374f6662e 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.9 +0.9.1 From 7b24b93a53e3f67e0df0a426b668d0c51bdd246b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 15:05:48 -0700 Subject: [PATCH 0482/4200] adding count and fixing select tests --- cqlengine/statements.py | 7 ++++++- .../tests/statements/test_select_statement.py | 17 ++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 110fc94dc2..7a172bdc16 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -108,6 +108,7 @@ class SelectStatement(BaseCQLStatement): def __init__(self, table, fields=None, + count=False, consistency=None, where=None, order_by=None, @@ -121,13 +122,17 @@ def __init__(self, ) self.fields = [fields] if isinstance(fields, basestring) else (fields or []) + self.count = count self.order_by = [order_by] if isinstance(order_by, basestring) else order_by self.limit = limit self.allow_filtering = allow_filtering def __unicode__(self): qs = ['SELECT'] - qs += [', '.join(['"{}"'.format(f) for f in self.fields]) if self.fields else '*'] + if self.count: + qs += ['COUNT(*)'] + else: + qs += [', '.join(['"{}"'.format(f) for f in self.fields]) if self.fields else '*'] qs += ['FROM', self.table] if self.where_clauses: diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index 25563c795d..4150ef3b24 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -18,22 +18,27 @@ def test_field_rendering(self): def test_none_fields_rendering(self): """ tests that a '*' is added if no fields are passed in """ - ss = SelectStatement('table', None) + ss = SelectStatement('table') self.assertTrue(unicode(ss).startswith('SELECT *'), unicode(ss)) self.assertTrue(str(ss).startswith('SELECT *'), str(ss)) def test_table_rendering(self): - ss = SelectStatement('table', None) + ss = SelectStatement('table') self.assertTrue(unicode(ss).startswith('SELECT * FROM table'), unicode(ss)) self.assertTrue(str(ss).startswith('SELECT * FROM table'), str(ss)) def test_where_clause_rendering(self): - ss = SelectStatement('table', None) + ss = SelectStatement('table') ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) - self.assertEqual(unicode(ss), 'SELECT * FROM table WHERE "a" = b', unicode(ss)) + self.assertEqual(unicode(ss), 'SELECT * FROM table WHERE "a" = :0', unicode(ss)) + + def test_count(self): + ss = SelectStatement('table', count=True) + ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) + self.assertEqual(unicode(ss), 'SELECT COUNT(*) FROM table WHERE "a" = :0', unicode(ss)) def test_context(self): - ss = SelectStatement('table', None) + ss = SelectStatement('table') ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) self.assertEqual(ss.get_context(), {0: 'b'}) @@ -50,5 +55,3 @@ def test_additional_rendering(self): self.assertIn('ORDER BY x, y', qstr) self.assertIn('ALLOW FILTERING', qstr) - self.fail("Implement ttl and consistency") - From 80eba6678937a617ad62b3adf936edbf964a7998 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 15:06:35 -0700 Subject: [PATCH 0483/4200] adding context placeholders to some where tests --- cqlengine/tests/statements/test_delete_statement.py | 2 +- cqlengine/tests/statements/test_where_clause.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cqlengine/tests/statements/test_delete_statement.py b/cqlengine/tests/statements/test_delete_statement.py index da24b410f7..58d05c867e 100644 --- a/cqlengine/tests/statements/test_delete_statement.py +++ b/cqlengine/tests/statements/test_delete_statement.py @@ -30,7 +30,7 @@ def test_table_rendering(self): def test_where_clause_rendering(self): ds = DeleteStatement('table', None) ds.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) - self.assertEqual(unicode(ds), 'DELETE * FROM table WHERE "a" = 0', unicode(ds)) + self.assertEqual(unicode(ds), 'DELETE * FROM table WHERE "a" = :0', unicode(ds)) def test_context(self): ds = DeleteStatement('table', None) diff --git a/cqlengine/tests/statements/test_where_clause.py b/cqlengine/tests/statements/test_where_clause.py index 353e506fd7..66defeadeb 100644 --- a/cqlengine/tests/statements/test_where_clause.py +++ b/cqlengine/tests/statements/test_where_clause.py @@ -13,5 +13,6 @@ def test_operator_check(self): def test_where_clause_rendering(self): """ tests that where clauses are rendered properly """ wc = WhereClause('a', EqualsOperator(), 'c') - self.assertEqual('"a" = c', unicode(wc)) - self.assertEqual('"a" = c', str(wc)) + wc.set_context_id(5) + self.assertEqual('"a" = :5', unicode(wc)) + self.assertEqual('"a" = :5', str(wc)) From a3df87c558bfc6164544e0e747c91c953ea64265 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 15:59:14 -0700 Subject: [PATCH 0484/4200] replacing select query with statement object --- cqlengine/query.py | 64 +++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index a542c303d2..2e0ac06b6d 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -12,6 +12,8 @@ from cqlengine.exceptions import CQLEngineException, ValidationError from cqlengine.functions import QueryValue, Token +from cqlengine import statements, operators + #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index @@ -326,41 +328,22 @@ def __len__(self): #----query generation / execution---- - def _where_clause(self): - """ Returns a where clause based on the given filter args """ - return ' AND '.join([f.cql for f in self._where]) - - def _where_values(self): - """ Returns the value dict to be passed to the cql query """ - values = {} - for where in self._where: - values.update(where.get_dict()) - return values - - def _get_select_statement(self): - """ returns the select portion of this queryset's cql statement """ - raise NotImplementedError + def _select_fields(self): + """ returns the fields to select """ + return [] def _select_query(self): """ Returns a select clause based on the given filter args """ - qs = [self._get_select_statement()] - qs += ['FROM {}'.format(self.column_family_name)] - - if self._where: - qs += ['WHERE {}'.format(self._where_clause())] - - if self._order: - qs += ['ORDER BY {}'.format(', '.join(self._order))] - - if self._limit: - qs += ['LIMIT {}'.format(self._limit)] - - if self._allow_filtering: - qs += ['ALLOW FILTERING'] - - return ' '.join(qs) + return statements.SelectStatement( + self.column_family_name, + fields=self._select_fields(), + where=self._where, + order_by=self._order, + limit=self._limit, + allow_filtering=self._allow_filtering + ) #----Reads------ @@ -368,7 +351,8 @@ def _execute_query(self): if self._batch: raise CQLEngineException("Only inserts, updates, and deletes are available in batch mode") if self._result_cache is None: - columns, self._result_cache = execute(self._select_query(), self._where_values(), self._consistency) + query = self._select_query() + columns, self._result_cache = execute(unicode(query).encode('utf-8'), query.get_context(), self._consistency) self._construct_result = self._get_result_constructor(columns) def _fill_result_cache_to_idx(self, idx): @@ -477,7 +461,7 @@ def filter(self, *args, **kwargs): #add arguments to the where clause filters clone = copy.deepcopy(self) for operator in args: - if not isinstance(operator, QueryOperator): + if not isinstance(operator, statements.WhereClause): raise QueryException('{} is not a valid query operator'.format(operator)) clone._where.append(operator) @@ -493,10 +477,10 @@ def filter(self, *args, **kwargs): raise QueryException("Can't resolve column name: '{}'".format(col_name)) #get query operator, or use equals if not supplied - operator_class = QueryOperator.get_operator(col_op or 'EQ') - operator = operator_class(column, val) + operator_class = operators.BaseWhereOperator.get_operator(col_op or 'EQ') + operator = operator_class() - clone._where.append(operator) + clone._where.append(statements.WhereClause(col_name, operator, val)) return clone @@ -717,6 +701,16 @@ def _get_select_statement(self): else: return 'SELECT *' + def _select_fields(self): + if self._defer_fields or self._only_fields: + fields = self.model._columns.keys() + if self._defer_fields: + fields = [f for f in fields if f not in self._defer_fields] + elif self._only_fields: + fields = self._only_fields + return [self.model._columns[f].db_field_name for f in fields] + return super(ModelQuerySet, self)._select_fields() + def _get_result_constructor(self, names): """ Returns a function that will be used to instantiate query results """ if not self._values_list: From 181991233389aed18e867d46c616fbc53a4c86ee Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 16:01:12 -0700 Subject: [PATCH 0485/4200] fixing operator query objects --- cqlengine/query.py | 12 ++++++------ cqlengine/tests/query/test_queryset.py | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 2e0ac06b6d..bb2f0e82a7 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -181,22 +181,22 @@ def in_(self, item): used in where you'd typically want to use python's `in` operator """ - return InOperator(self._get_column(), item) + return statements.WhereClause(self._get_column(), operators.InOperator(), item) def __eq__(self, other): - return EqualsOperator(self._get_column(), other) + return statements.WhereClause(self._get_column(), operators.EqualsOperator(), other) def __gt__(self, other): - return GreaterThanOperator(self._get_column(), other) + return statements.WhereClause(self._get_column(), operators.GreaterThanOperator(), other) def __ge__(self, other): - return GreaterThanOrEqualOperator(self._get_column(), other) + return statements.WhereClause(self._get_column(), operators.GreaterThanOrEqualOperator(), other) def __lt__(self, other): - return LessThanOperator(self._get_column(), other) + return statements.WhereClause(self._get_column(), operators.LessThanOperator(), other) def __le__(self, other): - return LessThanOrEqualOperator(self._get_column(), other) + return statements.WhereClause(self._get_column(), operators.LessThanOrEqualOperator(), other) class BatchType(object): diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 4fe258178a..2ae18a07f7 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -14,6 +14,9 @@ from datetime import timedelta from datetime import tzinfo +from cqlengine import statements +from cqlengine import operators + class TzOffset(tzinfo): """Minimal implementation of a timezone offset to help testing with timezone @@ -61,14 +64,17 @@ def test_query_filter_parsing(self): assert len(query1._where) == 1 op = query1._where[0] - assert isinstance(op, query.EqualsOperator) + + assert isinstance(op, statements.WhereClause) + assert isinstance(op.operator, operators.EqualsOperator) assert op.value == 5 query2 = query1.filter(expected_result__gte=1) assert len(query2._where) == 2 op = query2._where[1] - assert isinstance(op, query.GreaterThanOrEqualOperator) + self.assertIsInstance(op, statements.WhereClause) + self.assertIsInstance(op.operator, operators.GreaterThanOrEqualOperator) assert op.value == 1 def test_query_expression_parsing(self): @@ -77,14 +83,16 @@ def test_query_expression_parsing(self): assert len(query1._where) == 1 op = query1._where[0] - assert isinstance(op, query.EqualsOperator) + assert isinstance(op, statements.WhereClause) + assert isinstance(op.operator, operators.EqualsOperator) assert op.value == 5 query2 = query1.filter(TestModel.expected_result >= 1) assert len(query2._where) == 2 op = query2._where[1] - assert isinstance(op, query.GreaterThanOrEqualOperator) + self.assertIsInstance(op, statements.WhereClause) + self.assertIsInstance(op.operator, operators.GreaterThanOrEqualOperator) assert op.value == 1 def test_using_invalid_column_names_in_filter_kwargs_raises_error(self): From 74ed4f4d001e6a1e31bb49e8e479f826c4adc45e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 16:02:00 -0700 Subject: [PATCH 0486/4200] removing obsolete tests --- cqlengine/tests/query/test_queryset.py | 33 -------------------------- 1 file changed, 33 deletions(-) diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 2ae18a07f7..8017c1578a 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -116,39 +116,6 @@ def test_using_non_query_operators_in_query_args_raises_error(self): with self.assertRaises(query.QueryException): TestModel.objects(5) - def test_filter_method_where_clause_generation(self): - """ - Tests the where clause creation - """ - query1 = TestModel.objects(test_id=5) - ids = [o.query_value.identifier for o in query1._where] - where = query1._where_clause() - assert where == '"test_id" = :{}'.format(*ids) - - query2 = query1.filter(expected_result__gte=1) - ids = [o.query_value.identifier for o in query2._where] - where = query2._where_clause() - assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) - - def test_query_expression_where_clause_generation(self): - """ - Tests the where clause creation - """ - query1 = TestModel.objects(TestModel.test_id == 5) - ids = [o.query_value.identifier for o in query1._where] - where = query1._where_clause() - assert where == '"test_id" = :{}'.format(*ids) - - query2 = query1.filter(TestModel.expected_result >= 1) - ids = [o.query_value.identifier for o in query2._where] - where = query2._where_clause() - assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) - - def test_querystring_generation(self): - """ - Tests the select querystring creation - """ - def test_queryset_is_immutable(self): """ Tests that calling a queryset function that changes it's state returns a new queryset From 181002486366eb00277a723daa9446f416d1025d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 17:16:43 -0700 Subject: [PATCH 0487/4200] getting basic select queries working properly --- cqlengine/models.py | 4 ++++ cqlengine/named.py | 4 ++++ cqlengine/query.py | 18 ++++++++++++------ cqlengine/statements.py | 2 +- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index b6da73c8ba..19a05f73a7 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -129,7 +129,11 @@ class ColumnQueryEvaluator(AbstractQueryableColumn): def __init__(self, column): self.column = column + def __unicode__(self): + return self.column.db_field_name + def _get_column(self): + """ :rtype: ColumnQueryEvaluator """ return self.column diff --git a/cqlengine/named.py b/cqlengine/named.py index c1d1263a82..2b75443144 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -33,7 +33,11 @@ class NamedColumn(AbstractQueryableColumn): def __init__(self, name): self.name = name + def __unicode__(self): + return self.name + def _get_column(self): + """ :rtype: NamedColumn """ return self @property diff --git a/cqlengine/query.py b/cqlengine/query.py index bb2f0e82a7..80201a5ac1 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -175,28 +175,34 @@ class AbstractQueryableColumn(object): def _get_column(self): raise NotImplementedError + def __unicode__(self): + raise NotImplementedError + + def __str__(self): + return str(unicode(self)) + def in_(self, item): """ Returns an in operator used in where you'd typically want to use python's `in` operator """ - return statements.WhereClause(self._get_column(), operators.InOperator(), item) + return statements.WhereClause(unicode(self), operators.InOperator(), item) def __eq__(self, other): - return statements.WhereClause(self._get_column(), operators.EqualsOperator(), other) + return statements.WhereClause(unicode(self), operators.EqualsOperator(), other) def __gt__(self, other): - return statements.WhereClause(self._get_column(), operators.GreaterThanOperator(), other) + return statements.WhereClause(unicode(self), operators.GreaterThanOperator(), other) def __ge__(self, other): - return statements.WhereClause(self._get_column(), operators.GreaterThanOrEqualOperator(), other) + return statements.WhereClause(unicode(self), operators.GreaterThanOrEqualOperator(), other) def __lt__(self, other): - return statements.WhereClause(self._get_column(), operators.LessThanOperator(), other) + return statements.WhereClause(unicode(self), operators.LessThanOperator(), other) def __le__(self, other): - return statements.WhereClause(self._get_column(), operators.LessThanOrEqualOperator(), other) + return statements.WhereClause(unicode(self), operators.LessThanOrEqualOperator(), other) class BatchType(object): diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 7a172bdc16..9aa6e7febf 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -28,7 +28,7 @@ def set_context_id(self, i): def update_context(self, ctx): """ updates the query context with this clauses values """ assert isinstance(ctx, dict) - ctx[self.context_id] = self.value + ctx[str(self.context_id)] = self.value class WhereClause(BaseClause): From 49e58cf9673642c1dc24ddd9c602cebf1a7c4489 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 25 Oct 2013 17:16:53 -0700 Subject: [PATCH 0488/4200] ensure last item is removed properly --- cqlengine/tests/columns/test_container_columns.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 0d3dc524b2..9fbef0d805 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -51,6 +51,17 @@ def test_empty_set_initial(self): m.int_set.add(5) m.save() + def test_deleting_last_item_should_succeed(self): + m = TestSetModel.create() + m.int_set.add(5) + m.save() + m.int_set.remove(5) + m.save() + + m = TestSetModel.get(partition=m.partition) + self.assertNotIn(5, m.int_set) + + def test_empty_set_retrieval(self): m = TestSetModel.create() m2 = TestSetModel.get(partition=m.partition) From 647ebaf5f078508f6e7682420b23b9d24c009a22 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 17:20:50 -0700 Subject: [PATCH 0489/4200] modifying statement, clause, and operator stringifying to encode the object's unicode --- cqlengine/operators.py | 2 +- cqlengine/statements.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cqlengine/operators.py b/cqlengine/operators.py index cbbfe4ad66..4fef0a6b5d 100644 --- a/cqlengine/operators.py +++ b/cqlengine/operators.py @@ -15,7 +15,7 @@ def __unicode__(self): return self.cql_symbol def __str__(self): - return str(unicode(self)) + return unicode(self).encode('utf-8') @classmethod def get_operator(cls, symbol): diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 9aa6e7febf..45124c76b4 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -15,7 +15,7 @@ def __unicode__(self): raise NotImplementedError def __str__(self): - return str(unicode(self)) + return unicode(self).encode('utf-8') def get_context_size(self): """ returns the number of entries this clause will add to the query context """ @@ -95,7 +95,7 @@ def __unicode__(self): raise NotImplementedError def __str__(self): - return str(unicode(self)) + return unicode(self).encode('utf-8') @property def _where(self): From ec2337858c011799d796bb7405af548828d3a0a4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Fri, 25 Oct 2013 17:23:51 -0700 Subject: [PATCH 0490/4200] excluding order and limit from count queries --- cqlengine/statements.py | 4 ++-- cqlengine/tests/statements/test_select_statement.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 45124c76b4..55dfa27757 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -138,10 +138,10 @@ def __unicode__(self): if self.where_clauses: qs += [self._where] - if self.order_by: + if self.order_by and not self.count: qs += ['ORDER BY {}'.format(', '.join(unicode(o) for o in self.order_by))] - if self.limit: + if self.limit and not self.count: qs += ['LIMIT {}'.format(self.limit)] if self.allow_filtering: diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index 4150ef3b24..495258c2fd 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -33,9 +33,11 @@ def test_where_clause_rendering(self): self.assertEqual(unicode(ss), 'SELECT * FROM table WHERE "a" = :0', unicode(ss)) def test_count(self): - ss = SelectStatement('table', count=True) + ss = SelectStatement('table', count=True, limit=10, order_by='d') ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) self.assertEqual(unicode(ss), 'SELECT COUNT(*) FROM table WHERE "a" = :0', unicode(ss)) + self.assertNotIn('LIMIT', unicode(ss)) + self.assertNotIn('ORDER', unicode(ss)) def test_context(self): ss = SelectStatement('table') From 802cd08e09866bc78a725036398ccef84c50e55c Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 25 Oct 2013 17:28:12 -0700 Subject: [PATCH 0491/4200] making sure deletion works on the last item --- cqlengine/tests/columns/test_container_columns.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 9fbef0d805..b1a7268062 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -343,6 +343,18 @@ def test_empty_retrieve(self): tmp2 = TestMapModel.get(partition=tmp.partition) tmp2.int_map['blah'] = 1 + def test_remove_last_entry_works(self): + tmp = TestMapModel.create() + tmp.text_map["blah"] = datetime.now() + tmp.save() + del tmp.text_map["blah"] + tmp.save() + + tmp = TestMapModel.get(partition=tmp.partition) + self.assertNotIn("blah", tmp.int_map) + + + def test_io_success(self): """ Tests that a basic usage works as expected """ k1 = uuid4() From 3556c99724a59a899f0f33e686c0e2e552359c35 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 27 Oct 2013 16:22:31 -0700 Subject: [PATCH 0492/4200] refactoring count query --- cqlengine/query.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 80201a5ac1..c011059867 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -539,18 +539,12 @@ def count(self): """ Returns the number of rows matched by this query """ if self._batch: raise CQLEngineException("Only inserts, updates, and deletes are available in batch mode") + #TODO: check for previous query execution and return row count if it exists if self._result_cache is None: - qs = ['SELECT COUNT(*)'] - qs += ['FROM {}'.format(self.column_family_name)] - if self._where: - qs += ['WHERE {}'.format(self._where_clause())] - if self._allow_filtering: - qs += ['ALLOW FILTERING'] - - qs = ' '.join(qs) - - _, result = execute(qs, self._where_values()) + query = self._select_query() + query.count = True + _, result = execute(str(query), query.get_context()) return result[0][0] else: return len(self._result_cache) From cbbef483b20d9613216d2c08326e18cfcdebbd39 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 27 Oct 2013 16:22:47 -0700 Subject: [PATCH 0493/4200] adding quoting wrapper --- cqlengine/statements.py | 43 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 55dfa27757..a7bc9ca882 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -1,9 +1,42 @@ -from cqlengine.operators import BaseWhereOperator, BaseAssignmentOperator +from cqlengine.operators import BaseWhereOperator, InOperator class StatementException(Exception): pass +class ValueQuoter(object): + + def __init__(self, value): + self.value = value + + def __unicode__(self): + from cql.query import cql_quote + if isinstance(self.value, bool): + return 'true' if self.value else 'false' + elif isinstance(self.value, (list, tuple)): + return '[' + ', '.join([cql_quote(v) for v in self.value]) + ']' + elif isinstance(self.value, dict): + return '{' + ', '.join([cql_quote(k) + ':' + cql_quote(v) for k,v in self.value.items()]) + '}' + elif isinstance(self.value, set): + return '{' + ', '.join([cql_quote(v) for v in self.value]) + '}' + return cql_quote(self.value) + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.value == other.value + return False + + def __str__(self): + return unicode(self).encode('utf-8') + + +class InQuoter(ValueQuoter): + + def __unicode__(self): + from cql.query import cql_quote + return '(' + ', '.join([cql_quote(v) for v in self.value]) + ')' + + class BaseClause(object): def __init__(self, field, value): @@ -28,7 +61,7 @@ def set_context_id(self, i): def update_context(self, ctx): """ updates the query context with this clauses values """ assert isinstance(ctx, dict) - ctx[str(self.context_id)] = self.value + ctx[str(self.context_id)] = ValueQuoter(self.value) class WhereClause(BaseClause): @@ -45,6 +78,12 @@ def __init__(self, field, operator, value): def __unicode__(self): return u'"{}" {} :{}'.format(self.field, self.operator, self.context_id) + def update_context(self, ctx): + if isinstance(self.operator, InOperator): + ctx[str(self.context_id)] = InQuoter(self.value) + else: + super(WhereClause, self).update_context(ctx) + class AssignmentClause(BaseClause): """ a single variable st statement """ From 88804adaf9155d5cc3e4c327cf66d87670c16b00 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 27 Oct 2013 16:32:59 -0700 Subject: [PATCH 0494/4200] adding where clause equality method --- cqlengine/query.py | 1 - cqlengine/statements.py | 13 +++++++++++++ cqlengine/tests/statements/test_quoter.py | 0 cqlengine/tests/statements/test_where_clause.py | 6 ++++++ 4 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 cqlengine/tests/statements/test_quoter.py diff --git a/cqlengine/query.py b/cqlengine/query.py index c011059867..464220a4a7 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -540,7 +540,6 @@ def count(self): if self._batch: raise CQLEngineException("Only inserts, updates, and deletes are available in batch mode") - #TODO: check for previous query execution and return row count if it exists if self._result_cache is None: query = self._select_query() query.count = True diff --git a/cqlengine/statements.py b/cqlengine/statements.py index a7bc9ca882..281701a971 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -50,6 +50,14 @@ def __unicode__(self): def __str__(self): return unicode(self).encode('utf-8') + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.field == other.field and self.value == other.value + return False + + def __ne__(self, other): + return not self.__eq__(other) + def get_context_size(self): """ returns the number of entries this clause will add to the query context """ return 1 @@ -78,6 +86,11 @@ def __init__(self, field, operator, value): def __unicode__(self): return u'"{}" {} :{}'.format(self.field, self.operator, self.context_id) + def __eq__(self, other): + if super(WhereClause, self).__eq__(other): + return self.operator.__class__ == other.operator.__class__ + return False + def update_context(self, ctx): if isinstance(self.operator, InOperator): ctx[str(self.context_id)] = InQuoter(self.value) diff --git a/cqlengine/tests/statements/test_quoter.py b/cqlengine/tests/statements/test_quoter.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cqlengine/tests/statements/test_where_clause.py b/cqlengine/tests/statements/test_where_clause.py index 66defeadeb..938a2b4919 100644 --- a/cqlengine/tests/statements/test_where_clause.py +++ b/cqlengine/tests/statements/test_where_clause.py @@ -16,3 +16,9 @@ def test_where_clause_rendering(self): wc.set_context_id(5) self.assertEqual('"a" = :5', unicode(wc)) self.assertEqual('"a" = :5', str(wc)) + + def test_equality_method(self): + """ tests that 2 identical where clauses evaluate as == """ + wc1 = WhereClause('a', EqualsOperator(), 'c') + wc2 = WhereClause('a', EqualsOperator(), 'c') + assert wc1 == wc2 From 14008640fca758dabd07e95370a8166cb9121e95 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 27 Oct 2013 16:40:39 -0700 Subject: [PATCH 0495/4200] commenting out old operators --- cqlengine/query.py | 312 +++++++++++++++++++++++---------------------- 1 file changed, 157 insertions(+), 155 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 464220a4a7..cd5d7cec52 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -12,159 +12,161 @@ from cqlengine.exceptions import CQLEngineException, ValidationError from cqlengine.functions import QueryValue, Token -from cqlengine import statements, operators - #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index +from cqlengine.operators import InOperator, EqualsOperator, GreaterThanOperator, GreaterThanOrEqualOperator +from cqlengine.operators import LessThanOperator, LessThanOrEqualOperator, BaseWhereOperator +from cqlengine.statements import WhereClause, SelectStatement + class QueryException(CQLEngineException): pass class DoesNotExist(QueryException): pass class MultipleObjectsReturned(QueryException): pass -class QueryOperatorException(QueryException): pass - - -class QueryOperator(object): - # The symbol that identifies this operator in filter kwargs - # ie: colname__ - symbol = None - - # The comparator symbol this operator uses in cql - cql_symbol = None - - QUERY_VALUE_WRAPPER = QueryValue - - def __init__(self, column, value): - self.column = column - self.value = value - - if isinstance(value, QueryValue): - self.query_value = value - else: - self.query_value = self.QUERY_VALUE_WRAPPER(value) - - #perform validation on this operator - self.validate_operator() - self.validate_value() - - @property - def cql(self): - """ - Returns this operator's portion of the WHERE clause - """ - return '{} {} {}'.format(self.column.cql, self.cql_symbol, self.query_value.cql) - - def validate_operator(self): - """ - Checks that this operator can be used on the column provided - """ - if self.symbol is None: - raise QueryOperatorException( - "{} is not a valid operator, use one with 'symbol' defined".format( - self.__class__.__name__ - ) - ) - if self.cql_symbol is None: - raise QueryOperatorException( - "{} is not a valid operator, use one with 'cql_symbol' defined".format( - self.__class__.__name__ - ) - ) - - def validate_value(self): - """ - Checks that the compare value works with this operator - - Doesn't do anything by default - """ - pass - - def get_dict(self): - """ - Returns this operators contribution to the cql.query arg dictionanry - - ie: if this column's name is colname, and the identifier is colval, - this should return the dict: {'colval':} - SELECT * FROM column_family WHERE colname=:colval - """ - return self.query_value.get_dict(self.column) - - @classmethod - def get_operator(cls, symbol): - if not hasattr(cls, 'opmap'): - QueryOperator.opmap = {} - def _recurse(klass): - if klass.symbol: - QueryOperator.opmap[klass.symbol.upper()] = klass - for subklass in klass.__subclasses__(): - _recurse(subklass) - pass - _recurse(QueryOperator) - try: - return QueryOperator.opmap[symbol.upper()] - except KeyError: - raise QueryOperatorException("{} doesn't map to a QueryOperator".format(symbol)) - - # equality operator, used by tests - - def __eq__(self, op): - return self.__class__ is op.__class__ and \ - self.column.db_field_name == op.column.db_field_name and \ - self.value == op.value - - def __ne__(self, op): - return not (self == op) - - def __hash__(self): - return hash(self.column.db_field_name) ^ hash(self.value) - - -class EqualsOperator(QueryOperator): - symbol = 'EQ' - cql_symbol = '=' - - -class IterableQueryValue(QueryValue): - def __init__(self, value): - try: - super(IterableQueryValue, self).__init__(value, [uuid4().hex for i in value]) - except TypeError: - raise QueryException("in operator arguments must be iterable, {} found".format(value)) - - def get_dict(self, column): - return dict((i, column.to_database(v)) for (i, v) in zip(self.identifier, self.value)) - - def get_cql(self): - return '({})'.format(', '.join(':{}'.format(i) for i in self.identifier)) - - -class InOperator(EqualsOperator): - symbol = 'IN' - cql_symbol = 'IN' - - QUERY_VALUE_WRAPPER = IterableQueryValue - - -class GreaterThanOperator(QueryOperator): - symbol = "GT" - cql_symbol = '>' - - -class GreaterThanOrEqualOperator(QueryOperator): - symbol = "GTE" - cql_symbol = '>=' - - -class LessThanOperator(QueryOperator): - symbol = "LT" - cql_symbol = '<' - - -class LessThanOrEqualOperator(QueryOperator): - symbol = "LTE" - cql_symbol = '<=' - +# class QueryOperatorException(QueryException): pass +# +# +# class QueryOperator(object): +# # The symbol that identifies this operator in filter kwargs +# # ie: colname__ +# symbol = None +# +# # The comparator symbol this operator uses in cql +# cql_symbol = None +# +# QUERY_VALUE_WRAPPER = QueryValue +# +# def __init__(self, column, value): +# self.column = column +# self.value = value +# +# if isinstance(value, QueryValue): +# self.query_value = value +# else: +# self.query_value = self.QUERY_VALUE_WRAPPER(value) +# +# #perform validation on this operator +# self.validate_operator() +# self.validate_value() +# +# @property +# def cql(self): +# """ +# Returns this operator's portion of the WHERE clause +# """ +# return '{} {} {}'.format(self.column.cql, self.cql_symbol, self.query_value.cql) +# +# def validate_operator(self): +# """ +# Checks that this operator can be used on the column provided +# """ +# if self.symbol is None: +# raise QueryOperatorException( +# "{} is not a valid operator, use one with 'symbol' defined".format( +# self.__class__.__name__ +# ) +# ) +# if self.cql_symbol is None: +# raise QueryOperatorException( +# "{} is not a valid operator, use one with 'cql_symbol' defined".format( +# self.__class__.__name__ +# ) +# ) +# +# def validate_value(self): +# """ +# Checks that the compare value works with this operator +# +# Doesn't do anything by default +# """ +# pass +# +# def get_dict(self): +# """ +# Returns this operators contribution to the cql.query arg dictionanry +# +# ie: if this column's name is colname, and the identifier is colval, +# this should return the dict: {'colval':} +# SELECT * FROM column_family WHERE colname=:colval +# """ +# return self.query_value.get_dict(self.column) +# +# @classmethod +# def get_operator(cls, symbol): +# if not hasattr(cls, 'opmap'): +# QueryOperator.opmap = {} +# def _recurse(klass): +# if klass.symbol: +# QueryOperator.opmap[klass.symbol.upper()] = klass +# for subklass in klass.__subclasses__(): +# _recurse(subklass) +# pass +# _recurse(QueryOperator) +# try: +# return QueryOperator.opmap[symbol.upper()] +# except KeyError: +# raise QueryOperatorException("{} doesn't map to a QueryOperator".format(symbol)) +# +# # equality operator, used by tests +# +# def __eq__(self, op): +# return self.__class__ is op.__class__ and \ +# self.column.db_field_name == op.column.db_field_name and \ +# self.value == op.value +# +# def __ne__(self, op): +# return not (self == op) +# +# def __hash__(self): +# return hash(self.column.db_field_name) ^ hash(self.value) +# +# +# class EqualsOperator(QueryOperator): +# symbol = 'EQ' +# cql_symbol = '=' +# +# +# class IterableQueryValue(QueryValue): +# def __init__(self, value): +# try: +# super(IterableQueryValue, self).__init__(value, [uuid4().hex for i in value]) +# except TypeError: +# raise QueryException("in operator arguments must be iterable, {} found".format(value)) +# +# def get_dict(self, column): +# return dict((i, column.to_database(v)) for (i, v) in zip(self.identifier, self.value)) +# +# def get_cql(self): +# return '({})'.format(', '.join(':{}'.format(i) for i in self.identifier)) +# +# +# class InOperator(EqualsOperator): +# symbol = 'IN' +# cql_symbol = 'IN' +# +# QUERY_VALUE_WRAPPER = IterableQueryValue +# +# +# class GreaterThanOperator(QueryOperator): +# symbol = "GT" +# cql_symbol = '>' +# +# +# class GreaterThanOrEqualOperator(QueryOperator): +# symbol = "GTE" +# cql_symbol = '>=' +# +# +# class LessThanOperator(QueryOperator): +# symbol = "LT" +# cql_symbol = '<' +# +# +# class LessThanOrEqualOperator(QueryOperator): +# symbol = "LTE" +# cql_symbol = '<=' +# class AbstractQueryableColumn(object): """ @@ -187,22 +189,22 @@ def in_(self, item): used in where you'd typically want to use python's `in` operator """ - return statements.WhereClause(unicode(self), operators.InOperator(), item) + return WhereClause(unicode(self), InOperator(), item) def __eq__(self, other): - return statements.WhereClause(unicode(self), operators.EqualsOperator(), other) + return WhereClause(unicode(self), EqualsOperator(), other) def __gt__(self, other): - return statements.WhereClause(unicode(self), operators.GreaterThanOperator(), other) + return WhereClause(unicode(self), GreaterThanOperator(), other) def __ge__(self, other): - return statements.WhereClause(unicode(self), operators.GreaterThanOrEqualOperator(), other) + return WhereClause(unicode(self), GreaterThanOrEqualOperator(), other) def __lt__(self, other): - return statements.WhereClause(unicode(self), operators.LessThanOperator(), other) + return WhereClause(unicode(self), LessThanOperator(), other) def __le__(self, other): - return statements.WhereClause(unicode(self), operators.LessThanOrEqualOperator(), other) + return WhereClause(unicode(self), LessThanOrEqualOperator(), other) class BatchType(object): @@ -342,7 +344,7 @@ def _select_query(self): """ Returns a select clause based on the given filter args """ - return statements.SelectStatement( + return SelectStatement( self.column_family_name, fields=self._select_fields(), where=self._where, @@ -467,7 +469,7 @@ def filter(self, *args, **kwargs): #add arguments to the where clause filters clone = copy.deepcopy(self) for operator in args: - if not isinstance(operator, statements.WhereClause): + if not isinstance(operator, WhereClause): raise QueryException('{} is not a valid query operator'.format(operator)) clone._where.append(operator) @@ -483,10 +485,10 @@ def filter(self, *args, **kwargs): raise QueryException("Can't resolve column name: '{}'".format(col_name)) #get query operator, or use equals if not supplied - operator_class = operators.BaseWhereOperator.get_operator(col_op or 'EQ') + operator_class = BaseWhereOperator.get_operator(col_op or 'EQ') operator = operator_class() - clone._where.append(statements.WhereClause(col_name, operator, val)) + clone._where.append(WhereClause(col_name, operator, val)) return clone From 0807b299dd6b00f2f5bcbc4413fb738f0d660337 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 27 Oct 2013 17:14:27 -0700 Subject: [PATCH 0496/4200] adding equality and hash methods --- cqlengine/query.py | 29 +++++++++++++------------- cqlengine/statements.py | 6 ++++++ cqlengine/tests/query/test_queryset.py | 2 +- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index cd5d7cec52..dee495c53f 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -340,10 +340,14 @@ def _select_fields(self): """ returns the fields to select """ return [] + def _validate_select(self): + """ put select query validation here """ + def _select_query(self): """ Returns a select clause based on the given filter args """ + self._validate_select() return SelectStatement( self.column_family_name, fields=self._select_fields(), @@ -627,7 +631,9 @@ def delete(self, columns=[]): execute(qs, self._where_values()) def __eq__(self, q): - return set(self._where) == set(q._where) + if len(self._where) == len(q._where): + return all([w in q._where for w in self._where]) + return False def __ne__(self, q): return not (self != q) @@ -667,30 +673,25 @@ class ModelQuerySet(AbstractQuerySet): """ """ - def _validate_where_syntax(self): + def _validate_select(self): """ Checks that a filterset will not create invalid cql """ - #check that there's either a = or IN relationship with a primary key or indexed field - equal_ops = [w for w in self._where if isinstance(w, EqualsOperator)] - token_ops = [w for w in self._where if isinstance(w.value, Token)] - if not any([w.column.primary_key or w.column.index for w in equal_ops]) and not token_ops: + equal_ops = [self.model._columns.get(w.field) for w in self._where if isinstance(w.operator, EqualsOperator)] + token_ops = [self.model._columns.get(w.field) for w in self._where if isinstance(w.operator, Token)] + if not any([w.primary_key or w.index for w in equal_ops]) and not token_ops: raise QueryException('Where clauses require either a "=" or "IN" comparison with either a primary key or indexed field') if not self._allow_filtering: #if the query is not on an indexed field - if not any([w.column.index for w in equal_ops]): - if not any([w.column.partition_key for w in equal_ops]) and not token_ops: + if not any([w.index for w in equal_ops]): + if not any([w.partition_key for w in equal_ops]) and not token_ops: raise QueryException('Filtering on a clustering key without a partition key is not allowed unless allow_filtering() is called on the querset') - if any(not w.column.partition_key for w in token_ops): + if any(not w.partition_key for w in token_ops): raise QueryException('The token() function is only supported on the partition key') - def _where_clause(self): - """ Returns a where clause based on the given filter args """ - self._validate_where_syntax() - return super(ModelQuerySet, self)._where_clause() - def _get_select_statement(self): """ Returns the fields to be returned by the select query """ + self._validate_select() if self._defer_fields or self._only_fields: fields = self.model._columns.keys() if self._defer_fields: diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 281701a971..80ec6a5aef 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -50,6 +50,9 @@ def __unicode__(self): def __str__(self): return unicode(self).encode('utf-8') + def __hash__(self): + return hash(self.field) ^ hash(self.value) + def __eq__(self, other): if isinstance(other, self.__class__): return self.field == other.field and self.value == other.value @@ -86,6 +89,9 @@ def __init__(self, field, operator, value): def __unicode__(self): return u'"{}" {} :{}'.format(self.field, self.operator, self.context_id) + def __hash__(self): + return super(WhereClause, self).__hash__() ^ hash(self.operator) + def __eq__(self, other): if super(WhereClause, self).__eq__(other): return self.operator.__class__ == other.operator.__class__ diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 8017c1578a..97e6033551 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -491,12 +491,12 @@ def test_conn_is_returned_after_filling_cache(self): assert q._cur is None - class TimeUUIDQueryModel(Model): partition = columns.UUID(primary_key=True) time = columns.TimeUUID(primary_key=True) data = columns.Text(required=False) + class TestMinMaxTimeUUIDFunctions(BaseCassEngTestCase): @classmethod From 898fc438ea058de721192c115628c0eed7d95a98 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 27 Oct 2013 17:18:10 -0700 Subject: [PATCH 0497/4200] removing unused methods --- cqlengine/query.py | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index dee495c53f..46efaad460 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -340,14 +340,15 @@ def _select_fields(self): """ returns the fields to select """ return [] - def _validate_select(self): + def _validate_select_where(self): """ put select query validation here """ def _select_query(self): """ Returns a select clause based on the given filter args """ - self._validate_select() + if self._where: + self._validate_select_where() return SelectStatement( self.column_family_name, fields=self._select_fields(), @@ -656,10 +657,6 @@ class SimpleQuerySet(AbstractQuerySet): """ - def _get_select_statement(self): - """ Returns the fields to be returned by the select query """ - return 'SELECT *' - def _get_result_constructor(self, names): """ Returns a function that will be used to instantiate query results @@ -673,8 +670,8 @@ class ModelQuerySet(AbstractQuerySet): """ """ - def _validate_select(self): - """ Checks that a filterset will not create invalid cql """ + def _validate_select_where(self): + """ Checks that a filterset will not create invalid select statement """ #check that there's either a = or IN relationship with a primary key or indexed field equal_ops = [self.model._columns.get(w.field) for w in self._where if isinstance(w.operator, EqualsOperator)] token_ops = [self.model._columns.get(w.field) for w in self._where if isinstance(w.operator, Token)] @@ -689,20 +686,6 @@ def _validate_select(self): if any(not w.partition_key for w in token_ops): raise QueryException('The token() function is only supported on the partition key') - def _get_select_statement(self): - """ Returns the fields to be returned by the select query """ - self._validate_select() - if self._defer_fields or self._only_fields: - fields = self.model._columns.keys() - if self._defer_fields: - fields = [f for f in fields if f not in self._defer_fields] - elif self._only_fields: - fields = self._only_fields - db_fields = [self.model._columns[f].db_field_name for f in fields] - return 'SELECT {}'.format(', '.join(['"{}"'.format(f) for f in db_fields])) - else: - return 'SELECT *' - def _select_fields(self): if self._defer_fields or self._only_fields: fields = self.model._columns.keys() From 736ec6dc7f33d510173c39c4be024fe7fab615ae Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 27 Oct 2013 19:55:32 -0700 Subject: [PATCH 0498/4200] fixing delete syntax --- cqlengine/statements.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 80ec6a5aef..9b5c62790c 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -301,7 +301,8 @@ def __init__(self, table, fields=None, consistency=None, where=None): def __unicode__(self): qs = ['DELETE'] - qs += [', '.join(['"{}"'.format(f) for f in self.fields]) if self.fields else '*'] + if self.fields: + qs += [', '.join(['"{}"'.format(f) for f in self.fields])] qs += ['FROM', self.table] if self.where_clauses: From e0164167c6dcd8425395575ac5594c45ca91fcf2 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 27 Oct 2013 19:55:49 -0700 Subject: [PATCH 0499/4200] replacing delete call with statement --- cqlengine/query.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 46efaad460..9db0aebb30 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -16,7 +16,7 @@ #http://www.datastax.com/docs/1.1/references/cql/index from cqlengine.operators import InOperator, EqualsOperator, GreaterThanOperator, GreaterThanOrEqualOperator from cqlengine.operators import LessThanOperator, LessThanOrEqualOperator, BaseWhereOperator -from cqlengine.statements import WhereClause, SelectStatement +from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement class QueryException(CQLEngineException): pass @@ -613,23 +613,24 @@ def defer(self, fields): def create(self, **kwargs): return self.model(**kwargs).batch(self._batch).ttl(self._ttl).consistency(self._consistency).save() - #----delete--- - def delete(self, columns=[]): + def delete(self): """ Deletes the contents of a query """ #validate where clause partition_key = self.model._primary_keys.values()[0] - if not any([c.column.db_field_name == partition_key.db_field_name for c in self._where]): + if not any([c.field == partition_key.column_name for c in self._where]): raise QueryException("The partition key must be defined on delete queries") - qs = ['DELETE FROM {}'.format(self.column_family_name)] - qs += ['WHERE {}'.format(self._where_clause())] - qs = ' '.join(qs) + + dq = DeleteStatement( + self.column_family_name, + where=self._where + ) if self._batch: - self._batch.add_query(qs, self._where_values()) + self._batch.add_query(str(dq), dq.get_context()) else: - execute(qs, self._where_values()) + execute(str(dq), dq.get_context()) def __eq__(self, q): if len(self._where) == len(q._where): From 187c5b4678ce4f3c080bcf3fb296a2fb495c1977 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 28 Oct 2013 08:19:24 -0700 Subject: [PATCH 0500/4200] adding set update clause and supporting unit tests --- cqlengine/query.py | 12 +- cqlengine/statements.py | 63 ++++++++++ .../tests/columns/test_container_columns.py | 2 +- .../statements/test_assignment_clause.py | 13 -- .../statements/test_assignment_clauses.py | 115 ++++++++++++++++++ 5 files changed, 188 insertions(+), 17 deletions(-) delete mode 100644 cqlengine/tests/statements/test_assignment_clause.py create mode 100644 cqlengine/tests/statements/test_assignment_clauses.py diff --git a/cqlengine/query.py b/cqlengine/query.py index 9db0aebb30..c5939f592d 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -16,7 +16,7 @@ #http://www.datastax.com/docs/1.1/references/cql/index from cqlengine.operators import InOperator, EqualsOperator, GreaterThanOperator, GreaterThanOrEqualOperator from cqlengine.operators import LessThanOperator, LessThanOrEqualOperator, BaseWhereOperator -from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement +from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement class QueryException(CQLEngineException): pass @@ -795,7 +795,10 @@ def update(self, **values): ttl_stmt ) ctx.update(self._where_values()) - execute(qs, ctx, self._consistency) + if self._batch: + self._batch.add_query(qs, ctx) + else: + execute(qs, ctx, self._consistency) if nulled_columns: qs = "DELETE {} FROM {} WHERE {}".format( @@ -803,7 +806,10 @@ def update(self, **values): self.column_family_name, self._where_clause() ) - execute(qs, self._where_values(), self._consistency) + if self._batch: + self._batch.add_query(qs, self._where_values()) + else: + execute(qs, self._where_values(), self._consistency) class DMLQuery(object): diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 9b5c62790c..4812b56510 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -114,6 +114,69 @@ def insert_tuple(self): return self.field, self.context_id +class SetUpdateClause(AssignmentClause): + """ updates a set collection """ + + def __init__(self, field, value, previous=None): + super(SetUpdateClause, self).__init__(field, value) + self.previous = previous + self._assignments = None + self._additions = None + self._removals = None + self._analyzed = False + + def __unicode__(self): + qs = [] + ctx_id = self.context_id + if self._assignments: + qs += ['"{}" = :{}'.format(self.field, ctx_id)] + ctx_id += 1 + if self._additions: + qs += ['"{0}" = "{0}" + :{1}'.format(self.field, ctx_id)] + ctx_id += 1 + if self._removals: + qs += ['"{0}" = "{0}" - :{1}'.format(self.field, ctx_id)] + + return ', '.join(qs) + + def _analyze(self): + """ works out the updates to be performed """ + if self.value is None or self.value == self.previous: + pass + elif self.previous is None or not any({v in self.previous for v in self.value}): + self._assignments = self.value + else: + # partial update time + self._additions = (self.value - self.previous) or None + self._removals = (self.previous - self.value) or None + self._analyzed = True + + def get_context_size(self): + if not self._analyzed: self._analyze() + return int(bool(self._assignments)) + int(bool(self._additions)) + int(bool(self._removals)) + + def update_context(self, ctx): + if not self._analyzed: self._analyze() + ctx_id = self.context_id + if self._assignments: + ctx[str(ctx_id)] = self._assignments + ctx_id += 1 + if self._additions: + ctx[str(ctx_id)] = self._additions + ctx_id += 1 + if self._removals: + ctx[str(ctx_id)] = self._removals + + + +class ListUpdateClause(AssignmentClause): + """ updates a list collection """ + + +class MapUpdateClause(AssignmentClause): + """ updates a map collection """ + + class BaseCQLStatement(object): """ The base cql statement class """ diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 0d3dc524b2..3d83c3be8e 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -105,7 +105,7 @@ def test_partial_update_creation(self): assert len([s for s in statements if '"TEST" = "TEST" +' in s]) == 1 def test_update_from_none(self): - """ Tests that updating an 'None' list creates a straight insert statement """ + """ Tests that updating a 'None' list creates a straight insert statement """ ctx = {} col = columns.Set(columns.Integer, db_field="TEST") statements = col.get_update_statement({1, 2, 3, 4}, None, ctx) diff --git a/cqlengine/tests/statements/test_assignment_clause.py b/cqlengine/tests/statements/test_assignment_clause.py deleted file mode 100644 index b778d4b4a8..0000000000 --- a/cqlengine/tests/statements/test_assignment_clause.py +++ /dev/null @@ -1,13 +0,0 @@ -from unittest import TestCase -from cqlengine.statements import AssignmentClause - - -class AssignmentClauseTests(TestCase): - - def test_rendering(self): - pass - - def test_insert_tuple(self): - ac = AssignmentClause('a', 'b') - ac.set_context_id(10) - self.assertEqual(ac.insert_tuple(), ('a', 10)) diff --git a/cqlengine/tests/statements/test_assignment_clauses.py b/cqlengine/tests/statements/test_assignment_clauses.py new file mode 100644 index 0000000000..8f807b0431 --- /dev/null +++ b/cqlengine/tests/statements/test_assignment_clauses.py @@ -0,0 +1,115 @@ +from unittest import TestCase +from cqlengine.statements import AssignmentClause, SetUpdateClause + + +class AssignmentClauseTests(TestCase): + + def test_rendering(self): + pass + + def test_insert_tuple(self): + ac = AssignmentClause('a', 'b') + ac.set_context_id(10) + self.assertEqual(ac.insert_tuple(), ('a', 10)) + + +class SetUpdateClauseTests(TestCase): + + def test_update_from_none(self): + c = SetUpdateClause('s', {1, 2}, None) + c._analyze() + c.set_context_id(0) + + self.assertEqual(c._assignments, {1, 2}) + self.assertIsNone(c._additions) + self.assertIsNone(c._removals) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"s" = :0') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': {1, 2}}) + + def test_null_update(self): + """ tests setting a set to None creates an empty update statement """ + c = SetUpdateClause('s', None, {1, 2}) + c._analyze() + c.set_context_id(0) + + self.assertIsNone(c._assignments) + self.assertIsNone(c._additions) + self.assertIsNone(c._removals) + + self.assertEqual(c.get_context_size(), 0) + self.assertEqual(str(c), '') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {}) + + def test_no_update(self): + """ tests an unchanged value creates an empty update statement """ + c = SetUpdateClause('s', {1, 2}, {1, 2}) + c._analyze() + c.set_context_id(0) + + self.assertIsNone(c._assignments) + self.assertIsNone(c._additions) + self.assertIsNone(c._removals) + + self.assertEqual(c.get_context_size(), 0) + self.assertEqual(str(c), '') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {}) + + def test_additions(self): + c = SetUpdateClause('s', {1, 2, 3}, {1, 2}) + c._analyze() + c.set_context_id(0) + + self.assertIsNone(c._assignments) + self.assertEqual(c._additions, {3}) + self.assertIsNone(c._removals) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"s" = "s" + :0') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': {3}}) + + def test_removals(self): + c = SetUpdateClause('s', {1, 2}, {1, 2, 3}) + c._analyze() + c.set_context_id(0) + + self.assertIsNone(c._assignments) + self.assertIsNone(c._additions) + self.assertEqual(c._removals, {3}) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"s" = "s" - :0') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': {3}}) + + def test_additions_and_removals(self): + c = SetUpdateClause('s', {2, 3}, {1, 2}) + c._analyze() + c.set_context_id(0) + + self.assertIsNone(c._assignments) + self.assertEqual(c._additions, {3}) + self.assertEqual(c._removals, {1}) + + self.assertEqual(c.get_context_size(), 2) + self.assertEqual(str(c), '"s" = "s" + :0, "s" = "s" - :1') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': {3}, '1': {1}}) + From 022d31021bff735e8b93f1fa82a63ac49f027bd4 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 28 Oct 2013 14:28:53 -0700 Subject: [PATCH 0501/4200] validation around deleting containers --- cqlengine/tests/columns/test_container_columns.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index b1a7268062..5af677fb0c 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -411,9 +411,15 @@ def test_updates_from_none(self): m.int_map = expected m.save() + m2 = TestMapModel.get(partition=m.partition) assert m2.int_map == expected + m2.int_map = None + m2.save() + m3 = TestMapModel.get(partition=m.partition) + assert m3.int_map != expected + def test_updates_to_none(self): """ Tests that setting the field to None works as expected """ m = TestMapModel.create(int_map={1: uuid4()}) From 20afb040701cf03ae41d11306a54df31afdde433 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 28 Oct 2013 18:29:55 -0700 Subject: [PATCH 0502/4200] adding list update clause --- cqlengine/statements.py | 108 +++++++++++++++- .../statements/test_assignment_clauses.py | 121 +++++++++++++++++- 2 files changed, 221 insertions(+), 8 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 4812b56510..43a4c1017a 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -114,16 +114,31 @@ def insert_tuple(self): return self.field, self.context_id -class SetUpdateClause(AssignmentClause): - """ updates a set collection """ +class ContainerUpdateClause(AssignmentClause): def __init__(self, field, value, previous=None): - super(SetUpdateClause, self).__init__(field, value) + super(ContainerUpdateClause, self).__init__(field, value) self.previous = previous self._assignments = None + self._analyzed = False + + def _analyze(self): + raise NotImplementedError + + def get_context_size(self): + raise NotImplementedError + + def update_context(self, ctx): + raise NotImplementedError + + +class SetUpdateClause(ContainerUpdateClause): + """ updates a set collection """ + + def __init__(self, field, value, previous=None): + super(SetUpdateClause, self).__init__(field, value, previous) self._additions = None self._removals = None - self._analyzed = False def __unicode__(self): qs = [] @@ -168,12 +183,91 @@ def update_context(self, ctx): ctx[str(ctx_id)] = self._removals - -class ListUpdateClause(AssignmentClause): +class ListUpdateClause(ContainerUpdateClause): """ updates a list collection """ + def __init__(self, field, value, previous=None): + super(ListUpdateClause, self).__init__(field, value, previous) + self._append = None + self._prepend = None + + def __unicode__(self): + qs = [] + ctx_id = self.context_id + if self._assignments: + qs += ['"{}" = :{}'.format(self.field, ctx_id)] + ctx_id += 1 + + if self._prepend: + qs += ['"{0}" = :{1} + "{0}"'.format(self.field, ctx_id)] + ctx_id += 1 + + if self._append: + qs += ['"{0}" = "{0}" + :{1}'.format(self.field, ctx_id)] + + return ', '.join(qs) + + def get_context_size(self): + if not self._analyzed: self._analyze() + return int(bool(self._assignments)) + int(bool(self._append)) + int(bool(self._prepend)) + + def update_context(self, ctx): + if not self._analyzed: self._analyze() + ctx_id = self.context_id + if self._assignments: + ctx[str(ctx_id)] = self._assignments + ctx_id += 1 + if self._prepend: + # CQL seems to prepend element at a time, starting + # with the element at idx 0, we can either reverse + # it here, or have it inserted in reverse + ctx[str(ctx_id)] = list(reversed(self._prepend)) + ctx_id += 1 + if self._append: + ctx[str(ctx_id)] = self._append + + def _analyze(self): + """ works out the updates to be performed """ + if self.value is None or self.value == self.previous: + pass + + elif self.previous is None: + self._assignments = self.value + + elif len(self.value) < len(self.previous): + # if elements have been removed, + # rewrite the whole list + self._assignments = self.value + + elif len(self.previous) == 0: + # if we're updating from an empty + # list, do a complete insert + self._assignments = self.value + else: + + # the max start idx we want to compare + search_space = len(self.value) - max(0, len(self.previous)-1) + + # the size of the sub lists we want to look at + search_size = len(self.previous) + + for i in range(search_space): + #slice boundary + j = i + search_size + sub = self.value[i:j] + idx_cmp = lambda idx: self.previous[idx] == sub[idx] + if idx_cmp(0) and idx_cmp(-1) and self.previous == sub: + self._prepend = self.value[:i] or None + self._append = self.value[j:] or None + break + + # if both append and prepend are still None after looking + # at both lists, an insert statement will be created + if self._prepend is self._append is None: + self._assignments = self.value + -class MapUpdateClause(AssignmentClause): +class MapUpdateClause(ContainerUpdateClause): """ updates a map collection """ diff --git a/cqlengine/tests/statements/test_assignment_clauses.py b/cqlengine/tests/statements/test_assignment_clauses.py index 8f807b0431..bb38fb97f8 100644 --- a/cqlengine/tests/statements/test_assignment_clauses.py +++ b/cqlengine/tests/statements/test_assignment_clauses.py @@ -1,5 +1,5 @@ from unittest import TestCase -from cqlengine.statements import AssignmentClause, SetUpdateClause +from cqlengine.statements import AssignmentClause, SetUpdateClause, ListUpdateClause class AssignmentClauseTests(TestCase): @@ -113,3 +113,122 @@ def test_additions_and_removals(self): c.update_context(ctx) self.assertEqual(ctx, {'0': {3}, '1': {1}}) + +class ListUpdateClauseTests(TestCase): + + def test_update_from_none(self): + c = ListUpdateClause('s', [1, 2, 3]) + c._analyze() + c.set_context_id(0) + + self.assertEqual(c._assignments, [1, 2, 3]) + self.assertIsNone(c._append) + self.assertIsNone(c._prepend) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"s" = :0') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': [1, 2, 3]}) + + def test_update_from_empty(self): + c = ListUpdateClause('s', [1, 2, 3], previous=[]) + c._analyze() + c.set_context_id(0) + + self.assertEqual(c._assignments, [1, 2, 3]) + self.assertIsNone(c._append) + self.assertIsNone(c._prepend) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"s" = :0') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': [1, 2, 3]}) + + def test_update_from_different_list(self): + c = ListUpdateClause('s', [1, 2, 3], previous=[3, 2, 1]) + c._analyze() + c.set_context_id(0) + + self.assertEqual(c._assignments, [1, 2, 3]) + self.assertIsNone(c._append) + self.assertIsNone(c._prepend) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"s" = :0') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': [1, 2, 3]}) + + def test_append(self): + c = ListUpdateClause('s', [1, 2, 3, 4], previous=[1, 2]) + c._analyze() + c.set_context_id(0) + + self.assertIsNone(c._assignments) + self.assertEqual(c._append, [3, 4]) + self.assertIsNone(c._prepend) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"s" = "s" + :0') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': [3, 4]}) + + def test_prepend(self): + c = ListUpdateClause('s', [1, 2, 3, 4], previous=[3, 4]) + c._analyze() + c.set_context_id(0) + + self.assertIsNone(c._assignments) + self.assertIsNone(c._append) + self.assertEqual(c._prepend, [1, 2]) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"s" = :0 + "s"') + + ctx = {} + c.update_context(ctx) + # test context list reversal + self.assertEqual(ctx, {'0': [2, 1]}) + + def test_append_and_prepend(self): + c = ListUpdateClause('s', [1, 2, 3, 4, 5, 6], previous=[3, 4]) + c._analyze() + c.set_context_id(0) + + self.assertIsNone(c._assignments) + self.assertEqual(c._append, [5, 6]) + self.assertEqual(c._prepend, [1, 2]) + + self.assertEqual(c.get_context_size(), 2) + self.assertEqual(str(c), '"s" = :0 + "s", "s" = "s" + :1') + + ctx = {} + c.update_context(ctx) + # test context list reversal + self.assertEqual(ctx, {'0': [2, 1], '1': [5, 6]}) + + def test_shrinking_list_update(self): + """ tests that updating to a smaller list results in an insert statement """ + c = ListUpdateClause('s', [1, 2, 3], previous=[1, 2, 3, 4]) + c._analyze() + c.set_context_id(0) + + self.assertEqual(c._assignments, [1, 2, 3]) + self.assertIsNone(c._append) + self.assertIsNone(c._prepend) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"s" = :0') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': [1, 2, 3]}) + + From 06632cf53215b7f7d076052f1356e62ae85dbbe4 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 28 Oct 2013 18:56:32 -0700 Subject: [PATCH 0503/4200] adding map update clause, delete clause, and field delete clause --- cqlengine/statements.py | 44 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 43a4c1017a..b3b1c6d5b3 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -266,10 +266,44 @@ def _analyze(self): if self._prepend is self._append is None: self._assignments = self.value + self._analyzed = True + class MapUpdateClause(ContainerUpdateClause): """ updates a map collection """ + def __init__(self, field, value, previous=None): + super(MapUpdateClause, self).__init__(field, value, previous) + + +class BaseDeleteClause(BaseClause): + pass + + +class FieldDeleteClause(BaseDeleteClause): + """ deletes a field from a row """ + + def __init__(self, field): + super(FieldDeleteClause, self).__init__(field, None) + + def __unicode__(self): + return self.field + + def update_context(self, ctx): + pass + + def get_context_size(self): + return 0 + + +class MapDeleteClause(BaseDeleteClause): + """ removes keys from a map """ + + def __init__(self, field, value, previous=None): + super(MapDeleteClause, self).__init__(field, value) + self.previous = previous + self._analysed = False + class BaseCQLStatement(object): """ The base cql statement class """ @@ -454,7 +488,15 @@ def __init__(self, table, fields=None, consistency=None, where=None): consistency=consistency, where=where, ) - self.fields = [fields] if isinstance(fields, basestring) else (fields or []) + for field in fields or []: + self.add_field(field) + + def add_field(self, field): + if isinstance(field, basestring): + field = FieldDeleteClause(field) + if not isinstance(field, BaseClause): + raise StatementException("only instances of AssignmentClause can be added to statements") + self.fields.append(field) def __unicode__(self): qs = ['DELETE'] From cae7a1945a184e6a78add19e9d180b6fbee590f0 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 28 Oct 2013 19:01:44 -0700 Subject: [PATCH 0504/4200] adding not implemented errors and stubbed tests for map and delete field clauses --- cqlengine/statements.py | 37 ++++++++++++++++++- .../statements/test_assignment_clauses.py | 10 +++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index b3b1c6d5b3..a32bd6cbb9 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -192,6 +192,7 @@ def __init__(self, field, value, previous=None): self._prepend = None def __unicode__(self): + if not self._analyzed: self._analyze() qs = [] ctx_id = self.context_id if self._assignments: @@ -275,6 +276,21 @@ class MapUpdateClause(ContainerUpdateClause): def __init__(self, field, value, previous=None): super(MapUpdateClause, self).__init__(field, value, previous) + def get_context_size(self): + if not self._analyzed: + self._analyze() + raise NotImplementedError('implement this') + + def update_context(self, ctx): + if not self._analyzed: + self._analyze() + raise NotImplementedError('implement this') + + def __unicode__(self): + if not self._analyzed: + self._analyze() + raise NotImplementedError('implement this') + class BaseDeleteClause(BaseClause): pass @@ -302,7 +318,26 @@ class MapDeleteClause(BaseDeleteClause): def __init__(self, field, value, previous=None): super(MapDeleteClause, self).__init__(field, value) self.previous = previous - self._analysed = False + self._analyzed = False + self._removals = None + + def _analyze(self): + self._analyzed = True + + def update_context(self, ctx): + if not self._analyzed: + self._analyze() + raise NotImplementedError('implement this') + + def get_context_size(self): + if not self._analyzed: + self._analyze() + raise NotImplementedError('implement this') + + def __unicode__(self): + if not self._analyzed: + self._analyze() + raise NotImplementedError('implement this') class BaseCQLStatement(object): diff --git a/cqlengine/tests/statements/test_assignment_clauses.py b/cqlengine/tests/statements/test_assignment_clauses.py index bb38fb97f8..3da24decde 100644 --- a/cqlengine/tests/statements/test_assignment_clauses.py +++ b/cqlengine/tests/statements/test_assignment_clauses.py @@ -232,3 +232,13 @@ def test_shrinking_list_update(self): self.assertEqual(ctx, {'0': [1, 2, 3]}) +class MapUpdateTests(TestCase): + pass + + +class MapDeleteTests(TestCase): + pass + + +class FieldDeleteTests(TestCase): + pass From 871ba29f3bb8e2a9781ec73cd9d3f980127e593d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 30 Oct 2013 20:07:10 -0700 Subject: [PATCH 0505/4200] implementing map update clause and adding stubbed out tests --- cqlengine/statements.py | 31 +++++++++++++------ .../statements/test_assignment_clauses.py | 14 +++++++-- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index a32bd6cbb9..05ea9def94 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -275,21 +275,34 @@ class MapUpdateClause(ContainerUpdateClause): def __init__(self, field, value, previous=None): super(MapUpdateClause, self).__init__(field, value, previous) + self._updates = None + + def _analyze(self): + self._updates = sorted([k for k, v in self.value.items() if v != self.previous.get(k)]) or None + self._analyzed = True def get_context_size(self): - if not self._analyzed: - self._analyze() - raise NotImplementedError('implement this') + if not self._analyzed: self._analyze() + return len(self._updates or []) * 2 def update_context(self, ctx): - if not self._analyzed: - self._analyze() - raise NotImplementedError('implement this') + if not self._analyzed: self._analyze() + ctx_id = self.context_id + for key in self._updates or []: + ctx[str(ctx_id)] = key + ctx[str(ctx_id + 1)] = self.value.get(key) + ctx_id += 2 def __unicode__(self): - if not self._analyzed: - self._analyze() - raise NotImplementedError('implement this') + if not self._analyzed: self._analyze() + qs = [] + + ctx_id = self.context_id + for _ in self._updates or []: + qs += ['"{}"[:{}] = :{}'.format(self.field, ctx_id, ctx_id + 1)] + ctx_id += 2 + + return ', '.join(qs) class BaseDeleteClause(BaseClause): diff --git a/cqlengine/tests/statements/test_assignment_clauses.py b/cqlengine/tests/statements/test_assignment_clauses.py index 3da24decde..bbe044a5bf 100644 --- a/cqlengine/tests/statements/test_assignment_clauses.py +++ b/cqlengine/tests/statements/test_assignment_clauses.py @@ -233,11 +233,21 @@ def test_shrinking_list_update(self): class MapUpdateTests(TestCase): - pass + + def test_update(self): + pass + + def test_update_from_null(self): + pass + + def test_nulled_columns_arent_included(self): + pass class MapDeleteTests(TestCase): - pass + + def test_update(self): + pass class FieldDeleteTests(TestCase): From cdf45bf1abe889c85199e0c120741689e18d3d7e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 30 Oct 2013 20:08:22 -0700 Subject: [PATCH 0506/4200] removing extra line --- docs/topics/models.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 9d214d3a7f..c55a81d687 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -147,7 +147,6 @@ Model Methods -- method:: update(**values) - Performs an update on the model instance. You can pass in values to set on the model for updating, or you can call without values to execute an update against any modified fields. If no fields on the model have been modified since loading, no query will be From 979166f161f46c4c027b1651e92db33dde66b02c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 30 Oct 2013 20:12:12 -0700 Subject: [PATCH 0507/4200] fixing update method syntax --- docs/topics/models.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/models.rst b/docs/topics/models.rst index c55a81d687..1fae6070d4 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -145,14 +145,14 @@ Model Methods Sets the ttl values to run instance updates and inserts queries with. - -- method:: update(**values) + .. method:: update(**values) Performs an update on the model instance. You can pass in values to set on the model for updating, or you can call without values to execute an update against any modified fields. If no fields on the model have been modified since loading, no query will be performed. Model validation is performed normally. - -- method:: get_changed_columns() + .. method:: get_changed_columns() Returns a list of column names that have changed since the model was instantiated or saved From ff70a5595b2d17d0bf2d5cd7344e37825d2353b8 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 31 Oct 2013 21:23:05 -0700 Subject: [PATCH 0508/4200] implementing map delete and field delete --- cqlengine/statements.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 05ea9def94..f5dc82064b 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -276,6 +276,7 @@ class MapUpdateClause(ContainerUpdateClause): def __init__(self, field, value, previous=None): super(MapUpdateClause, self).__init__(field, value, previous) self._updates = None + self.previous = self.previous or {} def _analyze(self): self._updates = sorted([k for k, v in self.value.items() if v != self.previous.get(k)]) or None @@ -316,7 +317,7 @@ def __init__(self, field): super(FieldDeleteClause, self).__init__(field, None) def __unicode__(self): - return self.field + return '"{}"'.format(self.field) def update_context(self, ctx): pass @@ -330,27 +331,27 @@ class MapDeleteClause(BaseDeleteClause): def __init__(self, field, value, previous=None): super(MapDeleteClause, self).__init__(field, value) - self.previous = previous + self.value = self.value or {} + self.previous = previous or {} self._analyzed = False self._removals = None def _analyze(self): + self._removals = sorted([k for k in self.previous if k not in self.value]) self._analyzed = True def update_context(self, ctx): - if not self._analyzed: - self._analyze() - raise NotImplementedError('implement this') + if not self._analyzed: self._analyze() + for idx, key in enumerate(self._removals): + ctx[str(self.context_id + idx)] = key def get_context_size(self): - if not self._analyzed: - self._analyze() - raise NotImplementedError('implement this') + if not self._analyzed: self._analyze() + return len(self._removals) def __unicode__(self): - if not self._analyzed: - self._analyze() - raise NotImplementedError('implement this') + if not self._analyzed: self._analyze() + return ', '.join(['"{}"[:{}]'.format(self.field, self.context_id + i) for i in range(len(self._removals))]) class BaseCQLStatement(object): From 6b75dc357b242ff6f0fcfeebd1213540b6d4f794 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 31 Oct 2013 21:23:17 -0700 Subject: [PATCH 0509/4200] adding tests around map update and delete, and field delete --- .../statements/test_assignment_clauses.py | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/cqlengine/tests/statements/test_assignment_clauses.py b/cqlengine/tests/statements/test_assignment_clauses.py index bbe044a5bf..03d2a5ab0f 100644 --- a/cqlengine/tests/statements/test_assignment_clauses.py +++ b/cqlengine/tests/statements/test_assignment_clauses.py @@ -1,5 +1,5 @@ from unittest import TestCase -from cqlengine.statements import AssignmentClause, SetUpdateClause, ListUpdateClause +from cqlengine.statements import AssignmentClause, SetUpdateClause, ListUpdateClause, MapUpdateClause, MapDeleteClause, FieldDeleteClause class AssignmentClauseTests(TestCase): @@ -235,20 +235,57 @@ def test_shrinking_list_update(self): class MapUpdateTests(TestCase): def test_update(self): - pass + c = MapUpdateClause('s', {3: 0, 5: 6}, {5: 0, 3: 4}) + c._analyze() + c.set_context_id(0) + + self.assertEqual(c._updates, [3, 5]) + self.assertEqual(c.get_context_size(), 4) + self.assertEqual(str(c), '"s"[:0] = :1, "s"[:2] = :3') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': 3, "1": 0, '2': 5, '3': 6}) def test_update_from_null(self): - pass + c = MapUpdateClause('s', {3: 0, 5: 6}) + c._analyze() + c.set_context_id(0) + + self.assertEqual(c._updates, [3, 5]) + self.assertEqual(c.get_context_size(), 4) + self.assertEqual(str(c), '"s"[:0] = :1, "s"[:2] = :3') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': 3, "1": 0, '2': 5, '3': 6}) def test_nulled_columns_arent_included(self): - pass + c = MapUpdateClause('s', {3: 0}, {1: 2, 3: 4}) + c._analyze() + c.set_context_id(0) + + self.assertNotIn(1, c._updates) class MapDeleteTests(TestCase): def test_update(self): - pass + c = MapDeleteClause('s', {3: 0}, {1: 2, 3: 4, 5: 6}) + c._analyze() + c.set_context_id(0) + + self.assertEqual(c._removals, [1, 5]) + self.assertEqual(c.get_context_size(), 2) + self.assertEqual(str(c), '"s"[:0], "s"[:1]') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'0': 1, '1': 5}) class FieldDeleteTests(TestCase): - pass + + def test_str(self): + f = FieldDeleteClause("blake") + assert str(f) == '"blake"' From 6d873e604d99079899f9b017e8226b2bb167a1b8 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sat, 2 Nov 2013 10:02:38 -0700 Subject: [PATCH 0510/4200] fixing delete statement --- cqlengine/statements.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index f5dc82064b..1db9af0773 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -537,6 +537,7 @@ def __init__(self, table, fields=None, consistency=None, where=None): consistency=consistency, where=where, ) + self.fields = [] for field in fields or []: self.add_field(field) From 48870d59b1578a3811cf035af1eec7f6a105c67f Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 07:48:46 -0800 Subject: [PATCH 0511/4200] updating named query tests --- cqlengine/tests/query/test_named.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cqlengine/tests/query/test_named.py b/cqlengine/tests/query/test_named.py index cba46dcb59..8f9781da96 100644 --- a/cqlengine/tests/query/test_named.py +++ b/cqlengine/tests/query/test_named.py @@ -1,4 +1,4 @@ -from cqlengine import query +from cqlengine import operators from cqlengine.named import NamedKeyspace from cqlengine.query import ResultObject from cqlengine.tests.query.test_queryset import BaseQuerySetUsage @@ -21,14 +21,14 @@ def test_query_filter_parsing(self): assert len(query1._where) == 1 op = query1._where[0] - assert isinstance(op, query.EqualsOperator) + assert isinstance(op, operators.EqualsOperator) assert op.value == 5 query2 = query1.filter(expected_result__gte=1) assert len(query2._where) == 2 op = query2._where[1] - assert isinstance(op, query.GreaterThanOrEqualOperator) + assert isinstance(op, operators.GreaterThanOrEqualOperator) assert op.value == 1 def test_query_expression_parsing(self): @@ -37,14 +37,14 @@ def test_query_expression_parsing(self): assert len(query1._where) == 1 op = query1._where[0] - assert isinstance(op, query.EqualsOperator) + assert isinstance(op.operator, operators.EqualsOperator) assert op.value == 5 query2 = query1.filter(self.table.column('expected_result') >= 1) assert len(query2._where) == 2 op = query2._where[1] - assert isinstance(op, query.GreaterThanOrEqualOperator) + assert isinstance(op.operator, operators.GreaterThanOrEqualOperator) assert op.value == 1 def test_filter_method_where_clause_generation(self): From 5b2351488f84e9065069d3f17215966c7986d2ac Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 07:49:11 -0800 Subject: [PATCH 0512/4200] using to_database for filter values --- cqlengine/query.py | 2 +- cqlengine/tests/query/test_datetime_queries.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index c5939f592d..7de3898da6 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -493,7 +493,7 @@ def filter(self, *args, **kwargs): operator_class = BaseWhereOperator.get_operator(col_op or 'EQ') operator = operator_class() - clone._where.append(WhereClause(col_name, operator, val)) + clone._where.append(WhereClause(col_name, operator, column.to_database(val))) return clone diff --git a/cqlengine/tests/query/test_datetime_queries.py b/cqlengine/tests/query/test_datetime_queries.py index e374bd7d18..39cc0e332f 100644 --- a/cqlengine/tests/query/test_datetime_queries.py +++ b/cqlengine/tests/query/test_datetime_queries.py @@ -43,7 +43,7 @@ def test_range_query(self): end = start + timedelta(days=3) results = DateTimeQueryTestModel.filter(user=0, day__gte=start, day__lt=end) - assert len(results) == 3 + assert len(results) == 3 def test_datetime_precision(self): """ Tests that millisecond resolution is preserved when saving datetime objects """ From fb21199f51c42b81a017740dfaabb1d0f92e97cf Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 08:00:25 -0800 Subject: [PATCH 0513/4200] fixing statement tests --- cqlengine/statements.py | 8 ++++++-- cqlengine/tests/statements/test_base_clause.py | 2 +- cqlengine/tests/statements/test_delete_statement.py | 5 +++-- cqlengine/tests/statements/test_select_statement.py | 2 +- cqlengine/tests/statements/test_update_statement.py | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 1db9af0773..697bd6ceb5 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -72,7 +72,7 @@ def set_context_id(self, i): def update_context(self, ctx): """ updates the query context with this clauses values """ assert isinstance(ctx, dict) - ctx[str(self.context_id)] = ValueQuoter(self.value) + ctx[str(self.context_id)] = self.value class WhereClause(BaseClause): @@ -538,6 +538,8 @@ def __init__(self, table, fields=None, consistency=None, where=None): where=where, ) self.fields = [] + if isinstance(fields, basestring): + fields = [fields] for field in fields or []: self.add_field(field) @@ -551,7 +553,9 @@ def add_field(self, field): def __unicode__(self): qs = ['DELETE'] if self.fields: - qs += [', '.join(['"{}"'.format(f) for f in self.fields])] + qs += [', '.join(['{}'.format(f) for f in self.fields])] + else: + qs += ['*'] qs += ['FROM', self.table] if self.where_clauses: diff --git a/cqlengine/tests/statements/test_base_clause.py b/cqlengine/tests/statements/test_base_clause.py index 04d7d52845..c5bbeb402d 100644 --- a/cqlengine/tests/statements/test_base_clause.py +++ b/cqlengine/tests/statements/test_base_clause.py @@ -11,6 +11,6 @@ def test_context_updating(self): ctx = {} ss.set_context_id(10) ss.update_context(ctx) - assert ctx == {10: 'b'} + assert ctx == {'10': 'b'} diff --git a/cqlengine/tests/statements/test_delete_statement.py b/cqlengine/tests/statements/test_delete_statement.py index 58d05c867e..be4087779a 100644 --- a/cqlengine/tests/statements/test_delete_statement.py +++ b/cqlengine/tests/statements/test_delete_statement.py @@ -8,7 +8,8 @@ class DeleteStatementTests(TestCase): def test_single_field_is_listified(self): """ tests that passing a string field into the constructor puts it into a list """ ds = DeleteStatement('table', 'field') - self.assertEqual(ds.fields, ['field']) + self.assertEqual(len(ds.fields), 1) + self.assertEqual(ds.fields[0].field, 'field') def test_field_rendering(self): """ tests that fields are properly added to the select statement """ @@ -35,4 +36,4 @@ def test_where_clause_rendering(self): def test_context(self): ds = DeleteStatement('table', None) ds.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) - self.assertEqual(ds.get_context(), {0: 'b'}) + self.assertEqual(ds.get_context(), {'0': 'b'}) diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index 495258c2fd..d59c8eb77c 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -42,7 +42,7 @@ def test_count(self): def test_context(self): ss = SelectStatement('table') ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) - self.assertEqual(ss.get_context(), {0: 'b'}) + self.assertEqual(ss.get_context(), {'0': 'b'}) def test_additional_rendering(self): ss = SelectStatement( diff --git a/cqlengine/tests/statements/test_update_statement.py b/cqlengine/tests/statements/test_update_statement.py index 6c633852d1..0945d2341e 100644 --- a/cqlengine/tests/statements/test_update_statement.py +++ b/cqlengine/tests/statements/test_update_statement.py @@ -23,7 +23,7 @@ def test_context(self): us.add_assignment_clause(AssignmentClause('a', 'b')) us.add_assignment_clause(AssignmentClause('c', 'd')) us.add_where_clause(WhereClause('a', EqualsOperator(), 'x')) - self.assertEqual(us.get_context(), {0: 'b', 1: 'd', 2: 'x'}) + self.assertEqual(us.get_context(), {'0': 'b', '1': 'd', '2': 'x'}) def test_additional_rendering(self): us = UpdateStatement('table', ttl=60) From a0e99934020ae840bb07859dd227118e4eb156af Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 08:14:30 -0800 Subject: [PATCH 0514/4200] fixing delete statement --- cqlengine/statements.py | 2 -- cqlengine/tests/statements/test_delete_statement.py | 10 +++++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 697bd6ceb5..f974a18951 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -554,8 +554,6 @@ def __unicode__(self): qs = ['DELETE'] if self.fields: qs += [', '.join(['{}'.format(f) for f in self.fields])] - else: - qs += ['*'] qs += ['FROM', self.table] if self.where_clauses: diff --git a/cqlengine/tests/statements/test_delete_statement.py b/cqlengine/tests/statements/test_delete_statement.py index be4087779a..b1055ea4fb 100644 --- a/cqlengine/tests/statements/test_delete_statement.py +++ b/cqlengine/tests/statements/test_delete_statement.py @@ -20,18 +20,18 @@ def test_field_rendering(self): def test_none_fields_rendering(self): """ tests that a '*' is added if no fields are passed in """ ds = DeleteStatement('table', None) - self.assertTrue(unicode(ds).startswith('DELETE *'), unicode(ds)) - self.assertTrue(str(ds).startswith('DELETE *'), str(ds)) + self.assertTrue(unicode(ds).startswith('DELETE FROM'), unicode(ds)) + self.assertTrue(str(ds).startswith('DELETE FROM'), str(ds)) def test_table_rendering(self): ds = DeleteStatement('table', None) - self.assertTrue(unicode(ds).startswith('DELETE * FROM table'), unicode(ds)) - self.assertTrue(str(ds).startswith('DELETE * FROM table'), str(ds)) + self.assertTrue(unicode(ds).startswith('DELETE FROM table'), unicode(ds)) + self.assertTrue(str(ds).startswith('DELETE FROM table'), str(ds)) def test_where_clause_rendering(self): ds = DeleteStatement('table', None) ds.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) - self.assertEqual(unicode(ds), 'DELETE * FROM table WHERE "a" = :0', unicode(ds)) + self.assertEqual(unicode(ds), 'DELETE FROM table WHERE "a" = :0', unicode(ds)) def test_context(self): ds = DeleteStatement('table', None) From 2daf6390e6a8ec7b5d99d20861f8db3e98b8216b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 08:19:30 -0800 Subject: [PATCH 0515/4200] fixing IN statement to_database --- cqlengine/query.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 7de3898da6..3cdfd57e77 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -493,7 +493,14 @@ def filter(self, *args, **kwargs): operator_class = BaseWhereOperator.get_operator(col_op or 'EQ') operator = operator_class() - clone._where.append(WhereClause(col_name, operator, column.to_database(val))) + if isinstance(operator, InOperator): + if not isinstance(val, (list, tuple)): + raise QueryException('IN queries must use a list/tuple value') + query_val = [column.to_database(v) for v in val] + else: + query_val = column.to_database(val) + + clone._where.append(WhereClause(col_name, operator, query_val)) return clone From 7d98bfb67c82d5101fb4e5eba683ea4fdeb3fda6 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 08:42:51 -0800 Subject: [PATCH 0516/4200] refactoring where test --- cqlengine/tests/query/test_named.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/cqlengine/tests/query/test_named.py b/cqlengine/tests/query/test_named.py index 8f9781da96..e63261f79c 100644 --- a/cqlengine/tests/query/test_named.py +++ b/cqlengine/tests/query/test_named.py @@ -1,5 +1,6 @@ from cqlengine import operators from cqlengine.named import NamedKeyspace +from cqlengine.operators import EqualsOperator, GreaterThanOrEqualOperator from cqlengine.query import ResultObject from cqlengine.tests.query.test_queryset import BaseQuerySetUsage from cqlengine.tests.base import BaseCassEngTestCase @@ -52,14 +53,24 @@ def test_filter_method_where_clause_generation(self): Tests the where clause creation """ query1 = self.table.objects(test_id=5) - ids = [o.query_value.identifier for o in query1._where] - where = query1._where_clause() - assert where == '"test_id" = :{}'.format(*ids) + self.assertEqual(len(query1._where), 1) + where = query1._where[0] + self.assertEqual(where.field, 'test_id') + self.assertEqual(where.value, 5) query2 = query1.filter(expected_result__gte=1) - ids = [o.query_value.identifier for o in query2._where] - where = query2._where_clause() - assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) + where = query2._where + self.assertEqual(len(where), 2) + + where = query2._where[0] + self.assertEqual(where.field, 'test_id') + self.assertIsInstance(where.operator, EqualsOperator) + self.assertEqual(where.value, 5) + + where = query2._where[1] + self.assertEqual(where.field, 'expected_result') + self.assertIsInstance(where.operator, GreaterThanOrEqualOperator) + self.assertEqual(where.value, 1) def test_query_expression_where_clause_generation(self): """ From 1dd5b50bd7ce432a9dd39a8bae99fc154c98a077 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 08:43:05 -0800 Subject: [PATCH 0517/4200] refactoring update query --- cqlengine/query.py | 42 ++++++++++----------------- cqlengine/tests/query/test_updates.py | 3 ++ 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 3cdfd57e77..545d5810ae 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -16,7 +16,7 @@ #http://www.datastax.com/docs/1.1/references/cql/index from cqlengine.operators import InOperator, EqualsOperator, GreaterThanOperator, GreaterThanOrEqualOperator from cqlengine.operators import LessThanOperator, LessThanOrEqualOperator, BaseWhereOperator -from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement +from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause class QueryException(CQLEngineException): pass @@ -767,9 +767,8 @@ def update(self, **values): if not values: return - set_statements = [] - ctx = {} nulled_columns = set() + us = UpdateStatement(self.column_family_name, where=self._where, ttl=self._ttl) for name, val in values.items(): col = self.model._columns.get(name) # check for nonexistant columns @@ -784,39 +783,28 @@ def update(self, **values): nulled_columns.add(name) continue # add the update statements - if isinstance(col, (BaseContainerColumn, Counter)): - val_mgr = self.instance._values[name] - set_statements += col.get_update_statement(val, val_mgr.previous_value, ctx) - + if isinstance(col, Counter): + # TODO: implement counter updates + raise NotImplementedError else: - field_id = uuid4().hex - set_statements += ['"{}" = :{}'.format(col.db_field_name, field_id)] - ctx[field_id] = val - - if set_statements: - ttl_stmt = "USING TTL {}".format(self._ttl) if self._ttl else "" - qs = "UPDATE {} SET {} WHERE {} {}".format( - self.column_family_name, - ', '.join(set_statements), - self._where_clause(), - ttl_stmt - ) - ctx.update(self._where_values()) + us.add_assignment_clause(AssignmentClause(name, col.to_database(val))) + + if us.assignments: + qs = str(us) + ctx = us.get_context() if self._batch: self._batch.add_query(qs, ctx) else: execute(qs, ctx, self._consistency) if nulled_columns: - qs = "DELETE {} FROM {} WHERE {}".format( - ', '.join(nulled_columns), - self.column_family_name, - self._where_clause() - ) + ds = DeleteStatement(self.column_family_name, fields=nulled_columns, where=self._where) + qs = str(ds) + ctx = ds.get_context() if self._batch: - self._batch.add_query(qs, self._where_values()) + self._batch.add_query(qs, ctx) else: - execute(qs, self._where_values(), self._consistency) + execute(qs, ctx, self._consistency) class DMLQuery(object): diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py index 0c9c88cf21..b54b294c60 100644 --- a/cqlengine/tests/query/test_updates.py +++ b/cqlengine/tests/query/test_updates.py @@ -112,3 +112,6 @@ def test_mixed_value_and_null_update(self): assert row.cluster == i assert row.count == (6 if i == 3 else i) assert row.text == (None if i == 3 else str(i)) + + def test_counter_updates(self): + pass From 3a5a09fe5ad8d5b6d47ca7b24abd2eaeaaa001d9 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 08:46:12 -0800 Subject: [PATCH 0518/4200] fixing more where construction tests --- cqlengine/tests/query/test_named.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/cqlengine/tests/query/test_named.py b/cqlengine/tests/query/test_named.py index e63261f79c..38df15420a 100644 --- a/cqlengine/tests/query/test_named.py +++ b/cqlengine/tests/query/test_named.py @@ -22,14 +22,14 @@ def test_query_filter_parsing(self): assert len(query1._where) == 1 op = query1._where[0] - assert isinstance(op, operators.EqualsOperator) + assert isinstance(op.operator, operators.EqualsOperator) assert op.value == 5 query2 = query1.filter(expected_result__gte=1) assert len(query2._where) == 2 op = query2._where[1] - assert isinstance(op, operators.GreaterThanOrEqualOperator) + assert isinstance(op.operator, operators.GreaterThanOrEqualOperator) assert op.value == 1 def test_query_expression_parsing(self): @@ -59,8 +59,7 @@ def test_filter_method_where_clause_generation(self): self.assertEqual(where.value, 5) query2 = query1.filter(expected_result__gte=1) - where = query2._where - self.assertEqual(len(where), 2) + self.assertEqual(len(query2._where), 2) where = query2._where[0] self.assertEqual(where.field, 'test_id') @@ -77,14 +76,23 @@ def test_query_expression_where_clause_generation(self): Tests the where clause creation """ query1 = self.table.objects(self.table.column('test_id') == 5) - ids = [o.query_value.identifier for o in query1._where] - where = query1._where_clause() - assert where == '"test_id" = :{}'.format(*ids) + self.assertEqual(len(query1._where), 1) + where = query1._where[0] + self.assertEqual(where.field, 'test_id') + self.assertEqual(where.value, 5) query2 = query1.filter(self.table.column('expected_result') >= 1) - ids = [o.query_value.identifier for o in query2._where] - where = query2._where_clause() - assert where == '"test_id" = :{} AND "expected_result" >= :{}'.format(*ids) + self.assertEqual(len(query2._where), 2) + + where = query2._where[0] + self.assertEqual(where.field, 'test_id') + self.assertIsInstance(where.operator, EqualsOperator) + self.assertEqual(where.value, 5) + + where = query2._where[1] + self.assertEqual(where.field, 'expected_result') + self.assertIsInstance(where.operator, GreaterThanOrEqualOperator) + self.assertEqual(where.value, 1) class TestQuerySetCountSelectionAndIteration(BaseQuerySetUsage): @@ -99,7 +107,6 @@ def setUpClass(cls): cls.keyspace = NamedKeyspace(ks) cls.table = cls.keyspace.table(tn) - def test_count(self): """ Tests that adding filtering statements affects the count query as expected """ assert self.table.objects.count() == 12 From f426e0138e1eb25304c01e051ec5d01295e9c4ee Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 09:53:51 -0800 Subject: [PATCH 0519/4200] hacking in the query functions, also getting all tests to pass --- cqlengine/columns.py | 9 ++- cqlengine/functions.py | 63 ++++++++++---------- cqlengine/named.py | 4 ++ cqlengine/query.py | 8 ++- cqlengine/statements.py | 25 +++++++- cqlengine/tests/query/test_queryoperators.py | 34 +++++++---- 6 files changed, 93 insertions(+), 50 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 79b90e25b9..05ace2bb7f 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -865,8 +865,15 @@ def __init__(self, model): self.partition_columns = model._partition_keys.values() super(_PartitionKeysToken, self).__init__(partition_key=True) + @property + def db_field_name(self): + return 'token({})'.format(', '.join(['"{}"'.format(c.db_field_name) for c in self.partition_columns])) + def to_database(self, value): - raise NotImplementedError + from cqlengine.functions import Token + assert isinstance(value, Token) + value.set_columns(self.partition_columns) + return value def get_cql(self): return "token({})".format(", ".join(c.cql for c in self.partition_columns)) diff --git a/cqlengine/functions.py b/cqlengine/functions.py index 136618453c..ceb6a78be9 100644 --- a/cqlengine/functions.py +++ b/cqlengine/functions.py @@ -9,24 +9,24 @@ class QueryValue(object): be passed into .filter() keyword args """ - _cql_string = ':{}' + format_string = ':{}' - def __init__(self, value, identifier=None): + def __init__(self, value): self.value = value - self.identifier = uuid1().hex if identifier is None else identifier + self.context_id = None + + def __unicode__(self): + return self.format_string.format(self.context_id) - def get_cql(self): - return self._cql_string.format(self.identifier) + def set_context_id(self, ctx_id): + self.context_id = ctx_id - def get_value(self): - return self.value + def get_context_size(self): + return 1 - def get_dict(self, column): - return {self.identifier: column.to_database(self.get_value())} + def update_context(self, ctx): + ctx[str(self.context_id)] = self.value - @property - def cql(self): - return self.get_cql() class BaseQueryFunction(QueryValue): """ @@ -42,7 +42,7 @@ class MinTimeUUID(BaseQueryFunction): http://cassandra.apache.org/doc/cql3/CQL.html#timeuuidFun """ - _cql_string = 'MinTimeUUID(:{})' + format_string = 'MinTimeUUID(:{})' def __init__(self, value): """ @@ -53,14 +53,11 @@ def __init__(self, value): raise ValidationError('datetime instance is required') super(MinTimeUUID, self).__init__(value) - def get_value(self): + def update_context(self, ctx): epoch = datetime(1970, 1, 1, tzinfo=self.value.tzinfo) offset = epoch.tzinfo.utcoffset(epoch).total_seconds() if epoch.tzinfo else 0 + ctx[str(self.context_id)] = long(((self.value - epoch).total_seconds() - offset) * 1000) - return long(((self.value - epoch).total_seconds() - offset) * 1000) - - def get_dict(self, column): - return {self.identifier: self.get_value()} class MaxTimeUUID(BaseQueryFunction): """ @@ -69,7 +66,7 @@ class MaxTimeUUID(BaseQueryFunction): http://cassandra.apache.org/doc/cql3/CQL.html#timeuuidFun """ - _cql_string = 'MaxTimeUUID(:{})' + format_string = 'MaxTimeUUID(:{})' def __init__(self, value): """ @@ -80,14 +77,11 @@ def __init__(self, value): raise ValidationError('datetime instance is required') super(MaxTimeUUID, self).__init__(value) - def get_value(self): + def update_context(self, ctx): epoch = datetime(1970, 1, 1, tzinfo=self.value.tzinfo) offset = epoch.tzinfo.utcoffset(epoch).total_seconds() if epoch.tzinfo else 0 + ctx[str(self.context_id)] = long(((self.value - epoch).total_seconds() - offset) * 1000) - return long(((self.value - epoch).total_seconds() - offset) * 1000) - - def get_dict(self, column): - return {self.identifier: self.get_value()} class Token(BaseQueryFunction): """ @@ -99,15 +93,20 @@ class Token(BaseQueryFunction): def __init__(self, *values): if len(values) == 1 and isinstance(values[0], (list, tuple)): values = values[0] - super(Token, self).__init__(values, [uuid1().hex for i in values]) + super(Token, self).__init__(values) + self._columns = None + + def set_columns(self, columns): + self._columns = columns - def get_dict(self, column): - items = zip(self.identifier, self.value, column.partition_columns) - return dict( - (id, col.to_database(val)) for id, val, col in items - ) + def get_context_size(self): + return len(self.value) - def get_cql(self): - token_args = ', '.join(':{}'.format(id) for id in self.identifier) + def __unicode__(self): + token_args = ', '.join(':{}'.format(self.context_id + i) for i in range(self.get_context_size())) return "token({})".format(token_args) + def update_context(self, ctx): + for i, (col, val) in enumerate(zip(self._columns, self.value)): + ctx[str(self.context_id + i)] = col.to_database(val) + diff --git a/cqlengine/named.py b/cqlengine/named.py index 2b75443144..c6ba3ac995 100644 --- a/cqlengine/named.py +++ b/cqlengine/named.py @@ -40,6 +40,10 @@ def _get_column(self): """ :rtype: NamedColumn """ return self + @property + def db_field_name(self): + return self.name + @property def cql(self): return self.get_cql() diff --git a/cqlengine/query.py b/cqlengine/query.py index 545d5810ae..0418883fef 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -10,7 +10,7 @@ from cqlengine.connection import connection_manager, execute, RowResult from cqlengine.exceptions import CQLEngineException, ValidationError -from cqlengine.functions import QueryValue, Token +from cqlengine.functions import QueryValue, Token, BaseQueryFunction #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index @@ -480,12 +480,14 @@ def filter(self, *args, **kwargs): for arg, val in kwargs.items(): col_name, col_op = self._parse_filter_arg(arg) + quote_field = True #resolve column and operator try: column = self.model._get_column(col_name) except KeyError: if col_name == 'pk__token': column = columns._PartitionKeysToken(self.model) + quote_field = False else: raise QueryException("Can't resolve column name: '{}'".format(col_name)) @@ -497,10 +499,12 @@ def filter(self, *args, **kwargs): if not isinstance(val, (list, tuple)): raise QueryException('IN queries must use a list/tuple value') query_val = [column.to_database(v) for v in val] + elif isinstance(val, BaseQueryFunction): + query_val = val else: query_val = column.to_database(val) - clone._where.append(WhereClause(col_name, operator, query_val)) + clone._where.append(WhereClause(column.db_field_name, operator, query_val, quote_field=quote_field)) return clone diff --git a/cqlengine/statements.py b/cqlengine/statements.py index f974a18951..834e647924 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -1,3 +1,4 @@ +from cqlengine.functions import QueryValue from cqlengine.operators import BaseWhereOperator, InOperator @@ -78,16 +79,27 @@ def update_context(self, ctx): class WhereClause(BaseClause): """ a single where statement used in queries """ - def __init__(self, field, operator, value): + def __init__(self, field, operator, value, quote_field=True): + """ + + :param field: + :param operator: + :param value: + :param quote_field: hack to get the token function rendering properly + :return: + """ if not isinstance(operator, BaseWhereOperator): raise StatementException( "operator must be of type {}, got {}".format(BaseWhereOperator, type(operator)) ) super(WhereClause, self).__init__(field, value) self.operator = operator + self.query_value = self.value if isinstance(self.value, QueryValue) else QueryValue(self.value) + self.quote_field = quote_field def __unicode__(self): - return u'"{}" {} :{}'.format(self.field, self.operator, self.context_id) + field = ('"{}"' if self.quote_field else '{}').format(self.field) + return u'{} {} {}'.format(field, self.operator, unicode(self.query_value)) def __hash__(self): return super(WhereClause, self).__hash__() ^ hash(self.operator) @@ -97,11 +109,18 @@ def __eq__(self, other): return self.operator.__class__ == other.operator.__class__ return False + def get_context_size(self): + return self.query_value.get_context_size() + + def set_context_id(self, i): + super(WhereClause, self).set_context_id(i) + self.query_value.set_context_id(i) + def update_context(self, ctx): if isinstance(self.operator, InOperator): ctx[str(self.context_id)] = InQuoter(self.value) else: - super(WhereClause, self).update_context(ctx) + self.query_value.update_context(ctx) class AssignmentClause(BaseClause): diff --git a/cqlengine/tests/query/test_queryoperators.py b/cqlengine/tests/query/test_queryoperators.py index 8f5243fbab..11dec4dded 100644 --- a/cqlengine/tests/query/test_queryoperators.py +++ b/cqlengine/tests/query/test_queryoperators.py @@ -1,10 +1,12 @@ from datetime import datetime -import time +from cqlengine.columns import DateTime from cqlengine.tests.base import BaseCassEngTestCase from cqlengine import columns, Model from cqlengine import functions from cqlengine import query +from cqlengine.statements import WhereClause +from cqlengine.operators import EqualsOperator class TestQuerySetOperation(BaseCassEngTestCase): @@ -13,22 +15,26 @@ def test_maxtimeuuid_function(self): Tests that queries with helper functions are generated properly """ now = datetime.now() - col = columns.DateTime() - col.set_column_name('time') - qry = query.EqualsOperator(col, functions.MaxTimeUUID(now)) + where = WhereClause('time', EqualsOperator(), functions.MaxTimeUUID(now)) + where.set_context_id(5) - assert qry.cql == '"time" = MaxTimeUUID(:{})'.format(qry.value.identifier) + self.assertEqual(str(where), '"time" = MaxTimeUUID(:5)') + ctx = {} + where.update_context(ctx) + self.assertEqual(ctx, {'5': DateTime().to_database(now)}) def test_mintimeuuid_function(self): """ Tests that queries with helper functions are generated properly """ now = datetime.now() - col = columns.DateTime() - col.set_column_name('time') - qry = query.EqualsOperator(col, functions.MinTimeUUID(now)) + where = WhereClause('time', EqualsOperator(), functions.MinTimeUUID(now)) + where.set_context_id(5) - assert qry.cql == '"time" = MinTimeUUID(:{})'.format(qry.value.identifier) + self.assertEqual(str(where), '"time" = MinTimeUUID(:5)') + ctx = {} + where.update_context(ctx) + self.assertEqual(ctx, {'5': DateTime().to_database(now)}) def test_token_function(self): @@ -39,12 +45,16 @@ class TestModel(Model): func = functions.Token('a', 'b') q = TestModel.objects.filter(pk__token__gt=func) - self.assertEquals(q._where[0].cql, 'token("p1", "p2") > token(:{}, :{})'.format(*func.identifier)) + where = q._where[0] + where.set_context_id(1) + self.assertEquals(str(where), 'token("p1", "p2") > token(:{}, :{})'.format(1, 2)) - # Token(tuple()) is also possible for convinience + # Token(tuple()) is also possible for convenience # it (allows for Token(obj.pk) syntax) func = functions.Token(('a', 'b')) q = TestModel.objects.filter(pk__token__gt=func) - self.assertEquals(q._where[0].cql, 'token("p1", "p2") > token(:{}, :{})'.format(*func.identifier)) + where = q._where[0] + where.set_context_id(1) + self.assertEquals(str(where), 'token("p1", "p2") > token(:{}, :{})'.format(1, 2)) From b9fcb20ef37ad37518d436db26204d28bba37966 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 12:00:54 -0800 Subject: [PATCH 0520/4200] removing ttl statement method --- cqlengine/query.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 0418883fef..90805820d9 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -738,11 +738,6 @@ def _get_ordering_condition(self, colname): return column.db_field_name, order_type - def _get_ttl_statement(self): - if not self._ttl: - return "" - return "USING TTL {}".format(self._ttl) - def values_list(self, *fields, **kwargs): """ Instructs the query set to return tuples, not model instance """ flat = kwargs.pop('flat', False) From dcdc555422d88e538a078b6f4e7a74993a102bac Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 12:19:00 -0800 Subject: [PATCH 0521/4200] moving delete query to delete statement --- cqlengine/query.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 90805820d9..c5e36618a3 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -1003,21 +1003,20 @@ def delete(self): """ Deletes one instance """ if self.instance is None: raise CQLEngineException("DML Query intance attribute is None") - field_values = {} - qs = ['DELETE FROM {}'.format(self.column_family_name)] - qs += ['WHERE'] - where_statements = [] - for name, col in self.model._primary_keys.items(): - field_id = uuid4().hex - field_values[field_id] = col.to_database(getattr(self.instance, name)) - where_statements += ['"{}" = :{}'.format(col.db_field_name, field_id)] - - qs += [' AND '.join(where_statements)] - qs = ' '.join(qs) + ds = DeleteStatement(self.column_family_name) + for name, col in self.model._primary_keys.items(): + ds.add_where_clause(WhereClause( + col.db_field_name, + EqualsOperator(), + col.to_database(getattr(self.instance, name)) + )) + + qs = str(ds) + ctx = ds.get_context() if self._batch: - self._batch.add_query(qs, field_values) + self._batch.add_query(qs, ctx) else: - execute(qs, field_values, self._consistency) + execute(qs, ctx, self._consistency) From 25e787a82cb18882993ae2812f1f7b40051eb7bc Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 13:34:35 -0800 Subject: [PATCH 0522/4200] moving save insert to insert statement --- cqlengine/query.py | 38 ++++++++++++++++++++------------------ cqlengine/statements.py | 7 +++++++ 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index c5e36618a3..35e58459b4 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -16,7 +16,7 @@ #http://www.datastax.com/docs/1.1/references/cql/index from cqlengine.operators import InOperator, EqualsOperator, GreaterThanOperator, GreaterThanOrEqualOperator from cqlengine.operators import LessThanOperator, LessThanOrEqualOperator, BaseWhereOperator -from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause +from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause, InsertStatement class QueryException(CQLEngineException): pass @@ -938,6 +938,7 @@ def update(self): self._delete_null_columns() + # TODO: delete def _get_query_values(self): """ returns all the data needed to do queries @@ -970,31 +971,32 @@ def save(self): raise CQLEngineException("DML Query intance attribute is None") assert type(self.instance) == self.model - values, field_names, field_ids, field_values, query_values = self._get_query_values() - - qs = [] + nulled_fields = set() if self.instance._has_counter or self.instance._can_update(): return self.update() else: - qs += ["INSERT INTO {}".format(self.column_family_name)] - qs += ["({})".format(', '.join(['"{}"'.format(f) for f in field_names]))] - qs += ['VALUES'] - qs += ["({})".format(', '.join([':'+field_ids[f] for f in field_names]))] - - if self._ttl: - qs += ["USING TTL {}".format(self._ttl)] - - qs += [] - qs = ' '.join(qs) - + insert = InsertStatement(self.column_family_name, ttl=self._ttl) + for name, col in self.instance._columns.items(): + val = getattr(self.instance, name, None) + if col._val_is_null(val): + if self.instance._values[name].changed: + nulled_fields.add(col.db_field_name) + continue + insert.add_assignment_clause(AssignmentClause( + col.db_field_name, + col.to_database(getattr(self.instance, name, None)) + )) # skip query execution if it's empty # caused by pointless update queries - if qs: + if not insert.is_empty: + qs = str(insert) + ctx = insert.get_context() + if self._batch: - self._batch.add_query(qs, query_values) + self._batch.add_query(qs, ctx) else: - execute(qs, query_values, self._consistency) + execute(qs, ctx, self._consistency) # delete any nulled columns self._delete_null_columns() diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 834e647924..ce8de5836b 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -414,6 +414,9 @@ def __unicode__(self): def __str__(self): return unicode(self).encode('utf-8') + def __repr__(self): + return self.__unicode__() + @property def _where(self): return 'WHERE {}'.format(' AND '.join([unicode(c) for c in self.where_clauses])) @@ -500,6 +503,10 @@ def add_assignment_clause(self, clause): self.context_counter += clause.get_context_size() self.assignments.append(clause) + @property + def is_empty(self): + return len(self.assignments) == 0 + def get_context(self): ctx = super(AssignmentStatement, self).get_context() for clause in self.assignments: From 042d0aee0e21d1c80ec9a61c83dce11de874b501 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 15:24:37 -0800 Subject: [PATCH 0523/4200] adding BaseCQLStatemtent support to query execution --- cqlengine/connection.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 6fec0d3d64..6e08a3580f 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -17,6 +17,7 @@ from contextlib import contextmanager from thrift.transport.TTransport import TTransportException +from cqlengine.statements import BaseCQLStatement LOG = logging.getLogger('cqlengine.cql') @@ -230,6 +231,9 @@ def execute(self, query, params, consistency_level=None): def execute(query, params=None, consistency_level=None): + if isinstance(query, BaseCQLStatement): + params = query.get_context() + query = str(query) params = params or {} if consistency_level is None: consistency_level = connection_pool._consistency From 8767fb18cacc1d33930c8281986db5964acb67d8 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 15:24:56 -0800 Subject: [PATCH 0524/4200] encapsulating raw or batch query execution into a querset method --- cqlengine/query.py | 58 ++++++++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 35e58459b4..55588546dc 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -16,7 +16,7 @@ #http://www.datastax.com/docs/1.1/references/cql/index from cqlengine.operators import InOperator, EqualsOperator, GreaterThanOperator, GreaterThanOrEqualOperator from cqlengine.operators import LessThanOperator, LessThanOrEqualOperator, BaseWhereOperator -from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause, InsertStatement +from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause, InsertStatement, BaseCQLStatement class QueryException(CQLEngineException): pass @@ -229,7 +229,10 @@ def __init__(self, batch_type=None, timestamp=None, consistency=None): self._consistency = consistency def add_query(self, query, params): - self.queries.append((query, params)) + if not isinstance(query, BaseCQLStatement): + raise CQLEngineException('only BaseCQLStatements can be added to a batch query') + # TODO: modify query's context id starting point + self.queries.append((str(query), query.get_context())) def consistency(self, consistency): self._consistency = consistency @@ -305,6 +308,12 @@ def __init__(self, model): def column_family_name(self): return self.model.column_family_name() + def _execute(self, q, params=None): + if self._batch: + return self._batch.add_query(q, params=params) + else: + return execute(q, params=params, consistency_level=self._consistency) + def __unicode__(self): return self._select_query() @@ -364,8 +373,7 @@ def _execute_query(self): if self._batch: raise CQLEngineException("Only inserts, updates, and deletes are available in batch mode") if self._result_cache is None: - query = self._select_query() - columns, self._result_cache = execute(unicode(query).encode('utf-8'), query.get_context(), self._consistency) + columns, self._result_cache = self._execute(self._select_query()) self._construct_result = self._get_result_constructor(columns) def _fill_result_cache_to_idx(self, idx): @@ -561,7 +569,7 @@ def count(self): if self._result_cache is None: query = self._select_query() query.count = True - _, result = execute(str(query), query.get_context()) + _, result = self._execute(query) return result[0][0] else: return len(self._result_cache) @@ -637,11 +645,7 @@ def delete(self): self.column_family_name, where=self._where ) - - if self._batch: - self._batch.add_query(str(dq), dq.get_context()) - else: - execute(str(dq), dq.get_context()) + self._execute(dq) def __eq__(self, q): if len(self._where) == len(q._where): @@ -825,6 +829,12 @@ def __init__(self, model, instance=None, batch=None, ttl=None, consistency=None) self._ttl = ttl self._consistency = consistency + def _execute(self, q, params=None): + if self._batch: + return self._batch.add_query(q, params=params) + else: + return execute(q, params=params, consistency_level=self._consistency) + def batch(self, batch_obj): if batch_obj is not None and not isinstance(batch_obj, BatchQuery): raise CQLEngineException('batch_obj must be a BatchQuery instance or None') @@ -864,10 +874,7 @@ def _delete_null_columns(self): qs = ' '.join(qs) - if self._batch: - self._batch.add_query(qs, query_values) - else: - execute(qs, query_values) + self._execute(qs, query_values) def update(self): """ @@ -931,10 +938,7 @@ def update(self): # skip query execution if it's empty # caused by pointless update queries if qs: - if self._batch: - self._batch.add_query(qs, query_values) - else: - execute(qs, query_values, consistency_level=self._consistency) + self._execute(qs, query_values) self._delete_null_columns() @@ -990,13 +994,7 @@ def save(self): # skip query execution if it's empty # caused by pointless update queries if not insert.is_empty: - qs = str(insert) - ctx = insert.get_context() - - if self._batch: - self._batch.add_query(qs, ctx) - else: - execute(qs, ctx, self._consistency) + self._execute(insert) # delete any nulled columns self._delete_null_columns() @@ -1004,7 +1002,7 @@ def save(self): def delete(self): """ Deletes one instance """ if self.instance is None: - raise CQLEngineException("DML Query intance attribute is None") + raise CQLEngineException("DML Query instance attribute is None") ds = DeleteStatement(self.column_family_name) for name, col in self.model._primary_keys.items(): @@ -1013,12 +1011,6 @@ def delete(self): EqualsOperator(), col.to_database(getattr(self.instance, name)) )) - - qs = str(ds) - ctx = ds.get_context() - if self._batch: - self._batch.add_query(qs, ctx) - else: - execute(qs, ctx, self._consistency) + self._execute(ds) From 095ef4d89a2bf273f1675756607ecfd43bb3f619 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 15:48:19 -0800 Subject: [PATCH 0525/4200] moving delete null columns to delete statement --- cqlengine/query.py | 61 ++++++++++++++--------------------------- cqlengine/statements.py | 8 ++++++ 2 files changed, 29 insertions(+), 40 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 55588546dc..c7df62cb65 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -16,7 +16,7 @@ #http://www.datastax.com/docs/1.1/references/cql/index from cqlengine.operators import InOperator, EqualsOperator, GreaterThanOperator, GreaterThanOrEqualOperator from cqlengine.operators import LessThanOperator, LessThanOrEqualOperator, BaseWhereOperator -from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause, InsertStatement, BaseCQLStatement +from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause, InsertStatement, BaseCQLStatement, MapUpdateClause, MapDeleteClause class QueryException(CQLEngineException): pass @@ -310,9 +310,9 @@ def column_family_name(self): def _execute(self, q, params=None): if self._batch: - return self._batch.add_query(q, params=params) + return self._batch.add_query(q) else: - return execute(q, params=params, consistency_level=self._consistency) + return execute(q, consistency_level=self._consistency) def __unicode__(self): return self._select_query() @@ -793,21 +793,11 @@ def update(self, **values): us.add_assignment_clause(AssignmentClause(name, col.to_database(val))) if us.assignments: - qs = str(us) - ctx = us.get_context() - if self._batch: - self._batch.add_query(qs, ctx) - else: - execute(qs, ctx, self._consistency) + self._execute(us) if nulled_columns: ds = DeleteStatement(self.column_family_name, fields=nulled_columns, where=self._where) - qs = str(ds) - ctx = ds.get_context() - if self._batch: - self._batch.add_query(qs, ctx) - else: - execute(qs, ctx, self._consistency) + self._execute(ds) class DMLQuery(object): @@ -845,36 +835,27 @@ def _delete_null_columns(self): """ executes a delete query to remove columns that have changed to null """ - values, field_names, field_ids, field_values, query_values = self._get_query_values() - - # delete nulled columns and removed map keys - qs = ['DELETE'] - query_values = {} - - del_statements = [] - for k,v in self.instance._values.items(): + ds = DeleteStatement(self.column_family_name) + deleted_fields = False + for _, v in self.instance._values.items(): col = v.column if v.deleted: - del_statements += ['"{}"'.format(col.db_field_name)] + ds.add_field(col.db_field_name) + deleted_fields = True elif isinstance(col, Map): - del_statements += col.get_delete_statement(v.value, v.previous_value, query_values) + uc = MapDeleteClause(col.db_field_name, v.value, v.previous_value) + if uc.get_context_size() > 0: + ds.add_field(uc) + deleted_fields = True - if del_statements: - qs += [', '.join(del_statements)] - - qs += ['FROM {}'.format(self.column_family_name)] - - qs += ['WHERE'] - where_statements = [] + if deleted_fields: for name, col in self.model._primary_keys.items(): - field_id = uuid4().hex - query_values[field_id] = field_values[name] - where_statements += ['"{}" = :{}'.format(col.db_field_name, field_id)] - qs += [' AND '.join(where_statements)] - - qs = ' '.join(qs) - - self._execute(qs, query_values) + ds.add_where_clause(WhereClause( + col.db_field_name, + EqualsOperator(), + col.to_database(getattr(self.instance, name)) + )) + self._execute(ds) def update(self): """ diff --git a/cqlengine/statements.py b/cqlengine/statements.py index ce8de5836b..4156c9ac6e 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -569,11 +569,19 @@ def __init__(self, table, fields=None, consistency=None, where=None): for field in fields or []: self.add_field(field) + def get_context(self): + ctx = super(DeleteStatement, self).get_context() + for field in self.fields: + field.update_context(ctx) + return ctx + def add_field(self, field): if isinstance(field, basestring): field = FieldDeleteClause(field) if not isinstance(field, BaseClause): raise StatementException("only instances of AssignmentClause can be added to statements") + field.set_context_id(self.context_counter) + self.context_counter += field.get_context_size() self.fields.append(field) def __unicode__(self): From 845a52a08196f472663d5bd8432056daaf1eb5ed Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 16:03:50 -0800 Subject: [PATCH 0526/4200] moving update query to update statement --- cqlengine/query.py | 95 ++++++++++++++--------------------------- cqlengine/statements.py | 35 +++++++++------ 2 files changed, 53 insertions(+), 77 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index c7df62cb65..f5c15e3a23 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -5,7 +5,7 @@ from time import time from uuid import uuid1 from cqlengine import BaseContainerColumn, BaseValueManager, Map, columns -from cqlengine.columns import Counter +from cqlengine.columns import Counter, List, Set from cqlengine.connection import connection_manager, execute, RowResult @@ -16,7 +16,7 @@ #http://www.datastax.com/docs/1.1/references/cql/index from cqlengine.operators import InOperator, EqualsOperator, GreaterThanOperator, GreaterThanOrEqualOperator from cqlengine.operators import LessThanOperator, LessThanOrEqualOperator, BaseWhereOperator -from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause, InsertStatement, BaseCQLStatement, MapUpdateClause, MapDeleteClause +from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause, InsertStatement, BaseCQLStatement, MapUpdateClause, MapDeleteClause, ListUpdateClause, SetUpdateClause class QueryException(CQLEngineException): pass @@ -868,83 +868,52 @@ def update(self): raise CQLEngineException("DML Query intance attribute is None") assert type(self.instance) == self.model - values, field_names, field_ids, field_values, query_values = self._get_query_values() - - qs = [] - qs += ["UPDATE {}".format(self.column_family_name)] - qs += ["SET"] - - set_statements = [] + statement = UpdateStatement(self.column_family_name, ttl=self._ttl) #get defined fields and their column names for name, col in self.model._columns.items(): if not col.is_primary_key: - val = values.get(name) + val = getattr(self.instance, name, None) + val_mgr = self.instance._values[name] # don't update something that is null if val is None: continue # don't update something if it hasn't changed - if not self.instance._values[name].changed and not isinstance(col, Counter): + if not val_mgr.changed and not isinstance(col, Counter): continue - # add the update statements - if isinstance(col, (BaseContainerColumn, Counter)): - #remove value from query values, the column will handle it - query_values.pop(field_ids.get(name), None) - - val_mgr = self.instance._values[name] - set_statements += col.get_update_statement(val, val_mgr.previous_value, query_values) - + if isinstance(col, List): + clause = ListUpdateClause(col.db_field_name, val, val_mgr.previous_value, column=col) + if clause.get_context_size() > 0: + statement.add_assignment_clause(clause) + elif isinstance(col, Map): + clause = MapUpdateClause(col.db_field_name, val, val_mgr.previous_value, column=col) + if clause.get_context_size() > 0: + statement.add_assignment_clause(clause) + elif isinstance(col, Set): + clause = SetUpdateClause(col.db_field_name, val, val_mgr.previous_value, column=col) + if clause.get_context_size() > 0: + statement.add_assignment_clause(clause) + elif isinstance(col, Counter): + raise NotImplementedError else: - set_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] - qs += [', '.join(set_statements)] - - qs += ['WHERE'] + statement.add_assignment_clause(AssignmentClause( + col.db_field_name, + col.to_database(val) + )) - where_statements = [] - for name, col in self.model._primary_keys.items(): - where_statements += ['"{}" = :{}'.format(col.db_field_name, field_ids[col.db_field_name])] - - qs += [' AND '.join(where_statements)] - - if self._ttl: - qs += ["USING TTL {}".format(self._ttl)] - - # clear the qs if there are no set statements and this is not a counter model - if not set_statements and not self.instance._has_counter: - qs = [] - - qs = ' '.join(qs) - # skip query execution if it's empty - # caused by pointless update queries - if qs: - self._execute(qs, query_values) + if statement.get_context_size() > 0 or self.instance._has_counter: + for name, col in self.model._primary_keys.items(): + statement.add_where_clause(WhereClause( + col.db_field_name, + EqualsOperator(), + col.to_database(getattr(self.instance, name)) + )) + self._execute(statement) self._delete_null_columns() - # TODO: delete - def _get_query_values(self): - """ - returns all the data needed to do queries - """ - #organize data - value_pairs = [] - values = self.instance._as_dict() - - #get defined fields and their column names - for name, col in self.model._columns.items(): - val = values.get(name) - if col._val_is_null(val): continue - value_pairs += [(col.db_field_name, val)] - - #construct query string - field_names = zip(*value_pairs)[0] - field_ids = {n:uuid4().hex for n in field_names} - field_values = dict(value_pairs) - query_values = {field_ids[n]:field_values[n] for n in field_names} - return values, field_names, field_ids, field_values, query_values - def save(self): """ Creates / updates a row. diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 4156c9ac6e..ba13ea0b11 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -135,11 +135,15 @@ def insert_tuple(self): class ContainerUpdateClause(AssignmentClause): - def __init__(self, field, value, previous=None): + def __init__(self, field, value, previous=None, column=None): super(ContainerUpdateClause, self).__init__(field, value) self.previous = previous self._assignments = None self._analyzed = False + self._column = column + + def _to_database(self, val): + return self._column.to_database(val) if self._column else val def _analyze(self): raise NotImplementedError @@ -154,8 +158,8 @@ def update_context(self, ctx): class SetUpdateClause(ContainerUpdateClause): """ updates a set collection """ - def __init__(self, field, value, previous=None): - super(SetUpdateClause, self).__init__(field, value, previous) + def __init__(self, field, value, previous=None, column=None): + super(SetUpdateClause, self).__init__(field, value, previous, column=column) self._additions = None self._removals = None @@ -193,20 +197,20 @@ def update_context(self, ctx): if not self._analyzed: self._analyze() ctx_id = self.context_id if self._assignments: - ctx[str(ctx_id)] = self._assignments + ctx[str(ctx_id)] = self._to_database(self._assignments) ctx_id += 1 if self._additions: - ctx[str(ctx_id)] = self._additions + ctx[str(ctx_id)] = self._to_database(self._additions) ctx_id += 1 if self._removals: - ctx[str(ctx_id)] = self._removals + ctx[str(ctx_id)] = self._to_database(self._removals) class ListUpdateClause(ContainerUpdateClause): """ updates a list collection """ - def __init__(self, field, value, previous=None): - super(ListUpdateClause, self).__init__(field, value, previous) + def __init__(self, field, value, previous=None, column=None): + super(ListUpdateClause, self).__init__(field, value, previous, column=column) self._append = None self._prepend = None @@ -235,16 +239,16 @@ def update_context(self, ctx): if not self._analyzed: self._analyze() ctx_id = self.context_id if self._assignments: - ctx[str(ctx_id)] = self._assignments + ctx[str(ctx_id)] = self._to_database(self._assignments) ctx_id += 1 if self._prepend: # CQL seems to prepend element at a time, starting # with the element at idx 0, we can either reverse # it here, or have it inserted in reverse - ctx[str(ctx_id)] = list(reversed(self._prepend)) + ctx[str(ctx_id)] = self._to_database(list(reversed(self._prepend))) ctx_id += 1 if self._append: - ctx[str(ctx_id)] = self._append + ctx[str(ctx_id)] = self._to_database(self._append) def _analyze(self): """ works out the updates to be performed """ @@ -292,8 +296,8 @@ def _analyze(self): class MapUpdateClause(ContainerUpdateClause): """ updates a map collection """ - def __init__(self, field, value, previous=None): - super(MapUpdateClause, self).__init__(field, value, previous) + def __init__(self, field, value, previous=None, column=None): + super(MapUpdateClause, self).__init__(field, value, previous, column=column) self._updates = None self.previous = self.previous or {} @@ -310,7 +314,7 @@ def update_context(self, ctx): ctx_id = self.context_id for key in self._updates or []: ctx[str(ctx_id)] = key - ctx[str(ctx_id + 1)] = self.value.get(key) + ctx[str(ctx_id + 1)] = self._to_database(self.value.get(key)) ctx_id += 2 def __unicode__(self): @@ -408,6 +412,9 @@ def get_context(self): clause.update_context(ctx) return ctx + def get_context_size(self): + return len(self.get_context()) + def __unicode__(self): raise NotImplementedError From e3e7c18376cac94b43fb8278cebeabe751b612a1 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 16:12:54 -0800 Subject: [PATCH 0527/4200] adding counter column update clause and supporting unit tests --- cqlengine/statements.py | 19 +++++++++++ .../statements/test_assignment_clauses.py | 33 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index ba13ea0b11..c1de6414e2 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -329,6 +329,25 @@ def __unicode__(self): return ', '.join(qs) +class CounterUpdateClause(ContainerUpdateClause): + + def __init__(self, field, value, previous=None, column=None): + super(CounterUpdateClause, self).__init__(field, value, previous, column) + self.previous = self.previous or 0 + + def get_context_size(self): + return 1 if self.value != self.previous else 0 + + def update_context(self, ctx): + if self.value != self.previous: + ctx[str(self.context_id)] = self._to_database(abs(self.value - self.previous)) + + def __unicode__(self): + delta = self.value - self.previous + sign = '-' if delta < 0 else '+' + return '"{0}" = "{0}" {1} :{2}'.format(self.field, sign, self.context_id) + + class BaseDeleteClause(BaseClause): pass diff --git a/cqlengine/tests/statements/test_assignment_clauses.py b/cqlengine/tests/statements/test_assignment_clauses.py index 03d2a5ab0f..1b2e430e9a 100644 --- a/cqlengine/tests/statements/test_assignment_clauses.py +++ b/cqlengine/tests/statements/test_assignment_clauses.py @@ -1,5 +1,5 @@ from unittest import TestCase -from cqlengine.statements import AssignmentClause, SetUpdateClause, ListUpdateClause, MapUpdateClause, MapDeleteClause, FieldDeleteClause +from cqlengine.statements import AssignmentClause, SetUpdateClause, ListUpdateClause, MapUpdateClause, MapDeleteClause, FieldDeleteClause, CounterUpdateClause class AssignmentClauseTests(TestCase): @@ -268,6 +268,37 @@ def test_nulled_columns_arent_included(self): self.assertNotIn(1, c._updates) +class CounterUpdateTests(TestCase): + + def test_positive_update(self): + c = CounterUpdateClause('a', 5, 3) + c.set_context_id(5) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"a" = "a" + :5') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'5': 2}) + + def test_negative_update(self): + c = CounterUpdateClause('a', 4, 7) + c.set_context_id(3) + + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"a" = "a" - :3') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'3': 3}) + + def noop_update(self): + c = CounterUpdateClause('a', 5, 5) + c.set_context_id(5) + + self.assertEqual(c.get_context_size(), 0) + + class MapDeleteTests(TestCase): def test_update(self): From e66eecf28b1e1cd91f16f3c74fff1da5c8036179 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 16:16:27 -0800 Subject: [PATCH 0528/4200] adding counter column support to update and condensing the logic --- cqlengine/query.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index f5c15e3a23..b924cae6c2 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -16,7 +16,7 @@ #http://www.datastax.com/docs/1.1/references/cql/index from cqlengine.operators import InOperator, EqualsOperator, GreaterThanOperator, GreaterThanOrEqualOperator from cqlengine.operators import LessThanOperator, LessThanOrEqualOperator, BaseWhereOperator -from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause, InsertStatement, BaseCQLStatement, MapUpdateClause, MapDeleteClause, ListUpdateClause, SetUpdateClause +from cqlengine.statements import WhereClause, SelectStatement, DeleteStatement, UpdateStatement, AssignmentClause, InsertStatement, BaseCQLStatement, MapUpdateClause, MapDeleteClause, ListUpdateClause, SetUpdateClause, CounterUpdateClause class QueryException(CQLEngineException): pass @@ -883,20 +883,18 @@ def update(self): if not val_mgr.changed and not isinstance(col, Counter): continue - if isinstance(col, List): - clause = ListUpdateClause(col.db_field_name, val, val_mgr.previous_value, column=col) - if clause.get_context_size() > 0: - statement.add_assignment_clause(clause) - elif isinstance(col, Map): - clause = MapUpdateClause(col.db_field_name, val, val_mgr.previous_value, column=col) - if clause.get_context_size() > 0: - statement.add_assignment_clause(clause) - elif isinstance(col, Set): - clause = SetUpdateClause(col.db_field_name, val, val_mgr.previous_value, column=col) + if isinstance(col, (BaseContainerColumn, Counter)): + # get appropriate clause + if isinstance(col, List): klass = ListUpdateClause + elif isinstance(col, Map): klass = MapUpdateClause + elif isinstance(col, Set): klass = SetUpdateClause + elif isinstance(col, Counter): klass = CounterUpdateClause + else: raise RuntimeError + + # do the stuff + clause = klass(col.db_field_name, val, val_mgr.previous_value, column=col) if clause.get_context_size() > 0: statement.add_assignment_clause(clause) - elif isinstance(col, Counter): - raise NotImplementedError else: statement.add_assignment_clause(AssignmentClause( col.db_field_name, From 7a4e2be45ef59d98407d22b9686c3e871f5ddca3 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 16:23:39 -0800 Subject: [PATCH 0529/4200] fixing map context updates --- cqlengine/statements.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index c1de6414e2..cfa3eb5fbf 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -313,8 +313,9 @@ def update_context(self, ctx): if not self._analyzed: self._analyze() ctx_id = self.context_id for key in self._updates or []: - ctx[str(ctx_id)] = key - ctx[str(ctx_id + 1)] = self._to_database(self.value.get(key)) + val = self.value.get(key) + ctx[str(ctx_id)] = self._column.key_col.to_database(key) if self._column else key + ctx[str(ctx_id + 1)] = self._column.value_col.to_database(val) if self._column else val ctx_id += 2 def __unicode__(self): From b41a1d6d63cc63bfbb537959cfa8476f98d91097 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 16:31:26 -0800 Subject: [PATCH 0530/4200] getting noop counter column updates working properly --- cqlengine/statements.py | 5 ++--- cqlengine/tests/statements/test_assignment_clauses.py | 7 ++++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index cfa3eb5fbf..650d237b7c 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -337,11 +337,10 @@ def __init__(self, field, value, previous=None, column=None): self.previous = self.previous or 0 def get_context_size(self): - return 1 if self.value != self.previous else 0 + return 1 def update_context(self, ctx): - if self.value != self.previous: - ctx[str(self.context_id)] = self._to_database(abs(self.value - self.previous)) + ctx[str(self.context_id)] = self._to_database(abs(self.value - self.previous)) def __unicode__(self): delta = self.value - self.previous diff --git a/cqlengine/tests/statements/test_assignment_clauses.py b/cqlengine/tests/statements/test_assignment_clauses.py index 1b2e430e9a..f7fbda38fb 100644 --- a/cqlengine/tests/statements/test_assignment_clauses.py +++ b/cqlengine/tests/statements/test_assignment_clauses.py @@ -296,7 +296,12 @@ def noop_update(self): c = CounterUpdateClause('a', 5, 5) c.set_context_id(5) - self.assertEqual(c.get_context_size(), 0) + self.assertEqual(c.get_context_size(), 1) + self.assertEqual(str(c), '"a" = "a" + :5') + + ctx = {} + c.update_context(ctx) + self.assertEqual(ctx, {'5': 0}) class MapDeleteTests(TestCase): From e7107c3c0c656139e0cc01b3d3fd7ed38e9ec07b Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 16:47:56 -0800 Subject: [PATCH 0531/4200] adding support for changing the root context id of a statement --- cqlengine/statements.py | 22 ++++++++++++++++++- .../tests/statements/test_delete_statement.py | 11 +++++++++- .../tests/statements/test_insert_statement.py | 13 +++++++++++ .../tests/statements/test_select_statement.py | 11 ++++++++++ .../tests/statements/test_update_statement.py | 9 ++++++++ 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 650d237b7c..dd367a0f88 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -403,7 +403,8 @@ def __init__(self, table, consistency=None, where=None): super(BaseCQLStatement, self).__init__() self.table = table self.consistency = consistency - self.context_counter = 0 + self.context_id = 0 + self.context_counter = self.context_id self.where_clauses = [] for clause in where or []: @@ -434,6 +435,13 @@ def get_context(self): def get_context_size(self): return len(self.get_context()) + def update_context_id(self, i): + self.context_id = i + self.context_counter = self.context_id + for clause in self.where_clauses: + clause.set_context_id(self.context_counter) + self.context_counter += clause.get_context_size() + def __unicode__(self): raise NotImplementedError @@ -517,6 +525,12 @@ def __init__(self, for assignment in assignments or []: self.add_assignment_clause(assignment) + def update_context_id(self, i): + super(AssignmentStatement, self).update_context_id(i) + for assignment in self.assignments: + assignment.set_context_id(self.context_counter) + self.context_counter += assignment.get_context_size() + def add_assignment_clause(self, clause): """ adds an assignment clause to this statement @@ -595,6 +609,12 @@ def __init__(self, table, fields=None, consistency=None, where=None): for field in fields or []: self.add_field(field) + def update_context_id(self, i): + super(DeleteStatement, self).update_context_id(i) + for field in self.fields: + field.set_context_id(self.context_counter) + self.context_counter += field.get_context_size() + def get_context(self): ctx = super(DeleteStatement, self).get_context() for field in self.fields: diff --git a/cqlengine/tests/statements/test_delete_statement.py b/cqlengine/tests/statements/test_delete_statement.py index b1055ea4fb..9ac9554383 100644 --- a/cqlengine/tests/statements/test_delete_statement.py +++ b/cqlengine/tests/statements/test_delete_statement.py @@ -1,5 +1,5 @@ from unittest import TestCase -from cqlengine.statements import DeleteStatement, WhereClause +from cqlengine.statements import DeleteStatement, WhereClause, MapDeleteClause from cqlengine.operators import * @@ -33,6 +33,15 @@ def test_where_clause_rendering(self): ds.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) self.assertEqual(unicode(ds), 'DELETE FROM table WHERE "a" = :0', unicode(ds)) + def test_context_update(self): + ds = DeleteStatement('table', None) + ds.add_field(MapDeleteClause('d', {1: 2}, {1:2, 3: 4})) + ds.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) + + ds.update_context_id(7) + self.assertEqual(unicode(ds), 'DELETE "d"[:8] FROM table WHERE "a" = :7') + self.assertEqual(ds.get_context(), {'7': 'b', '8': 3}) + def test_context(self): ds = DeleteStatement('table', None) ds.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) diff --git a/cqlengine/tests/statements/test_insert_statement.py b/cqlengine/tests/statements/test_insert_statement.py index 51280a314f..5e22d7d99e 100644 --- a/cqlengine/tests/statements/test_insert_statement.py +++ b/cqlengine/tests/statements/test_insert_statement.py @@ -20,6 +20,19 @@ def test_statement(self): 'INSERT INTO table ("a", "c") VALUES (:0, :1)' ) + def test_context_update(self): + ist = InsertStatement('table', None) + ist.add_assignment_clause(AssignmentClause('a', 'b')) + ist.add_assignment_clause(AssignmentClause('c', 'd')) + + ist.update_context_id(4) + self.assertEqual( + unicode(ist), + 'INSERT INTO table ("a", "c") VALUES (:4, :5)' + ) + ctx = ist.get_context() + self.assertEqual(ctx, {'4': 'b', '5': 'd'}) + def test_additional_rendering(self): ist = InsertStatement('table', ttl=60) ist.add_assignment_clause(AssignmentClause('a', 'b')) diff --git a/cqlengine/tests/statements/test_select_statement.py b/cqlengine/tests/statements/test_select_statement.py index d59c8eb77c..f358c6ae5a 100644 --- a/cqlengine/tests/statements/test_select_statement.py +++ b/cqlengine/tests/statements/test_select_statement.py @@ -44,6 +44,17 @@ def test_context(self): ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) self.assertEqual(ss.get_context(), {'0': 'b'}) + def test_context_id_update(self): + """ tests that the right things happen the the context id """ + ss = SelectStatement('table') + ss.add_where_clause(WhereClause('a', EqualsOperator(), 'b')) + self.assertEqual(ss.get_context(), {'0': 'b'}) + self.assertEqual(str(ss), 'SELECT * FROM table WHERE "a" = :0') + + ss.update_context_id(5) + self.assertEqual(ss.get_context(), {'5': 'b'}) + self.assertEqual(str(ss), 'SELECT * FROM table WHERE "a" = :5') + def test_additional_rendering(self): ss = SelectStatement( 'table', diff --git a/cqlengine/tests/statements/test_update_statement.py b/cqlengine/tests/statements/test_update_statement.py index 0945d2341e..86dbc8be44 100644 --- a/cqlengine/tests/statements/test_update_statement.py +++ b/cqlengine/tests/statements/test_update_statement.py @@ -25,6 +25,15 @@ def test_context(self): us.add_where_clause(WhereClause('a', EqualsOperator(), 'x')) self.assertEqual(us.get_context(), {'0': 'b', '1': 'd', '2': 'x'}) + def test_context_update(self): + us = UpdateStatement('table') + us.add_assignment_clause(AssignmentClause('a', 'b')) + us.add_assignment_clause(AssignmentClause('c', 'd')) + us.add_where_clause(WhereClause('a', EqualsOperator(), 'x')) + us.update_context_id(3) + self.assertEqual(unicode(us), 'UPDATE table SET "a" = :4, "c" = :5 WHERE "a" = :3') + self.assertEqual(us.get_context(), {'4': 'b', '5': 'd', '3': 'x'}) + def test_additional_rendering(self): us = UpdateStatement('table', ttl=60) us.add_assignment_clause(AssignmentClause('a', 'b')) From fb502a023fd0f348e67ecadbaff29fd675fa3b5e Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 16:51:26 -0800 Subject: [PATCH 0532/4200] removing parameters from batch and execute methods --- cqlengine/query.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index b924cae6c2..04379bb0b9 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -228,7 +228,7 @@ def __init__(self, batch_type=None, timestamp=None, consistency=None): self.timestamp = timestamp self._consistency = consistency - def add_query(self, query, params): + def add_query(self, query): if not isinstance(query, BaseCQLStatement): raise CQLEngineException('only BaseCQLStatements can be added to a batch query') # TODO: modify query's context id starting point @@ -308,7 +308,7 @@ def __init__(self, model): def column_family_name(self): return self.model.column_family_name() - def _execute(self, q, params=None): + def _execute(self, q): if self._batch: return self._batch.add_query(q) else: @@ -819,11 +819,11 @@ def __init__(self, model, instance=None, batch=None, ttl=None, consistency=None) self._ttl = ttl self._consistency = consistency - def _execute(self, q, params=None): + def _execute(self, q): if self._batch: - return self._batch.add_query(q, params=params) + return self._batch.add_query(q) else: - return execute(q, params=params, consistency_level=self._consistency) + return execute(q, consistency_level=self._consistency) def batch(self, batch_obj): if batch_obj is not None and not isinstance(batch_obj, BatchQuery): From 5a976717b5446ea797cd77920f396ab039f3447d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 16:54:39 -0800 Subject: [PATCH 0533/4200] reworking batch to work with the new statement objects --- cqlengine/query.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 04379bb0b9..273f338877 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -231,8 +231,7 @@ def __init__(self, batch_type=None, timestamp=None, consistency=None): def add_query(self, query): if not isinstance(query, BaseCQLStatement): raise CQLEngineException('only BaseCQLStatements can be added to a batch query') - # TODO: modify query's context id starting point - self.queries.append((str(query), query.get_context())) + self.queries.append(query) def consistency(self, consistency): self._consistency = consistency @@ -250,9 +249,13 @@ def execute(self): query_list = [opener] parameters = {} - for query, params in self.queries: - query_list.append(' ' + query) - parameters.update(params) + ctx_counter = 0 + for query in self.queries: + query.update_context_id(ctx_counter) + ctx = query.get_context() + ctx_counter += len(ctx) + query_list.append(' ' + str(query)) + parameters.update(ctx) query_list.append('APPLY BATCH;') From fe6359e4d0bf95d7b75f11bf832c1f061083b5df Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Sun, 3 Nov 2013 16:57:03 -0800 Subject: [PATCH 0534/4200] removing old code --- cqlengine/query.py | 158 ++------------------------------------------- 1 file changed, 5 insertions(+), 153 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 273f338877..04afff145e 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -1,16 +1,12 @@ import copy from datetime import datetime -from uuid import uuid4 -from hashlib import md5 -from time import time -from uuid import uuid1 -from cqlengine import BaseContainerColumn, BaseValueManager, Map, columns +from cqlengine import BaseContainerColumn, Map, columns from cqlengine.columns import Counter, List, Set -from cqlengine.connection import connection_manager, execute, RowResult +from cqlengine.connection import execute, RowResult from cqlengine.exceptions import CQLEngineException, ValidationError -from cqlengine.functions import QueryValue, Token, BaseQueryFunction +from cqlengine.functions import Token, BaseQueryFunction #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index @@ -24,150 +20,6 @@ class DoesNotExist(QueryException): pass class MultipleObjectsReturned(QueryException): pass -# class QueryOperatorException(QueryException): pass -# -# -# class QueryOperator(object): -# # The symbol that identifies this operator in filter kwargs -# # ie: colname__ -# symbol = None -# -# # The comparator symbol this operator uses in cql -# cql_symbol = None -# -# QUERY_VALUE_WRAPPER = QueryValue -# -# def __init__(self, column, value): -# self.column = column -# self.value = value -# -# if isinstance(value, QueryValue): -# self.query_value = value -# else: -# self.query_value = self.QUERY_VALUE_WRAPPER(value) -# -# #perform validation on this operator -# self.validate_operator() -# self.validate_value() -# -# @property -# def cql(self): -# """ -# Returns this operator's portion of the WHERE clause -# """ -# return '{} {} {}'.format(self.column.cql, self.cql_symbol, self.query_value.cql) -# -# def validate_operator(self): -# """ -# Checks that this operator can be used on the column provided -# """ -# if self.symbol is None: -# raise QueryOperatorException( -# "{} is not a valid operator, use one with 'symbol' defined".format( -# self.__class__.__name__ -# ) -# ) -# if self.cql_symbol is None: -# raise QueryOperatorException( -# "{} is not a valid operator, use one with 'cql_symbol' defined".format( -# self.__class__.__name__ -# ) -# ) -# -# def validate_value(self): -# """ -# Checks that the compare value works with this operator -# -# Doesn't do anything by default -# """ -# pass -# -# def get_dict(self): -# """ -# Returns this operators contribution to the cql.query arg dictionanry -# -# ie: if this column's name is colname, and the identifier is colval, -# this should return the dict: {'colval':} -# SELECT * FROM column_family WHERE colname=:colval -# """ -# return self.query_value.get_dict(self.column) -# -# @classmethod -# def get_operator(cls, symbol): -# if not hasattr(cls, 'opmap'): -# QueryOperator.opmap = {} -# def _recurse(klass): -# if klass.symbol: -# QueryOperator.opmap[klass.symbol.upper()] = klass -# for subklass in klass.__subclasses__(): -# _recurse(subklass) -# pass -# _recurse(QueryOperator) -# try: -# return QueryOperator.opmap[symbol.upper()] -# except KeyError: -# raise QueryOperatorException("{} doesn't map to a QueryOperator".format(symbol)) -# -# # equality operator, used by tests -# -# def __eq__(self, op): -# return self.__class__ is op.__class__ and \ -# self.column.db_field_name == op.column.db_field_name and \ -# self.value == op.value -# -# def __ne__(self, op): -# return not (self == op) -# -# def __hash__(self): -# return hash(self.column.db_field_name) ^ hash(self.value) -# -# -# class EqualsOperator(QueryOperator): -# symbol = 'EQ' -# cql_symbol = '=' -# -# -# class IterableQueryValue(QueryValue): -# def __init__(self, value): -# try: -# super(IterableQueryValue, self).__init__(value, [uuid4().hex for i in value]) -# except TypeError: -# raise QueryException("in operator arguments must be iterable, {} found".format(value)) -# -# def get_dict(self, column): -# return dict((i, column.to_database(v)) for (i, v) in zip(self.identifier, self.value)) -# -# def get_cql(self): -# return '({})'.format(', '.join(':{}'.format(i) for i in self.identifier)) -# -# -# class InOperator(EqualsOperator): -# symbol = 'IN' -# cql_symbol = 'IN' -# -# QUERY_VALUE_WRAPPER = IterableQueryValue -# -# -# class GreaterThanOperator(QueryOperator): -# symbol = "GT" -# cql_symbol = '>' -# -# -# class GreaterThanOrEqualOperator(QueryOperator): -# symbol = "GTE" -# cql_symbol = '>=' -# -# -# class LessThanOperator(QueryOperator): -# symbol = "LT" -# cql_symbol = '<' -# -# -# class LessThanOrEqualOperator(QueryOperator): -# symbol = "LTE" -# cql_symbol = '<=' -# - class AbstractQueryableColumn(object): """ exposes cql query operators through pythons @@ -318,7 +170,7 @@ def _execute(self, q): return execute(q, consistency_level=self._consistency) def __unicode__(self): - return self._select_query() + return unicode(self._select_query()) def __str__(self): return str(self.__unicode__()) @@ -328,7 +180,7 @@ def __call__(self, *args, **kwargs): def __deepcopy__(self, memo): clone = self.__class__(self.model) - for k,v in self.__dict__.items(): + for k, v in self.__dict__.items(): if k in ['_con', '_cur', '_result_cache', '_result_idx']: # don't clone these clone.__dict__[k] = None elif k == '_batch': From cfc77117939fd8701ac4b454b2e080c703a214c3 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 4 Nov 2013 09:58:43 -0800 Subject: [PATCH 0535/4200] fixing bug in set update that would prevent the last item in a set from being removed --- cqlengine/statements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index dd367a0f88..b3b95656a2 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -181,7 +181,7 @@ def _analyze(self): """ works out the updates to be performed """ if self.value is None or self.value == self.previous: pass - elif self.previous is None or not any({v in self.previous for v in self.value}): + elif self.previous is None: self._assignments = self.value else: # partial update time From 8210bacac77c33cd6cfc7038e3a8a74f14789fea Mon Sep 17 00:00:00 2001 From: Dvir Volk Date: Tue, 12 Nov 2013 13:03:02 +0200 Subject: [PATCH 0536/4200] Changed List column's default to be list and not set --- cqlengine/columns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 09f6a9fa3a..ef47f284ef 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -647,7 +647,7 @@ def __str__(self): def __nonzero__(self): return bool(self.value) - def __init__(self, value_type, default=set, **kwargs): + def __init__(self, value_type, default=list, **kwargs): return super(List, self).__init__(value_type=value_type, default=default, **kwargs) def validate(self, value): From a080aff3a73351d37126b14eef606061b445aa37 Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Fri, 7 Jun 2013 19:18:07 +0300 Subject: [PATCH 0537/4200] Fixed a bug when NetworkTopologyStrategy is used. Although the Cassandra documentation implies that the `replication_factor` parameter would be ignored in this case its presence will cause an error when creating a keyspace using NetworkTopologyStrategy. --- cqlengine/management.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cqlengine/management.py b/cqlengine/management.py index e05c7fc265..b75412c70c 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -37,6 +37,12 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, } replication_map.update(replication_values) + if strategy_class.lower() != 'simplestrategy': + # Although the Cassandra documentation states for `replication_factor` + # that it is "Required if class is SimpleStrategy; otherwise, + # not used." we get an error if it is present. + replication_map.pop('replication_factor', None) + query = """ CREATE KEYSPACE {} WITH REPLICATION = {} From 4b3edc03d07b3258a38ed070a6fc982b93d288ed Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Tue, 26 Nov 2013 16:48:14 +0200 Subject: [PATCH 0538/4200] Test demonstrating regression in querying with dates --- cqlengine/tests/model/test_model_io.py | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 91d4591246..82abef74ba 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -1,5 +1,6 @@ from uuid import uuid4 import random +from datetime import date from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.management import create_table @@ -238,3 +239,33 @@ def test_reserved_cql_words_can_be_used_as_column_names(self): assert model1.insert == model2[0].insert +class TestQueryModel(Model): + test_id = columns.UUID(primary_key=True, default=uuid4) + date = columns.Date(primary_key=True) + description = columns.Text() + + +class TestQuerying(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(TestQuerying, cls).setUpClass() + delete_table(TestQueryModel) + create_table(TestQueryModel) + + @classmethod + def tearDownClass(cls): + super(TestQuerying, cls).tearDownClass() + delete_table(TestQueryModel) + + def test_query_with_date(self): + uid = uuid4() + day = date(2013, 11, 26) + TestQueryModel.create(test_id=uid, date=day, description=u'foo') + + inst = TestQueryModel.filter( + TestQueryModel.test_id == uid, + TestQueryModel.date == day).limit(1).first() + + assert inst.test_id == uid + assert inst.date == day From 225f09212b88fe07e4ce9564fcff682eb84b051c Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 26 Nov 2013 07:30:34 -0800 Subject: [PATCH 0539/4200] removing double replication strategy fix --- cqlengine/management.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index b10fd4fc59..81771c48b9 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -42,12 +42,6 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, # not used." we get an error if it is present. replication_map.pop('replication_factor', None) - if strategy_class.lower() != 'simplestrategy': - # Although the Cassandra documentation states for `replication_factor` - # that it is "Required if class is SimpleStrategy; otherwise, - # not used." we get an error if it is present. - replication_map.pop('replication_factor', None) - query = """ CREATE KEYSPACE {} WITH REPLICATION = {} From cfc076749fd874b974ef6fac1d0508ab2ef41660 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 26 Nov 2013 08:12:07 -0800 Subject: [PATCH 0540/4200] fixing polymorphic test --- cqlengine/tests/model/test_polymorphism.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/tests/model/test_polymorphism.py b/cqlengine/tests/model/test_polymorphism.py index 00078bcb50..4528833517 100644 --- a/cqlengine/tests/model/test_polymorphism.py +++ b/cqlengine/tests/model/test_polymorphism.py @@ -45,8 +45,8 @@ class M1(Base): assert Base._is_polymorphic_base assert not M1._is_polymorphic_base - assert Base._polymorphic_column == Base.type1 - assert M1._polymorphic_column == M1.type1 + assert Base._polymorphic_column is Base._columns['type1'] + assert M1._polymorphic_column is M1._columns['type1'] assert Base._polymorphic_column_name == 'type1' assert M1._polymorphic_column_name == 'type1' From 556a0d776b5f45767fbab63cd60e99aeebc7582d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 26 Nov 2013 08:12:25 -0800 Subject: [PATCH 0541/4200] fixing to_database behavior for functions --- cqlengine/functions.py | 18 ++++++++++++------ cqlengine/query.py | 20 +++++++++++++------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/cqlengine/functions.py b/cqlengine/functions.py index ceb6a78be9..5796ba4ae4 100644 --- a/cqlengine/functions.py +++ b/cqlengine/functions.py @@ -53,10 +53,13 @@ def __init__(self, value): raise ValidationError('datetime instance is required') super(MinTimeUUID, self).__init__(value) - def update_context(self, ctx): - epoch = datetime(1970, 1, 1, tzinfo=self.value.tzinfo) + def to_database(self, val): + epoch = datetime(1970, 1, 1, tzinfo=val.tzinfo) offset = epoch.tzinfo.utcoffset(epoch).total_seconds() if epoch.tzinfo else 0 - ctx[str(self.context_id)] = long(((self.value - epoch).total_seconds() - offset) * 1000) + return long(((val - epoch).total_seconds() - offset) * 1000) + + def update_context(self, ctx): + ctx[str(self.context_id)] = self.to_database(self.value) class MaxTimeUUID(BaseQueryFunction): @@ -77,10 +80,13 @@ def __init__(self, value): raise ValidationError('datetime instance is required') super(MaxTimeUUID, self).__init__(value) - def update_context(self, ctx): - epoch = datetime(1970, 1, 1, tzinfo=self.value.tzinfo) + def to_database(self, val): + epoch = datetime(1970, 1, 1, tzinfo=val.tzinfo) offset = epoch.tzinfo.utcoffset(epoch).total_seconds() if epoch.tzinfo else 0 - ctx[str(self.context_id)] = long(((self.value - epoch).total_seconds() - offset) * 1000) + return long(((val - epoch).total_seconds() - offset) * 1000) + + def update_context(self, ctx): + ctx[str(self.context_id)] = self.to_database(self.value) class Token(BaseQueryFunction): diff --git a/cqlengine/query.py b/cqlengine/query.py index 04afff145e..297c2b18b4 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -6,7 +6,7 @@ from cqlengine.connection import execute, RowResult from cqlengine.exceptions import CQLEngineException, ValidationError -from cqlengine.functions import Token, BaseQueryFunction +from cqlengine.functions import Token, BaseQueryFunction, QueryValue #CQL 3 reference: #http://www.datastax.com/docs/1.1/references/cql/index @@ -35,28 +35,34 @@ def __unicode__(self): def __str__(self): return str(unicode(self)) + def _to_database(self, val): + if isinstance(val, QueryValue): + return val + else: + return self._get_column().to_database(val) + def in_(self, item): """ Returns an in operator - used in where you'd typically want to use python's `in` operator + used where you'd typically want to use python's `in` operator """ return WhereClause(unicode(self), InOperator(), item) def __eq__(self, other): - return WhereClause(unicode(self), EqualsOperator(), other) + return WhereClause(unicode(self), EqualsOperator(), self._to_database(other)) def __gt__(self, other): - return WhereClause(unicode(self), GreaterThanOperator(), other) + return WhereClause(unicode(self), GreaterThanOperator(), self._to_database(other)) def __ge__(self, other): - return WhereClause(unicode(self), GreaterThanOrEqualOperator(), other) + return WhereClause(unicode(self), GreaterThanOrEqualOperator(), self._to_database(other)) def __lt__(self, other): - return WhereClause(unicode(self), LessThanOperator(), other) + return WhereClause(unicode(self), LessThanOperator(), self._to_database(other)) def __le__(self, other): - return WhereClause(unicode(self), LessThanOrEqualOperator(), other) + return WhereClause(unicode(self), LessThanOrEqualOperator(), self._to_database(other)) class BatchType(object): From 33023837d2d2f17e254c80c447d77f06869334e5 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 26 Nov 2013 08:15:24 -0800 Subject: [PATCH 0542/4200] bumping version and updating changelog --- changelog | 4 ++++ cqlengine/VERSION | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 4aad4980c0..28ce74dff7 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,9 @@ CHANGELOG +0.9.2 +* fixing create keyspace with network topology strategy +* fixing regression with query expressions + 0.9 * adding update method * adding support for ttls diff --git a/cqlengine/VERSION b/cqlengine/VERSION index f374f6662e..2003b639c4 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.9.1 +0.9.2 From d315b0f1f743ed7e7ff4e86a2bdff2a415a4c523 Mon Sep 17 00:00:00 2001 From: Russell Haering Date: Sun, 1 Dec 2013 18:33:49 -0800 Subject: [PATCH 0543/4200] validate Token and pk__token filter parameters --- cqlengine/query.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cqlengine/query.py b/cqlengine/query.py index 297c2b18b4..ebb97c51c5 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -355,11 +355,22 @@ def filter(self, *args, **kwargs): column = self.model._get_column(col_name) except KeyError: if col_name == 'pk__token': + if not isinstance(val, Token): + raise QueryException("Virtual column 'pk__token' may only be compared to Token() values") column = columns._PartitionKeysToken(self.model) quote_field = False else: raise QueryException("Can't resolve column name: '{}'".format(col_name)) + if isinstance(val, Token): + if col_name != 'pk__token': + raise QueryException("Token() values may only be compared to the 'pk__token' virtual column") + partition_columns = column.partition_columns + if len(partition_columns) != len(val.value): + raise QueryException( + 'Token() received {} arguments but model has {} partition keys'.format( + len(partition_columns), len(val.value))) + #get query operator, or use equals if not supplied operator_class = BaseWhereOperator.get_operator(col_op or 'EQ') operator = operator_class() From 2a1cb90c9a5077b9617f92e4b67fdd3ab9fc5373 Mon Sep 17 00:00:00 2001 From: Russell Haering Date: Sun, 1 Dec 2013 19:05:09 -0800 Subject: [PATCH 0544/4200] update validation of Token use at query-execution time --- cqlengine/query.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index ebb97c51c5..7608bbf834 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -562,17 +562,15 @@ def _validate_select_where(self): """ Checks that a filterset will not create invalid select statement """ #check that there's either a = or IN relationship with a primary key or indexed field equal_ops = [self.model._columns.get(w.field) for w in self._where if isinstance(w.operator, EqualsOperator)] - token_ops = [self.model._columns.get(w.field) for w in self._where if isinstance(w.operator, Token)] - if not any([w.primary_key or w.index for w in equal_ops]) and not token_ops: + token_comparison = any([w for w in self._where if isinstance(w.value, Token)]) + if not any([w.primary_key or w.index for w in equal_ops]) and not token_comparison: raise QueryException('Where clauses require either a "=" or "IN" comparison with either a primary key or indexed field') if not self._allow_filtering: #if the query is not on an indexed field if not any([w.index for w in equal_ops]): - if not any([w.partition_key for w in equal_ops]) and not token_ops: + if not any([w.partition_key for w in equal_ops]) and not token_comparison: raise QueryException('Filtering on a clustering key without a partition key is not allowed unless allow_filtering() is called on the querset') - if any(not w.partition_key for w in token_ops): - raise QueryException('The token() function is only supported on the partition key') def _select_fields(self): if self._defer_fields or self._only_fields: From 5a5e7ef5cdc1fb09da29bbe597ccf04deb0fc898 Mon Sep 17 00:00:00 2001 From: Russell Haering Date: Sun, 1 Dec 2013 19:40:19 -0800 Subject: [PATCH 0545/4200] pass partition columns to Token for use serializing values --- cqlengine/query.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cqlengine/query.py b/cqlengine/query.py index 7608bbf834..1b9ea24489 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -370,6 +370,7 @@ def filter(self, *args, **kwargs): raise QueryException( 'Token() received {} arguments but model has {} partition keys'.format( len(partition_columns), len(val.value))) + val.set_columns(partition_columns) #get query operator, or use equals if not supplied operator_class = BaseWhereOperator.get_operator(col_op or 'EQ') From a597fa5b2f0038f1cda54f8018afab1db7ad6b63 Mon Sep 17 00:00:00 2001 From: Russell Haering Date: Mon, 2 Dec 2013 10:23:55 -0800 Subject: [PATCH 0546/4200] verify SELECT statement generation with token() works now --- cqlengine/tests/query/test_queryoperators.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cqlengine/tests/query/test_queryoperators.py b/cqlengine/tests/query/test_queryoperators.py index 11dec4dded..b0f3eb571f 100644 --- a/cqlengine/tests/query/test_queryoperators.py +++ b/cqlengine/tests/query/test_queryoperators.py @@ -49,6 +49,9 @@ class TestModel(Model): where.set_context_id(1) self.assertEquals(str(where), 'token("p1", "p2") > token(:{}, :{})'.format(1, 2)) + # Verify that a SELECT query can be successfully generated + str(q._select_query()) + # Token(tuple()) is also possible for convenience # it (allows for Token(obj.pk) syntax) func = functions.Token(('a', 'b')) @@ -57,4 +60,5 @@ class TestModel(Model): where = q._where[0] where.set_context_id(1) self.assertEquals(str(where), 'token("p1", "p2") > token(:{}, :{})'.format(1, 2)) + str(q._select_query()) From f54c3f3613333d20fa03b9b61b96399e9485a992 Mon Sep 17 00:00:00 2001 From: Russell Haering Date: Mon, 2 Dec 2013 10:25:34 -0800 Subject: [PATCH 0547/4200] add tests to verify Token and pk__token validation --- cqlengine/tests/query/test_queryoperators.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cqlengine/tests/query/test_queryoperators.py b/cqlengine/tests/query/test_queryoperators.py index b0f3eb571f..5988396a62 100644 --- a/cqlengine/tests/query/test_queryoperators.py +++ b/cqlengine/tests/query/test_queryoperators.py @@ -62,3 +62,13 @@ class TestModel(Model): self.assertEquals(str(where), 'token("p1", "p2") > token(:{}, :{})'.format(1, 2)) str(q._select_query()) + # The 'pk__token' virtual column may only be compared to a Token + self.assertRaises(query.QueryException, TestModel.objects.filter, pk__token__gt=10) + + # A Token may only be compared to the `pk__token' virtual column + func = functions.Token('a', 'b') + self.assertRaises(query.QueryException, TestModel.objects.filter, p1__gt=func) + + # The # of arguments to Token must match the # of partition keys + func = functions.Token('a') + self.assertRaises(query.QueryException, TestModel.objects.filter, pk__token__gt=func) From 5fa4ba4ead7dc2e2b7440a23fad1e1bc67c6557d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 5 Dec 2013 07:41:41 -0800 Subject: [PATCH 0548/4200] adding test around token values --- cqlengine/tests/query/test_queryoperators.py | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/cqlengine/tests/query/test_queryoperators.py b/cqlengine/tests/query/test_queryoperators.py index 11dec4dded..69fb0a6d4b 100644 --- a/cqlengine/tests/query/test_queryoperators.py +++ b/cqlengine/tests/query/test_queryoperators.py @@ -7,6 +7,7 @@ from cqlengine import query from cqlengine.statements import WhereClause from cqlengine.operators import EqualsOperator +from cqlengine.management import sync_table, drop_table class TestQuerySetOperation(BaseCassEngTestCase): @@ -36,7 +37,41 @@ def test_mintimeuuid_function(self): where.update_context(ctx) self.assertEqual(ctx, {'5': DateTime().to_database(now)}) + +class TokenTestModel(Model): + key = columns.Integer(primary_key=True) + val = columns.Integer() + + +class TestTokenFunction(BaseCassEngTestCase): + + def setUp(self): + super(TestTokenFunction, self).setUp() + sync_table(TokenTestModel) + + def tearDown(self): + super(TestTokenFunction, self).tearDown() + drop_table(TokenTestModel) + def test_token_function(self): + """ Tests that token functions work properly """ + assert TokenTestModel.objects().count() == 0 + for i in range(10): + TokenTestModel.create(key=i, val=i) + assert TokenTestModel.objects().count() == 10 + seen_keys = set() + last_token = None + for instance in TokenTestModel.objects().limit(5): + last_token = instance.key + seen_keys.add(last_token) + assert len(seen_keys) == 5 + for instance in TokenTestModel.objects(pk__token__gt=functions.Token(last_token)): + seen_keys.add(instance.key) + + assert len(seen_keys) == 10 + assert all([i in seen_keys for i in range(10)]) + + def test_compound_pk_token_function(self): class TestModel(Model): p1 = columns.Text(partition_key=True) From 2951ba35de1fc7213121a9b45b7ff679d543c5d3 Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Thu, 28 Nov 2013 14:26:46 +0200 Subject: [PATCH 0549/4200] Use CL.ONE explicitly when interacting with the system keyspace The system keyspace uses "LocalStrategy" for replication so it needs to be accessed with CL.ONE. If the default consistency level is set to something else all management commands which rely on introspecting the system keyspace will fail. --- cqlengine/management.py | 16 ++++++++++------ .../tests/connections/test_connection_pool.py | 3 ++- cqlengine/tests/management/test_management.py | 3 ++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 81771c48b9..b7cdf4d127 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -1,6 +1,7 @@ import json import warnings from cqlengine import SizeTieredCompactionStrategy, LeveledCompactionStrategy +from cqlengine import ONE from cqlengine.named import NamedTable from cqlengine.connection import connection_manager, execute @@ -28,7 +29,7 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, :param **replication_values: 1.2 only, additional values to ad to the replication data map """ with connection_manager() as con: - _, keyspaces = con.execute("""SELECT keyspace_name FROM system.schema_keyspaces""", {}) + _, keyspaces = con.execute("""SELECT keyspace_name FROM system.schema_keyspaces""", {}, ONE) if name not in [r[0] for r in keyspaces]: #try the 1.2 method replication_map = { @@ -55,7 +56,7 @@ def create_keyspace(name, strategy_class='SimpleStrategy', replication_factor=3, def delete_keyspace(name): with connection_manager() as con: - _, keyspaces = con.execute("""SELECT keyspace_name FROM system.schema_keyspaces""", {}) + _, keyspaces = con.execute("""SELECT keyspace_name FROM system.schema_keyspaces""", {}, ONE) if name in [r[0] for r in keyspaces]: execute("DROP KEYSPACE {}".format(name)) @@ -80,7 +81,8 @@ def sync_table(model, create_missing_keyspace=True): with connection_manager() as con: tables = con.execute( "SELECT columnfamily_name from system.schema_columnfamilies WHERE keyspace_name = :ks_name", - {'ks_name': ks_name} + {'ks_name': ks_name}, + ONE ) tables = [x[0] for x in tables.results] @@ -115,7 +117,8 @@ def sync_table(model, create_missing_keyspace=True): with connection_manager() as con: _, idx_names = con.execute( "SELECT index_name from system.\"IndexInfo\" WHERE table_name=:table_name", - {'table_name': raw_cf_name} + {'table_name': raw_cf_name}, + ONE ) idx_names = [i[0] for i in idx_names] @@ -230,7 +233,7 @@ def get_fields(model): logger.debug("get_fields %s %s", ks_name, col_family) - tmp = con.execute(query, {'ks_name':ks_name, 'col_family':col_family}) + tmp = con.execute(query, {'ks_name': ks_name, 'col_family': col_family}, ONE) return [Field(x[0], x[1]) for x in tmp.results] # convert to Field named tuples @@ -282,7 +285,8 @@ def drop_table(model): with connection_manager() as con: _, tables = con.execute( "SELECT columnfamily_name from system.schema_columnfamilies WHERE keyspace_name = :ks_name", - {'ks_name': ks_name} + {'ks_name': ks_name}, + ONE ) raw_cf_name = model.column_family_name(include_keyspace=False) if raw_cf_name not in [t[0] for t in tables]: diff --git a/cqlengine/tests/connections/test_connection_pool.py b/cqlengine/tests/connections/test_connection_pool.py index 29b3ea50a9..b34ea47182 100644 --- a/cqlengine/tests/connections/test_connection_pool.py +++ b/cqlengine/tests/connections/test_connection_pool.py @@ -2,6 +2,7 @@ from cql import OperationalError from mock import MagicMock, patch, Mock +from cqlengine import ONE from cqlengine.connection import ConnectionPool, Host @@ -18,4 +19,4 @@ def cursor(self): with patch.object(p, 'get', return_value=MockConnection()): with self.assertRaises(OperationalError): - p.execute("select * from system.peers", {}) + p.execute("select * from system.peers", {}, ONE) diff --git a/cqlengine/tests/management/test_management.py b/cqlengine/tests/management/test_management.py index 88405b1e40..25f4d268b6 100644 --- a/cqlengine/tests/management/test_management.py +++ b/cqlengine/tests/management/test_management.py @@ -1,5 +1,6 @@ from mock import MagicMock, patch +from cqlengine import ONE from cqlengine.exceptions import CQLEngineException from cqlengine.management import create_table, delete_table, get_fields from cqlengine.tests.base import BaseCassEngTestCase @@ -22,7 +23,7 @@ def test_totally_dead_pool(self): with patch('cqlengine.connection.cql.connect') as mock: mock.side_effect=CQLEngineException with self.assertRaises(CQLEngineException): - self.pool.execute("select * from system.peers", {}) + self.pool.execute("select * from system.peers", {}, ONE) def test_dead_node(self): """ From 380eeeb8cabce3cd817ffa775d58c596b0e53c81 Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Tue, 10 Dec 2013 11:57:24 +0200 Subject: [PATCH 0550/4200] Make get_table_settings use CL.ONE explicitly --- cqlengine/management.py | 5 +++-- cqlengine/query.py | 10 +++++----- setup.py | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index b7cdf4d127..6d41489e6f 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -239,8 +239,9 @@ def get_fields(model): def get_table_settings(model): - return schema_columnfamilies.get(keyspace_name=model._get_keyspace(), - columnfamily_name=model.column_family_name(include_keyspace=False)) + return schema_columnfamilies.objects.consistency(ONE).get( + keyspace_name=model._get_keyspace(), + columnfamily_name=model.column_family_name(include_keyspace=False)) def update_compaction(model): diff --git a/cqlengine/query.py b/cqlengine/query.py index 1b9ea24489..ed59e07700 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -318,6 +318,11 @@ def first(self): def all(self): return copy.deepcopy(self) + def consistency(self, consistency): + clone = copy.deepcopy(self) + clone._consistency = consistency + return clone + def _parse_filter_arg(self, arg): """ Parses a filter arg in the format: @@ -626,11 +631,6 @@ def values_list(self, *fields, **kwargs): clone._flat_values_list = flat return clone - def consistency(self, consistency): - clone = copy.deepcopy(self) - clone._consistency = consistency - return clone - def ttl(self, ttl): clone = copy.deepcopy(self) clone._ttl = ttl diff --git a/setup.py b/setup.py index 2965a6d73a..98700ad684 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ setup( name='cqlengine', - version=version, + version='0.9.2-2951ba35de1fc7213121a9b45b7ff679d543c5d3', description='Cassandra CQL 3 Object Mapper for Python', long_description=long_desc, classifiers = [ From 10c516138e6e3c4560889a1d87a4400954c63c2a Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 11 Dec 2013 10:41:46 -0800 Subject: [PATCH 0551/4200] execute_on_exception in batches --- cqlengine/query.py | 8 ++-- cqlengine/tests/query/test_batch_query.py | 49 +++++++++++++++++++++-- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 1b9ea24489..33d0ae15e7 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -78,13 +78,15 @@ class BatchQuery(object): """ _consistency = None - def __init__(self, batch_type=None, timestamp=None, consistency=None): + + def __init__(self, batch_type=None, timestamp=None, consistency=None, execute_on_exception=False): self.queries = [] self.batch_type = batch_type if timestamp is not None and not isinstance(timestamp, datetime): raise CQLEngineException('timestamp object must be an instance of datetime') self.timestamp = timestamp self._consistency = consistency + self._execute_on_exception = execute_on_exception def add_query(self, query): if not isinstance(query, BaseCQLStatement): @@ -125,8 +127,8 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - #don't execute if there was an exception - if exc_type is not None: return + #don't execute if there was an exception by default + if exc_type is not None and not self._execute_on_exception: return self.execute() diff --git a/cqlengine/tests/query/test_batch_query.py b/cqlengine/tests/query/test_batch_query.py index 37e8d28d56..94080729de 100644 --- a/cqlengine/tests/query/test_batch_query.py +++ b/cqlengine/tests/query/test_batch_query.py @@ -3,7 +3,7 @@ from uuid import uuid4 import random from cqlengine import Model, columns -from cqlengine.management import delete_table, create_table +from cqlengine.management import drop_table, sync_table from cqlengine.query import BatchQuery, DMLQuery from cqlengine.tests.base import BaseCassEngTestCase @@ -13,18 +13,23 @@ class TestMultiKeyModel(Model): count = columns.Integer(required=False) text = columns.Text(required=False) +class BatchQueryLogModel(Model): + # simple k/v table + k = columns.Integer(primary_key=True) + v = columns.Integer() + class BatchQueryTests(BaseCassEngTestCase): @classmethod def setUpClass(cls): super(BatchQueryTests, cls).setUpClass() - delete_table(TestMultiKeyModel) - create_table(TestMultiKeyModel) + drop_table(TestMultiKeyModel) + sync_table(TestMultiKeyModel) @classmethod def tearDownClass(cls): super(BatchQueryTests, cls).tearDownClass() - delete_table(TestMultiKeyModel) + drop_table(TestMultiKeyModel) def setUp(self): super(BatchQueryTests, self).setUp() @@ -123,3 +128,39 @@ def test_dml_none_success_case(self): q.batch(None) assert q._batch is None + + def test_batch_execute_on_exception_succeeds(self): + # makes sure if execute_on_exception == True we still apply the batch + drop_table(BatchQueryLogModel) + sync_table(BatchQueryLogModel) + + obj = BatchQueryLogModel.objects(k=1) + self.assertEqual(0, len(obj)) + + try: + with BatchQuery(execute_on_exception=True) as b: + BatchQueryLogModel.batch(b).create(k=1, v=1) + raise Exception("Blah") + except: + pass + + obj = BatchQueryLogModel.objects(k=1) + self.assertEqual(1, len(obj)) + + def test_batch_execute_on_exception_skips_if_not_specified(self): + # makes sure if execute_on_exception == True we still apply the batch + drop_table(BatchQueryLogModel) + sync_table(BatchQueryLogModel) + + obj = BatchQueryLogModel.objects(k=2) + self.assertEqual(0, len(obj)) + + try: + with BatchQuery(execute_on_exception=True) as b: + BatchQueryLogModel.batch(b).create(k=2, v=2) + raise Exception("Blah") + except: + pass + + obj = BatchQueryLogModel.objects(k=2) + self.assertEqual(1, len(obj)) From 3d351e475d28f927b0c25f6806da38512d7de488 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 11 Dec 2013 14:59:39 -0800 Subject: [PATCH 0552/4200] fixed weird bug in tests which allowed them to pass but for the wrong reasons --- cqlengine/tests/query/test_batch_query.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cqlengine/tests/query/test_batch_query.py b/cqlengine/tests/query/test_batch_query.py index 94080729de..f4a138509e 100644 --- a/cqlengine/tests/query/test_batch_query.py +++ b/cqlengine/tests/query/test_batch_query.py @@ -140,7 +140,7 @@ def test_batch_execute_on_exception_succeeds(self): try: with BatchQuery(execute_on_exception=True) as b: BatchQueryLogModel.batch(b).create(k=1, v=1) - raise Exception("Blah") + raise Exception("Blah") except: pass @@ -156,11 +156,11 @@ def test_batch_execute_on_exception_skips_if_not_specified(self): self.assertEqual(0, len(obj)) try: - with BatchQuery(execute_on_exception=True) as b: + with BatchQuery() as b: BatchQueryLogModel.batch(b).create(k=2, v=2) - raise Exception("Blah") + raise Exception("Blah") except: pass obj = BatchQueryLogModel.objects(k=2) - self.assertEqual(1, len(obj)) + self.assertEqual(0, len(obj)) From 70e55fd0e66016889461edf6ea26a3dbc733648c Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 11 Dec 2013 15:13:46 -0800 Subject: [PATCH 0553/4200] added inline explanation of why the 2 new batch exception tests should pass --- cqlengine/tests/query/test_batch_query.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cqlengine/tests/query/test_batch_query.py b/cqlengine/tests/query/test_batch_query.py index f4a138509e..1d7e1804db 100644 --- a/cqlengine/tests/query/test_batch_query.py +++ b/cqlengine/tests/query/test_batch_query.py @@ -145,6 +145,7 @@ def test_batch_execute_on_exception_succeeds(self): pass obj = BatchQueryLogModel.objects(k=1) + # should be 1 because the batch should execute self.assertEqual(1, len(obj)) def test_batch_execute_on_exception_skips_if_not_specified(self): @@ -163,4 +164,6 @@ def test_batch_execute_on_exception_skips_if_not_specified(self): pass obj = BatchQueryLogModel.objects(k=2) + + # should be 0 because the batch should not execute self.assertEqual(0, len(obj)) From 448d861c7bcb42a1c2e5026c38417c954d266b28 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 11 Dec 2013 15:25:06 -0800 Subject: [PATCH 0554/4200] updated batch docs --- docs/topics/queryset.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index a73ed1d05f..4cf0d20488 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -312,6 +312,20 @@ Batch Queries ExampleModel.objects(id=some_id2).batch(b).delete() b.execute() + + Typically you will not want the block to execute if an exception occurs inside the `with` block. However, in the case that this is desirable, it's achievable by using the following syntax: + + .. code-block:: python + + with BatchQuery(execute_on_exception=True) as b: + LogEntry.batch(b).create(k=1, v=1) + mystery_function() # exception thrown in here + LogEntry.batch(b).create(k=1, v=2) # this code is never reached due to the exception, but anything leading up to here will execute in the batch. + + If an exception is thrown somewhere in the block, any statements that have been added to the batch will still be executed. This is useful for some logging situations. + + + QuerySet method reference ========================= From a45d867b4f7010d190afda555ce45ec283a10a7c Mon Sep 17 00:00:00 2001 From: Konstantin Podshumok Date: Thu, 12 Dec 2013 11:06:29 +0300 Subject: [PATCH 0555/4200] log exception on _create_connection failure --- cqlengine/connection.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 6e08a3580f..cc352a6549 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -197,9 +197,9 @@ def _create_connection(self): ) new_conn.set_cql_version('3.0.0') return new_conn - except Exception as e: - logging.debug("Could not establish connection to {}:{}".format(host.name, host.port)) - pass + except Exception as exc: + logging.debug("Could not establish connection to" + " {}:{} ({!r})".format(host.name, host.port, exc)) raise CQLConnectionError("Could not connect to any server in cluster") From 57741b1f7df2cdb58cb2b181c06736ecf562bf75 Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Thu, 12 Dec 2013 12:11:45 +0200 Subject: [PATCH 0556/4200] fix ttl in update statement --- cqlengine/statements.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index b3b95656a2..8ba5741756 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -582,15 +582,16 @@ class UpdateStatement(AssignmentStatement): def __unicode__(self): qs = ['UPDATE', self.table] + + if self.ttl: + qs += ["USING TTL {}".format(self.ttl)] + qs += ['SET'] qs += [', '.join([unicode(c) for c in self.assignments])] if self.where_clauses: qs += [self._where] - if self.ttl: - qs += ["USING TTL {}".format(self.ttl)] - return ' '.join(qs) From 29912bb944cec7ac48e44cd8597f354b2276d8b2 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 12 Dec 2013 10:53:04 -0800 Subject: [PATCH 0557/4200] test verifying the TTL syntax is valid --- cqlengine/tests/test_ttl.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cqlengine/tests/test_ttl.py b/cqlengine/tests/test_ttl.py index 716caface8..0d465ac09a 100644 --- a/cqlengine/tests/test_ttl.py +++ b/cqlengine/tests/test_ttl.py @@ -63,6 +63,12 @@ def test_update_includes_ttl(self): query = m.call_args[0][0] self.assertIn("USING TTL", query) + def test_update_syntax_valid(self): + # sanity test that ensures the TTL syntax is accepted by cassandra + model = TestTTLModel.create(text="goodbye blake") + model.ttl(60).update(text="goodbye forever") + + From 2b8a811bd77c6a79e8c4cf6529fc67731bd0b26b Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 12 Dec 2013 10:53:04 -0800 Subject: [PATCH 0558/4200] test verifying the TTL syntax is valid --- cqlengine/tests/test_ttl.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cqlengine/tests/test_ttl.py b/cqlengine/tests/test_ttl.py index 716caface8..0d465ac09a 100644 --- a/cqlengine/tests/test_ttl.py +++ b/cqlengine/tests/test_ttl.py @@ -63,6 +63,12 @@ def test_update_includes_ttl(self): query = m.call_args[0][0] self.assertIn("USING TTL", query) + def test_update_syntax_valid(self): + # sanity test that ensures the TTL syntax is accepted by cassandra + model = TestTTLModel.create(text="goodbye blake") + model.ttl(60).update(text="goodbye forever") + + From 25bd8af1d35cb3dc9c6d1c030592eef5714b7cf1 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 12 Dec 2013 13:46:52 -0800 Subject: [PATCH 0559/4200] fixed issue with setting a list to an empty list --- cqlengine/statements.py | 7 ++-- .../tests/columns/test_container_columns.py | 39 ++++++++++++++----- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 8ba5741756..45f3fabead 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -218,7 +218,7 @@ def __unicode__(self): if not self._analyzed: self._analyze() qs = [] ctx_id = self.context_id - if self._assignments: + if self._assignments is not None: qs += ['"{}" = :{}'.format(self.field, ctx_id)] ctx_id += 1 @@ -233,12 +233,12 @@ def __unicode__(self): def get_context_size(self): if not self._analyzed: self._analyze() - return int(bool(self._assignments)) + int(bool(self._append)) + int(bool(self._prepend)) + return int(self._assignments is not None) + int(bool(self._append)) + int(bool(self._prepend)) def update_context(self, ctx): if not self._analyzed: self._analyze() ctx_id = self.context_id - if self._assignments: + if self._assignments is not None: ctx[str(ctx_id)] = self._to_database(self._assignments) ctx_id += 1 if self._prepend: @@ -263,6 +263,7 @@ def _analyze(self): # rewrite the whole list self._assignments = self.value + elif len(self.previous) == 0: # if we're updating from an empty # list, do a complete insert diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 1cb19f81f3..2a3707d03b 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -4,7 +4,7 @@ from cqlengine import Model, ValidationError from cqlengine import columns -from cqlengine.management import create_table, delete_table +from cqlengine.management import sync_table, drop_table from cqlengine.tests.base import BaseCassEngTestCase @@ -34,13 +34,13 @@ class TestSetColumn(BaseCassEngTestCase): @classmethod def setUpClass(cls): super(TestSetColumn, cls).setUpClass() - delete_table(TestSetModel) - create_table(TestSetModel) + drop_table(TestSetModel) + sync_table(TestSetModel) @classmethod def tearDownClass(cls): super(TestSetColumn, cls).tearDownClass() - delete_table(TestSetModel) + drop_table(TestSetModel) def test_empty_set_initial(self): """ @@ -187,13 +187,13 @@ class TestListColumn(BaseCassEngTestCase): @classmethod def setUpClass(cls): super(TestListColumn, cls).setUpClass() - delete_table(TestListModel) - create_table(TestListModel) + drop_table(TestListModel) + sync_table(TestListModel) @classmethod def tearDownClass(cls): super(TestListColumn, cls).tearDownClass() - delete_table(TestListModel) + drop_table(TestListModel) def test_initial(self): tmp = TestListModel.create() @@ -315,6 +315,24 @@ def test_default_empty_container_saving(self): m = TestListModel.get(partition=pkey) self.assertEqual(m.int_list, [1,2,3,4]) + def test_remove_entry_works(self): + pkey = uuid4() + tmp = TestListModel.create(partition=pkey, int_list=[1,2]) + tmp.int_list.pop() + tmp.update() + tmp = TestListModel.get(partition=pkey) + self.assertEqual(tmp.int_list, [1]) + + def test_update_from_non_empty_to_empty(self): + pkey = uuid4() + tmp = TestListModel.create(partition=pkey, int_list=[1,2]) + tmp.int_list = [] + tmp.update() + + tmp = TestListModel.get(partition=pkey) + self.assertEqual(tmp.int_list, []) + + class TestMapModel(Model): partition = columns.UUID(primary_key=True, default=uuid4) @@ -326,13 +344,13 @@ class TestMapColumn(BaseCassEngTestCase): @classmethod def setUpClass(cls): super(TestMapColumn, cls).setUpClass() - delete_table(TestMapModel) - create_table(TestMapModel) + drop_table(TestMapModel) + sync_table(TestMapModel) @classmethod def tearDownClass(cls): super(TestMapColumn, cls).tearDownClass() - delete_table(TestMapModel) + drop_table(TestMapModel) def test_empty_default(self): tmp = TestMapModel.create() @@ -343,6 +361,7 @@ def test_empty_retrieve(self): tmp2 = TestMapModel.get(partition=tmp.partition) tmp2.int_map['blah'] = 1 + def test_remove_last_entry_works(self): tmp = TestMapModel.create() tmp.text_map["blah"] = datetime.now() From 5c425e819333a89cfd1f31620b691d60017fedda Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 12 Dec 2013 15:19:53 -0800 Subject: [PATCH 0560/4200] disallow saving None inside set and list --- cqlengine/columns.py | 5 +++++ cqlengine/tests/columns/test_container_columns.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index ef47f284ef..11a9f60dcf 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -574,6 +574,9 @@ def validate(self, value): else: raise ValidationError('{} cannot be coerced to a set object'.format(val)) + if None in val: + raise ValidationError("None not allowed in a set") + return {self.value_col.validate(v) for v in val} def to_python(self, value): @@ -655,6 +658,8 @@ def validate(self, value): if val is None: return if not isinstance(val, (set, list, tuple)): raise ValidationError('{} is not a list object'.format(val)) + if None in val: + raise ValidationError("None is not allowed in a list") return [self.value_col.validate(v) for v in val] def to_python(self, value): diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index 2a3707d03b..da26582322 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -42,6 +42,10 @@ def tearDownClass(cls): super(TestSetColumn, cls).tearDownClass() drop_table(TestSetModel) + def test_add_none_fails(self): + with self.assertRaises(ValidationError): + m = TestSetModel.create(int_set=set([None])) + def test_empty_set_initial(self): """ tests that sets are set() by default, should never be none @@ -332,6 +336,13 @@ def test_update_from_non_empty_to_empty(self): tmp = TestListModel.get(partition=pkey) self.assertEqual(tmp.int_list, []) + def test_insert_none(self): + pkey = uuid4() + with self.assertRaises(ValidationError): + TestListModel.create(partition=pkey, int_list=[None]) + + + class TestMapModel(Model): From daa4772e45cd844107ef41f151495cd0aa4d8ea4 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 12 Dec 2013 15:23:00 -0800 Subject: [PATCH 0561/4200] Fixed #90, putting None inside containers now throws a validation error --- cqlengine/tests/columns/test_container_columns.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cqlengine/tests/columns/test_container_columns.py b/cqlengine/tests/columns/test_container_columns.py index da26582322..d54a339f06 100644 --- a/cqlengine/tests/columns/test_container_columns.py +++ b/cqlengine/tests/columns/test_container_columns.py @@ -367,6 +367,15 @@ def test_empty_default(self): tmp = TestMapModel.create() tmp.int_map['blah'] = 1 + def test_add_none_as_map_key(self): + with self.assertRaises(ValidationError): + TestMapModel.create(int_map={None:1}) + + def test_add_none_as_map_value(self): + with self.assertRaises(ValidationError): + TestMapModel.create(int_map={None:1}) + + def test_empty_retrieve(self): tmp = TestMapModel.create() tmp2 = TestMapModel.get(partition=tmp.partition) From 1791544946a3465a5f742ae5841d1b1f73954003 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 12 Dec 2013 16:00:39 -0800 Subject: [PATCH 0562/4200] ensure we aren't trying to include polymorphic keys when we blah.objects.delete --- cqlengine/tests/model/test_polymorphism.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cqlengine/tests/model/test_polymorphism.py b/cqlengine/tests/model/test_polymorphism.py index 4528833517..f950bbcdf8 100644 --- a/cqlengine/tests/model/test_polymorphism.py +++ b/cqlengine/tests/model/test_polymorphism.py @@ -1,7 +1,9 @@ import uuid +import mock from cqlengine import columns from cqlengine import models +from cqlengine.connection import ConnectionPool from cqlengine.tests.base import BaseCassEngTestCase from cqlengine import management @@ -118,6 +120,21 @@ def test_query_deserialization(self): assert isinstance(p1r, Poly1) assert isinstance(p2r, Poly2) + def test_delete_on_polymorphic_subclass_does_not_include_polymorphic_key(self): + p1 = Poly1.create() + + with mock.patch.object(ConnectionPool, 'execute') as m: + Poly1.objects(partition=p1.partition).delete() + + # make sure our polymorphic key isn't in the CQL + # not sure how we would even get here if it was in there + # since the CQL would fail. + + self.assertNotIn("row_type", m.call_args[0][0]) + + + + class UnindexedPolyBase(models.Model): partition = columns.UUID(primary_key=True, default=uuid.uuid4) From adcfbb9b13f435841b215ac8e87a59fd11c74217 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 12 Dec 2013 16:56:30 -0800 Subject: [PATCH 0563/4200] version bump --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 2003b639c4..78bc1abd14 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.9.2 +0.10.0 From 4e8244022bcf0d3e3012ba8ea9a514a19a66f457 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 12 Dec 2013 17:02:11 -0800 Subject: [PATCH 0564/4200] fixed version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 98700ad684..2965a6d73a 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ setup( name='cqlengine', - version='0.9.2-2951ba35de1fc7213121a9b45b7ff679d543c5d3', + version=version, description='Cassandra CQL 3 Object Mapper for Python', long_description=long_desc, classifiers = [ From 5165bb078c3fbd51dbe112e25a7c5c7974800397 Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Sat, 14 Dec 2013 20:23:21 +0200 Subject: [PATCH 0565/4200] Fix logic determining if compaction options need syncing The compaction_strategy_options as received from the `system.schema_columnfamilies` table are all string valued and comparison to typed values (ints, floats) as defined in models results in sync_table() issuning ALTER TABLE statements redundantly even if there were no changes to compaction options. This patch casts the values defined in models to strings to ensure they compare equal as needed. Additionally, this patch normalizes handling of the `min_threshold` and `max_threshold` options for SizeTieredCompactionStrategy which exist outside the `compaction_strategy_options` mapping and have different names in the `system.schema_columnfamilies` table compared to the CQL options. A typo in the `BaseModel.__compaction_tombstone_threshold__` option is also fixed. --- cqlengine/management.py | 29 +++++++++++--- cqlengine/models.py | 2 +- .../management/test_compaction_settings.py | 40 ++++++++++++++++++- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 6d41489e6f..04c23992da 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -207,9 +207,12 @@ def setter(key, limited_to_strategy = None): raise CQLEngineException("{} is limited to {}".format(key, limited_to_strategy)) if tmp: - result[key] = tmp + # Explicitly cast the values to strings to be able to compare the + # values against introspected values from Cassandra. + result[key] = str(tmp) setter('tombstone_compaction_interval') + setter('tombstone_threshold') setter('bucket_high', SizeTieredCompactionStrategy) setter('bucket_low', SizeTieredCompactionStrategy) @@ -217,7 +220,7 @@ def setter(key, limited_to_strategy = None): setter('min_threshold', SizeTieredCompactionStrategy) setter('min_sstable_size', SizeTieredCompactionStrategy) - setter("sstable_size_in_mb", LeveledCompactionStrategy) + setter('sstable_size_in_mb', LeveledCompactionStrategy) return result @@ -245,6 +248,14 @@ def get_table_settings(model): def update_compaction(model): + """Updates the compaction options for the given model if necessary. + + :param model: The model to update. + + :return: `True`, if the compaction options were modified in Cassandra, + `False` otherwise. + :rtype: bool + """ logger.debug("Checking %s for compaction differences", model) row = get_table_settings(model) # check compaction_strategy_class @@ -253,13 +264,17 @@ def update_compaction(model): do_update = not row['compaction_strategy_class'].endswith(model.__compaction__) - existing_options = row['compaction_strategy_options'] - existing_options = json.loads(existing_options) + existing_options = json.loads(row['compaction_strategy_options']) + # The min/max thresholds are stored differently in the system data dictionary + existing_options.update({ + 'min_threshold': str(row['min_compaction_threshold']), + 'max_threshold': str(row['max_compaction_threshold']), + }) desired_options = get_compaction_options(model) desired_options.pop('class', None) - for k,v in desired_options.items(): + for k, v in desired_options.items(): val = existing_options.pop(k, None) if val != v: do_update = True @@ -273,12 +288,16 @@ def update_compaction(model): query = "ALTER TABLE {} with compaction = {}".format(cf_name, options) logger.debug(query) execute(query) + return True + + return False def delete_table(model): warnings.warn("delete_table has been deprecated in favor of drop_table()", DeprecationWarning) return drop_table(model) + def drop_table(model): # don't try to delete non existant tables diff --git a/cqlengine/models.py b/cqlengine/models.py index 04854c020a..252984a6bb 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -214,7 +214,7 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass # compaction options __compaction__ = None __compaction_tombstone_compaction_interval__ = None - __compaction_tombstone_threshold = None + __compaction_tombstone_threshold__ = None # compaction - size tiered options __compaction_bucket_high__ = None diff --git a/cqlengine/tests/management/test_compaction_settings.py b/cqlengine/tests/management/test_compaction_settings.py index 014abcb2db..e2147e6ccd 100644 --- a/cqlengine/tests/management/test_compaction_settings.py +++ b/cqlengine/tests/management/test_compaction_settings.py @@ -39,7 +39,7 @@ def test_size_tiered(self): def test_min_threshold(self): self.model.__compaction_min_threshold__ = 2 result = get_compaction_options(self.model) - assert result['min_threshold'] == 2 + assert result['min_threshold'] == '2' class LeveledCompactionTest(BaseCompactionTest): @@ -69,7 +69,7 @@ def test_sstable_size_in_mb(self): with patch.object(self.model, '__compaction_sstable_size_in_mb__', 32): result = get_compaction_options(self.model) - assert result['sstable_size_in_mb'] == 32 + assert result['sstable_size_in_mb'] == '32' class LeveledcompactionTestTable(Model): @@ -90,6 +90,42 @@ def test_alter_is_called_table(self): sync_table(LeveledcompactionTestTable) assert mock.called == 1 + def test_compaction_not_altered_without_changes_leveled(self): + from cqlengine.management import update_compaction + + class LeveledCompactionChangesDetectionTest(Model): + __compaction__ = LeveledCompactionStrategy + __compaction_sstable_size_in_mb__ = 160 + __compaction_tombstone_threshold__ = 0.125 + __compaction_tombstone_compaction_interval__ = 3600 + + pk = columns.Integer(primary_key=True) + + drop_table(LeveledCompactionChangesDetectionTest) + sync_table(LeveledCompactionChangesDetectionTest) + + assert not update_compaction(LeveledCompactionChangesDetectionTest) + + def test_compaction_not_altered_without_changes_sizetiered(self): + from cqlengine.management import update_compaction + + class SizeTieredCompactionChangesDetectionTest(Model): + __compaction__ = SizeTieredCompactionStrategy + __compaction_bucket_high__ = 20 + __compaction_bucket_low__ = 10 + __compaction_max_threshold__ = 200 + __compaction_min_threshold__ = 100 + __compaction_min_sstable_size__ = 1000 + __compaction_tombstone_threshold__ = 0.125 + __compaction_tombstone_compaction_interval__ = 3600 + + pk = columns.Integer(primary_key=True) + + drop_table(SizeTieredCompactionChangesDetectionTest) + sync_table(SizeTieredCompactionChangesDetectionTest) + + assert not update_compaction(SizeTieredCompactionChangesDetectionTest) + def test_alter_actually_alters(self): tmp = copy.deepcopy(LeveledcompactionTestTable) drop_table(tmp) From 2810fead112e32f9082aeafee92c10dec441b296 Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Mon, 16 Dec 2013 15:04:57 +0200 Subject: [PATCH 0566/4200] bugfix: connection.setup - cast port to int --- cqlengine/connection.py | 9 +++++++-- .../connections/test_connection_setup.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 cqlengine/tests/connections/test_connection_setup.py diff --git a/cqlengine/connection.py b/cqlengine/connection.py index cc352a6549..01b94023fa 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -85,12 +85,17 @@ def setup( host = host.strip() host = host.split(':') if len(host) == 1: - _hosts.append(Host(host[0], 9160)) + port = 9160 elif len(host) == 2: - _hosts.append(Host(*host)) + try: + port = int(host[1]) + except ValueError: + raise CQLConnectionError("Can't parse {}".format(''.join(host))) else: raise CQLConnectionError("Can't parse {}".format(''.join(host))) + _hosts.append(Host(host[0], port)) + if not _hosts: raise CQLConnectionError("At least one host required") diff --git a/cqlengine/tests/connections/test_connection_setup.py b/cqlengine/tests/connections/test_connection_setup.py new file mode 100644 index 0000000000..5f1f7b774c --- /dev/null +++ b/cqlengine/tests/connections/test_connection_setup.py @@ -0,0 +1,20 @@ +from unittest import TestCase +from mock import MagicMock, patch, Mock + +from cqlengine.connection import setup, CQLConnectionError, Host + + +class OperationalErrorLoggingTest(TestCase): + @patch('cqlengine.connection.ConnectionPool', return_value=None, autospec=True) + def test_setup_hosts(self, PatchedConnectionPool): + with self.assertRaises(CQLConnectionError): + setup(hosts=['localhost:abcd']) + self.assertEqual(len(PatchedConnectionPool.mock_calls), 0) + + with self.assertRaises(CQLConnectionError): + setup(hosts=['localhost:9160:abcd']) + self.assertEqual(len(PatchedConnectionPool.mock_calls), 0) + + setup(hosts=['localhost:9161', 'remotehost']) + self.assertEqual(len(PatchedConnectionPool.mock_calls), 1) + self.assertEqual(PatchedConnectionPool.call_args[0][0], [Host('localhost', 9161), Host('remotehost', 9160)]) From d2786f8f791c35fc4a0e3cb9e6cad61a7d7fa137 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 12:19:44 -0800 Subject: [PATCH 0567/4200] throwing timestamp options everywhere --- cqlengine/models.py | 30 +++++++++++++++++++++++++++--- cqlengine/query.py | 3 ++- cqlengine/statements.py | 11 +++++++++-- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 04854c020a..28a5a0fad7 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -93,6 +93,28 @@ def ttl_setter(ts): def __call__(self, *args, **kwargs): raise NotImplementedError +class TimestampDescriptor(object): + """ + returns a query set descriptor with a timestamp specified + """ + def __get__(self, instance, model): + if instance: + # instance method + def timestamp_setter(ts): + instance._timestamp = ts + return instance + return timestamp_setter + + qs = model.__queryset__(model) + + def timestamp_setter(ts): + qs._timestamp = ts + return qs + + return timestamp_setter + + def __call__(self, *args, **kwargs): + raise NotImplementedError class ConsistencyDescriptor(object): """ @@ -200,6 +222,7 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass objects = QuerySetDescriptor() ttl = TTLDescriptor() consistency = ConsistencyDescriptor() + timestamp = TimestampDescriptor() #table names will be generated automatically from it's model and package name #however, you can also define them manually here @@ -231,8 +254,9 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass __queryset__ = ModelQuerySet __dmlquery__ = DMLQuery - __ttl__ = None + #__ttl__ = None # this doesn't seem to be used __consistency__ = None # can be set per query + __timestamp__ = None # optional timestamp to include with the operation __read_repair_chance__ = 0.1 @@ -257,11 +281,11 @@ def __repr__(self): """ Pretty printing of models by their primary key """ - return '{} <{}>'.format(self.__class__.__name__, + return '{} <{}>'.format(self.__class__.__name__, ', '.join(('{}={}'.format(k, getattr(self, k)) for k,v in self._primary_keys.iteritems())) ) - + @classmethod def _discover_polymorphic_submodels(cls): diff --git a/cqlengine/query.py b/cqlengine/query.py index 78d273c03e..24a3374455 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -684,13 +684,14 @@ class DMLQuery(object): _ttl = None _consistency = None - def __init__(self, model, instance=None, batch=None, ttl=None, consistency=None): + def __init__(self, model, instance=None, batch=None, ttl=None, consistency=None, timestamp=None): self.model = model self.column_family_name = self.model.column_family_name() self.instance = instance self._batch = batch self._ttl = ttl self._consistency = consistency + self._timestamp = timestamp def _execute(self, q): if self._batch: diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 45f3fabead..2afeae0156 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -400,12 +400,13 @@ def __unicode__(self): class BaseCQLStatement(object): """ The base cql statement class """ - def __init__(self, table, consistency=None, where=None): + def __init__(self, table, consistency=None, timestamp=None, where=None): super(BaseCQLStatement, self).__init__() self.table = table self.consistency = consistency self.context_id = 0 self.context_counter = self.context_id + self.timestamp = timestamp self.where_clauses = [] for clause in where or []: @@ -513,7 +514,8 @@ def __init__(self, assignments=None, consistency=None, where=None, - ttl=None): + ttl=None, + timestamp=None): super(AssignmentStatement, self).__init__( table, consistency=consistency, @@ -575,6 +577,9 @@ def __unicode__(self): if self.ttl: qs += ["USING TTL {}".format(self.ttl)] + if self.timestamp: + qs += ["USING TIMESTAMP {}".format(self.timestamp)] + return ' '.join(qs) @@ -586,6 +591,8 @@ def __unicode__(self): if self.ttl: qs += ["USING TTL {}".format(self.ttl)] + if self.timestamp: + qs += ["USING TIMESTAMP {}".format(self.timestamp)] qs += ['SET'] qs += [', '.join([unicode(c) for c in self.assignments])] From 81131ac2af2ab767cbe818f4f41eb7cda34a6423 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 16:00:44 -0800 Subject: [PATCH 0568/4200] added sure library, basic test setup --- cqlengine/statements.py | 22 +++++++++++++++++++++ cqlengine/tests/test_timestamp.py | 32 +++++++++++++++++++++++++++++++ requirements.txt | 1 + 3 files changed, 55 insertions(+) create mode 100644 cqlengine/tests/test_timestamp.py diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 2afeae0156..815ccfe34f 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta from cqlengine.functions import QueryValue from cqlengine.operators import BaseWhereOperator, InOperator @@ -444,6 +445,20 @@ def update_context_id(self, i): clause.set_context_id(self.context_counter) self.context_counter += clause.get_context_size() + @property + def timestamp_normalized(self): + if isinstance(self.timestamp, int): + return self.timestamp + + if isinstance(self.timestamp, datetime): + # do stuff + return self.timestamp + + if isinstance(self.timestamp, timedelta): + # do more stuff + return self.timestamp + + def __unicode__(self): raise NotImplementedError @@ -645,6 +660,13 @@ def __unicode__(self): qs += [', '.join(['{}'.format(f) for f in self.fields])] qs += ['FROM', self.table] + delete_option = [] + if self.timestamp: + delete_option += ["TIMESTAMP {}".format(self.timestamp)] + + if delete_option: + qs += ["USING {}".format(" AND ".join(delete_option))] + if self.where_clauses: qs += [self._where] diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py new file mode 100644 index 0000000000..f1e7c91ac3 --- /dev/null +++ b/cqlengine/tests/test_timestamp.py @@ -0,0 +1,32 @@ +""" +Tests surrounding the blah.timestamp( timedelta(seconds=30) ) format. +""" +from datetime import timedelta + +import unittest +from uuid import uuid4 +import sure +from cqlengine import Model, columns +from cqlengine.management import sync_table +from cqlengine.tests.base import BaseCassEngTestCase + + +class TestTimestampModel(Model): + id = columns.UUID(primary_key=True, default=lambda:uuid4()) + count = columns.Integer() + + +class CreateWithTimestampTest(BaseCassEngTestCase): + + @classmethod + def setUpClass(cls): + super(CreateWithTimestampTest, cls).setUpClass() + sync_table(TestTimestampModel) + + def test_batch(self): + pass + + def test_non_batch(self): + tmp = TestTimestampModel.timestamp(timedelta(seconds=30)).create(count=1) + tmp.should.be.ok + diff --git a/requirements.txt b/requirements.txt index 04a6bc4465..1fd68cdd9c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ ipython==0.13.1 ipdb==0.7 Sphinx==1.1.3 mock==1.0.1 +sure From 9640b1c02e99e3ee045a9416e3aba8866d1452a6 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 16:15:33 -0800 Subject: [PATCH 0569/4200] better class structure for the billions of tests i'm about to write --- cqlengine/models.py | 10 ++++++---- cqlengine/query.py | 5 ++++- cqlengine/tests/test_timestamp.py | 22 ++++++++++++++++++---- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 28a5a0fad7..7b254c9900 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -222,16 +222,18 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass objects = QuerySetDescriptor() ttl = TTLDescriptor() consistency = ConsistencyDescriptor() + + # custom timestamps, see USING TIMESTAMP X timestamp = TimestampDescriptor() - #table names will be generated automatically from it's model and package name - #however, you can also define them manually here + # table names will be generated automatically from it's model + # however, you can also define them manually here __table_name__ = None - #the keyspace for this model + # the keyspace for this model __keyspace__ = None - #polymorphism options + # polymorphism options __polymorphic_key__ = None # compaction options diff --git a/cqlengine/query.py b/cqlengine/query.py index 24a3374455..fea1ec196c 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -166,6 +166,7 @@ def __init__(self, model): self._batch = None self._ttl = None self._consistency = None + self._timestamp = None @property def column_family_name(self): @@ -510,7 +511,9 @@ def defer(self, fields): return self._only_or_defer('defer', fields) def create(self, **kwargs): - return self.model(**kwargs).batch(self._batch).ttl(self._ttl).consistency(self._consistency).save() + return self.model(**kwargs).batch(self._batch).ttl(self._ttl).\ + consistency(self._consistency).\ + timestamp(self._timestamp).save() def delete(self): """ diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index f1e7c91ac3..845b97b9aa 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -5,8 +5,10 @@ import unittest from uuid import uuid4 +import mock import sure from cqlengine import Model, columns +from cqlengine.connection import ConnectionPool from cqlengine.management import sync_table from cqlengine.tests.base import BaseCassEngTestCase @@ -16,17 +18,29 @@ class TestTimestampModel(Model): count = columns.Integer() -class CreateWithTimestampTest(BaseCassEngTestCase): - +class BaseTimestampTest(BaseCassEngTestCase): @classmethod def setUpClass(cls): - super(CreateWithTimestampTest, cls).setUpClass() + super(BaseTimestampTest, cls).setUpClass() sync_table(TestTimestampModel) + +class CreateWithTimestampTest(BaseTimestampTest): + def test_batch(self): pass - def test_non_batch(self): + def test_non_batch_syntax_integration(self): tmp = TestTimestampModel.timestamp(timedelta(seconds=30)).create(count=1) tmp.should.be.ok + def test_non_batch_syntax_unit(self): + + with mock.patch.object(ConnectionPool, "execute") as m: + TestTimestampModel.timestamp(timedelta(seconds=30)).create(count=1) + + query = m.call_args[0][0] + + "USING TIMESTAMP".should.be.within(query) + + From a4c439af10439bea496fc52cf1dadad9de0e713d Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 16:18:29 -0800 Subject: [PATCH 0570/4200] ensure we're only operating on a copy of the instance --- cqlengine/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 7b254c9900..869a02012b 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -1,6 +1,6 @@ from collections import OrderedDict import re - +import copy from cqlengine import columns from cqlengine.exceptions import ModelException, CQLEngineException, ValidationError from cqlengine.query import ModelQuerySet, DMLQuery, AbstractQueryableColumn @@ -76,6 +76,7 @@ class TTLDescriptor(object): """ def __get__(self, instance, model): if instance: + instance = copy.deepcopy(instance) # instance method def ttl_setter(ts): instance._ttl = ts @@ -100,6 +101,7 @@ class TimestampDescriptor(object): def __get__(self, instance, model): if instance: # instance method + instance = copy.deepcopy(instance) def timestamp_setter(ts): instance._timestamp = ts return instance @@ -122,6 +124,7 @@ class ConsistencyDescriptor(object): """ def __get__(self, instance, model): if instance: + instance = copy.deepcopy(instance) def consistency_setter(consistency): instance.__consistency__ = consistency return instance From 74e55366d5ac1bc7ee27316da35fdffba4edaeed Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 16:29:41 -0800 Subject: [PATCH 0571/4200] tracking down issues with missing timestamp values --- cqlengine/models.py | 4 +++- cqlengine/query.py | 3 ++- cqlengine/tests/test_timestamp.py | 5 +++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 869a02012b..a78678e5e2 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -261,7 +261,7 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass #__ttl__ = None # this doesn't seem to be used __consistency__ = None # can be set per query - __timestamp__ = None # optional timestamp to include with the operation + __timestamp__ = None # optional timestamp to include with the operation (USING TIMESTAMP) __read_repair_chance__ = 0.1 @@ -464,6 +464,7 @@ def save(self): self.__dmlquery__(self.__class__, self, batch=self._batch, ttl=self._ttl, + timestamp=self._timestamp, consistency=self.__consistency__).save() #reset the value managers @@ -498,6 +499,7 @@ def update(self, **values): self.__dmlquery__(self.__class__, self, batch=self._batch, ttl=self._ttl, + timestamp=self.__timestamp__, consistency=self.__consistency__).update() #reset the value managers diff --git a/cqlengine/query.py b/cqlengine/query.py index fea1ec196c..9de5a745f8 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -686,6 +686,7 @@ class DMLQuery(object): """ _ttl = None _consistency = None + _timestamp = None def __init__(self, model, instance=None, batch=None, ttl=None, consistency=None, timestamp=None): self.model = model @@ -804,7 +805,7 @@ def save(self): if self.instance._has_counter or self.instance._can_update(): return self.update() else: - insert = InsertStatement(self.column_family_name, ttl=self._ttl) + insert = InsertStatement(self.column_family_name, ttl=self._ttl, timestamp=self._timestamp) for name, col in self.instance._columns.items(): val = getattr(self.instance, name, None) if col._val_is_null(val): diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 845b97b9aa..c58952c183 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -30,6 +30,11 @@ class CreateWithTimestampTest(BaseTimestampTest): def test_batch(self): pass + def test_timestamp_is_set_on_model_queryset(self): + delta = timedelta(seconds=30) + tmp = TestTimestampModel.timestamp(delta) + tmp._timestamp.should.equal(delta) + def test_non_batch_syntax_integration(self): tmp = TestTimestampModel.timestamp(timedelta(seconds=30)).create(count=1) tmp.should.be.ok From 67ccf4ce69eb53ef1d350d17141c0ee6ba21de8f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 16:30:42 -0800 Subject: [PATCH 0572/4200] ensuring we store the timestamp on the insert statement --- cqlengine/statements.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 815ccfe34f..da1bcb38ae 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -621,11 +621,12 @@ def __unicode__(self): class DeleteStatement(BaseCQLStatement): """ a cql delete statement """ - def __init__(self, table, fields=None, consistency=None, where=None): + def __init__(self, table, fields=None, consistency=None, where=None, timestamp=None): super(DeleteStatement, self).__init__( table, consistency=consistency, where=where, + timestamp=timestamp ) self.fields = [] if isinstance(fields, basestring): From 3ecad5814a8d14b7886f5f29b8d93d6a434f0bbe Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 16:32:50 -0800 Subject: [PATCH 0573/4200] assignment statement saves the timestamp --- cqlengine/statements.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index da1bcb38ae..d8d9121e8e 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -537,6 +537,7 @@ def __init__(self, where=where, ) self.ttl = ttl + self.timestamp = timestamp # add assignments self.assignments = [] From 826b5b78660e472e91cfe848b51e677c1d0e4ccb Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 16:46:13 -0800 Subject: [PATCH 0574/4200] standard insert seems to work with custom timestamp --- cqlengine/statements.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index d8d9121e8e..1ffc479058 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -447,16 +447,23 @@ def update_context_id(self, i): @property def timestamp_normalized(self): - if isinstance(self.timestamp, int): - return self.timestamp - - if isinstance(self.timestamp, datetime): - # do stuff + """ + we're expecting self.timestamp to be either a long, a datetime, or a timedelta + :return: + """ + if isinstance(self.timestamp, (int, long)): return self.timestamp if isinstance(self.timestamp, timedelta): - # do more stuff - return self.timestamp + tmp = datetime.now() + self.timestamp + else: + tmp = self.timestamp + + epoch = datetime(1970, 1, 1, tzinfo=tmp.tzinfo) + + # do more stuff + offset = epoch.tzinfo.utcoffset(epoch).total_seconds() if epoch.tzinfo else 0 + return long(((tmp - epoch).total_seconds() - offset) * 1000) def __unicode__(self): @@ -594,7 +601,7 @@ def __unicode__(self): qs += ["USING TTL {}".format(self.ttl)] if self.timestamp: - qs += ["USING TIMESTAMP {}".format(self.timestamp)] + qs += ["USING TIMESTAMP {}".format(self.timestamp_normalized)] return ' '.join(qs) From 3f52742374930c710362dc5d5ee5226569aa15e9 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 17:18:32 -0800 Subject: [PATCH 0575/4200] standardizing on _timestamp across the board --- cqlengine/models.py | 7 +++++-- cqlengine/statements.py | 3 +++ cqlengine/tests/test_timestamp.py | 18 ++++++++++++++++++ cqlengine/tests/test_ttl.py | 4 ++-- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index a78678e5e2..294f1a57e4 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -261,13 +261,16 @@ class MultipleObjectsReturned(_MultipleObjectsReturned): pass #__ttl__ = None # this doesn't seem to be used __consistency__ = None # can be set per query - __timestamp__ = None # optional timestamp to include with the operation (USING TIMESTAMP) __read_repair_chance__ = 0.1 + + _timestamp = None # optional timestamp to include with the operation (USING TIMESTAMP) + def __init__(self, **values): self._values = {} self._ttl = None + self._timestamp = None for name, column in self._columns.items(): value = values.get(name, None) @@ -499,7 +502,7 @@ def update(self, **values): self.__dmlquery__(self.__class__, self, batch=self._batch, ttl=self._ttl, - timestamp=self.__timestamp__, + timestamp=self._timestamp, consistency=self.__consistency__).update() #reset the value managers diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 1ffc479058..dd5042d33f 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -451,6 +451,9 @@ def timestamp_normalized(self): we're expecting self.timestamp to be either a long, a datetime, or a timedelta :return: """ + if not self.timestamp: + return None + if isinstance(self.timestamp, (int, long)): return self.timestamp diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index c58952c183..ea0c42a94c 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -30,6 +30,12 @@ class CreateWithTimestampTest(BaseTimestampTest): def test_batch(self): pass + def test_timestamp_not_included_on_normal_create(self): + with mock.patch.object(ConnectionPool, "execute") as m: + TestTimestampModel.create(count=2) + + "USING TIMESTAMP".shouldnt.be.within(m.call_args[0][0]) + def test_timestamp_is_set_on_model_queryset(self): delta = timedelta(seconds=30) tmp = TestTimestampModel.timestamp(delta) @@ -49,3 +55,15 @@ def test_non_batch_syntax_unit(self): "USING TIMESTAMP".should.be.within(query) +class UpdateWithTimestampTest(BaseTimestampTest): + def setUp(self): + self.instance = TestTimestampModel.create(count=1) + + def test_instance_update_includes_timestamp_in_query(self): + + with mock.patch.object(ConnectionPool, "execute") as m: + self.instance.timestamp(timedelta(seconds=30)).update(count=2) + + query = m.call_args[0][0] + + "USING TIMESTAMP".should.be.within(query) diff --git a/cqlengine/tests/test_ttl.py b/cqlengine/tests/test_ttl.py index 0d465ac09a..c08a1a19e0 100644 --- a/cqlengine/tests/test_ttl.py +++ b/cqlengine/tests/test_ttl.py @@ -80,13 +80,13 @@ def test_instance_is_returned(self): """ o = TestTTLModel.create(text="whatever") o.text = "new stuff" - o.ttl(60) + o = o.ttl(60) self.assertEqual(60, o._ttl) def test_ttl_is_include_with_query_on_update(self): o = TestTTLModel.create(text="whatever") o.text = "new stuff" - o.ttl(60) + o = o.ttl(60) with mock.patch.object(ConnectionPool, 'execute') as m: o.save() From 44baa39e54c761b60daa3c3b7312d4b1dd86e4fd Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 17:38:46 -0800 Subject: [PATCH 0576/4200] fixed deprecated calls, it seems that timestamp is working but broke batch --- cqlengine/query.py | 4 ++-- cqlengine/statements.py | 10 ++++++++-- cqlengine/tests/test_batch_query.py | 10 ++++++---- cqlengine/tests/test_timestamp.py | 4 +--- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 9de5a745f8..d237e4a721 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -647,7 +647,7 @@ def update(self, **values): return nulled_columns = set() - us = UpdateStatement(self.column_family_name, where=self._where, ttl=self._ttl) + us = UpdateStatement(self.column_family_name, where=self._where, ttl=self._ttl, timestamp=self._timestamp) for name, val in values.items(): col = self.model._columns.get(name) # check for nonexistant columns @@ -746,7 +746,7 @@ def update(self): raise CQLEngineException("DML Query intance attribute is None") assert type(self.instance) == self.model - statement = UpdateStatement(self.column_family_name, ttl=self._ttl) + statement = UpdateStatement(self.column_family_name, ttl=self._ttl, timestamp=self._timestamp) #get defined fields and their column names for name, col in self.model._columns.items(): if not col.is_primary_key: diff --git a/cqlengine/statements.py b/cqlengine/statements.py index dd5042d33f..e004b33bd1 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -615,10 +615,16 @@ class UpdateStatement(AssignmentStatement): def __unicode__(self): qs = ['UPDATE', self.table] + using_options = [] + if self.ttl: - qs += ["USING TTL {}".format(self.ttl)] + using_options += ["TTL {}".format(self.ttl)] + if self.timestamp: - qs += ["USING TIMESTAMP {}".format(self.timestamp)] + using_options += ["TIMESTAMP {}".format(self.timestamp_normalized)] + + if using_options: + qs += ["USING {}".format(" AND ".join(using_options))] qs += ['SET'] qs += [', '.join([unicode(c) for c in self.assignments])] diff --git a/cqlengine/tests/test_batch_query.py b/cqlengine/tests/test_batch_query.py index 7ee98b7084..e55c37a20f 100644 --- a/cqlengine/tests/test_batch_query.py +++ b/cqlengine/tests/test_batch_query.py @@ -1,8 +1,10 @@ from unittest import skip from uuid import uuid4 import random +import sure + from cqlengine import Model, columns -from cqlengine.management import delete_table, create_table +from cqlengine.management import drop_table, sync_table from cqlengine.query import BatchQuery from cqlengine.tests.base import BaseCassEngTestCase @@ -17,13 +19,13 @@ class BatchQueryTests(BaseCassEngTestCase): @classmethod def setUpClass(cls): super(BatchQueryTests, cls).setUpClass() - delete_table(TestMultiKeyModel) - create_table(TestMultiKeyModel) + drop_table(TestMultiKeyModel) + sync_table(TestMultiKeyModel) @classmethod def tearDownClass(cls): super(BatchQueryTests, cls).tearDownClass() - delete_table(TestMultiKeyModel) + drop_table(TestMultiKeyModel) def setUp(self): super(BatchQueryTests, self).setUp() diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index ea0c42a94c..58409b1253 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -64,6 +64,4 @@ def test_instance_update_includes_timestamp_in_query(self): with mock.patch.object(ConnectionPool, "execute") as m: self.instance.timestamp(timedelta(seconds=30)).update(count=2) - query = m.call_args[0][0] - - "USING TIMESTAMP".should.be.within(query) + "USING TIMESTAMP".should.be.within(m.call_args[0][0]) From 2d62e523f1ea638730d4153f0406e53d0f97f55a Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 17:51:51 -0800 Subject: [PATCH 0577/4200] working to fix batches --- cqlengine/tests/test_batch_query.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/test_batch_query.py b/cqlengine/tests/test_batch_query.py index e55c37a20f..7698ee68bd 100644 --- a/cqlengine/tests/test_batch_query.py +++ b/cqlengine/tests/test_batch_query.py @@ -1,6 +1,9 @@ from unittest import skip from uuid import uuid4 import random +from cqlengine.connection import ConnectionPool + +import mock import sure from cqlengine import Model, columns @@ -38,13 +41,26 @@ def test_insert_success_case(self): b = BatchQuery() inst = TestMultiKeyModel.batch(b).create(partition=self.pkey, cluster=2, count=3, text='4') + with self.assertRaises(TestMultiKeyModel.DoesNotExist): TestMultiKeyModel.get(partition=self.pkey, cluster=2) - b.execute() + with mock.patch.object(ConnectionPool, 'execute') as m: + b.execute() + + m.call_count.should.be(1) TestMultiKeyModel.get(partition=self.pkey, cluster=2) + def test_batch_is_executed(self): + b = BatchQuery() + inst = TestMultiKeyModel.batch(b).create(partition=self.pkey, cluster=2, count=3, text='4') + + with self.assertRaises(TestMultiKeyModel.DoesNotExist): + TestMultiKeyModel.get(partition=self.pkey, cluster=2) + + b.execute() + def test_update_success_case(self): inst = TestMultiKeyModel.create(partition=self.pkey, cluster=2, count=3, text='4') From 5407f41e951a4a34c483170bf6935fc9b38bdc1a Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 17:58:28 -0800 Subject: [PATCH 0578/4200] fixed brokenness... --- cqlengine/models.py | 6 +++--- cqlengine/tests/test_batch_query.py | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 294f1a57e4..df528746f9 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -76,7 +76,7 @@ class TTLDescriptor(object): """ def __get__(self, instance, model): if instance: - instance = copy.deepcopy(instance) + #instance = copy.deepcopy(instance) # instance method def ttl_setter(ts): instance._ttl = ts @@ -101,7 +101,7 @@ class TimestampDescriptor(object): def __get__(self, instance, model): if instance: # instance method - instance = copy.deepcopy(instance) + #instance = copy.deepcopy(instance) def timestamp_setter(ts): instance._timestamp = ts return instance @@ -124,7 +124,7 @@ class ConsistencyDescriptor(object): """ def __get__(self, instance, model): if instance: - instance = copy.deepcopy(instance) + #instance = copy.deepcopy(instance) def consistency_setter(consistency): instance.__consistency__ = consistency return instance diff --git a/cqlengine/tests/test_batch_query.py b/cqlengine/tests/test_batch_query.py index 7698ee68bd..4beb33d9da 100644 --- a/cqlengine/tests/test_batch_query.py +++ b/cqlengine/tests/test_batch_query.py @@ -45,10 +45,8 @@ def test_insert_success_case(self): with self.assertRaises(TestMultiKeyModel.DoesNotExist): TestMultiKeyModel.get(partition=self.pkey, cluster=2) - with mock.patch.object(ConnectionPool, 'execute') as m: - b.execute() + b.execute() - m.call_count.should.be(1) TestMultiKeyModel.get(partition=self.pkey, cluster=2) From 55cbca4127c396944093219276dc99226be69d1b Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 19 Dec 2013 12:42:29 -0800 Subject: [PATCH 0579/4200] delete is pushing timestamp in when it's specified --- cqlengine/models.py | 8 +++++++- cqlengine/query.py | 5 +++-- cqlengine/statements.py | 5 +++-- cqlengine/tests/test_timestamp.py | 23 +++++++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index df528746f9..838028f46c 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -514,7 +514,7 @@ def update(self, **values): def delete(self): """ Deletes this instance """ - self.__dmlquery__(self.__class__, self, batch=self._batch).delete() + self.__dmlquery__(self.__class__, self, batch=self._batch, timestamp=self._timestamp).delete() def get_changed_columns(self): """ returns a list of the columns that have been updated since instantiation or save """ @@ -528,9 +528,15 @@ def _inst_batch(self, batch): self._batch = batch return self + # def __deepcopy__(self): + # tmp = type(self)() + # tmp.__dict__.update(self.__dict__) + # return tmp + batch = hybrid_classmethod(_class_batch, _inst_batch) + class ModelMetaClass(type): def __new__(cls, name, bases, attrs): diff --git a/cqlengine/query.py b/cqlengine/query.py index d237e4a721..0cad904477 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -526,7 +526,8 @@ def delete(self): dq = DeleteStatement( self.column_family_name, - where=self._where + where=self._where, + timestamp=self._timestamp ) self._execute(dq) @@ -830,7 +831,7 @@ def delete(self): if self.instance is None: raise CQLEngineException("DML Query instance attribute is None") - ds = DeleteStatement(self.column_family_name) + ds = DeleteStatement(self.column_family_name, timestamp=self._timestamp) for name, col in self.model._primary_keys.items(): ds.add_where_clause(WhereClause( col.db_field_name, diff --git a/cqlengine/statements.py b/cqlengine/statements.py index e004b33bd1..766f590317 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -679,11 +679,12 @@ def __unicode__(self): qs += ['FROM', self.table] delete_option = [] + if self.timestamp: - delete_option += ["TIMESTAMP {}".format(self.timestamp)] + delete_option += ["TIMESTAMP {}".format(self.timestamp_normalized)] if delete_option: - qs += ["USING {}".format(" AND ".join(delete_option))] + qs += [" USING {} ".format(" AND ".join(delete_option))] if self.where_clauses: qs += [self._where] diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 58409b1253..43583f6031 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -65,3 +65,26 @@ def test_instance_update_includes_timestamp_in_query(self): self.instance.timestamp(timedelta(seconds=30)).update(count=2) "USING TIMESTAMP".should.be.within(m.call_args[0][0]) + +class DeleteWithTimestampTest(BaseTimestampTest): + def test_non_batch(self): + """ + we don't expect the model to come back at the end because the deletion timestamp should be in the future + """ + uid = uuid4() + tmp = TestTimestampModel.create(id=uid, count=1) + + TestTimestampModel.get(id=uid).should.be.ok + + tmp.timestamp(timedelta(seconds=30)).delete() + + tmp = TestTimestampModel.create(id=uid, count=1) + + with self.assertRaises(TestTimestampModel.DoesNotExist): + TestTimestampModel.get(id=uid) + + + + + + From c393d433c1c766b0f2bb2905acd40a7272616d9f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 19 Dec 2013 15:50:42 -0800 Subject: [PATCH 0580/4200] fixed timezone issue. python must be on same TZ as cassandra when using timedelta and .timestamp --- cqlengine/statements.py | 8 ++------ cqlengine/tests/test_timestamp.py | 5 ++++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 766f590317..0e85c9292e 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -448,7 +448,7 @@ def update_context_id(self, i): @property def timestamp_normalized(self): """ - we're expecting self.timestamp to be either a long, a datetime, or a timedelta + we're expecting self.timestamp to be either a long, int, a datetime, or a timedelta :return: """ if not self.timestamp: @@ -462,11 +462,7 @@ def timestamp_normalized(self): else: tmp = self.timestamp - epoch = datetime(1970, 1, 1, tzinfo=tmp.tzinfo) - - # do more stuff - offset = epoch.tzinfo.utcoffset(epoch).total_seconds() if epoch.tzinfo else 0 - return long(((tmp - epoch).total_seconds() - offset) * 1000) + return long(((tmp - datetime.fromtimestamp(0)).total_seconds()) * 1000000) def __unicode__(self): diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 43583f6031..4c9901c78d 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -76,7 +76,10 @@ def test_non_batch(self): TestTimestampModel.get(id=uid).should.be.ok - tmp.timestamp(timedelta(seconds=30)).delete() + tmp.timestamp(timedelta(seconds=5)).delete() + + with self.assertRaises(TestTimestampModel.DoesNotExist): + TestTimestampModel.get(id=uid) tmp = TestTimestampModel.create(id=uid, count=1) From a327b462dd9308c01e7ca524780330d90165d6d7 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 19 Dec 2013 16:25:50 -0800 Subject: [PATCH 0581/4200] updated changelog --- changelog | 8 ++++++++ cqlengine/tests/test_timestamp.py | 6 ++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/changelog b/changelog index 28ce74dff7..806b3c442d 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,13 @@ CHANGELOG +0.11.0 + +* support for USING TIMESTAMP + +0.10.0 + +* support for execute_on_exception within batches + 0.9.2 * fixing create keyspace with network topology strategy * fixing regression with query expressions diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 4c9901c78d..647673b830 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -7,7 +7,7 @@ from uuid import uuid4 import mock import sure -from cqlengine import Model, columns +from cqlengine import Model, columns, BatchQuery from cqlengine.connection import ConnectionPool from cqlengine.management import sync_table from cqlengine.tests.base import BaseCassEngTestCase @@ -25,10 +25,12 @@ def setUpClass(cls): sync_table(TestTimestampModel) + + class CreateWithTimestampTest(BaseTimestampTest): def test_batch(self): - pass + assert False def test_timestamp_not_included_on_normal_create(self): with mock.patch.object(ConnectionPool, "execute") as m: From 817e386f35c0ea69b9f4e6d99de17a42d7a3a1c5 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 19 Dec 2013 16:26:06 -0800 Subject: [PATCH 0582/4200] changelog --- changelog | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog b/changelog index 806b3c442d..cca5fbd101 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,6 @@ CHANGELOG -0.11.0 +0.11.0 (in progress) * support for USING TIMESTAMP From 6cb5a6caa3985aa3ff2ae437ad788c5b7213d5f4 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 19 Dec 2013 17:26:08 -0800 Subject: [PATCH 0583/4200] fixed batch queries --- changelog | 2 ++ cqlengine/query.py | 16 ++++++++++++---- cqlengine/tests/test_timestamp.py | 6 ++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/changelog b/changelog index cca5fbd101..0764800fb2 100644 --- a/changelog +++ b/changelog @@ -3,6 +3,8 @@ CHANGELOG 0.11.0 (in progress) * support for USING TIMESTAMP + - allows for long, timedelta, and datetime +* fixed use of USING TIMESTAMP in batches 0.10.0 diff --git a/cqlengine/query.py b/cqlengine/query.py index 0cad904477..a21720fcf9 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -1,5 +1,5 @@ import copy -from datetime import datetime +from datetime import datetime, timedelta from cqlengine import BaseContainerColumn, Map, columns from cqlengine.columns import Counter, List, Set @@ -82,7 +82,7 @@ class BatchQuery(object): def __init__(self, batch_type=None, timestamp=None, consistency=None, execute_on_exception=False): self.queries = [] self.batch_type = batch_type - if timestamp is not None and not isinstance(timestamp, datetime): + if timestamp is not None and not isinstance(timestamp, (datetime, timedelta)): raise CQLEngineException('timestamp object must be an instance of datetime') self.timestamp = timestamp self._consistency = consistency @@ -103,8 +103,16 @@ def execute(self): opener = 'BEGIN ' + (self.batch_type + ' ' if self.batch_type else '') + ' BATCH' if self.timestamp: - epoch = datetime(1970, 1, 1) - ts = long((self.timestamp - epoch).total_seconds() * 1000) + + if isinstance(self.timestamp, (int, long)): + ts = self.timestamp + elif isinstance(self.timestamp, timedelta): + ts = long((datetime.now() + self.timestamp - datetime.fromtimestamp(0)).total_seconds() * 1000000) + elif isinstance(self.timestamp, datetime): + ts = long((self.timestamp - datetime.fromtimestamp(0)).total_seconds() * 1000000) + else: + raise ValueError("Batch expects a long, a timedelta, or a datetime") + opener += ' USING TIMESTAMP {}'.format(ts) query_list = [opener] diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 647673b830..386d6db1ff 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -24,6 +24,12 @@ def setUpClass(cls): super(BaseTimestampTest, cls).setUpClass() sync_table(TestTimestampModel) +class BatchTest(BaseTimestampTest): + def test_batch_is_included(self): + with mock.patch.object(ConnectionPool, "execute") as m, BatchQuery(timestamp=timedelta(seconds=30)) as b: + TestTimestampModel.batch(b).create(count=1) + + "USING TIMESTAMP".should.be.within(m.call_args[0][0]) From 97b439bf9507c3f072b1b5a00f41d24102ae33fb Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 20 Dec 2013 14:37:45 -0800 Subject: [PATCH 0584/4200] timestamp working in batch mode --- cqlengine/tests/test_timestamp.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 386d6db1ff..33c2a9c2a9 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -36,7 +36,11 @@ def test_batch_is_included(self): class CreateWithTimestampTest(BaseTimestampTest): def test_batch(self): - assert False + with mock.patch.object(ConnectionPool, "execute") as m, BatchQuery() as b: + TestTimestampModel.timestamp(timedelta(seconds=10)).batch(b).create(count=1) + + "USING TIMESTAMP".should.be.within(m.call_args[0][0]) + def test_timestamp_not_included_on_normal_create(self): with mock.patch.object(ConnectionPool, "execute") as m: From ee4f9af63400faa0753d67b23ec77f9f96316d5e Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 20 Dec 2013 15:38:46 -0800 Subject: [PATCH 0585/4200] use a regex instead of simple text match for better error catching --- cqlengine/tests/test_timestamp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 33c2a9c2a9..0bb6a8ca90 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -39,7 +39,7 @@ def test_batch(self): with mock.patch.object(ConnectionPool, "execute") as m, BatchQuery() as b: TestTimestampModel.timestamp(timedelta(seconds=10)).batch(b).create(count=1) - "USING TIMESTAMP".should.be.within(m.call_args[0][0]) + m.call_args[0][0].should.match(r"INSERT.*USING TIMESTAMP") def test_timestamp_not_included_on_normal_create(self): From ea203929a4cdcdf6ec47f7d8204ab29da4e3585f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 20 Dec 2013 15:41:52 -0800 Subject: [PATCH 0586/4200] more testing around our query --- cqlengine/tests/test_timestamp.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 0bb6a8ca90..32bebcd252 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -39,7 +39,11 @@ def test_batch(self): with mock.patch.object(ConnectionPool, "execute") as m, BatchQuery() as b: TestTimestampModel.timestamp(timedelta(seconds=10)).batch(b).create(count=1) - m.call_args[0][0].should.match(r"INSERT.*USING TIMESTAMP") + query = m.call_args[0][0] + + query.should.match(r"INSERT.*USING TIMESTAMP") + query.should_not.match(r"TIMESTAMP.*INSERT") + def test_timestamp_not_included_on_normal_create(self): From be20a9a9f03d7b6e80ead12a0bd423d26162ff12 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 20 Dec 2013 15:57:19 -0800 Subject: [PATCH 0587/4200] checked batch updates for timestamp --- cqlengine/tests/test_timestamp.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 32bebcd252..6aec5702fd 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -40,7 +40,7 @@ def test_batch(self): TestTimestampModel.timestamp(timedelta(seconds=10)).batch(b).create(count=1) query = m.call_args[0][0] - + query.should.match(r"INSERT.*USING TIMESTAMP") query.should_not.match(r"TIMESTAMP.*INSERT") @@ -76,12 +76,20 @@ def setUp(self): self.instance = TestTimestampModel.create(count=1) def test_instance_update_includes_timestamp_in_query(self): + # not a batch with mock.patch.object(ConnectionPool, "execute") as m: self.instance.timestamp(timedelta(seconds=30)).update(count=2) "USING TIMESTAMP".should.be.within(m.call_args[0][0]) + def test_instance_update_in_batch(self): + with mock.patch.object(ConnectionPool, "execute") as m, BatchQuery() as b: + self.instance.batch(b).timestamp(timedelta(seconds=30)).update(count=2) + + query = m.call_args[0][0] + "USING TIMESTAMP".should.be.within(query) + class DeleteWithTimestampTest(BaseTimestampTest): def test_non_batch(self): """ From ba22b4c9b62618f83e952b0bf60615b8f8dba426 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 20 Dec 2013 16:26:25 -0800 Subject: [PATCH 0588/4200] blind deletes function properly --- cqlengine/query.py | 5 +++++ cqlengine/tests/test_timestamp.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/cqlengine/query.py b/cqlengine/query.py index a21720fcf9..f991f5e9cb 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -650,6 +650,11 @@ def ttl(self, ttl): clone._ttl = ttl return clone + def timestamp(self, timestamp): + clone = copy.deepcopy(self) + clone._timestamp = timestamp + return clone + def update(self, **values): """ Updates the rows in this queryset """ if not values: diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 6aec5702fd..48ff93d5be 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -111,6 +111,24 @@ def test_non_batch(self): TestTimestampModel.get(id=uid) + def test_blind_delete(self): + """ + we don't expect the model to come back at the end because the deletion timestamp should be in the future + """ + uid = uuid4() + tmp = TestTimestampModel.create(id=uid, count=1) + + TestTimestampModel.get(id=uid).should.be.ok + + TestTimestampModel.objects(id=uid).timestamp(timedelta(seconds=5)).delete() + + with self.assertRaises(TestTimestampModel.DoesNotExist): + TestTimestampModel.get(id=uid) + + tmp = TestTimestampModel.create(id=uid, count=1) + + with self.assertRaises(TestTimestampModel.DoesNotExist): + TestTimestampModel.get(id=uid) From c0eb900ede9e6859e888fe137534834b63740e04 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Fri, 20 Dec 2013 16:38:27 -0800 Subject: [PATCH 0589/4200] updated docs with timestamp information, test that timestamps in the past work properly --- cqlengine/tests/test_timestamp.py | 35 ++++++++++++++++++++++++++++++- docs/topics/models.rst | 4 ++++ docs/topics/queryset.rst | 3 +++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 48ff93d5be..33ec3624ef 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -1,7 +1,7 @@ """ Tests surrounding the blah.timestamp( timedelta(seconds=30) ) format. """ -from datetime import timedelta +from datetime import timedelta, datetime import unittest from uuid import uuid4 @@ -130,6 +130,39 @@ def test_blind_delete(self): with self.assertRaises(TestTimestampModel.DoesNotExist): TestTimestampModel.get(id=uid) + def test_blind_delete_with_datetime(self): + """ + we don't expect the model to come back at the end because the deletion timestamp should be in the future + """ + uid = uuid4() + tmp = TestTimestampModel.create(id=uid, count=1) + + TestTimestampModel.get(id=uid).should.be.ok + + plus_five_seconds = datetime.now() + timedelta(seconds=5) + + TestTimestampModel.objects(id=uid).timestamp(plus_five_seconds).delete() + + with self.assertRaises(TestTimestampModel.DoesNotExist): + TestTimestampModel.get(id=uid) + + tmp = TestTimestampModel.create(id=uid, count=1) + + with self.assertRaises(TestTimestampModel.DoesNotExist): + TestTimestampModel.get(id=uid) + + def test_delete_in_the_past(self): + uid = uuid4() + tmp = TestTimestampModel.create(id=uid, count=1) + + TestTimestampModel.get(id=uid).should.be.ok + + # delete the in past, should not affect the object created above + TestTimestampModel.objects(id=uid).timestamp(timedelta(seconds=-60)).delete() + + TestTimestampModel.get(id=uid) + + diff --git a/docs/topics/models.rst b/docs/topics/models.rst index 1fae6070d4..b34d7f5332 100644 --- a/docs/topics/models.rst +++ b/docs/topics/models.rst @@ -141,6 +141,10 @@ Model Methods Sets the batch object to run instance updates and inserts queries with. + .. method:: timestamp(timedelta_or_datetime) + + Sets the timestamp for the query + .. method:: ttl(ttl_in_sec) Sets the ttl values to run instance updates and inserts queries with. diff --git a/docs/topics/queryset.rst b/docs/topics/queryset.rst index 4cf0d20488..569fb58103 100644 --- a/docs/topics/queryset.rst +++ b/docs/topics/queryset.rst @@ -377,6 +377,9 @@ QuerySet method reference Enables the (usually) unwise practive of querying on a clustering key without also defining a partition key + .. method:: timestamp(timestamp_or_long_or_datetime) + + Allows for custom timestamps to be saved with the record. .. method:: ttl(ttl_in_seconds) From b541d3a420983c4103f82508808189b99bda92ca Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 30 Dec 2013 14:58:58 -0800 Subject: [PATCH 0590/4200] making connection test imports play nice with nose --- cqlengine/tests/connections/test_connection_setup.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cqlengine/tests/connections/test_connection_setup.py b/cqlengine/tests/connections/test_connection_setup.py index 5f1f7b774c..e07820f93c 100644 --- a/cqlengine/tests/connections/test_connection_setup.py +++ b/cqlengine/tests/connections/test_connection_setup.py @@ -1,20 +1,22 @@ from unittest import TestCase -from mock import MagicMock, patch, Mock +from mock import patch -from cqlengine.connection import setup, CQLConnectionError, Host +from cqlengine.connection import setup as setup_connection +from cqlengine.connection import CQLConnectionError, Host class OperationalErrorLoggingTest(TestCase): + @patch('cqlengine.connection.ConnectionPool', return_value=None, autospec=True) def test_setup_hosts(self, PatchedConnectionPool): with self.assertRaises(CQLConnectionError): - setup(hosts=['localhost:abcd']) + setup_connection(hosts=['localhost:abcd']) self.assertEqual(len(PatchedConnectionPool.mock_calls), 0) with self.assertRaises(CQLConnectionError): - setup(hosts=['localhost:9160:abcd']) + setup_connection(hosts=['localhost:9160:abcd']) self.assertEqual(len(PatchedConnectionPool.mock_calls), 0) - setup(hosts=['localhost:9161', 'remotehost']) + setup_connection(hosts=['localhost:9161', 'remotehost']) self.assertEqual(len(PatchedConnectionPool.mock_calls), 1) self.assertEqual(PatchedConnectionPool.call_args[0][0], [Host('localhost', 9161), Host('remotehost', 9160)]) From c28ec3fab47b41c0580f5a5b7844d2afe5b68d39 Mon Sep 17 00:00:00 2001 From: Mike Hall Date: Thu, 9 Jan 2014 14:44:53 -0800 Subject: [PATCH 0591/4200] accept uuid strings with or without dashes before converting to python UUID type --- cqlengine/columns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 11a9f60dcf..4ba74273b1 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -372,7 +372,7 @@ class UUID(Column): """ db_type = 'uuid' - re_uuid = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') + re_uuid = re.compile(r'[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}') def validate(self, value): val = super(UUID, self).validate(value) From fb73405e58d14716f2d37b51fe3ea1d41f7cda68 Mon Sep 17 00:00:00 2001 From: Mike Hall Date: Fri, 10 Jan 2014 08:53:32 -0800 Subject: [PATCH 0592/4200] added test for uuids without dashes --- cqlengine/tests/columns/test_validation.py | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cqlengine/tests/columns/test_validation.py b/cqlengine/tests/columns/test_validation.py index 61020e27a5..4232b4003c 100644 --- a/cqlengine/tests/columns/test_validation.py +++ b/cqlengine/tests/columns/test_validation.py @@ -162,6 +162,33 @@ def test_decimal_io(self): dt2 = self.DecimalTest.objects(test_id=0).first() assert dt2.dec_val == D('5') +class TestUUID(BaseCassEngTestCase): + class UUIDTest(Model): + test_id = Integer(primary_key=True) + a_uuid = UUID(default=uuid4()) + + @classmethod + def setUpClass(cls): + super(TestUUID, cls).setUpClass() + create_table(cls.UUIDTest) + + @classmethod + def tearDownClass(cls): + super(TestUUID, cls).tearDownClass() + delete_table(cls.UUIDTest) + + def test_uuid_str_with_dashes(self): + a_uuid = uuid4() + t0 = self.UUIDTest.create(test_id=0, a_uuid=str(a_uuid)) + t1 = self.UUIDTest.get(test_id=0) + assert a_uuid == t1.a_uuid + + def test_uuid_str_no_dashes(self): + a_uuid = uuid4() + t0 = self.UUIDTest.create(test_id=1, a_uuid=a_uuid.hex) + t1 = self.UUIDTest.get(test_id=1) + assert a_uuid == t1.a_uuid + class TestTimeUUID(BaseCassEngTestCase): class TimeUUIDTest(Model): test_id = Integer(primary_key=True) From cf369e848e6d7e687a22823b66740633f58a3de4 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 14 Jan 2014 12:18:39 -0800 Subject: [PATCH 0593/4200] reset ttl and timestamp after save / update --- cqlengine/models.py | 10 ++++++---- cqlengine/tests/test_timestamp.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 838028f46c..4e170d2dd0 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -475,6 +475,9 @@ def save(self): v.reset_previous_value() self._is_persisted = True + self._ttl = None + self._timestamp = None + return self def update(self, **values): @@ -510,6 +513,9 @@ def update(self, **values): v.reset_previous_value() self._is_persisted = True + self._ttl = None + self._timestamp = None + return self def delete(self): @@ -528,10 +534,6 @@ def _inst_batch(self, batch): self._batch = batch return self - # def __deepcopy__(self): - # tmp = type(self)() - # tmp.__dict__.update(self.__dict__) - # return tmp batch = hybrid_classmethod(_class_batch, _inst_batch) diff --git a/cqlengine/tests/test_timestamp.py b/cqlengine/tests/test_timestamp.py index 33ec3624ef..a5d9b870cc 100644 --- a/cqlengine/tests/test_timestamp.py +++ b/cqlengine/tests/test_timestamp.py @@ -110,6 +110,21 @@ def test_non_batch(self): with self.assertRaises(TestTimestampModel.DoesNotExist): TestTimestampModel.get(id=uid) + # calling .timestamp sets the TS on the model + tmp.timestamp(timedelta(seconds=5)) + tmp._timestamp.should.be.ok + + # calling save clears the set timestamp + tmp.save() + tmp._timestamp.shouldnt.be.ok + + tmp.timestamp(timedelta(seconds=5)) + tmp.update() + tmp._timestamp.shouldnt.be.ok + + + + def test_blind_delete(self): """ From 1a0325443dda9ae94e9ee681d420375faafd84bc Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 14 Jan 2014 12:22:02 -0800 Subject: [PATCH 0594/4200] changelog update --- changelog | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index 0764800fb2..c9e9b0ff51 100644 --- a/changelog +++ b/changelog @@ -2,9 +2,10 @@ CHANGELOG 0.11.0 (in progress) -* support for USING TIMESTAMP +* support for USING TIMESTAMP via a .timestamp(timedelta(seconds=30)) syntax - allows for long, timedelta, and datetime * fixed use of USING TIMESTAMP in batches +* clear TTL and timestamp off models after persisting to DB 0.10.0 From 516b16ee5610a32f9926e2d99bebb464f0104e6f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 15 Jan 2014 12:37:57 -0800 Subject: [PATCH 0595/4200] simplified return of the timestamp on the class --- cqlengine/models.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 4e170d2dd0..af5da3ffe1 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -1,6 +1,5 @@ from collections import OrderedDict import re -import copy from cqlengine import columns from cqlengine.exceptions import ModelException, CQLEngineException, ValidationError from cqlengine.query import ModelQuerySet, DMLQuery, AbstractQueryableColumn @@ -101,19 +100,13 @@ class TimestampDescriptor(object): def __get__(self, instance, model): if instance: # instance method - #instance = copy.deepcopy(instance) def timestamp_setter(ts): instance._timestamp = ts return instance return timestamp_setter - qs = model.__queryset__(model) - - def timestamp_setter(ts): - qs._timestamp = ts - return qs + return model.objects.timestamp - return timestamp_setter def __call__(self, *args, **kwargs): raise NotImplementedError From 19777eb68e75bab674ccc4e75d6aef2837ef821a Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 15 Jan 2014 12:42:47 -0800 Subject: [PATCH 0596/4200] release process --- RELEASE.txt | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 RELEASE.txt diff --git a/RELEASE.txt b/RELEASE.txt new file mode 100644 index 0000000000..c4def8ddce --- /dev/null +++ b/RELEASE.txt @@ -0,0 +1,7 @@ +Check changelog +Ensure docs are updated +Tests pass +Update VERSION +Push tag to github +Push release to pypi + From 701099e69db492c081309f3c27243efdf1409136 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 15 Jan 2014 13:47:32 -0800 Subject: [PATCH 0597/4200] updated changelog --- changelog | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog b/changelog index c9e9b0ff51..71d3dd469f 100644 --- a/changelog +++ b/changelog @@ -6,6 +6,7 @@ CHANGELOG - allows for long, timedelta, and datetime * fixed use of USING TIMESTAMP in batches * clear TTL and timestamp off models after persisting to DB +* allows UUID without - (Thanks to Michael Haddad, github.com/mahall) 0.10.0 From 5a0f945294a1b7120a745727951ed25f52c78e2f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 15 Jan 2014 15:53:07 -0800 Subject: [PATCH 0598/4200] updated changelog with note for dokai's fix to table settings --- changelog | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog b/changelog index 71d3dd469f..a908f72092 100644 --- a/changelog +++ b/changelog @@ -7,6 +7,7 @@ CHANGELOG * fixed use of USING TIMESTAMP in batches * clear TTL and timestamp off models after persisting to DB * allows UUID without - (Thanks to Michael Haddad, github.com/mahall) +* fixes regarding syncing schema settings (thanks Kai Lautaportti github.com/dokai) 0.10.0 From 83d26c2f8d4381fb81ea4c3541f02fe21bc4b734 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 15 Jan 2014 15:58:26 -0800 Subject: [PATCH 0599/4200] test around delete consistency #148 --- cqlengine/models.py | 2 +- cqlengine/tests/test_consistency.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 57a9f2df24..4f70c3eed3 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -513,7 +513,7 @@ def update(self, **values): def delete(self): """ Deletes this instance """ - self.__dmlquery__(self.__class__, self, batch=self._batch, timestamp=self._timestamp).delete() + self.__dmlquery__(self.__class__, self, batch=self._batch, timestamp=self._timestamp, consistency=self.__consistency__).delete() def get_changed_columns(self): """ returns a list of the columns that have been updated since instantiation or save """ diff --git a/cqlengine/tests/test_consistency.py b/cqlengine/tests/test_consistency.py index 94a236aaf8..2f54ae243c 100644 --- a/cqlengine/tests/test_consistency.py +++ b/cqlengine/tests/test_consistency.py @@ -76,3 +76,19 @@ def test_blind_update(self): args = m.call_args self.assertEqual(ALL, args[0][2]) + + + def test_delete(self): + # ensures we always carry consistency through on delete statements + t = TestConsistencyModel.create(text="bacon and eggs") + t.text = "ham and cheese sandwich" + uid = t.id + + with mock.patch.object(ConnectionPool, 'execute') as m: + t.consistency(ALL).delete() + + with mock.patch.object(ConnectionPool, 'execute') as m: + TestConsistencyModel.objects(id=uid).consistency(ALL).delete() + + args = m.call_args + self.assertEqual(ALL, args[0][2]) From e4e4bb7d322c5ad946a7c5e9b89b1630b14903c0 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 15 Jan 2014 17:30:49 -0800 Subject: [PATCH 0600/4200] version bump --- cqlengine/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/VERSION b/cqlengine/VERSION index 78bc1abd14..d9df1bbc0c 100644 --- a/cqlengine/VERSION +++ b/cqlengine/VERSION @@ -1 +1 @@ -0.10.0 +0.11.0 From ac2dd3aa5bed561e0ca708466d2a991207c83b54 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Wed, 22 Jan 2014 11:53:34 +0100 Subject: [PATCH 0601/4200] Use validation method to normalize Boolean values. --- cqlengine/columns.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 4ba74273b1..f899f92af6 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -442,11 +442,15 @@ class Quoter(ValueQuoter): def __str__(self): return 'true' if self.value else 'false' - def to_python(self, value): + def validate(self, value): + """ Always returns a Python boolean. """ return bool(value) + def to_python(self, value): + return self.validate(value) + def to_database(self, value): - return self.Quoter(bool(value)) + return self.Quoter(self.validate(value)) class Float(Column): From 3d2268c6e8f94d7aa19699efb205643661ddb547 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Wed, 22 Jan 2014 11:54:27 +0100 Subject: [PATCH 0602/4200] Unquote boolean values when normalizing. --- cqlengine/columns.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index f899f92af6..2452a91c9c 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -444,6 +444,8 @@ def __str__(self): def validate(self, value): """ Always returns a Python boolean. """ + if isinstance(value, self.Quoter): + value = value.value return bool(value) def to_python(self, value): From b768b6e6b86f7e790f136c5fa83a9b29352a9b0f Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Wed, 22 Jan 2014 11:56:57 +0100 Subject: [PATCH 0603/4200] Update changelog. --- changelog | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/changelog b/changelog index a908f72092..1284fa6015 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,10 @@ CHANGELOG -0.11.0 (in progress) +0.11.1 (in progress) + +* Normalize and unquote boolean values. + +0.11.0 * support for USING TIMESTAMP via a .timestamp(timedelta(seconds=30)) syntax - allows for long, timedelta, and datetime From af8662d87efd37b06f0da0b30b06eeeb03711f9c Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Wed, 22 Jan 2014 11:57:51 +0100 Subject: [PATCH 0604/4200] Add myself to the list of contributors. --- AUTHORS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index a220cb26d6..70cdaeaf80 100644 --- a/AUTHORS +++ b/AUTHORS @@ -6,5 +6,5 @@ Jon Haddad CONTRIBUTORS Eric Scrivner - test environment, connection pooling - +Kevin Deldycke From 70d866018d3fc56e960d0f7746a5a6d33f8aadd8 Mon Sep 17 00:00:00 2001 From: drewlll2ll Date: Wed, 22 Jan 2014 11:08:16 -0500 Subject: [PATCH 0605/4200] Fixed incompatibilities w/ cassandra 2.0 --- cqlengine/columns.py | 14 ++++++++++++-- cqlengine/statements.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 11a9f60dcf..2ee93ecd0a 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -5,6 +5,7 @@ import re from uuid import uuid1, uuid4 from cql.query import cql_quote +from cql.cqltypes import DateType from cqlengine.exceptions import ValidationError @@ -209,7 +210,11 @@ class Bytes(Column): def to_database(self, value): val = super(Bytes, self).to_database(value) if val is None: return - return val.encode('hex') + return '0x' + val.encode('hex') + + def to_python(self, value): + #return value[2:].decode('hex') + return value class Ascii(Column): @@ -326,7 +331,10 @@ def to_python(self, value): return value elif isinstance(value, date): return datetime(*(value.timetuple()[:6])) - return datetime.utcfromtimestamp(value) + try: + return datetime.utcfromtimestamp(value) + except TypeError: + return datetime.utcfromtimestamp(DateType.deserialize(value)) def to_database(self, value): value = super(DateTime, self).to_database(value) @@ -352,6 +360,8 @@ def to_python(self, value): return value.date() elif isinstance(value, date): return value + else: + value = DateType.deserialize(value) return datetime.utcfromtimestamp(value).date() diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 45f3fabead..014939ba86 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -5,6 +5,25 @@ class StatementException(Exception): pass +# Monkey patch cql_quote to allow raw hex values +import cql.query + + +def cql_quote_replacement(term): + if isinstance(term, basestring) and term.startswith('0x'): + if isinstance(term, unicode): + return term.encode('utf8') + else: + return term + elif isinstance(term, unicode): + return "'%s'" % cql.query.__escape_quotes(term.encode('utf8')) + elif isinstance(term, (str, bool)): + return "'%s'" % cql.query.__escape_quotes(str(term)) + else: + return str(term) +cql.query.cql_quote = cql_quote_replacement + + class ValueQuoter(object): def __init__(self, value): From c533ae61996447fdc035c9a9561a3a26e546a3ed Mon Sep 17 00:00:00 2001 From: drewlll2ll Date: Wed, 22 Jan 2014 11:32:59 -0500 Subject: [PATCH 0606/4200] Fixed Date column --- cqlengine/columns.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cqlengine/columns.py b/cqlengine/columns.py index 2ee93ecd0a..7365d2b21a 100644 --- a/cqlengine/columns.py +++ b/cqlengine/columns.py @@ -353,17 +353,16 @@ def to_database(self, value): class Date(Column): db_type = 'timestamp' - def to_python(self, value): if value is None: return if isinstance(value, datetime): return value.date() elif isinstance(value, date): return value - else: - value = DateType.deserialize(value) - - return datetime.utcfromtimestamp(value).date() + try: + return datetime.utcfromtimestamp(value).date() + except TypeError: + return datetime.utcfromtimestamp(DateType.deserialize(value)).date() def to_database(self, value): value = super(Date, self).to_database(value) From a2a532aa5aac0c6156d14eed64afedfec082d1aa Mon Sep 17 00:00:00 2001 From: drewlll2ll Date: Wed, 22 Jan 2014 12:11:03 -0500 Subject: [PATCH 0607/4200] Updated get_fields to be 2.0 compatible --- cqlengine/management.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cqlengine/management.py b/cqlengine/management.py index 6d41489e6f..0eb3bdf390 100644 --- a/cqlengine/management.py +++ b/cqlengine/management.py @@ -228,13 +228,19 @@ def get_fields(model): col_family = model.column_family_name(include_keyspace=False) with connection_manager() as con: - query = "SELECT column_name, validator FROM system.schema_columns \ + query = "SELECT * FROM system.schema_columns \ WHERE keyspace_name = :ks_name AND columnfamily_name = :col_family" logger.debug("get_fields %s %s", ks_name, col_family) tmp = con.execute(query, {'ks_name': ks_name, 'col_family': col_family}, ONE) - return [Field(x[0], x[1]) for x in tmp.results] + + column_indices = [tmp.columns.index('column_name'), tmp.columns.index('validator')] + try: + type_index = tmp.columns.index('type') + return [Field(x[column_indices[0]], x[column_indices[1]]) for x in tmp.results if x[type_index] == 'regular'] + except ValueError: + return [Field(x[column_indices[0]], x[column_indices[1]]) for x in tmp.results] # convert to Field named tuples From 74bc38e7e73f3a4a03203052c1fc0b5dc2f3f8d3 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 23 Jan 2014 10:46:49 +0100 Subject: [PATCH 0608/4200] Unit test boolean value quoting. --- cqlengine/tests/columns/test_value_io.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cqlengine/tests/columns/test_value_io.py b/cqlengine/tests/columns/test_value_io.py index 54d8f5d5c5..c4958f40dd 100644 --- a/cqlengine/tests/columns/test_value_io.py +++ b/cqlengine/tests/columns/test_value_io.py @@ -144,6 +144,19 @@ class TestBooleanIO(BaseColumnIOTest): pkey_val = True data_val = False + def comparator_converter(self, val): + return val.value if isinstance(val, columns.Boolean.Quoter) else val + +class TestBooleanQuoter(BaseColumnIOTest): + + column = columns.Boolean + + pkey_val = True + data_val = columns.Boolean.Quoter(False) + + def comparator_converter(self, val): + return val.value if isinstance(val, columns.Boolean.Quoter) else val + class TestFloatIO(BaseColumnIOTest): column = columns.Float From cda56c117ba77977b9d2f14ba7ca85229445565d Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Mon, 27 Jan 2014 18:54:43 +0200 Subject: [PATCH 0609/4200] fix (rare) race condition in ConnectionPool.get() --- cqlengine/connection.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 01b94023fa..6872bb8e97 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -3,7 +3,11 @@ #http://cassandra.apache.org/doc/cql/CQL.html from collections import namedtuple -import Queue +try: + import Queue as queue +except ImportError: + # python 3 + import queue import random import cql @@ -118,7 +122,7 @@ def __init__( self._consistency = consistency self._timeout = timeout - self._queue = Queue.Queue(maxsize=_max_connections) + self._queue = queue.Queue(maxsize=_max_connections) def clear(self): """ @@ -138,11 +142,14 @@ def get(self): a new one. """ try: - if self._queue.empty(): - return self._create_connection() + # get with blocking=False (default) returns an item if one + # is immediately available, else raises the Empty exception return self._queue.get() - except CQLConnectionError as cqle: - raise cqle + except queue.Empty: + try: + return self._create_connection() + except CQLConnectionError as cqle: + raise cqle def put(self, conn): """ From 1b0cbe27d59e1869b8de410de5b52ca06620db15 Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Sun, 2 Feb 2014 13:46:25 +0200 Subject: [PATCH 0610/4200] connection queue - issue non-blocking get to pool queue --- cqlengine/connection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 6872bb8e97..2eb2d30ef9 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -142,9 +142,9 @@ def get(self): a new one. """ try: - # get with blocking=False (default) returns an item if one + # get with block=False returns an item if one # is immediately available, else raises the Empty exception - return self._queue.get() + return self._queue.get(block=False) except queue.Empty: try: return self._create_connection() From 79fc15ecd825af9a3884c254407065d32b17c296 Mon Sep 17 00:00:00 2001 From: Travis Glines Date: Thu, 6 Feb 2014 16:04:02 -0800 Subject: [PATCH 0611/4200] Added test to verify limit immutability and deep copy in queryset --- cqlengine/tests/query/test_queryset.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cqlengine/tests/query/test_queryset.py b/cqlengine/tests/query/test_queryset.py index 97e6033551..fbe2affdcc 100644 --- a/cqlengine/tests/query/test_queryset.py +++ b/cqlengine/tests/query/test_queryset.py @@ -127,6 +127,20 @@ def test_queryset_is_immutable(self): assert len(query2._where) == 2 assert len(query1._where) == 1 + def test_queryset_limit_immutability(self): + """ + Tests that calling a queryset function that changes it's state returns a new queryset with same limit + """ + query1 = TestModel.objects(test_id=5).limit(1) + assert query1._limit == 1 + + query2 = query1.filter(expected_result__gte=1) + assert query2._limit == 1 + + query3 = query1.filter(expected_result__gte=1).limit(2) + assert query1._limit == 1 + assert query3._limit == 2 + def test_the_all_method_duplicates_queryset(self): """ Tests that calling all on a queryset with previously defined filters duplicates queryset From 3cf722a963652dc4f6ac45456521b9e133f95a49 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Fri, 7 Feb 2014 16:27:22 +0100 Subject: [PATCH 0612/4200] Allow acces to instance columns as if it is a dict. --- changelog | 1 + cqlengine/models.py | 31 ++++++++++++++++++++++++++ cqlengine/tests/model/test_model_io.py | 21 +++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/changelog b/changelog index a908f72092..8f14d773cf 100644 --- a/changelog +++ b/changelog @@ -8,6 +8,7 @@ CHANGELOG * clear TTL and timestamp off models after persisting to DB * allows UUID without - (Thanks to Michael Haddad, github.com/mahall) * fixes regarding syncing schema settings (thanks Kai Lautaportti github.com/dokai) +* allow acces to instance columns as if it is a dict 0.10.0 diff --git a/cqlengine/models.py b/cqlengine/models.py index 4f70c3eed3..27f0316bb2 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -421,6 +421,37 @@ def validate(self): val = col.validate(getattr(self, name)) setattr(self, name, val) + ### Let an instance be used like a dict of its columns keys/values + + def __iter__(self): + """ Iterate over column ids. """ + for column_id in self._columns.keys(): + yield column_id + + def __getitem__(self, key): + """ Returns column's value. """ + if not isinstance(key, basestring): + raise TypeError + if key not in self._columns.keys(): + raise KeyError + return getattr(self, key) + + def __len__(self): + """ Returns the number of columns defined on that model. """ + return len(self._columns.keys()) + + def keys(self): + """ Returns list of column's IDs. """ + return [k for k in self] + + def values(self): + """ Returns list of column's values. """ + return [self[k] for k in self] + + def items(self): + """ Returns a dictionnary of columns's IDs/values. """ + return [(k, self[k]) for k in self] + def _as_dict(self): """ Returns a map of column names to cleaned values """ values = self._dynamic_columns or {} diff --git a/cqlengine/tests/model/test_model_io.py b/cqlengine/tests/model/test_model_io.py index 82abef74ba..512aa2324f 100644 --- a/cqlengine/tests/model/test_model_io.py +++ b/cqlengine/tests/model/test_model_io.py @@ -1,6 +1,7 @@ from uuid import uuid4 import random from datetime import date +from operator import itemgetter from cqlengine.tests.base import BaseCassEngTestCase from cqlengine.management import create_table @@ -43,6 +44,26 @@ def test_model_save_and_load(self): for cname in tm._columns.keys(): self.assertEquals(getattr(tm, cname), getattr(tm2, cname)) + def test_model_read_as_dict(self): + """ + Tests that columns of an instance can be read as a dict. + """ + tm = TestModel.create(count=8, text='123456789', a_bool=True) + column_dict = { + 'id': tm.id, + 'count': tm.count, + 'text': tm.text, + 'a_bool': tm.a_bool, + } + self.assertEquals(sorted(tm.keys()), sorted(column_dict.keys())) + self.assertEquals(sorted(tm.values()), sorted(column_dict.values())) + self.assertEquals( + sorted(tm.items(), key=itemgetter(0)), + sorted(column_dict.items(), key=itemgetter(0))) + self.assertEquals(len(tm), len(column_dict)) + for column_id in column_dict.keys(): + self.assertEqual(tm[column_id], column_dict[column_id]) + def test_model_updating_works_properly(self): """ Tests that subsequent saves after initial model creation work From ef5745bc5f85f613a663ed1d2102320e1f0acd1b Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Fri, 7 Feb 2014 16:31:39 +0100 Subject: [PATCH 0613/4200] Fix comment. --- cqlengine/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/models.py b/cqlengine/models.py index 27f0316bb2..5655c9df54 100644 --- a/cqlengine/models.py +++ b/cqlengine/models.py @@ -449,7 +449,7 @@ def values(self): return [self[k] for k in self] def items(self): - """ Returns a dictionnary of columns's IDs/values. """ + """ Returns a list of columns's IDs/values. """ return [(k, self[k]) for k in self] def _as_dict(self): From 95d99bf35982a75df961522367a0122effec4f24 Mon Sep 17 00:00:00 2001 From: Danny Cosson Date: Sun, 9 Feb 2014 14:39:26 -0500 Subject: [PATCH 0614/4200] Implements the ability to blind update add to a set. --- cqlengine/query.py | 20 +++++++++++++++----- cqlengine/statements.py | 9 ++++++--- cqlengine/tests/query/test_updates.py | 12 +++++++++++- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index f991f5e9cb..f226a70ed4 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -663,24 +663,34 @@ def update(self, **values): nulled_columns = set() us = UpdateStatement(self.column_family_name, where=self._where, ttl=self._ttl, timestamp=self._timestamp) for name, val in values.items(): - col = self.model._columns.get(name) + col_name, col_op = self._parse_filter_arg(name) + col = self.model._columns.get(col_name) # check for nonexistant columns if col is None: - raise ValidationError("{}.{} has no column named: {}".format(self.__module__, self.model.__name__, name)) + raise ValidationError("{}.{} has no column named: {}".format(self.__module__, self.model.__name__, col_name)) # check for primary key update attempts if col.is_primary_key: - raise ValidationError("Cannot apply update to primary key '{}' for {}.{}".format(name, self.__module__, self.model.__name__)) + raise ValidationError("Cannot apply update to primary key '{}' for {}.{}".format(col_name, self.__module__, self.model.__name__)) val = col.validate(val) if val is None: - nulled_columns.add(name) + nulled_columns.add(col_name) continue + # add the update statements if isinstance(col, Counter): # TODO: implement counter updates raise NotImplementedError + elif isinstance(col, BaseContainerColumn): + if isinstance(col, List): klass = ListUpdateClause + elif isinstance(col, Map): klass = MapUpdateClause + elif isinstance(col, Set): klass = SetUpdateClause + else: raise RuntimeError + us.add_assignment_clause(klass( + col_name, col.to_database(val), operation=col_op)) else: - us.add_assignment_clause(AssignmentClause(name, col.to_database(val))) + us.add_assignment_clause(AssignmentClause( + col_name, col.to_database(val))) if us.assignments: self._execute(us) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 0e85c9292e..ba4d7061c1 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -136,10 +136,11 @@ def insert_tuple(self): class ContainerUpdateClause(AssignmentClause): - def __init__(self, field, value, previous=None, column=None): + def __init__(self, field, value, operation=None, previous=None, column=None): super(ContainerUpdateClause, self).__init__(field, value) self.previous = previous self._assignments = None + self._operation = operation self._analyzed = False self._column = column @@ -159,8 +160,8 @@ def update_context(self, ctx): class SetUpdateClause(ContainerUpdateClause): """ updates a set collection """ - def __init__(self, field, value, previous=None, column=None): - super(SetUpdateClause, self).__init__(field, value, previous, column=column) + def __init__(self, field, value, operation=None, previous=None, column=None): + super(SetUpdateClause, self).__init__(field, value, operation, previous, column=column) self._additions = None self._removals = None @@ -182,6 +183,8 @@ def _analyze(self): """ works out the updates to be performed """ if self.value is None or self.value == self.previous: pass + elif self._operation == "add": + self._additions = self.value elif self.previous is None: self._assignments = self.value else: diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py index b54b294c60..13f2be039c 100644 --- a/cqlengine/tests/query/test_updates.py +++ b/cqlengine/tests/query/test_updates.py @@ -13,7 +13,7 @@ class TestQueryUpdateModel(Model): cluster = columns.Integer(primary_key=True) count = columns.Integer(required=False) text = columns.Text(required=False, index=True) - + text_set = columns.Set(columns.Text, required=False) class QueryUpdateTests(BaseCassEngTestCase): @@ -115,3 +115,13 @@ def test_mixed_value_and_null_update(self): def test_counter_updates(self): pass + + def test_set_add_updates(self): + partition = uuid4() + cluster = 1 + TestQueryUpdateModel.objects.create( + partition=partition, cluster=cluster, text_set={"foo"}) + TestQueryUpdateModel.objects( + partition=partition, cluster=cluster).update(text_set__add={'bar'}) + obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) + self.assertEqual(obj.text_set, {"foo", "bar"}) From b777f8cfc6977b59cccf6bc8cc7e78a8a5b0e357 Mon Sep 17 00:00:00 2001 From: Danny Cosson Date: Sun, 9 Feb 2014 14:42:39 -0500 Subject: [PATCH 0615/4200] Adds a test for a blind update append when the record doesn't exist yet --- cqlengine/tests/query/test_updates.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py index 13f2be039c..36b229dfc7 100644 --- a/cqlengine/tests/query/test_updates.py +++ b/cqlengine/tests/query/test_updates.py @@ -125,3 +125,13 @@ def test_set_add_updates(self): partition=partition, cluster=cluster).update(text_set__add={'bar'}) obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) self.assertEqual(obj.text_set, {"foo", "bar"}) + + def test_set_add_updates_new_record(self): + """ If the key doesn't exist yet, an update creates the record + """ + partition = uuid4() + cluster = 1 + TestQueryUpdateModel.objects( + partition=partition, cluster=cluster).update(text_set__add={'bar'}) + obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) + self.assertEqual(obj.text_set, {"bar"}) From 47b1d2e3cf3619dc86342bc68c73080bbc226ea5 Mon Sep 17 00:00:00 2001 From: Danny Cosson Date: Sun, 9 Feb 2014 14:45:27 -0500 Subject: [PATCH 0616/4200] Adds blind set removal, and a test that it works --- cqlengine/statements.py | 2 ++ cqlengine/tests/query/test_updates.py | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index ba4d7061c1..111574c167 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -185,6 +185,8 @@ def _analyze(self): pass elif self._operation == "add": self._additions = self.value + elif self._operation == "remove": + self._removals = self.value elif self.previous is None: self._assignments = self.value else: diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py index 36b229dfc7..fc1fe0e171 100644 --- a/cqlengine/tests/query/test_updates.py +++ b/cqlengine/tests/query/test_updates.py @@ -135,3 +135,14 @@ def test_set_add_updates_new_record(self): partition=partition, cluster=cluster).update(text_set__add={'bar'}) obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) self.assertEqual(obj.text_set, {"bar"}) + + def test_set_remove_updates(self): + partition = uuid4() + cluster = 1 + TestQueryUpdateModel.objects.create( + partition=partition, cluster=cluster, text_set={"foo", "baz"}) + TestQueryUpdateModel.objects( + partition=partition, cluster=cluster).update( + text_set__remove={'foo'}) + obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) + self.assertEqual(obj.text_set, {"baz"}) From cc52ad6c6652b2f52a11fcdc075e0f3df27f927f Mon Sep 17 00:00:00 2001 From: Danny Cosson Date: Sun, 9 Feb 2014 14:47:42 -0500 Subject: [PATCH 0617/4200] Adds a test that removing something that's not in a set just fails quietly --- cqlengine/tests/query/test_updates.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py index fc1fe0e171..65f8f57211 100644 --- a/cqlengine/tests/query/test_updates.py +++ b/cqlengine/tests/query/test_updates.py @@ -146,3 +146,16 @@ def test_set_remove_updates(self): text_set__remove={'foo'}) obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) self.assertEqual(obj.text_set, {"baz"}) + + def test_set_remove_new_record(self): + """ Removing something not in the set should silently do nothing + """ + partition = uuid4() + cluster = 1 + TestQueryUpdateModel.objects.create( + partition=partition, cluster=cluster, text_set={"foo"}) + TestQueryUpdateModel.objects( + partition=partition, cluster=cluster).update( + text_set__remove={'afsd'}) + obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) + self.assertEqual(obj.text_set, {"foo"}) From 57f92c0777d1fe3d48141b5b947f897b1e2ddc76 Mon Sep 17 00:00:00 2001 From: Danny Cosson Date: Sun, 9 Feb 2014 15:42:28 -0500 Subject: [PATCH 0618/4200] Adds ability to blind append and prepend to a list --- cqlengine/query.py | 3 +-- cqlengine/statements.py | 13 ++++++++++--- cqlengine/tests/query/test_updates.py | 23 +++++++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index f226a70ed4..5f32a87cb4 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -681,9 +681,8 @@ def update(self, **values): if isinstance(col, Counter): # TODO: implement counter updates raise NotImplementedError - elif isinstance(col, BaseContainerColumn): + elif isinstance(col, (List, Set)): if isinstance(col, List): klass = ListUpdateClause - elif isinstance(col, Map): klass = MapUpdateClause elif isinstance(col, Set): klass = SetUpdateClause else: raise RuntimeError us.add_assignment_clause(klass( diff --git a/cqlengine/statements.py b/cqlengine/statements.py index 111574c167..d53e0cd0fa 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -215,8 +215,8 @@ def update_context(self, ctx): class ListUpdateClause(ContainerUpdateClause): """ updates a list collection """ - def __init__(self, field, value, previous=None, column=None): - super(ListUpdateClause, self).__init__(field, value, previous, column=column) + def __init__(self, field, value, operation=None, previous=None, column=None): + super(ListUpdateClause, self).__init__(field, value, operation, previous, column=column) self._append = None self._prepend = None @@ -261,6 +261,14 @@ def _analyze(self): if self.value is None or self.value == self.previous: pass + elif self._operation == "append": + self._append = self.value + + elif self._operation == "prepend": + # self.value is a Quoter but we reverse self._prepend later as if + # it's a list, so we have to set it to the underlying list + self._prepend = self.value.value + elif self.previous is None: self._assignments = self.value @@ -269,7 +277,6 @@ def _analyze(self): # rewrite the whole list self._assignments = self.value - elif len(self.previous) == 0: # if we're updating from an empty # list, do a complete insert diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py index 65f8f57211..1f62e8ce5d 100644 --- a/cqlengine/tests/query/test_updates.py +++ b/cqlengine/tests/query/test_updates.py @@ -14,6 +14,7 @@ class TestQueryUpdateModel(Model): count = columns.Integer(required=False) text = columns.Text(required=False, index=True) text_set = columns.Set(columns.Text, required=False) + text_list = columns.List(columns.Text, required=False) class QueryUpdateTests(BaseCassEngTestCase): @@ -159,3 +160,25 @@ def test_set_remove_new_record(self): text_set__remove={'afsd'}) obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) self.assertEqual(obj.text_set, {"foo"}) + + def test_list_append_updates(self): + partition = uuid4() + cluster = 1 + TestQueryUpdateModel.objects.create( + partition=partition, cluster=cluster, text_list=["foo"]) + TestQueryUpdateModel.objects( + partition=partition, cluster=cluster).update( + text_list__append=['bar']) + obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) + self.assertEqual(obj.text_list, ["foo", "bar"]) + + def test_list_prepend_updates(self): + partition = uuid4() + cluster = 1 + TestQueryUpdateModel.objects.create( + partition=partition, cluster=cluster, text_list=["foo"]) + TestQueryUpdateModel.objects( + partition=partition, cluster=cluster).update( + text_list__prepend=['bar']) + obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) + self.assertEqual(obj.text_list, ["bar", "foo"]) From e11f53e370e634dc271a18056cd158204971d561 Mon Sep 17 00:00:00 2001 From: Danny Cosson Date: Mon, 10 Feb 2014 10:15:52 -0500 Subject: [PATCH 0619/4200] Updates the prepend to list test to make sure order is preserved when multiple items are prepended --- cqlengine/tests/query/test_updates.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py index 1f62e8ce5d..1541f69968 100644 --- a/cqlengine/tests/query/test_updates.py +++ b/cqlengine/tests/query/test_updates.py @@ -173,12 +173,13 @@ def test_list_append_updates(self): self.assertEqual(obj.text_list, ["foo", "bar"]) def test_list_prepend_updates(self): + """ Prepend two things since order is reversed by default by CQL """ partition = uuid4() cluster = 1 TestQueryUpdateModel.objects.create( partition=partition, cluster=cluster, text_list=["foo"]) TestQueryUpdateModel.objects( partition=partition, cluster=cluster).update( - text_list__prepend=['bar']) + text_list__prepend=['bar', 'baz']) obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) - self.assertEqual(obj.text_list, ["bar", "foo"]) + self.assertEqual(obj.text_list, ["bar", "baz", "foo"]) From 9dc55ec54bef7c218ad2d9036228a2555b2b6aa5 Mon Sep 17 00:00:00 2001 From: Danny Cosson Date: Mon, 10 Feb 2014 11:28:03 -0500 Subject: [PATCH 0620/4200] Adds a map __merge update mode, to merge in map with the existing values on the server. --- cqlengine/query.py | 3 ++- cqlengine/statements.py | 12 +++++++----- cqlengine/tests/query/test_updates.py | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index 5f32a87cb4..ce7036554b 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -681,9 +681,10 @@ def update(self, **values): if isinstance(col, Counter): # TODO: implement counter updates raise NotImplementedError - elif isinstance(col, (List, Set)): + elif isinstance(col, (List, Set, Map)): if isinstance(col, List): klass = ListUpdateClause elif isinstance(col, Set): klass = SetUpdateClause + elif isinstance(col, Map): klass = MapUpdateClause else: raise RuntimeError us.add_assignment_clause(klass( col_name, col.to_database(val), operation=col_op)) diff --git a/cqlengine/statements.py b/cqlengine/statements.py index d53e0cd0fa..5ae1d213d4 100644 --- a/cqlengine/statements.py +++ b/cqlengine/statements.py @@ -310,13 +310,16 @@ def _analyze(self): class MapUpdateClause(ContainerUpdateClause): """ updates a map collection """ - def __init__(self, field, value, previous=None, column=None): - super(MapUpdateClause, self).__init__(field, value, previous, column=column) + def __init__(self, field, value, operation=None, previous=None, column=None): + super(MapUpdateClause, self).__init__(field, value, operation, previous, column=column) self._updates = None self.previous = self.previous or {} def _analyze(self): - self._updates = sorted([k for k, v in self.value.items() if v != self.previous.get(k)]) or None + if self._operation == "merge": + self._updates = self.value.value + else: + self._updates = sorted([k for k, v in self.value.items() if v != self.previous.get(k)]) or None self._analyzed = True def get_context_size(self): @@ -326,8 +329,7 @@ def get_context_size(self): def update_context(self, ctx): if not self._analyzed: self._analyze() ctx_id = self.context_id - for key in self._updates or []: - val = self.value.get(key) + for key, val in self._updates.items(): ctx[str(ctx_id)] = self._column.key_col.to_database(key) if self._column else key ctx[str(ctx_id + 1)] = self._column.value_col.to_database(val) if self._column else val ctx_id += 2 diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py index 1541f69968..723a8e87f2 100644 --- a/cqlengine/tests/query/test_updates.py +++ b/cqlengine/tests/query/test_updates.py @@ -15,6 +15,7 @@ class TestQueryUpdateModel(Model): text = columns.Text(required=False, index=True) text_set = columns.Set(columns.Text, required=False) text_list = columns.List(columns.Text, required=False) + text_map = columns.Map(columns.Text, columns.Text, required=False) class QueryUpdateTests(BaseCassEngTestCase): @@ -183,3 +184,16 @@ def test_list_prepend_updates(self): text_list__prepend=['bar', 'baz']) obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) self.assertEqual(obj.text_list, ["bar", "baz", "foo"]) + + def test_map_merge_updates(self): + """ Merge a dictionary into existing value """ + partition = uuid4() + cluster = 1 + TestQueryUpdateModel.objects.create( + partition=partition, cluster=cluster, + text_map={"foo": '1', "bar": '2'}) + TestQueryUpdateModel.objects( + partition=partition, cluster=cluster).update( + text_map__merge={"bar": '3', "baz": '4'}) + obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) + self.assertEqual(obj.text_map, {"foo": '1', "bar": '3', "baz": '4'}) From c344f1e2742dc49e7d2ea17f4ec5a0632daabef2 Mon Sep 17 00:00:00 2001 From: Danny Cosson Date: Mon, 10 Feb 2014 11:54:31 -0500 Subject: [PATCH 0621/4200] Tries to add a test that doesn't work because of a bug in the underlying cql library. So doing an update with a dict __merge with a None value will not be possible for the time being. --- cqlengine/tests/query/test_updates.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cqlengine/tests/query/test_updates.py b/cqlengine/tests/query/test_updates.py index 723a8e87f2..592eb28595 100644 --- a/cqlengine/tests/query/test_updates.py +++ b/cqlengine/tests/query/test_updates.py @@ -197,3 +197,22 @@ def test_map_merge_updates(self): text_map__merge={"bar": '3', "baz": '4'}) obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) self.assertEqual(obj.text_map, {"foo": '1', "bar": '3', "baz": '4'}) + + def test_map_merge_none_deletes_key(self): + """ The CQL behavior is if you set a key in a map to null it deletes + that key from the map. Test that this works with __merge. + + This test fails because of a bug in the cql python library not + converting None to null (and the cql library is no longer in active + developement). + """ + # partition = uuid4() + # cluster = 1 + # TestQueryUpdateModel.objects.create( + # partition=partition, cluster=cluster, + # text_map={"foo": '1', "bar": '2'}) + # TestQueryUpdateModel.objects( + # partition=partition, cluster=cluster).update( + # text_map__merge={"bar": None}) + # obj = TestQueryUpdateModel.objects.get(partition=partition, cluster=cluster) + # self.assertEqual(obj.text_map, {"foo": '1'}) From a2c7a547ee2a8e0401862f190fafeb3ab9c2d4d4 Mon Sep 17 00:00:00 2001 From: Niklas Korz Date: Tue, 11 Feb 2014 18:53:09 +0100 Subject: [PATCH 0622/4200] QueryException Token values len I assume these two have to be swapped, otherwise it doesn't make much sense. --- cqlengine/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cqlengine/query.py b/cqlengine/query.py index f991f5e9cb..3c629ebf23 100644 --- a/cqlengine/query.py +++ b/cqlengine/query.py @@ -385,7 +385,7 @@ def filter(self, *args, **kwargs): if len(partition_columns) != len(val.value): raise QueryException( 'Token() received {} arguments but model has {} partition keys'.format( - len(partition_columns), len(val.value))) + len(val.value), len(partition_columns))) val.set_columns(partition_columns) #get query operator, or use equals if not supplied From a32b92165863db3af83240c23fa71811b058558a Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 11 Feb 2014 11:19:03 -0800 Subject: [PATCH 0623/4200] removing uneccesary try/except block --- cqlengine/connection.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cqlengine/connection.py b/cqlengine/connection.py index 2eb2d30ef9..e03a35b136 100644 --- a/cqlengine/connection.py +++ b/cqlengine/connection.py @@ -146,10 +146,7 @@ def get(self): # is immediately available, else raises the Empty exception return self._queue.get(block=False) except queue.Empty: - try: - return self._create_connection() - except CQLConnectionError as cqle: - raise cqle + return self._create_connection() def put(self, conn): """ From 7fa6a6ed9b3dad79e4e7393322b39c1c46b5ff18 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Tue, 11 Feb 2014 11:22:42 -0800 Subject: [PATCH 0624/4200] updating changelog --- changelog | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/changelog b/changelog index 1284fa6015..af328c69fd 100644 --- a/changelog +++ b/changelog @@ -2,7 +2,8 @@ CHANGELOG 0.11.1 (in progress) -* Normalize and unquote boolean values. +* Normalize and unquote boolean values. (Thanks Kevin Deldycke github.com/kdeldycke) +* Fix race condition in connection manager (Thanks Roey Berman github.com/bergundy) 0.11.0 @@ -10,7 +11,7 @@ CHANGELOG - allows for long, timedelta, and datetime * fixed use of USING TIMESTAMP in batches * clear TTL and timestamp off models after persisting to DB -* allows UUID without - (Thanks to Michael Haddad, github.com/mahall) +* allows UUID without dashes - (Thanks to Michael Hall, github.com/mahall) * fixes regarding syncing schema settings (thanks Kai Lautaportti github.com/dokai) 0.10.0 From 1db9679cf8f0e2ffed3374a38858952ae83b8c89 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 13 Feb 2014 18:13:46 -0600 Subject: [PATCH 0625/4200] Add version field, setter to Host --- cassandra/pool.py | 22 ++++++++++++++++++++++ tests/unit/test_host_connection_pool.py | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/cassandra/pool.py b/cassandra/pool.py index 6b12caa95b..974b803acd 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -3,6 +3,7 @@ """ import logging +import re import socket import time from threading import RLock, Condition @@ -26,6 +27,13 @@ class NoConnectionsAvailable(Exception): pass +# example matches: +# 1.0.0 +# 1.0.0-beta1 +# 2.0-SNAPSHOT +version_re = re.compile(r"(?P\d+)\.(?P\d+)(?:\.(?P\d+))?(?:-(?P