diff --git a/docs/src/piccolo/schema/column_types.rst b/docs/src/piccolo/schema/column_types.rst index bdf2258bf..3758053fa 100644 --- a/docs/src/piccolo/schema/column_types.rst +++ b/docs/src/piccolo/schema/column_types.rst @@ -119,6 +119,12 @@ UUID Text **** +==== +Char +==== + +.. autoclass:: Char + ====== Secret ====== diff --git a/piccolo/apps/playground/commands/run.py b/piccolo/apps/playground/commands/run.py index 129abaf94..1effd6922 100644 --- a/piccolo/apps/playground/commands/run.py +++ b/piccolo/apps/playground/commands/run.py @@ -16,6 +16,7 @@ UUID, Array, Boolean, + Char, Date, ForeignKey, Integer, @@ -73,6 +74,7 @@ class Venue(Table): name = Varchar(length=100) capacity = Integer(default=0) address = Text(null=True) + country_code = Char(length=2) @classmethod def get_readable(cls) -> Readable: @@ -246,7 +248,7 @@ def populate(): c_sharps = Band(name="C-Sharps", popularity=700, manager=anders.id) c_sharps.save().run_sync() - venue = Venue(name="Amazing Venue", capacity=5000) + venue = Venue(name="Amazing Venue", capacity=5000, country_code="GB") venue.save().run_sync() concert = Concert( diff --git a/piccolo/apps/schema/commands/generate.py b/piccolo/apps/schema/commands/generate.py index 5e1785784..d4e94ef16 100644 --- a/piccolo/apps/schema/commands/generate.py +++ b/piccolo/apps/schema/commands/generate.py @@ -22,6 +22,7 @@ BigInt, Boolean, Bytea, + Char, Date, DoublePrecision, ForeignKey, @@ -291,6 +292,7 @@ def __add__(self, value: OutputSchema) -> OutputSchema: "bigint": BigInt, "boolean": Boolean, "bytea": Bytea, + "char": Char, "character varying": Varchar, "date": Date, "integer": Integer, @@ -318,6 +320,7 @@ def __add__(self, value: OutputSchema) -> OutputSchema: Boolean: re.compile(r"^(?Ptrue|false)$"), Bytea: re.compile(r"'(?P.*)'::bytea$"), DoublePrecision: re.compile(r"(?P[+-]?(?:[0-9]*[.])?[0-9]+)"), + Char: re.compile(r"^'(?P.*)'::bpchar$"), Varchar: re.compile(r"^'(?P.*)'::character varying$"), Date: re.compile(r"^(?P(?:\d{4}-\d{2}-\d{2})|CURRENT_DATE)$"), Integer: re.compile(r"^(?P-?\d+)$"), diff --git a/piccolo/columns/__init__.py b/piccolo/columns/__init__.py index 12a258960..8758493ca 100644 --- a/piccolo/columns/__init__.py +++ b/piccolo/columns/__init__.py @@ -8,6 +8,7 @@ BigSerial, Boolean, Bytea, + Char, Date, Decimal, DoublePrecision, @@ -46,6 +47,7 @@ "BigSerial", "Boolean", "Bytea", + "Char", "Date", "Decimal", "DoublePrecision", diff --git a/piccolo/columns/column_types.py b/piccolo/columns/column_types.py index 887129b4b..8b9791143 100644 --- a/piccolo/columns/column_types.py +++ b/piccolo/columns/column_types.py @@ -386,6 +386,48 @@ class Email(Varchar): pass +class Char(Varchar): + """ + Used for storing text when you want to enforce character length limits. + Uses the ``str`` type for values. + + .. hint:: + :class:`Varchar` is generally recommended over :class:`Char`. However, + :class:`Char` may have slight performance advantages, and makes sense + for certain fields like country codes where you know the values will be + an exact length. + + In Postgres the values are 'blank padded'. For example, if the length is 2, + and you store ``'X'``, then you will get ``'X '`` back from the database. + + .. note:: + In SQLite :class:`Char` and :class:`Varchar` behave identically. + + **Example** + + .. code-block:: python + + class Venue(Table): + name = Varchar() + country_code = Char(length=2) + + # Create + >>> await Venue(name='Amazing Venue', country_code='GB').save() + + # Query + >>> await Venue.select(Venue.country_code) + {'country_code': 'GB'} + + :param length: + The maximum number of characters allowed. + + """ + + @property + def column_type(self): + return f"CHAR({self.length})" if self.length else "CHAR" + + class Secret(Varchar): """ This is just an alias to ``Varchar(secret=True)``. It's here for backwards diff --git a/tests/apps/migrations/auto/integration/test_migrations.py b/tests/apps/migrations/auto/integration/test_migrations.py index 656976200..726152ea1 100644 --- a/tests/apps/migrations/auto/integration/test_migrations.py +++ b/tests/apps/migrations/auto/integration/test_migrations.py @@ -31,6 +31,7 @@ BigInt, BigSerial, Boolean, + Char, Date, Decimal, DoublePrecision, @@ -271,6 +272,25 @@ def test_varchar_column(self): ), ) + @engines_skip("cockroach") + def test_char_column(self): + self._test_migrations( + table_snapshots=[ + [self.table(column)] + for column in [ + Char(default="GB", length=2, null=True), + Char(default="GB", length=2, null=False), + ] + ], + test_function=lambda x: all( + [ + x.data_type == "character", + x.is_nullable == "NO", + x.column_default in ("'GB'::bpchar", "'GB':::STRING"), + ] + ), + ) + def test_text_column(self): self._test_migrations( table_snapshots=[ diff --git a/tests/columns/test_char.py b/tests/columns/test_char.py new file mode 100644 index 000000000..00c020091 --- /dev/null +++ b/tests/columns/test_char.py @@ -0,0 +1,37 @@ +from piccolo.columns.column_types import Char +from piccolo.table import Table +from piccolo.testing.test_case import AsyncTableTest +from tests.base import engines_only + + +class Venue(Table): + country_code = Char(length=2) + + +@engines_only("postgres", "cockroach") +class TestChar(AsyncTableTest): + """ + SQLite doesn't enforce any constraints on max character length. + + https://www.sqlite.org/faq.html#q9 + """ + + tables = [Venue] + + async def test_length(self): + row = Venue(country_code="GB") + await row.save() + + with self.assertRaises(Exception): + row.country_code = "XXX" + await row.save() + + async def test_padding(self): + """ + Postgres returns ``Char`` columns padded with blank spaces. + """ + row = Venue(country_code="G") + await row.save() + + await row.refresh() + self.assertEqual(row.country_code, "G ")