├── priv ├── dev_test_repo │ └── migrations │ │ ├── .gitkeep │ │ └── .gitkeep.license ├── resource_snapshots │ └── test_repo │ │ ├── authors │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── orgs │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── posts │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── profile │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── users │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── accounts │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── comments │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── managers │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── post_links │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── post_ratings │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── post_views │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ ├── comment_ratings │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json │ │ └── integer_posts │ │ ├── 20240405234211.json.license │ │ └── 20240405234211.json └── test_repo │ └── migrations │ └── 20240405234211_migrate_resources1.exs ├── .tool-versions ├── test ├── dev_test.db ├── dev_test.db.license ├── support │ ├── test_repo.ex │ ├── types │ │ ├── email.ex │ │ ├── status.ex │ │ ├── status_enum.ex │ │ ├── status_enum_no_cast.ex │ │ └── money.ex │ ├── dev_test_repo.ex │ ├── test_app.ex │ ├── resources │ │ ├── integer_post.ex │ │ ├── organization.ex │ │ ├── rating.ex │ │ ├── bio.ex │ │ ├── profile.ex │ │ ├── user.ex │ │ ├── account.ex │ │ ├── post_views.ex │ │ ├── post_link.ex │ │ ├── manager.ex │ │ ├── comment.ex │ │ ├── author.ex │ │ └── post.ex │ ├── repo_case.ex │ ├── domain.ex │ ├── test_custom_extension.ex │ ├── concat.ex │ └── relationships │ │ └── comments_containing_title.ex ├── test_helper.exs ├── enum_test.exs ├── type_test.exs ├── ecto_compatibility_test.exs ├── select_test.exs ├── bulk_destroy_test.exs ├── bulk_update_test.exs ├── aggregate_test.exs ├── polymorphism_test.exs ├── string_trim_test.exs ├── custom_index_test.exs ├── embeddable_resource_test.exs ├── unique_identity_test.exs ├── update_test.exs ├── upsert_test.exs ├── primary_key_test.exs ├── atomics_test.exs ├── manual_relationships_test.exs ├── bulk_create_test.exs ├── sort_test.exs ├── dev_migrations_test.exs └── load_test.exs ├── logos ├── small-logo.png └── small-logo.png.license ├── mix.lock.license ├── .tool-versions.license ├── documentation ├── dsls │ └── DSL-AshSqlite.DataLayer.md.license └── topics │ ├── development │ ├── testing.md │ └── migrations-and-tasks.md │ ├── about-ash-sqlite │ └── what-is-ash-sqlite.md │ ├── resources │ ├── references.md │ └── polymorphic-resources.md │ └── advanced │ ├── expressions.md │ └── manual-relationships.md ├── lib ├── functions │ ├── like.ex │ └── ilike.ex ├── ash_sqlite.ex ├── type.ex ├── custom_extension.ex ├── transformers │ ├── verify_repo.ex │ ├── validate_references.ex │ └── ensure_table_or_polymorphic.ex ├── manual_relationship.ex ├── statement.ex ├── mix │ ├── tasks │ │ ├── ash_sqlite.create.ex │ │ ├── ash_sqlite.drop.ex │ │ ├── ash_sqlite.rollback.ex │ │ ├── ash_sqlite.migrate.ex │ │ └── ash_sqlite.generate_migrations.ex │ └── helpers.ex ├── reference.ex ├── migration_generator │ └── phase.ex ├── custom_index.ex ├── data_layer │ └── info.ex └── repo.ex ├── .github ├── workflows │ └── elixir.yml └── dependabot.yml ├── .gitignore ├── LICENSES └── MIT.txt ├── .check.exs ├── .formatter.exs ├── config └── config.exs ├── README.md ├── CHANGELOG.md ├── mix.exs └── .credo.exs /priv/dev_test_repo/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.0.1 2 | elixir 1.18.4-otp-27 3 | pipx 1.8.0 4 | -------------------------------------------------------------------------------- /test/dev_test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_sqlite/HEAD/test/dev_test.db -------------------------------------------------------------------------------- /logos/small-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_sqlite/HEAD/logos/small-logo.png -------------------------------------------------------------------------------- /mix.lock.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /.tool-versions.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/dev_test.db.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/small-logo.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/dev_test_repo/migrations/.gitkeep.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /documentation/dsls/DSL-AshSqlite.DataLayer.md.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/authors/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/orgs/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/posts/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/profile/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/users/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/accounts/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/comments/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/managers/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/post_links/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/post_ratings/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/post_views/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/comment_ratings/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/integer_posts/20240405234211.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/support/test_repo.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.TestRepo do 6 | @moduledoc false 7 | use AshSqlite.Repo, 8 | otp_app: :ash_sqlite 9 | end 10 | -------------------------------------------------------------------------------- /test/support/types/email.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Test.Support.Types.Email do 6 | @moduledoc false 7 | use Ash.Type.NewType, 8 | subtype_of: :string 9 | end 10 | -------------------------------------------------------------------------------- /test/support/types/status.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Types.Status do 6 | @moduledoc false 7 | use Ash.Type.Enum, values: [:open, :closed] 8 | 9 | def storage_type, do: :string 10 | end 11 | -------------------------------------------------------------------------------- /test/support/types/status_enum.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Types.StatusEnum do 6 | @moduledoc false 7 | use Ash.Type.Enum, values: [:open, :closed] 8 | 9 | def storage_type, do: :status 10 | end 11 | -------------------------------------------------------------------------------- /lib/functions/like.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Functions.Like do 6 | @moduledoc """ 7 | Maps to the builtin sqlite function `like`. 8 | """ 9 | 10 | use Ash.Query.Function, name: :like 11 | 12 | def args, do: [[:string, :string]] 13 | end 14 | -------------------------------------------------------------------------------- /lib/functions/ilike.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Functions.ILike do 6 | @moduledoc """ 7 | Maps to the builtin sqlite function `ilike`. 8 | """ 9 | 10 | use Ash.Query.Function, name: :ilike 11 | 12 | def args, do: [[:string, :string]] 13 | end 14 | -------------------------------------------------------------------------------- /test/support/types/status_enum_no_cast.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Types.StatusEnumNoCast do 6 | @moduledoc false 7 | use Ash.Type.Enum, values: [:open, :closed] 8 | 9 | def storage_type, do: :status 10 | 11 | def cast_in_query?, do: false 12 | end 13 | -------------------------------------------------------------------------------- /lib/ash_sqlite.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite do 6 | @moduledoc """ 7 | The AshSqlite extension gives you tools to map a resource to a sqlite database table. 8 | 9 | For more, check out the [getting started guide](/documentation/tutorials/getting-started-with-ash-sqlite.md) 10 | """ 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | ExUnit.start() 6 | ExUnit.configure(stacktrace_depth: 100) 7 | 8 | AshSqlite.TestRepo.start_link() 9 | AshSqlite.DevTestRepo.start_link() 10 | 11 | Ecto.Adapters.SQL.Sandbox.mode(AshSqlite.TestRepo, :manual) 12 | Ecto.Adapters.SQL.Sandbox.mode(AshSqlite.DevTestRepo, :manual) 13 | -------------------------------------------------------------------------------- /documentation/topics/development/testing.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Testing With Sqlite 8 | 9 | Testing resources with SQLite generally requires passing `async?: false` to 10 | your tests, due to `SQLite`'s limitation of having a single write transaction 11 | open at any one time. 12 | 13 | This should be coupled with to make sure that Ash does not spawn any tasks. 14 | 15 | ```elixir 16 | config :ash, :disable_async?, true 17 | ``` 18 | -------------------------------------------------------------------------------- /test/support/dev_test_repo.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.DevTestRepo do 6 | @moduledoc false 7 | use AshSqlite.Repo, 8 | otp_app: :ash_sqlite 9 | 10 | def on_transaction_begin(data) do 11 | send(self(), data) 12 | end 13 | 14 | def prefer_transaction?, do: false 15 | 16 | def prefer_transaction_for_atomic_updates?, do: false 17 | end 18 | -------------------------------------------------------------------------------- /test/enum_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.EnumTest do 6 | @moduledoc false 7 | use AshSqlite.RepoCase, async: false 8 | alias AshSqlite.Test.Post 9 | 10 | require Ash.Query 11 | 12 | test "valid values are properly inserted" do 13 | Post 14 | |> Ash.Changeset.for_create(:create, %{title: "title", status: :open}) 15 | |> Ash.create!() 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: CI 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | branches: [main] 11 | pull_request: 12 | branches: [main] 13 | jobs: 14 | ash-ci: 15 | uses: ash-project/ash/.github/workflows/ash-ci.yml@main 16 | with: 17 | sqlite: true 18 | reuse: true 19 | secrets: 20 | hex_api_key: ${{ secrets.HEX_API_KEY }} 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | --- 6 | updates: 7 | - directory: / 8 | groups: 9 | dev-dependencies: 10 | dependency-type: development 11 | production-dependencies: 12 | dependency-type: production 13 | package-ecosystem: mix 14 | schedule: 15 | day: thursday 16 | interval: monthly 17 | versioning-strategy: lockfile-only 18 | version: 2 19 | -------------------------------------------------------------------------------- /test/type_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.TypeTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.Post 8 | 9 | require Ash.Query 10 | 11 | test "uuids can be used as strings in fragments" do 12 | uuid = Ash.UUID.generate() 13 | 14 | Post 15 | |> Ash.Query.filter(fragment("? = ?", id, type(^uuid, :uuid))) 16 | |> Ash.read!() 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/support/test_app.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.TestApp do 6 | @moduledoc false 7 | def start(_type, _args) do 8 | children = [ 9 | AshSqlite.TestRepo 10 | ] 11 | 12 | # See https://hexdocs.pm/elixir/Supervisor.html 13 | # for other strategies and supported options 14 | opts = [strategy: :one_for_one, name: AshSqlite.Supervisor] 15 | Supervisor.start_link(children, opts) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/ecto_compatibility_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.EctoCompatibilityTest do 6 | use AshSqlite.RepoCase, async: false 7 | require Ash.Query 8 | 9 | test "call Ecto.Repo.insert! via Ash Repo" do 10 | org = 11 | %AshSqlite.Test.Organization{ 12 | id: Ash.UUID.generate(), 13 | name: "The Org" 14 | } 15 | |> AshSqlite.TestRepo.insert!() 16 | 17 | assert org.name == "The Org" 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/types/money.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Money do 6 | @moduledoc false 7 | use Ash.Resource, 8 | data_layer: :embedded 9 | 10 | attributes do 11 | attribute :amount, :integer do 12 | public?(true) 13 | allow_nil?(false) 14 | constraints(min: 0) 15 | end 16 | 17 | attribute :currency, :atom do 18 | public?(true) 19 | constraints(one_of: [:eur, :usd]) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/select_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.SelectTest do 6 | @moduledoc false 7 | use AshSqlite.RepoCase, async: false 8 | alias AshSqlite.Test.Post 9 | 10 | require Ash.Query 11 | 12 | test "values not selected in the query are not present in the response" do 13 | Post 14 | |> Ash.Changeset.for_create(:create, %{title: "title"}) 15 | |> Ash.create!() 16 | 17 | assert [%{title: %Ash.NotLoaded{}}] = Ash.read!(Ash.Query.select(Post, :id)) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/bulk_destroy_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.BulkDestroyTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.Post 8 | 9 | test "bulk destroys honor changeset filters" do 10 | Ash.bulk_create!([%{title: "fred"}, %{title: "george"}], Post, :create) 11 | 12 | Post 13 | |> Ash.bulk_destroy!(:destroy_only_freds, %{}, return_errors?: true) 14 | 15 | # 😢 sad 16 | assert ["george"] = Ash.read!(Post) |> Enum.map(& &1.title) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/type.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Type do 6 | @moduledoc """ 7 | Sqlite specific callbacks for `Ash.Type`. 8 | 9 | Use this in addition to `Ash.Type`. 10 | """ 11 | 12 | @callback value_to_sqlite_default(Ash.Type.t(), Ash.Type.constraints(), term) :: 13 | {:ok, String.t()} | :error 14 | 15 | defmacro __using__(_) do 16 | quote do 17 | @behaviour AshSqlite.Type 18 | def value_to_sqlite_default(_, _, _), do: :error 19 | 20 | defoverridable value_to_sqlite_default: 3 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/support/resources/integer_post.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.IntegerPost do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshSqlite.Test.Domain, 9 | data_layer: AshSqlite.DataLayer 10 | 11 | sqlite do 12 | table "integer_posts" 13 | repo AshSqlite.TestRepo 14 | end 15 | 16 | actions do 17 | default_accept(:*) 18 | defaults([:create, :read, :update, :destroy]) 19 | end 20 | 21 | attributes do 22 | integer_primary_key(:id) 23 | attribute(:title, :string, public?: true) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/resources/organization.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Organization do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshSqlite.Test.Domain, 9 | data_layer: AshSqlite.DataLayer 10 | 11 | sqlite do 12 | table("orgs") 13 | repo(AshSqlite.TestRepo) 14 | end 15 | 16 | actions do 17 | default_accept(:*) 18 | defaults([:create, :read, :update, :destroy]) 19 | end 20 | 21 | attributes do 22 | uuid_primary_key(:id, writable?: true) 23 | attribute(:name, :string, public?: true) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/resources/rating.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Rating do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshSqlite.Test.Domain, 9 | data_layer: AshSqlite.DataLayer 10 | 11 | sqlite do 12 | polymorphic?(true) 13 | repo AshSqlite.TestRepo 14 | end 15 | 16 | actions do 17 | default_accept(:*) 18 | defaults([:create, :read, :update, :destroy]) 19 | end 20 | 21 | attributes do 22 | uuid_primary_key(:id) 23 | attribute(:score, :integer, public?: true) 24 | attribute(:resource_id, :uuid, public?: true) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/bulk_update_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.BulkUpdateTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.Post 8 | 9 | test "bulk updates honor update action filters" do 10 | Ash.bulk_create!([%{title: "fred"}, %{title: "george"}], Post, :create) 11 | 12 | Post 13 | |> Ash.bulk_update!(:update_only_freds, %{title: "fred_stuff"}, return_errors?: true) 14 | 15 | titles = 16 | Post 17 | |> Ash.read!() 18 | |> Enum.map(& &1.title) 19 | |> Enum.sort() 20 | 21 | assert titles == ["fred_stuff", "george"] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/support/resources/bio.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Bio do 6 | @moduledoc false 7 | use Ash.Resource, data_layer: :embedded 8 | 9 | actions do 10 | default_accept(:*) 11 | defaults([:create, :read, :update, :destroy]) 12 | end 13 | 14 | attributes do 15 | attribute(:title, :string, public?: true) 16 | attribute(:bio, :string, public?: true) 17 | attribute(:years_of_experience, :integer, public?: true) 18 | 19 | attribute :list_of_strings, {:array, :string} do 20 | public?(true) 21 | allow_nil?(true) 22 | default(nil) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/repo_case.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.RepoCase do 6 | @moduledoc false 7 | use ExUnit.CaseTemplate 8 | 9 | alias Ecto.Adapters.SQL.Sandbox 10 | 11 | using do 12 | quote do 13 | alias AshSqlite.TestRepo 14 | 15 | import Ecto 16 | import Ecto.Query 17 | import AshSqlite.RepoCase 18 | 19 | # and any other stuff 20 | end 21 | end 22 | 23 | setup tags do 24 | :ok = Sandbox.checkout(AshSqlite.TestRepo) 25 | 26 | unless tags[:async] do 27 | Sandbox.mode(AshSqlite.TestRepo, {:shared, self()}) 28 | end 29 | 30 | :ok 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/custom_extension.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.CustomExtension do 6 | @moduledoc """ 7 | A custom extension implementation. 8 | """ 9 | 10 | @callback install(version :: integer) :: String.t() 11 | 12 | @callback uninstall(version :: integer) :: String.t() 13 | 14 | defmacro __using__(name: name, latest_version: latest_version) do 15 | quote do 16 | @behaviour AshSqlite.CustomExtension 17 | 18 | @extension_name unquote(name) 19 | @extension_latest_version unquote(latest_version) 20 | 21 | def extension, do: {@extension_name, @extension_latest_version, &install/1, &uninstall/1} 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/support/resources/profile.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Profile do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshSqlite.Test.Domain, 9 | data_layer: AshSqlite.DataLayer 10 | 11 | sqlite do 12 | table("profile") 13 | repo(AshSqlite.TestRepo) 14 | end 15 | 16 | attributes do 17 | uuid_primary_key(:id, writable?: true) 18 | attribute(:description, :string, public?: true) 19 | end 20 | 21 | actions do 22 | default_accept(:*) 23 | defaults([:create, :read, :update, :destroy]) 24 | end 25 | 26 | relationships do 27 | belongs_to(:author, AshSqlite.Test.Author, public?: true) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/transformers/verify_repo.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Transformers.VerifyRepo do 6 | @moduledoc false 7 | use Spark.Dsl.Transformer 8 | alias Spark.Dsl.Transformer 9 | 10 | def after_compile?, do: true 11 | 12 | def transform(dsl) do 13 | repo = Transformer.get_option(dsl, [:sqlite], :repo) 14 | 15 | cond do 16 | match?({:error, _}, Code.ensure_compiled(repo)) -> 17 | {:error, "Could not find repo module #{repo}"} 18 | 19 | repo.__adapter__() != Ecto.Adapters.SQLite3 -> 20 | {:error, "Expected a repo using the sqlite adapter `Ecto.Adapters.SQLite3`"} 21 | 22 | true -> 23 | {:ok, dsl} 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/support/resources/user.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.User do 6 | @moduledoc false 7 | use Ash.Resource, domain: AshSqlite.Test.Domain, data_layer: AshSqlite.DataLayer 8 | 9 | actions do 10 | default_accept(:*) 11 | defaults([:create, :read, :update, :destroy]) 12 | end 13 | 14 | attributes do 15 | uuid_primary_key(:id) 16 | attribute(:is_active, :boolean, public?: true) 17 | end 18 | 19 | sqlite do 20 | table "users" 21 | repo(AshSqlite.TestRepo) 22 | end 23 | 24 | relationships do 25 | belongs_to(:organization, AshSqlite.Test.Organization, public?: true) 26 | has_many(:accounts, AshSqlite.Test.Account, public?: true) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/domain.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Domain do 6 | @moduledoc false 7 | use Ash.Domain 8 | 9 | resources do 10 | resource(AshSqlite.Test.Post) 11 | resource(AshSqlite.Test.Comment) 12 | resource(AshSqlite.Test.IntegerPost) 13 | resource(AshSqlite.Test.Rating) 14 | resource(AshSqlite.Test.PostLink) 15 | resource(AshSqlite.Test.PostView) 16 | resource(AshSqlite.Test.Author) 17 | resource(AshSqlite.Test.Profile) 18 | resource(AshSqlite.Test.User) 19 | resource(AshSqlite.Test.Account) 20 | resource(AshSqlite.Test.Organization) 21 | resource(AshSqlite.Test.Manager) 22 | end 23 | 24 | authorization do 25 | authorize(:when_requested) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/resources/account.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Account do 6 | @moduledoc false 7 | use Ash.Resource, domain: AshSqlite.Test.Domain, data_layer: AshSqlite.DataLayer 8 | 9 | actions do 10 | default_accept(:*) 11 | defaults([:create, :read, :update, :destroy]) 12 | end 13 | 14 | attributes do 15 | uuid_primary_key(:id) 16 | attribute(:is_active, :boolean, public?: true) 17 | end 18 | 19 | calculations do 20 | calculate( 21 | :active, 22 | :boolean, 23 | expr(is_active), 24 | public?: true 25 | ) 26 | end 27 | 28 | sqlite do 29 | table "accounts" 30 | repo(AshSqlite.TestRepo) 31 | end 32 | 33 | relationships do 34 | belongs_to(:user, AshSqlite.Test.User, public?: true) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/transformers/validate_references.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Transformers.ValidateReferences do 6 | @moduledoc false 7 | use Spark.Dsl.Transformer 8 | alias Spark.Dsl.Transformer 9 | 10 | def after_compile?, do: true 11 | 12 | def transform(dsl) do 13 | dsl 14 | |> AshSqlite.DataLayer.Info.references() 15 | |> Enum.each(fn reference -> 16 | unless Ash.Resource.Info.relationship(dsl, reference.relationship) do 17 | raise Spark.Error.DslError, 18 | path: [:sqlite, :references, reference.relationship], 19 | module: Transformer.get_persisted(dsl, :module), 20 | message: 21 | "Found reference configuration for relationship `#{reference.relationship}`, but no such relationship exists" 22 | end 23 | end) 24 | 25 | {:ok, dsl} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/orgs/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "uuid", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "text", 17 | "source": "name", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | } 23 | ], 24 | "table": "orgs", 25 | "hash": "106CE7B860A710A1275B05F81F2272B74678DC467F87E4179F9BEA8BC979613C", 26 | "repo": "Elixir.AshSqlite.TestRepo", 27 | "identities": [], 28 | "base_filter": null, 29 | "multitenancy": { 30 | "global": null, 31 | "strategy": null, 32 | "attribute": null 33 | }, 34 | "custom_indexes": [], 35 | "custom_statements": [], 36 | "has_create_action": true 37 | } -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/integer_posts/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "bigint", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": true, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "text", 17 | "source": "title", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | } 23 | ], 24 | "table": "integer_posts", 25 | "hash": "A3F61182D99B092A9D17E34B645823D8B0561B467B0195EFE0DA42947153D7E0", 26 | "repo": "Elixir.AshSqlite.TestRepo", 27 | "identities": [], 28 | "base_filter": null, 29 | "multitenancy": { 30 | "global": null, 31 | "strategy": null, 32 | "attribute": null 33 | }, 34 | "custom_indexes": [], 35 | "custom_statements": [], 36 | "has_create_action": true 37 | } -------------------------------------------------------------------------------- /test/aggregate_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.AggregatesTest do 6 | use AshSqlite.RepoCase, async: false 7 | 8 | require Ash.Query 9 | alias AshSqlite.Test.Post 10 | 11 | test "a count with a filter returns the appropriate value" do 12 | Ash.Seed.seed!(%Post{title: "foo"}) 13 | Ash.Seed.seed!(%Post{title: "foo"}) 14 | Ash.Seed.seed!(%Post{title: "bar"}) 15 | 16 | count = 17 | Post 18 | |> Ash.Query.filter(title == "foo") 19 | |> Ash.count!() 20 | 21 | assert count == 2 22 | end 23 | 24 | test "pagination returns the count" do 25 | Ash.Seed.seed!(%Post{title: "foo"}) 26 | Ash.Seed.seed!(%Post{title: "foo"}) 27 | Ash.Seed.seed!(%Post{title: "bar"}) 28 | 29 | Post 30 | |> Ash.Query.page(offset: 1, limit: 1, count: true) 31 | |> Ash.Query.for_read(:paginated) 32 | |> Ash.read!() 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/polymorphism_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.PolymorphismTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.{Post, Rating} 8 | 9 | require Ash.Query 10 | 11 | test "you can create related data" do 12 | Post 13 | |> Ash.Changeset.for_create(:create, rating: %{score: 10}) 14 | |> Ash.create!() 15 | 16 | assert [%{score: 10}] = 17 | Rating 18 | |> Ash.Query.set_context(%{data_layer: %{table: "post_ratings"}}) 19 | |> Ash.read!() 20 | end 21 | 22 | test "you can read related data" do 23 | Post 24 | |> Ash.Changeset.for_create(:create, rating: %{score: 10}) 25 | |> Ash.create!() 26 | 27 | assert [%{score: 10}] = 28 | Post 29 | |> Ash.Query.load(:ratings) 30 | |> Ash.read_one!() 31 | |> Map.get(:ratings) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/transformers/ensure_table_or_polymorphic.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Transformers.EnsureTableOrPolymorphic do 6 | @moduledoc false 7 | use Spark.Dsl.Transformer 8 | alias Spark.Dsl.Transformer 9 | 10 | def transform(dsl) do 11 | if Transformer.get_option(dsl, [:sqlite], :polymorphic?) || 12 | Transformer.get_option(dsl, [:sqlite], :table) do 13 | {:ok, dsl} 14 | else 15 | resource = Transformer.get_persisted(dsl, :module) 16 | 17 | raise Spark.Error.DslError, 18 | module: resource, 19 | message: """ 20 | Must configure a table for #{inspect(resource)}. 21 | 22 | For example: 23 | 24 | ```elixir 25 | sqlite do 26 | table "the_table" 27 | repo YourApp.Repo 28 | end 29 | ``` 30 | """, 31 | path: [:sqlite, :table] 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/string_trim_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.StringTrimTest do 6 | @moduledoc false 7 | 8 | use AshSqlite.RepoCase, async: false 9 | 10 | alias AshSqlite.Test.Post 11 | require Ash.Query 12 | 13 | test "string_trim can be used in filters to normalize whitespace" do 14 | Post 15 | |> Ash.Changeset.for_create(:create, %{title: "match"}) 16 | |> Ash.create!() 17 | 18 | # A direct equality filter on the untrimmed value should not match when comparing to the trimmed string 19 | assert [] == 20 | Post 21 | |> Ash.Query.filter(title == " match ") 22 | |> Ash.read!() 23 | 24 | # Using string_trim in the filter should match the record 25 | assert [%Post{title: "match"}] = 26 | Post 27 | |> Ash.Query.filter(title == string_trim(" match ")) 28 | |> Ash.read!() 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/support/resources/post_views.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.PostView do 6 | @moduledoc false 7 | use Ash.Resource, domain: AshSqlite.Test.Domain, data_layer: AshSqlite.DataLayer 8 | 9 | actions do 10 | default_accept(:*) 11 | defaults([:create, :read]) 12 | end 13 | 14 | attributes do 15 | create_timestamp(:time) 16 | attribute(:browser, :atom, constraints: [one_of: [:firefox, :chrome, :edge]], public?: true) 17 | end 18 | 19 | relationships do 20 | belongs_to :post, AshSqlite.Test.Post do 21 | public?(true) 22 | allow_nil?(false) 23 | attribute_writable?(true) 24 | end 25 | end 26 | 27 | resource do 28 | require_primary_key?(false) 29 | end 30 | 31 | sqlite do 32 | table "post_views" 33 | repo AshSqlite.TestRepo 34 | 35 | references do 36 | reference :post, ignore?: true 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where third-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | ash_sqlite-*.tar 28 | 29 | test_migration_path 30 | test_snapshots_path 31 | 32 | test/test.db 33 | test/test.db-shm 34 | test/test.db-wal 35 | 36 | test/dev_test.db 37 | test/dev_test.db-shm 38 | test/dev_test.db-wal 39 | notes/ 40 | -------------------------------------------------------------------------------- /test/support/test_custom_extension.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.TestCustomExtension do 6 | @moduledoc false 7 | 8 | use AshSqlite.CustomExtension, name: "demo-functions", latest_version: 1 9 | 10 | @impl true 11 | def install(0) do 12 | """ 13 | execute(\"\"\" 14 | CREATE OR REPLACE FUNCTION ash_demo_functions() 15 | RETURNS boolean AS $$ SELECT TRUE $$ 16 | LANGUAGE SQL 17 | IMMUTABLE; 18 | \"\"\") 19 | """ 20 | end 21 | 22 | @impl true 23 | def install(1) do 24 | """ 25 | execute(\"\"\" 26 | CREATE OR REPLACE FUNCTION ash_demo_functions() 27 | RETURNS boolean AS $$ SELECT FALSE $$ 28 | LANGUAGE SQL 29 | IMMUTABLE; 30 | \"\"\") 31 | """ 32 | end 33 | 34 | @impl true 35 | def uninstall(_version) do 36 | """ 37 | execute(\"\"\" 38 | DROP FUNCTION IF EXISTS ash_demo_functions() 39 | \"\"\") 40 | """ 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/custom_index_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.CustomIndexTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.Post 8 | 9 | require Ash.Query 10 | 11 | test "unique constraint errors are properly caught" do 12 | Post 13 | |> Ash.Changeset.for_create(:create, %{ 14 | title: "first", 15 | uniq_custom_one: "what", 16 | uniq_custom_two: "what2" 17 | }) 18 | |> Ash.create!() 19 | 20 | assert_raise Ash.Error.Invalid, 21 | ~r/Invalid value provided for uniq_custom_one: dude what the heck/, 22 | fn -> 23 | Post 24 | |> Ash.Changeset.for_create(:create, %{ 25 | title: "first", 26 | uniq_custom_one: "what", 27 | uniq_custom_two: "what2" 28 | }) 29 | |> Ash.create!() 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/manual_relationship.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.ManualRelationship do 6 | @moduledoc "A behavior for sqlite-specific manual relationship functionality" 7 | 8 | @callback ash_sqlite_join( 9 | source_query :: Ecto.Query.t(), 10 | opts :: Keyword.t(), 11 | current_binding :: term, 12 | destination_binding :: term, 13 | type :: :inner | :left, 14 | destination_query :: Ecto.Query.t() 15 | ) :: {:ok, Ecto.Query.t()} | {:error, term} 16 | 17 | @callback ash_sqlite_subquery( 18 | opts :: Keyword.t(), 19 | current_binding :: term, 20 | destination_binding :: term, 21 | destination_query :: Ecto.Query.t() 22 | ) :: {:ok, Ecto.Query.t()} | {:error, term} 23 | 24 | defmacro __using__(_) do 25 | quote do 26 | @behaviour AshSqlite.ManualRelationship 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /test/embeddable_resource_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.EmbeddableResourceTest do 6 | @moduledoc false 7 | use AshSqlite.RepoCase, async: false 8 | alias AshSqlite.Test.{Author, Bio, Post} 9 | 10 | require Ash.Query 11 | 12 | setup do 13 | post = 14 | Post 15 | |> Ash.Changeset.for_create(:create, %{title: "title"}) 16 | |> Ash.create!() 17 | 18 | %{post: post} 19 | end 20 | 21 | test "calculations can load json", %{post: post} do 22 | assert %{calc_returning_json: %AshSqlite.Test.Money{amount: 100, currency: :usd}} = 23 | Ash.load!(post, :calc_returning_json) 24 | end 25 | 26 | test "embeds with list attributes set to nil are loaded as nil" do 27 | post = 28 | Author 29 | |> Ash.Changeset.for_create(:create, %{bio: %Bio{list_of_strings: nil}}) 30 | |> Ash.create!() 31 | 32 | assert is_nil(post.bio.list_of_strings) 33 | 34 | post = Ash.reload!(post) 35 | 36 | assert is_nil(post.bio.list_of_strings) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /.check.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [ 6 | ## all available options with default values (see `mix check` docs for description) 7 | # parallel: true, 8 | # skipped: true, 9 | retry: false, 10 | ## list of tools (see `mix check` docs for defaults) 11 | tools: [ 12 | ## curated tools may be disabled (e.g. the check for compilation warnings) 13 | # {:compiler, false}, 14 | 15 | ## ...or adjusted (e.g. use one-line formatter for more compact credo output) 16 | # {:credo, "mix credo --format oneline"}, 17 | 18 | {:check_formatter, command: "mix spark.formatter --check"}, 19 | {:check_migrations, command: "mix test.check_migrations"}, 20 | {:reuse, command: ["pipx", "run", "reuse", "lint", "-q"]} 21 | ## custom new tools may be added (mix tasks or arbitrary commands) 22 | # {:my_mix_task, command: "mix release", env: %{"MIX_ENV" => "prod"}}, 23 | # {:my_arbitrary_tool, command: "npm test", cd: "assets"}, 24 | # {:my_arbitrary_script, command: ["my_script", "argument with spaces"], cd: "scripts"} 25 | ] 26 | ] 27 | -------------------------------------------------------------------------------- /test/support/concat.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Concat do 6 | @moduledoc false 7 | use Ash.Resource.Calculation 8 | require Ash.Query 9 | 10 | def init(opts) do 11 | if opts[:keys] && is_list(opts[:keys]) && Enum.all?(opts[:keys], &is_atom/1) do 12 | {:ok, opts} 13 | else 14 | {:error, "Expected a `keys` option for which keys to concat"} 15 | end 16 | end 17 | 18 | def expression(opts, %{arguments: %{separator: separator}}) do 19 | Enum.reduce(opts[:keys], nil, fn key, expr -> 20 | if expr do 21 | if separator do 22 | expr(^expr <> ^separator <> ^ref(key)) 23 | else 24 | expr(^expr <> ^ref(key)) 25 | end 26 | else 27 | expr(^ref(key)) 28 | end 29 | end) 30 | end 31 | 32 | def calculate(records, opts, %{separator: separator}) do 33 | Enum.map(records, fn record -> 34 | Enum.map_join(opts[:keys], separator, fn key -> 35 | to_string(Map.get(record, key)) 36 | end) 37 | end) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/support/resources/post_link.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.PostLink do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshSqlite.Test.Domain, 9 | data_layer: AshSqlite.DataLayer 10 | 11 | sqlite do 12 | table "post_links" 13 | repo AshSqlite.TestRepo 14 | end 15 | 16 | actions do 17 | default_accept(:*) 18 | defaults([:create, :read, :update, :destroy]) 19 | end 20 | 21 | identities do 22 | identity(:unique_link, [:source_post_id, :destination_post_id]) 23 | end 24 | 25 | attributes do 26 | attribute :state, :atom do 27 | public?(true) 28 | constraints(one_of: [:active, :archived]) 29 | default(:active) 30 | end 31 | end 32 | 33 | relationships do 34 | belongs_to :source_post, AshSqlite.Test.Post do 35 | public?(true) 36 | allow_nil?(false) 37 | primary_key?(true) 38 | end 39 | 40 | belongs_to :destination_post, AshSqlite.Test.Post do 41 | public?(true) 42 | allow_nil?(false) 43 | primary_key?(true) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | spark_locals_without_parens = [ 6 | base_filter_sql: 1, 7 | code?: 1, 8 | deferrable: 1, 9 | down: 1, 10 | exclusion_constraint_names: 1, 11 | foreign_key_names: 1, 12 | identity_index_names: 1, 13 | ignore?: 1, 14 | include: 1, 15 | index: 1, 16 | index: 2, 17 | message: 1, 18 | migrate?: 1, 19 | migration_defaults: 1, 20 | migration_ignore_attributes: 1, 21 | migration_types: 1, 22 | name: 1, 23 | on_delete: 1, 24 | on_update: 1, 25 | polymorphic?: 1, 26 | polymorphic_name: 1, 27 | polymorphic_on_delete: 1, 28 | polymorphic_on_update: 1, 29 | reference: 1, 30 | reference: 2, 31 | repo: 1, 32 | skip_unique_indexes: 1, 33 | statement: 1, 34 | statement: 2, 35 | strict?: 1, 36 | table: 1, 37 | unique: 1, 38 | unique_index_names: 1, 39 | up: 1, 40 | using: 1, 41 | where: 1 42 | ] 43 | 44 | [ 45 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 46 | locals_without_parens: spark_locals_without_parens, 47 | export: [ 48 | locals_without_parens: spark_locals_without_parens 49 | ] 50 | ] 51 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/post_views/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "utc_datetime_usec", 7 | "source": "time", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": false 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "text", 17 | "source": "browser", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | }, 23 | { 24 | "default": "nil", 25 | "size": null, 26 | "type": "uuid", 27 | "source": "post_id", 28 | "references": null, 29 | "allow_nil?": false, 30 | "generated?": false, 31 | "primary_key?": false 32 | } 33 | ], 34 | "table": "post_views", 35 | "hash": "D0749D9F514E36781D95F2967C97860C58C6DEAE95543DFAAB0E9C09A1480E93", 36 | "repo": "Elixir.AshSqlite.TestRepo", 37 | "identities": [], 38 | "base_filter": null, 39 | "multitenancy": { 40 | "global": null, 41 | "strategy": null, 42 | "attribute": null 43 | }, 44 | "custom_indexes": [], 45 | "custom_statements": [], 46 | "has_create_action": true 47 | } -------------------------------------------------------------------------------- /documentation/topics/about-ash-sqlite/what-is-ash-sqlite.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # What is AshSqlite? 8 | 9 | AshSqlite is the SQLite `Ash.DataLayer` for [Ash Framework](https://hexdocs.pm/ash). This doesn't have all of the features of [AshPostgres](https://hexdocs.pm/ash_postgres), but it does support most of the features of Ash data layers. The main feature missing is Aggregate support. 10 | 11 | Use this to persist records in a SQLite table. For example, the resource below would be persisted in a table called `tweets`: 12 | 13 | ```elixir 14 | defmodule MyApp.Tweet do 15 | use Ash.Resource, 16 | data_layer: AshSQLite.DataLayer 17 | 18 | attributes do 19 | integer_primary_key :id 20 | attribute :text, :string 21 | end 22 | 23 | relationships do 24 | belongs_to :author, MyApp.User 25 | end 26 | 27 | sqlite do 28 | table "tweets" 29 | repo MyApp.Repo 30 | end 31 | end 32 | ``` 33 | 34 | The table might look like this: 35 | 36 | | id | text | author_id | 37 | | --- | --------------- | --------- | 38 | | 1 | "Hello, world!" | 1 | 39 | 40 | Creating records would add to the table, destroying records would remove from the table, and updating records would update the table. 41 | -------------------------------------------------------------------------------- /documentation/topics/resources/references.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # References 8 | 9 | To configure the foreign keys on a resource, we use the `references` block. 10 | 11 | For example: 12 | 13 | ```elixir 14 | references do 15 | reference :post, on_delete: :delete, on_update: :update, name: "comments_to_posts_fkey" 16 | end 17 | ``` 18 | 19 | ## Important 20 | 21 | No resource logic is applied with these operations! No authorization rules or validations take place, and no notifications are issued. This operation happens *directly* in the database. That 22 | 23 | ## Nothing vs Restrict 24 | 25 | The difference between `:nothing` and `:restrict` is subtle and, if you are unsure, choose `:nothing` (the default behavior). `:restrict` will prevent the deletion from happening *before* the end of the database transaction, whereas `:nothing` allows the transaction to complete before doing so. This allows for things like updating or deleting the destination row and *then* updating updating or deleting the reference(as long as you are in a transaction). 26 | 27 | ## On Delete 28 | 29 | This option is called `on_delete`, instead of `on_destroy`, because it is hooking into the database level deletion, *not* a `destroy` action in your resource. 30 | -------------------------------------------------------------------------------- /test/support/resources/manager.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Manager do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshSqlite.Test.Domain, 9 | data_layer: AshSqlite.DataLayer 10 | 11 | sqlite do 12 | table("managers") 13 | repo(AshSqlite.TestRepo) 14 | end 15 | 16 | actions do 17 | default_accept(:*) 18 | defaults([:read, :update, :destroy]) 19 | 20 | create :create do 21 | primary?(true) 22 | argument(:organization_id, :uuid, allow_nil?: false) 23 | 24 | change(manage_relationship(:organization_id, :organization, type: :append_and_remove)) 25 | end 26 | end 27 | 28 | identities do 29 | identity(:uniq_code, :code) 30 | end 31 | 32 | attributes do 33 | uuid_primary_key(:id) 34 | attribute(:name, :string, public?: true) 35 | attribute(:code, :string, allow_nil?: false, public?: true) 36 | attribute(:must_be_present, :string, allow_nil?: false, public?: true) 37 | attribute(:role, :string, public?: true) 38 | end 39 | 40 | relationships do 41 | belongs_to :organization, AshSqlite.Test.Organization do 42 | public?(true) 43 | attribute_writable?(true) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/statement.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Statement do 6 | @moduledoc "Represents a custom statement to be run in generated migrations" 7 | 8 | @fields [ 9 | :name, 10 | :up, 11 | :down, 12 | :code? 13 | ] 14 | 15 | defstruct [:__spark_metadata__ | @fields] 16 | 17 | def fields, do: @fields 18 | 19 | @schema [ 20 | name: [ 21 | type: :atom, 22 | required: true, 23 | doc: """ 24 | The name of the statement, must be unique within the resource 25 | """ 26 | ], 27 | code?: [ 28 | type: :boolean, 29 | default: false, 30 | doc: """ 31 | By default, we place the strings inside of ecto migration's `execute/1` function and assume they are sql. Use this option if you want to provide custom elixir code to be placed directly in the migrations 32 | """ 33 | ], 34 | up: [ 35 | type: :string, 36 | doc: """ 37 | How to create the structure of the statement 38 | """, 39 | required: true 40 | ], 41 | down: [ 42 | type: :string, 43 | doc: "How to tear down the structure of the statement", 44 | required: true 45 | ] 46 | ] 47 | 48 | def schema, do: @schema 49 | end 50 | -------------------------------------------------------------------------------- /lib/mix/tasks/ash_sqlite.create.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.AshSqlite.Create do 6 | use Mix.Task 7 | 8 | @shortdoc "Creates the repository storage" 9 | 10 | @switches [ 11 | quiet: :boolean, 12 | domains: :string, 13 | no_compile: :boolean, 14 | no_deps_check: :boolean 15 | ] 16 | 17 | @aliases [ 18 | q: :quiet 19 | ] 20 | 21 | @moduledoc """ 22 | Create the storage for repos in all resources for the given (or configured) domains. 23 | 24 | ## Examples 25 | 26 | mix ash_sqlite.create 27 | mix ash_sqlite.create --domains MyApp.Domain1,MyApp.Domain2 28 | 29 | ## Command line options 30 | 31 | * `--domains` - the domains who's repos you want to migrate. 32 | * `--quiet` - do not log output 33 | * `--no-compile` - do not compile before creating 34 | * `--no-deps-check` - do not compile before creating 35 | """ 36 | 37 | @doc false 38 | def run(args) do 39 | {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) 40 | 41 | repos = AshSqlite.Mix.Helpers.repos!(opts, args) 42 | 43 | repo_args = 44 | Enum.flat_map(repos, fn repo -> 45 | ["-r", to_string(repo)] 46 | end) 47 | 48 | rest_opts = AshSqlite.Mix.Helpers.delete_arg(args, "--domains") 49 | 50 | Mix.Task.reenable("ecto.create") 51 | 52 | Mix.Task.run("ecto.create", repo_args ++ rest_opts) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/unique_identity_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.UniqueIdentityTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.Post 8 | 9 | require Ash.Query 10 | 11 | test "unique constraint errors are properly caught" do 12 | post = 13 | Post 14 | |> Ash.Changeset.for_create(:create, %{title: "title"}) 15 | |> Ash.create!() 16 | 17 | assert_raise Ash.Error.Invalid, 18 | ~r/Invalid value provided for id: has already been taken/, 19 | fn -> 20 | Post 21 | |> Ash.Changeset.for_create(:create, %{id: post.id}) 22 | |> Ash.create!() 23 | end 24 | end 25 | 26 | test "a unique constraint can be used to upsert when the resource has a base filter" do 27 | post = 28 | Post 29 | |> Ash.Changeset.for_create(:create, %{ 30 | title: "title", 31 | uniq_one: "fred", 32 | uniq_two: "astair", 33 | price: 10 34 | }) 35 | |> Ash.create!() 36 | 37 | new_post = 38 | Post 39 | |> Ash.Changeset.for_create(:create, %{ 40 | title: "title2", 41 | uniq_one: "fred", 42 | uniq_two: "astair" 43 | }) 44 | |> Ash.create!(upsert?: true, upsert_identity: :uniq_one_and_two) 45 | 46 | assert new_post.id == post.id 47 | assert new_post.price == 10 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/update_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.UpdateTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.Post 8 | 9 | require Ash.Query 10 | 11 | test "updating a record when multiple records are in the table will only update the desired record" do 12 | # This test is here because of a previous bug in update that caused 13 | # all records in the table to be updated. 14 | id_1 = Ash.UUID.generate() 15 | id_2 = Ash.UUID.generate() 16 | 17 | new_post_1 = 18 | Post 19 | |> Ash.Changeset.for_create(:create, %{ 20 | id: id_1, 21 | title: "new_post_1" 22 | }) 23 | |> Ash.create!() 24 | 25 | _new_post_2 = 26 | Post 27 | |> Ash.Changeset.for_create(:create, %{ 28 | id: id_2, 29 | title: "new_post_2" 30 | }) 31 | |> Ash.create!() 32 | 33 | {:ok, updated_post_1} = 34 | new_post_1 35 | |> Ash.Changeset.for_update(:update, %{ 36 | title: "new_post_1_updated" 37 | }) 38 | |> Ash.update() 39 | 40 | # It is deliberate that post 2 is re-fetched from the db after the 41 | # update to post 1. This ensure that post 2 was not updated. 42 | post_2 = Ash.get!(Post, id_2) 43 | 44 | assert updated_post_1.id == id_1 45 | assert updated_post_1.title == "new_post_1_updated" 46 | 47 | assert post_2.id == id_2 48 | assert post_2.title == "new_post_2" 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/accounts/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "uuid", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "boolean", 17 | "source": "is_active", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | }, 23 | { 24 | "default": "nil", 25 | "size": null, 26 | "type": "uuid", 27 | "source": "user_id", 28 | "references": { 29 | "name": "accounts_user_id_fkey", 30 | "table": "users", 31 | "on_delete": null, 32 | "multitenancy": { 33 | "global": null, 34 | "strategy": null, 35 | "attribute": null 36 | }, 37 | "primary_key?": true, 38 | "destination_attribute": "id", 39 | "on_update": null, 40 | "deferrable": false, 41 | "destination_attribute_default": null, 42 | "destination_attribute_generated": null 43 | }, 44 | "allow_nil?": true, 45 | "generated?": false, 46 | "primary_key?": false 47 | } 48 | ], 49 | "table": "accounts", 50 | "hash": "2320B8B55C597C2F07DED9B7BF714832FE22B0AA5E05959A4EA0553669BC368D", 51 | "repo": "Elixir.AshSqlite.TestRepo", 52 | "identities": [], 53 | "base_filter": null, 54 | "multitenancy": { 55 | "global": null, 56 | "strategy": null, 57 | "attribute": null 58 | }, 59 | "custom_indexes": [], 60 | "custom_statements": [], 61 | "has_create_action": true 62 | } -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/profile/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "uuid", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "text", 17 | "source": "description", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | }, 23 | { 24 | "default": "nil", 25 | "size": null, 26 | "type": "uuid", 27 | "source": "author_id", 28 | "references": { 29 | "name": "profile_author_id_fkey", 30 | "table": "authors", 31 | "on_delete": null, 32 | "multitenancy": { 33 | "global": null, 34 | "strategy": null, 35 | "attribute": null 36 | }, 37 | "primary_key?": true, 38 | "destination_attribute": "id", 39 | "on_update": null, 40 | "deferrable": false, 41 | "destination_attribute_default": null, 42 | "destination_attribute_generated": null 43 | }, 44 | "allow_nil?": true, 45 | "generated?": false, 46 | "primary_key?": false 47 | } 48 | ], 49 | "table": "profile", 50 | "hash": "710F812AC63D2051F6AB22912CE5304088AF1D8F03C2BAFDC07EB24FA62136C2", 51 | "repo": "Elixir.AshSqlite.TestRepo", 52 | "identities": [], 53 | "base_filter": null, 54 | "multitenancy": { 55 | "global": null, 56 | "strategy": null, 57 | "attribute": null 58 | }, 59 | "custom_indexes": [], 60 | "custom_statements": [], 61 | "has_create_action": true 62 | } -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/users/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "uuid", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "boolean", 17 | "source": "is_active", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | }, 23 | { 24 | "default": "nil", 25 | "size": null, 26 | "type": "uuid", 27 | "source": "organization_id", 28 | "references": { 29 | "name": "users_organization_id_fkey", 30 | "table": "orgs", 31 | "on_delete": null, 32 | "multitenancy": { 33 | "global": null, 34 | "strategy": null, 35 | "attribute": null 36 | }, 37 | "primary_key?": true, 38 | "destination_attribute": "id", 39 | "on_update": null, 40 | "deferrable": false, 41 | "destination_attribute_default": null, 42 | "destination_attribute_generated": null 43 | }, 44 | "allow_nil?": true, 45 | "generated?": false, 46 | "primary_key?": false 47 | } 48 | ], 49 | "table": "users", 50 | "hash": "F1D2233C0B448A17B31E8971DEF529020894252BBF5BAFD58D7280FA36249071", 51 | "repo": "Elixir.AshSqlite.TestRepo", 52 | "identities": [], 53 | "base_filter": null, 54 | "multitenancy": { 55 | "global": null, 56 | "strategy": null, 57 | "attribute": null 58 | }, 59 | "custom_indexes": [], 60 | "custom_statements": [], 61 | "has_create_action": true 62 | } -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import Config 6 | 7 | if Mix.env() == :dev do 8 | config :git_ops, 9 | mix_project: AshSqlite.MixProject, 10 | changelog_file: "CHANGELOG.md", 11 | repository_url: "https://github.com/ash-project/ash_sqlite", 12 | # Instructs the tool to manage your mix version in your `mix.exs` file 13 | # See below for more information 14 | manage_mix_version?: true, 15 | # Instructs the tool to manage the version in your README.md 16 | # Pass in `true` to use `"README.md"` or a string to customize 17 | manage_readme_version: [ 18 | "README.md", 19 | "documentation/tutorials/getting-started-with-ash-sqlite.md" 20 | ], 21 | version_tag_prefix: "v" 22 | end 23 | 24 | if Mix.env() == :test do 25 | config :ash, :validate_domain_resource_inclusion?, false 26 | config :ash, :validate_domain_config_inclusion?, false 27 | 28 | config :ash_sqlite, AshSqlite.TestRepo, 29 | database: Path.join(__DIR__, "../test/test.db"), 30 | pool_size: 1, 31 | migration_lock: false, 32 | pool: Ecto.Adapters.SQL.Sandbox, 33 | migration_primary_key: [name: :id, type: :binary_id] 34 | 35 | config :ash_sqlite, AshSqlite.DevTestRepo, 36 | database: Path.join(__DIR__, "../test/dev_test.db"), 37 | pool_size: 1, 38 | migration_lock: false, 39 | pool: Ecto.Adapters.SQL.Sandbox, 40 | migration_primary_key: [name: :id, type: :binary_id] 41 | 42 | config :ash_sqlite, 43 | ecto_repos: [AshSqlite.TestRepo, AshSqlite.DevTestRepo], 44 | ash_domains: [ 45 | AshSqlite.Test.Domain 46 | ] 47 | 48 | config :logger, level: :warning 49 | end 50 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/post_ratings/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "uuid", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "bigint", 17 | "source": "score", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | }, 23 | { 24 | "default": "nil", 25 | "size": null, 26 | "type": "uuid", 27 | "source": "resource_id", 28 | "references": { 29 | "name": "post_ratings_resource_id_fkey", 30 | "table": "posts", 31 | "on_delete": null, 32 | "multitenancy": { 33 | "global": null, 34 | "strategy": null, 35 | "attribute": null 36 | }, 37 | "primary_key?": true, 38 | "destination_attribute": "id", 39 | "on_update": null, 40 | "deferrable": false, 41 | "destination_attribute_default": "nil", 42 | "destination_attribute_generated": false 43 | }, 44 | "allow_nil?": true, 45 | "generated?": false, 46 | "primary_key?": false 47 | } 48 | ], 49 | "table": "post_ratings", 50 | "hash": "73A4E0A79F5A6449FFE48E2469FDC275723EF207780DA9027F3BBE3119DC0FFA", 51 | "repo": "Elixir.AshSqlite.TestRepo", 52 | "identities": [], 53 | "base_filter": null, 54 | "multitenancy": { 55 | "global": null, 56 | "strategy": null, 57 | "attribute": null 58 | }, 59 | "custom_indexes": [], 60 | "custom_statements": [], 61 | "has_create_action": true 62 | } -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/comment_ratings/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "uuid", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "bigint", 17 | "source": "score", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | }, 23 | { 24 | "default": "nil", 25 | "size": null, 26 | "type": "uuid", 27 | "source": "resource_id", 28 | "references": { 29 | "name": "comment_ratings_resource_id_fkey", 30 | "table": "comments", 31 | "on_delete": null, 32 | "multitenancy": { 33 | "global": null, 34 | "strategy": null, 35 | "attribute": null 36 | }, 37 | "primary_key?": true, 38 | "destination_attribute": "id", 39 | "on_update": null, 40 | "deferrable": false, 41 | "destination_attribute_default": "nil", 42 | "destination_attribute_generated": false 43 | }, 44 | "allow_nil?": true, 45 | "generated?": false, 46 | "primary_key?": false 47 | } 48 | ], 49 | "table": "comment_ratings", 50 | "hash": "88FFC6DC62CEA37397A9C16C51E43F6FF6EED6C34E4C529FFB4D20EF1BCFF98F", 51 | "repo": "Elixir.AshSqlite.TestRepo", 52 | "identities": [], 53 | "base_filter": null, 54 | "multitenancy": { 55 | "global": null, 56 | "strategy": null, 57 | "attribute": null 58 | }, 59 | "custom_indexes": [], 60 | "custom_statements": [], 61 | "has_create_action": true 62 | } -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/authors/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "uuid", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "text", 17 | "source": "first_name", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | }, 23 | { 24 | "default": "nil", 25 | "size": null, 26 | "type": "text", 27 | "source": "last_name", 28 | "references": null, 29 | "allow_nil?": true, 30 | "generated?": false, 31 | "primary_key?": false 32 | }, 33 | { 34 | "default": "nil", 35 | "size": null, 36 | "type": "map", 37 | "source": "bio", 38 | "references": null, 39 | "allow_nil?": true, 40 | "generated?": false, 41 | "primary_key?": false 42 | }, 43 | { 44 | "default": "nil", 45 | "size": null, 46 | "type": [ 47 | "array", 48 | "text" 49 | ], 50 | "source": "badges", 51 | "references": null, 52 | "allow_nil?": true, 53 | "generated?": false, 54 | "primary_key?": false 55 | } 56 | ], 57 | "table": "authors", 58 | "hash": "EFBB1E574CC263E6E650121801C48B4370F1C9A7C8A213BEF111BFC769BF6651", 59 | "repo": "Elixir.AshSqlite.TestRepo", 60 | "identities": [], 61 | "base_filter": null, 62 | "multitenancy": { 63 | "global": null, 64 | "strategy": null, 65 | "attribute": null 66 | }, 67 | "custom_indexes": [], 68 | "custom_statements": [], 69 | "has_create_action": true 70 | } -------------------------------------------------------------------------------- /documentation/topics/advanced/expressions.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Expressions 8 | 9 | In addition to the expressions listed in the [Ash expressions guide](https://hexdocs.pm/ash/expressions.html), AshSqlite provides the following expressions 10 | 11 | # Fragments 12 | 13 | Fragments allow you to use arbitrary sqlite expressions in your queries. Fragments can often be an escape hatch to allow you to do things that don't have something officially supported with Ash. 14 | 15 | ### Examples 16 | 17 | #### Simple expressions 18 | 19 | ```elixir 20 | fragment("? / ?", points, count) 21 | ``` 22 | 23 | #### Calling functions 24 | 25 | ```elixir 26 | fragment("repeat('hello', 4)") 27 | ``` 28 | 29 | #### Using entire queries 30 | 31 | ```elixir 32 | fragment("points > (SELECT SUM(points) FROM games WHERE user_id = ? AND id != ?)", user_id, id) 33 | ``` 34 | 35 | > ### a last resport {: .warning} 36 | > 37 | > Using entire queries as shown above is a last resort, but can sometimes be the best way to accomplish a given task. 38 | 39 | #### In calculations 40 | 41 | ```elixir 42 | calculations do 43 | calculate :lower_name, :string, expr( 44 | fragment("LOWER(?)", name) 45 | ) 46 | end 47 | ``` 48 | 49 | #### In migrations 50 | 51 | ```elixir 52 | create table(:managers, primary_key: false) do 53 | add :id, :uuid, null: false, default: fragment("UUID_GENERATE_V4()"), primary_key: true 54 | end 55 | ``` 56 | 57 | ## Like 58 | 59 | These wrap the sqlite builtin like operator 60 | 61 | Please be aware, these match _patterns_ not raw text. Use `contains/1` if you want to match text without supporting patterns, i.e `%` and `_` have semantic meaning! 62 | 63 | For example: 64 | 65 | ```elixir 66 | Ash.Query.filter(User, like(name, "%obo%")) # name contains obo anywhere in the string, case sensitively 67 | ``` 68 | -------------------------------------------------------------------------------- /test/upsert_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.UpsertTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.Post 8 | 9 | require Ash.Query 10 | 11 | test "upserting results in the same created_at timestamp, but a new updated_at timestamp" do 12 | id = Ash.UUID.generate() 13 | 14 | new_post = 15 | Post 16 | |> Ash.Changeset.for_create(:create, %{ 17 | id: id, 18 | title: "title2" 19 | }) 20 | |> Ash.create!(upsert?: true) 21 | 22 | assert new_post.id == id 23 | assert new_post.created_at == new_post.updated_at 24 | 25 | updated_post = 26 | Post 27 | |> Ash.Changeset.for_create(:create, %{ 28 | id: id, 29 | title: "title3" 30 | }) 31 | |> Ash.create!(upsert?: true) 32 | 33 | assert updated_post.id == id 34 | assert updated_post.created_at == new_post.created_at 35 | assert updated_post.created_at != updated_post.updated_at 36 | end 37 | 38 | test "upserting a field with a default sets to the new value" do 39 | id = Ash.UUID.generate() 40 | 41 | new_post = 42 | Post 43 | |> Ash.Changeset.for_create(:create, %{ 44 | id: id, 45 | title: "title2" 46 | }) 47 | |> Ash.create!(upsert?: true) 48 | 49 | assert new_post.id == id 50 | assert new_post.created_at == new_post.updated_at 51 | 52 | updated_post = 53 | Post 54 | |> Ash.Changeset.for_create(:create, %{ 55 | id: id, 56 | title: "title2", 57 | decimal: Decimal.new(5) 58 | }) 59 | |> Ash.create!(upsert?: true) 60 | 61 | assert updated_post.id == id 62 | assert Decimal.equal?(updated_post.decimal, Decimal.new(5)) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/reference.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Reference do 6 | @moduledoc "Represents the configuration of a reference (i.e foreign key)." 7 | defstruct [ 8 | :relationship, 9 | :on_delete, 10 | :on_update, 11 | :name, 12 | :deferrable, 13 | :__spark_metadata__, 14 | ignore?: false 15 | ] 16 | 17 | def schema do 18 | [ 19 | relationship: [ 20 | type: :atom, 21 | required: true, 22 | doc: "The relationship to be configured" 23 | ], 24 | ignore?: [ 25 | type: :boolean, 26 | doc: 27 | "If set to true, no reference is created for the given relationship. This is useful if you need to define it in some custom way" 28 | ], 29 | on_delete: [ 30 | type: {:one_of, [:delete, :nilify, :nothing, :restrict]}, 31 | doc: """ 32 | What should happen to records of this resource when the referenced record of the *destination* resource is deleted. 33 | """ 34 | ], 35 | on_update: [ 36 | type: {:one_of, [:update, :nilify, :nothing, :restrict]}, 37 | doc: """ 38 | What should happen to records of this resource when the referenced destination_attribute of the *destination* record is update. 39 | """ 40 | ], 41 | deferrable: [ 42 | type: {:one_of, [false, true, :initially]}, 43 | default: false, 44 | doc: """ 45 | Wether or not the constraint is deferrable. This only affects the migration generator. 46 | """ 47 | ], 48 | name: [ 49 | type: :string, 50 | doc: 51 | "The name of the foreign key to generate in the database. Defaults to __fkey" 52 | ] 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/mix/tasks/ash_sqlite.drop.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.AshSqlite.Drop do 6 | use Mix.Task 7 | 8 | @shortdoc "Drops the repository storage for the repos in the specified (or configured) domains" 9 | @default_opts [force: false, force_drop: false] 10 | 11 | @aliases [ 12 | f: :force, 13 | q: :quiet 14 | ] 15 | 16 | @switches [ 17 | force: :boolean, 18 | force_drop: :boolean, 19 | quiet: :boolean, 20 | domains: :string, 21 | no_compile: :boolean, 22 | no_deps_check: :boolean 23 | ] 24 | 25 | @moduledoc """ 26 | Drop the storage for the given repository. 27 | 28 | ## Examples 29 | 30 | mix ash_sqlite.drop 31 | mix ash_sqlite.drop -d MyApp.Domain1,MyApp.Domain2 32 | 33 | ## Command line options 34 | 35 | * `--domains` - the domains who's repos should be dropped 36 | * `-q`, `--quiet` - run the command quietly 37 | * `-f`, `--force` - do not ask for confirmation when dropping the database. 38 | Configuration is asked only when `:start_permanent` is set to true 39 | (typically in production) 40 | * `--no-compile` - do not compile before dropping 41 | * `--no-deps-check` - do not compile before dropping 42 | """ 43 | 44 | @doc false 45 | def run(args) do 46 | {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) 47 | opts = Keyword.merge(@default_opts, opts) 48 | 49 | repos = AshSqlite.Mix.Helpers.repos!(opts, args) 50 | 51 | repo_args = 52 | Enum.flat_map(repos, fn repo -> 53 | ["-r", to_string(repo)] 54 | end) 55 | 56 | rest_opts = AshSqlite.Mix.Helpers.delete_arg(args, "--domains") 57 | 58 | Mix.Task.reenable("ecto.drop") 59 | 60 | Mix.Task.run("ecto.drop", repo_args ++ rest_opts) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ![Logo](https://github.com/ash-project/ash/blob/main/logos/cropped-for-header-black-text.png?raw=true#gh-light-mode-only) 8 | ![Logo](https://github.com/ash-project/ash/blob/main/logos/cropped-for-header-white-text.png?raw=true#gh-dark-mojde-only) 9 | 10 | [![CI](https://github.com/ash-project/ash_sqlite/actions/workflows/elixir.yml/badge.svg)](https://github.com/ash-project/ash_sqlite/actions/workflows/elixir.yml) 11 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 12 | [![Hex version badge](https://img.shields.io/hexpm/v/ash_sqlite.svg)](https://hex.pm/packages/ash_sqlite) 13 | [![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/ash_sqlite) 14 | [![REUSE status](https://api.reuse.software/badge/github.com/ash-project/ash_sqlite)](https://api.reuse.software/info/github.com/ash-project/ash_sqlite) 15 | 16 | # AshSqlite 17 | 18 | Welcome! `AshSqlite` is the SQLite data layer for [Ash Framework](https://hexdocs.pm/ash). 19 | 20 | ## Tutorials 21 | 22 | - [Get Started](documentation/tutorials/getting-started-with-ash-sqlite.md) 23 | 24 | ## Topics 25 | 26 | - [What is AshSqlite?](documentation/topics/about-ash-sqlite/what-is-ash-sqlite.md) 27 | 28 | ### Resources 29 | 30 | - [References](documentation/topics/resources/references.md) 31 | - [Polymorphic Resources](documentation/topics/resources/polymorphic-resources.md) 32 | 33 | ### Development 34 | 35 | - [Migrations and tasks](documentation/topics/development/migrations-and-tasks.md) 36 | - [Testing](documentation/topics/development/testing.md) 37 | 38 | ### Advanced 39 | 40 | - [Expressions](documentation/topics/advanced/expressions.md) 41 | - [Manual Relationships](documentation/topics/advanced/manual-relationships.md) 42 | 43 | ## Reference 44 | 45 | - [AshSqlite.DataLayer DSL](documentation/dsls/DSL-AshSqlite.DataLayer.md) 46 | -------------------------------------------------------------------------------- /test/support/relationships/comments_containing_title.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Post.CommentsContainingTitle do 6 | @moduledoc false 7 | 8 | use Ash.Resource.ManualRelationship 9 | use AshSqlite.ManualRelationship 10 | require Ash.Query 11 | require Ecto.Query 12 | 13 | def load(posts, _opts, %{query: query, actor: actor, authorize?: authorize?}) do 14 | post_ids = Enum.map(posts, & &1.id) 15 | 16 | {:ok, 17 | query 18 | |> Ash.Query.filter(post_id in ^post_ids) 19 | |> Ash.Query.filter(contains(title, post.title)) 20 | |> Ash.read!(actor: actor, authorize?: authorize?) 21 | |> Enum.group_by(& &1.post_id)} 22 | end 23 | 24 | def ash_sqlite_join(query, _opts, current_binding, as_binding, :inner, destination_query) do 25 | {:ok, 26 | Ecto.Query.from(_ in query, 27 | join: dest in ^destination_query, 28 | as: ^as_binding, 29 | on: dest.post_id == as(^current_binding).id, 30 | on: fragment("instr(?, ?) > 0", dest.title, as(^current_binding).title) 31 | )} 32 | end 33 | 34 | def ash_sqlite_join(query, _opts, current_binding, as_binding, :left, destination_query) do 35 | {:ok, 36 | Ecto.Query.from(_ in query, 37 | left_join: dest in ^destination_query, 38 | as: ^as_binding, 39 | on: dest.post_id == as(^current_binding).id, 40 | on: fragment("instr(?, ?) > 0", dest.title, as(^current_binding).title) 41 | )} 42 | end 43 | 44 | def ash_sqlite_subquery(_opts, current_binding, as_binding, destination_query) do 45 | {:ok, 46 | Ecto.Query.from(_ in destination_query, 47 | where: parent_as(^current_binding).id == as(^as_binding).post_id, 48 | where: 49 | fragment("instr(?, ?) > 0", as(^as_binding).title, parent_as(^current_binding).title) 50 | )} 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/primary_key_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.PrimaryKeyTest do 6 | @moduledoc false 7 | use AshSqlite.RepoCase, async: false 8 | alias AshSqlite.Test.{IntegerPost, Post, PostView} 9 | 10 | require Ash.Query 11 | 12 | test "creates record with integer primary key" do 13 | assert %IntegerPost{} = 14 | IntegerPost |> Ash.Changeset.for_create(:create, %{title: "title"}) |> Ash.create!() 15 | end 16 | 17 | test "creates record with uuid primary key" do 18 | assert %Post{} = Post |> Ash.Changeset.for_create(:create, %{title: "title"}) |> Ash.create!() 19 | end 20 | 21 | describe "resources without a primary key" do 22 | test "records can be created" do 23 | post = 24 | Post 25 | |> Ash.Changeset.for_action(:create, %{title: "not very interesting"}) 26 | |> Ash.create!() 27 | 28 | assert {:ok, view} = 29 | PostView 30 | |> Ash.Changeset.for_action(:create, %{browser: :firefox, post_id: post.id}) 31 | |> Ash.create() 32 | 33 | assert view.browser == :firefox 34 | assert view.post_id == post.id 35 | assert DateTime.diff(DateTime.utc_now(), view.time, :microsecond) < 1_000_000 36 | end 37 | 38 | test "records can be queried" do 39 | post = 40 | Post 41 | |> Ash.Changeset.for_action(:create, %{title: "not very interesting"}) 42 | |> Ash.create!() 43 | 44 | expected = 45 | PostView 46 | |> Ash.Changeset.for_action(:create, %{browser: :firefox, post_id: post.id}) 47 | |> Ash.create!() 48 | 49 | assert {:ok, [actual]} = Ash.read(PostView) 50 | 51 | assert actual.time == expected.time 52 | assert actual.browser == expected.browser 53 | assert actual.post_id == expected.post_id 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/support/resources/comment.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Comment do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshSqlite.Test.Domain, 9 | data_layer: AshSqlite.DataLayer, 10 | authorizers: [ 11 | Ash.Policy.Authorizer 12 | ] 13 | 14 | policies do 15 | bypass action_type(:read) do 16 | # Check that the comment is in the same org (via post) as actor 17 | authorize_if(relates_to_actor_via([:post, :organization, :users])) 18 | end 19 | end 20 | 21 | sqlite do 22 | table "comments" 23 | repo(AshSqlite.TestRepo) 24 | 25 | references do 26 | reference(:post, on_delete: :delete, on_update: :update, name: "special_name_fkey") 27 | end 28 | end 29 | 30 | actions do 31 | default_accept(:*) 32 | defaults([:read, :update, :destroy]) 33 | 34 | create :create do 35 | primary?(true) 36 | argument(:rating, :map) 37 | 38 | change(manage_relationship(:rating, :ratings, on_missing: :ignore, on_match: :create)) 39 | end 40 | end 41 | 42 | attributes do 43 | uuid_primary_key(:id) 44 | attribute(:title, :string, public?: true) 45 | attribute(:likes, :integer, public?: true) 46 | attribute(:arbitrary_timestamp, :utc_datetime_usec, public?: true) 47 | create_timestamp(:created_at, writable?: true, public?: true) 48 | end 49 | 50 | relationships do 51 | belongs_to(:post, AshSqlite.Test.Post, public?: true) 52 | belongs_to(:author, AshSqlite.Test.Author, public?: true) 53 | 54 | has_many(:ratings, AshSqlite.Test.Rating, 55 | public?: true, 56 | destination_attribute: :resource_id, 57 | relationship_context: %{data_layer: %{table: "comment_ratings"}} 58 | ) 59 | 60 | has_many(:popular_ratings, AshSqlite.Test.Rating, 61 | public?: true, 62 | destination_attribute: :resource_id, 63 | relationship_context: %{data_layer: %{table: "comment_ratings"}}, 64 | filter: expr(score > 5) 65 | ) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/migration_generator/phase.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.MigrationGenerator.Phase do 6 | @moduledoc false 7 | 8 | defmodule Create do 9 | @moduledoc false 10 | defstruct [:table, :multitenancy, operations: [], options: [], commented?: false] 11 | 12 | import AshSqlite.MigrationGenerator.Operation.Helper, only: [as_atom: 1] 13 | 14 | def up(%{table: table, operations: operations, options: options}) do 15 | opts = 16 | if options[:strict?] do 17 | ~s', options: "STRICT"' 18 | else 19 | "" 20 | end 21 | 22 | "create table(:#{as_atom(table)}, primary_key: false#{opts}) do\n" <> 23 | Enum.map_join(operations, "\n", fn operation -> operation.__struct__.up(operation) end) <> 24 | "\nend" 25 | end 26 | 27 | def down(%{table: table}) do 28 | opts = "" 29 | 30 | "drop table(:#{as_atom(table)}#{opts})" 31 | end 32 | end 33 | 34 | defmodule Alter do 35 | @moduledoc false 36 | defstruct [:table, :multitenancy, operations: [], commented?: false] 37 | 38 | import AshSqlite.MigrationGenerator.Operation.Helper, only: [as_atom: 1] 39 | 40 | def up(%{table: table, operations: operations}) do 41 | body = 42 | operations 43 | |> Enum.map_join("\n", fn operation -> operation.__struct__.up(operation) end) 44 | |> String.trim() 45 | 46 | if body == "" do 47 | "" 48 | else 49 | opts = "" 50 | 51 | "alter table(:#{as_atom(table)}#{opts}) do\n" <> 52 | body <> 53 | "\nend" 54 | end 55 | end 56 | 57 | def down(%{table: table, operations: operations}) do 58 | body = 59 | operations 60 | |> Enum.reverse() 61 | |> Enum.map_join("\n", fn operation -> operation.__struct__.down(operation) end) 62 | |> String.trim() 63 | 64 | if body == "" do 65 | "" 66 | else 67 | opts = "" 68 | 69 | "alter table(:#{as_atom(table)}#{opts}) do\n" <> 70 | body <> 71 | "\nend" 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/atomics_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.AtomicsTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.Post 8 | 9 | import Ash.Expr 10 | 11 | test "atomics work on upserts" do 12 | id = Ash.UUID.generate() 13 | 14 | Post 15 | |> Ash.Changeset.for_create(:create, %{id: id, title: "foo", price: 1}, upsert?: true) 16 | |> Ash.Changeset.atomic_update(:price, expr(price + 1)) 17 | |> Ash.create!() 18 | 19 | Post 20 | |> Ash.Changeset.for_create(:create, %{id: id, title: "foo", price: 1}, upsert?: true) 21 | |> Ash.Changeset.atomic_update(:price, expr(price + 1)) 22 | |> Ash.create!() 23 | 24 | assert [%{price: 2}] = Post |> Ash.read!() 25 | end 26 | 27 | test "a basic atomic works" do 28 | post = 29 | Post 30 | |> Ash.Changeset.for_create(:create, %{title: "foo", price: 1}) 31 | |> Ash.create!() 32 | 33 | assert %{price: 2} = 34 | post 35 | |> Ash.Changeset.for_update(:update, %{}) 36 | |> Ash.Changeset.atomic_update(:price, expr(price + 1)) 37 | |> Ash.update!() 38 | end 39 | 40 | test "an atomic that violates a constraint will return the proper error" do 41 | post = 42 | Post 43 | |> Ash.Changeset.for_create(:create, %{title: "foo", price: 1}) 44 | |> Ash.create!() 45 | 46 | assert_raise Ash.Error.Invalid, ~r/does not exist/, fn -> 47 | post 48 | |> Ash.Changeset.for_update(:update, %{}) 49 | |> Ash.Changeset.atomic_update(:organization_id, Ash.UUID.generate()) 50 | |> Ash.update!() 51 | end 52 | end 53 | 54 | test "an atomic can refer to a calculation" do 55 | post = 56 | Post 57 | |> Ash.Changeset.for_create(:create, %{title: "foo", price: 1}) 58 | |> Ash.create!() 59 | 60 | post = 61 | post 62 | |> Ash.Changeset.for_update(:update, %{}) 63 | |> Ash.Changeset.atomic_update(:score, expr(score_after_winning)) 64 | |> Ash.update!() 65 | 66 | assert post.score == 1 67 | end 68 | 69 | test "an atomic can be attached to an action" do 70 | post = 71 | Post 72 | |> Ash.Changeset.for_create(:create, %{title: "foo", price: 1}) 73 | |> Ash.create!() 74 | 75 | assert Post.increment_score!(post, 2).score == 2 76 | 77 | assert Post.increment_score!(post, 2).score == 4 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/post_links/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "text", 7 | "source": "state", 8 | "references": null, 9 | "allow_nil?": true, 10 | "generated?": false, 11 | "primary_key?": false 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "uuid", 17 | "source": "source_post_id", 18 | "references": { 19 | "name": "post_links_source_post_id_fkey", 20 | "table": "posts", 21 | "on_delete": null, 22 | "multitenancy": { 23 | "global": null, 24 | "strategy": null, 25 | "attribute": null 26 | }, 27 | "primary_key?": true, 28 | "destination_attribute": "id", 29 | "on_update": null, 30 | "deferrable": false, 31 | "destination_attribute_default": null, 32 | "destination_attribute_generated": null 33 | }, 34 | "allow_nil?": false, 35 | "generated?": false, 36 | "primary_key?": true 37 | }, 38 | { 39 | "default": "nil", 40 | "size": null, 41 | "type": "uuid", 42 | "source": "destination_post_id", 43 | "references": { 44 | "name": "post_links_destination_post_id_fkey", 45 | "table": "posts", 46 | "on_delete": null, 47 | "multitenancy": { 48 | "global": null, 49 | "strategy": null, 50 | "attribute": null 51 | }, 52 | "primary_key?": true, 53 | "destination_attribute": "id", 54 | "on_update": null, 55 | "deferrable": false, 56 | "destination_attribute_default": null, 57 | "destination_attribute_generated": null 58 | }, 59 | "allow_nil?": false, 60 | "generated?": false, 61 | "primary_key?": true 62 | } 63 | ], 64 | "table": "post_links", 65 | "hash": "6ADC017A784C2619574DE223A15A29ECAF6D67C0543DF67A8E4E215E8F8ED300", 66 | "repo": "Elixir.AshSqlite.TestRepo", 67 | "identities": [ 68 | { 69 | "name": "unique_link", 70 | "keys": [ 71 | "source_post_id", 72 | "destination_post_id" 73 | ], 74 | "base_filter": null, 75 | "index_name": "post_links_unique_link_index" 76 | } 77 | ], 78 | "base_filter": null, 79 | "multitenancy": { 80 | "global": null, 81 | "strategy": null, 82 | "attribute": null 83 | }, 84 | "custom_indexes": [], 85 | "custom_statements": [], 86 | "has_create_action": true 87 | } -------------------------------------------------------------------------------- /test/support/resources/author.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Author do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshSqlite.Test.Domain, 9 | data_layer: AshSqlite.DataLayer 10 | 11 | sqlite do 12 | table("authors") 13 | repo(AshSqlite.TestRepo) 14 | end 15 | 16 | attributes do 17 | uuid_primary_key(:id, writable?: true) 18 | attribute(:first_name, :string, public?: true) 19 | attribute(:last_name, :string, public?: true) 20 | attribute(:bio, AshSqlite.Test.Bio, public?: true) 21 | attribute(:badges, {:array, :atom}, public?: true) 22 | end 23 | 24 | actions do 25 | default_accept(:*) 26 | defaults([:create, :read, :update, :destroy]) 27 | end 28 | 29 | relationships do 30 | has_one(:profile, AshSqlite.Test.Profile, public?: true) 31 | has_many(:posts, AshSqlite.Test.Post, public?: true) 32 | end 33 | 34 | calculations do 35 | calculate(:title, :string, expr(bio[:title])) 36 | calculate(:full_name, :string, expr(first_name <> " " <> last_name)) 37 | # calculate(:full_name_with_nils, :string, expr(string_join([first_name, last_name], " "))) 38 | # calculate(:full_name_with_nils_no_joiner, :string, expr(string_join([first_name, last_name]))) 39 | # calculate(:split_full_name, {:array, :string}, expr(string_split(full_name))) 40 | 41 | calculate(:first_name_or_bob, :string, expr(first_name || "bob")) 42 | calculate(:first_name_and_bob, :string, expr(first_name && "bob")) 43 | 44 | calculate( 45 | :conditional_full_name, 46 | :string, 47 | expr( 48 | if( 49 | is_nil(first_name) or is_nil(last_name), 50 | "(none)", 51 | first_name <> " " <> last_name 52 | ) 53 | ) 54 | ) 55 | 56 | calculate( 57 | :nested_conditional, 58 | :string, 59 | expr( 60 | if( 61 | is_nil(first_name), 62 | "No First Name", 63 | if( 64 | is_nil(last_name), 65 | "No Last Name", 66 | first_name <> " " <> last_name 67 | ) 68 | ) 69 | ) 70 | ) 71 | 72 | calculate :param_full_name, 73 | :string, 74 | {AshSqlite.Test.Concat, keys: [:first_name, :last_name]} do 75 | argument(:separator, :string, default: " ", constraints: [allow_empty?: true, trim?: false]) 76 | end 77 | 78 | calculate(:post_titles, {:array, :string}, expr(list(posts, field: :title))) 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/managers/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "uuid", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "text", 17 | "source": "name", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | }, 23 | { 24 | "default": "nil", 25 | "size": null, 26 | "type": "text", 27 | "source": "code", 28 | "references": null, 29 | "allow_nil?": false, 30 | "generated?": false, 31 | "primary_key?": false 32 | }, 33 | { 34 | "default": "nil", 35 | "size": null, 36 | "type": "text", 37 | "source": "must_be_present", 38 | "references": null, 39 | "allow_nil?": false, 40 | "generated?": false, 41 | "primary_key?": false 42 | }, 43 | { 44 | "default": "nil", 45 | "size": null, 46 | "type": "text", 47 | "source": "role", 48 | "references": null, 49 | "allow_nil?": true, 50 | "generated?": false, 51 | "primary_key?": false 52 | }, 53 | { 54 | "default": "nil", 55 | "size": null, 56 | "type": "uuid", 57 | "source": "organization_id", 58 | "references": { 59 | "name": "managers_organization_id_fkey", 60 | "table": "orgs", 61 | "on_delete": null, 62 | "multitenancy": { 63 | "global": null, 64 | "strategy": null, 65 | "attribute": null 66 | }, 67 | "primary_key?": true, 68 | "destination_attribute": "id", 69 | "on_update": null, 70 | "deferrable": false, 71 | "destination_attribute_default": null, 72 | "destination_attribute_generated": null 73 | }, 74 | "allow_nil?": true, 75 | "generated?": false, 76 | "primary_key?": false 77 | } 78 | ], 79 | "table": "managers", 80 | "hash": "1A4EFC8497F6A73543858892D6324407A7060AC2585EDCA9A759D1E8AF509DEF", 81 | "repo": "Elixir.AshSqlite.TestRepo", 82 | "identities": [ 83 | { 84 | "name": "uniq_code", 85 | "keys": [ 86 | "code" 87 | ], 88 | "base_filter": null, 89 | "index_name": "managers_uniq_code_index" 90 | } 91 | ], 92 | "base_filter": null, 93 | "multitenancy": { 94 | "global": null, 95 | "strategy": null, 96 | "attribute": null 97 | }, 98 | "custom_indexes": [], 99 | "custom_statements": [], 100 | "has_create_action": true 101 | } -------------------------------------------------------------------------------- /documentation/topics/resources/polymorphic-resources.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Polymorphic Resources 8 | 9 | To support leveraging the same resource backed by multiple tables (useful for things like polymorphic associations), AshSqlite supports setting the `data_layer.table` context for a given resource. For this example, lets assume that you have a `MyApp.Post` resource and a `MyApp.Comment` resource. For each of those resources, users can submit `reactions`. However, you want a separate table for `post_reactions` and `comment_reactions`. You could accomplish that like so: 10 | 11 | ```elixir 12 | defmodule MyApp.Reaction do 13 | use Ash.Resource, 14 | domain: MyApp.Domain, 15 | data_layer: AshSqlite.DataLayer 16 | 17 | sqlite do 18 | polymorphic? true # Without this, `table` is a required configuration 19 | end 20 | 21 | attributes do 22 | attribute(:resource_id, :uuid) 23 | end 24 | 25 | ... 26 | end 27 | ``` 28 | 29 | Then, in your related resources, you set the table context like so: 30 | 31 | ```elixir 32 | defmodule MyApp.Post do 33 | use Ash.Resource, 34 | domain: MyApp.Domain, 35 | data_layer: AshSqlite.DataLayer 36 | 37 | ... 38 | 39 | relationships do 40 | has_many :reactions, MyApp.Reaction, 41 | relationship_context: %{data_layer: %{table: "post_reactions"}}, 42 | destination_attribute: :resource_id 43 | end 44 | end 45 | 46 | defmodule MyApp.Comment do 47 | use Ash.Resource, 48 | domain: MyApp.Domain, 49 | data_layer: AshSqlite.DataLayer 50 | 51 | ... 52 | 53 | relationships do 54 | has_many :reactions, MyApp.Reaction, 55 | relationship_context: %{data_layer: %{table: "comment_reactions"}}, 56 | destination_attribute: :resource_id 57 | end 58 | end 59 | ``` 60 | 61 | With this, when loading or editing related data, ash will automatically set that context. 62 | For managing related data, see `Ash.Changeset.manage_relationship/4` and other relationship functions 63 | in `Ash.Changeset` 64 | 65 | ## Table specific actions 66 | 67 | To make actions use a specific table, you can use the `set_context` query preparation/change. 68 | 69 | For example: 70 | 71 | ```elixir 72 | defmodule MyApp.Reaction do 73 | actions do 74 | read :for_comments do 75 | prepare set_context(%{data_layer: %{table: "comment_reactions"}}) 76 | end 77 | 78 | read :for_posts do 79 | prepare set_context(%{data_layer: %{table: "post_reactions"}}) 80 | end 81 | end 82 | end 83 | ``` 84 | 85 | ## Migrations 86 | 87 | When a migration is marked as `polymorphic? true`, the migration generator will look at 88 | all resources that are related to it, that set the `%{data_layer: %{table: "table"}}` context. 89 | For each of those, a migration is generated/managed automatically. This means that adding reactions 90 | to a new resource is as easy as adding the relationship and table context, and then running 91 | `mix ash_sqlite.generate_migrations`. 92 | -------------------------------------------------------------------------------- /lib/mix/tasks/ash_sqlite.rollback.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.AshSqlite.Rollback do 6 | use Mix.Task 7 | 8 | import AshSqlite.Mix.Helpers, 9 | only: [migrations_path: 2] 10 | 11 | @shortdoc "Rolls back the repository migrations for all repositories in the provided (or configured) domains" 12 | 13 | @moduledoc """ 14 | Reverts applied migrations in the given repository. 15 | Migrations are expected at "priv/YOUR_REPO/migrations" directory 16 | of the current application but it can be configured by specifying 17 | the `:priv` key under the repository configuration. 18 | Runs the latest applied migration by default. To roll back to 19 | a version number, supply `--to version_number`. To roll back a 20 | specific number of times, use `--step n`. To undo all applied 21 | migrations, provide `--all`. 22 | 23 | This is only really useful if your domain or domains only use a single repo. 24 | If you have multiple repos and you want to run a single migration and/or 25 | migrate/roll them back to different points, you will need to use the 26 | ecto specific task, `mix ecto.migrate` and provide your repo name. 27 | 28 | ## Examples 29 | mix ash_sqlite.rollback 30 | mix ash_sqlite.rollback -r Custom.Repo 31 | mix ash_sqlite.rollback -n 3 32 | mix ash_sqlite.rollback --step 3 33 | mix ash_sqlite.rollback -v 20080906120000 34 | mix ash_sqlite.rollback --to 20080906120000 35 | 36 | ## Command line options 37 | * `--domains` - the domains who's repos should be rolledback 38 | * `--all` - revert all applied migrations 39 | * `--step` / `-n` - revert n number of applied migrations 40 | * `--to` / `-v` - revert all migrations down to and including version 41 | * `--quiet` - do not log migration commands 42 | * `--pool-size` - the pool size if the repository is started only for the task (defaults to 1) 43 | * `--log-sql` - log the raw sql migrations are running 44 | """ 45 | 46 | @doc false 47 | def run(args) do 48 | {opts, _, _} = 49 | OptionParser.parse(args, 50 | switches: [ 51 | all: :boolean, 52 | step: :integer, 53 | to: :integer, 54 | start: :boolean, 55 | quiet: :boolean, 56 | pool_size: :integer, 57 | log_sql: :boolean 58 | ], 59 | aliases: [n: :step, v: :to] 60 | ) 61 | 62 | repos = AshSqlite.Mix.Helpers.repos!(opts, args) 63 | 64 | repo_args = 65 | Enum.flat_map(repos, fn repo -> 66 | ["-r", to_string(repo)] 67 | end) 68 | 69 | rest_opts = 70 | args 71 | |> AshSqlite.Mix.Helpers.delete_arg("--domains") 72 | |> AshSqlite.Mix.Helpers.delete_arg("--migrations-path") 73 | 74 | Mix.Task.reenable("ecto.rollback") 75 | 76 | for repo <- repos do 77 | Mix.Task.run( 78 | "ecto.rollback", 79 | repo_args ++ rest_opts ++ ["--migrations-path", migrations_path(opts, repo)] 80 | ) 81 | 82 | Mix.Task.reenable("ecto.rollback") 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/comments/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "uuid", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "text", 17 | "source": "title", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | }, 23 | { 24 | "default": "nil", 25 | "size": null, 26 | "type": "bigint", 27 | "source": "likes", 28 | "references": null, 29 | "allow_nil?": true, 30 | "generated?": false, 31 | "primary_key?": false 32 | }, 33 | { 34 | "default": "nil", 35 | "size": null, 36 | "type": "utc_datetime_usec", 37 | "source": "arbitrary_timestamp", 38 | "references": null, 39 | "allow_nil?": true, 40 | "generated?": false, 41 | "primary_key?": false 42 | }, 43 | { 44 | "default": "nil", 45 | "size": null, 46 | "type": "utc_datetime_usec", 47 | "source": "created_at", 48 | "references": null, 49 | "allow_nil?": false, 50 | "generated?": false, 51 | "primary_key?": false 52 | }, 53 | { 54 | "default": "nil", 55 | "size": null, 56 | "type": "uuid", 57 | "source": "post_id", 58 | "references": { 59 | "name": "special_name_fkey", 60 | "table": "posts", 61 | "on_delete": "delete", 62 | "multitenancy": { 63 | "global": null, 64 | "strategy": null, 65 | "attribute": null 66 | }, 67 | "primary_key?": true, 68 | "destination_attribute": "id", 69 | "on_update": "update", 70 | "deferrable": false, 71 | "destination_attribute_default": null, 72 | "destination_attribute_generated": null 73 | }, 74 | "allow_nil?": true, 75 | "generated?": false, 76 | "primary_key?": false 77 | }, 78 | { 79 | "default": "nil", 80 | "size": null, 81 | "type": "uuid", 82 | "source": "author_id", 83 | "references": { 84 | "name": "comments_author_id_fkey", 85 | "table": "authors", 86 | "on_delete": null, 87 | "multitenancy": { 88 | "global": null, 89 | "strategy": null, 90 | "attribute": null 91 | }, 92 | "primary_key?": true, 93 | "destination_attribute": "id", 94 | "on_update": null, 95 | "deferrable": false, 96 | "destination_attribute_default": null, 97 | "destination_attribute_generated": null 98 | }, 99 | "allow_nil?": true, 100 | "generated?": false, 101 | "primary_key?": false 102 | } 103 | ], 104 | "table": "comments", 105 | "hash": "4F081363C965C68A8E3CC755BCA058C9DC0FB18F5BE5B44FEBEB41B787727702", 106 | "repo": "Elixir.AshSqlite.TestRepo", 107 | "identities": [], 108 | "base_filter": null, 109 | "multitenancy": { 110 | "global": null, 111 | "strategy": null, 112 | "attribute": null 113 | }, 114 | "custom_indexes": [], 115 | "custom_statements": [], 116 | "has_create_action": true 117 | } -------------------------------------------------------------------------------- /lib/custom_index.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.CustomIndex do 6 | @moduledoc "Represents a custom index on the table backing a resource" 7 | @fields [ 8 | :table, 9 | :fields, 10 | :name, 11 | :unique, 12 | :using, 13 | :where, 14 | :include, 15 | :message 16 | ] 17 | 18 | defstruct [:__spark_metadata__ | @fields] 19 | 20 | def fields, do: @fields 21 | 22 | @schema [ 23 | fields: [ 24 | type: {:wrap_list, {:or, [:atom, :string]}}, 25 | doc: "The fields to include in the index." 26 | ], 27 | name: [ 28 | type: :string, 29 | doc: "the name of the index. Defaults to \"\#\{table\}_\#\{column\}_index\"." 30 | ], 31 | unique: [ 32 | type: :boolean, 33 | doc: "indicates whether the index should be unique.", 34 | default: false 35 | ], 36 | using: [ 37 | type: :string, 38 | doc: "configures the index type." 39 | ], 40 | where: [ 41 | type: :string, 42 | doc: "specify conditions for a partial index." 43 | ], 44 | message: [ 45 | type: :string, 46 | doc: "A custom message to use for unique indexes that have been violated" 47 | ], 48 | include: [ 49 | type: {:list, :string}, 50 | doc: 51 | "specify fields for a covering index. This is not supported by all databases. For more information on SQLite support, please read the official docs." 52 | ] 53 | ] 54 | 55 | def schema, do: @schema 56 | 57 | # sobelow_skip ["DOS.StringToAtom"] 58 | def transform(%__MODULE__{fields: fields} = index) do 59 | index = %{ 60 | index 61 | | fields: 62 | Enum.map(fields, fn field -> 63 | if is_atom(field) do 64 | field 65 | else 66 | String.to_atom(field) 67 | end 68 | end) 69 | } 70 | 71 | cond do 72 | index.name -> 73 | if Regex.match?(~r/^[0-9a-zA-Z_]+$/, index.name) do 74 | {:ok, index} 75 | else 76 | {:error, 77 | "Custom index name #{index.name} is not valid. Must have letters, numbers and underscores only"} 78 | end 79 | 80 | mismatched_field = 81 | Enum.find(index.fields, fn field -> 82 | !Regex.match?(~r/^[0-9a-zA-Z_]+$/, to_string(field)) 83 | end) -> 84 | {:error, 85 | """ 86 | Custom index field #{mismatched_field} contains invalid index name characters. 87 | 88 | A name must be set manually, i.e 89 | 90 | `name: "your_desired_index_name"` 91 | 92 | Index names must have letters, numbers and underscores only 93 | """} 94 | 95 | true -> 96 | {:ok, index} 97 | end 98 | end 99 | 100 | def name(_resource, %{name: name}) when is_binary(name) do 101 | name 102 | end 103 | 104 | # sobelow_skip ["DOS.StringToAtom"] 105 | def name(table, %{fields: fields}) do 106 | [table, fields, "index"] 107 | |> List.flatten() 108 | |> Enum.map(&to_string(&1)) 109 | |> Enum.map(&String.replace(&1, ~r"[^\w_]", "_")) 110 | |> Enum.map_join("_", &String.replace_trailing(&1, "_", "")) 111 | |> String.to_atom() 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /documentation/topics/advanced/manual-relationships.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Join Manual Relationships 8 | 9 | See [Defining Manual Relationships](https://hexdocs.pm/ash/defining-manual-relationships.html) for an idea of manual relationships in general. 10 | Manual relationships allow for expressing complex/non-typical relationships between resources in a standard way. 11 | Individual data layers may interact with manual relationships in their own way, so see their corresponding guides. 12 | 13 | ## Example 14 | 15 | ```elixir 16 | # in the resource 17 | 18 | relationships do 19 | has_many :tickets_above_threshold, Helpdesk.Support.Ticket do 20 | manual Helpdesk.Support.Ticket.Relationships.TicketsAboveThreshold 21 | end 22 | end 23 | 24 | # implementation 25 | defmodule Helpdesk.Support.Ticket.Relationships.TicketsAboveThreshold do 26 | use Ash.Resource.ManualRelationship 27 | use AshSqlite.ManualRelationship 28 | 29 | require Ash.Query 30 | require Ecto.Query 31 | 32 | def load(records, _opts, %{query: query, actor: actor, authorize?: authorize?}) do 33 | # Use existing records to limit resultds 34 | rep_ids = Enum.map(records, & &1.id) 35 | # Using Ash to get the destination records is ideal, so you can authorize access like normal 36 | # but if you need to use a raw ecto query here, you can. As long as you return the right structure. 37 | 38 | {:ok, 39 | query 40 | |> Ash.Query.filter(representative_id in ^rep_ids) 41 | |> Ash.Query.filter(priority > representative.priority_threshold) 42 | |> Helpdesk.Support.read!(actor: actor, authorize?: authorize?) 43 | # Return the items grouped by the primary key of the source, i.e representative.id => [...tickets above threshold] 44 | |> Enum.group_by(& &1.representative_id)} 45 | end 46 | 47 | # query is the "source" query that is being built. 48 | 49 | # _opts are options provided to the manual relationship, i.e `{Manual, opt: :val}` 50 | 51 | # current_binding is what the source of the relationship is bound to. Access fields with `as(^current_binding).field` 52 | 53 | # as_binding is the binding that your join should create. When you join, make sure you say `as: ^as_binding` on the 54 | # part of the query that represents the destination of the relationship 55 | 56 | # type is `:inner` or `:left`. 57 | # destination_query is what you should join to to add the destination to the query, i.e `join: dest in ^destination-query` 58 | def ash_sqlite_join(query, _opts, current_binding, as_binding, :inner, destination_query) do 59 | {:ok, 60 | Ecto.Query.from(_ in query, 61 | join: dest in ^destination_query, 62 | as: ^as_binding, 63 | on: dest.representative_id == as(^current_binding).id, 64 | on: dest.priority > as(^current_binding).priority_threshold 65 | )} 66 | end 67 | 68 | def ash_sqlite_join(query, _opts, current_binding, as_binding, :left, destination_query) do 69 | {:ok, 70 | Ecto.Query.from(_ in query, 71 | left_join: dest in ^destination_query, 72 | as: ^as_binding, 73 | on: dest.representative_id == as(^current_binding).id, 74 | on: dest.priority > as(^current_binding).priority_threshold 75 | )} 76 | end 77 | 78 | # _opts are options provided to the manual relationship, i.e `{Manual, opt: :val}` 79 | 80 | # current_binding is what the source of the relationship is bound to. Access fields with `parent_as(^current_binding).field` 81 | 82 | # as_binding is the binding that has already been created for your join. Access fields on it via `as(^as_binding)` 83 | 84 | # destination_query is what you should use as the basis of your query 85 | def ash_sqlite_subquery(_opts, current_binding, as_binding, destination_query) do 86 | {:ok, 87 | Ecto.Query.from(_ in destination_query, 88 | where: parent_as(^current_binding).id == as(^as_binding).representative_id, 89 | where: as(^as_binding).priority > parent_as(^current_binding).priority_threshold 90 | )} 91 | end 92 | end 93 | ``` 94 | -------------------------------------------------------------------------------- /test/manual_relationships_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.ManualRelationshipsTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.{Comment, Post} 8 | 9 | require Ash.Query 10 | 11 | describe "manual first" do 12 | test "relationships can be filtered on with no data" do 13 | Post 14 | |> Ash.Changeset.for_create(:create, %{title: "title"}) 15 | |> Ash.create!() 16 | 17 | assert [] = 18 | Post |> Ash.Query.filter(comments_containing_title.title == "title") |> Ash.read!() 19 | end 20 | 21 | test "relationships can be filtered on with data" do 22 | post = 23 | Post 24 | |> Ash.Changeset.for_create(:create, %{title: "title"}) 25 | |> Ash.create!() 26 | 27 | Comment 28 | |> Ash.Changeset.for_create(:create, %{title: "title2"}) 29 | |> Ash.create!() 30 | 31 | Comment 32 | |> Ash.Changeset.for_create(:create, %{title: "title2"}) 33 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 34 | |> Ash.create!() 35 | 36 | Comment 37 | |> Ash.Changeset.for_create(:create, %{title: "no match"}) 38 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 39 | |> Ash.create!() 40 | 41 | assert [_] = 42 | Post 43 | |> Ash.Query.filter(comments_containing_title.title == "title2") 44 | |> Ash.read!() 45 | end 46 | end 47 | 48 | describe "manual last" do 49 | test "relationships can be filtered on with no data" do 50 | post = 51 | Post 52 | |> Ash.Changeset.for_create(:create, %{title: "title"}) 53 | |> Ash.create!() 54 | 55 | Comment 56 | |> Ash.Changeset.for_create(:create, %{title: "no match"}) 57 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 58 | |> Ash.create!() 59 | 60 | assert [] = 61 | Comment 62 | |> Ash.Query.filter(post.comments_containing_title.title == "title2") 63 | |> Ash.read!() 64 | end 65 | 66 | test "relationships can be filtered on with data" do 67 | post = 68 | Post 69 | |> Ash.Changeset.for_create(:create, %{title: "title"}) 70 | |> Ash.create!() 71 | 72 | Comment 73 | |> Ash.Changeset.for_create(:create, %{title: "title2"}) 74 | |> Ash.create!() 75 | 76 | Comment 77 | |> Ash.Changeset.for_create(:create, %{title: "title2"}) 78 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 79 | |> Ash.create!() 80 | 81 | Comment 82 | |> Ash.Changeset.for_create(:create, %{title: "no match"}) 83 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 84 | |> Ash.create!() 85 | 86 | assert [_, _] = 87 | Comment 88 | |> Ash.Query.filter(post.comments_containing_title.title == "title2") 89 | |> Ash.read!() 90 | end 91 | end 92 | 93 | describe "manual middle" do 94 | test "relationships can be filtered on with data" do 95 | post = 96 | Post 97 | |> Ash.Changeset.for_create(:create, %{title: "title"}) 98 | |> Ash.create!() 99 | 100 | Comment 101 | |> Ash.Changeset.for_create(:create, %{title: "title2"}) 102 | |> Ash.create!() 103 | 104 | Comment 105 | |> Ash.Changeset.for_create(:create, %{title: "title2"}) 106 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 107 | |> Ash.create!() 108 | 109 | Comment 110 | |> Ash.Changeset.for_create(:create, %{title: "no match"}) 111 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 112 | |> Ash.create!() 113 | 114 | assert [_, _] = 115 | Comment 116 | |> Ash.Query.filter(post.comments_containing_title.post.title == "title") 117 | |> Ash.read!() 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/mix/tasks/ash_sqlite.migrate.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.AshSqlite.Migrate do 6 | use Mix.Task 7 | 8 | import AshSqlite.Mix.Helpers, 9 | only: [migrations_path: 2] 10 | 11 | @shortdoc "Runs the repository migrations for all repositories in the provided (or configured) domains" 12 | 13 | @aliases [ 14 | n: :step 15 | ] 16 | 17 | @switches [ 18 | all: :boolean, 19 | step: :integer, 20 | to: :integer, 21 | quiet: :boolean, 22 | pool_size: :integer, 23 | log_sql: :boolean, 24 | strict_version_order: :boolean, 25 | domains: :string, 26 | no_compile: :boolean, 27 | no_deps_check: :boolean, 28 | migrations_path: :keep 29 | ] 30 | 31 | @moduledoc """ 32 | Runs the pending migrations for the given repository. 33 | 34 | Migrations are expected at "priv/YOUR_REPO/migrations" directory 35 | of the current application, where "YOUR_REPO" is the last segment 36 | in your repository name. For example, the repository `MyApp.Repo` 37 | will use "priv/repo/migrations". The repository `Whatever.MyRepo` 38 | will use "priv/my_repo/migrations". 39 | 40 | This task runs all pending migrations by default. To migrate up to a 41 | specific version number, supply `--to version_number`. To migrate a 42 | specific number of times, use `--step n`. 43 | 44 | This is only really useful if your domain or domains only use a single repo. 45 | If you have multiple repos and you want to run a single migration and/or 46 | migrate/roll them back to different points, you will need to use the 47 | ecto specific task, `mix ecto.migrate` and provide your repo name. 48 | 49 | If a repository has not yet been started, one will be started outside 50 | your application supervision tree and shutdown afterwards. 51 | 52 | ## Examples 53 | 54 | mix ash_sqlite.migrate 55 | mix ash_sqlite.migrate --domains MyApp.Domain1,MyApp.Domain2 56 | 57 | mix ash_sqlite.migrate -n 3 58 | mix ash_sqlite.migrate --step 3 59 | 60 | mix ash_sqlite.migrate --to 20080906120000 61 | 62 | ## Command line options 63 | 64 | * `--domains` - the domains who's repos should be migrated 65 | 66 | * `--all` - run all pending migrations 67 | 68 | * `--step`, `-n` - run n number of pending migrations 69 | 70 | * `--to` - run all migrations up to and including version 71 | 72 | * `--quiet` - do not log migration commands 73 | 74 | * `--pool-size` - the pool size if the repository is started only for the task (defaults to 2) 75 | 76 | * `--log-sql` - log the raw sql migrations are running 77 | 78 | * `--strict-version-order` - abort when applying a migration with old timestamp 79 | 80 | * `--no-compile` - does not compile applications before migrating 81 | 82 | * `--no-deps-check` - does not check depedendencies before migrating 83 | 84 | * `--migrations-path` - the path to load the migrations from, defaults to 85 | `"priv/repo/migrations"`. This option may be given multiple times in which case the migrations 86 | are loaded from all the given directories and sorted as if they were in the same one. 87 | 88 | Note, if you have migrations paths e.g. `a/` and `b/`, and run 89 | `mix ecto.migrate --migrations-path a/`, the latest migrations from `a/` will be run (even 90 | if `b/` contains the overall latest migrations.) 91 | """ 92 | 93 | @impl true 94 | def run(args) do 95 | {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) 96 | 97 | repos = AshSqlite.Mix.Helpers.repos!(opts, args) 98 | 99 | repo_args = 100 | Enum.flat_map(repos, fn repo -> 101 | ["-r", to_string(repo)] 102 | end) 103 | 104 | rest_opts = 105 | args 106 | |> AshSqlite.Mix.Helpers.delete_arg("--domains") 107 | |> AshSqlite.Mix.Helpers.delete_arg("--migrations-path") 108 | 109 | Mix.Task.reenable("ecto.migrate") 110 | 111 | for repo <- repos do 112 | Mix.Task.run( 113 | "ecto.migrate", 114 | repo_args ++ rest_opts ++ ["--migrations-path", migrations_path(opts, repo)] 115 | ) 116 | 117 | Mix.Task.reenable("ecto.migrate") 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /documentation/topics/development/migrations-and-tasks.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Migrations 8 | 9 | ## Tasks 10 | 11 | Ash comes with its own tasks, and AshSqlite exposes lower level tasks that you can use if necessary. This guide shows the process using `ash.*` tasks, and the `ash_sqlite.*` tasks are illustrated at the bottom. 12 | 13 | ## Basic Workflow 14 | 15 | ### Development Workflow (Recommended) 16 | 17 | For development iterations, use the dev workflow to avoid naming migrations prematurely: 18 | 19 | 1. Make resource changes 20 | 2. Run `mix ash.codegen --dev` to generate dev migrations 21 | 3. Review the migrations and run `mix ash.migrate` to run them 22 | 4. Continue making changes and running `mix ash.codegen --dev` as needed 23 | 5. When your feature is complete, run `mix ash.codegen add_feature_name` to generate final named migrations (this will remove dev migrations and squash them) 24 | 6. Review the migrations and run `mix ash.migrate` to run them 25 | 26 | ### Traditional Migration Generation 27 | 28 | For single-step changes or when you know the final feature name: 29 | 30 | 1. Make resource changes 31 | 2. Run `mix ash.codegen --name add_a_combobulator` to generate migrations and resource snapshots 32 | 3. Run `mix ash.migrate` to run those migrations 33 | 34 | > **Tip**: The dev workflow (`--dev` flag) is preferred during development as it allows you to iterate without thinking of migration names and provides better development ergonomics. 35 | 36 | > **Warning**: Always review migrations before applying them to ensure they are correct and safe. 37 | 38 | For more information on generating migrations, run `mix help ash_sqlite.generate_migrations` (the underlying task that is called by `mix ash.codegen`) 39 | 40 | ### Regenerating Migrations 41 | 42 | Often, you will run into a situation where you want to make a slight change to a resource after you've already generated and run migrations. If you are using git and would like to undo those changes, then regenerate the migrations, this script may prove useful: 43 | 44 | ```bash 45 | #!/bin/bash 46 | 47 | # Get count of untracked migrations 48 | N_MIGRATIONS=$(git ls-files --others priv/repo/migrations | wc -l) 49 | 50 | # Rollback untracked migrations 51 | mix ash_sqlite.rollback -n $N_MIGRATIONS 52 | 53 | # Delete untracked migrations and snapshots 54 | git ls-files --others priv/repo/migrations | xargs rm 55 | git ls-files --others priv/resource_snapshots | xargs rm 56 | 57 | # Regenerate migrations 58 | mix ash.codegen --name $1 59 | 60 | # Run migrations if flag 61 | if echo $* | grep -e "-m" -q 62 | then 63 | mix ash.migrate 64 | fi 65 | ``` 66 | 67 | After saving this file to something like `regen.sh`, make it executable with `chmod +x regen.sh`. Now you can run it with `./regen.sh name_of_operation`. If you would like the migrations to automatically run after regeneration, add the `-m` flag: `./regen.sh name_of_operation -m`. 68 | 69 | ## Multiple Repos 70 | 71 | If you are using multiple repos, you will likely need to use `mix ecto.migrate` and manage it separately for each repo, as the options would 72 | be applied to both repo, which wouldn't make sense. 73 | 74 | ## Running Migrations in Production 75 | 76 | Define a module similar to the following: 77 | 78 | ```elixir 79 | defmodule MyApp.Release do 80 | @moduledoc """ 81 | Houses tasks that need to be executed in the released application (because mix is not present in releases). 82 | """ 83 | @app :my_ap 84 | def migrate do 85 | load_app() 86 | 87 | for repo <- repos() do 88 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 89 | end 90 | end 91 | 92 | def rollback(repo, version) do 93 | load_app() 94 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 95 | end 96 | 97 | defp repos do 98 | domains() 99 | |> Enum.flat_map(fn domain -> 100 | domain 101 | |> Ash.Domain.Info.resources() 102 | |> Enum.map(&AshSqlite.repo/1) 103 | end) 104 | |> Enum.uniq() 105 | end 106 | 107 | defp domains do 108 | Application.fetch_env!(:my_app, :ash_domains) 109 | end 110 | 111 | defp load_app do 112 | Application.load(@app) 113 | end 114 | end 115 | ``` 116 | 117 | # AshSqlite-specific tasks 118 | 119 | - `mix ash_sqlite.generate_migrations` 120 | - `mix ash_sqlite.create` 121 | - `mix ash_sqlite.migrate` 122 | - `mix ash_sqlite.rollback` 123 | - `mix ash_sqlite.drop` 124 | -------------------------------------------------------------------------------- /lib/mix/helpers.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Mix.Helpers do 6 | @moduledoc false 7 | def domains!(opts, args) do 8 | apps = 9 | if apps_paths = Mix.Project.apps_paths() do 10 | apps_paths |> Map.keys() |> Enum.sort() 11 | else 12 | [Mix.Project.config()[:app]] 13 | end 14 | 15 | configured_domains = Enum.flat_map(apps, &Application.get_env(&1, :ash_domains, [])) 16 | 17 | domains = 18 | if opts[:domains] && opts[:domains] != "" do 19 | opts[:domains] 20 | |> Kernel.||("") 21 | |> String.split(",") 22 | |> Enum.flat_map(fn 23 | "" -> 24 | [] 25 | 26 | domain -> 27 | [Module.concat([domain])] 28 | end) 29 | else 30 | configured_domains 31 | end 32 | 33 | domains 34 | |> Enum.map(&ensure_compiled(&1, args)) 35 | |> case do 36 | [] -> 37 | [] 38 | 39 | domains -> 40 | domains 41 | end 42 | end 43 | 44 | def repos!(opts, args) do 45 | if opts[:domains] && opts[:domains] != "" do 46 | domains = domains!(opts, args) 47 | 48 | resources = 49 | domains 50 | |> Enum.flat_map(&Ash.Domain.Info.resources/1) 51 | |> Enum.filter(&(Ash.DataLayer.data_layer(&1) == AshSqlite.DataLayer)) 52 | |> case do 53 | [] -> 54 | raise """ 55 | No resources with `data_layer: AshSqlite.DataLayer` found in the domains #{Enum.map_join(domains, ",", &inspect/1)}. 56 | 57 | Must be able to find at least one resource with `data_layer: AshSqlite.DataLayer`. 58 | """ 59 | 60 | resources -> 61 | resources 62 | end 63 | 64 | resources 65 | |> Enum.map(&AshSqlite.DataLayer.Info.repo/1) 66 | |> Enum.uniq() 67 | |> case do 68 | [] -> 69 | raise """ 70 | No repos could be found configured on the resources in the domains: #{Enum.map_join(domains, ",", &inspect/1)} 71 | 72 | At least one resource must have a repo configured. 73 | 74 | The following resources were found with `data_layer: AshSqlite.DataLayer`: 75 | 76 | #{Enum.map_join(resources, "\n", &"* #{inspect(&1)}")} 77 | """ 78 | 79 | repos -> 80 | repos 81 | end 82 | else 83 | if Code.ensure_loaded?(Mix.Tasks.App.Config) do 84 | Mix.Task.run("app.config", args) 85 | else 86 | Mix.Task.run("loadpaths", args) 87 | "--no-compile" not in args && Mix.Task.run("compile", args) 88 | end 89 | 90 | Mix.Project.config()[:app] 91 | |> Application.get_env(:ecto_repos, []) 92 | |> Enum.filter(fn repo -> 93 | Spark.implements_behaviour?(repo, AshSqlite.Repo) 94 | end) 95 | end 96 | end 97 | 98 | def delete_flag(args, arg) do 99 | case Enum.split_while(args, &(&1 != arg)) do 100 | {left, [_ | rest]} -> 101 | delete_flag(left ++ rest, arg) 102 | 103 | _ -> 104 | args 105 | end 106 | end 107 | 108 | def delete_arg(args, arg) do 109 | case Enum.split_while(args, &(&1 != arg)) do 110 | {left, [_, _ | rest]} -> 111 | delete_arg(left ++ rest, arg) 112 | 113 | _ -> 114 | args 115 | end 116 | end 117 | 118 | defp ensure_compiled(domain, args) do 119 | if Code.ensure_loaded?(Mix.Tasks.App.Config) do 120 | Mix.Task.run("app.config", args) 121 | else 122 | Mix.Task.run("loadpaths", args) 123 | "--no-compile" not in args && Mix.Task.run("compile", args) 124 | end 125 | 126 | case Code.ensure_compiled(domain) do 127 | {:module, _} -> 128 | domain 129 | |> Ash.Domain.Info.resources() 130 | |> Enum.each(&Code.ensure_compiled/1) 131 | 132 | # TODO: We shouldn't need to make sure that the resources are compiled 133 | 134 | domain 135 | 136 | {:error, error} -> 137 | Mix.raise("Could not load #{inspect(domain)}, error: #{inspect(error)}. ") 138 | end 139 | end 140 | 141 | def migrations_path(opts, repo) do 142 | opts[:migrations_path] || repo.config()[:migrations_path] || derive_migrations_path(repo) 143 | end 144 | 145 | def derive_migrations_path(repo) do 146 | config = repo.config() 147 | priv = config[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}" 148 | app = Keyword.fetch!(config, :otp_app) 149 | Application.app_dir(app, Path.join(priv, "migrations")) 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/data_layer/info.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.DataLayer.Info do 6 | @moduledoc "Introspection functions for " 7 | 8 | alias Spark.Dsl.Extension 9 | 10 | @doc "The configured repo for a resource" 11 | def repo(resource) do 12 | Extension.get_opt(resource, [:sqlite], :repo, nil, true) 13 | end 14 | 15 | @doc "The configured table for a resource" 16 | def table(resource) do 17 | Extension.get_opt(resource, [:sqlite], :table, nil, true) 18 | end 19 | 20 | @doc "The configured references for a resource" 21 | def references(resource) do 22 | Extension.get_entities(resource, [:sqlite, :references]) 23 | end 24 | 25 | @doc "The configured reference for a given relationship of a resource" 26 | def reference(resource, relationship) do 27 | resource 28 | |> Extension.get_entities([:sqlite, :references]) 29 | |> Enum.find(&(&1.relationship == relationship)) 30 | end 31 | 32 | @doc "A keyword list of customized migration types" 33 | def migration_types(resource) do 34 | Extension.get_opt(resource, [:sqlite], :migration_types, []) 35 | end 36 | 37 | @doc "A keyword list of customized migration defaults" 38 | def migration_defaults(resource) do 39 | Extension.get_opt(resource, [:sqlite], :migration_defaults, []) 40 | end 41 | 42 | @doc "A list of attributes to be ignored when generating migrations" 43 | def migration_ignore_attributes(resource) do 44 | Extension.get_opt(resource, [:sqlite], :migration_ignore_attributes, []) 45 | end 46 | 47 | @doc "The configured custom_indexes for a resource" 48 | def custom_indexes(resource) do 49 | Extension.get_entities(resource, [:sqlite, :custom_indexes]) 50 | end 51 | 52 | @doc "The configured custom_statements for a resource" 53 | def custom_statements(resource) do 54 | Extension.get_entities(resource, [:sqlite, :custom_statements]) 55 | end 56 | 57 | @doc "The configured polymorphic_reference_on_delete for a resource" 58 | def polymorphic_on_delete(resource) do 59 | Extension.get_opt(resource, [:sqlite, :references], :polymorphic_on_delete, nil, true) 60 | end 61 | 62 | @doc "The configured polymorphic_reference_on_update for a resource" 63 | def polymorphic_on_update(resource) do 64 | Extension.get_opt(resource, [:sqlite, :references], :polymorphic_on_update, nil, true) 65 | end 66 | 67 | @doc "The configured polymorphic_reference_name for a resource" 68 | def polymorphic_name(resource) do 69 | Extension.get_opt(resource, [:sqlite, :references], :polymorphic_on_delete, nil, true) 70 | end 71 | 72 | @doc "The configured polymorphic? for a resource" 73 | def polymorphic?(resource) do 74 | Extension.get_opt(resource, [:sqlite], :polymorphic?, nil, true) 75 | end 76 | 77 | @doc "The configured unique_index_names" 78 | def unique_index_names(resource) do 79 | Extension.get_opt(resource, [:sqlite], :unique_index_names, [], true) 80 | end 81 | 82 | @doc "The configured exclusion_constraint_names" 83 | def exclusion_constraint_names(resource) do 84 | Extension.get_opt(resource, [:sqlite], :exclusion_constraint_names, [], true) 85 | end 86 | 87 | @doc "The configured identity_index_names" 88 | def identity_index_names(resource) do 89 | Extension.get_opt(resource, [:sqlite], :identity_index_names, [], true) 90 | end 91 | 92 | @doc "Identities not to include in the migrations" 93 | def skip_identities(resource) do 94 | Extension.get_opt(resource, [:sqlite], :skip_identities, [], true) 95 | end 96 | 97 | @doc "The configured foreign_key_names" 98 | def foreign_key_names(resource) do 99 | Extension.get_opt(resource, [:sqlite], :foreign_key_names, [], true) 100 | end 101 | 102 | @doc "Whether or not the resource should be included when generating migrations" 103 | def migrate?(resource) do 104 | Extension.get_opt(resource, [:sqlite], :migrate?, nil, true) 105 | end 106 | 107 | @doc "A list of keys to always include in upserts." 108 | def global_upsert_keys(resource) do 109 | Extension.get_opt(resource, [:sqlite], :global_upsert_keys, []) 110 | end 111 | 112 | @doc "A stringified version of the base_filter, to be used in a where clause when generating unique indexes" 113 | def base_filter_sql(resource) do 114 | Extension.get_opt(resource, [:sqlite], :base_filter_sql, nil) 115 | end 116 | 117 | @doc "Skip generating unique indexes when generating migrations" 118 | def skip_unique_indexes(resource) do 119 | Extension.get_opt(resource, [:sqlite], :skip_unique_indexes, []) 120 | end 121 | 122 | @doc "Whether the migration generator should create a strict table" 123 | def strict?(resource) do 124 | Extension.get_opt(resource, [:sqlite], :strict?, false) 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /test/bulk_create_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.BulkCreateTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.Post 8 | 9 | describe "bulk creates" do 10 | test "bulk creates insert each input" do 11 | Ash.bulk_create!([%{title: "fred"}, %{title: "george"}], Post, :create) 12 | 13 | assert [%{title: "fred"}, %{title: "george"}] = 14 | Post 15 | |> Ash.Query.sort(:title) 16 | |> Ash.read!() 17 | end 18 | 19 | test "bulk creates can be streamed" do 20 | assert [{:ok, %{title: "fred"}}, {:ok, %{title: "george"}}] = 21 | Ash.bulk_create!([%{title: "fred"}, %{title: "george"}], Post, :create, 22 | return_stream?: true, 23 | return_records?: true 24 | ) 25 | |> Enum.sort_by(fn {:ok, result} -> result.title end) 26 | end 27 | 28 | test "bulk creates can upsert" do 29 | assert [ 30 | {:ok, %{title: "fred", uniq_one: "one", uniq_two: "two", price: 10}}, 31 | {:ok, %{title: "george", uniq_one: "three", uniq_two: "four", price: 20}} 32 | ] = 33 | Ash.bulk_create!( 34 | [ 35 | %{title: "fred", uniq_one: "one", uniq_two: "two", price: 10}, 36 | %{title: "george", uniq_one: "three", uniq_two: "four", price: 20} 37 | ], 38 | Post, 39 | :create, 40 | return_stream?: true, 41 | return_records?: true 42 | ) 43 | |> Enum.sort_by(fn {:ok, result} -> result.title end) 44 | 45 | assert [ 46 | {:ok, %{title: "fred", uniq_one: "one", uniq_two: "two", price: 1000}}, 47 | {:ok, %{title: "george", uniq_one: "three", uniq_two: "four", price: 20_000}} 48 | ] = 49 | Ash.bulk_create!( 50 | [ 51 | %{title: "something", uniq_one: "one", uniq_two: "two", price: 1000}, 52 | %{title: "else", uniq_one: "three", uniq_two: "four", price: 20_000} 53 | ], 54 | Post, 55 | :create, 56 | upsert?: true, 57 | upsert_identity: :uniq_one_and_two, 58 | upsert_fields: [:price], 59 | return_stream?: true, 60 | return_records?: true 61 | ) 62 | |> Enum.sort_by(fn 63 | {:ok, result} -> 64 | result.title 65 | 66 | _ -> 67 | nil 68 | end) 69 | end 70 | 71 | test "bulk creates can create relationships" do 72 | Ash.bulk_create!( 73 | [%{title: "fred", rating: %{score: 5}}, %{title: "george", rating: %{score: 0}}], 74 | Post, 75 | :create 76 | ) 77 | 78 | assert [ 79 | %{title: "fred", ratings: [%{score: 5}]}, 80 | %{title: "george", ratings: [%{score: 0}]} 81 | ] = 82 | Post 83 | |> Ash.Query.sort(:title) 84 | |> Ash.Query.load(:ratings) 85 | |> Ash.read!() 86 | end 87 | end 88 | 89 | describe "validation errors" do 90 | test "skips invalid by default" do 91 | assert %{records: [_], errors: [_]} = 92 | Ash.bulk_create([%{title: "fred"}, %{title: "not allowed"}], Post, :create, 93 | return_records?: true, 94 | return_errors?: true 95 | ) 96 | end 97 | 98 | test "returns errors in the stream" do 99 | assert [{:ok, _}, {:error, _}] = 100 | Ash.bulk_create!([%{title: "fred"}, %{title: "not allowed"}], Post, :create, 101 | return_records?: true, 102 | return_stream?: true, 103 | return_errors?: true 104 | ) 105 | |> Enum.to_list() 106 | end 107 | end 108 | 109 | describe "database errors" do 110 | test "database errors affect the entire batch" do 111 | org = 112 | AshSqlite.Test.Organization 113 | |> Ash.Changeset.for_create(:create, %{name: "foo"}) 114 | |> Ash.create!() 115 | 116 | Ash.bulk_create( 117 | [ 118 | %{title: "fred", organization_id: org.id}, 119 | %{title: "george", organization_id: Ash.UUID.generate()} 120 | ], 121 | Post, 122 | :create, 123 | return_records?: true 124 | ) 125 | 126 | assert [] = 127 | Post 128 | |> Ash.Query.sort(:title) 129 | |> Ash.read!() 130 | end 131 | 132 | test "database errors don't affect other batches" do 133 | Ash.bulk_create( 134 | [%{title: "george", organization_id: Ash.UUID.generate()}, %{title: "fred"}], 135 | Post, 136 | :create, 137 | return_records?: true, 138 | batch_size: 1 139 | ) 140 | 141 | assert [%{title: "fred"}] = 142 | Post 143 | |> Ash.Query.sort(:title) 144 | |> Ash.read!() 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /test/sort_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.SortTest do 6 | @moduledoc false 7 | use AshSqlite.RepoCase, async: false 8 | alias AshSqlite.Test.{Comment, Post, PostLink} 9 | 10 | require Ash.Query 11 | 12 | test "multi-column sorts work" do 13 | Post 14 | |> Ash.Changeset.for_create(:create, %{title: "aaa", score: 0}) 15 | |> Ash.create!() 16 | 17 | Post 18 | |> Ash.Changeset.for_create(:create, %{title: "aaa", score: 1}) 19 | |> Ash.create!() 20 | 21 | Post 22 | |> Ash.Changeset.for_create(:create, %{title: "bbb", score: 0}) 23 | |> Ash.create!() 24 | 25 | assert [ 26 | %{title: "aaa", score: 0}, 27 | %{title: "aaa", score: 1}, 28 | %{title: "bbb"} 29 | ] = 30 | Ash.read!( 31 | Post 32 | |> Ash.Query.sort(title: :asc, score: :asc) 33 | ) 34 | end 35 | 36 | test "multi-column sorts work on inclusion" do 37 | post = 38 | Post 39 | |> Ash.Changeset.for_create(:create, %{title: "aaa", score: 0}) 40 | |> Ash.create!() 41 | 42 | Post 43 | |> Ash.Changeset.for_create(:create, %{title: "aaa", score: 1}) 44 | |> Ash.create!() 45 | 46 | Post 47 | |> Ash.Changeset.for_create(:create, %{title: "bbb", score: 0}) 48 | |> Ash.create!() 49 | 50 | Comment 51 | |> Ash.Changeset.for_create(:create, %{title: "aaa", likes: 1}) 52 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 53 | |> Ash.create!() 54 | 55 | Comment 56 | |> Ash.Changeset.for_create(:create, %{title: "bbb", likes: 1}) 57 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 58 | |> Ash.create!() 59 | 60 | Comment 61 | |> Ash.Changeset.for_create(:create, %{title: "aaa", likes: 2}) 62 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 63 | |> Ash.create!() 64 | 65 | posts = 66 | Post 67 | |> Ash.Query.load( 68 | comments: 69 | Comment 70 | |> Ash.Query.sort([:title, :likes]) 71 | |> Ash.Query.select([:title, :likes]) 72 | |> Ash.Query.limit(1) 73 | ) 74 | |> Ash.Query.sort([:title, :score]) 75 | |> Ash.read!() 76 | 77 | assert [ 78 | %{title: "aaa", comments: [%{title: "aaa"}]}, 79 | %{title: "aaa"}, 80 | %{title: "bbb"} 81 | ] = posts 82 | end 83 | 84 | test "multicolumn sort works with a select statement" do 85 | Post 86 | |> Ash.Changeset.for_create(:create, %{title: "aaa", score: 0}) 87 | |> Ash.create!() 88 | 89 | Post 90 | |> Ash.Changeset.for_create(:create, %{title: "aaa", score: 1}) 91 | |> Ash.create!() 92 | 93 | Post 94 | |> Ash.Changeset.for_create(:create, %{title: "bbb", score: 0}) 95 | |> Ash.create!() 96 | 97 | assert [ 98 | %{title: "aaa", score: 0}, 99 | %{title: "aaa", score: 1}, 100 | %{title: "bbb"} 101 | ] = 102 | Ash.read!( 103 | Post 104 | |> Ash.Query.sort(title: :asc, score: :asc) 105 | |> Ash.Query.select([:title, :score]) 106 | ) 107 | end 108 | 109 | test "sorting when joining to a many to many relationship sorts properly" do 110 | post1 = 111 | Post 112 | |> Ash.Changeset.for_create(:create, %{title: "aaa", score: 0}) 113 | |> Ash.create!() 114 | 115 | post2 = 116 | Post 117 | |> Ash.Changeset.for_create(:create, %{title: "bbb", score: 1}) 118 | |> Ash.create!() 119 | 120 | post3 = 121 | Post 122 | |> Ash.Changeset.for_create(:create, %{title: "ccc", score: 0}) 123 | |> Ash.create!() 124 | 125 | PostLink 126 | |> Ash.Changeset.new() 127 | |> Ash.Changeset.manage_relationship(:source_post, post1, type: :append) 128 | |> Ash.Changeset.manage_relationship(:destination_post, post3, type: :append) 129 | |> Ash.create!() 130 | 131 | PostLink 132 | |> Ash.Changeset.new() 133 | |> Ash.Changeset.manage_relationship(:source_post, post2, type: :append) 134 | |> Ash.Changeset.manage_relationship(:destination_post, post2, type: :append) 135 | |> Ash.create!() 136 | 137 | PostLink 138 | |> Ash.Changeset.new() 139 | |> Ash.Changeset.manage_relationship(:source_post, post3, type: :append) 140 | |> Ash.Changeset.manage_relationship(:destination_post, post1, type: :append) 141 | |> Ash.create!() 142 | 143 | assert [ 144 | %{title: "aaa"}, 145 | %{title: "bbb"}, 146 | %{title: "ccc"} 147 | ] = 148 | Ash.read!( 149 | Post 150 | |> Ash.Query.sort(title: :asc) 151 | |> Ash.Query.filter(linked_posts.title in ["aaa", "bbb", "ccc"]) 152 | ) 153 | 154 | assert [ 155 | %{title: "ccc"}, 156 | %{title: "bbb"}, 157 | %{title: "aaa"} 158 | ] = 159 | Ash.read!( 160 | Post 161 | |> Ash.Query.sort(title: :desc) 162 | |> Ash.Query.filter(linked_posts.title in ["aaa", "bbb", "ccc"] or title == "aaa") 163 | ) 164 | 165 | assert [ 166 | %{title: "ccc"}, 167 | %{title: "bbb"}, 168 | %{title: "aaa"} 169 | ] = 170 | Ash.read!( 171 | Post 172 | |> Ash.Query.sort(title: :desc) 173 | |> Ash.Query.filter( 174 | linked_posts.title in ["aaa", "bbb", "ccc"] or 175 | post_links.source_post_id == ^post2.id 176 | ) 177 | ) 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/repo.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Repo do 6 | @moduledoc """ 7 | Resources that use `AshSqlite.DataLayer` use a `Repo` to access the database. 8 | 9 | This repo is a thin wrapper around an `Ecto.Repo`. 10 | 11 | You can use `Ecto.Repo`'s `init/2` to configure your repo like normal, but 12 | instead of returning `{:ok, config}`, use `super(config)` to pass the 13 | configuration to the `AshSqlite.Repo` implementation. 14 | 15 | ## Additional Repo Configuration 16 | 17 | Because an `AshPostgres.Repo` is also an `Ecto.Repo`, it has all of the same callbacks. 18 | 19 | In the `c:Ecto.Repo.init/2` callback, you can configure the following additional items: 20 | 21 | - `:tenant_migrations_path` - The path where your tenant migrations are stored (only relevant for a multitenant implementation) 22 | - `:snapshots_path` - The path where the resource snapshots for the migration generator are stored. 23 | """ 24 | 25 | @doc "Use this to inform the data layer about what extensions are installed" 26 | @callback installed_extensions() :: [String.t()] 27 | 28 | @doc """ 29 | Use this to inform the data layer about the oldest potential sqlite version it will be run on. 30 | 31 | Must be an integer greater than or equal to 13. 32 | """ 33 | @callback min_pg_version() :: integer() 34 | 35 | @doc "The path where your migrations are stored" 36 | @callback migrations_path() :: String.t() | nil 37 | @doc "Allows overriding a given migration type for *all* fields, for example if you wanted to always use :timestamptz for :utc_datetime fields" 38 | @callback override_migration_type(atom) :: atom 39 | 40 | defmacro __using__(opts) do 41 | quote bind_quoted: [opts: opts] do 42 | otp_app = opts[:otp_app] || raise("Must configure OTP app") 43 | 44 | use Ecto.Repo, 45 | adapter: Ecto.Adapters.SQLite3, 46 | otp_app: otp_app 47 | 48 | @behaviour AshSqlite.Repo 49 | 50 | defoverridable insert: 2, insert: 1, insert!: 2, insert!: 1 51 | 52 | def installed_extensions, do: [] 53 | def migrations_path, do: nil 54 | def override_migration_type(type), do: type 55 | def min_pg_version, do: 10 56 | 57 | def init(_, config) do 58 | new_config = 59 | config 60 | |> Keyword.put(:installed_extensions, installed_extensions()) 61 | |> Keyword.put(:migrations_path, migrations_path()) 62 | |> Keyword.put(:case_sensitive_like, :on) 63 | 64 | {:ok, new_config} 65 | end 66 | 67 | def insert(struct_or_changeset, opts \\ []) do 68 | struct_or_changeset 69 | |> to_ecto() 70 | |> then(fn value -> 71 | repo = get_dynamic_repo() 72 | 73 | Ecto.Repo.Schema.insert( 74 | __MODULE__, 75 | repo, 76 | value, 77 | Ecto.Repo.Supervisor.tuplet(repo, prepare_opts(:insert, opts)) 78 | ) 79 | end) 80 | |> from_ecto() 81 | end 82 | 83 | def insert!(struct_or_changeset, opts \\ []) do 84 | struct_or_changeset 85 | |> to_ecto() 86 | |> then(fn value -> 87 | repo = get_dynamic_repo() 88 | 89 | Ecto.Repo.Schema.insert!( 90 | __MODULE__, 91 | repo, 92 | value, 93 | Ecto.Repo.Supervisor.tuplet(repo, prepare_opts(:insert, opts)) 94 | ) 95 | end) 96 | |> from_ecto() 97 | end 98 | 99 | def from_ecto({:ok, result}), do: {:ok, from_ecto(result)} 100 | def from_ecto({:error, _} = other), do: other 101 | 102 | def from_ecto(nil), do: nil 103 | 104 | def from_ecto(value) when is_list(value) do 105 | Enum.map(value, &from_ecto/1) 106 | end 107 | 108 | def from_ecto(%resource{} = record) do 109 | if Spark.Dsl.is?(resource, Ash.Resource) do 110 | empty = struct(resource) 111 | 112 | resource 113 | |> Ash.Resource.Info.relationships() 114 | |> Enum.reduce(record, fn relationship, record -> 115 | case Map.get(record, relationship.name) do 116 | %Ecto.Association.NotLoaded{} -> 117 | Map.put(record, relationship.name, Map.get(empty, relationship.name)) 118 | 119 | value -> 120 | Map.put(record, relationship.name, from_ecto(value)) 121 | end 122 | end) 123 | else 124 | record 125 | end 126 | end 127 | 128 | def from_ecto(other), do: other 129 | 130 | def to_ecto(nil), do: nil 131 | 132 | def to_ecto(value) when is_list(value) do 133 | Enum.map(value, &to_ecto/1) 134 | end 135 | 136 | def to_ecto(%resource{} = record) do 137 | if Spark.Dsl.is?(resource, Ash.Resource) do 138 | resource 139 | |> Ash.Resource.Info.relationships() 140 | |> Enum.reduce(record, fn relationship, record -> 141 | value = 142 | case Map.get(record, relationship.name) do 143 | %Ash.NotLoaded{} -> 144 | %Ecto.Association.NotLoaded{ 145 | __field__: relationship.name, 146 | __cardinality__: relationship.cardinality 147 | } 148 | 149 | value -> 150 | to_ecto(value) 151 | end 152 | 153 | Map.put(record, relationship.name, value) 154 | end) 155 | else 156 | record 157 | end 158 | end 159 | 160 | def to_ecto(other), do: other 161 | 162 | defoverridable init: 2, 163 | installed_extensions: 0, 164 | override_migration_type: 1, 165 | min_pg_version: 0 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /lib/mix/tasks/ash_sqlite.generate_migrations.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.AshSqlite.GenerateMigrations do 6 | @moduledoc """ 7 | Generates migrations, and stores a snapshot of your resources. 8 | 9 | Options: 10 | 11 | * `domains` - a comma separated list of domain modules, for which migrations will be generated 12 | * `snapshot-path` - a custom path to store the snapshots, defaults to "priv/resource_snapshots" 13 | * `migration-path` - a custom path to store the migrations, defaults to "priv". 14 | Migrations are stored in a folder for each repo, so `priv/repo_name/migrations` 15 | * `drop-columns` - whether or not to drop columns as attributes are removed. See below for more 16 | * `name` - 17 | names the generated migrations, prepending with the timestamp. The default is `migrate_resources_`, 18 | where `` is the count of migrations matching `*migrate_resources*` plus one. 19 | For example, `--name add_special_column` would get a name like `20210708181402_add_special_column.exs` 20 | 21 | Flags: 22 | 23 | * `quiet` - messages for file creations will not be printed 24 | * `no-format` - files that are created will not be formatted with the code formatter 25 | * `dry-run` - no files are created, instead the new migration is printed 26 | * `check` - no files are created, returns an exit(1) code if the current snapshots and resources don't fit 27 | * `dev` - dev files are created (see Development Workflow section below) 28 | 29 | #### Snapshots 30 | 31 | Snapshots are stored in a folder for each table that migrations are generated for. Each snapshot is 32 | stored in a file with a timestamp of when it was generated. 33 | This is important because it allows for simultaneous work to be done on separate branches, and for rolling back 34 | changes more easily, e.g removing a generated migration, and deleting the most recent snapshot, without having to redo 35 | all of it 36 | 37 | #### Dropping columns 38 | 39 | Generally speaking, it is bad practice to drop columns when you deploy a change that 40 | would remove an attribute. The main reasons for this are backwards compatibility and rolling restarts. 41 | If you deploy an attribute removal, and run migrations. Regardless of your deployment sstrategy, you 42 | won't be able to roll back, because the data has been deleted. In a rolling restart situation, some of 43 | the machines/pods/whatever may still be running after the column has been deleted, causing errors. With 44 | this in mind, its best not to delete those columns until later, after the data has been confirmed unnecessary. 45 | To that end, the migration generator leaves the column dropping code commented. You can pass `--drop_columns` 46 | to tell it to uncomment those statements. Additionally, you can just uncomment that code on a case by case 47 | basis. 48 | 49 | #### Conflicts/Multiple Resources 50 | 51 | It will raise on conflicts that it can't resolve, like the same field with different 52 | types. It will prompt to resolve conflicts that can be resolved with human input. 53 | For example, if you remove an attribute and add an attribute, it will ask you if you are renaming 54 | the column in question. If not, it will remove one column and add the other. 55 | 56 | Additionally, it lowers things to the database where possible: 57 | 58 | #### Defaults 59 | There are three anonymous functions that will translate to database-specific defaults currently: 60 | 61 | * `&DateTime.utc_now/0` 62 | 63 | Non-function default values will be dumped to their native type and inspected. This may not work for some types, 64 | and may require manual intervention/patches to the migration generator code. 65 | 66 | #### Development Workflow 67 | 68 | The `--dev` flag enables a development-focused migration workflow that allows you to iterate 69 | on resource changes without committing to migration names prematurely: 70 | 71 | 1. Make resource changes 72 | 2. Run `mix ash_sqlite.generate_migrations --dev` to generate dev migrations 73 | - Creates migration files with `_dev.exs` suffix 74 | - Creates snapshot files with `_dev.json` suffix 75 | - No migration name required 76 | 3. Continue making changes and running `--dev` as needed 77 | 4. When ready, run `mix ash_sqlite.generate_migrations my_feature_name` to: 78 | - Remove all dev migrations and snapshots 79 | - Generate final named migrations that consolidate all changes 80 | - Create clean snapshots 81 | 82 | This workflow prevents migration history pollution during development while maintaining 83 | the ability to generate clean, well-named migrations for production. 84 | 85 | #### Identities 86 | 87 | Identities will cause the migration generator to generate unique constraints. If multiple 88 | resources target the same table, you will be asked to select the primary key, and any others 89 | will be added as unique constraints. 90 | """ 91 | use Mix.Task 92 | 93 | @shortdoc "Generates migrations, and stores a snapshot of your resources" 94 | def run(args) do 95 | {opts, _, _} = 96 | OptionParser.parse(args, 97 | strict: [ 98 | domains: :string, 99 | snapshot_path: :string, 100 | migration_path: :string, 101 | quiet: :boolean, 102 | name: :string, 103 | no_format: :boolean, 104 | dry_run: :boolean, 105 | check: :boolean, 106 | dev: :boolean, 107 | auto_name: :boolean, 108 | drop_columns: :boolean 109 | ] 110 | ) 111 | 112 | domains = AshSqlite.Mix.Helpers.domains!(opts, args) 113 | 114 | if Enum.empty?(domains) && !opts[:snapshots_only] do 115 | IO.warn(""" 116 | No domains found, so no resource-related migrations will be generated. 117 | Pass the `--domains` option or configure `config :your_app, ash_domains: [...]` 118 | """) 119 | end 120 | 121 | opts = 122 | opts 123 | |> Keyword.put(:format, !opts[:no_format]) 124 | |> Keyword.delete(:no_format) 125 | 126 | AshSqlite.MigrationGenerator.generate(domains, opts) 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Change Log 8 | 9 | All notable changes to this project will be documented in this file. 10 | See [Conventional Commits](Https://conventionalcommits.org) for commit guidelines. 11 | 12 | 13 | 14 | ## [v0.2.14](https://github.com/ash-project/ash_sqlite/compare/v0.2.13...v0.2.14) (2025-11-05) 15 | 16 | 17 | 18 | 19 | ### Bug Fixes: 20 | 21 | * Get rid of deprecation warnings (#188) by Jonatan Männchen 22 | 23 | * ignore unkown option in generate_migrations task #180 (#181) by Abdessabour Moutik 24 | 25 | ## [v0.2.13](https://github.com/ash-project/ash_sqlite/compare/v0.2.12...v0.2.13) (2025-08-31) 26 | 27 | 28 | 29 | 30 | ### Bug Fixes: 31 | 32 | * generate_migrations --dev duplicating migration files (#173) by Georges Dubus 33 | 34 | * override default implementation of string trim test by Zach Daniel 35 | 36 | ## [v0.2.12](https://github.com/ash-project/ash_sqlite/compare/v0.2.11...v0.2.12) (2025-07-22) 37 | 38 | 39 | 40 | 41 | ### Bug Fixes: 42 | 43 | * Reverse migrations order when reverting dev migrations (#167) by Kenneth Kostrešević 44 | 45 | * update ecto & ecto_sql by Zach Daniel 46 | 47 | ### Improvements: 48 | 49 | * make rollback more reliable by using `--to` instead of `-n` by Zach Daniel 50 | 51 | ## [v0.2.11](https://github.com/ash-project/ash_sqlite/compare/v0.2.10...v0.2.11) (2025-06-16) 52 | 53 | 54 | 55 | 56 | ### Improvements: 57 | 58 | * support update_query and destroy_query by Zach Daniel 59 | 60 | ## [v0.2.10](https://github.com/ash-project/ash_sqlite/compare/v0.2.9...v0.2.10) (2025-06-15) 61 | 62 | 63 | 64 | 65 | ### Bug Fixes: 66 | 67 | * properly apply filters on destroy & update by Zach Daniel 68 | 69 | ## [v0.2.9](https://github.com/ash-project/ash_sqlite/compare/v0.2.8...v0.2.9) (2025-05-30) 70 | 71 | 72 | 73 | 74 | ### Bug Fixes: 75 | 76 | * properly fetch options in installer 77 | 78 | ### Improvements: 79 | 80 | * strict table support (#157) 81 | 82 | * support new PendingCodegen error 83 | 84 | ## [v0.2.8](https://github.com/ash-project/ash_sqlite/compare/v0.2.7...v0.2.8) (2025-05-29) 85 | 86 | 87 | 88 | 89 | ### Bug Fixes: 90 | 91 | * properly fetch options in installer 92 | 93 | ### Improvements: 94 | 95 | * --dev codegen flag (#154) 96 | 97 | ## [v0.2.7](https://github.com/ash-project/ash_sqlite/compare/v0.2.6...v0.2.7) (2025-05-26) 98 | 99 | 100 | 101 | 102 | ### Bug Fixes: 103 | 104 | * various fixes around parameterized type data shape change 105 | 106 | * Remove unused `:inflex` dependency 107 | 108 | * Fix leftover reference to `Inflex` after it was moved to Igniter instead 109 | 110 | ### Improvements: 111 | 112 | * Fix igniter deprecation warning. (#152) 113 | 114 | ## [v0.2.6](https://github.com/ash-project/ash_sqlite/compare/v0.2.5...v0.2.6) (2025-04-29) 115 | 116 | 117 | 118 | 119 | ### Bug Fixes: 120 | 121 | * ensure upsert_fields honor update_defaults 122 | 123 | * ensure all upsert_fields are accounted for 124 | 125 | ## [v0.2.5](https://github.com/ash-project/ash_sqlite/compare/v0.2.4...v0.2.5) (2025-03-11) 126 | 127 | 128 | 129 | 130 | ### Bug Fixes: 131 | 132 | * Handle empty upsert fields (#135) 133 | 134 | ## [v0.2.4](https://github.com/ash-project/ash_sqlite/compare/v0.2.3...v0.2.4) (2025-02-25) 135 | 136 | 137 | 138 | 139 | ### Bug Fixes: 140 | 141 | * remove list literal usage for `in` in ash_sqlite 142 | 143 | ## [v0.2.3](https://github.com/ash-project/ash_sqlite/compare/v0.2.2...v0.2.3) (2025-01-26) 144 | 145 | 146 | 147 | 148 | ### Bug Fixes: 149 | 150 | * use `AshSql` for running aggregate queries 151 | 152 | ### Improvements: 153 | 154 | * update ash version for better aggregate support validation 155 | 156 | ## [v0.2.2](https://github.com/ash-project/ash_sqlite/compare/v0.2.1...v0.2.2) (2025-01-22) 157 | 158 | 159 | 160 | 161 | ### Bug Fixes: 162 | 163 | * Remove a postgresql specific configuration from `ash_sqlite.install` (#103) 164 | 165 | ### Improvements: 166 | 167 | * add installer for sqlite 168 | 169 | * make igniter optional 170 | 171 | * improve dry_run logic and fix priv path setup 172 | 173 | * honor repo configs and add snapshot configs 174 | 175 | ## [v0.2.1](https://github.com/ash-project/ash_sqlite/compare/v0.2.0...v0.2.1) (2024-10-09) 176 | 177 | 178 | 179 | 180 | ### Bug Fixes: 181 | 182 | * don't raise error on codegen with no domains 183 | 184 | * installer: use correct module name in the `DataCase` moduledocs. (#82) 185 | 186 | ### Improvements: 187 | 188 | * add `--repo` option to installer, warn on clashing existing repo 189 | 190 | * modify mix task aliases according to installer 191 | 192 | ## [v0.2.0](https://github.com/ash-project/ash_sqlite/compare/v0.1.3...v0.2.0) (2024-09-10) 193 | 194 | 195 | 196 | 197 | ### Features: 198 | 199 | * add igniter-based AshSqlite.Install mix task (#66) 200 | 201 | ### Improvements: 202 | 203 | * fix warnings from latest igniter updates 204 | 205 | ## [v0.1.3](https://github.com/ash-project/ash_sqlite/compare/v0.1.2...v0.1.3) (2024-05-31) 206 | 207 | 208 | 209 | 210 | ### Bug Fixes: 211 | 212 | * use `Ecto.ParameterizedType.init/2` 213 | 214 | * handle new/old ecto parameterized type format 215 | 216 | ## [v0.1.2](https://github.com/ash-project/ash_sqlite/compare/v0.1.2-rc.1...v0.1.2) (2024-05-11) 217 | 218 | 219 | 220 | 221 | ## [v0.1.2-rc.1](https://github.com/ash-project/ash_sqlite/compare/v0.1.2-rc.0...v0.1.2-rc.1) (2024-05-06) 222 | 223 | 224 | 225 | 226 | ### Bug Fixes: 227 | 228 | * properly scope deletes to the records in question 229 | 230 | * update ash_sqlite to get `ilike` behavior fix 231 | 232 | ### Improvements: 233 | 234 | * support `contains` function 235 | 236 | ## [v0.1.2-rc.0](https://github.com/ash-project/ash_sqlite/compare/v0.1.1...v0.1.2-rc.0) (2024-04-15) 237 | 238 | 239 | 240 | 241 | ### Bug Fixes: 242 | 243 | * reenable mix tasks that we need to call 244 | 245 | ### Improvements: 246 | 247 | * support `mix ash.rollback` 248 | 249 | * support Ash 3.0, leverage `ash_sql` package 250 | 251 | * fix datetime migration type discovery 252 | 253 | ## [v0.1.1](https://github.com/ash-project/ash_sqlite/compare/v0.1.0...v0.1.1) (2023-10-12) 254 | 255 | 256 | 257 | 258 | ### Improvements: 259 | 260 | * add `SqliteMigrationDefault` 261 | 262 | * support query aggregates 263 | 264 | ## [v0.1.0](https://github.com/ash-project/ash_sqlite/compare/v0.1.0...v0.1.0) (2023-10-12) 265 | 266 | 267 | ### Improvements: 268 | 269 | * Port and adjust `AshPostgres` to `AshSqlite` 270 | -------------------------------------------------------------------------------- /test/dev_migrations_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.DevMigrationsTest do 6 | use AshSqlite.RepoCase, async: false 7 | @moduletag :migration 8 | 9 | alias Ecto.Adapters.SQL.Sandbox 10 | 11 | setup do 12 | current_shell = Mix.shell() 13 | 14 | :ok = Mix.shell(Mix.Shell.Process) 15 | 16 | on_exit(fn -> 17 | Mix.shell(current_shell) 18 | end) 19 | 20 | Sandbox.checkout(AshSqlite.DevTestRepo) 21 | Sandbox.mode(AshSqlite.DevTestRepo, {:shared, self()}) 22 | end 23 | 24 | defmacrop defresource(mod, do: body) do 25 | quote do 26 | Code.compiler_options(ignore_module_conflict: true) 27 | 28 | defmodule unquote(mod) do 29 | use Ash.Resource, 30 | domain: nil, 31 | data_layer: AshSqlite.DataLayer 32 | 33 | unquote(body) 34 | end 35 | 36 | Code.compiler_options(ignore_module_conflict: false) 37 | end 38 | end 39 | 40 | defmacrop defposts(do: body) do 41 | quote do 42 | defresource Post do 43 | sqlite do 44 | table "posts" 45 | repo(AshSqlite.DevTestRepo) 46 | 47 | custom_indexes do 48 | # need one without any opts 49 | index(["id"]) 50 | index(["id"], unique: true, name: "test_unique_index") 51 | end 52 | end 53 | 54 | actions do 55 | defaults([:create, :read, :update, :destroy]) 56 | end 57 | 58 | unquote(body) 59 | end 60 | end 61 | end 62 | 63 | defmacrop defdomain(resources) do 64 | quote do 65 | Code.compiler_options(ignore_module_conflict: true) 66 | 67 | defmodule Domain do 68 | use Ash.Domain 69 | 70 | resources do 71 | for resource <- unquote(resources) do 72 | resource(resource) 73 | end 74 | end 75 | end 76 | 77 | Code.compiler_options(ignore_module_conflict: false) 78 | end 79 | end 80 | 81 | setup do 82 | File.mkdir_p!("priv/dev_test_repo/migrations") 83 | resource_dev_path = "priv/resource_snapshots/dev_test_repo" 84 | 85 | initial_resource_files = 86 | if File.exists?(resource_dev_path), do: File.ls!(resource_dev_path), else: [] 87 | 88 | migrations_dev_path = "priv/dev_test_repo/migrations" 89 | 90 | initial_migration_files = 91 | if File.exists?(migrations_dev_path), do: File.ls!(migrations_dev_path), else: [] 92 | 93 | on_exit(fn -> 94 | if File.exists?(resource_dev_path) do 95 | current_resource_files = File.ls!(resource_dev_path) 96 | new_resource_files = current_resource_files -- initial_resource_files 97 | Enum.each(new_resource_files, &File.rm_rf!(Path.join(resource_dev_path, &1))) 98 | end 99 | 100 | if File.exists?(migrations_dev_path) do 101 | current_migration_files = File.ls!(migrations_dev_path) 102 | new_migration_files = current_migration_files -- initial_migration_files 103 | Enum.each(new_migration_files, &File.rm!(Path.join(migrations_dev_path, &1))) 104 | end 105 | 106 | # Clean up test directories 107 | File.rm_rf!("test_snapshots_path") 108 | File.rm_rf!("test_migration_path") 109 | 110 | try do 111 | AshSqlite.DevTestRepo.query!("DROP TABLE IF EXISTS posts") 112 | rescue 113 | _ -> :ok 114 | end 115 | end) 116 | end 117 | 118 | describe "--dev option" do 119 | test "generates dev migration" do 120 | defposts do 121 | attributes do 122 | uuid_primary_key(:id) 123 | attribute(:title, :string, public?: true) 124 | end 125 | end 126 | 127 | defdomain([Post]) 128 | 129 | AshSqlite.MigrationGenerator.generate(Domain, 130 | snapshot_path: "test_snapshots_path", 131 | migration_path: "test_migration_path", 132 | dev: true 133 | ) 134 | 135 | assert [dev_file] = 136 | Path.wildcard("test_migration_path/**/*_migrate_resources*.exs") 137 | 138 | assert String.contains?(dev_file, "_dev.exs") 139 | contents = File.read!(dev_file) 140 | 141 | AshSqlite.MigrationGenerator.generate(Domain, 142 | snapshot_path: "test_snapshots_path", 143 | migration_path: "test_migration_path", 144 | auto_name: true 145 | ) 146 | 147 | assert [file] = 148 | Path.wildcard("test_migration_path/**/*_migrate_resources*.exs") 149 | 150 | refute String.contains?(file, "_dev.exs") 151 | 152 | assert contents == File.read!(file) 153 | end 154 | 155 | test "removes dev migrations when generating regular migrations" do 156 | defposts do 157 | attributes do 158 | uuid_primary_key(:id) 159 | attribute(:title, :string, public?: true) 160 | end 161 | end 162 | 163 | defdomain([Post]) 164 | 165 | # Generate dev migration first 166 | AshSqlite.MigrationGenerator.generate(Domain, 167 | snapshot_path: "test_snapshots_path", 168 | migration_path: "test_migration_path", 169 | dev: true 170 | ) 171 | 172 | assert [dev_file] = 173 | Path.wildcard("test_migration_path/**/*_migrate_resources*.exs") 174 | 175 | assert String.contains?(dev_file, "_dev.exs") 176 | 177 | # Generate regular migration - should remove dev migration 178 | AshSqlite.MigrationGenerator.generate(Domain, 179 | snapshot_path: "test_snapshots_path", 180 | migration_path: "test_migration_path", 181 | auto_name: true 182 | ) 183 | 184 | # Should only have regular migration now 185 | files = Path.wildcard("test_migration_path/**/*_migrate_resources*.exs") 186 | assert length(files) == 1 187 | assert [regular_file] = files 188 | refute String.contains?(regular_file, "_dev.exs") 189 | end 190 | 191 | test "requires name when not using dev option" do 192 | defposts do 193 | attributes do 194 | uuid_primary_key(:id) 195 | attribute(:title, :string, public?: true) 196 | end 197 | end 198 | 199 | defdomain([Post]) 200 | 201 | assert_raise RuntimeError, ~r/Name must be provided/, fn -> 202 | AshSqlite.MigrationGenerator.generate(Domain, 203 | snapshot_path: "test_snapshots_path", 204 | migration_path: "test_migration_path" 205 | ) 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /priv/resource_snapshots/test_repo/posts/20240405234211.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "default": "nil", 5 | "size": null, 6 | "type": "uuid", 7 | "source": "id", 8 | "references": null, 9 | "allow_nil?": false, 10 | "generated?": false, 11 | "primary_key?": true 12 | }, 13 | { 14 | "default": "nil", 15 | "size": null, 16 | "type": "text", 17 | "source": "title", 18 | "references": null, 19 | "allow_nil?": true, 20 | "generated?": false, 21 | "primary_key?": false 22 | }, 23 | { 24 | "default": "nil", 25 | "size": null, 26 | "type": "bigint", 27 | "source": "score", 28 | "references": null, 29 | "allow_nil?": true, 30 | "generated?": false, 31 | "primary_key?": false 32 | }, 33 | { 34 | "default": "nil", 35 | "size": null, 36 | "type": "boolean", 37 | "source": "public", 38 | "references": null, 39 | "allow_nil?": true, 40 | "generated?": false, 41 | "primary_key?": false 42 | }, 43 | { 44 | "default": "nil", 45 | "size": null, 46 | "type": "citext", 47 | "source": "category", 48 | "references": null, 49 | "allow_nil?": true, 50 | "generated?": false, 51 | "primary_key?": false 52 | }, 53 | { 54 | "default": "nil", 55 | "size": null, 56 | "type": "text", 57 | "source": "type", 58 | "references": null, 59 | "allow_nil?": true, 60 | "generated?": false, 61 | "primary_key?": false 62 | }, 63 | { 64 | "default": "nil", 65 | "size": null, 66 | "type": "bigint", 67 | "source": "price", 68 | "references": null, 69 | "allow_nil?": true, 70 | "generated?": false, 71 | "primary_key?": false 72 | }, 73 | { 74 | "default": "nil", 75 | "size": null, 76 | "type": "decimal", 77 | "source": "decimal", 78 | "references": null, 79 | "allow_nil?": true, 80 | "generated?": false, 81 | "primary_key?": false 82 | }, 83 | { 84 | "default": "nil", 85 | "size": null, 86 | "type": "text", 87 | "source": "status", 88 | "references": null, 89 | "allow_nil?": true, 90 | "generated?": false, 91 | "primary_key?": false 92 | }, 93 | { 94 | "default": "nil", 95 | "size": null, 96 | "type": "status", 97 | "source": "status_enum", 98 | "references": null, 99 | "allow_nil?": true, 100 | "generated?": false, 101 | "primary_key?": false 102 | }, 103 | { 104 | "default": "nil", 105 | "size": null, 106 | "type": "map", 107 | "source": "stuff", 108 | "references": null, 109 | "allow_nil?": true, 110 | "generated?": false, 111 | "primary_key?": false 112 | }, 113 | { 114 | "default": "nil", 115 | "size": null, 116 | "type": "text", 117 | "source": "uniq_one", 118 | "references": null, 119 | "allow_nil?": true, 120 | "generated?": false, 121 | "primary_key?": false 122 | }, 123 | { 124 | "default": "nil", 125 | "size": null, 126 | "type": "text", 127 | "source": "uniq_two", 128 | "references": null, 129 | "allow_nil?": true, 130 | "generated?": false, 131 | "primary_key?": false 132 | }, 133 | { 134 | "default": "nil", 135 | "size": null, 136 | "type": "text", 137 | "source": "uniq_custom_one", 138 | "references": null, 139 | "allow_nil?": true, 140 | "generated?": false, 141 | "primary_key?": false 142 | }, 143 | { 144 | "default": "nil", 145 | "size": null, 146 | "type": "text", 147 | "source": "uniq_custom_two", 148 | "references": null, 149 | "allow_nil?": true, 150 | "generated?": false, 151 | "primary_key?": false 152 | }, 153 | { 154 | "default": "nil", 155 | "size": null, 156 | "type": "utc_datetime_usec", 157 | "source": "created_at", 158 | "references": null, 159 | "allow_nil?": false, 160 | "generated?": false, 161 | "primary_key?": false 162 | }, 163 | { 164 | "default": "nil", 165 | "size": null, 166 | "type": "utc_datetime_usec", 167 | "source": "updated_at", 168 | "references": null, 169 | "allow_nil?": false, 170 | "generated?": false, 171 | "primary_key?": false 172 | }, 173 | { 174 | "default": "nil", 175 | "size": null, 176 | "type": "uuid", 177 | "source": "organization_id", 178 | "references": { 179 | "name": "posts_organization_id_fkey", 180 | "table": "orgs", 181 | "on_delete": null, 182 | "multitenancy": { 183 | "global": null, 184 | "strategy": null, 185 | "attribute": null 186 | }, 187 | "primary_key?": true, 188 | "destination_attribute": "id", 189 | "on_update": null, 190 | "deferrable": false, 191 | "destination_attribute_default": null, 192 | "destination_attribute_generated": null 193 | }, 194 | "allow_nil?": true, 195 | "generated?": false, 196 | "primary_key?": false 197 | }, 198 | { 199 | "default": "nil", 200 | "size": null, 201 | "type": "uuid", 202 | "source": "author_id", 203 | "references": { 204 | "name": "posts_author_id_fkey", 205 | "table": "authors", 206 | "on_delete": null, 207 | "multitenancy": { 208 | "global": null, 209 | "strategy": null, 210 | "attribute": null 211 | }, 212 | "primary_key?": true, 213 | "destination_attribute": "id", 214 | "on_update": null, 215 | "deferrable": false, 216 | "destination_attribute_default": null, 217 | "destination_attribute_generated": null 218 | }, 219 | "allow_nil?": true, 220 | "generated?": false, 221 | "primary_key?": false 222 | } 223 | ], 224 | "table": "posts", 225 | "hash": "00D35B64138747A522AD4EAB9BB8E09BDFE30C95844FD1D46E0951E85EA18FBE", 226 | "repo": "Elixir.AshSqlite.TestRepo", 227 | "identities": [ 228 | { 229 | "name": "uniq_one_and_two", 230 | "keys": [ 231 | "uniq_one", 232 | "uniq_two" 233 | ], 234 | "base_filter": "type = 'sponsored'", 235 | "index_name": "posts_uniq_one_and_two_index" 236 | } 237 | ], 238 | "base_filter": "type = 'sponsored'", 239 | "multitenancy": { 240 | "global": null, 241 | "strategy": null, 242 | "attribute": null 243 | }, 244 | "custom_indexes": [ 245 | { 246 | "message": "dude what the heck", 247 | "name": null, 248 | "table": null, 249 | "include": null, 250 | "fields": [ 251 | "uniq_custom_one", 252 | "uniq_custom_two" 253 | ], 254 | "where": null, 255 | "unique": true, 256 | "using": null 257 | } 258 | ], 259 | "custom_statements": [], 260 | "has_create_action": true 261 | } -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.MixProject do 6 | use Mix.Project 7 | 8 | @description """ 9 | The SQLite data layer for Ash Framework. 10 | """ 11 | 12 | @version "0.2.14" 13 | 14 | def project do 15 | [ 16 | app: :ash_sqlite, 17 | version: @version, 18 | elixir: "~> 1.11", 19 | start_permanent: Mix.env() == :prod, 20 | deps: deps(), 21 | description: @description, 22 | elixirc_paths: elixirc_paths(Mix.env()), 23 | preferred_cli_env: [ 24 | coveralls: :test, 25 | "coveralls.github": :test, 26 | "test.create": :test, 27 | "test.migrate": :test, 28 | "test.rollback": :test, 29 | "test.check_migrations": :test, 30 | "test.drop": :test, 31 | "test.generate_migrations": :test, 32 | "test.reset": :test 33 | ], 34 | dialyzer: [ 35 | plt_add_apps: [:ecto, :ash, :mix] 36 | ], 37 | docs: &docs/0, 38 | aliases: aliases(), 39 | package: package(), 40 | source_url: "https://github.com/ash-project/ash_sqlite", 41 | homepage_url: "https://github.com/ash-project/ash_sqlite", 42 | consolidate_protocols: Mix.env() != :test 43 | ] 44 | end 45 | 46 | if Mix.env() == :test do 47 | def application() do 48 | [ 49 | mod: {AshSqlite.TestApp, []} 50 | ] 51 | end 52 | end 53 | 54 | defp elixirc_paths(:test), do: ["lib", "test/support"] 55 | defp elixirc_paths(_), do: ["lib"] 56 | 57 | defp package do 58 | [ 59 | maintainers: [ 60 | "Zach Daniel " 61 | ], 62 | licenses: ["MIT"], 63 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* CHANGELOG* documentation), 64 | links: %{ 65 | "GitHub" => "https://github.com/ash-project/ash_sqlite", 66 | "Changelog" => "https://github.com/ash-project/ash_sqlite/blob/main/CHANGELOG.md", 67 | "Discord" => "https://discord.gg/HTHRaaVPUc", 68 | "Website" => "https://ash-hq.org", 69 | "Forum" => "https://elixirforum.com/c/elixir-framework-forums/ash-framework-forum", 70 | "REUSE Compliance" => "https://api.reuse.software/info/github.com/ash-project/ash_sqlite" 71 | } 72 | ] 73 | end 74 | 75 | defp docs do 76 | [ 77 | main: "readme", 78 | source_ref: "v#{@version}", 79 | logo: "logos/small-logo.png", 80 | extras: [ 81 | {"README.md", title: "Home"}, 82 | "documentation/tutorials/getting-started-with-ash-sqlite.md", 83 | "documentation/topics/about-ash-sqlite/what-is-ash-sqlite.md", 84 | "documentation/topics/resources/references.md", 85 | "documentation/topics/resources/polymorphic-resources.md", 86 | "documentation/topics/development/migrations-and-tasks.md", 87 | "documentation/topics/development/testing.md", 88 | "documentation/topics/advanced/expressions.md", 89 | "documentation/topics/advanced/manual-relationships.md", 90 | {"documentation/dsls/DSL-AshSqlite.DataLayer.md", 91 | search_data: Spark.Docs.search_data_for(AshSqlite.DataLayer)}, 92 | "CHANGELOG.md" 93 | ], 94 | skip_undefined_reference_warnings_on: [ 95 | "CHANGELOG.md" 96 | ], 97 | groups_for_extras: [ 98 | Tutorials: [ 99 | ~r'documentation/tutorials' 100 | ], 101 | "How To": ~r'documentation/how_to', 102 | Topics: ~r'documentation/topics', 103 | DSLs: ~r'documentation/dsls', 104 | "About AshSqlite": [ 105 | "CHANGELOG.md" 106 | ] 107 | ], 108 | groups_for_modules: [ 109 | AshSqlite: [ 110 | AshSqlite, 111 | AshSqlite.Repo, 112 | AshSqlite.DataLayer 113 | ], 114 | Utilities: [ 115 | AshSqlite.ManualRelationship 116 | ], 117 | Introspection: [ 118 | AshSqlite.DataLayer.Info, 119 | AshSqlite.CustomExtension, 120 | AshSqlite.CustomIndex, 121 | AshSqlite.Reference, 122 | AshSqlite.Statement 123 | ], 124 | Types: [ 125 | AshSqlite.Type 126 | ], 127 | Expressions: [ 128 | AshSqlite.Functions.Fragment, 129 | AshSqlite.Functions.Like 130 | ], 131 | Internals: ~r/.*/ 132 | ] 133 | ] 134 | end 135 | 136 | # Run "mix help deps" to learn about dependencies. 137 | defp deps do 138 | [ 139 | {:ecto_sql, "~> 3.13"}, 140 | {:ecto_sqlite3, "~> 0.12"}, 141 | {:ecto, "~> 3.13"}, 142 | {:jason, "~> 1.0"}, 143 | {:ash, ash_version("~> 3.9")}, 144 | {:ash_sql, ash_sql_version("~> 0.2 and >= 0.2.20")}, 145 | {:igniter, "~> 0.6 and >= 0.6.14", optional: true}, 146 | {:simple_sat, ">= 0.0.0", only: [:dev, :test]}, 147 | {:git_ops, "~> 2.5", only: [:dev, :test]}, 148 | {:ex_doc, "~> 0.37-rc", only: [:dev, :test], runtime: false}, 149 | {:ex_check, "~> 0.14", only: [:dev, :test]}, 150 | {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, 151 | {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, 152 | {:sobelow, ">= 0.0.0", only: [:dev, :test], runtime: false}, 153 | {:mix_audit, ">= 0.0.0", only: [:dev, :test], runtime: false} 154 | ] 155 | end 156 | 157 | defp ash_version(default_version) do 158 | case System.get_env("ASH_VERSION") do 159 | nil -> 160 | default_version 161 | 162 | "local" -> 163 | [path: "../ash", override: true] 164 | 165 | "main" -> 166 | [git: "https://github.com/ash-project/ash.git", override: true] 167 | 168 | version when is_binary(version) -> 169 | "~> #{version}" 170 | 171 | version -> 172 | version 173 | end 174 | end 175 | 176 | defp ash_sql_version(default_version) do 177 | case System.get_env("ASH_SQL_VERSION") do 178 | nil -> 179 | default_version 180 | 181 | "local" -> 182 | [path: "../ash_sql", override: true] 183 | 184 | "main" -> 185 | [git: "https://github.com/ash-project/ash_sql.git"] 186 | 187 | version when is_binary(version) -> 188 | "~> #{version}" 189 | 190 | version -> 191 | version 192 | end 193 | end 194 | 195 | defp aliases do 196 | [ 197 | sobelow: 198 | "sobelow --skip -i Config.Secrets --ignore-files lib/migration_generator/migration_generator.ex", 199 | credo: "credo --strict", 200 | docs: [ 201 | "spark.cheat_sheets", 202 | "docs", 203 | "spark.replace_doc_links" 204 | ], 205 | "spark.formatter": "spark.formatter --extensions AshSqlite.DataLayer", 206 | "spark.cheat_sheets": "spark.cheat_sheets --extensions AshSqlite.DataLayer", 207 | "test.generate_migrations": "ash_sqlite.generate_migrations", 208 | "test.check_migrations": "ash_sqlite.generate_migrations --check", 209 | "test.migrate": "ash_sqlite.migrate", 210 | "test.rollback": "ash_sqlite.rollback", 211 | "test.create": "ash_sqlite.create", 212 | "test.reset": ["test.drop", "test.create", "test.migrate"], 213 | "test.drop": "ash_sqlite.drop" 214 | ] 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /test/support/resources/post.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.Post do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshSqlite.Test.Domain, 9 | data_layer: AshSqlite.DataLayer, 10 | authorizers: [ 11 | Ash.Policy.Authorizer 12 | ] 13 | 14 | policies do 15 | bypass action_type(:read) do 16 | # Check that the post is in the same org as actor 17 | authorize_if(relates_to_actor_via([:organization, :users])) 18 | end 19 | end 20 | 21 | sqlite do 22 | table("posts") 23 | repo(AshSqlite.TestRepo) 24 | base_filter_sql("type = 'sponsored'") 25 | 26 | custom_indexes do 27 | index([:uniq_custom_one, :uniq_custom_two], 28 | unique: true, 29 | message: "dude what the heck" 30 | ) 31 | end 32 | end 33 | 34 | resource do 35 | base_filter(expr(type == type(:sponsored, ^Ash.Type.Atom))) 36 | end 37 | 38 | actions do 39 | default_accept(:*) 40 | defaults([:read, :update, :destroy]) 41 | 42 | read :paginated do 43 | pagination(offset?: true, required?: true) 44 | end 45 | 46 | create :create do 47 | primary?(true) 48 | argument(:rating, :map) 49 | 50 | change( 51 | manage_relationship(:rating, :ratings, 52 | on_missing: :ignore, 53 | on_no_match: :create, 54 | on_match: :create 55 | ) 56 | ) 57 | end 58 | 59 | update :increment_score do 60 | argument(:amount, :integer, default: 1) 61 | change(atomic_update(:score, expr((score || 0) + ^arg(:amount)))) 62 | end 63 | 64 | update :update_only_freds do 65 | change(filter(expr(title == "fred"))) 66 | end 67 | 68 | destroy :destroy_only_freds do 69 | change(filter(expr(title == "fred"))) 70 | end 71 | end 72 | 73 | identities do 74 | identity(:uniq_one_and_two, [:uniq_one, :uniq_two]) 75 | end 76 | 77 | attributes do 78 | uuid_primary_key(:id, writable?: true) 79 | attribute(:title, :string, public?: true) 80 | attribute(:score, :integer, public?: true) 81 | attribute(:public, :boolean, public?: true) 82 | attribute(:category, :ci_string, public?: true) 83 | attribute(:type, :atom, default: :sponsored, writable?: false) 84 | attribute(:price, :integer, public?: true) 85 | attribute(:decimal, :decimal, default: Decimal.new(0), public?: true) 86 | attribute(:status, AshSqlite.Test.Types.Status, public?: true) 87 | attribute(:status_enum, AshSqlite.Test.Types.StatusEnum, public?: true) 88 | 89 | attribute(:status_enum_no_cast, AshSqlite.Test.Types.StatusEnumNoCast, 90 | source: :status_enum, 91 | public?: true 92 | ) 93 | 94 | attribute(:stuff, :map, public?: true) 95 | attribute(:uniq_one, :string, public?: true) 96 | attribute(:uniq_two, :string, public?: true) 97 | attribute(:uniq_custom_one, :string, public?: true) 98 | attribute(:uniq_custom_two, :string, public?: true) 99 | create_timestamp(:created_at) 100 | update_timestamp(:updated_at) 101 | end 102 | 103 | code_interface do 104 | define(:get_by_id, action: :read, get_by: [:id]) 105 | define(:increment_score, args: [{:optional, :amount}]) 106 | end 107 | 108 | relationships do 109 | belongs_to :organization, AshSqlite.Test.Organization do 110 | public?(true) 111 | attribute_writable?(true) 112 | end 113 | 114 | belongs_to(:author, AshSqlite.Test.Author, public?: true) 115 | 116 | has_many(:comments, AshSqlite.Test.Comment, destination_attribute: :post_id, public?: true) 117 | 118 | has_many :comments_matching_post_title, AshSqlite.Test.Comment do 119 | public?(true) 120 | filter(expr(title == parent_expr(title))) 121 | end 122 | 123 | has_many :popular_comments, AshSqlite.Test.Comment do 124 | public?(true) 125 | destination_attribute(:post_id) 126 | filter(expr(likes > 10)) 127 | end 128 | 129 | has_many :comments_containing_title, AshSqlite.Test.Comment do 130 | public?(true) 131 | manual(AshSqlite.Test.Post.CommentsContainingTitle) 132 | end 133 | 134 | has_many(:ratings, AshSqlite.Test.Rating, 135 | public?: true, 136 | destination_attribute: :resource_id, 137 | relationship_context: %{data_layer: %{table: "post_ratings"}} 138 | ) 139 | 140 | has_many(:post_links, AshSqlite.Test.PostLink, 141 | public?: true, 142 | destination_attribute: :source_post_id, 143 | filter: [state: :active] 144 | ) 145 | 146 | many_to_many(:linked_posts, __MODULE__, 147 | public?: true, 148 | through: AshSqlite.Test.PostLink, 149 | join_relationship: :post_links, 150 | source_attribute_on_join_resource: :source_post_id, 151 | destination_attribute_on_join_resource: :destination_post_id 152 | ) 153 | 154 | has_many(:views, AshSqlite.Test.PostView, public?: true) 155 | end 156 | 157 | validations do 158 | validate(attribute_does_not_equal(:title, "not allowed")) 159 | end 160 | 161 | calculations do 162 | calculate(:score_after_winning, :integer, expr((score || 0) + 1)) 163 | calculate(:negative_score, :integer, expr(-score)) 164 | calculate(:category_label, :string, expr("(" <> category <> ")")) 165 | calculate(:score_with_score, :string, expr(score <> score)) 166 | calculate(:foo_bar_from_stuff, :string, expr(stuff[:foo][:bar])) 167 | 168 | calculate( 169 | :score_map, 170 | :map, 171 | expr(%{ 172 | negative_score: %{foo: negative_score, bar: negative_score} 173 | }) 174 | ) 175 | 176 | calculate( 177 | :calc_returning_json, 178 | AshSqlite.Test.Money, 179 | expr( 180 | fragment(""" 181 | '{"amount":100, "currency": "usd"}' 182 | """) 183 | ) 184 | ) 185 | 186 | calculate( 187 | :was_created_in_the_last_month, 188 | :boolean, 189 | expr( 190 | # This is written in a silly way on purpose, to test a regression 191 | if( 192 | fragment("(? <= (DATE(? - '+1 month')))", now(), created_at), 193 | true, 194 | false 195 | ) 196 | ) 197 | ) 198 | 199 | calculate( 200 | :price_string, 201 | :string, 202 | CalculatePostPriceString 203 | ) 204 | 205 | calculate( 206 | :price_string_with_currency_sign, 207 | :string, 208 | CalculatePostPriceStringWithSymbol 209 | ) 210 | end 211 | end 212 | 213 | defmodule CalculatePostPriceString do 214 | @moduledoc false 215 | use Ash.Resource.Calculation 216 | 217 | @impl true 218 | def load(_, _, _), do: [:price] 219 | 220 | @impl true 221 | def calculate(records, _, _) do 222 | Enum.map(records, fn %{price: price} -> 223 | dollars = div(price, 100) 224 | cents = rem(price, 100) 225 | "#{dollars}.#{cents}" 226 | end) 227 | end 228 | end 229 | 230 | defmodule CalculatePostPriceStringWithSymbol do 231 | @moduledoc false 232 | use Ash.Resource.Calculation 233 | 234 | @impl true 235 | def load(_, _, _), do: [:price_string] 236 | 237 | @impl true 238 | def calculate(records, _, _) do 239 | Enum.map(records, fn %{price_string: price_string} -> 240 | "#{price_string}$" 241 | end) 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # This file contains the configuration for Credo and you are probably reading 6 | # this after creating it with `mix credo.gen.config`. 7 | # 8 | # If you find anything wrong or unclear in this file, please report an 9 | # issue on GitHub: https://github.com/rrrene/credo/issues 10 | # 11 | %{ 12 | # 13 | # You can have as many configs as you like in the `configs:` field. 14 | configs: [ 15 | %{ 16 | # 17 | # Run any config using `mix credo -C `. If no config name is given 18 | # "default" is used. 19 | # 20 | name: "default", 21 | # 22 | # These are the files included in the analysis: 23 | files: %{ 24 | # 25 | # You can give explicit globs or simply directories. 26 | # In the latter case `**/*.{ex,exs}` will be used. 27 | # 28 | included: [ 29 | "lib/", 30 | "src/", 31 | "test/", 32 | "web/", 33 | "apps/*/lib/", 34 | "apps/*/src/", 35 | "apps/*/test/", 36 | "apps/*/web/" 37 | ], 38 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 39 | }, 40 | # 41 | # Load and configure plugins here: 42 | # 43 | plugins: [], 44 | # 45 | # If you create your own checks, you must specify the source files for 46 | # them here, so they can be loaded by Credo before running the analysis. 47 | # 48 | requires: [], 49 | # 50 | # If you want to enforce a style guide and need a more traditional linting 51 | # experience, you can change `strict` to `true` below: 52 | # 53 | strict: false, 54 | # 55 | # To modify the timeout for parsing files, change this value: 56 | # 57 | parse_timeout: 5000, 58 | # 59 | # If you want to use uncolored output by default, you can change `color` 60 | # to `false` below: 61 | # 62 | color: true, 63 | # 64 | # You can customize the parameters of any check by adding a second element 65 | # to the tuple. 66 | # 67 | # To disable a check put `false` as second element: 68 | # 69 | # {Credo.Check.Design.DuplicatedCode, false} 70 | # 71 | checks: [ 72 | # 73 | ## Consistency Checks 74 | # 75 | {Credo.Check.Consistency.ExceptionNames, []}, 76 | {Credo.Check.Consistency.LineEndings, []}, 77 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 78 | {Credo.Check.Consistency.SpaceAroundOperators, false}, 79 | {Credo.Check.Consistency.SpaceInParentheses, []}, 80 | {Credo.Check.Consistency.TabsOrSpaces, []}, 81 | 82 | # 83 | ## Design Checks 84 | # 85 | # You can customize the priority of any check 86 | # Priority values are: `low, normal, high, higher` 87 | # 88 | {Credo.Check.Design.AliasUsage, false}, 89 | # You can also customize the exit_status of each check. 90 | # If you don't want TODO comments to cause `mix credo` to fail, just 91 | # set this value to 0 (zero). 92 | # 93 | {Credo.Check.Design.TagTODO, false}, 94 | {Credo.Check.Design.TagFIXME, []}, 95 | 96 | # 97 | ## Readability Checks 98 | # 99 | {Credo.Check.Readability.AliasOrder, []}, 100 | {Credo.Check.Readability.FunctionNames, []}, 101 | {Credo.Check.Readability.LargeNumbers, []}, 102 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 103 | {Credo.Check.Readability.ModuleAttributeNames, []}, 104 | {Credo.Check.Readability.ModuleDoc, []}, 105 | {Credo.Check.Readability.ModuleNames, []}, 106 | {Credo.Check.Readability.ParenthesesInCondition, false}, 107 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 108 | {Credo.Check.Readability.PredicateFunctionNames, []}, 109 | {Credo.Check.Readability.PreferImplicitTry, []}, 110 | {Credo.Check.Readability.RedundantBlankLines, []}, 111 | {Credo.Check.Readability.Semicolons, []}, 112 | {Credo.Check.Readability.SpaceAfterCommas, []}, 113 | {Credo.Check.Readability.StringSigils, []}, 114 | {Credo.Check.Readability.TrailingBlankLine, []}, 115 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 116 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 117 | {Credo.Check.Readability.VariableNames, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.CondStatements, []}, 123 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 124 | {Credo.Check.Refactor.FunctionArity, []}, 125 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 126 | {Credo.Check.Refactor.MapInto, []}, 127 | {Credo.Check.Refactor.MatchInCondition, []}, 128 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 129 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 130 | {Credo.Check.Refactor.Nesting, [max_nesting: 5]}, 131 | {Credo.Check.Refactor.UnlessWithElse, []}, 132 | {Credo.Check.Refactor.WithClauses, []}, 133 | 134 | # 135 | ## Warnings 136 | # 137 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 138 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 139 | {Credo.Check.Warning.IExPry, []}, 140 | {Credo.Check.Warning.IoInspect, []}, 141 | {Credo.Check.Warning.LazyLogging, []}, 142 | {Credo.Check.Warning.MixEnv, false}, 143 | {Credo.Check.Warning.OperationOnSameValues, []}, 144 | {Credo.Check.Warning.OperationWithConstantResult, []}, 145 | {Credo.Check.Warning.RaiseInsideRescue, []}, 146 | {Credo.Check.Warning.UnusedEnumOperation, []}, 147 | {Credo.Check.Warning.UnusedFileOperation, []}, 148 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 149 | {Credo.Check.Warning.UnusedListOperation, []}, 150 | {Credo.Check.Warning.UnusedPathOperation, []}, 151 | {Credo.Check.Warning.UnusedRegexOperation, []}, 152 | {Credo.Check.Warning.UnusedStringOperation, []}, 153 | {Credo.Check.Warning.UnusedTupleOperation, []}, 154 | {Credo.Check.Warning.UnsafeExec, []}, 155 | 156 | # 157 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 158 | 159 | # 160 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 161 | # 162 | {Credo.Check.Readability.StrictModuleLayout, false}, 163 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 164 | {Credo.Check.Consistency.UnusedVariableNames, false}, 165 | {Credo.Check.Design.DuplicatedCode, false}, 166 | {Credo.Check.Readability.AliasAs, false}, 167 | {Credo.Check.Readability.MultiAlias, false}, 168 | {Credo.Check.Readability.Specs, false}, 169 | {Credo.Check.Readability.SinglePipe, false}, 170 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 171 | {Credo.Check.Refactor.ABCSize, false}, 172 | {Credo.Check.Refactor.AppendSingleItem, false}, 173 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 174 | {Credo.Check.Refactor.ModuleDependencies, false}, 175 | {Credo.Check.Refactor.NegatedIsNil, false}, 176 | {Credo.Check.Refactor.PipeChainStart, false}, 177 | {Credo.Check.Refactor.VariableRebinding, false}, 178 | {Credo.Check.Warning.LeakyEnvironment, false}, 179 | {Credo.Check.Warning.MapGetUnsafePass, false}, 180 | {Credo.Check.Warning.UnsafeToAtom, false} 181 | 182 | # 183 | # Custom checks can be created using `mix credo.gen.check`. 184 | # 185 | ] 186 | } 187 | ] 188 | } 189 | -------------------------------------------------------------------------------- /priv/test_repo/migrations/20240405234211_migrate_resources1.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.TestRepo.Migrations.MigrateResources1 do 6 | @moduledoc """ 7 | Updates resources based on their most recent snapshots. 8 | 9 | This file was autogenerated with `mix ash_sqlite.generate_migrations` 10 | """ 11 | 12 | use Ecto.Migration 13 | 14 | def up do 15 | create table(:users, primary_key: false) do 16 | add :organization_id, 17 | references(:orgs, column: :id, name: "users_organization_id_fkey", type: :uuid) 18 | 19 | add :is_active, :boolean 20 | add :id, :uuid, null: false, primary_key: true 21 | end 22 | 23 | create table(:profile, primary_key: false) do 24 | add :author_id, 25 | references(:authors, column: :id, name: "profile_author_id_fkey", type: :uuid) 26 | 27 | add :description, :text 28 | add :id, :uuid, null: false, primary_key: true 29 | end 30 | 31 | create table(:posts, primary_key: false) do 32 | add :author_id, references(:authors, column: :id, name: "posts_author_id_fkey", type: :uuid) 33 | 34 | add :organization_id, 35 | references(:orgs, column: :id, name: "posts_organization_id_fkey", type: :uuid) 36 | 37 | add :updated_at, :utc_datetime_usec, null: false 38 | add :created_at, :utc_datetime_usec, null: false 39 | add :uniq_custom_two, :text 40 | add :uniq_custom_one, :text 41 | add :uniq_two, :text 42 | add :uniq_one, :text 43 | add :stuff, :map 44 | add :status_enum, :status 45 | add :status, :text 46 | add :decimal, :decimal 47 | add :price, :bigint 48 | add :type, :text 49 | add :category, :citext 50 | add :public, :boolean 51 | add :score, :bigint 52 | add :title, :text 53 | add :id, :uuid, null: false, primary_key: true 54 | end 55 | 56 | create table(:post_views, primary_key: false) do 57 | add :post_id, :uuid, null: false 58 | add :browser, :text 59 | add :time, :utc_datetime_usec, null: false 60 | end 61 | 62 | create table(:post_ratings, primary_key: false) do 63 | add :resource_id, 64 | references(:posts, column: :id, name: "post_ratings_resource_id_fkey", type: :uuid) 65 | 66 | add :score, :bigint 67 | add :id, :uuid, null: false, primary_key: true 68 | end 69 | 70 | create table(:post_links, primary_key: false) do 71 | add :destination_post_id, 72 | references(:posts, 73 | column: :id, 74 | name: "post_links_destination_post_id_fkey", 75 | type: :uuid 76 | ), 77 | primary_key: true, 78 | null: false 79 | 80 | add :source_post_id, 81 | references(:posts, column: :id, name: "post_links_source_post_id_fkey", type: :uuid), 82 | primary_key: true, 83 | null: false 84 | 85 | add :state, :text 86 | end 87 | 88 | create unique_index(:post_links, [:source_post_id, :destination_post_id], 89 | name: "post_links_unique_link_index" 90 | ) 91 | 92 | create table(:orgs, primary_key: false) do 93 | add :name, :text 94 | add :id, :uuid, null: false, primary_key: true 95 | end 96 | 97 | create table(:managers, primary_key: false) do 98 | add :organization_id, 99 | references(:orgs, column: :id, name: "managers_organization_id_fkey", type: :uuid) 100 | 101 | add :role, :text 102 | add :must_be_present, :text, null: false 103 | add :code, :text, null: false 104 | add :name, :text 105 | add :id, :uuid, null: false, primary_key: true 106 | end 107 | 108 | create unique_index(:managers, [:code], name: "managers_uniq_code_index") 109 | 110 | create table(:integer_posts, primary_key: false) do 111 | add :title, :text 112 | add :id, :bigserial, null: false, primary_key: true 113 | end 114 | 115 | create table(:comments, primary_key: false) do 116 | add :author_id, 117 | references(:authors, column: :id, name: "comments_author_id_fkey", type: :uuid) 118 | 119 | add :post_id, 120 | references(:posts, 121 | column: :id, 122 | name: "special_name_fkey", 123 | type: :uuid, 124 | on_delete: :delete_all, 125 | on_update: :update_all 126 | ) 127 | 128 | add :created_at, :utc_datetime_usec, null: false 129 | add :arbitrary_timestamp, :utc_datetime_usec 130 | add :likes, :bigint 131 | add :title, :text 132 | add :id, :uuid, null: false, primary_key: true 133 | end 134 | 135 | create table(:comment_ratings, primary_key: false) do 136 | add :resource_id, 137 | references(:comments, 138 | column: :id, 139 | name: "comment_ratings_resource_id_fkey", 140 | type: :uuid 141 | ) 142 | 143 | add :score, :bigint 144 | add :id, :uuid, null: false, primary_key: true 145 | end 146 | 147 | create table(:authors, primary_key: false) do 148 | add :badges, {:array, :text} 149 | add :bio, :map 150 | add :last_name, :text 151 | add :first_name, :text 152 | add :id, :uuid, null: false, primary_key: true 153 | end 154 | 155 | create index(:posts, ["uniq_custom_one", "uniq_custom_two"], unique: true) 156 | 157 | create unique_index(:posts, [:uniq_one, :uniq_two], 158 | where: "type = 'sponsored'", 159 | name: "posts_uniq_one_and_two_index" 160 | ) 161 | 162 | create table(:accounts, primary_key: false) do 163 | add :user_id, references(:users, column: :id, name: "accounts_user_id_fkey", type: :uuid) 164 | add :is_active, :boolean 165 | add :id, :uuid, null: false, primary_key: true 166 | end 167 | end 168 | 169 | def down do 170 | drop constraint(:accounts, "accounts_user_id_fkey") 171 | 172 | drop table(:accounts) 173 | 174 | drop_if_exists unique_index(:posts, [:uniq_one, :uniq_two], 175 | name: "posts_uniq_one_and_two_index" 176 | ) 177 | 178 | drop_if_exists index(:posts, ["uniq_custom_one", "uniq_custom_two"], 179 | name: "posts_uniq_custom_one_uniq_custom_two_index" 180 | ) 181 | 182 | drop table(:authors) 183 | 184 | drop constraint(:comment_ratings, "comment_ratings_resource_id_fkey") 185 | 186 | drop table(:comment_ratings) 187 | 188 | drop constraint(:comments, "special_name_fkey") 189 | 190 | drop constraint(:comments, "comments_author_id_fkey") 191 | 192 | drop table(:comments) 193 | 194 | drop table(:integer_posts) 195 | 196 | drop_if_exists unique_index(:managers, [:code], name: "managers_uniq_code_index") 197 | 198 | drop constraint(:managers, "managers_organization_id_fkey") 199 | 200 | drop table(:managers) 201 | 202 | drop table(:orgs) 203 | 204 | drop_if_exists unique_index(:post_links, [:source_post_id, :destination_post_id], 205 | name: "post_links_unique_link_index" 206 | ) 207 | 208 | drop constraint(:post_links, "post_links_source_post_id_fkey") 209 | 210 | drop constraint(:post_links, "post_links_destination_post_id_fkey") 211 | 212 | drop table(:post_links) 213 | 214 | drop constraint(:post_ratings, "post_ratings_resource_id_fkey") 215 | 216 | drop table(:post_ratings) 217 | 218 | drop table(:post_views) 219 | 220 | drop constraint(:posts, "posts_organization_id_fkey") 221 | 222 | drop constraint(:posts, "posts_author_id_fkey") 223 | 224 | drop table(:posts) 225 | 226 | drop constraint(:profile, "profile_author_id_fkey") 227 | 228 | drop table(:profile) 229 | 230 | drop constraint(:users, "users_organization_id_fkey") 231 | 232 | drop table(:users) 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /test/load_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 ash_sqlite contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshSqlite.Test.LoadTest do 6 | use AshSqlite.RepoCase, async: false 7 | alias AshSqlite.Test.{Comment, Post} 8 | 9 | require Ash.Query 10 | 11 | test "has_many relationships can be loaded" do 12 | assert %Post{comments: %Ash.NotLoaded{type: :relationship}} = 13 | post = 14 | Post 15 | |> Ash.Changeset.for_create(:create, %{title: "title"}) 16 | |> Ash.create!() 17 | 18 | Comment 19 | |> Ash.Changeset.for_create(:create, %{title: "match"}) 20 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 21 | |> Ash.create!() 22 | 23 | results = 24 | Post 25 | |> Ash.Query.load(:comments) 26 | |> Ash.read!() 27 | 28 | assert [%Post{comments: [%{title: "match"}]}] = results 29 | end 30 | 31 | test "belongs_to relationships can be loaded" do 32 | assert %Comment{post: %Ash.NotLoaded{type: :relationship}} = 33 | comment = 34 | Comment 35 | |> Ash.Changeset.for_create(:create, %{}) 36 | |> Ash.create!() 37 | 38 | Post 39 | |> Ash.Changeset.for_create(:create, %{title: "match"}) 40 | |> Ash.Changeset.manage_relationship(:comments, [comment], type: :append_and_remove) 41 | |> Ash.create!() 42 | 43 | results = 44 | Comment 45 | |> Ash.Query.load(:post) 46 | |> Ash.read!() 47 | 48 | assert [%Comment{post: %{title: "match"}}] = results 49 | end 50 | 51 | test "many_to_many loads work" do 52 | source_post = 53 | Post 54 | |> Ash.Changeset.for_create(:create, %{title: "source"}) 55 | |> Ash.create!() 56 | 57 | destination_post = 58 | Post 59 | |> Ash.Changeset.for_create(:create, %{title: "destination"}) 60 | |> Ash.create!() 61 | 62 | destination_post2 = 63 | Post 64 | |> Ash.Changeset.for_create(:create, %{title: "destination"}) 65 | |> Ash.create!() 66 | 67 | source_post 68 | |> Ash.Changeset.new() 69 | |> Ash.Changeset.manage_relationship(:linked_posts, [destination_post, destination_post2], 70 | type: :append_and_remove 71 | ) 72 | |> Ash.update!() 73 | 74 | results = 75 | source_post 76 | |> Ash.load!(:linked_posts) 77 | 78 | assert %{linked_posts: [%{title: "destination"}, %{title: "destination"}]} = results 79 | end 80 | 81 | test "many_to_many loads work when nested" do 82 | source_post = 83 | Post 84 | |> Ash.Changeset.for_create(:create, %{title: "source"}) 85 | |> Ash.create!() 86 | 87 | destination_post = 88 | Post 89 | |> Ash.Changeset.for_create(:create, %{title: "destination"}) 90 | |> Ash.create!() 91 | 92 | source_post 93 | |> Ash.Changeset.new() 94 | |> Ash.Changeset.manage_relationship(:linked_posts, [destination_post], 95 | type: :append_and_remove 96 | ) 97 | |> Ash.update!() 98 | 99 | destination_post 100 | |> Ash.Changeset.new() 101 | |> Ash.Changeset.manage_relationship(:linked_posts, [source_post], type: :append_and_remove) 102 | |> Ash.update!() 103 | 104 | results = 105 | source_post 106 | |> Ash.load!(linked_posts: :linked_posts) 107 | 108 | assert %{linked_posts: [%{title: "destination", linked_posts: [%{title: "source"}]}]} = 109 | results 110 | end 111 | 112 | describe "lateral join loads" do 113 | # uncomment when lateral join is supported 114 | # it does not necessarily have to be implemented *exactly* as lateral join 115 | # test "parent references are resolved" do 116 | # post1 = 117 | # Post 118 | # |> Ash.Changeset.new(%{title: "title"}) 119 | # |> Api.create!() 120 | 121 | # post2 = 122 | # Post 123 | # |> Ash.Changeset.new(%{title: "title"}) 124 | # |> Api.create!() 125 | 126 | # post2_id = post2.id 127 | 128 | # post3 = 129 | # Post 130 | # |> Ash.Changeset.new(%{title: "no match"}) 131 | # |> Api.create!() 132 | 133 | # assert [%{posts_with_matching_title: [%{id: ^post2_id}]}] = 134 | # Post 135 | # |> Ash.Query.load(:posts_with_matching_title) 136 | # |> Ash.Query.filter(id == ^post1.id) 137 | # |> Api.read!() 138 | 139 | # assert [%{posts_with_matching_title: []}] = 140 | # Post 141 | # |> Ash.Query.load(:posts_with_matching_title) 142 | # |> Ash.Query.filter(id == ^post3.id) 143 | # |> Api.read!() 144 | # end 145 | 146 | # test "parent references work when joining for filters" do 147 | # %{id: post1_id} = 148 | # Post 149 | # |> Ash.Changeset.new(%{title: "title"}) 150 | # |> Api.create!() 151 | 152 | # post2 = 153 | # Post 154 | # |> Ash.Changeset.new(%{title: "title"}) 155 | # |> Api.create!() 156 | 157 | # Post 158 | # |> Ash.Changeset.new(%{title: "no match"}) 159 | # |> Api.create!() 160 | 161 | # Post 162 | # |> Ash.Changeset.new(%{title: "no match"}) 163 | # |> Api.create!() 164 | 165 | # assert [%{id: ^post1_id}] = 166 | # Post 167 | # |> Ash.Query.filter(posts_with_matching_title.id == ^post2.id) 168 | # |> Api.read!() 169 | # end 170 | 171 | # test "lateral join loads (loads with limits or offsets) are supported" do 172 | # assert %Post{comments: %Ash.NotLoaded{type: :relationship}} = 173 | # post = 174 | # Post 175 | # |> Ash.Changeset.new(%{title: "title"}) 176 | # |> Api.create!() 177 | 178 | # Comment 179 | # |> Ash.Changeset.new(%{title: "abc"}) 180 | # |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 181 | # |> Api.create!() 182 | 183 | # Comment 184 | # |> Ash.Changeset.new(%{title: "def"}) 185 | # |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 186 | # |> Api.create!() 187 | 188 | # comments_query = 189 | # Comment 190 | # |> Ash.Query.limit(1) 191 | # |> Ash.Query.sort(:title) 192 | 193 | # results = 194 | # Post 195 | # |> Ash.Query.load(comments: comments_query) 196 | # |> Api.read!() 197 | 198 | # assert [%Post{comments: [%{title: "abc"}]}] = results 199 | 200 | # comments_query = 201 | # Comment 202 | # |> Ash.Query.limit(1) 203 | # |> Ash.Query.sort(title: :desc) 204 | 205 | # results = 206 | # Post 207 | # |> Ash.Query.load(comments: comments_query) 208 | # |> Api.read!() 209 | 210 | # assert [%Post{comments: [%{title: "def"}]}] = results 211 | 212 | # comments_query = 213 | # Comment 214 | # |> Ash.Query.limit(2) 215 | # |> Ash.Query.sort(title: :desc) 216 | 217 | # results = 218 | # Post 219 | # |> Ash.Query.load(comments: comments_query) 220 | # |> Api.read!() 221 | 222 | # assert [%Post{comments: [%{title: "def"}, %{title: "abc"}]}] = results 223 | # end 224 | 225 | test "loading many to many relationships on records works without loading its join relationship when using code interface" do 226 | source_post = 227 | Post 228 | |> Ash.Changeset.for_create(:create, %{title: "source"}) 229 | |> Ash.create!() 230 | 231 | destination_post = 232 | Post 233 | |> Ash.Changeset.for_create(:create, %{title: "abc"}) 234 | |> Ash.create!() 235 | 236 | destination_post2 = 237 | Post 238 | |> Ash.Changeset.for_create(:create, %{title: "def"}) 239 | |> Ash.create!() 240 | 241 | source_post 242 | |> Ash.Changeset.new() 243 | |> Ash.Changeset.manage_relationship(:linked_posts, [destination_post, destination_post2], 244 | type: :append_and_remove 245 | ) 246 | |> Ash.update!() 247 | 248 | assert %{linked_posts: [_, _]} = Post.get_by_id!(source_post.id, load: [:linked_posts]) 249 | end 250 | end 251 | end 252 | --------------------------------------------------------------------------------