Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/src/piccolo/api_reference/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ UUID
~~~~

.. autoclass:: UUID4
:members:

.. autoclass:: UUID7

-------------------------------------------------------------------------------

Expand Down
4 changes: 2 additions & 2 deletions docs/src/piccolo/contributing/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ Make sure the test database exists:
.. code-block:: console

cockroach sql --insecure
>>> create database piccolo
>>> use piccolo
>>> create database piccolo;
>>> use piccolo;

-------------------------------------------------------------------------------

Expand Down
1 change: 1 addition & 0 deletions docs/src/piccolo/tutorials/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ help you solve common problems:
./avoiding_circular_imports
./moving_table_between_apps
./uuid_columns_in_piccolo_1.31.0
./uuid_v7_support.rst
26 changes: 26 additions & 0 deletions docs/src/piccolo/tutorials/uuid_v7_support.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
UUID v7 support
===============

.. note:: CockroachDB does not currently support UUID v7.

With Postgres 18 and above, UUID v7 is natively supported.

For this to work in older versions of Postgres, a function has to be registered
in the database.


The easiest option is to add the following to :class:`PostgresEngine <piccolo.engines.postgres.PostgresEngine>`:

.. code-block:: python

DB = PostgresEngine(
polyfills=['uuid7'],
...
)

Which will try and register a ``uuid7`` function on initialisation.

Alternatives
------------

If you'd rather, you can register an extension such as `pg_uuidv7 <https://github.com/fboulnois/pg_uuidv7>`_.
53 changes: 51 additions & 2 deletions piccolo/columns/defaults/uuid.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from enum import Enum
from typing import Union

from piccolo.utils.uuid import uuid7

from .base import Default


Expand All @@ -17,6 +19,18 @@ class UUID4(Default):

@property
def postgres(self):
"""
Historically we had to use `uuid_generate_v4()` from the `uuid-ossp`
extension.

Since Postgres 13 there is a built-in `gen_random_uuid` function which
generates UUID v4 values.

In Postgres 18, `uuidv4` was added, which is the same as
`gen_random_uuid`, but more precisely named. We will move to this at
some point in the future.

"""
return "gen_random_uuid()"

@property
Expand All @@ -31,7 +45,42 @@ def python(self):
return uuid.uuid4()


UUIDArg = Union[UUID4, uuid.UUID, str, Enum, None, Callable[[], uuid.UUID]]
class UUID7(Default):
@property
def postgres(self):
"""
Supported in Postgres 18 and above.

For older versions. a custom function has to be loaded into the
database (see ``PostgresEngine.polyfills``).

"""
return "uuidv7()"

@property
def cockroach(self):
"""
Unfortunately CockroachDB doesn't current support this.
"""
raise NotImplementedError()
Comment on lines +60 to +65
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if it's a good idea, but since Cockroach doesn't natively support uuidv7, we can generate default values ​​from Python. Something like this

@property
def cockroach(self):
    return QueryString(f"'{uuid7()}'")

I tried it locally with Cockroach and it worked. A valid uuidv7 is generated and saved in the database. Piccolo unittests also pass.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally I'd love to find a script which provides UUID7 for Cockroach. I can't get the Postgres one to work with Cockroach:

ERROR: encode(): set_bit(): set_bit(): unknown signature: overlay(bytes, bytes, int, int)
SQLSTATE: 42883
HINT: No function matches the given name and argument types. You might need to add explicit type casts.

Even though this works:

@property
def cockroach(self):
    return QueryString(f"'{uuid7()}'")

We need a function which generates the UUID within the database itself. For now I think I'll just leave out Cockroach support until they natively support it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never mind, it was just an idea. I haven't found any examples of custom functions that will work in Cockroach, which means it's not that common in Cockroach. You are right that it should be left as it is and wait for the native implementation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's hard with Cockroach because it's mostly Postgres compatible, but when it comes to functions etc it's missing a few.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm tempted to not even support older versions of Postgres and Python with uuid7, just because it relies on other people's code (the uuid7 Python backport, and the Postgres function), which are under different licenses. It becomes a bit of a mess.

So if people want to use it, they just have to use Python 3.14 and Postgres 18.

We'll just make the tests only run for that version of Python and Postgres.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually a good idea and the code will be much simpler. With this we only rely on the Python 3.14 standard library and the native Postgres 18 implementation. That should be emphasized in the documentation.


@property
def sqlite(self):
return "''"

def python(self):
return uuid7()


UUIDArg = Union[
UUID4,
UUID7,
uuid.UUID,
str,
Enum,
None,
Callable[[], uuid.UUID],
]


__all__ = ["UUIDArg", "UUID4"]
__all__ = ["UUIDArg", "UUID4", "UUID7"]
12 changes: 10 additions & 2 deletions piccolo/engine/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import contextvars
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Mapping, Optional, Union
from typing import TYPE_CHECKING, Any, Literal, Mapping, Optional, Union

from typing_extensions import Self

Expand Down Expand Up @@ -352,6 +352,7 @@ class PostgresEngine(Engine[PostgresTransaction]):
"config",
"extensions",
"extra_nodes",
"polyfills",
"pool",
)

Expand All @@ -362,12 +363,14 @@ def __init__(
log_queries: bool = False,
log_responses: bool = False,
extra_nodes: Optional[Mapping[str, PostgresEngine]] = None,
polyfills: list[Literal["uuidv7"]] = [],
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great, really clever. Can you add a description of polyfills to the PostgresEngine docstring?

) -> None:
if extra_nodes is None:
extra_nodes = {}

self.config = config
self.extensions = extensions
self.polyfills = polyfills
self.log_queries = log_queries
self.log_responses = log_responses
self.extra_nodes = extra_nodes
Expand All @@ -380,7 +383,7 @@ def __init__(
engine_type="postgres",
log_queries=log_queries,
log_responses=log_responses,
min_version_number=10,
min_version_number=13,
)

@staticmethod
Expand Down Expand Up @@ -434,6 +437,11 @@ async def prep_database(self):
level=Level.medium,
)

if "uuidv7" in self.polyfills:
from piccolo.utils.uuid import UUID7_DB_POLYFILL

await self._run_in_new_connection(UUID7_DB_POLYFILL)

###########################################################################
# These typos existed in the codebase for a while, so leaving these proxy
# methods for now to ensure backwards compatibility.
Expand Down
29 changes: 29 additions & 0 deletions piccolo/utils/uuid/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
try:
from uuid import uuid7 # type: ignore
except ImportError:
# For version < Python 3.14
from ._uuid_backport import uuid7


# https://github.com/dverite/postgres-uuidv7-sql/blob/396a44433e6e0eb63b1d9d1517e9098256d97351/sql/uuidv7-sql--1.0.sql#L6-L19
UUID7_DB_POLYFILL = """
CREATE OR REPLACE FUNCTION uuidv7(
timestamptz DEFAULT clock_timestamp()
) RETURNS uuid
AS $$
-- Replace the first 48 bits of a uuidv4 with the current
-- number of milliseconds since 1970-01-01 UTC
-- and set the "ver" field to 7 by setting additional bits
select encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid()) placing
substring(int8send((extract(epoch from $1)*1000)::bigint) from 3)
from 1 for 6),
52, 1),
53, 1), 'hex')::uuid;
$$ LANGUAGE sql volatile parallel safe;
"""


__all__ = ("uuid7", "UUID7_DB_POLYFILL")
95 changes: 95 additions & 0 deletions piccolo/utils/uuid/_uuid_backport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# flake8: noqa

r"""
Backport of uuid7 from Python 3.14 - we highly recommend using Python 3.14 if
using uuid7. This is here as a convenience for testing.

Original author: Ka-Ping Yee <ping@zesty.ca>
"""

import os
import time
from uuid import UUID, SafeUUID

_UINT_128_MAX = (1 << 128) - 1
# RFC 4122 variant bits and version bits to activate on a UUID integral value.
_RFC_4122_VERSION_7_FLAGS = (7 << 76) | (0x8000 << 48)


_last_timestamp_v7 = None
_last_counter_v7 = 0 # 42-bit counter


def _uuid7_get_counter_and_tail():
rand = int.from_bytes(os.urandom(10), byteorder="big")
# 42-bit counter with MSB set to 0
counter = (rand >> 32) & 0x1FF_FFFF_FFFF
# 32-bit random data
tail = rand & 0xFFFF_FFFF
return counter, tail


def uuid7():
"""Generate a UUID from a Unix timestamp in milliseconds and random bits.

UUIDv7 objects feature monotonicity within a millisecond.
"""
# --- 48 --- -- 4 -- --- 12 --- -- 2 -- --- 30 --- - 32 -
# unix_ts_ms | version | counter_hi | variant | counter_lo | random
#
# 'counter = counter_hi | counter_lo' is a 42-bit counter constructed
# with Method 1 of RFC 9562, §6.2, and its MSB is set to 0.
#
# 'random' is a 32-bit random value regenerated for every new UUID.
#
# If multiple UUIDs are generated within the same millisecond, the LSB
# of 'counter' is incremented by 1. When overflowing, the timestamp is
# advanced and the counter is reset to a random 42-bit integer with MSB
# set to 0.

global _last_timestamp_v7
global _last_counter_v7

nanoseconds = time.time_ns()
timestamp_ms = nanoseconds // 1_000_000

if _last_timestamp_v7 is None or timestamp_ms > _last_timestamp_v7:
counter, tail = _uuid7_get_counter_and_tail()
else:
if timestamp_ms < _last_timestamp_v7:
timestamp_ms = _last_timestamp_v7 + 1
# advance the 42-bit counter
counter = _last_counter_v7 + 1
if counter > 0x3FF_FFFF_FFFF:
# advance the 48-bit timestamp
timestamp_ms += 1
counter, tail = _uuid7_get_counter_and_tail()
else:
# 32-bit random data
tail = int.from_bytes(os.urandom(4))

unix_ts_ms = timestamp_ms & 0xFFFF_FFFF_FFFF
counter_msbs = counter >> 30
# keep 12 counter's MSBs and clear variant bits
counter_hi = counter_msbs & 0x0FFF
# keep 30 counter's LSBs and clear version bits
counter_lo = counter & 0x3FFF_FFFF
# ensure that the tail is always a 32-bit integer (by construction,
# it is already the case, but future interfaces may allow the user
# to specify the random tail)
tail &= 0xFFFF_FFFF

int_uuid_7 = unix_ts_ms << 80
int_uuid_7 |= counter_hi << 64
int_uuid_7 |= counter_lo << 32
int_uuid_7 |= tail
# by construction, the variant and version bits are already cleared
int_uuid_7 |= _RFC_4122_VERSION_7_FLAGS

assert 0 <= int_uuid_7 <= _UINT_128_MAX, repr(int_uuid_7)
res = UUID(int=int_uuid_7, is_safe=SafeUUID.unknown)

# defer global update until all computations are done
_last_timestamp_v7 = timestamp_ms
_last_counter_v7 = counter
return res
2 changes: 1 addition & 1 deletion tests/cockroach_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"user": os.environ.get("PG_USER", "root"),
"password": os.environ.get("PG_PASSWORD", ""),
"database": os.environ.get("PG_DATABASE", "piccolo"),
}
},
)


Expand Down
31 changes: 24 additions & 7 deletions tests/columns/test_uuid.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
import uuid

from piccolo.columns.column_types import UUID
from piccolo.columns.defaults.uuid import UUID7
from piccolo.table import Table
from piccolo.testing.test_case import TableTest
from piccolo.testing.test_case import AsyncTableTest
from tests.base import engines_only


class MyTable(Table):
class UUIDTable(Table):
uuid = UUID()


class TestUUID(TableTest):
tables = [MyTable]
class TestUUID(AsyncTableTest):
tables = [UUIDTable]

def test_return_type(self):
row = MyTable()
row.save().run_sync()
async def test_return_type(self):
row = UUIDTable()
await row.save()

self.assertIsInstance(row.uuid, uuid.UUID)


class UUID7Table(Table):
uuid_7 = UUID(default=UUID7())


@engines_only("postgres", "sqlite")
class TestUUID7(AsyncTableTest):
tables = [UUID7Table]

async def test_return_type(self):
row = UUID7Table()
await row.save()

self.assertIsInstance(row.uuid_7, uuid.UUID)
3 changes: 2 additions & 1 deletion tests/postgres_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"user": os.environ.get("PG_USER", "postgres"),
"password": os.environ.get("PG_PASSWORD", ""),
"database": os.environ.get("PG_DATABASE", "piccolo"),
}
},
polyfills=["uuidv7"],
)


Expand Down