|
| 1 | +# Rules for working with AshPostgres |
| 2 | + |
| 3 | +## Understanding AshPostgres |
| 4 | + |
| 5 | +AshPostgres is the PostgreSQL data layer for Ash Framework. It's the most fully-featured Ash data layer and should be your default choice unless you have specific requirements for another data layer. Any PostgreSQL version higher than 13 is fully supported. |
| 6 | + |
| 7 | +## Basic Configuration |
| 8 | + |
| 9 | +To use AshPostgres, add the data layer to your resource: |
| 10 | + |
| 11 | +```elixir |
| 12 | +defmodule MyApp.Tweet do |
| 13 | + use Ash.Resource, |
| 14 | + data_layer: AshPostgres.DataLayer |
| 15 | + |
| 16 | + attributes do |
| 17 | + integer_primary_key :id |
| 18 | + attribute :text, :string |
| 19 | + end |
| 20 | + |
| 21 | + relationships do |
| 22 | + belongs_to :author, MyApp.User |
| 23 | + end |
| 24 | + |
| 25 | + postgres do |
| 26 | + table "tweets" |
| 27 | + repo MyApp.Repo |
| 28 | + end |
| 29 | +end |
| 30 | +``` |
| 31 | + |
| 32 | +## PostgreSQL Configuration |
| 33 | + |
| 34 | +### Table & Schema Configuration |
| 35 | + |
| 36 | +```elixir |
| 37 | +postgres do |
| 38 | + # Required: Define the table name for this resource |
| 39 | + table "users" |
| 40 | + |
| 41 | + # Optional: Define the PostgreSQL schema |
| 42 | + schema "public" |
| 43 | + |
| 44 | + # Required: Define the Ecto repo to use |
| 45 | + repo MyApp.Repo |
| 46 | + |
| 47 | + # Optional: Control whether migrations are generated for this resource |
| 48 | + migrate? true |
| 49 | +end |
| 50 | +``` |
| 51 | + |
| 52 | +## Foreign Key References |
| 53 | + |
| 54 | +Use the `references` section to configure foreign key behavior: |
| 55 | + |
| 56 | +```elixir |
| 57 | +postgres do |
| 58 | + table "comments" |
| 59 | + repo MyApp.Repo |
| 60 | + |
| 61 | + references do |
| 62 | + # Simple reference with defaults |
| 63 | + reference :post |
| 64 | + |
| 65 | + # Fully configured reference |
| 66 | + reference :user, |
| 67 | + on_delete: :delete, # What happens when referenced row is deleted |
| 68 | + on_update: :update, # What happens when referenced row is updated |
| 69 | + name: "comments_to_users_fkey", # Custom constraint name |
| 70 | + deferrable: true, # Make constraint deferrable |
| 71 | + initially_deferred: false # Defer constraint check to end of transaction |
| 72 | + end |
| 73 | +end |
| 74 | +``` |
| 75 | + |
| 76 | +### Foreign Key Actions |
| 77 | + |
| 78 | +For `on_delete` and `on_update` options: |
| 79 | + |
| 80 | +- `:nothing` or `:restrict` - Prevent the change to the referenced row |
| 81 | +- `:delete` - Delete the row when the referenced row is deleted (for `on_delete` only) |
| 82 | +- `:update` - Update the row according to changes in the referenced row (for `on_update` only) |
| 83 | +- `:nilify` - Set all foreign key columns to NULL |
| 84 | +- `{:nilify, columns}` - Set specific columns to NULL (Postgres 15.0+ only) |
| 85 | + |
| 86 | +> **Warning**: These operations happen directly at the database level. No resource logic, authorization rules, validations, or notifications are triggered. |
| 87 | +
|
| 88 | +## Check Constraints |
| 89 | + |
| 90 | +Define database check constraints: |
| 91 | + |
| 92 | +```elixir |
| 93 | +postgres do |
| 94 | + check_constraints do |
| 95 | + check_constraint :positive_amount, |
| 96 | + check: "amount > 0", |
| 97 | + name: "positive_amount_check", |
| 98 | + message: "Amount must be positive" |
| 99 | + |
| 100 | + check_constraint :status_valid, |
| 101 | + check: "status IN ('pending', 'active', 'completed')" |
| 102 | + end |
| 103 | +end |
| 104 | +``` |
| 105 | + |
| 106 | +## Custom Indexes |
| 107 | + |
| 108 | +Define custom indexes beyond those automatically created for identities and relationships: |
| 109 | + |
| 110 | +```elixir |
| 111 | +postgres do |
| 112 | + custom_indexes do |
| 113 | + index [:first_name, :last_name] |
| 114 | + |
| 115 | + index :email, |
| 116 | + unique: true, |
| 117 | + name: "users_email_index", |
| 118 | + where: "email IS NOT NULL", |
| 119 | + using: :gin |
| 120 | + |
| 121 | + index [:status, :created_at], |
| 122 | + concurrently: true, |
| 123 | + include: [:user_id] |
| 124 | + end |
| 125 | +end |
| 126 | +``` |
| 127 | + |
| 128 | +## Custom SQL Statements |
| 129 | + |
| 130 | +Include custom SQL in migrations: |
| 131 | + |
| 132 | +```elixir |
| 133 | +postgres do |
| 134 | + custom_statements do |
| 135 | + statement "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"" |
| 136 | + |
| 137 | + statement """ |
| 138 | + CREATE TRIGGER update_updated_at |
| 139 | + BEFORE UPDATE ON posts |
| 140 | + FOR EACH ROW |
| 141 | + EXECUTE FUNCTION trigger_set_timestamp(); |
| 142 | + """ |
| 143 | + |
| 144 | + statement "DROP INDEX IF EXISTS posts_title_index", |
| 145 | + on_destroy: true # Only run when resource is destroyed/dropped |
| 146 | + end |
| 147 | +end |
| 148 | +``` |
| 149 | + |
| 150 | +## Migrations and Codegen |
| 151 | + |
| 152 | +### Generating Migrations |
| 153 | + |
| 154 | +After creating or modifying Ash resources: |
| 155 | + |
| 156 | +1. Run `mix ash.codegen add_feature_name` to generate migrations |
| 157 | +2. Review the generated migrations in `priv/repo/migrations` |
| 158 | +3. Run `mix ash.migrate` to apply the migrations |
| 159 | + |
| 160 | +## Multitenancy |
| 161 | + |
| 162 | +AshPostgres supports schema-based multitenancy: |
| 163 | + |
| 164 | +```elixir |
| 165 | +defmodule MyApp.Tenant do |
| 166 | + use Ash.Resource, |
| 167 | + data_layer: AshPostgres.DataLayer |
| 168 | + |
| 169 | + # Resource definition... |
| 170 | + |
| 171 | + postgres do |
| 172 | + table "tenants" |
| 173 | + repo MyApp.Repo |
| 174 | + |
| 175 | + # Automatically create/manage tenant schemas |
| 176 | + manage_tenant do |
| 177 | + template ["tenant_", :id] |
| 178 | + end |
| 179 | + end |
| 180 | +end |
| 181 | +``` |
| 182 | + |
| 183 | +### Setting Up Multitenancy |
| 184 | + |
| 185 | +1. Configure your repo to support multitenancy: |
| 186 | + |
| 187 | +```elixir |
| 188 | +defmodule MyApp.Repo do |
| 189 | + use AshPostgres.Repo, otp_app: :my_app |
| 190 | + |
| 191 | + # Return all tenant schemas for migrations |
| 192 | + def all_tenants do |
| 193 | + import Ecto.Query, only: [from: 2] |
| 194 | + all(from(t in "tenants", select: fragment("? || ?", "tenant_", t.id))) |
| 195 | + end |
| 196 | +end |
| 197 | +``` |
| 198 | + |
| 199 | +2. Mark resources that should be multi-tenant: |
| 200 | + |
| 201 | +```elixir |
| 202 | +defmodule MyApp.Post do |
| 203 | + use Ash.Resource, |
| 204 | + data_layer: AshPostgres.DataLayer |
| 205 | + |
| 206 | + multitenancy do |
| 207 | + strategy :context |
| 208 | + attribute :tenant |
| 209 | + end |
| 210 | + |
| 211 | + # Resource definition... |
| 212 | +end |
| 213 | +``` |
| 214 | + |
| 215 | +3. When tenant migrations are generated, they'll be in `priv/repo/tenant_migrations` |
| 216 | + |
| 217 | +4. Run tenant migrations in addition to regular migrations: |
| 218 | + |
| 219 | +```bash |
| 220 | +# Run regular migrations |
| 221 | +mix ash.migrate |
| 222 | + |
| 223 | +# Run tenant migrations |
| 224 | +mix ash_postgres.migrate --tenants |
| 225 | +``` |
| 226 | + |
| 227 | +## Advanced Features |
| 228 | + |
| 229 | +### Manual Relationships |
| 230 | + |
| 231 | +For complex relationships that can't be expressed with standard relationship types: |
| 232 | + |
| 233 | +```elixir |
| 234 | +defmodule MyApp.Post.Relationships.HighlyRatedComments do |
| 235 | + use Ash.Resource.ManualRelationship |
| 236 | + use AshPostgres.ManualRelationship |
| 237 | + |
| 238 | + def load(posts, _opts, context) do |
| 239 | + post_ids = Enum.map(posts, & &1.id) |
| 240 | + |
| 241 | + {:ok, |
| 242 | + MyApp.Comment |
| 243 | + |> Ash.Query.filter(post_id in ^post_ids) |
| 244 | + |> Ash.Query.filter(rating > 4) |
| 245 | + |> MyApp.read!() |
| 246 | + |> Enum.group_by(& &1.post_id)} |
| 247 | + end |
| 248 | + |
| 249 | + def ash_postgres_join(query, _opts, current_binding, as_binding, :inner, destination_query) do |
| 250 | + {:ok, |
| 251 | + Ecto.Query.from(_ in query, |
| 252 | + join: dest in ^destination_query, |
| 253 | + as: ^as_binding, |
| 254 | + on: dest.post_id == as(^current_binding).id, |
| 255 | + on: dest.rating > 4 |
| 256 | + )} |
| 257 | + end |
| 258 | + |
| 259 | + # Other required callbacks... |
| 260 | +end |
| 261 | + |
| 262 | +# In your resource: |
| 263 | +relationships do |
| 264 | + has_many :highly_rated_comments, MyApp.Comment do |
| 265 | + manual MyApp.Post.Relationships.HighlyRatedComments |
| 266 | + end |
| 267 | +end |
| 268 | +``` |
| 269 | + |
| 270 | +### Using Multiple Repos (Read Replicas) |
| 271 | + |
| 272 | +Configure different repos for reads vs mutations: |
| 273 | + |
| 274 | +```elixir |
| 275 | +postgres do |
| 276 | + repo fn resource, type -> |
| 277 | + case type do |
| 278 | + :read -> MyApp.ReadReplicaRepo |
| 279 | + :mutate -> MyApp.WriteRepo |
| 280 | + end |
| 281 | + end |
| 282 | +end |
| 283 | +``` |
| 284 | + |
| 285 | +## Best Practices |
| 286 | + |
| 287 | +1. **Organize migrations**: Run `mix ash.codegen` after each meaningful set of resource changes with a descriptive name: |
| 288 | + ```bash |
| 289 | + mix ash.codegen --name add_user_roles |
| 290 | + mix ash.codegen --name implement_post_tagging |
| 291 | + ``` |
| 292 | + |
| 293 | +2. **Use check constraints for domain invariants**: Enforce data integrity at the database level: |
| 294 | + ```elixir |
| 295 | + check_constraints do |
| 296 | + check_constraint :valid_status, check: "status IN ('pending', 'active', 'completed')" |
| 297 | + check_constraint :positive_balance, check: "balance >= 0" |
| 298 | + end |
| 299 | + ``` |
| 300 | + |
| 301 | +3. **Use custom statements for schema-only changes**: If you need to add database objects not directly tied to resources: |
| 302 | + ```elixir |
| 303 | + custom_statements do |
| 304 | + statement "CREATE EXTENSION IF NOT EXISTS \"pgcrypto\"" |
| 305 | + statement "CREATE INDEX users_search_idx ON users USING gin(search_vector)" |
| 306 | + end |
| 307 | + ``` |
| 308 | + |
| 309 | +Remember that using AshPostgres provides a full-featured PostgreSQL data layer for your Ash application, giving you both the structure and declarative approach of Ash along with the power and flexibility of PostgreSQL. |
0 commit comments