Skip to content
Open
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
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ spark_locals_without_parens = [
code?: 1,
concurrently: 1,
create?: 1,
create_table_options: 1,
deferrable: 1,
down: 1,
error_fields: 1,
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Minimum required PostgreSQL version: `13.0`

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

Expand Down
1 change: 1 addition & 0 deletions documentation/dsls/DSL-AshPostgres.DataLayer.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ end
| [`table`](#postgres-table){: #postgres-table } | `String.t` | | The table to store and read the resource from. If this is changed, the migration generator will not remove the old table. |
| [`schema`](#postgres-schema){: #postgres-schema } | `String.t` | | The schema that the table is located in. Schema-based multitenancy will supercede this option. If this is changed, the migration generator will not remove the old schema. |
| [`polymorphic?`](#postgres-polymorphic?){: #postgres-polymorphic? } | `boolean` | `false` | Declares this resource as polymorphic. See the [polymorphic resources guide](/documentation/topics/resources/polymorphic-resources.md) for more. |
| [`create_table_options`](#postgres-create_table_options){: #postgres-create_table_options } | `String.t` | | 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. |


### postgres.custom_indexes
Expand Down
232 changes: 232 additions & 0 deletions documentation/topics/advanced/partitioned-tables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
<!--
SPDX-FileCopyrightText: 2025 ash_postgres contributors

SPDX-License-Identifier: MIT
-->

# Partitioned Tables

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.

For more information on PostgreSQL partitioning, see the [PostgreSQL partitioning documentation](https://www.postgresql.org/docs/current/ddl-partitioning.html).

> ### Multitenancy and Partitioning {: .info}
>
> 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.

## Setting Up a Partitioned Table

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.

### Range Partitioning Example

Here's an example of setting up a range-partitioned table by date:

```elixir
defmodule MyApp.SensorReading do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer

attributes do
uuid_primary_key :id
attribute :sensor_id, :integer
attribute :reading_value, :float
create_timestamp :inserted_at
end

postgres do
table "sensor_readings"
repo MyApp.Repo

# Configure the table as a partitioned table
create_table_options "PARTITION BY RANGE (inserted_at)"

# Create a default partition to catch any data that doesn't fit into specific partitions
custom_statements do
statement :default_partition do
up """
CREATE TABLE IF NOT EXISTS sensor_readings_default
PARTITION OF sensor_readings DEFAULT;
"""
down """
DROP TABLE IF EXISTS sensor_readings_default;
"""
end
end
end
end
```

### List Partitioning Example

Here's an example of list partitioning by region:

```elixir
defmodule MyApp.Order do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer

attributes do
uuid_primary_key :id
attribute :order_number, :string
attribute :region, :string
attribute :total, :decimal
create_timestamp :inserted_at
end

postgres do
table "orders"
repo MyApp.Repo

# Configure the table as a list-partitioned table
create_table_options "PARTITION BY LIST (region)"

# Create a default partition
custom_statements do
statement :default_partition do
up """
CREATE TABLE IF NOT EXISTS orders_default
PARTITION OF orders DEFAULT;
"""
down """
DROP TABLE IF EXISTS orders_default;
"""
end
end
end
end
```

### Hash Partitioning Example

Here's an example of hash partitioning:

```elixir
defmodule MyApp.LogEntry do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer

attributes do
uuid_primary_key :id
attribute :user_id, :integer
attribute :message, :string
create_timestamp :inserted_at
end

postgres do
table "log_entries"
repo MyApp.Repo

# Configure the table as a hash-partitioned table
create_table_options "PARTITION BY HASH (user_id)"

# Create a default partition
custom_statements do
statement :default_partition do
up """
CREATE TABLE IF NOT EXISTS log_entries_default
PARTITION OF log_entries DEFAULT;
"""
down """
DROP TABLE IF EXISTS log_entries_default;
"""
end
end
end
end
```

## Creating Additional Partitions

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:

```elixir
postgres do
table "sensor_readings"
repo MyApp.Repo

create_table_options "PARTITION BY RANGE (inserted_at)"

custom_statements do
statement :default_partition do
up """
CREATE TABLE IF NOT EXISTS sensor_readings_default
PARTITION OF sensor_readings DEFAULT;
"""
down """
DROP TABLE IF EXISTS sensor_readings_default;
"""
end

# Example: Create a partition for January 2024
statement :january_2024_partition do
up """
CREATE TABLE IF NOT EXISTS sensor_readings_2024_01
PARTITION OF sensor_readings
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
"""
down """
DROP TABLE IF EXISTS sensor_readings_2024_01;
"""
end

# Example: Create a partition for February 2024
statement :february_2024_partition do
up """
CREATE TABLE IF NOT EXISTS sensor_readings_2024_02
PARTITION OF sensor_readings
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
"""
down """
DROP TABLE IF EXISTS sensor_readings_2024_02;
"""
end
end
end
```

## Dynamically Creating Partitions

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:

```elixir
def create_partition(resource, partition_name, list_value) do
repo = AshPostgres.DataLayer.Info.repo(resource)
table_name = AshPostgres.DataLayer.Info.table(resource)
schema = AshPostgres.DataLayer.Info.schema(resource) || "public"

sql = """
CREATE TABLE IF NOT EXISTS "#{schema}"."#{partition_name}"
PARTITION OF "#{schema}"."#{table_name}"
FOR VALUES IN ('#{list_value}')
"""

case Ecto.Adapters.SQL.query(repo, sql, []) do
{:ok, _} ->
:ok

{:error, %{postgres: %{code: :duplicate_table}}} ->
:ok

{:error, error} ->
{:error, "Failed to create partition for #{table_name}: #{inspect(error)}"}
end
end
```

Similarly, you'll want to dynamically drop partitions when they're no longer needed.



> ### Partitioning is Complex {: .warning}
>
> 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).

## See Also

- [Ecto.Migration.table/2 documentation](https://hexdocs.pm/ecto_sql/Ecto.Migration.html#table/2) for more information on table options
- [PostgreSQL Partitioning documentation](https://www.postgresql.org/docs/current/ddl-partitioning.html) for detailed information on partitioning strategies
- [Custom Statements documentation](https://hexdocs.pm/ash_postgres/dsl-ashpostgres-datalayer.html#postgres-custom_statements) for more information on using custom statements in migrations
6 changes: 6 additions & 0 deletions lib/data_layer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,12 @@ defmodule AshPostgres.DataLayer do
doc: """
Declares this resource as polymorphic. See the [polymorphic resources guide](/documentation/topics/resources/polymorphic-resources.md) for more.
"""
],
create_table_options: [
type: :string,
doc: """
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.
"""
]
]
}
Expand Down
5 changes: 5 additions & 0 deletions lib/data_layer/info.ex
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,9 @@ defmodule AshPostgres.DataLayer.Info do
def manage_tenant_update?(resource) do
Extension.get_opt(resource, [:postgres, :manage_tenant], :update?, false)
end

@doc "String passed to table/2 in the create table migration for a given resource"
def create_table_options(resource) do
Extension.get_opt(resource, [:postgres], :create_table_options, nil)
end
end
17 changes: 13 additions & 4 deletions lib/migration_generator/migration_generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1366,7 +1366,8 @@ defmodule AshPostgres.MigrationGenerator do
table: table,
schema: schema,
multitenancy: multitenancy,
repo: repo
repo: repo,
create_table_options: create_table_options
}
| rest
],
Expand All @@ -1375,7 +1376,13 @@ defmodule AshPostgres.MigrationGenerator do
) do
group_into_phases(
rest,
%Phase.Create{table: table, schema: schema, multitenancy: multitenancy, repo: repo},
%Phase.Create{
table: table,
schema: schema,
multitenancy: multitenancy,
repo: repo,
create_table_options: create_table_options
},
acc
)
end
Expand Down Expand Up @@ -2031,7 +2038,8 @@ defmodule AshPostgres.MigrationGenerator do
schema: snapshot.schema,
repo: snapshot.repo,
multitenancy: snapshot.multitenancy,
old_multitenancy: empty_snapshot.multitenancy
old_multitenancy: empty_snapshot.multitenancy,
create_table_options: snapshot.create_table_options
}
| acc
])
Expand Down Expand Up @@ -3103,7 +3111,8 @@ defmodule AshPostgres.MigrationGenerator do
repo: AshPostgres.DataLayer.Info.repo(resource, :mutate),
multitenancy: multitenancy(resource),
base_filter: AshPostgres.DataLayer.Info.base_filter_sql(resource),
has_create_action: has_create_action?(resource)
has_create_action: has_create_action?(resource),
create_table_options: AshPostgres.DataLayer.Info.create_table_options(resource)
}

hash =
Expand Down
2 changes: 1 addition & 1 deletion lib/migration_generator/operation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do

defmodule CreateTable do
@moduledoc false
defstruct [:table, :schema, :multitenancy, :old_multitenancy, :repo]
defstruct [:table, :schema, :multitenancy, :old_multitenancy, :repo, :create_table_options]
end

defmodule AddAttribute do
Expand Down
24 changes: 20 additions & 4 deletions lib/migration_generator/phase.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ defmodule AshPostgres.MigrationGenerator.Phase do

defmodule Create do
@moduledoc false
defstruct [:table, :schema, :multitenancy, :repo, operations: [], commented?: false]
defstruct [
:table,
:schema,
:multitenancy,
:repo,
:create_table_options,
operations: [],
commented?: false
]

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

Expand All @@ -16,10 +24,18 @@ defmodule AshPostgres.MigrationGenerator.Phase do
table: table,
operations: operations,
multitenancy: multitenancy,
repo: repo
repo: repo,
create_table_options: create_table_options
}) do
table_options_str =
if create_table_options do
", options: #{inspect(create_table_options)}"
else
""
end

if multitenancy.strategy == :context do
"create table(:#{as_atom(table)}, primary_key: false, prefix: prefix()) do\n" <>
"create table(:#{as_atom(table)}, primary_key: false, prefix: prefix()#{table_options_str}) do\n" <>
Enum.map_join(operations, "\n", fn operation -> operation.__struct__.up(operation) end) <>
"\nend"
else
Expand All @@ -38,7 +54,7 @@ defmodule AshPostgres.MigrationGenerator.Phase do
end

pre_create <>
"create table(:#{as_atom(table)}, primary_key: false#{opts}) do\n" <>
"create table(:#{as_atom(table)}, primary_key: false#{opts}#{table_options_str}) do\n" <>
Enum.map_join(operations, "\n", fn operation -> operation.__struct__.up(operation) end) <>
"\nend"
end
Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,10 @@ defmodule AshPostgres.MixProject do
"documentation/topics/development/testing.md",
"documentation/topics/development/upgrading-to-2.0.md",
"documentation/topics/advanced/expressions.md",
"documentation/topics/advanced/manual-relationships.md",
"documentation/topics/advanced/partitioned-tables.md",
"documentation/topics/advanced/schema-based-multitenancy.md",
"documentation/topics/advanced/using-multiple-repos.md",
"documentation/topics/advanced/manual-relationships.md",
{"documentation/dsls/DSL-AshPostgres.DataLayer.md",
search_data: Spark.Docs.search_data_for(AshPostgres.DataLayer)},
"CHANGELOG.md"
Expand Down
Loading
Loading