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