diff --git a/NEWS b/NEWS index 206265a8..eabb8392 100644 --- a/NEWS +++ b/NEWS @@ -7,6 +7,8 @@ What's new in psycopg 2.8 New features: - Added `~psycopg2.extensions.encrypt_password()` function (:ticket:`#576`). +- `~psycopg2.sql.Identifier` can represent qualified names in SQL composition + (:ticket:`#732`). - `!str()` on `~psycopg2.extras.Range` produces a human-readable representation (:ticket:`#773`). - `~psycopg2.extras.DictCursor` and `~psycopg2.extras.RealDictCursor` rows diff --git a/doc/src/sql.rst b/doc/src/sql.rst index fe807c6a..9cd18e7e 100644 --- a/doc/src/sql.rst +++ b/doc/src/sql.rst @@ -77,16 +77,26 @@ to cursor methods such as `~cursor.execute()`, `~cursor.executemany()`, .. autoclass:: Identifier - .. autoattribute:: string + .. versionchanged:: 2.8 + added support for multiple strings. + + .. autoattribute:: strings + + .. versionadded:: 2.8 + previous verions only had a `!string` attribute. The attribute + still exists but is deprecate and will only work if the + `!Identifier` wraps a single string. .. autoclass:: Literal .. autoattribute:: wrapped + .. autoclass:: Placeholder .. autoattribute:: name + .. autoclass:: Composed .. autoattribute:: seq diff --git a/lib/sql.py b/lib/sql.py index 7ba92952..241b8279 100644 --- a/lib/sql.py +++ b/lib/sql.py @@ -290,7 +290,7 @@ class SQL(Composable): class Identifier(Composable): """ - A `Composable` representing an SQL identifer. + A `Composable` representing an SQL identifer or a dot-separated sequence. Identifiers usually represent names of database objects, such as tables or fields. PostgreSQL identifiers follow `different rules`__ than SQL string @@ -307,20 +307,50 @@ class Identifier(Composable): >>> print(sql.SQL(', ').join([t1, t2, t3]).as_string(conn)) "foo", "ba'r", "ba""z" - """ - def __init__(self, string): - if not isinstance(string, string_types): - raise TypeError("SQL identifiers must be strings") + Multiple strings can be passed to the object to represent a qualified name, + i.e. a dot-separated sequence of identifiers. - super(Identifier, self).__init__(string) + Example:: + + >>> query = sql.SQL("select {} from {}").format( + ... sql.Identifier("table", "field"), + ... sql.Identifier("schema", "table")) + >>> print(query.as_string(conn)) + select "table"."field" from "schema"."table" + + """ + def __init__(self, *strings): + if not strings: + raise TypeError("Identifier cannot be empty") + + for s in strings: + if not isinstance(s, string_types): + raise TypeError("SQL identifier parts must be strings") + + super(Identifier, self).__init__(strings) + + @property + def strings(self): + """A tuple with the strings wrapped by the `Identifier`.""" + return self._wrapped @property def string(self): - """The string wrapped by the `Identifier`.""" - return self._wrapped + """The string wrapped by the `Identifier`. + """ + if len(self._wrapped) == 1: + return self._wrapped[0] + else: + raise AttributeError( + "the Identifier wraps more than one than one string") + + def __repr__(self): + return "%s(%s)" % ( + self.__class__.__name__, + ', '.join(map(repr, self._wrapped))) def as_string(self, context): - return ext.quote_ident(self._wrapped, context) + return '.'.join(ext.quote_ident(s, context) for s in self._wrapped) class Literal(Composable): diff --git a/tests/test_sql.py b/tests/test_sql.py index 81b22a4e..cc9bba2b 100755 --- a/tests/test_sql.py +++ b/tests/test_sql.py @@ -24,9 +24,8 @@ import datetime as dt import unittest -from .testutils import (ConnectingTestCase, - skip_before_postgres, skip_before_python, skip_copy_if_green, - StringIO) +from .testutils import ( + ConnectingTestCase, skip_before_postgres, skip_copy_if_green, StringIO) import psycopg2 from psycopg2 import sql @@ -181,26 +180,43 @@ class IdentifierTests(ConnectingTestCase): def test_init(self): self.assert_(isinstance(sql.Identifier('foo'), sql.Identifier)) self.assert_(isinstance(sql.Identifier(u'foo'), sql.Identifier)) + self.assert_(isinstance(sql.Identifier('foo', 'bar', 'baz'), sql.Identifier)) + self.assertRaises(TypeError, sql.Identifier) self.assertRaises(TypeError, sql.Identifier, 10) self.assertRaises(TypeError, sql.Identifier, dt.date(2016, 12, 31)) - def test_string(self): + def test_strings(self): + self.assertEqual(sql.Identifier('foo').strings, ('foo',)) + self.assertEqual(sql.Identifier('foo', 'bar').strings, ('foo', 'bar')) + + # Legacy method self.assertEqual(sql.Identifier('foo').string, 'foo') + self.assertRaises(AttributeError, + getattr, sql.Identifier('foo', 'bar'), 'string') def test_repr(self): obj = sql.Identifier("fo'o") self.assertEqual(repr(obj), 'Identifier("fo\'o")') self.assertEqual(repr(obj), str(obj)) + obj = sql.Identifier("fo'o", 'ba"r') + self.assertEqual(repr(obj), 'Identifier("fo\'o", \'ba"r\')') + self.assertEqual(repr(obj), str(obj)) + def test_eq(self): self.assert_(sql.Identifier('foo') == sql.Identifier('foo')) + self.assert_(sql.Identifier('foo', 'bar') == sql.Identifier('foo', 'bar')) self.assert_(sql.Identifier('foo') != sql.Identifier('bar')) self.assert_(sql.Identifier('foo') != 'foo') self.assert_(sql.Identifier('foo') != sql.SQL('foo')) def test_as_str(self): - self.assertEqual(sql.Identifier('foo').as_string(self.conn), '"foo"') - self.assertEqual(sql.Identifier("fo'o").as_string(self.conn), '"fo\'o"') + self.assertEqual( + sql.Identifier('foo').as_string(self.conn), '"foo"') + self.assertEqual( + sql.Identifier('foo', 'bar').as_string(self.conn), '"foo"."bar"') + self.assertEqual( + sql.Identifier("fo'o", 'ba"r').as_string(self.conn), '"fo\'o"."ba""r"') def test_join(self): self.assert_(not hasattr(sql.Identifier('foo'), 'join'))