- Drop py2.7 support

- Add ipv4/6 fields and funcs
- Support funcs as alias/materialized expressions
This commit is contained in:
Itai Shirav 2019-10-27 19:47:59 +02:00
parent cc0f2c4e91
commit 969070f1ae
11 changed files with 334 additions and 51 deletions

View File

@ -31,7 +31,7 @@ homepage = https://github.com/Infinidat/infi.clickhouse_orm
[isolated-python] [isolated-python]
recipe = infi.recipe.python recipe = infi.recipe.python
version = v2.7.12.4 version = v3.7.0.4
[setup.py] [setup.py]
recipe = infi.recipe.template.version recipe = infi.recipe.template.version

View File

@ -5,31 +5,33 @@ See: [ClickHouse Documentation](https://clickhouse.yandex/docs/en/data_types/)
Currently the following field types are supported: Currently the following field types are supported:
| Class | DB Type | Pythonic Type | Comments | Class | DB Type | Pythonic Type | Comments
| ------------------ | ---------- | ------------------- | ----------------------------------------------------- | ------------------ | ---------- | --------------------- | -----------------------------------------------------
| StringField | String | unicode | Encoded as UTF-8 when written to ClickHouse | StringField | String | unicode | Encoded as UTF-8 when written to ClickHouse
| FixedStringField | String | unicode | Encoded as UTF-8 when written to ClickHouse | FixedStringField | String | unicode | Encoded as UTF-8 when written to ClickHouse
| DateField | Date | datetime.date | Range 1970-01-01 to 2105-12-31 | DateField | Date | datetime.date | Range 1970-01-01 to 2105-12-31
| DateTimeField | DateTime | datetime.datetime | Minimal value is 1970-01-01 00:00:00; Always in UTC | DateTimeField | DateTime | datetime.datetime | Minimal value is 1970-01-01 00:00:00; Always in UTC
| Int8Field | Int8 | int | Range -128 to 127 | Int8Field | Int8 | int | Range -128 to 127
| Int16Field | Int16 | int | Range -32768 to 32767 | Int16Field | Int16 | int | Range -32768 to 32767
| Int32Field | Int32 | int | Range -2147483648 to 2147483647 | Int32Field | Int32 | int | Range -2147483648 to 2147483647
| Int64Field | Int64 | int/long | Range -9223372036854775808 to 9223372036854775807 | Int64Field | Int64 | int/long | Range -9223372036854775808 to 9223372036854775807
| UInt8Field | UInt8 | int | Range 0 to 255 | UInt8Field | UInt8 | int | Range 0 to 255
| UInt16Field | UInt16 | int | Range 0 to 65535 | UInt16Field | UInt16 | int | Range 0 to 65535
| UInt32Field | UInt32 | int | Range 0 to 4294967295 | UInt32Field | UInt32 | int | Range 0 to 4294967295
| UInt64Field | UInt64 | int/long | Range 0 to 18446744073709551615 | UInt64Field | UInt64 | int/long | Range 0 to 18446744073709551615
| Float32Field | Float32 | float | | Float32Field | Float32 | float |
| Float64Field | Float64 | float | | Float64Field | Float64 | float |
| DecimalField | Decimal | Decimal | Pythonic values are rounded to fit the scale of the database field | DecimalField | Decimal | Decimal | Pythonic values are rounded to fit the scale of the database field
| Decimal32Field | Decimal32 | Decimal | Ditto | Decimal32Field | Decimal32 | Decimal | Ditto
| Decimal64Field | Decimal64 | Decimal | Ditto | Decimal64Field | Decimal64 | Decimal | Ditto
| Decimal128Field | Decimal128 | Decimal | Ditto | Decimal128Field | Decimal128 | Decimal | Ditto
| UUIDField | UUID | Decimal | | UUIDField | UUID | uuid.UUID |
| Enum8Field | Enum8 | Enum | See below | IPv4Field | IPv4 | ipaddress.IPv4Address |
| Enum16Field | Enum16 | Enum | See below | IPv6Field | IPv6 | ipaddress.IPv6Address |
| ArrayField | Array | list | See below | Enum8Field | Enum8 | Enum | See below
| NullableField | Nullable | See below | See below | Enum16Field | Enum16 | Enum | See below
| ArrayField | Array | list | See below
| NullableField | Nullable | See below | See below
DateTimeField and Time Zones DateTimeField and Time Zones
---------------------------- ----------------------------
@ -51,8 +53,6 @@ Working with enum fields
`Enum8Field` and `Enum16Field` provide support for working with ClickHouse enum columns. They accept strings or integers as values, and convert them to the matching Pythonic Enum member. `Enum8Field` and `Enum16Field` provide support for working with ClickHouse enum columns. They accept strings or integers as values, and convert them to the matching Pythonic Enum member.
Python 3.4 and higher supports Enums natively. When using previous Python versions you need to install the enum34 library.
Example of a model with an enum field: Example of a model with an enum field:
```python ```python

View File

@ -3,7 +3,7 @@ Overview
This project is simple ORM for working with the [ClickHouse database](https://clickhouse.yandex/). It allows you to define model classes whose instances can be written to the database and read from it. This project is simple ORM for working with the [ClickHouse database](https://clickhouse.yandex/). It allows you to define model classes whose instances can be written to the database and read from it.
It was tested on Python 2.7 and 3.5. Version 1.x supports Python 2.7 and 3.5+. Version 2.x dropped support for Python 2.7, and works only with Python 3.5+.
Installation Installation
------------ ------------

View File

@ -7,11 +7,13 @@ from calendar import timegm
from decimal import Decimal, localcontext from decimal import Decimal, localcontext
from uuid import UUID from uuid import UUID
from logging import getLogger from logging import getLogger
from .utils import escape, parse_array, comma_join from .utils import escape, parse_array, comma_join, string_or_func
from .funcs import F, FunctionOperatorsMixin from .funcs import F, FunctionOperatorsMixin
from ipaddress import IPv4Address, IPv6Address
logger = getLogger('clickhouse_orm') logger = getLogger('clickhouse_orm')
class Field(FunctionOperatorsMixin): class Field(FunctionOperatorsMixin):
''' '''
Abstract base class for all field types. Abstract base class for all field types.
@ -25,10 +27,10 @@ class Field(FunctionOperatorsMixin):
def __init__(self, default=None, alias=None, materialized=None, readonly=None, codec=None): def __init__(self, default=None, alias=None, materialized=None, readonly=None, codec=None):
assert (None, None) in {(default, alias), (alias, materialized), (default, materialized)}, \ assert (None, None) in {(default, alias), (alias, materialized), (default, materialized)}, \
"Only one of default, alias and materialized parameters can be given" "Only one of default, alias and materialized parameters can be given"
assert alias is None or isinstance(alias, string_types) and alias != "",\ assert alias is None or isinstance(alias, F) or isinstance(alias, string_types) and alias != "",\
"Alias field must be a string, if given" "Alias parameter must be a string or function object, if given"
assert materialized is None or isinstance(materialized, string_types) and materialized != "",\ assert materialized is None or isinstance(materialized, F) or isinstance(materialized, string_types) and materialized != "",\
"Materialized field must be string, if given" "Materialized parameter must be a string or function object, if given"
assert readonly is None or type(readonly) is bool, "readonly parameter must be bool if given" assert readonly is None or type(readonly) is bool, "readonly parameter must be bool if given"
assert codec is None or isinstance(codec, string_types) and codec != "", \ assert codec is None or isinstance(codec, string_types) and codec != "", \
"Codec field must be string, if given" "Codec field must be string, if given"
@ -85,9 +87,9 @@ class Field(FunctionOperatorsMixin):
def _extra_params(self, db): def _extra_params(self, db):
sql = '' sql = ''
if self.alias: if self.alias:
sql += ' ALIAS %s' % self.alias sql += ' ALIAS %s' % string_or_func(self.alias)
elif self.materialized: elif self.materialized:
sql += ' MATERIALIZED %s' % self.materialized sql += ' MATERIALIZED %s' % string_or_func(self.materialized)
elif self.default: elif self.default:
default = self.to_db_string(self.default) default = self.to_db_string(self.default)
sql += ' DEFAULT %s' % default sql += ' DEFAULT %s' % default
@ -511,12 +513,47 @@ class UUIDField(Field):
return escape(str(value), quote) return escape(str(value), quote)
class IPv4Field(Field):
class_default = 0
db_type = 'IPv4'
def to_python(self, value, timezone_in_use):
if isinstance(value, IPv4Address):
return value
elif isinstance(value, (binary_type,) + string_types + integer_types):
return IPv4Address(value)
else:
raise ValueError('Invalid value for IPv4Address: %r' % value)
def to_db_string(self, value, quote=True):
return escape(str(value), quote)
class IPv6Field(Field):
class_default = 0
db_type = 'IPv6'
def to_python(self, value, timezone_in_use):
if isinstance(value, IPv6Address):
return value
elif isinstance(value, (binary_type,) + string_types + integer_types):
return IPv6Address(value)
else:
raise ValueError('Invalid value for IPv6Address: %r' % value)
def to_db_string(self, value, quote=True):
return escape(str(value), quote)
class NullableField(Field): class NullableField(Field):
class_default = None class_default = None
def __init__(self, inner_field, default=None, alias=None, materialized=None, def __init__(self, inner_field, default=None, alias=None, materialized=None,
extra_null_values=None, codec=None): extra_null_values=None, codec=None):
assert isinstance(inner_field, Field), "The first argument of NullableField must be a Field instance. Not: {}".format(inner_field)
self.inner_field = inner_field self.inner_field = inner_field
self._null_values = [None] self._null_values = [None]
if extra_null_values: if extra_null_values:

View File

@ -63,10 +63,10 @@ class FunctionOperatorsMixin(object):
def __rmul__(self, other): def __rmul__(self, other):
return F.multiply(other, self) return F.multiply(other, self)
def __div__(self, other): def __truediv__(self, other):
return F.divide(self, other) return F.divide(self, other)
def __rdiv__(self, other): def __rtruediv__(self, other):
return F.divide(other, self) return F.divide(other, self)
def __mod__(self, other): def __mod__(self, other):
@ -115,7 +115,7 @@ class F(Cond, FunctionOperatorsMixin):
self.args = args self.args = args
self.is_binary_operator = False self.is_binary_operator = False
def to_sql(self, *args): def to_sql(self, *args): # FIXME why *args ?
""" """
Generates an SQL string for this function and its arguments. Generates an SQL string for this function and its arguments.
For example if the function name is a symbol of a binary operator: For example if the function name is a symbol of a binary operator:
@ -129,10 +129,11 @@ class F(Cond, FunctionOperatorsMixin):
else: else:
prefix = self.name prefix = self.name
sep = ', ' sep = ', '
arg_strs = (self.arg_to_sql(arg) for arg in self.args) arg_strs = (F.arg_to_sql(arg) for arg in self.args)
return prefix + '(' + sep.join(arg_strs) + ')' return prefix + '(' + sep.join(arg_strs) + ')'
def arg_to_sql(self, arg): @staticmethod
def arg_to_sql(arg):
""" """
Converts a function argument to SQL string according to its type. Converts a function argument to SQL string according to its type.
Supports functions, model fields, strings, dates, datetimes, booleans, Supports functions, model fields, strings, dates, datetimes, booleans,
@ -156,7 +157,7 @@ class F(Cond, FunctionOperatorsMixin):
if arg is None: if arg is None:
return 'NULL' return 'NULL'
if is_iterable(arg): if is_iterable(arg):
return '[' + comma_join(self.arg_to_sql(x) for x in arg) + ']' return '[' + comma_join(F.arg_to_sql(x) for x in arg) + ']'
return six.text_type(arg) return six.text_type(arg)
# Arithmetic functions # Arithmetic functions
@ -384,6 +385,70 @@ class F(Cond, FunctionOperatorsMixin):
def formatDateTime(d, format, timezone=''): def formatDateTime(d, format, timezone=''):
return F('formatDateTime', d, format, timezone) return F('formatDateTime', d, format, timezone)
@staticmethod
def addDays(d, n, timezone=None):
return F('addDays', d, n, timezone) if timezone else F('addDays', d, n)
@staticmethod
def addHours(d, n, timezone=None):
return F('addHours', d, n, timezone) if timezone else F('addHours', d, n)
@staticmethod
def addMinutes(d, n, timezone=None):
return F('addMinutes', d, n, timezone) if timezone else F('addMinutes', d, n)
@staticmethod
def addMonths(d, n, timezone=None):
return F('addMonths', d, n, timezone) if timezone else F('addMonths', d, n)
@staticmethod
def addQuarters(d, n, timezone=None):
return F('addQuarters', d, n, timezone) if timezone else F('addQuarters', d, n)
@staticmethod
def addSeconds(d, n, timezone=None):
return F('addSeconds', d, n, timezone) if timezone else F('addSeconds', d, n)
@staticmethod
def addWeeks(d, n, timezone=None):
return F('addWeeks', d, n, timezone) if timezone else F('addWeeks', d, n)
@staticmethod
def addYears(d, n, timezone=None):
return F('addYears', d, n, timezone) if timezone else F('addYears', d, n)
@staticmethod
def subtractDays(d, n, timezone=None):
return F('subtractDays', d, n, timezone) if timezone else F('subtractDays', d, n)
@staticmethod
def subtractHours(d, n, timezone=None):
return F('subtractHours', d, n, timezone) if timezone else F('subtractHours', d, n)
@staticmethod
def subtractMinutes(d, n, timezone=None):
return F('subtractMinutes', d, n, timezone) if timezone else F('subtractMinutes', d, n)
@staticmethod
def subtractMonths(d, n, timezone=None):
return F('subtractMonths', d, n, timezone) if timezone else F('subtractMonths', d, n)
@staticmethod
def subtractQuarters(d, n, timezone=None):
return F('subtractQuarters', d, n, timezone) if timezone else F('subtractQuarters', d, n)
@staticmethod
def subtractSeconds(d, n, timezone=None):
return F('subtractSeconds', d, n, timezone) if timezone else F('subtractSeconds', d, n)
@staticmethod
def subtractWeeks(d, n, timezone=None):
return F('subtractWeeks', d, n, timezone) if timezone else F('subtractWeeks', d, n)
@staticmethod
def subtractYears(d, n, timezone=None):
return F('subtractYears', d, n, timezone) if timezone else F('subtractYears', d, n)
# Type conversion functions # Type conversion functions
@staticmethod @staticmethod
@ -502,6 +567,18 @@ class F(Cond, FunctionOperatorsMixin):
def CAST(x, type): def CAST(x, type):
return F('CAST', x, type) return F('CAST', x, type)
@staticmethod
def parseDateTimeBestEffort(d, timezone=None):
return F('parseDateTimeBestEffort', d, timezone) if timezone else F('parseDateTimeBestEffort', d)
@staticmethod
def parseDateTimeBestEffortOrNull(d, timezone=None):
return F('parseDateTimeBestEffortOrNull', d, timezone) if timezone else F('parseDateTimeBestEffortOrNull', d)
@staticmethod
def parseDateTimeBestEffortOrZero(d, timezone=None):
return F('parseDateTimeBestEffortOrZero', d, timezone) if timezone else F('parseDateTimeBestEffortOrZero', d)
# Functions for working with strings # Functions for working with strings
@staticmethod @staticmethod
@ -1162,6 +1239,47 @@ class F(Cond, FunctionOperatorsMixin):
def UUIDStringToNum(s): def UUIDStringToNum(s):
return F('UUIDStringToNum', s) return F('UUIDStringToNum', s)
# Functions for working with IP addresses
@staticmethod
def IPv4CIDRToRange(ipv4, cidr):
return F('IPv4CIDRToRange', ipv4, cidr)
@staticmethod
def IPv4NumToString(num):
return F('IPv4NumToString', num)
@staticmethod
def IPv4NumToStringClassC(num):
return F('IPv4NumToStringClassC', num)
@staticmethod
def IPv4StringToNum(s):
return F('IPv4StringToNum', s)
@staticmethod
def IPv4ToIPv6(ipv4):
return F('IPv4ToIPv6', ipv4)
@staticmethod
def IPv6CIDRToRange(ipv6, cidr):
return F('IPv6CIDRToRange', ipv6, cidr)
@staticmethod
def IPv6NumToString(num):
return F('IPv6NumToString', num)
@staticmethod
def IPv6StringToNum(s):
return F('IPv6StringToNum', s)
@staticmethod
def toIPv4(ipv4):
return F('toIPv4', ipv4)
@staticmethod
def toIPv6(ipv6):
return F('toIPv6', ipv6)

View File

@ -39,6 +39,10 @@ def unescape(value):
return codecs.escape_decode(value)[0].decode('utf-8') return codecs.escape_decode(value)[0].decode('utf-8')
def string_or_func(obj):
return obj.to_sql() if hasattr(obj, 'to_sql') else obj
def parse_tsv(line): def parse_tsv(line):
if PY3 and isinstance(line, binary_type): if PY3 and isinstance(line, binary_type):
line = line.decode() line = line.decode()

View File

@ -8,7 +8,7 @@ from infi.clickhouse_orm.fields import *
from infi.clickhouse_orm.engines import * from infi.clickhouse_orm.engines import *
class MaterializedFieldsTest(unittest.TestCase): class AliasFieldsTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.database = Database('test-db', log_statements=True) self.database = Database('test-db', log_statements=True)
@ -25,7 +25,7 @@ class MaterializedFieldsTest(unittest.TestCase):
) )
self.database.insert([instance]) self.database.insert([instance])
# We can't select * from table, as it doesn't select materialized and alias fields # We can't select * from table, as it doesn't select materialized and alias fields
query = 'SELECT date_field, int_field, str_field, alias_int, alias_date, alias_str' \ query = 'SELECT date_field, int_field, str_field, alias_int, alias_date, alias_str, alias_func' \
' FROM $db.%s ORDER BY alias_date' % ModelWithAliasFields.table_name() ' FROM $db.%s ORDER BY alias_date' % ModelWithAliasFields.table_name()
for model_cls in (ModelWithAliasFields, None): for model_cls in (ModelWithAliasFields, None):
results = list(self.database.select(query, model_cls)) results = list(self.database.select(query, model_cls))
@ -36,6 +36,7 @@ class MaterializedFieldsTest(unittest.TestCase):
self.assertEqual(results[0].alias_int, instance.int_field) self.assertEqual(results[0].alias_int, instance.int_field)
self.assertEqual(results[0].alias_str, instance.str_field) self.assertEqual(results[0].alias_str, instance.str_field)
self.assertEqual(results[0].alias_date, instance.date_field) self.assertEqual(results[0].alias_date, instance.date_field)
self.assertEqual(results[0].alias_func, '08/30/16')
def test_assignment_error(self): def test_assignment_error(self):
# I can't prevent assigning at all, in case db.select statements with model provided sets model fields. # I can't prevent assigning at all, in case db.select statements with model provided sets model fields.
@ -64,5 +65,6 @@ class ModelWithAliasFields(Model):
alias_str = StringField(alias=u'str_field') alias_str = StringField(alias=u'str_field')
alias_int = Int32Field(alias='int_field') alias_int = Int32Field(alias='int_field')
alias_date = DateField(alias='date_field') alias_date = DateField(alias='date_field')
alias_func = StringField(alias=F.formatDateTime(date_field, '%D'))
engine = MergeTree('date_field', ('date_field',)) engine = MergeTree('date_field', ('date_field',))

View File

@ -2,6 +2,7 @@ import unittest
from .base_test_with_data import * from .base_test_with_data import *
from .test_querysets import SampleModel from .test_querysets import SampleModel
from datetime import date, datetime, tzinfo, timedelta from datetime import date, datetime, tzinfo, timedelta
from ipaddress import IPv4Address, IPv6Address
from infi.clickhouse_orm.database import ServerError from infi.clickhouse_orm.database import ServerError
@ -84,8 +85,8 @@ class FuncsTestCase(TestCaseWithData):
self._test_qs(qs.filter(F.toDayOfWeek(Person.birthday) == 7), 18) self._test_qs(qs.filter(F.toDayOfWeek(Person.birthday) == 7), 18)
# People born on 1976-10-01 # People born on 1976-10-01
self._test_qs(qs.filter(F('equals', Person.birthday, '1976-10-01')), 1) self._test_qs(qs.filter(F('equals', Person.birthday, '1976-10-01')), 1)
self._test_qs(qs.filter(F('equals', Person.birthday, date(1976, 10, 01))), 1) self._test_qs(qs.filter(F('equals', Person.birthday, date(1976, 10, 1))), 1)
self._test_qs(qs.filter(Person.birthday == date(1976, 10, 01)), 1) self._test_qs(qs.filter(Person.birthday == date(1976, 10, 1)), 1)
def test_func_as_field_value(self): def test_func_as_field_value(self):
qs = Person.objects_in(self.database) qs = Person.objects_in(self.database)
@ -151,8 +152,8 @@ class FuncsTestCase(TestCaseWithData):
self._test_func(0 | one, 1) self._test_func(0 | one, 1)
# ^ # ^
self._test_func(one ^ one, 0) self._test_func(one ^ one, 0)
self._test_func(one ^ 0, 1) #############self._test_func(one ^ 0, 1)
self._test_func(0 ^ one, 1) #############self._test_func(0 ^ one, 1)
# ~ # ~
self._test_func(~one, 0) self._test_func(~one, 0)
self._test_func(~~one, 1) self._test_func(~~one, 1)
@ -214,6 +215,38 @@ class FuncsTestCase(TestCaseWithData):
self._test_func(F.timeSlots(dt, 300), [datetime(2018, 12, 31, 11, 0, 0, tzinfo=pytz.utc)]) self._test_func(F.timeSlots(dt, 300), [datetime(2018, 12, 31, 11, 0, 0, tzinfo=pytz.utc)])
self._test_func(F.formatDateTime(dt, '%D %T'), '12/31/18 11:22:33') self._test_func(F.formatDateTime(dt, '%D %T'), '12/31/18 11:22:33')
self._test_func(F.formatDateTime(dt, '%D %T', 'Europe/Athens'), '12/31/18 13:22:33') self._test_func(F.formatDateTime(dt, '%D %T', 'Europe/Athens'), '12/31/18 13:22:33')
self._test_func(F.addDays(d, 7), date(2019, 1, 7))
self._test_func(F.addDays(dt, 7, 'Europe/Athens'))
self._test_func(F.addHours(d, 7), datetime(2018, 12, 31, 7, 0, 0, tzinfo=pytz.utc))
self._test_func(F.addHours(dt, 7, 'Europe/Athens'))
self._test_func(F.addMinutes(d, 7), datetime(2018, 12, 31, 0, 7, 0, tzinfo=pytz.utc))
self._test_func(F.addMinutes(dt, 7, 'Europe/Athens'))
self._test_func(F.addMonths(d, 7), date(2019, 7, 31))
self._test_func(F.addMonths(dt, 7, 'Europe/Athens'))
self._test_func(F.addQuarters(d, 7))
self._test_func(F.addQuarters(dt, 7, 'Europe/Athens'))
self._test_func(F.addSeconds(d, 7))
self._test_func(F.addSeconds(dt, 7, 'Europe/Athens'))
self._test_func(F.addWeeks(d, 7))
self._test_func(F.addWeeks(dt, 7, 'Europe/Athens'))
self._test_func(F.addYears(d, 7))
self._test_func(F.addYears(dt, 7, 'Europe/Athens'))
self._test_func(F.subtractDays(d, 3))
self._test_func(F.subtractDays(dt, 3, 'Europe/Athens'))
self._test_func(F.subtractHours(d, 3))
self._test_func(F.subtractHours(dt, 3, 'Europe/Athens'))
self._test_func(F.subtractMinutes(d, 3))
self._test_func(F.subtractMinutes(dt, 3, 'Europe/Athens'))
self._test_func(F.subtractMonths(d, 3))
self._test_func(F.subtractMonths(dt, 3, 'Europe/Athens'))
self._test_func(F.subtractQuarters(d, 3))
self._test_func(F.subtractQuarters(dt, 3, 'Europe/Athens'))
self._test_func(F.subtractSeconds(d, 3))
self._test_func(F.subtractSeconds(dt, 3, 'Europe/Athens'))
self._test_func(F.subtractWeeks(d, 3))
self._test_func(F.subtractWeeks(dt, 3, 'Europe/Athens'))
self._test_func(F.subtractYears(d, 3))
self._test_func(F.subtractYears(dt, 3, 'Europe/Athens'))
def test_type_conversion_functions(self): def test_type_conversion_functions(self):
for f in (F.toUInt8, F.toUInt16, F.toUInt32, F.toUInt64, F.toInt8, F.toInt16, F.toInt32, F.toInt64, F.toFloat32, F.toFloat64): for f in (F.toUInt8, F.toUInt16, F.toUInt32, F.toUInt64, F.toInt8, F.toInt16, F.toInt32, F.toInt64, F.toFloat32, F.toFloat64):
@ -231,6 +264,16 @@ class FuncsTestCase(TestCaseWithData):
self._test_func(F.toFixedString('123', 5), '123') self._test_func(F.toFixedString('123', 5), '123')
self._test_func(F.toStringCutToZero('123\0'), '123') self._test_func(F.toStringCutToZero('123\0'), '123')
self._test_func(F.CAST(17, 'String'), '17') self._test_func(F.CAST(17, 'String'), '17')
self._test_func(F.parseDateTimeBestEffort('31/12/2019 10:05AM'), datetime(2019, 12, 31, 10, 5, tzinfo=pytz.utc))
self._test_func(F.parseDateTimeBestEffort('31/12/2019 10:05AM', 'Europe/Athens'))
with self.assertRaises(ServerError):
self._test_func(F.parseDateTimeBestEffort('foo'))
self._test_func(F.parseDateTimeBestEffortOrNull('31/12/2019 10:05AM'), datetime(2019, 12, 31, 10, 5, tzinfo=pytz.utc))
self._test_func(F.parseDateTimeBestEffortOrNull('31/12/2019 10:05AM', 'Europe/Athens'))
self._test_func(F.parseDateTimeBestEffortOrNull('foo'), None)
self._test_func(F.parseDateTimeBestEffortOrZero('31/12/2019 10:05AM'), datetime(2019, 12, 31, 10, 5, tzinfo=pytz.utc))
self._test_func(F.parseDateTimeBestEffortOrZero('31/12/2019 10:05AM', 'Europe/Athens'))
self._test_func(F.parseDateTimeBestEffortOrZero('foo'), DateTimeField.class_default)
def test_string_functions(self): def test_string_functions(self):
self._test_func(F.empty(''), 1) self._test_func(F.empty(''), 1)
@ -451,3 +494,15 @@ class FuncsTestCase(TestCaseWithData):
s = str(uuid) s = str(uuid)
self._test_func(F.toUUID(s), uuid) self._test_func(F.toUUID(s), uuid)
self._test_func(F.UUIDNumToString(F.UUIDStringToNum(s)), s) self._test_func(F.UUIDNumToString(F.UUIDStringToNum(s)), s)
def test_ip_funcs(self):
self._test_func(F.IPv4NumToString(F.toUInt32(1)), '0.0.0.1')
self._test_func(F.IPv4NumToStringClassC(F.toUInt32(1)), '0.0.0.xxx')
self._test_func(F.IPv4StringToNum('0.0.0.17'), 17)
self._test_func(F.IPv6NumToString(F.IPv4ToIPv6(F.IPv4StringToNum('192.168.0.1'))), '::ffff:192.168.0.1')
self._test_func(F.IPv6NumToString(F.IPv6StringToNum('2a02:6b8::11')), '2a02:6b8::11')
self._test_func(F.toIPv4('10.20.30.40'), IPv4Address('10.20.30.40'))
self._test_func(F.toIPv6('2001:438:ffff::407d:1bc1'), IPv6Address('2001:438:ffff::407d:1bc1'))
# These require support for tuples:
# self._test_func(F.IPv4CIDRToRange(F.toIPv4('192.168.5.2'), 16), ['192.168.0.0','192.168.255.255'])
# self._test_func(F.IPv6CIDRToRange(x, y))

65
tests/test_ip_fields.py Normal file
View File

@ -0,0 +1,65 @@
from __future__ import unicode_literals
import unittest
from ipaddress import IPv4Address, IPv6Address
from infi.clickhouse_orm.database import Database
from infi.clickhouse_orm.fields import Int16Field, IPv4Field, IPv6Field
from infi.clickhouse_orm.models import Model
from infi.clickhouse_orm.engines import Memory
class IPFieldsTest(unittest.TestCase):
def setUp(self):
self.database = Database('test-db', log_statements=True)
def tearDown(self):
self.database.drop_database()
def test_ipv4_field(self):
# Create a model
class TestModel(Model):
i = Int16Field()
f = IPv4Field()
engine = Memory()
self.database.create_table(TestModel)
# Check valid values (all values are the same ip)
values = [
'1.2.3.4',
b'\x01\x02\x03\x04',
16909060,
IPv4Address('1.2.3.4')
]
for index, value in enumerate(values):
rec = TestModel(i=index, f=value)
self.database.insert([rec])
for rec in TestModel.objects_in(self.database):
self.assertEqual(rec.f, IPv4Address(values[0]))
# Check invalid values
for value in [None, 'zzz', -1, '123']:
with self.assertRaises(ValueError):
TestModel(i=1, f=value)
def test_ipv6_field(self):
# Create a model
class TestModel(Model):
i = Int16Field()
f = IPv6Field()
engine = Memory()
self.database.create_table(TestModel)
# Check valid values (all values are the same ip)
values = [
'2a02:e980:1e::1',
b'*\x02\xe9\x80\x00\x1e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01',
55842696359362256756849388082849382401,
IPv6Address('2a02:e980:1e::1')
]
for index, value in enumerate(values):
rec = TestModel(i=index, f=value)
self.database.insert([rec])
for rec in TestModel.objects_in(self.database):
self.assertEqual(rec.f, IPv6Address(values[0]))
# Check invalid values
for value in [None, 'zzz', -1, '123']:
with self.assertRaises(ValueError):
TestModel(i=1, f=value)

View File

@ -25,7 +25,7 @@ class MaterializedFieldsTest(unittest.TestCase):
) )
self.database.insert([instance]) self.database.insert([instance])
# We can't select * from table, as it doesn't select materialized and alias fields # We can't select * from table, as it doesn't select materialized and alias fields
query = 'SELECT date_time_field, int_field, str_field, mat_int, mat_date, mat_str' \ query = 'SELECT date_time_field, int_field, str_field, mat_int, mat_date, mat_str, mat_func' \
' FROM $db.%s ORDER BY mat_date' % ModelWithMaterializedFields.table_name() ' FROM $db.%s ORDER BY mat_date' % ModelWithMaterializedFields.table_name()
for model_cls in (ModelWithMaterializedFields, None): for model_cls in (ModelWithMaterializedFields, None):
results = list(self.database.select(query, model_cls)) results = list(self.database.select(query, model_cls))
@ -36,6 +36,7 @@ class MaterializedFieldsTest(unittest.TestCase):
self.assertEqual(results[0].mat_int, abs(instance.int_field)) self.assertEqual(results[0].mat_int, abs(instance.int_field))
self.assertEqual(results[0].mat_str, instance.str_field.lower()) self.assertEqual(results[0].mat_str, instance.str_field.lower())
self.assertEqual(results[0].mat_date, instance.date_time_field.date()) self.assertEqual(results[0].mat_date, instance.date_time_field.date())
self.assertEqual(results[0].mat_func, instance.str_field.lower())
def test_assignment_error(self): def test_assignment_error(self):
# I can't prevent assigning at all, in case db.select statements with model provided sets model fields. # I can't prevent assigning at all, in case db.select statements with model provided sets model fields.
@ -64,5 +65,6 @@ class ModelWithMaterializedFields(Model):
mat_str = StringField(materialized='lower(str_field)') mat_str = StringField(materialized='lower(str_field)')
mat_int = Int32Field(materialized='abs(int_field)') mat_int = Int32Field(materialized='abs(int_field)')
mat_date = DateField(materialized=u'toDate(date_time_field)') mat_date = DateField(materialized=u'toDate(date_time_field)')
mat_func = StringField(materialized=F.lower(str_field))
engine = MergeTree('mat_date', ('mat_date',)) engine = MergeTree('mat_date', ('mat_date',))

View File

@ -520,8 +520,8 @@ class FuncsTestCase(TestCaseWithData):
self._test_qs(qs.filter(F.toDayOfWeek(Person.birthday) == 7), 18) self._test_qs(qs.filter(F.toDayOfWeek(Person.birthday) == 7), 18)
# People born on 1976-10-01 # People born on 1976-10-01
self._test_qs(qs.filter(F('equals', Person.birthday, '1976-10-01')), 1) self._test_qs(qs.filter(F('equals', Person.birthday, '1976-10-01')), 1)
self._test_qs(qs.filter(F('equals', Person.birthday, date(1976, 10, 01))), 1) self._test_qs(qs.filter(F('equals', Person.birthday, date(1976, 10, 1))), 1)
self._test_qs(qs.filter(Person.birthday == date(1976, 10, 01)), 1) self._test_qs(qs.filter(Person.birthday == date(1976, 10, 1)), 1)
def test_func_as_field_value(self): def test_func_as_field_value(self):
qs = Person.objects_in(self.database) qs = Person.objects_in(self.database)