Skip to content

Commit 0174e2a

Browse files
committed
improvement: add create_table_options for partitioned tables and more
1 parent 23cc10a commit 0174e2a

File tree

9 files changed

+396
-10
lines changed

9 files changed

+396
-10
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Minimum required PostgreSQL version: `13.0`
4242

4343
- [Expressions](documentation/topics/advanced/expressions.md)
4444
- [Manual Relationships](documentation/topics/advanced/manual-relationships.md)
45+
- [Partitioned Tables](documentation/topics/advanced/partitioned-tables.md)
4546
- [Schema Based Multitenancy](documentation/topics/advanced/schema-based-multitenancy.md)
4647
- [Read Replicas](documentation/topics/advanced/using-multiple-repos.md)
4748

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2025 ash_postgres contributors
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# Partitioned Tables
8+
9+
PostgreSQL supports table partitioning, which allows you to split a large table into smaller, more manageable pieces. Partitioning can improve query performance, simplify maintenance, and enable better data management strategies.
10+
11+
For more information on PostgreSQL partitioning, see the [PostgreSQL partitioning documentation](https://www.postgresql.org/docs/current/ddl-partitioning.html).
12+
13+
> ### Multitenancy and Partitioning {: .info}
14+
>
15+
> If you're interested in using partitions for multitenancy, start with AshPostgres's [Schema Based Multitenancy](schema-based-multitenancy.html) feature, which uses PostgreSQL schemas to separate tenant data. Schema-based multitenancy is generally the recommended approach for multitenancy in AshPostgres.
16+
17+
## Setting Up a Partitioned Table
18+
19+
To create a partitioned table in AshPostgres, you'll use the `create_table_options` DSL option to specify the partitioning strategy. This option passes configuration directly to Ecto's `create table/2` function.
20+
21+
### Range Partitioning Example
22+
23+
Here's an example of setting up a range-partitioned table by date:
24+
25+
```elixir
26+
defmodule MyApp.SensorReading do
27+
use Ash.Resource,
28+
domain: MyApp.Domain,
29+
data_layer: AshPostgres.DataLayer
30+
31+
attributes do
32+
uuid_primary_key :id
33+
attribute :sensor_id, :integer
34+
attribute :reading_value, :float
35+
create_timestamp :inserted_at
36+
end
37+
38+
postgres do
39+
table "sensor_readings"
40+
repo MyApp.Repo
41+
42+
# Configure the table as a partitioned table
43+
create_table_options "PARTITION BY RANGE (inserted_at)"
44+
45+
# Create a default partition to catch any data that doesn't fit into specific partitions
46+
custom_statements do
47+
statement :default_partition do
48+
up """
49+
CREATE TABLE IF NOT EXISTS sensor_readings_default
50+
PARTITION OF sensor_readings DEFAULT;
51+
"""
52+
down """
53+
DROP TABLE IF EXISTS sensor_readings_default;
54+
"""
55+
end
56+
end
57+
end
58+
end
59+
```
60+
61+
### List Partitioning Example
62+
63+
Here's an example of list partitioning by region:
64+
65+
```elixir
66+
defmodule MyApp.Order do
67+
use Ash.Resource,
68+
domain: MyApp.Domain,
69+
data_layer: AshPostgres.DataLayer
70+
71+
attributes do
72+
uuid_primary_key :id
73+
attribute :order_number, :string
74+
attribute :region, :string
75+
attribute :total, :decimal
76+
create_timestamp :inserted_at
77+
end
78+
79+
postgres do
80+
table "orders"
81+
repo MyApp.Repo
82+
83+
# Configure the table as a list-partitioned table
84+
create_table_options "PARTITION BY LIST (region)"
85+
86+
# Create a default partition
87+
custom_statements do
88+
statement :default_partition do
89+
up """
90+
CREATE TABLE IF NOT EXISTS orders_default
91+
PARTITION OF orders DEFAULT;
92+
"""
93+
down """
94+
DROP TABLE IF EXISTS orders_default;
95+
"""
96+
end
97+
end
98+
end
99+
end
100+
```
101+
102+
### Hash Partitioning Example
103+
104+
Here's an example of hash partitioning:
105+
106+
```elixir
107+
defmodule MyApp.LogEntry do
108+
use Ash.Resource,
109+
domain: MyApp.Domain,
110+
data_layer: AshPostgres.DataLayer
111+
112+
attributes do
113+
uuid_primary_key :id
114+
attribute :user_id, :integer
115+
attribute :message, :string
116+
create_timestamp :inserted_at
117+
end
118+
119+
postgres do
120+
table "log_entries"
121+
repo MyApp.Repo
122+
123+
# Configure the table as a hash-partitioned table
124+
create_table_options "PARTITION BY HASH (user_id)"
125+
126+
# Create a default partition
127+
custom_statements do
128+
statement :default_partition do
129+
up """
130+
CREATE TABLE IF NOT EXISTS log_entries_default
131+
PARTITION OF log_entries DEFAULT;
132+
"""
133+
down """
134+
DROP TABLE IF EXISTS log_entries_default;
135+
"""
136+
end
137+
end
138+
end
139+
end
140+
```
141+
142+
## Creating Additional Partitions
143+
144+
After the initial migration, you can create additional partitions as needed using custom statements. For example, to create monthly partitions for a range-partitioned table:
145+
146+
```elixir
147+
postgres do
148+
table "sensor_readings"
149+
repo MyApp.Repo
150+
151+
create_table_options "PARTITION BY RANGE (inserted_at)"
152+
153+
custom_statements do
154+
statement :default_partition do
155+
up """
156+
CREATE TABLE IF NOT EXISTS sensor_readings_default
157+
PARTITION OF sensor_readings DEFAULT;
158+
"""
159+
down """
160+
DROP TABLE IF EXISTS sensor_readings_default;
161+
"""
162+
end
163+
164+
# Example: Create a partition for January 2024
165+
statement :january_2024_partition do
166+
up """
167+
CREATE TABLE IF NOT EXISTS sensor_readings_2024_01
168+
PARTITION OF sensor_readings
169+
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
170+
"""
171+
down """
172+
DROP TABLE IF EXISTS sensor_readings_2024_01;
173+
"""
174+
end
175+
176+
# Example: Create a partition for February 2024
177+
statement :february_2024_partition do
178+
up """
179+
CREATE TABLE IF NOT EXISTS sensor_readings_2024_02
180+
PARTITION OF sensor_readings
181+
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
182+
"""
183+
down """
184+
DROP TABLE IF EXISTS sensor_readings_2024_02;
185+
"""
186+
end
187+
end
188+
end
189+
```
190+
191+
## Dynamically Creating Partitions
192+
193+
For list-partitioned tables, you may want to create partitions dynamically as part of a action. Here's an example helper function for creating partitions:
194+
195+
```elixir
196+
def create_partition(resource, partition_name, list_value) do
197+
repo = AshPostgres.DataLayer.Info.repo(resource)
198+
table_name = AshPostgres.DataLayer.Info.table(resource)
199+
schema = AshPostgres.DataLayer.Info.schema(resource) || "public"
200+
201+
sql = """
202+
CREATE TABLE IF NOT EXISTS "#{schema}"."#{partition_name}"
203+
PARTITION OF "#{schema}"."#{table_name}"
204+
FOR VALUES IN ('#{list_value}')
205+
"""
206+
207+
case Ecto.Adapters.SQL.query(repo, sql, []) do
208+
{:ok, _} ->
209+
:ok
210+
211+
{:error, %{postgres: %{code: :duplicate_table}}} ->
212+
:ok
213+
214+
{:error, error} ->
215+
{:error, "Failed to create partition for #{table_name}: #{inspect(error)}"}
216+
end
217+
end
218+
```
219+
220+
Similarly, you'll want to dynamically drop partitions when they're no longer needed.
221+
222+
223+
224+
> ### Partitioning is Complex {: .warning}
225+
>
226+
> Table partitioning is a complex topic with many considerations around performance, maintenance, foreign keys, and data management. This guide shows how to configure partitioned tables in AshPostgres, but it is not a comprehensive primer on PostgreSQL partitioning. For detailed information on partitioning strategies, best practices, and limitations, please refer to the [PostgreSQL partitioning documentation](https://www.postgresql.org/docs/current/ddl-partitioning.html).
227+
228+
## See Also
229+
230+
- [Ecto.Migration.table/2 documentation](https://hexdocs.pm/ecto_sql/Ecto.Migration.html#table/2) for more information on table options
231+
- [PostgreSQL Partitioning documentation](https://www.postgresql.org/docs/current/ddl-partitioning.html) for detailed information on partitioning strategies
232+
- [Custom Statements documentation](https://hexdocs.pm/ash_postgres/dsl-ashpostgres-datalayer.html#postgres-custom_statements) for more information on using custom statements in migrations

lib/data_layer.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,12 @@ defmodule AshPostgres.DataLayer do
400400
doc: """
401401
Declares this resource as polymorphic. See the [polymorphic resources guide](/documentation/topics/resources/polymorphic-resources.md) for more.
402402
"""
403+
],
404+
create_table_options: [
405+
type: :string,
406+
doc: """
407+
Options passed to ecto's table/2 in the create migration. See the [Ecto.Migration.table/2](https://hexdocs.pm/ecto_sql/Ecto.Migration.html#table/2) documentation for more information.
408+
"""
403409
]
404410
]
405411
}

lib/data_layer/info.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,4 +226,9 @@ defmodule AshPostgres.DataLayer.Info do
226226
def manage_tenant_update?(resource) do
227227
Extension.get_opt(resource, [:postgres, :manage_tenant], :update?, false)
228228
end
229+
230+
@doc "String passed to table/2 in the create table migration for a given resource"
231+
def create_table_options(resource) do
232+
Extension.get_opt(resource, [:postgres], :create_table_options, nil)
233+
end
229234
end

lib/migration_generator/migration_generator.ex

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,7 +1366,8 @@ defmodule AshPostgres.MigrationGenerator do
13661366
table: table,
13671367
schema: schema,
13681368
multitenancy: multitenancy,
1369-
repo: repo
1369+
repo: repo,
1370+
create_table_options: create_table_options
13701371
}
13711372
| rest
13721373
],
@@ -1375,7 +1376,13 @@ defmodule AshPostgres.MigrationGenerator do
13751376
) do
13761377
group_into_phases(
13771378
rest,
1378-
%Phase.Create{table: table, schema: schema, multitenancy: multitenancy, repo: repo},
1379+
%Phase.Create{
1380+
table: table,
1381+
schema: schema,
1382+
multitenancy: multitenancy,
1383+
repo: repo,
1384+
create_table_options: create_table_options
1385+
},
13791386
acc
13801387
)
13811388
end
@@ -2031,7 +2038,8 @@ defmodule AshPostgres.MigrationGenerator do
20312038
schema: snapshot.schema,
20322039
repo: snapshot.repo,
20332040
multitenancy: snapshot.multitenancy,
2034-
old_multitenancy: empty_snapshot.multitenancy
2041+
old_multitenancy: empty_snapshot.multitenancy,
2042+
create_table_options: snapshot.create_table_options
20352043
}
20362044
| acc
20372045
])
@@ -3103,7 +3111,8 @@ defmodule AshPostgres.MigrationGenerator do
31033111
repo: AshPostgres.DataLayer.Info.repo(resource, :mutate),
31043112
multitenancy: multitenancy(resource),
31053113
base_filter: AshPostgres.DataLayer.Info.base_filter_sql(resource),
3106-
has_create_action: has_create_action?(resource)
3114+
has_create_action: has_create_action?(resource),
3115+
create_table_options: AshPostgres.DataLayer.Info.create_table_options(resource)
31073116
}
31083117

31093118
hash =

lib/migration_generator/operation.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do
149149

150150
defmodule CreateTable do
151151
@moduledoc false
152-
defstruct [:table, :schema, :multitenancy, :old_multitenancy, :repo]
152+
defstruct [:table, :schema, :multitenancy, :old_multitenancy, :repo, :create_table_options]
153153
end
154154

155155
defmodule AddAttribute do

lib/migration_generator/phase.ex

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,15 @@ defmodule AshPostgres.MigrationGenerator.Phase do
77

88
defmodule Create do
99
@moduledoc false
10-
defstruct [:table, :schema, :multitenancy, :repo, operations: [], commented?: false]
10+
defstruct [
11+
:table,
12+
:schema,
13+
:multitenancy,
14+
:repo,
15+
:create_table_options,
16+
operations: [],
17+
commented?: false
18+
]
1119

1220
import AshPostgres.MigrationGenerator.Operation.Helper, only: [as_atom: 1]
1321

@@ -16,10 +24,18 @@ defmodule AshPostgres.MigrationGenerator.Phase do
1624
table: table,
1725
operations: operations,
1826
multitenancy: multitenancy,
19-
repo: repo
27+
repo: repo,
28+
create_table_options: create_table_options
2029
}) do
30+
table_options_str =
31+
if create_table_options do
32+
", options: #{inspect(create_table_options)}"
33+
else
34+
""
35+
end
36+
2137
if multitenancy.strategy == :context do
22-
"create table(:#{as_atom(table)}, primary_key: false, prefix: prefix()) do\n" <>
38+
"create table(:#{as_atom(table)}, primary_key: false, prefix: prefix()#{table_options_str}) do\n" <>
2339
Enum.map_join(operations, "\n", fn operation -> operation.__struct__.up(operation) end) <>
2440
"\nend"
2541
else
@@ -38,7 +54,7 @@ defmodule AshPostgres.MigrationGenerator.Phase do
3854
end
3955

4056
pre_create <>
41-
"create table(:#{as_atom(table)}, primary_key: false#{opts}) do\n" <>
57+
"create table(:#{as_atom(table)}, primary_key: false#{opts}#{table_options_str}) do\n" <>
4258
Enum.map_join(operations, "\n", fn operation -> operation.__struct__.up(operation) end) <>
4359
"\nend"
4460
end

mix.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,10 @@ defmodule AshPostgres.MixProject do
107107
"documentation/topics/development/testing.md",
108108
"documentation/topics/development/upgrading-to-2.0.md",
109109
"documentation/topics/advanced/expressions.md",
110+
"documentation/topics/advanced/manual-relationships.md",
111+
"documentation/topics/advanced/partitioned-tables.md",
110112
"documentation/topics/advanced/schema-based-multitenancy.md",
111113
"documentation/topics/advanced/using-multiple-repos.md",
112-
"documentation/topics/advanced/manual-relationships.md",
113114
{"documentation/dsls/DSL-AshPostgres.DataLayer.md",
114115
search_data: Spark.Docs.search_data_for(AshPostgres.DataLayer)},
115116
"CHANGELOG.md"

0 commit comments

Comments
 (0)