From 9f6fcb11ebe2948167e982aa9ca9713b54a0e47d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 8 Nov 2012 21:28:44 -0800 Subject: [PATCH 0001/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] #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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] =?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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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/4528] 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 87e1fbe3c8a58f91bc1af37405f30940c2c27e1d Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Wed, 11 Sep 2013 22:49:54 +0200 Subject: [PATCH 0391/4528] Add tox and travis config file, update .gitnore. --- .gitignore | 1 + .travis.yml | 16 ++++++++++++++++ tox.ini | 9 +++++++++ 3 files changed, 26 insertions(+) create mode 100644 .travis.yml create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index c4fca1c5f2..c5d48f33e9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.swo *.so *.egg-info +.tox build MANIFEST dist diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..2676048de9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,16 @@ +language: python +python: 2.7 +env: + - TOX_ENV=py26 + - TOX_ENV=py27 + - TOX_ENV=pypy + +before_install: + - sudo apt-get update -y + - sudo apt-get install -y build-essential python-dev + - sudo apt-get install -y libev4 libev-dev +install: + - pip install tox + +script: + - tox -e $TOX_ENV diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000000..a2aa5f8e3c --- /dev/null +++ b/tox.ini @@ -0,0 +1,9 @@ +[tox] +envlist = py26,py27,pypy + +[testenv] +deps = nose + mock + ccm +commands = {envpython} setup.py build_ext --inplace + nosetests tests/unit/ From e72cde5f0e7de393e2b9494062245e0eaf1f7392 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Mon, 23 Sep 2013 17:53:26 -0700 Subject: [PATCH 0392/4528] 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 0393/4528] 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 0394/4528] 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 0395/4528] 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 0396/4528] 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 0397/4528] 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 0398/4528] 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 0399/4528] 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 0400/4528] 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 453309e0a865c2528fbea0ea468e45f8ad6839d3 Mon Sep 17 00:00:00 2001 From: Daniel Koepke Date: Mon, 30 Sep 2013 13:39:28 -0700 Subject: [PATCH 0401/4528] First cut at a gevent connection class. It seems to kind of work. The integration tests aren't working right; that needs to be fixed before first. A simple test of the basic functionality seems to work, but I've not yet tested the recovery/retry and other execptional conditions. --- cassandra/cluster.py | 12 +- cassandra/connection.py | 7 +- cassandra/io/geventreactor.py | 249 ++++++++++++++++++++ tests/integration/test_connection.py | 1 + tests/integration/test_gevent_connection.py | 27 +++ 5 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 cassandra/io/geventreactor.py create mode 100644 tests/integration/test_gevent_connection.py diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 9e98827637..290a372035 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -214,12 +214,12 @@ def __init__(self, Any of the mutable Cluster attributes may be set as keyword arguments to the constructor. """ - if 'gevent.monkey' in sys.modules: - raise Exception( - "gevent monkey-patching detected. This driver does not currently " - "support gevent, and monkey patching will break the driver " - "completely. You can track progress towards adding gevent " - "support here: https://datastax-oss.atlassian.net/browse/PYTHON-7.") + # if 'gevent.monkey' in sys.modules: + # raise Exception( + # "gevent monkey-patching detected. This driver does not currently " + # "support gevent, and monkey patching will break the driver " + # "completely. You can track progress towards adding gevent " + # "support here: https://datastax-oss.atlassian.net/browse/PYTHON-7.") self.contact_points = contact_points self.port = port diff --git a/cassandra/connection.py b/cassandra/connection.py index f795b178af..258734b8c5 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -1,8 +1,13 @@ import errno from functools import wraps, partial import logging +import sys from threading import Event, Lock, RLock -from Queue import Queue + +if 'gevent.monkey' in sys.modules: + from gevent.queue import Queue +else: + from Queue import Queue from cassandra import ConsistencyLevel, AuthenticationFailed from cassandra.marshal import int8_unpack, int32_pack diff --git a/cassandra/io/geventreactor.py b/cassandra/io/geventreactor.py new file mode 100644 index 0000000000..aae4d7dd2f --- /dev/null +++ b/cassandra/io/geventreactor.py @@ -0,0 +1,249 @@ +import gevent +from gevent import select +from gevent import socket +from gevent.event import Event + +from collections import defaultdict, deque +from functools import partial +import logging +import os +import sys +import traceback + +if 'gevent.monkey' in sys.modules: + from gevent.queue import Queue +else: + from Queue import Queue + +from errno import EALREADY, EINPROGRESS, EWOULDBLOCK, EINVAL + +from cassandra.connection import (Connection, ResponseWaiter, ConnectionShutdown, + ConnectionBusy, NONBLOCKING) +from cassandra.decoder import RegisterMessage +from cassandra.marshal import int32_unpack + + +log = logging.getLogger(__name__) + +_starting_conns = set() + + +def is_timeout(err): + return ( + err in (EINPROGRESS, EALREADY, EWOULDBLOCK) or + (err == EINVAL and os.name in ('nt', 'ce')) + ) + + +class GeventConnection(Connection): + """ + An implementation of :class:`.Connection` that utilizes ``gevent``. + """ + + _buf = "" + _total_reqd_bytes = 0 + _read_watcher = None + _write_watcher = None + _socket = None + + @classmethod + def factory(cls, *args, **kwargs): + conn = cls(*args, **kwargs) + conn.connected_event.wait() + if conn.last_error: + raise conn.last_error + else: + return conn + + def __init__(self, *args, **kwargs): + super(GeventConnection, self).__init__(*args, **kwargs) + + self.connected_event = Event() + + self._callbacks = {} + self._push_watchers = defaultdict(set) + self.deque = deque() + + log.debug("About to connect in gevent %s" % ((self.host, self.port),)) + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.settimeout(1.0) # TODO potentially make this value configurable + self._socket.connect((self.host, self.port)) + log.debug("Did connect in gevent") + + self._read_watcher = gevent.spawn(lambda: self.handle_read()) + self._write_watcher = gevent.spawn(lambda: self.handle_write()) + + if self.sockopts: + for args in self.sockopts: + self._socket.setsockopt(*args) + + self._send_options_message() + + def close(self): + if self.is_closed: + return + self.is_closed = True + + log.debug("Closing connection to %s" % (self.host,)) + if self._read_watcher: + self._read_watcher.kill() + if self._write_watcher: + self._write_watcher.kill() + if self._socket: + self._socket.close() + log.debug("Closed socket to %s" % (self.host,)) + + # don't leave in-progress operations hanging + self.connected_event.set() + if not self.is_defunct: + self._error_all_callbacks( + ConnectionShutdown("Connection to %s was closed" % self.host)) + + def __del__(self): + try: + self.close() + except TypeError: + pass + + def defunct(self, exc): + if self.is_defunct: + return + self.is_defunct = True + + trace = traceback.format_exc(exc) + if trace != "None": + log.debug("Defuncting connection to %s: %s\n%s", + self.host, exc, traceback.format_exc(exc)) + else: + log.debug("Defuncting connection to %s: %s", self.host, exc) + + self.last_error = exc + self._error_all_callbacks(exc) + self.connected_event.set() + return exc + + def _error_all_callbacks(self, exc): + new_exc = ConnectionShutdown(str(exc)) + for cb in self._callbacks.values(): + cb(new_exc) + + def handle_error(self): + self.defunct(sys.exc_info()[1]) + + def handle_close(self): + log.debug("connection closed by server") + self.close() + + def handle_write(self): + wlist = (self._socket,) + + while True: + try: + select.select((), wlist, ()) + except Exception as err: + log.debug("Write loop got error %s" % err) + return + + if self.deque: + next_msg = self.deque.popleft() + try: + self._socket.sendall(next_msg) + except socket.error as err: + if (err.args[0] in NONBLOCKING): + self.deque.appendleft(next_msg) + gevent.sleep(1.0) + else: + self.defunct(err) + return # Leave the write loop + else: + gevent.sleep(0.1) + + def handle_read(self): + rlist = (self._socket,) + + while True: + try: + select.select(rlist, (), ()) + except Exception as err: + log.debug("Read loop got error %s" % err) + return + + try: + buf = self._socket.recv(self.in_buffer_size) + except socket.error as err: + if not is_timeout(err): + self.defunct(err) + return # leave the read loop + + if buf: + self._buf += buf + while True: + if len(self._buf) < 8: + # we don't have a complete header yet + break + elif self._total_reqd_bytes and len(self._buf) < self._total_reqd_bytes: + # we already saw a header, but we don't have a complete message yet + break + else: + body_len = int32_unpack(self._buf[4:8]) + if len(self._buf) - 8 >= body_len: + msg = self._buf[:8 + body_len] + self._buf = self._buf[8 + body_len:] + self._total_reqd_bytes = 0 + self.process_msg(msg, body_len) + else: + self._total_reqd_bytes = body_len + 8 + + def handle_pushed(self, response): + log.debug("Message pushed from server: %r", response) + for cb in self._push_watchers.get(response.event_type, []): + try: + cb(response.event_args) + except Exception: + log.exception("Pushed event handler errored, ignoring:") + + def push(self, data): + sabs = self.out_buffer_size + if len(data) > sabs: + chunks = [] + for i in xrange(0, len(data), sabs): + chunks.append(data[i:i + sabs]) + else: + chunks = [data] + + self.deque.extend(chunks) + + def send_msg(self, msg, cb): + if self.is_defunct: + raise ConnectionShutdown("Connection to %s is defunct" % self.host) + elif self.is_closed: + raise ConnectionShutdown("Connection to %s is closed" % self.host) + + try: + request_id = self._id_queue.get_nowait() + except Queue.EMPTY: + raise ConnectionBusy( + "Connection to %s is at the max number of requests" % self.host) + + self._callbacks[request_id] = cb + self.push(msg.to_string(request_id, compression=self.compressor)) + return request_id + + def wait_for_response(self, msg): + return self.wait_for_responses(msg)[0] + + def wait_for_responses(self, *msgs): + waiter = ResponseWaiter(len(msgs)) + for i, msg in enumerate(msgs): + self.send_msg(msg, partial(waiter.got_response, index=i)) + + return waiter.deliver() + + def register_watcher(self, event_type, callback): + self._push_watchers[event_type].add(callback) + self.wait_for_response(RegisterMessage(event_list=[event_type])) + + def register_watchers(self, type_callback_dict): + for event_type, callback in type_callback_dict.items(): + self._push_watchers[event_type].add(callback) + self.wait_for_response(RegisterMessage(event_list=type_callback_dict.keys())) diff --git a/tests/integration/test_connection.py b/tests/integration/test_connection.py index 229f1ccd61..46b0cadffc 100644 --- a/tests/integration/test_connection.py +++ b/tests/integration/test_connection.py @@ -15,6 +15,7 @@ except ImportError: LibevConnection = None + class ConnectionTest(object): klass = None diff --git a/tests/integration/test_gevent_connection.py b/tests/integration/test_gevent_connection.py new file mode 100644 index 0000000000..152b34813a --- /dev/null +++ b/tests/integration/test_gevent_connection.py @@ -0,0 +1,27 @@ +try: + import unittest2 as unittest +except ImportError: + import unittest # noqa + +try: + import gevent.monkey + gevent.monkey.patch_all() +except ImportError: + pass + +try: + from cassandra.io.geventreactor import GeventConnection +except ImportError: + GeventConnection = None + +from .test_connection import ConnectionTest + + +class GeventConnectionTest(ConnectionTest, unittest.TestCase): + + klass = GeventConnection + + @classmethod + def setup_class(cls): + if GeventConnection is None: + raise unittest.SkipTest('gevent does not appear to be installed properly') From 32ae3413d281f4f618c4f90628cbc2207bb917b9 Mon Sep 17 00:00:00 2001 From: Daniel Koepke Date: Mon, 30 Sep 2013 13:50:10 -0700 Subject: [PATCH 0402/4528] Remove unused set. --- cassandra/io/geventreactor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cassandra/io/geventreactor.py b/cassandra/io/geventreactor.py index aae4d7dd2f..0ffb261c87 100644 --- a/cassandra/io/geventreactor.py +++ b/cassandra/io/geventreactor.py @@ -25,8 +25,6 @@ log = logging.getLogger(__name__) -_starting_conns = set() - def is_timeout(err): return ( From 9e4e7b381825881b7053eea976086d09f5195321 Mon Sep 17 00:00:00 2001 From: Daniel Koepke Date: Mon, 30 Sep 2013 13:53:22 -0700 Subject: [PATCH 0403/4528] Clean up. --- cassandra/cluster.py | 8 -------- cassandra/io/geventreactor.py | 2 -- 2 files changed, 10 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 290a372035..77fd5d0316 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -5,7 +5,6 @@ from concurrent.futures import ThreadPoolExecutor import logging -import sys import time from threading import Lock, RLock, Thread, Event import Queue @@ -214,13 +213,6 @@ def __init__(self, Any of the mutable Cluster attributes may be set as keyword arguments to the constructor. """ - # if 'gevent.monkey' in sys.modules: - # raise Exception( - # "gevent monkey-patching detected. This driver does not currently " - # "support gevent, and monkey patching will break the driver " - # "completely. You can track progress towards adding gevent " - # "support here: https://datastax-oss.atlassian.net/browse/PYTHON-7.") - self.contact_points = contact_points self.port = port self.compression = compression diff --git a/cassandra/io/geventreactor.py b/cassandra/io/geventreactor.py index 0ffb261c87..1d56bb771b 100644 --- a/cassandra/io/geventreactor.py +++ b/cassandra/io/geventreactor.py @@ -62,11 +62,9 @@ def __init__(self, *args, **kwargs): self._push_watchers = defaultdict(set) self.deque = deque() - log.debug("About to connect in gevent %s" % ((self.host, self.port),)) self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.settimeout(1.0) # TODO potentially make this value configurable self._socket.connect((self.host, self.port)) - log.debug("Did connect in gevent") self._read_watcher = gevent.spawn(lambda: self.handle_read()) self._write_watcher = gevent.spawn(lambda: self.handle_write()) From 1c0bfed338cd94e3d3fd2308dd50bd32a8a1ef22 Mon Sep 17 00:00:00 2001 From: Daniel Koepke Date: Tue, 1 Oct 2013 13:55:56 -0700 Subject: [PATCH 0404/4528] Clean-up and refactor. --- cassandra/io/geventreactor.py | 94 +++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 43 deletions(-) diff --git a/cassandra/io/geventreactor.py b/cassandra/io/geventreactor.py index 1d56bb771b..c19a27b6e0 100644 --- a/cassandra/io/geventreactor.py +++ b/cassandra/io/geventreactor.py @@ -3,13 +3,18 @@ from gevent import socket from gevent.event import Event -from collections import defaultdict, deque +from collections import defaultdict from functools import partial import logging import os import sys import traceback +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO # ignore flake8 warning: # NOQA + if 'gevent.monkey' in sys.modules: from gevent.queue import Queue else: @@ -18,7 +23,7 @@ from errno import EALREADY, EINPROGRESS, EWOULDBLOCK, EINVAL from cassandra.connection import (Connection, ResponseWaiter, ConnectionShutdown, - ConnectionBusy, NONBLOCKING) + ConnectionBusy) from cassandra.decoder import RegisterMessage from cassandra.marshal import int32_unpack @@ -38,7 +43,6 @@ class GeventConnection(Connection): An implementation of :class:`.Connection` that utilizes ``gevent``. """ - _buf = "" _total_reqd_bytes = 0 _read_watcher = None _write_watcher = None @@ -57,22 +61,21 @@ def __init__(self, *args, **kwargs): super(GeventConnection, self).__init__(*args, **kwargs) self.connected_event = Event() + self._iobuf = StringIO() + self._write_queue = Queue() self._callbacks = {} self._push_watchers = defaultdict(set) - self.deque = deque() self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._socket.settimeout(1.0) # TODO potentially make this value configurable self._socket.connect((self.host, self.port)) - self._read_watcher = gevent.spawn(lambda: self.handle_read()) - self._write_watcher = gevent.spawn(lambda: self.handle_write()) - if self.sockopts: for args in self.sockopts: self._socket.setsockopt(*args) + self._read_watcher = gevent.spawn(lambda: self.handle_read()) + self._write_watcher = gevent.spawn(lambda: self.handle_write()) self._send_options_message() def close(self): @@ -135,24 +138,18 @@ def handle_write(self): while True: try: + next_msg = self._write_queue.get() select.select((), wlist, ()) except Exception as err: - log.debug("Write loop got error %s" % err) + log.debug("Write loop: got error %s" % err) return - if self.deque: - next_msg = self.deque.popleft() - try: - self._socket.sendall(next_msg) - except socket.error as err: - if (err.args[0] in NONBLOCKING): - self.deque.appendleft(next_msg) - gevent.sleep(1.0) - else: - self.defunct(err) - return # Leave the write loop - else: - gevent.sleep(0.1) + try: + self._socket.sendall(next_msg) + except socket.error as err: + log.debug("Write loop: got error, defuncting socket and exiting") + self.defunct(err) + return # Leave the write loop def handle_read(self): rlist = (self._socket,) @@ -161,7 +158,6 @@ def handle_read(self): try: select.select(rlist, (), ()) except Exception as err: - log.debug("Read loop got error %s" % err) return try: @@ -172,26 +168,44 @@ def handle_read(self): return # leave the read loop if buf: - self._buf += buf + self._iobuf.write(buf) while True: - if len(self._buf) < 8: - # we don't have a complete header yet - break - elif self._total_reqd_bytes and len(self._buf) < self._total_reqd_bytes: - # we already saw a header, but we don't have a complete message yet + pos = self._iobuf.tell() + if pos < 8 or (self._total_reqd_bytes > 0 and pos < self._total_reqd_bytes): + # we don't have a complete header yet or we + # already saw a header, but we don't have a + # complete message yet break else: - body_len = int32_unpack(self._buf[4:8]) - if len(self._buf) - 8 >= body_len: - msg = self._buf[:8 + body_len] - self._buf = self._buf[8 + body_len:] + # have enough for header, read body len from header + self._iobuf.seek(4) + body_len_bytes = self._iobuf.read(4) + body_len = int32_unpack(body_len_bytes) + + # seek to end to get length of current buffer + self._iobuf.seek(0, os.SEEK_END) + pos = self._iobuf.tell() + + if pos - 8 >= body_len: + # read message header and body + self._iobuf.seek(0) + msg = self._iobuf.read(8 + body_len) + + # leave leftover in current buffer + leftover = self._iobuf.read() + self._iobuf = StringIO() + self._iobuf.write(leftover) + self._total_reqd_bytes = 0 self.process_msg(msg, body_len) else: self._total_reqd_bytes = body_len + 8 + break + else: + log.debug("connection closed by server") + self.close() def handle_pushed(self, response): - log.debug("Message pushed from server: %r", response) for cb in self._push_watchers.get(response.event_type, []): try: cb(response.event_args) @@ -199,15 +213,9 @@ def handle_pushed(self, response): log.exception("Pushed event handler errored, ignoring:") def push(self, data): - sabs = self.out_buffer_size - if len(data) > sabs: - chunks = [] - for i in xrange(0, len(data), sabs): - chunks.append(data[i:i + sabs]) - else: - chunks = [data] - - self.deque.extend(chunks) + chunk_size = self.out_buffer_size + for i in xrange(0, len(data), chunk_size): + self._write_queue.put(data[i:i+chunk_size]) def send_msg(self, msg, cb): if self.is_defunct: From 5003e608c6593e98b82479ae9db876fa0ef0eccc Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Wed, 2 Oct 2013 13:40:36 -0700 Subject: [PATCH 0405/4528] 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 5ff7c9c125248baf42f4600b678f317f36d3eed0 Mon Sep 17 00:00:00 2001 From: Daniel Koepke Date: Thu, 3 Oct 2013 10:45:21 -0700 Subject: [PATCH 0406/4528] Use gevent as default connection class when monkey patched. --- cassandra/cluster.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 77fd5d0316..b8b8296623 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -8,6 +8,7 @@ import time from threading import Lock, RLock, Thread, Event import Queue +import sys import weakref try: from weakref import WeakSet @@ -38,11 +39,15 @@ from cassandra.pool import (_ReconnectionHandler, _HostReconnectionHandler, HostConnectionPool) -# libev is all around faster, so we want to try and default to using that when we can -try: - from cassandra.io.libevreactor import LibevConnection as DefaultConnection -except ImportError: - from cassandra.io.asyncorereactor import AsyncoreConnection as DefaultConnection # NOQA +# default to gevent when we are monkey patched, otherwise if libev is available, use that as the +# default because it's faster than asyncore +if 'gevent.monkey' in sys.modules: + from cassandra.io.geventreactor import GeventConnection as DefaultConnection +else: + try: + from cassandra.io.libevreactor import LibevConnection as DefaultConnection + except ImportError: + from cassandra.io.asyncorereactor import AsyncoreConnection as DefaultConnection # NOQA # Forces load of utf8 encoding module to avoid deadlock that occurs # if code that is being imported tries to import the module in a seperate From 755fdaf1fb8ea022f6a45bbf3d0648451e7ee6b3 Mon Sep 17 00:00:00 2001 From: Daniel Koepke Date: Thu, 3 Oct 2013 10:45:30 -0700 Subject: [PATCH 0407/4528] Bring back the timeout. --- cassandra/io/geventreactor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cassandra/io/geventreactor.py b/cassandra/io/geventreactor.py index c19a27b6e0..0e0ef0f6fe 100644 --- a/cassandra/io/geventreactor.py +++ b/cassandra/io/geventreactor.py @@ -68,6 +68,7 @@ def __init__(self, *args, **kwargs): self._push_watchers = defaultdict(set) self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.settimeout(1.0) self._socket.connect((self.host, self.port)) if self.sockopts: From 10e4d4290405083979ff7d1ce272ed97efb6f759 Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Thu, 3 Oct 2013 16:22:34 -0700 Subject: [PATCH 0408/4528] 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 0409/4528] 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 0410/4528] 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 0411/4528] 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 0412/4528] 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 0413/4528] 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 0414/4528] 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 0415/4528] 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 0416/4528] 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 0417/4528] 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 efc854ebe8d718cfea59e124eb4778ff18e4bf3c Mon Sep 17 00:00:00 2001 From: Daniel Koepke Date: Wed, 23 Oct 2013 13:28:24 -0700 Subject: [PATCH 0418/4528] Small cleanup. --- cassandra/io/geventreactor.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/cassandra/io/geventreactor.py b/cassandra/io/geventreactor.py index 0e0ef0f6fe..e798343437 100644 --- a/cassandra/io/geventreactor.py +++ b/cassandra/io/geventreactor.py @@ -1,7 +1,7 @@ import gevent -from gevent import select -from gevent import socket +from gevent import select, socket from gevent.event import Event +from gevent.queue import Queue from collections import defaultdict from functools import partial @@ -15,11 +15,6 @@ except ImportError: from StringIO import StringIO # ignore flake8 warning: # NOQA -if 'gevent.monkey' in sys.modules: - from gevent.queue import Queue -else: - from Queue import Queue - from errno import EALREADY, EINPROGRESS, EWOULDBLOCK, EINVAL from cassandra.connection import (Connection, ResponseWaiter, ConnectionShutdown, From 72959da7db8a1fd479fbe8d1bea406ca49f1c8f1 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 23 Oct 2013 16:49:58 -0700 Subject: [PATCH 0419/4528] 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 0420/4528] 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 0421/4528] 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 0422/4528] 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 0423/4528] 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 0424/4528] 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 0425/4528] 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 0426/4528] 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 0427/4528] 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 0428/4528] 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 0429/4528] 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 0430/4528] 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 0431/4528] 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 0432/4528] 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 0433/4528] 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 0434/4528] 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 0435/4528] 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 0436/4528] 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 0437/4528] 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 0438/4528] 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 0439/4528] 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 0440/4528] 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 0441/4528] 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 0442/4528] 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 0443/4528] 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 0444/4528] 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 0445/4528] 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 0446/4528] 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 0447/4528] 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 0448/4528] 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 0449/4528] 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 0450/4528] 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 0451/4528] 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 0452/4528] 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 0453/4528] 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 0454/4528] 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 0455/4528] 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 0456/4528] 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 0457/4528] 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 0458/4528] 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 0459/4528] 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 0460/4528] 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 0461/4528] 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 0462/4528] 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 0463/4528] 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 0464/4528] 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 0465/4528] 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 0466/4528] 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 0467/4528] 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 0468/4528] 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 0469/4528] 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 0470/4528] 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 0471/4528] 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 0472/4528] 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 0473/4528] 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 0474/4528] 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 0475/4528] 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 0476/4528] 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 0477/4528] 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 0478/4528] 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 0479/4528] 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 0480/4528] 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 0481/4528] 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 0482/4528] 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 0483/4528] 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 0484/4528] 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 0485/4528] 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 0486/4528] 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 0487/4528] 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 0488/4528] 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 0489/4528] 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 0490/4528] 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 0491/4528] 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 0492/4528] 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 0493/4528] 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 0494/4528] 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 0495/4528] 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 0496/4528] 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 0497/4528] 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 0498/4528] 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 0499/4528] 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 0500/4528] 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 0501/4528] 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 0502/4528] 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 0503/4528] 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 0504/4528] 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 0505/4528] 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 0506/4528] 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 0507/4528] 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 0508/4528] 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 0509/4528] 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 0510/4528] 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 0511/4528] 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 0512/4528] 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 0513/4528] 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 0514/4528] 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 0515/4528] 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 40b3a5346eff9c785505da7c299b215288bcb675 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 31 Oct 2013 14:05:20 -0500 Subject: [PATCH 0516/4528] Handle failure to submit request after preparing After re-preparing an unrecognized prepared statement, the prepared statement will be resubmitted. The submission process was not being checked for failures, which could leave the ResponseFuture hanging endlessly. --- cassandra/cluster.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index f553661294..d7c9b76ec8 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1867,7 +1867,10 @@ def _execute_after_prepare(self, response): if response.kind == ResultMessage.KIND_PREPARED: # use self._query to re-use the same host and # at the same time properly borrow the connection - self._query(self._current_host) + request_id = self._query(self._current_host) + if request_id is None: + # this host errored out, move on to the next + self.send_request() else: self._set_final_exception(ConnectionException( "Got unexpected response when preparing statement " From 28c5cf04dbcf7ae322c3faa1b6298e26558d3144 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 31 Oct 2013 14:35:26 -0500 Subject: [PATCH 0517/4528] Handle failure to submit message when re-preparing Similar to the fix in 40b3a53, this could ignore failures when retrieving a connection from the pool or sending a message on that connection during repreparation of an unrecognized prepared statement. --- cassandra/cluster.py | 37 ++++++++++++++++-------------- tests/unit/test_response_future.py | 5 ++-- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index d7c9b76ec8..a2e72905fa 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1675,7 +1675,13 @@ def send_request(self): self._set_final_exception(NoHostAvailable( "Unable to complete the operation against any hosts", self._errors)) - def _query(self, host): + def _query(self, host, message=None, cb=None): + if message is None: + message = self.message + + if cb is None: + cb = self._set_result + pool = self.session._pools.get(host) if not pool: self._errors[host] = ConnectionException("Host has been marked down or removed") @@ -1684,21 +1690,11 @@ def _query(self, host): self._errors[host] = ConnectionException("Pool is shutdown") return None - return self._borrow_conn_and_send_message(host, pool, self.message, self._set_result) - - def _borrow_conn_and_send_message(self, host, pool, message, cb): - if cb is None: - cb = self._set_result - connection = None try: # TODO get connectTimeout from cluster settings connection = pool.borrow_connection(timeout=2.0) request_id = connection.send_msg(message, cb=cb) - self._current_host = host - self._current_pool = pool - self._connection = connection - return request_id except Exception as exc: log.debug("Error querying host %s", host, exc_info=True) self._errors[host] = exc @@ -1706,6 +1702,17 @@ def _borrow_conn_and_send_message(self, host, pool, message, cb): pool.return_connection(connection) return None + self._current_host = host + self._current_pool = pool + self._connection = connection + return request_id + + def _reprepare(self, prepare_message): + request_id = self._query(self._current_host, prepare_message, cb=self._execute_after_prepare) + if request_id is None: + # try to submit the original prepared statement on some other host + self.send_request() + def _set_result(self, response): try: if self._current_pool and self._connection: @@ -1769,7 +1776,7 @@ def _set_result(self, response): self._metrics.on_other_error() # need to retry against a different host here log.warn("Host %s is overloaded, retrying against a different " - "host" % (self._current_host)) + "host", self._current_host) self._retry(reuse_connection=False, consistency_level=None) return elif isinstance(response, IsBootstrappingErrorMessage): @@ -1800,11 +1807,7 @@ def _set_result(self, response): prepare_message = PrepareMessage(query=prepared_statement.query_string) # since this might block, run on the executor to avoid hanging # the event loop thread - self.session.submit(self._borrow_conn_and_send_message, - self._current_host, - self._current_pool, - prepare_message, - self._execute_after_prepare) + self.session.submit(self._reprepare, prepare_message) return else: if hasattr(response, 'to_exception'): diff --git a/tests/unit/test_response_future.py b/tests/unit/test_response_future.py index 928dcc30b8..ea05a86f88 100644 --- a/tests/unit/test_response_future.py +++ b/tests/unit/test_response_future.py @@ -366,8 +366,9 @@ def test_prepared_query_not_found(self): session.submit.assert_called_once() args, kwargs = session.submit.call_args - self.assertIsInstance(args[-2], PrepareMessage) - self.assertEquals(args[-2].query, "SELECT * FROM foobar") + self.assertEquals(rf._reprepare, args[-2]) + self.assertIsInstance(args[-1], PrepareMessage) + self.assertEquals(args[-1].query, "SELECT * FROM foobar") def test_prepared_query_not_found_bad_keyspace(self): session = self.make_session() From cabcc9b6aa2860cf04eeded660b4dc1b47f054eb Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 31 Oct 2013 14:44:10 -0500 Subject: [PATCH 0518/4528] Log at debug when re-preparing unrecognized stmts --- cassandra/cluster.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index a2e72905fa..a554aa3a6f 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1804,6 +1804,8 @@ def _set_result(self, response): (current_keyspace, prepared_keyspace))) return + log.debug("Re-preparing unrecognized prepared statement against host %s: %s", + self._current_host, prepared_statement.query_string) prepare_message = PrepareMessage(query=prepared_statement.query_string) # since this might block, run on the executor to avoid hanging # the event loop thread From ff70a5595b2d17d0bf2d5cd7344e37825d2353b8 Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 31 Oct 2013 21:23:05 -0700 Subject: [PATCH 0519/4528] 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 0520/4528] 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 0521/4528] 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 0522/4528] 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 0523/4528] 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 0524/4528] 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 0525/4528] 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 0526/4528] 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 0527/4528] 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 0528/4528] 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 0529/4528] 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 0530/4528] 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 0531/4528] 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 0532/4528] 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 0533/4528] 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 0534/4528] 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 0535/4528] 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 0536/4528] 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 0537/4528] 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 0538/4528] 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 0539/4528] 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 0540/4528] 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 0541/4528] 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 0542/4528] 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 0543/4528] 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 0544/4528] 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 0545/4528] 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 0546/4528] 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 46d1cc1316331d2b4145106a9b4bd73bf9ac969d Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Mon, 4 Nov 2013 15:33:19 -0600 Subject: [PATCH 0547/4528] After re-preparing stmts, run query in executor The query was being resubmitted on the event loop thread, which could cause a deadlock if there were not any connections available for use. --- cassandra/cluster.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index a554aa3a6f..a6079b6337 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1708,7 +1708,8 @@ def _query(self, host, message=None, cb=None): return request_id def _reprepare(self, prepare_message): - request_id = self._query(self._current_host, prepare_message, cb=self._execute_after_prepare) + cb = partial(self.session.submit, self._execute_after_prepare) + request_id = self._query(self._current_host, prepare_message, cb=cb) if request_id is None: # try to submit the original prepared statement on some other host self.send_request() From fa4b02920d8fce5acd782924f75a63ef045c2205 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Mon, 4 Nov 2013 16:08:28 -0600 Subject: [PATCH 0548/4528] Log when connection creation is actually started --- cassandra/pool.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cassandra/pool.py b/cassandra/pool.py index 3c28c3a0ee..e6d50311ea 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -347,6 +347,7 @@ def _add_conn_if_under_max(self): self.open_count += 1 + log.debug("Going to open new connection to host %s", self.host) try: conn = self._session.cluster.connection_factory(self.host.address) if self._session.keyspace: From a2ae4906a3c332e565234e846a2c56f7bb22f5c8 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Mon, 4 Nov 2013 16:19:52 -0600 Subject: [PATCH 0549/4528] Raise min trash interval to 10 seconds --- cassandra/pool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/pool.py b/cassandra/pool.py index e6d50311ea..aa591172eb 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -228,7 +228,7 @@ def on_exception(self, exc, next_delay): _MAX_SIMULTANEOUS_CREATION = 1 -_MIN_TRASH_INTERVAL = 5 +_MIN_TRASH_INTERVAL = 10 class HostConnectionPool(object): From dd84464991134d35732edd063c1417fdeb4003f4 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Mon, 4 Nov 2013 16:20:14 -0600 Subject: [PATCH 0550/4528] Check open conn count before submitting new creation task --- cassandra/pool.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cassandra/pool.py b/cassandra/pool.py index aa591172eb..cfc3c8c46b 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -322,6 +322,8 @@ def _maybe_spawn_new_connection(self): with self._lock: if self._scheduled_for_creation >= _MAX_SIMULTANEOUS_CREATION: return + if self.open_count >= self._session.cluster.get_max_connections_per_host(self.host_distance): + return self._scheduled_for_creation += 1 log.debug("Submitting task for creation of new Connection to %s", self.host) From 6d6fc66ba4e2e82ae64695f2929b33467ee70e40 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Mon, 4 Nov 2013 16:20:37 -0600 Subject: [PATCH 0551/4528] Include open count in pool state string --- cassandra/pool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/pool.py b/cassandra/pool.py index cfc3c8c46b..1c2a348740 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -543,4 +543,4 @@ def connection_finished_setting_keyspace(conn, error): def get_state(self): in_flights = ", ".join([str(c.in_flight) for c in self._connections]) - return "shutdown: %s, in_flights: %s" % (self.is_shutdown, in_flights) + return "shutdown: %s, open_count: %d, in_flights: %s" % (self.is_shutdown, self.open_count, in_flights) From 23968a67ef39d39670c65c803cac8aa66200cac3 Mon Sep 17 00:00:00 2001 From: Matt Robenolt Date: Tue, 5 Nov 2013 05:29:22 -0800 Subject: [PATCH 0552/4528] Don't recommend using sudo + pip --- README.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 91b2aca516..c95d8730cd 100644 --- a/README.rst +++ b/README.rst @@ -31,12 +31,12 @@ the instructions in the section below before installing the driver. Installation through pip is recommended:: - $ sudo pip install cassandra-driver + $ pip install cassandra-driver If you want to install manually, you can instead do:: - $ sudo pip install futures scales blist # install dependencies - $ sudo python setup.py install + $ pip install futures scales blist # install dependencies + $ python setup.py install C Extensions ^^^^^^^^^^^^ @@ -103,11 +103,11 @@ be used automatically. For lz4 support:: - sudo pip install lz4 + $ pip install lz4 For snappy support:: - sudo pip install python-snappy + $ pip install python-snappy (If using a Debian Linux derivative such as Ubuntu, it may be easier to just run ``apt-get install python-snappy``.) From 31f684bb91f3e0a41238c869a7c8fc11ebf21515 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 5 Nov 2013 12:15:11 -0600 Subject: [PATCH 0553/4528] Handle large messages through libev correctly This fixes a problem similar to what 880408d92c2c5 fixed for asyncore. --- cassandra/io/libevreactor.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index ac26e1e4ec..4526508952 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -217,14 +217,17 @@ def handle_write(self, watcher, revents): def handle_read(self, watcher, revents): try: - buf = self._socket.recv(self.in_buffer_size) + while True: + buf = self._socket.recv(self.in_buffer_size) + self._iobuf.write(buf) + if len(buf) < self.in_buffer_size: + break except socket.error as err: if err.args[0] not in NONBLOCKING: self.defunct(err) return - if buf: - self._iobuf.write(buf) + if self._iobuf.tell(): while True: pos = self._iobuf.tell() if pos < 8 or (self._total_reqd_bytes > 0 and pos < self._total_reqd_bytes): @@ -256,9 +259,6 @@ def handle_read(self, watcher, revents): else: self._total_reqd_bytes = body_len + 8 break - else: - log.debug("connection (%s) to host %s closed by server", id(self), self.host) - self.close() def handle_pushed(self, response): log.debug("Message pushed from server: %r", response) From bfcce337396a2d8163e16c1ccd8b4a16bcaa6f9e Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 5 Nov 2013 13:23:24 -0600 Subject: [PATCH 0554/4528] Mark hosts up at the right time when adding them Hosts outside of the intial set of contact points were not being marked up correctly when they were discovered and added to the cluster and sessions, resulting in them being skipped for statement preparation and other operations. --- cassandra/cluster.py | 32 ++++++++++++++++++++------------ cassandra/pool.py | 2 ++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index a6079b6337..fb2c9b7d13 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -612,7 +612,7 @@ def on_add(self, host): if self._is_shutdown: return - log.debug("Adding new host %s", host) + log.debug("Adding or renewing pools for new host %s and notifying listeners", host) self._prepare_all_queries(host) self.load_balancing_policy.on_add(host) @@ -634,28 +634,36 @@ def future_completed(future): if futures: return - # all futures have completed at this point + log.debug('All futures have completed for added host %s', host) + for exc in [f for f in futures_results if isinstance(f, Exception)]: log.error("Unexpected failure while adding node %s, will not mark up:", host, exc_info=exc) return if not all(futures_results): - log.debug("Connection pool could not be created, not marking node %s up:", host) + log.warn("Connection pool could not be created, not marking node %s up:", host) return - # mark the host as up and notify all listeners - host.set_up() - for listener in self.listeners: - listener.on_add(host) - - # see if there are any pools to add or remove now that the host is marked up - for session in self.sessions: - session.update_created_pools() + self._finalize_add(host) for session in self.sessions: - future = session.add_or_renew_host(host, is_host_addition=True) + future = session.add_or_renew_pool(host, is_host_addition=True) + futures.add(future) future.add_done_callback(future_completed) + if not futures: + self._finalize_add(host) + + def _finalize_add(self, host): + # mark the host as up and notify all listeners + host.set_up() + for listener in self.listeners: + listener.on_add(host) + + # see if there are any pools to add or remove now that the host is marked up + for session in self.sessions: + session.update_created_pools() + def on_remove(self, host): if self._is_shutdown: return diff --git a/cassandra/pool.py b/cassandra/pool.py index 1c2a348740..70345026f8 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -82,6 +82,8 @@ def set_location_info(self, datacenter, rack): self._rack = rack def set_up(self): + if not self.is_up: + log.debug("Host %s is now marked up", self.address) self.conviction_policy.reset() self.is_up = True From 4257aa859c0d447eb281edd310c6bde73146b613 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 5 Nov 2013 13:29:57 -0600 Subject: [PATCH 0555/4528] Don't ignored libev conns closed by server This bug was introduced by 31f684bb91f3e0a41238c869a7c8fc11ebf21515 --- cassandra/io/libevreactor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index 4526508952..6b7376e3dd 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -259,6 +259,9 @@ def handle_read(self, watcher, revents): else: self._total_reqd_bytes = body_len + 8 break + else: + log.debug("Connection %s closed by server", self) + self.close() def handle_pushed(self, response): log.debug("Message pushed from server: %r", response) From 96f57caea953b593bfb2f235eb2b837e7540dc6d Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 5 Nov 2013 13:32:07 -0600 Subject: [PATCH 0556/4528] Minor cleanup in metrics test --- tests/integration/test_metrics.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/integration/test_metrics.py b/tests/integration/test_metrics.py index b745637844..91004b5d77 100644 --- a/tests/integration/test_metrics.py +++ b/tests/integration/test_metrics.py @@ -3,7 +3,6 @@ from cassandra import ConsistencyLevel, WriteTimeout, Unavailable, ReadTimeout from cassandra.cluster import Cluster, NoHostAvailable -from cassandra.decoder import QueryMessage from tests.integration import get_node, get_cluster @@ -133,12 +132,10 @@ def test_other_error(self): # TODO: Bootstrapping or Overloaded cases pass - def test_ignore(self): # TODO: Look for ways to generate ignores pass - def test_retry(self): # TODO: Look for ways to generate retries pass From 01d1c94999cd8a28c4aac5ab1e1793f95923bf62 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 09:55:52 -0600 Subject: [PATCH 0557/4528] Define __str__ for Statement subclasses --- cassandra/query.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cassandra/query.py b/cassandra/query.py index 149270dc4c..1591f790b3 100644 --- a/cassandra/query.py +++ b/cassandra/query.py @@ -102,10 +102,11 @@ def __init__(self, query_string, *args, **kwargs): def query_string(self): return self._query_string - def __repr__(self): + def __str__(self): consistency = ConsistencyLevel.value_to_name[self.consistency_level] return (u'' % (self.query_string, consistency)) + __repr__ = __str__ class PreparedStatement(object): @@ -169,10 +170,11 @@ def bind(self, values): """ return BoundStatement(self).bind(values) - def __repr__(self): + def __str__(self): consistency = ConsistencyLevel.value_to_name[self.consistency_level] return (u'' % (self.query_string, consistency)) + __repr__ = __str__ class BoundStatement(Statement): @@ -267,10 +269,11 @@ def keyspace(self): else: return None - def __repr__(self): + def __str__(self): consistency = ConsistencyLevel.value_to_name[self.consistency_level] return (u'' % (self.prepared_statement.query_string, self.raw_values, consistency)) + __repr__ = __str__ class ValueSequence(object): From 4a779bef9a01a83650fcb6b5fa3f56014cf5c8c3 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 09:57:19 -0600 Subject: [PATCH 0558/4528] Better ResponseFuture __str/repr__ --- cassandra/cluster.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index fb2c9b7d13..dd700b1edb 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -2075,7 +2075,7 @@ def add_callbacks(self, callback, errback, self.add_errback(errback, *errback_args, **(errback_kwargs or {})) def __str__(self): - query = self.query.query_string + result = "(no result yet)" if self._final_result is _NO_RESULT_YET else self._final_result return "" \ - % (query, self._req_id, self._final_result, self._final_exception, self._current_host) + % (self.query, self._req_id, result, self._final_exception, self._current_host) __repr__ = __str__ From 4b55466ee2f8ba72f7ba8114fdfa87871381ac4c Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 11:04:48 -0600 Subject: [PATCH 0559/4528] Create conn pools in parallel when creating sesssions --- cassandra/cluster.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index dd700b1edb..ce1df37991 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -837,8 +837,12 @@ def __init__(self, cluster, hosts): self._load_balancer = cluster.load_balancing_policy self._metrics = cluster.metrics + # create connection pools in parallel + futures = [] for host in hosts: - future = self.add_or_renew_pool(host, is_host_addition=False) + futures.append(self.add_or_renew_pool(host, is_host_addition=False)) + + for future in futures: future.result() def execute(self, query, parameters=None, trace=False): From e2604e8ac9c9e9c9913e52d7a105b91607f80540 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 11:25:47 -0600 Subject: [PATCH 0560/4528] Minor comment in asyncore.handle_read() --- cassandra/io/asyncorereactor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index 3d3292014b..a1150e13dd 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -270,6 +270,7 @@ def handle_read(self): # complete message yet break else: + # have enough for header, read body len from header self._iobuf.seek(4) body_len = int32_unpack(self._iobuf.read(4)) From 854feba0049d0d338fe7933b15e9a6cbaedb9d4c Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 14:27:50 -0600 Subject: [PATCH 0561/4528] Expose LIBEV_ERROR in libevwrapper.c --- cassandra/io/libevwrapper.c | 1 + 1 file changed, 1 insertion(+) diff --git a/cassandra/io/libevwrapper.c b/cassandra/io/libevwrapper.c index 1dec4b3d55..1802e7441c 100644 --- a/cassandra/io/libevwrapper.c +++ b/cassandra/io/libevwrapper.c @@ -354,6 +354,7 @@ initlibevwrapper(void) m = Py_InitModule3("libevwrapper", module_methods, "libev wrapper methods"); PyModule_AddIntConstant(m, "EV_READ", EV_READ); PyModule_AddIntConstant(m, "EV_WRITE", EV_WRITE); + PyModule_AddIntConstant(m, "EV_ERROR", EV_ERROR); Py_INCREF(&libevwrapper_LoopType); PyModule_AddObject(m, "Loop", (PyObject *)&libevwrapper_LoopType); From 791d8133685690698bcdad8bc7f2da15267a7910 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 14:28:20 -0600 Subject: [PATCH 0562/4528] Whitespace cleanup in util.py --- cassandra/util.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cassandra/util.py b/cassandra/util.py index bbdb85087f..dfa1b0b891 100644 --- a/cassandra/util.py +++ b/cassandra/util.py @@ -167,6 +167,7 @@ def __exit__(self, e, t, b): class WeakSet(object): def __init__(self, data=None): self.data = set() + def _remove(item, selfref=ref(self)): self = selfref() if self is not None: @@ -174,6 +175,7 @@ def _remove(item, selfref=ref(self)): self._pending_removals.append(item) else: self.data.discard(item) + self._remove = _remove # A list of keys to be removed self._pending_removals = [] @@ -274,6 +276,7 @@ def difference_update(self, other): self.data.clear() else: self.data.difference_update(ref(item) for item in other) + def __isub__(self, other): if self._pending_removals: self._commit_removals() @@ -291,6 +294,7 @@ def intersection_update(self, other): if self._pending_removals: self._commit_removals() self.data.intersection_update(ref(item) for item in other) + def __iand__(self, other): if self._pending_removals: self._commit_removals() @@ -327,6 +331,7 @@ def symmetric_difference_update(self, other): self.data.clear() else: self.data.symmetric_difference_update(ref(item) for item in other) + def __ixor__(self, other): if self._pending_removals: self._commit_removals() @@ -341,4 +346,4 @@ def union(self, other): __or__ = union def isdisjoint(self, other): - return len(self.intersection(other)) == 0 \ No newline at end of file + return len(self.intersection(other)) == 0 From 77f09e6f6c362c2841f1e7636fb6cff0d80a120a Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 14:29:52 -0600 Subject: [PATCH 0563/4528] Ensure reconnector runs after failed host setup --- cassandra/cluster.py | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index ce1df37991..f2ce26b275 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -472,6 +472,8 @@ def _cleanup_failed_on_up_handling(self, host): for session in self.sessions: session.remove_pool(host) + self._start_reconnector(host, is_host_addition=False) + def _on_up_future_completed(self, host, futures, results, lock, finished_future): with lock: futures.discard(finished_future) @@ -564,6 +566,27 @@ def on_up(self, host): # for testing purposes return futures + def _start_reconnector(self, host, is_host_addition): + schedule = self.reconnection_policy.new_schedule() + + # in order to not hold references to this Cluster open and prevent + # proper shutdown when the program ends, we'll just make a closure + # of the current Cluster attributes to create new Connections with + conn_factory = self._make_connection_factory(host) + + reconnector = _HostReconnectionHandler( + host, conn_factory, is_host_addition, self.on_add, self.on_up, + self.scheduler, schedule, host.get_and_set_reconnection_handler, + new_handler=None) + + old_reconnector = host.get_and_set_reconnection_handler(reconnector) + if old_reconnector: + log.debug("Old host reconnector found for %s, cancelling", host) + old_reconnector.cancel() + + log.debug("Staring reconnector for host %s", host) + reconnector.start() + @run_in_executor def on_down(self, host, is_host_addition): """ @@ -588,25 +611,7 @@ def on_down(self, host, is_host_addition): for listener in self.listeners: listener.on_down(host) - schedule = self.reconnection_policy.new_schedule() - - # in order to not hold references to this Cluster open and prevent - # proper shutdown when the program ends, we'll just make a closure - # of the current Cluster attributes to create new Connections with - conn_factory = self._make_connection_factory(host) - - reconnector = _HostReconnectionHandler( - host, conn_factory, is_host_addition, self.on_add, self.on_up, - self.scheduler, schedule, host.get_and_set_reconnection_handler, - new_handler=None) - - old_reconnector = host.get_and_set_reconnection_handler(reconnector) - if old_reconnector: - log.debug("Old host reconnector found for %s, cancelling", host) - old_reconnector.cancel() - - log.debug("Staring reconnector for host %s", host) - reconnector.start() + self._start_reconnector(host, is_host_addition) def on_add(self, host): if self._is_shutdown: From 63c697d3b561bcf1b1e6a60aca61428d864c9b3f Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 14:30:50 -0600 Subject: [PATCH 0564/4528] Explicitly shutdown conn after prepared stmt failure --- cassandra/cluster.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index f2ce26b275..4f34e6146f 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -780,11 +780,12 @@ def _prepare_all_queries(self, host): log.debug("Got unexpected response when preparing " "statement on host %s: %r", host, response) - connection.close() log.debug("Done preparing all known prepared statements against host %s", host) except Exception: # log and ignore log.exception("Error trying to prepare all statements on host %s", host) + finally: + connection.close() def prepare_on_all_sessions(self, query_id, prepared_statement, excluded_host): self._prepared_statements[query_id] = prepared_statement From b3fd957ccd3fe67c080c466b465cbf4c047a47a8 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 14:31:43 -0600 Subject: [PATCH 0565/4528] Use WeakValueDictionary for prepared statements When nodes are added or come back up after being down, all known prepared statements are re-prepared on them. In order to avoid preparing statements that will never be used again, we'll use a dictionary of weak references to the statements so that once they are GC'ed, they are removed from our cache. --- cassandra/cluster.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 4f34e6146f..2e5ceb5afc 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -10,6 +10,7 @@ from threading import Lock, RLock, Thread, Event import Queue import weakref +from weakref import WeakValueDictionary try: from weakref import WeakSet except ImportError: @@ -34,10 +35,10 @@ from cassandra.policies import (RoundRobinPolicy, SimpleConvictionPolicy, ExponentialReconnectionPolicy, HostDistance, RetryPolicy) -from cassandra.query import (SimpleStatement, PreparedStatement, BoundStatement, - bind_params, QueryTrace, Statement) from cassandra.pool import (_ReconnectionHandler, _HostReconnectionHandler, HostConnectionPool) +from cassandra.query import (SimpleStatement, PreparedStatement, BoundStatement, + bind_params, QueryTrace, Statement) # libev is all around faster, so we want to try and default to using that when we can try: @@ -304,7 +305,7 @@ def __init__(self, self.sessions = WeakSet() self.metadata = Metadata(self) self.control_connection = None - self._prepared_statements = {} + self._prepared_statements = WeakValueDictionary() self._min_requests_per_connection = { HostDistance.LOCAL: DEFAULT_MIN_REQUESTS, From 05dfb46e8d14e8dc3abe6fb9a0c00fc02d67e308 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 14:33:24 -0600 Subject: [PATCH 0566/4528] Only log when host pool is actually removed --- cassandra/cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 2e5ceb5afc..aaeff9fc15 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1067,9 +1067,9 @@ def run_add_or_renew_pool(): return self.submit(run_add_or_renew_pool) def remove_pool(self, host): - log.debug("Removing connection pool for %r", host) pool = self._pools.pop(host, None) if pool: + log.debug("Removed connection pool for %r", host) return self.submit(pool.shutdown) else: return None From e918b84edcca4c544a72f4b9573ca83a0169a292 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 15:03:56 -0600 Subject: [PATCH 0567/4528] Add is_pending() for libev IO watchers --- cassandra/io/libevwrapper.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cassandra/io/libevwrapper.c b/cassandra/io/libevwrapper.c index 1802e7441c..c78712b574 100644 --- a/cassandra/io/libevwrapper.c +++ b/cassandra/io/libevwrapper.c @@ -189,10 +189,16 @@ IO_is_active(libevwrapper_IO *self) { return PyBool_FromLong(ev_is_active(&self->io)); } +static PyObject* +IO_is_pending(libevwrapper_IO *self) { + return PyBool_FromLong(ev_is_pending(&self->io)); +} + static PyMethodDef IO_methods[] = { {"start", (PyCFunction)IO_start, METH_NOARGS, "Start the watcher"}, {"stop", (PyCFunction)IO_stop, METH_NOARGS, "Stop the watcher"}, {"is_active", (PyCFunction)IO_is_active, METH_NOARGS, "Is the watcher active?"}, + {"is_pending", (PyCFunction)IO_is_pending, METH_NOARGS, "Is the watcher pending?"}, {NULL} /* Sentinal */ }; From 232b74e7a312863545b0ef67ee4113a01ac56b64 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 15:12:17 -0600 Subject: [PATCH 0568/4528] Libev performance, safety, and error handling improvements This commit does several things: * check for LIBEV_ERROR flags on read and write notifications. (Typically socket.recv() or socket.send() will error correctly, but it's safer to also check for this flag in multithreaded environments.) * Use a separate lock for maniuplating the queue of pending messages to be sent * Notify the event loop of watcher changes more frequently in order to avoid waiting to execute pending events --- cassandra/io/libevreactor.py | 73 ++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index 6b7376e3dd..5aa52f9ef7 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -114,6 +114,7 @@ def __init__(self, *args, **kwargs): self._callbacks = {} self._push_watchers = defaultdict(set) self.deque = deque() + self._deque_lock = Lock() self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if self.ssl_options: @@ -149,10 +150,11 @@ def close(self): self.is_closed = True log.debug("Closing connection (%s) to %s", id(self), self.host) - if self._read_watcher: - self._read_watcher.stop() - if self._write_watcher: - self._write_watcher.stop() + with _loop_lock: + if self._read_watcher: + self._read_watcher.stop() + if self._write_watcher: + self._write_watcher.stop() self._socket.close() with _loop_lock: _loop_notifier.send() @@ -194,28 +196,49 @@ def _error_all_callbacks(self, exc): id(self), self.host, exc_info=True) def handle_write(self, watcher, revents): - try: - next_msg = self.deque.popleft() - except IndexError: - self._write_watcher.stop() + if revents & libev.EV_ERROR: + self.defunct(Exception("lbev reported an error")) return - try: - sent = self._socket.send(next_msg) - except socket.error as err: - if (err.args[0] in NONBLOCKING): - self.deque.appendleft(next_msg) - else: - self.defunct(err) - return - else: - if sent < len(next_msg): - self.deque.appendleft(next_msg[sent:]) + while True: + try: + with self._deque_lock: + next_msg = self.deque.popleft() + except IndexError: + with self._deque_lock: + if not self.deque: + with _loop_lock: + if self._write_watcher.is_active(): + self._write_watcher.stop() + return - if not self.deque: - self._write_watcher.stop() + try: + sent = self._socket.send(next_msg) + except socket.error as err: + if (err.args[0] in NONBLOCKING): + with self._deque_lock: + self.deque.appendleft(next_msg) + _loop_notifier.send() + else: + self.defunct(err) + return + else: + if sent < len(next_msg): + with self._deque_lock: + self.deque.appendleft(next_msg[sent:]) + _loop_notifier.send() + elif not self.deque: + with self._deque_lock: + if not self.deque: + with _loop_lock: + if self._write_watcher.is_active(): + self._write_watcher.stop() + return def handle_read(self, watcher, revents): + if revents & libev.EV_ERROR: + self.defunct(Exception("lbev reported an error")) + return try: while True: buf = self._socket.recv(self.in_buffer_size) @@ -280,13 +303,13 @@ def push(self, data): else: chunks = [data] - with self.lock: + with self._deque_lock: self.deque.extend(chunks) + with _loop_lock: if not self._write_watcher.is_active(): - with _loop_lock: - self._write_watcher.start() - _loop_notifier.send() + self._write_watcher.start() + _loop_notifier.send() def send_msg(self, msg, cb, wait_for_id=False): if self.is_defunct: From 70329fe3b012b071559b96a7fe1a150474c2d5c6 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 15:23:09 -0600 Subject: [PATCH 0569/4528] Update unit tests for libev changes --- tests/unit/io/test_libevreactor.py | 57 ++++++++++++++---------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/tests/unit/io/test_libevreactor.py b/tests/unit/io/test_libevreactor.py index b46f175b4a..da9098d63b 100644 --- a/tests/unit/io/test_libevreactor.py +++ b/tests/unit/io/test_libevreactor.py @@ -62,20 +62,20 @@ def test_successful_connection(self, *args): c = self.make_connection() # let it write the OptionsMessage - c.handle_write(None, None) + c.handle_write(None, 0) # read in a SupportedMessage response header = self.make_header_prefix(SupportedMessage) options = self.make_options_body() c._socket.recv.return_value = self.make_msg(header, options) - c.handle_read(None, None) + c.handle_read(None, 0) # let it write out a StartupMessage - c.handle_write(None, None) + c.handle_write(None, 0) header = self.make_header_prefix(ReadyMessage, stream_id=1) c._socket.recv.return_value = self.make_msg(header) - c.handle_read(None, None) + c.handle_read(None, 0) self.assertTrue(c.connected_event.is_set()) @@ -83,13 +83,13 @@ def test_protocol_error(self, *args): c = self.make_connection() # let it write the OptionsMessage - c.handle_write(None, None) + c.handle_write(None, 0) # read in a SupportedMessage response header = self.make_header_prefix(SupportedMessage, version=0xa4) options = self.make_options_body() c._socket.recv.return_value = self.make_msg(header, options) - c.handle_read(None, None) + c.handle_read(None, 0) # make sure it errored correctly self.assertTrue(c.is_defunct) @@ -100,21 +100,21 @@ def test_error_message_on_startup(self, *args): c = self.make_connection() # let it write the OptionsMessage - c.handle_write(None, None) + c.handle_write(None, 0) # read in a SupportedMessage response header = self.make_header_prefix(SupportedMessage) options = self.make_options_body() c._socket.recv.return_value = self.make_msg(header, options) - c.handle_read(None, None) + c.handle_read(None, 0) # let it write out a StartupMessage - c.handle_write(None, None) + c.handle_write(None, 0) header = self.make_header_prefix(ServerError, stream_id=1) body = self.make_error_body(ServerError.error_code, ServerError.summary) c._socket.recv.return_value = self.make_msg(header, body) - c.handle_read(None, None) + c.handle_read(None, 0) # make sure it errored correctly self.assertTrue(c.is_defunct) @@ -126,7 +126,7 @@ def test_socket_error_on_write(self, *args): # make the OptionsMessage write fail c._socket.send.side_effect = socket_error(errno.EIO, "bad stuff!") - c.handle_write(None, None) + c.handle_write(None, 0) # make sure it errored correctly self.assertTrue(c.is_defunct) @@ -138,13 +138,13 @@ def test_blocking_on_write(self, *args): # make the OptionsMessage write block c._socket.send.side_effect = socket_error(errno.EAGAIN, "socket busy") - c.handle_write(None, None) + c.handle_write(None, 0) self.assertFalse(c.is_defunct) # try again with normal behavior c._socket.send.side_effect = lambda x: len(x) - c.handle_write(None, None) + c.handle_write(None, 0) self.assertFalse(c.is_defunct) self.assertTrue(c._socket.send.call_args is not None) @@ -154,26 +154,21 @@ def test_partial_send(self, *args): # only write the first four bytes of the OptionsMessage c._socket.send.side_effect = None c._socket.send.return_value = 4 - c.handle_write(None, None) + c.handle_write(None, 0) - orig_msg = c._socket.send.call_args[0][0] self.assertFalse(c.is_defunct) - - # try again with normal behavior - c._socket.send.side_effect = lambda x: len(x) - c.handle_write(None, None) - self.assertFalse(c.is_defunct) - self.assertEqual(c._socket.send.call_args[0][0], orig_msg[4:]) + self.assertEqual(2, c._socket.send.call_count) + self.assertEqual(4, len(c._socket.send.call_args[0][0])) def test_socket_error_on_read(self, *args): c = self.make_connection() # let it write the OptionsMessage - c.handle_write(None, None) + c.handle_write(None, 0) # read in a SupportedMessage response c._socket.recv.side_effect = socket_error(errno.EIO, "busy socket") - c.handle_read(None, None) + c.handle_read(None, 0) # make sure it errored correctly self.assertTrue(c.is_defunct) @@ -189,19 +184,19 @@ def test_partial_header_read(self, *args): # read in the first byte c._socket.recv.return_value = message[0] - c.handle_read(None, None) + c.handle_read(None, 0) self.assertEquals(c._iobuf.getvalue(), message[0]) c._socket.recv.return_value = message[1:] - c.handle_read(None, None) + c.handle_read(None, 0) self.assertEquals("", c._iobuf.getvalue()) # let it write out a StartupMessage - c.handle_write(None, None) + c.handle_write(None, 0) header = self.make_header_prefix(ReadyMessage, stream_id=1) c._socket.recv.return_value = self.make_msg(header) - c.handle_read(None, None) + c.handle_read(None, 0) self.assertTrue(c.connected_event.is_set()) self.assertFalse(c.is_defunct) @@ -215,20 +210,20 @@ def test_partial_message_read(self, *args): # read in the first nine bytes c._socket.recv.return_value = message[:9] - c.handle_read(None, None) + c.handle_read(None, 0) self.assertEquals(c._iobuf.getvalue(), message[:9]) # ... then read in the rest c._socket.recv.return_value = message[9:] - c.handle_read(None, None) + c.handle_read(None, 0) self.assertEquals("", c._iobuf.getvalue()) # let it write out a StartupMessage - c.handle_write(None, None) + c.handle_write(None, 0) header = self.make_header_prefix(ReadyMessage, stream_id=1) c._socket.recv.return_value = self.make_msg(header) - c.handle_read(None, None) + c.handle_read(None, 0) self.assertTrue(c.connected_event.is_set()) self.assertFalse(c.is_defunct) From 9c766f9b658babe03a8a2a5b55b09880484433d8 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 15:25:42 -0600 Subject: [PATCH 0570/4528] Specify version in install instructions Earlier beta versions (eg 1.0.0-beta4) used a non-PEP426 compliant version which happens to sort higher than the compliant version (eg 1.0.0b6), so to ensure the correct version is installed, the version must explicitly be specified. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c95d8730cd..7d57f54d5a 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ the instructions in the section below before installing the driver. Installation through pip is recommended:: - $ pip install cassandra-driver + $ pip install cassandra-driver==1.0.0b6 If you want to install manually, you can instead do:: From 6ed86099ecb4844d01c5253cad6c1311fde82b81 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 6 Nov 2013 15:42:53 -0600 Subject: [PATCH 0571/4528] Timeout schema agreement check queries --- cassandra/cluster.py | 12 ++++++++++-- tests/unit/test_control_connection.py | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index aaeff9fc15..f79062c08b 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1489,10 +1489,18 @@ def wait_for_schema_agreement(self, connection=None): start = self._time.time() elapsed = 0 cl = ConsistencyLevel.ONE - while elapsed < self._cluster.max_schema_agreement_wait: + total_timeout = self._cluster.max_schema_agreement_wait + while elapsed < total_timeout: peers_query = QueryMessage(query=self._SELECT_SCHEMA_PEERS, consistency_level=cl) local_query = QueryMessage(query=self._SELECT_SCHEMA_LOCAL, consistency_level=cl) - peers_result, local_result = connection.wait_for_responses(peers_query, local_query) + try: + timeout = min(2.0, total_timeout - elapsed) + peers_result, local_result = connection.wait_for_responses(peers_query, local_query, timeout=timeout) + except OperationTimedOut: + log.debug("[control connection] Timed out waiting for response during schema agreement check") + elapsed = self._time.time() - start + continue + peers_result = dict_factory(*peers_result.results) versions = set() diff --git a/tests/unit/test_control_connection.py b/tests/unit/test_control_connection.py index 6b820842bc..3c59bb6c92 100644 --- a/tests/unit/test_control_connection.py +++ b/tests/unit/test_control_connection.py @@ -87,7 +87,7 @@ def __init__(self): ["192.168.1.2", "10.0.0.2", "a", "dc1", "rack1", ["2", "102", "202"]]] ] - def wait_for_responses(self, peer_query, local_query): + def wait_for_responses(self, peer_query, local_query, timeout=None): local_response = ResultMessage( kind=ResultMessage.KIND_ROWS, results=self.local_results) peer_response = ResultMessage( From 8210bacac77c33cd6cfc7038e3a8a74f14789fea Mon Sep 17 00:00:00 2001 From: Dvir Volk Date: Tue, 12 Nov 2013 13:03:02 +0200 Subject: [PATCH 0572/4528] 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 30758a30ba2ddcdb4578a7cb3d77a9b263e5feb8 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 12 Nov 2013 13:49:53 -0600 Subject: [PATCH 0573/4528] Update changelog, bump to version 1.0.0b7 --- CHANGELOG.rst | 61 +++++++++++++++++++++++++++++++++++++++++++ cassandra/__init__.py | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4e38d22866..b8e41fb271 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,64 @@ +1.0.0b7 +======= +Nov 12, 2013 + +This release makes many stability improvements, especially around +prepared statements and node failure handling. In particular, +several cases where a request would never be completed (and as a +result, leave the application hanging) have been resolved. + +Features +-------- +* Add `timeout` kwarg to ``ResponseFuture.result()`` +* Create connection pools to all hosts in parallel when initializing + new Sesssions. + +Bug Fixes +--------- +* Properly set exception on ResponseFuture when a query fails + against all hosts +* Improved cleanup and reconnection efforts when reconnection fails + on a node that has recently come up +* Use correct consistency level on when retrying failed operations + against a different host. (An invalid consistency level was being + used, causing the retry to fail.) +* Better error messages for failed prepare() opertaions +* Prepare new statements against all hosts in parallel (formerly + sequential) +* Fix failure to save the new current keyspace on connections. (This + could cause problems for prepared statements and lead to extra + operations to continuously re-set the keyspace) +* Avoid sharing LoadBalancingPolicies across Cluster instances. (When a second + Cluster was connected, it effectively mark nodes down for the first Cluster.) +* Better handling of failures during the re-preparation sequence for unrecognized + prepared statements +* Throttle trashing of underutilized connections to avoid trashing newly + created connections +* Fix race condition which could result in trashed connections being closed + before the last operations had completed +* Avoid preparing statements on the event loop thread (which could lead to + deadlock) +* Correctly mark up non-contact point nodes discovered by the control + connection. (This lead to prepared statements not being prepared + against those hosts, generating extra traffic later when the + statements were executed and unrecognized.) +* Correctly handle large messages through libev +* Add timeout to schema agreement check queries +* More complete (and less contended) locking around manipulation of the + pending message deque for libev connections + +Other +----- +* Prepare statements in batches of 10. (When many prepared statements + are in use, this allows the driver to start utilizing nodes that + were restarted more quickly.) +* Better debug logging around connection management +* Don't retain unreferenced prepared statements in the local cache. + (If many different prepared statements were created, this would + increase memory usage and greatly increase the amount of time + required to being utilizing a node that was added or marked + up.) + 1.0.0b6 ======= Oct 22, 2013 diff --git a/cassandra/__init__.py b/cassandra/__init__.py index 952ec15fbb..d8fee386e5 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -1,4 +1,4 @@ -__version_info__ = (1, 0, '0b6', 'post') +__version_info__ = (1, 0, '0b7') __version__ = '.'.join(map(str, __version_info__)) From 06263d5c264af1b56f9a774881297cf63d315810 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 12 Nov 2013 13:52:01 -0600 Subject: [PATCH 0574/4528] Bump version to 1.0.0b7.post for post-release --- cassandra/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/__init__.py b/cassandra/__init__.py index d8fee386e5..37d70bd630 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -1,4 +1,4 @@ -__version_info__ = (1, 0, '0b7') +__version_info__ = (1, 0, '0b7', 'post') __version__ = '.'.join(map(str, __version_info__)) From 1ba840346df0cff9f1e112ca50b171617e8f5b4e Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 12 Nov 2013 13:59:19 -0600 Subject: [PATCH 0575/4528] Remove version-specific install instructions from readme --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 7d57f54d5a..c95d8730cd 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ the instructions in the section below before installing the driver. Installation through pip is recommended:: - $ pip install cassandra-driver==1.0.0b6 + $ pip install cassandra-driver If you want to install manually, you can instead do:: From 778612e6ddcd79c2afe58d4f35a4ebab08555762 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 12 Nov 2013 15:31:50 -0600 Subject: [PATCH 0576/4528] Fix changelog typos --- CHANGELOG.rst | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b8e41fb271..62023c284d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -19,19 +19,20 @@ Bug Fixes against all hosts * Improved cleanup and reconnection efforts when reconnection fails on a node that has recently come up -* Use correct consistency level on when retrying failed operations +* Use correct consistency level when retrying failed operations against a different host. (An invalid consistency level was being used, causing the retry to fail.) -* Better error messages for failed prepare() opertaions +* Better error messages for failed ``Session.prepare()`` opertaions * Prepare new statements against all hosts in parallel (formerly sequential) * Fix failure to save the new current keyspace on connections. (This could cause problems for prepared statements and lead to extra - operations to continuously re-set the keyspace) -* Avoid sharing LoadBalancingPolicies across Cluster instances. (When a second - Cluster was connected, it effectively mark nodes down for the first Cluster.) -* Better handling of failures during the re-preparation sequence for unrecognized - prepared statements + operations to continuously re-set the keyspace.) +* Avoid sharing ``LoadBalancingPolicies`` across ``Cluster`` instances. (When + a second ``Cluster`` was connected, it effectively mark nodes down for the + first ``Cluster``.) +* Better handling of failures during the re-preparation sequence for + unrecognized prepared statements * Throttle trashing of underutilized connections to avoid trashing newly created connections * Fix race condition which could result in trashed connections being closed @@ -56,7 +57,7 @@ Other * Don't retain unreferenced prepared statements in the local cache. (If many different prepared statements were created, this would increase memory usage and greatly increase the amount of time - required to being utilizing a node that was added or marked + required to begin utilizing a node that was added or marked up.) 1.0.0b6 From 9ad6229c49e21af65580e70d90f35f765fc32223 Mon Sep 17 00:00:00 2001 From: Michael Penick Date: Wed, 13 Nov 2013 08:10:38 -0700 Subject: [PATCH 0577/4528] Added an fake entry to the scheduled queue to prevent thread leak --- cassandra/cluster.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index f79062c08b..a78fca66f4 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1593,6 +1593,7 @@ def shutdown(self): # this can happen on interpreter shutdown pass self.is_shutdown = True + self._scheduled.put_nowait((None, None)) def schedule(self, delay, fn, *args, **kwargs): if not self.is_shutdown: From e473c007d374c1198c5dcb1669cd019f9fd5f3ad Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 13 Nov 2013 12:26:37 -0600 Subject: [PATCH 0578/4528] Sanitize column names for named_tuple_factory Fixes PYTHON-31 --- cassandra/decoder.py | 38 ++++++++++++++++---------------------- tests/unit/test_types.py | 16 ++++++++++++---- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/cassandra/decoder.py b/cassandra/decoder.py index cae889576f..5e770fb54a 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -1,31 +1,17 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from binascii import hexlify from collections import namedtuple -try: - from collections import OrderedDict -except ImportError: # Python <2.7 - from cassandra.util import OrderedDict # NOQA - import datetime import logging +import re import socket import types from uuid import UUID + +try: + from collections import OrderedDict +except ImportError: # Python <2.7 + from cassandra.util import OrderedDict # NOQA + try: from cStringIO import StringIO except ImportError: @@ -60,12 +46,20 @@ class InternalError(Exception): HEADER_DIRECTION_MASK = 0x80 +NON_ALPHA_REGEX = re.compile('\W') +END_UNDERSCORE_REGEX = re.compile('^_*(\w*[a-zA-Z0-9])_*$') + + +def _clean_column_name(name): + return END_UNDERSCORE_REGEX.sub("\g<1>", NON_ALPHA_REGEX.sub("_", name)) + + def tuple_factory(colnames, rows): return rows def named_tuple_factory(colnames, rows): - Row = namedtuple('Row', colnames) + Row = namedtuple('Row', map(_clean_column_name, colnames)) return [Row(*row) for row in rows] diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 0a781e742e..2c65131ae2 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -1,10 +1,9 @@ import unittest import datetime import cassandra -from cassandra.cqltypes import CassandraType, BooleanType, lookup_casstype_simple, lookup_casstype, \ - AsciiType, LongType, DecimalType, DoubleType, FloatType, Int32Type, UTF8Type, IntegerType, SetType, cql_typename - -from cassandra.cluster import Cluster +from cassandra.cqltypes import (BooleanType, lookup_casstype_simple, lookup_casstype, + LongType, DecimalType, SetType, cql_typename) +from cassandra.decoder import named_tuple_factory class TypeTests(unittest.TestCase): @@ -105,3 +104,12 @@ def test_cql_typename(self): self.assertEqual(cql_typename('DateType'), 'timestamp') self.assertEqual(cql_typename('org.apache.cassandra.db.marshal.ListType(IntegerType)'), 'list') + + def test_named_tuple_colname_substitution(self): + colnames = ("func(abc)", "[applied]", "func(func(abc))", "foo_bar") + rows = [(1, 2, 3, 4)] + result = named_tuple_factory(colnames, rows)[0] + self.assertEqual(result[0], result.func_abc) + self.assertEqual(result[1], result.applied) + self.assertEqual(result[2], result.func_func_abc) + self.assertEqual(result[3], result.foo_bar) From bad3866d11b11980acd2215152c06e8bb009f5da Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 15 Nov 2013 12:50:12 -0600 Subject: [PATCH 0579/4528] s/checksum/digest/ in RetryPolicy docs --- cassandra/policies.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cassandra/policies.py b/cassandra/policies.py index 0e250487fe..15a7194a1f 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -517,13 +517,13 @@ def on_read_timeout(self, query, consistency, required_responses, how many replicas needed to respond to meet the requested consistency level and how many actually did respond before the coordinator timed out the request. `data_retrieved` is a boolean indicating whether - any of those responses contained data (as opposed to just a checksum). + any of those responses contained data (as opposed to just a digest). `retry_num` counts how many times the operation has been retried, so the first time this method is called, `retry_num` will be 0. By default, operations will be retried at most once, and only if - a sufficient number of replicas responded (with checksums). + a sufficient number of replicas responded (with data digests). """ if retry_num != 0: return (self.RETHROW, None) From 380e739c81c023463d1d3d550ac95691c145f447 Mon Sep 17 00:00:00 2001 From: Michael Penick Date: Mon, 18 Nov 2013 15:55:40 -0700 Subject: [PATCH 0580/4528] Not checking None result --- cassandra/cluster.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index a78fca66f4..73aac336d6 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -847,7 +847,9 @@ def __init__(self, cluster, hosts): # create connection pools in parallel futures = [] for host in hosts: - futures.append(self.add_or_renew_pool(host, is_host_addition=False)) + future = self.add_or_renew_pool(host, is_host_addition=False); + if future is not None: + futures.append(future) for future in futures: future.result() From 4d666384bb469e20fdbdeec148b212ed34e02115 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 21 Nov 2013 17:48:26 -0600 Subject: [PATCH 0581/4528] Correctly handle ignored hosts everywhere Session.add_or_renew_pool returns None instead of a future when a host is ignored. --- cassandra/cluster.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 73aac336d6..c4d33ac937 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -552,8 +552,9 @@ def on_up(self, host): callback = partial(self._on_up_future_completed, host, futures, futures_results, futures_lock) for session in self.sessions: future = session.add_or_renew_pool(host, is_host_addition=False) - future.add_done_callback(callback) - futures.add(future) + if future is not None: + future.add_done_callback(callback) + futures.add(future) except Exception: # this shouldn't happen, but just in case, reset the condition for future in futures: @@ -654,8 +655,9 @@ def future_completed(future): for session in self.sessions: future = session.add_or_renew_pool(host, is_host_addition=True) - futures.add(future) - future.add_done_callback(future_completed) + if future is not None: + futures.add(future) + future.add_done_callback(future_completed) if not futures: self._finalize_add(host) @@ -847,7 +849,7 @@ def __init__(self, cluster, hosts): # create connection pools in parallel futures = [] for host in hosts: - future = self.add_or_renew_pool(host, is_host_addition=False); + future = self.add_or_renew_pool(host, is_host_addition=False) if future is not None: futures.append(future) From 56c11961e66af7aff8df9fc9ec74a9fea323c169 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 22 Nov 2013 13:28:59 -0600 Subject: [PATCH 0582/4528] Use Py_ssize_t for murmur3 key len --- cassandra/murmur3.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cassandra/murmur3.c b/cassandra/murmur3.c index 0e3d553a9a..3617819d8c 100644 --- a/cassandra/murmur3.c +++ b/cassandra/murmur3.c @@ -9,6 +9,7 @@ * */ +#define PY_SSIZE_T_CLEAN 1 #include #include @@ -172,7 +173,7 @@ static PyObject * murmur3(PyObject *self, PyObject *args) { const char *key; - int *len; + Py_ssize_t len; uint32_t seed = 0; if (!PyArg_ParseTuple(args, "s#|I", &key, &len, &seed)) { From 011f6258be550a81acfaca7ed6c6401ac9cedbd9 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 22 Nov 2013 13:32:46 -0600 Subject: [PATCH 0583/4528] libev C extension fixes from static analysis I used the gcc-python-plugin's gcc-with-cpychecker tool as described here: http://gcc-python-plugin.readthedocs.org/en/latest/cpychecker.html#gcc-with-cpychecker Note that the checker does still have some bugs, so it will raise errors with the current code. --- cassandra/io/libevwrapper.c | 42 ++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/cassandra/io/libevwrapper.c b/cassandra/io/libevwrapper.c index c78712b574..51d62543be 100644 --- a/cassandra/io/libevwrapper.c +++ b/cassandra/io/libevwrapper.c @@ -20,6 +20,7 @@ Loop_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { self->loop = ev_default_loop(0); if (!self->loop) { PyErr_SetString(PyExc_Exception, "Error getting default ev loop"); + Py_DECREF(self); return NULL; } } @@ -36,7 +37,7 @@ Loop_init(libevwrapper_Loop *self, PyObject *args, PyObject *kwds) { }; static PyObject * -Loop_start(libevwrapper_Loop *self) { +Loop_start(libevwrapper_Loop *self, PyObject *args) { Py_BEGIN_ALLOW_THREADS ev_run(self->loop, 0); Py_END_ALLOW_THREADS @@ -44,7 +45,7 @@ Loop_start(libevwrapper_Loop *self) { }; static PyObject * -Loop_unref(libevwrapper_Loop *self) { +Loop_unref(libevwrapper_Loop *self, PyObject *args) { ev_unref(self->loop); Py_RETURN_NONE; } @@ -138,7 +139,7 @@ static int IO_init(libevwrapper_IO *self, PyObject *args, PyObject *kwds) { PyObject *socket; PyObject *callback; - libevwrapper_Loop *loop; + PyObject *loop; int io_flags = 0; if (!PyArg_ParseTuple(args, "OiOO", &socket, &io_flags, &loop, &callback)) { @@ -173,24 +174,24 @@ IO_init(libevwrapper_IO *self, PyObject *args, PyObject *kwds) { } static PyObject* -IO_start(libevwrapper_IO *self) { +IO_start(libevwrapper_IO *self, PyObject *args) { ev_io_start(self->loop->loop, &self->io); Py_RETURN_NONE; } static PyObject* -IO_stop(libevwrapper_IO *self) { +IO_stop(libevwrapper_IO *self, PyObject *args) { ev_io_stop(self->loop->loop, &self->io); Py_RETURN_NONE; } static PyObject* -IO_is_active(libevwrapper_IO *self) { +IO_is_active(libevwrapper_IO *self, PyObject *args) { return PyBool_FromLong(ev_is_active(&self->io)); } static PyObject* -IO_is_pending(libevwrapper_IO *self) { +IO_is_pending(libevwrapper_IO *self, PyObject *args) { return PyBool_FromLong(ev_is_pending(&self->io)); } @@ -257,7 +258,7 @@ static void async_callback(EV_P_ ev_async *watcher, int revents) {}; static int Async_init(libevwrapper_Async *self, PyObject *args, PyObject *kwds) { - libevwrapper_Loop *loop; + PyObject *loop; static char *kwlist[] = {"loop", NULL}; if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", kwlist, &loop)) { @@ -267,7 +268,7 @@ Async_init(libevwrapper_Async *self, PyObject *args, PyObject *kwds) { if (loop) { Py_INCREF(loop); - self->loop = loop; + self->loop = (libevwrapper_Loop *)loop; } else { return -1; } @@ -276,13 +277,13 @@ Async_init(libevwrapper_Async *self, PyObject *args, PyObject *kwds) { }; static PyObject * -Async_start(libevwrapper_Async *self) { +Async_start(libevwrapper_Async *self, PyObject *args) { ev_async_start(self->loop->loop, &self->async); Py_RETURN_NONE; } static PyObject * -Async_send(libevwrapper_Async *self) { +Async_send(libevwrapper_Async *self, PyObject *args) { ev_async_send(self->loop->loop, &self->async); Py_RETURN_NONE; }; @@ -358,18 +359,25 @@ initlibevwrapper(void) return; m = Py_InitModule3("libevwrapper", module_methods, "libev wrapper methods"); - PyModule_AddIntConstant(m, "EV_READ", EV_READ); - PyModule_AddIntConstant(m, "EV_WRITE", EV_WRITE); - PyModule_AddIntConstant(m, "EV_ERROR", EV_ERROR); + + if (PyModule_AddIntConstant(m, "EV_READ", EV_READ) == -1) + return; + if (PyModule_AddIntConstant(m, "EV_WRITE", EV_WRITE) == -1) + return; + if (PyModule_AddIntConstant(m, "EV_ERROR", EV_ERROR) == -1) + return; Py_INCREF(&libevwrapper_LoopType); - PyModule_AddObject(m, "Loop", (PyObject *)&libevwrapper_LoopType); + if (PyModule_AddObject(m, "Loop", (PyObject *)&libevwrapper_LoopType) == -1) + return; Py_INCREF(&libevwrapper_IOType); - PyModule_AddObject(m, "IO", (PyObject *)&libevwrapper_IOType); + if (PyModule_AddObject(m, "IO", (PyObject *)&libevwrapper_IOType) == -1) + return; Py_INCREF(&libevwrapper_AsyncType); - PyModule_AddObject(m, "Async", (PyObject *)&libevwrapper_AsyncType); + if (PyModule_AddObject(m, "Async", (PyObject *)&libevwrapper_AsyncType) == -1) + return; if (!PyEval_ThreadsInitialized()) { PyEval_InitThreads(); From d6e36ab788ac96f8a23b069e7ac0243d4aaec026 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 22 Nov 2013 15:10:41 -0600 Subject: [PATCH 0584/4528] Add options to benchmark scripts --- benchmarks/base.py | 67 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/benchmarks/base.py b/benchmarks/base.py index 832c6b65c2..b5f523445a 100644 --- a/benchmarks/base.py +++ b/benchmarks/base.py @@ -2,6 +2,9 @@ import os.path import sys import time +from optparse import OptionParser +from greplin import scales + dirname = os.path.dirname(os.path.abspath(__file__)) sys.path.append(dirname) sys.path.append(os.path.join(dirname, '..')) @@ -16,16 +19,17 @@ handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")) log.addHandler(handler) +have_libev = False supported_reactors = [AsyncoreConnection] try: from cassandra.io.libevreactor import LibevConnection + have_libev = True supported_reactors.append(LibevConnection) except ImportError, exc: log.warning("Not benchmarking libev reactor: %s" % (exc,)) KEYSPACE = "testkeyspace" TABLE = "testtable" -NUM_QUERIES = 10000 def setup(): @@ -63,11 +67,12 @@ def teardown(): def benchmark(run_fn): - for conn_class in supported_reactors: + options, args = parse_options() + for conn_class in options.supported_reactors: setup() log.info("==== %s ====" % (conn_class.__name__,)) - cluster = Cluster(['127.0.0.1']) + cluster = Cluster(options.hosts, metrics_enabled=options.enable_metrics) cluster.connection_class = conn_class session = cluster.connect(KEYSPACE) @@ -83,11 +88,63 @@ def benchmark(run_fn): log.debug("Beginning inserts...") start = time.time() try: - run_fn(session, query, values, NUM_QUERIES) + run_fn(session, query, values, options.num_ops) end = time.time() finally: teardown() total = end - start log.info("Total time: %0.2fs" % total) - log.info("Average throughput: %0.2f/sec" % (NUM_QUERIES / total)) + log.info("Average throughput: %0.2f/sec" % (options.num_ops / total)) + if options.enable_metrics: + stats = scales.getStats()['cassandra'] + log.info("Connection errors: %d", stats['connection_errors']) + log.info("Write timeouts: %d", stats['write_timeouts']) + log.info("Read timeouts: %d", stats['read_timeouts']) + log.info("Unavailables: %d", stats['unavailables']) + log.info("Other errors: %d", stats['other_errors']) + log.info("Retries: %d", stats['retries']) + + request_timer = stats['request_timer'] + log.info("Request latencies:") + log.info(" min: %0.4fs", request_timer['min']) + log.info(" max: %0.4fs", request_timer['max']) + log.info(" mean: %0.4fs", request_timer['mean']) + log.info(" stddev: %0.4fs", request_timer['stddev']) + log.info(" median: %0.4fs", request_timer['median']) + log.info(" 75th: %0.4fs", request_timer['75percentile']) + log.info(" 95th: %0.4fs", request_timer['95percentile']) + log.info(" 98th: %0.4fs", request_timer['98percentile']) + log.info(" 99th: %0.4fs", request_timer['99percentile']) + log.info(" 99.9th: %0.4fs", request_timer['999percentile']) + + +def parse_options(): + parser = OptionParser() + parser.add_option('-H', '--hosts', default='127.0.0.1', + help='cassandra hosts to connect to (comma-separated list) [default: %default]') + parser.add_option('-t', '--threads', type='int', default=1, + help='number of threads [default: %default]') + parser.add_option('-n', '--num-ops', type='int', default=10000, + help='number of operations [default: %default]') + parser.add_option('--asyncore-only', action='store_true', dest='asyncore_only', + help='only benchmark with asyncore connections') + parser.add_option('--libev-only', action='store_true', dest='libev_only', + help='only benchmark with libev connections') + parser.add_option('-m', '--metrics', action='store_true', dest='enable_metrics', + help='enable and print metrics for operations') + options, args = parser.parse_args() + + options.hosts = options.hosts.split(',') + + if options.libev_only: + if not have_libev: + log.error("libev is not available") + sys.exit(1) + options.supported_reactors = [LibevConnection] + elif options.asyncore_only: + options.supported_reactors = [AsyncoreConnection] + else: + options.supported_reactors = supported_reactors + + return options, args From 6e42fd7bd93a2a7c59459ad3b6e7f9f8944bdf2a Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 22 Nov 2013 16:01:32 -0600 Subject: [PATCH 0585/4528] Add multithreading support to benchmarks --- benchmarks/base.py | 22 ++++--- benchmarks/callback_full_pipeline.py | 62 +++++++++++++++++++ benchmarks/future_batches.py | 49 +++++++++++++++ benchmarks/future_full_pipeline.py | 45 ++++++++++++++ benchmarks/future_full_throttle.py | 37 +++++++++++ .../single_thread_callback_full_pipeline.py | 38 ------------ benchmarks/single_thread_future_batches.py | 32 ---------- .../single_thread_future_full_pipeline.py | 28 --------- .../single_thread_future_full_throttle.py | 20 ------ benchmarks/single_thread_sync.py | 8 --- benchmarks/sync.py | 27 ++++++++ 11 files changed, 234 insertions(+), 134 deletions(-) create mode 100644 benchmarks/callback_full_pipeline.py create mode 100644 benchmarks/future_batches.py create mode 100644 benchmarks/future_full_pipeline.py create mode 100644 benchmarks/future_full_throttle.py delete mode 100644 benchmarks/single_thread_callback_full_pipeline.py delete mode 100644 benchmarks/single_thread_future_batches.py delete mode 100644 benchmarks/single_thread_future_full_pipeline.py delete mode 100644 benchmarks/single_thread_future_full_throttle.py delete mode 100644 benchmarks/single_thread_sync.py create mode 100644 benchmarks/sync.py diff --git a/benchmarks/base.py b/benchmarks/base.py index b5f523445a..ffcc0598ee 100644 --- a/benchmarks/base.py +++ b/benchmarks/base.py @@ -11,10 +11,10 @@ from cassandra.cluster import Cluster from cassandra.io.asyncorereactor import AsyncoreConnection +from cassandra.policies import HostDistance from cassandra.query import SimpleStatement log = logging.getLogger() -log.setLevel('INFO') handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")) log.addHandler(handler) @@ -31,9 +31,10 @@ KEYSPACE = "testkeyspace" TABLE = "testtable" -def setup(): +def setup(hosts): - cluster = Cluster(['127.0.0.1']) + cluster = Cluster(hosts) + cluster.set_core_connections_per_host(HostDistance.LOCAL, 1) session = cluster.connect() rows = session.execute("SELECT keyspace_name FROM system.schema_keyspaces") @@ -60,8 +61,9 @@ def setup(): ) """ % TABLE) -def teardown(): - cluster = Cluster(['127.0.0.1']) +def teardown(hosts): + cluster = Cluster(hosts) + cluster.set_core_connections_per_host(HostDistance.LOCAL, 1) session = cluster.connect() session.execute("DROP KEYSPACE " + KEYSPACE) @@ -69,7 +71,7 @@ def teardown(): def benchmark(run_fn): options, args = parse_options() for conn_class in options.supported_reactors: - setup() + setup(options.hosts) log.info("==== %s ====" % (conn_class.__name__,)) cluster = Cluster(options.hosts, metrics_enabled=options.enable_metrics) @@ -88,10 +90,10 @@ def benchmark(run_fn): log.debug("Beginning inserts...") start = time.time() try: - run_fn(session, query, values, options.num_ops) + run_fn(session, query, values, options.num_ops, options.threads) end = time.time() finally: - teardown() + teardown(options.hosts) total = end - start log.info("Total time: %0.2fs" % total) @@ -133,10 +135,14 @@ def parse_options(): help='only benchmark with libev connections') parser.add_option('-m', '--metrics', action='store_true', dest='enable_metrics', help='enable and print metrics for operations') + parser.add_option('-l', '--log-level', default='info', + help='logging level: debug, info, warning, or error') options, args = parser.parse_args() options.hosts = options.hosts.split(',') + log.setLevel(options.log_level.upper()) + if options.libev_only: if not have_libev: log.error("libev is not available") diff --git a/benchmarks/callback_full_pipeline.py b/benchmarks/callback_full_pipeline.py new file mode 100644 index 0000000000..0048e6e88c --- /dev/null +++ b/benchmarks/callback_full_pipeline.py @@ -0,0 +1,62 @@ +from base import benchmark + +import logging +from itertools import count +from threading import Event, Thread + +log = logging.getLogger(__name__) + +initial = object() + +class Runner(Thread): + + def __init__(self, session, query, values, num_queries, *args, **kwargs): + self.session = session + self.query = query + self.values = values + self.num_queries = num_queries + self.num_started = count() + self.num_finished = count() + self.event = Event() + Thread.__init__(self) + + def handle_error(self, exc): + log.error("Error on insert: %r", exc) + + def insert_next(self, previous_result): + current_num = self.num_started.next() + + if previous_result is not initial: + num = next(self.num_finished) + if num >= self.num_queries: + self.event.set() + + if current_num <= self.num_queries: + future = self.session.execute_async(self.query, self.values) + future.add_callbacks(self.insert_next, self.handle_error) + + def run(self): + for i in range(120): + self.insert_next(initial) + + self.event.wait() + +def execute(session, query, values, num_queries, num_threads): + + per_thread = num_queries / num_threads + threads = [] + for i in range(num_threads): + thread = Runner(session, query, values, per_thread) + thread.daemon = True + threads.append(thread) + + for thread in threads: + thread.start() + + for thread in threads: + while thread.is_alive(): + thread.join(timeout=0.5) + + +if __name__ == "__main__": + benchmark(execute) diff --git a/benchmarks/future_batches.py b/benchmarks/future_batches.py new file mode 100644 index 0000000000..32ab8c03d6 --- /dev/null +++ b/benchmarks/future_batches.py @@ -0,0 +1,49 @@ +import logging +import Queue +from threading import Thread + +from base import benchmark + +log = logging.getLogger(__name__) + +def execute(session, query, values, num_queries, num_threads): + + per_thread = num_queries / num_threads + + def run(): + futures = Queue.Queue(maxsize=121) + + for i in range(per_thread): + if i > 0 and i % 120 == 0: + # clear the existing queue + while True: + try: + futures.get_nowait().result() + except Queue.Empty: + break + + future = session.execute_async(query, values) + futures.put_nowait(future) + + while True: + try: + futures.get_nowait().result() + except Queue.Empty: + break + + threads = [] + for i in range(num_threads): + thread = Thread(target=run) + thread.daemon = True + threads.append(thread) + + for thread in threads: + thread.start() + + for thread in threads: + while thread.is_alive(): + thread.join(timeout=0.5) + + +if __name__ == "__main__": + benchmark(execute) diff --git a/benchmarks/future_full_pipeline.py b/benchmarks/future_full_pipeline.py new file mode 100644 index 0000000000..54c54f909c --- /dev/null +++ b/benchmarks/future_full_pipeline.py @@ -0,0 +1,45 @@ +import logging +import Queue +from threading import Thread + +from base import benchmark + +log = logging.getLogger(__name__) + +def execute(session, query, values, num_queries, num_threads): + + per_thread = num_queries / num_threads + + def run(): + futures = Queue.Queue(maxsize=121) + + for i in range(per_thread): + if i >= 120: + old_future = futures.get_nowait() + old_future.result() + + future = session.execute_async(query, values) + futures.put_nowait(future) + + while True: + try: + futures.get_nowait().result() + except Queue.Empty: + break + + threads = [] + for i in range(num_threads): + thread = Thread(target=run) + thread.daemon = True + threads.append(thread) + + for thread in threads: + thread.start() + + for thread in threads: + while thread.is_alive(): + thread.join(timeout=0.5) + + +if __name__ == "__main__": + benchmark(execute) diff --git a/benchmarks/future_full_throttle.py b/benchmarks/future_full_throttle.py new file mode 100644 index 0000000000..fcba63fc40 --- /dev/null +++ b/benchmarks/future_full_throttle.py @@ -0,0 +1,37 @@ +import logging +from threading import Thread + +from base import benchmark + +log = logging.getLogger(__name__) + +def execute(session, query, values, num_queries, num_threads): + + per_thread = num_queries / num_threads + + def run(): + futures = [] + + for i in range(per_thread): + future = session.execute_async(query, values) + futures.append(future) + + for future in futures: + future.result() + + threads = [] + for i in range(num_threads): + thread = Thread(target=run) + thread.daemon = True + threads.append(thread) + + for thread in threads: + thread.start() + + for thread in threads: + while thread.is_alive(): + thread.join(timeout=0.5) + + +if __name__ == "__main__": + benchmark(execute) diff --git a/benchmarks/single_thread_callback_full_pipeline.py b/benchmarks/single_thread_callback_full_pipeline.py deleted file mode 100644 index 248a90e93c..0000000000 --- a/benchmarks/single_thread_callback_full_pipeline.py +++ /dev/null @@ -1,38 +0,0 @@ -from base import benchmark - -import logging -from itertools import count -from threading import Event - -log = logging.getLogger(__name__) - -initial = object() - -def execute(session, query, values, num_queries): - - num_started = count() - num_finished = count() - event = Event() - - def handle_error(exc): - log.error("Error on insert: %r", exc) - - def insert_next(previous_result): - current_num = num_started.next() - - if previous_result is not initial: - num = next(num_finished) - if num >= num_queries: - event.set() - - if current_num <= num_queries: - future = session.execute_async(query, values) - future.add_callbacks(insert_next, handle_error) - - for i in range(120): - insert_next(initial) - - event.wait() - -if __name__ == "__main__": - benchmark(execute) diff --git a/benchmarks/single_thread_future_batches.py b/benchmarks/single_thread_future_batches.py deleted file mode 100644 index 8396916ad0..0000000000 --- a/benchmarks/single_thread_future_batches.py +++ /dev/null @@ -1,32 +0,0 @@ -from base import benchmark - -import logging -import Queue - -log = logging.getLogger(__name__) - -def execute(session, query, values, num_queries): - - futures = Queue.Queue(maxsize=121) - - for i in range(num_queries): - if i > 0 and i % 120 == 0: - # clear the existing queue - while True: - try: - futures.get_nowait().result() - except Queue.Empty: - break - - future = session.execute_async(query, values) - futures.put_nowait(future) - - while True: - try: - futures.get_nowait().result() - except Queue.Empty: - break - - -if __name__ == "__main__": - benchmark(execute) diff --git a/benchmarks/single_thread_future_full_pipeline.py b/benchmarks/single_thread_future_full_pipeline.py deleted file mode 100644 index a4b11ebcf8..0000000000 --- a/benchmarks/single_thread_future_full_pipeline.py +++ /dev/null @@ -1,28 +0,0 @@ -from base import benchmark - -import logging -import Queue - -log = logging.getLogger(__name__) - -def execute(session, query, values, num_queries): - - futures = Queue.Queue(maxsize=121) - - for i in range(num_queries): - if i >= 120: - old_future = futures.get_nowait() - old_future.result() - - future = session.execute_async(query, values) - futures.put_nowait(future) - - while True: - try: - futures.get_nowait().result() - except Queue.Empty: - break - - -if __name__ == "__main__": - benchmark(execute) diff --git a/benchmarks/single_thread_future_full_throttle.py b/benchmarks/single_thread_future_full_throttle.py deleted file mode 100644 index 18484dac6b..0000000000 --- a/benchmarks/single_thread_future_full_throttle.py +++ /dev/null @@ -1,20 +0,0 @@ -from base import benchmark - -import logging - -log = logging.getLogger(__name__) - -def execute(session, query, values, num_queries): - - futures = [] - - for i in range(num_queries): - future = session.execute_async(query, values) - futures.append(future) - - for future in futures: - future.result() - - -if __name__ == "__main__": - benchmark(execute) diff --git a/benchmarks/single_thread_sync.py b/benchmarks/single_thread_sync.py deleted file mode 100644 index 3fd0cf4894..0000000000 --- a/benchmarks/single_thread_sync.py +++ /dev/null @@ -1,8 +0,0 @@ -from base import benchmark - -def execute(session, query, values, num_queries): - for i in xrange(num_queries): - session.execute(query, values) - -if __name__ == "__main__": - benchmark(execute) diff --git a/benchmarks/sync.py b/benchmarks/sync.py new file mode 100644 index 0000000000..2702bd0e73 --- /dev/null +++ b/benchmarks/sync.py @@ -0,0 +1,27 @@ +from threading import Thread + +from base import benchmark + +def execute(session, query, values, num_queries, num_threads): + + per_thread = num_queries / num_threads + + def run(): + for i in xrange(per_thread): + session.execute(query, values) + + threads = [] + for i in range(num_threads): + thread = Thread(target=run) + thread.daemon = True + threads.append(thread) + + for thread in threads: + thread.start() + + for thread in threads: + while thread.is_alive(): + thread.join(timeout=0.5) + +if __name__ == "__main__": + benchmark(execute) From b9f6817f03ec25361123907c282550298cb9430c Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 22 Nov 2013 17:25:48 -0600 Subject: [PATCH 0586/4528] Less work while holding connection lock --- cassandra/pool.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cassandra/pool.py b/cassandra/pool.py index 70345026f8..d3a4aa9168 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -299,25 +299,25 @@ def borrow_connection(self, timeout): # trashing it (through the return_connection process), hold # the connection lock from this point until we've incremented # its in_flight count + need_to_wait = False with least_busy.lock: - # if we have too many requests on this connection but we still - # have space to open a new connection against this host, go ahead - # and schedule the creation of a new connection - if least_busy.in_flight >= max_reqs and len(self._connections) < max_conns: - self._maybe_spawn_new_connection() - if least_busy.in_flight >= MAX_STREAM_PER_CONNECTION: # once we release the lock, wait for another connection need_to_wait = True else: - need_to_wait = False least_busy.in_flight += 1 if need_to_wait: # wait_for_conn will increment in_flight on the conn least_busy = self._wait_for_conn(timeout) + # if we have too many requests on this connection but we still + # have space to open a new connection against this host, go ahead + # and schedule the creation of a new connection + if least_busy.in_flight >= max_reqs and len(self._connections) < max_conns: + self._maybe_spawn_new_connection() + return least_busy def _maybe_spawn_new_connection(self): From 95537a7eb9b992c9e3f6648a18ad144561829e3b Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 22 Nov 2013 17:31:23 -0600 Subject: [PATCH 0587/4528] Use separate lock for asyncore deque --- cassandra/io/asyncorereactor.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index a1150e13dd..ee0005ae94 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -112,6 +112,7 @@ def __init__(self, *args, **kwargs): self._callbacks = {} self._push_watchers = defaultdict(set) self.deque = deque() + self.deque_lock = Lock() with _starting_conns_lock: _starting_conns.add(self) @@ -236,16 +237,20 @@ def handle_write(self): sent = self.send(next_msg) except socket.error as err: if (err.args[0] in NONBLOCKING): - self.deque.appendleft(next_msg) + with self.deque_lock: + self.deque.appendleft(next_msg) else: self.defunct(err) return else: if sent < len(next_msg): - self.deque.appendleft(next_msg[sent:]) + with self.deque_lock: + self.deque.appendleft(next_msg[sent:]) if not self.deque: - self._writable = False + with self.deque_lock: + if not self.deque: + self._writable = False self._readable = True @@ -314,7 +319,7 @@ def push(self, data): else: chunks = [data] - with self.lock: + with self.deque_lock: self.deque.extend(chunks) self._writable = True From f01df9ad48abcb04bc5b05f11c6fc9fce63acc92 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 22 Nov 2013 18:00:09 -0600 Subject: [PATCH 0588/4528] Easy profiling of benchmarks --- benchmarks/base.py | 45 ++++++++++++++++++++++++++-- benchmarks/callback_full_pipeline.py | 38 +++++++---------------- benchmarks/future_batches.py | 30 ++++++------------- benchmarks/future_full_pipeline.py | 30 ++++++------------- benchmarks/future_full_throttle.py | 30 ++++++------------- benchmarks/sync.py | 28 +++++------------ 6 files changed, 89 insertions(+), 112 deletions(-) diff --git a/benchmarks/base.py b/benchmarks/base.py index ffcc0598ee..9dd9c40201 100644 --- a/benchmarks/base.py +++ b/benchmarks/base.py @@ -1,8 +1,11 @@ +from cProfile import Profile import logging import os.path import sys +from threading import Thread import time from optparse import OptionParser + from greplin import scales dirname = os.path.dirname(os.path.abspath(__file__)) @@ -68,7 +71,7 @@ def teardown(hosts): session.execute("DROP KEYSPACE " + KEYSPACE) -def benchmark(run_fn): +def benchmark(thread_class): options, args = parse_options() for conn_class in options.supported_reactors: setup(options.hosts) @@ -87,10 +90,24 @@ def benchmark(run_fn): """.format(table=TABLE)) values = {'key': 'key', 'a': 'a', 'b': 'b'} + per_thread = options.num_ops / options.threads + threads = [] + log.debug("Beginning inserts...") start = time.time() try: - run_fn(session, query, values, options.num_ops, options.threads) + for i in range(options.threads): + thread = thread_class(i, session, query, values, per_thread, options.profile) + thread.daemon = True + threads.append(thread) + + for thread in threads: + thread.start() + + for thread in threads: + while thread.is_alive(): + thread.join(timeout=0.5) + end = time.time() finally: teardown(options.hosts) @@ -137,6 +154,9 @@ def parse_options(): help='enable and print metrics for operations') parser.add_option('-l', '--log-level', default='info', help='logging level: debug, info, warning, or error') + parser.add_option('-p', '--profile', action='store_true', dest='profile', + help='Profile the run') + options, args = parser.parse_args() options.hosts = options.hosts.split(',') @@ -154,3 +174,24 @@ def parse_options(): options.supported_reactors = supported_reactors return options, args + + +class BenchmarkThread(Thread): + + def __init__(self, thread_num, session, query, values, num_queries, profile): + Thread.__init__(self) + self.thread_num = thread_num + self.session = session + self.query = query + self.values = values + self.num_queries = num_queries + self.profiler = Profile() if profile else None + + def start_profile(self): + if self.profiler: + self.profiler.enable() + + def finish_profile(self): + if self.profiler: + self.profiler.disable() + self.profiler.dump_stats('profile-%d' % self.thread_num) diff --git a/benchmarks/callback_full_pipeline.py b/benchmarks/callback_full_pipeline.py index 0048e6e88c..3faab2224b 100644 --- a/benchmarks/callback_full_pipeline.py +++ b/benchmarks/callback_full_pipeline.py @@ -1,24 +1,20 @@ -from base import benchmark - -import logging from itertools import count -from threading import Event, Thread +import logging +from threading import Event + +from base import benchmark, BenchmarkThread log = logging.getLogger(__name__) initial = object() -class Runner(Thread): +class Runner(BenchmarkThread): - def __init__(self, session, query, values, num_queries, *args, **kwargs): - self.session = session - self.query = query - self.values = values - self.num_queries = num_queries + def __init__(self, *args, **kwargs): + BenchmarkThread.__init__(self, *args, **kwargs) self.num_started = count() self.num_finished = count() self.event = Event() - Thread.__init__(self) def handle_error(self, exc): log.error("Error on insert: %r", exc) @@ -36,27 +32,15 @@ def insert_next(self, previous_result): future.add_callbacks(self.insert_next, self.handle_error) def run(self): + self.start_profile() + for i in range(120): self.insert_next(initial) self.event.wait() -def execute(session, query, values, num_queries, num_threads): - - per_thread = num_queries / num_threads - threads = [] - for i in range(num_threads): - thread = Runner(session, query, values, per_thread) - thread.daemon = True - threads.append(thread) - - for thread in threads: - thread.start() - - for thread in threads: - while thread.is_alive(): - thread.join(timeout=0.5) + self.finish_profile() if __name__ == "__main__": - benchmark(execute) + benchmark(Runner) diff --git a/benchmarks/future_batches.py b/benchmarks/future_batches.py index 32ab8c03d6..34f9d569c3 100644 --- a/benchmarks/future_batches.py +++ b/benchmarks/future_batches.py @@ -1,19 +1,18 @@ import logging import Queue -from threading import Thread -from base import benchmark +from base import benchmark, BenchmarkThread log = logging.getLogger(__name__) -def execute(session, query, values, num_queries, num_threads): +class Runner(BenchmarkThread): - per_thread = num_queries / num_threads - - def run(): + def run(self): futures = Queue.Queue(maxsize=121) - for i in range(per_thread): + self.start_profile() + + for i in range(self.num_queries): if i > 0 and i % 120 == 0: # clear the existing queue while True: @@ -22,7 +21,7 @@ def run(): except Queue.Empty: break - future = session.execute_async(query, values) + future = self.session.execute_async(self.query, self.values) futures.put_nowait(future) while True: @@ -31,19 +30,8 @@ def run(): except Queue.Empty: break - threads = [] - for i in range(num_threads): - thread = Thread(target=run) - thread.daemon = True - threads.append(thread) - - for thread in threads: - thread.start() - - for thread in threads: - while thread.is_alive(): - thread.join(timeout=0.5) + self.finish_profile() if __name__ == "__main__": - benchmark(execute) + benchmark(Runner) diff --git a/benchmarks/future_full_pipeline.py b/benchmarks/future_full_pipeline.py index 54c54f909c..843e34a859 100644 --- a/benchmarks/future_full_pipeline.py +++ b/benchmarks/future_full_pipeline.py @@ -1,24 +1,23 @@ import logging import Queue -from threading import Thread -from base import benchmark +from base import benchmark, BenchmarkThread log = logging.getLogger(__name__) -def execute(session, query, values, num_queries, num_threads): +class Runner(BenchmarkThread): - per_thread = num_queries / num_threads - - def run(): + def run(self): futures = Queue.Queue(maxsize=121) - for i in range(per_thread): + self.start_profile() + + for i in range(self.num_queries): if i >= 120: old_future = futures.get_nowait() old_future.result() - future = session.execute_async(query, values) + future = self.session.execute_async(self.query, self.values) futures.put_nowait(future) while True: @@ -27,19 +26,8 @@ def run(): except Queue.Empty: break - threads = [] - for i in range(num_threads): - thread = Thread(target=run) - thread.daemon = True - threads.append(thread) - - for thread in threads: - thread.start() - - for thread in threads: - while thread.is_alive(): - thread.join(timeout=0.5) + self.finish_profile if __name__ == "__main__": - benchmark(execute) + benchmark(Runner) diff --git a/benchmarks/future_full_throttle.py b/benchmarks/future_full_throttle.py index fcba63fc40..684244d2d7 100644 --- a/benchmarks/future_full_throttle.py +++ b/benchmarks/future_full_throttle.py @@ -1,37 +1,25 @@ import logging -from threading import Thread -from base import benchmark +from base import benchmark, BenchmarkThread log = logging.getLogger(__name__) -def execute(session, query, values, num_queries, num_threads): +class Runner(BenchmarkThread): - per_thread = num_queries / num_threads - - def run(): + def run(self): futures = [] - for i in range(per_thread): - future = session.execute_async(query, values) + self.start_profile() + + for i in range(self.num_queries): + future = self.session.execute_async(self.query, self.values) futures.append(future) for future in futures: future.result() - threads = [] - for i in range(num_threads): - thread = Thread(target=run) - thread.daemon = True - threads.append(thread) - - for thread in threads: - thread.start() - - for thread in threads: - while thread.is_alive(): - thread.join(timeout=0.5) + self.finish_profile() if __name__ == "__main__": - benchmark(execute) + benchmark(Runner) diff --git a/benchmarks/sync.py b/benchmarks/sync.py index 2702bd0e73..c6e91ba6e0 100644 --- a/benchmarks/sync.py +++ b/benchmarks/sync.py @@ -1,27 +1,15 @@ -from threading import Thread +from base import benchmark, BenchmarkThread -from base import benchmark +class Runner(BenchmarkThread): -def execute(session, query, values, num_queries, num_threads): + def run(self): + self.start_profile() - per_thread = num_queries / num_threads + for i in xrange(self.num_queries): + self.session.execute(self.query, self.values) - def run(): - for i in xrange(per_thread): - session.execute(query, values) + self.finish_profile() - threads = [] - for i in range(num_threads): - thread = Thread(target=run) - thread.daemon = True - threads.append(thread) - - for thread in threads: - thread.start() - - for thread in threads: - while thread.is_alive(): - thread.join(timeout=0.5) if __name__ == "__main__": - benchmark(execute) + benchmark(Runner) From 3c5501f127063ac29480317dfdf65fd82b837e63 Mon Sep 17 00:00:00 2001 From: barvinograd Date: Sat, 23 Nov 2013 20:32:29 +0200 Subject: [PATCH 0589/4528] remove asyncore DeprecationWarning --- cassandra/io/asyncorereactor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index ee0005ae94..5c61bef32c 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -153,7 +153,7 @@ def connect(self, address): raise ConnectionException("Timed out connecting to %s" % (address[0])) if err in (0, EISCONN): self.addr = address - self.setblocking(0) + self.socket.setblocking(0) self.handle_connect_event() else: raise socket.error(err, errorcode[err]) From a080aff3a73351d37126b14eef606061b445aa37 Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Fri, 7 Jun 2013 19:18:07 +0300 Subject: [PATCH 0590/4528] 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 0591/4528] 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 0592/4528] 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 0593/4528] 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 0594/4528] 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 0595/4528] 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 e2d430da788c5a1c898fa130d74509fed25806f7 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 26 Nov 2013 16:58:24 -0600 Subject: [PATCH 0596/4528] Hold reference to prepared stmt while executing Since we use a weak-reference dict for caching prepared statements, we need to make sure at least one reference is held during executing to avoid it being GC'ed. --- cassandra/cluster.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index c4d33ac937..3781be260a 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -779,7 +779,7 @@ def _prepare_all_queries(self, host): responses = connection.wait_for_responses(*messages, timeout=2.0) for response in responses: if (not isinstance(response, ResultMessage) or - response.kind != ResultMessage.KIND_PREPARED): + response.kind != ResultMessage.KIND_PREPARED): log.debug("Got unexpected response when preparing " "statement on host %s: %r", host, response) @@ -932,9 +932,11 @@ def execute_async(self, query, parameters=None, trace=False): ... log.exception("Operation failed:") """ + prepared_statement = None if isinstance(query, basestring): query = SimpleStatement(query) elif isinstance(query, PreparedStatement): + prepared_statement = query query = query.bind(parameters) if isinstance(query, BoundStatement): @@ -951,7 +953,9 @@ def execute_async(self, query, parameters=None, trace=False): if trace: message.tracing = True - future = ResponseFuture(self, message, query, metrics=self._metrics) + future = ResponseFuture( + self, message, query, metrics=self._metrics, + prepared_statement=prepared_statement) future.send_request() return future @@ -1544,7 +1548,7 @@ def _signal_error(self): # that errors have already been reported, so we're fine if host: self._cluster.signal_connection_failure( - host, self._connection.last_error, is_host_addition=False) + host, self._connection.last_error, is_host_addition=False) return # if the connection is not defunct or the host already left, reconnect @@ -1670,12 +1674,13 @@ class ResponseFuture(object): _start_time = None _metrics = None - def __init__(self, session, message, query, metrics=None): + def __init__(self, session, message, query, metrics=None, prepared_statement=None): self.session = session self.row_factory = session.row_factory self.message = message self.query = query self._metrics = metrics + self.prepared_statement = prepared_statement if metrics is not None: self._start_time = time.time() @@ -1823,7 +1828,12 @@ def _set_result(self, response): try: prepared_statement = self.session.cluster._prepared_statements[query_id] except KeyError: - log.error("Tried to execute unknown prepared statement %s", query_id.encode('hex')) + if self.prepared_statement: + query_string = ", " + self.prepared_statement.query_string + else: + query_string = "" + log.error("Tried to execute unknown prepared statement: id=%s%s", + query_id.encode('hex'), query_string) self._set_final_exception(response) return From fda2f31ea67a1ef4dda08d407c11841a4e93ab07 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 26 Nov 2013 16:59:52 -0600 Subject: [PATCH 0597/4528] Fix bad on_remove call on sessions, listeners --- cassandra/cluster.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 3781be260a..920c08da9e 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -680,9 +680,9 @@ def on_remove(self, host): host.set_down() self.load_balancing_policy.on_remove(host) for session in self.sessions: - session.on_remove() + session.on_remove(host) for listener in self.listeners: - listener.on_remove() + listener.on_remove(host) def signal_connection_failure(self, host, connection_exc, is_host_addition): is_down = host.signal_connection_failure(connection_exc) From 69337d87fb28deb47e6247110c26c193ced5bcb2 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 26 Nov 2013 17:56:39 -0600 Subject: [PATCH 0598/4528] Reference prepared stmt when exec'ing bound stmt --- cassandra/cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 920c08da9e..3ce026403d 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -936,7 +936,6 @@ def execute_async(self, query, parameters=None, trace=False): if isinstance(query, basestring): query = SimpleStatement(query) elif isinstance(query, PreparedStatement): - prepared_statement = query query = query.bind(parameters) if isinstance(query, BoundStatement): @@ -944,6 +943,7 @@ def execute_async(self, query, parameters=None, trace=False): query_id=query.prepared_statement.query_id, query_params=query.values, consistency_level=query.consistency_level) + prepared_statement = query.prepared_statement else: query_string = query.query_string if parameters: From d5265bd8b4a6435913fc06c73450773f01d90914 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 26 Nov 2013 17:57:09 -0600 Subject: [PATCH 0599/4528] Don't check for schema agreement when shutdown This avoids some unneeded noise in debug logs when shutting down a Cluster. --- cassandra/cluster.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 3ce026403d..fe5548241a 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1201,6 +1201,7 @@ class ControlConnection(object): # for testing purposes _time = time + _is_shutdown = False def __init__(self, cluster): # use a weak reference to allow the Cluster instance to be GC'ed (and @@ -1217,8 +1218,6 @@ def __init__(self, cluster): self._reconnection_handler = None self._reconnection_lock = RLock() - self._is_shutdown = False - def connect(self): if self._is_shutdown: return @@ -1490,6 +1489,9 @@ def wait_for_schema_agreement(self, connection=None): # a lock is just a simple way to cut down on the number of schema queries # we'll make. with self._schema_agreement_lock: + if self._is_shutdown: + return + log.debug("[control connection] Waiting for schema agreement") if not connection: connection = self._connection From 60a866c1c6f3a76b58877091912546d96f061a37 Mon Sep 17 00:00:00 2001 From: Yinyin L Date: Sun, 1 Dec 2013 23:19:19 +0800 Subject: [PATCH 0600/4528] fix "no viable alternative at input" on map type encode key and value of map data type with native data types' encoders --- cassandra/decoder.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/cassandra/decoder.py b/cassandra/decoder.py index 5e770fb54a..f282eaf523 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -801,7 +801,10 @@ def cql_encode_sequence(val): def cql_encode_map_collection(val): - return '{ %s }' % ' , '.join('%s : %s' % (cql_quote(k), cql_quote(v)) + return '{ %s }' % ' , '.join( + '%s : %s' % ( + cql_encode_native_types(k), + cql_encode_native_types(v)) for k, v in val.iteritems()) @@ -813,6 +816,27 @@ def cql_encode_set_collection(val): return '{ %s }' % ' , '.join(map(cql_quote, val)) +def cql_encode_native_types(val): + v_type = type(val) + if v_type in py_cql_collection_types: + encoder = cql_encode_object + else: + encoder = cql_encoders.get(v_type, cql_encode_object) + return encoder(val) + + +def cql_encode_builtin_types(val): + return cql_encoders.get(type(val), cql_encode_object)(val) + + +py_cql_collection_types = frozenset(( + dict, + list, tuple, + set, frozenset, + types.GeneratorType +)) + + cql_encoders = { float: cql_encode_object, bytearray: cql_encode_bytes, From 9268e177935a187b7fcf8338fb628f1aa70b4d4d Mon Sep 17 00:00:00 2001 From: Yinyin L Date: Sun, 1 Dec 2013 23:34:30 +0800 Subject: [PATCH 0601/4528] fix "no viable alternative at input" issue on list type use native type encoders to encode elements of list --- cassandra/decoder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/decoder.py b/cassandra/decoder.py index f282eaf523..164fc382d3 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -809,7 +809,7 @@ def cql_encode_map_collection(val): def cql_encode_list_collection(val): - return '[ %s ]' % ' , '.join(map(cql_quote, val)) + return '[ %s ]' % ' , '.join(map(cql_encode_native_types, val)) def cql_encode_set_collection(val): From d315b0f1f743ed7e7ff4e86a2bdff2a415a4c523 Mon Sep 17 00:00:00 2001 From: Russell Haering Date: Sun, 1 Dec 2013 18:33:49 -0800 Subject: [PATCH 0602/4528] 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 0603/4528] 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 0604/4528] 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 0605/4528] 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 0606/4528] 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 ba9a0cadc1bea558d1b72753ecfd65eb44f124cd Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 4 Dec 2013 13:43:49 -0600 Subject: [PATCH 0607/4528] Use timeouts on all Control Connection queries Fixes PYTHON-34 --- cassandra/cluster.py | 33 ++++++++++++++++++++------- tests/unit/test_control_connection.py | 23 ++++++++++++++++++- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index fe5548241a..fa343cd18b 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -224,6 +224,13 @@ class Cluster(object): If ``libev`` is installed, ``LibevConnection`` will be used instead. """ + control_connection_timeout = 2.0 + """ + A timeout, in seconds, for queries made by the control connection, such + as querying the current schema and information about nodes in the cluster. + If set to :const:`None`, there will be no timeout for these queries. + """ + sessions = None control_connection = None scheduler = None @@ -250,7 +257,8 @@ def __init__(self, sockopts=None, cql_version=None, executor_threads=2, - max_schema_agreement_wait=10): + max_schema_agreement_wait=10, + control_connection_timeout=2.0): """ Any of the mutable Cluster attributes may be set as keyword arguments to the constructor. @@ -295,6 +303,7 @@ def __init__(self, self.sockopts = sockopts self.cql_version = cql_version self.max_schema_agreement_wait = max_schema_agreement_wait + self.control_connection_timeout = control_connection_timeout self._listeners = set() self._listener_lock = Lock() @@ -335,7 +344,8 @@ def __init__(self, if self.metrics_enabled: self.metrics = Metrics(weakref.proxy(self)) - self.control_connection = ControlConnection(self) + self.control_connection = ControlConnection( + self, self.control_connection_timeout) def get_min_requests_per_connection(self, host_distance): return self._min_requests_per_connection[host_distance] @@ -1199,11 +1209,13 @@ class ControlConnection(object): _SELECT_SCHEMA_PEERS = "SELECT rpc_address, schema_version FROM system.peers" _SELECT_SCHEMA_LOCAL = "SELECT schema_version FROM system.local WHERE key='local'" + _is_shutdown = False + _timeout = None + # for testing purposes _time = time - _is_shutdown = False - def __init__(self, cluster): + def __init__(self, cluster, timeout): # use a weak reference to allow the Cluster instance to be GC'ed (and # shutdown) since implementing __del__ disables the cycle detector self._cluster = weakref.proxy(cluster) @@ -1211,6 +1223,7 @@ def __init__(self, cluster): self._balancing_policy.populate(cluster, []) self._reconnection_policy = cluster.reconnection_policy self._connection = None + self._timeout = timeout self._lock = RLock() self._schema_agreement_lock = Lock() @@ -1368,13 +1381,15 @@ def _refresh_schema(self, connection, keyspace=None, table=None): col_query = QueryMessage(query=self._SELECT_COLUMNS + where_clause, consistency_level=cl) if ks_query: - ks_result, cf_result, col_result = connection.wait_for_responses(ks_query, cf_query, col_query) + ks_result, cf_result, col_result = connection.wait_for_responses( + ks_query, cf_query, col_query, timeout=self._timeout) ks_result = dict_factory(*ks_result.results) cf_result = dict_factory(*cf_result.results) col_result = dict_factory(*col_result.results) else: ks_result = None - cf_result, col_result = connection.wait_for_responses(cf_query, col_query) + cf_result, col_result = connection.wait_for_responses( + cf_query, col_query, timeout=self._timeout) cf_result = dict_factory(*cf_result.results) col_result = dict_factory(*col_result.results) @@ -1393,7 +1408,8 @@ def _refresh_node_list_and_token_map(self, connection): cl = ConsistencyLevel.ONE peers_query = QueryMessage(query=self._SELECT_PEERS, consistency_level=cl) local_query = QueryMessage(query=self._SELECT_LOCAL, consistency_level=cl) - peers_result, local_result = connection.wait_for_responses(peers_query, local_query) + peers_result, local_result = connection.wait_for_responses( + peers_query, local_query, timeout=self._timeout) peers_result = dict_factory(*peers_result.results) partitioner = None @@ -1505,7 +1521,8 @@ def wait_for_schema_agreement(self, connection=None): local_query = QueryMessage(query=self._SELECT_SCHEMA_LOCAL, consistency_level=cl) try: timeout = min(2.0, total_timeout - elapsed) - peers_result, local_result = connection.wait_for_responses(peers_query, local_query, timeout=timeout) + peers_result, local_result = connection.wait_for_responses( + peers_query, local_query, timeout=timeout) except OperationTimedOut: log.debug("[control connection] Timed out waiting for response during schema agreement check") elapsed = self._time.time() - start diff --git a/tests/unit/test_control_connection.py b/tests/unit/test_control_connection.py index 3c59bb6c92..7b3cd3cc34 100644 --- a/tests/unit/test_control_connection.py +++ b/tests/unit/test_control_connection.py @@ -7,6 +7,7 @@ from concurrent.futures import ThreadPoolExecutor +from cassandra import OperationTimedOut from cassandra.decoder import ResultMessage from cassandra.cluster import ControlConnection, Cluster, _Scheduler from cassandra.pool import Host @@ -114,7 +115,7 @@ def setUp(self): self.connection = MockConnection() self.time = FakeTime() - self.control_connection = ControlConnection(self.cluster) + self.control_connection = ControlConnection(self.cluster, timeout=0.01) self.control_connection._connection = self.connection self.control_connection._time = self.time @@ -220,6 +221,26 @@ def test_refresh_nodes_and_tokens_remove_host(self): self.assertEqual(1, len(self.cluster.removed_hosts)) self.assertEqual(self.cluster.removed_hosts[0].address, "192.168.1.2") + def test_refresh_nodes_and_tokens_timeout(self): + + def bad_wait_for_responses(*args, **kwargs): + self.assertEqual(kwargs['timeout'], self.control_connection._timeout) + raise OperationTimedOut() + + self.connection.wait_for_responses = bad_wait_for_responses + self.control_connection.refresh_node_list_and_token_map() + self.cluster.executor.submit.assert_called_with(self.control_connection._reconnect) + + def test_refresh_schema_timeout(self): + + def bad_wait_for_responses(*args, **kwargs): + self.assertEqual(kwargs['timeout'], self.control_connection._timeout) + raise OperationTimedOut() + + self.connection.wait_for_responses = bad_wait_for_responses + self.control_connection.refresh_schema() + self.cluster.executor.submit.assert_called_with(self.control_connection._reconnect) + def test_handle_topology_change(self): event = { 'change_type': 'NEW_NODE', From 86249b518d6afd2e4c5f17ab97a73dc69f47aee8 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 4 Dec 2013 14:01:34 -0600 Subject: [PATCH 0608/4528] Add timeout parameter to Session.execute() --- cassandra/cluster.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index fa343cd18b..5e8ae2ee83 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -866,7 +866,7 @@ def __init__(self, cluster, hosts): for future in futures: future.result() - def execute(self, query, parameters=None, trace=False): + def execute(self, query, parameters=None, timeout=None, trace=False): """ Execute the given query and synchronously wait for the response. @@ -895,7 +895,7 @@ def execute(self, query, parameters=None, trace=False): future = self.execute_async(query, parameters, trace) try: - result = future.result() + result = future.result(timeout) finally: if trace: try: From 5fa4ba4ead7dc2e2b7440a23fad1e1bc67c6557d Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Thu, 5 Dec 2013 07:41:41 -0800 Subject: [PATCH 0609/4528] 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 bb91d0be40fe75cb1ae01ec5a17839bebc5c48cb Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 5 Dec 2013 13:21:37 -0600 Subject: [PATCH 0610/4528] Add default timeout to Session For PYTHON-35 --- cassandra/cluster.py | 53 ++++++++++++++++++++++++------- tests/integration/test_metrics.py | 4 +-- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 5e8ae2ee83..0c63f0eca1 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -65,6 +65,9 @@ DEFAULT_MAX_CONNECTIONS_PER_REMOTE_HOST = 2 +_NOT_SET = object() + + class NoHostAvailable(Exception): """ Raised when an operation is attempted but all connections are @@ -842,6 +845,21 @@ class Session(object): """ + default_timeout = 10.0 + """ + A default timeout, measured in seconds, for queries executed through + :meth:`.execute()` or :meth:`.execute_async()`. This default may be + overridden with the `timeout` parameter for either of those methods + or the `timeout` parameter for :meth:`~.ResponseFuture.result()`. + + Setting this to :const:`None` will cause no timeouts to be set by default. + + *Note*: This timeout currently has no effect on callbacks registered + on a :class:`~.ResponseFuture` through :meth:`~.ResponseFuture.add_callback` or + :meth:`~.ResponseFuture.add_errback`; even if a query exceeds this default + timeout, neither the registered callback or errback will be called. + """ + _lock = None _pools = None _load_balancer = None @@ -866,7 +884,7 @@ def __init__(self, cluster, hosts): for future in futures: future.result() - def execute(self, query, parameters=None, timeout=None, trace=False): + def execute(self, query, parameters=None, timeout=_NOT_SET, trace=False): """ Execute the given query and synchronously wait for the response. @@ -880,6 +898,12 @@ def execute(self, query, parameters=None, timeout=None, trace=False): argument. If a dict is used, ``%(name)s`` style placeholders must be used. + `timeout` should specify a floating-point timeout (in seconds) after + which an :exc:`.OperationTimedOut` exception will be raised if the query + has not completed. If not set, the timeout defaults to + :attr:`~.Session.default_timeout`. If set to :const:`None`, there is + no timeout. + If `trace` is set to :const:`True`, an attempt will be made to fetch the trace details and attach them to the `query`'s :attr:`~.Statement.trace` attribute in the form of a :class:`.QueryTrace` @@ -888,6 +912,9 @@ def execute(self, query, parameters=None, timeout=None, trace=False): trace details, the :attr:`~.Statement.trace` attribute will be left as :const:`None`. """ + if timeout is _NOT_SET: + timeout = self.default_timeout + if trace and not isinstance(query, Statement): raise TypeError( "The query argument must be an instance of a subclass of " @@ -964,7 +991,7 @@ def execute_async(self, query, parameters=None, trace=False): message.tracing = True future = ResponseFuture( - self, message, query, metrics=self._metrics, + self, message, query, self.default_timeout, metrics=self._metrics, prepared_statement=prepared_statement) future.send_request() return future @@ -1661,9 +1688,6 @@ def refresh_schema_and_set_result(keyspace, table, control_conn, response_future response_future._set_final_result(None) -_NO_RESULT_YET = object() - - class ResponseFuture(object): """ An asynchronous response delivery mechanism that is returned from calls @@ -1679,9 +1703,10 @@ class ResponseFuture(object): row_factory = None message = None query = None + default_timeout = None _req_id = None - _final_result = _NO_RESULT_YET + _final_result = _NOT_SET _final_exception = None _query_trace = None _callback = None @@ -1693,11 +1718,12 @@ class ResponseFuture(object): _start_time = None _metrics = None - def __init__(self, session, message, query, metrics=None, prepared_statement=None): + def __init__(self, session, message, query, default_timeout=None, metrics=None, prepared_statement=None): self.session = session self.row_factory = session.row_factory self.message = message self.query = query + self.default_timeout = default_timeout self._metrics = metrics self.prepared_statement = prepared_statement if metrics is not None: @@ -2008,7 +2034,7 @@ def _retry_task(self, reuse_connection): # otherwise, move onto another host self.send_request() - def result(self, timeout=None): + def result(self, timeout=_NOT_SET): """ Return the final result or raise an Exception if errors were encountered. If the final result or error has not been set @@ -2027,13 +2053,16 @@ def result(self, timeout=None): ... log.exception("Operation failed:") """ - if self._final_result is not _NO_RESULT_YET: + if timeout is _NOT_SET: + timeout = self.default_timeout + + if self._final_result is not _NOT_SET: return self._final_result elif self._final_exception: raise self._final_exception else: self._event.wait(timeout=timeout) - if self._final_result is not _NO_RESULT_YET: + if self._final_result is not _NOT_SET: return self._final_result elif self._final_exception: raise self._final_exception @@ -2081,7 +2110,7 @@ def add_callback(self, fn, *args, **kwargs): >>> future.add_callback(handle_results, time.time(), should_log=True) """ - if self._final_result is not _NO_RESULT_YET: + if self._final_result is not _NOT_SET: fn(self._final_result, *args, **kwargs) else: self._callback = (fn, args, kwargs) @@ -2128,7 +2157,7 @@ def add_callbacks(self, callback, errback, self.add_errback(errback, *errback_args, **(errback_kwargs or {})) def __str__(self): - result = "(no result yet)" if self._final_result is _NO_RESULT_YET else self._final_result + result = "(no result yet)" if self._final_result is _NOT_SET else self._final_result return "" \ % (self.query, self._req_id, result, self._final_exception, self._current_host) __repr__ = __str__ diff --git a/tests/integration/test_metrics.py b/tests/integration/test_metrics.py index 91004b5d77..eea15b943e 100644 --- a/tests/integration/test_metrics.py +++ b/tests/integration/test_metrics.py @@ -58,7 +58,7 @@ def test_write_timeout(self): try: # Test write query = SimpleStatement("INSERT INTO test3rf.test (k, v) VALUES (2, 2)", consistency_level=ConsistencyLevel.ALL) - self.assertRaises(WriteTimeout, session.execute, query) + self.assertRaises(WriteTimeout, session.execute, query, timeout=None) self.assertEqual(1, cluster.metrics.stats.write_timeouts) finally: @@ -88,7 +88,7 @@ def test_read_timeout(self): try: # Test read query = SimpleStatement("SELECT v FROM test3rf.test WHERE k=%(k)s", consistency_level=ConsistencyLevel.ALL) - self.assertRaises(ReadTimeout, session.execute, query, {'k': 1}) + self.assertRaises(ReadTimeout, session.execute, query, {'k': 1}, timeout=None) self.assertEqual(1, cluster.metrics.stats.read_timeouts) finally: From 6f5157cb30920d694a866bcb20cbc96b31d284ff Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 5 Dec 2013 15:34:08 -0600 Subject: [PATCH 0611/4528] Add null logging handler to cassandra package This prevents spurious warnings like 'No handlers could be found for logger "cassandra.cluster"'. This will not affect normal logging. --- cassandra/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cassandra/__init__.py b/cassandra/__init__.py index 37d70bd630..576613e186 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -1,3 +1,14 @@ +import logging + + +class NullHandler(logging.Handler): + + def emit(self, record): + pass + +# logging.getLogger('cassandra').addHandler(NullHandler()) + + __version_info__ = (1, 0, '0b7', 'post') __version__ = '.'.join(map(str, __version_info__)) From 7be4dbde4e412a183df1dec7d126e8394b46d36b Mon Sep 17 00:00:00 2001 From: Yinyin L Date: Fri, 6 Dec 2013 23:10:51 +0800 Subject: [PATCH 0612/4528] encode inner data type of collection with cql_encode_builtin_types() --- cassandra/decoder.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/cassandra/decoder.py b/cassandra/decoder.py index 164fc382d3..ce6c61ea80 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -803,26 +803,17 @@ def cql_encode_sequence(val): def cql_encode_map_collection(val): return '{ %s }' % ' , '.join( '%s : %s' % ( - cql_encode_native_types(k), - cql_encode_native_types(v)) + cql_encode_builtin_types(k), + cql_encode_builtin_types(v)) for k, v in val.iteritems()) def cql_encode_list_collection(val): - return '[ %s ]' % ' , '.join(map(cql_encode_native_types, val)) + return '[ %s ]' % ' , '.join(map(cql_encode_builtin_types, val)) def cql_encode_set_collection(val): - return '{ %s }' % ' , '.join(map(cql_quote, val)) - - -def cql_encode_native_types(val): - v_type = type(val) - if v_type in py_cql_collection_types: - encoder = cql_encode_object - else: - encoder = cql_encoders.get(v_type, cql_encode_object) - return encoder(val) + return '{ %s }' % ' , '.join(map(cql_encode_builtin_types, val)) def cql_encode_builtin_types(val): From b6493f4cc97183e1e3c8660e6400c3ab45d0c42f Mon Sep 17 00:00:00 2001 From: Yinyin L Date: Fri, 6 Dec 2013 23:20:55 +0800 Subject: [PATCH 0613/4528] rename generic encoding function to cql_encode_all_types() --- cassandra/decoder.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cassandra/decoder.py b/cassandra/decoder.py index ce6c61ea80..a59f7c7bd4 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -803,20 +803,20 @@ def cql_encode_sequence(val): def cql_encode_map_collection(val): return '{ %s }' % ' , '.join( '%s : %s' % ( - cql_encode_builtin_types(k), - cql_encode_builtin_types(v)) + cql_encode_all_types(k), + cql_encode_all_types(v)) for k, v in val.iteritems()) def cql_encode_list_collection(val): - return '[ %s ]' % ' , '.join(map(cql_encode_builtin_types, val)) + return '[ %s ]' % ' , '.join(map(cql_encode_all_types, val)) def cql_encode_set_collection(val): - return '{ %s }' % ' , '.join(map(cql_encode_builtin_types, val)) + return '{ %s }' % ' , '.join(map(cql_encode_all_types, val)) -def cql_encode_builtin_types(val): +def cql_encode_all_types(val): return cql_encoders.get(type(val), cql_encode_object)(val) From 21dfed31335dd53070ed230d11b580988d1c51e7 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Mon, 9 Dec 2013 19:16:43 -0600 Subject: [PATCH 0614/4528] Consolidate _child_policy and child_policy --- cassandra/policies.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cassandra/policies.py b/cassandra/policies.py index 15a7194a1f..7e15d2fa66 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -265,14 +265,14 @@ class TokenAwarePolicy(LoadBalancingPolicy): _cluster_metadata = None def __init__(self, child_policy): - self.child_policy = child_policy + self._child_policy = child_policy def populate(self, cluster, hosts): self._cluster_metadata = cluster.metadata - self.child_policy.populate(cluster, hosts) + self._child_policy.populate(cluster, hosts) def distance(self, *args, **kwargs): - return self.child_policy.distance(*args, **kwargs) + return self._child_policy.distance(*args, **kwargs) def make_query_plan(self, working_keyspace=None, query=None): if query and query.keyspace: @@ -280,7 +280,7 @@ def make_query_plan(self, working_keyspace=None, query=None): else: keyspace = working_keyspace - child = self.child_policy + child = self._child_policy if query is None: for host in child.make_query_plan(keyspace, query): yield host @@ -303,16 +303,16 @@ def make_query_plan(self, working_keyspace=None, query=None): yield host def on_up(self, *args, **kwargs): - return self.child_policy.on_up(*args, **kwargs) + return self._child_policy.on_up(*args, **kwargs) def on_down(self, *args, **kwargs): - return self.child_policy.on_down(*args, **kwargs) + return self._child_policy.on_down(*args, **kwargs) def on_add(self, *args, **kwargs): - return self.child_policy.on_add(*args, **kwargs) + return self._child_policy.on_add(*args, **kwargs) def on_remove(self, *args, **kwargs): - return self.child_policy.on_remove(*args, **kwargs) + return self._child_policy.on_remove(*args, **kwargs) class ConvictionPolicy(object): From 2951ba35de1fc7213121a9b45b7ff679d543c5d3 Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Thu, 28 Nov 2013 14:26:46 +0200 Subject: [PATCH 0615/4528] 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 0616/4528] 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 5b3d0c6b094c6934b1dfaa55ead5956defd7c542 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 10 Dec 2013 16:57:49 -0600 Subject: [PATCH 0617/4528] s/Note/Important/ for Session.timeout docstring --- cassandra/cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 0c63f0eca1..d5ee120ce3 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -854,7 +854,7 @@ class Session(object): Setting this to :const:`None` will cause no timeouts to be set by default. - *Note*: This timeout currently has no effect on callbacks registered + *Important*: This timeout currently has no effect on callbacks registered on a :class:`~.ResponseFuture` through :meth:`~.ResponseFuture.add_callback` or :meth:`~.ResponseFuture.add_errback`; even if a query exceeds this default timeout, neither the registered callback or errback will be called. From 847e2b94b568b30d31acb1d89564806778748fc0 Mon Sep 17 00:00:00 2001 From: Yinyin L Date: Thu, 12 Dec 2013 00:00:57 +0800 Subject: [PATCH 0618/4528] remove unused cql-collection-types-in-python set --- cassandra/decoder.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/cassandra/decoder.py b/cassandra/decoder.py index a59f7c7bd4..339a5dc31c 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -820,14 +820,6 @@ def cql_encode_all_types(val): return cql_encoders.get(type(val), cql_encode_object)(val) -py_cql_collection_types = frozenset(( - dict, - list, tuple, - set, frozenset, - types.GeneratorType -)) - - cql_encoders = { float: cql_encode_object, bytearray: cql_encode_bytes, From c59c4d8662114819b209739c09d3580c0e4cdf16 Mon Sep 17 00:00:00 2001 From: Yinyin L Date: Thu, 12 Dec 2013 00:23:28 +0800 Subject: [PATCH 0619/4528] add encoder mapping for OrderedDict OrderedDict is the resulted object type when SELECT-ing map data object. --- cassandra/decoder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cassandra/decoder.py b/cassandra/decoder.py index 5e770fb54a..d762411df0 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -825,6 +825,7 @@ def cql_encode_set_collection(val): datetime.datetime: cql_encode_datetime, datetime.date: cql_encode_date, dict: cql_encode_map_collection, + OrderedDict: cql_encode_map_collection, list: cql_encode_list_collection, tuple: cql_encode_list_collection, set: cql_encode_set_collection, From 10c516138e6e3c4560889a1d87a4400954c63c2a Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 11 Dec 2013 10:41:46 -0800 Subject: [PATCH 0620/4528] 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 0621/4528] 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 0622/4528] 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 0623/4528] 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 0624/4528] 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 0625/4528] 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 0626/4528] 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 0627/4528] 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 0628/4528] 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 7ec61e4d1ab7ec8d4fab1a4b0f60fcd6389170a8 Mon Sep 17 00:00:00 2001 From: Russ Bradberry Date: Thu, 12 Dec 2013 16:48:10 -0500 Subject: [PATCH 0629/4528] don't throw away a host that is in the contact points list --- cassandra/cluster.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index d5ee120ce3..db08c75c9e 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1479,7 +1479,8 @@ def _refresh_node_list_and_token_map(self, connection): for old_host in self._cluster.metadata.all_hosts(): if old_host.address != connection.host and \ - old_host.address not in found_hosts: + old_host.address not in found_hosts and \ + old_host.address not in self._cluster.contact_points: log.debug("[control connection] Found host that has been removed: %r", old_host) self._cluster.remove_host(old_host) From 934d8e7edf77678e4d9be435e80c096462061295 Mon Sep 17 00:00:00 2001 From: Russ Bradberry Date: Thu, 12 Dec 2013 16:48:52 -0500 Subject: [PATCH 0630/4528] add WhiteListRoundRobinPolicy --- cassandra/policies.py | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/cassandra/policies.py b/cassandra/policies.py index 7e15d2fa66..d6e4bce75e 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -315,6 +315,53 @@ def on_remove(self, *args, **kwargs): return self._child_policy.on_remove(*args, **kwargs) +class WhiteListRoundRobinPolicy(RoundRobinPolicy): + """ + A subclass of :class:`.RoundRobinPolicy` which evenly + distributes queries across all nodes in the cluster, + regardless of what datacenter the nodes may be in, but + only if that node exists in the list of allowed nodes + + This policy is addresses the issue described in + https://datastax-oss.atlassian.net/browse/JAVA-145 + Where connection errors occur when connection + attempts are made to private IP addresses remotely + """ + def __init__(self, hosts): + """ + :param hosts: List of hosts + """ + self._allowed_hosts = hosts + + def populate(self, cluster, hosts): + self._live_hosts = set() + for host in hosts: + if host.address in self._allowed_hosts: + self._live_hosts.add(host) + + if len(hosts) <= 1: + self._position = 0 + else: + self._position = randint(0, len(hosts) - 1) + + def distance(self, host): + if host.address in self._allowed_hosts: + return HostDistance.LOCAL + else: + return HostDistance.IGNORED + + def on_up(self, host): + if host.address in self._allowed_hosts: + self._live_hosts.add(host) + + def on_add(self, host): + if host.address in self._allowed_hosts: + self._live_hosts.add(host) + + def on_remove(self, host): + self._live_hosts.discard(host) + + class ConvictionPolicy(object): """ A policy which decides when hosts should be considered down From 5c425e819333a89cfd1f31620b691d60017fedda Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 12 Dec 2013 15:19:53 -0800 Subject: [PATCH 0631/4528] 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 0632/4528] 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 0633/4528] 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 0634/4528] 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 0635/4528] 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 caeab27405b0459be29ba16be52e79541039e49d Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 13 Dec 2013 16:49:18 -0600 Subject: [PATCH 0636/4528] Handle setting same keyspace twice Before this fix, the second "USE system" query would hang until an OperationTimedOut exception was raised. Fixes PYTHON-38 --- cassandra/cluster.py | 4 ++++ cassandra/connection.py | 1 + cassandra/pool.py | 4 ++++ tests/integration/test_cluster.py | 6 ++++++ 4 files changed, 15 insertions(+) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index db08c75c9e..985e3a9b36 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1177,6 +1177,10 @@ def _set_keyspace_for_all_pools(self, keyspace, callback): remaining_callbacks = set(self._pools.values()) errors = {} + if not remaining_callbacks: + callback(errors) + return + def pool_finished_setting_keyspace(pool, host_errors): remaining_callbacks.remove(pool) if host_errors: diff --git a/cassandra/connection.py b/cassandra/connection.py index 047ecf97bc..a736a4da39 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -322,6 +322,7 @@ def set_keyspace_async(self, keyspace, callback): occurred, otherwise :const:`None`. """ if not keyspace or keyspace == self.keyspace: + callback(self, None) return query = QueryMessage(query='USE "%s"' % (keyspace,), diff --git a/cassandra/pool.py b/cassandra/pool.py index d3a4aa9168..ffae74af0d 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -532,6 +532,10 @@ def _set_keyspace_for_all_conns(self, keyspace, callback): remaining_callbacks = set(self._connections) errors = [] + if not remaining_callbacks: + callback(self, errors) + return + def connection_finished_setting_keyspace(conn, error): remaining_callbacks.remove(conn) if error: diff --git a/tests/integration/test_cluster.py b/tests/integration/test_cluster.py index 085a17bde9..e7676b3f02 100644 --- a/tests/integration/test_cluster.py +++ b/tests/integration/test_cluster.py @@ -69,6 +69,12 @@ def test_connect_on_keyspace(self): result2 = session2.execute("SELECT * FROM test") self.assertEquals(result, result2) + def test_set_keyspace_twice(self): + cluster = Cluster() + session = cluster.connect() + session.execute("USE system") + session.execute("USE system") + def test_default_connections(self): """ Ensure errors are not thrown when using non-default policies From 5165bb078c3fbd51dbe112e25a7c5c7974800397 Mon Sep 17 00:00:00 2001 From: Kai Lautaportti Date: Sat, 14 Dec 2013 20:23:21 +0200 Subject: [PATCH 0637/4528] 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 0638/4528] 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 e467d005a83c485de40dc174332ab2601cf1b0f0 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 17 Dec 2013 12:31:28 -0600 Subject: [PATCH 0639/4528] Add support for ConsistencyLevel LOCAL_ONE Fixes #65 --- cassandra/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cassandra/__init__.py b/cassandra/__init__.py index 576613e186..18f22a9944 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -60,6 +60,12 @@ class ConsistencyLevel(object): Requires a quorum of replicas in each datacenter """ + LOCAL_ONE = 10 + """ + Sends a request only to replicas in the local datacenter and waits for + one response. + """ + ConsistencyLevel.value_to_name = { ConsistencyLevel.ANY: 'ANY', ConsistencyLevel.ONE: 'ONE', @@ -68,7 +74,8 @@ class ConsistencyLevel(object): ConsistencyLevel.QUORUM: 'QUORUM', ConsistencyLevel.ALL: 'ALL', ConsistencyLevel.LOCAL_QUORUM: 'LOCAL_QUORUM', - ConsistencyLevel.EACH_QUORUM: 'EACH_QUORUM' + ConsistencyLevel.EACH_QUORUM: 'EACH_QUORUM', + ConsistencyLevel.LOCAL_ONE: 'LOCAL_ONE' } ConsistencyLevel.name_to_value = { @@ -79,7 +86,8 @@ class ConsistencyLevel(object): 'QUORUM': ConsistencyLevel.QUORUM, 'ALL': ConsistencyLevel.ALL, 'LOCAL_QUORUM': ConsistencyLevel.LOCAL_QUORUM, - 'EACH_QUORUM': ConsistencyLevel.EACH_QUORUM + 'EACH_QUORUM': ConsistencyLevel.EACH_QUORUM, + 'LOCAL_ONE': ConsistencyLevel.LOCAL_ONE } From 8c51776bdf9fc43200bc7e9f78941990699f134b Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Tue, 5 Nov 2013 17:16:53 -0600 Subject: [PATCH 0640/4528] Rearrange integration tests with new consistency test --- tests/integration/long/__init__.py | 1 + tests/integration/long/test_consistency.py | 386 ++++++++++++++++++ tests/integration/long/utils.py | 108 +++++ tests/integration/standard/__init__.py | 1 + .../{ => standard}/test_cluster.py | 0 .../{ => standard}/test_connection.py | 0 .../{ => standard}/test_factories.py | 0 .../{ => standard}/test_metadata.py | 0 .../{ => standard}/test_metrics.py | 0 .../test_prepared_statements.py | 0 .../integration/{ => standard}/test_query.py | 0 .../integration/{ => standard}/test_types.py | 0 12 files changed, 496 insertions(+) create mode 100644 tests/integration/long/__init__.py create mode 100644 tests/integration/long/test_consistency.py create mode 100644 tests/integration/long/utils.py create mode 100644 tests/integration/standard/__init__.py rename tests/integration/{ => standard}/test_cluster.py (100%) rename tests/integration/{ => standard}/test_connection.py (100%) rename tests/integration/{ => standard}/test_factories.py (100%) rename tests/integration/{ => standard}/test_metadata.py (100%) rename tests/integration/{ => standard}/test_metrics.py (100%) rename tests/integration/{ => standard}/test_prepared_statements.py (100%) rename tests/integration/{ => standard}/test_query.py (100%) rename tests/integration/{ => standard}/test_types.py (100%) diff --git a/tests/integration/long/__init__.py b/tests/integration/long/__init__.py new file mode 100644 index 0000000000..a1379440a7 --- /dev/null +++ b/tests/integration/long/__init__.py @@ -0,0 +1 @@ +__author__ = 'joaquin' diff --git a/tests/integration/long/test_consistency.py b/tests/integration/long/test_consistency.py new file mode 100644 index 0000000000..13ae73fc09 --- /dev/null +++ b/tests/integration/long/test_consistency.py @@ -0,0 +1,386 @@ +import cassandra + +from cassandra import ConsistencyLevel +from cassandra.cluster import Cluster +from cassandra.policies import TokenAwarePolicy, RoundRobinPolicy +from tests.integration.long.utils import reset_coordinators, force_stop, \ + create_schema, init, query, assert_queried, wait_for_down, wait_for_up, \ + start + +try: + import unittest2 as unittest +except ImportError: + import unittest # noqa + + +class ConsistencyTests(unittest.TestCase): + def _cl_failure(self, consistency_level, e): + self.fail('%s seen for CL.%s with message: %s' % ( + type(e), ConsistencyLevel.value_to_name[consistency_level], + e.message)) + + def _cl_expected_failure(self, cl): + self.fail('Test passed at ConsistencyLevel.%s' % + ConsistencyLevel.value_to_name[cl]) + + + def test_rfone_tokenaware(self): + keyspace = 'test_rfone_tokenaware' + cluster = Cluster( + load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) + session = cluster.connect() + + create_schema(session, keyspace, replication_factor=1) + init(session, keyspace, 12) + + reset_coordinators() + query(session, keyspace, 12) + assert_queried(1, 0) + assert_queried(2, 12) + assert_queried(3, 0) + + try: + force_stop(2) + wait_for_down(cluster, 2) + + accepted_list = [ConsistencyLevel.ANY] + + fail_list = [ + ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL, + ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM + ] + + # Test writes that expected to complete successfully + # BUG: CL.ANY should work + # for cl in accepted_list: + # try: + # init(session, keyspace, 12, consistency_level=cl) + # except Exception as e: + # self._cl_failure(cl, e) + + # Test reads that expected to complete successfully + for cl in accepted_list: + try: + query(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.ANY]: + self._cl_failure(cl, e) + except Exception as e: + self._cl_failure(cl, e) + + # Test writes that expected to fail + for cl in fail_list: + try: + init(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.Unavailable as e: + if not cl in [ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL]: + self._cl_failure(cl, e) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + + # Test reads that expected to fail + for cl in fail_list: + try: + query(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.Unavailable as e: + if not cl in [ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL]: + self._cl_failure(cl, e) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + finally: + start(2) + wait_for_up(cluster, 2) + + + def test_rftwo_tokenaware(self): + keyspace = 'test_rftwo_tokenaware' + cluster = Cluster( + load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) + session = cluster.connect() + + create_schema(session, keyspace, replication_factor=2) + init(session, keyspace, 12) + + reset_coordinators() + query(session, keyspace, 12) + assert_queried(1, 0) + assert_queried(2, 12) + assert_queried(3, 0) + + try: + force_stop(2) + wait_for_down(cluster, 2) + + accepted_list = [ + ConsistencyLevel.ANY, + ConsistencyLevel.ONE + ] + + fail_list = [ + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL, + ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM + ] + + # Test writes that expected to complete successfully + for cl in accepted_list: + try: + init(session, keyspace, 12, consistency_level=cl) + except Exception as e: + self._cl_failure(cl, e) + + # Test reads that expected to complete successfully + for cl in accepted_list: + try: + reset_coordinators() + query(session, keyspace, 12, consistency_level=cl) + # Bug: I believe the Java-Driver does this differently + # and RoundRobins after the ideal token is not available. + # I like the Python Driver's approach, but we should + # probably make all policies act the same way, whichever + # way gets chosen? + assert_queried(1, 0) + assert_queried(2, 0) + assert_queried(3, 12) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.ANY]: + self._cl_failure(cl, e) + except Exception as e: + self._cl_failure(cl, e) + + # Test writes that expected to fail + for cl in fail_list: + try: + init(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.Unavailable as e: + if not cl in [ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL]: + self._cl_failure(cl, e) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + + # Test reads that expected to fail + for cl in fail_list: + try: + query(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.Unavailable as e: + if not cl in [ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL]: + self._cl_failure(cl, e) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + finally: + start(2) + wait_for_up(cluster, 2) + + def test_rfthree_tokenaware(self): + keyspace = 'test_rfthree_tokenaware' + cluster = Cluster( + load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) + session = cluster.connect() + + create_schema(session, keyspace, replication_factor=3) + init(session, keyspace, 12) + + reset_coordinators() + query(session, keyspace, 12) + assert_queried(1, 0) + assert_queried(2, 12) + assert_queried(3, 0) + + try: + force_stop(2) + wait_for_down(cluster, 2) + + accepted_list = [ + ConsistencyLevel.ANY, + ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM + ] + + fail_list = [ + ConsistencyLevel.THREE, + ConsistencyLevel.ALL, + ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM + ] + + # Test writes that expected to complete successfully + for cl in accepted_list: + try: + init(session, keyspace, 12, consistency_level=cl) + except Exception as e: + self._cl_failure(cl, e) + + # Test reads that expected to complete successfully + for cl in accepted_list: + try: + reset_coordinators() + query(session, keyspace, 12, consistency_level=cl) + # Bug: I believe the Java-Driver does this differently + assert_queried(1, 12) + assert_queried(2, 0) + assert_queried(3, 0) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.ANY]: + self._cl_failure(cl, e) + except Exception as e: + self._cl_failure(cl, e) + + # Test writes that expected to fail + for cl in fail_list: + try: + init(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.Unavailable as e: + if not cl in [ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL]: + self._cl_failure(cl, e) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + + # Test reads that expected to fail + for cl in fail_list: + try: + query(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.Unavailable as e: + if not cl in [ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL]: + self._cl_failure(cl, e) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + finally: + start(2) + wait_for_up(cluster, 2) + + + def test_rfthree_tokenaware_none_down(self): + keyspace = 'test_rfthree_tokenaware_none_down' + cluster = Cluster( + load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) + session = cluster.connect() + + create_schema(session, keyspace, replication_factor=3) + init(session, keyspace, 12) + + reset_coordinators() + query(session, keyspace, 12) + assert_queried(1, 0) + assert_queried(2, 12) + assert_queried(3, 0) + + accepted_list = [ + ConsistencyLevel.ANY, + ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL, + ] + + fail_list = [ + ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM + ] + + # Test writes that expected to complete successfully + for cl in accepted_list: + try: + init(session, keyspace, 12, consistency_level=cl) + except Exception as e: + self._cl_failure(cl, e) + + # Test reads that expected to complete successfully + for cl in accepted_list: + try: + reset_coordinators() + query(session, keyspace, 12, consistency_level=cl) + assert_queried(1, 0) + assert_queried(2, 12) + assert_queried(3, 0) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.ANY]: + self._cl_failure(cl, e) + except Exception as e: + self._cl_failure(cl, e) + + # Test writes that expected to fail + for cl in fail_list: + try: + init(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.Unavailable as e: + if not cl in [ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL]: + self._cl_failure(cl, e) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + + # Test reads that expected to fail + for cl in fail_list: + try: + query(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.Unavailable as e: + if not cl in [ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL]: + self._cl_failure(cl, e) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py new file mode 100644 index 0000000000..43322efe20 --- /dev/null +++ b/tests/integration/long/utils.py @@ -0,0 +1,108 @@ +import struct +import time + +from cassandra.query import SimpleStatement +from cassandra import ConsistencyLevel +from tests.integration import get_node + + +coordinators = {} + + +def add_coordinator(future): + global coordinators + coordinator = future._current_host.address + if coordinator in coordinators: + coordinators[coordinator] += 1 + else: + coordinators[coordinator] = 1 + if future._errors: + print 'future._errors', future._errors + future.result() + + +def reset_coordinators(): + global coordinators + coordinators = {} + + +def assert_queried(node, n): + ip = '127.0.0.%s' % node + print coordinators + if ip in coordinators: + if coordinators[ip] == n: + return + raise RuntimeError( + 'IP: %s. Expected: %s. Received: %s.' % (ip, n, coordinators[ip])) + else: + if n == 0: + return + raise RuntimeError('IP: %s. Expected: %s. Received: %s.' % (ip, n, 0)) + + +def create_schema(session, keyspace, replication_class='SimpleStrategy', + replication_factor=1): + results = session.execute( + 'SELECT keyspace_name FROM system.schema_keyspaces') + existing_keyspaces = [row[0] for row in results] + if keyspace in existing_keyspaces: + session.execute('DROP KEYSPACE %s' % keyspace) + if replication_class == 'SimpleStrategy': + ddl = "\n CREATE KEYSPACE %s\n WITH replication" \ + " = {'class': 'SimpleStrategy', 'replication_factor': '%s'}" + session.execute(ddl % (keyspace, replication_factor)) + ddl = '\n CREATE TABLE %s.cf (\n k int PRIMARY ' \ + 'KEY,\n i int)\n ' + session.execute(ddl % keyspace) + + +def init(session, keyspace, n, consistency_level=ConsistencyLevel.ONE): + reset_coordinators() + session.execute('USE %s' % keyspace) + for i in range(n): + ss = SimpleStatement('INSERT INTO %s(k, i) VALUES (0, 0)' % 'cf', + consistency_level=consistency_level) + session.execute(ss) + + +def query(session, keyspace, n, consistency_level=ConsistencyLevel.ONE): + routing_key = struct.pack('>i', 0) + for i in range(n): + ss = SimpleStatement('SELECT * FROM %s WHERE k = 0' % 'cf', + consistency_level=consistency_level, + routing_key=routing_key) + add_coordinator(session.execute_async(ss)) + + +def start(node): + get_node(node).start() + + +def stop(node): + get_node(node).stop() + + +def force_stop(node): + get_node(node).stop(wait=False, gently=False) + + +def wait_for_up(cluster, node): + while True: + host = cluster.metadata.get_host('127.0.0.%s' % node) + if host and host.monitor.is_up: + # BUG: This shouldn't be needed. + # Ideally, host.monitor.is_up would be enough? + # If not, what should I be using? + # time.sleep(25) + return + + +def wait_for_down(cluster, node): + while True: + host = cluster.metadata.get_host('127.0.0.%s' % node) + if not host or not host.monitor.is_up: + # BUG: This shouldn't be needed. + # Ideally, host.monitor.is_up would be enough? + # If not, what should I be using? + # time.sleep(25) + return diff --git a/tests/integration/standard/__init__.py b/tests/integration/standard/__init__.py new file mode 100644 index 0000000000..a1379440a7 --- /dev/null +++ b/tests/integration/standard/__init__.py @@ -0,0 +1 @@ +__author__ = 'joaquin' diff --git a/tests/integration/test_cluster.py b/tests/integration/standard/test_cluster.py similarity index 100% rename from tests/integration/test_cluster.py rename to tests/integration/standard/test_cluster.py diff --git a/tests/integration/test_connection.py b/tests/integration/standard/test_connection.py similarity index 100% rename from tests/integration/test_connection.py rename to tests/integration/standard/test_connection.py diff --git a/tests/integration/test_factories.py b/tests/integration/standard/test_factories.py similarity index 100% rename from tests/integration/test_factories.py rename to tests/integration/standard/test_factories.py diff --git a/tests/integration/test_metadata.py b/tests/integration/standard/test_metadata.py similarity index 100% rename from tests/integration/test_metadata.py rename to tests/integration/standard/test_metadata.py diff --git a/tests/integration/test_metrics.py b/tests/integration/standard/test_metrics.py similarity index 100% rename from tests/integration/test_metrics.py rename to tests/integration/standard/test_metrics.py diff --git a/tests/integration/test_prepared_statements.py b/tests/integration/standard/test_prepared_statements.py similarity index 100% rename from tests/integration/test_prepared_statements.py rename to tests/integration/standard/test_prepared_statements.py diff --git a/tests/integration/test_query.py b/tests/integration/standard/test_query.py similarity index 100% rename from tests/integration/test_query.py rename to tests/integration/standard/test_query.py diff --git a/tests/integration/test_types.py b/tests/integration/standard/test_types.py similarity index 100% rename from tests/integration/test_types.py rename to tests/integration/standard/test_types.py From d37b83bd0a09d2e7e0bcb7adac93c25af3564772 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Fri, 6 Dec 2013 17:29:02 -0600 Subject: [PATCH 0641/4528] Commit pending changes to rebase --- tests/integration/long/test_consistency.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/integration/long/test_consistency.py b/tests/integration/long/test_consistency.py index 13ae73fc09..d2d8ca24a8 100644 --- a/tests/integration/long/test_consistency.py +++ b/tests/integration/long/test_consistency.py @@ -32,14 +32,15 @@ def test_rfone_tokenaware(self): create_schema(session, keyspace, replication_factor=1) init(session, keyspace, 12) - - reset_coordinators() query(session, keyspace, 12) + assert_queried(1, 0) assert_queried(2, 12) assert_queried(3, 0) + try: + reset_coordinators() force_stop(2) wait_for_down(cluster, 2) @@ -79,7 +80,7 @@ def test_rfone_tokenaware(self): try: init(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) - except cassandra.Unavailable as e: + except (cassandra.Unavailable, cassandra.WriteTimeout) as e: if not cl in [ConsistencyLevel.ONE, ConsistencyLevel.TWO, ConsistencyLevel.QUORUM, @@ -120,6 +121,7 @@ def test_rftwo_tokenaware(self): create_schema(session, keyspace, replication_factor=2) init(session, keyspace, 12) + wait_for_up(cluster, 2) reset_coordinators() query(session, keyspace, 12) From fb8aeada275a2a9e878d251487a6fc4ff8e6db6e Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Thu, 12 Dec 2013 19:25:46 -0600 Subject: [PATCH 0642/4528] ConsistencyTests now work. Added SchemaTests as well. --- cassandra/metadata.py | 9 ++-- tests/integration/long/__init__.py | 1 - tests/integration/long/test_consistency.py | 44 +++++++++---------- tests/integration/long/test_schema.py | 51 ++++++++++++++++++++++ tests/integration/long/utils.py | 47 ++++++++++++-------- 5 files changed, 106 insertions(+), 46 deletions(-) create mode 100644 tests/integration/long/test_schema.py diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 8131bed636..359646a802 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -360,14 +360,13 @@ def __init__(self, replication_factor): def make_token_replica_map(self, token_to_host_owner, ring): replica_map = {} for i in range(len(ring)): - j, hosts = 0, set() + j, hosts = 0, list() while len(hosts) < self.replication_factor and j < len(ring): token = ring[(i + j) % len(ring)] - hosts.add(token_to_host_owner[token]) + hosts.append(token_to_host_owner[token]) j += 1 replica_map[ring[i]] = hosts - return replica_map def export_for_schema(self): @@ -384,7 +383,7 @@ def __init__(self, dc_replication_factors): def make_token_replica_map(self, token_to_host_owner, ring): # note: this does not account for hosts having different racks - replica_map = defaultdict(set) + replica_map = defaultdict(list) ring_len = len(ring) ring_len_range = range(ring_len) dc_rf_map = dict((dc, int(rf)) @@ -401,7 +400,7 @@ def make_token_replica_map(self, token_to_host_owner, ring): # we already have all replicas for this DC continue - replica_map[ring[i]].add(host) + replica_map[ring[i]].append(host) if remaining[dc] == 1: del remaining[dc] diff --git a/tests/integration/long/__init__.py b/tests/integration/long/__init__.py index a1379440a7..e69de29bb2 100644 --- a/tests/integration/long/__init__.py +++ b/tests/integration/long/__init__.py @@ -1 +0,0 @@ -__author__ = 'joaquin' diff --git a/tests/integration/long/test_consistency.py b/tests/integration/long/test_consistency.py index d2d8ca24a8..fb976ce9d8 100644 --- a/tests/integration/long/test_consistency.py +++ b/tests/integration/long/test_consistency.py @@ -5,7 +5,7 @@ from cassandra.policies import TokenAwarePolicy, RoundRobinPolicy from tests.integration.long.utils import reset_coordinators, force_stop, \ create_schema, init, query, assert_queried, wait_for_down, wait_for_up, \ - start + start, get_queried try: import unittest2 as unittest @@ -29,6 +29,8 @@ def test_rfone_tokenaware(self): cluster = Cluster( load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) session = cluster.connect() + wait_for_up(cluster, 1) + wait_for_up(cluster, 2) create_schema(session, keyspace, replication_factor=1) init(session, keyspace, 12) @@ -38,7 +40,6 @@ def test_rfone_tokenaware(self): assert_queried(2, 12) assert_queried(3, 0) - try: reset_coordinators() force_stop(2) @@ -57,12 +58,11 @@ def test_rfone_tokenaware(self): ] # Test writes that expected to complete successfully - # BUG: CL.ANY should work - # for cl in accepted_list: - # try: - # init(session, keyspace, 12, consistency_level=cl) - # except Exception as e: - # self._cl_failure(cl, e) + for cl in accepted_list: + try: + init(session, keyspace, 12, consistency_level=cl) + except Exception as e: + self._cl_failure(cl, e) # Test reads that expected to complete successfully for cl in accepted_list: @@ -118,18 +118,19 @@ def test_rftwo_tokenaware(self): cluster = Cluster( load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) session = cluster.connect() + wait_for_up(cluster, 1) + wait_for_up(cluster, 2) create_schema(session, keyspace, replication_factor=2) init(session, keyspace, 12) - wait_for_up(cluster, 2) - - reset_coordinators() query(session, keyspace, 12) + assert_queried(1, 0) assert_queried(2, 12) assert_queried(3, 0) try: + reset_coordinators() force_stop(2) wait_for_down(cluster, 2) @@ -159,11 +160,6 @@ def test_rftwo_tokenaware(self): try: reset_coordinators() query(session, keyspace, 12, consistency_level=cl) - # Bug: I believe the Java-Driver does this differently - # and RoundRobins after the ideal token is not available. - # I like the Python Driver's approach, but we should - # probably make all policies act the same way, whichever - # way gets chosen? assert_queried(1, 0) assert_queried(2, 0) assert_queried(3, 12) @@ -218,14 +214,14 @@ def test_rfthree_tokenaware(self): create_schema(session, keyspace, replication_factor=3) init(session, keyspace, 12) - - reset_coordinators() query(session, keyspace, 12) + assert_queried(1, 0) assert_queried(2, 12) assert_queried(3, 0) try: + reset_coordinators() force_stop(2) wait_for_down(cluster, 2) @@ -255,10 +251,9 @@ def test_rfthree_tokenaware(self): try: reset_coordinators() query(session, keyspace, 12, consistency_level=cl) - # Bug: I believe the Java-Driver does this differently - assert_queried(1, 12) + assert_queried(1, 0) assert_queried(2, 0) - assert_queried(3, 0) + assert_queried(3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -308,16 +303,19 @@ def test_rfthree_tokenaware_none_down(self): cluster = Cluster( load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) session = cluster.connect() + wait_for_up(cluster, 1) + wait_for_up(cluster, 2) create_schema(session, keyspace, replication_factor=3) init(session, keyspace, 12) - - reset_coordinators() query(session, keyspace, 12) + assert_queried(1, 0) assert_queried(2, 12) assert_queried(3, 0) + reset_coordinators() + accepted_list = [ ConsistencyLevel.ANY, ConsistencyLevel.ONE, diff --git a/tests/integration/long/test_schema.py b/tests/integration/long/test_schema.py new file mode 100644 index 0000000000..57484d4644 --- /dev/null +++ b/tests/integration/long/test_schema.py @@ -0,0 +1,51 @@ +import logging +import cassandra + +from cassandra import ConsistencyLevel +from cassandra.cluster import Cluster +from cassandra.query import SimpleStatement + + +try: + import unittest2 as unittest +except ImportError: + import unittest # noqa + +log = logging.getLogger(__name__) + +class SchemaTests(unittest.TestCase): + def test_recreates(self): + cluster = Cluster() + session = cluster.connect() + + + replication_factor = 3 + + for i in range(2): + for keyspace in range(0, 100): + keyspace = 'ks_%s' % keyspace + results = session.execute('SELECT keyspace_name FROM system.schema_keyspaces') + existing_keyspaces = [row[0] for row in results] + if keyspace in existing_keyspaces: + ddl = 'DROP KEYSPACE %s' % keyspace + log.debug(ddl) + session.execute(ddl) + + ddl = "CREATE KEYSPACE %s WITH replication" \ + " = {'class': 'SimpleStrategy', 'replication_factor': '%s'}" % (keyspace, replication_factor) + log.debug(ddl) + session.execute(ddl) + + ddl = 'CREATE TABLE %s.cf (k int PRIMARY KEY, i int)' % keyspace + log.debug(ddl) + session.execute(ddl) + + statement = 'USE %s' % keyspace + log.debug(ddl) + session.execute(statement) + + statement = 'INSERT INTO %s(k, i) VALUES (0, 0)' % 'cf' + log.debug(statement) + ss = SimpleStatement(statement, + consistency_level=ConsistencyLevel.QUORUM) + session.execute(ss) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index 43322efe20..88ac1042bb 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -26,39 +26,52 @@ def reset_coordinators(): coordinators = {} +def get_queried(node): + ip = '127.0.0.%s' % node + if not ip in coordinators: + return 0 + return coordinators[ip] + + def assert_queried(node, n): ip = '127.0.0.%s' % node - print coordinators if ip in coordinators: if coordinators[ip] == n: return raise RuntimeError( - 'IP: %s. Expected: %s. Received: %s.' % (ip, n, coordinators[ip])) + 'IP: %s. Expected: %s. Received: %s. Full detail: %s.' % (ip, n, coordinators[ip], coordinators)) else: if n == 0: return - raise RuntimeError('IP: %s. Expected: %s. Received: %s.' % (ip, n, 0)) + raise RuntimeError('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % (ip, n, 0, coordinators)) def create_schema(session, keyspace, replication_class='SimpleStrategy', replication_factor=1): + results = session.execute( 'SELECT keyspace_name FROM system.schema_keyspaces') existing_keyspaces = [row[0] for row in results] if keyspace in existing_keyspaces: session.execute('DROP KEYSPACE %s' % keyspace) + if replication_class == 'SimpleStrategy': - ddl = "\n CREATE KEYSPACE %s\n WITH replication" \ + ddl = "CREATE KEYSPACE %s WITH replication" \ " = {'class': 'SimpleStrategy', 'replication_factor': '%s'}" session.execute(ddl % (keyspace, replication_factor)) - ddl = '\n CREATE TABLE %s.cf (\n k int PRIMARY ' \ - 'KEY,\n i int)\n ' + + ddl = 'CREATE TABLE %s.cf (k int PRIMARY KEY, i int)' session.execute(ddl % keyspace) + session.execute('USE %s' % keyspace) + + # BUG: probably related to PYTHON-39 + time.sleep(5) def init(session, keyspace, n, consistency_level=ConsistencyLevel.ONE): reset_coordinators() - session.execute('USE %s' % keyspace) + # BUG: PYTHON-38 + # session.execute('USE %s' % keyspace) for i in range(n): ss = SimpleStatement('INSERT INTO %s(k, i) VALUES (0, 0)' % 'cf', consistency_level=consistency_level) @@ -85,24 +98,24 @@ def stop(node): def force_stop(node): get_node(node).stop(wait=False, gently=False) +def ring(node): + print 'From node%s:' % node + get_node(node).nodetool('ring') + def wait_for_up(cluster, node): while True: host = cluster.metadata.get_host('127.0.0.%s' % node) - if host and host.monitor.is_up: - # BUG: This shouldn't be needed. - # Ideally, host.monitor.is_up would be enough? - # If not, what should I be using? - # time.sleep(25) + if host and host.is_up: + # BUG: shouldn't have to, but we do + time.sleep(5) return def wait_for_down(cluster, node): while True: host = cluster.metadata.get_host('127.0.0.%s' % node) - if not host or not host.monitor.is_up: - # BUG: This shouldn't be needed. - # Ideally, host.monitor.is_up would be enough? - # If not, what should I be using? - # time.sleep(25) + if not host or not host.is_up: + # BUG: shouldn't have to, but we do + time.sleep(5) return From c41c9f17de8a46e18da00e00f768515168d60c4c Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Thu, 12 Dec 2013 20:03:39 -0600 Subject: [PATCH 0643/4528] Patch tests to work with recently patched code --- tests/integration/standard/test_metadata.py | 12 ++++++------ tests/unit/test_metadata.py | 2 +- tests/unit/test_policies.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/integration/standard/test_metadata.py b/tests/integration/standard/test_metadata.py index 32be266c32..60a819b000 100644 --- a/tests/integration/standard/test_metadata.py +++ b/tests/integration/standard/test_metadata.py @@ -349,9 +349,9 @@ def test_token_map(self): self.assertNotEqual(list(get_replicas('test3rf', ring[0])), []) for i, token in enumerate(ring): - self.assertEqual(get_replicas('test3rf', token), set(owners)) - self.assertEqual(get_replicas('test2rf', token), set([owners[i], owners[(i + 1) % 3]])) - self.assertEqual(get_replicas('test1rf', token), set([owners[i]])) + self.assertEqual(set(get_replicas('test3rf', token)), set(owners)) + self.assertEqual(set(get_replicas('test2rf', token)), set([owners[i], owners[(i + 1) % 3]])) + self.assertEqual(set(get_replicas('test1rf', token)), set([owners[i]])) class TokenMetadataTest(unittest.TestCase): @@ -379,15 +379,15 @@ def test_getting_replicas(self): # tokens match node tokens exactly for token, expected_host in zip(tokens, hosts): replicas = token_map.get_replicas("ks", token) - self.assertEqual(replicas, set([expected_host])) + self.assertEqual(set(replicas), set([expected_host])) # shift the tokens back by one for token, expected_host in zip(tokens[1:], hosts[1:]): replicas = token_map.get_replicas("ks", MD5Token(str(token.value - 1))) - self.assertEqual(replicas, set([expected_host])) + self.assertEqual(set(replicas), set([expected_host])) # shift the tokens forward by one for i, token in enumerate(tokens): replicas = token_map.get_replicas("ks", MD5Token(str(token.value + 1))) expected_host = hosts[(i + 1) % len(hosts)] - self.assertEqual(replicas, set([expected_host])) + self.assertEqual(set(replicas), set([expected_host])) diff --git a/tests/unit/test_metadata.py b/tests/unit/test_metadata.py index 199c865900..f52c9c32bd 100644 --- a/tests/unit/test_metadata.py +++ b/tests/unit/test_metadata.py @@ -96,7 +96,7 @@ def test_nts_make_token_replica_map_empty_dc(self): nts = NetworkTopologyStrategy({'dc1': 1, 'dc2': 0}) replica_map = nts.make_token_replica_map(token_to_host_owner, ring) - self.assertEqual(replica_map[MD5Token(0)], set([host])) + self.assertEqual(set(replica_map[MD5Token(0)]), set([host])) def test_nts_export_for_schema(self): # TODO: Cover NetworkTopologyStrategy.export_for_schema() diff --git a/tests/unit/test_policies.py b/tests/unit/test_policies.py index d461295c8b..ab99300483 100644 --- a/tests/unit/test_policies.py +++ b/tests/unit/test_policies.py @@ -351,7 +351,7 @@ def test_get_distance(self): self.assertEqual(policy.distance(remote_host), HostDistance.IGNORED) # dc2 isn't registered in the policy's live_hosts dict - policy.child_policy.used_hosts_per_remote_dc = 1 + policy._child_policy.used_hosts_per_remote_dc = 1 self.assertEqual(policy.distance(remote_host), HostDistance.IGNORED) # make sure the policy has both dcs registered From 9c9ebce1eca9a947fc711cacaf9bb7169dc63140 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Thu, 12 Dec 2013 20:04:59 -0600 Subject: [PATCH 0644/4528] PyCharm auto-code removal --- tests/integration/standard/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/standard/__init__.py b/tests/integration/standard/__init__.py index a1379440a7..e69de29bb2 100644 --- a/tests/integration/standard/__init__.py +++ b/tests/integration/standard/__init__.py @@ -1 +0,0 @@ -__author__ = 'joaquin' From 98584beb3cabe8f3b1d7194755a4ab98bb127d28 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Mon, 16 Dec 2013 13:39:27 -0600 Subject: [PATCH 0645/4528] Added policy checks to avoid unexpected exceptions. --- cassandra/cluster.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 985e3a9b36..e528a8f96f 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -283,14 +283,23 @@ def __init__(self, self.auth_provider = auth_provider if load_balancing_policy is not None: + if isinstance(load_balancing_policy, type): + raise ValueError("load_balancing_policy must be an instance") + self.load_balancing_policy = load_balancing_policy else: self.load_balancing_policy = RoundRobinPolicy() if reconnection_policy is not None: + if isinstance(reconnection_policy, type): + raise ValueError("reconnection_policy must be an instance") + self.reconnection_policy = reconnection_policy if default_retry_policy is not None: + if isinstance(default_retry_policy, type): + raise ValueError("default_retry_policy must be an instance") + self.default_retry_policy = default_retry_policy if conviction_policy_factory is not None: From 13818a02c5757eb5148b1ea7f00017df4f874149 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Mon, 16 Dec 2013 13:39:59 -0600 Subject: [PATCH 0646/4528] Add the rest of the CL tests. --- tests/integration/long/test_consistency.py | 274 ++++++++++++++++++++- 1 file changed, 273 insertions(+), 1 deletion(-) diff --git a/tests/integration/long/test_consistency.py b/tests/integration/long/test_consistency.py index fb976ce9d8..34992442bc 100644 --- a/tests/integration/long/test_consistency.py +++ b/tests/integration/long/test_consistency.py @@ -2,7 +2,8 @@ from cassandra import ConsistencyLevel from cassandra.cluster import Cluster -from cassandra.policies import TokenAwarePolicy, RoundRobinPolicy +from cassandra.policies import TokenAwarePolicy, RoundRobinPolicy, \ + DowngradingConsistencyRetryPolicy from tests.integration.long.utils import reset_coordinators, force_stop, \ create_schema, init, query, assert_queried, wait_for_down, wait_for_up, \ start, get_queried @@ -384,3 +385,274 @@ def test_rfthree_tokenaware_none_down(self): if not cl in [ConsistencyLevel.LOCAL_QUORUM, ConsistencyLevel.EACH_QUORUM]: self._cl_failure(cl, e) + + + def test_rfone_downgradingcl(self): + keyspace = 'test_rfone_downgradingcl' + cluster = Cluster( + load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy()), + default_retry_policy=DowngradingConsistencyRetryPolicy()) + session = cluster.connect() + + create_schema(session, keyspace, replication_factor=1) + init(session, keyspace, 12) + query(session, keyspace, 12) + + assert_queried(1, 0) + assert_queried(2, 12) + assert_queried(3, 0) + + try: + reset_coordinators() + force_stop(2) + wait_for_down(cluster, 2) + + accepted_list = [ + ConsistencyLevel.ANY + ] + + fail_list = [ + ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL, + ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM + ] + + # Test writes that expected to complete successfully + for cl in accepted_list: + try: + init(session, keyspace, 12, consistency_level=cl) + except Exception as e: + self._cl_failure(cl, e) + + # Test reads that expected to complete successfully + for cl in accepted_list: + try: + reset_coordinators() + query(session, keyspace, 12, consistency_level=cl) + assert_queried(1, 0) + assert_queried(2, 0) + assert_queried(3, 12) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.ANY]: + self._cl_failure(cl, e) + except Exception as e: + self._cl_failure(cl, e) + + # Test writes that expected to fail + for cl in fail_list: + try: + init(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.Unavailable as e: + if not cl in [ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL]: + self._cl_failure(cl, e) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + + # Test reads that expected to fail + for cl in fail_list: + try: + query(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.Unavailable as e: + if not cl in [ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL]: + self._cl_failure(cl, e) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + finally: + start(2) + wait_for_up(cluster, 2) + + + def test_rftwo_downgradingcl(self): + keyspace = 'test_rftwo_downgradingcl' + cluster = Cluster( + load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy()), + default_retry_policy=DowngradingConsistencyRetryPolicy()) + session = cluster.connect() + + create_schema(session, keyspace, replication_factor=2) + init(session, keyspace, 12) + query(session, keyspace, 12) + + assert_queried(1, 0) + assert_queried(2, 12) + assert_queried(3, 0) + + try: + reset_coordinators() + force_stop(2) + wait_for_down(cluster, 2) + + accepted_list = [ + ConsistencyLevel.ANY, + ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL + ] + + fail_list = [ + ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM + ] + + # Test writes that expected to complete successfully + for cl in accepted_list: + try: + init(session, keyspace, 12, consistency_level=cl) + except Exception as e: + self._cl_failure(cl, e) + + # Test reads that expected to complete successfully + for cl in accepted_list: + try: + reset_coordinators() + query(session, keyspace, 12, consistency_level=cl) + assert_queried(1, 0) + assert_queried(2, 0) + assert_queried(3, 12) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.ANY]: + self._cl_failure(cl, e) + except Exception as e: + self._cl_failure(cl, e) + + # Test writes that expected to fail + for cl in fail_list: + try: + init(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + + # Test reads that expected to fail + for cl in fail_list: + try: + query(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + finally: + start(2) + wait_for_up(cluster, 2) + + + def test_rfthree_roundrobin_downgradingcl(self): + keyspace = 'test_rfthree_roundrobin_downgradingcl' + cluster = Cluster( + load_balancing_policy=RoundRobinPolicy(), + default_retry_policy=DowngradingConsistencyRetryPolicy()) + self.rfthree_downgradingcl(cluster, keyspace) + + def test_rfthree_tokenaware_downgradingcl(self): + keyspace = 'test_rfthree_tokenaware_downgradingcl' + cluster = Cluster( + load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy()), + default_retry_policy=DowngradingConsistencyRetryPolicy()) + self.rfthree_downgradingcl(cluster, keyspace) + + def rfthree_downgradingcl(self, cluster, keyspace): + session = cluster.connect() + + create_schema(session, keyspace, replication_factor=2) + init(session, keyspace, 12) + query(session, keyspace, 12) + + try: + assert_queried(1, 0) + assert_queried(2, 12) + assert_queried(3, 0) + except: + assert_queried(1, 4) + assert_queried(2, 4) + assert_queried(3, 4) + + try: + reset_coordinators() + force_stop(2) + wait_for_down(cluster, 2) + + accepted_list = [ + ConsistencyLevel.ANY, + ConsistencyLevel.ONE, + ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, + ConsistencyLevel.THREE, + ConsistencyLevel.ALL + ] + + fail_list = [ + ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM + ] + + # Test writes that expected to complete successfully + for cl in accepted_list: + try: + init(session, keyspace, 12, consistency_level=cl) + except Exception as e: + self._cl_failure(cl, e) + + # Test reads that expected to complete successfully + for cl in accepted_list: + try: + reset_coordinators() + query(session, keyspace, 12, consistency_level=cl) + # assert_queried(1, 0) + # assert_queried(2, 0) + # assert_queried(3, 12) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.ANY]: + self._cl_failure(cl, e) + except Exception as e: + self._cl_failure(cl, e) + + # Test writes that expected to fail + for cl in fail_list: + try: + init(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + + # Test reads that expected to fail + for cl in fail_list: + try: + query(session, keyspace, 12, consistency_level=cl) + self._cl_expected_failure(cl) + except cassandra.InvalidRequest as e: + if not cl in [ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]: + self._cl_failure(cl, e) + finally: + start(2) + wait_for_up(cluster, 2) + + # TODO: can't be done in this class since we reuse the ccm cluster + # instead we should create these elsewhere + # def test_rfthree_downgradingcl_twodcs(self): + # def test_rfthree_downgradingcl_twodcs_dcaware(self): From ed4636b6494eade54b295e79676a4743c93a092e Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Mon, 16 Dec 2013 13:40:33 -0600 Subject: [PATCH 0647/4528] Modify create_schema to work with NTS. --- tests/integration/long/utils.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index 88ac1042bb..ad27f1ff1c 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -46,8 +46,8 @@ def assert_queried(node, n): raise RuntimeError('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % (ip, n, 0, coordinators)) -def create_schema(session, keyspace, replication_class='SimpleStrategy', - replication_factor=1): +def create_schema(session, keyspace, replication_class='SS', + replication_factor=1, replication_strategy=None): results = session.execute( 'SELECT keyspace_name FROM system.schema_keyspaces') @@ -55,13 +55,20 @@ def create_schema(session, keyspace, replication_class='SimpleStrategy', if keyspace in existing_keyspaces: session.execute('DROP KEYSPACE %s' % keyspace) - if replication_class == 'SimpleStrategy': + if replication_class == 'SS': ddl = "CREATE KEYSPACE %s WITH replication" \ " = {'class': 'SimpleStrategy', 'replication_factor': '%s'}" session.execute(ddl % (keyspace, replication_factor)) + elif replication_class == 'NTS': + if not replication_strategy: + raise Exception('replication_strategy is not set') - ddl = 'CREATE TABLE %s.cf (k int PRIMARY KEY, i int)' - session.execute(ddl % keyspace) + ddl = "CREATE KEYSPACE %s" \ + " WITH replication = { 'class' : 'NetworkTopologyStrategy', %s }" + session.execute(ddl % (keyspace, str(replication_strategy)[1:-1])) + + ddl = 'CREATE TABLE %s.cf (k int PRIMARY KEY, i int)' + session.execute(ddl % keyspace) session.execute('USE %s' % keyspace) # BUG: probably related to PYTHON-39 From bf0d8a286137b6ac9278df36b5d622683832ee92 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Tue, 17 Dec 2013 12:46:56 -0600 Subject: [PATCH 0648/4528] Correctly handle replica set->list conversion --- cassandra/metadata.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 359646a802..e3e33bc9fd 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -360,13 +360,13 @@ def __init__(self, replication_factor): def make_token_replica_map(self, token_to_host_owner, ring): replica_map = {} for i in range(len(ring)): - j, hosts = 0, list() + j, hosts = 0, set() while len(hosts) < self.replication_factor and j < len(ring): token = ring[(i + j) % len(ring)] - hosts.append(token_to_host_owner[token]) + hosts.add(token_to_host_owner[token]) j += 1 - replica_map[ring[i]] = hosts + replica_map[ring[i]] = list(sorted(hosts)) return replica_map def export_for_schema(self): @@ -383,7 +383,7 @@ def __init__(self, dc_replication_factors): def make_token_replica_map(self, token_to_host_owner, ring): # note: this does not account for hosts having different racks - replica_map = defaultdict(list) + replica_map = defaultdict(set) ring_len = len(ring) ring_len_range = range(ring_len) dc_rf_map = dict((dc, int(rf)) @@ -400,7 +400,7 @@ def make_token_replica_map(self, token_to_host_owner, ring): # we already have all replicas for this DC continue - replica_map[ring[i]].append(host) + replica_map[ring[i]].add(host) if remaining[dc] == 1: del remaining[dc] @@ -409,6 +409,8 @@ def make_token_replica_map(self, token_to_host_owner, ring): else: remaining[dc] -= 1 + replica_map[ring[i]] = list(sorted(replica_map[ring[i]])) + return replica_map def export_for_schema(self): From dda950196b1e88624622304f07c2b5528bf0fef6 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 17 Dec 2013 13:15:36 -0600 Subject: [PATCH 0649/4528] Fix unit test breakage from #63 --- tests/unit/test_control_connection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_control_connection.py b/tests/unit/test_control_connection.py index 7b3cd3cc34..2b7aec44b6 100644 --- a/tests/unit/test_control_connection.py +++ b/tests/unit/test_control_connection.py @@ -48,6 +48,7 @@ class MockCluster(object): load_balancing_policy = RoundRobinPolicy() reconnection_policy = ConstantReconnectionPolicy(2) down_host = None + contact_points = [] def __init__(self): self.metadata = MockMetadata() From ce6f9dfe6799d0f99ca55d287445431442be7d88 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Tue, 17 Dec 2013 13:36:55 -0600 Subject: [PATCH 0650/4528] Polish policy instance error messages --- cassandra/cluster.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index e528a8f96f..f9df898b54 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -284,7 +284,7 @@ def __init__(self, if load_balancing_policy is not None: if isinstance(load_balancing_policy, type): - raise ValueError("load_balancing_policy must be an instance") + raise TypeError("load_balancing_policy should not be a class, it should be an instance of that class") self.load_balancing_policy = load_balancing_policy else: @@ -292,13 +292,13 @@ def __init__(self, if reconnection_policy is not None: if isinstance(reconnection_policy, type): - raise ValueError("reconnection_policy must be an instance") + raise TypeError("reconnection_policy should not be a class, it should be an instance of that class") self.reconnection_policy = reconnection_policy if default_retry_policy is not None: if isinstance(default_retry_policy, type): - raise ValueError("default_retry_policy must be an instance") + raise TypeError("default_retry_policy should not be a class, it should be an instance of that class") self.default_retry_policy = default_retry_policy From 0f528f69526b94a1a778a32fc22200bb16ce2509 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 17 Dec 2013 14:59:24 -0600 Subject: [PATCH 0651/4528] Use same conn to check schema agreement Before doing this, the control connection would be used to check for schema agreement. The schema check could occasionally complete before the node that the control connection was open to had even registered the schema change. By re-using the connection that the schema change was performed on, we avoid this race condition. Fixes PYTHON-33 --- cassandra/cluster.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 985e3a9b36..5a9443a028 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1686,9 +1686,10 @@ def run(self): def refresh_schema_and_set_result(keyspace, table, control_conn, response_future): try: - control_conn.refresh_schema(keyspace, table) + control_conn._refresh_schema(response_future._connection, keyspace, table) except Exception: log.exception("Exception refreshing schema in response to schema change:") + response_future.session.submit(control_conn.refresh_schema, keyspace, table) finally: response_future._set_final_result(None) From 5d2d738b5bfc8c880fb1d351970ce08494445709 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Tue, 17 Dec 2013 15:56:02 -0600 Subject: [PATCH 0652/4528] use nose's fail, instead of raising Exceptions in utils.py --- tests/integration/long/test_consistency.py | 84 +++++++++++----------- tests/integration/long/utils.py | 9 +-- 2 files changed, 47 insertions(+), 46 deletions(-) diff --git a/tests/integration/long/test_consistency.py b/tests/integration/long/test_consistency.py index 34992442bc..72ed0bb514 100644 --- a/tests/integration/long/test_consistency.py +++ b/tests/integration/long/test_consistency.py @@ -37,9 +37,9 @@ def test_rfone_tokenaware(self): init(session, keyspace, 12) query(session, keyspace, 12) - assert_queried(1, 0) - assert_queried(2, 12) - assert_queried(3, 0) + assert_queried(self, 1, 0) + assert_queried(self, 2, 12) + assert_queried(self, 3, 0) try: reset_coordinators() @@ -126,9 +126,9 @@ def test_rftwo_tokenaware(self): init(session, keyspace, 12) query(session, keyspace, 12) - assert_queried(1, 0) - assert_queried(2, 12) - assert_queried(3, 0) + assert_queried(self, 1, 0) + assert_queried(self, 2, 12) + assert_queried(self, 3, 0) try: reset_coordinators() @@ -161,9 +161,9 @@ def test_rftwo_tokenaware(self): try: reset_coordinators() query(session, keyspace, 12, consistency_level=cl) - assert_queried(1, 0) - assert_queried(2, 0) - assert_queried(3, 12) + assert_queried(self, 1, 0) + assert_queried(self, 2, 0) + assert_queried(self, 3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -217,9 +217,9 @@ def test_rfthree_tokenaware(self): init(session, keyspace, 12) query(session, keyspace, 12) - assert_queried(1, 0) - assert_queried(2, 12) - assert_queried(3, 0) + assert_queried(self, 1, 0) + assert_queried(self, 2, 12) + assert_queried(self, 3, 0) try: reset_coordinators() @@ -252,9 +252,9 @@ def test_rfthree_tokenaware(self): try: reset_coordinators() query(session, keyspace, 12, consistency_level=cl) - assert_queried(1, 0) - assert_queried(2, 0) - assert_queried(3, 12) + assert_queried(self, 1, 0) + assert_queried(self, 2, 0) + assert_queried(self, 3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -311,9 +311,9 @@ def test_rfthree_tokenaware_none_down(self): init(session, keyspace, 12) query(session, keyspace, 12) - assert_queried(1, 0) - assert_queried(2, 12) - assert_queried(3, 0) + assert_queried(self, 1, 0) + assert_queried(self, 2, 12) + assert_queried(self, 3, 0) reset_coordinators() @@ -343,9 +343,9 @@ def test_rfthree_tokenaware_none_down(self): try: reset_coordinators() query(session, keyspace, 12, consistency_level=cl) - assert_queried(1, 0) - assert_queried(2, 12) - assert_queried(3, 0) + assert_queried(self, 1, 0) + assert_queried(self, 2, 12) + assert_queried(self, 3, 0) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -398,9 +398,9 @@ def test_rfone_downgradingcl(self): init(session, keyspace, 12) query(session, keyspace, 12) - assert_queried(1, 0) - assert_queried(2, 12) - assert_queried(3, 0) + assert_queried(self, 1, 0) + assert_queried(self, 2, 12) + assert_queried(self, 3, 0) try: reset_coordinators() @@ -433,9 +433,9 @@ def test_rfone_downgradingcl(self): try: reset_coordinators() query(session, keyspace, 12, consistency_level=cl) - assert_queried(1, 0) - assert_queried(2, 0) - assert_queried(3, 12) + assert_queried(self, 1, 0) + assert_queried(self, 2, 0) + assert_queried(self, 3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -491,9 +491,9 @@ def test_rftwo_downgradingcl(self): init(session, keyspace, 12) query(session, keyspace, 12) - assert_queried(1, 0) - assert_queried(2, 12) - assert_queried(3, 0) + assert_queried(self, 1, 0) + assert_queried(self, 2, 12) + assert_queried(self, 3, 0) try: reset_coordinators() @@ -526,9 +526,9 @@ def test_rftwo_downgradingcl(self): try: reset_coordinators() query(session, keyspace, 12, consistency_level=cl) - assert_queried(1, 0) - assert_queried(2, 0) - assert_queried(3, 12) + assert_queried(self, 1, 0) + assert_queried(self, 2, 0) + assert_queried(self, 3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -581,13 +581,13 @@ def rfthree_downgradingcl(self, cluster, keyspace): query(session, keyspace, 12) try: - assert_queried(1, 0) - assert_queried(2, 12) - assert_queried(3, 0) + assert_queried(self, 1, 0) + assert_queried(self, 2, 12) + assert_queried(self, 3, 0) except: - assert_queried(1, 4) - assert_queried(2, 4) - assert_queried(3, 4) + assert_queried(self, 1, 4) + assert_queried(self, 2, 4) + assert_queried(self, 3, 4) try: reset_coordinators() @@ -620,9 +620,9 @@ def rfthree_downgradingcl(self, cluster, keyspace): try: reset_coordinators() query(session, keyspace, 12, consistency_level=cl) - # assert_queried(1, 0) - # assert_queried(2, 0) - # assert_queried(3, 12) + # assert_queried(self, 1, 0) + # assert_queried(self, 2, 0) + # assert_queried(self, 3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index ad27f1ff1c..4230afc9a3 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -33,17 +33,18 @@ def get_queried(node): return coordinators[ip] -def assert_queried(node, n): +def assert_queried(testcase, node, n): ip = '127.0.0.%s' % node if ip in coordinators: if coordinators[ip] == n: return - raise RuntimeError( - 'IP: %s. Expected: %s. Received: %s. Full detail: %s.' % (ip, n, coordinators[ip], coordinators)) + testcase.fail('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % ( + ip, n, coordinators[ip], coordinators)) else: if n == 0: return - raise RuntimeError('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % (ip, n, 0, coordinators)) + testcase.fail('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % ( + ip, n, 0, coordinators)) def create_schema(session, keyspace, replication_class='SS', From caf49c92aadcfb7fc5d56bbcfd4e7264cc64087c Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Tue, 17 Dec 2013 16:03:58 -0600 Subject: [PATCH 0653/4528] Use defaultdict(int) instead of checking and assigning --- tests/integration/long/utils.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index 4230afc9a3..307a546949 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -6,16 +6,14 @@ from tests.integration import get_node -coordinators = {} +coordinators = defaultdict(int) def add_coordinator(future): global coordinators coordinator = future._current_host.address - if coordinator in coordinators: - coordinators[coordinator] += 1 - else: - coordinators[coordinator] = 1 + coordinators[coordinator] += 1 + if future._errors: print 'future._errors', future._errors future.result() From ae94856b3b5ad704656f9c4d50ecb98e22f58939 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Tue, 17 Dec 2013 16:11:19 -0600 Subject: [PATCH 0654/4528] Incorporated feedback --- tests/integration/long/utils.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index 307a546949..279cc4164e 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -1,13 +1,17 @@ +import logging import struct import time +from collections import defaultdict + from cassandra.query import SimpleStatement from cassandra import ConsistencyLevel from tests.integration import get_node - coordinators = defaultdict(int) +log = logging.getLogger(__name__) + def add_coordinator(future): global coordinators @@ -15,7 +19,7 @@ def add_coordinator(future): coordinators[coordinator] += 1 if future._errors: - print 'future._errors', future._errors + log.error('future._errors: %s' % future._errors) future.result() @@ -45,7 +49,7 @@ def assert_queried(testcase, node, n): ip, n, 0, coordinators)) -def create_schema(session, keyspace, replication_class='SS', +def create_schema(session, keyspace, simple_strategy=True, replication_factor=1, replication_strategy=None): results = session.execute( @@ -54,11 +58,11 @@ def create_schema(session, keyspace, replication_class='SS', if keyspace in existing_keyspaces: session.execute('DROP KEYSPACE %s' % keyspace) - if replication_class == 'SS': + if simple_strategy: ddl = "CREATE KEYSPACE %s WITH replication" \ " = {'class': 'SimpleStrategy', 'replication_factor': '%s'}" session.execute(ddl % (keyspace, replication_factor)) - elif replication_class == 'NTS': + else: if not replication_strategy: raise Exception('replication_strategy is not set') @@ -84,9 +88,9 @@ def init(session, keyspace, n, consistency_level=ConsistencyLevel.ONE): session.execute(ss) -def query(session, keyspace, n, consistency_level=ConsistencyLevel.ONE): +def query(session, keyspace, count, consistency_level=ConsistencyLevel.ONE): routing_key = struct.pack('>i', 0) - for i in range(n): + for i in range(count): ss = SimpleStatement('SELECT * FROM %s WHERE k = 0' % 'cf', consistency_level=consistency_level, routing_key=routing_key) From 02fe5792a8d8798b80a8a6f8da1fdf1365c691c8 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 18 Dec 2013 11:05:03 -0600 Subject: [PATCH 0655/4528] Use select backend for libev It appears that the epoll backend may have been causing the hanging write requests at the connection level, which appears to be the last major cause of PYTHON-27, PYTHON-29, and PYTHON-32. For now, we will hardcode select as the libev backend; this would be the best choice in almost all circumstances, in any case. --- cassandra/io/libevwrapper.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cassandra/io/libevwrapper.c b/cassandra/io/libevwrapper.c index 51d62543be..59805a5651 100644 --- a/cassandra/io/libevwrapper.c +++ b/cassandra/io/libevwrapper.c @@ -17,7 +17,12 @@ Loop_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { self = (libevwrapper_Loop *)type->tp_alloc(type, 0); if (self != NULL) { - self->loop = ev_default_loop(0); + // select is the most portable backend, it is generally the most + // efficient for the number of file descriptors we'll be working with, + // and the epoll backend seems to occasionally leave write requests + // hanging (although I'm not sure if that's due to a misuse of libev + // or not) + self->loop = ev_default_loop(EVBACKEND_SELECT); if (!self->loop) { PyErr_SetString(PyExc_Exception, "Error getting default ev loop"); Py_DECREF(self); From 2766377dfa9c66212c23c3d043c0d9ec6a3380f8 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 18 Dec 2013 12:43:53 -0600 Subject: [PATCH 0656/4528] Preserve milli-precision in non-prepared datetimes When a datetime was used for a 'timestamp' value in a non-prepared statement, all sub-second precision was lost. After this change, millisecond level precision will be kept. --- cassandra/decoder.py | 4 +++- tests/integration/test_types.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cassandra/decoder.py b/cassandra/decoder.py index 5d487e1b87..f9bfa16c16 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -1,4 +1,5 @@ from binascii import hexlify +import calendar from collections import namedtuple import datetime import logging @@ -788,7 +789,8 @@ def cql_encode_object(val): def cql_encode_datetime(val): - return "'%s'" % val.strftime('%Y-%m-%d %H:%M:%S-0000') + timestamp = calendar.timegm(val.utctimetuple()) + return str(long(timestamp * 1e3 + getattr(val, 'microsecond', 0) / 1e3)) def cql_encode_date(val): diff --git a/tests/integration/test_types.py b/tests/integration/test_types.py index ed0e1fa380..c8c808b521 100644 --- a/tests/integration/test_types.py +++ b/tests/integration/test_types.py @@ -131,7 +131,7 @@ def test_basic_types(self): v1_uuid = uuid1() v4_uuid = uuid4() - mydatetime = datetime(2013, 1, 1, 1, 1, 1) + mydatetime = datetime(2013, 12, 31, 23, 59, 59, 999000) params = [ "sometext", From d2786f8f791c35fc4a0e3cb9e6cad61a7d7fa137 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 12:19:44 -0800 Subject: [PATCH 0657/4528] 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 284bb378ff72b09fe5c38fa6a5fa8639cf4819ec Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Wed, 18 Dec 2013 14:24:27 -0600 Subject: [PATCH 0658/4528] Re-implement previous replica sorting with vnode support --- cassandra/metadata.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index e3e33bc9fd..5a5083e714 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -360,13 +360,15 @@ def __init__(self, replication_factor): def make_token_replica_map(self, token_to_host_owner, ring): replica_map = {} for i in range(len(ring)): - j, hosts = 0, set() + j, hosts = 0, list() while len(hosts) < self.replication_factor and j < len(ring): token = ring[(i + j) % len(ring)] - hosts.add(token_to_host_owner[token]) + host = token_to_host_owner[token] + if not host in hosts: + hosts.append(host) j += 1 - replica_map[ring[i]] = list(sorted(hosts)) + replica_map[ring[i]] = hosts return replica_map def export_for_schema(self): @@ -383,7 +385,7 @@ def __init__(self, dc_replication_factors): def make_token_replica_map(self, token_to_host_owner, ring): # note: this does not account for hosts having different racks - replica_map = defaultdict(set) + replica_map = defaultdict(list) ring_len = len(ring) ring_len_range = range(ring_len) dc_rf_map = dict((dc, int(rf)) @@ -400,7 +402,8 @@ def make_token_replica_map(self, token_to_host_owner, ring): # we already have all replicas for this DC continue - replica_map[ring[i]].add(host) + if not host in replica_map[ring[i]]: + replica_map[ring[i]].append(host) if remaining[dc] == 1: del remaining[dc] @@ -408,9 +411,6 @@ def make_token_replica_map(self, token_to_host_owner, ring): break else: remaining[dc] -= 1 - - replica_map[ring[i]] = list(sorted(replica_map[ring[i]])) - return replica_map def export_for_schema(self): From 75e63d8a95aa3b9a8ac3a75ac19e2d211448c2fe Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Wed, 18 Dec 2013 14:24:47 -0600 Subject: [PATCH 0659/4528] Create the CoordinatorStats class --- tests/integration/long/test_consistency.py | 220 +++++++++++---------- tests/integration/long/utils.py | 95 ++++----- 2 files changed, 162 insertions(+), 153 deletions(-) diff --git a/tests/integration/long/test_consistency.py b/tests/integration/long/test_consistency.py index 72ed0bb514..fde957c8c7 100644 --- a/tests/integration/long/test_consistency.py +++ b/tests/integration/long/test_consistency.py @@ -4,9 +4,8 @@ from cassandra.cluster import Cluster from cassandra.policies import TokenAwarePolicy, RoundRobinPolicy, \ DowngradingConsistencyRetryPolicy -from tests.integration.long.utils import reset_coordinators, force_stop, \ - create_schema, init, query, assert_queried, wait_for_down, wait_for_up, \ - start, get_queried +from tests.integration.long.utils import force_stop, create_schema, \ + wait_for_down, wait_for_up, start, CoordinatorStats try: import unittest2 as unittest @@ -15,6 +14,10 @@ class ConsistencyTests(unittest.TestCase): + + def setUp(self): + self.cs = CoordinatorStats() + def _cl_failure(self, consistency_level, e): self.fail('%s seen for CL.%s with message: %s' % ( type(e), ConsistencyLevel.value_to_name[consistency_level], @@ -34,15 +37,15 @@ def test_rfone_tokenaware(self): wait_for_up(cluster, 2) create_schema(session, keyspace, replication_factor=1) - init(session, keyspace, 12) - query(session, keyspace, 12) + self.cs.init(session, keyspace, 12) + self.cs.query(session, keyspace, 12) - assert_queried(self, 1, 0) - assert_queried(self, 2, 12) - assert_queried(self, 3, 0) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 12) + self.cs.assert_queried(self, 3, 0) try: - reset_coordinators() + self.cs.reset_coordinators() force_stop(2) wait_for_down(cluster, 2) @@ -61,14 +64,14 @@ def test_rfone_tokenaware(self): # Test writes that expected to complete successfully for cl in accepted_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) except Exception as e: self._cl_failure(cl, e) # Test reads that expected to complete successfully for cl in accepted_list: try: - query(session, keyspace, 12, consistency_level=cl) + self.cs.query(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: @@ -79,7 +82,7 @@ def test_rfone_tokenaware(self): # Test writes that expected to fail for cl in fail_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except (cassandra.Unavailable, cassandra.WriteTimeout) as e: if not cl in [ConsistencyLevel.ONE, @@ -96,7 +99,7 @@ def test_rfone_tokenaware(self): # Test reads that expected to fail for cl in fail_list: try: - query(session, keyspace, 12, consistency_level=cl) + self.cs.query(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.Unavailable as e: if not cl in [ConsistencyLevel.ONE, @@ -123,15 +126,15 @@ def test_rftwo_tokenaware(self): wait_for_up(cluster, 2) create_schema(session, keyspace, replication_factor=2) - init(session, keyspace, 12) - query(session, keyspace, 12) + self.cs.init(session, keyspace, 12) + self.cs.query(session, keyspace, 12) - assert_queried(self, 1, 0) - assert_queried(self, 2, 12) - assert_queried(self, 3, 0) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 12) + self.cs.assert_queried(self, 3, 0) try: - reset_coordinators() + self.cs.reset_coordinators() force_stop(2) wait_for_down(cluster, 2) @@ -152,18 +155,18 @@ def test_rftwo_tokenaware(self): # Test writes that expected to complete successfully for cl in accepted_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) except Exception as e: self._cl_failure(cl, e) # Test reads that expected to complete successfully for cl in accepted_list: try: - reset_coordinators() - query(session, keyspace, 12, consistency_level=cl) - assert_queried(self, 1, 0) - assert_queried(self, 2, 0) - assert_queried(self, 3, 12) + self.cs.reset_coordinators() + self.cs.query(session, keyspace, 12, consistency_level=cl) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 0) + self.cs.assert_queried(self, 3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -173,7 +176,7 @@ def test_rftwo_tokenaware(self): # Test writes that expected to fail for cl in fail_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.Unavailable as e: if not cl in [ConsistencyLevel.ONE, @@ -190,7 +193,7 @@ def test_rftwo_tokenaware(self): # Test reads that expected to fail for cl in fail_list: try: - query(session, keyspace, 12, consistency_level=cl) + self.cs.query(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.Unavailable as e: if not cl in [ConsistencyLevel.ONE, @@ -214,15 +217,15 @@ def test_rfthree_tokenaware(self): session = cluster.connect() create_schema(session, keyspace, replication_factor=3) - init(session, keyspace, 12) - query(session, keyspace, 12) + self.cs.init(session, keyspace, 12) + self.cs.query(session, keyspace, 12) - assert_queried(self, 1, 0) - assert_queried(self, 2, 12) - assert_queried(self, 3, 0) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 12) + self.cs.assert_queried(self, 3, 0) try: - reset_coordinators() + self.cs.reset_coordinators() force_stop(2) wait_for_down(cluster, 2) @@ -243,18 +246,18 @@ def test_rfthree_tokenaware(self): # Test writes that expected to complete successfully for cl in accepted_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) except Exception as e: self._cl_failure(cl, e) # Test reads that expected to complete successfully for cl in accepted_list: try: - reset_coordinators() - query(session, keyspace, 12, consistency_level=cl) - assert_queried(self, 1, 0) - assert_queried(self, 2, 0) - assert_queried(self, 3, 12) + self.cs.reset_coordinators() + self.cs.query(session, keyspace, 12, consistency_level=cl) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 0) + self.cs.assert_queried(self, 3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -264,7 +267,7 @@ def test_rfthree_tokenaware(self): # Test writes that expected to fail for cl in fail_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.Unavailable as e: if not cl in [ConsistencyLevel.ONE, @@ -281,7 +284,7 @@ def test_rfthree_tokenaware(self): # Test reads that expected to fail for cl in fail_list: try: - query(session, keyspace, 12, consistency_level=cl) + self.cs.query(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.Unavailable as e: if not cl in [ConsistencyLevel.ONE, @@ -308,14 +311,14 @@ def test_rfthree_tokenaware_none_down(self): wait_for_up(cluster, 2) create_schema(session, keyspace, replication_factor=3) - init(session, keyspace, 12) - query(session, keyspace, 12) + self.cs.init(session, keyspace, 12) + self.cs.query(session, keyspace, 12) - assert_queried(self, 1, 0) - assert_queried(self, 2, 12) - assert_queried(self, 3, 0) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 12) + self.cs.assert_queried(self, 3, 0) - reset_coordinators() + self.cs.reset_coordinators() accepted_list = [ ConsistencyLevel.ANY, @@ -334,18 +337,18 @@ def test_rfthree_tokenaware_none_down(self): # Test writes that expected to complete successfully for cl in accepted_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) except Exception as e: self._cl_failure(cl, e) # Test reads that expected to complete successfully for cl in accepted_list: try: - reset_coordinators() - query(session, keyspace, 12, consistency_level=cl) - assert_queried(self, 1, 0) - assert_queried(self, 2, 12) - assert_queried(self, 3, 0) + self.cs.reset_coordinators() + self.cs.query(session, keyspace, 12, consistency_level=cl) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 12) + self.cs.assert_queried(self, 3, 0) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -355,7 +358,7 @@ def test_rfthree_tokenaware_none_down(self): # Test writes that expected to fail for cl in fail_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.Unavailable as e: if not cl in [ConsistencyLevel.ONE, @@ -372,7 +375,7 @@ def test_rfthree_tokenaware_none_down(self): # Test reads that expected to fail for cl in fail_list: try: - query(session, keyspace, 12, consistency_level=cl) + self.cs.query(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.Unavailable as e: if not cl in [ConsistencyLevel.ONE, @@ -395,15 +398,15 @@ def test_rfone_downgradingcl(self): session = cluster.connect() create_schema(session, keyspace, replication_factor=1) - init(session, keyspace, 12) - query(session, keyspace, 12) + self.cs.init(session, keyspace, 12) + self.cs.query(session, keyspace, 12) - assert_queried(self, 1, 0) - assert_queried(self, 2, 12) - assert_queried(self, 3, 0) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 12) + self.cs.assert_queried(self, 3, 0) try: - reset_coordinators() + self.cs.reset_coordinators() force_stop(2) wait_for_down(cluster, 2) @@ -424,18 +427,18 @@ def test_rfone_downgradingcl(self): # Test writes that expected to complete successfully for cl in accepted_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) except Exception as e: self._cl_failure(cl, e) # Test reads that expected to complete successfully for cl in accepted_list: try: - reset_coordinators() - query(session, keyspace, 12, consistency_level=cl) - assert_queried(self, 1, 0) - assert_queried(self, 2, 0) - assert_queried(self, 3, 12) + self.cs.reset_coordinators() + self.cs.query(session, keyspace, 12, consistency_level=cl) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 0) + self.cs.assert_queried(self, 3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -445,7 +448,7 @@ def test_rfone_downgradingcl(self): # Test writes that expected to fail for cl in fail_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.Unavailable as e: if not cl in [ConsistencyLevel.ONE, @@ -462,7 +465,7 @@ def test_rfone_downgradingcl(self): # Test reads that expected to fail for cl in fail_list: try: - query(session, keyspace, 12, consistency_level=cl) + self.cs.query(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.Unavailable as e: if not cl in [ConsistencyLevel.ONE, @@ -488,15 +491,15 @@ def test_rftwo_downgradingcl(self): session = cluster.connect() create_schema(session, keyspace, replication_factor=2) - init(session, keyspace, 12) - query(session, keyspace, 12) + self.cs.init(session, keyspace, 12) + self.cs.query(session, keyspace, 12) - assert_queried(self, 1, 0) - assert_queried(self, 2, 12) - assert_queried(self, 3, 0) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 12) + self.cs.assert_queried(self, 3, 0) try: - reset_coordinators() + self.cs.reset_coordinators() force_stop(2) wait_for_down(cluster, 2) @@ -517,18 +520,18 @@ def test_rftwo_downgradingcl(self): # Test writes that expected to complete successfully for cl in accepted_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) except Exception as e: self._cl_failure(cl, e) # Test reads that expected to complete successfully for cl in accepted_list: try: - reset_coordinators() - query(session, keyspace, 12, consistency_level=cl) - assert_queried(self, 1, 0) - assert_queried(self, 2, 0) - assert_queried(self, 3, 12) + self.cs.reset_coordinators() + self.cs.query(session, keyspace, 12, consistency_level=cl) + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 0) + self.cs.assert_queried(self, 3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -538,7 +541,7 @@ def test_rftwo_downgradingcl(self): # Test writes that expected to fail for cl in fail_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.LOCAL_QUORUM, @@ -548,7 +551,7 @@ def test_rftwo_downgradingcl(self): # Test reads that expected to fail for cl in fail_list: try: - query(session, keyspace, 12, consistency_level=cl) + self.cs.query(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.LOCAL_QUORUM, @@ -564,33 +567,33 @@ def test_rfthree_roundrobin_downgradingcl(self): cluster = Cluster( load_balancing_policy=RoundRobinPolicy(), default_retry_policy=DowngradingConsistencyRetryPolicy()) - self.rfthree_downgradingcl(cluster, keyspace) + self.rfthree_downgradingcl(cluster, keyspace, True) def test_rfthree_tokenaware_downgradingcl(self): keyspace = 'test_rfthree_tokenaware_downgradingcl' cluster = Cluster( load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy()), default_retry_policy=DowngradingConsistencyRetryPolicy()) - self.rfthree_downgradingcl(cluster, keyspace) + self.rfthree_downgradingcl(cluster, keyspace, False) - def rfthree_downgradingcl(self, cluster, keyspace): + def rfthree_downgradingcl(self, cluster, keyspace, roundrobin): session = cluster.connect() create_schema(session, keyspace, replication_factor=2) - init(session, keyspace, 12) - query(session, keyspace, 12) - - try: - assert_queried(self, 1, 0) - assert_queried(self, 2, 12) - assert_queried(self, 3, 0) - except: - assert_queried(self, 1, 4) - assert_queried(self, 2, 4) - assert_queried(self, 3, 4) + self.cs.init(session, keyspace, 12) + self.cs.query(session, keyspace, 12) + + if roundrobin: + self.cs.assert_queried(self, 1, 4) + self.cs.assert_queried(self, 2, 4) + self.cs.assert_queried(self, 3, 4) + else: + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 12) + self.cs.assert_queried(self, 3, 0) try: - reset_coordinators() + self.cs.reset_coordinators() force_stop(2) wait_for_down(cluster, 2) @@ -611,18 +614,23 @@ def rfthree_downgradingcl(self, cluster, keyspace): # Test writes that expected to complete successfully for cl in accepted_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) except Exception as e: self._cl_failure(cl, e) # Test reads that expected to complete successfully for cl in accepted_list: try: - reset_coordinators() - query(session, keyspace, 12, consistency_level=cl) - # assert_queried(self, 1, 0) - # assert_queried(self, 2, 0) - # assert_queried(self, 3, 12) + self.cs.reset_coordinators() + self.cs.query(session, keyspace, 12, consistency_level=cl) + if roundrobin: + self.cs.assert_queried(self, 1, 6) + self.cs.assert_queried(self, 2, 0) + self.cs.assert_queried(self, 3, 6) + else: + self.cs.assert_queried(self, 1, 0) + self.cs.assert_queried(self, 2, 0) + self.cs.assert_queried(self, 3, 12) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.ANY]: self._cl_failure(cl, e) @@ -632,7 +640,7 @@ def rfthree_downgradingcl(self, cluster, keyspace): # Test writes that expected to fail for cl in fail_list: try: - init(session, keyspace, 12, consistency_level=cl) + self.cs.init(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.LOCAL_QUORUM, @@ -642,7 +650,7 @@ def rfthree_downgradingcl(self, cluster, keyspace): # Test reads that expected to fail for cl in fail_list: try: - query(session, keyspace, 12, consistency_level=cl) + self.cs.query(session, keyspace, 12, consistency_level=cl) self._cl_expected_failure(cl) except cassandra.InvalidRequest as e: if not cl in [ConsistencyLevel.LOCAL_QUORUM, diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index 279cc4164e..8e49467a07 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -8,45 +8,65 @@ from cassandra import ConsistencyLevel from tests.integration import get_node -coordinators = defaultdict(int) log = logging.getLogger(__name__) -def add_coordinator(future): - global coordinators - coordinator = future._current_host.address - coordinators[coordinator] += 1 +class CoordinatorStats(): + def __init__(self): + self.coordinators = defaultdict(int) - if future._errors: - log.error('future._errors: %s' % future._errors) - future.result() + def add_coordinator(self, future): + coordinator = future._current_host.address + self.coordinators[coordinator] += 1 + if future._errors: + log.error('future._errors: %s' % future._errors) + future.result() -def reset_coordinators(): - global coordinators - coordinators = {} + def reset_coordinators(self): + self.coordinators = defaultdict(int) -def get_queried(node): - ip = '127.0.0.%s' % node - if not ip in coordinators: - return 0 - return coordinators[ip] + def get_queried(self, node): + ip = '127.0.0.%s' % node + if not ip in self.coordinators: + return 0 + return self.coordinators[ip] -def assert_queried(testcase, node, n): - ip = '127.0.0.%s' % node - if ip in coordinators: - if coordinators[ip] == n: - return - testcase.fail('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % ( - ip, n, coordinators[ip], coordinators)) - else: - if n == 0: - return - testcase.fail('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % ( - ip, n, 0, coordinators)) + + def assert_queried(self, testcase, node, n): + ip = '127.0.0.%s' % node + if ip in self.coordinators: + if self.coordinators[ip] == n: + return + testcase.fail('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % ( + ip, n, self.coordinators[ip], self.coordinators)) + else: + if n == 0: + return + testcase.fail('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % ( + ip, n, 0, self.coordinators)) + + + def init(self, session, keyspace, n, consistency_level=ConsistencyLevel.ONE): + self.reset_coordinators() + # BUG: PYTHON-38 + # session.execute('USE %s' % keyspace) + for i in range(n): + ss = SimpleStatement('INSERT INTO %s(k, i) VALUES (0, 0)' % 'cf', + consistency_level=consistency_level) + session.execute(ss) + + + def query(self, session, keyspace, count, consistency_level=ConsistencyLevel.ONE): + routing_key = struct.pack('>i', 0) + for i in range(count): + ss = SimpleStatement('SELECT * FROM %s WHERE k = 0' % 'cf', + consistency_level=consistency_level, + routing_key=routing_key) + self.add_coordinator(session.execute_async(ss)) def create_schema(session, keyspace, simple_strategy=True, @@ -78,25 +98,6 @@ def create_schema(session, keyspace, simple_strategy=True, time.sleep(5) -def init(session, keyspace, n, consistency_level=ConsistencyLevel.ONE): - reset_coordinators() - # BUG: PYTHON-38 - # session.execute('USE %s' % keyspace) - for i in range(n): - ss = SimpleStatement('INSERT INTO %s(k, i) VALUES (0, 0)' % 'cf', - consistency_level=consistency_level) - session.execute(ss) - - -def query(session, keyspace, count, consistency_level=ConsistencyLevel.ONE): - routing_key = struct.pack('>i', 0) - for i in range(count): - ss = SimpleStatement('SELECT * FROM %s WHERE k = 0' % 'cf', - consistency_level=consistency_level, - routing_key=routing_key) - add_coordinator(session.execute_async(ss)) - - def start(node): get_node(node).start() From e26da843cc46bb9f58d163f3d3bee90bbb74aa20 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Wed, 18 Dec 2013 16:12:38 -0600 Subject: [PATCH 0660/4528] Print more in-depth stacktraces --- tests/integration/long/test_consistency.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/integration/long/test_consistency.py b/tests/integration/long/test_consistency.py index fde957c8c7..0f12b0c5f0 100644 --- a/tests/integration/long/test_consistency.py +++ b/tests/integration/long/test_consistency.py @@ -1,3 +1,4 @@ +import traceback import cassandra from cassandra import ConsistencyLevel @@ -19,13 +20,13 @@ def setUp(self): self.cs = CoordinatorStats() def _cl_failure(self, consistency_level, e): - self.fail('%s seen for CL.%s with message: %s' % ( + self.fail('%s seen for CL.%s:\n\n%s' % ( type(e), ConsistencyLevel.value_to_name[consistency_level], - e.message)) + traceback.format_exc())) def _cl_expected_failure(self, cl): - self.fail('Test passed at ConsistencyLevel.%s' % - ConsistencyLevel.value_to_name[cl]) + self.fail('Test passed at ConsistencyLevel.%s:\n\n%s' % ( + ConsistencyLevel.value_to_name[cl], traceback.format_exc())) def test_rfone_tokenaware(self): From 9209bef19edb4eb4e591286b8fa17f7235cc733e Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Wed, 18 Dec 2013 16:12:56 -0600 Subject: [PATCH 0661/4528] optimize/remove existing bug comments --- tests/integration/long/test_consistency.py | 6 +++--- tests/integration/long/utils.py | 13 ++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/integration/long/test_consistency.py b/tests/integration/long/test_consistency.py index 0f12b0c5f0..0f7306d6bc 100644 --- a/tests/integration/long/test_consistency.py +++ b/tests/integration/long/test_consistency.py @@ -34,7 +34,7 @@ def test_rfone_tokenaware(self): cluster = Cluster( load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) session = cluster.connect() - wait_for_up(cluster, 1) + wait_for_up(cluster, 1, wait=False) wait_for_up(cluster, 2) create_schema(session, keyspace, replication_factor=1) @@ -123,7 +123,7 @@ def test_rftwo_tokenaware(self): cluster = Cluster( load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) session = cluster.connect() - wait_for_up(cluster, 1) + wait_for_up(cluster, 1, wait=False) wait_for_up(cluster, 2) create_schema(session, keyspace, replication_factor=2) @@ -308,7 +308,7 @@ def test_rfthree_tokenaware_none_down(self): cluster = Cluster( load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) session = cluster.connect() - wait_for_up(cluster, 1) + wait_for_up(cluster, 1, wait=False) wait_for_up(cluster, 2) create_schema(session, keyspace, replication_factor=3) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index 8e49467a07..54aa156a3d 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -94,9 +94,6 @@ def create_schema(session, keyspace, simple_strategy=True, session.execute(ddl % keyspace) session.execute('USE %s' % keyspace) - # BUG: probably related to PYTHON-39 - time.sleep(5) - def start(node): get_node(node).start() @@ -114,19 +111,21 @@ def ring(node): get_node(node).nodetool('ring') -def wait_for_up(cluster, node): +def wait_for_up(cluster, node, wait=True): while True: host = cluster.metadata.get_host('127.0.0.%s' % node) if host and host.is_up: # BUG: shouldn't have to, but we do - time.sleep(5) + if wait: + time.sleep(5) return -def wait_for_down(cluster, node): +def wait_for_down(cluster, node, wait=True): while True: host = cluster.metadata.get_host('127.0.0.%s' % node) if not host or not host.is_up: # BUG: shouldn't have to, but we do - time.sleep(5) + if wait: + time.sleep(5) return From 5a64fa2b672fd180e0941aa59362a2ff6dea4aba Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Wed, 18 Dec 2013 16:27:57 -0600 Subject: [PATCH 0662/4528] Remove last PYTHON-38 workaround --- tests/integration/long/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index 54aa156a3d..f132531a29 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -52,8 +52,7 @@ def assert_queried(self, testcase, node, n): def init(self, session, keyspace, n, consistency_level=ConsistencyLevel.ONE): self.reset_coordinators() - # BUG: PYTHON-38 - # session.execute('USE %s' % keyspace) + session.execute('USE %s' % keyspace) for i in range(n): ss = SimpleStatement('INSERT INTO %s(k, i) VALUES (0, 0)' % 'cf', consistency_level=consistency_level) From 81131ac2af2ab767cbe818f4f41eb7cda34a6423 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 18 Dec 2013 16:00:44 -0800 Subject: [PATCH 0663/4528] 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 0664/4528] 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 0665/4528] 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 0666/4528] 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 0667/4528] 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 0668/4528] 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 0669/4528] 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 0670/4528] 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 0671/4528] 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 0672/4528] 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 0673/4528] 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 b090b555f519c89a52e10519f519fe07adcb9101 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 19 Dec 2013 10:41:27 -0600 Subject: [PATCH 0674/4528] Add explicit support for varchar Fixes PYTHON-40 --- cassandra/cqltypes.py | 4 ++++ tests/integration/test_types.py | 15 +++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index 5e16f78b99..a5439b3948 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -510,6 +510,10 @@ def serialize(ustr): return ustr.encode('utf8') +class VarcharType(UTF8Type): + typename = 'varchar' + + class _ParameterizedType(_CassandraType): def __init__(self, val): if not self.subtypes: diff --git a/tests/integration/test_types.py b/tests/integration/test_types.py index c8c808b521..7ac470d03d 100644 --- a/tests/integration/test_types.py +++ b/tests/integration/test_types.py @@ -124,7 +124,8 @@ def test_basic_types(self): p timestamp, q uuid, r timeuuid, - s varint, + s varchar, + t varint, PRIMARY KEY (a, b) ) """) @@ -151,6 +152,7 @@ def test_basic_types(self): mydatetime, # timestamp v4_uuid, # uuid v1_uuid, # timeuuid + u"sometext\u1234", # varchar 123456789123456789123456789 # varint ] @@ -172,12 +174,13 @@ def test_basic_types(self): mydatetime, # timestamp v4_uuid, # uuid v1_uuid, # timeuuid + u"sometext\u1234", # varchar 123456789123456789123456789 # varint ) s.execute(""" - INSERT INTO mytable (a, b, c, d, f, g, h, i, j, k, l, m, n, o, p, q, r, s) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + INSERT INTO mytable (a, b, c, d, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, params) results = s.execute("SELECT * FROM mytable") @@ -187,8 +190,8 @@ def test_basic_types(self): # try the same thing with a prepared statement prepared = s.prepare(""" - INSERT INTO mytable (a, b, c, d, f, g, h, i, j, k, l, m, n, o, p, q, r, s) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO mytable (a, b, c, d, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """) s.execute(prepared.bind(params)) @@ -200,7 +203,7 @@ def test_basic_types(self): # query with prepared statement prepared = s.prepare(""" - SELECT a, b, c, d, f, g, h, i, j, k, l, m, n, o, p, q, r, s FROM mytable + SELECT a, b, c, d, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t FROM mytable """) results = s.execute(prepared.bind(())) From 55cbca4127c396944093219276dc99226be69d1b Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Thu, 19 Dec 2013 12:42:29 -0800 Subject: [PATCH 0675/4528] 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 0676/4528] 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 0677/4528] 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 0678/4528] 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 0679/4528] 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 0680/4528] 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 0681/4528] 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 0682/4528] 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 0683/4528] 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 0684/4528] 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 0685/4528] 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 01c19ab3ba800ab4febd54e7a4a9c9df3ff47015 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 26 Dec 2013 17:17:52 -0600 Subject: [PATCH 0686/4528] Threadsafe method for updating watcher status --- cassandra/io/libevreactor.py | 117 +++++++++++++++++++-------- cassandra/io/libevwrapper.c | 125 +++++++++++++++++++++++++++++ tests/unit/io/test_libevreactor.py | 2 + 3 files changed, 211 insertions(+), 33 deletions(-) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index 5aa52f9ef7..ae56f9b7e2 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -87,6 +87,17 @@ class LibevConnection(Connection): An implementation of :class:`.Connection` that utilizes libev. """ + # class-level set of all connections; only replaced with a new copy + # while holding _conn_set_lock, never modified in place + _live_conns = set() + # newly created connections that need their write/read watcher started + _new_conns = set() + # recently closed connections that need their write/read watcher stopped + _closed_conns = set() + _conn_set_lock = Lock() + + _write_watcher_is_active = False + _total_reqd_bytes = 0 _read_watcher = None _write_watcher = None @@ -105,6 +116,65 @@ def factory(cls, *args, **kwargs): else: return conn + @classmethod + def _connection_created(cls, conn): + with cls._conn_set_lock: + new_live_conns = cls._live_conns.copy() + new_live_conns.add(conn) + cls._live_conns = new_live_conns + + new_new_conns = cls._new_conns.copy() + new_new_conns.add(conn) + cls._new_conns = new_new_conns + + @classmethod + def _connection_destroyed(cls, conn): + with cls._conn_set_lock: + new_live_conns = cls._live_conns.copy() + new_live_conns.discard(conn) + cls._live_conns = new_live_conns + + new_closed_conns = cls._closed_conns.copy() + new_closed_conns.add(conn) + cls._closed_conns = new_closed_conns + + @classmethod + def loop_will_run(cls, prepare): + changed = False + for conn in cls._live_conns: + if not conn.deque and conn._write_watcher_is_active: + conn._write_watcher.stop() + conn._write_watcher_is_active = False + changed = True + elif conn.deque and not conn._write_watcher_is_active: + conn._write_watcher.start() + conn._write_watcher_is_active = True + changed = True + + if cls._new_conns: + with cls._conn_set_lock: + to_start = cls._new_conns + cls._new_conns = set() + + for conn in to_start: + conn._read_watcher.start() + + changed = True + + if cls._closed_conns: + with cls._conn_set_lock: + to_stop = cls._closed_conns + cls._closed_conns = set() + + for conn in to_stop: + conn._write_watcher.stop() + conn._read_watcher.stop() + + changed = True + + if changed: + _loop_notifier.send() + def __init__(self, *args, **kwargs): Connection.__init__(self, *args, **kwargs) @@ -129,19 +199,17 @@ def __init__(self, *args, **kwargs): for args in self.sockopts: self._socket.setsockopt(*args) - self._read_watcher = libev.IO(self._socket._sock, libev.EV_READ, _loop, self.handle_read) - self._write_watcher = libev.IO(self._socket._sock, libev.EV_WRITE, _loop, self.handle_write) with _loop_lock: - self._read_watcher.start() - self._write_watcher.start() + self._read_watcher = libev.IO(self._socket._sock, libev.EV_READ, _loop, self.handle_read) + self._write_watcher = libev.IO(self._socket._sock, libev.EV_WRITE, _loop, self.handle_write) self._send_options_message() + self.__class__._connection_created(self) + # start the global event loop if needed - if not _start_loop(): - # if the loop was already started, notify it - with _loop_lock: - _loop_notifier.send() + _start_loop() + _loop_notifier.send() def close(self): with self.lock: @@ -150,14 +218,9 @@ def close(self): self.is_closed = True log.debug("Closing connection (%s) to %s", id(self), self.host) - with _loop_lock: - if self._read_watcher: - self._read_watcher.stop() - if self._write_watcher: - self._write_watcher.stop() + self.__class__._connection_destroyed(self) + _loop_notifier.send() self._socket.close() - with _loop_lock: - _loop_notifier.send() # don't leave in-progress operations hanging if not self.is_defunct: @@ -205,11 +268,6 @@ def handle_write(self, watcher, revents): with self._deque_lock: next_msg = self.deque.popleft() except IndexError: - with self._deque_lock: - if not self.deque: - with _loop_lock: - if self._write_watcher.is_active(): - self._write_watcher.stop() return try: @@ -218,7 +276,6 @@ def handle_write(self, watcher, revents): if (err.args[0] in NONBLOCKING): with self._deque_lock: self.deque.appendleft(next_msg) - _loop_notifier.send() else: self.defunct(err) return @@ -226,14 +283,6 @@ def handle_write(self, watcher, revents): if sent < len(next_msg): with self._deque_lock: self.deque.appendleft(next_msg[sent:]) - _loop_notifier.send() - elif not self.deque: - with self._deque_lock: - if not self.deque: - with _loop_lock: - if self._write_watcher.is_active(): - self._write_watcher.stop() - return def handle_read(self, watcher, revents): if revents & libev.EV_ERROR: @@ -305,10 +354,6 @@ def push(self, data): with self._deque_lock: self.deque.extend(chunks) - - with _loop_lock: - if not self._write_watcher.is_active(): - self._write_watcher.start() _loop_notifier.send() def send_msg(self, msg, cb, wait_for_id=False): @@ -372,3 +417,9 @@ def register_watchers(self, type_callback_dict): for event_type, callback in type_callback_dict.items(): self._push_watchers[event_type].add(callback) self.wait_for_response(RegisterMessage(event_list=type_callback_dict.keys())) + + +_preparer = libev.Prepare(_loop, LibevConnection.loop_will_run) +# prevent _preparer from keeping the loop from returning +_loop.unref() +_preparer.start() diff --git a/cassandra/io/libevwrapper.c b/cassandra/io/libevwrapper.c index 59805a5651..6bb1db2d5d 100644 --- a/cassandra/io/libevwrapper.c +++ b/cassandra/io/libevwrapper.c @@ -339,6 +339,123 @@ static PyTypeObject libevwrapper_AsyncType = { (initproc)Async_init, /* tp_init */ }; +typedef struct libevwrapper_Prepare { + PyObject_HEAD + struct ev_prepare prepare; + struct libevwrapper_Loop *loop; + PyObject *callback; +} libevwrapper_Prepare; + +static void +Prepare_dealloc(libevwrapper_Prepare *self) { + Py_XDECREF(self->loop); + Py_XDECREF(self->callback); + self->ob_type->tp_free((PyObject *)self); +} + +static void prepare_callback(struct ev_loop *loop, ev_prepare *watcher, int revents) { + libevwrapper_Prepare *self = watcher->data; + + PyGILState_STATE gstate; + gstate = PyGILState_Ensure(); + + PyObject *result = PyObject_CallFunction(self->callback, "O", self); + if (!result) { + PyErr_WriteUnraisable(self->callback); + } + Py_XDECREF(result); + + PyGILState_Release(gstate); +} + +static int +Prepare_init(libevwrapper_Prepare *self, PyObject *args, PyObject *kwds) { + PyObject *callback; + PyObject *loop; + + if (!PyArg_ParseTuple(args, "OO", &loop, &callback)) { + return -1; + } + + if (loop) { + Py_INCREF(loop); + self->loop = (libevwrapper_Loop *)loop; + } else { + return -1; + } + + if (callback) { + if (!PyCallable_Check(callback)) { + PyErr_SetString(PyExc_TypeError, "callback parameter must be callable"); + Py_XDECREF(loop); + return -1; + } + Py_INCREF(callback); + self->callback = callback; + } + ev_prepare_init(&self->prepare, prepare_callback); + self->prepare.data = self; + return 0; +} + +static PyObject * +Prepare_start(libevwrapper_Prepare *self, PyObject *args) { + ev_prepare_start(self->loop->loop, &self->prepare); + Py_RETURN_NONE; +} + +static PyObject * +Prepare_stop(libevwrapper_Prepare *self, PyObject *args) { + ev_prepare_stop(self->loop->loop, &self->prepare); + Py_RETURN_NONE; +} + +static PyMethodDef Prepare_methods[] = { + {"start", (PyCFunction)Prepare_start, METH_NOARGS, "Start the Prepare watcher"}, + {"stop", (PyCFunction)Prepare_stop, METH_NOARGS, "Stop the Prepare watcher"}, + {NULL} /* Sentinal */ +}; + +static PyTypeObject libevwrapper_PrepareType = { + PyObject_HEAD_INIT(NULL) + 0, /*ob_size*/ + "cassandra.io.libevwrapper.Prepare", /*tp_name*/ + sizeof(libevwrapper_Prepare), /*tp_basicsize*/ + 0, /*tp_itemsize*/ + (destructor)Prepare_dealloc, /*tp_dealloc*/ + 0, /*tp_print*/ + 0, /*tp_getattr*/ + 0, /*tp_setattr*/ + 0, /*tp_compare*/ + 0, /*tp_repr*/ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ + 0, /*tp_hash */ + 0, /*tp_call*/ + 0, /*tp_str*/ + 0, /*tp_getattro*/ + 0, /*tp_setattro*/ + 0, /*tp_as_buffer*/ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/ + "Prepare objects", /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + Prepare_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)Prepare_init, /* tp_init */ +}; + static PyMethodDef module_methods[] = { {NULL} /* Sentinal */ }; @@ -359,6 +476,10 @@ initlibevwrapper(void) if (PyType_Ready(&libevwrapper_IOType) < 0) return; + libevwrapper_PrepareType.tp_new = PyType_GenericNew; + if (PyType_Ready(&libevwrapper_PrepareType) < 0) + return; + libevwrapper_AsyncType.tp_new = PyType_GenericNew; if (PyType_Ready(&libevwrapper_AsyncType) < 0) return; @@ -380,6 +501,10 @@ initlibevwrapper(void) if (PyModule_AddObject(m, "IO", (PyObject *)&libevwrapper_IOType) == -1) return; + Py_INCREF(&libevwrapper_PrepareType); + if (PyModule_AddObject(m, "Prepare", (PyObject *)&libevwrapper_PrepareType) == -1) + return; + Py_INCREF(&libevwrapper_AsyncType); if (PyModule_AddObject(m, "Async", (PyObject *)&libevwrapper_AsyncType) == -1) return; diff --git a/tests/unit/io/test_libevreactor.py b/tests/unit/io/test_libevreactor.py index da9098d63b..7f9f2615e3 100644 --- a/tests/unit/io/test_libevreactor.py +++ b/tests/unit/io/test_libevreactor.py @@ -24,6 +24,8 @@ @patch('socket.socket') @patch('cassandra.io.libevwrapper.IO') +@patch('cassandra.io.libevwrapper.Prepare') +@patch('cassandra.io.libevwrapper.Async') @patch('cassandra.io.libevreactor._start_loop') class LibevConnectionTest(unittest.TestCase): From 8aea9634d6dc241664efab3c0f963aa09a33063d Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 26 Dec 2013 19:01:37 -0600 Subject: [PATCH 0687/4528] Check for non-None watcher before stopping --- cassandra/io/libevreactor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index ae56f9b7e2..672bb9c022 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -167,8 +167,10 @@ def loop_will_run(cls, prepare): cls._closed_conns = set() for conn in to_stop: - conn._write_watcher.stop() - conn._read_watcher.stop() + if conn._write_watcher: + conn._write_watcher.stop() + if conn._write_watcher: + conn._read_watcher.stop() changed = True From cdea9e8b1540721f7038792187dc9c487bf4ceeb Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 26 Dec 2013 19:05:37 -0600 Subject: [PATCH 0688/4528] Update policy unit test for attribute rename --- tests/unit/test_policies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_policies.py b/tests/unit/test_policies.py index d461295c8b..ab99300483 100644 --- a/tests/unit/test_policies.py +++ b/tests/unit/test_policies.py @@ -351,7 +351,7 @@ def test_get_distance(self): self.assertEqual(policy.distance(remote_host), HostDistance.IGNORED) # dc2 isn't registered in the policy's live_hosts dict - policy.child_policy.used_hosts_per_remote_dc = 1 + policy._child_policy.used_hosts_per_remote_dc = 1 self.assertEqual(policy.distance(remote_host), HostDistance.IGNORED) # make sure the policy has both dcs registered From 236e617dac78e9725a46e58d7d7730caeb24f8bc Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 27 Dec 2013 10:58:26 -0600 Subject: [PATCH 0689/4528] Handle more watcher == None cases when stopping loop --- cassandra/io/libevreactor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index 672bb9c022..c40024c46b 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -143,7 +143,8 @@ def loop_will_run(cls, prepare): changed = False for conn in cls._live_conns: if not conn.deque and conn._write_watcher_is_active: - conn._write_watcher.stop() + if conn._write_watcher: + conn._write_watcher.stop() conn._write_watcher_is_active = False changed = True elif conn.deque and not conn._write_watcher_is_active: @@ -169,7 +170,7 @@ def loop_will_run(cls, prepare): for conn in to_stop: if conn._write_watcher: conn._write_watcher.stop() - if conn._write_watcher: + if conn._read_watcher: conn._read_watcher.stop() changed = True From 46fb837843296bde9933eda7b092fc5dca22f7a0 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 27 Dec 2013 11:41:38 -0600 Subject: [PATCH 0690/4528] Hold GIL while setting python error indicator PyErr_SetFromErrno() will result in PyThreadState_Get() being called, which explicitly states that the GIL must be held when it is called. Before this fix, the process could segfault. --- cassandra/io/libevwrapper.c | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/cassandra/io/libevwrapper.c b/cassandra/io/libevwrapper.c index 6bb1db2d5d..e243049902 100644 --- a/cassandra/io/libevwrapper.c +++ b/cassandra/io/libevwrapper.c @@ -118,26 +118,28 @@ IO_dealloc(libevwrapper_IO *self) { }; static void io_callback(struct ev_loop *loop, ev_io *watcher, int revents) { + PyGILState_STATE gstate; if (revents & EV_ERROR) { - if (errno) { - PyErr_SetFromErrno(PyExc_IOError); - } else { - PyErr_SetString(PyExc_IOError, "libev errored"); + if (!PyErr_Occurred()) { + gstate = PyGILState_Ensure(); + if (errno) { + PyErr_SetFromErrno(PyExc_IOError); + } else { + PyErr_SetString(PyExc_IOError, "libev errored"); + } + PyGILState_Release(gstate); } - } - - libevwrapper_IO *self = watcher->data; - - PyGILState_STATE gstate; - gstate = PyGILState_Ensure(); + } else { + libevwrapper_IO *self = watcher->data; - PyObject *result = PyObject_CallFunction(self->callback, "Ob", self, revents); - if (!result) { - PyErr_WriteUnraisable(self->callback); + gstate = PyGILState_Ensure(); + PyObject *result = PyObject_CallFunction(self->callback, "Ob", self, revents); + if (!result) { + PyErr_WriteUnraisable(self->callback); + } + Py_XDECREF(result); + PyGILState_Release(gstate); } - Py_XDECREF(result); - - PyGILState_Release(gstate); }; static int From 48be621748e9fefc960d0979a242a5473c48f069 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 27 Dec 2013 11:50:11 -0600 Subject: [PATCH 0691/4528] Also hold GIL when checking for error indicator --- cassandra/io/libevwrapper.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cassandra/io/libevwrapper.c b/cassandra/io/libevwrapper.c index e243049902..851df9207c 100644 --- a/cassandra/io/libevwrapper.c +++ b/cassandra/io/libevwrapper.c @@ -120,15 +120,15 @@ IO_dealloc(libevwrapper_IO *self) { static void io_callback(struct ev_loop *loop, ev_io *watcher, int revents) { PyGILState_STATE gstate; if (revents & EV_ERROR) { + gstate = PyGILState_Ensure(); if (!PyErr_Occurred()) { - gstate = PyGILState_Ensure(); if (errno) { PyErr_SetFromErrno(PyExc_IOError); } else { PyErr_SetString(PyExc_IOError, "libev errored"); } - PyGILState_Release(gstate); } + PyGILState_Release(gstate); } else { libevwrapper_IO *self = watcher->data; From b6bc523ac02f97c3f653004400f54be467779190 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 27 Dec 2013 15:34:21 -0600 Subject: [PATCH 0692/4528] Raise exc if TokenAware + M3P + no murmur3 ext Instead of logging a warning, if the TokenAwarePolicy is used with a cluster that uses the Murmur3Partitioner, but the murmur3 extension has not been compiled, an exception will be raised when Cluster.connect() is called. --- cassandra/metadata.py | 10 ++++++---- cassandra/policies.py | 18 ++++++++++++++++++ tests/unit/test_policies.py | 18 +++++++++--------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 8131bed636..52e9fb8cf7 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -261,10 +261,6 @@ def rebuild_token_map(self, partitioner, token_map): token_class = MD5Token elif partitioner.endswith('Murmur3Partitioner'): token_class = Murmur3Token - if murmur3 is None: - log.warning( - "The murmur3 C extension is not available, token awareness " - "cannot be supported for the Murmur3Partitioner") elif partitioner.endswith('ByteOrderedPartitioner'): token_class = BytesToken else: @@ -297,6 +293,12 @@ def get_replicas(self, keyspace, key): except NoMurmur3: return [] + def can_support_partitioner(self): + if self.partitioner.endswith('Murmur3Partitioner') and murmur3 is None: + return False + else: + return True + def add_host(self, address): cluster = self.cluster_ref() with self._hosts_lock: diff --git a/cassandra/policies.py b/cassandra/policies.py index d6e4bce75e..34c31a2c15 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -109,6 +109,15 @@ def make_query_plan(self, working_keyspace=None, query=None): """ raise NotImplementedError() + def check_supported(self): + """ + This will be called after the cluster Metadata has been initialized. + If the load balancing policy implementation cannot be supported for + some reason (such as a missing C extension), this is the point at + which it should raise an exception. + """ + pass + class RoundRobinPolicy(LoadBalancingPolicy): """ @@ -271,6 +280,15 @@ def populate(self, cluster, hosts): self._cluster_metadata = cluster.metadata self._child_policy.populate(cluster, hosts) + def check_supported(self): + if not self._cluster_metadata.can_support_partitioner(): + raise Exception( + '%s cannot be used with the cluster partitioner (%s) because ' + 'the relevant C extension for this driver was not compiled. ' + 'See the installation instructions for details on building ' + 'and installing the C extensions.' % (self.__class__.__name__, + self._cluster_metadata.partitioner)) + def distance(self, *args, **kwargs): return self._child_policy.distance(*args, **kwargs) diff --git a/tests/unit/test_policies.py b/tests/unit/test_policies.py index ab99300483..2512862ad4 100644 --- a/tests/unit/test_policies.py +++ b/tests/unit/test_policies.py @@ -130,14 +130,14 @@ def test_with_remotes(self): # allow all of the remote hosts to be used policy = DCAwareRoundRobinPolicy("dc1", used_hosts_per_remote_dc=2) - policy.populate(None, hosts) + policy.populate(Mock(spec=Metadata), hosts) qplan = list(policy.make_query_plan()) self.assertEqual(set(qplan[:2]), local_hosts) self.assertEqual(set(qplan[2:]), remote_hosts) # allow only one of the remote hosts to be used policy = DCAwareRoundRobinPolicy("dc1", used_hosts_per_remote_dc=1) - policy.populate(None, hosts) + policy.populate(Mock(spec=Metadata), hosts) qplan = list(policy.make_query_plan()) self.assertEqual(set(qplan[:2]), local_hosts) @@ -147,7 +147,7 @@ def test_with_remotes(self): # allow no remote hosts to be used policy = DCAwareRoundRobinPolicy("dc1", used_hosts_per_remote_dc=0) - policy.populate(None, hosts) + policy.populate(Mock(spec=Metadata), hosts) qplan = list(policy.make_query_plan()) self.assertEqual(2, len(qplan)) self.assertEqual(local_hosts, set(qplan)) @@ -156,7 +156,7 @@ def test_get_distance(self): policy = DCAwareRoundRobinPolicy("dc1", used_hosts_per_remote_dc=0) host = Host("ip1", SimpleConvictionPolicy) host.set_location_info("dc1", "rack1") - policy.populate(None, [host]) + policy.populate(Mock(spec=Metadata), [host]) self.assertEqual(policy.distance(host), HostDistance.LOCAL) @@ -170,14 +170,14 @@ def test_get_distance(self): self.assertEqual(policy.distance(remote_host), HostDistance.IGNORED) # make sure the policy has both dcs registered - policy.populate(None, [host, remote_host]) + policy.populate(Mock(spec=Metadata), [host, remote_host]) self.assertEqual(policy.distance(remote_host), HostDistance.REMOTE) # since used_hosts_per_remote_dc is set to 1, only the first # remote host in dc2 will be REMOTE, the rest are IGNORED second_remote_host = Host("ip3", SimpleConvictionPolicy) second_remote_host.set_location_info("dc2", "rack1") - policy.populate(None, [host, remote_host, second_remote_host]) + policy.populate(Mock(spec=Metadata), [host, remote_host, second_remote_host]) distances = set([policy.distance(remote_host), policy.distance(second_remote_host)]) self.assertEqual(distances, set([HostDistance.REMOTE, HostDistance.IGNORED])) @@ -189,7 +189,7 @@ def test_status_updates(self): h.set_location_info("dc2", "rack1") policy = DCAwareRoundRobinPolicy("dc1", used_hosts_per_remote_dc=1) - policy.populate(None, hosts) + policy.populate(Mock(spec=Metadata), hosts) policy.on_down(hosts[0]) policy.on_remove(hosts[2]) @@ -231,7 +231,7 @@ def test_no_live_nodes(self): hosts.append(h) policy = DCAwareRoundRobinPolicy("dc1", used_hosts_per_remote_dc=1) - policy.populate(None, hosts) + policy.populate(Mock(spec=Metadata), hosts) for host in hosts: policy.on_down(host) @@ -328,7 +328,7 @@ def get_replicas(keyspace, packed_key): class FakeCluster: def __init__(self): - self.metadata = None + self.metadata = Mock(spec=Metadata) def test_get_distance(self): """ From d6b380aee899dbb21f8ef439102322d5908b9449 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 27 Dec 2013 15:36:11 -0600 Subject: [PATCH 0693/4528] Always use Cluster policies in control connection Before this fix, if the cluster's load_balancing_policy or reconnection_policy were set after __init__() but before connect(), the control connection would not observe the change and would continue to use the wrong policies. --- cassandra/cluster.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 5a9443a028..30d16e5b85 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -433,6 +433,8 @@ def connect(self, keyspace=None): self.shutdown() raise + self.load_balancing_policy.check_supported() + session = self._new_session() if keyspace: session.set_keyspace(keyspace) @@ -1250,9 +1252,6 @@ def __init__(self, cluster, timeout): # use a weak reference to allow the Cluster instance to be GC'ed (and # shutdown) since implementing __del__ disables the cycle detector self._cluster = weakref.proxy(cluster) - self._balancing_policy = cluster.load_balancing_policy - self._balancing_policy.populate(cluster, []) - self._reconnection_policy = cluster.reconnection_policy self._connection = None self._timeout = timeout @@ -1290,7 +1289,7 @@ def _reconnect_internal(self): a connection to that host. """ errors = {} - for host in self._balancing_policy.make_query_plan(): + for host in self._cluster.load_balancing_policy.make_query_plan(): try: return self._try_connect(host) except ConnectionException as exc: @@ -1341,7 +1340,7 @@ def _reconnect(self): self._set_new_connection(self._reconnect_internal()) except NoHostAvailable: # make a retry schedule (which includes backoff) - schedule = self._reconnection_policy.new_schedule() + schedule = self.cluster.reconnection_policy.new_schedule() with self._reconnection_lock: From 8ed3bff63b56d626b7735b5360861aca70eddc65 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 27 Dec 2013 15:38:33 -0600 Subject: [PATCH 0694/4528] Pass errno to libev read/write handlers on error Previously, the error would be set for the current thread, so whatever the next line of Python that was interpreted in that thread was would see the error. Instead, this passes the errno to the appropriate handler function, allowing the connection to be defuncted. --- cassandra/io/libevreactor.py | 18 ++++++++++++++---- cassandra/io/libevwrapper.c | 29 ++++++++++------------------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index c40024c46b..d8e3472ff9 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -261,9 +261,14 @@ def _error_all_callbacks(self, exc): "failed connection (%s) to host %s:", id(self), self.host, exc_info=True) - def handle_write(self, watcher, revents): + def handle_write(self, watcher, revents, errno=None): if revents & libev.EV_ERROR: - self.defunct(Exception("lbev reported an error")) + if errno: + exc = IOError(errno, os.strerror(errno)) + else: + exc = Exception("libev reported an error") + + self.defunct(exc) return while True: @@ -287,9 +292,14 @@ def handle_write(self, watcher, revents): with self._deque_lock: self.deque.appendleft(next_msg[sent:]) - def handle_read(self, watcher, revents): + def handle_read(self, watcher, revents, errno=None): if revents & libev.EV_ERROR: - self.defunct(Exception("lbev reported an error")) + if errno: + exc = IOError(errno, os.strerror(errno)) + else: + exc = Exception("libev reported an error") + + self.defunct(exc) return try: while True: diff --git a/cassandra/io/libevwrapper.c b/cassandra/io/libevwrapper.c index 851df9207c..08f7e3c5a4 100644 --- a/cassandra/io/libevwrapper.c +++ b/cassandra/io/libevwrapper.c @@ -118,28 +118,19 @@ IO_dealloc(libevwrapper_IO *self) { }; static void io_callback(struct ev_loop *loop, ev_io *watcher, int revents) { - PyGILState_STATE gstate; - if (revents & EV_ERROR) { - gstate = PyGILState_Ensure(); - if (!PyErr_Occurred()) { - if (errno) { - PyErr_SetFromErrno(PyExc_IOError); - } else { - PyErr_SetString(PyExc_IOError, "libev errored"); - } - } - PyGILState_Release(gstate); + libevwrapper_IO *self = watcher->data; + PyObject *result; + PyGILState_STATE gstate = PyGILState_Ensure(); + if (revents & EV_ERROR && errno) { + result = PyObject_CallFunction(self->callback, "Obi", self, revents, errno); } else { - libevwrapper_IO *self = watcher->data; - - gstate = PyGILState_Ensure(); PyObject *result = PyObject_CallFunction(self->callback, "Ob", self, revents); - if (!result) { - PyErr_WriteUnraisable(self->callback); - } - Py_XDECREF(result); - PyGILState_Release(gstate); } + if (!result) { + PyErr_WriteUnraisable(self->callback); + } + Py_XDECREF(result); + PyGILState_Release(gstate); }; static int From cea1a5a12597def68d2462c8c3a5e8a957d3a128 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 27 Dec 2013 15:40:30 -0600 Subject: [PATCH 0695/4528] Don't re-declare result variable --- cassandra/io/libevwrapper.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/io/libevwrapper.c b/cassandra/io/libevwrapper.c index 08f7e3c5a4..f098c7507a 100644 --- a/cassandra/io/libevwrapper.c +++ b/cassandra/io/libevwrapper.c @@ -124,7 +124,7 @@ static void io_callback(struct ev_loop *loop, ev_io *watcher, int revents) { if (revents & EV_ERROR && errno) { result = PyObject_CallFunction(self->callback, "Obi", self, revents, errno); } else { - PyObject *result = PyObject_CallFunction(self->callback, "Ob", self, revents); + result = PyObject_CallFunction(self->callback, "Ob", self, revents); } if (!result) { PyErr_WriteUnraisable(self->callback); From ed842e9440826d38a51e25c4fe66d3a699fee69d Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 27 Dec 2013 17:51:53 -0600 Subject: [PATCH 0696/4528] Consistency test cleanup --- tests/integration/long/test_consistency.py | 710 +++++---------------- tests/integration/long/utils.py | 66 +- 2 files changed, 169 insertions(+), 607 deletions(-) diff --git a/tests/integration/long/test_consistency.py b/tests/integration/long/test_consistency.py index 0f7306d6bc..bf8646a887 100644 --- a/tests/integration/long/test_consistency.py +++ b/tests/integration/long/test_consistency.py @@ -1,10 +1,13 @@ +import struct import traceback -import cassandra +import cassandra from cassandra import ConsistencyLevel from cassandra.cluster import Cluster from cassandra.policies import TokenAwarePolicy, RoundRobinPolicy, \ DowngradingConsistencyRetryPolicy +from cassandra.query import SimpleStatement + from tests.integration.long.utils import force_stop, create_schema, \ wait_for_down, wait_for_up, start, CoordinatorStats @@ -13,295 +16,133 @@ except ImportError: import unittest # noqa +ALL_CONSISTENCY_LEVELS = set([ + ConsistencyLevel.ANY, ConsistencyLevel.ONE, ConsistencyLevel.TWO, + ConsistencyLevel.QUORUM, ConsistencyLevel.THREE, + ConsistencyLevel.ALL, ConsistencyLevel.LOCAL_QUORUM, + ConsistencyLevel.EACH_QUORUM]) + +MULTI_DC_CONSISTENCY_LEVELS = set([ + ConsistencyLevel.LOCAL_QUORUM, ConsistencyLevel.EACH_QUORUM]) + +SINGLE_DC_CONSISTENCY_LEVELS = ALL_CONSISTENCY_LEVELS - MULTI_DC_CONSISTENCY_LEVELS + class ConsistencyTests(unittest.TestCase): def setUp(self): - self.cs = CoordinatorStats() + self.coordinator_stats = CoordinatorStats() def _cl_failure(self, consistency_level, e): - self.fail('%s seen for CL.%s:\n\n%s' % ( - type(e), ConsistencyLevel.value_to_name[consistency_level], + self.fail('Instead of success, saw %s for CL.%s:\n\n%s' % ( + e, ConsistencyLevel.value_to_name[consistency_level], traceback.format_exc())) def _cl_expected_failure(self, cl): self.fail('Test passed at ConsistencyLevel.%s:\n\n%s' % ( ConsistencyLevel.value_to_name[cl], traceback.format_exc())) + def _insert(self, session, keyspace, count, consistency_level=ConsistencyLevel.ONE): + session.execute('USE %s' % keyspace) + for i in range(count): + ss = SimpleStatement('INSERT INTO cf(k, i) VALUES (0, 0)', + consistency_level=consistency_level) + session.execute(ss) + + def _query(self, session, keyspace, count, consistency_level=ConsistencyLevel.ONE): + routing_key = struct.pack('>i', 0) + for i in range(count): + ss = SimpleStatement('SELECT * FROM cf WHERE k = 0', + consistency_level=consistency_level, + routing_key=routing_key) + self.coordinator_stats.add_coordinator(session.execute_async(ss)) + + def _assert_writes_succeed(self, session, keyspace, consistency_levels): + for cl in consistency_levels: + self.coordinator_stats.reset_counts() + try: + self._insert(session, keyspace, 1, cl) + except Exception as e: + self._cl_failure(cl, e) - def test_rfone_tokenaware(self): - keyspace = 'test_rfone_tokenaware' - cluster = Cluster( - load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) - session = cluster.connect() - wait_for_up(cluster, 1, wait=False) - wait_for_up(cluster, 2) - - create_schema(session, keyspace, replication_factor=1) - self.cs.init(session, keyspace, 12) - self.cs.query(session, keyspace, 12) - - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 12) - self.cs.assert_queried(self, 3, 0) - - try: - self.cs.reset_coordinators() - force_stop(2) - wait_for_down(cluster, 2) - - accepted_list = [ConsistencyLevel.ANY] - - fail_list = [ - ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL, - ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM - ] - - # Test writes that expected to complete successfully - for cl in accepted_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - except Exception as e: - self._cl_failure(cl, e) + def _assert_reads_succeed(self, session, keyspace, consistency_levels, expected_reader=3): + for cl in consistency_levels: + self.coordinator_stats.reset_counts() + try: + self._query(session, keyspace, 1, cl) + for i in range(3): + if i == expected_reader: + self.coordinator_stats.assert_query_count_equals(self, i, 1) + else: + self.coordinator_stats.assert_query_count_equals(self, i, 0) + except Exception as e: + self._cl_failure(cl, e) - # Test reads that expected to complete successfully - for cl in accepted_list: - try: - self.cs.query(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.ANY]: - self._cl_failure(cl, e) - except Exception as e: - self._cl_failure(cl, e) - - # Test writes that expected to fail - for cl in fail_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except (cassandra.Unavailable, cassandra.WriteTimeout) as e: - if not cl in [ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL]: - self._cl_failure(cl, e) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) - - # Test reads that expected to fail - for cl in fail_list: - try: - self.cs.query(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.Unavailable as e: - if not cl in [ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL]: - self._cl_failure(cl, e) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) - finally: - start(2) - wait_for_up(cluster, 2) + def _assert_writes_fail(self, session, keyspace, consistency_levels): + for cl in consistency_levels: + self.coordinator_stats.reset_counts() + try: + self._insert(session, keyspace, 1, cl) + self._cl_expected_failure(cl) + except (cassandra.Unavailable, cassandra.WriteTimeout): + pass + def _assert_reads_fail(self, session, keyspace, consistency_levels): + for cl in consistency_levels: + self.coordinator_stats.reset_counts() + try: + self._query(session, keyspace, 1, cl) + self._cl_expected_failure(cl) + except (cassandra.Unavailable, cassandra.ReadTimeout): + pass - def test_rftwo_tokenaware(self): - keyspace = 'test_rftwo_tokenaware' + def _test_tokenaware_one_node_down(self, keyspace, rf, accepted): cluster = Cluster( load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) session = cluster.connect() wait_for_up(cluster, 1, wait=False) wait_for_up(cluster, 2) - create_schema(session, keyspace, replication_factor=2) - self.cs.init(session, keyspace, 12) - self.cs.query(session, keyspace, 12) - - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 12) - self.cs.assert_queried(self, 3, 0) + create_schema(session, keyspace, replication_factor=rf) + self._insert(session, keyspace, count=1) + self._query(session, keyspace, count=1) + self.coordinator_stats.assert_query_count_equals(self, 1, 0) + self.coordinator_stats.assert_query_count_equals(self, 2, 1) + self.coordinator_stats.assert_query_count_equals(self, 3, 0) try: - self.cs.reset_coordinators() force_stop(2) wait_for_down(cluster, 2) - accepted_list = [ - ConsistencyLevel.ANY, - ConsistencyLevel.ONE - ] - - fail_list = [ - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL, - ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM - ] - - # Test writes that expected to complete successfully - for cl in accepted_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - except Exception as e: - self._cl_failure(cl, e) - - # Test reads that expected to complete successfully - for cl in accepted_list: - try: - self.cs.reset_coordinators() - self.cs.query(session, keyspace, 12, consistency_level=cl) - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 0) - self.cs.assert_queried(self, 3, 12) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.ANY]: - self._cl_failure(cl, e) - except Exception as e: - self._cl_failure(cl, e) - - # Test writes that expected to fail - for cl in fail_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.Unavailable as e: - if not cl in [ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL]: - self._cl_failure(cl, e) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) - - # Test reads that expected to fail - for cl in fail_list: - try: - self.cs.query(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.Unavailable as e: - if not cl in [ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL]: - self._cl_failure(cl, e) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) - finally: - start(2) - wait_for_up(cluster, 2) - - def test_rfthree_tokenaware(self): - keyspace = 'test_rfthree_tokenaware' - cluster = Cluster( - load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy())) - session = cluster.connect() - - create_schema(session, keyspace, replication_factor=3) - self.cs.init(session, keyspace, 12) - self.cs.query(session, keyspace, 12) - - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 12) - self.cs.assert_queried(self, 3, 0) - - try: - self.cs.reset_coordinators() - force_stop(2) - wait_for_down(cluster, 2) - - accepted_list = [ - ConsistencyLevel.ANY, - ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM - ] - - fail_list = [ - ConsistencyLevel.THREE, - ConsistencyLevel.ALL, - ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM - ] - - # Test writes that expected to complete successfully - for cl in accepted_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - except Exception as e: - self._cl_failure(cl, e) - - # Test reads that expected to complete successfully - for cl in accepted_list: - try: - self.cs.reset_coordinators() - self.cs.query(session, keyspace, 12, consistency_level=cl) - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 0) - self.cs.assert_queried(self, 3, 12) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.ANY]: - self._cl_failure(cl, e) - except Exception as e: - self._cl_failure(cl, e) - - # Test writes that expected to fail - for cl in fail_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.Unavailable as e: - if not cl in [ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL]: - self._cl_failure(cl, e) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) - - # Test reads that expected to fail - for cl in fail_list: - try: - self.cs.query(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.Unavailable as e: - if not cl in [ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL]: - self._cl_failure(cl, e) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) + self._assert_writes_succeed(session, keyspace, accepted) + self._assert_reads_succeed(session, keyspace, + accepted - set([ConsistencyLevel.ANY])) + self._assert_writes_fail(session, keyspace, + SINGLE_DC_CONSISTENCY_LEVELS - accepted) + self._assert_reads_fail(session, keyspace, + SINGLE_DC_CONSISTENCY_LEVELS - accepted) finally: start(2) wait_for_up(cluster, 2) + def test_rfone_tokenaware_one_node_down(self): + self._test_tokenaware_one_node_down( + keyspace='test_rfone_tokenaware', + rf=1, + accepted=set([ConsistencyLevel.ANY])) + + def test_rftwo_tokenaware_one_node_down(self): + self._test_tokenaware_one_node_down( + keyspace='test_rftwo_tokenaware', + rf=2, + accepted=set([ConsistencyLevel.ANY, ConsistencyLevel.ONE])) + + def test_rfthree_tokenaware_one_node_down(self): + self._test_tokenaware_one_node_down( + keyspace='test_rfthree_tokenaware', + rf=3, + accepted=set([ConsistencyLevel.ANY, ConsistencyLevel.ONE, + ConsistencyLevel.TWO, ConsistencyLevel.QUORUM])) def test_rfthree_tokenaware_none_down(self): keyspace = 'test_rfthree_tokenaware_none_down' @@ -312,256 +153,58 @@ def test_rfthree_tokenaware_none_down(self): wait_for_up(cluster, 2) create_schema(session, keyspace, replication_factor=3) - self.cs.init(session, keyspace, 12) - self.cs.query(session, keyspace, 12) - - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 12) - self.cs.assert_queried(self, 3, 0) - - self.cs.reset_coordinators() - - accepted_list = [ - ConsistencyLevel.ANY, - ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL, - ] - - fail_list = [ - ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM - ] - - # Test writes that expected to complete successfully - for cl in accepted_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - except Exception as e: - self._cl_failure(cl, e) + self._insert(session, keyspace, count=1) + self._query(session, keyspace, count=1) + self.coordinator_stats.assert_query_count_equals(self, 1, 0) + self.coordinator_stats.assert_query_count_equals(self, 2, 1) + self.coordinator_stats.assert_query_count_equals(self, 3, 0) - # Test reads that expected to complete successfully - for cl in accepted_list: - try: - self.cs.reset_coordinators() - self.cs.query(session, keyspace, 12, consistency_level=cl) - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 12) - self.cs.assert_queried(self, 3, 0) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.ANY]: - self._cl_failure(cl, e) - except Exception as e: - self._cl_failure(cl, e) - - # Test writes that expected to fail - for cl in fail_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.Unavailable as e: - if not cl in [ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL]: - self._cl_failure(cl, e) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) - - # Test reads that expected to fail - for cl in fail_list: - try: - self.cs.query(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.Unavailable as e: - if not cl in [ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL]: - self._cl_failure(cl, e) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) + self.coordinator_stats.reset_counts() + self._assert_writes_succeed(session, keyspace, SINGLE_DC_CONSISTENCY_LEVELS) + self._assert_reads_succeed(session, keyspace, + SINGLE_DC_CONSISTENCY_LEVELS - set([ConsistencyLevel.ANY]), + expected_reader=2) - def test_rfone_downgradingcl(self): - keyspace = 'test_rfone_downgradingcl' + def _test_downgrading_cl(self, keyspace, rf, accepted): cluster = Cluster( load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy()), default_retry_policy=DowngradingConsistencyRetryPolicy()) session = cluster.connect() - create_schema(session, keyspace, replication_factor=1) - self.cs.init(session, keyspace, 12) - self.cs.query(session, keyspace, 12) - - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 12) - self.cs.assert_queried(self, 3, 0) + create_schema(session, keyspace, replication_factor=rf) + self._insert(session, keyspace, 1) + self._query(session, keyspace, 1) + self.coordinator_stats.assert_query_count_equals(self, 1, 0) + self.coordinator_stats.assert_query_count_equals(self, 2, 1) + self.coordinator_stats.assert_query_count_equals(self, 3, 0) try: - self.cs.reset_coordinators() force_stop(2) wait_for_down(cluster, 2) - accepted_list = [ - ConsistencyLevel.ANY - ] - - fail_list = [ - ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL, - ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM - ] - - # Test writes that expected to complete successfully - for cl in accepted_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - except Exception as e: - self._cl_failure(cl, e) - - # Test reads that expected to complete successfully - for cl in accepted_list: - try: - self.cs.reset_coordinators() - self.cs.query(session, keyspace, 12, consistency_level=cl) - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 0) - self.cs.assert_queried(self, 3, 12) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.ANY]: - self._cl_failure(cl, e) - except Exception as e: - self._cl_failure(cl, e) - - # Test writes that expected to fail - for cl in fail_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.Unavailable as e: - if not cl in [ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL]: - self._cl_failure(cl, e) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) - - # Test reads that expected to fail - for cl in fail_list: - try: - self.cs.query(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.Unavailable as e: - if not cl in [ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL]: - self._cl_failure(cl, e) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) + self._assert_writes_succeed(session, keyspace, accepted) + self._assert_reads_succeed(session, keyspace, + accepted - set([ConsistencyLevel.ANY])) + self._assert_writes_fail(session, keyspace, + SINGLE_DC_CONSISTENCY_LEVELS - accepted) + self._assert_reads_fail(session, keyspace, + SINGLE_DC_CONSISTENCY_LEVELS - accepted) finally: start(2) wait_for_up(cluster, 2) + def test_rfone_downgradingcl(self): + self._test_downgrading_cl( + keyspace='test_rfone_downgradingcl', + rf=1, + accepted=set([ConsistencyLevel.ANY])) def test_rftwo_downgradingcl(self): - keyspace = 'test_rftwo_downgradingcl' - cluster = Cluster( - load_balancing_policy=TokenAwarePolicy(RoundRobinPolicy()), - default_retry_policy=DowngradingConsistencyRetryPolicy()) - session = cluster.connect() - - create_schema(session, keyspace, replication_factor=2) - self.cs.init(session, keyspace, 12) - self.cs.query(session, keyspace, 12) - - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 12) - self.cs.assert_queried(self, 3, 0) - - try: - self.cs.reset_coordinators() - force_stop(2) - wait_for_down(cluster, 2) - - accepted_list = [ - ConsistencyLevel.ANY, - ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL - ] - - fail_list = [ - ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM - ] - - # Test writes that expected to complete successfully - for cl in accepted_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - except Exception as e: - self._cl_failure(cl, e) - - # Test reads that expected to complete successfully - for cl in accepted_list: - try: - self.cs.reset_coordinators() - self.cs.query(session, keyspace, 12, consistency_level=cl) - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 0) - self.cs.assert_queried(self, 3, 12) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.ANY]: - self._cl_failure(cl, e) - except Exception as e: - self._cl_failure(cl, e) - - # Test writes that expected to fail - for cl in fail_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) - - # Test reads that expected to fail - for cl in fail_list: - try: - self.cs.query(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) - finally: - start(2) - wait_for_up(cluster, 2) - + self._test_downgrading_cl( + keyspace='test_rftwo_downgradingcl', + rf=2, + accepted=SINGLE_DC_CONSISTENCY_LEVELS) def test_rfthree_roundrobin_downgradingcl(self): keyspace = 'test_rfthree_roundrobin_downgradingcl' @@ -581,82 +224,37 @@ def rfthree_downgradingcl(self, cluster, keyspace, roundrobin): session = cluster.connect() create_schema(session, keyspace, replication_factor=2) - self.cs.init(session, keyspace, 12) - self.cs.query(session, keyspace, 12) + self._insert(session, keyspace, count=12) + self._query(session, keyspace, count=12) if roundrobin: - self.cs.assert_queried(self, 1, 4) - self.cs.assert_queried(self, 2, 4) - self.cs.assert_queried(self, 3, 4) + self.coordinator_stats.assert_query_count_equals(self, 1, 4) + self.coordinator_stats.assert_query_count_equals(self, 2, 4) + self.coordinator_stats.assert_query_count_equals(self, 3, 4) else: - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 12) - self.cs.assert_queried(self, 3, 0) + self.coordinator_stats.assert_query_count_equals(self, 1, 0) + self.coordinator_stats.assert_query_count_equals(self, 2, 12) + self.coordinator_stats.assert_query_count_equals(self, 3, 0) try: - self.cs.reset_coordinators() + self.coordinator_stats.reset_counts() force_stop(2) wait_for_down(cluster, 2) - accepted_list = [ - ConsistencyLevel.ANY, - ConsistencyLevel.ONE, - ConsistencyLevel.TWO, - ConsistencyLevel.QUORUM, - ConsistencyLevel.THREE, - ConsistencyLevel.ALL - ] - - fail_list = [ - ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM - ] - - # Test writes that expected to complete successfully - for cl in accepted_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - except Exception as e: - self._cl_failure(cl, e) + self._assert_writes_succeed(session, keyspace, SINGLE_DC_CONSISTENCY_LEVELS) # Test reads that expected to complete successfully - for cl in accepted_list: - try: - self.cs.reset_coordinators() - self.cs.query(session, keyspace, 12, consistency_level=cl) - if roundrobin: - self.cs.assert_queried(self, 1, 6) - self.cs.assert_queried(self, 2, 0) - self.cs.assert_queried(self, 3, 6) - else: - self.cs.assert_queried(self, 1, 0) - self.cs.assert_queried(self, 2, 0) - self.cs.assert_queried(self, 3, 12) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.ANY]: - self._cl_failure(cl, e) - except Exception as e: - self._cl_failure(cl, e) - - # Test writes that expected to fail - for cl in fail_list: - try: - self.cs.init(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) - - # Test reads that expected to fail - for cl in fail_list: - try: - self.cs.query(session, keyspace, 12, consistency_level=cl) - self._cl_expected_failure(cl) - except cassandra.InvalidRequest as e: - if not cl in [ConsistencyLevel.LOCAL_QUORUM, - ConsistencyLevel.EACH_QUORUM]: - self._cl_failure(cl, e) + for cl in SINGLE_DC_CONSISTENCY_LEVELS - set([ConsistencyLevel.ANY]): + self.coordinator_stats.reset_counts() + self._query(session, keyspace, 12, consistency_level=cl) + if roundrobin: + self.coordinator_stats.assert_query_count_equals(self, 1, 6) + self.coordinator_stats.assert_query_count_equals(self, 2, 0) + self.coordinator_stats.assert_query_count_equals(self, 3, 6) + else: + self.coordinator_stats.assert_query_count_equals(self, 1, 0) + self.coordinator_stats.assert_query_count_equals(self, 2, 0) + self.coordinator_stats.assert_query_count_equals(self, 3, 12) finally: start(2) wait_for_up(cluster, 2) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index f132531a29..e8e4ca4419 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -1,11 +1,8 @@ import logging -import struct import time from collections import defaultdict -from cassandra.query import SimpleStatement -from cassandra import ConsistencyLevel from tests.integration import get_node @@ -13,59 +10,26 @@ class CoordinatorStats(): + def __init__(self): - self.coordinators = defaultdict(int) + self.coordinator_counts = defaultdict(int) def add_coordinator(self, future): coordinator = future._current_host.address - self.coordinators[coordinator] += 1 + self.coordinator_counts[coordinator] += 1 if future._errors: - log.error('future._errors: %s' % future._errors) + log.error('future._errors: %s', future._errors) future.result() + def reset_counts(self): + self.coordinator_counts = defaultdict(int) - def reset_coordinators(self): - self.coordinators = defaultdict(int) - - - def get_queried(self, node): - ip = '127.0.0.%s' % node - if not ip in self.coordinators: - return 0 - return self.coordinators[ip] - - - def assert_queried(self, testcase, node, n): - ip = '127.0.0.%s' % node - if ip in self.coordinators: - if self.coordinators[ip] == n: - return - testcase.fail('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % ( - ip, n, self.coordinators[ip], self.coordinators)) - else: - if n == 0: - return - testcase.fail('IP: %s. Expected: %s. Received: %s. Full detail: %s.' % ( - ip, n, 0, self.coordinators)) - - - def init(self, session, keyspace, n, consistency_level=ConsistencyLevel.ONE): - self.reset_coordinators() - session.execute('USE %s' % keyspace) - for i in range(n): - ss = SimpleStatement('INSERT INTO %s(k, i) VALUES (0, 0)' % 'cf', - consistency_level=consistency_level) - session.execute(ss) - - - def query(self, session, keyspace, count, consistency_level=ConsistencyLevel.ONE): - routing_key = struct.pack('>i', 0) - for i in range(count): - ss = SimpleStatement('SELECT * FROM %s WHERE k = 0' % 'cf', - consistency_level=consistency_level, - routing_key=routing_key) - self.add_coordinator(session.execute_async(ss)) + def assert_query_count_equals(self, testcase, node, expected): + ip = '127.0.0.%d' % node + if self.coordinator_counts[ip] != expected: + testcase.fail('Expected %d queries to %s, but got %d. Query counts: %s' % ( + expected, ip, self.coordinator_counts[ip], dict(self.coordinator_counts))) def create_schema(session, keyspace, simple_strategy=True, @@ -75,22 +39,22 @@ def create_schema(session, keyspace, simple_strategy=True, 'SELECT keyspace_name FROM system.schema_keyspaces') existing_keyspaces = [row[0] for row in results] if keyspace in existing_keyspaces: - session.execute('DROP KEYSPACE %s' % keyspace) + session.execute('DROP KEYSPACE %s' % keyspace, timeout=10) if simple_strategy: ddl = "CREATE KEYSPACE %s WITH replication" \ " = {'class': 'SimpleStrategy', 'replication_factor': '%s'}" - session.execute(ddl % (keyspace, replication_factor)) + session.execute(ddl % (keyspace, replication_factor), timeout=10) else: if not replication_strategy: raise Exception('replication_strategy is not set') ddl = "CREATE KEYSPACE %s" \ " WITH replication = { 'class' : 'NetworkTopologyStrategy', %s }" - session.execute(ddl % (keyspace, str(replication_strategy)[1:-1])) + session.execute(ddl % (keyspace, str(replication_strategy)[1:-1]), timeout=10) ddl = 'CREATE TABLE %s.cf (k int PRIMARY KEY, i int)' - session.execute(ddl % keyspace) + session.execute(ddl % keyspace, timeout=10) session.execute('USE %s' % keyspace) From 83b9df7db694e39bb7c9a3cdaefbc291586bd00e Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 27 Dec 2013 18:19:59 -0600 Subject: [PATCH 0697/4528] Update changelog for release in progress --- CHANGELOG.rst | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 62023c284d..0cd2a0aaef 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,44 @@ +1.0.0 Final +=========== +(In Progress) + +Bug Fixes +--------- +* Prevent leak of Scheduler thread (even with proper shutdown) +* Correctly handle ignored hosts, which are common with the + DCAwareRoundRobinPolicy +* Hold strong reference to prepared statement while executing it to avoid + garbage collection +* Add NullHandler logging handler to the cassandra package to avoid + warnings about there being no configured logger +* Fix bad handling of nodes that have been removed from the cluster +* Properly escape string types within cql collections +* Handle setting the same keyspace twice in a row +* Avoid race condition during schema agreement checks that could result + in schema update queries returning before all nodes had seen the change +* Preserve millisecond-level precision in datetimes when performing inserts + with simple (non-prepared) statements +* Properly defunct connections when libev reports an error by setting + errno instead of simply logging the error +* Fix endless hanging of some requests when using the libev reactor + +Features +-------- +* Add default query timeout to ``Session`` +* Add timeout parameter to ``Session.execute()`` +* Add ``WhiteListRoundRobinPolicy`` as a load balancing policy option +* Support for consistency level ``LOCAL_ONE`` + +Other +----- +* Raise Exception if ``TokenAwarePolicy`` is used against a cluster using the + ``Murmur3Partitioner`` if the murmur3 C extension has not been compiled +* Add encoder mapping for ``OrderedDict`` +* Use timeouts on all control connection queries +* Benchmark improvements, including command line options and eay + multithreading support +* Reduced lock contention when using the asyncore reactor + 1.0.0b7 ======= Nov 12, 2013 From b541d3a420983c4103f82508808189b99bda92ca Mon Sep 17 00:00:00 2001 From: Blake Eggleston Date: Mon, 30 Dec 2013 14:58:58 -0800 Subject: [PATCH 0698/4528] 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 38bd0a7903e96ca5c262c82e7ec0634f3b19cb0b Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 3 Jan 2014 17:38:48 -0600 Subject: [PATCH 0699/4528] Use lock when updating prepared stmt cache --- cassandra/cluster.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 2eaa297d48..7c5baec8e8 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -241,6 +241,7 @@ class Cluster(object): _is_shutdown = False _is_setup = False _prepared_statements = None + _prepared_statement_lock = Lock() _listeners = None _listener_lock = None @@ -357,7 +358,7 @@ def __init__(self, self.metrics = Metrics(weakref.proxy(self)) self.control_connection = ControlConnection( - self, self.control_connection_timeout) + self, self.control_connection_timeout) def get_min_requests_per_connection(self, host_distance): return self._min_requests_per_connection[host_distance] @@ -815,7 +816,8 @@ def _prepare_all_queries(self, host): connection.close() def prepare_on_all_sessions(self, query_id, prepared_statement, excluded_host): - self._prepared_statements[query_id] = prepared_statement + with self._prepared_statement_lock: + self._prepared_statements[query_id] = prepared_statement for session in self.sessions: session.prepare_on_all_hosts(prepared_statement.query_string, excluded_host) @@ -1421,14 +1423,14 @@ def _refresh_schema(self, connection, keyspace=None, table=None): if ks_query: ks_result, cf_result, col_result = connection.wait_for_responses( - ks_query, cf_query, col_query, timeout=self._timeout) + ks_query, cf_query, col_query, timeout=self._timeout) ks_result = dict_factory(*ks_result.results) cf_result = dict_factory(*cf_result.results) col_result = dict_factory(*col_result.results) else: ks_result = None cf_result, col_result = connection.wait_for_responses( - cf_query, col_query, timeout=self._timeout) + cf_query, col_query, timeout=self._timeout) cf_result = dict_factory(*cf_result.results) col_result = dict_factory(*col_result.results) @@ -1448,7 +1450,7 @@ def _refresh_node_list_and_token_map(self, connection): peers_query = QueryMessage(query=self._SELECT_PEERS, consistency_level=cl) local_query = QueryMessage(query=self._SELECT_LOCAL, consistency_level=cl) peers_result, local_result = connection.wait_for_responses( - peers_query, local_query, timeout=self._timeout) + peers_query, local_query, timeout=self._timeout) peers_result = dict_factory(*peers_result.results) partitioner = None @@ -1562,7 +1564,7 @@ def wait_for_schema_agreement(self, connection=None): try: timeout = min(2.0, total_timeout - elapsed) peers_result, local_result = connection.wait_for_responses( - peers_query, local_query, timeout=timeout) + peers_query, local_query, timeout=timeout) except OperationTimedOut: log.debug("[control connection] Timed out waiting for response during schema agreement check") elapsed = self._time.time() - start @@ -1883,7 +1885,14 @@ def _set_result(self, response): self._retry(reuse_connection=False, consistency_level=None) return elif isinstance(response, PreparedQueryNotFound): - query_id = response.info + if self.prepared_statement: + query_id = self.prepared_statement.query_id + assert query_id == response.info, \ + "Got different query ID in server response (%s) than we " \ + "had before (%s)" % (response.info, query_id) + else: + query_id = response.info + try: prepared_statement = self.session.cluster._prepared_statements[query_id] except KeyError: From 7474e3c48fccdac6d728663587d86ce1a6b950d8 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 7 Jan 2014 16:04:17 -0600 Subject: [PATCH 0700/4528] Avoid raising None on failed set_keyspace If the connection was already defunct when the set_keyspace call failed, None would be raised, resulting in a TypeError. --- cassandra/connection.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index a736a4da39..027259ea9e 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -305,14 +305,18 @@ def set_keyspace_blocking(self, keyspace): # the keyspace probably doesn't exist raise ire.to_exception() except Exception as exc: - raise self.defunct(ConnectionException( - "Problem while setting keyspace: %r" % (exc,), self.host)) + conn_exc = ConnectionException( + "Problem while setting keyspace: %r" % (exc,), self.host) + self.defunct(conn_exc) + raise conn_exc if isinstance(result, ResultMessage): self.keyspace = keyspace else: - raise self.defunct(ConnectionException( - "Problem while setting keyspace: %r" % (result,), self.host)) + conn_exc = ConnectionException( + "Problem while setting keyspace: %r" % (result,), self.host) + self.defunct(conn_exc) + raise conn_exc def set_keyspace_async(self, keyspace, callback): """ From 6761bc2e17a4ca42e25c113cfb80a8770145a58b Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 7 Jan 2014 17:06:10 -0600 Subject: [PATCH 0701/4528] Hold deque lock when popping in asyncore In multithreaded environments, this could result in corruption of the deque, leading to malformed messages. Additionally, this change attempts multiple writes whenever select() reports that the FD is available for writing, similar to what is already done for libev. --- cassandra/io/asyncorereactor.py | 44 +++++++++++++++------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index 5c61bef32c..9b3858ac91 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -227,32 +227,28 @@ def handle_close(self): self.close() def handle_write(self): - try: - next_msg = self.deque.popleft() - except IndexError: - self._writable = False - return - - try: - sent = self.send(next_msg) - except socket.error as err: - if (err.args[0] in NONBLOCKING): - with self.deque_lock: - self.deque.appendleft(next_msg) - else: - self.defunct(err) - return - else: - if sent < len(next_msg): - with self.deque_lock: - self.deque.appendleft(next_msg[sent:]) - - if not self.deque: + while True: + try: with self.deque_lock: - if not self.deque: - self._writable = False + next_msg = self.deque.popleft() + except IndexError: + self._writable = False + return - self._readable = True + try: + sent = self.send(next_msg) + self._readable = True + except socket.error as err: + if (err.args[0] in NONBLOCKING): + with self.deque_lock: + self.deque.appendleft(next_msg) + else: + self.defunct(err) + return + else: + if sent < len(next_msg): + with self.deque_lock: + self.deque.appendleft(next_msg[sent:]) def handle_read(self): try: From 180eca93c463653f87733704a48d5723037ae28e Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 8 Jan 2014 11:45:17 -0600 Subject: [PATCH 0702/4528] More control conn debug logs on startup --- cassandra/cluster.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 7c5baec8e8..c2a27acdd6 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1434,6 +1434,7 @@ def _refresh_schema(self, connection, keyspace=None, table=None): cf_result = dict_factory(*cf_result.results) col_result = dict_factory(*col_result.results) + log.debug("[control connection] Fetched schema, rebuilding metadata") self._cluster.metadata.rebuild_schema(keyspace, table, ks_result, cf_result, col_result) def refresh_node_list_and_token_map(self): @@ -1499,6 +1500,7 @@ def _refresh_node_list_and_token_map(self, connection): self._cluster.remove_host(old_host) if partitioner: + log.debug("[control connection] Fetched ring info, rebuilding metadata") self._cluster.metadata.rebuild_token_map(partitioner, token_map) def _handle_topology_change(self, event): From dcce8643ae6cc914983d5abd03e8591f661f069a Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 8 Jan 2014 15:37:26 -0600 Subject: [PATCH 0703/4528] Optimize NTS replica map building In the case where one DC used vnodes and the other did not, the previous algorithm hit a worst-case of O(n^2) on the number of tokens. --- cassandra/metadata.py | 54 ++++++++++++++++++------- cassandra/pool.py | 3 -- tests/unit/test_host_connection_pool.py | 2 - 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index a4f6eb3693..2aa1fa6beb 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -5,6 +5,7 @@ except ImportError: # Python <2.7 from cassandra.util import OrderedDict # NOQA from hashlib import md5 +from itertools import islice, cycle import json import logging import re @@ -394,25 +395,50 @@ def make_token_replica_map(self, token_to_host_owner, ring): for dc, rf in self.dc_replication_factors.items() if rf > 0) dcs = dict((h, h.datacenter) for h in set(token_to_host_owner.values())) + # build a map of DCs to lists of indexes into `ring` for tokens that + # belong to that DC + dc_to_token_offset = defaultdict(list) + for i, token in enumerate(ring): + host = token_to_host_owner[token] + dc_to_token_offset[dcs[host]].append(i) + + # A map of DCs to an index into the dc_to_token_offset value for that dc. + # This is how we keep track of advancing around the ring for each DC. + dc_to_current_index = defaultdict(int) + for i in ring_len_range: remaining = dc_rf_map.copy() - for j in ring_len_range: - token = ring[(i + j) % ring_len] - host = token_to_host_owner[token] - dc = dcs[host] - if not dc in remaining: - # we already have all replicas for this DC - continue + replicas = replica_map[ring[i]] - if not host in replica_map[ring[i]]: - replica_map[ring[i]].append(host) + # go through each DC and find the replicas in that DC + for dc in dc_to_token_offset.keys(): + if dc not in remaining: + continue - if remaining[dc] == 1: - del remaining[dc] - if not remaining: + # advance our per-DC index until we're up to at least the + # current token in the ring + token_offsets = dc_to_token_offset[dc] + index = dc_to_current_index[dc] + num_tokens = len(token_offsets) + while index < num_tokens and token_offsets[index] < i: + index += 1 + dc_to_current_index[dc] = index + + # now add the next RF distinct token owners to the set of + # replicas for this DC + for token_offset in islice(cycle(token_offsets), index, index + num_tokens): + host = token_to_host_owner[ring[token_offset]] + if host in replicas: + continue + + replicas.append(host) + dc_remaining = remaining[dc] - 1 + if dc_remaining == 0: + del remaining[dc] break - else: - remaining[dc] -= 1 + else: + remaining[dc] = dc_remaining + return replica_map def export_for_schema(self): diff --git a/cassandra/pool.py b/cassandra/pool.py index ffae74af0d..a8e54d05a9 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -107,9 +107,6 @@ def get_and_set_reconnection_handler(self, new_handler): return old def __eq__(self, other): - if not isinstance(other, Host): - return False - return self.address == other.address def __str__(self): diff --git a/tests/unit/test_host_connection_pool.py b/tests/unit/test_host_connection_pool.py index 871d091f5e..dd45cd954d 100644 --- a/tests/unit/test_host_connection_pool.py +++ b/tests/unit/test_host_connection_pool.py @@ -225,5 +225,3 @@ def test_host_equality(self): self.assertEqual(a, b, 'Two Host instances should be equal when sharing.') self.assertNotEqual(a, c, 'Two Host instances should NOT be equal when using two different addresses.') self.assertNotEqual(b, c, 'Two Host instances should NOT be equal when using two different addresses.') - - self.assertFalse(a == '127.0.0.1') From bdebe1f3588b0162e181186e849427d3331627c1 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Wed, 8 Jan 2014 17:22:44 -0600 Subject: [PATCH 0704/4528] New large data tests --- tests/integration/long/test_large_data.py | 212 ++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 tests/integration/long/test_large_data.py diff --git a/tests/integration/long/test_large_data.py b/tests/integration/long/test_large_data.py new file mode 100644 index 0000000000..103b103477 --- /dev/null +++ b/tests/integration/long/test_large_data.py @@ -0,0 +1,212 @@ +from struct import pack +import unittest + +import cassandra + +from cassandra import ConsistencyLevel +from cassandra.cluster import Cluster +from cassandra.decoder import dict_factory +from cassandra.query import SimpleStatement +from tests.integration.long.utils import create_schema + + +# Converts an integer to an string of letters +def create_column_name(i): + letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] + + column_name = '' + while True: + column_name += letters[i % 10] + i /= 10 + if not i: + break + + return column_name + + +class LargeDataTests(unittest.TestCase): + + def setUp(self): + self.keyspace = 'large_data' + + def wide_rows(self, session, table, key): + # Write + for i in range(100000): + statement = SimpleStatement('INSERT INTO %s (k, i) VALUES (%s, %s)' + % (table, key, i), + consistency_level=ConsistencyLevel.QUORUM) + session.execute(statement) + + # Read + results = session.execute('SELECT i FROM %s WHERE k=%s' % (table, key)) + + # Verify + i = 0 + for row in results: + self.assertEqual(row['i'], i) + i += 1 + + + def wide_batch_rows(self, session, table, key): + # Write + statement = 'BEGIN BATCH ' + for i in range(2000): + statement += 'INSERT INTO %s (k, i) VALUES (%s, %s) ' % (table, key, i) + statement += 'APPLY BATCH' + statement = SimpleStatement(statement, consistency_level=ConsistencyLevel.QUORUM) + session.execute(statement) + + # Read + results = session.execute('SELECT i FROM %s WHERE k=%s' % (table, key)) + + # Verify + i = 0 + for row in results: + self.assertEqual(row['i'], i) + i += 1 + + + + def wide_byte_rows(self, session, table, key): + # Build small ByteBuffer sample + bb = '0xCAFE' + + # Write + for i in range(1000000): + statement = SimpleStatement('INSERT INTO %s (k, i) VALUES (%s, %s)' + % (table, key, str(bb)), + consistency_level=ConsistencyLevel.QUORUM) + session.execute(statement) + + # Read + results = session.execute('SELECT i FROM %s WHERE k=%s' % (table, key)) + + # Verify + bb = pack('>H', 0xCAFE) + i = 0 + for row in results: + self.assertEqual(row['i'], bb) + i += 1 + + + def large_text(self, session, table, key): + # Create ultra-long text + text = '' + for i in range(1000000): + text += str(i) + + # Write + session.execute(SimpleStatement("INSERT INTO %s (k, txt) VALUES (%s, '%s')" + % (table, key, text), + consistency_level=ConsistencyLevel.QUORUM)) + + # Read + result = session.execute('SELECT * FROM %s WHERE k=%s' % (table, key)) + + # Verify + for row in result: + self.assertEqual(row['txt'], text) + + + def wide_table(self, session, table, key): + # Write + insert_statement = 'INSERT INTO %s (key, ' + + column_names = [] + for i in range(330): + column_names.append(create_column_name(i)) + insert_statement += ', '.join(column_names) + + insert_statement += ') VALUES (%s, ' + + values = [] + for i in range(330): + values.append(str(i)) + insert_statement += ', '.join(values) + + insert_statement += ')' + + insert_statement = insert_statement % (table, key) + + session.execute(SimpleStatement(insert_statement, consistency_level=ConsistencyLevel.QUORUM)) + + # Read + result = session.execute('SELECT * FROM %s WHERE key=%s' % (table, key)) + + # Verify + for row in result: + for i in range(330): + self.assertEqual(row[create_column_name(i)], i) + + + + + def test_wide_rows(self): + table = 'wide_rows' + + cluster = Cluster() + session = cluster.connect() + session.row_factory = dict_factory + + create_schema(session, self.keyspace) + session.execute('CREATE TABLE %s (k INT, i INT, PRIMARY KEY(k, i))' % table) + + self.wide_rows(session, table, 0) + + + def test_wide_batch_rows(self): + table = 'wide_batch_rows' + + cluster = Cluster() + session = cluster.connect() + session.row_factory = dict_factory + + create_schema(session, self.keyspace) + session.execute('CREATE TABLE %s (k INT, i INT, PRIMARY KEY(k, i))' % table) + + self.wide_batch_rows(session, table, 0) + + + def test_wide_byte_rows(self): + table = 'wide_byte_rows' + + cluster = Cluster() + session = cluster.connect() + session.row_factory = dict_factory + + create_schema(session, self.keyspace) + session.execute('CREATE TABLE %s (k INT, i BLOB, PRIMARY KEY(k, i))' % table) + + self.wide_byte_rows(session, table, 0) + + + def test_large_text(self): + table = 'large_text' + + cluster = Cluster() + session = cluster.connect() + session.row_factory = dict_factory + + create_schema(session, self.keyspace) + session.execute('CREATE TABLE %s (k int PRIMARY KEY, txt text)' % table) + + self.large_text(session, table, 0) + + + def test_wide_table(self): + table = 'wide_table' + + cluster = Cluster() + session = cluster.connect() + session.row_factory = dict_factory + + create_schema(session, self.keyspace) + table_declaration = 'CREATE TABLE %s (key INT PRIMARY KEY, ' + column_names = [] + for i in range(330): + column_names.append(create_column_name(i)) + table_declaration += ' INT, '.join(column_names) + table_declaration += ' INT)' + session.execute(table_declaration % table) + + self.wide_table(session, table, 0) From 3c116aed2b70b3b9fbf3537eca8f098e1a052ff1 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Wed, 8 Jan 2014 17:23:48 -0600 Subject: [PATCH 0705/4528] Adjust row_factories within create_schema method. Use a longer timeout for wide_tables test. --- tests/integration/long/utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index e8e4ca4419..5b1f547457 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -3,6 +3,8 @@ from collections import defaultdict +from cassandra.decoder import named_tuple_factory + from tests.integration import get_node @@ -34,12 +36,14 @@ def assert_query_count_equals(self, testcase, node, expected): def create_schema(session, keyspace, simple_strategy=True, replication_factor=1, replication_strategy=None): + row_factory = session.row_factory + session.row_factory = named_tuple_factory results = session.execute( 'SELECT keyspace_name FROM system.schema_keyspaces') existing_keyspaces = [row[0] for row in results] if keyspace in existing_keyspaces: - session.execute('DROP KEYSPACE %s' % keyspace, timeout=10) + session.execute('DROP KEYSPACE %s' % keyspace, timeout=20) if simple_strategy: ddl = "CREATE KEYSPACE %s WITH replication" \ @@ -57,6 +61,8 @@ def create_schema(session, keyspace, simple_strategy=True, session.execute(ddl % keyspace, timeout=10) session.execute('USE %s' % keyspace) + session.row_factory = row_factory + def start(node): get_node(node).start() From 23a168edaf6a0a2e7740cac120dc3e15b12a97d2 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 8 Jan 2014 18:23:50 -0600 Subject: [PATCH 0706/4528] Only build token replica map as needed --- cassandra/cluster.py | 7 +- cassandra/metadata.py | 165 +++++++++++++------- tests/integration/standard/test_metadata.py | 7 +- 3 files changed, 119 insertions(+), 60 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index c2a27acdd6..e59fbb2732 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1435,7 +1435,12 @@ def _refresh_schema(self, connection, keyspace=None, table=None): col_result = dict_factory(*col_result.results) log.debug("[control connection] Fetched schema, rebuilding metadata") - self._cluster.metadata.rebuild_schema(keyspace, table, ks_result, cf_result, col_result) + if table: + self._cluster.metadata.table_changed(keyspace, table, cf_result, col_result) + elif keyspace: + self._cluster.metadata.keyspace_changed(keyspace, ks_result, cf_result, col_result) + else: + self._cluster.metadata.rebuild_schema(ks_result, cf_result, col_result) def refresh_node_list_and_token_map(self): try: diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 2aa1fa6beb..ba8a4d2343 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -76,7 +76,7 @@ def export_schema_as_string(self): """ return "\n".join(ks.export_as_string() for ks in self.keyspaces.values()) - def rebuild_schema(self, keyspace, table, ks_results, cf_results, col_results): + def rebuild_schema(self, ks_results, cf_results, col_results): """ Rebuild the view of the current schema from a fresh set of rows from the system schema tables. @@ -94,40 +94,82 @@ def rebuild_schema(self, keyspace, table, ks_results, cf_results, col_results): cfname = row["columnfamily_name"] col_def_rows[ksname][cfname].append(row) - # either table or ks_results must be None - if not table: - # ks_results is not None - added_keyspaces = set() - for row in ks_results: - keyspace_meta = self._build_keyspace_metadata(row) - for table_row in cf_def_rows.get(keyspace_meta.name, []): - table_meta = self._build_table_metadata( - keyspace_meta, table_row, col_def_rows[keyspace_meta.name]) - keyspace_meta.tables[table_meta.name] = table_meta - - added_keyspaces.add(keyspace_meta.name) - self.keyspaces[keyspace_meta.name] = keyspace_meta - - if not keyspace: - # remove not-just-added keyspaces - self.keyspaces = dict((name, meta) for name, meta in self.keyspaces.items() - if name in added_keyspaces) - if self.token_map: - self.token_map.rebuild(self.keyspaces.values()) + current_keyspaces = set() + for row in ks_results: + keyspace_meta = self._build_keyspace_metadata(row) + for table_row in cf_def_rows.get(keyspace_meta.name, []): + table_meta = self._build_table_metadata( + keyspace_meta, table_row, col_def_rows[keyspace_meta.name]) + keyspace_meta.tables[table_meta.name] = table_meta + + current_keyspaces.add(keyspace_meta.name) + old_keyspace_meta = self.keyspaces.get(keyspace_meta.name, None) + self.keyspaces[keyspace_meta.name] = keyspace_meta + if old_keyspace_meta: + self._keyspace_updated(keyspace_meta.name) + else: + self._keyspace_added(keyspace_meta.name) + + # remove not-just-added keyspaces + removed_keyspaces = [ksname for ksname in self.keyspaces.keys() + if ksname not in current_keyspaces] + self.keyspaces = dict((name, meta) for name, meta in self.keyspaces.items() + if name in current_keyspaces) + for ksname in removed_keyspaces: + self._keyspace_removed(ksname) + + def keyspace_changed(self, keyspace, ks_results, cf_results, col_results): + col_def_rows = defaultdict(list) + for row in col_results: + cfname = row["columnfamily_name"] + col_def_rows[cfname].append(row) + + keyspace_meta = self._build_keyspace_metadata(ks_results[0]) + old_keyspace_meta = self.keyspaces[keyspace] + + new_table_metas = {} + for table_row in cf_results: + table_meta = self._build_table_metadata( + keyspace_meta, table_row, col_def_rows[table_row.columnfamily_name]) + new_table_metas[table_meta.name] = table_meta + + keyspace_meta.tables = new_table_metas + + self.keyspaces[keyspace] = keyspace_meta + if old_keyspace_meta: + if (keyspace_meta.replication_strategy != old_keyspace_meta.replication_strategy): + self._keyspace_updated(keyspace) else: - # keyspace is not None, table is not None - try: - keyspace_meta = self.keyspaces[keyspace] - except KeyError: - # we're trying to update a table in a keyspace we don't know - # about, something went wrong. - # TODO log error, submit schema refresh - pass - if keyspace in cf_def_rows: - for table_row in cf_def_rows[keyspace]: - table_meta = self._build_table_metadata( - keyspace_meta, table_row, col_def_rows[keyspace]) - keyspace_meta.tables[table_meta.name] = table_meta + self._keyspace_added(keyspace) + + def table_changed(self, keyspace, table, cf_results, col_results): + try: + keyspace_meta = self.keyspaces[keyspace] + except KeyError: + # we're trying to update a table in a keyspace we don't know about + log.error("Tried to update schema for table '%s' in unknown keyspace '%s'", + table, keyspace) + return + + if not cf_results: + # the table was removed + del keyspace_meta.tables[table] + else: + assert len(cf_results) == 1 + keyspace_meta.tables[table] = self._build_table_metadata( + keyspace_meta, cf_results[0], col_results) + + def _keyspace_added(self, ksname): + if self.token_map: + self.token_map.rebuild_keyspace(ksname) + + def _keyspace_updated(self, ksname): + if self.token_map: + self.token_map.rebuild_keyspace(ksname) + + def _keyspace_removed(self, ksname): + if self.token_map: + self.token_map.remove_keyspace(ksname) def _build_keyspace_metadata(self, row): name = row["keyspace_name"] @@ -278,8 +320,7 @@ def rebuild_token_map(self, partitioner, token_map): all_tokens = sorted(ring) self.token_map = TokenMap( - token_class, token_to_host_owner, all_tokens, - self.keyspaces.values()) + token_class, token_to_host_owner, all_tokens, self) def get_replicas(self, keyspace, key): """ @@ -378,6 +419,12 @@ def export_for_schema(self): return "{'class': 'SimpleStrategy', 'replication_factor': '%d'}" \ % (self.replication_factor,) + def __eq__(self, other): + if not isinstance(other, SimpleStrategy): + return False + + return self.replication_factor == other.replication_factor + class NetworkTopologyStrategy(ReplicationStrategy): name = "NetworkTopologyStrategy" @@ -447,6 +494,11 @@ def export_for_schema(self): ret += ", '%s': '%d'" % (dc, repl_factor) return ret + "}" + def __eq__(self, other): + if not isinstance(other, NetworkTopologyStrategy): + return False + + return self.dc_replication_factors == other.dc_replication_factors class LocalStrategy(ReplicationStrategy): @@ -458,6 +510,9 @@ def make_token_replica_map(self, token_to_host_owner, ring): def export_for_schema(self): return "{'class': 'LocalStrategy'}" + def __eq__(self, other): + return isinstance(other, LocalStrategy) + class KeyspaceMetadata(object): """ @@ -783,33 +838,26 @@ class TokenMap(object): An ordered list of :class:`.Token` instances in the ring. """ - def __init__(self, token_class, token_to_host_owner, all_tokens, keyspaces): + _metadata = None + + def __init__(self, token_class, token_to_host_owner, all_tokens, metadata): self.token_class = token_class self.ring = all_tokens self.token_to_host_owner = token_to_host_owner self.tokens_to_hosts_by_ks = {} - self.rebuild(keyspaces) + self._metadata = metadata - def rebuild(self, current_keyspaces): - """ - Given an up-to-date list of :class:`.KeyspaceMetadata` instances, rebuild - the per-keyspace replication map. - """ - tokens_to_hosts_by_ks = {} - for ks_metadata in current_keyspaces: - strategy = ks_metadata.replication_strategy - if strategy is None: - token_to_hosts = defaultdict(set) - for token, host in self.token_to_host_owner.items(): - token_to_hosts[token].add(host) - tokens_to_hosts_by_ks[ks_metadata.name] = token_to_hosts - else: - tokens_to_hosts_by_ks[ks_metadata.name] = \ - strategy.make_token_replica_map( - self.token_to_host_owner, self.ring) + def rebuild_keyspace(self, keyspace): + self.tokens_to_hosts_by_ks[keyspace] = \ + self.replica_map_for_keyspace(self._metadata.keyspaces[keyspace]) + + def replica_map_for_keyspace(self, ks_metadata): + strategy = ks_metadata.replication_strategy + return strategy.make_token_replica_map(self.token_to_host_owner, self.ring) - self.tokens_to_hosts_by_ks = tokens_to_hosts_by_ks + def remove_keyspace(self, keyspace): + del self.tokens_to_hosts_by_ks[keyspace] def get_replicas(self, keyspace, token): """ @@ -818,7 +866,10 @@ def get_replicas(self, keyspace, token): """ tokens_to_hosts = self.tokens_to_hosts_by_ks.get(keyspace, None) if tokens_to_hosts is None: - return set() + self.rebuild_keyspace(keyspace) + tokens_to_hosts = self.tokens_to_hosts_by_ks.get(keyspace, None) + if tokens_to_hosts is None: + return [] point = bisect_left(self.ring, token) if point == 0 and token != self.ring[0]: diff --git a/tests/integration/standard/test_metadata.py b/tests/integration/standard/test_metadata.py index 60a819b000..2dfa56e5d2 100644 --- a/tests/integration/standard/test_metadata.py +++ b/tests/integration/standard/test_metadata.py @@ -3,10 +3,12 @@ except ImportError: import unittest # noqa +from mock import Mock + from cassandra import AlreadyExists from cassandra.cluster import Cluster -from cassandra.metadata import KeyspaceMetadata, TableMetadata, Token, MD5Token, TokenMap +from cassandra.metadata import Metadata, KeyspaceMetadata, TableMetadata, Token, MD5Token, TokenMap from cassandra.policies import SimpleConvictionPolicy from cassandra.pool import Host @@ -374,7 +376,8 @@ def test_getting_replicas(self): hosts = [Host("ip%d" % i, SimpleConvictionPolicy) for i in range(len(tokens))] token_to_primary_replica = dict(zip(tokens, hosts)) keyspace = KeyspaceMetadata("ks", True, "SimpleStrategy", {"replication_factor": "1"}) - token_map = TokenMap(MD5Token, token_to_primary_replica, tokens, [keyspace]) + metadata = Mock(spec=Metadata, keyspaces={'ks': keyspace}) + token_map = TokenMap(MD5Token, token_to_primary_replica, tokens, metadata) # tokens match node tokens exactly for token, expected_host in zip(tokens, hosts): From 70f74ea9f3ff45dd23020ffa411988f805980c3d Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 9 Jan 2014 09:40:46 -0600 Subject: [PATCH 0707/4528] Minor PEP-8 fixes --- cassandra/cqltypes.py | 5 +++++ cassandra/decoder.py | 2 ++ cassandra/io/libevreactor.py | 2 ++ cassandra/marshal.py | 6 +++++- cassandra/metadata.py | 2 ++ cassandra/metrics.py | 1 + cassandra/policies.py | 1 + cassandra/query.py | 1 + cassandra/util.py | 1 + 9 files changed, 20 insertions(+), 1 deletion(-) diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index a5439b3948..bf48724b68 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -51,16 +51,19 @@ except ImportError: # Python <2.7 from cassandra.util import OrderedDict # NOQA + def trim_if_startswith(s, prefix): if s.startswith(prefix): return s[len(prefix):] return s + def unix_time_from_uuid1(u): return (u.get_time() - 0x01B21DD213814000) / 10000000.0 _casstypes = {} + class CassandraTypeType(type): """ The CassandraType objects in this module will normally be used directly, @@ -391,6 +394,7 @@ class IntegerType(_CassandraType): have_ipv6_packing = hasattr(socket, 'inet_ntop') + class InetAddressType(_CassandraType): typename = 'inet' @@ -482,6 +486,7 @@ def serialize(v): return int64_pack(long(converted)) + class TimeUUIDType(DateType): typename = 'timeuuid' diff --git a/cassandra/decoder.py b/cassandra/decoder.py index f9bfa16c16..effc2d9b48 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -31,6 +31,7 @@ log = logging.getLogger(__name__) + class NotSupportedError(Exception): pass @@ -75,6 +76,7 @@ def ordered_dict_factory(colnames, rows): _message_types_by_name = {} _message_types_by_opcode = {} + class _register_msg_type(type): def __init__(cls, name, bases, dct): if not name.startswith('_'): diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index d8e3472ff9..d281b9be41 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -38,6 +38,7 @@ _loop_started = None _loop_lock = Lock() + def _run_loop(): while True: end_condition = _loop.start() @@ -53,6 +54,7 @@ def _run_loop(): _loop_started = False break + def _start_loop(): global _loop_started should_start = False diff --git a/cassandra/marshal.py b/cassandra/marshal.py index cf9c765662..b1e17d1089 100644 --- a/cassandra/marshal.py +++ b/cassandra/marshal.py @@ -1,8 +1,9 @@ import struct + def _make_packer(format_string): try: - packer = struct.Struct(format_string) # new in Python 2.5 + packer = struct.Struct(format_string) # new in Python 2.5 except AttributeError: pack = lambda x: struct.pack(format_string, x) unpack = lambda s: struct.unpack(format_string, s) @@ -22,12 +23,14 @@ def _make_packer(format_string): float_pack, float_unpack = _make_packer('>f') double_pack, double_unpack = _make_packer('>d') + def varint_unpack(term): val = int(term.encode('hex'), 16) if (ord(term[0]) & 128) != 0: val = val - (1 << (len(term) * 8)) return val + def bitlength(n): bitlen = 0 while n > 0: @@ -35,6 +38,7 @@ def bitlength(n): bitlen += 1 return bitlen + def varint_pack(big): pos = True if big == 0: diff --git a/cassandra/metadata.py b/cassandra/metadata.py index ba8a4d2343..2915c9f259 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -425,6 +425,7 @@ def __eq__(self, other): return self.replication_factor == other.replication_factor + class NetworkTopologyStrategy(ReplicationStrategy): name = "NetworkTopologyStrategy" @@ -500,6 +501,7 @@ def __eq__(self, other): return self.dc_replication_factors == other.dc_replication_factors + class LocalStrategy(ReplicationStrategy): name = "LocalStrategy" diff --git a/cassandra/metrics.py b/cassandra/metrics.py index c5c5380127..f54962029c 100644 --- a/cassandra/metrics.py +++ b/cassandra/metrics.py @@ -5,6 +5,7 @@ log = logging.getLogger(__name__) + class Metrics(object): request_timer = None diff --git a/cassandra/policies.py b/cassandra/policies.py index 34c31a2c15..7bed8c04ea 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -6,6 +6,7 @@ log = logging.getLogger(__name__) + class HostDistance(object): """ A measure of how "distant" a node is from the client, which diff --git a/cassandra/query.py b/cassandra/query.py index 1591f790b3..76143da847 100644 --- a/cassandra/query.py +++ b/cassandra/query.py @@ -13,6 +13,7 @@ from cassandra.decoder import (cql_encoders, cql_encode_object, cql_encode_sequence) + class Statement(object): """ An abstract class representing a single query. There are two subclasses: diff --git a/cassandra/util.py b/cassandra/util.py index dfa1b0b891..8204df6514 100644 --- a/cassandra/util.py +++ b/cassandra/util.py @@ -26,6 +26,7 @@ from UserDict import DictMixin + class OrderedDict(dict, DictMixin): """ A dictionary which maintains the insertion order of keys. """ From 5cf25b523769a1e3bcbf7f3f4aa2303ac22d6861 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 9 Jan 2014 10:23:44 -0600 Subject: [PATCH 0708/4528] Row format is dict, not namedtuple for schema refresh --- cassandra/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 2915c9f259..2b59a1a0d1 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -130,7 +130,7 @@ def keyspace_changed(self, keyspace, ks_results, cf_results, col_results): new_table_metas = {} for table_row in cf_results: table_meta = self._build_table_metadata( - keyspace_meta, table_row, col_def_rows[table_row.columnfamily_name]) + keyspace_meta, table_row, col_def_rows[table_row['columnfamily_name']]) new_table_metas[table_meta.name] = table_meta keyspace_meta.tables = new_table_metas From 1316b81e9821f587652ed5c4dc0188acf10169a3 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 9 Jan 2014 10:32:35 -0600 Subject: [PATCH 0709/4528] Handle dropped keyspaces correctly --- cassandra/metadata.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 2b59a1a0d1..156d260b94 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -119,18 +119,24 @@ def rebuild_schema(self, ks_results, cf_results, col_results): self._keyspace_removed(ksname) def keyspace_changed(self, keyspace, ks_results, cf_results, col_results): + if not ks_results: + if keyspace in self.keyspaces: + del self.keyspaces[keyspace] + self._keyspace_removed(keyspace) + return + col_def_rows = defaultdict(list) for row in col_results: cfname = row["columnfamily_name"] col_def_rows[cfname].append(row) keyspace_meta = self._build_keyspace_metadata(ks_results[0]) - old_keyspace_meta = self.keyspaces[keyspace] + old_keyspace_meta = self.keyspaces.get(keyspace, None) new_table_metas = {} for table_row in cf_results: table_meta = self._build_table_metadata( - keyspace_meta, table_row, col_def_rows[table_row['columnfamily_name']]) + keyspace_meta, table_row, col_def_rows) new_table_metas[table_meta.name] = table_meta keyspace_meta.tables = new_table_metas @@ -157,7 +163,7 @@ def table_changed(self, keyspace, table, cf_results, col_results): else: assert len(cf_results) == 1 keyspace_meta.tables[table] = self._build_table_metadata( - keyspace_meta, cf_results[0], col_results) + keyspace_meta, cf_results[0], {table: col_results}) def _keyspace_added(self, ksname): if self.token_map: From e0b5e6522adb5e21047a61fc93c91590924f494e Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 9 Jan 2014 10:49:16 -0600 Subject: [PATCH 0710/4528] Don't make ks recreate test incredibly long --- tests/integration/long/test_schema.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/integration/long/test_schema.py b/tests/integration/long/test_schema.py index 57484d4644..4aaf8bffd0 100644 --- a/tests/integration/long/test_schema.py +++ b/tests/integration/long/test_schema.py @@ -1,11 +1,9 @@ import logging -import cassandra from cassandra import ConsistencyLevel from cassandra.cluster import Cluster from cassandra.query import SimpleStatement - try: import unittest2 as unittest except ImportError: @@ -13,16 +11,16 @@ log = logging.getLogger(__name__) + class SchemaTests(unittest.TestCase): + def test_recreates(self): cluster = Cluster() session = cluster.connect() - - replication_factor = 3 for i in range(2): - for keyspace in range(0, 100): + for keyspace in range(5): keyspace = 'ks_%s' % keyspace results = session.execute('SELECT keyspace_name FROM system.schema_keyspaces') existing_keyspaces = [row[0] for row in results] @@ -31,8 +29,10 @@ def test_recreates(self): log.debug(ddl) session.execute(ddl) - ddl = "CREATE KEYSPACE %s WITH replication" \ - " = {'class': 'SimpleStrategy', 'replication_factor': '%s'}" % (keyspace, replication_factor) + ddl = """ + CREATE KEYSPACE %s + WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '%s'} + """ % (keyspace, str(replication_factor)) log.debug(ddl) session.execute(ddl) From cea2d5b5e9f9aa51b64a120efc895f5212e6fd26 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 9 Jan 2014 10:52:48 -0600 Subject: [PATCH 0711/4528] Close conns in trash on shutdown --- cassandra/pool.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cassandra/pool.py b/cassandra/pool.py index a8e54d05a9..9c2803663b 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -509,6 +509,9 @@ def shutdown(self): conn.close() self.open_count -= 1 + for conn in self._trash: + conn.close() + def ensure_core_connections(self): if self.is_shutdown: return From 188db7d443775e2246c8c23d761f46eedb9dc4bf Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 9 Jan 2014 11:07:31 -0600 Subject: [PATCH 0712/4528] Remove __del__ from Connection subclasses --- cassandra/io/asyncorereactor.py | 6 ------ cassandra/io/libevreactor.py | 3 --- 2 files changed, 9 deletions(-) diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index 9b3858ac91..b5136f1900 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -179,12 +179,6 @@ def close(self): self._error_all_callbacks( ConnectionShutdown("Connection to %s was closed" % self.host)) - def __del__(self): - try: - self.close() - except TypeError: - pass - def defunct(self, exc): with self.lock: if self.is_defunct: diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index d281b9be41..e4789c1444 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -232,9 +232,6 @@ def close(self): self._error_all_callbacks( ConnectionShutdown("Connection to %s was closed" % self.host)) - def __del__(self): - self.close() - def defunct(self, exc): with self.lock: if self.is_defunct: From 993b5752d17e6552249bac9447ee30fd9e1d8d9d Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 9 Jan 2014 13:10:43 -0600 Subject: [PATCH 0713/4528] Avoid double erroring of callbacks Under certain conditions, when a connection was closed by the server, the close() method would first be called, followed by the defunct() method shortly afterwards. Both of these would error out all callbacks. This avoids doing "defunct" work if the connection is already closed, and holds a lock to avoid any other race conditions which might result in erroring all callbacks twice. --- cassandra/io/asyncorereactor.py | 7 +++++-- cassandra/io/libevreactor.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index b5136f1900..706e7b7c6f 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -181,7 +181,7 @@ def close(self): def defunct(self, exc): with self.lock: - if self.is_defunct: + if self.is_defunct or self.is_closed: return self.is_defunct = True @@ -199,8 +199,11 @@ def defunct(self, exc): return exc def _error_all_callbacks(self, exc): + with self.lock: + callbacks = self._callbacks + self._callbacks = {} new_exc = ConnectionShutdown(str(exc)) - for cb in self._callbacks.values(): + for cb in callbacks: try: cb(new_exc) except Exception: diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index e4789c1444..e5dbcd7bc7 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -234,7 +234,7 @@ def close(self): def defunct(self, exc): with self.lock: - if self.is_defunct: + if self.is_defunct or self.is_closed: return self.is_defunct = True @@ -251,8 +251,11 @@ def defunct(self, exc): return exc def _error_all_callbacks(self, exc): + with self.lock: + callbacks = self._callbacks + self._callbacks = {} new_exc = ConnectionShutdown(str(exc)) - for cb in self._callbacks.values(): + for cb in callbacks.values(): try: cb(new_exc) except Exception: From 0e7775532ae1326c33aa80383fd25411a83a9b7b Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 9 Jan 2014 13:24:59 -0600 Subject: [PATCH 0714/4528] Ensure in_flight is decremented when ops time out Internal operations, such as control connection queries and preparing of statements, use a different method for synchronously executing queries. If those timed out, the in_flight count for the relevant connection would not be decremented, leaving the connection to appear more busy than it actually was. --- cassandra/connection.py | 5 ++++- cassandra/io/asyncorereactor.py | 8 ++------ cassandra/io/libevreactor.py | 8 ++------ 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index 027259ea9e..22d4fd01a8 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -357,13 +357,16 @@ def __str__(self): class ResponseWaiter(object): - def __init__(self, num_responses): + def __init__(self, connection, num_responses): + self.connection = connection self.pending = num_responses self.error = None self.responses = [None] * num_responses self.event = Event() def got_response(self, response, index): + with self.connection.lock: + self.connection.in_flight -= 1 if isinstance(response, Exception): self.error = response self.event.set() diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index 706e7b7c6f..74373576cd 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -347,7 +347,7 @@ def wait_for_response(self, msg, timeout=None): def wait_for_responses(self, *msgs, **kwargs): timeout = kwargs.get('timeout') - waiter = ResponseWaiter(len(msgs)) + waiter = ResponseWaiter(self, len(msgs)) # busy wait for sufficient space on the connection messages_sent = 0 @@ -370,11 +370,7 @@ def wait_for_responses(self, *msgs, **kwargs): raise OperationTimedOut() time.sleep(0.01) - try: - return waiter.deliver(timeout) - finally: - with self.lock: - self.in_flight -= len(msgs) + return waiter.deliver(timeout) def register_watcher(self, event_type, callback): self._push_watchers[event_type].add(callback) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index e5dbcd7bc7..10475d1f5c 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -395,7 +395,7 @@ def wait_for_response(self, msg, timeout=None): def wait_for_responses(self, *msgs, **kwargs): timeout = kwargs.get('timeout') - waiter = ResponseWaiter(len(msgs)) + waiter = ResponseWaiter(self, len(msgs)) # busy wait for sufficient space on the connection messages_sent = 0 @@ -418,11 +418,7 @@ def wait_for_responses(self, *msgs, **kwargs): raise OperationTimedOut() time.sleep(0.01) - try: - return waiter.deliver(timeout) - finally: - with self.lock: - self.in_flight -= len(msgs) + return waiter.deliver(timeout) def register_watcher(self, event_type, callback): self._push_watchers[event_type].add(callback) From aab36b62cd1219e9c61e5cff6cecb544e04c8184 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 9 Jan 2014 13:26:57 -0600 Subject: [PATCH 0715/4528] Actually add NullHandler in logging to cassandra For some reason this was commented out duing the initial implementation --- cassandra/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/__init__.py b/cassandra/__init__.py index 18f22a9944..3169e2a46c 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -6,7 +6,7 @@ class NullHandler(logging.Handler): def emit(self, record): pass -# logging.getLogger('cassandra').addHandler(NullHandler()) +logging.getLogger('cassandra').addHandler(NullHandler()) __version_info__ = (1, 0, '0b7', 'post') From ec38b81feb2304f26fd7718bd375700301b7694b Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 9 Jan 2014 16:34:33 -0600 Subject: [PATCH 0716/4528] Handle missing/unrecognized replication strategy --- cassandra/metadata.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 156d260b94..f46170018c 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -862,7 +862,10 @@ def rebuild_keyspace(self, keyspace): def replica_map_for_keyspace(self, ks_metadata): strategy = ks_metadata.replication_strategy - return strategy.make_token_replica_map(self.token_to_host_owner, self.ring) + if strategy: + return strategy.make_token_replica_map(self.token_to_host_owner, self.ring) + else: + return None def remove_keyspace(self, keyspace): del self.tokens_to_hosts_by_ks[keyspace] From c28ec3fab47b41c0580f5a5b7844d2afe5b68d39 Mon Sep 17 00:00:00 2001 From: Mike Hall Date: Thu, 9 Jan 2014 14:44:53 -0800 Subject: [PATCH 0717/4528] 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 a5baef0fef9ae80f0513e68bf005aa5db8e7958b Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Thu, 9 Jan 2014 18:10:51 -0600 Subject: [PATCH 0718/4528] Use efficient bulk writing --- tests/integration/long/test_large_data.py | 28 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/integration/long/test_large_data.py b/tests/integration/long/test_large_data.py index 103b103477..7d902598fe 100644 --- a/tests/integration/long/test_large_data.py +++ b/tests/integration/long/test_large_data.py @@ -1,3 +1,4 @@ +import Queue from struct import pack import unittest @@ -30,22 +31,37 @@ def setUp(self): self.keyspace = 'large_data' def wide_rows(self, session, table, key): - # Write + # Write via async futures + futures = Queue.Queue(maxsize=121) + for i in range(100000): + if i > 0 and i % 120 == 0: + # clear the existing queue + while True: + try: + futures.get_nowait().result() + except Queue.Empty: + break + statement = SimpleStatement('INSERT INTO %s (k, i) VALUES (%s, %s)' % (table, key, i), consistency_level=ConsistencyLevel.QUORUM) - session.execute(statement) + + future = session.execute_async(statement) + futures.put_nowait(future) + + while True: + try: + futures.get_nowait().result() + except Queue.Empty: + break # Read results = session.execute('SELECT i FROM %s WHERE k=%s' % (table, key)) # Verify - i = 0 - for row in results: + for i, row in enumerate(results): self.assertEqual(row['i'], i) - i += 1 - def wide_batch_rows(self, session, table, key): # Write From 495b01af5eaa4f0c9cda618044574d2fbf3b0ccc Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Thu, 9 Jan 2014 18:12:32 -0600 Subject: [PATCH 0719/4528] wide-row fix --- tests/integration/long/test_large_data.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/integration/long/test_large_data.py b/tests/integration/long/test_large_data.py index 7d902598fe..35f2296e86 100644 --- a/tests/integration/long/test_large_data.py +++ b/tests/integration/long/test_large_data.py @@ -81,16 +81,14 @@ def wide_batch_rows(self, session, table, key): self.assertEqual(row['i'], i) i += 1 - - def wide_byte_rows(self, session, table, key): # Build small ByteBuffer sample bb = '0xCAFE' # Write for i in range(1000000): - statement = SimpleStatement('INSERT INTO %s (k, i) VALUES (%s, %s)' - % (table, key, str(bb)), + statement = SimpleStatement('INSERT INTO %s (k, i, v) VALUES (%s, %s, %s)' + % (table, key, i, str(bb)), consistency_level=ConsistencyLevel.QUORUM) session.execute(statement) @@ -191,11 +189,10 @@ def test_wide_byte_rows(self): session.row_factory = dict_factory create_schema(session, self.keyspace) - session.execute('CREATE TABLE %s (k INT, i BLOB, PRIMARY KEY(k, i))' % table) + session.execute('CREATE TABLE %s (k INT, i INT, v BLOB, PRIMARY KEY(k, i))' % table) self.wide_byte_rows(session, table, 0) - def test_large_text(self): table = 'large_text' From 95f18714b0b2df394ef36edf77380f6933a2aff7 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Thu, 9 Jan 2014 18:13:27 -0600 Subject: [PATCH 0720/4528] string concat optimizations --- tests/integration/long/test_large_data.py | 26 ++++------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/tests/integration/long/test_large_data.py b/tests/integration/long/test_large_data.py index 35f2296e86..c575f01334 100644 --- a/tests/integration/long/test_large_data.py +++ b/tests/integration/long/test_large_data.py @@ -102,12 +102,9 @@ def wide_byte_rows(self, session, table, key): self.assertEqual(row['i'], bb) i += 1 - def large_text(self, session, table, key): # Create ultra-long text - text = '' - for i in range(1000000): - text += str(i) + text = 'a' * 1000000 # Write session.execute(SimpleStatement("INSERT INTO %s (k, txt) VALUES (%s, '%s')" @@ -121,25 +118,13 @@ def large_text(self, session, table, key): for row in result: self.assertEqual(row['txt'], text) - def wide_table(self, session, table, key): # Write insert_statement = 'INSERT INTO %s (key, ' - - column_names = [] - for i in range(330): - column_names.append(create_column_name(i)) - insert_statement += ', '.join(column_names) - + insert_statement += ', '.join(create_column_name(i) for i in range(330)) insert_statement += ') VALUES (%s, ' - - values = [] - for i in range(330): - values.append(str(i)) - insert_statement += ', '.join(values) - + insert_statement += ', '.join(str(i) for i in range(330)) insert_statement += ')' - insert_statement = insert_statement % (table, key) session.execute(SimpleStatement(insert_statement, consistency_level=ConsistencyLevel.QUORUM)) @@ -215,10 +200,7 @@ def test_wide_table(self): create_schema(session, self.keyspace) table_declaration = 'CREATE TABLE %s (key INT PRIMARY KEY, ' - column_names = [] - for i in range(330): - column_names.append(create_column_name(i)) - table_declaration += ' INT, '.join(column_names) + table_declaration += ' INT, '.join(create_column_name(i) for i in range(330)) table_declaration += ' INT)' session.execute(table_declaration % table) From 561bf654507ab0ed3176fb4bc1b9e0976ff7c72a Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Thu, 9 Jan 2014 18:13:45 -0600 Subject: [PATCH 0721/4528] Make whitespace flake8 compliant --- tests/integration/long/test_large_data.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/integration/long/test_large_data.py b/tests/integration/long/test_large_data.py index c575f01334..2ebf8937c2 100644 --- a/tests/integration/long/test_large_data.py +++ b/tests/integration/long/test_large_data.py @@ -137,9 +137,6 @@ def wide_table(self, session, table, key): for i in range(330): self.assertEqual(row[create_column_name(i)], i) - - - def test_wide_rows(self): table = 'wide_rows' @@ -152,7 +149,6 @@ def test_wide_rows(self): self.wide_rows(session, table, 0) - def test_wide_batch_rows(self): table = 'wide_batch_rows' @@ -165,7 +161,6 @@ def test_wide_batch_rows(self): self.wide_batch_rows(session, table, 0) - def test_wide_byte_rows(self): table = 'wide_byte_rows' @@ -190,7 +185,6 @@ def test_large_text(self): self.large_text(session, table, 0) - def test_wide_table(self): table = 'wide_table' From 3770634db53a8d82faa5da22857eb5b4d05020c5 Mon Sep 17 00:00:00 2001 From: Joaquin Casares Date: Thu, 9 Jan 2014 18:16:01 -0600 Subject: [PATCH 0722/4528] make change to enumerate() --- tests/integration/long/test_large_data.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/integration/long/test_large_data.py b/tests/integration/long/test_large_data.py index 2ebf8937c2..94cf722608 100644 --- a/tests/integration/long/test_large_data.py +++ b/tests/integration/long/test_large_data.py @@ -76,10 +76,8 @@ def wide_batch_rows(self, session, table, key): results = session.execute('SELECT i FROM %s WHERE k=%s' % (table, key)) # Verify - i = 0 - for row in results: + for i, row in enumerate(results): self.assertEqual(row['i'], i) - i += 1 def wide_byte_rows(self, session, table, key): # Build small ByteBuffer sample @@ -97,10 +95,8 @@ def wide_byte_rows(self, session, table, key): # Verify bb = pack('>H', 0xCAFE) - i = 0 for row in results: self.assertEqual(row['i'], bb) - i += 1 def large_text(self, session, table, key): # Create ultra-long text From fb73405e58d14716f2d37b51fe3ea1d41f7cda68 Mon Sep 17 00:00:00 2001 From: Mike Hall Date: Fri, 10 Jan 2014 08:53:32 -0800 Subject: [PATCH 0723/4528] 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 fda6f29306633a778b04ca9d3a9be10ee24749d9 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 10 Jan 2014 13:50:09 -0600 Subject: [PATCH 0724/4528] Defunct connection when internal query fails Errors that occurred during wait_for_responses(), which is used by the control connection and for preparing statements, were not causing the connection to be closed or defuncted. --- cassandra/cluster.py | 1 - cassandra/io/asyncorereactor.py | 8 +++++++- cassandra/io/libevreactor.py | 8 +++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index e59fbb2732..2552328b66 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -785,7 +785,6 @@ def _prepare_all_queries(self, host): self.control_connection.wait_for_schema_agreement(connection) except Exception: log.debug("Error waiting for schema agreement before preparing statements against host %s", host, exc_info=True) - # TODO: potentially error out the connection? statements = self._prepared_statements.values() for keyspace, ks_statements in groupby(statements, lambda s: s.keyspace): diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index 74373576cd..60c4078ee6 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -370,7 +370,13 @@ def wait_for_responses(self, *msgs, **kwargs): raise OperationTimedOut() time.sleep(0.01) - return waiter.deliver(timeout) + try: + return waiter.deliver(timeout) + except OperationTimedOut: + raise + except Exception, exc: + self.defunct(exc) + raise def register_watcher(self, event_type, callback): self._push_watchers[event_type].add(callback) diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index 10475d1f5c..4cbc676dea 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -418,7 +418,13 @@ def wait_for_responses(self, *msgs, **kwargs): raise OperationTimedOut() time.sleep(0.01) - return waiter.deliver(timeout) + try: + return waiter.deliver(timeout) + except OperationTimedOut: + raise + except Exception, exc: + self.defunct(exc) + raise def register_watcher(self, event_type, callback): self._push_watchers[event_type].add(callback) From 97f4856ef5aea2e698169fc573d50b4e6c23f0d0 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 14 Jan 2014 12:56:45 -0600 Subject: [PATCH 0725/4528] Don't log single connection failures at ERROR If the connection failure is of an expected type, logging at WARN is sufficient. --- cassandra/pool.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cassandra/pool.py b/cassandra/pool.py index 9c2803663b..2e6f17274e 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -3,6 +3,7 @@ """ import logging +import socket import time from threading import RLock, Condition import weakref @@ -331,6 +332,8 @@ def _maybe_spawn_new_connection(self): def _create_new_connection(self): try: self._add_conn_if_under_max() + except (ConnectionException, socket.error), exc: + log.warn("Failed to create new connection to %s: %s", self.host, exc) except Exception: log.exception("Unexpectedly failed to create new connection") finally: @@ -361,8 +364,8 @@ def _add_conn_if_under_max(self): id(conn), self.host) self._signal_available_conn() return True - except ConnectionException as exc: - log.exception("Failed to add new connection to pool for host %s", self.host) + except (ConnectionException, socket.error) as exc: + log.warn("Failed to add new connection to pool for host %s: %s", self.host, exc) with self._lock: self.open_count -= 1 if self._session.cluster.signal_connection_failure(self.host, exc, is_host_addition=False): From cf369e848e6d7e687a22823b66740633f58a3de4 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Tue, 14 Jan 2014 12:18:39 -0800 Subject: [PATCH 0726/4528] 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 0727/4528] 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 cfb8692047a5d11ae598394e33765df8643fcad4 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 14 Jan 2014 16:24:31 -0600 Subject: [PATCH 0728/4528] Large data test cleanup and speedup --- tests/integration/long/test_large_data.py | 150 +++++++++------------- 1 file changed, 59 insertions(+), 91 deletions(-) diff --git a/tests/integration/long/test_large_data.py b/tests/integration/long/test_large_data.py index 94cf722608..df46f3ae45 100644 --- a/tests/integration/long/test_large_data.py +++ b/tests/integration/long/test_large_data.py @@ -2,8 +2,6 @@ from struct import pack import unittest -import cassandra - from cassandra import ConsistencyLevel from cassandra.cluster import Cluster from cassandra.decoder import dict_factory @@ -30,11 +28,17 @@ class LargeDataTests(unittest.TestCase): def setUp(self): self.keyspace = 'large_data' - def wide_rows(self, session, table, key): - # Write via async futures - futures = Queue.Queue(maxsize=121) + def make_session_and_keyspace(self): + cluster = Cluster() + session = cluster.connect() + session.row_factory = dict_factory - for i in range(100000): + create_schema(session, self.keyspace) + return session + + def batch_futures(self, session, statement_generator): + futures = Queue.Queue(maxsize=121) + for i, statement in enumerate(statement_generator): if i > 0 and i % 120 == 0: # clear the existing queue while True: @@ -43,10 +47,6 @@ def wide_rows(self, session, table, key): except Queue.Empty: break - statement = SimpleStatement('INSERT INTO %s (k, i) VALUES (%s, %s)' - % (table, key, i), - consistency_level=ConsistencyLevel.QUORUM) - future = session.execute_async(statement) futures.put_nowait(future) @@ -56,142 +56,110 @@ def wide_rows(self, session, table, key): except Queue.Empty: break + def test_wide_rows(self): + table = 'wide_rows' + session = self.make_session_and_keyspace() + session.execute('CREATE TABLE %s (k INT, i INT, PRIMARY KEY(k, i))' % table) + + # Write via async futures + self.batch_futures( + session, + (SimpleStatement('INSERT INTO %s (k, i) VALUES (0, %s)' % (table, i), + consistency_level=ConsistencyLevel.QUORUM) + for i in range(1000000))) + # Read - results = session.execute('SELECT i FROM %s WHERE k=%s' % (table, key)) + results = session.execute('SELECT i FROM %s WHERE k=%s' % (table, 0)) # Verify for i, row in enumerate(results): self.assertEqual(row['i'], i) - def wide_batch_rows(self, session, table, key): + def test_wide_batch_rows(self): + table = 'wide_batch_rows' + session = self.make_session_and_keyspace() + session.execute('CREATE TABLE %s (k INT, i INT, PRIMARY KEY(k, i))' % table) + # Write statement = 'BEGIN BATCH ' for i in range(2000): - statement += 'INSERT INTO %s (k, i) VALUES (%s, %s) ' % (table, key, i) + statement += 'INSERT INTO %s (k, i) VALUES (%s, %s) ' % (table, 0, i) statement += 'APPLY BATCH' statement = SimpleStatement(statement, consistency_level=ConsistencyLevel.QUORUM) session.execute(statement) # Read - results = session.execute('SELECT i FROM %s WHERE k=%s' % (table, key)) + results = session.execute('SELECT i FROM %s WHERE k=%s' % (table, 0)) # Verify for i, row in enumerate(results): self.assertEqual(row['i'], i) - def wide_byte_rows(self, session, table, key): + def test_wide_byte_rows(self): + table = 'wide_byte_rows' + session = self.make_session_and_keyspace() + session.execute('CREATE TABLE %s (k INT, i INT, v BLOB, PRIMARY KEY(k, i))' % table) + # Build small ByteBuffer sample bb = '0xCAFE' # Write - for i in range(1000000): - statement = SimpleStatement('INSERT INTO %s (k, i, v) VALUES (%s, %s, %s)' - % (table, key, i, str(bb)), - consistency_level=ConsistencyLevel.QUORUM) - session.execute(statement) + self.batch_futures( + session, + (SimpleStatement('INSERT INTO %s (k, i, v) VALUES (0, %s, %s)' % (table, i, str(bb)), + consistency_level=ConsistencyLevel.QUORUM) + for i in range(1000000))) # Read - results = session.execute('SELECT i FROM %s WHERE k=%s' % (table, key)) + results = session.execute('SELECT i, v FROM %s WHERE k=%s' % (table, 0)) # Verify bb = pack('>H', 0xCAFE) for row in results: - self.assertEqual(row['i'], bb) + self.assertEqual(row['v'], bb) + + def test_large_text(self): + table = 'large_text' + session = self.make_session_and_keyspace() + session.execute('CREATE TABLE %s (k int PRIMARY KEY, txt text)' % table) - def large_text(self, session, table, key): # Create ultra-long text text = 'a' * 1000000 # Write session.execute(SimpleStatement("INSERT INTO %s (k, txt) VALUES (%s, '%s')" - % (table, key, text), + % (table, 0, text), consistency_level=ConsistencyLevel.QUORUM)) # Read - result = session.execute('SELECT * FROM %s WHERE k=%s' % (table, key)) + result = session.execute('SELECT * FROM %s WHERE k=%s' % (table, 0)) # Verify for row in result: self.assertEqual(row['txt'], text) - def wide_table(self, session, table, key): + def test_wide_table(self): + table = 'wide_table' + session = self.make_session_and_keyspace() + table_declaration = 'CREATE TABLE %s (key INT PRIMARY KEY, ' + table_declaration += ' INT, '.join(create_column_name(i) for i in range(330)) + table_declaration += ' INT)' + session.execute(table_declaration % table) + # Write insert_statement = 'INSERT INTO %s (key, ' insert_statement += ', '.join(create_column_name(i) for i in range(330)) insert_statement += ') VALUES (%s, ' insert_statement += ', '.join(str(i) for i in range(330)) insert_statement += ')' - insert_statement = insert_statement % (table, key) + insert_statement = insert_statement % (table, 0) session.execute(SimpleStatement(insert_statement, consistency_level=ConsistencyLevel.QUORUM)) # Read - result = session.execute('SELECT * FROM %s WHERE key=%s' % (table, key)) + result = session.execute('SELECT * FROM %s WHERE key=%s' % (table, 0)) # Verify for row in result: for i in range(330): self.assertEqual(row[create_column_name(i)], i) - - def test_wide_rows(self): - table = 'wide_rows' - - cluster = Cluster() - session = cluster.connect() - session.row_factory = dict_factory - - create_schema(session, self.keyspace) - session.execute('CREATE TABLE %s (k INT, i INT, PRIMARY KEY(k, i))' % table) - - self.wide_rows(session, table, 0) - - def test_wide_batch_rows(self): - table = 'wide_batch_rows' - - cluster = Cluster() - session = cluster.connect() - session.row_factory = dict_factory - - create_schema(session, self.keyspace) - session.execute('CREATE TABLE %s (k INT, i INT, PRIMARY KEY(k, i))' % table) - - self.wide_batch_rows(session, table, 0) - - def test_wide_byte_rows(self): - table = 'wide_byte_rows' - - cluster = Cluster() - session = cluster.connect() - session.row_factory = dict_factory - - create_schema(session, self.keyspace) - session.execute('CREATE TABLE %s (k INT, i INT, v BLOB, PRIMARY KEY(k, i))' % table) - - self.wide_byte_rows(session, table, 0) - - def test_large_text(self): - table = 'large_text' - - cluster = Cluster() - session = cluster.connect() - session.row_factory = dict_factory - - create_schema(session, self.keyspace) - session.execute('CREATE TABLE %s (k int PRIMARY KEY, txt text)' % table) - - self.large_text(session, table, 0) - - def test_wide_table(self): - table = 'wide_table' - - cluster = Cluster() - session = cluster.connect() - session.row_factory = dict_factory - - create_schema(session, self.keyspace) - table_declaration = 'CREATE TABLE %s (key INT PRIMARY KEY, ' - table_declaration += ' INT, '.join(create_column_name(i) for i in range(330)) - table_declaration += ' INT)' - session.execute(table_declaration % table) - - self.wide_table(session, table, 0) From 7162ad7bf8baccdfee684b9f3a4479b6b5e0203f Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 14 Jan 2014 16:33:58 -0600 Subject: [PATCH 0729/4528] Avoid dict change during iteration --- cassandra/cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 2552328b66..73cbb7fab9 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1045,7 +1045,7 @@ def prepare_on_all_hosts(self, query, excluded_host): Intended for internal use only. """ futures = [] - for host in self._pools: + for host in self._pools[:]: if host != excluded_host and host.is_up: future = ResponseFuture(self, PrepareMessage(query=query), None) From 66e10039bf251219c3c6fe906dbd07566a867d93 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 14 Jan 2014 16:36:57 -0600 Subject: [PATCH 0730/4528] Increase stmt preparation timeout to 5 seconds --- cassandra/cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 73cbb7fab9..be42f78948 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -800,7 +800,7 @@ def _prepare_all_queries(self, host): for ks_chunk in chunks: messages = [PrepareMessage(query=s.query_string) for s in ks_chunk] # TODO: make this timeout configurable somehow? - responses = connection.wait_for_responses(*messages, timeout=2.0) + responses = connection.wait_for_responses(*messages, timeout=5.0) for response in responses: if (not isinstance(response, ResultMessage) or response.kind != ResultMessage.KIND_PREPARED): From a8bd56ffea04b9ec102302bce8f58d6570714e0a Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 14 Jan 2014 16:37:23 -0600 Subject: [PATCH 0731/4528] Log timeouts of bulk stmt preparation at WARNING --- cassandra/cluster.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index be42f78948..213469a3d2 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -808,6 +808,8 @@ def _prepare_all_queries(self, host): "statement on host %s: %r", host, response) log.debug("Done preparing all known prepared statements against host %s", host) + except OperationTimedOut: + log.warn("Timed out trying to prepare all statements on host %s", host) except Exception: # log and ignore log.exception("Error trying to prepare all statements on host %s", host) From 9b2ce2ba76fb3e579400de9eb8d75ab22eaed1b3 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 14 Jan 2014 16:45:45 -0600 Subject: [PATCH 0732/4528] Update prepared statement cache if item is missing --- cassandra/cluster.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 213469a3d2..fad741d427 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1904,14 +1904,14 @@ def _set_result(self, response): try: prepared_statement = self.session.cluster._prepared_statements[query_id] except KeyError: - if self.prepared_statement: - query_string = ", " + self.prepared_statement.query_string + if not self.prepared_statement: + log.error("Tried to execute unknown prepared statement: id=%s", + query_id.encode('hex')) + self._set_final_exception(response) + return else: - query_string = "" - log.error("Tried to execute unknown prepared statement: id=%s%s", - query_id.encode('hex'), query_string) - self._set_final_exception(response) - return + prepared_statement = self.prepared_statement + self.session.cluster._prepared_statements[query_id] = prepared_statement current_keyspace = self._connection.keyspace prepared_keyspace = prepared_statement.keyspace From 77bf3ea22f3ff4e345a858a8fe51313063824e75 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 14 Jan 2014 17:07:44 -0600 Subject: [PATCH 0733/4528] Correctly error callbacks on asyncore reactor This was broken by this commit: 993b5752d17e6552249bac9447ee30fd9e1d8d9d --- cassandra/io/asyncorereactor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index 60c4078ee6..87eab0da9c 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -203,7 +203,7 @@ def _error_all_callbacks(self, exc): callbacks = self._callbacks self._callbacks = {} new_exc = ConnectionShutdown(str(exc)) - for cb in callbacks: + for cb in callbacks.values(): try: cb(new_exc) except Exception: From 3e39275ea0631c1b2bbc353c3f19c78ae5e8453d Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 14 Jan 2014 17:55:40 -0600 Subject: [PATCH 0734/4528] Break from handle_write() if nothing was sent asyncore will return 0 if the socket raised EWOULDBLOCK on the send() call --- cassandra/io/asyncorereactor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index 87eab0da9c..0bd57cf9f2 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -246,6 +246,8 @@ def handle_write(self): if sent < len(next_msg): with self.deque_lock: self.deque.appendleft(next_msg[sent:]) + if sent == 0: + return def handle_read(self): try: From 5f7e4a9016d5215063b9521b8dfd45b368670c62 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 14 Jan 2014 17:56:37 -0600 Subject: [PATCH 0735/4528] Add asyncore unit test --- tests/unit/io/test_asyncorereactor.py | 234 ++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 tests/unit/io/test_asyncorereactor.py diff --git a/tests/unit/io/test_asyncorereactor.py b/tests/unit/io/test_asyncorereactor.py new file mode 100644 index 0000000000..0ed26eda36 --- /dev/null +++ b/tests/unit/io/test_asyncorereactor.py @@ -0,0 +1,234 @@ +try: + import unittest2 as unittest +except ImportError: + import unittest # noqa + +import errno +from StringIO import StringIO +import socket +from socket import error as socket_error + +from mock import patch, Mock + +from cassandra.connection import (PROTOCOL_VERSION, + HEADER_DIRECTION_TO_CLIENT, + ConnectionException) + +from cassandra.decoder import (write_stringmultimap, write_int, write_string, + SupportedMessage, ReadyMessage, ServerError) +from cassandra.marshal import uint8_pack, uint32_pack + +from cassandra.io.asyncorereactor import AsyncoreConnection + + +class LibevConnectionTest(unittest.TestCase): + + def setUp(self): + self.socket_patcher = patch('socket.socket', spec=socket.socket) + self.mock_socket = self.socket_patcher.start() + self.mock_socket().connect_ex.return_value = 0 + self.mock_socket().getsockopt.return_value = 0 + + def tearDown(self): + self.socket_patcher.stop() + + def make_connection(self): + c = AsyncoreConnection('1.2.3.4') + c.socket = Mock() + c.socket.send.side_effect = lambda x: len(x) + return c + + def make_header_prefix(self, message_class, version=PROTOCOL_VERSION, stream_id=0): + return ''.join(map(uint8_pack, [ + 0xff & (HEADER_DIRECTION_TO_CLIENT | version), + 0, # flags (compression) + stream_id, + message_class.opcode # opcode + ])) + + def make_options_body(self): + options_buf = StringIO() + write_stringmultimap(options_buf, { + 'CQL_VERSION': ['3.0.1'], + 'COMPRESSION': [] + }) + return options_buf.getvalue() + + def make_error_body(self, code, msg): + buf = StringIO() + write_int(buf, code) + write_string(buf, msg) + return buf.getvalue() + + def make_msg(self, header, body=""): + return header + uint32_pack(len(body)) + body + + def test_successful_connection(self, *args): + c = self.make_connection() + + # let it write the OptionsMessage + c.handle_write() + + # read in a SupportedMessage response + header = self.make_header_prefix(SupportedMessage) + options = self.make_options_body() + c.socket.recv.return_value = self.make_msg(header, options) + c.handle_read() + + # let it write out a StartupMessage + c.handle_write() + + header = self.make_header_prefix(ReadyMessage, stream_id=1) + c.socket.recv.return_value = self.make_msg(header) + c.handle_read() + + self.assertTrue(c.connected_event.is_set()) + + def test_protocol_error(self, *args): + c = self.make_connection() + + # let it write the OptionsMessage + c.handle_write() + + # read in a SupportedMessage response + header = self.make_header_prefix(SupportedMessage, version=0xa4) + options = self.make_options_body() + c.socket.recv.return_value = self.make_msg(header, options) + c.handle_read() + + # make sure it errored correctly + self.assertTrue(c.is_defunct) + self.assertTrue(c.connected_event.is_set()) + self.assertIsInstance(c.last_error, ConnectionException) + + def test_error_message_on_startup(self, *args): + c = self.make_connection() + + # let it write the OptionsMessage + c.handle_write() + + # read in a SupportedMessage response + header = self.make_header_prefix(SupportedMessage) + options = self.make_options_body() + c.socket.recv.return_value = self.make_msg(header, options) + c.handle_read() + + # let it write out a StartupMessage + c.handle_write() + + header = self.make_header_prefix(ServerError, stream_id=1) + body = self.make_error_body(ServerError.error_code, ServerError.summary) + c.socket.recv.return_value = self.make_msg(header, body) + c.handle_read() + + # make sure it errored correctly + self.assertTrue(c.is_defunct) + self.assertIsInstance(c.last_error, ConnectionException) + self.assertTrue(c.connected_event.is_set()) + + def test_socket_error_on_write(self, *args): + c = self.make_connection() + + # make the OptionsMessage write fail + c.socket.send.side_effect = socket_error(errno.EIO, "bad stuff!") + c.handle_write() + + # make sure it errored correctly + self.assertTrue(c.is_defunct) + self.assertIsInstance(c.last_error, socket_error) + self.assertTrue(c.connected_event.is_set()) + + def test_blocking_on_write(self, *args): + c = self.make_connection() + + # make the OptionsMessage write block + c.socket.send.side_effect = socket_error(errno.EAGAIN, "socket busy") + c.handle_write() + + self.assertFalse(c.is_defunct) + + # try again with normal behavior + c.socket.send.side_effect = lambda x: len(x) + c.handle_write() + self.assertFalse(c.is_defunct) + self.assertTrue(c.socket.send.call_args is not None) + + def test_partial_send(self, *args): + c = self.make_connection() + + # only write the first four bytes of the OptionsMessage + c.socket.send.side_effect = None + c.socket.send.return_value = 4 + c.handle_write() + + self.assertFalse(c.is_defunct) + self.assertEqual(2, c.socket.send.call_count) + self.assertEqual(4, len(c.socket.send.call_args[0][0])) + + def test_socket_error_on_read(self, *args): + c = self.make_connection() + + # let it write the OptionsMessage + c.handle_write() + + # read in a SupportedMessage response + c.socket.recv.side_effect = socket_error(errno.EIO, "busy socket") + c.handle_read() + + # make sure it errored correctly + self.assertTrue(c.is_defunct) + self.assertIsInstance(c.last_error, socket_error) + self.assertTrue(c.connected_event.is_set()) + + def test_partial_header_read(self, *args): + c = self.make_connection() + + header = self.make_header_prefix(SupportedMessage) + options = self.make_options_body() + message = self.make_msg(header, options) + + # read in the first byte + c.socket.recv.return_value = message[0] + c.handle_read() + self.assertEquals(c._iobuf.getvalue(), message[0]) + + c.socket.recv.return_value = message[1:] + c.handle_read() + self.assertEquals("", c._iobuf.getvalue()) + + # let it write out a StartupMessage + c.handle_write() + + header = self.make_header_prefix(ReadyMessage, stream_id=1) + c.socket.recv.return_value = self.make_msg(header) + c.handle_read() + + self.assertTrue(c.connected_event.is_set()) + self.assertFalse(c.is_defunct) + + def test_partial_message_read(self, *args): + c = self.make_connection() + + header = self.make_header_prefix(SupportedMessage) + options = self.make_options_body() + message = self.make_msg(header, options) + + # read in the first nine bytes + c.socket.recv.return_value = message[:9] + c.handle_read() + self.assertEquals(c._iobuf.getvalue(), message[:9]) + + # ... then read in the rest + c.socket.recv.return_value = message[9:] + c.handle_read() + self.assertEquals("", c._iobuf.getvalue()) + + # let it write out a StartupMessage + c.handle_write() + + header = self.make_header_prefix(ReadyMessage, stream_id=1) + c.socket.recv.return_value = self.make_msg(header) + c.handle_read() + + self.assertTrue(c.connected_event.is_set()) + self.assertFalse(c.is_defunct) From a16599128d839ab38a297c1a2dafd4cd6e134f3e Mon Sep 17 00:00:00 2001 From: Daniele Salatti Date: Wed, 15 Jan 2014 09:50:14 +0100 Subject: [PATCH 0736/4528] =?UTF-8?q?README:=20adding=20=E2=80=94pre=20to?= =?UTF-8?q?=20pip=20since=20package=20is=20still=20beta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c95d8730cd..08b6f1ac9e 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ the instructions in the section below before installing the driver. Installation through pip is recommended:: - $ pip install cassandra-driver + $ pip install cassandra-driver --pre If you want to install manually, you can instead do:: From 488c333b4e86a269bd5b886d10a1605909915d55 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 15 Jan 2014 12:18:18 -0600 Subject: [PATCH 0737/4528] Add unittest2 as test req in setup.py --- setup.py | 3 ++- tests/unit/test_metadata.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 0f7952a83e..717a5021f2 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ with open("README.rst") as f: long_description = f.read() + class DocCommand(Command): description = "generate or test documentation" @@ -162,7 +163,7 @@ def run_setup(extensions): packages=['cassandra', 'cassandra.io'], include_package_data=True, install_requires=dependencies, - tests_require=['nose', 'mock', 'ccm'], + tests_require=['nose', 'mock', 'ccm', 'unittest2'], classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', diff --git a/tests/unit/test_metadata.py b/tests/unit/test_metadata.py index f52c9c32bd..7fe76069ad 100644 --- a/tests/unit/test_metadata.py +++ b/tests/unit/test_metadata.py @@ -1,4 +1,7 @@ -import unittest +try: + import unittest2 as unittest +except ImportError: + import unittest # noqa import cassandra from cassandra.metadata import (TableMetadata, Murmur3Token, MD5Token, @@ -128,6 +131,7 @@ def test_simple_strategy_make_token_replica_map(self): self.assertItemsEqual(rf3_replicas[MD5Token(100)], [host2, host3, host1]) self.assertItemsEqual(rf3_replicas[MD5Token(200)], [host3, host1, host2]) + class TestTokens(unittest.TestCase): def test_protect_name(self): From e9b81997fe610d0373a7f1a8d056003a4714224c Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 15 Jan 2014 12:59:41 -0600 Subject: [PATCH 0738/4528] Add unittest2 and pip to tox deps This seems to be required for pypy to work correctly --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index a2aa5f8e3c..4eb6ab57e8 100644 --- a/tox.ini +++ b/tox.ini @@ -5,5 +5,7 @@ envlist = py26,py27,pypy deps = nose mock ccm + unittest2 + pip commands = {envpython} setup.py build_ext --inplace nosetests tests/unit/ From e1d24db2737a12f08d5b638a7590b76aa072e26a Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 15 Jan 2014 13:00:27 -0600 Subject: [PATCH 0739/4528] Minor fixes for pypy tests --- cassandra/query.py | 2 +- tests/unit/io/test_libevreactor.py | 9 +++++++-- tests/unit/test_marshalling.py | 7 ++++++- tests/unit/test_metadata.py | 24 ++++++++++++------------ 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/cassandra/query.py b/cassandra/query.py index 76143da847..803c054b66 100644 --- a/cassandra/query.py +++ b/cassandra/query.py @@ -229,7 +229,7 @@ def bind(self, values): try: self.values.append(col_type.serialize(value)) - except struct.error: + except (TypeError, struct.error): col_name = col_spec[2] expected_type = col_type actual_type = type(value) diff --git a/tests/unit/io/test_libevreactor.py b/tests/unit/io/test_libevreactor.py index 7f9f2615e3..ca303b835c 100644 --- a/tests/unit/io/test_libevreactor.py +++ b/tests/unit/io/test_libevreactor.py @@ -19,8 +19,9 @@ try: from cassandra.io.libevreactor import LibevConnection -except ImportError as exc: - raise unittest.SkipTest('libev does not appear to be installed correctly: %s' % (exc,)) +except ImportError: + LibevConnection = None # noqa + @patch('socket.socket') @patch('cassandra.io.libevwrapper.IO') @@ -29,6 +30,10 @@ @patch('cassandra.io.libevreactor._start_loop') class LibevConnectionTest(unittest.TestCase): + def setUp(self): + if LibevConnection is None: + raise unittest.SkipTest('libev does not appear to be installed correctly') + def make_connection(self): c = LibevConnection('1.2.3.4') c._socket = Mock() diff --git a/tests/unit/test_marshalling.py b/tests/unit/test_marshalling.py index f0d295d360..e5d40f5477 100644 --- a/tests/unit/test_marshalling.py +++ b/tests/unit/test_marshalling.py @@ -8,7 +8,11 @@ from decimal import Decimal from uuid import UUID -from blist import sortedset +try: + from blist import sortedset +except ImportError: + sortedset = set + try: from collections import OrderedDict except ImportError: # Python <2.7 @@ -85,6 +89,7 @@ # CPython marshalled_value_pairs += marshalled_value_pairs_unsafe + class TestUnmarshal(unittest.TestCase): def test_unmarshalling(self): for serializedval, valtype, nativeval in marshalled_value_pairs: diff --git a/tests/unit/test_metadata.py b/tests/unit/test_metadata.py index 7fe76069ad..d4d66b3e27 100644 --- a/tests/unit/test_metadata.py +++ b/tests/unit/test_metadata.py @@ -7,7 +7,7 @@ from cassandra.metadata import (TableMetadata, Murmur3Token, MD5Token, BytesToken, ReplicationStrategy, NetworkTopologyStrategy, SimpleStrategy, - LocalStrategy) + LocalStrategy, NoMurmur3) from cassandra.policies import SimpleConvictionPolicy from cassandra.pool import Host @@ -203,22 +203,22 @@ def test_is_valid_name(self): for keyword in non_valid_keywords: self.assertEqual(table_metadata.is_valid_name(keyword), False) - def test_token_values(self): - """ - Spot check token classes and values - """ - - # spot check murmur3 - murmur3_token = Murmur3Token(cassandra.metadata.MIN_LONG - 1) - self.assertEqual(murmur3_token.hash_fn('123'), -7468325962851647638) - self.assertEqual(murmur3_token.hash_fn(str(cassandra.metadata.MAX_LONG)), 7162290910810015547) - self.assertEqual(str(murmur3_token), '') - + def test_murmur3_tokens(self): + try: + murmur3_token = Murmur3Token(cassandra.metadata.MIN_LONG - 1) + self.assertEqual(murmur3_token.hash_fn('123'), -7468325962851647638) + self.assertEqual(murmur3_token.hash_fn(str(cassandra.metadata.MAX_LONG)), 7162290910810015547) + self.assertEqual(str(murmur3_token), '') + except NoMurmur3: + raise unittest.SkipTest('The murmur3 extension is not available') + + def test_md5_tokens(self): md5_token = MD5Token(cassandra.metadata.MIN_LONG - 1) self.assertEqual(md5_token.hash_fn('123'), 42767516990368493138776584305024125808L) self.assertEqual(md5_token.hash_fn(str(cassandra.metadata.MAX_LONG)), 28528976619278518853815276204542453639L) self.assertEqual(str(md5_token), '') + def test_bytes_tokens(self): bytes_token = BytesToken(str(cassandra.metadata.MIN_LONG - 1)) self.assertEqual(bytes_token.hash_fn('123'), '123') self.assertEqual(bytes_token.hash_fn(123), 123) From 63156a21a1ff541f7a516c4a17800176847a5669 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 15 Jan 2014 14:20:26 -0600 Subject: [PATCH 0740/4528] Add integration tests to tox, related fixes --- cassandra/cluster.py | 2 +- cassandra/decoder.py | 11 +++++++++-- setup.py | 2 +- tests/integration/__init__.py | 5 ++++- tests/integration/standard/test_cluster.py | 3 ++- tests/integration/standard/test_connection.py | 7 ++++--- tests/integration/standard/test_factories.py | 9 ++++++--- tests/integration/standard/test_metadata.py | 9 ++++++++- tests/integration/standard/test_metrics.py | 6 +++++- tests/integration/standard/test_query.py | 9 +++++++-- tests/integration/standard/test_types.py | 7 +++++-- tests/unit/io/test_asyncorereactor.py | 18 ++++++++++-------- tox.ini | 3 ++- 13 files changed, 64 insertions(+), 27 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index fad741d427..794919aa19 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1047,7 +1047,7 @@ def prepare_on_all_hosts(self, query, excluded_host): Intended for internal use only. """ futures = [] - for host in self._pools[:]: + for host in self._pools.keys(): if host != excluded_host and host.is_up: future = ResponseFuture(self, PrepareMessage(query=query), None) diff --git a/cassandra/decoder.py b/cassandra/decoder.py index effc2d9b48..57f6f68e51 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -5,6 +5,7 @@ import logging import re import socket +import sys import types from uuid import UUID @@ -782,8 +783,13 @@ def cql_encode_str(val): return cql_quote(val) -def cql_encode_bytes(val): - return '0x' + hexlify(val) +if sys.version_info >= (2, 7): + def cql_encode_bytes(val): + return '0x' + hexlify(val) +else: + # python 2.6 requires string or read-only buffer for hexlify + def cql_encode_bytes(val): + return '0x' + hexlify(buffer(val)) def cql_encode_object(val): @@ -826,6 +832,7 @@ def cql_encode_all_types(val): cql_encoders = { float: cql_encode_object, + buffer: cql_encode_bytes, bytearray: cql_encode_bytes, str: cql_encode_str, unicode: cql_encode_unicode, diff --git a/setup.py b/setup.py index 717a5021f2..72a23ce6d9 100644 --- a/setup.py +++ b/setup.py @@ -163,7 +163,7 @@ def run_setup(extensions): packages=['cassandra', 'cassandra.io'], include_package_data=True, install_requires=dependencies, - tests_require=['nose', 'mock', 'ccm', 'unittest2'], + tests_require=['nose', 'mock', 'ccm', 'unittest2', 'PyYAML'], classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 4e0140cf63..235288bbc5 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -14,7 +14,7 @@ from ccmlib.cluster import Cluster as CCMCluster from ccmlib import common except ImportError as e: - raise unittest.SkipTest('ccm is a dependency for integration tests') + raise unittest.SkipTest('ccm is a dependency for integration tests:', e) CLUSTER_NAME = 'test_cluster' CCM_CLUSTER = None @@ -52,9 +52,11 @@ def _tuple_version(version_string): def get_cluster(): return CCM_CLUSTER + def get_node(node_id): return CCM_CLUSTER.nodes['node%s' % node_id] + def setup_package(): try: try: @@ -78,6 +80,7 @@ def setup_package(): CCM_CLUSTER = cluster setup_test_keyspace() + def setup_test_keyspace(): cluster = Cluster() session = cluster.connect() diff --git a/tests/integration/standard/test_cluster.py b/tests/integration/standard/test_cluster.py index e7676b3f02..cb7f915be1 100644 --- a/tests/integration/standard/test_cluster.py +++ b/tests/integration/standard/test_cluster.py @@ -10,6 +10,7 @@ from cassandra.cluster import Cluster, NoHostAvailable + class ClusterTests(unittest.TestCase): def test_basic(self): @@ -100,7 +101,7 @@ def test_double_shutdown(self): cluster.shutdown() self.fail('A double cluster.shutdown() should throw an error.') except Exception as e: - self.assertEqual(e.message, 'The Cluster was already shutdown') + self.assertIn('The Cluster was already shutdown', str(e)) def test_connect_to_already_shutdown_cluster(self): """ diff --git a/tests/integration/standard/test_connection.py b/tests/integration/standard/test_connection.py index 229f1ccd61..38903486f4 100644 --- a/tests/integration/standard/test_connection.py +++ b/tests/integration/standard/test_connection.py @@ -15,6 +15,7 @@ except ImportError: LibevConnection = None + class ConnectionTest(object): klass = None @@ -176,7 +177,7 @@ class LibevConnectionTest(ConnectionTest, unittest.TestCase): klass = LibevConnection - @classmethod - def setup_class(cls): + def setUp(self): if LibevConnection is None: - raise unittest.SkipTest('pyev does not appear to be installed properly') + raise unittest.SkipTest( + 'libev does not appear to be installed properly') diff --git a/tests/integration/standard/test_factories.py b/tests/integration/standard/test_factories.py index 982a6fff86..11fe165908 100644 --- a/tests/integration/standard/test_factories.py +++ b/tests/integration/standard/test_factories.py @@ -1,5 +1,8 @@ -import unittest -import cassandra +try: + import unittest2 as unittest +except ImportError: + import unittest # noqa + from cassandra.cluster import Cluster from cassandra.decoder import tuple_factory, named_tuple_factory, dict_factory, ordered_dict_factory @@ -8,6 +11,7 @@ except ImportError: # Python <2.7 from cassandra.util import OrderedDict # NOQA + class TestFactories(unittest.TestCase): """ Test different row_factories and access code @@ -78,7 +82,6 @@ def test_named_tuple_factoryy(self): self.assertEqual(result[1].k, result[1].v) self.assertEqual(result[1].k, 2) - def test_dict_factory(self): cluster = Cluster() session = cluster.connect() diff --git a/tests/integration/standard/test_metadata.py b/tests/integration/standard/test_metadata.py index 2dfa56e5d2..b542b29ba3 100644 --- a/tests/integration/standard/test_metadata.py +++ b/tests/integration/standard/test_metadata.py @@ -8,12 +8,14 @@ from cassandra import AlreadyExists from cassandra.cluster import Cluster -from cassandra.metadata import Metadata, KeyspaceMetadata, TableMetadata, Token, MD5Token, TokenMap +from cassandra.metadata import (Metadata, KeyspaceMetadata, TableMetadata, + Token, MD5Token, TokenMap, murmur3) from cassandra.policies import SimpleConvictionPolicy from cassandra.pool import Host from tests.integration import get_cluster + class SchemaMetadataTest(unittest.TestCase): ksname = "schemametadatatest" @@ -276,7 +278,9 @@ def test_indexes(self): self.assertEqual(d_index, statements[1]) self.assertEqual(e_index, statements[2]) + class TestCodeCoverage(unittest.TestCase): + def test_export_schema(self): """ Test export schema functionality @@ -326,6 +330,9 @@ def test_replicas(self): """ Ensure cluster.metadata.get_replicas return correctly when not attached to keyspace """ + if murmur3 is None: + raise unittest.SkipTest('the murmur3 extension is not available') + cluster = Cluster() self.assertEqual(cluster.metadata.get_replicas('test3rf', 'key'), []) diff --git a/tests/integration/standard/test_metrics.py b/tests/integration/standard/test_metrics.py index eea15b943e..8f5b3ae76e 100644 --- a/tests/integration/standard/test_metrics.py +++ b/tests/integration/standard/test_metrics.py @@ -1,4 +1,8 @@ -import unittest +try: + import unittest2 as unittest +except ImportError: + import unittest # noqa + from cassandra.query import SimpleStatement from cassandra import ConsistencyLevel, WriteTimeout, Unavailable, ReadTimeout diff --git a/tests/integration/standard/test_query.py b/tests/integration/standard/test_query.py index 2b80cfe8d4..5a698afd4e 100644 --- a/tests/integration/standard/test_query.py +++ b/tests/integration/standard/test_query.py @@ -1,4 +1,8 @@ -import unittest +try: + import unittest2 as unittest +except ImportError: + import unittest # noqa + from cassandra.query import PreparedStatement, BoundStatement, ValueSequence, SimpleStatement from cassandra.cluster import Cluster @@ -19,7 +23,6 @@ def test_query(self): session.execute(bound) self.assertEqual(bound.routing_key, '\x00\x00\x00\x01') - def test_value_sequence(self): """ Test the output of ValueSequences() @@ -45,6 +48,7 @@ def test_trace_prints_okay(self): for event in statement.trace.events: str(event) + class PreparedStatementTests(unittest.TestCase): def test_routing_key(self): @@ -140,6 +144,7 @@ def test_bound_keyspace(self): bound.prepared_statement.column_metadata = None self.assertEqual(bound.keyspace, None) + class PrintStatementTests(unittest.TestCase): """ Test that shows the format used when printing Statements diff --git a/tests/integration/standard/test_types.py b/tests/integration/standard/test_types.py index 7ac470d03d..d4866c4560 100644 --- a/tests/integration/standard/test_types.py +++ b/tests/integration/standard/test_types.py @@ -7,7 +7,10 @@ from datetime import datetime from uuid import uuid1, uuid4 -from blist import sortedset +try: + from blist import sortedset +except ImportError: + sortedset = set # noqa from cassandra import InvalidRequest from cassandra.cluster import Cluster @@ -146,7 +149,7 @@ def test_basic_types(self): "1.2.3.4", # inet 12345, # int ['a', 'b', 'c'], # list collection - {1, 2, 3}, # set collection + set([1, 2, 3]), # set collection {'a': 1, 'b': 2}, # map collection "text", # text mydatetime, # timestamp diff --git a/tests/unit/io/test_asyncorereactor.py b/tests/unit/io/test_asyncorereactor.py index 0ed26eda36..3b4946cb1b 100644 --- a/tests/unit/io/test_asyncorereactor.py +++ b/tests/unit/io/test_asyncorereactor.py @@ -23,14 +23,16 @@ class LibevConnectionTest(unittest.TestCase): - def setUp(self): - self.socket_patcher = patch('socket.socket', spec=socket.socket) - self.mock_socket = self.socket_patcher.start() - self.mock_socket().connect_ex.return_value = 0 - self.mock_socket().getsockopt.return_value = 0 - - def tearDown(self): - self.socket_patcher.stop() + @classmethod + def setUpClass(cls): + cls.socket_patcher = patch('socket.socket', spec=socket.socket) + cls.mock_socket = cls.socket_patcher.start() + cls.mock_socket().connect_ex.return_value = 0 + cls.mock_socket().getsockopt.return_value = 0 + + @classmethod + def tearDownClass(cls): + cls.socket_patcher.stop() def make_connection(self): c = AsyncoreConnection('1.2.3.4') diff --git a/tox.ini b/tox.ini index 4eb6ab57e8..f7190c86d6 100644 --- a/tox.ini +++ b/tox.ini @@ -7,5 +7,6 @@ deps = nose ccm unittest2 pip + PyYAML commands = {envpython} setup.py build_ext --inplace - nosetests tests/unit/ + nosetests tests/unit/ tests/integration/standard/ From 516b16ee5610a32f9926e2d99bebb464f0104e6f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 15 Jan 2014 12:37:57 -0800 Subject: [PATCH 0741/4528] 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 0742/4528] 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 074fa6bc94f161cae9db62f71272a983af6e73a8 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 15 Jan 2014 15:23:45 -0600 Subject: [PATCH 0743/4528] Whitespace fix --- tests/integration/long/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/long/utils.py b/tests/integration/long/utils.py index 5b1f547457..cb411a2e80 100644 --- a/tests/integration/long/utils.py +++ b/tests/integration/long/utils.py @@ -75,6 +75,7 @@ def stop(node): def force_stop(node): get_node(node).stop(wait=False, gently=False) + def ring(node): print 'From node%s:' % node get_node(node).nodetool('ring') From 701099e69db492c081309f3c27243efdf1409136 Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 15 Jan 2014 13:47:32 -0800 Subject: [PATCH 0744/4528] 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 3289fcb3b61483d36cc1bde60ee57537132e799f Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 15 Jan 2014 15:48:23 -0600 Subject: [PATCH 0745/4528] Increase verbosity in tox tests --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index f7190c86d6..265305434e 100644 --- a/tox.ini +++ b/tox.ini @@ -9,4 +9,4 @@ deps = nose pip PyYAML commands = {envpython} setup.py build_ext --inplace - nosetests tests/unit/ tests/integration/standard/ + nosetests --verbosity=2 tests/unit/ tests/integration/standard/ From e3d51d0f928e1bde9af2a01d7c01dc595ae41aad Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 15 Jan 2014 15:49:56 -0600 Subject: [PATCH 0746/4528] Add build status to README --- README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.rst b/README.rst index 08b6f1ac9e..1798b07347 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,9 @@ DataStax Python Driver for Apache Cassandra (Beta) ================================================== + +.. image:: https://travis-ci.org/datastax/python-driver.png?branch=master + :target: https://travis-ci.org/datastax/python-driver + A Python client driver for Apache Cassandra. This driver works exclusively with the Cassandra Query Language v3 (CQL3) and Cassandra's native protocol. As such, only Cassandra 1.2+ is supported. From 5a0f945294a1b7120a745727951ed25f52c78e2f Mon Sep 17 00:00:00 2001 From: Jon Haddad Date: Wed, 15 Jan 2014 15:53:07 -0800 Subject: [PATCH 0747/4528] 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 0748/4528] 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 0749/4528] 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 84d105af1eae02753f66898598cdd4022c7dd66f Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 16 Jan 2014 11:37:28 -0600 Subject: [PATCH 0750/4528] Only run unit tests in tox for now --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 265305434e..eb51cc788c 100644 --- a/tox.ini +++ b/tox.ini @@ -9,4 +9,4 @@ deps = nose pip PyYAML commands = {envpython} setup.py build_ext --inplace - nosetests --verbosity=2 tests/unit/ tests/integration/standard/ + nosetests --verbosity=2 tests/unit/ From 1a94f1baba0e324f208a1132efb42a6ac3b8518c Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 16 Jan 2014 11:45:05 -0600 Subject: [PATCH 0751/4528] Don't error log ConnectionExceptions during handshake --- cassandra/connection.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index 22d4fd01a8..1ebb523858 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -220,8 +220,14 @@ def _handle_options_response(self, options_response): return if not isinstance(options_response, SupportedMessage): - log.error("Did not get expected SupportedMessage response; instead, got: %s", options_response) - raise ConnectionException("Did not get expected SupportedMessage response; instead, got: %s" % (options_response,)) + if isinstance(options_response, ConnectionException): + raise options_response + else: + log.error("Did not get expected SupportedMessage response; " \ + "instead, got: %s", options_response) + raise ConnectionException("Did not get expected SupportedMessage " \ + "response; instead, got: %s" \ + % (options_response,)) log.debug("Received options response on new connection (%s) from %s", id(self), self.host) From db11b411f400437a144e13e8b2fb0910702a6479 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 16 Jan 2014 12:39:17 -0600 Subject: [PATCH 0752/4528] Add integration tests for tz-aware datetimes --- tests/integration/standard/test_types.py | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/integration/standard/test_types.py b/tests/integration/standard/test_types.py index d4866c4560..94c2706fb4 100644 --- a/tests/integration/standard/test_types.py +++ b/tests/integration/standard/test_types.py @@ -219,3 +219,33 @@ def test_basic_types(self): for expected, actual in zip(expected_vals, results[0]): self.assertEquals(expected, actual) + + def test_timezone_aware_datetimes(self): + """ Ensure timezone-aware datetimes are converted to timestamps correctly """ + try: + import pytz + except ImportError, exc: + raise unittest.SkipTest('pytz is not available: %r' % (exc,)) + + dt = datetime(1997, 8, 29, 11, 14) + eastern_tz = pytz.timezone('US/Eastern') + eastern_tz.localize(dt) + + c = Cluster() + s = c.connect() + + s.execute("""CREATE KEYSPACE tz_aware_test + WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor': '1'}""") + s.set_keyspace("tz_aware_test") + s.execute("CREATE TABLE mytable (a ascii PRIMARY KEY, b timestamp)") + + # test non-prepared statement + s.execute("INSERT INTO mytable (a, b) VALUES ('key1', %s)", parameters=(dt,)) + result = s.execute("SELECT b FROM mytable WHERE a='key1'")[0].b + self.assertEquals(dt.utctimetuple(), result.utctimetuple()) + + # test prepared statement + prepared = s.prepare("INSERT INTO mytable (a, b) VALUES ('key2', ?)") + s.execute(prepared, parameters=(dt,)) + result = s.execute("SELECT b FROM mytable WHERE a='key2'")[0].b + self.assertEquals(dt.utctimetuple(), result.utctimetuple()) From a8dd5f72c98b991330a2363f1a6eb53705994865 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 16 Jan 2014 13:37:03 -0600 Subject: [PATCH 0753/4528] Warn when non-datetime timestamps are used in prepared stmts --- cassandra/cqltypes.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index bf48724b68..d023350b01 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -439,6 +439,8 @@ class CounterColumnType(_CassandraType): '%Y-%m-%d' ) +_have_warned_about_timestamps = False + class DateType(_CassandraType): typename = 'timestamp' @@ -482,6 +484,20 @@ def serialize(v): if type(v) not in _number_types: raise TypeError('DateType arguments must be a datetime or timestamp') + if not _have_warned_about_timestamps: + global _have_warned_about_timestamps + _have_warned_about_timestamps = True + warnings.warn("timestamp columns in Cassandra hold a number of " + "milliseconds since the unix epoch. Currently, when executing " + "prepared statements, this driver multiplies timestamp " + "values by 1000 so that the result of time.time() " + "can be used directly. However, the driver cannot " + "match this behavior for non-prepared statements, " + "so the 2.0 version of the driver will no longer multiply " + "timestamps by 1000. It is suggested that you simply use " + "datetime.datetime objects for 'timestamp' values to avoid " + "any ambiguity and to guarantee a smooth upgrade of the " + "driver.") converted = v * 1e3 return int64_pack(long(converted)) From b918383313ad4c8a31097b9aea9e2db9f39c596c Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 17 Jan 2014 14:16:25 -0600 Subject: [PATCH 0754/4528] Update API documentation --- cassandra/__init__.py | 5 ++ cassandra/cluster.py | 88 +++++++++++++++++++++++++++---- cassandra/metadata.py | 8 +++ cassandra/pool.py | 7 ++- docs/api/cassandra.rst | 14 +++++ docs/api/cassandra/cluster.rst | 80 +++++++++++++++++++++++++--- docs/api/cassandra/connection.rst | 1 + docs/api/cassandra/pool.rst | 8 +-- 8 files changed, 189 insertions(+), 22 deletions(-) diff --git a/cassandra/__init__.py b/cassandra/__init__.py index 3169e2a46c..febe0cd489 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -221,4 +221,9 @@ class AuthenticationFailed(Exception): class OperationTimedOut(Exception): + """ + The operation took longer than the specified (client-side) timeout + to complete. This is not an error generated by Cassandra, only + the driver. + """ pass diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 794919aa19..2d1f09de1c 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -5,6 +5,7 @@ from concurrent.futures import ThreadPoolExecutor import logging +import socket import sys import time from threading import Lock, RLock, Thread, Event @@ -132,6 +133,13 @@ class Cluster(object): The server-side port to open connections to. Defaults to 9042. """ + cql_version = None + """ + If a specific version of CQL should be used, this may be set to that + string version. Otherwise, the highest CQL version supported by the + server will be automatically used. + """ + compression = True """ Whether or not compression should be enabled when possible. Defaults to @@ -173,7 +181,8 @@ class Cluster(object): metrics_enabled = False """ - Whether or not metric collection is enabled. + Whether or not metric collection is enabled. If enabled, :attr:`.metrics` + will be an instance of :class:`.metrics.Metrics`. """ metrics = None @@ -373,18 +382,42 @@ def set_max_requests_per_connection(self, host_distance, max_requests): self._max_requests_per_connection[host_distance] = max_requests def get_core_connections_per_host(self, host_distance): + """ + Gets the minimum number of connections that will be opened for each + host with :class:`~.HostDistance` equal to `host_distance`. The default + is 2 for :attr:`~HostDistance.LOCAL` and 1 for + :attr:`~HostDistance.REMOTE`. + """ return self._core_connections_per_host[host_distance] def set_core_connections_per_host(self, host_distance, core_connections): + """ + Sets the minimum number of connections that will be opened for each + host with :class:`~.HostDistance` equal to `host_distance`. The default + is 2 for :attr:`~HostDistance.LOCAL` and 1 for + :attr:`~HostDistance.REMOTE`. + """ old = self._core_connections_per_host[host_distance] self._core_connections_per_host[host_distance] = core_connections if old < core_connections: - self.ensure_core_connections() + self._ensure_core_connections() def get_max_connections_per_host(self, host_distance): + """ + Gets the maximum number of connections that will be opened for each + host with :class:`~.HostDistance` equal to `host_distance`. The default + is 8 for :attr:`~HostDistance.LOCAL` and 2 for + :attr:`~HostDistance.REMOTE`. + """ return self._max_connections_per_host[host_distance] def set_max_connections_per_host(self, host_distance, max_connections): + """ + Gets the maximum number of connections that will be opened for each + host with :class:`~.HostDistance` equal to `host_distance`. The default + is 2 for :attr:`~HostDistance.LOCAL` and 1 for + :attr:`~HostDistance.REMOTE`. + """ self._max_connections_per_host[host_distance] = max_connections def connection_factory(self, address, *args, **kwargs): @@ -453,6 +486,9 @@ def connect(self, keyspace=None): def shutdown(self): """ Closes all sessions and connection associated with this Cluster. + To ensure all connections are properly closed, **you should always + call shutdown() on a Cluster instance when you are done with it**. + Once shutdown, a Cluster should not be used for any purpose. """ with self._lock: @@ -756,7 +792,7 @@ def listeners(self): with self._listener_lock: return self._listeners.copy() - def ensure_core_connections(self): + def _ensure_core_connections(self): """ If any host has fewer than the configured number of core connections open, attempt to open connections until that number is met. @@ -810,8 +846,9 @@ def _prepare_all_queries(self, host): log.debug("Done preparing all known prepared statements against host %s", host) except OperationTimedOut: log.warn("Timed out trying to prepare all statements on host %s", host) + except (ConnectionException, socket.error) as exc: + log.warn("Error trying to prepare all statements on host %s: %r", host, exc) except Exception: - # log and ignore log.exception("Error trying to prepare all statements on host %s", host) finally: connection.close() @@ -864,13 +901,13 @@ class Session(object): A default timeout, measured in seconds, for queries executed through :meth:`.execute()` or :meth:`.execute_async()`. This default may be overridden with the `timeout` parameter for either of those methods - or the `timeout` parameter for :meth:`~.ResponseFuture.result()`. + or the `timeout` parameter for :meth:`.ResponseFuture.result()`. Setting this to :const:`None` will cause no timeouts to be set by default. - *Important*: This timeout currently has no effect on callbacks registered - on a :class:`~.ResponseFuture` through :meth:`~.ResponseFuture.add_callback` or - :meth:`~.ResponseFuture.add_errback`; even if a query exceeds this default + **Important**: This timeout currently has no effect on callbacks registered + on a :class:`~.ResponseFuture` through :meth:`.ResponseFuture.add_callback` or + :meth:`.ResponseFuture.add_errback`; even if a query exceeds this default timeout, neither the registered callback or errback will be called. """ @@ -951,7 +988,7 @@ def execute_async(self, query, parameters=None, trace=False): Execute the given query and return a :class:`~.ResponseFuture` object which callbacks may be attached to for asynchronous response delivery. You may also call :meth:`~.ResponseFuture.result()` - on the ``ResponseFuture`` to syncronously block for results at + on the :class:`.ResponseFuture` to syncronously block for results at any time. If `trace` is set to :const:`True`, you may call @@ -1018,7 +1055,20 @@ def prepare(self, query): >>> session = cluster.connect("mykeyspace") >>> query = "INSERT INTO users (id, name, age) VALUES (?, ?, ?)" >>> prepared = session.prepare(query) - >>> session.execute(prepared.bind((user.id, user.name, user.age))) + >>> session.execute(prepared, (user.id, user.name, user.age)) + + Or you may bind values to the prepared statement ahead of time:: + + >>> prepared = session.prepare(query) + >>> bound_stmt = prepared.bind((user.id, user.name, user.age)) + >>> session.execute(bound_stmt) + + Of course, prepared statements may (and should) be reused:: + + >>> prepared = session.prepare(query) + >>> for user in users: + ... bound = prepared.bind((user.id, user.name, user.age)) + ... session.execute(bound) """ message = PrepareMessage(query=query) @@ -1723,10 +1773,16 @@ class ResponseFuture(object): :meth:`.add_callback()`, :meth:`.add_errback()`, and :meth:`.add_callbacks()`. """ + + query = None + """ + The :class:`~.Statement` instance that is being executed through this + :class:`.ResponseFuture`. + """ + session = None row_factory = None message = None - query = None default_timeout = None _req_id = None @@ -2071,6 +2127,12 @@ def result(self, timeout=_NOT_SET): encountered. If the final result or error has not been set yet, this method will block until that time. + You may set a timeout (in seconds) with the `timeout` parameter. + By default, the :attr:`~.default_timeout` for the :class:`.Session` + this was created through will be used for the timeout on this + operation. If the timeout is exceeded, an + :exc:`cassandra.OperationTimedOut` will be raised. + Example usage:: >>> future = session.execute_async("SELECT * FROM mycf") @@ -2128,6 +2190,10 @@ def add_callback(self, fn, *args, **kwargs): If the final result has already been seen when this method is called, the callback will be called immediately (before this method returns). + **Important**: if the callback you attach results in an exception being + raised, **the exception will be ignored**, so please ensure your + callback handles all error cases that you care about. + Usage example:: >>> session = cluster.connect("mykeyspace") diff --git a/cassandra/metadata.py b/cassandra/metadata.py index f46170018c..11b3eafb43 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -402,7 +402,11 @@ def export_for_schema(self): class SimpleStrategy(ReplicationStrategy): name = "SimpleStrategy" + replication_factor = None + """ + The replication factor for this keyspace. + """ def __init__(self, replication_factor): self.replication_factor = int(replication_factor) @@ -435,7 +439,11 @@ def __eq__(self, other): class NetworkTopologyStrategy(ReplicationStrategy): name = "NetworkTopologyStrategy" + dc_replication_factors = None + """ + A map of datacenter names to the replication factor for that DC. + """ def __init__(self, dc_replication_factors): self.dc_replication_factors = dc_replication_factors diff --git a/cassandra/pool.py b/cassandra/pool.py index 2e6f17274e..29d9062b4c 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -38,11 +38,16 @@ class Host(object): conviction_policy = None """ - A class:`ConvictionPolicy` instance for determining when this node should + A :class:`~.ConvictionPolicy` instance for determining when this node should be marked up or down. """ is_up = None + """ + :const:`True` if the node is considered up, :const:`False` if it is + considered down, and :const:`None` if it is not known if the node is + up or down. + """ _datacenter = None _rack = None diff --git a/docs/api/cassandra.rst b/docs/api/cassandra.rst index 50e4856e2f..90d23d108e 100644 --- a/docs/api/cassandra.rst +++ b/docs/api/cassandra.rst @@ -3,6 +3,14 @@ .. module:: cassandra +.. data:: __version_info__ + + The version of the driver in a tuple format + +.. data:: __version__ + + The version of the driver in a string format + .. autoclass:: ConsistencyLevel :members: @@ -18,6 +26,9 @@ .. autoexception:: WriteTimeout() :members: +.. autoexception:: AlreadyExists() + :members: + .. autoexception:: InvalidRequest() :members: @@ -26,3 +37,6 @@ .. autoexception:: AuthenticationFailed() :members: + +.. autoexception:: OperationTimedOut() + :members: diff --git a/docs/api/cassandra/cluster.rst b/docs/api/cassandra/cluster.rst index 17a39a10f7..c71317a84c 100644 --- a/docs/api/cassandra/cluster.rst +++ b/docs/api/cassandra/cluster.rst @@ -4,16 +4,84 @@ .. module:: cassandra.cluster .. autoclass:: Cluster ([contact_points=('127.0.0.1',)][, port=9042][, executor_threads=2], **attr_kwargs) - :members: - :exclude-members: on_up, on_down, add_host, remove_host, connection_factory + + .. autoattribute:: cql_version + + .. autoattribute:: port + + .. autoattribute:: compression + + .. autoattribute:: auth_provider + + .. autoattribute:: load_balancing_policy + + .. autoattribute:: reconnection_policy + + .. autoattribute:: default_retry_policy + + .. autoattribute:: conviction_policy_factory + + .. autoattribute:: connection_class + + .. autoattribute:: metrics_enabled + + .. autoattribute:: metrics + + .. autoattribute:: metadata + + .. autoattribute:: ssl_options + + .. autoattribute:: sockopts + + .. autoattribute:: max_schema_agreement_wait + + .. autoattribute:: control_connection_timeout + + .. automethod:: connect + + .. automethod:: shutdown + + .. automethod:: register_listener + + .. automethod:: unregister_listener + + .. automethod:: get_core_connections_per_host + + .. automethod:: set_core_connections_per_host + + .. automethod:: get_max_connections_per_host + + .. automethod:: set_max_connections_per_host .. autoclass:: Session () - :members: - :exclude-members: on_up, on_down, on_add, on_remove, add_host, prepare_on_all_hosts, submit + + .. autoattribute:: default_timeout + + .. autoattribute:: row_factory + + .. automethod:: execute(statement[, parameters][, timeout][, trace]) + + .. automethod:: execute_async(statement[, parameters][, trace]) + + .. automethod:: prepare(statement) + + .. automethod:: shutdown() + + .. automethod:: set_keyspace(keyspace) .. autoclass:: ResponseFuture () - :members: - :exclude-members: send_request + + .. autoattribute:: query + + .. automethod:: result([timeout]) + + .. automethod:: get_query_trace() + + .. automethod:: add_callback(fn, *args, **kwargs) + + .. automethod:: add_errback(fn, *args, **kwargs) + + .. automethod:: add_callbacks(callback, errback, callback_args=(), callback_kwargs=None, errback_args=(), errback_args=None) .. autoexception:: NoHostAvailable () :members: diff --git a/docs/api/cassandra/connection.rst b/docs/api/cassandra/connection.rst index 8a21d57618..3e9851b1a3 100644 --- a/docs/api/cassandra/connection.rst +++ b/docs/api/cassandra/connection.rst @@ -4,5 +4,6 @@ .. module:: cassandra.connection .. autoexception:: ConnectionException () +.. autoexception:: ConnectionShutdown () .. autoexception:: ConnectionBusy () .. autoexception:: ProtocolError () diff --git a/docs/api/cassandra/pool.rst b/docs/api/cassandra/pool.rst index c0f5418502..b14d30e19c 100644 --- a/docs/api/cassandra/pool.rst +++ b/docs/api/cassandra/pool.rst @@ -4,8 +4,8 @@ .. automodule:: cassandra.pool .. autoclass:: Host () - :members: - :exclude-members: set_location_info, get_and_set_reconnection_handler + :members: + :exclude-members: set_location_info, get_and_set_reconnection_handler -.. autoclass:: HealthMonitor () - :members: +.. autoexception:: NoConnectionsAvailable + :members: From 5550ee462451debe17b8905615ccde64bc88467d Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 17 Jan 2014 15:49:31 -0600 Subject: [PATCH 0755/4528] Exponential trace fetch backoff, now configurable, test --- cassandra/cluster.py | 21 +++++++++++++++++---- cassandra/query.py | 22 ++++++++++++++-------- tests/integration/standard/test_cluster.py | 12 +++++++++++- tests/integration/standard/test_query.py | 1 + 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 2d1f09de1c..1506165fef 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -911,6 +911,17 @@ class Session(object): timeout, neither the registered callback or errback will be called. """ + max_trace_wait = 2.0 + """ + The maximum amount of time (in seconds) the driver will wait for trace + details to be populated server-side for a query before giving up. + If the `trace` parameter for :meth:`~.execute()` or :meth:`~.execute_async()` + is :const:`True`, the driver will repeatedly attempt to fetch trace + details for the query (using exponential backoff) until this limit is + hit. If the limit is passed, :exc:`cassandra.query.TraceUnavailable` + will be raised. + """ + _lock = None _pools = None _load_balancer = None @@ -977,7 +988,7 @@ def execute(self, query, parameters=None, timeout=_NOT_SET, trace=False): finally: if trace: try: - query.trace = future.get_query_trace() + query.trace = future.get_query_trace(self.max_trace_wait) except Exception: log.exception("Unable to fetch query trace:") @@ -2162,17 +2173,19 @@ def result(self, timeout=_NOT_SET): else: raise OperationTimedOut() - def get_query_trace(self): + def get_query_trace(self, max_wait=None): """ Returns the :class:`~.query.QueryTrace` instance representing a trace of the last attempt for this operation, or :const:`None` if tracing was not enabled for this query. Note that this may raise an exception if - there are problems retrieving the trace details from Cassandra. + there are problems retrieving the trace details from Cassandra. If the + trace is not available after `max_wait` seconds, + :exc:`cassandra.query.TraceUnavailable` will be raised. """ if not self._query_trace: return None - self._query_trace.populate() + self._query_trace.populate(max_wait) return self._query_trace def add_callback(self, fn, *args, **kwargs): diff --git a/cassandra/query.py b/cassandra/query.py index 803c054b66..8fc960fd1f 100644 --- a/cassandra/query.py +++ b/cassandra/query.py @@ -13,6 +13,9 @@ from cassandra.decoder import (cql_encoders, cql_encode_object, cql_encode_sequence) +import logging +log = logging.getLogger(__name__) + class Statement(object): """ @@ -386,27 +389,29 @@ class QueryTrace(object): _SELECT_SESSIONS_FORMAT = "SELECT * FROM system_traces.sessions WHERE session_id = %s" _SELECT_EVENTS_FORMAT = "SELECT * FROM system_traces.events WHERE session_id = %s" _BASE_RETRY_SLEEP = 0.003 - _MAX_ATTEMPTS = 5 def __init__(self, trace_id, session): self.trace_id = trace_id self._session = session - def populate(self): + def populate(self, max_wait=2.0): """ Retrieves the actual tracing details from Cassandra and populates the attributes of this instance. Because tracing details are stored asynchronously by Cassandra, this may need to retry the session - detail fetch up to five times before raising :exc:`.TraceUnavailable`. - - Currently intended for internal use only. + detail fetch. If the trace is still not available after `max_wait` + seconds, :exc:`.TraceUnavailable` will be raised; if `max_wait` is + :const:`None`, this will retry forever. """ attempt = 0 - while attempt <= self._MAX_ATTEMPTS: - attempt += 1 + start = time.time() + while True: + if max_wait is not None and time.time() - start >= max_wait: + raise TraceUnavailable("Trace information was not available within %f seconds" % (max_wait,)) session_results = self._session.execute(self._SELECT_SESSIONS_FORMAT, (self.trace_id,)) if not session_results or session_results[0].duration is None: - time.sleep(self._BASE_RETRY_SLEEP * attempt) + time.sleep(self._BASE_RETRY_SLEEP * (2 ** attempt)) + attempt += 1 continue session_row = session_results[0] @@ -419,6 +424,7 @@ def populate(self): event_results = self._session.execute(self._SELECT_EVENTS_FORMAT, (self.trace_id,)) self.events = tuple(TraceEvent(r.activity, r.event_id, r.source, r.source_elapsed, r.thread) for r in event_results) + break def __str__(self): return "%s [%s] coordinator: %s, started at: %s, duration: %s, parameters: %s" \ diff --git a/tests/integration/standard/test_cluster.py b/tests/integration/standard/test_cluster.py index cb7f915be1..95309bcdc3 100644 --- a/tests/integration/standard/test_cluster.py +++ b/tests/integration/standard/test_cluster.py @@ -4,7 +4,7 @@ import unittest # noqa import cassandra -from cassandra.query import SimpleStatement +from cassandra.query import SimpleStatement, TraceUnavailable from cassandra.io.asyncorereactor import AsyncoreConnection from cassandra.policies import RoundRobinPolicy, ExponentialReconnectionPolicy, RetryPolicy, SimpleConvictionPolicy, HostDistance @@ -214,6 +214,16 @@ def test_trace(self): future.result() self.assertEqual(None, future.get_query_trace()) + def test_trace_timeout(self): + cluster = Cluster() + session = cluster.connect() + + query = "SELECT * FROM system.local" + statement = SimpleStatement(query) + future = session.execute_async(statement, trace=True) + future.result() + self.assertRaises(TraceUnavailable, future.get_query_trace, -1.0) + def test_string_coverage(self): """ Ensure str(future) returns without error diff --git a/tests/integration/standard/test_query.py b/tests/integration/standard/test_query.py index 5a698afd4e..b6aad8a77d 100644 --- a/tests/integration/standard/test_query.py +++ b/tests/integration/standard/test_query.py @@ -8,6 +8,7 @@ class QueryTest(unittest.TestCase): + def test_query(self): cluster = Cluster() session = cluster.connect() From 5ae9fbde4d672abcdf04b70b4b38dbf76cfdc9bb Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 17 Jan 2014 15:52:03 -0600 Subject: [PATCH 0756/4528] Fix max_trace_wait docstring --- cassandra/cluster.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 1506165fef..80c19563ef 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -918,9 +918,8 @@ class Session(object): If the `trace` parameter for :meth:`~.execute()` or :meth:`~.execute_async()` is :const:`True`, the driver will repeatedly attempt to fetch trace details for the query (using exponential backoff) until this limit is - hit. If the limit is passed, :exc:`cassandra.query.TraceUnavailable` - will be raised. - """ + hit. If the limit is passed, an error will be logged and the + :attr:`.Statement.trace` will be left as :const:`None`. """ _lock = None _pools = None From 4634a431c44428c471dd1c488881f378ed5baf31 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 17 Jan 2014 15:52:57 -0600 Subject: [PATCH 0757/4528] Fix benchmark warning when libev is not available --- benchmarks/base.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/benchmarks/base.py b/benchmarks/base.py index 9dd9c40201..90f41703d9 100644 --- a/benchmarks/base.py +++ b/benchmarks/base.py @@ -29,11 +29,12 @@ have_libev = True supported_reactors.append(LibevConnection) except ImportError, exc: - log.warning("Not benchmarking libev reactor: %s" % (exc,)) + pass KEYSPACE = "testkeyspace" TABLE = "testtable" + def setup(hosts): cluster = Cluster(hosts) @@ -64,6 +65,7 @@ def setup(hosts): ) """ % TABLE) + def teardown(hosts): cluster = Cluster(hosts) cluster.set_core_connections_per_host(HostDistance.LOCAL, 1) @@ -163,15 +165,17 @@ def parse_options(): log.setLevel(options.log_level.upper()) - if options.libev_only: + if options.asyncore_only: + options.supported_reactors = [AsyncoreConnection] + elif options.libev_only: if not have_libev: log.error("libev is not available") sys.exit(1) options.supported_reactors = [LibevConnection] - elif options.asyncore_only: - options.supported_reactors = [AsyncoreConnection] else: options.supported_reactors = supported_reactors + if not have_libev: + log.warning("Not benchmarking libev reactor because libev is not available") return options, args From a78f419aacb062a661a0a2c3dc5ce81233c61800 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 17 Jan 2014 15:53:38 -0600 Subject: [PATCH 0758/4528] Fix flake8 warning about global var usage --- cassandra/cqltypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index d023350b01..da8eb95eac 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -476,6 +476,7 @@ def deserialize(byts): @staticmethod def serialize(v): + global _have_warned_about_timestamps try: converted = calendar.timegm(v.utctimetuple()) converted = converted * 1e3 + getattr(v, 'microsecond', 0) / 1e3 @@ -485,7 +486,6 @@ def serialize(v): raise TypeError('DateType arguments must be a datetime or timestamp') if not _have_warned_about_timestamps: - global _have_warned_about_timestamps _have_warned_about_timestamps = True warnings.warn("timestamp columns in Cassandra hold a number of " "milliseconds since the unix epoch. Currently, when executing " From ac2dd3aa5bed561e0ca708466d2a991207c83b54 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Wed, 22 Jan 2014 11:53:34 +0100 Subject: [PATCH 0759/4528] 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 0760/4528] 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 0761/4528] 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 0762/4528] 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 0763/4528] 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 0764/4528] 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 0765/4528] 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 0766/4528] 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 3cd102cdd3d9ac3229d8de06a22614fc8683ec6f Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 23 Jan 2014 15:46:17 -0600 Subject: [PATCH 0767/4528] Remove auth/security from TODO in README --- README.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/README.rst b/README.rst index 1798b07347..9bd3e9ff8b 100644 --- a/README.rst +++ b/README.rst @@ -23,7 +23,6 @@ recommended at this time. Features to be Added -------------------- * C extension for encoding/decoding messages -* Authentication/security feature support * Twisted, gevent support * Python 3 support * IPv6 Support From 21a9defb5d68bedd8988ac3f0679051451f7705a Mon Sep 17 00:00:00 2001 From: Samuel Toriel Date: Thu, 23 Jan 2014 19:24:23 -0500 Subject: [PATCH 0768/4528] Getting started guide. How to: 1. Setup a cluster and get a session 2. Execute queries on that session (with block and async calls) 3. Understanding how session's execute a query with a load balancing policy --- docs/api/getting_started.rst | 52 ++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 53 insertions(+) create mode 100644 docs/api/getting_started.rst diff --git a/docs/api/getting_started.rst b/docs/api/getting_started.rst new file mode 100644 index 0000000000..ce5c93a139 --- /dev/null +++ b/docs/api/getting_started.rst @@ -0,0 +1,52 @@ +Getting Started +=============== + +Before we can start executing any queries against Cassandra we need to setup our cluster. Setting up our cluster +allows us to set specific options like what port CQL native transport is listening to for connections, SSL options, +the loadbalancing policy to use for handling queries and more which can be found here :doc:`/api/cassandra/cluster` :: + + from cassandra.cluster import Cluster + + options = { + 'contact_points': ['10.1.1.3', '10.1.1.4', '10.1.1.5'], + 'port': 9042 + } + + cluster = Cluster(**options) + session = cluster.connect(keyspace='users') + +Instantiating a cluster does not actually connect us to any nodes. To begin executing queries we need a session, which is created by calling cluster.connect(). connect takes an optional 'keyspace' argument allowing all queries in that session to be operated on that keyspace. Alternatively, you can set the keyspace after the session is created. Sessions should NOT be instantiated outside the use of a Cluster. The Cluster handles the +creation and disposal of sessions. :: + + session.set_keyspace('users') + +Now that we have a session we can begin to execute queries. If you are using Cassandra in a way that allows you to not block calls you can execute queries asynchronously. More about the execute and execute_async functions can be found here :doc:`/api/cassandra/cluster` (this should link to execute and execute_async) :: + + # without async (blocking) + result = session.execute('SELECT * FROM users') + for row in results: + print row + + # with async + def handle_success(results): + for row in results: + print row + + def handle_error(exception_error): + print exception_error + + future = session.execute_async('SELECT * FROM users') + future.add_callbacks(handle_success, handle_error) + +When executing queries from a session, the driver picks a Cassandra node to act as the coordinator. As shown earlier in our Cluster example with the options we passed several ip addresses which the driver can communicate with. By default the driver will touch those nodes in the list and grab the ip addresses of any nodes that those nodes have found via gossip. To change this behavior you can use different Load Balancing policies that are available here :doc:`/api/cassandra/policies` :: + + from cassandra.cluster import Cluster + from cassandra.policies import DCAwareRoundRobinPolicy + + options = { + 'contact_points': ['10.1.1.3', '10.1.1.4', '10.1.1.5'], + 'port': 9042, + 'load_balancing_policy': DCAwareRoundRobinPolicy(local_dc='datacenter1') + } + + cluster = Cluster(**options) \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 9571e31093..85d733769e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ Contents: :maxdepth: 2 api/index + api/getting_started Indices and Tables ================== From c77351dd8f4a09efc598d3d7b91bb5acd9b2ee34 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 24 Jan 2014 15:55:41 -0600 Subject: [PATCH 0769/4528] Installation, more complete Getting Started guide --- docs/api/getting_started.rst | 359 +++++++++++++++++++++++++++++++---- docs/api/installation.rst | 111 +++++++++++ 2 files changed, 435 insertions(+), 35 deletions(-) create mode 100644 docs/api/installation.rst diff --git a/docs/api/getting_started.rst b/docs/api/getting_started.rst index ce5c93a139..41b6feb2e8 100644 --- a/docs/api/getting_started.rst +++ b/docs/api/getting_started.rst @@ -1,52 +1,341 @@ Getting Started =============== -Before we can start executing any queries against Cassandra we need to setup our cluster. Setting up our cluster -allows us to set specific options like what port CQL native transport is listening to for connections, SSL options, -the loadbalancing policy to use for handling queries and more which can be found here :doc:`/api/cassandra/cluster` :: +First, make sure you have the driver properly :doc:`installed `. - from cassandra.cluster import Cluster +Connecting to Cassandra +----------------------- +Before we can start executing any queries against Cassandra we need to setup +our :class:`~.Cluster`. As the name suggests, you will typically have one +instance of :class:`~.Cluster` for each Cassandra cluster you want to interact +with. - options = { - 'contact_points': ['10.1.1.3', '10.1.1.4', '10.1.1.5'], - 'port': 9042 - } +The simplest way to create a :class:`~.Cluster` is like this - cluster = Cluster(**options) - session = cluster.connect(keyspace='users') +.. code-block:: python -Instantiating a cluster does not actually connect us to any nodes. To begin executing queries we need a session, which is created by calling cluster.connect(). connect takes an optional 'keyspace' argument allowing all queries in that session to be operated on that keyspace. Alternatively, you can set the keyspace after the session is created. Sessions should NOT be instantiated outside the use of a Cluster. The Cluster handles the -creation and disposal of sessions. :: + from cassandra.cluster import Cluster + cluster = Cluster(['10.1.1.3', '10.1.1.4', '10.1.1.5']) - session.set_keyspace('users') +The set of IP addresses we pass to the :class:`~.Cluster` are simply +an initial set of contact points. After the driver connects to one +of these addresses it will automatically discover the rest of the +nodes in the cluster and connect to them, so you don't need to list +every node in your cluster. -Now that we have a session we can begin to execute queries. If you are using Cassandra in a way that allows you to not block calls you can execute queries asynchronously. More about the execute and execute_async functions can be found here :doc:`/api/cassandra/cluster` (this should link to execute and execute_async) :: +If you need to use a non-standard port, use SSL, or customize the driver's +behavior in some other way, this is the place to do it: - # without async (blocking) - result = session.execute('SELECT * FROM users') - for row in results: - print row +.. code-block:: python - # with async - def handle_success(results): - for row in results: - print row + from cassandra.cluster import Cluster + from cassandra.polices import DCAwareRoundRobinPolicy - def handle_error(exception_error): - print exception_error + cluster = Cluster( + contact_points=['10.1.1.3', '10.1.1.4', '10.1.1.5'], + load_balancing_policy=DCAwareRoundRobinPolicy(local_dc='US_EAST'), + port=9042) - future = session.execute_async('SELECT * FROM users') - future.add_callbacks(handle_success, handle_error) -When executing queries from a session, the driver picks a Cassandra node to act as the coordinator. As shown earlier in our Cluster example with the options we passed several ip addresses which the driver can communicate with. By default the driver will touch those nodes in the list and grab the ip addresses of any nodes that those nodes have found via gossip. To change this behavior you can use different Load Balancing policies that are available here :doc:`/api/cassandra/policies` :: +You can find a more complete list of options in the :class:`~.Cluster` documentation. - from cassandra.cluster import Cluster - from cassandra.policies import DCAwareRoundRobinPolicy +Instantiating a :class:`~.Cluster` does not actually connect us to any nodes. +To establish connections and begin executing queries we need a +:class:`~.Session`, which is created by calling :meth:`.Cluster.connect()`. +The :meth:`~.Cluster.connect()` method takes an optional ``keyspace`` argument +which sets the default keyspace for all queries made through that :class:`~.Session`: - options = { - 'contact_points': ['10.1.1.3', '10.1.1.4', '10.1.1.5'], - 'port': 9042, - 'load_balancing_policy': DCAwareRoundRobinPolicy(local_dc='datacenter1') - } +.. code-block:: python - cluster = Cluster(**options) \ No newline at end of file + cluster = Cluster(['10.1.1.3', '10.1.1.4', '10.1.1.5']) + session = cluster.connect('mykeyspace') + + +You can always change a Sesssion's keyspace using :meth:`~.Session.set_keyspace` or +by executing a ``USE `` query: + +.. code-block:: python + + session.set_keyspace('users') + # or you can do this instead + session.execute('USE users') + + +Executing Queries +----------------- +Now that we have a :class:`.Session` we can begin to execute queries. The most +basic and natural way to execute a query is to use :meth:`~.Session.execute()`: + +.. code-block:: python + + rows = session.execute('SELECT name, age, email FROM users') + for user_row in rows: + print user_row.name, user_row.age, user_row.email + +This will transparently pick a Cassandra node to execute the query against +and handle any retries that are necessary if the operation fails. + +By default, each row in the result set will be a +`namedtuple `_. +Each row will have a matching attribute for each column defined in the schema, +such as ``name``, ``age``, and so on. You can also treat them as normal tuples +by unpacking them or accessing fields by position: + +.. code-block:: python + + rows = session.execute('SELECT name, age, email FROM users') + for (name, age, email) in rows: + print name, age, email + +.. code-block:: python + + rows = session.execute('SELECT name, age, email FROM users') + names = [row[0] for row in rows] + ages = [row[1] for row in rows] + emails = [row[2] for row in rows] + +If you prefer another result format, such as a ``dict`` per row, you +can change the :attr:`~.Session.row_factory` attribute. + +Passing Parameters to CQL Queries +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +When executing non-prepared statements, the driver supports two forms of +parameter place-holders: positional and named. + +Positional parameters are used with a ``%s`` placeholder. For example, +when you execute: + +.. code-block:: python + + session.execute( + """ + INSERT INTO users (name, credits, user_id) + VALUES (%s, %s, %s) + """ + ("John O'Reilly", 42, uuid.uuid1()) + ) + +It is translated to the following CQL query: + +.. code-block:: SQL + + INSERT INTO users (name, credits, user_id) + VALUES ('John O''Reilly', 42, 2644bada-852c-11e3-89fb-e0b9a54a6d93) + +Note that you should use ``%s`` for all types of arguments, not just strings. +For example, this would be **wrong**: + +.. code-block:: python + + session.execute("INSERT INTO USERS (name, age) VALUES (%s, %d)", ("bob", 42)) # wrong + +Instead, use ``%s`` for the age placeholder. + +If you need to use a literal ``%`` character, use ``%%``. + +**Note**: you must always use a sequence for the second argument, even if you are +only passing in a single variable: + +.. code-block:: python + + session.execute("INSERT INTO foo (bar) VALUES (%s)", "blah") # wrong + session.execute("INSERT INTO foo (bar) VALUES (%s)", ("blah")) # wrong + session.execute("INSERT INTO foo (bar) VALUES (%s)", ("blah", )) # right + session.execute("INSERT INTO foo (bar) VALUES (%s)", ["blah"]) # right + + +Note that the second line is incorrect because in Python, single-element tuples +require a comma. + +Named place-holders use the ``%(name)s`` form: + +.. code-block:: python + + session.execute( + """ + INSERT INTO users (name, credits, user_id, username) + VALUES (%(name)s, %(credits)s, %(user_id)s, %(name)s) + """ + {'name': "John O'Reilly", 'credits': 42, 'user_id': uuid.uuid1()} + ) + +Note that you can repeat placeholders with the same name, such as ``%(name)s`` +in the above example. + +Only data values should be supplied this way. Other items, such as keyspaces, +table names, and column names should be set ahead of time (typically using +normal string formatting). + +Type Conversions +^^^^^^^^^^^^^^^^ +For non-prepared statements, Python types are cast to CQL literals in the +following way: + +.. table:: + + +--------------------+-------------------------+ + | Python Type | CQL Literal Type | + +====================+=========================+ + | ``None`` | ``NULL`` | + +--------------------+-------------------------+ + | ``bool`` | ``bool`` | + +--------------------+-------------------------+ + | ``float`` | | ``float`` | + | | | ``double`` | + +--------------------+-------------------------+ + | | ``int`` | | ``int`` | + | | ``long`` | | ``bigint`` | + | | | ``varint`` | + | | | ``counter`` | + +--------------------+-------------------------+ + | ``decimal.Decimal``| ``decimal`` | + +--------------------+-------------------------+ + | | ``str`` | | ``ascii`` | + | | ``unicode`` | | ``varchar`` | + | | | ``text`` | + +--------------------+-------------------------+ + | | ``buffer`` | ``blob`` | + | | ``bytearray`` | | + +--------------------+-------------------------+ + | | ``date`` | ``timestamp`` | + | | ``datetime`` | | + +--------------------+-------------------------+ + | | ``list`` | ``list`` | + | | ``tuple`` | | + | | generator | | + +--------------------+-------------------------+ + | | ``set`` | ``set`` | + | | ``frozenset`` | | + +--------------------+-------------------------+ + | | ``dict`` | ``map`` | + | | ``OrderedDict`` | | + +--------------------+-------------------------+ + | ``uuid.UUID`` | | ``timeuuid`` | + | | | ``uuid`` | + +--------------------+-------------------------+ + + +Asynchronous Queries +^^^^^^^^^^^^^^^^^^^^ +The driver supports asynchronous query execution through +:meth:`~.Session.execute_async()`. Instead of waiting for the query to +complete and returning rows directly, this method almost immediately +returns a :class:`~.ResponseFuture` object. There are two ways of +getting the final result from this object. + +The first is by calling :meth:`~.ResponseFuture.result()` on it. If +the query has not yet completed, this will block until it has and +then return the result or raise an Exception if an error occurred. +For example: + +.. code-block:: python + + from cassandra import ReadTimeout + + query = "SELECT * FROM users WHERE user_id=%s" + future = session.execute_async(query, [user_id]) + + # ... do some other work + + try: + rows = future.result() + user = rows[0] + print user.name, user.age + except ReadTimeout: + log.exception("Query timed out:") + +This works well for executing many queries concurrently: + +.. code-block:: python + + # build a list of futures + futures = [] + query = "SELECT * FROM users WHERE user_id=%s" + for user_id in ids_to_fetch: + futures.append(session.execute_async(query, [user_id]) + + # wait for them to complete and use the results + for future in futures: + rows = future.result() + print rows[0].name + +Alternatively, instead of calling :meth:`~.ResponseFuture.result()`, +you can attach callback and errback functions through the +:meth:`~.ResponseFuture.add_callback()`, +:meth:`~.ResponseFuture.add_errback()`, and +:meth:`~.ResponseFuture.add_callbacks()`, methods. If you have used +Twisted Python before, this is designed to be a lightweight version of +that: + +.. code-block:: python + + def handle_success(rows): + user = rows[0] + try: + process_user(user.name, user.age, user.id) + except Exception: + log.error("Failed to process user %s", user.id) + # don't re-raise errors in the callback + + def handle_error(exception): + log.error("Failed to fetch user info: %s", exception) + + + future = session.execute_async(query) + future.add_callbacks(handle_success, handle_error) + +There are a few important things to remember when working with callbacks: + * **Exceptions that are raised inside the callback functions will be logged and then ignored.** + * Your callback will be run on the event loop thread, so any long-running + operations will prevent other requests from being handled + + +Setting a Consistency Level +^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The consistency level used for a query determines how many of the +replicas of the data you are interacting with need to respond for +the query to be considered a success. + +By default, :attr:`.ConsistencyLevel.ONE` will be used for all queries. To +specify a different consistency level, you will need to wrap your queries +in a :class:`~.SimpleStatement`: + +.. code-block:: python + + from cassandra import ConsistencyLevel + from cassandra.query import SimpleStatement + + query = SimpleStatement( + "INSERT INTO users (name, age) VALUES (%s, %s)", + consistency_level=ConsistencyLevel.QUORUM) + session.execute(query, ('John', 42)) + +Prepared Statements +------------------- +Prepared statements are queries that are parsed by Cassandra and then saved +for later use. When the driver uses a prepared statement, it only needs to +send the values of parameters to bind. This lowers network traffic +and CPU utilization within Cassandra because Cassandra does not have to +re-parse the query each time. + +To prepare a query, use :meth:`.Session.prepare()`: + +.. code-block:: python + + user_lookup_stmt = session.prepare("SELECT * FROM users WHERE user_id=?") + + users = [] + for user_id in user_ids_to_query: + user = session.execute(user_lookup_stmt, [user_id]) + users.append(user) + +:meth:`~.Session.prepare()` returns a :class:`~.PreparedStatement` instance +which can be used in place of :class:`~.SimpleStatement` instances or literal +string queries. It is automatically prepared against all nodes, and the driver +handles re-preparing against new nodes and restarted nodes when necessary. + +Note that the placeholders for prepared statements are ``?`` characters. This +is different than for simple, non-prepared statements (although future versions +of the driver may use the same placeholders for both). Cassandra 2.0 added +support for named placeholders; the 1.0 version of the driver does not support +them, but the 2.0 version will. diff --git a/docs/api/installation.rst b/docs/api/installation.rst new file mode 100644 index 0000000000..7709fd27dd --- /dev/null +++ b/docs/api/installation.rst @@ -0,0 +1,111 @@ +Installation +============ + +Supported Platforms +------------------- +Python 2.6 and 2.7 are supported. Both CPython (the standard Python +implementation) and `PyPy `_ are supported and tested +against. + +Linux, OSX, and Windows are supported. + +Support for Python 3 is planned. + +Installation through pip +------------------------ +`pip `_ is the suggested tool for installing +packages. It will handle installing all python dependencies for the driver at +the same time as the driver itself. To install the driver:: + + pip install --pre cassandra-driver + +The ``--pre`` option is only needed while the python driver is still marked as +a beta package. + +Manual Installation +------------------- +You can always install the driver directly from a source checkout or tarball. +When installing manually, ensure the python dependencies are already +installed: ``futures``, ``scales``, and ``blist``. + +Once the dependencies are installed, simply run:: + + python setup.py install + +(Optional) Non-python Dependencies +---------------------------------- +The driver has several **optional** features that have non-Python dependencies. + +C Extensions +^^^^^^^^^^^^ +By default, two C extensions are compiled: one that adds support +for token-aware routing with the Murmur3Partitioner, and one that +allows you to use libev for the event loop, which improves performance. + +When installing manually through setup.py, you can disable both with +the ``--no-extensions`` option, or selectively disable one or the other +with ``--no-murmur3`` and ``--no-libev``. + +To compile the extenions, ensure that GCC and the Python headers are available. + +On Ubuntu and Debian, this can be accomplished by running:: + + $ sudo apt-get install gcc python-dev + +On RedHat and RedHat-based systems like CentOS and Fedora:: + + $ sudo yum install gcc python-devel + +On OS X, homebrew installations of Python should provide the necessary headers. + +libev support +^^^^^^^^^^^^^ +The driver currently uses Python's ``asyncore`` module for its default +event loop. For better performance, ``libev`` is also supported through +a C extension. + +If you're on Linux, you should be able to install libev +through a package manager. For example, on Debian/Ubuntu:: + + $ sudo apt-get install libev4 libev-dev + +On RHEL/CentOS/Fedora:: + + $ sudo yum install libev libev-devel + +If you're on Mac OS X, you should be able to install libev +through `Homebrew `_. For example, on Mac OS X:: + + $ brew install libev + +If successful, you should be able to build and install the extension +(just using ``setup.py build`` or ``setup.py install``) and then use +the libev event loop by doing the following: + +.. code-block:: python + + >>> from cassandra.io.libevreactor import LibevConnection + >>> from cassandra.cluster import Cluster + + >>> cluster = Cluster() + >>> cluster.connection_class = LibevConnection + >>> session = cluster.connect() + +Compression Support +^^^^^^^^^^^^^^^^^^^ +Compression can optionally be used for communication between the driver and +Cassandra. There are currently two supported compression algorithms: +snappy (in Cassandra 1.2+) and LZ4 (only in Cassandra 2.0+). If either is +available for the driver and Cassandra also supports it, it will +be used automatically. + +For lz4 support:: + + $ pip install lz4 + +For snappy support:: + + $ pip install python-snappy + +(If using a Debian Linux derivative such as Ubuntu, it may be easier to +just run ``apt-get install python-snappy``.) From f16610219e0a921019c3a098a7c9fbd6215b39a1 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 24 Jan 2014 16:01:27 -0600 Subject: [PATCH 0770/4528] Update link to docs in README --- README.rst | 2 +- docs/index.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 9bd3e9ff8b..e10f23bcce 100644 --- a/README.rst +++ b/README.rst @@ -18,7 +18,7 @@ recommended at this time. * `JIRA `_ * `Mailing List `_ * IRC: #datastax-drivers on irc.freenode.net (you can use `freenode's web-based client `_) -* `API Documentation `_ +* `Documentation `_ Features to be Added -------------------- diff --git a/docs/index.rst b/docs/index.rst index 85d733769e..5c69b4c530 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ Contents: :maxdepth: 2 api/index + api/installation api/getting_started Indices and Tables From d25ffbdf87720479e9ed1d3d3c03f689b1467e1d Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 24 Jan 2014 16:17:40 -0600 Subject: [PATCH 0771/4528] Generators map to list collections, not sequences --- cassandra/decoder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cassandra/decoder.py b/cassandra/decoder.py index 57f6f68e51..2b25f2e6dc 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -788,7 +788,7 @@ def cql_encode_bytes(val): return '0x' + hexlify(val) else: # python 2.6 requires string or read-only buffer for hexlify - def cql_encode_bytes(val): + def cql_encode_bytes(val): # noqa return '0x' + hexlify(buffer(val)) @@ -848,5 +848,5 @@ def cql_encode_all_types(val): tuple: cql_encode_list_collection, set: cql_encode_set_collection, frozenset: cql_encode_set_collection, - types.GeneratorType: cql_encode_sequence + types.GeneratorType: cql_encode_list_collection } From b183202266643806be356485c0eaab92d2e13cba Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 24 Jan 2014 16:23:53 -0600 Subject: [PATCH 0772/4528] Update unit test for generator change --- tests/unit/test_parameter_binding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_parameter_binding.py b/tests/unit/test_parameter_binding.py index 6bb34608f4..29fa7dba36 100644 --- a/tests/unit/test_parameter_binding.py +++ b/tests/unit/test_parameter_binding.py @@ -25,7 +25,7 @@ def test_sequence_param(self): def test_generator_param(self): result = bind_params("%s", ((i for i in xrange(3)),)) - self.assertEquals(result, "( 0 , 1 , 2 )") + self.assertEquals(result, "[ 0 , 1 , 2 ]") def test_none_param(self): result = bind_params("%s", (None,)) From f950bb1b2aff978d94efffade4281aa3e819397e Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 24 Jan 2014 17:16:26 -0600 Subject: [PATCH 0773/4528] Reconnect after failed node up under all conditions --- cassandra/cluster.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 80c19563ef..5ad7ad12f6 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -617,9 +617,12 @@ def on_up(self, host): future.add_done_callback(callback) futures.add(future) except Exception: - # this shouldn't happen, but just in case, reset the condition + log.exception("Unexpected failure handling node %s being marked up:") for future in futures: future.cancel() + + self._cleanup_failed_on_up_handling(host) + host._handle_node_up_condition.acquire() host._currently_handling_node_up = False host._handle_node_up_condition.notify() From 7dc3b87441e12fe63efbb537b099cb76a953234e Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 24 Jan 2014 17:29:34 -0600 Subject: [PATCH 0774/4528] Fix bad log statement --- cassandra/cluster.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 5ad7ad12f6..d01c08ea9a 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -601,6 +601,7 @@ def on_up(self, host): reconnector.cancel() self._prepare_all_queries(host) + log.debug("Done preparing all queries for host %s", host) for session in self.sessions: session.remove_pool(host) @@ -617,7 +618,7 @@ def on_up(self, host): future.add_done_callback(callback) futures.add(future) except Exception: - log.exception("Unexpected failure handling node %s being marked up:") + log.exception("Unexpected failure handling node %s being marked up:", host) for future in futures: future.cancel() From cda56c117ba77977b9d2f14ba7ca85229445565d Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Mon, 27 Jan 2014 18:54:43 +0200 Subject: [PATCH 0775/4528] 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 b2b53bc6dfdab3beb167b58acf9644ce17a0e51e Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 11:33:04 -0600 Subject: [PATCH 0776/4528] Avoid race condition when adding new host If the futures completed before the original loop did, we would run _finalize_add twice --- cassandra/cluster.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index d01c08ea9a..dff887321f 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -718,13 +718,15 @@ def future_completed(future): self._finalize_add(host) + have_future = False for session in self.sessions: future = session.add_or_renew_pool(host, is_host_addition=True) if future is not None: + have_future = True futures.add(future) future.add_done_callback(future_completed) - if not futures: + if not have_future: self._finalize_add(host) def _finalize_add(self, host): From 75486a1c1bcd241795327ed9abca7ead20f3a871 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 11:34:47 -0600 Subject: [PATCH 0777/4528] Don't reference uninitialized conn in finally block If we failed to get a connection, the finally block would try to reference an uninitialized variable --- cassandra/cluster.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index dff887321f..c1c66c5460 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -821,6 +821,7 @@ def _prepare_all_queries(self, host): return log.debug("Preparing all known prepared statements against host %s", host) + connection = None try: connection = self.connection_factory(host.address) try: @@ -857,7 +858,8 @@ def _prepare_all_queries(self, host): except Exception: log.exception("Error trying to prepare all statements on host %s", host) finally: - connection.close() + if connection: + connection.close() def prepare_on_all_sessions(self, query_id, prepared_statement, excluded_host): with self._prepared_statement_lock: From 3eba956fa0bc37fe9817f74e0bede19c7353dfce Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 11:36:11 -0600 Subject: [PATCH 0778/4528] Log unhandled exceptions in scheduled tasks --- cassandra/cluster.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index c1c66c5460..f2b1d78e08 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1760,7 +1760,8 @@ def run(self): return if run_at <= time.time(): fn, args, kwargs = task - self._executor.submit(fn, *args, **kwargs) + future = self._executor.submit(fn, *args, **kwargs) + future.add_done_callback(self._log_if_failed) else: self._scheduled.put_nowait((run_at, task)) break @@ -1769,6 +1770,13 @@ def run(self): time.sleep(0.1) + def _log_if_failed(self, future): + exc = future.exception() + if exc: + log.warn( + "An internally scheduled tasked failed with an unhandled exception:", + exc_info=exc) + def refresh_schema_and_set_result(keyspace, table, control_conn, response_future): try: From 463898851435b02ed1024734bd78e8ad99df2e43 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 12:21:28 -0600 Subject: [PATCH 0779/4528] Check that conn is not None before closing --- cassandra/pool.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cassandra/pool.py b/cassandra/pool.py index 29d9062b4c..6b12caa95b 100644 --- a/cassandra/pool.py +++ b/cassandra/pool.py @@ -164,7 +164,8 @@ def run(self): self.on_reconnection(conn) self.callback(*(self.callback_args), **(self.callback_kwargs)) finally: - conn.close() + if conn: + conn.close() def cancel(self): self._cancelled = True From a22f80b8ae9238d5df3a03d2b093c28a1bee4415 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 12:22:39 -0600 Subject: [PATCH 0780/4528] Ensure reconnector is started on failed node add Because the node was still marked down, the reconnector would not be started when we failed to add a connection pool for a recently added node. --- cassandra/cluster.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index f2b1d78e08..04b9361af3 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -655,7 +655,7 @@ def _start_reconnector(self, host, is_host_addition): reconnector.start() @run_in_executor - def on_down(self, host, is_host_addition): + def on_down(self, host, is_host_addition, force_if_down=False): """ Intended for internal use only. """ @@ -663,7 +663,7 @@ def on_down(self, host, is_host_addition): return with host.lock: - if (not host.is_up) or host.is_currently_reconnecting(): + if (not (host.is_up or force_if_down)) or host.is_currently_reconnecting(): return host.set_down() @@ -686,6 +686,7 @@ def on_add(self, host): log.debug("Adding or renewing pools for new host %s and notifying listeners", host) self._prepare_all_queries(host) + log.debug("Done preparing queries for new host %s", host) self.load_balancing_policy.on_add(host) self.control_connection.on_add(host) @@ -713,7 +714,7 @@ def future_completed(future): return if not all(futures_results): - log.warn("Connection pool could not be created, not marking node %s up:", host) + log.warn("Connection pool could not be created, not marking node %s up", host) return self._finalize_add(host) @@ -754,7 +755,7 @@ def on_remove(self, host): def signal_connection_failure(self, host, connection_exc, is_host_addition): is_down = host.signal_connection_failure(connection_exc) if is_down: - self.on_down(host, is_host_addition) + self.on_down(host, is_host_addition, force_if_down=True) return is_down def add_host(self, address, signal): @@ -1179,7 +1180,7 @@ def run_add_or_renew_pool(): self.cluster.signal_connection_failure(host, conn_exc, is_host_addition) return False except Exception as conn_exc: - log.debug("Signaling connection failure during Session.add_host: %s", conn_exc) + log.warn("Failed to create connection pool for new host %s: %s", host, conn_exc) self.cluster.signal_connection_failure(host, conn_exc, is_host_addition) return False From 601f0f605721c0e6f9e76b16222bff2d916f2ca2 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 12:23:44 -0600 Subject: [PATCH 0781/4528] Increase delay before connecting to new nodes --- cassandra/cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 04b9361af3..5677b8aa60 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1582,7 +1582,7 @@ def _handle_topology_change(self, event): change_type = event["change_type"] addr, port = event["address"] if change_type == "NEW_NODE": - self._cluster.scheduler.schedule(1, self._cluster.add_host, addr, signal=True) + self._cluster.scheduler.schedule(10, self._cluster.add_host, addr, signal=True) elif change_type == "REMOVED_NODE": host = self._cluster.metadata.get_host(addr) self._cluster.scheduler.schedule(0, self._cluster.remove_host, host) From 9dca5a50c9c42830314500d7e096aa71c0cfb7c3 Mon Sep 17 00:00:00 2001 From: Alex Popescu Date: Mon, 27 Jan 2014 16:09:24 -0800 Subject: [PATCH 0782/4528] doc changes --- cassandra/cluster.py | 14 ++++++++------ cassandra/connection.py | 2 +- cassandra/io/asyncorereactor.py | 2 +- cassandra/io/libevreactor.py | 2 +- cassandra/metadata.py | 14 +++++++------- cassandra/policies.py | 8 ++++---- cassandra/query.py | 5 ++++- docs/api/getting_started.rst | 19 ++++++++++++------- docs/api/installation.rst | 13 +++++++------ 9 files changed, 45 insertions(+), 34 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 5677b8aa60..86bca9ecde 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -877,7 +877,7 @@ class Session(object): Queries and statements can be executed through ``Session`` instances using the :meth:`~.Session.execute()` and :meth:`~.Session.execute_async()` - method. + methods. Example usage:: @@ -898,10 +898,10 @@ class Session(object): returned row will be a named tuple. You can alternatively use any of the following: - - :func:`cassandra.decoder.tuple_factory` - - :func:`cassandra.decoder.named_tuple_factory` - - :func:`cassandra.decoder.dict_factory` - - :func:`cassandra.decoder.ordered_dict_factory` + - :func:`cassandra.decoder.tuple_factory` - return a result row as a tuple + - :func:`cassandra.decoder.named_tuple_factory` - return a result row as a named tuple + - :func:`cassandra.decoder.dict_factory` - return a result row as a dict + - :func:`cassandra.decoder.ordered_dict_factory` - return a result row as an OrderedDict """ @@ -1005,7 +1005,7 @@ def execute(self, query, parameters=None, timeout=_NOT_SET, trace=False): def execute_async(self, query, parameters=None, trace=False): """ Execute the given query and return a :class:`~.ResponseFuture` object - which callbacks may be attached to for asynchronous response + to which callbacks may be attached to for asynchronous response delivery. You may also call :meth:`~.ResponseFuture.result()` on the :class:`.ResponseFuture` to syncronously block for results at any time. @@ -1089,6 +1089,8 @@ def prepare(self, query): ... bound = prepared.bind((user.id, user.name, user.age)) ... session.execute(bound) + **Important**: PreparedStatements should be prepared only once. + Preparing the same query more than once will likely affect performance. """ message = PrepareMessage(query=query) future = ResponseFuture(self, message, query=None) diff --git a/cassandra/connection.py b/cassandra/connection.py index 1ebb523858..b23bacc41f 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -74,7 +74,7 @@ def __init__(self, message, host=None): class ConnectionShutdown(ConnectionException): """ - Raised when a connection has been defuncted or closed. + Raised when a connection has been marked as defunct or has been closed. """ pass diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index 0bd57cf9f2..4a956727db 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -80,7 +80,7 @@ def _start_loop(): class AsyncoreConnection(Connection, asyncore.dispatcher): """ - An implementation of :class:`.Connection` that utilizes the ``asyncore`` + An implementation of :class:`.Connection` that uses the ``asyncore`` module in the Python standard library for its event loop. """ diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index 4cbc676dea..f24964bb02 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -86,7 +86,7 @@ def wrapper(self, *args, **kwargs): class LibevConnection(Connection): """ - An implementation of :class:`.Connection` that utilizes libev. + An implementation of :class:`.Connection` that uses libev for its event loop. """ # class-level set of all connections; only replaced with a new copy diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 11b3eafb43..02fc0244f8 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -536,12 +536,12 @@ class KeyspaceMetadata(object): """ name = None - """ The string name of the keyspace """ + """ The string name of the keyspace. """ durable_writes = True """ A boolean indicating whether durable writes are enabled for this keyspace - or not + or not. """ replication_strategy = None @@ -575,10 +575,10 @@ class TableMetadata(object): """ keyspace = None - """ An instance of :class:`~.KeyspaceMetadata` """ + """ An instance of :class:`~.KeyspaceMetadata`. """ name = None - """ The string name of the table """ + """ The string name of the table. """ partition_key = None """ @@ -952,7 +952,7 @@ def hash_fn(cls, key): raise NoMurmur3() def __init__(self, token): - """ `token` should be an int or string representing the token """ + """ `token` should be an int or string representing the token. """ self.value = int(token) @@ -966,7 +966,7 @@ def hash_fn(cls, key): return abs(varint_unpack(md5(key).digest())) def __init__(self, token): - """ `token` should be an int or string representing the token """ + """ `token` should be an int or string representing the token. """ self.value = int(token) @@ -976,7 +976,7 @@ class BytesToken(Token): """ def __init__(self, token_string): - """ `token_string` should be string representing the token """ + """ `token_string` should be string representing the token. """ if not isinstance(token_string, basestring): raise TypeError( "Tokens for ByteOrderedPartitioner should be strings (got %s)" diff --git a/cassandra/policies.py b/cassandra/policies.py index 7bed8c04ea..e866a8c45b 100644 --- a/cassandra/policies.py +++ b/cassandra/policies.py @@ -427,7 +427,7 @@ def reset(self): class ReconnectionPolicy(object): """ This class and its subclasses govern how frequently an attempt is made - to reconnect to nodes that are marked dead. + to reconnect to nodes that are marked as dead. If custom behavior is needed, this class may be subclassed. """ @@ -681,7 +681,7 @@ class DowngradingConsistencyRetryPolicy(RetryPolicy): **BEWARE**: This policy may retry queries using a lower consistency level than the one initially requested. By doing so, it may break consistency guarantees. In other words, if you use this retry policy, - there is cases (documented below) where a read at :attr:`~.QUORUM` + there are cases (documented below) where a read at :attr:`~.QUORUM` *may not* see a preceding write at :attr:`~.QUORUM`. Do not use this policy unless you have understood the cases where this can happen and are ok with that. It is also recommended to subclass this class so @@ -691,7 +691,7 @@ class DowngradingConsistencyRetryPolicy(RetryPolicy): This policy implements the same retries as :class:`.RetryPolicy`, but on top of that, it also retries in the following cases: - * On a read timeout: if the number of replica that responded is + * On a read timeout: if the number of replicas that responded is greater than one but lower than is required by the requested consistency level, the operation is retried at a lower consistency level. @@ -703,7 +703,7 @@ class DowngradingConsistencyRetryPolicy(RetryPolicy): * On an unavailable exception: if at least one replica is alive, the operation is retried at a lower consistency level. - The reasoning being this retry policy is as follows:. If, based + The reasoning behind this retry policy is as follows: if, based on the information the Cassandra coordinator node returns, retrying the operation with the initially requested consistency has a chance to succeed, do it. Otherwise, if based on that information we know the diff --git a/cassandra/query.py b/cassandra/query.py index 8fc960fd1f..698eb5d600 100644 --- a/cassandra/query.py +++ b/cassandra/query.py @@ -118,6 +118,9 @@ class PreparedStatement(object): A statement that has been prepared against at least one Cassandra node. Instances of this class should not be created directly, but through :meth:`.Session.prepare()`. + + A :class:`.PreparedStatement` should be prepared only once. Re-preparing a statement + may affect performance (as the operation requires a network roundtrip). """ column_metadata = None @@ -169,7 +172,7 @@ def from_message(cls, query_id, column_metadata, cluster_metadata, query, keyspa def bind(self, values): """ Creates and returns a :class:`BoundStatement` instance using `values`. - The `values` parameter *must* be a sequence, such as a tuple or list, + The `values` parameter **must** be a sequence, such as a tuple or list, even if there is only one value to bind. """ return BoundStatement(self).bind(values) diff --git a/docs/api/getting_started.rst b/docs/api/getting_started.rst index 41b6feb2e8..ccd520e011 100644 --- a/docs/api/getting_started.rst +++ b/docs/api/getting_started.rst @@ -5,21 +5,22 @@ First, make sure you have the driver properly :doc:`installed `. Connecting to Cassandra ----------------------- -Before we can start executing any queries against Cassandra we need to setup -our :class:`~.Cluster`. As the name suggests, you will typically have one +Before we can start executing any queries against a Cassandra cluster we need to setup +an instance of :class:`~.Cluster`. As the name suggests, you will typically have one instance of :class:`~.Cluster` for each Cassandra cluster you want to interact with. -The simplest way to create a :class:`~.Cluster` is like this +The simplest way to create a :class:`~.Cluster` is like this: .. code-block:: python from cassandra.cluster import Cluster + cluster = Cluster(['10.1.1.3', '10.1.1.4', '10.1.1.5']) -The set of IP addresses we pass to the :class:`~.Cluster` are simply +The set of IP addresses we pass to the :class:`~.Cluster` is simply an initial set of contact points. After the driver connects to one -of these addresses it will automatically discover the rest of the +of these nodes it will *automatically discover* the rest of the nodes in the cluster and connect to them, so you don't need to list every node in your cluster. @@ -47,6 +48,8 @@ which sets the default keyspace for all queries made through that :class:`~.Sess .. code-block:: python + from cassandra.cluster import Cluster, Session + cluster = Cluster(['10.1.1.3', '10.1.1.4', '10.1.1.5']) session = cluster.connect('mykeyspace') @@ -63,8 +66,8 @@ by executing a ``USE `` query: Executing Queries ----------------- -Now that we have a :class:`.Session` we can begin to execute queries. The most -basic and natural way to execute a query is to use :meth:`~.Session.execute()`: +Now that we have a :class:`.Session` we can begin to execute queries. The simplest +way to execute a query is to use :meth:`~.Session.execute()`: .. code-block:: python @@ -97,6 +100,8 @@ by unpacking them or accessing fields by position: If you prefer another result format, such as a ``dict`` per row, you can change the :attr:`~.Session.row_factory` attribute. +For queries that will be run repeatedly, you should use `Prepared statements <#prepared-statements>`_. + Passing Parameters to CQL Queries ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When executing non-prepared statements, the driver supports two forms of diff --git a/docs/api/installation.rst b/docs/api/installation.rst index 7709fd27dd..440df1bd66 100644 --- a/docs/api/installation.rst +++ b/docs/api/installation.rst @@ -17,16 +17,16 @@ Installation through pip packages. It will handle installing all python dependencies for the driver at the same time as the driver itself. To install the driver:: - pip install --pre cassandra-driver + pip install cassandra-driver -The ``--pre`` option is only needed while the python driver is still marked as -a beta package. +You can use ``pip install --pre cassandra-driver`` if you need to install a beta version. Manual Installation ------------------- You can always install the driver directly from a source checkout or tarball. When installing manually, ensure the python dependencies are already -installed: ``futures``, ``scales``, and ``blist``. +installed. You can find the list of dependencies in +`requirements.txt `_. Once the dependencies are installed, simply run:: @@ -39,8 +39,9 @@ The driver has several **optional** features that have non-Python dependencies. C Extensions ^^^^^^^^^^^^ By default, two C extensions are compiled: one that adds support -for token-aware routing with the Murmur3Partitioner, and one that -allows you to use libev for the event loop, which improves performance. +for token-aware routing with the ``Murmur3Partitioner``, and one that +allows you to use `libev `_ +for the event loop, which improves performance. When installing manually through setup.py, you can disable both with the ``--no-extensions`` option, or selectively disable one or the other From 42d5c46e16a4dcee9d11109074dc00668ed073cb Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 12:35:00 -0600 Subject: [PATCH 0783/4528] Minor doc tweaks --- cassandra/cluster.py | 2 +- docs/api/getting_started.rst | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 86bca9ecde..5cc1c0abbb 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -1005,7 +1005,7 @@ def execute(self, query, parameters=None, timeout=_NOT_SET, trace=False): def execute_async(self, query, parameters=None, trace=False): """ Execute the given query and return a :class:`~.ResponseFuture` object - to which callbacks may be attached to for asynchronous response + which callbacks may be attached to for asynchronous response delivery. You may also call :meth:`~.ResponseFuture.result()` on the :class:`.ResponseFuture` to syncronously block for results at any time. diff --git a/docs/api/getting_started.rst b/docs/api/getting_started.rst index ccd520e011..c287fc7fc5 100644 --- a/docs/api/getting_started.rst +++ b/docs/api/getting_started.rst @@ -48,7 +48,7 @@ which sets the default keyspace for all queries made through that :class:`~.Sess .. code-block:: python - from cassandra.cluster import Cluster, Session + from cassandra.cluster import Cluster cluster = Cluster(['10.1.1.3', '10.1.1.4', '10.1.1.5']) session = cluster.connect('mykeyspace') @@ -100,7 +100,8 @@ by unpacking them or accessing fields by position: If you prefer another result format, such as a ``dict`` per row, you can change the :attr:`~.Session.row_factory` attribute. -For queries that will be run repeatedly, you should use `Prepared statements <#prepared-statements>`_. +For queries that will be run repeatedly, you should use +`Prepared statements <#prepared-statements>`_. Passing Parameters to CQL Queries ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 2410a17a9e6b149bd542ebbc09a1d0c1b189326e Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 13:47:36 -0600 Subject: [PATCH 0784/4528] Add requirements.txt --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..db728f05a6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +blist==1.3.4 +futures==2.1.4 +scales==1.0.3 From db6b546628ae21e0e1307704947d8962141ffc5f Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 13:53:58 -0600 Subject: [PATCH 0785/4528] Add test-requirements.txt --- test-requirements.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 test-requirements.txt diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000000..c3abe728da --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,5 @@ +nose +mock +ccm +unittest2 +PyYAML From 92c89f87a21ed7597c100929b912c22ebad9854c Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 13:54:26 -0600 Subject: [PATCH 0786/4528] Remove versions from requirements.txt --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index db728f05a6..1f4212c218 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -blist==1.3.4 -futures==2.1.4 -scales==1.0.3 +blist +futures +scales From 56823a3dc8335b53f3b916cc6754c06a6d850a44 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 14:02:37 -0600 Subject: [PATCH 0787/4528] Fix location of getting started, installation guides --- docs/{api => }/getting_started.rst | 0 docs/index.rst | 4 ++-- docs/{api => }/installation.rst | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/{api => }/getting_started.rst (100%) rename docs/{api => }/installation.rst (100%) diff --git a/docs/api/getting_started.rst b/docs/getting_started.rst similarity index 100% rename from docs/api/getting_started.rst rename to docs/getting_started.rst diff --git a/docs/index.rst b/docs/index.rst index 5c69b4c530..f0b68777f5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,8 +7,8 @@ Contents: :maxdepth: 2 api/index - api/installation - api/getting_started + installation + getting_started Indices and Tables ================== diff --git a/docs/api/installation.rst b/docs/installation.rst similarity index 100% rename from docs/api/installation.rst rename to docs/installation.rst From 3b546c10237c3795f0894e11383a89435b1d474a Mon Sep 17 00:00:00 2001 From: Alex Popescu Date: Tue, 28 Jan 2014 12:56:18 -0800 Subject: [PATCH 0788/4528] Ignore Pycharm and pyenv --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 09665562e8..29785e797b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ *.so *.egg-info .tox +.idea/ +.python-version build MANIFEST dist From 35cc20d47d0a4292cb2bb15ec452c920ab1583a1 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 15:39:15 -0600 Subject: [PATCH 0789/4528] Add notes on patterns and their performance --- docs/index.rst | 1 + docs/performance.rst | 271 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 docs/performance.rst diff --git a/docs/index.rst b/docs/index.rst index f0b68777f5..b5bdea6ad2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,6 +9,7 @@ Contents: api/index installation getting_started + performance Indices and Tables ================== diff --git a/docs/performance.rst b/docs/performance.rst new file mode 100644 index 0000000000..c288b81b77 --- /dev/null +++ b/docs/performance.rst @@ -0,0 +1,271 @@ +Performance Notes +================= +The python driver for Cassandra offers several methods for executing queries. +You can synchronously block for queries to complete using +:meth:`.Session.execute()`, you can use a future-like interface through +:meth:`.Session.execute_async()`, or you can attach a callback to the future +with :meth:`.ResponseFuture.add_callback()`. Each of these methods has +different performance characteristics and behaves differently when +multiple threads are used. + +Benchmark Notes +--------------- +All benchmarks were executed using the +`benchmark scripts `_ +in the driver repository. They were executed on a laptop with 16 GiB of RAM, an SSD, +and a 2 GHz, four core CPU with hyperthreading. The Cassandra cluster was a three +node `ccm `_ cluster running on the same laptop +with version 1.2.13 of Cassandra. I suggest testing these benchmarks against your + own cluster when tuning the driver for optimal throughput or latency. + +The 1.0.0 version of the driver was used with all default settings. + +Each benchmark completes 100,000 small inserts. The replication factor for the + keyspace was three, so all nodes were replicas for the inserted rows. + +Synchronous Execution (`sync.py `_) +------------------------------------------------------------------------------------------------------------- +Although this is the simplest way to make queries, it has low throughput +in single threaded environments. This is basically what the benchmark +is doing: + +.. code-block:: python + + from cassandra.cluster import Cluster + + cluster = Cluster([127.0.0.1, 127.0.0.2, 127.0.0.3]) + session = cluster.connect() + + for i in range(100000): + session.execute("INSERT INTO mykeyspace.mytable (key, b, c) VALUES (a, 'b', 'c')") + +.. code-block:: bash + + ~/python-driver $ python benchmarks/sync.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=1 + Average throughput: 434.08/sec + + +This technique does scale reasonably well as we add more threads: + +.. code-block:: bash + + ~/python-driver $ python benchmarks/sync.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=2 + Average throughput: 830.49/sec + ~/python-driver $ python benchmarks/sync.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=4 + Average throughput: 1078.27/sec + ~/python-driver $ python benchmarks/sync.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=8 + Average throughput: 1275.20/sec + ~/python-driver $ python benchmarks/sync.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=16 + Average throughput: 1345.56/sec + + +In my environment, throughput is maximized at about 20 threads. + + +Batched Futures (`future_batches.py `_) +--------------------------------------------------------------------------------------------------------------------------- +This is a simple way to work with futures for higher throughput. Essentially, +we start 120 queries asynchronously at the same time and then wait for them +all to complete. We then repeat this process until all 100,000 operations +have completed: + +.. code-block:: python + + futures = Queue.Queue(maxsize=121) + for i in range(100000): + if i % 120 == 0: + # clear the existing queue + while True: + try: + futures.get_nowait().result() + except Queue.Empty: + break + + future = session.execute_async(query) + futures.put_nowait(future) + +As expected, this improves throughput in a single-threaded environment: + +.. code-block:: bash + + ~/python-driver $ python benchmarks/future_batches.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=1 + Average throughput: 3477.56/sec + +However, adding more threads may actually harm throughput: + +.. code-block:: bash + + ~/python-driver $ python benchmarks/future_batches.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=2 + Average throughput: 2360.52/sec + ~/python-driver $ python benchmarks/future_batches.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=4 + Average throughput: 2293.21/sec + ~/python-driver $ python benchmarks/future_batches.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=8 + Average throughput: 2244.85/sec + + +Queued Futures (`future_full_pipeline.py `_) +-------------------------------------------------------------------------------------------------------------------------------------- +This pattern is similar to batched futures. The main difference is that +every time we put a future on the queue, we pull the oldest future out +and wait for it to complete: + +.. code-block:: python + + futures = Queue.Queue(maxsize=121) + for i in range(100000): + if i >= 120: + old_future = futures.get_nowait() + old_future.result() + + future = session.execute_async(query) + futures.put_nowait(future) + +This gets slightly better throughput than the Batched Futures pattern: + +.. code-block:: bash + + ~/python-driver $ python benchmarks/future_full_pipeline.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=1 + Average throughput: 3635.76/sec + +But this has the same throughput issues when multiple threads are used: + +.. code-block:: bash + + ~/python-driver $ python benchmarks/future_full_pipeline.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=2 + Average throughput: 2213.62/sec + ~/python-driver $ python benchmarks/future_full_pipeline.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=4 + Average throughput: 2707.62/sec + ~/python-driver $ python benchmarks/future_full_pipeline.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=8 + Average throughput: 2462.42/sec + +Unthrottled Futures (`future_full_throttle.py `_) +------------------------------------------------------------------------------------------------------------------------------------------- +What happens if we don't throttle our async requests at all? + +.. code-block:: python + + futures = [] + for i in range(100000): + future = session.execute_async(query) + futures.append(future) + + for future in futures: + future.result() + +Throughput is about the same as the previous pattern, but a lot of memory will +be consumed by the list of Futures: + +.. code-block:: bash + + ~/python-driver $ python benchmarks/future_full_throttle.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=1 + Average throughput: 3474.11/sec + ~/python-driver $ python benchmarks/future_full_throttle.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=2 + Average throughput: 2389.61/sec + ~/python-driver $ python benchmarks/future_full_throttle.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=4 + Average throughput: 2371.75/sec + ~/python-driver $ python benchmarks/future_full_throttle.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=8 + Average throughput: 2165.29/sec + +Callback Chaining (`callbacks_full_pipeline.py `_) +----------------------------------------------------------------------------------------------------------------------------------------------- +This pattern is very different from the previous patterns. Here we're taking +advantage of the :meth:`.ResponseFuture.add_callback()` function to start +another request as soon as one finishes. Futhermore, we're starting 120 +of these callback chains, so we've always got about 120 operations in +flight at any time: + +.. code-block:: python + + from itertools import count + from threading import Event + + num_started = count() + num_finished = count() + initial = object() + finished_event = Event() + + def handle_error(exc): + log.error("Error on insert: %r", exc) + + def insert_next(previous_result): + current_num = num_started.next() + + if previous_result is not initial: + num = next(num_finished) + if num >= 100000: + finished_event.set() + + if current_num <= 100000: + future = session.execute_async(query) + future.add_callbacks(insert_next, handle_error) + + for i in range(120): + insert_next(initial) + + finished_event.wait() + +This is a more complex pattern, but the throughput is excellent: + +.. code-block:: bash + + ~/python-driver $ python benchmarks/callback_full_pipeline.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=1 + Average throughput: 7647.30/sec + +Part of the reason why performance is so good is that everything is running on +single thread: the internal event loop thread that powers the driver. The +downside to this is that adding more threads doesn't improve anything: + +.. code-block:: bash + + ~/python-driver $ python benchmarks/callback_full_pipeline.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=2 + Average throughput: 7704.58/sec + + +What happens if we have more than 120 callback chains running? + +With 250 chains: + +.. code-block:: bash + + ~/python-driver $ python benchmarks/callback_full_pipeline.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=1 + Average throughput: 7794.22/sec + +Things look pretty good with 250 chains. If we try 500 chains, we start to max out +all of the connections in the connection pools. The problem is that the current +version of the driver isn't very good at throttling these callback chains, so +a lot of time gets spent waiting for new connections and performance drops +dramatically: + +.. code-block:: bash + + ~/python-driver $ python benchmarks/callback_full_pipeline.py -n 100000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --libev-only --threads=1 + Average throughput: 679.61/sec + +Until this is improved, you should limit the number of callback chains you run. + +PyPy +---- +Almost all of these patterns become CPU-bound pretty quickly with CPython, the +normal implementation of python. `PyPy `_ is an alternative +implementation of Python (written in Python) which uses a JIT compiler to +reduce CPU consumption. This leads to a huge improvement in the driver +performance: + +.. code-block:: bash + + ~/python-driver $ pypy benchmarks/callback_full_pipeline.py -n 500000 --hosts=127.0.0.1,127.0.0.2,127.0.0.3 --asyncore-only --threads=1 + Average throughput: 18782.00/sec + +Eventually the driver may add C extensions to reduce CPU consumption, which +would probably narrow the gap between the performance of CPython and PyPy. + +multiprocessing +--------------- +All of the patterns here may be used over multiple processes using the +`multiprocessing `_ +module. Multiple processes will scale significantly better than multiple +threads will, so if high throughput is your goal, consider this option. + +Just be sure to **never share any :class:`~.Cluster`, :class:`~.Session`, +or :class:`~.ResponseFuture` objects across multiple processes**. These +objects should all be created after forking the process, not before. From 02b95cc7612113edf2524587d65b48b3a8128385 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 15:44:05 -0600 Subject: [PATCH 0790/4528] Fix bold/link combination --- docs/performance.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/performance.rst b/docs/performance.rst index c288b81b77..bf53221ed1 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -266,6 +266,6 @@ All of the patterns here may be used over multiple processes using the module. Multiple processes will scale significantly better than multiple threads will, so if high throughput is your goal, consider this option. -Just be sure to **never share any :class:`~.Cluster`, :class:`~.Session`, -or :class:`~.ResponseFuture` objects across multiple processes**. These +Just be sure to **never share any** :class:`~.Cluster`, :class:`~.Session`, +**or** :class:`~.ResponseFuture` **objects across multiple processes**. These objects should all be created after forking the process, not before. From 5b28939a5c0fdf6b2a1655433ea3e4ed491c1b1f Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 15:54:19 -0600 Subject: [PATCH 0791/4528] Fix bad doc indentation --- docs/performance.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/performance.rst b/docs/performance.rst index bf53221ed1..3a137d5c26 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -16,12 +16,12 @@ in the driver repository. They were executed on a laptop with 16 GiB of RAM, an and a 2 GHz, four core CPU with hyperthreading. The Cassandra cluster was a three node `ccm `_ cluster running on the same laptop with version 1.2.13 of Cassandra. I suggest testing these benchmarks against your - own cluster when tuning the driver for optimal throughput or latency. +own cluster when tuning the driver for optimal throughput or latency. The 1.0.0 version of the driver was used with all default settings. Each benchmark completes 100,000 small inserts. The replication factor for the - keyspace was three, so all nodes were replicas for the inserted rows. +keyspace was three, so all nodes were replicas for the inserted rows. Synchronous Execution (`sync.py `_) ------------------------------------------------------------------------------------------------------------- From 4cfcc6a7f391eebeeb0dcf99e6eb1cd4ccbd74e3 Mon Sep 17 00:00:00 2001 From: Alex Popescu Date: Tue, 28 Jan 2014 14:28:00 -0800 Subject: [PATCH 0792/4528] docs/performance.rst --- docs/performance.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/performance.rst b/docs/performance.rst index 3a137d5c26..d6896839d2 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -18,7 +18,10 @@ node `ccm `_ cluster running on the same laptop with version 1.2.13 of Cassandra. I suggest testing these benchmarks against your own cluster when tuning the driver for optimal throughput or latency. -The 1.0.0 version of the driver was used with all default settings. +The 1.0.0 version of the driver was used with all default settings. For these +benchmarks, the driver was configured to use the ``libev`` reactor. You can also run +the benchmarks using the ``asyncore`` event loop (:class:`~.AsyncoreConnection`) +by using the ``--asyncore-only`` command line option. Each benchmark completes 100,000 small inserts. The replication factor for the keyspace was three, so all nodes were replicas for the inserted rows. From f10693d6a05ffae558d91da8e07dc1cb52a177c4 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 28 Jan 2014 17:24:45 -0600 Subject: [PATCH 0793/4528] Fix CQL boolean type in docs --- docs/getting_started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index c287fc7fc5..15ae34a5a5 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -184,7 +184,7 @@ following way: +====================+=========================+ | ``None`` | ``NULL`` | +--------------------+-------------------------+ - | ``bool`` | ``bool`` | + | ``bool`` | ``boolean`` | +--------------------+-------------------------+ | ``float`` | | ``float`` | | | | ``double`` | From 8208b466fefa3e0e09aa9c6806ba6597e795a63b Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 29 Jan 2014 12:59:28 -0600 Subject: [PATCH 0794/4528] Bump version to 1.0.0, update changelog, setup.py, README Hooray! --- CHANGELOG.rst | 17 +++++- README.rst | 120 +++++++++--------------------------------- cassandra/__init__.py | 2 +- docs/conf.py | 2 +- setup.py | 2 +- 5 files changed, 44 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0cd2a0aaef..699c4fdd1b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,6 @@ 1.0.0 Final =========== -(In Progress) +Jan 29, 2014 Bug Fixes --------- @@ -21,6 +21,14 @@ Bug Fixes * Properly defunct connections when libev reports an error by setting errno instead of simply logging the error * Fix endless hanging of some requests when using the libev reactor +* Always start a reconnection process when we fail to connect to + a newly bootstrapped node +* Generators map to CQL lists, not key sequences +* Always defunct connections when an internal operation fails +* Correctly break from handle_write() if nothing was sent (asyncore + reactor only) +* Avoid potential double-erroring of callbacks when a connection + becomes defunct Features -------- @@ -28,6 +36,8 @@ Features * Add timeout parameter to ``Session.execute()`` * Add ``WhiteListRoundRobinPolicy`` as a load balancing policy option * Support for consistency level ``LOCAL_ONE`` +* Make the backoff for fetching traces exponentially increasing and + configurable Other ----- @@ -38,6 +48,11 @@ Other * Benchmark improvements, including command line options and eay multithreading support * Reduced lock contention when using the asyncore reactor +* Warn when non-datetimes are used for 'timestamp' column values in + prepared statements +* Add requirements.txt and test-requirements.txt +* TravisCI integration for running unit tests against Python 2.6, + Python 2.7, and PyPy 1.0.0b7 ======= diff --git a/README.rst b/README.rst index e10f23bcce..c624220834 100644 --- a/README.rst +++ b/README.rst @@ -8,112 +8,42 @@ A Python client driver for Apache Cassandra. This driver works exclusively with the Cassandra Query Language v3 (CQL3) and Cassandra's native protocol. As such, only Cassandra 1.2+ is supported. -**Warning** - -This driver is currently under heavy development, so the API and layout of -packages, modules, classes, and functions are subject to change. There may -also be serious bugs, so usage in a production environment is *not* -recommended at this time. - -* `JIRA `_ -* `Mailing List `_ -* IRC: #datastax-drivers on irc.freenode.net (you can use `freenode's web-based client `_) -* `Documentation `_ - -Features to be Added --------------------- -* C extension for encoding/decoding messages -* Twisted, gevent support -* Python 3 support -* IPv6 Support - Installation ------------ -If you would like to use the optional C extensions, please follow -the instructions in the section below before installing the driver. - Installation through pip is recommended:: - $ pip install cassandra-driver --pre - -If you want to install manually, you can instead do:: - - $ pip install futures scales blist # install dependencies - $ python setup.py install - -C Extensions -^^^^^^^^^^^^ -By default, two C extensions are compiled: one that adds support -for token-aware routing with the Murmur3Partitioner, and one that -allows you to use libev for the event loop, which improves performance. - -When running setup.py, you can disable both with the ``--no-extensions`` -option, or selectively disable one or the other with ``--no-murmur3`` and -``--no-libev``. - -To compile the extenions, ensure that GCC and the Python headers are available. - -On Ubuntu and Debian, this can be accomplished by running:: - - $ sudo apt-get install build-essential python-dev - -On RedHat and RedHat-based systems like CentOS and Fedora:: + $ pip install cassandra-driver - $ sudo yum install gcc python-devel +For more complete installation instructions, see the +`installation guide `_. -On OS X, homebrew installations of Python should provide the necessary headers. +Documentation +------------- +All documentation for the python driver, including installation details, API documentation, +and a Getting Started guide, can be found `here `_. -libev support -^^^^^^^^^^^^^ -The driver currently uses Python's ``asyncore`` module for its default -event loop. For better performance, ``libev`` is also supported through -a C extension. +Reporting Problems +------------------ +Please report any bugs and make any feature requests on the +`JIRA `_ issue tracker. -If you're on Linux, you should be able to install libev -through a package manager. For example, on Debian/Ubuntu:: +If you would like to contribute, please feel free to open a pull request. - $ sudo apt-get install libev4 libev-dev - -On RHEL/CentOS/Fedora:: - - $ sudo yum install libev libev-devel - -If you're on Mac OS X, you should be able to install libev -through `Homebrew `_. For example, on Mac OS X:: - - $ brew install libev - -If successful, you should be able to build and install the extension -(just using ``setup.py build`` or ``setup.py install``) and then use -the libev event loop by doing the following: - -.. code-block:: python - - >>> from cassandra.io.libevreactor import LibevConnection - >>> from cassandra.cluster import Cluster - - >>> cluster = Cluster() - >>> cluster.connection_class = LibevConnection - >>> session = cluster.connect() - -Compression Support -^^^^^^^^^^^^^^^^^^^ -Compression can optionally be used for communication between the driver and -Cassandra. There are currently two supported compression algorithms: -snappy (in Cassandra 1.2+) and LZ4 (only in Cassandra 2.0+). If either is -available for the driver and Cassandra also supports it, it will -be used automatically. - -For lz4 support:: - - $ pip install lz4 - -For snappy support:: +Getting Help +------------ +Your two best options for getting help with the driver are the +`mailing list `_ +and the IRC channel. - $ pip install python-snappy +For IRC, use the #datastax-drivers channel on irc.freenode.net. If you don't have an IRC client, +you can use `freenode's web-based client `_. -(If using a Debian Linux derivative such as Ubuntu, it may be easier to -just run ``apt-get install python-snappy``.) +Features to be Added +-------------------- +* C extension for encoding/decoding messages +* Twisted, gevent support +* Python 3 support +* IPv6 Support License ------- diff --git a/cassandra/__init__.py b/cassandra/__init__.py index febe0cd489..1f69ec40bc 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -9,7 +9,7 @@ def emit(self, record): logging.getLogger('cassandra').addHandler(NullHandler()) -__version_info__ = (1, 0, '0b7', 'post') +__version_info__ = (1, 0, '0') __version__ = '.'.join(map(str, __version_info__)) diff --git a/docs/conf.py b/docs/conf.py index 46fdb24240..3bc211bdcf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,7 +43,7 @@ # General information about the project. project = u'Cassandra Driver' -copyright = u'2013, DataStax' +copyright = u'2014, DataStax' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/setup.py b/setup.py index 72a23ce6d9..9a00de9309 100644 --- a/setup.py +++ b/setup.py @@ -165,7 +165,7 @@ def run_setup(extensions): install_requires=dependencies, tests_require=['nose', 'mock', 'ccm', 'unittest2', 'PyYAML'], classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Natural Language :: English', From d38344d6610893e60551121f01cf3244f4c8c8c9 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 29 Jan 2014 13:01:41 -0600 Subject: [PATCH 0795/4528] Remove "Beta" from README title --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index c624220834..128487a9ba 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -DataStax Python Driver for Apache Cassandra (Beta) -================================================== +DataStax Python Driver for Apache Cassandra +=========================================== .. image:: https://travis-ci.org/datastax/python-driver.png?branch=master :target: https://travis-ci.org/datastax/python-driver From 6bb0c5375d1aa1047c7bc93e7d8ab8e8b375252e Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 29 Jan 2014 13:04:17 -0600 Subject: [PATCH 0796/4528] Bump version for post-release --- cassandra/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/__init__.py b/cassandra/__init__.py index 1f69ec40bc..8b4ea7961a 100644 --- a/cassandra/__init__.py +++ b/cassandra/__init__.py @@ -9,7 +9,7 @@ def emit(self, record): logging.getLogger('cassandra').addHandler(NullHandler()) -__version_info__ = (1, 0, '0') +__version_info__ = (1, 0, 0, 'post') __version__ = '.'.join(map(str, __version_info__)) From ce9d2c8bde208f44427242fb92fef182f23a6643 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 29 Jan 2014 13:45:17 -0600 Subject: [PATCH 0797/4528] Guarantee order of maps for unit test --- tests/unit/test_marshalling.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_marshalling.py b/tests/unit/test_marshalling.py index e5d40f5477..31277e871f 100644 --- a/tests/unit/test_marshalling.py +++ b/tests/unit/test_marshalling.py @@ -76,10 +76,15 @@ ('\x00\x01\x00\x10\xafYC\xa3\xea<\x11\xe1\xabc\xc4,\x03"y\xf0', 'ListType(TimeUUIDType)', (UUID(bytes='\xafYC\xa3\xea<\x11\xe1\xabc\xc4,\x03"y\xf0'),)), ) +ordered_dict_value = OrderedDict() +ordered_dict_value[u'\u307fbob'] = 199 +ordered_dict_value[u''] = -1 +ordered_dict_value[u'\\'] = 0 + # these following entries work for me right now, but they're dependent on # vagaries of internal python ordering for unordered types marshalled_value_pairs_unsafe = ( - ('\x00\x03\x00\x06\xe3\x81\xbfbob\x00\x04\x00\x00\x00\xc7\x00\x00\x00\x04\xff\xff\xff\xff\x00\x01\\\x00\x04\x00\x00\x00\x00', 'MapType(UTF8Type, Int32Type)', OrderedDict({u'\u307fbob': 199, u'': -1, u'\\': 0})), + ('\x00\x03\x00\x06\xe3\x81\xbfbob\x00\x04\x00\x00\x00\xc7\x00\x00\x00\x04\xff\xff\xff\xff\x00\x01\\\x00\x04\x00\x00\x00\x00', 'MapType(UTF8Type, Int32Type)', ordered_dict_value), ('\x00\x02\x00\x08@\x01\x99\x99\x99\x99\x99\x9a\x00\x08@\x14\x00\x00\x00\x00\x00\x00', 'SetType(DoubleType)', sortedset([2.2, 5.0])), ('\x00', 'IntegerType', 0), ) From 00a82dbfa2b0ce96d983089e34458a0615fc9e2f Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 29 Jan 2014 13:50:13 -0600 Subject: [PATCH 0798/4528] Avoid different orderings in param binding unit test --- tests/unit/test_parameter_binding.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_parameter_binding.py b/tests/unit/test_parameter_binding.py index 29fa7dba36..01cf9eb104 100644 --- a/tests/unit/test_parameter_binding.py +++ b/tests/unit/test_parameter_binding.py @@ -8,6 +8,11 @@ from cassandra.query import InvalidParameterTypeError from cassandra.cqltypes import Int32Type +try: + from collections import OrderedDict +except ImportError: # Python <2.7 + from cassandra.util import OrderedDict # NOQA + class ParamBindingTest(unittest.TestCase): @@ -36,12 +41,16 @@ def test_list_collection(self): self.assertEquals(result, "[ 'a' , 'b' , 'c' ]") def test_set_collection(self): - result = bind_params("%s", (set(['a', 'b', 'c']),)) - self.assertEquals(result, "{ 'a' , 'c' , 'b' }") + result = bind_params("%s", (set(['a', 'b']),)) + self.assertIn(result, ("{ 'a' , 'b' }", "{ 'b', 'a' }")) def test_map_collection(self): - result = bind_params("%s", ({'a': 'a', 'b': 'b'},)) - self.assertEquals(result, "{ 'a' : 'a' , 'b' : 'b' }") + vals = OrderedDict() + vals['a'] = 'a' + vals['b'] = 'b' + vals['c'] = 'c' + result = bind_params("%s", (vals,)) + self.assertEquals(result, "{ 'a' : 'a' , 'b' : 'b' , 'c' : 'c' }") def test_quote_escaping(self): result = bind_params("%s", ("""'ef''ef"ef""ef'""",)) From ad7c3677911d222cd7c64468c14fc1b67f911007 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 29 Jan 2014 14:06:43 -0600 Subject: [PATCH 0799/4528] Add missing space in expected test result --- tests/unit/test_parameter_binding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_parameter_binding.py b/tests/unit/test_parameter_binding.py index 01cf9eb104..7706e6bc84 100644 --- a/tests/unit/test_parameter_binding.py +++ b/tests/unit/test_parameter_binding.py @@ -42,7 +42,7 @@ def test_list_collection(self): def test_set_collection(self): result = bind_params("%s", (set(['a', 'b']),)) - self.assertIn(result, ("{ 'a' , 'b' }", "{ 'b', 'a' }")) + self.assertIn(result, ("{ 'a' , 'b' }", "{ 'b' , 'a' }")) def test_map_collection(self): vals = OrderedDict() From e87f45aad3ddff9bbd362e28994f074103748b62 Mon Sep 17 00:00:00 2001 From: Alex Popescu Date: Wed, 29 Jan 2014 15:15:57 -0800 Subject: [PATCH 0800/4528] documentation section --- README.rst | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 128487a9ba..4ef23511c4 100644 --- a/README.rst +++ b/README.rst @@ -19,8 +19,15 @@ For more complete installation instructions, see the Documentation ------------- -All documentation for the python driver, including installation details, API documentation, -and a Getting Started guide, can be found `here `_. + +Here are a couple of links for getting up to speed: + +* `Installation `_ +* `Getting started guide `_ +* `API docs `_ + +There are also some +`notes on performance `_. Reporting Problems ------------------ From 6c174d2f39ff35f743395bc364314364cf6ceccb Mon Sep 17 00:00:00 2001 From: Alex Popescu Date: Wed, 29 Jan 2014 15:18:16 -0800 Subject: [PATCH 0801/4528] improved --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 4ef23511c4..12417d8316 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,7 @@ For more complete installation instructions, see the Documentation ------------- -Here are a couple of links for getting up to speed: +A couple of links for getting up to speed: * `Installation `_ * `Getting started guide `_ From f970cc2faaf5018c3b1c49d10015afdea425a0d8 Mon Sep 17 00:00:00 2001 From: Alex Popescu Date: Wed, 29 Jan 2014 15:57:18 -0800 Subject: [PATCH 0802/4528] improved documentation section --- README.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 12417d8316..3b224f67c9 100644 --- a/README.rst +++ b/README.rst @@ -26,8 +26,9 @@ A couple of links for getting up to speed: * `Getting started guide `_ * `API docs `_ -There are also some -`notes on performance `_. +You can also find some +`notes about the performance `_ +on the `documentation page `_. Reporting Problems ------------------ From b76cf4be066effe3bdf82b28cbc380d23ce75ff5 Mon Sep 17 00:00:00 2001 From: Alex Popescu Date: Wed, 29 Jan 2014 15:57:34 -0800 Subject: [PATCH 0803/4528] added link to SSL setup post --- docs/installation.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/installation.rst b/docs/installation.rst index 440df1bd66..30bb10476b 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -110,3 +110,7 @@ For snappy support:: (If using a Debian Linux derivative such as Ubuntu, it may be easier to just run ``apt-get install python-snappy``.) + +Setting SSL +----------- +Andrew Mussey has published a thorough guide on `Using SSL with the DataStax Python driver `_. From a1f39a6c90d18f3fc23b3a0c828815b464ed31d2 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 31 Jan 2014 11:43:02 -0600 Subject: [PATCH 0804/4528] Note that core/max conn limits are per-Session --- cassandra/cluster.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 5cc1c0abbb..fed69f5adc 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -383,18 +383,18 @@ def set_max_requests_per_connection(self, host_distance, max_requests): def get_core_connections_per_host(self, host_distance): """ - Gets the minimum number of connections that will be opened for each - host with :class:`~.HostDistance` equal to `host_distance`. The default - is 2 for :attr:`~HostDistance.LOCAL` and 1 for + Gets the minimum number of connections per Session that will be opened + for each host with :class:`~.HostDistance` equal to `host_distance`. + The default is 2 for :attr:`~HostDistance.LOCAL` and 1 for :attr:`~HostDistance.REMOTE`. """ return self._core_connections_per_host[host_distance] def set_core_connections_per_host(self, host_distance, core_connections): """ - Sets the minimum number of connections that will be opened for each - host with :class:`~.HostDistance` equal to `host_distance`. The default - is 2 for :attr:`~HostDistance.LOCAL` and 1 for + Sets the minimum number of connections per Session that will be opened + for each host with :class:`~.HostDistance` equal to `host_distance`. + The default is 2 for :attr:`~HostDistance.LOCAL` and 1 for :attr:`~HostDistance.REMOTE`. """ old = self._core_connections_per_host[host_distance] @@ -404,18 +404,18 @@ def set_core_connections_per_host(self, host_distance, core_connections): def get_max_connections_per_host(self, host_distance): """ - Gets the maximum number of connections that will be opened for each - host with :class:`~.HostDistance` equal to `host_distance`. The default - is 8 for :attr:`~HostDistance.LOCAL` and 2 for + Gets the maximum number of connections per Session that will be opened + for each host with :class:`~.HostDistance` equal to `host_distance`. + The default is 8 for :attr:`~HostDistance.LOCAL` and 2 for :attr:`~HostDistance.REMOTE`. """ return self._max_connections_per_host[host_distance] def set_max_connections_per_host(self, host_distance, max_connections): """ - Gets the maximum number of connections that will be opened for each - host with :class:`~.HostDistance` equal to `host_distance`. The default - is 2 for :attr:`~HostDistance.LOCAL` and 1 for + Gets the maximum number of connections per Session that will be opened + for each host with :class:`~.HostDistance` equal to `host_distance`. + The default is 2 for :attr:`~HostDistance.LOCAL` and 1 for :attr:`~HostDistance.REMOTE`. """ self._max_connections_per_host[host_distance] = max_connections From 67be0a1c695d6f2dee862097bab46775aa413ba6 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Fri, 31 Jan 2014 12:45:37 -0600 Subject: [PATCH 0805/4528] Include table indexes in KSMetadata.export_as_string() --- CHANGELOG.rst | 8 ++++++++ cassandra/metadata.py | 2 +- tests/integration/standard/test_metadata.py | 6 ++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 699c4fdd1b..9578455d4c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,11 @@ +1.0.1 +===== +(In Progress) + +Bug Fixes +--------- +* Include table indexes in ``KeyspaceMetadata.export_as_string()`` + 1.0.0 Final =========== Jan 29, 2014 diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 02fc0244f8..54f574a6ed 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -561,7 +561,7 @@ def __init__(self, name, durable_writes, strategy_class, strategy_options): self.tables = {} def export_as_string(self): - return "\n".join([self.as_cql_query()] + [t.as_cql_query() for t in self.tables.values()]) + return "\n".join([self.as_cql_query()] + [t.export_as_string() for t in self.tables.values()]) def as_cql_query(self): ret = "CREATE KEYSPACE %s WITH REPLICATION = %s " % \ diff --git a/tests/integration/standard/test_metadata.py b/tests/integration/standard/test_metadata.py index b542b29ba3..0b1583f8f0 100644 --- a/tests/integration/standard/test_metadata.py +++ b/tests/integration/standard/test_metadata.py @@ -278,6 +278,12 @@ def test_indexes(self): self.assertEqual(d_index, statements[1]) self.assertEqual(e_index, statements[2]) + # make sure indexes are included in KeyspaceMetadata.export_as_string() + ksmeta = self.cluster.metadata.keyspaces[self.ksname] + statement = ksmeta.export_as_string() + self.assertIn('CREATE INDEX d_index', statement) + self.assertIn('CREATE INDEX e_index', statement) + class TestCodeCoverage(unittest.TestCase): From 5ce7659075f2b49d704290ed7fd06d41a7116b0c Mon Sep 17 00:00:00 2001 From: Fred Wulff Date: Sun, 2 Feb 2014 01:03:45 -0800 Subject: [PATCH 0806/4528] Fix Token's __hash__ method so that BytesToken instances to be added to token_to_host_owner (currently the driver is broken on ByteOrderedPartitioner) --- cassandra/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cassandra/metadata.py b/cassandra/metadata.py index 54f574a6ed..a87387f185 100644 --- a/cassandra/metadata.py +++ b/cassandra/metadata.py @@ -924,7 +924,7 @@ def __eq__(self, other): return self.value == other.value def __hash__(self): - return self.value + return hash(self.value) def __repr__(self): return "<%s: %r>" % (self.__class__.__name__, self.value) From 1b0cbe27d59e1869b8de410de5b52ca06620db15 Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Sun, 2 Feb 2014 13:46:25 +0200 Subject: [PATCH 0807/4528] 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 5381488086e58b4d5f51b0dcb4ee637b58e45d00 Mon Sep 17 00:00:00 2001 From: Greg Banks Date: Tue, 4 Feb 2014 09:34:41 -0800 Subject: [PATCH 0808/4528] fix callback chaining benchmarking example if there was an error in any query, callbacks_full_pipeline.py would hang because "self.event" (or "finished_event" in the docs) would never get set. this also fixes some minor pep8 issues. --- benchmarks/callback_full_pipeline.py | 30 ++++++++++++++-------------- docs/performance.rst | 26 +++++++++++------------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/benchmarks/callback_full_pipeline.py b/benchmarks/callback_full_pipeline.py index 3faab2224b..10ad2d3c9f 100644 --- a/benchmarks/callback_full_pipeline.py +++ b/benchmarks/callback_full_pipeline.py @@ -1,12 +1,16 @@ -from itertools import count import logging + +from itertools import count from threading import Event from base import benchmark, BenchmarkThread + log = logging.getLogger(__name__) -initial = object() + +sentinal = object() + class Runner(BenchmarkThread): @@ -16,26 +20,22 @@ def __init__(self, *args, **kwargs): self.num_finished = count() self.event = Event() - def handle_error(self, exc): - log.error("Error on insert: %r", exc) - - def insert_next(self, previous_result): - current_num = self.num_started.next() - - if previous_result is not initial: - num = next(self.num_finished) - if num >= self.num_queries: + def insert_next(self, previous_result=sentinal): + if previous_result is not sentinal: + if isinstance(previous_result, BaseException): + log.error("Error on insert: %r", previous_result) + if self.num_finished.next() >= self.num_queries: self.event.set() - if current_num <= self.num_queries: + if self.num_started.next() <= self.num_queries: future = self.session.execute_async(self.query, self.values) - future.add_callbacks(self.insert_next, self.handle_error) + future.add_callbacks(self.insert_next, self.insert_next) def run(self): self.start_profile() - for i in range(120): - self.insert_next(initial) + for _ in xrange(min(120, self.num_queries)): + self.insert_next() self.event.wait() diff --git a/docs/performance.rst b/docs/performance.rst index d6896839d2..7c307e2945 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -182,28 +182,26 @@ flight at any time: from itertools import count from threading import Event + sentinal = object() + num_queries = 100000 num_started = count() num_finished = count() - initial = object() finished_event = Event() - def handle_error(exc): - log.error("Error on insert: %r", exc) - - def insert_next(previous_result): - current_num = num_started.next() - - if previous_result is not initial: - num = next(num_finished) - if num >= 100000: + def insert_next(previous_result=sentinal): + if previous_result is not sentinal: + if isinstance(previous_result, BaseException): + log.error("Error on insert: %r", previous_result) + if num_finished.next() >= num_queries: finished_event.set() - if current_num <= 100000: + if num_started.next() <= num_queries: future = session.execute_async(query) - future.add_callbacks(insert_next, handle_error) + # NOTE: this callback also handles errors + future.add_callbacks(insert_next, insert_next) - for i in range(120): - insert_next(initial) + for i in range(min(120, num_queries)): + insert_next() finished_event.wait() From 66d1c324a9aec558d6ad37c92e06f568e7097a48 Mon Sep 17 00:00:00 2001 From: Greg Banks Date: Tue, 4 Feb 2014 09:41:24 -0800 Subject: [PATCH 0809/4528] whoops. fix that typo. --- benchmarks/callback_full_pipeline.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmarks/callback_full_pipeline.py b/benchmarks/callback_full_pipeline.py index 10ad2d3c9f..614b1a4636 100644 --- a/benchmarks/callback_full_pipeline.py +++ b/benchmarks/callback_full_pipeline.py @@ -9,7 +9,7 @@ log = logging.getLogger(__name__) -sentinal = object() +sentinel = object() class Runner(BenchmarkThread): @@ -20,8 +20,8 @@ def __init__(self, *args, **kwargs): self.num_finished = count() self.event = Event() - def insert_next(self, previous_result=sentinal): - if previous_result is not sentinal: + def insert_next(self, previous_result=sentinel): + if previous_result is not sentinel: if isinstance(previous_result, BaseException): log.error("Error on insert: %r", previous_result) if self.num_finished.next() >= self.num_queries: From 5a420f396744778440e5716f8c61dd3e2a953f25 Mon Sep 17 00:00:00 2001 From: Greg Banks Date: Tue, 4 Feb 2014 09:44:01 -0800 Subject: [PATCH 0810/4528] fix it there too. --- docs/performance.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/performance.rst b/docs/performance.rst index 7c307e2945..3e34dee171 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -182,14 +182,14 @@ flight at any time: from itertools import count from threading import Event - sentinal = object() + sentinel = object() num_queries = 100000 num_started = count() num_finished = count() finished_event = Event() - def insert_next(previous_result=sentinal): - if previous_result is not sentinal: + def insert_next(previous_result=sentinel): + if previous_result is not sentinel: if isinstance(previous_result, BaseException): log.error("Error on insert: %r", previous_result) if num_finished.next() >= num_queries: From 79fc15ecd825af9a3884c254407065d32b17c296 Mon Sep 17 00:00:00 2001 From: Travis Glines Date: Thu, 6 Feb 2014 16:04:02 -0800 Subject: [PATCH 0811/4528] 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 0812/4528] 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 0813/4528] 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 b186ecd8348503348c13cbfb144cd3f0808247a3 Mon Sep 17 00:00:00 2001 From: nisanharamati Date: Fri, 7 Feb 2014 14:34:12 -0800 Subject: [PATCH 0814/4528] Fix UTF8Type serialization for already-encoded utf8 If your data is already utf-8 encoded by the time it reaches the UTF8Type.serialize() method, the encode() will fail, raising a UnicodeDecodeError. Explicitly testing the type for Unicode before the .encode() should guarantee it doesn't fail, and in cases where it isn't Unicode, we simply test whether it's already a valid byte-encoded unicode string by attempting to decode it. This shouldn't add any significant overhead for strings passed in as Unicode, will prevent unnecessary failures with already-encoded Unicode strings, and raise a UnicodeDecodeError otherwise. --- cassandra/cqltypes.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index da8eb95eac..216f9164eb 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -528,7 +528,15 @@ def deserialize(byts): @staticmethod def serialize(ustr): - return ustr.encode('utf8') + # ustr.encode('utf8') fails when the string is already encoded + # this is common if your data comes through other database drivers (e.g. odbc, psycopg2, etc.) + if isinstance(ustr, unicode): # check type explicitly. Unicode will encode successfuly. + return ustr.encode('utf8') + # otherwise, our input string is either already encoded or not unicode to begin with. + # since all cassandra strings are utf-8, we can validate that the ustr is already encoded utf-8 by decoding it + else: + ustr.decode('utf-8') # will raise UnicodeDecodeError if not utf8 encoded byte string. + return ustr # definitely valid :) class VarcharType(UTF8Type): From 61a1c71aa9c1930512dc8ce4051253c349df24e2 Mon Sep 17 00:00:00 2001 From: nisanharamati Date: Fri, 7 Feb 2014 16:30:21 -0800 Subject: [PATCH 0815/4528] try..except on UTF8Type serialize for pre-encoded strings Wrap ustr.decode() with a try..except block to handle failure to encode strings of already-encoded unicode. --- cassandra/cqltypes.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index 216f9164eb..39294822c5 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -528,15 +528,11 @@ def deserialize(byts): @staticmethod def serialize(ustr): - # ustr.encode('utf8') fails when the string is already encoded - # this is common if your data comes through other database drivers (e.g. odbc, psycopg2, etc.) - if isinstance(ustr, unicode): # check type explicitly. Unicode will encode successfuly. - return ustr.encode('utf8') - # otherwise, our input string is either already encoded or not unicode to begin with. - # since all cassandra strings are utf-8, we can validate that the ustr is already encoded utf-8 by decoding it - else: - ustr.decode('utf-8') # will raise UnicodeDecodeError if not utf8 encoded byte string. - return ustr # definitely valid :) + try: + return ustr.encode('utf-8') + except UnicodeDecodeError: + # already utf-8 + return ustr class VarcharType(UTF8Type): From 95d99bf35982a75df961522367a0122effec4f24 Mon Sep 17 00:00:00 2001 From: Danny Cosson Date: Sun, 9 Feb 2014 14:39:26 -0500 Subject: [PATCH 0816/4528] 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 0817/4528] 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 0818/4528] 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 0819/4528] 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 0820/4528] 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 0821/4528] 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 0822/4528] 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 0823/4528] 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 0824/4528] 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 0825/4528] 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 0826/4528] 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 3b7413b427f0aff779e1d8c0d47d02b3a1c6c338 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Tue, 11 Feb 2014 17:17:50 -0600 Subject: [PATCH 0827/4528] Fix pep8 error in example.py --- example.py | 1 + 1 file changed, 1 insertion(+) diff --git a/example.py b/example.py index 86ac9eaa42..424613ae57 100755 --- a/example.py +++ b/example.py @@ -14,6 +14,7 @@ KEYSPACE = "testkeyspace" + def main(): cluster = Cluster(['127.0.0.1']) session = cluster.connect() From 658551b8362b1c706060497a568d0007db763979 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 12 Feb 2014 11:38:47 -0600 Subject: [PATCH 0828/4528] Skip OPTIONS message if cql_version, compression not set By default compression is enabled, so the OPTIONS message will typically be sent. But, if cql_version was not set and either compression was explicitly disabled or no compressors are locally supported, the driver will skip the OPTIONS message. Related: PYTHON-47, CASSANDRA-6663 --- cassandra/connection.py | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/cassandra/connection.py b/cassandra/connection.py index b23bacc41f..630386f1d6 100644 --- a/cassandra/connection.py +++ b/cassandra/connection.py @@ -106,6 +106,9 @@ def wrapper(self, *args, **kwargs): return wrapper +DEFAULT_CQL_VERSION = '3.0.0' + + class Connection(object): in_buffer_size = 4096 @@ -211,8 +214,16 @@ def process_msg(self, msg, body_len): @defunct_on_error def _send_options_message(self): - log.debug("Sending initial options message for new connection (%s) to %s", id(self), self.host) - self.send_msg(OptionsMessage(), self._handle_options_response) + if self.cql_version is None and (not self.compression or not locally_supported_compressions): + log.debug("Not sending options message for new connection(%s) to %s " + "because compression is disabled and a cql version was not " + "specified", id(self), self.host) + self._compressor = None + self.cql_version = DEFAULT_CQL_VERSION + self._send_startup_message() + else: + log.debug("Sending initial options message for new connection (%s) to %s", id(self), self.host) + self.send_msg(OptionsMessage(), self._handle_options_response) @defunct_on_error def _handle_options_response(self, options_response): @@ -231,36 +242,42 @@ def _handle_options_response(self, options_response): log.debug("Received options response on new connection (%s) from %s", id(self), self.host) - self.supported_cql_versions = options_response.cql_versions - self.remote_supported_compressions = options_response.options['COMPRESSION'] + supported_cql_versions = options_response.cql_versions + remote_supported_compressions = options_response.options['COMPRESSION'] if self.cql_version: - if self.cql_version not in self.supported_cql_versions: + if self.cql_version not in supported_cql_versions: raise ProtocolError( "cql_version %r is not supported by remote (w/ native " "protocol). Supported versions: %r" - % (self.cql_version, self.supported_cql_versions)) + % (self.cql_version, supported_cql_versions)) else: - self.cql_version = self.supported_cql_versions[0] + self.cql_version = supported_cql_versions[0] - opts = {} self._compressor = None + compression_type = None if self.compression: overlap = (set(locally_supported_compressions.keys()) & - set(self.remote_supported_compressions)) + set(remote_supported_compressions)) if len(overlap) == 0: log.debug("No available compression types supported on both ends." " locally supported: %r. remotely supported: %r", locally_supported_compressions.keys(), - self.remote_supported_compressions) + remote_supported_compressions) else: compression_type = iter(overlap).next() # choose any - opts['COMPRESSION'] = compression_type # set the decompressor here, but set the compressor only after # a successful Ready message self._compressor, self.decompressor = \ locally_supported_compressions[compression_type] + self._send_startup_message(compression_type) + + @defunct_on_error + def _send_startup_message(self, compression=None): + opts = {} + if compression: + opts['COMPRESSION'] = compression sm = StartupMessage(cqlversion=self.cql_version, options=opts) self.send_msg(sm, cb=self._handle_startup_response) From 7b029c98c908408a7d7ed0f1141773d034db01ac Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 12 Feb 2014 11:50:27 -0600 Subject: [PATCH 0829/4528] Update changelog --- CHANGELOG.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9578455d4c..c45ff4fd2d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,13 @@ Bug Fixes --------- * Include table indexes in ``KeyspaceMetadata.export_as_string()`` +* Fix broken token awareness on ByteOrderedPartitioner + +Other +----- +* Skip sending OPTIONS message on connection creation if compression is + disabled or not available and a CQL version has not been explicitly + set 1.0.0 Final =========== From eb402f6aad6b58f5f20bfbb7dfcfd01b51a45b01 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Wed, 12 Feb 2014 12:00:23 -0600 Subject: [PATCH 0830/4528] Update unit tests for skipping OPTIONS message --- tests/unit/io/test_asyncorereactor.py | 4 ++-- tests/unit/io/test_libevreactor.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/io/test_asyncorereactor.py b/tests/unit/io/test_asyncorereactor.py index 3b4946cb1b..a8eaf5c35b 100644 --- a/tests/unit/io/test_asyncorereactor.py +++ b/tests/unit/io/test_asyncorereactor.py @@ -21,7 +21,7 @@ from cassandra.io.asyncorereactor import AsyncoreConnection -class LibevConnectionTest(unittest.TestCase): +class AsyncoreConnectionTest(unittest.TestCase): @classmethod def setUpClass(cls): @@ -35,7 +35,7 @@ def tearDownClass(cls): cls.socket_patcher.stop() def make_connection(self): - c = AsyncoreConnection('1.2.3.4') + c = AsyncoreConnection('1.2.3.4', cql_version='3.0.1') c.socket = Mock() c.socket.send.side_effect = lambda x: len(x) return c diff --git a/tests/unit/io/test_libevreactor.py b/tests/unit/io/test_libevreactor.py index ca303b835c..875e3610fd 100644 --- a/tests/unit/io/test_libevreactor.py +++ b/tests/unit/io/test_libevreactor.py @@ -35,7 +35,7 @@ def setUp(self): raise unittest.SkipTest('libev does not appear to be installed correctly') def make_connection(self): - c = LibevConnection('1.2.3.4') + c = LibevConnection('1.2.3.4', cql_version='3.0.1') c._socket = Mock() c._socket.send.side_effect = lambda x: len(x) return c From f0fc2288fc4f907d3f012f02e0c64a6093b64dfa Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 13 Feb 2014 12:38:49 -0600 Subject: [PATCH 0831/4528] Always close sockets when defuncting connections Under certain error conditions, this could result in an FD leak for the socket handles. (In my testing, they seemed to eventually be GC'ed and closed, but this behavior is probably not dependable.) Fixes #80 --- CHANGELOG.rst | 2 ++ cassandra/io/asyncorereactor.py | 5 +++-- cassandra/io/libevreactor.py | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c45ff4fd2d..abe56c0656 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,8 @@ Bug Fixes --------- * Include table indexes in ``KeyspaceMetadata.export_as_string()`` * Fix broken token awareness on ByteOrderedPartitioner +* Always close socket when defuncting error'ed connections to avoid a potential + file descriptor leak Other ----- diff --git a/cassandra/io/asyncorereactor.py b/cassandra/io/asyncorereactor.py index 4a956727db..6fefa49d75 100644 --- a/cassandra/io/asyncorereactor.py +++ b/cassandra/io/asyncorereactor.py @@ -173,11 +173,11 @@ def close(self): with _starting_conns_lock: _starting_conns.discard(self) - # don't leave in-progress operations hanging - self.connected_event.set() if not self.is_defunct: self._error_all_callbacks( ConnectionShutdown("Connection to %s was closed" % self.host)) + # don't leave in-progress operations hanging + self.connected_event.set() def defunct(self, exc): with self.lock: @@ -194,6 +194,7 @@ def defunct(self, exc): id(self), self.host, exc) self.last_error = exc + self.close() self._error_all_callbacks(exc) self.connected_event.set() return exc diff --git a/cassandra/io/libevreactor.py b/cassandra/io/libevreactor.py index f24964bb02..35fc1771ab 100644 --- a/cassandra/io/libevreactor.py +++ b/cassandra/io/libevreactor.py @@ -246,6 +246,7 @@ def defunct(self, exc): log.debug("Defuncting connection (%s) to %s: %s", id(self), self.host, exc) self.last_error = exc + self.close() self._error_all_callbacks(exc) self.connected_event.set() return exc From 6bd519b8f9558e26c98faa562506d6901d2097be Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 13 Feb 2014 16:23:49 -0600 Subject: [PATCH 0832/4528] Handle custom types by full classname lookup --- CHANGELOG.rst | 1 + cassandra/decoder.py | 14 +++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index abe56c0656..3875521a23 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,7 @@ Bug Fixes * Fix broken token awareness on ByteOrderedPartitioner * Always close socket when defuncting error'ed connections to avoid a potential file descriptor leak +* Handle "custom" types (such as the replaced DateType) correctly Other ----- diff --git a/cassandra/decoder.py b/cassandra/decoder.py index 2b25f2e6dc..c5a47d48d5 100644 --- a/cassandra/decoder.py +++ b/cassandra/decoder.py @@ -28,7 +28,7 @@ DoubleType, FloatType, Int32Type, InetAddressType, IntegerType, ListType, LongType, MapType, SetType, TimeUUIDType, - UTF8Type, UUIDType) + UTF8Type, UUIDType, lookup_casstype) log = logging.getLogger(__name__) @@ -424,6 +424,9 @@ def send_body(self, f): write_consistency_level(f, self.consistency_level) +CUSTOM_TYPE = object() + + class ResultMessage(_MessageType): opcode = 0x08 name = 'RESULT' @@ -436,6 +439,7 @@ class ResultMessage(_MessageType): KIND_SCHEMA_CHANGE = 0x0005 type_codes = { + 0x0000: CUSTOM_TYPE, 0x0001: AsciiType, 0x0002: LongType, 0x0003: BytesType, @@ -525,8 +529,8 @@ def read_type(cls, f): try: typeclass = cls.type_codes[optid] except KeyError: - raise NotSupportedError("Unknown data type code 0x%x. Have to skip" - " entire result set." % optid) + raise NotSupportedError("Unknown data type code 0x%04x. Have to skip" + " entire result set." % (optid,)) if typeclass in (ListType, SetType): subtype = cls.read_type(f) typeclass = typeclass.apply_parameters(subtype) @@ -534,6 +538,10 @@ def read_type(cls, f): keysubtype = cls.read_type(f) valsubtype = cls.read_type(f) typeclass = typeclass.apply_parameters(keysubtype, valsubtype) + elif typeclass == CUSTOM_TYPE: + classname = read_string(f) + typeclass = lookup_casstype(classname) + return typeclass @staticmethod From 1db9679cf8f0e2ffed3374a38858952ae83b8c89 Mon Sep 17 00:00:00 2001 From: Tyler Hobbs Date: Thu, 13 Feb 2014 18:13:46 -0600 Subject: [PATCH 0833/4528] 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