├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── pgpart ├── __init__.py ├── cli.py ├── listp.py └── rangep.py ├── requirements ├── common.txt ├── development.txt └── test.txt ├── setup.cfg ├── setup.py └── tests └── test_range.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | [report] 5 | show_missing = False 6 | ignore_errors = True 7 | omit = 8 | setup.py 9 | exclude_lines = 10 | pragma: no cover 11 | def __repr__ 12 | if self\.debug 13 | raise AssertionError 14 | raise NotImplementedError 15 | if 0: 16 | if __name__ == .__main__.: 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5.0b3" 6 | - "3.5-dev" 7 | sudo: false 8 | # command to install dependencies 9 | cache: 10 | directories: 11 | - $HOME/.cache/pip 12 | install: 13 | - pip install pip --upgrade 14 | - pip install -r requirements/development.txt 15 | - pip install codecov 16 | - pip install -e . 17 | # command to run tests 18 | script: py.test -v tests pgpart 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Akira Chiku 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | 4 | recursive-include tests * 5 | recursive-include requirements * 6 | recursive-exclude * __pycache__ 7 | recursive-exclude htmlcov * 8 | recursive-exclude * *.py[co] 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pgpart 2 | 3 | [![Build Status](https://travis-ci.org/achiku/pgpart.svg?branch=master)](https://travis-ci.org/achiku/pgpart) 4 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/achiku/pgpart/master/LICENSE) 5 | 6 | ## Description 7 | 8 | Creating PostgreSQL partitioned table DDLs should be easier. 9 | 10 | 11 | ## Why created 12 | 13 | Unlike MySQL and Oracle, PostgreSQL uses table inheritance and triggers (or rules) to realize horizontal table partitioning. Manually writing child tables with check constraints, and triggers with bunch of if-else statements is boring, time cunsuming, and error-prone. This tool generates child tables/trigger DDLs with given parent table name, partition key name, and time range, so that developers don't have to spend too much time on writing/checking partitioning DDLs. 14 | 15 | 16 | ## Installation 17 | 18 | ``` 19 | pip install pgpart 20 | ``` 21 | 22 | ## Usage 23 | 24 | ##### Create/Drop Monthly Range Partition 25 | 26 | ``` 27 | $ pgpart rangep create --parent-name sale --partition-key sold_at --start-month 201608 --end-month 201611 28 | ``` 29 | 30 | ```sql 31 | CREATE TABLE sale_201608 ( 32 | CHECK (sold_at >= '2016-08-01' AND sold_at < '2016-09-01') 33 | ) INHERITS (sale); 34 | 35 | CREATE TABLE sale_201609 ( 36 | CHECK (sold_at >= '2016-09-01' AND sold_at < '2016-10-01') 37 | ) INHERITS (sale); 38 | 39 | CREATE TABLE sale_201610 ( 40 | CHECK (sold_at >= '2016-10-01' AND sold_at < '2016-11-01') 41 | ) INHERITS (sale); 42 | 43 | CREATE OR REPLACE FUNCTION sale_insert_trigger() 44 | RETURNS TRIGGER AS $$ 45 | 46 | BEGIN 47 | IF (NEW.sold_at >= '2016-08-01' AND NEW.sold_at < '2016-09-01') THEN 48 | INSERT INTO sale_201608 VALUES (NEW.*); 49 | ELSIF (NEW.sold_at >= '2016-09-01' AND NEW.sold_at < '2016-10-01') THEN 50 | INSERT INTO sale_201609 VALUES (NEW.*); 51 | ELSIF (NEW.sold_at >= '2016-10-01' AND NEW.sold_at < '2016-11-01') THEN 52 | INSERT INTO sale_201610 VALUES (NEW.*); 53 | ELSE 54 | RAISE EXCEPTION 'Date out of range. Fix the sale_insert_trigger() function.'; 55 | END IF; 56 | 57 | RETURN NULL; 58 | END; 59 | $$ 60 | LANGUAGE plpgsql; 61 | 62 | CREATE TRIGGER insert_sale_trigger 63 | BEFORE INSERT ON sale 64 | FOR EACH ROW EXECUTE PROCEDURE sale_insert_trigger(); 65 | ``` 66 | 67 | ``` 68 | $ pgpart rangep drop --parent-name sale --partition-key sold_at --start-month 201608 --end-month 201611 69 | ``` 70 | 71 | ```sql 72 | DROP TABLE sale_201608 ; 73 | 74 | DROP TABLE sale_201609 ; 75 | 76 | DROP TABLE sale_201610 ; 77 | 78 | DROP TRIGGER insert_sale_trigger ON sale ; 79 | 80 | DROP FUNCTION sale_insert_trigger() ; 81 | ``` 82 | 83 | 84 | ##### Create/Drop Yearly Range Partition 85 | 86 | - not implemented yet 87 | 88 | 89 | ##### Create/Drop Daily Range Partition 90 | 91 | - not implemented yet 92 | 93 | 94 | ##### Create/Drop List Partition 95 | 96 | - not implemented yet 97 | -------------------------------------------------------------------------------- /pgpart/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """pgpart module""" 3 | 4 | __version__ = '0.1.0' 5 | -------------------------------------------------------------------------------- /pgpart/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import click 3 | 4 | from . import __version__ 5 | 6 | 7 | class PgpartCLI(click.MultiCommand): 8 | 9 | """Jangle CLI main class""" 10 | 11 | def list_commands(self, ctx): 12 | """return available modules""" 13 | return ['rangep', 'listp'] 14 | 15 | def get_command(self, ctx, name): 16 | """get command""" 17 | try: 18 | mod = __import__('pgpart.' + name, None, None, ['cli']) 19 | return mod.cli 20 | except ImportError: 21 | pass 22 | 23 | 24 | cli = PgpartCLI(help="Generate PostgreSQL partitioning DDL (v{0})".format(__version__)) 25 | 26 | 27 | if __name__ == '__main__': 28 | cli() 29 | -------------------------------------------------------------------------------- /pgpart/listp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import click 3 | 4 | 5 | @click.group() 6 | def cli(): 7 | """List partitioning CLI group""" 8 | pass 9 | -------------------------------------------------------------------------------- /pgpart/rangep.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | 4 | import click 5 | from dateutil import relativedelta 6 | 7 | 8 | @click.group() 9 | def cli(): 10 | """Range partitioning CLI group""" 11 | pass 12 | 13 | 14 | def validate_month(ctx, param, value): 15 | try: 16 | dt = datetime.strptime(value+"01", "%Y%m%d") 17 | return dt 18 | except ValueError: 19 | raise click.BadParameter('month need to be in format YYYYMM') 20 | 21 | 22 | @cli.command(help='Create monthly range partition DDL') 23 | @click.option('--parent-name', '-n', required=True, help='parent table name') 24 | @click.option('--partition-key', '-k', required=True, help='partition key column name') 25 | @click.option('--start-month', '-s', 26 | required=True, callback=validate_month, help='monthly range partition start date (YYYYMM)') 27 | @click.option('--end-month', '-e', 28 | required=True, callback=validate_month, help='monthly range partition end date (YYYYMM)') 29 | def create(parent_name, partition_key, start_month, end_month): 30 | """Generate monthly range partition DDL""" 31 | duration = month_timerange(start_month, end_month) 32 | table_ddls = generate_partitioned_table_ddl(parent_name, partition_key, duration) 33 | trigger_ddl = generate_trigger(parent_name, partition_key, duration) 34 | 35 | click.echo('\n'.join(table_ddls)) 36 | click.echo(trigger_ddl) 37 | click.echo(create_trigger_tmpl.format(parent_name=parent_name)) 38 | 39 | 40 | @cli.command(help='Drop monthly range partition DDL') 41 | @click.option('--parent-name', '-n', required=True, help='parent table name') 42 | @click.option('--partition-key', '-k', required=True, help='partition key column name') 43 | @click.option('--start-month', '-s', 44 | required=True, callback=validate_month, help='monthly range partition start date (YYYYMM)') 45 | @click.option('--end-month', '-e', 46 | required=True, callback=validate_month, help='monthly range partition end date (YYYYMM)') 47 | def drop(parent_name, partition_key, start_month, end_month): 48 | duration = month_timerange(start_month, end_month) 49 | for d in duration: 50 | click.echo(drop_table_tmpl.format( 51 | parent_name=parent_name, year_month='{:%Y%m}'.format(d['start']))) 52 | click.echo(drop_trigger_tmpl.format(parent_name=parent_name)) 53 | click.echo(drop_function_tmpl.format(parent_name=parent_name)) 54 | 55 | 56 | drop_table_tmpl = """ 57 | DROP TABLE {parent_name}_{year_month} ;""" 58 | 59 | drop_function_tmpl = """ 60 | DROP FUNCTION {parent_name}_insert_trigger() ;""" 61 | 62 | drop_trigger_tmpl = """ 63 | DROP TRIGGER insert_{parent_name}_trigger ON {parent_name} ;""" 64 | 65 | partitioned_table_tmpl = """ 66 | CREATE TABLE {parent_name}_{year_month} ( 67 | CHECK ({partition_key} >= '{start_date}' AND {partition_key} < '{end_date}') 68 | ) INHERITS ({parent_name});""" 69 | 70 | trigger_conditions_tmpl = """ 71 | {ifelse} (NEW.{partition_key} >= '{start_date}' AND NEW.{partition_key} < '{end_date}') THEN 72 | INSERT INTO {parent_name}_{year_month} VALUES (NEW.*);""" 73 | 74 | create_function_for_partitioned_table_tmpl = """ 75 | CREATE OR REPLACE FUNCTION {parent_name}_insert_trigger() 76 | RETURNS TRIGGER AS $$ 77 | 78 | BEGIN{conditions} 79 | ELSE 80 | RAISE EXCEPTION 'Date out of range. Fix the {parent_name}_insert_trigger() function.'; 81 | END IF; 82 | 83 | RETURN NULL; 84 | END; 85 | $$ 86 | LANGUAGE plpgsql;""" 87 | 88 | create_trigger_tmpl = """ 89 | CREATE TRIGGER insert_{parent_name}_trigger 90 | BEFORE INSERT ON {parent_name} 91 | FOR EACH ROW EXECUTE PROCEDURE {parent_name}_insert_trigger();""" 92 | 93 | 94 | def month_timerange(start_date, end_date): 95 | intervals = [] 96 | dt = start_date 97 | while dt != end_date: 98 | nextdt = dt + relativedelta.relativedelta(months=1) 99 | intervals.append({'start': dt, 'end': nextdt}) 100 | dt = nextdt 101 | return intervals 102 | 103 | 104 | def generate_partitioned_table_ddl(parent_name, partition_key, month_range): 105 | ddls = [] 106 | for d in month_range: 107 | ddl = partitioned_table_tmpl.format( 108 | parent_name=parent_name, 109 | partition_key=partition_key, 110 | year_month='{:%Y%m}'.format(d['start']), 111 | start_date='{:%Y-%m-%d}'.format(d['start']), 112 | end_date='{:%Y-%m-%d}'.format(d['end']), 113 | ) 114 | ddls.append(ddl) 115 | return ddls 116 | 117 | 118 | def generate_trigger_conditions(parent_name, partition_key, month_range): 119 | conditions = [] 120 | for i, d in enumerate(month_range): 121 | ifelse = 'IF' if i == 0 else 'ELSIF' 122 | condition = trigger_conditions_tmpl.format( 123 | ifelse=ifelse, 124 | parent_name=parent_name, 125 | partition_key=partition_key, 126 | year_month='{:%Y%m}'.format(d['start']), 127 | start_date='{:%Y-%m-%d}'.format(d['start']), 128 | end_date='{:%Y-%m-%d}'.format(d['end']), 129 | ) 130 | conditions.append(condition) 131 | return conditions 132 | 133 | 134 | def generate_trigger(parent_name, partition_key, month_range): 135 | cond = '' 136 | for c in generate_trigger_conditions(parent_name, partition_key, month_range): 137 | cond = cond + c 138 | return create_function_for_partitioned_table_tmpl.format( 139 | parent_name=parent_name, 140 | conditions=cond, 141 | ) 142 | -------------------------------------------------------------------------------- /requirements/common.txt: -------------------------------------------------------------------------------- 1 | click==6.6 2 | python-dateutil==2.5.3 3 | -------------------------------------------------------------------------------- /requirements/development.txt: -------------------------------------------------------------------------------- 1 | -r common.txt 2 | -r test.txt 3 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | pytest==3.0.2 2 | isort==4.2.5 3 | flake8==3.0.4 4 | pytest-cov==2.3.1 5 | pytest-isort==0.1.0 6 | pytest-pep8==1.0.6 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [isort] 5 | line_length=120 6 | wrap_length=80 7 | indent=' ' 8 | multi_line_output=5 9 | 10 | [pytest] 11 | addopts = --cov=. --cov-report=html --pep8 --isort 12 | norecursedirs = .git requirements env venv 13 | isort_ignore = 14 | */dist/*.py ALL 15 | */pgpart.egg-info/*.py ALL 16 | pep8ignore = 17 | */dist/*.py ALL 18 | */pgpart.egg-info/*.py ALL 19 | pep8maxlinelength = 120 20 | 21 | [metadata] 22 | description-file = README.md 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Generate PostgreSQL partitioned table DDL""" 3 | import os 4 | import re 5 | 6 | from setuptools import find_packages, setup 7 | 8 | 9 | base_dir = os.path.dirname(os.path.abspath(__file__)) 10 | version = '' 11 | with open('pgpart/__init__.py', 'r') as fd: 12 | version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', 13 | fd.read(), re.MULTILINE).group(1) 14 | 15 | if not version: 16 | raise RuntimeError('Cannot find version information') 17 | 18 | with open(os.path.join(base_dir, 'requirements/common.txt')) as f: 19 | requirements = [r.strip() for r in f.readlines()] 20 | 21 | with open(os.path.join(base_dir, 'requirements/test.txt')) as f: 22 | test_requirements = [r.strip() for r in f.readlines()] 23 | 24 | setup( 25 | name='pgpart', 26 | version=version, 27 | url='https://github.com/achiku/pgpart', 28 | license='MIT', 29 | author='Akira Chiku', 30 | author_email='akira.chiku@gmail.com', 31 | description='Generate PostgreSQL partitioned table DDL', 32 | long_description=__doc__, 33 | packages=find_packages(exclude=['tests']), 34 | include_package_data=True, 35 | zip_safe=False, 36 | platforms='any', 37 | install_requires=requirements, 38 | entry_points={ 39 | 'console_scripts': [ 40 | 'pgpart = pgpart.cli:cli', 41 | ], 42 | }, 43 | classifiers=[ 44 | # As from http://pypi.python.org/pypi?%3Aaction=list_classifiers 45 | 'Development Status :: 1 - Planning', 46 | # 'Development Status :: 2 - Pre-Alpha', 47 | # 'Development Status :: 3 - Alpha', 48 | # 'Development Status :: 4 - Beta', 49 | # 'Development Status :: 5 - Production/Stable', 50 | # 'Development Status :: 6 - Mature', 51 | # 'Development Status :: 7 - Inactive', 52 | 'Environment :: Console', 53 | 'Intended Audience :: Developers', 54 | 'License :: OSI Approved :: MIT License', 55 | 'Operating System :: POSIX', 56 | 'Operating System :: MacOS', 57 | 'Operating System :: Unix', 58 | # 'Operating System :: Windows', 59 | # 'Programming Language :: Python', 60 | 'Programming Language :: Python :: 2.7', 61 | 'Programming Language :: Python :: 3.4', 62 | 'Programming Language :: Python :: 3.5', 63 | 'Topic :: Software Development :: Libraries :: Python Modules', 64 | ], 65 | test_suite='tests', 66 | tests_require=test_requirements 67 | ) 68 | -------------------------------------------------------------------------------- /tests/test_range.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | 4 | import pytest 5 | 6 | 7 | @pytest.mark.parametrize('start, end, expected', [ 8 | (datetime(2016, 11, 1), datetime(2016, 11, 1), []), 9 | ( 10 | datetime(2016, 10, 1), datetime(2016, 12, 1), 11 | [ 12 | {'start': datetime(2016, 10, 1), 'end': datetime(2016, 11, 1)}, 13 | {'start': datetime(2016, 11, 1), 'end': datetime(2016, 12, 1)}, 14 | ] 15 | ), 16 | ( 17 | datetime(2016, 11, 1), datetime(2017, 2, 1), 18 | [ 19 | {'start': datetime(2016, 11, 1), 'end': datetime(2016, 12, 1)}, 20 | {'start': datetime(2016, 12, 1), 'end': datetime(2017, 1, 1)}, 21 | {'start': datetime(2017, 1, 1), 'end': datetime(2017, 2, 1)}, 22 | ] 23 | ), 24 | ]) 25 | def test_month_timerange(start, end, expected): 26 | from pgpart.rangep import month_timerange 27 | l = month_timerange(start, end) 28 | assert l == expected 29 | 30 | 31 | def test_generate_partitioned_table_ddl(): 32 | from pgpart.rangep import generate_partitioned_table_ddl 33 | parent_name = 'sale' 34 | partition_key = 'sold_at' 35 | month_range = [ 36 | {'start': datetime(2016, 11, 1), 'end': datetime(2016, 12, 1)}, 37 | ] 38 | ddls = generate_partitioned_table_ddl(parent_name, partition_key, month_range) 39 | s = """ 40 | CREATE TABLE sale_201611 ( 41 | CHECK (sold_at >= '2016-11-01' AND sold_at < '2016-12-01') 42 | ) INHERITS (sale);""" 43 | 44 | assert len(ddls) == 1 45 | assert ddls[0] == s 46 | 47 | 48 | def test_generate_trigger_condition(): 49 | from pgpart.rangep import generate_trigger_conditions 50 | parent_name = 'sale' 51 | partition_key = 'sold_at' 52 | month_range = [ 53 | {'start': datetime(2016, 11, 1), 'end': datetime(2016, 12, 1)}, 54 | {'start': datetime(2016, 12, 1), 'end': datetime(2017, 1, 1)}, 55 | ] 56 | conditions = generate_trigger_conditions(parent_name, partition_key, month_range) 57 | s = """ 58 | IF (NEW.sold_at >= '2016-11-01' AND NEW.sold_at < '2016-12-01') THEN 59 | INSERT INTO sale_201611 VALUES (NEW.*); 60 | 61 | ELSIF (NEW.sold_at >= '2016-12-01' AND NEW.sold_at < '2017-01-01') THEN 62 | INSERT INTO sale_201612 VALUES (NEW.*);""" 63 | 64 | assert len(conditions) == 2 65 | assert '\n'.join(conditions) == s 66 | 67 | 68 | def test_generate_trigger(): 69 | from pgpart.rangep import generate_trigger 70 | parent_name = 'sale' 71 | partition_key = 'sold_at' 72 | month_range = [ 73 | {'start': datetime(2016, 11, 1), 'end': datetime(2016, 12, 1)}, 74 | {'start': datetime(2016, 12, 1), 'end': datetime(2017, 1, 1)}, 75 | ] 76 | ddl = generate_trigger(parent_name, partition_key, month_range) 77 | s = """ 78 | CREATE OR REPLACE FUNCTION sale_insert_trigger() 79 | RETURNS TRIGGER AS $$ 80 | 81 | BEGIN 82 | IF (NEW.sold_at >= '2016-11-01' AND NEW.sold_at < '2016-12-01') THEN 83 | INSERT INTO sale_201611 VALUES (NEW.*); 84 | ELSIF (NEW.sold_at >= '2016-12-01' AND NEW.sold_at < '2017-01-01') THEN 85 | INSERT INTO sale_201612 VALUES (NEW.*); 86 | ELSE 87 | RAISE EXCEPTION 'Date out of range. Fix the sale_insert_trigger() function.'; 88 | END IF; 89 | 90 | RETURN NULL; 91 | END; 92 | $$ 93 | LANGUAGE plpgsql;""" 94 | 95 | assert ddl == s 96 | --------------------------------------------------------------------------------