Skip to content

Commit b638bba

Browse files
committed
Add support for partitioning by numeric range
1 parent 96915b9 commit b638bba

4 files changed

Lines changed: 606 additions & 8 deletions

File tree

src/psycopack/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
from ._conn import get_db_connection
66
from ._cur import get_cursor
77
from ._introspect import BackfillBatch
8-
from ._partition import DateRangeStrategy, PartitionConfig, PartitionInterval
8+
from ._partition import (
9+
DateRangeStrategy,
10+
NumericRangeStrategy,
11+
PartitionConfig,
12+
PartitionInterval,
13+
)
914
from ._registry import RegistryException, UnexpectedSyncStrategy
1015
from ._repack import (
1116
BasePsycopackError,
@@ -48,6 +53,7 @@
4853
"NoReferencesPrivilege",
4954
"NoReferringTableOwnership",
5055
"NotTableOwner",
56+
"NumericRangeStrategy",
5157
"PartitionConfig",
5258
"PartitionInterval",
5359
"PartitioningForTableWithReferringFKs",

src/psycopack/_commands.py

Lines changed: 139 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ def _create_non_partitioned_table(
7979

8080
def _create_partitioned_table(self, *, base_table: str, copy_table: str) -> None:
8181
assert self.partition_config is not None
82-
assert isinstance(self.partition_config.strategy, _partition.DateRangeStrategy)
8382

8483
# Create the parent partitioned table
8584
self.cur.execute(
@@ -105,6 +104,23 @@ def _create_partitioned_table(self, *, base_table: str, copy_table: str) -> None
105104
def _create_partitions(self, *, base_table: str, copy_table: str) -> None:
106105
assert self.partition_config is not None
107106
strategy = self.partition_config.strategy
107+
108+
if isinstance(strategy, _partition.DateRangeStrategy):
109+
self._create_date_range_partitions(
110+
base_table=base_table, copy_table=copy_table
111+
)
112+
elif isinstance(strategy, _partition.NumericRangeStrategy):
113+
self._create_numeric_range_partitions(
114+
base_table=base_table, copy_table=copy_table
115+
)
116+
else: # pragma: no cover
117+
raise NotImplementedError
118+
119+
def _create_date_range_partitions(
120+
self, *, base_table: str, copy_table: str
121+
) -> None:
122+
assert self.partition_config is not None
123+
strategy = self.partition_config.strategy
108124
assert isinstance(strategy, _partition.DateRangeStrategy)
109125

110126
num_of_extra_partitions = self.partition_config.num_of_extra_partitions_ahead
@@ -260,6 +276,121 @@ def _create_datetime_partition(
260276
.as_string(self.conn)
261277
)
262278

279+
def _create_numeric_range_partitions(
280+
self, *, base_table: str, copy_table: str
281+
) -> None:
282+
assert self.partition_config is not None
283+
strategy = self.partition_config.strategy
284+
assert isinstance(strategy, _partition.NumericRangeStrategy)
285+
286+
# Get the PK column name - this should be the same as partition_config.column
287+
pk_column = self.partition_config.column
288+
num_of_extra_partitions = self.partition_config.num_of_extra_partitions_ahead
289+
290+
# Get min and max positive values
291+
min_and_max_positive = self.introspector.get_min_and_max_pk(
292+
table=base_table, pk_column=pk_column, positive=True
293+
)
294+
# Get min and max negative values
295+
min_and_max_negative = self.introspector.get_min_and_max_pk(
296+
table=base_table, pk_column=pk_column, positive=False
297+
)
298+
299+
# Handle negative values if they exist
300+
if min_and_max_negative:
301+
min_negative, max_negative = min_and_max_negative
302+
# Align min_negative to range boundary (floor division)
303+
partition_start = (
304+
min_negative // strategy.range_size
305+
) * strategy.range_size
306+
# Align max_negative to next boundary
307+
partition_end = (
308+
(max_negative // strategy.range_size) + 1
309+
) * strategy.range_size
310+
311+
current_start = partition_start
312+
while current_start < partition_end:
313+
current_end = current_start + strategy.range_size
314+
partition_suffix = self._get_numeric_partition_suffix(
315+
start=current_start, end=current_end
316+
)
317+
self._create_numeric_partition(
318+
base_table=base_table,
319+
copy_table=copy_table,
320+
partition_suffix=partition_suffix,
321+
start=current_start,
322+
end=current_end,
323+
)
324+
current_start = current_end
325+
326+
# Handle positive values if they exist
327+
if min_and_max_positive:
328+
min_positive, max_positive = min_and_max_positive
329+
# Align min_positive to range boundary (floor division)
330+
partition_start = (
331+
min_positive // strategy.range_size
332+
) * strategy.range_size
333+
# Calculate end including extra partitions
334+
partition_end = (
335+
(max_positive // strategy.range_size) + 1 + num_of_extra_partitions
336+
) * strategy.range_size
337+
338+
current_start = partition_start
339+
while current_start < partition_end:
340+
current_end = current_start + strategy.range_size
341+
partition_suffix = self._get_numeric_partition_suffix(
342+
start=current_start, end=current_end
343+
)
344+
self._create_numeric_partition(
345+
base_table=base_table,
346+
copy_table=copy_table,
347+
partition_suffix=partition_suffix,
348+
start=current_start,
349+
end=current_end,
350+
)
351+
current_start = current_end
352+
353+
def _get_numeric_partition_suffix(self, *, start: int, end: int) -> str:
354+
"""
355+
Generate a numeric range partition suffix.
356+
For negative ranges: returns pn1000_n1 (for -1000 to -1)
357+
For positive ranges: returns p0_999 (for 0 to 999)
358+
"""
359+
if start < 0:
360+
return f"pn{abs(start)}_n{abs(end)}"
361+
else:
362+
return f"p{start}_{end - 1}"
363+
364+
def _create_numeric_partition(
365+
self,
366+
*,
367+
base_table: str,
368+
copy_table: str,
369+
partition_suffix: str,
370+
start: int,
371+
end: int,
372+
) -> None:
373+
"""Create a single numeric range partition."""
374+
self.cur.execute(
375+
psycopg.sql.SQL(
376+
dedent("""
377+
CREATE TABLE {schema}.{partition_name}
378+
PARTITION OF {schema}.{copy_table}
379+
FOR VALUES FROM ({start}) TO ({end});
380+
""")
381+
)
382+
.format(
383+
schema=psycopg.sql.Identifier(self.schema),
384+
partition_name=psycopg.sql.Identifier(
385+
f"{base_table}_{partition_suffix}"
386+
),
387+
copy_table=psycopg.sql.Identifier(copy_table),
388+
start=psycopg.sql.Literal(start),
389+
end=psycopg.sql.Literal(end),
390+
)
391+
.as_string(self.conn)
392+
)
393+
263394
def drop_sequence_if_exists(self, *, seq: str) -> None:
264395
self.cur.execute(
265396
psycopg.sql.SQL("DROP SEQUENCE IF EXISTS {schema}.{seq};")
@@ -307,11 +438,14 @@ def set_table_id_seq(self, *, table: str, seq: str, pk_column: str) -> None:
307438
def add_pk(self, *, table: str, pk_column: str) -> None:
308439
# For partitioned tables, the PK must include all partitioning columns
309440
if self.partition_config:
441+
# Build list of unique columns for the primary key
442+
pk_column_list = [pk_column]
443+
# Only add partition column if it's different from pk_column
444+
if self.partition_config.column != pk_column:
445+
pk_column_list.append(self.partition_config.column)
446+
310447
pk_columns = psycopg.sql.SQL(", ").join(
311-
[
312-
psycopg.sql.Identifier(pk_column),
313-
psycopg.sql.Identifier(self.partition_config.column),
314-
]
448+
[psycopg.sql.Identifier(col) for col in pk_column_list]
315449
)
316450
self.cur.execute(
317451
psycopg.sql.SQL(

src/psycopack/_partition.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ class DateRangeStrategy:
1212
partition_by: PartitionInterval
1313

1414

15+
@dataclasses.dataclass
16+
class NumericRangeStrategy:
17+
range_size: int
18+
19+
1520
@dataclasses.dataclass
1621
class PartitionConfig:
1722
column: str
1823
num_of_extra_partitions_ahead: int
19-
# Todo: Add support for other types of partitioning.
20-
strategy: DateRangeStrategy
24+
strategy: DateRangeStrategy | NumericRangeStrategy

0 commit comments

Comments
 (0)