Add Exclude constraint
issue1422 review44061002
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
* Add Exclude constraint
|
||||
* Allow to perform unaccented searches on Char fields
|
||||
* Always raise exception in table handler
|
||||
* Simplify API for Session: new, remove, check and reset
|
||||
|
||||
@@ -527,6 +527,37 @@ Instance attributes:
|
||||
|
||||
The tuple of SQL Column instances.
|
||||
|
||||
.. attribute:: Unique.operators
|
||||
|
||||
The tuple of `Equal` operators.
|
||||
|
||||
Exclude
|
||||
-------
|
||||
|
||||
.. class:: Exclude(table[, (expression, operator), ...[, where]])
|
||||
|
||||
It represents an exclude :class:`Constraint` which guarantees that if any two
|
||||
rows are compared on the specified expression using the specified operator not
|
||||
all of these comparisons will return `TRUE`.
|
||||
|
||||
Instance attributes:
|
||||
|
||||
.. attribute:: Exclude.excludes
|
||||
|
||||
The tuple of expression and operator.
|
||||
|
||||
.. attribute:: Exclude.columns
|
||||
|
||||
The tuple of expressions.
|
||||
|
||||
.. attribute:: Exclude.operators
|
||||
|
||||
The tuple of operators.
|
||||
|
||||
.. attribute:: Exclude.where
|
||||
|
||||
The clause for which the exclusion applies.
|
||||
|
||||
========
|
||||
Workflow
|
||||
========
|
||||
|
||||
@@ -135,7 +135,7 @@ class DatabaseInterface(object):
|
||||
'''
|
||||
raise NotImplementedError
|
||||
|
||||
def has_constraint(self):
|
||||
def has_constraint(self, constraint):
|
||||
'''
|
||||
Return True if database handle constraint.
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ class Database(DatabaseInterface):
|
||||
cursor = connection.cursor()
|
||||
cursor.execute('LOCK "%s" IN EXCLUSIVE MODE NOWAIT' % table)
|
||||
|
||||
def has_constraint(self):
|
||||
def has_constraint(self, constraint):
|
||||
return True
|
||||
|
||||
def has_multirow_insert(self):
|
||||
|
||||
@@ -410,7 +410,7 @@ class Database(DatabaseInterface):
|
||||
def lock(self, connection, table):
|
||||
pass
|
||||
|
||||
def has_constraint(self):
|
||||
def has_constraint(self, constraint):
|
||||
return False
|
||||
|
||||
def has_multirow_insert(self):
|
||||
|
||||
@@ -4,7 +4,7 @@ from .model import Model
|
||||
from .modelview import ModelView
|
||||
from .modelstorage import ModelStorage, EvalEnvironment
|
||||
from .modelsingleton import ModelSingleton
|
||||
from .modelsql import ModelSQL, Check, Unique
|
||||
from .modelsql import ModelSQL, Check, Unique, Exclude
|
||||
from .workflow import Workflow
|
||||
from .dictschema import DictSchemaMixin
|
||||
from .match import MatchMixin
|
||||
@@ -14,7 +14,7 @@ from .descriptors import dualmethod
|
||||
from .order import sequence_ordered
|
||||
|
||||
__all__ = ['Model', 'ModelView', 'ModelStorage', 'ModelSingleton', 'ModelSQL',
|
||||
'Check', 'Unique',
|
||||
'Check', 'Unique', 'Exclude',
|
||||
'Workflow', 'DictSchemaMixin', 'MatchMixin', 'UnionMixin', 'dualmethod',
|
||||
'MultiValueMixin', 'ValueMixin',
|
||||
'EvalEnvironment', 'sequence_ordered']
|
||||
|
||||
@@ -8,7 +8,7 @@ from sql import (Table, Column, Literal, Desc, Asc, Expression, Null,
|
||||
NullsFirst, NullsLast)
|
||||
from sql.functions import CurrentTimestamp, Extract
|
||||
from sql.conditionals import Coalesce
|
||||
from sql.operators import Or, And, Operator
|
||||
from sql.operators import Or, And, Operator, Equal
|
||||
from sql.aggregate import Count, Max
|
||||
|
||||
from trytond.model import ModelStorage, ModelView
|
||||
@@ -77,6 +77,10 @@ class Unique(Constraint):
|
||||
def columns(self):
|
||||
return self._columns
|
||||
|
||||
@property
|
||||
def operators(self):
|
||||
return tuple(Equal for c in self._columns)
|
||||
|
||||
def __str__(self):
|
||||
return 'UNIQUE(%s)' % (', '.join(map(str, self.columns)))
|
||||
|
||||
@@ -88,6 +92,53 @@ class Unique(Constraint):
|
||||
return tuple(p)
|
||||
|
||||
|
||||
class Exclude(Constraint):
|
||||
__slots__ = ('_excludes', '_where')
|
||||
|
||||
def __init__(self, table, *excludes, **kwargs):
|
||||
super(Exclude, self).__init__(table)
|
||||
assert all(isinstance(c, Expression) and issubclass(o, Operator)
|
||||
for c, o in excludes), excludes
|
||||
self._excludes = tuple(excludes)
|
||||
where = kwargs.get('where')
|
||||
if where is not None:
|
||||
assert isinstance(where, Expression)
|
||||
self._where = where
|
||||
|
||||
@property
|
||||
def excludes(self):
|
||||
return self._excludes
|
||||
|
||||
@property
|
||||
def columns(self):
|
||||
return tuple(c for c, _ in self._excludes)
|
||||
|
||||
@property
|
||||
def operators(self):
|
||||
return tuple(o for _, o in self._excludes)
|
||||
|
||||
@property
|
||||
def where(self):
|
||||
return self._where
|
||||
|
||||
def __str__(self):
|
||||
exclude = ', '.join('%s WITH %s' % (column, operator._operator)
|
||||
for column, operator in self.excludes)
|
||||
where = ''
|
||||
if self.where:
|
||||
where = ' WHERE ' + str(self.where)
|
||||
return 'EXCLUDE (%s)' % exclude + where
|
||||
|
||||
@property
|
||||
def params(self):
|
||||
p = []
|
||||
for column, operator in self._excludes:
|
||||
p.extend(column.params)
|
||||
if self.where:
|
||||
p.extend(self.where.params)
|
||||
return tuple(p)
|
||||
|
||||
|
||||
class ModelSQL(ModelStorage):
|
||||
"""
|
||||
Define a model with storage in database.
|
||||
@@ -1451,30 +1502,38 @@ class ModelSQL(ModelStorage):
|
||||
def validate(cls, records):
|
||||
super(ModelSQL, cls).validate(records)
|
||||
transaction = Transaction()
|
||||
database = transaction.database
|
||||
connection = transaction.connection
|
||||
has_constraint = database.has_constraint
|
||||
lock = database.lock
|
||||
cursor = transaction.connection.cursor()
|
||||
if transaction.database.has_constraint():
|
||||
return
|
||||
# Works only for a single transaction
|
||||
ids = map(int, records)
|
||||
for _, sql, error in cls._sql_constraints:
|
||||
if has_constraint(sql):
|
||||
continue
|
||||
table = sql.table
|
||||
if isinstance(sql, Unique):
|
||||
columns = [Column(table, c.name) for c in sql.columns]
|
||||
if isinstance(sql, (Unique, Exclude)):
|
||||
lock(connection, cls._table)
|
||||
columns = list(sql.columns)
|
||||
columns.insert(0, table.id)
|
||||
in_max = transaction.database.IN_MAX // (len(columns) + 1)
|
||||
for sub_ids in grouped_slice(ids, in_max):
|
||||
red_sql = reduce_ids(table.id, sub_ids)
|
||||
where = reduce_ids(table.id, sub_ids)
|
||||
if isinstance(sql, Exclude):
|
||||
where &= sql.where
|
||||
|
||||
cursor.execute(*table.select(*columns, where=red_sql))
|
||||
cursor.execute(*table.select(*columns, where=where))
|
||||
|
||||
where = Literal(False)
|
||||
for row in cursor.fetchall():
|
||||
clause = table.id != row[0]
|
||||
for column, value in zip(sql.columns, row[1:]):
|
||||
for column, operator, value in zip(
|
||||
sql.columns, sql.operators, row[1:]):
|
||||
if value is None:
|
||||
# NULL is always unique
|
||||
clause &= Literal(False)
|
||||
clause &= Column(table, column.name) == value
|
||||
clause &= operator(column, value)
|
||||
where |= clause
|
||||
cursor.execute(
|
||||
*table.select(table.id, where=where, limit=1))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
from trytond.model import ModelSQL, fields
|
||||
from sql.operators import Equal
|
||||
|
||||
from trytond.model import ModelSQL, fields, Check, Unique, Exclude
|
||||
from trytond.pool import Pool
|
||||
|
||||
|
||||
@@ -58,6 +60,50 @@ class ModelTranslation(ModelSQL):
|
||||
name = fields.Char("Name", translate=True)
|
||||
|
||||
|
||||
class ModelCheck(ModelSQL):
|
||||
"ModelSQL with check constraint"
|
||||
__name__ = 'test.modelsql.check'
|
||||
value = fields.Integer("Value")
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super(ModelCheck, cls).__setup__()
|
||||
t = cls.__table__()
|
||||
cls._sql_constraints = [
|
||||
('check', Check(t, (t.value > 42)),
|
||||
"Value must be greater than 42."),
|
||||
]
|
||||
|
||||
|
||||
class ModelUnique(ModelSQL):
|
||||
"ModelSQL with unique constraint"
|
||||
__name__ = 'test.modelsql.unique'
|
||||
value = fields.Integer("Value")
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super(ModelUnique, cls).__setup__()
|
||||
t = cls.__table__()
|
||||
cls._sql_constraints = [
|
||||
('unique', Unique(t, t.value), "Value must be unique."),
|
||||
]
|
||||
|
||||
|
||||
class ModelExclude(ModelSQL):
|
||||
"ModelSQL with exclude constraint"
|
||||
__name__ = 'test.modelsql.exclude'
|
||||
value = fields.Integer("Value")
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super(ModelExclude, cls).__setup__()
|
||||
t = cls.__table__()
|
||||
cls._sql_constraints = [
|
||||
('exclude', Exclude(t, (t.value, Equal), where=t.value > 0),
|
||||
"Value must be unique."),
|
||||
]
|
||||
|
||||
|
||||
def register(module):
|
||||
Pool.register(
|
||||
ModelSQLRequiredField,
|
||||
@@ -67,4 +113,7 @@ def register(module):
|
||||
ModelSQLOne2ManyTarget,
|
||||
NullOrder,
|
||||
ModelTranslation,
|
||||
ModelCheck,
|
||||
ModelUnique,
|
||||
ModelExclude,
|
||||
module=module, type_='model')
|
||||
|
||||
@@ -173,6 +173,93 @@ class ModelSQLTestCase(unittest.TestCase):
|
||||
delete_ids.assert_called_with(
|
||||
'test.modelsql.translation', 'model', [record.id])
|
||||
|
||||
@with_transaction()
|
||||
def test_constraint_check(self):
|
||||
"Test check constraint"
|
||||
pool = Pool()
|
||||
Model = pool.get('test.modelsql.check')
|
||||
|
||||
record, = Model.create([{'value': 50}])
|
||||
|
||||
self.assertTrue(record.id)
|
||||
|
||||
@with_transaction()
|
||||
def test_constraint_check_null(self):
|
||||
"Test check constraint with null"
|
||||
pool = Pool()
|
||||
Model = pool.get('test.modelsql.check')
|
||||
|
||||
record, = Model.create([{'value': None}])
|
||||
|
||||
self.assertTrue(record.id)
|
||||
|
||||
@with_transaction()
|
||||
def test_constraint_check_invalid(self):
|
||||
"Test invalid check constraint"
|
||||
pool = Pool()
|
||||
Model = pool.get('test.modelsql.check')
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
Model.create([{'value': 10}])
|
||||
|
||||
@with_transaction()
|
||||
def test_constraint_unique(self):
|
||||
"Test unique constraint"
|
||||
pool = Pool()
|
||||
Model = pool.get('test.modelsql.unique')
|
||||
|
||||
records = Model.create([{'value': 1}, {'value': 2}])
|
||||
|
||||
self.assertEqual(len(records), 2)
|
||||
|
||||
@with_transaction()
|
||||
def test_constraint_unique_null(self):
|
||||
"Test unique constraint with null"
|
||||
pool = Pool()
|
||||
Model = pool.get('test.modelsql.unique')
|
||||
|
||||
records = Model.create([{'value': None}, {'value': None}])
|
||||
|
||||
self.assertEqual(len(records), 2)
|
||||
|
||||
@with_transaction()
|
||||
def test_constraint_unique_invalid(self):
|
||||
"Test invalid unique constraint"
|
||||
pool = Pool()
|
||||
Model = pool.get('test.modelsql.unique')
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
Model.create([{'value': 42}, {'value': 42}])
|
||||
|
||||
@with_transaction()
|
||||
def test_constraint_exclude(self):
|
||||
"Test exclude constraint"
|
||||
pool = Pool()
|
||||
Model = pool.get('test.modelsql.exclude')
|
||||
|
||||
records = Model.create([{'value': 1}, {'value': 2}])
|
||||
|
||||
self.assertEqual(len(records), 2)
|
||||
|
||||
@with_transaction()
|
||||
def test_constraint_exclude_exclusion(self):
|
||||
"Test exclude constraint exclusion"
|
||||
pool = Pool()
|
||||
Model = pool.get('test.modelsql.exclude')
|
||||
|
||||
records = Model.create([{'value': -1}, {'value': -1}])
|
||||
|
||||
self.assertEqual(len(records), 2)
|
||||
|
||||
@with_transaction()
|
||||
def test_constraint_exclude_invalid(self):
|
||||
"Test invalid exclude constraint"
|
||||
pool = Pool()
|
||||
Model = pool.get('test.modelsql.exclude')
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
Model.create([{'value': 42}, {'value': 42}])
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromTestCase(ModelSQLTestCase)
|
||||
|
||||
Reference in New Issue
Block a user