diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f334de --- /dev/null +++ b/.gitignore @@ -0,0 +1,103 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +*.ipynb +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +venv3.7/ +ENV/ + +# mock event +*.event + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +#IDE specific +.idea/ +.vscode/ + +#eclipse project +.project +.pydevproject + +.DS_Store diff --git a/architect/databases/bases.py b/architect/databases/bases.py index e0efab7..0e77f1c 100644 --- a/architect/databases/bases.py +++ b/architect/databases/bases.py @@ -16,8 +16,11 @@ def __init__(self, model, **meta): self.model = model self.database = model.architect.operation self.table = meta['table'] - self.column_value = meta['column_value'] - self.column_name = meta['column'] + self.column_values = meta['column_values'] + self.columns = meta['columns'] + # backward compatibility + self.column_value = meta['column_values'][0] + self.column_name = meta['columns'][0] self.pks = meta['pk'] if isinstance(meta['pk'], list) else [meta['pk']] def prepare(self): diff --git a/architect/databases/postgresql/partition.py b/architect/databases/postgresql/partition.py index 39aa0f7..821e7fa 100644 --- a/architect/databases/postgresql/partition.py +++ b/architect/databases/postgresql/partition.py @@ -18,7 +18,11 @@ def prepare(self): """ Prepares needed triggers and functions for those triggers. """ - indentation = {'declarations': 5, 'variables': 6} + command_str = self._get_command_str() + return self.database.execute(command_str) + + def _get_command_str(self): + indentation = {'declarations': 5, 'variables': 5} definitions, formatters = self._get_definitions() for definition in indentation: @@ -28,22 +32,17 @@ def prepare(self): definitions[definition] = '\n'.join(definitions[definition]).format(**formatters) - return self.database.execute(""" + return """ -- We need to create a before insert function CREATE OR REPLACE FUNCTION {{parent_table}}_insert_child() RETURNS TRIGGER AS $$ DECLARE - match "{{parent_table}}".{{column}}%TYPE; + {declarations} tablename VARCHAR; checks TEXT; - {declarations} + BEGIN - IF NEW.{{column}} IS NULL THEN - tablename := '{{parent_table}}_null'; - checks := '{{column}} IS NULL'; - ELSE - {variables} - END IF; + {variables} BEGIN EXECUTE 'CREATE TABLE IF NOT EXISTS ' || tablename || ' ( @@ -100,8 +99,12 @@ def prepare(self): """.format(**definitions).format( pk=' AND '.join('{pk} = NEW.{pk}'.format(pk=pk) for pk in self.pks), parent_table=self.table, - column='"{0}"'.format(self.column_name) - )) + **{ + 'column_{idx}'.format(idx=idx): '"{0}"'.format(column) for idx, column in enumerate( + self.columns + ) + } + ) def exists(self): """ @@ -128,27 +131,46 @@ class RangePartition(Partition): """ def __init__(self, model, **meta): super(RangePartition, self).__init__(model, **meta) - self.constraint = meta['constraint'] - self.subtype = meta['subtype'] + self.constraints = meta['constraints'] + self.subtypes = meta['subtypes'] def _get_definitions(self): """ Dynamically returns needed definitions depending on the partition subtype. """ - try: - definitions = getattr(self, '_get_{0}_definitions'.format(self.subtype))() - formatters = dict(constraint=self.constraint, subtype=self.subtype, **definitions.pop('formatters', {})) - return definitions, formatters - except AttributeError: - import re - expression = '_get_(\w+)_function' - raise PartitionRangeSubtypeError( - model=self.model.__name__, - dialect=self.dialect, - current=self.subtype, - allowed=[re.match(expression, c).group(1) for c in dir(self) if re.match(expression, c) is not None]) + definitions = dict() + for idx, subtype in enumerate(self.subtypes): + try: + if definitions: + definitions_temp = getattr(self, '_get_{0}_definitions'.format(subtype))(idx) + definitions['formatters'] = {**definitions['formatters'], **definitions_temp['formatters']} + definitions['declarations'] += definitions_temp['declarations'] + definitions['variables'] += definitions_temp['variables'] + else: + definitions = getattr(self, '_get_{0}_definitions'.format(subtype))(idx) + except AttributeError: + import re + expression = '_get_(\w+)_function' + raise PartitionRangeSubtypeError( + model=self.model.__name__, + dialect=self.dialect, + current=subtype, + allowed=[re.match(expression, c).group(1) for c in dir(self) if + re.match(expression, c) is not None]) - def _get_date_definitions(self): + formatters = dict(**definitions.pop('formatters', {})) + tablename = "tablename := '{{parent_table}}'" + checks = "checks := " + for idx in range(len(self.constraints)): + tablename += ' || tablename_{idx}'.format(idx=idx) + formatters["constraint_{}".format(idx)] = self.constraints[idx] + formatters["subtype_{}".format(idx)] = self.subtypes[idx] + checks += " || ' AND ' || ".join(['checks_{idx}'.format(idx=idx) for idx in range(len(self.constraints))]) + definitions['variables'].append(tablename+';') + definitions['variables'].append(checks+';') + return definitions, formatters + + def _get_date_definitions(self, idx): """ Returns definitions for date partition subtype. """ @@ -160,86 +182,124 @@ def _get_date_definitions(self): } try: - pattern = patterns[self.constraint] + pattern = patterns[self.constraints[idx]] except KeyError: raise PartitionConstraintError( model=self.model.__name__, dialect=self.dialect, - current=self.constraint, + current=self.constraints[idx], allowed=patterns.keys()) return { 'formatters': {'pattern': pattern}, + 'declarations': [ + 'match_{idx} {{{{parent_table}}}}.{{{{column_{idx}}}}}%TYPE;'.format(idx=idx), + 'tablename_{idx} VARCHAR;'.format(idx=idx), + 'checks_{idx} TEXT;'.format(idx=idx), + ], 'variables': [ - "match := DATE_TRUNC('{constraint}', NEW.{{column}});", - "tablename := '{{parent_table}}_' || TO_CHAR(NEW.{{column}}, '{pattern}');", - "checks := '{{column}} >= ''' || match || ''' AND {{column}} < ''' || (match + INTERVAL '1 {constraint}') || '''';" + "match_{idx} := DATE_TRUNC('{{constraint_{idx}}}', NEW.{{{{column_{idx}}}}});".format(idx=idx), + "tablename_{idx} := '__' || TO_CHAR(NEW.{{{{column_{idx}}}}}, '{{pattern}}');".format(idx=idx), + "checks_{idx} := '{{{{column_{idx}}}}} >= ''' || match_{idx} || ''' AND {{{{column_{idx}}}}} < ''' " + "|| (match_{idx} + INTERVAL '1 {{constraint_{idx}}}') || '''';".format(idx=idx) ] } - def _get_integer_definitions(self): + def _get_integer_definitions(self, idx): """ Returns definitions for integer partition subtype. """ - if not self.constraint.isdigit() or int(self.constraint) < 1: + if not self.constraints[idx].isdigit() or int(self.constraints[idx]) < 1: raise PartitionConstraintError( model=self.model.__name__, dialect=self.dialect, - current=self.constraint, + current=self.constraints[idx], allowed=['positive integer']) return { + 'formatters': {'idx': idx}, + 'declarations': [ + 'match_{idx} {{{{parent_table}}}}.{{{{column_{idx}}}}}%TYPE;'.format(idx=idx), + 'tablename_{idx} VARCHAR;'.format(idx=idx), + 'checks_{idx} TEXT;'.format(idx=idx), + ], 'variables': [ - "IF NEW.{{column}} = 0 THEN", - " tablename := '{{parent_table}}_0';", - " checks := '{{column}} = 0';", + "IF NEW.{{{{column_{idx}}}}} IS NULL THEN".format(idx=idx), + " tablename_{idx} := '__null';".format(idx=idx), + " checks_{idx} := '{{{{column_{idx}}}}} IS NULL';".format(idx=idx), "ELSE", - " IF NEW.{{column}} > 0 THEN", - " match := ((NEW.{{column}} - 1) / {constraint}) * {constraint} + 1;", - " tablename := '{{parent_table}}_' || match || '_' || (match + {constraint}) - 1;", + " IF NEW.{{{{column_{idx}}}}} = 0 THEN".format(idx=idx), + " tablename_{idx} := '__0';".format(idx=idx), + " checks_{idx} := '{{{{column_{idx}}}}} = 0';".format(idx=idx), " ELSE", - " match := FLOOR(NEW.{{column}} :: FLOAT / {constraint} :: FLOAT) * {constraint};", - " tablename := '{{parent_table}}_m' || ABS(match) || '_m' || ABS((match + {constraint}) - 1);", + " IF NEW.{{{{column_{idx}}}}} > 0 THEN".format(idx=idx), + " match_{idx} := ((NEW.{{{{column_{idx}}}}} - 1) / " + "{{constraint_{idx}}}) * {{constraint_{idx}}} + 1;".format(idx=idx), + " tablename_{idx} := '__' || match_{idx} || '_' || " + "(match_{idx} + {{constraint_{idx}}}) - 1;".format(idx=idx), + " ELSE", + " match_{idx} := FLOOR(NEW.{{{{column_{idx}}}}} :: FLOAT / " + "{{constraint_{idx}}} :: FLOAT) * {{constraint_{idx}}};".format(idx=idx), + " tablename_{idx} := '__m' || ABS(match_{idx}) || '_m' || " + "ABS((match_{idx} + {{constraint_{idx}}}) - 1);".format(idx=idx), + " END IF;", + " checks_{idx} := '{{{{column_{idx}}}}} >= ' || match_{idx} || " + "' AND {{{{column_{idx}}}}} <= ' || (match_{idx} + {{constraint_{idx}}}) - 1;".format(idx=idx), " END IF;", - " checks := '{{column}} >= ' || match || ' AND {{column}} <= ' || (match + {constraint}) - 1;", - "END IF;" + "END IF;", + ] } - def _get_string_firstchars_definitions(self): + def _get_string_firstchars_definitions(self, idx): """ Returns definitions for string firstchars partition subtype. """ - if not self.constraint.isdigit() or int(self.constraint) < 1: + if not self.constraints[idx].isdigit() or int(self.constraints[idx]) < 1: raise PartitionConstraintError( model=self.model.__name__, dialect=self.dialect, - current=self.constraint, + current=self.constraints[idx], allowed=['positive integer']) return { + 'formatters': {}, + 'declarations': [ + 'match_{idx} {{{{parent_table}}}}.{{{{column_{idx}}}}}%TYPE;'.format(idx=idx), + 'tablename_{idx} VARCHAR;'.format(idx=idx), + 'checks_{idx} TEXT;'.format(idx=idx), + ], 'variables': [ - "match := LOWER(SUBSTR(NEW.{{column}}, 1, {constraint}));", - "tablename := QUOTE_IDENT('{{parent_table}}_' || match);", - "checks := 'LOWER(SUBSTR({{column}}, 1, {constraint})) = ''' || match || '''';" + "match_{idx} := LOWER(SUBSTR(NEW.{{{{column_{idx}}}}}, 1, {{constraint_{idx}}}));".format(idx=idx), + "tablename_{idx} := QUOTE_IDENT('__' || match_{idx});".format(idx=idx), + "checks_{idx} := 'LOWER(SUBSTR({{{{column_{idx}}}}}, 1, " + "{{constraint_{idx}}})) = ''' || match_{idx} || '''';".format(idx=idx) ] } - def _get_string_lastchars_definitions(self): + def _get_string_lastchars_definitions(self, idx): """ Returns definitions for string lastchars partition subtype. """ - if not self.constraint.isdigit() or int(self.constraint) < 1: + if not self.constraints[idx].isdigit() or int(self.constraints[idx]) < 1: raise PartitionConstraintError( model=self.model.__name__, dialect=self.dialect, - current=self.constraint, + current=self.constraints[idx], allowed=['positive integer']) return { + 'formatters': {}, + 'declarations': [ + 'match_{idx} {{{{parent_table}}}}.{{{{column_{idx}}}}}%TYPE;'.format(idx=idx), + 'tablename_{idx} VARCHAR;'.format(idx=idx), + 'checks_{idx} TEXT;'.format(idx=idx), + ], 'variables': [ - "match := LOWER(SUBSTRING(NEW.{{column}} FROM '.{{{{{constraint}}}}}$'));", - "tablename := QUOTE_IDENT('{{parent_table}}_' || match);", - "checks := 'LOWER(SUBSTRING({{column}} FROM ''.{{{{{constraint}}}}}$'')) = ''' || match || '''';" + "match_{idx} := LOWER(SUBSTRING(NEW.{{{{column_{idx}}}}} " + "FROM '.{{{{{{{{{{constraint_{idx}}}}}}}}}}}$'));".format(idx=idx), + "tablename_{idx} := QUOTE_IDENT('__' || match_{idx});".format(idx=idx), + "checks_{idx} := 'LOWER(SUBSTRING({{{{column_{idx}}}}} FROM " + "''.{{{{{{{{{{constraint_{idx}}}}}}}}}}}$'')) = ''' || match_{idx} || '''';".format(idx=idx) ] } diff --git a/architect/orms/bases.py b/architect/orms/bases.py index 7b0a1c5..059ed08 100644 --- a/architect/orms/bases.py +++ b/architect/orms/bases.py @@ -111,18 +111,26 @@ def model_meta(self): """ raise NotImplementedError('Property "model_meta" not implemented in: {0}'.format(self.__class__.__name__)) - def _column_value(self, allowed_columns): + def _column_values(self, allowed_columns): """ Returns current value for the specified partition column. :param list allowed_columns: (required). Names of valid columns for current model. """ try: - return None if self.model_obj is None else getattr(self.model_obj, self.options['column']) + columns = self.options['columns'] + if self.model_obj is None: + return [None] * len(columns) + else: + return_list = list() + for column in columns: + try: + return_list.append(getattr(self.model_obj, column)) + except AttributeError: + raise PartitionColumnError( + model=self.model_cls.__name__, + current=column, + allowed=allowed_columns) + return return_list except KeyError as key: raise OptionNotSetError(model=self.model_cls.__name__, current=key) - except AttributeError: - raise PartitionColumnError( - model=self.model_cls.__name__, - current=self.options['column'], - allowed=allowed_columns) diff --git a/architect/orms/decorators.py b/architect/orms/decorators.py index b5292ab..ac8e698 100644 --- a/architect/orms/decorators.py +++ b/architect/orms/decorators.py @@ -13,6 +13,19 @@ FeatureUninstallError, MethodAutoDecorateError ) +from copy import deepcopy + + +def set_list_vals(key, **options): + options = deepcopy(options) + val = options.get(key) + key_plural = '{}s'.format(key) + list_vals = options.get(key_plural, []) + if not list_vals: + if val and type(val) == str: + list_vals = [val] + options[key_plural] = list_vals + return options class install(object): @@ -25,6 +38,11 @@ def __init__(self, feature, **options): :param string feature: (required). A feature to install. :param dictionary options: (optional). Feature options. """ + # for backward compatibility + options = set_list_vals('subtype', **options) + options = set_list_vals('constraint', **options) + options = set_list_vals('column', **options) + self.features = {} self.feature = feature self.options = {'feature': options, 'global': dict((k, v) for k, v in options.items() if k in ('db',))} diff --git a/architect/orms/django/features.py b/architect/orms/django/features.py index e2b4197..676e3a0 100644 --- a/architect/orms/django/features.py +++ b/architect/orms/django/features.py @@ -76,24 +76,27 @@ def model_meta(self): meta = self.model_cls._meta try: - if self.model_obj is None: - column_value = None - else: - field = meta.get_field(self.options['column']) - column_value = field.pre_save(self.model_obj, self.model_obj.pk is None) + columns = self.options['columns'] + column_values = list() + for column in columns: + try: + field = meta.get_field(column) + if self.model_obj is None: + column_values.append(None) + else: + column_values.append(field.pre_save(self.model_obj, self.model_obj.pk is None)) + except FieldDoesNotExist: + raise PartitionColumnError( + model=self.model_cls.__name__, + current=column, + allowed=[f.name for f in meta.fields]) except KeyError as key: raise OptionNotSetError(model=self.model_cls.__name__, current=key) - except FieldDoesNotExist: - raise PartitionColumnError( - model=self.model_cls.__name__, - current=self.options['column'], - allowed=[f.name for f in meta.fields]) - return { 'table': meta.db_table, 'pk': meta.pk.column, 'dialect': self.connection.db.vendor, - 'column_value': column_value, + 'column_values': column_values, } @staticmethod diff --git a/architect/orms/peewee/features.py b/architect/orms/peewee/features.py index cca5154..3e5bb0f 100644 --- a/architect/orms/peewee/features.py +++ b/architect/orms/peewee/features.py @@ -29,7 +29,7 @@ def model_meta(self): 'table': getattr(meta, names['meta_table']), 'pk': list(pk.field_names) if isinstance(pk, CompositeKey) else pk.name, 'dialect': meta.database.__class__.__name__.lower().replace('database', ''), - 'column_value': self._column_value([field for field in meta.fields.keys()]), + 'column_values': self._column_values([field for field in meta.fields.keys()]), } @staticmethod diff --git a/architect/orms/pony/features.py b/architect/orms/pony/features.py index be21161..8074a54 100644 --- a/architect/orms/pony/features.py +++ b/architect/orms/pony/features.py @@ -22,7 +22,7 @@ def model_meta(self): 'table': self.model_cls._table_, 'pk': self.model_cls._pk_columns_, 'dialect': self.model_cls._database_.provider.dialect.lower(), - 'column_value': self._column_value(self.model_cls._columns_), + 'column_values': self._column_values(self.model_cls._columns_), } @staticmethod diff --git a/architect/orms/sqlalchemy/features.py b/architect/orms/sqlalchemy/features.py index 64e6ec4..8b1f008 100644 --- a/architect/orms/sqlalchemy/features.py +++ b/architect/orms/sqlalchemy/features.py @@ -39,7 +39,7 @@ def model_meta(self): 'table': self.model_cls.__table__.name, 'pk': self.model_cls.__table__.primary_key.columns.keys(), 'dialect': self.connection.dialect.name, - 'column_value': self._column_value(self.model_cls.__table__.columns.keys()), + 'column_values': self._column_values(self.model_cls.__table__.columns.keys()), } @staticmethod diff --git a/architect/orms/sqlobject/features.py b/architect/orms/sqlobject/features.py index 82b8356..247ea37 100644 --- a/architect/orms/sqlobject/features.py +++ b/architect/orms/sqlobject/features.py @@ -38,7 +38,7 @@ def model_meta(self): 'table': self.model_cls.sqlmeta.table, 'pk': self.model_cls.sqlmeta.idName, 'dialect': self.model_cls._connection.dbName, - 'column_value': self._column_value(self.model_cls.sqlmeta.columns.keys()), + 'column_values': self._column_values(self.model_cls.sqlmeta.columns.keys()), } @staticmethod diff --git a/tests/test_backward_compatibility.py b/tests/test_backward_compatibility.py new file mode 100644 index 0000000..802dfe9 --- /dev/null +++ b/tests/test_backward_compatibility.py @@ -0,0 +1,13 @@ +""" +Tests database specific behaviour which is independent from ORM being used. +""" +from . import unittest, mock +from architect.orms.decorators import set_list_vals + + +class SetListValsTestCase(unittest.TestCase): + def test__get_command_str_single_column(self): + options = { + 'column': 'dfs' + } + assert set_list_vals('column', **options)['columns'][0] == 'dfs' diff --git a/tests/test_bases.py b/tests/test_bases.py index 9ded900..608392d 100644 --- a/tests/test_bases.py +++ b/tests/test_bases.py @@ -11,7 +11,7 @@ class BasePartitionTestCase(unittest.TestCase): @classmethod def setUpClass(cls): - cls.Partition = BasePartition(mock.Mock(), table=None, column_value=None, column=None, pk=None) + cls.Partition = BasePartition(mock.Mock(), table=None, column_values=[None], columns=[None], pk=None) def test_prepare_not_implemented(self): self.assertRaises(NotImplementedError, lambda: self.Partition.prepare()) @@ -57,10 +57,10 @@ def test_model_meta_not_implemented(self): def test_column_value_raises_option_not_set_error(self): from architect.exceptions import OptionNotSetError - self.assertRaises(OptionNotSetError, lambda: self.PartitionFeature._column_value([])) + self.assertRaises(OptionNotSetError, lambda: self.PartitionFeature._column_values([])) def test_column_value_raises_partition_column_error(self): from architect.exceptions import PartitionColumnError - self.PartitionFeature.options = {'column': 'foo'} + self.PartitionFeature.options = {'columns': ['foo']} self.PartitionFeature.model_obj = mock.Mock(spec=[]) - self.assertRaises(PartitionColumnError, lambda: self.PartitionFeature._column_value([])) + self.assertRaises(PartitionColumnError, lambda: self.PartitionFeature._column_values([])) diff --git a/tests/test_multi_column.py b/tests/test_multi_column.py new file mode 100644 index 0000000..959bb20 --- /dev/null +++ b/tests/test_multi_column.py @@ -0,0 +1,221 @@ +""" +Tests database specific behaviour which is independent from ORM being used. +""" +from . import unittest, mock + +from architect.databases.postgresql.partition import RangePartition + + +class SingleColumnPartitionTestCase(object): + def setUp(self): + model = mock.Mock(__name__='Foo') + defaults = { + 'table': 'shipping_volume_predictedvolume', + 'column_values': [1], + 'columns': ['source_file_id'], + 'pk': 'id' + } + self.range_partition = RangePartition(model, **dict(constraints=['1'], subtypes=['integer'], **defaults)) + + +class PostgresqlPartitionSingleColumnTestCase(SingleColumnPartitionTestCase, unittest.TestCase): + def test__get_command_str_single_column(self): + command_str = self.range_partition._get_command_str() + target_command_str = """ + -- We need to create a before insert function + CREATE OR REPLACE FUNCTION shipping_volume_predictedvolume_insert_child() + RETURNS TRIGGER AS $$ + DECLARE + match_0 shipping_volume_predictedvolume."source_file_id"%TYPE; + tablename_0 VARCHAR; + checks_0 TEXT; + tablename VARCHAR; + checks TEXT; + + BEGIN + IF NEW."source_file_id" IS NULL THEN + tablename_0 := '__null'; + checks_0 := '"source_file_id" IS NULL'; + ELSE + IF NEW."source_file_id" = 0 THEN + tablename_0 := '__0'; + checks_0 := '"source_file_id" = 0'; + ELSE + IF NEW."source_file_id" > 0 THEN + match_0 := ((NEW."source_file_id" - 1) / 1) * 1 + 1; + tablename_0 := '__' || match_0 || '_' || (match_0 + 1) - 1; + ELSE + match_0 := FLOOR(NEW."source_file_id" :: FLOAT / 1 :: FLOAT) * 1; + tablename_0 := '__m' || ABS(match_0) || '_m' || ABS((match_0 + 1) - 1); + END IF; + checks_0 := '"source_file_id" >= ' || match_0 || ' AND "source_file_id" <= ' || (match_0 + 1) - 1; + END IF; + END IF; + tablename := 'shipping_volume_predictedvolume' || tablename_0; + checks := checks_0; + + BEGIN + EXECUTE 'CREATE TABLE IF NOT EXISTS ' || tablename || ' ( + CHECK (' || checks || '), + LIKE "shipping_volume_predictedvolume" INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING INDEXES + ) INHERITS ("shipping_volume_predictedvolume");'; + EXCEPTION WHEN duplicate_table THEN + -- pass + END; + + EXECUTE 'INSERT INTO ' || tablename || ' VALUES (($1).*);' USING NEW; + RETURN NEW; + END; + $$ LANGUAGE plpgsql SECURITY DEFINER; + + -- Then we create a trigger which calls the before insert function + DO $$ + BEGIN + IF NOT EXISTS( + SELECT 1 + FROM information_schema.triggers + WHERE event_object_table = 'shipping_volume_predictedvolume' + AND trigger_name = LOWER('before_insert_shipping_volume_predictedvolume_trigger') + ) THEN + CREATE TRIGGER before_insert_shipping_volume_predictedvolume_trigger + BEFORE INSERT ON "shipping_volume_predictedvolume" + FOR EACH ROW EXECUTE PROCEDURE shipping_volume_predictedvolume_insert_child(); + END IF; + END $$; + + -- Then we create a function to delete duplicate row from the master table after insert + CREATE OR REPLACE FUNCTION shipping_volume_predictedvolume_delete_master() + RETURNS TRIGGER AS $$ + BEGIN + DELETE FROM ONLY "shipping_volume_predictedvolume" WHERE id = NEW.id; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + -- Lastly we create the after insert trigger that calls the after insert function + DO $$ + BEGIN + IF NOT EXISTS( + SELECT 1 + FROM information_schema.triggers + WHERE event_object_table = 'shipping_volume_predictedvolume' + AND trigger_name = LOWER('after_insert_shipping_volume_predictedvolume_trigger') + ) THEN + CREATE TRIGGER after_insert_shipping_volume_predictedvolume_trigger + AFTER INSERT ON "shipping_volume_predictedvolume" + FOR EACH ROW EXECUTE PROCEDURE shipping_volume_predictedvolume_delete_master(); + END IF; + END $$; + """ + + assert command_str == target_command_str + + +class DoubleColumnPartitionTestCase(object): + def setUp(self): + model = mock.Mock(__name__='Foo') + defaults = { + 'table': 'shipping_volume_predictedvolume', + 'column_values': [None, None], + 'columns': ['source_file_id', 'date'], + 'pk': 'id' + } + self.range_partition = RangePartition(model, **dict(constraints=['1', 'month'], subtypes=['integer', 'date'], **defaults)) + + +class PostgresqlPartitionDoubleColumnTestCase(DoubleColumnPartitionTestCase, unittest.TestCase): + def test__get_command_str_single_column(self): + command_str = self.range_partition._get_command_str() + target_command_str = """ + -- We need to create a before insert function + CREATE OR REPLACE FUNCTION shipping_volume_predictedvolume_insert_child() + RETURNS TRIGGER AS $$ + DECLARE + match_0 shipping_volume_predictedvolume."source_file_id"%TYPE; + tablename_0 VARCHAR; + checks_0 TEXT; + match_1 shipping_volume_predictedvolume."date"%TYPE; + tablename_1 VARCHAR; + checks_1 TEXT; + tablename VARCHAR; + checks TEXT; + + BEGIN + IF NEW."source_file_id" IS NULL THEN + tablename_0 := '__null'; + checks_0 := '"source_file_id" IS NULL'; + ELSE + IF NEW."source_file_id" = 0 THEN + tablename_0 := '__0'; + checks_0 := '"source_file_id" = 0'; + ELSE + IF NEW."source_file_id" > 0 THEN + match_0 := ((NEW."source_file_id" - 1) / 1) * 1 + 1; + tablename_0 := '__' || match_0 || '_' || (match_0 + 1) - 1; + ELSE + match_0 := FLOOR(NEW."source_file_id" :: FLOAT / 1 :: FLOAT) * 1; + tablename_0 := '__m' || ABS(match_0) || '_m' || ABS((match_0 + 1) - 1); + END IF; + checks_0 := '"source_file_id" >= ' || match_0 || ' AND "source_file_id" <= ' || (match_0 + 1) - 1; + END IF; + END IF; + match_1 := DATE_TRUNC('month', NEW."date"); + tablename_1 := '__' || TO_CHAR(NEW."date", '"y"YYYY"m"MM'); + checks_1 := '"date" >= ''' || match_1 || ''' AND "date" < ''' || (match_1 + INTERVAL '1 month') || ''''; + tablename := 'shipping_volume_predictedvolume' || tablename_0 || tablename_1; + checks := checks_0 || ' AND ' || checks_1; + + BEGIN + EXECUTE 'CREATE TABLE IF NOT EXISTS ' || tablename || ' ( + CHECK (' || checks || '), + LIKE "shipping_volume_predictedvolume" INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING INDEXES + ) INHERITS ("shipping_volume_predictedvolume");'; + EXCEPTION WHEN duplicate_table THEN + -- pass + END; + + EXECUTE 'INSERT INTO ' || tablename || ' VALUES (($1).*);' USING NEW; + RETURN NEW; + END; + $$ LANGUAGE plpgsql SECURITY DEFINER; + + -- Then we create a trigger which calls the before insert function + DO $$ + BEGIN + IF NOT EXISTS( + SELECT 1 + FROM information_schema.triggers + WHERE event_object_table = 'shipping_volume_predictedvolume' + AND trigger_name = LOWER('before_insert_shipping_volume_predictedvolume_trigger') + ) THEN + CREATE TRIGGER before_insert_shipping_volume_predictedvolume_trigger + BEFORE INSERT ON "shipping_volume_predictedvolume" + FOR EACH ROW EXECUTE PROCEDURE shipping_volume_predictedvolume_insert_child(); + END IF; + END $$; + + -- Then we create a function to delete duplicate row from the master table after insert + CREATE OR REPLACE FUNCTION shipping_volume_predictedvolume_delete_master() + RETURNS TRIGGER AS $$ + BEGIN + DELETE FROM ONLY "shipping_volume_predictedvolume" WHERE id = NEW.id; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + -- Lastly we create the after insert trigger that calls the after insert function + DO $$ + BEGIN + IF NOT EXISTS( + SELECT 1 + FROM information_schema.triggers + WHERE event_object_table = 'shipping_volume_predictedvolume' + AND trigger_name = LOWER('after_insert_shipping_volume_predictedvolume_trigger') + ) THEN + CREATE TRIGGER after_insert_shipping_volume_predictedvolume_trigger + AFTER INSERT ON "shipping_volume_predictedvolume" + FOR EACH ROW EXECUTE PROCEDURE shipping_volume_predictedvolume_delete_master(); + END IF; + END $$; + """ + assert command_str == target_command_str