From ed6ef8002e58b503195a1cd93821db362d043190 Mon Sep 17 00:00:00 2001 From: Pavel Shpak Date: Sun, 24 Nov 2024 21:18:09 +0200 Subject: [PATCH 1/6] Improve generators attributes Fixes https://github.com/phoenixframework/phoenix/issues/5987 Structural and general changes - (1) Extract `Mix.Phoenix.Attribute` module. It parses cli attribute into `Attribute` struct, with validation of types and options, and prefilling some default options. Covered with tests. - (2) Create specs for attribute types and options, with examples, to be used in generators docs and in console errors. - (3) Extract `Mix.Phoenix.Migration` module. It prepares data, based on `Attribute` struct, to apply in migration file. Covered with tests. This eliminate migration data preparation in cases with no migration generation (e.g. for embedded schema or when flag is passed). Thoughts for later: potentially this logic can be used in new `mix phx.gen.migration`, as extension of `mix echo.gen.migration` with attributes. - (4) Extract `Mix.Phoenix.TestData` module. It prepares data, based on `Attribute` struct, to apply in test files. Covered with tests. - (5) Refactor only schema related data preparation in `Mix.Phoenix.Schema`, based on `Attribute` struct. Only parsing cli attributes and preparing general `sample_values` is done during creation of `Schema` struct. Specific data for generated files are created on demand based on `schema.attrs` and `schema.sample_values`. - (6) Rely on correct type inferred from referenced schema for associations, get rid of unneeded any more general foreign type setup as schema module attribute. It original implementation there it was a bug when referenced schema had different type. - (7) Extracted `Mix.Phoenix.Web` module. It prepares data, based on `Attribute` struct, to apply in html, live files. Covered with tests. It also postpone data generation. Thus e.g. data generation will be invoked only if conflicts check passes. - (8) Add utility function `Mix.Phoenix.indent_text` to indent text or list of lines with spaces, and prepend and append empty lines when needed. Use it for new and old refactored cases. - (9) Generate `context` files before `json`, `html`, `live` files, as it doesn't need specific extra bindings. - (10) Extract fixture data generation, to be done only when fixture is going to be created. - (11) Extract html assertion data generation, to be done only when web files is going to be created. - (12) Correct array values rendering for index and html. Array of integers is interpolating into string with wrong representation (`"#{[1,2]}"` is `<<1, 2>>`, `"#{[42,43]}"` is `"*+"`). And it even leads to errors during run of generated tests (e.g. `"#{[1042,1043]}"` leads to error `(ArgumentError) lists in Phoenix.HTML templates only support iodata, and not chardata.`). Correct with simple default rendering logic - adjustment with `(values || []) |> List.flatten() |> Enum.join(", ")`. It works for any levels nesting arrays. And feels reasonable default representation for array values. - (13) In generated tests files, relocate `create_attrs` and `update_attrs` from module attributes into `test` body. More practical approach, as more often we need to generate some data. E.g. with current improvement we add generation of associated records, which is pretty general case (to have associations). Attributes related changes - (14) Fix `[array,enum]` issue. Potentially nested arrays like `[array,[array,enum]]` should work as well (for now it is outside of this change scope). - (15) Add `array` alias for `[array,string]`. - (16) Fix possible collision of `enum` values with options. Extract enum values into specific options. Add support both variants: string option `[one,two]`, integer option `[[one,1],[two,2]]`. Adjust migration and other files representation. - (17) Add option-flag `required` to use in migration, schema and input form in html and live. When no attribute marked as required, the first given attribute is set to be required, with provided notification and confirmation message in console. - (18) Add alias `*` for option-flag `required`. - (19) Add option-flag `virtual` to use in schema and field skipping in migration. Add support of type `any` to be used only with this flag (add validation logic). - (20) Add option-flag `index` to use in migration. - (21) Add `default,value` option for types `boolean`, `integer`, `decimal`, `float`. - (22) Add `precision,value` and `scale,value` options for `decimal` type, to use in migration and test files. Add validations: `scale` can be used only with `precision`, `scale` have to be less then `precision`, minimum for `scale` is `1`, minimum for `precision` is `2`. - (23) Add option `Context.Schema` for `references` type. This options should be used when referenced schema cannot be inferred from the attribute name (e.g. when it has different context). Referenced table and type will be properly inferred from schema reflection. It should fix issue with incorrect references type in case when schema created `--binary-id` option, but referenced schema was created with general `id`. And vice versa. - (24) Add option `assoc,value` for `references` type. For cases when it cannot be inferred from the attribute name. - (25) Add option `on_delete,value` for `references` type. Pretty often we want to deviate from default value `nothing`. - (26) Add option `column,value` for `references` type. For cases when referenced column differs from default `id`. - (27) Add option `type,value` for `references` type. For rear cases when referenced schema is not reachable (e.g. in generators' tests), or when referenced column is not default `id`. For now support values `id`, `binary_id`, `string`. - (28) Add option `table,value` for `references` type. For rear cases when referenced schema is not reachable (e.g. in generators' tests). - (29) Update guides about changes in `references` interface and other parts. Notes NOTE: (1) One drawback so far is necessity to specify `string` type if any option is provided. Which can be neglected I think. Otherwise we need more specific separation between name-type and options, something in a way of `name:type|options`. Then it would be easy and safe to parse omitted string type in `name|options`. Let me know if you want apply this approach. it should be easy enough to modify. NOTE: (2) There is bug in current original version: live test fail in case of invalid attributes, when only boolean attributes exists (cannot find `"can't be blank"` text.). An this bug still exists in this PR. It is out of scope of this PR to decide approach to fix it. NOTE: (3) Looks like there is issue with recently added `--primary-key` option (generated code has errors on execution "(Postgrex.Error) ERROR 23502 (not_null_violation) null value in column" - related to primary key column). An this bug still exists in this PR. It is out of scope of this PR to decide approach to fix it. --- CHANGELOG.md | 4 +- guides/contexts.md | 129 ++- guides/ecto.md | 26 +- guides/json_and_apis.md | 2 +- guides/mix_tasks.md | 10 +- guides/testing/testing_contexts.md | 17 +- guides/testing/testing_controllers.md | 26 +- .../app_with_defaults_test.exs | 30 +- .../app_with_mssql_adapter_test.exs | 55 +- .../app_with_mysql_adapter_test.exs | 37 +- .../app_with_sqlite3_adapter.exs | 21 +- .../umbrella_app_with_defaults_test.exs | 30 +- lib/mix/phoenix.ex | 123 ++- lib/mix/phoenix/attribute.ex | 757 +++++++++++++++ lib/mix/phoenix/migration.ex | 113 +++ lib/mix/phoenix/schema.ex | 677 ++++---------- lib/mix/phoenix/test_data.ex | 404 ++++++++ lib/mix/phoenix/web.ex | 121 +++ lib/mix/tasks/phx.gen.auth.ex | 8 +- lib/mix/tasks/phx.gen.context.ex | 49 +- lib/mix/tasks/phx.gen.embedded.ex | 34 +- lib/mix/tasks/phx.gen.html.ex | 116 +-- lib/mix/tasks/phx.gen.json.ex | 23 +- lib/mix/tasks/phx.gen.live.ex | 92 +- lib/mix/tasks/phx.gen.schema.ex | 128 ++- priv/templates/phx.gen.context/fixtures.ex | 11 +- .../phx.gen.context/fixtures_module.ex | 4 +- priv/templates/phx.gen.context/test_cases.exs | 20 +- .../phx.gen.embedded/embedded_schema.ex | 15 +- .../phx.gen.html/controller_test.exs | 19 +- priv/templates/phx.gen.html/html.ex | 2 +- priv/templates/phx.gen.html/index.html.heex | 7 +- .../phx.gen.html/resource_form.html.heex | 2 +- priv/templates/phx.gen.html/show.html.heex | 3 +- .../phx.gen.json/controller_test.exs | 28 +- priv/templates/phx.gen.json/json.ex | 2 +- priv/templates/phx.gen.live/form.ex | 2 +- priv/templates/phx.gen.live/index.ex | 5 +- priv/templates/phx.gen.live/live_test.exs | 47 +- priv/templates/phx.gen.live/show.ex | 5 +- priv/templates/phx.gen.schema/migration.exs | 20 +- priv/templates/phx.gen.schema/schema.ex | 23 +- test/mix/phoenix/attribute_test.exs | 883 ++++++++++++++++++ test/mix/phoenix/migration_test.exs | 161 ++++ test/mix/phoenix/schema_test.exs | 290 ++++++ test/mix/phoenix/test_data_test.exs | 883 ++++++++++++++++++ test/mix/phoenix/web_test.exs | 281 ++++++ test/mix/phoenix_test.exs | 160 ++-- test/mix/tasks/phx.gen.context_test.exs | 72 +- test/mix/tasks/phx.gen.embedded_test.exs | 29 +- test/mix/tasks/phx.gen.html_test.exs | 117 +-- test/mix/tasks/phx.gen.json_test.exs | 72 +- test/mix/tasks/phx.gen.live_test.exs | 447 ++++----- test/mix/tasks/phx.gen.schema_test.exs | 496 +++++----- test/mix/tasks/phx_test.exs | 5 +- test/phoenix/verified_routes_test.exs | 1 - 56 files changed, 5369 insertions(+), 1775 deletions(-) create mode 100644 lib/mix/phoenix/attribute.ex create mode 100644 lib/mix/phoenix/migration.ex create mode 100644 lib/mix/phoenix/test_data.ex create mode 100644 lib/mix/phoenix/web.ex create mode 100644 test/mix/phoenix/attribute_test.exs create mode 100644 test/mix/phoenix/migration_test.exs create mode 100644 test/mix/phoenix/schema_test.exs create mode 100644 test/mix/phoenix/test_data_test.exs create mode 100644 test/mix/phoenix/web_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index d3849ce1c1..372dcb15b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,7 +74,7 @@ future styling). ### Enhancements * [phx.gen.auth] Add enhanced session fixation protection. - For applications whichs previously used `phx.gen.auth`, the following line can be added to the `renew_session` function in the auth module: + For applications which's previously used `phx.gen.auth`, the following line can be added to the `renew_session` function in the auth module: ```diff defp renew_session(conn) do @@ -231,7 +231,7 @@ future styling). * [Router] Add `Phoenix.VerifiedRoutes` for `~p`-based route generation with compile-time verification. * [Router] Support `helpers: false` to `use Phoenix.Router` to disable helper generation * [Router] Add `--info [url]` switch to `phx.routes` to get route information about a url/path - * [Flash] Add `Phoenix.Flash` for unfied flash access + * [Flash] Add `Phoenix.Flash` for unified flash access ### JavaScript Client Bug Fixes * Fix heartbeat being sent after disconnect and causing abnormal disconnects diff --git a/guides/contexts.md b/guides/contexts.md index 333eaa209f..a4d11af6a3 100644 --- a/guides/contexts.md +++ b/guides/contexts.md @@ -31,8 +31,8 @@ In order to run the context generators, we need to come up with a module name th To jump-start our catalog context, we'll use `mix phx.gen.html` which creates a context module that wraps up Ecto access for creating, updating, and deleting products, along with web files like controllers and templates for the web interface into our context. Run the following command at your project root: ```console -$ mix phx.gen.html Catalog Product products title:string \ -description:string price:decimal views:integer +$ mix phx.gen.html Catalog Product products title:string:* \ +description:string:* price:decimal:*:precision,15:scale,6 views:integer:*:default,0 * creating lib/hello_web/controllers/product_controller.ex * creating lib/hello_web/controllers/product_html/edit.html.heex @@ -71,23 +71,21 @@ Phoenix generated the web files as expected in `lib/hello_web/`. We can also see end ``` -With the new route in place, Phoenix reminds us to update our repo by running `mix ecto.migrate`, but first we need to make a few tweaks to the generated migration in `priv/repo/migrations/*_create_products.exs`: +With the new route in place, Phoenix reminds us to update our repo by running `mix ecto.migrate`, but first let's see our attributes options nicely added to the generated migration in `priv/repo/migrations/*_create_products.exs`: ```elixir def change do - create table(:products) do - add :title, :string - add :description, :string -- add :price, :decimal -+ add :price, :decimal, precision: 15, scale: 6, null: false -- add :views, :integer -+ add :views, :integer, default: 0, null: false + create table("products") do + add :title, :string, null: false + add :description, :string, null: false + add :price, :decimal, precision: 15, scale: 6, null: false + add :views, :integer, default: 0, null: false timestamps() end ``` -We modified our price column to a specific precision of 15, scale of 6, along with a not-null constraint. This ensures we store currency with proper precision for any mathematical operations we may perform. Next, we added a default value and not-null constraint to our views count. With our changes in place, we're ready to migrate up our database. Let's do that now: +We see price column with specific precision of 15 and scale of 6, along with a not-null constraint. This ensures we store currency with proper precision for any mathematical operations we may perform. Next, we added a default value and not-null constraint to our views count. With all options in place, we're ready to migrate up our database. Let's do that now: ```console $ mix ecto.migrate @@ -238,7 +236,7 @@ defmodule Hello.Catalog.Product do field :description, :string field :price, :decimal field :title, :string - field :views, :integer + field :views, :integer, default: 0 timestamps() end @@ -334,7 +332,7 @@ For now, categories will contain only textual information. Our first order of bu ```console $ mix phx.gen.context Catalog Category categories \ -title:string:unique +title:string:*:unique You are generating into an existing context. ... @@ -366,20 +364,20 @@ defmodule Hello.Repo.Migrations.CreateProductCategories do use Ecto.Migration def change do - create table(:product_categories, primary_key: false) do - add :product_id, references(:products, on_delete: :delete_all) - add :category_id, references(:categories, on_delete: :delete_all) + create table("product_categories", primary_key: false) do + add :product_id, references("products", on_delete: :delete_all) + add :category_id, references("categories", on_delete: :delete_all) end - create index(:product_categories, [:product_id]) - create unique_index(:product_categories, [:category_id, :product_id]) + create index("product_categories", [:product_id]) + create index("product_categories", [:category_id, :product_id], unique: true) end end ``` We created a `product_categories` table and used the `primary_key: false` option since our join table does not need a primary key. Next we defined our `:product_id` and `:category_id` foreign key fields, and passed `on_delete: :delete_all` to ensure the database prunes our join table records if a linked product or category is deleted. By using a database constraint, we enforce data integrity at the database level, rather than relying on ad-hoc and error-prone application logic. -Next, we created indexes for our foreign keys, one of which is a unique index to ensure a product cannot have duplicate categories. Note that we do not necessarily need single-column index for `category_id` because it is in the leftmost prefix of multicolumn index, which is enough for the database optimizer. Adding a redundant index, on the other hand, only adds overhead on write. +Next, we created indexes for our foreign keys, one of which is a unique index to ensure a product cannot have duplicate categories. Note that we do not necessarily need single-column index for `category_id` because it is in the leftmost prefix of multi-column index, which is enough for the database optimizer. Adding a redundant index, on the other hand, only adds overhead on write. With our migrations in place, we can migrate up. @@ -437,7 +435,7 @@ Perfect. Before we integrate categories in the web layer, we need to let our con field :description, :string field :price, :decimal field :title, :string - field :views, :integer + field :views, :integer, default: 0 + many_to_many :categories, Category, join_through: "product_categories", on_replace: :delete @@ -563,7 +561,7 @@ Let's create a `ShoppingCart` context to handle basic cart duties. Before we wri From the description, it's clear we need a `Cart` resource for storing the user's cart, along with a `CartItem` to track products in the cart. With our plan set, let's get to work. Run the following command to generate our new context: ```console -$ mix phx.gen.context ShoppingCart Cart carts user_uuid:uuid:unique +$ mix phx.gen.context ShoppingCart Cart carts user_uuid:uuid:unique:* * creating lib/hello/shopping_cart/cart.ex * creating priv/repo/migrations/20210205203128_create_carts.exs @@ -591,8 +589,8 @@ We generated our new context `ShoppingCart`, with a new `ShoppingCart.Cart` sche ```console $ mix phx.gen.context ShoppingCart CartItem cart_items \ -cart_id:references:carts product_id:references:products \ -price_when_carted:decimal quantity:integer +cart_id:references:on_delete,delete_all:* product_id:references:Catalog.Product:on_delete,delete_all:* \ +price_when_carted:decimal:*:precision,15:scale,6 quantity:integer:* You are generating into an existing context. ... @@ -609,27 +607,24 @@ Remember to update your repository by running migrations: ``` -We generated a new resource inside our `ShoppingCart` named `CartItem`. This schema and table will hold references to a cart and product, along with the price at the time we added the item to our cart, and the quantity the user wishes to purchase. Let's touch up the generated migration file in `priv/repo/migrations/*_create_cart_items.ex`: +We generated a new resource inside our `ShoppingCart` named `CartItem`. This schema and table will hold references to a cart and product, along with the price at the time we added the item to our cart, and the quantity the user wishes to purchase. Let's check the generated migration file in `priv/repo/migrations/*_create_cart_items.ex`: ```elixir - create table(:cart_items) do -- add :price_when_carted, :decimal -+ add :price_when_carted, :decimal, precision: 15, scale: 6, null: false - add :quantity, :integer -- add :cart_id, references(:carts, on_delete: :nothing) -+ add :cart_id, references(:carts, on_delete: :delete_all) -- add :product_id, references(:products, on_delete: :nothing) -+ add :product_id, references(:products, on_delete: :delete_all) + create table("cart_items") do + add :price_when_carted, :decimal, precision: 15, scale: 6, null: false + add :quantity, :integer, null: false + add :cart_id, references("carts", on_delete: :delete_all), null: false + add :product_id, references("products", on_delete: :delete_all), null: false timestamps() end -- create index(:cart_items, [:cart_id]) - create index(:cart_items, [:product_id]) -+ create unique_index(:cart_items, [:cart_id, :product_id]) +- create index("cart_items", [:cart_id]) + create index("cart_items", [:product_id]) ++ create index("cart_items", [:cart_id, :product_id], unique: true) ``` -We used the `:delete_all` strategy again to enforce data integrity. This way, when a cart or product is deleted from the application, we don't have to rely on application code in our `ShoppingCart` or `Catalog` contexts to worry about cleaning up the records. This keeps our application code decoupled and the data integrity enforcement where it belongs – in the database. We also added a unique constraint to ensure a duplicate product is not allowed to be added to a cart. As with the `product_categories` table, using a multi-column index lets us remove the separate index for the leftmost field (`cart_id`). With our database tables in place, we can now migrate up: +We used the `:delete_all` strategy again to enforce data integrity, though this time we passed it via attribute option. This way, when a cart or product is deleted from the application, we don't have to rely on application code in our `ShoppingCart` or `Catalog` contexts to worry about cleaning up the records. This keeps our application code decoupled and the data integrity enforcement where it belongs – in the database. Notice how generator inferred referenced table from referenced schemas. And we only need to provide schema for product relation `Catalog.Product`, because it is in different context. For cart referenced schema was inferred automatically, from field name `cart_id`. We also added a unique constraint to ensure a duplicate product is not allowed to be added to a cart. As with the `product_categories` table, using a multi-column index lets us remove the separate index for the leftmost field (`cart_id`). With our database tables in place, we can now migrate up: ```console $ mix ecto.migrate @@ -675,17 +670,14 @@ Now that we know where our data dependencies exist, let's add our schema associa end ``` -Now that our cart is associated to the items we place in it, let's set up the cart item associations inside `lib/hello/shopping_cart/cart_item.ex`: +Now that our cart is associated to the items we place in it, let's tweak the cart item associations inside `lib/hello/shopping_cart/cart_item.ex`: ```elixir schema "cart_items" do field :price_when_carted, :decimal field :quantity, :integer -- field :cart_id, :id -- field :product_id, :id - -+ belongs_to :cart, Hello.ShoppingCart.Cart -+ belongs_to :product, Hello.Catalog.Product + belongs_to :cart, Hello.ShoppingCart.Cart + belongs_to :product, Hello.Catalog.Product timestamps() end @@ -693,13 +685,17 @@ Now that our cart is associated to the items we place in it, let's set up the ca @doc false def changeset(cart_item, attrs) do cart_item - |> cast(attrs, [:price_when_carted, :quantity]) - |> validate_required([:price_when_carted, :quantity]) +- |> cast(attrs, [:price_when_carted, :quantity, :cart_id, :product_id]) +- |> validate_required([:price_when_carted, :quantity, :cart_id, :product_id]) +- |> assoc_constraint(:cart) +- |> assoc_constraint(:product) ++ |> cast(attrs, [:price_when_carted, :quantity]) ++ |> validate_required([:price_when_carted, :quantity]) + |> validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100) end ``` -First, we replaced the `cart_id` field with a standard `belongs_to` pointing at our `ShoppingCart.Cart` schema. Next, we replaced our `product_id` field by adding our first cross-context data dependency with a `belongs_to` for the `Catalog.Product` schema. Here, we intentionally coupled the data boundaries because it provides exactly what we need: an isolated context API with the bare minimum knowledge necessary to reference a product in our system. Next, we added a new validation to our changeset. With `validate_number/3`, we ensure any quantity provided by user input is between 0 and 100. +From generator we have a standard association `belongs_to :cart` pointing at our `ShoppingCart.Cart` schema in the same context. For `product` we have our first cross-context data dependency with a `belongs_to` for the `Catalog.Product` schema. As mentioned before, because of contexts difference, we had to pass it as an option to the `product_id` attribute in generation command. Here, we intentionally coupled the data boundaries because it provides exactly what we need: an isolated context API with the bare minimum knowledge necessary to reference a product in our system. Next, we remove generated association validations. In general cases we don't need to load associated records into memory and just validate them based on foreign key constraint. But in our case we anyway are going to load product for price info and cart will be loaded into assign already. So we will put associations to cart_item outside of this changeset, during cart_item creation, as we will see soon. Also we added a new validation to our changeset. With `validate_number/3`, we ensure any quantity provided by user input is between 0 and 100. With our schemas in place, we can start integrating the new data structures and `ShoppingCart` context APIs into our web-facing features. @@ -950,7 +946,7 @@ Next we can create the template at `lib/hello_web/controllers/cart_html/show.htm <.simple_form :let={f} for={@changeset} action={~p"/cart"}> <.inputs_for :let={item_form} field={f[:items]}> - <% item = item_form.data %> + <% item = item_form.data %> <.input field={item_form[:quantity]} type="number" label={item.product.title} /> <%= currency_to_str(ShoppingCart.total_item_price(item)) %> @@ -1050,7 +1046,7 @@ If we stop and consider the order process, we'll see that orders involve related Naming wise, `Orders` clearly defines the scope of our context, so let's get started by again taking advantage of the context generators. Run the following command in your console: ```console -$ mix phx.gen.context Orders Order orders user_uuid:uuid total_price:decimal +$ mix phx.gen.context Orders Order orders user_uuid:uuid:* total_price:decimal:*:precision,15:scale,6 * creating lib/hello/orders/order.ex * creating priv/repo/migrations/20210209214612_create_orders.exs @@ -1066,14 +1062,13 @@ Remember to update your repository by running migrations: $ mix ecto.migrate ``` -We generated an `Orders` context. We added a `user_uuid` field to associate our placeholder current user to an order, along with a `total_price` column. With our starting point in place, let's open up the newly created migration in `priv/repo/migrations/*_create_orders.exs` and make the following changes: +We generated an `Orders` context. We added a `user_uuid` field to associate our placeholder current user to an order, along with a `total_price` column. With our starting point in place, let's open up the newly created migration in `priv/repo/migrations/*_create_orders.exs` to check generated code: ```elixir def change do - create table(:orders) do - add :user_uuid, :uuid -- add :total_price, :decimal -+ add :total_price, :decimal, precision: 15, scale: 6, null: false + create table("orders") do + add :user_uuid, :uuid, null: false + add :total_price, :decimal, precision: 15, scale: 6, null: false timestamps() end @@ -1086,8 +1081,8 @@ The orders table alone doesn't hold much information, but we know we'll need to ```console $ mix phx.gen.context Orders LineItem order_line_items \ -price:decimal quantity:integer \ -order_id:references:orders product_id:references:products +price:decimal:*:precision,15:scale,6 quantity:integer \ +order_id:references product_id:references:Catalog.Product You are generating into an existing context. ... @@ -1103,22 +1098,21 @@ Remember to update your repository by running migrations: $ mix ecto.migrate ``` -We used the `phx.gen.context` command to generate the `LineItem` Ecto schema and inject supporting functions into our orders context. Like before, let's modify the migration in `priv/repo/migrations/*_create_order_line_items.exs` and make the following decimal field changes: +We used the `phx.gen.context` command to generate the `LineItem` Ecto schema and inject supporting functions into our orders context. Like before, generator applied options to decimal column and inferred table names for references in the migration file `priv/repo/migrations/*_create_order_line_items.exs`: ```elixir def change do - create table(:order_line_items) do -- add :price, :decimal -+ add :price, :decimal, precision: 15, scale: 6, null: false + create table("order_line_items") do + add :price, :decimal, precision: 15, scale: 6, null: false add :quantity, :integer - add :order_id, references(:orders, on_delete: :nothing) - add :product_id, references(:products, on_delete: :nothing) + add :order_id, references("orders", on_delete: :nothing) + add :product_id, references("products", on_delete: :nothing) timestamps() end - create index(:order_line_items, [:order_id]) - create index(:order_line_items, [:product_id]) + create index("order_line_items", [:order_id]) + create index("order_line_items", [:product_id]) end ``` @@ -1136,23 +1130,20 @@ With our migration in place, let's wire up our orders and line items association end ``` -We used `has_many :line_items` to associate orders and line items, just like we've seen before. Next, we used the `:through` feature of `has_many`, which allows us to instruct ecto how to associate resources across another relationship. In this case, we can associate products of an order by finding all products through associated line items. Next, let's wire up the association in the other direction in `lib/hello/orders/line_item.ex`: +We used `has_many :line_items` to associate orders and line items, just like we've seen before. Next, we used the `:through` feature of `has_many`, which allows us to instruct ecto how to associate resources across another relationship. In this case, we can associate products of an order by finding all products through associated line items. Next, let's check the association in the other direction in `lib/hello/orders/line_item.ex`: ```elixir schema "order_line_items" do field :price, :decimal field :quantity, :integer -- field :order_id, :id -- field :product_id, :id - -+ belongs_to :order, Hello.Orders.Order -+ belongs_to :product, Hello.Catalog.Product + belongs_to :order, Hello.Orders.Order + belongs_to :product, Hello.Catalog.Product timestamps() end ``` -We used `belongs_to` to associate line items to orders and products. With our associations in place, we can start integrating the web interface into our order process. Open up your router `lib/hello_web/router.ex` and add the following line: +Generator added for us `belongs_to` to associate line items to orders and products, correctly inferring referenced schema for `order` from its field name. With our associations in place, we can start integrating the web interface into our order process. Open up your router `lib/hello_web/router.ex` and add the following line: ```elixir scope "/", HelloWeb do diff --git a/guides/ecto.md b/guides/ecto.md index a06b778d11..22be73e479 100644 --- a/guides/ecto.md +++ b/guides/ecto.md @@ -23,8 +23,8 @@ This guide assumes that we have generated our new application with Ecto integrat Once we have Ecto and PostgreSQL installed and configured, the easiest way to use Ecto is to generate an Ecto *schema* through the `phx.gen.schema` task. Ecto schemas are a way for us to specify how Elixir data types map to and from external sources, such as database tables. Let's generate a `User` schema with `name`, `email`, `bio`, and `number_of_pets` fields. ```console -$ mix phx.gen.schema User users name:string email:string \ -bio:string number_of_pets:integer +$ mix phx.gen.schema User users name:string:required email:string:* \ +bio:string:* number_of_pets:integer:* * creating ./lib/hello/user.ex * creating priv/repo/migrations/20170523151118_create_users.exs @@ -72,18 +72,18 @@ hello_dev=# \d hello_dev=# \q ``` -If we take a look at the migration generated by `phx.gen.schema` in `priv/repo/migrations/`, we'll see that it will add the columns we specified. It will also add timestamp columns for `inserted_at` and `updated_at` which come from the [`timestamps/1`] function. +If we take a look at the migration generated by `phx.gen.schema` in `priv/repo/migrations/`, we'll see that it will add the columns we specified. With `null: false` constraint for required fields. It will also add timestamp columns for `inserted_at` and `updated_at` which come from the [`timestamps/1`] function. ```elixir defmodule Hello.Repo.Migrations.CreateUsers do use Ecto.Migration def change do - create table(:users) do - add :name, :string - add :email, :string - add :bio, :string - add :number_of_pets, :integer + create table("users") do + add :name, :string, null: false + add :email, :string, null: false + add :bio, :string, null: false + add :number_of_pets, :integer, null: false timestamps() end @@ -100,10 +100,10 @@ Table "public.users" Column | Type | Modifiers ---------------+-----------------------------+---------------------------------------------------- id | bigint | not null default nextval('users_id_seq'::regclass) -name | character varying(255) | -email | character varying(255) | -bio | character varying(255) | -number_of_pets | integer | +name | character varying(255) | not null +email | character varying(255) | not null +bio | character varying(255) | not null +number_of_pets | integer | not null inserted_at | timestamp without time zone | not null updated_at | timestamp without time zone | not null Indexes: @@ -194,7 +194,7 @@ Right now, we have two transformations in our pipeline. In the first call, we in [`cast/3`] first takes a struct, then the parameters (the proposed updates), and then the final field is the list of columns to be updated. [`cast/3`] also will only take fields that exist in the schema. -Next, `Ecto.Changeset.validate_required/3` checks that this list of fields is present in the changeset that [`cast/3`] returns. By default with the generator, all fields are required. +Next, `Ecto.Changeset.validate_required/3` checks that this list of fields is present in the changeset that [`cast/3`] returns. In generation command we added option `required` to fields `name` and alias its `*` to rest of the fields. By default if no field is required then generator will set first given field as required, notifying us and asking for approval before generation. We can verify this functionality in `IEx`. Let's fire up our application inside IEx by running `iex -S mix`. In order to minimize typing and make this easier to read, let's alias our `Hello.User` struct. diff --git a/guides/json_and_apis.md b/guides/json_and_apis.md index 5994229dc5..7dd7b871bd 100644 --- a/guides/json_and_apis.md +++ b/guides/json_and_apis.md @@ -13,7 +13,7 @@ For this guide let's create a simple JSON API to store our favourite links, that For this guide, we will use Phoenix generators to scaffold our API infrastructure: ```console -mix phx.gen.json Urls Url urls link:string title:string +mix phx.gen.json Urls Url urls link:string:* title:string:* * creating lib/hello_web/controllers/url_controller.ex * creating lib/hello_web/controllers/url_json.ex * creating lib/hello_web/controllers/changeset_json.ex diff --git a/guides/mix_tasks.md b/guides/mix_tasks.md index 565a001472..9f4becbc4e 100644 --- a/guides/mix_tasks.md +++ b/guides/mix_tasks.md @@ -40,7 +40,7 @@ We will cover all Phoenix Mix tasks, except `phx.new`, `phx.new.ecto`, and `phx. Phoenix offers the ability to generate all the code to stand up a complete HTML resource — Ecto migration, Ecto context, controller with all the necessary actions, view, and templates. This can be a tremendous time saver. Let's take a look at how to make this happen. -The `mix phx.gen.html` task takes the following arguments: the module name of the context, the module name of the schema, the resource name, and a list of column_name:type attributes. The module name we pass in must conform to the Elixir rules of module naming, following proper capitalization. +The `mix phx.gen.html` task takes the following arguments: the module name of the context, the module name of the schema, the resource name, and a list of `name:type:options` attributes. The module name we pass in must conform to the Elixir rules of module naming, following proper capitalization. ```console $ mix phx.gen.html Blog Post posts body:string word_count:integer @@ -132,7 +132,7 @@ It will tell us we need to add a line to our router file, but since we skipped t Phoenix also offers the ability to generate all the code to stand up a complete JSON resource — Ecto migration, Ecto schema, controller with all the necessary actions and view. This command will not create any template for the app. -The `mix phx.gen.json` task takes the following arguments: the module name of the context, the module name of the schema, the resource name, and a list of column_name:type attributes. The module name we pass in must conform to the Elixir rules of module naming, following proper capitalization. +The `mix phx.gen.json` task takes the following arguments: the module name of the context, the module name of the schema, the resource name, and a list of `name:type:options` attributes. The module name we pass in must conform to the Elixir rules of module naming, following proper capitalization. ```console $ mix phx.gen.json Blog Post posts title:string content:string @@ -179,7 +179,7 @@ warning: no route path for HelloWeb.Router matches \"/posts\" If we don't need a complete HTML/JSON resource and only need a context, we can use the `mix phx.gen.context` task. It will generate a context, a schema, a migration and a test case. -The `mix phx.gen.context` task takes the following arguments: the module name of the context, the module name of the schema, the resource name, and a list of column_name:type attributes. +The `mix phx.gen.context` task takes the following arguments: the module name of the context, the module name of the schema, the resource name, and a list of `name:type:options` attributes. ```console $ mix phx.gen.context Accounts User users name:string age:integer @@ -211,10 +211,10 @@ $ mix phx.gen.context Admin.Accounts User users name:string age:integer If we don't need a complete HTML/JSON resource and are not interested in generating or altering a context we can use the `mix phx.gen.schema` task. It will generate a schema, and a migration. -The `mix phx.gen.schema` task takes the following arguments: the module name of the schema (which may be namespaced), the resource name, and a list of column_name:type attributes. +The `mix phx.gen.schema` task takes the following arguments: the module name of the schema (which may be namespaced), the resource name, and a list of `name:type:options` attributes. ```console -$ mix phx.gen.schema Accounts.Credential credentials email:string:unique user_id:references:users +$ mix phx.gen.schema Accounts.Credential credentials email:string:unique user_id:references * creating lib/hello/accounts/credential.ex * creating priv/repo/migrations/20170906162013_create_credentials.exs ``` diff --git a/guides/testing/testing_contexts.md b/guides/testing/testing_contexts.md index c2b330c217..c303456fd6 100644 --- a/guides/testing/testing_contexts.md +++ b/guides/testing/testing_contexts.md @@ -9,7 +9,7 @@ At the end of the Introduction to Testing guide, we generated an HTML resource for posts using the following command: ```console -$ mix phx.gen.html Blog Post posts title body:text +$ mix phx.gen.html Blog Post posts title:string:* body:text:* ``` This gave us a number of modules for free, including a Blog context and a Post schema, alongside their respective test files. As we have learned in the Context guide, the Blog context is simply a module with functions to a particular area of our business domain, while Post schema maps to a particular table in our database. @@ -60,7 +60,7 @@ Next, we define an alias, so we can refer to `Hello.Blog` simply as `Blog`. Then we start a `describe "posts"` block. A `describe` block is a feature in ExUnit that allows us to group similar tests. The reason why we have grouped all post related tests together is because contexts in Phoenix are capable of grouping multiple schemas together. For example, if we ran this command: ```console -$ mix phx.gen.html Blog Comment comments post_id:references:posts body:text +$ mix phx.gen.html Blog Comment comments post_id:references body:text ``` We will get a bunch of new functions in the `Hello.Blog` context, plus a whole new `describe "comments"` block in our test file. @@ -69,11 +69,14 @@ The tests defined for our context are very straight-forward. They call the funct ```elixir test "create_post/1 with valid data creates a post" do - valid_attrs = %{body: "some body", title: "some title"} - - assert {:ok, %Post{} = post} = Blog.create_post(valid_attrs) - assert post.body == "some body" - assert post.title == "some title" + create_attrs = %{ + body: "body value", + title: "title value" + } + + assert {:ok, %Post{} = post} = Blog.create_post(create_attrs) + assert post.body == "body value" + assert post.title == "title value" end ``` diff --git a/guides/testing/testing_controllers.md b/guides/testing/testing_controllers.md index b406dd1bef..51c6b3fef8 100644 --- a/guides/testing/testing_controllers.md +++ b/guides/testing/testing_controllers.md @@ -7,7 +7,7 @@ At the end of the Introduction to Testing guide, we generated an HTML resource for posts using the following command: ```console -$ mix phx.gen.html Blog Post posts title body:text +$ mix phx.gen.html Blog Post posts title:string:* body:text:* ``` This gave us a number of modules for free, including a PostController and the associated tests. We are going to explore those tests to learn more about testing controllers in general. At the end of the guide, we will generate a JSON resource, and explore how our API tests look like. @@ -22,10 +22,8 @@ defmodule HelloWeb.PostControllerTest do import Hello.BlogFixtures - @create_attrs %{body: "some body", title: "some title"} - @update_attrs %{body: "some updated body", title: "some updated title"} @invalid_attrs %{body: nil, title: nil} - + describe "index" do test "lists all posts", %{conn: conn} do conn = get(conn, ~p"/posts") @@ -36,7 +34,7 @@ defmodule HelloWeb.PostControllerTest do ... ``` -Similar to the `PageControllerTest` that ships with our application, this controller tests uses `use HelloWeb.ConnCase` to setup the testing structure. Then, as usual, it defines some aliases, some module attributes to use throughout testing, and then it starts a series of `describe` blocks, each of them to test a different controller action. +Similar to the `PageControllerTest` that ships with our application, this controller tests uses `use HelloWeb.ConnCase` to setup the testing structure. Then, as usual, it defines some aliases, module attribute for invalid data to use throughout testing, and then it starts a series of `describe` blocks, each of them to test a different controller action. ### The index action @@ -87,7 +85,12 @@ Since there are two possible outcomes for the `create`, we will have at least tw ```elixir describe "create post" do test "redirects to show when data is valid", %{conn: conn} do - conn = post(conn, ~p"/posts", post: @create_attrs) + create_attrs %{ + body: "body value", + title: "title value" + } + + conn = post(conn, ~p"/posts", post: create_attrs) assert %{id: id} = redirected_params(conn) assert redirected_to(conn) == ~p"/posts/#{id}" @@ -321,15 +324,20 @@ This is precisely what the first test for the `create` action verifies: ```elixir describe "create article" do test "renders article when data is valid", %{conn: conn} do - conn = post(conn, ~p"/articles", article: @create_attrs) + create_attrs %{ + body: "body value", + title: "title value" + } + + conn = post(conn, ~p"/articles", article: create_attrs) assert %{"id" => id} = json_response(conn, 201)["data"] conn = get(conn, ~p"/api/articles/#{id}") assert %{ "id" => ^id, - "body" => "some body", - "title" => "some title" + "body" => "body value", + "title" => "title value" } = json_response(conn, 200)["data"] end ``` diff --git a/integration_test/test/code_generation/app_with_defaults_test.exs b/integration_test/test/code_generation/app_with_defaults_test.exs index 57d069f794..e3f2074900 100644 --- a/integration_test/test/code_generation/app_with_defaults_test.exs +++ b/integration_test/test/code_generation/app_with_defaults_test.exs @@ -27,7 +27,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do with_installer_tmp("app_with_defaults", fn tmp_dir -> {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog") - mix_run!(~w(phx.gen.html Blog Post posts title:unique body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.html Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -50,7 +53,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do with_installer_tmp("app_with_defaults", fn tmp_dir -> {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog") - mix_run!(~w(phx.gen.html Blog Post posts title:unique body:string status:enum:unpublished:published:deleted order:integer:unique), app_root_path) + mix_run!( + ~w(phx.gen.html Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted] order:integer:unique), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -74,7 +80,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do with_installer_tmp("app_with_defaults", fn tmp_dir -> {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog") - mix_run!(~w(phx.gen.json Blog Post posts title:unique body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.json Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -97,7 +106,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do with_installer_tmp("app_with_defaults", fn tmp_dir -> {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog") - mix_run!(~w(phx.gen.json Blog Post posts title:unique body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.json Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -121,7 +133,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do with_installer_tmp("app_with_defaults", fn tmp_dir -> {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--live"]) - mix_run!(~w(phx.gen.live Blog Post posts title:unique body:string p:boolean s:enum:a:b:c), app_root_path) + mix_run!( + ~w(phx.gen.live Blog Post posts title:string:*:unique body:string p:boolean s:enum:[a,b,c]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -147,7 +162,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do with_installer_tmp("app_with_defaults", fn tmp_dir -> {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--live"]) - mix_run!(~w(phx.gen.live Blog Post posts title body:string public:boolean status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.live Blog Post posts title:string:* body:string public:boolean status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ diff --git a/integration_test/test/code_generation/app_with_mssql_adapter_test.exs b/integration_test/test/code_generation/app_with_mssql_adapter_test.exs index 96ca203de2..ccf7906619 100644 --- a/integration_test/test/code_generation/app_with_mssql_adapter_test.exs +++ b/integration_test/test/code_generation/app_with_mssql_adapter_test.exs @@ -8,7 +8,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_mssql_app", ["--database", "mssql"]) - mix_run!(~w(phx.gen.html Blog Post posts title body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.html Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/default_mssql_app_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -34,7 +37,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_mssql_app", ["--database", "mssql"]) - mix_run!(~w(phx.gen.json Blog Post posts title body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.json Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/default_mssql_app_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -60,7 +66,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_mssql_app", ["--database", "mssql", "--live"]) - mix_run!(~w(phx.gen.live Blog Post posts title body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.live Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/default_mssql_app_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -85,9 +94,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do describe "phx.gen.auth + pbkdf2 + existing context" do test "has no compilation or formatter warnings (--live)" do with_installer_tmp("new with defaults", fn tmp_dir -> - {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) + {app_root_path, _} = + generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) - mix_run!(~w(phx.gen.html Accounts Group groups name), app_root_path) + mix_run!(~w(phx.gen.html Accounts Group groups name:string:*), app_root_path) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -100,7 +110,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do """) end) - mix_run!(~w(phx.gen.auth Accounts User users --hashing-lib pbkdf2 --merge-with-existing-context --live), app_root_path) + mix_run!( + ~w(phx.gen.auth Accounts User users --hashing-lib pbkdf2 --merge-with-existing-context --live), + app_root_path + ) assert_no_compilation_warnings(app_root_path) assert_passes_formatter_check(app_root_path) @@ -109,9 +122,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do test "has no compilation or formatter warnings (--no-live)" do with_installer_tmp("new with defaults", fn tmp_dir -> - {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) + {app_root_path, _} = + generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) - mix_run!(~w(phx.gen.html Accounts Group groups name), app_root_path) + mix_run!(~w(phx.gen.html Accounts Group groups name:string:*), app_root_path) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -124,7 +138,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do """) end) - mix_run!(~w(phx.gen.auth Accounts User users --hashing-lib pbkdf2 --merge-with-existing-context --no-live), app_root_path) + mix_run!( + ~w(phx.gen.auth Accounts User users --hashing-lib pbkdf2 --merge-with-existing-context --no-live), + app_root_path + ) assert_no_compilation_warnings(app_root_path) assert_passes_formatter_check(app_root_path) @@ -134,9 +151,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do @tag database: :mssql test "has a passing test suite" do with_installer_tmp("app_with_defaults (--live)", fn tmp_dir -> - {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) + {app_root_path, _} = + generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) - mix_run!(~w(phx.gen.html Accounts Group groups name), app_root_path) + mix_run!(~w(phx.gen.html Accounts Group groups name:string:*), app_root_path) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -149,7 +167,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do """) end) - mix_run!(~w(phx.gen.auth Accounts User users --hashing-lib pbkdf2 --merge-with-existing-context --live), app_root_path) + mix_run!( + ~w(phx.gen.auth Accounts User users --hashing-lib pbkdf2 --merge-with-existing-context --live), + app_root_path + ) drop_test_database(app_root_path) assert_tests_pass(app_root_path) @@ -159,9 +180,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do @tag database: :mssql test "has a passing test suite (--no-live)" do with_installer_tmp("app_with_defaults", fn tmp_dir -> - {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) + {app_root_path, _} = + generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) - mix_run!(~w(phx.gen.html Accounts Group groups name), app_root_path) + mix_run!(~w(phx.gen.html Accounts Group groups name:string:*), app_root_path) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -174,7 +196,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do """) end) - mix_run!(~w(phx.gen.auth Accounts User users --hashing-lib pbkdf2 --merge-with-existing-context --no-live), app_root_path) + mix_run!( + ~w(phx.gen.auth Accounts User users --hashing-lib pbkdf2 --merge-with-existing-context --no-live), + app_root_path + ) drop_test_database(app_root_path) assert_tests_pass(app_root_path) diff --git a/integration_test/test/code_generation/app_with_mysql_adapter_test.exs b/integration_test/test/code_generation/app_with_mysql_adapter_test.exs index 0886d421f9..a62726ef14 100644 --- a/integration_test/test/code_generation/app_with_mysql_adapter_test.exs +++ b/integration_test/test/code_generation/app_with_mysql_adapter_test.exs @@ -8,7 +8,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMySqlAdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_mysql_app", ["--database", "mysql"]) - mix_run!(~w(phx.gen.html Blog Post posts title body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.html Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/default_mysql_app_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -34,7 +37,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMySqlAdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_mysql_app", ["--database", "mysql"]) - mix_run!(~w(phx.gen.json Blog Post posts title body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.json Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/default_mysql_app_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -60,7 +66,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMySqlAdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_mysql_app", ["--database", "mysql", "--live"]) - mix_run!(~w(phx.gen.live Blog Post posts title body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.live Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/default_mysql_app_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -85,7 +94,8 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMySqlAdapterTest do describe "phx.gen.auth + argon2" do test "has no compilation or formatter warnings (--live)" do with_installer_tmp("new with defaults", fn tmp_dir -> - {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mysql", "--binary-id"]) + {app_root_path, _} = + generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mysql", "--binary-id"]) mix_run!(~w(phx.gen.auth Accounts User users --hashing-lib argon2 --live), app_root_path) @@ -96,9 +106,13 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMySqlAdapterTest do test "has no compilation or formatter warnings (--no-live)" do with_installer_tmp("new with defaults", fn tmp_dir -> - {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mysql", "--binary-id"]) + {app_root_path, _} = + generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mysql", "--binary-id"]) - mix_run!(~w(phx.gen.auth Accounts User users --hashing-lib argon2 --no-live), app_root_path) + mix_run!( + ~w(phx.gen.auth Accounts User users --hashing-lib argon2 --no-live), + app_root_path + ) assert_no_compilation_warnings(app_root_path) assert_passes_formatter_check(app_root_path) @@ -108,7 +122,8 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMySqlAdapterTest do @tag database: :mysql test "has a passing test suite (--live)" do with_installer_tmp("app_with_defaults", fn tmp_dir -> - {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_app", ["--database", "mysql", "--binary-id"]) + {app_root_path, _} = + generate_phoenix_app(tmp_dir, "default_app", ["--database", "mysql", "--binary-id"]) mix_run!(~w(phx.gen.auth Accounts User users --hashing-lib argon2 --live), app_root_path) @@ -120,9 +135,13 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMySqlAdapterTest do @tag database: :mysql test "has a passing test suite (--no-live)" do with_installer_tmp("app_with_defaults", fn tmp_dir -> - {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_app", ["--database", "mysql", "--binary-id"]) + {app_root_path, _} = + generate_phoenix_app(tmp_dir, "default_app", ["--database", "mysql", "--binary-id"]) - mix_run!(~w(phx.gen.auth Accounts User users --hashing-lib argon2 --no-live), app_root_path) + mix_run!( + ~w(phx.gen.auth Accounts User users --hashing-lib argon2 --no-live), + app_root_path + ) drop_test_database(app_root_path) assert_tests_pass(app_root_path) diff --git a/integration_test/test/code_generation/app_with_sqlite3_adapter.exs b/integration_test/test/code_generation/app_with_sqlite3_adapter.exs index 4576a6cfc0..0e6b9fc05c 100644 --- a/integration_test/test/code_generation/app_with_sqlite3_adapter.exs +++ b/integration_test/test/code_generation/app_with_sqlite3_adapter.exs @@ -8,7 +8,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithSQLite3AdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_sqlite3_app", ["--database", "sqlite3"]) - mix_run!(~w(phx.gen.html Blog Post posts title body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.html Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/default_sqlite3_app_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -34,7 +37,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithSQLite3AdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_sqlite3_app", ["--database", "sqlite3"]) - mix_run!(~w(phx.gen.json Blog Post posts title body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.json Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/default_sqlite3_app_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -60,7 +66,10 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithSQLite3AdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_sqlite3_app", ["--database", "sqlite3", "--live"]) - mix_run!(~w(phx.gen.live Blog Post posts title body:string status:enum:unpublished:published:deleted), app_root_path) + mix_run!( + ~w(phx.gen.live Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + app_root_path + ) modify_file(Path.join(app_root_path, "lib/default_sqlite3_app_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -108,7 +117,8 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithSQLite3AdapterTest do @tag database: :sqlite3 test "has a passing test suite (--live)" do with_installer_tmp("app_with_defaults", fn tmp_dir -> - {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_app", ["--database", "sqlite3"]) + {app_root_path, _} = + generate_phoenix_app(tmp_dir, "default_app", ["--database", "sqlite3"]) mix_run!(~w(phx.gen.auth Accounts User users --live), app_root_path) @@ -119,7 +129,8 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithSQLite3AdapterTest do test "has a passing test suite (--no-live)" do with_installer_tmp("app_with_defaults", fn tmp_dir -> - {app_root_path, _} = generate_phoenix_app(tmp_dir, "default_app", ["--database", "sqlite3"]) + {app_root_path, _} = + generate_phoenix_app(tmp_dir, "default_app", ["--database", "sqlite3"]) mix_run!(~w(phx.gen.auth Accounts User users --no-live), app_root_path) diff --git a/integration_test/test/code_generation/umbrella_app_with_defaults_test.exs b/integration_test/test/code_generation/umbrella_app_with_defaults_test.exs index 9dc71ba64d..26656256b5 100644 --- a/integration_test/test/code_generation/umbrella_app_with_defaults_test.exs +++ b/integration_test/test/code_generation/umbrella_app_with_defaults_test.exs @@ -29,7 +29,10 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "rainy_day", ["--umbrella"]) web_root_path = Path.join(app_root_path, "apps/rainy_day_web") - mix_run!(~w(phx.gen.html Blog Post posts title:unique body:string status:enum:unpublished:published:deleted), web_root_path) + mix_run!( + ~w(phx.gen.html Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + web_root_path + ) modify_file(Path.join(web_root_path, "lib/rainy_day_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -53,7 +56,10 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "rainy_day", ["--umbrella"]) web_root_path = Path.join(app_root_path, "apps/rainy_day_web") - mix_run!(~w(phx.gen.html Blog Post posts title body:string status:enum:unpublished:published:deleted), web_root_path) + mix_run!( + ~w(phx.gen.html Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + web_root_path + ) modify_file(Path.join(web_root_path, "lib/rainy_day_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -78,7 +84,10 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "rainy_day", ["--umbrella"]) web_root_path = Path.join(app_root_path, "apps/rainy_day_web") - mix_run!(~w(phx.gen.json Blog Post posts title:unique body:string status:enum:unpublished:published:deleted), web_root_path) + mix_run!( + ~w(phx.gen.json Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + web_root_path + ) modify_file(Path.join(web_root_path, "lib/rainy_day_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -102,7 +111,10 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "rainy_day", ["--umbrella"]) web_root_path = Path.join(app_root_path, "apps/rainy_day_web") - mix_run!(~w(phx.gen.json Blog Post posts title body:string status:enum:unpublished:published:deleted), web_root_path) + mix_run!( + ~w(phx.gen.json Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + web_root_path + ) modify_file(Path.join(web_root_path, "lib/rainy_day_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -127,7 +139,10 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "rainy_day", ["--umbrella", "--live"]) web_root_path = Path.join(app_root_path, "apps/rainy_day_web") - mix_run!(~w(phx.gen.live Blog Post posts title:unique body:string status:enum:unpublished:published:deleted), web_root_path) + mix_run!( + ~w(phx.gen.live Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + web_root_path + ) modify_file(Path.join(web_root_path, "lib/rainy_day_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -154,7 +169,10 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "rainy_day", ["--umbrella", "--live"]) web_root_path = Path.join(app_root_path, "apps/rainy_day_web") - mix_run!(~w(phx.gen.live Blog Post posts title body:string status:enum:unpublished:published:deleted), web_root_path) + mix_run!( + ~w(phx.gen.live Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + web_root_path + ) modify_file(Path.join(web_root_path, "lib/rainy_day_web/router.ex"), fn file -> inject_before_final_end(file, """ diff --git a/lib/mix/phoenix.ex b/lib/mix/phoenix.ex index 7c6b8e6d53..8ec097c804 100644 --- a/lib/mix/phoenix.ex +++ b/lib/mix/phoenix.ex @@ -43,8 +43,12 @@ defmodule Mix.Phoenix do end) || raise "could not find #{source_file_path} in any of the sources" case format do - :text -> Mix.Generator.create_file(target, File.read!(source)) - :eex -> Mix.Generator.create_file(target, EEx.eval_file(source, binding)) + :text -> + Mix.Generator.create_file(target, File.read!(source)) + + :eex -> + Mix.Generator.create_file(target, EEx.eval_file(source, binding)) + :new_eex -> if File.exists?(target) do :ok @@ -55,10 +59,8 @@ defmodule Mix.Phoenix do end end - defp to_app_source(path, source_dir) when is_binary(path), - do: Path.join(path, source_dir) - defp to_app_source(app, source_dir) when is_atom(app), - do: Application.app_dir(app, source_dir) + defp to_app_source(path, source_dir) when is_binary(path), do: Path.join(path, source_dir) + defp to_app_source(app, source_dir) when is_atom(app), do: Application.app_dir(app, source_dir) @doc """ Inflects path, scope, alias and more from the given name. @@ -97,23 +99,25 @@ defmodule Mix.Phoenix do """ def inflect(singular) do - base = Mix.Phoenix.base() + base = base() web_module = base |> web_module() |> inspect() - scoped = Phoenix.Naming.camelize(singular) - path = Phoenix.Naming.underscore(scoped) - singular = String.split(path, "/") |> List.last - module = Module.concat(base, scoped) |> inspect - alias = String.split(module, ".") |> List.last - human = Phoenix.Naming.humanize(singular) - - [alias: alias, - human: human, - base: base, - web_module: web_module, - module: module, - scoped: scoped, - singular: singular, - path: path] + scoped = Phoenix.Naming.camelize(singular) + path = Phoenix.Naming.underscore(scoped) + singular = String.split(path, "/") |> List.last() + module = Module.concat(base, scoped) |> inspect + alias = String.split(module, ".") |> List.last() + human = Phoenix.Naming.humanize(singular) + + [ + alias: alias, + human: human, + base: base, + web_module: web_module, + module: module, + scoped: scoped, + singular: singular, + path: path + ] end @doc """ @@ -121,8 +125,9 @@ defmodule Mix.Phoenix do """ def check_module_name_availability!(name) do name = Module.concat(Elixir, name) + if Code.ensure_loaded?(name) do - Mix.raise "Module name #{inspect name} is already taken, please choose another name" + Mix.raise("Module name #{inspect(name)} is already taken, please choose another name") end end @@ -151,7 +156,7 @@ defmodule Mix.Phoenix do defp app_base(app) do case Application.get_env(app, :namespace, app) do ^app -> app |> to_string() |> Phoenix.Naming.camelize() - mod -> mod |> inspect() + mod -> inspect(mod) end end @@ -190,7 +195,7 @@ defmodule Mix.Phoenix do Checks if the given `app_path` is inside an umbrella. """ def in_umbrella?(app_path) do - umbrella = Path.expand(Path.join [app_path, "..", ".."]) + umbrella = Path.expand(Path.join([app_path, "..", ".."])) mix_path = Path.join(umbrella, "mix.exs") apps_path = Path.join(umbrella, "apps") File.exists?(mix_path) && File.exists?(apps_path) @@ -223,6 +228,7 @@ defmodule Mix.Phoenix do {^ctx_app, path} -> Path.relative_to_cwd(path) _ -> mix_app_path(ctx_app, this_app) end + Path.join(app_path, rel_path) end end @@ -270,8 +276,9 @@ defmodule Mix.Phoenix do case Application.get_env(this_otp_app, :generators)[:context_app] do nil -> :error + false -> - Mix.raise """ + Mix.raise(""" no context_app configured for current application #{this_otp_app}. Add the context_app generators config in config.exs, or pass the @@ -288,9 +295,11 @@ defmodule Mix.Phoenix do Note: cli option only works when `context_app` is not set to `false` in the config. - """ + """) + {app, _path} -> {:ok, app} + app -> {:ok, app} end @@ -300,11 +309,12 @@ defmodule Mix.Phoenix do case Mix.Project.deps_paths() do %{^app => path} -> Path.relative_to_cwd(path) + deps -> - Mix.raise """ - no directory for context_app #{inspect app} found in #{this_otp_app}'s deps. + Mix.raise(""" + no directory for context_app #{inspect(app)} found in #{this_otp_app}'s deps. - Ensure you have listed #{inspect app} as an in_umbrella dependency in mix.exs: + Ensure you have listed #{inspect(app)} as an in_umbrella dependency in mix.exs: def deps do [ @@ -315,9 +325,9 @@ defmodule Mix.Phoenix do Existing deps: - #{inspect Map.keys(deps)} + #{inspect(Map.keys(deps))} - """ + """) end end @@ -332,15 +342,18 @@ defmodule Mix.Phoenix do end) case Enum.filter(file_paths, &File.exists?(&1)) do - [] -> :ok + [] -> + :ok + conflicts -> - Mix.shell().info""" + Mix.shell().info(""" The following files conflict with new files to be generated: #{Enum.map_join(conflicts, "\n", &" * #{&1}")} See the --web option to namespace similarly named resources - """ + """) + unless Mix.shell().yes?("Proceed with interactive overwrite?") do System.halt() end @@ -358,14 +371,44 @@ defmodule Mix.Phoenix do end end - def to_text(data) do - inspect data, limit: :infinity, printable_limit: :infinity - end + @doc """ + Indent text with `spaces`, and gaps (empty lines) on `top` and `bottom`. + + ## Options + + * `:spaces` - Number of spaces to indent each line with. + It adds extra indentation, preserving current spaces at the beginning of a line. + Default: `0`. - def prepend_newline(string) do - "\n" <> string + * `:top` - Total number of empty lines before not empty text result. Default: `0`. + + * `:bottom` - Total number of empty lines after not empty text result. Default: `0`. + + * `:new_line` - Value to separate lines in resulting text. Default: `"\\n"`. + """ + def indent_text(text_or_lines, opts \\ []) + + def indent_text(text, opts) when is_binary(text) and is_list(opts), + do: indent_text(text |> String.split("\n"), opts) + + def indent_text(lines, opts) when is_list(lines) and is_list(opts) do + indent = String.duplicate(" ", Keyword.get(opts, :spaces, 0)) + gap_top = String.duplicate("\n", Keyword.get(opts, :top, 0)) + gap_bottom = String.duplicate("\n", Keyword.get(opts, :bottom, 0)) + new_line = Keyword.get(opts, :new_line, "\n") + + text = + lines + |> Enum.map(&String.trim_trailing/1) + |> Enum.map_join(new_line, &if(&1 == "", do: &1, else: indent <> &1)) + |> String.replace(~r/\A(#{new_line})+/, "") + |> String.trim_trailing() + + if text == "", do: "", else: gap_top <> text <> gap_bottom end + def prepend_newline(string) when is_binary(string), do: "\n" <> string + # In the context of a HEEx attribute value, transforms a given message into a # dynamic `gettext` call or a fixed-value string attribute, depending on the # `gettext?` parameter. diff --git a/lib/mix/phoenix/attribute.ex b/lib/mix/phoenix/attribute.ex new file mode 100644 index 0000000000..056302960d --- /dev/null +++ b/lib/mix/phoenix/attribute.ex @@ -0,0 +1,757 @@ +defmodule Mix.Phoenix.Attribute do + @moduledoc false + + alias Mix.Phoenix.{Attribute, Schema} + + defstruct name: nil, + type: nil, + options: %{} + + @default_type :string + @standard_types_specs %{ + "integer" => %{ + options: ["default,value"], + details: "", + examples: [ + "points:integer", + "points:integer:default,0" + ] + }, + "float" => %{ + options: ["default,value"], + details: "", + examples: [ + "sum:float", + "sum:float:default,0.0" + ] + }, + "decimal" => %{ + options: ["default,value", "precision,value", "scale,value"], + details: "Have specific options `precision` and `scale`.", + examples: [ + "price:decimal", + "price:decimal:precision,5:scale,2", + "price:decimal:precision,5", + "price:decimal:default,0.0" + ] + }, + "boolean" => %{ + options: ["default,value"], + details: "Default to `false`, which can be changed with option.", + examples: [ + "agreed:boolean", + "the_cake_is_a_lie:boolean:default,true" + ] + }, + "string" => %{ + options: ["size,value"], + details: + "Default type. Can be omitted if no options are provided. " <> + "Use `size` option to limit number of characters.", + examples: [ + "title", + "title:string", + "title:string:size,40:unique" + ] + }, + "text" => %{ + details: "", + examples: [] + }, + "binary" => %{ + details: "", + examples: [] + }, + "uuid" => %{ + details: "", + examples: [] + }, + "date" => %{ + details: "", + examples: [] + }, + "time" => %{ + details: "", + examples: [] + }, + "time_usec" => %{ + details: "", + examples: [] + }, + "naive_datetime" => %{ + details: "", + examples: [] + }, + "naive_datetime_usec" => %{ + details: "", + examples: [] + }, + "utc_datetime" => %{ + details: "", + examples: [] + }, + "utc_datetime_usec" => %{ + details: "", + examples: [] + }, + "map" => %{ + details: "", + examples: [] + }, + "enum" => %{ + options: ["[one,two]", "[[one,1],[two,2]]"], + details: + "Requires at least one value in options. Values are translated into list or keyword list.", + examples: [ + "status:enum:[published,unpublished]", + "status:enum:[[published,1],[unpublished,2]]", + "tags:[array,enum]:[published,unpublished]", + "tags:[array,enum]:[[published,1],[unpublished,2]]" + ] + }, + "references" => %{ + options: [ + "Context.Schema", + "table,value", + "column,value", + "type,value", + "assoc,value", + "on_delete,value" + ], + details: + "All info is inferred from the attribute name unless customized via options. " <> + "Referenced schema is inferred in scope of the given context. " <> + "Different schema can be provided in full form `Context.Schema` in options. " <> + "Referenced schema should exist in the app.", + examples: [ + "post_id:references", + "author_id:references:Accounts.User" + ] + }, + "any" => %{ + details: "Can be used only with option `virtual`.", + examples: ["data:any:virtual"] + } + } + @standard_types Map.keys(@standard_types_specs) + @specific_types_specs %{ + "datetime" => %{ + details: "An alias for `naive_datetime`.", + examples: [] + }, + "array" => %{ + details: "An alias for `[array,string]`.", + examples: ["tags:array"] + }, + "[array,inner_type]" => %{ + regex: ~r/^\[array,(?.+)\]$/, + details: "Composite type, requires `inner_type`, which cannot be `references`.", + examples: [ + "tags:[array,string]", + "tags:[array,integer]", + "tags:[array,enum]:[published,unpublished]", + "tags:[array,enum]:[[published,1],[unpublished,2]]" + ] + } + } + @supported_types_specs Map.merge(@standard_types_specs, @specific_types_specs) + + @doc """ + List of supported attribute types with details and examples. + """ + def supported_types do + "### Supported attribute types#{format_specs(@supported_types_specs)}" + end + + @precision_min 2 + @scale_min 1 + @supported_options_specs %{ + "unique" => %{ + details: "Adds unique index in migration and validation in schema.", + examples: ["title:string:unique"] + }, + "index" => %{ + details: "Adds index in migration.", + examples: ["title:string:index"] + }, + "redact" => %{ + details: "Adds option to schema field.", + examples: ["card_number:string:redact"] + }, + "required" => %{ + details: + "Adds `null: false` constraint in migration, validation in schema, " <> + "and mark in html input if no default option provided.", + examples: ["title:string:required"] + }, + "*" => %{ + details: "An alias for `required`.", + examples: ["title:string:*"] + }, + "virtual" => %{ + details: + "Adds option to schema field and omits migration changes. Can be used with type `any`.", + examples: [ + "current_guess:integer:virtual", + "data:any:virtual" + ] + }, + "[one,two]" => %{ + regex: ~r/^\[(?.+)\]$/, + details: "List of values for `enum` type. At least one value is mandatory.", + examples: ["status:enum:[published,unpublished]"] + }, + "[[one,1],[two,2]]" => %{ + regex: ~r/^\[\[(?.+)\]\]$/, + details: "Keyword list of values for `enum` type. At least one value is mandatory.", + examples: ["status:enum:[[published,1],[unpublished,2]]"] + }, + "default,value" => %{ + regex: ~r/^default,(?.+)$/, + details: + "Default option for `boolean`, `integer`, `decimal`, `float` types. " <> + "For `boolean` type values `true`, `1` are the same, the rest is `false`.", + examples: [ + "the_cake_is_a_lie:boolean:default,true", + "points:integer:default,0", + "price:decimal:default,0.0", + "sum:float:default,0.0" + ] + }, + "size,value" => %{ + regex: ~r/^size,(?\d+)$/, + details: "Positive number option for `string` type.", + examples: ["city:string:size,40"] + }, + "precision,value" => %{ + regex: ~r/^precision,(?\d+)$/, + details: "Number option for `decimal` type. Minimum is #{@precision_min}.", + examples: ["price:decimal:precision,5"] + }, + "scale,value" => %{ + regex: ~r/^scale,(?\d+)$/, + details: + "Number option for `decimal` type. Minimum is #{@scale_min}. " <> + "`scale` can be provided only with `precision` option and should be less than it.", + examples: ["price:decimal:precision,5:scale,2"] + }, + "Context.Schema" => %{ + details: + "Referenced schema name for `references`. " <> + "For cases when schema cannot be inferred from the attribute name, or context differs.", + examples: ["author_id:references:Accounts.User"] + }, + "table,value" => %{ + regex: ~r/^table,(?.+)$/, + details: + "Table name for `references`. " <> + "For cases when referenced schema is not reachable to reflect on.", + examples: ["booking_id:references:table,reservations"] + }, + "column,value" => %{ + regex: ~r/^column,(?.+)$/, + details: + "Referenced column name for `references`. " <> + "For cases when it differs from default value `id`.", + examples: ["book_id:references:column,isbn"] + }, + "type,value" => %{ + regex: ~r/^type,(?.+)$/, + details: + "Type of the column for `references`. " <> + "For cases when referenced schema is not reachable to reflect on. " <> + "Supported values: `id`, `binary_id`, `string`.", + examples: [ + "book_id:references:type,id", + "book_id:references:type,binary_id", + "isbn:references:column,isbn:type,string" + ] + }, + "assoc,value" => %{ + regex: ~r/^assoc,(?.+)$/, + details: + "Association name for `references`. " <> + "For cases when it cannot be inferred from the attribute name. " <> + "Default to attribute name without suffix `_id`.", + examples: ["booking_id:references:assoc,reservation"] + }, + "on_delete,value" => %{ + regex: ~r/^on_delete,(?.+)$/, + details: + "What to do if the referenced entry is deleted. " <> + "`value` may be `nothing` (default), `restrict`, `delete_all`, `nilify_all` or `nilify[columns]`. " <> + "`nilify[columns]` expects a comma-separated list of columns and is not supported by all databases.", + examples: [ + "author_id:references:on_delete,delete_all", + "book_id:references:on_delete,nilify[book_id,book_name]" + ] + } + } + + @doc """ + List of supported attribute options with details and examples. + """ + def supported_options do + "### Supported attribute options#{format_specs(@supported_options_specs)}" + end + + defp format_specs(specs) do + specs + |> Enum.sort_by(fn {value, _info} -> value end) + |> Enum.map(fn {value, %{details: details, examples: examples}} -> + formatted_details = if details != "", do: " - #{details}" + + formatted_examples = + if Enum.any?(examples) do + "\n Examples:#{Mix.Phoenix.indent_text(examples, spaces: 10, top: 2)}" + end + + "* `#{value}`#{formatted_details}#{formatted_examples}" + end) + |> Enum.join("\n\n") + |> Mix.Phoenix.indent_text(spaces: 2, top: 2, bottom: 1) + end + + defp raise_unknown_type_error(type, cli_attr) do + Mix.raise(""" + Unknown type `#{type}` is given in CLI attribute `#{cli_attr}`. + + #{supported_types()} + """) + end + + defp raise_unknown_option_error({option, type, cli_attr}) do + Mix.raise(""" + Unknown option `#{option}` is given in CLI attribute `#{cli_attr}`. + #{type_specs(type)} + """) + end + + defp raise_type_error(type, cli_attr) do + Mix.raise(""" + CLI attribute `#{cli_attr}` has issue related to its type `#{type}`. + #{type_specs(type)} + """) + end + + # THOUGHTS: Can also be used to print help info about type in console. + @doc """ + List of supported options for the given attribute's type, with details. + """ + def type_specs(type) do + type_spec = Map.fetch!(@supported_types_specs, Atom.to_string(type)) + + formatted_details = + if type_spec[:details] != "", do: "\n`#{type}` - #{type_spec[:details]}\n\n" + + virtual_option = if type == :references, do: [], else: ["virtual"] + general_options = ["unique", "index", "redact", "required", "*"] ++ virtual_option + type_options = general_options ++ Map.get(type_spec, :options, []) + type_options_specs = Map.take(@supported_options_specs, type_options) + + "#{formatted_details}`#{type}` supports following options.#{format_specs(type_options_specs)}" + end + + @doc """ + General sorting for attributes - by name with references at the end. + """ + def sort(attrs) when is_list(attrs), do: Enum.sort_by(attrs, &{&1.type == :references, &1.name}) + + @doc """ + Excludes references from attributes. + """ + def without_references(attrs) when is_list(attrs), + do: Enum.reject(attrs, &(&1.type == :references)) + + @doc """ + Returns only references from attributes. + """ + def references(attrs) when is_list(attrs), do: Enum.filter(attrs, &(&1.type == :references)) + + @doc """ + Excludes virtual attributes. + """ + def without_virtual(attrs) when is_list(attrs), do: Enum.reject(attrs, & &1.options[:virtual]) + + @doc """ + Returns only virtual attributes. + """ + def virtual(attrs) when is_list(attrs), do: Enum.filter(attrs, & &1.options[:virtual]) + + @doc """ + Returns required attributes. + """ + def required(attrs) when is_list(attrs), do: Enum.filter(attrs, & &1.options[:required]) + + @doc """ + Returns unique attributes. + """ + def unique(attrs) when is_list(attrs), do: Enum.filter(attrs, & &1.options[:unique]) + + @doc """ + Returns attributes which have index (unique or general). + """ + def indexed(attrs) when is_list(attrs), + do: Enum.filter(attrs, &(&1.options[:unique] || &1.options[:index])) + + @doc """ + Parses list of CLI attributes into %Attribute{} structs. + Performs attributes' types and options validation. + Prefills some mandatory and default data to options map. + Checks that at least one attribute is required. + """ + def parse([], _), do: [] + + def parse(cli_attrs, schema_details) when is_list(cli_attrs) do + attrs = Enum.map(cli_attrs, &parse_attr(&1, schema_details)) + + if Enum.any?(attrs, & &1.options[:required]) do + attrs + else + with_first_attr_required(attrs, hd(cli_attrs)) + end + end + + defp with_first_attr_required(attrs, first_cli_attr) do + Mix.shell().info(""" + At least one attribute has to be specified as required. + Use option `required` or its alias `*`. + + Examples: + + title:string:required + name:string:*:unique + + None of the given attributes are set to be required, + Hence first attribute `#{first_cli_attr}` is going to be required. + """) + + if not Mix.shell().yes?("Proceed with chosen required attribute?"), do: System.halt() + + [first | rest] = attrs + required_first = %{first | options: Map.put(first.options, :required, true)} + [required_first | rest] + end + + defp parse_attr(cli_attr, schema_details) when is_binary(cli_attr) do + cli_attr + |> String.split(":") + |> parse_name() + |> parse_type(cli_attr) + |> parse_options(cli_attr) + |> validate_attr(cli_attr) + |> prefill_data(schema_details) + |> new() + end + + defp new({name, type, %{} = options}) do + %Attribute{ + name: name, + type: type, + options: options + } + end + + defp parse_name([name | rest]), do: {String.to_atom(name), rest} + + defp parse_type({name, []}, _cli_attr), do: {name, @default_type, %{}} + defp parse_type({name, [type]}, cli_attr), do: {name, string_to_type(type, cli_attr), %{}} + + defp parse_type({name, [type | options]}, cli_attr), + do: {name, string_to_type(type, cli_attr), options} + + defp string_to_type(type, _cli_attr) when type in @standard_types, do: String.to_atom(type) + defp string_to_type("datetime", _cli_attr), do: :naive_datetime + defp string_to_type("array", _cli_attr), do: {:array, :string} + + defp string_to_type(type, cli_attr) do + cond do + match = regex_match("[array,inner_type]", type, @specific_types_specs) -> + if match["inner_type"] == "references", do: raise_unknown_type_error(type, cli_attr) + {:array, string_to_type(match["inner_type"], cli_attr)} + + true -> + raise_unknown_type_error(type, cli_attr) + end + end + + defp parse_options({name, type, options}, cli_attr) do + options = + Enum.reduce(options, %{}, fn option, parsed_options -> + Map.merge(parsed_options, string_to_options({option, type, cli_attr})) + end) + + {name, type, options} + end + + defp string_to_options({"*", _, _}), do: %{required: true} + + defp string_to_options({"virtual", type, _}) when type not in [:references], + do: %{virtual: true} + + @flag_options ["unique", "index", "redact", "required"] + defp string_to_options({option, _, _}) when option in @flag_options, + do: %{String.to_atom(option) => true} + + defp string_to_options({option, {:array, inner_type}, cli_attr}), + do: string_to_options({option, inner_type, cli_attr}) + + defp string_to_options({option, :enum, _} = data) do + cond do + match = regex_match("[[one,1],[two,2]]", option) -> + parsed_values = + match["values"] + |> String.split("],[") + |> Enum.map(fn value -> + [value_name, value_int] = String.split(value, ",") + {String.to_atom(value_name), String.to_integer(value_int)} + end) + + %{values: parsed_values} + + match = regex_match("[one,two]", option) -> + parsed_values = match["values"] |> String.split(",") |> Enum.map(&String.to_atom/1) + %{values: parsed_values} + + true -> + raise_unknown_option_error(data) + end + end + + defp string_to_options({option, :decimal, _} = data) do + cond do + match = regex_match("precision,value", option) -> + %{precision: String.to_integer(match["value"])} + + match = regex_match("scale,value", option) -> + %{scale: String.to_integer(match["value"])} + + match = regex_match("default,value", option) -> + %{default: match["value"] |> String.to_float() |> Float.to_string()} + + true -> + raise_unknown_option_error(data) + end + end + + defp string_to_options({option, :float, _} = data) do + cond do + match = regex_match("default,value", option) -> %{default: String.to_float(match["value"])} + true -> raise_unknown_option_error(data) + end + end + + defp string_to_options({option, :integer, _} = data) do + cond do + match = regex_match("default,value", option) -> + %{default: String.to_integer(match["value"])} + + true -> + raise_unknown_option_error(data) + end + end + + defp string_to_options({option, :boolean, _} = data) do + cond do + match = regex_match("default,value", option) -> %{default: match["value"] in ["true", "1"]} + true -> raise_unknown_option_error(data) + end + end + + defp string_to_options({option, :string, _} = data) do + cond do + match = regex_match("size,value", option) -> %{size: String.to_integer(match["value"])} + true -> raise_unknown_option_error(data) + end + end + + defp string_to_options({option, :references, _} = data) do + cond do + match = regex_match("on_delete,value", option) -> + on_delete = references_on_delete(match["value"]) || raise_unknown_option_error(data) + %{on_delete: on_delete} + + match = regex_match("assoc,value", option) -> + %{association_name: String.to_atom(match["value"])} + + match = regex_match("column,value", option) -> + %{referenced_column: String.to_atom(match["value"])} + + match = regex_match("type,value", option) -> + type = references_type(match["value"]) || raise_unknown_option_error(data) + %{referenced_type: type} + + match = regex_match("table,value", option) -> + %{referenced_table: match["value"]} + + Schema.valid?(option) -> + %{association_schema: option} + + true -> + raise_unknown_option_error(data) + end + end + + defp string_to_options({_, _, _} = data), do: raise_unknown_option_error(data) + + @referenced_types ["id", "binary_id", "string"] + defp references_type(value), do: if(value in @referenced_types, do: String.to_atom(value)) + + @references_on_delete_values ["nothing", "delete_all", "nilify_all", "restrict"] + defp references_on_delete(value) when value in @references_on_delete_values, + do: String.to_atom(value) + + defp references_on_delete(value) do + if columns_match = Regex.named_captures(~r/^nilify\[(?.+)\]$/, value) do + {:nilify, columns_match["columns"] |> String.split(",") |> Enum.map(&String.to_atom/1)} + end + end + + defp regex_match(spec_key, value, spec \\ @supported_options_specs), + do: Regex.named_captures(spec[spec_key].regex, value) + + defp validate_attr({_name, :any, options} = attr, cli_attr) do + cond do + not Map.has_key?(options, :virtual) -> raise_type_error(:any, cli_attr) + true -> attr + end + end + + defp validate_attr({_name, :string, options} = attr, cli_attr) do + cond do + Map.get(options, :size, 1) <= 0 -> raise_type_error(:string, cli_attr) + true -> attr + end + end + + defp validate_attr({_name, :decimal, options} = attr, cli_attr) do + cond do + Map.has_key?(options, :scale) and not Map.has_key?(options, :precision) -> + raise_type_error(:decimal, cli_attr) + + Map.get(options, :precision, @precision_min) <= + (scale = Map.get(options, :scale, @scale_min)) or scale <= 0 -> + raise_type_error(:decimal, cli_attr) + + true -> + attr + end + end + + defp validate_attr({_name, :enum, options} = attr, cli_attr) do + cond do + not Map.has_key?(options, :values) -> raise_type_error(:enum, cli_attr) + true -> attr + end + end + + defp validate_attr({name, {:array, inner_type}, options} = attr, cli_attr) do + validate_attr({name, inner_type, options}, cli_attr) + attr + end + + defp validate_attr(attr, _cli_attr), do: attr + + defp prefill_data({name, :boolean, options}, _schema_details) do + options = + options + |> Map.put(:required, true) + |> Map.put_new(:default, false) + + {name, :boolean, options} + end + + defp prefill_data({name, :decimal, options}, _schema_details) do + options = + options + |> maybe_adjust_decimal_default() + + {name, :decimal, options} + end + + defp prefill_data({name, :references, options}, schema_details) do + options = + options + |> Map.put(:index, true) + |> Map.put_new(:on_delete, :nothing) + |> derive_association_name(name) + |> derive_association_schema(name, schema_details) + |> derive_referenced_table() + |> derive_referenced_column() + |> derive_referenced_type() + + {name, :references, options} + end + + defp prefill_data(attr, _schema_details), do: attr + + defp maybe_adjust_decimal_default(%{default: default} = options), + do: Map.put(options, :default, adjust_decimal_value(default, options)) + + defp maybe_adjust_decimal_default(options), do: options + + @doc """ + Returns adjusted decimal value to options `precision` and `scale`. + At this moment `precision` and `scale` are validated: `precision` > `scale` > 0. + """ + def adjust_decimal_value(value, %{precision: precision} = options) do + [whole_part, fractional_part] = String.split(value, ".") + + scale_default = [String.length(fractional_part), precision - 1] |> Enum.min() + scale = Map.get(options, :scale, scale_default) + fractional_part = fractional_part |> String.slice(0, scale) |> String.pad_trailing(scale, "0") + + whole_length = precision - scale + whole_part = whole_part |> String.slice(-whole_length, whole_length) + + "#{whole_part}.#{fractional_part}" + end + + def adjust_decimal_value(value, %{}), do: value + + defp derive_association_name(options, name) do + association_name = + options[:association_name] || + name |> Atom.to_string() |> String.replace("_id", "") |> String.to_atom() + + Map.put(options, :association_name, association_name) + end + + defp derive_association_schema(options, name, {schema_module, context_base}) do + full_referenced_schema = + if association_schema = options[:association_schema] do + [context_base, association_schema] + else + name = name |> Atom.to_string() |> String.replace("_id", "") |> Phoenix.Naming.camelize() + (schema_module |> Module.split() |> Enum.drop(-1)) ++ [name] + end + + Map.put(options, :association_schema, Module.concat(full_referenced_schema)) + end + + defp derive_referenced_table(options) do + # NOTE: Option `referenced_table` is for cases when `association_schema` is not reachable. + # E.g. in generators' tests. + referenced_table = + options[:referenced_table] || options.association_schema.__schema__(:source) + + Map.put(options, :referenced_table, referenced_table) + end + + defp derive_referenced_column(options) do + referenced_column = + options[:referenced_column] || options.association_schema.__schema__(:primary_key) |> hd() + + Map.put(options, :referenced_column, referenced_column) + end + + defp derive_referenced_type(options) do + # NOTE: Option `referenced_type` is for cases when `association_schema` is not reachable. + # E.g. in generators' tests. + referenced_type = + options[:referenced_type] || + options.association_schema.__schema__(:type, options.referenced_column) + + Map.put(options, :referenced_type, referenced_type) + end +end diff --git a/lib/mix/phoenix/migration.ex b/lib/mix/phoenix/migration.ex new file mode 100644 index 0000000000..e14d7da500 --- /dev/null +++ b/lib/mix/phoenix/migration.ex @@ -0,0 +1,113 @@ +defmodule Mix.Phoenix.Migration do + @moduledoc false + + alias Mix.Phoenix.{Schema, Attribute} + + # THOUGHTS: Consider to make this module independent from schema. + # To reuse it for migration generator. + # Attributes parsing already extracted to reuse. + + @doc """ + Returns migration module based on the Mix application. + """ + def module do + case Application.get_env(:ecto_sql, :migration_module, Ecto.Migration) do + migration_module when is_atom(migration_module) -> migration_module + other -> Mix.raise("Expected :migration_module to be a module, got: #{inspect(other)}") + end + end + + @doc """ + Returns possible table options. + """ + def table_options(%Schema{} = schema) do + primary_key = if schema.binary_id || schema.opts[:primary_key], do: ", primary_key: false" + prefix = if schema.prefix, do: ", prefix: :#{schema.prefix}" + + [primary_key, prefix] |> Enum.map_join(&(&1 || "")) + end + + @doc """ + Returns specific primary key column by options `binary_id` or `primary_key`. + """ + def maybe_specific_primary_key(%Schema{} = schema) do + if schema.binary_id || schema.opts[:primary_key] do + name = schema.opts[:primary_key] || :id + type = if schema.binary_id, do: :binary_id, else: :id + " add :#{name}, :#{type}, primary_key: true\n" + end + end + + @doc """ + Returns formatted columns and references. + """ + def columns_and_references(%Schema{} = schema) do + schema.attrs + |> Attribute.without_virtual() + |> Attribute.sort() + |> Enum.map(&"add :#{&1.name}, #{column_specifics(&1)}#{null_false(&1)}") + |> Mix.Phoenix.indent_text(spaces: 6, bottom: 1) + end + + defp null_false(%Attribute{} = attr), do: if(attr.options[:required], do: ", null: false") + + defp column_specifics(%Attribute{type: :references} = attr) do + table = attr.options.referenced_table + + column = + if attr.options.referenced_column != :id, do: ", column: :#{attr.options.referenced_column}" + + type = if attr.options.referenced_type != :id, do: ", type: :#{attr.options.referenced_type}" + on_delete = ", on_delete: #{inspect(attr.options.on_delete)}" + + ~s/references("#{table}"#{column}#{type}#{on_delete})/ + end + + defp column_specifics(%Attribute{} = attr) do + type = inspect(column_type(attr)) + precision_and_scale = column_precision_and_scale(attr) + size = if attr.options[:size], do: ", size: #{attr.options.size}" + default = if Map.has_key?(attr.options, :default), do: ", default: #{attr.options.default}" + + "#{type}#{precision_and_scale}#{size}#{default}" + end + + defp column_type(%Attribute{type: {:array, inner_type}} = attr), + do: {:array, column_type(%{attr | type: inner_type})} + + defp column_type(%Attribute{type: :enum, options: %{values: [value | _rest]}}), + do: if(is_atom(value), do: :string, else: :integer) + + defp column_type(%Attribute{type: type}), do: type + + defp column_precision_and_scale(%Attribute{} = attr) do + precision = attr.options[:precision] + precision = if precision, do: ", precision: #{precision}", else: "" + scale = attr.options[:scale] + if scale, do: "#{precision}, scale: #{scale}", else: precision + end + + @doc """ + Returns type option for `timestamps` function. + """ + def timestamps_type(%Schema{timestamp_type: :naive_datetime}), do: "" + def timestamps_type(%Schema{timestamp_type: timestamp_type}), do: "type: :#{timestamp_type}" + + @doc """ + Returns formatted indexes. + """ + def indexes(%Schema{} = schema) do + schema.attrs + |> Attribute.indexed() + |> Attribute.without_virtual() + |> Attribute.sort() + |> Enum.map(&~s/create index("#{schema.table}", #{index_specifics(&1)})/) + |> Mix.Phoenix.indent_text(spaces: 4, top: 2) + end + + defp index_specifics(attr) do + unique = if attr.options[:unique], do: ", unique: true" + + "[:#{attr.name}]#{unique}" + end +end diff --git a/lib/mix/phoenix/schema.ex b/lib/mix/phoenix/schema.ex index 7fec805e31..302c71a083 100644 --- a/lib/mix/phoenix/schema.ex +++ b/lib/mix/phoenix/schema.ex @@ -1,72 +1,39 @@ defmodule Mix.Phoenix.Schema do @moduledoc false - alias Mix.Phoenix.Schema + alias Mix.Phoenix.{Schema, Attribute, TestData} defstruct module: nil, + alias: nil, repo: nil, repo_alias: nil, table: nil, - collection: nil, - embedded?: false, - generate?: true, - opts: [], - alias: nil, file: nil, - attrs: [], - string_attr: nil, - plural: nil, singular: nil, - uniques: [], - redacts: [], - assocs: [], - types: [], - indexes: [], - defaults: [], + plural: nil, + collection: nil, human_singular: nil, human_plural: nil, binary_id: false, - migration_defaults: nil, - migration?: false, - params: %{}, - optionals: [], sample_id: nil, - web_path: nil, + timestamp_type: :naive_datetime, web_namespace: nil, - context_app: nil, + web_path: nil, route_helper: nil, route_prefix: nil, api_route_prefix: nil, - migration_module: nil, - fixture_unique_functions: [], - fixture_params: [], + context_app: nil, prefix: nil, - timestamp_type: :naive_datetime - - @valid_types [ - :integer, - :float, - :decimal, - :boolean, - :map, - :string, - :array, - :references, - :text, - :date, - :time, - :time_usec, - :naive_datetime, - :naive_datetime_usec, - :utc_datetime, - :utc_datetime_usec, - :uuid, - :binary, - :enum - ] - - def valid_types, do: @valid_types + embedded?: false, + generate?: true, + migration?: false, + opts: [], + attrs: [], + sample_values: %{} + @doc """ + Validates format of schema name. + """ def valid?(schema) do schema =~ ~r/^[A-Z]\w*(\.[A-Z]\w*)*$/ end @@ -75,547 +42,229 @@ defmodule Mix.Phoenix.Schema do ctx_app = opts[:context_app] || Mix.Phoenix.context_app() otp_app = Mix.Phoenix.otp_app() opts = Keyword.merge(Application.get_env(otp_app, :generators, []), opts) - base = Mix.Phoenix.context_base(ctx_app) - basename = Phoenix.Naming.underscore(schema_name) - module = Module.concat([base, schema_name]) - repo = opts[:repo] || Module.concat([base, "Repo"]) + context_base = Mix.Phoenix.context_base(ctx_app) + module = Module.concat([context_base, schema_name]) + alias = module |> Module.split() |> List.last() |> Module.concat(nil) + repo = opts[:repo] || Module.concat([context_base, "Repo"]) repo_alias = if String.ends_with?(Atom.to_string(repo), ".Repo"), do: "", else: ", as: Repo" + basename = Phoenix.Naming.underscore(schema_name) file = Mix.Phoenix.context_lib_path(ctx_app, basename <> ".ex") table = opts[:table] || schema_plural - {cli_attrs, uniques, redacts} = extract_attr_flags(cli_attrs) - {assocs, attrs} = partition_attrs_and_assocs(module, attrs(cli_attrs)) - types = types(attrs) + singular = module |> Module.split() |> List.last() |> Phoenix.Naming.underscore() + collection = if schema_plural == singular, do: singular <> "_collection", else: schema_plural web_namespace = opts[:web] && Phoenix.Naming.camelize(opts[:web]) web_path = web_namespace && Phoenix.Naming.underscore(web_namespace) api_prefix = Application.get_env(otp_app, :generators)[:api_prefix] || "/api" + embedded? = Keyword.get(opts, :embedded, false) generate? = Keyword.get(opts, :schema, true) + migration? = Keyword.get(opts, :migration, true) - singular = - module - |> Module.split() - |> List.last() - |> Phoenix.Naming.underscore() - - collection = if schema_plural == singular, do: singular <> "_collection", else: schema_plural - string_attr = string_attr(types) - create_params = params(attrs, :create) - - optionals = for {key, :map} <- types, do: key, into: [] - - default_params_key = - case Enum.at(create_params, 0) do - {key, _} -> key - nil -> :some_field - end - - fixture_unique_functions = fixture_unique_functions(singular, uniques, attrs) + attrs = Attribute.parse(cli_attrs, {module, context_base}) + sample_values = TestData.sample_values(attrs, module) %Schema{ - opts: opts, - migration?: Keyword.get(opts, :migration, true), module: module, + alias: alias, repo: repo, repo_alias: repo_alias, table: table, - embedded?: embedded?, - alias: module |> Module.split() |> List.last() |> Module.concat(nil), file: file, - attrs: attrs, - plural: schema_plural, singular: singular, + plural: schema_plural, collection: collection, - optionals: optionals, - assocs: assocs, - types: types, - defaults: schema_defaults(attrs), - uniques: uniques, - redacts: redacts, - indexes: indexes(table, assocs, uniques), human_singular: Phoenix.Naming.humanize(singular), human_plural: Phoenix.Naming.humanize(schema_plural), binary_id: opts[:binary_id], + sample_id: sample_id(opts), timestamp_type: opts[:timestamp_type] || :naive_datetime, - migration_defaults: migration_defaults(attrs), - string_attr: string_attr, - params: %{ - create: create_params, - update: params(attrs, :update), - default_key: string_attr || default_params_key - }, web_namespace: web_namespace, web_path: web_path, route_helper: route_helper(web_path, singular), route_prefix: route_prefix(web_path, schema_plural), api_route_prefix: api_route_prefix(web_path, schema_plural, api_prefix), - sample_id: sample_id(opts), context_app: ctx_app, + prefix: opts[:prefix], + embedded?: embedded?, generate?: generate?, - migration_module: migration_module(), - fixture_unique_functions: Enum.sort(fixture_unique_functions), - fixture_params: fixture_params(attrs, fixture_unique_functions), - prefix: opts[:prefix] + migration?: migration?, + opts: opts, + attrs: attrs, + sample_values: sample_values } end - @doc """ - Returns the string value of the default schema param. - """ - def default_param(%Schema{} = schema, action) do - schema.params - |> Map.fetch!(action) - |> Map.fetch!(schema.params.default_key) - |> to_string() - end - - def extract_attr_flags(cli_attrs) do - {attrs, uniques, redacts} = - Enum.reduce(cli_attrs, {[], [], []}, fn attr, {attrs, uniques, redacts} -> - [attr_name | rest] = String.split(attr, ":") - attr_name = String.to_atom(attr_name) - split_flags(Enum.reverse(rest), attr_name, attrs, uniques, redacts) - end) - - {Enum.reverse(attrs), uniques, redacts} - end - - defp split_flags(["unique" | rest], name, attrs, uniques, redacts), - do: split_flags(rest, name, attrs, [name | uniques], redacts) - - defp split_flags(["redact" | rest], name, attrs, uniques, redacts), - do: split_flags(rest, name, attrs, uniques, [name | redacts]) - - defp split_flags(rest, name, attrs, uniques, redacts), - do: {[Enum.join([name | Enum.reverse(rest)], ":") | attrs], uniques, redacts} - - @doc """ - Parses the attrs as received by generators. - """ - def attrs(attrs) do - Enum.map(attrs, fn attr -> - attr - |> String.split(":", parts: 3) - |> list_to_attr() - |> validate_attr!() - end) + # TODO: Check for clean up. + # Looks like anachronism, which wasn't used until only `phx.gen.auth` start to use it. + defp sample_id(opts) do + if Keyword.get(opts, :binary_id, false) do + Keyword.get(opts, :sample_binary_id, "11111111-1111-1111-1111-111111111111") + else + -1 + end end - @doc """ - Generates some sample params based on the parsed attributes. - """ - def params(attrs, action \\ :create) when action in [:create, :update] do - Map.new(attrs, fn {k, t} -> {k, type_to_default(k, t, action)} end) + defp route_helper(web_path, singular) do + "#{web_path}_#{singular}" + |> String.trim_leading("_") + |> String.replace("/", "_") end - @doc """ - Converts the given value to map format when it's a date, time, datetime or naive_datetime. - - Since `form.html.heex` generated by the live generator uses selects for dates and/or - times, fixtures must use map format for those fields in order to submit the live form. - """ - def live_form_value(%Date{} = date), do: Calendar.strftime(date, "%Y-%m-%d") - - def live_form_value(%Time{} = time), do: Calendar.strftime(time, "%H:%M") - - def live_form_value(%NaiveDateTime{} = naive) do - NaiveDateTime.to_iso8601(naive) + defp route_prefix(web_path, plural) do + path = Path.join(for str <- [web_path, plural], do: to_string(str)) + "/" <> String.trim_leading(path, "/") end - def live_form_value(%DateTime{} = naive) do - DateTime.to_iso8601(naive) + defp api_route_prefix(web_path, plural, api_prefix) do + path = Path.join(for str <- [api_prefix, web_path, plural], do: to_string(str)) + "/" <> String.trim_leading(path, "/") end - def live_form_value(value), do: value - - @doc """ - Builds an invalid value for `@invalid_attrs` which is nil by default. - - * In case the value is a list, this will return an empty array. - * In case the value is date, datetime, naive_datetime or time, this will return an invalid date. - * In case it is a boolean, we keep it as false - """ - def invalid_form_value(value) when is_list(value), do: [] - - def invalid_form_value(%{day: _day, month: _month, year: _year} = _date), - do: "2022-00" - - def invalid_form_value(%{hour: _hour, minute: _minute}), do: %{hour: 14, minute: 00} - def invalid_form_value(true), do: false - def invalid_form_value(_value), do: nil - @doc """ - Generates an invalid error message according to the params present in the schema. + Returns module attributes. """ - def failed_render_change_message(_schema) do - "can't be blank" - end - - def type_for_migration({:enum, _}), do: :string - def type_for_migration(other), do: other + def module_attributes(%Schema{} = schema) do + schema_prefix = if schema.prefix, do: "\n@schema_prefix :#{schema.prefix}" + + derive = + if schema.opts[:primary_key], + do: "\n@derive {Phoenix.Param, key: :#{schema.opts[:primary_key]}}" + + primary_key = + if schema.binary_id || schema.opts[:primary_key] do + name = schema.opts[:primary_key] || :id + type = if schema.binary_id, do: :binary_id, else: :id + "\n@primary_key {:#{name}, :#{type}, autogenerate: true}" + end - def format_fields_for_schema(schema) do - Enum.map_join(schema.types, "\n", fn {k, v} -> - " field #{inspect(k)}, #{type_and_opts_for_schema(v)}#{schema.defaults[k]}#{maybe_redact_field(k in schema.redacts)}" - end) + [schema_prefix, derive, primary_key] + |> Enum.map_join(&(&1 || "")) + |> Mix.Phoenix.indent_text(spaces: 2, top: 1) end @doc """ - Returns the required fields in the schema. Anything not in the `optionals` list - is considered required. + Returns formatted fields and associations. """ - def required_fields(schema) do - Enum.reject(schema.attrs, fn {key, _} -> key in schema.optionals end) + def fields_and_associations(%Schema{} = schema) do + schema.attrs + |> Attribute.sort() + |> Enum.map(&field_specifics/1) + |> Mix.Phoenix.indent_text(spaces: 4, top: 1, bottom: 1) end - def type_and_opts_for_schema({:enum, opts}), - do: ~s|Ecto.Enum, values: #{inspect(Keyword.get(opts, :values))}| + defp field_specifics(%Attribute{type: :references} = attr) do + association_name = attr.options.association_name + association_schema = inspect(attr.options.association_schema) + foreign_key = if :"#{association_name}_id" != attr.name, do: ", foreign_key: :#{attr.name}" - def type_and_opts_for_schema(other), do: inspect(other) + references = + if attr.options.referenced_column != :id, + do: ", references: :#{attr.options.referenced_column}" - def maybe_redact_field(true), do: ", redact: true" - def maybe_redact_field(false), do: "" + type = if attr.options.referenced_type != :id, do: ", type: :#{attr.options.referenced_type}" - @doc """ - Returns the string value for use in EEx templates. - """ - def value(schema, field, value) do - schema.types - |> Keyword.fetch!(field) - |> inspect_value(value) + "belongs_to :#{association_name}, #{association_schema}#{foreign_key}#{references}#{type}" end - defp inspect_value(:decimal, value), do: "Decimal.new(\"#{value}\")" - defp inspect_value(_type, value), do: inspect(value) + defp field_specifics(%Attribute{} = attr) do + name = inspect(attr.name) + type = inspect(field_type(attr)) + values = enum_values_option(attr) - defp list_to_attr([key]), do: {String.to_atom(key), :string} - defp list_to_attr([key, value]), do: {String.to_atom(key), String.to_atom(value)} + default = + if Map.has_key?(attr.options, :default), + do: ", default: #{field_value(attr.options.default, attr)}" - defp list_to_attr([key, comp, value]) do - {String.to_atom(key), {String.to_atom(comp), String.to_atom(value)}} - end + redact = if attr.options[:redact], do: ", redact: true" + virtual = if attr.options[:virtual], do: ", virtual: true" - @one_day_in_seconds 24 * 3600 - - defp type_to_default(key, t, :create) do - case t do - {:array, type} -> - build_array_values(type, :create) - - {:enum, values} -> - build_enum_values(values, :create) - - :integer -> - 42 - - :float -> - 120.5 - - :decimal -> - "120.5" - - :boolean -> - true - - :map -> - %{} - - :text -> - "some #{key}" - - :date -> - Date.add(Date.utc_today(), -1) - - :time -> - ~T[14:00:00] - - :time_usec -> - ~T[14:00:00.000000] - - :uuid -> - "7488a646-e31f-11e4-aace-600308960662" - - :utc_datetime -> - DateTime.add( - build_utc_datetime(), - -@one_day_in_seconds, - :second, - Calendar.UTCOnlyTimeZoneDatabase - ) - - :utc_datetime_usec -> - DateTime.add( - build_utc_datetime_usec(), - -@one_day_in_seconds, - :second, - Calendar.UTCOnlyTimeZoneDatabase - ) - - :naive_datetime -> - NaiveDateTime.add(build_utc_naive_datetime(), -@one_day_in_seconds) - - :naive_datetime_usec -> - NaiveDateTime.add(build_utc_naive_datetime_usec(), -@one_day_in_seconds) - - _ -> - "some #{key}" - end + "field #{name}, #{type}#{values}#{default}#{redact}#{virtual}" end - defp type_to_default(key, t, :update) do - case t do - {:array, type} -> build_array_values(type, :update) - {:enum, values} -> build_enum_values(values, :update) - :integer -> 43 - :float -> 456.7 - :decimal -> "456.7" - :boolean -> false - :map -> %{} - :text -> "some updated #{key}" - :date -> Date.utc_today() - :time -> ~T[15:01:01] - :time_usec -> ~T[15:01:01.000000] - :uuid -> "7488a646-e31f-11e4-aace-600308960668" - :utc_datetime -> build_utc_datetime() - :utc_datetime_usec -> build_utc_datetime_usec() - :naive_datetime -> build_utc_naive_datetime() - :naive_datetime_usec -> build_utc_naive_datetime_usec() - _ -> "some updated #{key}" - end - end - - defp build_array_values(:string, :create), - do: Enum.map([1, 2], &"option#{&1}") + defp field_type(%Attribute{type: {:array, inner_type}} = attr), + do: {:array, field_type(%{attr | type: inner_type})} - defp build_array_values(:integer, :create), - do: [1, 2] + defp field_type(%Attribute{type: :text}), do: :string + defp field_type(%Attribute{type: :uuid}), do: Ecto.UUID + defp field_type(%Attribute{type: :enum}), do: Ecto.Enum + defp field_type(%Attribute{type: type}), do: type - defp build_array_values(:string, :update), - do: ["option1"] + defp enum_values_option(%Attribute{type: :enum} = attr), + do: ", values: #{inspect(attr.options.values)}" - defp build_array_values(:integer, :update), - do: [1] + defp enum_values_option(%Attribute{type: {:array, inner_type}} = attr), + do: enum_values_option(%{attr | type: inner_type}) - defp build_array_values(_, _), - do: [] + defp enum_values_option(_attr), do: "" - defp build_enum_values(values, action) do - case {action, translate_enum_vals(values)} do - {:create, vals} -> hd(vals) - {:update, [val | []]} -> val - {:update, vals} -> vals |> tl() |> hd() - end - end + def field_value(value, %Attribute{type: :decimal}), do: "Decimal.new(\"#{value}\")" + def field_value(value, %Attribute{}), do: inspect(value) - defp build_utc_datetime_usec, - do: %{DateTime.utc_now() | second: 0, microsecond: {0, 6}} - - defp build_utc_datetime, - do: DateTime.truncate(build_utc_datetime_usec(), :second) - - defp build_utc_naive_datetime_usec, - do: %{NaiveDateTime.utc_now() | second: 0, microsecond: {0, 6}} - - defp build_utc_naive_datetime, - do: NaiveDateTime.truncate(build_utc_naive_datetime_usec(), :second) - - @enum_missing_value_error """ - Enum type requires at least one value - For example: - - mix phx.gen.schema Comment comments body:text status:enum:published:unpublished + @doc """ + Returns type option for `timestamps` function. """ + def timestamps_type(%Schema{timestamp_type: :naive_datetime}), do: "" + def timestamps_type(%Schema{timestamp_type: timestamp_type}), do: "type: :#{timestamp_type}" - defp validate_attr!({name, :datetime}), do: {name, :naive_datetime} - - defp validate_attr!({name, :array}) do - Mix.raise(""" - Phoenix generators expect the type of the array to be given to #{name}:array. - For example: - - mix phx.gen.schema Post posts settings:array:string - """) - end - - defp validate_attr!({_name, :enum}), do: Mix.raise(@enum_missing_value_error) - defp validate_attr!({_name, type} = attr) when type in @valid_types, do: attr - defp validate_attr!({_name, {type, _}} = attr) when type in @valid_types, do: attr - - defp validate_attr!({_, type}) do - Mix.raise( - "Unknown type `#{inspect(type)}` given to generator. " <> - "The supported types are: #{@valid_types |> Enum.sort() |> Enum.join(", ")}" - ) - end - - defp partition_attrs_and_assocs(schema_module, attrs) do - {assocs, attrs} = - Enum.split_with(attrs, fn - {_, {:references, _}} -> - true - - {key, :references} -> - Mix.raise(""" - Phoenix generators expect the table to be given to #{key}:references. - For example: - - mix phx.gen.schema Comment comments body:text post_id:references:posts - """) - - _ -> - false - end) - - assocs = - Enum.map(assocs, fn {key_id, {:references, source}} -> - key = String.replace(Atom.to_string(key_id), "_id", "") - base = schema_module |> Module.split() |> Enum.drop(-1) - module = Module.concat(base ++ [Phoenix.Naming.camelize(key)]) - {String.to_atom(key), key_id, inspect(module), source} - end) - - {assocs, attrs} - end - - defp schema_defaults(attrs) do - Enum.into(attrs, %{}, fn - {key, :boolean} -> {key, ", default: false"} - {key, _} -> {key, ""} - end) - end - - defp string_attr(types) do - Enum.find_value(types, fn - {key, :string} -> key - _ -> false - end) - end - - defp types(attrs) do - Keyword.new(attrs, fn - {key, {:enum, vals}} -> {key, {:enum, values: translate_enum_vals(vals)}} - {key, {root, val}} -> {key, {root, schema_type(val)}} - {key, val} -> {key, schema_type(val)} - end) - end - - def translate_enum_vals(vals) do - vals - |> Atom.to_string() - |> String.split(":") - |> Enum.map(&String.to_atom/1) - end - - defp schema_type(:text), do: :string - defp schema_type(:uuid), do: Ecto.UUID - - defp schema_type(val) do - if Code.ensure_loaded?(Ecto.Type) and not Ecto.Type.primitive?(val) do - Mix.raise("Unknown type `#{val}` given to generator") - else - val - end - end - - defp indexes(table, assocs, uniques) do - uniques = Enum.map(uniques, fn key -> {key, true} end) - assocs = Enum.map(assocs, fn {_, key, _, _} -> {key, false} end) - - (uniques ++ assocs) - |> Enum.uniq_by(fn {key, _} -> key end) - |> Enum.map(fn - {key, false} -> "create index(:#{table}, [:#{key}])" - {key, true} -> "create unique_index(:#{table}, [:#{key}])" - end) - end - - defp migration_defaults(attrs) do - Enum.into(attrs, %{}, fn - {key, :boolean} -> {key, ", default: false, null: false"} - {key, _} -> {key, ""} - end) - end - - defp sample_id(opts) do - if Keyword.get(opts, :binary_id, false) do - Keyword.get(opts, :sample_binary_id, "11111111-1111-1111-1111-111111111111") - else - -1 - end - end - - defp route_helper(web_path, singular) do - "#{web_path}_#{singular}" - |> String.trim_leading("_") - |> String.replace("/", "_") + @doc """ + Returns formatted fields to cast. + """ + def cast_fields(%Schema{} = schema) do + schema.attrs + |> Attribute.sort() + |> Enum.map_join(", ", &inspect(&1.name)) end - defp route_prefix(web_path, plural) do - path = Path.join(for str <- [web_path, plural], do: to_string(str)) - "/" <> String.trim_leading(path, "/") + @doc """ + Returns formatted fields to require. + """ + def required_fields(%Schema{} = schema) do + schema.attrs + |> Attribute.required() + |> Attribute.sort() + |> Enum.map_join(", ", &inspect(&1.name)) end - defp api_route_prefix(web_path, plural, api_prefix) do - path = Path.join(for str <- [api_prefix, web_path, plural], do: to_string(str)) - "/" <> String.trim_leading(path, "/") + @doc """ + Returns specific changeset constraints. + """ + def changeset_constraints(%Schema{} = schema) do + length_validations(schema) <> + assoc_constraints(schema) <> + unique_constraints(schema) end - defp migration_module do - case Application.get_env(:ecto_sql, :migration_module, Ecto.Migration) do - migration_module when is_atom(migration_module) -> migration_module - other -> Mix.raise("Expected :migration_module to be a module, got: #{inspect(other)}") - end + @doc """ + Returns length validations. + """ + def length_validations(%Schema{} = schema) do + schema.attrs + |> Enum.filter(& &1.options[:size]) + |> Attribute.sort() + |> Enum.map(&"|> validate_length(:#{&1.name}, max: #{&1.options[:size]})") + |> Mix.Phoenix.indent_text(spaces: 4, top: 1) end - defp fixture_unique_functions(singular, uniques, attrs) do - uniques - |> Enum.filter(&Keyword.has_key?(attrs, &1)) - |> Enum.into(%{}, fn attr -> - function_name = "unique_#{singular}_#{attr}" - - {function_def, needs_impl?} = - case Keyword.fetch!(attrs, attr) do - :integer -> - function_def = - """ - def #{function_name}, do: System.unique_integer([:positive]) - """ - - {function_def, false} - - type when type in [:string, :text] -> - function_def = - """ - def #{function_name}, do: "some #{attr}\#{System.unique_integer([:positive])}" - """ - - {function_def, false} - - _ -> - function_def = - """ - def #{function_name} do - raise "implement the logic to generate a unique #{singular} #{attr}" - end - """ - - {function_def, true} - end - - {attr, {function_name, function_def, needs_impl?}} - end) + @doc """ + Returns association constraints. + """ + def assoc_constraints(%Schema{} = schema) do + schema.attrs + |> Attribute.references() + |> Enum.sort_by(& &1.options.association_name) + |> Enum.map(&"|> assoc_constraint(:#{&1.options.association_name})") + |> Mix.Phoenix.indent_text(spaces: 4, top: 1) end - defp fixture_params(attrs, fixture_unique_functions) do - attrs - |> Enum.sort() - |> Enum.map(fn {attr, type} -> - case fixture_unique_functions do - %{^attr => {function_name, _function_def, _needs_impl?}} -> - {attr, "#{function_name}()"} - - %{} -> - {attr, inspect(type_to_default(attr, type, :create))} - end - end) + @doc """ + Returns unique constraints. + """ + def unique_constraints(%Schema{} = schema) do + schema.attrs + |> Attribute.unique() + |> Attribute.without_virtual() + |> Attribute.sort() + |> Enum.map(&"|> unique_constraint(:#{&1.name})") + |> Mix.Phoenix.indent_text(spaces: 4, top: 1) end end diff --git a/lib/mix/phoenix/test_data.ex b/lib/mix/phoenix/test_data.ex new file mode 100644 index 0000000000..4face9edcd --- /dev/null +++ b/lib/mix/phoenix/test_data.ex @@ -0,0 +1,404 @@ +defmodule Mix.Phoenix.TestData do + @moduledoc false + + alias Mix.Phoenix.{Schema, Attribute} + + @doc """ + Clears virtual fields logic to be used in context test file. + """ + def virtual_clearance(%Schema{} = schema) do + schema_singular = schema.singular + + cleared_virtual = + schema.attrs + |> Attribute.virtual() + |> Attribute.sort() + |> Enum.map_join(", ", &"#{&1.name}: #{Schema.field_value(&1.options[:default], &1)}") + + if cleared_virtual != "" do + ("# NOTE: Virtual fields updated to defaults or nil before comparison.\n" <> + "#{schema_singular} = %{#{schema_singular} | #{cleared_virtual}}") + |> Mix.Phoenix.indent_text(spaces: 6, top: 1) + end + end + + @doc """ + Map of data to be used in a fixture file. + """ + def fixture(%Schema{} = schema) do + unique_functions = fixture_unique_functions(schema.attrs, schema.singular) + + %{ + unique_functions: unique_functions, + attrs: fixture_attrs(schema, unique_functions) + } + end + + defp fixture_unique_functions(schema_attrs, schema_singular) do + schema_attrs + |> Attribute.unique() + |> Attribute.without_references() + |> Attribute.sort() + |> Enum.into(%{}, fn attr -> + function_name = "unique_#{schema_singular}_#{attr.name}" + + {function_def, needs_implementation?} = + case attr.type do + :integer -> + function_def = + """ + def #{function_name}, do: System.unique_integer([:positive]) + """ + + {function_def, false} + + type when type in [:string, :text] -> + function_def = + """ + def #{function_name}, do: "\#{System.unique_integer([:positive])}#{attr.name} value" + """ + + {function_def, false} + + _ -> + function_def = + """ + def #{function_name} do + raise "implement the logic to generate a unique #{schema_singular} #{attr.name}" + end + """ + + {function_def, true} + end + + {attr.name, {function_name, function_def, needs_implementation?}} + end) + end + + defp fixture_attrs(schema, unique_functions) do + schema.sample_values.create + |> Enum.map(fn {attr, value} -> + value = fixture_attr_value(value, attr, unique_functions) + "#{attr.name}: #{value}" + end) + |> Mix.Phoenix.indent_text(spaces: 8, top: 1, new_line: ",\n") + end + + # NOTE: For references we create new fixture, which is unique. + defp fixture_attr_value(value, %Attribute{type: :references}, _), do: value + + defp fixture_attr_value(_, %Attribute{options: %{unique: true}} = attr, unique_functions) do + {function_name, _, _} = Map.fetch!(unique_functions, attr.name) + "#{function_name}()" + end + + defp fixture_attr_value(value, %Attribute{} = attr, _), + do: Map.get(attr.options, :default, value) |> inspect() + + @doc """ + Invalid attributes used in live. + """ + def live_invalid_attrs(%Schema{} = schema) do + schema.sample_values.create + |> Enum.map(fn {attr, value} -> + value = value |> live_attr_value() |> live_invalid_attr_value() |> inspect() + "#{attr.name}: #{value}" + end) + |> Mix.Phoenix.indent_text(spaces: 4, top: 1, new_line: ",\n") + end + + defp live_invalid_attr_value(value) when is_list(value), do: [] + defp live_invalid_attr_value(true), do: false + defp live_invalid_attr_value(_value), do: nil + + @doc """ + Returns message for live assertion in case of invalid attributes. + """ + def live_required_attr_message, do: "can't be blank" + + @doc """ + Attributes with references used for `action` in live. + """ + def live_action_attrs_with_references(%Schema{} = schema, action) + when action in [:create, :update] do + references_and_attrs = + Mix.Phoenix.indent_text(schema.sample_values.references_assigns, bottom: 2) <> + "#{action}_attrs = %{" <> + Mix.Phoenix.indent_text( + live_action_attrs(schema, action), + spaces: 2, + top: 1, + bottom: 1, + new_line: ",\n" + ) <> "}" + + Mix.Phoenix.indent_text(references_and_attrs, spaces: 6) + end + + defp live_action_attrs(%Schema{} = schema, action) when action in [:create, :update] do + schema.sample_values + |> Map.fetch!(action) + |> Enum.map(fn {attr, value} -> + value = value |> live_attr_value() |> format_attr_value(attr.type) + "#{attr.name}: #{value}" + end) + end + + defp live_attr_value(%Date{} = date), do: Calendar.strftime(date, "%Y-%m-%d") + defp live_attr_value(%Time{} = time), do: Calendar.strftime(time, "%H:%M") + defp live_attr_value(%NaiveDateTime{} = naive), do: NaiveDateTime.to_iso8601(naive) + defp live_attr_value(%DateTime{} = naive), do: DateTime.to_iso8601(naive) + defp live_attr_value(value), do: value + + @doc """ + Attributes with references used for `action` in context, html, json. + """ + def action_attrs_with_references(%Schema{} = schema, action) + when action in [:create, :update] do + references_and_attrs = + Mix.Phoenix.indent_text(schema.sample_values.references_assigns, bottom: 2) <> + "#{action}_attrs = %{" <> + Mix.Phoenix.indent_text( + action_attrs(schema, action), + spaces: 2, + top: 1, + bottom: 1, + new_line: ",\n" + ) <> "}" + + Mix.Phoenix.indent_text(references_and_attrs, spaces: 6) + end + + defp action_attrs(%Schema{} = schema, action) when action in [:create, :update] do + schema.sample_values + |> Map.fetch!(action) + |> Enum.map(fn {attr, value} -> + value = value |> format_attr_value(attr.type) + "#{attr.name}: #{value}" + end) + end + + defp format_attr_value(value, :references), do: value + defp format_attr_value(value, _type), do: inspect(value) + + @doc """ + Values assertions used for `action` in json. + """ + def json_values_assertions(%Schema{} = schema, action) when action in [:create, :update] do + json_values = + schema.sample_values + |> Map.fetch!(action) + |> Enum.map(fn {attr, value} -> + ~s("#{attr.name}" => #{json_assertion_value(attr, value)}) + end) + + [~s("id" => ^id) | json_values] + |> Mix.Phoenix.indent_text(spaces: 15, new_line: ",\n") + end + + defp json_assertion_value(%Attribute{type: :references} = attr, _value), + do: json_references_value_assign(attr) + + defp json_assertion_value(attr, value) do + if(attr.options[:virtual], do: attr.options[:default], else: value) + |> Phoenix.json_library().encode!() + |> Phoenix.json_library().decode!() + |> inspect() + end + + defp json_references_value_assign(%Attribute{name: name}), do: "json_#{name}" + + @doc """ + Values assertions used for references in json. + """ + def json_references_values_assertions(%Schema{} = schema) do + schema.attrs + |> Attribute.references() + |> Enum.map(&"assert #{json_references_value_assign(&1)} == #{references_value(&1)}") + |> Mix.Phoenix.indent_text(spaces: 6, top: 2) + end + + @doc """ + Returns data to use in html assertions, if there is a suitable field. + """ + def html_assertion_field(%Schema{} = schema) do + if html_assertion_attr = html_assertion_attr(schema.attrs) do + %{ + name: html_assertion_attr.name, + create_value: html_assertion_attr_value(html_assertion_attr, schema.sample_values.create), + update_value: html_assertion_attr_value(html_assertion_attr, schema.sample_values.update) + } + end + end + + # NOTE: For now we use only string field. + # Though current logic likely adjusted to other types as well, even `:references`. + # So, we can consider to use other types in cases with no string attributes. + defp html_assertion_attr(attrs), do: Enum.find(attrs, &(&1.type in [:string, :text])) + + defp html_assertion_attr_value(%Attribute{} = html_assertion_attr, sample_values) do + sample_values + |> Enum.find_value(fn {attr, value} -> if attr == html_assertion_attr, do: value end) + |> format_attr_value(html_assertion_attr.type) + end + + @doc """ + Values assertions used for `action` in context. + """ + def context_values_assertions(%Schema{} = schema, action) when action in [:create, :update] do + schema_singular = schema.singular + + schema.sample_values + |> Map.fetch!(action) + |> Enum.map(fn {attr, value} -> + "assert #{schema_singular}.#{attr.name} == #{context_assertion_value(value, attr)}" + end) + |> Mix.Phoenix.indent_text(spaces: 6) + end + + defp context_assertion_value(value, %Attribute{type: :references}), do: value + defp context_assertion_value(value, %Attribute{} = attr), do: Schema.field_value(value, attr) + + @doc """ + Map of base sample attrs to be used in test files. + Specific formatting logic is invoked per case when it needed only (based on these data). + """ + def sample_values(attrs, schema_module) do + attrs = Attribute.sort(attrs) + + %{ + invalid: invalid_attrs(attrs), + create: sample_action_attrs(attrs, :create), + update: sample_action_attrs(attrs, :update), + references_assigns: references_assigns(attrs, schema_module) + } + end + + defp invalid_attrs(attrs), do: Enum.map_join(attrs, ", ", &"#{&1.name}: nil") + + defp sample_action_attrs(attrs, action) when action in [:create, :update], + do: Enum.map(attrs, &{&1, sample_attr_value(&1, action)}) + + defp sample_attr_value(%Attribute{} = attr, :create) do + case attr.type do + :references -> references_value(attr) + {:array, type} -> [sample_attr_value(%{attr | type: type}, :create)] + :enum -> enum_value(attr.options.values, :create) + :integer -> 142 + :float -> 120.5 + :decimal -> Attribute.adjust_decimal_value("22.5", attr.options) + :boolean -> true + :map -> %{} + :uuid -> "7488a646-e31f-11e4-aace-600308960662" + :date -> date_value(:create) + :time -> ~T[14:00:00] + :time_usec -> ~T[14:00:00.000000] + :utc_datetime -> utc_datetime_value(:create) + :utc_datetime_usec -> utc_datetime_usec_value(:create) + :naive_datetime -> utc_naive_datetime_value(:create) + :naive_datetime_usec -> utc_naive_datetime_usec_value(:create) + _ -> maybe_apply_limit("#{attr.name} value", attr) + end + end + + defp sample_attr_value(%Attribute{} = attr, :update) do + case attr.type do + :references -> references_value(attr) + {:array, type} -> [sample_attr_value(%{attr | type: type}, :update)] + :enum -> enum_value(attr.options.values, :update) + :integer -> 303 + :float -> 456.7 + :decimal -> Attribute.adjust_decimal_value("18.7", attr.options) + :boolean -> false + :map -> %{} + :uuid -> "7488a646-e31f-11e4-aace-600308960668" + :date -> date_value(:update) + :time -> ~T[15:01:01] + :time_usec -> ~T[15:01:01.000000] + :utc_datetime -> utc_datetime_value(:update) + :utc_datetime_usec -> utc_datetime_usec_value(:update) + :naive_datetime -> utc_naive_datetime_value(:update) + :naive_datetime_usec -> utc_naive_datetime_usec_value(:update) + _ -> maybe_apply_limit("updated #{attr.name} value", attr) + end + end + + defp maybe_apply_limit(value, attr) do + if size = attr.options[:size] do + String.slice(value, 0, size) + else + value + end + end + + defp enum_value([{_, _} | _] = values, action), do: enum_value(Keyword.keys(values), action) + defp enum_value([first | _], :create), do: first + defp enum_value([first | rest], :update), do: List.first(rest) || first + + defp date_value(:create), do: Date.add(date_value(:update), -1) + defp date_value(:update), do: Date.utc_today() + + @one_day_in_seconds 24 * 3600 + + defp utc_datetime_value(:create) do + DateTime.add( + utc_datetime_value(:update), + -@one_day_in_seconds, + :second, + Calendar.UTCOnlyTimeZoneDatabase + ) + end + + defp utc_datetime_value(:update), + do: DateTime.truncate(utc_datetime_usec_value(:update), :second) + + defp utc_datetime_usec_value(:create) do + DateTime.add( + utc_datetime_usec_value(:update), + -@one_day_in_seconds, + :second, + Calendar.UTCOnlyTimeZoneDatabase + ) + end + + defp utc_datetime_usec_value(:update), + do: %{DateTime.utc_now() | second: 0, microsecond: {0, 6}} + + defp utc_naive_datetime_value(:create), + do: NaiveDateTime.add(utc_naive_datetime_value(:update), -@one_day_in_seconds) + + defp utc_naive_datetime_value(:update), + do: NaiveDateTime.truncate(utc_naive_datetime_usec_value(:update), :second) + + defp utc_naive_datetime_usec_value(:create), + do: NaiveDateTime.add(utc_naive_datetime_usec_value(:update), -@one_day_in_seconds) + + defp utc_naive_datetime_usec_value(:update), + do: %{NaiveDateTime.utc_now() | second: 0, microsecond: {0, 6}} + + defp references_assigns(attrs, schema_module) do + attrs + |> Attribute.references() + |> Attribute.sort() + |> Enum.map(&references_assign(&1, schema_module)) + end + + defp references_assign(%Attribute{} = attr, schema_module) do + association_name = attr.options.association_name + + [referenced_schema_name | referenced_rest] = + attr.options.association_schema |> Module.split() |> Enum.reverse() + + referenced_context = referenced_rest |> Enum.reverse() |> Module.concat() |> inspect() + context = schema_module |> Module.split() |> Enum.drop(-1) |> Module.concat() |> inspect() + fixtures_module = if referenced_context != context, do: "#{referenced_context}Fixtures." + + fixture_method = "#{Phoenix.Naming.underscore(referenced_schema_name)}_fixture()" + + "#{association_name} = #{fixtures_module}#{fixture_method}" + end + + # NOTE: Based on `references_assign` part. + defp references_value(%Attribute{} = attr), + do: "#{attr.options.association_name}.#{attr.options.referenced_column}" +end diff --git a/lib/mix/phoenix/web.ex b/lib/mix/phoenix/web.ex new file mode 100644 index 0000000000..733e765601 --- /dev/null +++ b/lib/mix/phoenix/web.ex @@ -0,0 +1,121 @@ +defmodule Mix.Phoenix.Web do + @moduledoc false + + alias Mix.Phoenix.{Schema, Attribute} + + @doc """ + Returns table columns for live index page, based on attributes. + For array attribute adds `array_values(...)` wrapper to render values in basic manner. + """ + def live_table_columns(%Schema{} = schema) do + schema_singular = schema.singular + + schema.attrs + |> Enum.map(fn attr -> + value_expression = maybe_array_values(attr, "#{schema_singular}.#{attr.name}") + + ~s(<:col :let={{_id, #{schema_singular}}} label="#{label(attr.name)}"><%= #{value_expression} %>) + end) + |> Mix.Phoenix.indent_text(spaces: 6, top: 1) + end + + @doc """ + Returns table columns for html index page, based on attributes. + For array attribute adds `array_values(...)` wrapper to render values in basic manner. + """ + def table_columns(%Schema{} = schema) do + schema_singular = schema.singular + + schema.attrs + |> Enum.map(fn attr -> + value_expression = maybe_array_values(attr, "#{schema_singular}.#{attr.name}") + + ~s(<:col :let={#{schema_singular}} label="#{label(attr.name)}"><%= #{value_expression} %>) + end) + |> Mix.Phoenix.indent_text(spaces: 2, top: 1) + end + + @doc """ + Returns list items for html and live show pages, based on attributes. + For array attribute adds `array_values(...)` wrapper to render values in basic manner. + """ + def list_items(%Schema{} = schema) do + schema_singular = schema.singular + + schema.attrs + |> Enum.map(fn attr -> + value_expression = maybe_array_values(attr, "@#{schema_singular}.#{attr.name}") + ~s(<:item title="#{label(attr.name)}"><%= #{value_expression} %>) + end) + end + + defp maybe_array_values(%Attribute{type: {:array, _}}, value), do: "array_values(#{value})" + defp maybe_array_values(_, value), do: value + + @doc """ + Returns implementation of `array_values(...)` wrapper to render values in basic manner, + if there is an array attribute. + """ + def maybe_def_array_values(%Schema{} = schema, privacy \\ :defp) + when privacy in [:def, :defp] do + if Enum.any?(schema.attrs, &(is_tuple(&1.type) and elem(&1.type, 0) == :array)) do + ~s/#{privacy} array_values(values), do: (values || []) |> List.flatten() |> Enum.join(", ")/ + |> Mix.Phoenix.indent_text(spaces: 2, top: 2) + end + end + + @doc """ + Returns form inputs for html and live, based on attributes. + Takes into account types and options of attributes. + """ + def form_inputs(%Schema{} = schema, form) do + schema.attrs + |> Enum.reject(&(&1.type == :map)) + |> Enum.map( + &~s(<.input field={#{form}[:#{&1.name}]} label="#{label(&1.name)}"#{input_specifics(&1, schema)}#{required_mark(&1)} />) + ) + |> Enum.map_join("\n", &String.trim_trailing/1) + end + + defp label(name), do: name |> to_string() |> Phoenix.Naming.humanize() + + defp required_mark(%Attribute{options: options}), + do: if(not Map.has_key?(options, :default) and options[:required], do: " required", else: "") + + defp input_specifics(%Attribute{type: :integer}, _schema), do: ~s( type="number") + defp input_specifics(%Attribute{type: :float}, _schema), do: ~s( type="number" step="any") + defp input_specifics(%Attribute{type: :decimal}, _schema), do: ~s( type="number" step="any") + defp input_specifics(%Attribute{type: :boolean}, _schema), do: ~s( type="checkbox") + defp input_specifics(%Attribute{type: :text}, _schema), do: ~s( type="textarea") + defp input_specifics(%Attribute{type: :date}, _schema), do: ~s( type="date") + defp input_specifics(%Attribute{type: :time}, _schema), do: ~s( type="time") + defp input_specifics(%Attribute{type: :utc_datetime}, _schema), do: ~s( type="datetime-local") + defp input_specifics(%Attribute{type: :naive_datetime}, _schema), do: ~s( type="datetime-local") + + # NOTE: This implements only case with one level array. + # For nested arrays some grouping logic is needed, or new input creation on user action. + defp input_specifics(%Attribute{type: {:array, _type}} = attr, schema), + do: ~s( type="select" options={#{array_example_options(attr, schema)}} multiple) + + defp input_specifics(%Attribute{type: :enum} = attr, schema), + do: ~s( type="select" options={#{enum_options(attr, schema)}} prompt="Choose a value") + + defp input_specifics(%Attribute{}, _schema), do: ~s( type="text") + + defp enum_options(attr, schema), + do: "Ecto.Enum.values(#{inspect(schema.module)}, :#{attr.name})" + + defp array_example_options(%Attribute{type: {:array, :enum}} = attr, schema), + do: enum_options(attr, schema) + + defp array_example_options(%Attribute{type: {:array, _}} = attr, schema) do + (array_example_option(attr, schema, :create) ++ array_example_option(attr, schema, :update)) + |> inspect() + end + + defp array_example_option(target_attr, schema, action) when action in [:create, :update] do + schema.sample_values + |> Map.fetch!(action) + |> Enum.find_value(fn {attr, value} -> if attr == target_attr, do: value end) + end +end diff --git a/lib/mix/tasks/phx.gen.auth.ex b/lib/mix/tasks/phx.gen.auth.ex index 04cb5d2348..835fc357ba 100644 --- a/lib/mix/tasks/phx.gen.auth.ex +++ b/lib/mix/tasks/phx.gen.auth.ex @@ -458,7 +458,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth do paths |> Mix.Phoenix.eval_from("priv/templates/phx.gen.auth/context_functions.ex", binding) - |> prepend_newline() + |> Mix.Phoenix.prepend_newline() |> inject_before_final_end(file) end @@ -467,7 +467,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth do paths |> Mix.Phoenix.eval_from("priv/templates/phx.gen.auth/test_cases.exs", binding) - |> prepend_newline() + |> Mix.Phoenix.prepend_newline() |> inject_before_final_end(test_file) end @@ -480,7 +480,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth do paths |> Mix.Phoenix.eval_from("priv/templates/phx.gen.auth/context_fixtures_functions.ex", binding) - |> prepend_newline() + |> Mix.Phoenix.prepend_newline() |> inject_before_final_end(test_fixtures_file) end @@ -787,8 +787,6 @@ defmodule Mix.Tasks.Phx.Gen.Auth do defp pad(i) when i < 10, do: <> defp pad(i), do: to_string(i) - defp prepend_newline(string) when is_binary(string), do: "\n" <> string - defp get_ecto_adapter!(%Schema{repo: repo}) do if Code.ensure_loaded?(repo) do repo.__adapter__() diff --git a/lib/mix/tasks/phx.gen.context.ex b/lib/mix/tasks/phx.gen.context.ex index 1dfad60e39..65c5c832f2 100644 --- a/lib/mix/tasks/phx.gen.context.ex +++ b/lib/mix/tasks/phx.gen.context.ex @@ -24,9 +24,9 @@ defmodule Mix.Tasks.Phx.Gen.Context do * a context module in `accounts.ex`, serving as the API boundary * a schema in `accounts/user.ex`, with a `users` table - - A migration file for the repository and test files for the context - will also be generated. + * a migration for the schema in `priv/repo/migrations` + * a context test module in `test/my_app/accounts_test.exs` + * a context test helper module in `test/support/fixtures/accounts_fixtures.ex` ## Generating without a schema @@ -45,7 +45,9 @@ defmodule Mix.Tasks.Phx.Gen.Context do ## binary_id Generated migration can use `binary_id` for schema's primary key - and its references with option `--binary-id`. + with option `--binary-id`. + + $ mix phx.gen.context Blog.Post posts title --binary-id ## Default options @@ -54,8 +56,8 @@ defmodule Mix.Tasks.Phx.Gen.Context do config :your_app, :generators, migration: true, - binary_id: false, timestamp_type: :naive_datetime, + binary_id: false, sample_binary_id: "11111111-1111-1111-1111-111111111111" You can override those options per invocation by providing corresponding @@ -76,7 +78,7 @@ defmodule Mix.Tasks.Phx.Gen.Context do use Mix.Task - alias Mix.Phoenix.{Context, Schema} + alias Mix.Phoenix.{Context, Schema, TestData} alias Mix.Tasks.Phx.Gen @switches [ @@ -160,6 +162,7 @@ defmodule Mix.Tasks.Phx.Gen.Context do @doc false def copy_new_files(%Context{schema: schema} = context, paths, binding) do if schema.generate?, do: Gen.Schema.copy_new_files(schema, paths, binding) + inject_schema_access(context, paths, binding) inject_tests(context, paths, binding) inject_test_fixture(context, paths, binding) @@ -205,6 +208,9 @@ defmodule Mix.Tasks.Phx.Gen.Context do defp inject_tests(%Context{test_file: test_file} = context, paths, binding) do ensure_test_file_exists(context, paths, binding) + virtual_clearance = TestData.virtual_clearance(binding[:schema]) + binding = Keyword.merge(binding, virtual_clearance: virtual_clearance) + paths |> Mix.Phoenix.eval_from("priv/templates/phx.gen.context/test_cases.exs", binding) |> inject_eex_before_final_end(test_file, binding) @@ -231,50 +237,39 @@ defmodule Mix.Tasks.Phx.Gen.Context do ) do ensure_test_fixtures_file_exists(context, paths, binding) + fixture = TestData.fixture(binding[:schema]) + binding = Keyword.merge(binding, fixture: fixture) + paths |> Mix.Phoenix.eval_from("priv/templates/phx.gen.context/fixtures.ex", binding) |> Mix.Phoenix.prepend_newline() |> inject_eex_before_final_end(test_fixtures_file, binding) - maybe_print_unimplemented_fixture_functions(context) + maybe_print_unimplemented_fixture_functions(fixture, test_fixtures_file) end - defp maybe_print_unimplemented_fixture_functions(%Context{} = context) do - fixture_functions_needing_implementations = + defp maybe_print_unimplemented_fixture_functions(fixture, test_fixtures_file) do + unimplemented_fixture_functions = Enum.flat_map( - context.schema.fixture_unique_functions, + fixture.unique_functions, fn {_field, {_function_name, function_def, true}} -> [function_def] {_field, {_function_name, _function_def, false}} -> [] end ) - if Enum.any?(fixture_functions_needing_implementations) do + if Enum.any?(unimplemented_fixture_functions) do Mix.shell().info(""" Some of the generated database columns are unique. Please provide unique implementations for the following fixture function(s) in - #{context.test_fixtures_file}: + #{test_fixtures_file}: - #{fixture_functions_needing_implementations |> Enum.map_join(&indent(&1, 2)) |> String.trim_trailing()} + #{unimplemented_fixture_functions |> Enum.join("\n") |> Mix.Phoenix.indent_text(spaces: 2)} """) end end - defp indent(string, spaces) do - indent_string = String.duplicate(" ", spaces) - - string - |> String.split("\n") - |> Enum.map_join(fn line -> - if String.trim(line) == "" do - "\n" - else - indent_string <> line <> "\n" - end - end) - end - defp inject_eex_before_final_end(content_to_inject, file_path, binding) do file = File.read!(file_path) diff --git a/lib/mix/tasks/phx.gen.embedded.ex b/lib/mix/tasks/phx.gen.embedded.ex index 977cac2efa..a51ffd8303 100644 --- a/lib/mix/tasks/phx.gen.embedded.ex +++ b/lib/mix/tasks/phx.gen.embedded.ex @@ -14,27 +14,28 @@ defmodule Mix.Tasks.Phx.Gen.Embedded do ## Attributes - The resource fields are given using `name:type` syntax - where type are the types supported by Ecto. Omitting - the type makes it default to `:string`: + The resource fields are given using `name:type:options` syntax + where type are the types supported by Ecto. Default type is `string`, + which can be omitted when field doesn't have options. mix phx.gen.embedded Blog.Post title views:integer - The following types are supported: + #{Mix.Phoenix.Attribute.supported_types()} - #{for attr <- Mix.Phoenix.Schema.valid_types(), do: " * `#{inspect attr}`\n"} - * `:datetime` - An alias for `:naive_datetime` + #{Mix.Phoenix.Attribute.supported_options()} """ use Mix.Task alias Mix.Phoenix.Schema - @switches [binary_id: :boolean, web: :string] + @switches [web: :string] @doc false def run(args) do if Mix.Project.umbrella?() do - Mix.raise "mix phx.gen.embedded must be invoked from within your *_web application root directory" + Mix.raise( + "mix phx.gen.embedded must be invoked from within your *_web application root directory" + ) end schema = build(args) @@ -50,14 +51,13 @@ defmodule Mix.Tasks.Phx.Gen.Embedded do def build(args) do {schema_opts, parsed, _} = OptionParser.parse(args, switches: @switches) [schema_name | attrs] = validate_args!(parsed) + opts = schema_opts |> Keyword.put(:embedded, true) |> Keyword.put(:migration, false) - schema = Schema.new(schema_name, nil, attrs, opts) - - schema + Schema.new(schema_name, nil, attrs, opts) end @doc false @@ -65,27 +65,27 @@ defmodule Mix.Tasks.Phx.Gen.Embedded do if Schema.valid?(schema) do args else - raise_with_help "Expected the schema argument, #{inspect schema}, to be a valid module name" + raise_with_help("Expected the schema, #{inspect(schema)}, to be a valid module name") end end + def validate_args!(_) do - raise_with_help "Invalid arguments" + raise_with_help("Invalid arguments") end @doc false - @spec raise_with_help(String.t) :: no_return() + @spec raise_with_help(String.t()) :: no_return() def raise_with_help(msg) do - Mix.raise """ + Mix.raise(""" #{msg} mix phx.gen.embedded expects a module name followed by any number of attributes: mix phx.gen.embedded Blog.Post title:string - """ + """) end - defp prompt_for_conflicts(schema) do schema |> files_to_be_generated() diff --git a/lib/mix/tasks/phx.gen.html.ex b/lib/mix/tasks/phx.gen.html.ex index 76fe2aa388..9755068df9 100644 --- a/lib/mix/tasks/phx.gen.html.ex +++ b/lib/mix/tasks/phx.gen.html.ex @@ -85,7 +85,7 @@ defmodule Mix.Tasks.Phx.Gen.Html do """ use Mix.Task - alias Mix.Phoenix.{Context, Schema} + alias Mix.Phoenix.{Context, TestData} alias Mix.Tasks.Phx.Gen @doc false @@ -99,7 +99,7 @@ defmodule Mix.Tasks.Phx.Gen.Html do {context, schema} = Gen.Context.build(args) Gen.Context.prompt_for_code_injection(context) - binding = [context: context, schema: schema, inputs: inputs(schema)] + binding = [context: context, schema: schema] paths = Mix.Phoenix.generator_paths() prompt_for_conflicts(context) @@ -116,13 +116,14 @@ defmodule Mix.Tasks.Phx.Gen.Html do |> Mix.Phoenix.prompt_for_conflicts() end - defp context_files(%Context{generate?: true} = context) do - Gen.Context.files_to_be_generated(context) - end + # TODO: Looks like this logic (check) belongs to `Gen.Context.files_to_be_generated` function. + # Like, there is no need to scatter and repeat this across different generators. + # Similar for `Gen.Schema.files_to_be_generated` invocation. + # Double check and extract, if it's correct. + defp context_files(%Context{generate?: false}), do: [] - defp context_files(%Context{generate?: false}) do - [] - end + defp context_files(%Context{generate?: true} = context), + do: Gen.Context.files_to_be_generated(context) @doc false def files_to_be_generated(%Context{schema: schema, context_app: context_app}) do @@ -149,9 +150,14 @@ defmodule Mix.Tasks.Phx.Gen.Html do @doc false def copy_new_files(%Context{} = context, paths, binding) do + if context.generate?, do: Gen.Context.copy_new_files(context, paths, binding) + + html_assertion_field = TestData.html_assertion_field(binding[:schema]) + binding = Keyword.merge(binding, html_assertion_field: html_assertion_field) + files = files_to_be_generated(context) Mix.Phoenix.copy_from(paths, "priv/templates/phx.gen.html", binding, files) - if context.generate?, do: Gen.Context.copy_new_files(context, paths, binding) + context end @@ -179,96 +185,4 @@ defmodule Mix.Tasks.Phx.Gen.Html do if context.generate?, do: Gen.Context.print_shell_instructions(context) end - - @doc false - def inputs(%Schema{} = schema) do - schema.attrs - |> Enum.reject(fn {_key, type} -> type == :map end) - |> Enum.map(fn - {key, :integer} -> - ~s(<.input field={f[#{inspect(key)}]} type="number" label="#{label(key)}" />) - - {key, :float} -> - ~s(<.input field={f[#{inspect(key)}]} type="number" label="#{label(key)}" step="any" />) - - {key, :decimal} -> - ~s(<.input field={f[#{inspect(key)}]} type="number" label="#{label(key)}" step="any" />) - - {key, :boolean} -> - ~s(<.input field={f[#{inspect(key)}]} type="checkbox" label="#{label(key)}" />) - - {key, :text} -> - ~s(<.input field={f[#{inspect(key)}]} type="textarea" label="#{label(key)}" />) - - {key, :date} -> - ~s(<.input field={f[#{inspect(key)}]} type="date" label="#{label(key)}" />) - - {key, :time} -> - ~s(<.input field={f[#{inspect(key)}]} type="time" label="#{label(key)}" />) - - {key, :utc_datetime} -> - ~s(<.input field={f[#{inspect(key)}]} type="datetime-local" label="#{label(key)}" />) - - {key, :naive_datetime} -> - ~s(<.input field={f[#{inspect(key)}]} type="datetime-local" label="#{label(key)}" />) - - {key, {:array, _} = type} -> - ~s""" - <.input - field={f[#{inspect(key)}]} - type="select" - multiple - label="#{label(key)}" - options={#{inspect(default_options(type))}} - /> - """ - - {key, {:enum, _}} -> - ~s""" - <.input - field={f[#{inspect(key)}]} - type="select" - label="#{label(key)}" - prompt="Choose a value" - options={Ecto.Enum.values(#{inspect(schema.module)}, #{inspect(key)})} - /> - """ - - {key, _} -> - ~s(<.input field={f[#{inspect(key)}]} type="text" label="#{label(key)}" />) - end) - end - - defp default_options({:array, :string}), - do: Enum.map([1, 2], &{"Option #{&1}", "option#{&1}"}) - - defp default_options({:array, :integer}), - do: Enum.map([1, 2], &{"#{&1}", &1}) - - defp default_options({:array, _}), do: [] - - defp label(key), do: Phoenix.Naming.humanize(to_string(key)) - - @doc false - def indent_inputs(inputs, column_padding) do - columns = String.duplicate(" ", column_padding) - - inputs - |> Enum.map(fn input -> - lines = input |> String.split("\n") |> Enum.reject(&(&1 == "")) - - case lines do - [] -> - [] - - [line] -> - [columns, line] - - [first_line | rest] -> - rest = Enum.map_join(rest, "\n", &(columns <> &1)) - [columns, first_line, "\n", rest] - end - end) - |> Enum.intersperse("\n") - end end diff --git a/lib/mix/tasks/phx.gen.json.ex b/lib/mix/tasks/phx.gen.json.ex index 08efb5f16c..c3b2c09826 100644 --- a/lib/mix/tasks/phx.gen.json.ex +++ b/lib/mix/tasks/phx.gen.json.ex @@ -83,7 +83,7 @@ defmodule Mix.Tasks.Phx.Gen.Json do use Mix.Task - alias Mix.Phoenix.Context + alias Mix.Phoenix.{Context, Schema} alias Mix.Tasks.Phx.Gen @doc false @@ -120,13 +120,10 @@ defmodule Mix.Tasks.Phx.Gen.Json do |> Mix.Phoenix.prompt_for_conflicts() end - defp context_files(%Context{generate?: true} = context) do - Gen.Context.files_to_be_generated(context) - end + defp context_files(%Context{generate?: false}), do: [] - defp context_files(%Context{generate?: false}) do - [] - end + defp context_files(%Context{generate?: true} = context), + do: Gen.Context.files_to_be_generated(context) @doc false def files_to_be_generated(%Context{schema: schema, context_app: context_app}) do @@ -148,9 +145,10 @@ defmodule Mix.Tasks.Phx.Gen.Json do @doc false def copy_new_files(%Context{} = context, paths, binding) do + if context.generate?, do: Gen.Context.copy_new_files(context, paths, binding) + files = files_to_be_generated(context) Mix.Phoenix.copy_from(paths, "priv/templates/phx.gen.json", binding, files) - if context.generate?, do: Gen.Context.copy_new_files(context, paths, binding) context end @@ -179,4 +177,13 @@ defmodule Mix.Tasks.Phx.Gen.Json do if context.generate?, do: Gen.Context.print_shell_instructions(context) end + + @doc false + def data_with_id(%Schema{} = schema) do + schema_singular = schema.singular + + [:id | Enum.map(schema.attrs, & &1.name)] + |> Enum.map(&"#{&1}: #{schema_singular}.#{&1}") + |> Mix.Phoenix.indent_text(spaces: 6, new_line: ",\n") + end end diff --git a/lib/mix/tasks/phx.gen.live.ex b/lib/mix/tasks/phx.gen.live.ex index f0b6aa28aa..70e162a511 100644 --- a/lib/mix/tasks/phx.gen.live.ex +++ b/lib/mix/tasks/phx.gen.live.ex @@ -94,7 +94,7 @@ defmodule Mix.Tasks.Phx.Gen.Live do """ use Mix.Task - alias Mix.Phoenix.{Context, Schema} + alias Mix.Phoenix.{Context, TestData} alias Mix.Tasks.Phx.Gen @doc false @@ -108,7 +108,7 @@ defmodule Mix.Tasks.Phx.Gen.Live do {context, schema} = Gen.Context.build(args) Gen.Context.prompt_for_code_injection(context) - binding = [context: context, schema: schema, inputs: inputs(schema)] + binding = [context: context, schema: schema] paths = Mix.Phoenix.generator_paths() prompt_for_conflicts(context) @@ -126,13 +126,10 @@ defmodule Mix.Tasks.Phx.Gen.Live do |> Mix.Phoenix.prompt_for_conflicts() end - defp context_files(%Context{generate?: true} = context) do - Gen.Context.files_to_be_generated(context) - end + defp context_files(%Context{generate?: false}), do: [] - defp context_files(%Context{generate?: false}) do - [] - end + defp context_files(%Context{generate?: true} = context), + do: Gen.Context.files_to_be_generated(context) defp files_to_be_generated(%Context{schema: schema, context_app: context_app}) do web_prefix = Mix.Phoenix.web_path(context_app) @@ -153,18 +150,21 @@ defmodule Mix.Tasks.Phx.Gen.Live do end defp copy_new_files(%Context{} = context, binding, paths) do - files = files_to_be_generated(context) + if context.generate?, do: Gen.Context.copy_new_files(context, paths, binding) + + html_assertion_field = TestData.html_assertion_field(binding[:schema]) binding = Keyword.merge(binding, + html_assertion_field: html_assertion_field, assigns: %{ web_namespace: inspect(context.web_module), gettext: true } ) + files = files_to_be_generated(context) Mix.Phoenix.copy_from(paths, "priv/templates/phx.gen.live", binding, files) - if context.generate?, do: Gen.Context.copy_new_files(context, paths, binding) context end @@ -263,76 +263,4 @@ defmodule Mix.Tasks.Phx.Gen.Live do ~s|live "/#{schema.plural}/:id/edit", #{inspect(schema.alias)}Live.Form, :edit| ] end - - @doc false - def inputs(%Schema{} = schema) do - schema.attrs - |> Enum.reject(fn {_key, type} -> type == :map end) - |> Enum.map(fn - {_, {:references, _}} -> - nil - - {key, :integer} -> - ~s(<.input field={@form[#{inspect(key)}]} type="number" label="#{label(key)}" />) - - {key, :float} -> - ~s(<.input field={@form[#{inspect(key)}]} type="number" label="#{label(key)}" step="any" />) - - {key, :decimal} -> - ~s(<.input field={@form[#{inspect(key)}]} type="number" label="#{label(key)}" step="any" />) - - {key, :boolean} -> - ~s(<.input field={@form[#{inspect(key)}]} type="checkbox" label="#{label(key)}" />) - - {key, :text} -> - ~s(<.input field={@form[#{inspect(key)}]} type="textarea" label="#{label(key)}" />) - - {key, :date} -> - ~s(<.input field={@form[#{inspect(key)}]} type="date" label="#{label(key)}" />) - - {key, :time} -> - ~s(<.input field={@form[#{inspect(key)}]} type="time" label="#{label(key)}" />) - - {key, :utc_datetime} -> - ~s(<.input field={@form[#{inspect(key)}]} type="datetime-local" label="#{label(key)}" />) - - {key, :naive_datetime} -> - ~s(<.input field={@form[#{inspect(key)}]} type="datetime-local" label="#{label(key)}" />) - - {key, {:array, _} = type} -> - ~s""" - <.input - field={@form[#{inspect(key)}]} - type="select" - multiple - label="#{label(key)}" - options={#{inspect(default_options(type))}} - /> - """ - - {key, {:enum, _}} -> - ~s""" - <.input - field={@form[#{inspect(key)}]} - type="select" - label="#{label(key)}" - prompt="Choose a value" - options={Ecto.Enum.values(#{inspect(schema.module)}, #{inspect(key)})} - /> - """ - - {key, _} -> - ~s(<.input field={@form[#{inspect(key)}]} type="text" label="#{label(key)}" />) - end) - end - - defp default_options({:array, :string}), - do: Enum.map([1, 2], &{"Option #{&1}", "option#{&1}"}) - - defp default_options({:array, :integer}), - do: Enum.map([1, 2], &{"#{&1}", &1}) - - defp default_options({:array, _}), do: [] - - defp label(key), do: Phoenix.Naming.humanize(to_string(key)) end diff --git a/lib/mix/tasks/phx.gen.schema.ex b/lib/mix/tasks/phx.gen.schema.ex index 68f3c82ddf..b3c9f1d8c4 100644 --- a/lib/mix/tasks/phx.gen.schema.ex +++ b/lib/mix/tasks/phx.gen.schema.ex @@ -33,45 +33,52 @@ defmodule Mix.Tasks.Phx.Gen.Schema do ## Attributes - The resource fields are given using `name:type` syntax - where type are the types supported by Ecto. Omitting - the type makes it default to `:string`: + The resource fields are given using `name:type:options` syntax + where type are the types supported by Ecto. Default type is `string`, + which can be omitted when field doesn't have options. - $ mix phx.gen.schema Blog.Post blog_posts title views:integer + $ mix phx.gen.schema Blog.Post blog_posts title slug:string:unique views:integer - The following types are supported: + The generator also supports references. The given column name we will + properly associate to the primary key column of the referenced table. + Be default all info is going to be inferred from column name via + referenced schema search in the same context. - #{for attr <- Mix.Phoenix.Schema.valid_types(), do: " * `#{inspect attr}`\n"} - * `:datetime` - An alias for `:naive_datetime` + $ mix phx.gen.schema Blog.Post blog_posts title user_id:references - The generator also supports references, which we will properly - associate the given column to the primary key column of the - referenced table: + We can provide specifics via options. E.g. if we associate with schema + in different context we can specify options for full schema name + (schema naming has the same approach as schema we are creating). - $ mix phx.gen.schema Blog.Post blog_posts title user_id:references:users + $ mix phx.gen.schema Blog.Post blog_posts title user_id:references:Accounts.User - This will result in a migration with an `:integer` column - of `:user_id` and create an index. + This will result in a migration with column `:user_id` properly set + with referenced table and type, and create an index. + See other options below. - Furthermore an array type can also be given if it is - supported by your database, although it requires the - type of the underlying array element to be given too: + An array type can also be given if it is supported by your database. + By default type of underlying array element is `string`. + You can provide specific type: - $ mix phx.gen.schema Blog.Post blog_posts tags:array:string + $ mix phx.gen.schema Blog.Post blog_posts tags:array + $ mix phx.gen.schema Blog.Post blog_posts tags:[array,integer] - Unique columns can be automatically generated by using: + Unique columns can be automatically generated with option `unique`. - $ mix phx.gen.schema Blog.Post blog_posts title:unique unique_int:integer:unique + $ mix phx.gen.schema Blog.Post blog_posts title:string:unique unique_int:integer:unique - Redact columns can be automatically generated by using: + Redact columns can be automatically generated with option `redact`. - $ mix phx.gen.schema Accounts.Superhero superheroes secret_identity:redact password:string:redact + $ mix phx.gen.schema Accounts.Superhero superheroes secret_identity:string:redact password:string:redact - Ecto.Enum fields can be generated by using: + Ecto.Enum fields can be generated with mandatory list of values in + options. At least one value should be provided. - $ mix phx.gen.schema Blog.Post blog_posts title status:enum:unpublished:published:deleted + $ mix phx.gen.schema Blog.Post blog_posts title status:enum:[unpublished,published,deleted] - If no data type is given, it defaults to a string. + #{Mix.Phoenix.Attribute.supported_types()} + + #{Mix.Phoenix.Attribute.supported_options()} ## table @@ -84,7 +91,9 @@ defmodule Mix.Tasks.Phx.Gen.Schema do ## binary_id Generated migration can use `binary_id` for schema's primary key - and its references with option `--binary-id`. + with option `--binary-id`. + + $ mix phx.gen.schema Blog.Post posts title --binary-id ## primary_key @@ -132,8 +141,8 @@ defmodule Mix.Tasks.Phx.Gen.Schema do config :your_app, :generators, migration: true, - binary_id: false, timestamp_type: :naive_datetime, + binary_id: false, sample_binary_id: "11111111-1111-1111-1111-111111111111" You can override those options per invocation by providing corresponding @@ -149,17 +158,29 @@ defmodule Mix.Tasks.Phx.Gen.Schema do """ use Mix.Task + # TODO: shpakvel, update this doc. alias Mix.Phoenix.Schema - @switches [migration: :boolean, binary_id: :boolean, table: :string, web: :string, - context_app: :string, prefix: :string, repo: :string, migration_dir: :string, - primary_key: :string] + @switches [ + migration: :boolean, + binary_id: :boolean, + table: :string, + web: :string, + context_app: :string, + prefix: :string, + repo: :string, + migration_dir: :string, + compile: :boolean, + primary_key: :string + ] @doc false def run(args) do if Mix.Project.umbrella?() do - Mix.raise "mix phx.gen.schema must be invoked from within your *_web application root directory" + Mix.raise( + "mix phx.gen.schema must be invoked from within your *_web application root directory" + ) end schema = build(args, []) @@ -183,6 +204,12 @@ defmodule Mix.Tasks.Phx.Gen.Schema do {schema_opts, parsed, _} = OptionParser.parse(args, switches: @switches) [schema_name, plural | attrs] = validate_args!(parsed, help) + if Mix.env() != :test or "--compile" in args do + # NOTE: It is needed to get loaded Ecto.Schema for using reflection. + Mix.Task.run("compile") + validate_required_dependencies!() + end + opts = parent_opts |> Keyword.merge(schema_opts) @@ -192,6 +219,10 @@ defmodule Mix.Tasks.Phx.Gen.Schema do Schema.new(schema_name, plural, attrs, opts) end + defp validate_required_dependencies! do + if not Code.ensure_loaded?(Ecto.Schema), do: Mix.raise("mix phx.gen.schema requires ecto") + end + defp maybe_update_repo_module(opts) do if is_nil(opts[:repo]) do opts @@ -201,6 +232,7 @@ defmodule Mix.Tasks.Phx.Gen.Schema do end defp put_context_app(opts, nil), do: opts + defp put_context_app(opts, string) do Keyword.put(opts, :context_app, String.to_atom(string)) end @@ -211,7 +243,11 @@ defmodule Mix.Tasks.Phx.Gen.Schema do end @doc false - def copy_new_files(%Schema{context_app: ctx_app, repo: repo, opts: opts} = schema, paths, binding) do + def copy_new_files( + %Schema{context_app: ctx_app, repo: repo, opts: opts} = schema, + paths, + binding + ) do files = files_to_be_generated(schema) Mix.Phoenix.copy_from(paths, "priv/templates/phx.gen.schema", binding, files) @@ -231,9 +267,9 @@ defmodule Mix.Tasks.Phx.Gen.Schema do migration_path = Path.join(migration_dir, "#{timestamp()}_create_#{schema.table}.exs") - Mix.Phoenix.copy_from paths, "priv/templates/phx.gen.schema", binding, [ - {:eex, "migration.exs", migration_path}, - ] + Mix.Phoenix.copy_from(paths, "priv/templates/phx.gen.schema", binding, [ + {:eex, "migration.exs", migration_path} + ]) end schema @@ -242,12 +278,12 @@ defmodule Mix.Tasks.Phx.Gen.Schema do @doc false def print_shell_instructions(%Schema{} = schema) do if schema.migration? do - Mix.shell().info """ + Mix.shell().info(""" Remember to update your repository by running migrations: $ mix ecto.migrate - """ + """) end end @@ -255,21 +291,26 @@ defmodule Mix.Tasks.Phx.Gen.Schema do def validate_args!([schema, plural | _] = args, help) do cond do not Schema.valid?(schema) -> - help.raise_with_help "Expected the schema argument, #{inspect schema}, to be a valid module name" + help.raise_with_help("Expected the schema, #{inspect(schema)}, to be a valid module name") + String.contains?(plural, ":") or plural != Phoenix.Naming.underscore(plural) -> - help.raise_with_help "Expected the plural argument, #{inspect plural}, to be all lowercase using snake_case convention" + help.raise_with_help( + "Expected the plural argument, #{inspect(plural)}, to be all lowercase using snake_case convention" + ) + true -> args end end + def validate_args!(_, help) do - help.raise_with_help "Invalid arguments" + help.raise_with_help("Invalid arguments") end @doc false - @spec raise_with_help(String.t) :: no_return() + @spec raise_with_help(String.t()) :: no_return() def raise_with_help(msg) do - Mix.raise """ + Mix.raise(""" #{msg} mix phx.gen.schema expects both a module name and @@ -277,13 +318,14 @@ defmodule Mix.Tasks.Phx.Gen.Schema do any number of attributes: mix phx.gen.schema Blog.Post blog_posts title:string - """ + """) end defp timestamp do {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" end - defp pad(i) when i < 10, do: << ?0, ?0 + i >> + + defp pad(i) when i < 10, do: <> defp pad(i), do: to_string(i) end diff --git a/priv/templates/phx.gen.context/fixtures.ex b/priv/templates/phx.gen.context/fixtures.ex index 13e1f735c9..2e60c8c365 100644 --- a/priv/templates/phx.gen.context/fixtures.ex +++ b/priv/templates/phx.gen.context/fixtures.ex @@ -1,17 +1,16 @@ -<%= for {attr, {_function_name, function_def, _needs_impl?}} <- schema.fixture_unique_functions do %> @doc """ - Generate a unique <%= schema.singular %> <%= attr %>. +<%= for {attr_name, {_, function_def, _}} <- fixture.unique_functions do %> @doc """ + Generate a unique <%= schema.singular %> <%= attr_name %>. """ <%= function_def %> <% end %> @doc """ Generate a <%= schema.singular %>. """ def <%= schema.singular %>_fixture(attrs \\ %{}) do - {:ok, <%= schema.singular %>} = +<%= schema.sample_values.references_assigns |> Mix.Phoenix.indent_text(spaces: 4, bottom: 2) %> {:ok, <%= schema.singular %>} = attrs - |> Enum.into(%{ -<%= schema.fixture_params |> Enum.map(fn {key, code} -> " #{key}: #{code}" end) |> Enum.join(",\n") %> + |> Enum.into(%{<%= fixture.attrs %> }) - |> <%= inspect context.module %>.create_<%= schema.singular %>() + |> <%= inspect(context.module) %>.create_<%= schema.singular %>() <%= schema.singular %> end diff --git a/priv/templates/phx.gen.context/fixtures_module.ex b/priv/templates/phx.gen.context/fixtures_module.ex index 111acbfee9..a3e34eb32f 100644 --- a/priv/templates/phx.gen.context/fixtures_module.ex +++ b/priv/templates/phx.gen.context/fixtures_module.ex @@ -1,6 +1,6 @@ -defmodule <%= inspect context.module %>Fixtures do +defmodule <%= inspect(context.module) %>Fixtures do @moduledoc """ This module defines test helpers for creating - entities via the `<%= inspect context.module %>` context. + entities via the `<%= inspect(context.module) %>` context. """ end diff --git a/priv/templates/phx.gen.context/test_cases.exs b/priv/templates/phx.gen.context/test_cases.exs index a6f9cfff9b..9da47ced2e 100644 --- a/priv/templates/phx.gen.context/test_cases.exs +++ b/priv/templates/phx.gen.context/test_cases.exs @@ -2,25 +2,27 @@ describe "<%= schema.plural %>" do alias <%= inspect schema.module %> - import <%= inspect context.module %>Fixtures + import <%= inspect(context.module) %>Fixtures - @invalid_attrs <%= Mix.Phoenix.to_text for {key, _} <- schema.params.create, into: %{}, do: {key, nil} %> + @invalid_attrs %{<%= schema.sample_values.invalid %>} test "list_<%= schema.plural %>/0 returns all <%= schema.plural %>" do <%= schema.singular %> = <%= schema.singular %>_fixture() +<%= virtual_clearance %> assert <%= inspect context.alias %>.list_<%= schema.plural %>() == [<%= schema.singular %>] end test "get_<%= schema.singular %>!/1 returns the <%= schema.singular %> with given id" do <%= schema.singular %> = <%= schema.singular %>_fixture() +<%= virtual_clearance %> assert <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.<%= schema.opts[:primary_key] || :id %>) == <%= schema.singular %> end test "create_<%= schema.singular %>/1 with valid data creates a <%= schema.singular %>" do - valid_attrs = <%= Mix.Phoenix.to_text schema.params.create %> +<%= Mix.Phoenix.TestData.action_attrs_with_references(schema, :create) %> - assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.create_<%= schema.singular %>(valid_attrs)<%= for {field, value} <- schema.params.create do %> - assert <%= schema.singular %>.<%= field %> == <%= Mix.Phoenix.Schema.value(schema, field, value) %><% end %> + assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.create_<%= schema.singular %>(create_attrs) +<%= Mix.Phoenix.TestData.context_values_assertions(schema, :create) %> end test "create_<%= schema.singular %>/1 with invalid data returns error changeset" do @@ -29,15 +31,17 @@ test "update_<%= schema.singular %>/2 with valid data updates the <%= schema.singular %>" do <%= schema.singular %> = <%= schema.singular %>_fixture() - update_attrs = <%= Mix.Phoenix.to_text schema.params.update%> - assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.update_<%= schema.singular %>(<%= schema.singular %>, update_attrs)<%= for {field, value} <- schema.params.update do %> - assert <%= schema.singular %>.<%= field %> == <%= Mix.Phoenix.Schema.value(schema, field, value) %><% end %> +<%= Mix.Phoenix.TestData.action_attrs_with_references(schema, :update) %> + + assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.update_<%= schema.singular %>(<%= schema.singular %>, update_attrs) +<%= Mix.Phoenix.TestData.context_values_assertions(schema, :update) %> end test "update_<%= schema.singular %>/2 with invalid data returns error changeset" do <%= schema.singular %> = <%= schema.singular %>_fixture() assert {:error, %Ecto.Changeset{}} = <%= inspect context.alias %>.update_<%= schema.singular %>(<%= schema.singular %>, @invalid_attrs) +<%= virtual_clearance %> assert <%= schema.singular %> == <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.<%= schema.opts[:primary_key] || :id %>) end diff --git a/priv/templates/phx.gen.embedded/embedded_schema.ex b/priv/templates/phx.gen.embedded/embedded_schema.ex index 81320e36ac..8c9598bf99 100644 --- a/priv/templates/phx.gen.embedded/embedded_schema.ex +++ b/priv/templates/phx.gen.embedded/embedded_schema.ex @@ -1,17 +1,14 @@ -defmodule <%= inspect schema.module %> do +defmodule <%= inspect(schema.module) %> do use Ecto.Schema import Ecto.Changeset - alias <%= inspect schema.module %> + alias <%= inspect(schema.module) %> - embedded_schema do <%= if !Enum.empty?(schema.types) do %> -<%= Mix.Phoenix.Schema.format_fields_for_schema(schema) %><% end %> -<%= for {_, k, _, _} <- schema.assocs do %> field <%= inspect k %>, <%= if schema.binary_id do %>:binary_id<% else %>:id<% end %> -<% end %> end + embedded_schema do<%= Mix.Phoenix.Schema.fields_and_associations(schema) %> end @doc false - def changeset(%<%= inspect schema.alias %>{} = <%= schema.singular %>, attrs) do + def changeset(%<%= inspect(schema.alias) %>{} = <%= schema.singular %>, attrs) do <%= schema.singular %> - |> cast(attrs, [<%= Enum.map_join(schema.attrs, ", ", &inspect(elem(&1, 0))) %>]) - |> validate_required([<%= Enum.map_join(schema.attrs, ", ", &inspect(elem(&1, 0))) %>]) + |> cast(attrs, [<%= Mix.Phoenix.Schema.cast_fields(schema) %>]) + |> validate_required([<%= Mix.Phoenix.Schema.required_fields(schema) %>])<%= Mix.Phoenix.Schema.length_validations(schema) %> end end diff --git a/priv/templates/phx.gen.html/controller_test.exs b/priv/templates/phx.gen.html/controller_test.exs index 7132344210..ec8f91e8d3 100644 --- a/priv/templates/phx.gen.html/controller_test.exs +++ b/priv/templates/phx.gen.html/controller_test.exs @@ -1,11 +1,9 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ControllerTest do use <%= inspect context.web_module %>.ConnCase - import <%= inspect context.module %>Fixtures + import <%= inspect(context.module) %>Fixtures - @create_attrs <%= Mix.Phoenix.to_text schema.params.create %> - @update_attrs <%= Mix.Phoenix.to_text schema.params.update %> - @invalid_attrs <%= Mix.Phoenix.to_text (for {key, _} <- schema.params.create, into: %{}, do: {key, nil}) %> + @invalid_attrs %{<%= schema.sample_values.invalid %>} describe "index" do test "lists all <%= schema.plural %>", %{conn: conn} do @@ -23,7 +21,9 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web describe "create <%= schema.singular %>" do test "redirects to show when data is valid", %{conn: conn} do - conn = post(conn, ~p"<%= schema.route_prefix %>", <%= schema.singular %>: @create_attrs) +<%= Mix.Phoenix.TestData.action_attrs_with_references(schema, :create) %> + + conn = post(conn, ~p"<%= schema.route_prefix %>", <%= schema.singular %>: create_attrs) assert %{id: id} = redirected_params(conn) assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/#{id}" @@ -51,12 +51,13 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web setup [:create_<%= schema.singular %>] test "redirects when data is valid", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = put(conn, ~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}", <%= schema.singular %>: @update_attrs) +<%= Mix.Phoenix.TestData.action_attrs_with_references(schema, :update) %> + + conn = put(conn, ~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}", <%= schema.singular %>: update_attrs) assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}" - conn = get(conn, ~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}")<%= if schema.string_attr do %> - assert html_response(conn, 200) =~ <%= inspect Mix.Phoenix.Schema.default_param(schema, :update) %><% else %> - assert html_response(conn, 200)<% end %> + conn = get(conn, ~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}") + assert html_response(conn, 200)<%= if html_assertion_field do %> =~ <%= html_assertion_field.update_value %><% end %> end test "renders errors when data is invalid", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do diff --git a/priv/templates/phx.gen.html/html.ex b/priv/templates/phx.gen.html/html.ex index 1bd05b1e70..146dfe251f 100644 --- a/priv/templates/phx.gen.html/html.ex +++ b/priv/templates/phx.gen.html/html.ex @@ -12,5 +12,5 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web attr :changeset, Ecto.Changeset, required: true attr :action, :string, required: true - def <%= schema.singular %>_form(assigns) + def <%= schema.singular %>_form(assigns)<%= Mix.Phoenix.Web.maybe_def_array_values(schema, :def) %> end diff --git a/priv/templates/phx.gen.html/index.html.heex b/priv/templates/phx.gen.html/index.html.heex index c1f89108af..e225886511 100644 --- a/priv/templates/phx.gen.html/index.html.heex +++ b/priv/templates/phx.gen.html/index.html.heex @@ -9,8 +9,11 @@ -<.table id="<%= schema.plural %>" rows={@<%= schema.collection %>} row_click={&JS.navigate(~p"<%= schema.route_prefix %>/#{&1}")}><%= for {k, _} <- schema.attrs do %> - <:col :let={<%= schema.singular %>} label="<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>"><%%= <%= schema.singular %>.<%= k %> %><% end %> +<.table + id="<%= schema.plural %>" + rows={@<%= schema.collection %>} + row_click={&JS.navigate(~p"<%= schema.route_prefix %>/#{&1}")} +><%= Mix.Phoenix.Web.table_columns(schema) %> <:action :let={<%= schema.singular %>}>
<.link navigate={~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}"}>Show diff --git a/priv/templates/phx.gen.html/resource_form.html.heex b/priv/templates/phx.gen.html/resource_form.html.heex index 5d8e0cbaea..dba8ef5d64 100644 --- a/priv/templates/phx.gen.html/resource_form.html.heex +++ b/priv/templates/phx.gen.html/resource_form.html.heex @@ -2,7 +2,7 @@ <.error :if={@changeset.action}> Oops, something went wrong! Please check the errors below. -<%= Mix.Tasks.Phx.Gen.Html.indent_inputs(inputs, 2) %> +<%= Mix.Phoenix.Web.form_inputs(schema, "f") |> Mix.Phoenix.indent_text(spaces: 2) %> <:actions> <.button>Save <%= schema.human_singular %> diff --git a/priv/templates/phx.gen.html/show.html.heex b/priv/templates/phx.gen.html/show.html.heex index 9b46db5678..a501d33d6b 100644 --- a/priv/templates/phx.gen.html/show.html.heex +++ b/priv/templates/phx.gen.html/show.html.heex @@ -10,8 +10,7 @@ -<.list><%= for {k, _} <- schema.attrs do %> - <:item title="<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>"><%%= @<%= schema.singular %>.<%= k %> %><% end %> +<.list><%= Mix.Phoenix.Web.list_items(schema) |> Mix.Phoenix.indent_text(spaces: 2, top: 1) %> <.back navigate={~p"<%= schema.route_prefix %>"}>Back to <%= schema.plural %> diff --git a/priv/templates/phx.gen.json/controller_test.exs b/priv/templates/phx.gen.json/controller_test.exs index d6f3e4a3f2..6f7a0bbbc6 100644 --- a/priv/templates/phx.gen.json/controller_test.exs +++ b/priv/templates/phx.gen.json/controller_test.exs @@ -1,17 +1,11 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ControllerTest do use <%= inspect context.web_module %>.ConnCase - import <%= inspect context.module %>Fixtures + import <%= inspect(context.module) %>Fixtures alias <%= inspect schema.module %> - @create_attrs %{ -<%= schema.params.create |> Enum.map(fn {key, val} -> " #{key}: #{inspect(val)}" end) |> Enum.join(",\n") %> - } - @update_attrs %{ -<%= schema.params.update |> Enum.map(fn {key, val} -> " #{key}: #{inspect(val)}" end) |> Enum.join(",\n") %> - } - @invalid_attrs <%= Mix.Phoenix.to_text for {key, _} <- schema.params.create, into: %{}, do: {key, nil} %> + @invalid_attrs %{<%= schema.sample_values.invalid %>} setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} @@ -26,15 +20,16 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web describe "create <%= schema.singular %>" do test "renders <%= schema.singular %> when data is valid", %{conn: conn} do - conn = post(conn, ~p"<%= schema.api_route_prefix %>", <%= schema.singular %>: @create_attrs) +<%= Mix.Phoenix.TestData.action_attrs_with_references(schema, :create) %> + + conn = post(conn, ~p"<%= schema.api_route_prefix %>", <%= schema.singular %>: create_attrs) assert %{"id" => id} = json_response(conn, 201)["data"] conn = get(conn, ~p"<%= schema.api_route_prefix %>/#{id}") assert %{ - "id" => ^id<%= for {key, val} <- schema.params.create |> Phoenix.json_library().encode!() |> Phoenix.json_library().decode!() do %>, - "<%= key %>" => <%= inspect(val) %><% end %> - } = json_response(conn, 200)["data"] +<%= Mix.Phoenix.TestData.json_values_assertions(schema, :create) %> + } = json_response(conn, 200)["data"]<%= Mix.Phoenix.TestData.json_references_values_assertions(schema) %> end test "renders errors when data is invalid", %{conn: conn} do @@ -47,15 +42,16 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web setup [:create_<%= schema.singular %>] test "renders <%= schema.singular %> when data is valid", %{conn: conn, <%= schema.singular %>: %<%= inspect schema.alias %>{id: id} = <%= schema.singular %>} do - conn = put(conn, ~p"<%= schema.api_route_prefix %>/#{<%= schema.singular %>}", <%= schema.singular %>: @update_attrs) +<%= Mix.Phoenix.TestData.action_attrs_with_references(schema, :update) %> + + conn = put(conn, ~p"<%= schema.api_route_prefix %>/#{<%= schema.singular %>}", <%= schema.singular %>: update_attrs) assert %{"id" => ^id} = json_response(conn, 200)["data"] conn = get(conn, ~p"<%= schema.api_route_prefix %>/#{id}") assert %{ - "id" => ^id<%= for {key, val} <- schema.params.update |> Phoenix.json_library().encode!() |> Phoenix.json_library().decode!() do %>, - "<%= key %>" => <%= inspect(val) %><% end %> - } = json_response(conn, 200)["data"] +<%= Mix.Phoenix.TestData.json_values_assertions(schema, :update) %> + } = json_response(conn, 200)["data"]<%= Mix.Phoenix.TestData.json_references_values_assertions(schema) %> end test "renders errors when data is invalid", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do diff --git a/priv/templates/phx.gen.json/json.ex b/priv/templates/phx.gen.json/json.ex index 5cff79e99e..4a5af9963a 100644 --- a/priv/templates/phx.gen.json/json.ex +++ b/priv/templates/phx.gen.json/json.ex @@ -17,7 +17,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web defp data(%<%= inspect schema.alias %>{} = <%= schema.singular %>) do %{ -<%= [{:id, :id} | schema.attrs] |> Enum.map(fn {k, _} -> " #{k}: #{schema.singular}.#{k}" end) |> Enum.join(",\n") %> +<%= Mix.Tasks.Phx.Gen.Json.data_with_id(schema) %> } end end diff --git a/priv/templates/phx.gen.live/form.ex b/priv/templates/phx.gen.live/form.ex index 25627a4e3c..e2b2676c27 100644 --- a/priv/templates/phx.gen.live/form.ex +++ b/priv/templates/phx.gen.live/form.ex @@ -13,7 +13,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web <.simple_form for={@form} id="<%= schema.singular %>-form" phx-change="validate" phx-submit="save"> -<%= Mix.Tasks.Phx.Gen.Html.indent_inputs(inputs, 6) %> +<%= Mix.Phoenix.Web.form_inputs(schema, "@form") |> Mix.Phoenix.indent_text(spaces: 6) %> <:actions> <.button phx-disable-with="Saving...">Save <%= schema.human_singular %> diff --git a/priv/templates/phx.gen.live/index.ex b/priv/templates/phx.gen.live/index.ex index 36f2e11cc2..9537cfafb3 100644 --- a/priv/templates/phx.gen.live/index.ex +++ b/priv/templates/phx.gen.live/index.ex @@ -21,8 +21,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web id="<%= schema.plural %>" rows={@streams.<%= schema.collection %>} row_click={fn {_id, <%= schema.singular %>} -> JS.navigate(~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}") end} - ><%= for {k, _} <- schema.attrs do %> - <:col :let={{_id, <%= schema.singular %>}} label="<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>"><%%= <%= schema.singular %>.<%= k %> %><% end %> + ><%= Mix.Phoenix.Web.live_table_columns(schema) %> <:action :let={{_id, <%= schema.singular %>}}>
<.link navigate={~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}"}>Show @@ -56,5 +55,5 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web {:ok, _} = <%= inspect context.alias %>.delete_<%= schema.singular %>(<%= schema.singular %>) {:noreply, stream_delete(socket, :<%= schema.collection %>, <%= schema.singular %>)} - end + end<%= Mix.Phoenix.Web.maybe_def_array_values(schema) %> end diff --git a/priv/templates/phx.gen.live/live_test.exs b/priv/templates/phx.gen.live/live_test.exs index dd90d352dc..eb82ade74c 100644 --- a/priv/templates/phx.gen.live/live_test.exs +++ b/priv/templates/phx.gen.live/live_test.exs @@ -2,11 +2,10 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web use <%= inspect context.web_module %>.ConnCase import Phoenix.LiveViewTest - import <%= inspect context.module %>Fixtures + import <%= inspect(context.module) %>Fixtures - @create_attrs <%= Mix.Phoenix.to_text for {key, value} <- schema.params.create, into: %{}, do: {key, Mix.Phoenix.Schema.live_form_value(value)} %> - @update_attrs <%= Mix.Phoenix.to_text for {key, value} <- schema.params.update, into: %{}, do: {key, Mix.Phoenix.Schema.live_form_value(value)} %> - @invalid_attrs <%= Mix.Phoenix.to_text for {key, value} <- schema.params.create, into: %{}, do: {key, value |> Mix.Phoenix.Schema.live_form_value() |> Mix.Phoenix.Schema.invalid_form_value()} %> + @invalid_attrs %{<%= Mix.Phoenix.TestData.live_invalid_attrs(schema) %> + } defp create_<%= schema.singular %>(_) do <%= schema.singular %> = <%= schema.singular %>_fixture() @@ -16,11 +15,11 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web describe "Index" do setup [:create_<%= schema.singular %>] - test "lists all <%= schema.plural %>", <%= if schema.string_attr do %>%{conn: conn, <%= schema.singular %>: <%= schema.singular %>}<% else %>%{conn: conn}<% end %> do + test "lists all <%= schema.plural %>", %{conn: conn<%= if html_assertion_field do %>, <%= schema.singular %>: <%= schema.singular %><% end %>} do {:ok, _index_live, html} = live(conn, ~p"<%= schema.route_prefix %>") - assert html =~ "Listing <%= schema.human_plural %>"<%= if schema.string_attr do %> - assert html =~ <%= schema.singular %>.<%= schema.string_attr %><% end %> + assert html =~ "Listing <%= schema.human_plural %>"<%= if html_assertion_field do %> + assert html =~ <%= schema.singular %>.<%= html_assertion_field.name %><% end %> end test "saves new <%= schema.singular %>", %{conn: conn} do @@ -36,17 +35,19 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web assert form_live |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @invalid_attrs) - |> render_change() =~ "<%= Mix.Phoenix.Schema.failed_render_change_message(schema) %>" + |> render_change() =~ "<%= Mix.Phoenix.TestData.live_required_attr_message() %>" + +<%= Mix.Phoenix.TestData.live_action_attrs_with_references(schema, :create) %> assert {:ok, index_live, _html} = form_live - |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @create_attrs) + |> form("#<%= schema.singular %>-form", <%= schema.singular %>: create_attrs) |> render_submit() |> follow_redirect(conn, ~p"<%= schema.route_prefix %>") html = render(index_live) - assert html =~ "<%= schema.human_singular %> created successfully"<%= if schema.string_attr do %> - assert html =~ "some <%= schema.string_attr %>"<% end %> + assert html =~ "<%= schema.human_singular %> created successfully"<%= if html_assertion_field do %> + assert html =~ <%= html_assertion_field.create_value %><% end %> end test "updates <%= schema.singular %> in listing", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do @@ -62,17 +63,19 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web assert form_live |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @invalid_attrs) - |> render_change() =~ "<%= Mix.Phoenix.Schema.failed_render_change_message(schema) %>" + |> render_change() =~ "<%= Mix.Phoenix.TestData.live_required_attr_message() %>" + +<%= Mix.Phoenix.TestData.live_action_attrs_with_references(schema, :update) %> assert {:ok, index_live, _html} = form_live - |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @update_attrs) + |> form("#<%= schema.singular %>-form", <%= schema.singular %>: update_attrs) |> render_submit() |> follow_redirect(conn, ~p"<%= schema.route_prefix %>") html = render(index_live) - assert html =~ "<%= schema.human_singular %> updated successfully"<%= if schema.string_attr do %> - assert html =~ "some updated <%= schema.string_attr %>"<% end %> + assert html =~ "<%= schema.human_singular %> updated successfully"<%= if html_assertion_field do %> + assert html =~ <%= html_assertion_field.update_value %><% end %> end test "deletes <%= schema.singular %> in listing", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do @@ -89,8 +92,8 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web test "displays <%= schema.singular %>", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do {:ok, _show_live, html} = live(conn, ~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}") - assert html =~ "Show <%= schema.human_singular %>"<%= if schema.string_attr do %> - assert html =~ <%= schema.singular %>.<%= schema.string_attr %><% end %> + assert html =~ "Show <%= schema.human_singular %>"<%= if html_assertion_field do %> + assert html =~ <%= schema.singular %>.<%= html_assertion_field.name %><% end %> end test "updates <%= schema.singular %> and returns to show", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do @@ -106,17 +109,19 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web assert form_live |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @invalid_attrs) - |> render_change() =~ "<%= Mix.Phoenix.Schema.failed_render_change_message(schema) %>" + |> render_change() =~ "<%= Mix.Phoenix.TestData.live_required_attr_message() %>" + +<%= Mix.Phoenix.TestData.live_action_attrs_with_references(schema, :update) %> assert {:ok, show_live, _html} = form_live - |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @update_attrs) + |> form("#<%= schema.singular %>-form", <%= schema.singular %>: update_attrs) |> render_submit() |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}") html = render(show_live) - assert html =~ "<%= schema.human_singular %> updated successfully"<%= if schema.string_attr do %> - assert html =~ "some updated <%= schema.string_attr %>"<% end %> + assert html =~ "<%= schema.human_singular %> updated successfully"<%= if html_assertion_field do %> + assert html =~ <%= html_assertion_field.update_value %><% end %> end end end diff --git a/priv/templates/phx.gen.live/show.ex b/priv/templates/phx.gen.live/show.ex index 6973b730bf..abd5e211cf 100644 --- a/priv/templates/phx.gen.live/show.ex +++ b/priv/templates/phx.gen.live/show.ex @@ -18,8 +18,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web - <.list><%= for {k, _} <- schema.attrs do %> - <:item title="<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>"><%%= @<%= schema.singular %>.<%= k %> %><% end %> + <.list><%= Mix.Phoenix.Web.list_items(schema) |> Mix.Phoenix.indent_text(spaces: 6, top: 1) %> <.back navigate={~p"<%= schema.route_prefix %>"}>Back to <%= schema.plural %> @@ -37,5 +36,5 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web socket |> assign(:page_title, "Show <%= schema.human_singular %>") |> assign(:<%= schema.singular %>, <%= inspect context.alias %>.get_<%= schema.singular %>!(id))} - end + end<%= Mix.Phoenix.Web.maybe_def_array_values(schema) %> end diff --git a/priv/templates/phx.gen.schema/migration.exs b/priv/templates/phx.gen.schema/migration.exs index 7b3fcc3a18..feab7905ed 100644 --- a/priv/templates/phx.gen.schema/migration.exs +++ b/priv/templates/phx.gen.schema/migration.exs @@ -1,16 +1,10 @@ -defmodule <%= inspect schema.repo %>.Migrations.Create<%= Macro.camelize(schema.table) %> do - use <%= inspect schema.migration_module %> +defmodule <%= inspect(schema.repo) %>.Migrations.Create<%= Macro.camelize(schema.table) %> do + use <%= inspect(Mix.Phoenix.Migration.module()) %> def change do - create table(:<%= schema.table %><%= if schema.binary_id || schema.opts[:primary_key] do %>, primary_key: false<% end %><%= if schema.prefix do %>, prefix: :<%= schema.prefix %><% end %>) do -<%= if schema.binary_id do %> add :<%= schema.opts[:primary_key] || :id %>, :binary_id, primary_key: true -<% else %><%= if schema.opts[:primary_key] do %> add :<%= schema.opts[:primary_key] %>, :id, primary_key: true -<% end %><% end %><%= for {k, v} <- schema.attrs do %> add <%= inspect k %>, <%= inspect Mix.Phoenix.Schema.type_for_migration(v) %><%= schema.migration_defaults[k] %> -<% end %><%= for {_, i, _, s} <- schema.assocs do %> add <%= inspect(i) %>, references(<%= inspect(s) %>, on_delete: :nothing<%= if schema.binary_id do %>, type: :binary_id<% end %>) -<% end %> - timestamps(<%= if schema.timestamp_type != :naive_datetime, do: "type: #{inspect schema.timestamp_type}" %>) - end -<%= if Enum.any?(schema.indexes) do %><%= for index <- schema.indexes do %> - <%= index %><% end %> -<% end %> end + create table("<%= schema.table %>"<%= Mix.Phoenix.Migration.table_options(schema) %>) do +<%= Mix.Phoenix.Migration.maybe_specific_primary_key(schema) %><%= Mix.Phoenix.Migration.columns_and_references(schema) %> + timestamps(<%= Mix.Phoenix.Migration.timestamps_type(schema) %>) + end<%= Mix.Phoenix.Migration.indexes(schema) %> + end end diff --git a/priv/templates/phx.gen.schema/schema.ex b/priv/templates/phx.gen.schema/schema.ex index 082b3f43da..64f3a7b048 100644 --- a/priv/templates/phx.gen.schema/schema.ex +++ b/priv/templates/phx.gen.schema/schema.ex @@ -1,24 +1,15 @@ -defmodule <%= inspect schema.module %> do +defmodule <%= inspect(schema.module) %> do use Ecto.Schema import Ecto.Changeset -<%= if schema.prefix do %> - @schema_prefix :<%= schema.prefix %><% end %><%= if schema.opts[:primary_key] do %> - @derive {Phoenix.Param, key: :<%= schema.opts[:primary_key] %>}<% end %><%= if schema.binary_id do %> - @primary_key {:<%= schema.opts[:primary_key] || :id %>, :binary_id, autogenerate: true} - @foreign_key_type :binary_id<% else %><%= if schema.opts[:primary_key] do %> - @primary_key {:<%= schema.opts[:primary_key] %>, :id, autogenerate: true}<% end %><% end %> - schema <%= inspect schema.table %> do -<%= Mix.Phoenix.Schema.format_fields_for_schema(schema) %> -<%= for {_, k, _, _} <- schema.assocs do %> field <%= inspect k %>, <%= if schema.binary_id do %>:binary_id<% else %>:id<% end %> -<% end %> - timestamps(<%= if schema.timestamp_type != :naive_datetime, do: "type: #{inspect schema.timestamp_type}" %>) +<%= Mix.Phoenix.Schema.module_attributes(schema) %> + schema "<%= schema.table %>" do<%= Mix.Phoenix.Schema.fields_and_associations(schema) %> + timestamps(<%= Mix.Phoenix.Schema.timestamps_type(schema) %>) end @doc false def changeset(<%= schema.singular %>, attrs) do <%= schema.singular %> - |> cast(attrs, [<%= Enum.map_join(schema.attrs, ", ", &inspect(elem(&1, 0))) %>]) - |> validate_required([<%= Enum.map_join(Mix.Phoenix.Schema.required_fields(schema), ", ", &inspect(elem(&1, 0))) %>]) -<%= for k <- schema.uniques do %> |> unique_constraint(<%= inspect k %>) -<% end %> end + |> cast(attrs, [<%= Mix.Phoenix.Schema.cast_fields(schema) %>]) + |> validate_required([<%= Mix.Phoenix.Schema.required_fields(schema) %>])<%= Mix.Phoenix.Schema.changeset_constraints(schema) %> + end end diff --git a/test/mix/phoenix/attribute_test.exs b/test/mix/phoenix/attribute_test.exs new file mode 100644 index 0000000000..cd3fe58796 --- /dev/null +++ b/test/mix/phoenix/attribute_test.exs @@ -0,0 +1,883 @@ +defmodule Mix.Phoenix.AttributeTest do + use ExUnit.Case, async: true + + alias Mix.Phoenix.Attribute + + describe "parse/2" do + def parse_cli_attrs(cli_attrs), + do: Attribute.parse(cli_attrs, {TestApp.Blog.Comment, TestApp}) + + test "parses CLI attribute string into %Attribute{} struct, " <> + "performs attribute's type and options validation, " <> + "prefills some mandatory and default data to options map" do + cli_attrs = [ + "points:integer:unique", + "points:integer:default,0", + "current_guess:integer:unique:virtual", + "sum:float", + "sum:float:default,0.0", + "price:decimal", + "price:decimal:precision,5:scale,2:unique", + "price:decimal:precision,5", + "price:decimal:default,0.0", + "agreed:boolean", + "the_cake_is_a_lie:boolean:default,true", + "title", + "title:string", + "title:string:unique", + "title:string:index", + "title:string:required", + "title:string:*:size,40", + "card_number:string:redact", + "name:text", + "data:binary", + "token:uuid", + "date_of_birth:date", + "happy_hour:time", + "happy_hour:time_usec", + "joined:naive_datetime", + "joined:naive_datetime_usec", + "joined:utc_datetime", + "joined:utc_datetime_usec", + "meta:map", + "status:enum:[published,unpublished]", + "status:enum:[[published,1],[unpublished,2]]", + "post_id:references:table,posts:column,id:type,id", + "author_id:references:table,users:column,id:type,binary_id:Accounts.Admin.User:on_delete,delete_all", + "booking_id:references:table,bookings:column,id:type,id:assoc,reservation:unique", + "book_id:references:table,books:type,string:column,isbn:on_delete,nilify[book_id,book_name]", + "data:any:virtual", + "joined:datetime", + "tags:array", + "tags:[array,string]", + "tags:[array,integer]", + "tags:[array,enum]:[published,unpublished]", + "tags:[array,enum]:[[published,1],[unpublished,2]]" + ] + + assert parse_cli_attrs(cli_attrs) == [ + %Attribute{name: :points, options: %{unique: true}, type: :integer}, + %Attribute{name: :points, options: %{default: 0}, type: :integer}, + %Attribute{ + name: :current_guess, + options: %{virtual: true, unique: true}, + type: :integer + }, + %Attribute{name: :sum, options: %{}, type: :float}, + %Attribute{name: :sum, options: %{default: 0.0}, type: :float}, + %Attribute{name: :price, options: %{}, type: :decimal}, + %Attribute{ + name: :price, + options: %{precision: 5, scale: 2, unique: true}, + type: :decimal + }, + %Attribute{name: :price, type: :decimal, options: %{precision: 5}}, + %Attribute{name: :price, type: :decimal, options: %{default: "0.0"}}, + %Attribute{ + name: :agreed, + type: :boolean, + options: %{default: false, required: true} + }, + %Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true} + }, + %Attribute{name: :title, type: :string, options: %{}}, + %Attribute{name: :title, type: :string, options: %{}}, + %Attribute{name: :title, type: :string, options: %{unique: true}}, + %Attribute{name: :title, type: :string, options: %{index: true}}, + %Attribute{name: :title, type: :string, options: %{required: true}}, + %Attribute{name: :title, type: :string, options: %{required: true, size: 40}}, + %Attribute{name: :card_number, type: :string, options: %{redact: true}}, + %Attribute{name: :name, options: %{}, type: :text}, + %Attribute{name: :data, type: :binary, options: %{}}, + %Attribute{name: :token, type: :uuid, options: %{}}, + %Attribute{name: :date_of_birth, options: %{}, type: :date}, + %Attribute{name: :happy_hour, options: %{}, type: :time}, + %Attribute{name: :happy_hour, type: :time_usec, options: %{}}, + %Attribute{name: :joined, options: %{}, type: :naive_datetime}, + %Attribute{name: :joined, options: %{}, type: :naive_datetime_usec}, + %Attribute{name: :joined, type: :utc_datetime, options: %{}}, + %Attribute{name: :joined, type: :utc_datetime_usec, options: %{}}, + %Attribute{name: :meta, type: :map, options: %{}}, + %Attribute{ + name: :status, + type: :enum, + options: %{values: [:published, :unpublished]} + }, + %Attribute{ + name: :status, + type: :enum, + options: %{values: [published: 1, unpublished: 2]} + }, + %Attribute{ + name: :post_id, + type: :references, + options: %{ + index: true, + association_name: :post, + referenced_column: :id, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, + %Attribute{ + name: :author_id, + type: :references, + options: %{ + index: true, + association_name: :author, + referenced_column: :id, + referenced_type: :binary_id, + referenced_table: "users", + on_delete: :delete_all, + association_schema: TestApp.Accounts.Admin.User + } + }, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_column: :id, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + }, + %Attribute{ + name: :book_id, + type: :references, + options: %{ + index: true, + association_name: :book, + referenced_column: :isbn, + referenced_type: :string, + referenced_table: "books", + on_delete: {:nilify, [:book_id, :book_name]}, + association_schema: TestApp.Blog.Book + } + }, + %Attribute{name: :data, type: :any, options: %{virtual: true}}, + %Attribute{name: :joined, type: :naive_datetime, options: %{}}, + %Attribute{name: :tags, type: {:array, :string}, options: %{}}, + %Attribute{name: :tags, type: {:array, :string}, options: %{}}, + %Attribute{name: :tags, type: {:array, :integer}, options: %{}}, + %Attribute{ + name: :tags, + type: {:array, :enum}, + options: %{values: [:published, :unpublished]} + }, + %Attribute{ + name: :tags, + type: {:array, :enum}, + options: %{values: [published: 1, unpublished: 2]} + } + ] + end + + test "raises with an unknown type, providing list of supported types" do + assert_raise( + Mix.Error, + ~r"Unknown type `other` is given in CLI attribute `some:other`", + fn -> parse_cli_attrs(["some:other"]) end + ) + + assert_raise( + Mix.Error, + ~r"Supported attribute types", + fn -> parse_cli_attrs(["some:other"]) end + ) + end + + test "raises with an unknown option, providing list of supported options for the type" do + assert_raise( + Mix.Error, + ~r"Unknown option `other` is given in CLI attribute `title:string:other`", + fn -> parse_cli_attrs(["title:string:other"]) end + ) + + assert_raise( + Mix.Error, + ~r"`string` supports following options", + fn -> parse_cli_attrs(["title:string:other"]) end + ) + end + + test "raises with a type specific issue, providing list of supported options for the type" do + assert_raise( + Mix.Error, + ~r"CLI attribute `data:any` has issue related to its type `any`", + fn -> parse_cli_attrs(["data:any"]) end + ) + + assert_raise( + Mix.Error, + ~r"CLI attribute `city:string:size,0` has issue related to its type `string`", + fn -> parse_cli_attrs(["city:string:size,0"]) end + ) + + assert_raise( + Mix.Error, + ~r"CLI attribute `price:decimal:scale,1` has issue related to its type `decimal`", + fn -> parse_cli_attrs(["price:decimal:scale,1"]) end + ) + + assert_raise( + Mix.Error, + ~r"CLI attribute `price:decimal:precision,10:scale,10` has issue related to its type `decimal`", + fn -> parse_cli_attrs(["price:decimal:precision,10:scale,10"]) end + ) + + assert_raise( + Mix.Error, + ~r"CLI attribute `status:enum` has issue related to its type `enum`", + fn -> parse_cli_attrs(["status:enum"]) end + ) + + assert_raise( + Mix.Error, + ~r"CLI attribute `status:\[array,enum\]` has issue related to its type `enum`", + fn -> parse_cli_attrs(["status:[array,enum]"]) end + ) + + assert_raise( + Mix.Error, + ~r"`enum` supports following options", + fn -> parse_cli_attrs(["status:[array,enum]"]) end + ) + end + + test "notifies about first attribute will be marked as required " <> + "if none of the given attributes are set to be required" do + send(self(), {:mix_shell_input, :yes?, true}) + parse_cli_attrs(["body:text:index", "number_of_words:integer"]) + + assert_received {:mix_shell, :info, + ["At least one attribute has to be specified as required." <> notice]} + + assert notice =~ "Hence first attribute `body:text:index` is going to be required." + + assert_received {:mix_shell, :yes?, [question]} + assert question =~ "Proceed with chosen required attribute?" + end + end + + test "supported_types/0 lists supported attribute types with details and examples" do + assert Attribute.supported_types() == + """ + ### Supported attribute types + + * `[array,inner_type]` - Composite type, requires `inner_type`, which cannot be `references`. + Examples: + + tags:[array,string] + tags:[array,integer] + tags:[array,enum]:[published,unpublished] + tags:[array,enum]:[[published,1],[unpublished,2]] + + * `any` - Can be used only with option `virtual`. + Examples: + + data:any:virtual + + * `array` - An alias for `[array,string]`. + Examples: + + tags:array + + * `binary` + + * `boolean` - Default to `false`, which can be changed with option. + Examples: + + agreed:boolean + the_cake_is_a_lie:boolean:default,true + + * `date` + + * `datetime` - An alias for `naive_datetime`. + + * `decimal` - Have specific options `precision` and `scale`. + Examples: + + price:decimal + price:decimal:precision,5:scale,2 + price:decimal:precision,5 + price:decimal:default,0.0 + + * `enum` - Requires at least one value in options. Values are translated into list or keyword list. + Examples: + + status:enum:[published,unpublished] + status:enum:[[published,1],[unpublished,2]] + tags:[array,enum]:[published,unpublished] + tags:[array,enum]:[[published,1],[unpublished,2]] + + * `float` + Examples: + + sum:float + sum:float:default,0.0 + + * `integer` + Examples: + + points:integer + points:integer:default,0 + + * `map` + + * `naive_datetime` + + * `naive_datetime_usec` + + * `references` - All info is inferred from the attribute name unless customized via options. Referenced schema is inferred in scope of the given context. Different schema can be provided in full form `Context.Schema` in options. Referenced schema should exist in the app. + Examples: + + post_id:references + author_id:references:Accounts.User + + * `string` - Default type. Can be omitted if no options are provided. Use `size` option to limit number of characters. + Examples: + + title + title:string + title:string:size,40:unique + + * `text` + + * `time` + + * `time_usec` + + * `utc_datetime` + + * `utc_datetime_usec` + + * `uuid` + """ + end + + test "supported_options/0 lists supported attribute options with details and examples" do + assert Attribute.supported_options() == + """ + ### Supported attribute options + + * `*` - An alias for `required`. + Examples: + + title:string:* + + * `Context.Schema` - Referenced schema name for `references`. For cases when schema cannot be inferred from the attribute name, or context differs. + Examples: + + author_id:references:Accounts.User + + * `[[one,1],[two,2]]` - Keyword list of values for `enum` type. At least one value is mandatory. + Examples: + + status:enum:[[published,1],[unpublished,2]] + + * `[one,two]` - List of values for `enum` type. At least one value is mandatory. + Examples: + + status:enum:[published,unpublished] + + * `assoc,value` - Association name for `references`. For cases when it cannot be inferred from the attribute name. Default to attribute name without suffix `_id`. + Examples: + + booking_id:references:assoc,reservation + + * `column,value` - Referenced column name for `references`. For cases when it differs from default value `id`. + Examples: + + book_id:references:column,isbn + + * `default,value` - Default option for `boolean`, `integer`, `decimal`, `float` types. For `boolean` type values `true`, `1` are the same, the rest is `false`. + Examples: + + the_cake_is_a_lie:boolean:default,true + points:integer:default,0 + price:decimal:default,0.0 + sum:float:default,0.0 + + * `index` - Adds index in migration. + Examples: + + title:string:index + + * `on_delete,value` - What to do if the referenced entry is deleted. `value` may be `nothing` (default), `restrict`, `delete_all`, `nilify_all` or `nilify[columns]`. `nilify[columns]` expects a comma-separated list of columns and is not supported by all databases. + Examples: + + author_id:references:on_delete,delete_all + book_id:references:on_delete,nilify[book_id,book_name] + + * `precision,value` - Number option for `decimal` type. Minimum is 2. + Examples: + + price:decimal:precision,5 + + * `redact` - Adds option to schema field. + Examples: + + card_number:string:redact + + * `required` - Adds `null: false` constraint in migration, validation in schema, and mark in html input if no default option provided. + Examples: + + title:string:required + + * `scale,value` - Number option for `decimal` type. Minimum is 1. `scale` can be provided only with `precision` option and should be less than it. + Examples: + + price:decimal:precision,5:scale,2 + + * `size,value` - Positive number option for `string` type. + Examples: + + city:string:size,40 + + * `table,value` - Table name for `references`. For cases when referenced schema is not reachable to reflect on. + Examples: + + booking_id:references:table,reservations + + * `type,value` - Type of the column for `references`. For cases when referenced schema is not reachable to reflect on. Supported values: `id`, `binary_id`, `string`. + Examples: + + book_id:references:type,id + book_id:references:type,binary_id + isbn:references:column,isbn:type,string + + * `unique` - Adds unique index in migration and validation in schema. + Examples: + + title:string:unique + + * `virtual` - Adds option to schema field and omits migration changes. Can be used with type `any`. + Examples: + + current_guess:integer:virtual + data:any:virtual + """ + end + + test "type_specs/1 lists supported options for the given attribute's type, with details" do + assert Attribute.type_specs(:string) == + """ + + `string` - Default type. Can be omitted if no options are provided. Use `size` option to limit number of characters. + + `string` supports following options. + + * `*` - An alias for `required`. + Examples: + + title:string:* + + * `index` - Adds index in migration. + Examples: + + title:string:index + + * `redact` - Adds option to schema field. + Examples: + + card_number:string:redact + + * `required` - Adds `null: false` constraint in migration, validation in schema, and mark in html input if no default option provided. + Examples: + + title:string:required + + * `size,value` - Positive number option for `string` type. + Examples: + + city:string:size,40 + + * `unique` - Adds unique index in migration and validation in schema. + Examples: + + title:string:unique + + * `virtual` - Adds option to schema field and omits migration changes. Can be used with type `any`. + Examples: + + current_guess:integer:virtual + data:any:virtual + """ + + assert Attribute.type_specs(:enum) == + """ + + `enum` - Requires at least one value in options. Values are translated into list or keyword list. + + `enum` supports following options. + + * `*` - An alias for `required`. + Examples: + + title:string:* + + * `[[one,1],[two,2]]` - Keyword list of values for `enum` type. At least one value is mandatory. + Examples: + + status:enum:[[published,1],[unpublished,2]] + + * `[one,two]` - List of values for `enum` type. At least one value is mandatory. + Examples: + + status:enum:[published,unpublished] + + * `index` - Adds index in migration. + Examples: + + title:string:index + + * `redact` - Adds option to schema field. + Examples: + + card_number:string:redact + + * `required` - Adds `null: false` constraint in migration, validation in schema, and mark in html input if no default option provided. + Examples: + + title:string:required + + * `unique` - Adds unique index in migration and validation in schema. + Examples: + + title:string:unique + + * `virtual` - Adds option to schema field and omits migration changes. Can be used with type `any`. + Examples: + + current_guess:integer:virtual + data:any:virtual + """ + + assert Attribute.type_specs(:references) == + """ + + `references` - All info is inferred from the attribute name unless customized via options. Referenced schema is inferred in scope of the given context. Different schema can be provided in full form `Context.Schema` in options. Referenced schema should exist in the app. + + `references` supports following options. + + * `*` - An alias for `required`. + Examples: + + title:string:* + + * `Context.Schema` - Referenced schema name for `references`. For cases when schema cannot be inferred from the attribute name, or context differs. + Examples: + + author_id:references:Accounts.User + + * `assoc,value` - Association name for `references`. For cases when it cannot be inferred from the attribute name. Default to attribute name without suffix `_id`. + Examples: + + booking_id:references:assoc,reservation + + * `column,value` - Referenced column name for `references`. For cases when it differs from default value `id`. + Examples: + + book_id:references:column,isbn + + * `index` - Adds index in migration. + Examples: + + title:string:index + + * `on_delete,value` - What to do if the referenced entry is deleted. `value` may be `nothing` (default), `restrict`, `delete_all`, `nilify_all` or `nilify[columns]`. `nilify[columns]` expects a comma-separated list of columns and is not supported by all databases. + Examples: + + author_id:references:on_delete,delete_all + book_id:references:on_delete,nilify[book_id,book_name] + + * `redact` - Adds option to schema field. + Examples: + + card_number:string:redact + + * `required` - Adds `null: false` constraint in migration, validation in schema, and mark in html input if no default option provided. + Examples: + + title:string:required + + * `table,value` - Table name for `references`. For cases when referenced schema is not reachable to reflect on. + Examples: + + booking_id:references:table,reservations + + * `type,value` - Type of the column for `references`. For cases when referenced schema is not reachable to reflect on. Supported values: `id`, `binary_id`, `string`. + Examples: + + book_id:references:type,id + book_id:references:type,binary_id + isbn:references:column,isbn:type,string + + * `unique` - Adds unique index in migration and validation in schema. + Examples: + + title:string:unique + """ + end + + @parsed_attrs [ + %Attribute{name: :points, options: %{unique: true}, type: :integer}, + %Attribute{name: :price, options: %{}, type: :decimal}, + %Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true} + }, + %Attribute{name: :title, type: :string, options: %{index: true, required: true}}, + %Attribute{name: :card_number, type: :string, options: %{redact: true}}, + %Attribute{name: :name, options: %{}, type: :text}, + %Attribute{ + name: :post_id, + type: :references, + options: %{ + index: true, + association_name: :post, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + }, + %Attribute{name: :data, type: :any, options: %{virtual: true}}, + %Attribute{name: :tags, type: {:array, :integer}, options: %{}} + ] + + test "sort/1 performs general sorting for attributes - by name with references at the end" do + assert Attribute.sort(@parsed_attrs) == [ + %Attribute{name: :card_number, type: :string, options: %{redact: true}}, + %Attribute{name: :data, type: :any, options: %{virtual: true}}, + %Attribute{name: :name, type: :text, options: %{}}, + %Attribute{name: :points, type: :integer, options: %{unique: true}}, + %Attribute{name: :price, type: :decimal, options: %{}}, + %Attribute{name: :tags, type: {:array, :integer}, options: %{}}, + %Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true} + }, + %Attribute{name: :title, type: :string, options: %{index: true, required: true}}, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + }, + %Attribute{ + name: :post_id, + type: :references, + options: %{ + index: true, + association_name: :post, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + } + ] + end + + test "without_references/1 excludes references from attributes" do + assert Attribute.without_references(@parsed_attrs) == [ + %Attribute{name: :points, type: :integer, options: %{unique: true}}, + %Attribute{name: :price, type: :decimal, options: %{}}, + %Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true} + }, + %Attribute{name: :title, type: :string, options: %{index: true, required: true}}, + %Attribute{name: :card_number, type: :string, options: %{redact: true}}, + %Attribute{name: :name, type: :text, options: %{}}, + %Attribute{name: :data, type: :any, options: %{virtual: true}}, + %Attribute{name: :tags, type: {:array, :integer}, options: %{}} + ] + end + + test "references/1 returns only references from attributes" do + assert Attribute.references(@parsed_attrs) == [ + %Attribute{ + name: :post_id, + type: :references, + options: %{ + index: true, + association_name: :post, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + } + ] + end + + test "without_virtual/1 excludes virtual attributes" do + assert Attribute.without_virtual(@parsed_attrs) == [ + %Attribute{name: :points, type: :integer, options: %{unique: true}}, + %Attribute{name: :price, type: :decimal, options: %{}}, + %Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true} + }, + %Attribute{name: :title, type: :string, options: %{index: true, required: true}}, + %Attribute{name: :card_number, type: :string, options: %{redact: true}}, + %Attribute{name: :name, type: :text, options: %{}}, + %Attribute{ + name: :post_id, + type: :references, + options: %{ + index: true, + association_name: :post, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + }, + %Attribute{name: :tags, type: {:array, :integer}, options: %{}} + ] + end + + test "virtual/1 returns only virtual attributes" do + assert Attribute.virtual(@parsed_attrs) == [ + %Attribute{name: :data, type: :any, options: %{virtual: true}} + ] + end + + test "required/1 returns required attributes" do + assert Attribute.required(@parsed_attrs) == [ + %Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true} + }, + %Attribute{name: :title, type: :string, options: %{index: true, required: true}} + ] + end + + test "unique/1 returns unique attributes" do + assert Attribute.unique(@parsed_attrs) == [ + %Attribute{name: :points, type: :integer, options: %{unique: true}}, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + } + ] + end + + test "indexed/1 returns attributes which have index (unique or general)" do + assert Attribute.indexed(@parsed_attrs) == [ + %Attribute{name: :points, type: :integer, options: %{unique: true}}, + %Attribute{name: :title, type: :string, options: %{index: true, required: true}}, + %Attribute{ + name: :post_id, + type: :references, + options: %{ + index: true, + association_name: :post, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + } + ] + end + + test "adjust_decimal_value/2 returns adjusted decimal value to options precision and scale" do + assert Attribute.adjust_decimal_value("456.789", %{}) == "456.789" + assert Attribute.adjust_decimal_value("456.789", %{precision: 8}) == "456.789" + assert Attribute.adjust_decimal_value("456.789", %{precision: 6}) == "456.789" + assert Attribute.adjust_decimal_value("456.789", %{precision: 4}) == "6.789" + assert Attribute.adjust_decimal_value("456.789", %{precision: 2}) == "6.7" + assert Attribute.adjust_decimal_value("456.789", %{precision: 2, scale: 1}) == "6.7" + assert Attribute.adjust_decimal_value("456.789", %{precision: 4, scale: 2}) == "56.78" + assert Attribute.adjust_decimal_value("456.789", %{precision: 5, scale: 4}) == "6.7890" + assert Attribute.adjust_decimal_value("456.789", %{precision: 7, scale: 5}) == "56.78900" + assert Attribute.adjust_decimal_value("456.789", %{precision: 10, scale: 5}) == "456.78900" + end +end diff --git a/test/mix/phoenix/migration_test.exs b/test/mix/phoenix/migration_test.exs new file mode 100644 index 0000000000..ecdc58e16a --- /dev/null +++ b/test/mix/phoenix/migration_test.exs @@ -0,0 +1,161 @@ +defmodule Mix.Phoenix.MigrationTest do + use ExUnit.Case, async: true + + alias Mix.Phoenix.{Migration, Schema, Attribute} + + test "module/0 returns migration module based on the Mix application" do + assert Migration.module() == Ecto.Migration + Application.put_env(:ecto_sql, :migration_module, Sample.App.Migration) + assert Migration.module() == Sample.App.Migration + after + Application.delete_env(:ecto_sql, :migration_module) + end + + test "table_options/1 returns possible table options" do + assert Migration.table_options(%Schema{}) == "" + assert Migration.table_options(%Schema{prefix: "some_prefix"}) == ", prefix: :some_prefix" + assert Migration.table_options(%Schema{binary_id: true}) == ", primary_key: false" + assert Migration.table_options(%Schema{opts: [primary_key: "uuid"]}) == ", primary_key: false" + + schema = %Schema{prefix: "some_prefix", binary_id: true, opts: [primary_key: "uuid"]} + assert Migration.table_options(schema) == ", primary_key: false, prefix: :some_prefix" + end + + test "maybe_specific_primary_key/1 returns specific primary key column by options " <> + "`binary_id` or `primary_key`" do + assert Migration.maybe_specific_primary_key(%Schema{}) == nil + + assert Migration.maybe_specific_primary_key(%Schema{binary_id: true}) == + " add :id, :binary_id, primary_key: true\n" + + assert Migration.maybe_specific_primary_key(%Schema{opts: [primary_key: "uuid"]}) == + " add :uuid, :id, primary_key: true\n" + + schema = %Schema{binary_id: true, opts: [primary_key: "uuid"]} + + assert Migration.maybe_specific_primary_key(schema) == + " add :uuid, :binary_id, primary_key: true\n" + end + + @parsed_attrs [ + %Attribute{name: :points, options: %{unique: true}, type: :integer}, + %Attribute{name: :price, options: %{precision: 10, scale: 5}, type: :decimal}, + %Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true} + }, + %Attribute{name: :title, type: :string, options: %{size: 40, index: true, required: true}}, + %Attribute{name: :card_number, type: :string, options: %{size: 16, redact: true}}, + %Attribute{name: :name, options: %{}, type: :text}, + %Attribute{ + name: :post_id, + type: :references, + options: %{ + required: true, + index: true, + association_name: :post, + referenced_column: :id, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_column: :uuid, + referenced_type: :binary_id, + referenced_table: "bookings", + on_delete: :nilify_all, + association_schema: TestApp.Blog.Booking + } + }, + %Attribute{ + name: :book_id, + type: :references, + options: %{ + index: true, + association_name: :book, + referenced_column: :isbn, + referenced_type: :string, + referenced_table: "books", + on_delete: {:nilify, [:book_id, :book_name]}, + association_schema: TestApp.Blog.Book + } + }, + %Attribute{name: :data, type: :any, options: %{virtual: true, unique: true}}, + %Attribute{name: :tags1, type: {:array, :string}, options: %{required: true}}, + %Attribute{name: :tags2, type: {:array, :integer}, options: %{}}, + %Attribute{ + name: :tags3, + type: {:array, :enum}, + options: %{required: true, values: [:published, :unpublished]} + }, + %Attribute{ + name: :tags4, + type: {:array, :enum}, + options: %{values: [published: 1, unpublished: 2]} + }, + %Attribute{name: :stages1, type: :enum, options: %{values: [:published, :unpublished]}}, + %Attribute{ + name: :stages2, + type: :enum, + options: %{required: true, values: [published: 1, unpublished: 2]} + } + ] + + test "columns_and_references/1 returns formatted columns and references" do + schema = %Schema{attrs: @parsed_attrs} + + assert Migration.columns_and_references(schema) == + """ + add :card_number, :string, size: 16 + add :name, :text + add :points, :integer + add :price, :decimal, precision: 10, scale: 5 + add :stages1, :string + add :stages2, :integer, null: false + add :tags1, {:array, :string}, null: false + add :tags2, {:array, :integer} + add :tags3, {:array, :string}, null: false + add :tags4, {:array, :integer} + add :the_cake_is_a_lie, :boolean, default: true, null: false + add :title, :string, size: 40, null: false + add :book_id, references("books", column: :isbn, type: :string, on_delete: {:nilify, [:book_id, :book_name]}) + add :booking_id, references("bookings", column: :uuid, type: :binary_id, on_delete: :nilify_all) + add :post_id, references("posts", on_delete: :nothing), null: false + """ + end + + test "timestamps_type/1 returns type option for `timestamps` function" do + schema = %Schema{timestamp_type: :naive_datetime} + + assert Migration.timestamps_type(schema) == "" + + schema = %Schema{timestamp_type: :utc_datetime} + + assert Migration.timestamps_type(schema) == "type: :utc_datetime" + end + + test "indexes/1 returns formatted indexes" do + schema = %Schema{table: "comments", attrs: @parsed_attrs} + + assert Migration.indexes(schema) == + """ + + + create index("comments", [:points], unique: true) + create index("comments", [:title]) + create index("comments", [:book_id]) + create index("comments", [:booking_id], unique: true) + create index("comments", [:post_id]) + """ + |> String.trim_trailing("\n") + end +end diff --git a/test/mix/phoenix/schema_test.exs b/test/mix/phoenix/schema_test.exs new file mode 100644 index 0000000000..71488949d5 --- /dev/null +++ b/test/mix/phoenix/schema_test.exs @@ -0,0 +1,290 @@ +defmodule Mix.Phoenix.SchemaTest do + use ExUnit.Case, async: true + + alias Mix.Phoenix.{Schema, Attribute} + + test "valid?/1 validates format of schema name" do + refute Schema.valid?("name") + assert Schema.valid?("Name") + refute Schema.valid?("7Name") + assert Schema.valid?("Name7") + assert Schema.valid?("N7") + refute Schema.valid?("some.Name") + refute Schema.valid?("Some.name") + assert Schema.valid?("Some.Name") + refute Schema.valid?("Some00.7Name") + assert Schema.valid?("Some00.Name7") + refute Schema.valid?("Nested.context.with.Schema.Name") + assert Schema.valid?("Nested.Context.With.Schema.Name") + end + + test "module_attributes/1 returns module attributes" do + assert Schema.module_attributes(%Schema{}) == "" + + schema = %Schema{prefix: "some_prefix"} + + assert Schema.module_attributes(schema) == + """ + + @schema_prefix :some_prefix + """ + |> String.trim_trailing("\n") + + schema = %Schema{prefix: "some_prefix", binary_id: true} + + assert Schema.module_attributes(schema) == + """ + + @schema_prefix :some_prefix + @primary_key {:id, :binary_id, autogenerate: true} + """ + |> String.trim_trailing("\n") + + schema = %Schema{prefix: "some_prefix", opts: [primary_key: "uuid"]} + + assert Schema.module_attributes(schema) == + """ + + @schema_prefix :some_prefix + @derive {Phoenix.Param, key: :uuid} + @primary_key {:uuid, :id, autogenerate: true} + """ + |> String.trim_trailing("\n") + + schema = %Schema{binary_id: true, opts: [primary_key: "uuid"]} + + assert Schema.module_attributes(schema) == + """ + + @derive {Phoenix.Param, key: :uuid} + @primary_key {:uuid, :binary_id, autogenerate: true} + """ + |> String.trim_trailing("\n") + + schema = %Schema{prefix: "some_prefix", binary_id: true, opts: [primary_key: "uuid"]} + + assert Schema.module_attributes(schema) == + """ + + @schema_prefix :some_prefix + @derive {Phoenix.Param, key: :uuid} + @primary_key {:uuid, :binary_id, autogenerate: true} + """ + |> String.trim_trailing("\n") + end + + @parsed_attrs [ + %Attribute{name: :points, options: %{unique: true}, type: :integer}, + %Attribute{name: :price, options: %{precision: 10, scale: 5}, type: :decimal}, + %Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true} + }, + %Attribute{name: :title, type: :string, options: %{size: 40, index: true, required: true}}, + %Attribute{name: :card_number, type: :string, options: %{size: 16, redact: true}}, + %Attribute{name: :name, options: %{}, type: :text}, + %Attribute{ + name: :post_id, + type: :references, + options: %{ + required: true, + index: true, + association_name: :post, + referenced_column: :id, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_column: :uuid, + referenced_type: :binary_id, + referenced_table: "bookings", + on_delete: :nilify_all, + association_schema: TestApp.Blog.Booking + } + }, + %Attribute{ + name: :book_id, + type: :references, + options: %{ + index: true, + association_name: :book, + referenced_column: :isbn, + referenced_type: :string, + referenced_table: "books", + on_delete: {:nilify, [:book_id, :book_name]}, + association_schema: TestApp.Blog.Book + } + }, + %Attribute{name: :data, type: :any, options: %{virtual: true, unique: true}}, + %Attribute{name: :tags1, type: {:array, :string}, options: %{required: true}}, + %Attribute{name: :tags2, type: {:array, :integer}, options: %{}}, + %Attribute{ + name: :tags3, + type: {:array, :enum}, + options: %{required: true, values: [:published, :unpublished]} + }, + %Attribute{ + name: :tags4, + type: {:array, :enum}, + options: %{values: [published: 1, unpublished: 2]} + }, + %Attribute{name: :stages1, type: :enum, options: %{values: [:published, :unpublished]}}, + %Attribute{ + name: :stages2, + type: :enum, + options: %{required: true, values: [published: 1, unpublished: 2]} + } + ] + + test "fields_and_associations/1 returns formatted fields and associations" do + schema = %Schema{attrs: @parsed_attrs} + + assert Schema.fields_and_associations(schema) == + """ + + field :card_number, :string, redact: true + field :data, :any, virtual: true + field :name, :string + field :points, :integer + field :price, :decimal + field :stages1, Ecto.Enum, values: [:published, :unpublished] + field :stages2, Ecto.Enum, values: [published: 1, unpublished: 2] + field :tags1, {:array, :string} + field :tags2, {:array, :integer} + field :tags3, {:array, Ecto.Enum}, values: [:published, :unpublished] + field :tags4, {:array, Ecto.Enum}, values: [published: 1, unpublished: 2] + field :the_cake_is_a_lie, :boolean, default: true + field :title, :string + belongs_to :book, TestApp.Blog.Book, references: :isbn, type: :string + belongs_to :reservation, TestApp.Blog.Booking, foreign_key: :booking_id, references: :uuid, type: :binary_id + belongs_to :post, TestApp.Blog.Post + """ + + # `binary_id` should not influence foreign keys types. They are inferred from referenced schemas. + schema = %Schema{attrs: @parsed_attrs, binary_id: true} + + assert Schema.fields_and_associations(schema) == + """ + + field :card_number, :string, redact: true + field :data, :any, virtual: true + field :name, :string + field :points, :integer + field :price, :decimal + field :stages1, Ecto.Enum, values: [:published, :unpublished] + field :stages2, Ecto.Enum, values: [published: 1, unpublished: 2] + field :tags1, {:array, :string} + field :tags2, {:array, :integer} + field :tags3, {:array, Ecto.Enum}, values: [:published, :unpublished] + field :tags4, {:array, Ecto.Enum}, values: [published: 1, unpublished: 2] + field :the_cake_is_a_lie, :boolean, default: true + field :title, :string + belongs_to :book, TestApp.Blog.Book, references: :isbn, type: :string + belongs_to :reservation, TestApp.Blog.Booking, foreign_key: :booking_id, references: :uuid, type: :binary_id + belongs_to :post, TestApp.Blog.Post + """ + end + + test "timestamps_type/1 returns type option for `timestamps` function" do + schema = %Schema{timestamp_type: :naive_datetime} + + assert Schema.timestamps_type(schema) == "" + + schema = %Schema{timestamp_type: :utc_datetime} + + assert Schema.timestamps_type(schema) == "type: :utc_datetime" + end + + test "cast_fields/1 returns formatted fields to cast" do + schema = %Schema{attrs: @parsed_attrs} + + assert Schema.cast_fields(schema) == + ":card_number, " <> + ":data, " <> + ":name, " <> + ":points, " <> + ":price, " <> + ":stages1, " <> + ":stages2, " <> + ":tags1, " <> + ":tags2, " <> + ":tags3, " <> + ":tags4, " <> + ":the_cake_is_a_lie, " <> + ":title, " <> + ":book_id, " <> + ":booking_id, " <> + ":post_id" + end + + test "required_fields/1 returns formatted fields to require" do + schema = %Schema{attrs: @parsed_attrs} + + assert Schema.required_fields(schema) == + ":stages2, :tags1, :tags3, :the_cake_is_a_lie, :title, :post_id" + end + + test "changeset_constraints/1 returns specific changeset constraints" do + schema = %Schema{attrs: @parsed_attrs} + + assert Schema.changeset_constraints(schema) == + """ + + |> validate_length(:card_number, max: 16) + |> validate_length(:title, max: 40) + |> assoc_constraint(:book) + |> assoc_constraint(:post) + |> assoc_constraint(:reservation) + |> unique_constraint(:points) + |> unique_constraint(:booking_id) + """ + |> String.trim_trailing("\n") + end + + test "length_validations/1 returns length validations" do + schema = %Schema{attrs: @parsed_attrs} + + assert Schema.length_validations(schema) == + """ + + |> validate_length(:card_number, max: 16) + |> validate_length(:title, max: 40) + """ + |> String.trim_trailing("\n") + end + + test "assoc_constraints/1 returns association constraints" do + schema = %Schema{attrs: @parsed_attrs} + + assert Schema.assoc_constraints(schema) == + """ + + |> assoc_constraint(:book) + |> assoc_constraint(:post) + |> assoc_constraint(:reservation) + """ + |> String.trim_trailing("\n") + end + + test "unique_constraints/1 returns unique constraints" do + schema = %Schema{attrs: @parsed_attrs} + + assert Schema.unique_constraints(schema) == + """ + + |> unique_constraint(:points) + |> unique_constraint(:booking_id) + """ + |> String.trim_trailing("\n") + end +end diff --git a/test/mix/phoenix/test_data_test.exs b/test/mix/phoenix/test_data_test.exs new file mode 100644 index 0000000000..d1a0cabb78 --- /dev/null +++ b/test/mix/phoenix/test_data_test.exs @@ -0,0 +1,883 @@ +defmodule Mix.Phoenix.TestDataTest do + use ExUnit.Case, async: true + + alias Mix.Phoenix.{TestData, Schema, Attribute} + + @parsed_attrs [ + %Attribute{name: :points, options: %{unique: true}, type: :integer}, + %Attribute{name: :sum, options: %{}, type: :float}, + %Attribute{name: :price, options: %{precision: 10, scale: 5, unique: true}, type: :decimal}, + %Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true, virtual: true} + }, + %Attribute{name: :agreed, type: :boolean, options: %{default: false, required: true}}, + %Attribute{name: :title, type: :string, options: %{unique: true, required: true}}, + %Attribute{name: :title_limited, type: :string, options: %{size: 10}}, + %Attribute{name: :name, options: %{}, type: :text}, + %Attribute{name: :data, type: :binary, options: %{}}, + %Attribute{name: :token, type: :uuid, options: %{}}, + %Attribute{name: :date_of_birth, options: %{}, type: :date}, + %Attribute{name: :happy_hour, options: %{}, type: :time}, + %Attribute{name: :happy_hour, type: :time_usec, options: %{}}, + %Attribute{name: :joined, options: %{}, type: :naive_datetime}, + %Attribute{name: :joined, options: %{}, type: :naive_datetime_usec}, + %Attribute{name: :joined, type: :utc_datetime, options: %{}}, + %Attribute{name: :joined, type: :utc_datetime_usec, options: %{}}, + %Attribute{name: :meta, type: :map, options: %{virtual: true}}, + %Attribute{name: :status, type: :enum, options: %{values: [:published, :unpublished]}}, + %Attribute{name: :status, type: :enum, options: %{values: [published: 1, unpublished: 2]}}, + %Attribute{ + name: :post_id, + type: :references, + options: %{ + index: true, + association_name: :post, + referenced_column: :id, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, + %Attribute{ + name: :author_id, + type: :references, + options: %{ + index: true, + association_name: :author, + referenced_column: :id, + referenced_type: :id, + referenced_table: "users", + on_delete: :nothing, + association_schema: TestApp.Accounts.User + } + }, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_column: :id, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + }, + %Attribute{ + name: :book_id, + type: :references, + options: %{ + index: true, + association_name: :book, + referenced_column: :isbn, + referenced_type: :string, + referenced_table: "books", + on_delete: :nothing, + association_schema: TestApp.Blog.Book + } + }, + %Attribute{name: :data, type: :any, options: %{virtual: true}}, + %Attribute{name: :tags, type: {:array, :string}, options: %{}}, + %Attribute{name: :tags, type: {:array, :integer}, options: %{}}, + %Attribute{ + name: :tags, + type: {:array, :enum}, + options: %{values: [:published, :unpublished]} + }, + %Attribute{ + name: :tags, + type: {:array, :enum}, + options: %{values: [published: 1, unpublished: 2]} + } + ] + + @one_day_in_seconds 24 * 3600 + + defp date_value(:create), do: Date.add(date_value(:update), -1) + defp date_value(:update), do: Date.utc_today() + + defp utc_datetime_value(:create) do + DateTime.add( + utc_datetime_value(:update), + -@one_day_in_seconds, + :second, + Calendar.UTCOnlyTimeZoneDatabase + ) + end + + defp utc_datetime_value(:update), + do: DateTime.truncate(utc_datetime_usec_value(:update), :second) + + defp utc_datetime_usec_value(:create) do + DateTime.add( + utc_datetime_usec_value(:update), + -@one_day_in_seconds, + :second, + Calendar.UTCOnlyTimeZoneDatabase + ) + end + + defp utc_datetime_usec_value(:update), + do: %{DateTime.utc_now() | second: 0, microsecond: {0, 6}} + + defp utc_naive_datetime_value(:create), + do: NaiveDateTime.add(utc_naive_datetime_value(:update), -@one_day_in_seconds) + + defp utc_naive_datetime_value(:update), + do: NaiveDateTime.truncate(utc_naive_datetime_usec_value(:update), :second) + + defp utc_naive_datetime_usec_value(:create), + do: NaiveDateTime.add(utc_naive_datetime_usec_value(:update), -@one_day_in_seconds) + + defp utc_naive_datetime_usec_value(:update), + do: %{NaiveDateTime.utc_now() | second: 0, microsecond: {0, 6}} + + test "virtual_clearance/1 clears virtual fields logic to be used in context test file" do + schema = %Schema{singular: "comment", attrs: @parsed_attrs} + + assert TestData.virtual_clearance(schema) == + """ + + # NOTE: Virtual fields updated to defaults or nil before comparison. + comment = %{comment | data: nil, meta: nil, the_cake_is_a_lie: true} + """ + |> String.trim_trailing("\n") + end + + test "fixture/1 defaults fixture values for each type of attributes with unique functions" do + attrs = @parsed_attrs + sample_values = TestData.sample_values(attrs, TestApp.Blog.Comment) + schema = %Schema{singular: "comment", attrs: attrs, sample_values: sample_values} + + assert TestData.fixture(schema) == %{ + attrs: + """ + + agreed: false, + data: "data value", + data: "data value", + date_of_birth: #{date_value(:create) |> inspect()}, + happy_hour: ~T[14:00:00], + happy_hour: ~T[14:00:00.000000], + joined: #{utc_naive_datetime_value(:create) |> inspect()}, + joined: #{utc_naive_datetime_usec_value(:create) |> inspect()}, + joined: #{utc_datetime_value(:create) |> inspect()}, + joined: #{utc_datetime_usec_value(:create) |> inspect()}, + meta: %{}, + name: "name value", + points: unique_comment_points(), + price: unique_comment_price(), + status: :published, + status: :published, + sum: 120.5, + tags: ["tags value"], + tags: [142], + tags: [:published], + tags: [:published], + the_cake_is_a_lie: true, + title: unique_comment_title(), + title_limited: "title_limi", + token: "7488a646-e31f-11e4-aace-600308960662", + author_id: author.id, + book_id: book.isbn, + booking_id: reservation.id, + post_id: post.id + """ + |> String.trim_trailing("\n"), + unique_functions: %{ + title: + {"unique_comment_title", + """ + def unique_comment_title, do: "\#{System.unique_integer([:positive])}title value" + """, false}, + points: + {"unique_comment_points", + """ + def unique_comment_points, do: System.unique_integer([:positive]) + """, false}, + price: + {"unique_comment_price", + """ + def unique_comment_price do + raise "implement the logic to generate a unique comment price" + end + """, true} + } + } + end + + test "live_invalid_attrs/1 returns invalid attributes used in live" do + sample_values = TestData.sample_values(@parsed_attrs, TestApp.Blog.Comment) + schema = %Schema{sample_values: sample_values} + + assert TestData.live_invalid_attrs(schema) == + """ + + agreed: false, + data: nil, + data: nil, + date_of_birth: nil, + happy_hour: nil, + happy_hour: nil, + joined: nil, + joined: nil, + joined: nil, + joined: nil, + meta: nil, + name: nil, + points: nil, + price: nil, + status: nil, + status: nil, + sum: nil, + tags: [], + tags: [], + tags: [], + tags: [], + the_cake_is_a_lie: false, + title: nil, + title_limited: nil, + token: nil, + author_id: nil, + book_id: nil, + booking_id: nil, + post_id: nil + """ + |> String.trim_trailing("\n") + end + + test "live_action_attrs_with_references/2 returns attributes with references used for `action` in live" do + sample_values = TestData.sample_values(@parsed_attrs, TestApp.Blog.Comment) + schema = %Schema{sample_values: sample_values} + + assert TestData.live_action_attrs_with_references(schema, :create) == + """ + author = TestApp.AccountsFixtures.user_fixture() + book = book_fixture() + reservation = booking_fixture() + post = post_fixture() + + create_attrs = %{ + agreed: true, + data: "data value", + data: "data value", + date_of_birth: #{date_value(:create) |> Calendar.strftime("%Y-%m-%d") |> inspect()}, + happy_hour: "14:00", + happy_hour: "14:00", + joined: #{utc_naive_datetime_value(:create) |> NaiveDateTime.to_iso8601() |> inspect()}, + joined: #{utc_naive_datetime_usec_value(:create) |> NaiveDateTime.to_iso8601() |> inspect()}, + joined: #{utc_datetime_value(:create) |> DateTime.to_iso8601() |> inspect()}, + joined: #{utc_datetime_usec_value(:create) |> DateTime.to_iso8601() |> inspect()}, + meta: %{}, + name: "name value", + points: 142, + price: "22.50000", + status: :published, + status: :published, + sum: 120.5, + tags: ["tags value"], + tags: [142], + tags: [:published], + tags: [:published], + the_cake_is_a_lie: true, + title: "title value", + title_limited: "title_limi", + token: "7488a646-e31f-11e4-aace-600308960662", + author_id: author.id, + book_id: book.isbn, + booking_id: reservation.id, + post_id: post.id + } + """ + |> String.trim_trailing("\n") + + assert TestData.live_action_attrs_with_references(schema, :update) == + """ + author = TestApp.AccountsFixtures.user_fixture() + book = book_fixture() + reservation = booking_fixture() + post = post_fixture() + + update_attrs = %{ + agreed: false, + data: "updated data value", + data: "updated data value", + date_of_birth: #{date_value(:update) |> Calendar.strftime("%Y-%m-%d") |> inspect()}, + happy_hour: "15:01", + happy_hour: "15:01", + joined: #{utc_naive_datetime_value(:update) |> NaiveDateTime.to_iso8601() |> inspect()}, + joined: #{utc_naive_datetime_usec_value(:update) |> NaiveDateTime.to_iso8601() |> inspect()}, + joined: #{utc_datetime_value(:update) |> DateTime.to_iso8601() |> inspect()}, + joined: #{utc_datetime_usec_value(:update) |> DateTime.to_iso8601() |> inspect()}, + meta: %{}, + name: "updated name value", + points: 303, + price: "18.70000", + status: :unpublished, + status: :unpublished, + sum: 456.7, + tags: ["updated tags value"], + tags: [303], + tags: [:unpublished], + tags: [:unpublished], + the_cake_is_a_lie: false, + title: "updated title value", + title_limited: "updated ti", + token: "7488a646-e31f-11e4-aace-600308960668", + author_id: author.id, + book_id: book.isbn, + booking_id: reservation.id, + post_id: post.id + } + """ + |> String.trim_trailing("\n") + end + + test "action_attrs_with_references/2 returns attributes with references used for `action` in context, html, json" do + sample_values = TestData.sample_values(@parsed_attrs, TestApp.Blog.Comment) + schema = %Schema{sample_values: sample_values} + + assert TestData.action_attrs_with_references(schema, :create) == + """ + author = TestApp.AccountsFixtures.user_fixture() + book = book_fixture() + reservation = booking_fixture() + post = post_fixture() + + create_attrs = %{ + agreed: true, + data: "data value", + data: "data value", + date_of_birth: #{date_value(:create) |> inspect()}, + happy_hour: ~T[14:00:00], + happy_hour: ~T[14:00:00.000000], + joined: #{utc_naive_datetime_value(:create) |> inspect()}, + joined: #{utc_naive_datetime_usec_value(:create) |> inspect()}, + joined: #{utc_datetime_value(:create) |> inspect()}, + joined: #{utc_datetime_usec_value(:create) |> inspect()}, + meta: %{}, + name: "name value", + points: 142, + price: "22.50000", + status: :published, + status: :published, + sum: 120.5, + tags: ["tags value"], + tags: [142], + tags: [:published], + tags: [:published], + the_cake_is_a_lie: true, + title: "title value", + title_limited: "title_limi", + token: "7488a646-e31f-11e4-aace-600308960662", + author_id: author.id, + book_id: book.isbn, + booking_id: reservation.id, + post_id: post.id + } + """ + |> String.trim_trailing("\n") + + assert TestData.action_attrs_with_references(schema, :update) == + """ + author = TestApp.AccountsFixtures.user_fixture() + book = book_fixture() + reservation = booking_fixture() + post = post_fixture() + + update_attrs = %{ + agreed: false, + data: "updated data value", + data: "updated data value", + date_of_birth: #{date_value(:update) |> inspect()}, + happy_hour: ~T[15:01:01], + happy_hour: ~T[15:01:01.000000], + joined: #{utc_naive_datetime_value(:update) |> inspect()}, + joined: #{utc_naive_datetime_usec_value(:update) |> inspect()}, + joined: #{utc_datetime_value(:update) |> inspect()}, + joined: #{utc_datetime_usec_value(:update) |> inspect()}, + meta: %{}, + name: "updated name value", + points: 303, + price: "18.70000", + status: :unpublished, + status: :unpublished, + sum: 456.7, + tags: ["updated tags value"], + tags: [303], + tags: [:unpublished], + tags: [:unpublished], + the_cake_is_a_lie: false, + title: "updated title value", + title_limited: "updated ti", + token: "7488a646-e31f-11e4-aace-600308960668", + author_id: author.id, + book_id: book.isbn, + booking_id: reservation.id, + post_id: post.id + } + """ + |> String.trim_trailing("\n") + end + + defp process_json_value(value), + do: value |> Phoenix.json_library().encode!() |> Phoenix.json_library().decode!() |> inspect() + + test "json_values_assertions/2 returns values assertions used for `action` in json" do + sample_values = TestData.sample_values(@parsed_attrs, TestApp.Blog.Comment) + schema = %Schema{sample_values: sample_values} + + assert TestData.json_values_assertions(schema, :create) == + """ + "id" => ^id, + "agreed" => true, + "data" => "data value", + "data" => nil, + "date_of_birth" => #{date_value(:create) |> process_json_value()}, + "happy_hour" => "14:00:00", + "happy_hour" => "14:00:00.000000", + "joined" => #{utc_naive_datetime_value(:create) |> process_json_value()}, + "joined" => #{utc_naive_datetime_usec_value(:create) |> process_json_value()}, + "joined" => #{utc_datetime_value(:create) |> process_json_value()}, + "joined" => #{utc_datetime_usec_value(:create) |> process_json_value()}, + "meta" => nil, + "name" => "name value", + "points" => 142, + "price" => "22.50000", + "status" => "published", + "status" => "published", + "sum" => 120.5, + "tags" => ["tags value"], + "tags" => [142], + "tags" => ["published"], + "tags" => ["published"], + "the_cake_is_a_lie" => true, + "title" => "title value", + "title_limited" => "title_limi", + "token" => "7488a646-e31f-11e4-aace-600308960662", + "author_id" => json_author_id, + "book_id" => json_book_id, + "booking_id" => json_booking_id, + "post_id" => json_post_id + """ + |> String.trim_trailing("\n") + + assert TestData.json_values_assertions(schema, :update) == + """ + "id" => ^id, + "agreed" => false, + "data" => "updated data value", + "data" => nil, + "date_of_birth" => #{date_value(:update) |> process_json_value()}, + "happy_hour" => "15:01:01", + "happy_hour" => "15:01:01.000000", + "joined" => #{utc_naive_datetime_value(:update) |> process_json_value()}, + "joined" => #{utc_naive_datetime_usec_value(:update) |> process_json_value()}, + "joined" => #{utc_datetime_value(:update) |> process_json_value()}, + "joined" => #{utc_datetime_usec_value(:update) |> process_json_value()}, + "meta" => nil, + "name" => "updated name value", + "points" => 303, + "price" => "18.70000", + "status" => "unpublished", + "status" => "unpublished", + "sum" => 456.7, + "tags" => ["updated tags value"], + "tags" => [303], + "tags" => ["unpublished"], + "tags" => ["unpublished"], + "the_cake_is_a_lie" => true, + "title" => "updated title value", + "title_limited" => "updated ti", + "token" => "7488a646-e31f-11e4-aace-600308960668", + "author_id" => json_author_id, + "book_id" => json_book_id, + "booking_id" => json_booking_id, + "post_id" => json_post_id + """ + |> String.trim_trailing("\n") + end + + test "json_references_values_assertions/2 returns values assertions used for references in json" do + schema = %Schema{attrs: @parsed_attrs} + + assert TestData.json_references_values_assertions(schema) == + """ + + + assert json_post_id == post.id + assert json_author_id == author.id + assert json_booking_id == reservation.id + assert json_book_id == book.isbn + """ + |> String.trim_trailing("\n") + end + + test "html_assertion_field/2 returns data to use in html assertions, if there is a suitable field" do + sample_values = TestData.sample_values(@parsed_attrs, TestApp.Blog.Comment) + schema = %Schema{attrs: @parsed_attrs, sample_values: sample_values} + + assert TestData.html_assertion_field(schema) == %{ + name: :title, + create_value: "\"title value\"", + update_value: "\"updated title value\"" + } + end + + test "context_values_assertions/2 returns values assertions used for `action` in context" do + sample_values = TestData.sample_values(@parsed_attrs, TestApp.Blog.Comment) + schema = %Schema{singular: "comment", sample_values: sample_values} + + assert TestData.context_values_assertions(schema, :create) == + """ + assert comment.agreed == true + assert comment.data == "data value" + assert comment.data == "data value" + assert comment.date_of_birth == #{date_value(:create) |> inspect()} + assert comment.happy_hour == ~T[14:00:00] + assert comment.happy_hour == ~T[14:00:00.000000] + assert comment.joined == #{utc_naive_datetime_value(:create) |> inspect()} + assert comment.joined == #{utc_naive_datetime_usec_value(:create) |> inspect()} + assert comment.joined == #{utc_datetime_value(:create) |> inspect()} + assert comment.joined == #{utc_datetime_usec_value(:create) |> inspect()} + assert comment.meta == %{} + assert comment.name == "name value" + assert comment.points == 142 + assert comment.price == Decimal.new("22.50000") + assert comment.status == :published + assert comment.status == :published + assert comment.sum == 120.5 + assert comment.tags == ["tags value"] + assert comment.tags == [142] + assert comment.tags == [:published] + assert comment.tags == [:published] + assert comment.the_cake_is_a_lie == true + assert comment.title == "title value" + assert comment.title_limited == "title_limi" + assert comment.token == "7488a646-e31f-11e4-aace-600308960662" + assert comment.author_id == author.id + assert comment.book_id == book.isbn + assert comment.booking_id == reservation.id + assert comment.post_id == post.id + """ + |> String.trim_trailing("\n") + + assert TestData.context_values_assertions(schema, :update) == + """ + assert comment.agreed == false + assert comment.data == "updated data value" + assert comment.data == "updated data value" + assert comment.date_of_birth == #{date_value(:update) |> inspect()} + assert comment.happy_hour == ~T[15:01:01] + assert comment.happy_hour == ~T[15:01:01.000000] + assert comment.joined == #{utc_naive_datetime_value(:update) |> inspect()} + assert comment.joined == #{utc_naive_datetime_usec_value(:update) |> inspect()} + assert comment.joined == #{utc_datetime_value(:update) |> inspect()} + assert comment.joined == #{utc_datetime_usec_value(:update) |> inspect()} + assert comment.meta == %{} + assert comment.name == "updated name value" + assert comment.points == 303 + assert comment.price == Decimal.new("18.70000") + assert comment.status == :unpublished + assert comment.status == :unpublished + assert comment.sum == 456.7 + assert comment.tags == ["updated tags value"] + assert comment.tags == [303] + assert comment.tags == [:unpublished] + assert comment.tags == [:unpublished] + assert comment.the_cake_is_a_lie == false + assert comment.title == "updated title value" + assert comment.title_limited == "updated ti" + assert comment.token == "7488a646-e31f-11e4-aace-600308960668" + assert comment.author_id == author.id + assert comment.book_id == book.isbn + assert comment.booking_id == reservation.id + assert comment.post_id == post.id + """ + |> String.trim_trailing("\n") + end + + test "sample_values/1 returns map of base sample attrs to be used in test files, " <> + "specific formatting logic is invoked per case when it needed only (based on these data)" do + assert TestData.sample_values(@parsed_attrs, TestApp.Blog.Comment) == %{ + invalid: + "agreed: nil, " <> + "data: nil, " <> + "data: nil, " <> + "date_of_birth: nil, " <> + "happy_hour: nil, " <> + "happy_hour: nil, " <> + "joined: nil, " <> + "joined: nil, " <> + "joined: nil, " <> + "joined: nil, " <> + "meta: nil, " <> + "name: nil, " <> + "points: nil, " <> + "price: nil, " <> + "status: nil, " <> + "status: nil, " <> + "sum: nil, " <> + "tags: nil, " <> + "tags: nil, " <> + "tags: nil, " <> + "tags: nil, " <> + "the_cake_is_a_lie: nil, " <> + "title: nil, " <> + "title_limited: nil, " <> + "token: nil, " <> + "author_id: nil, " <> + "book_id: nil, " <> + "booking_id: nil, " <> + "post_id: nil", + create: [ + {%Attribute{ + name: :agreed, + type: :boolean, + options: %{default: false, required: true} + }, true}, + {%Attribute{name: :data, type: :binary, options: %{}}, "data value"}, + {%Attribute{name: :data, type: :any, options: %{virtual: true}}, "data value"}, + {%Attribute{name: :date_of_birth, type: :date, options: %{}}, date_value(:create)}, + {%Attribute{name: :happy_hour, type: :time, options: %{}}, ~T[14:00:00]}, + {%Attribute{name: :happy_hour, type: :time_usec, options: %{}}, + ~T[14:00:00.000000]}, + {%Attribute{name: :joined, type: :naive_datetime, options: %{}}, + utc_naive_datetime_value(:create)}, + {%Attribute{name: :joined, type: :naive_datetime_usec, options: %{}}, + utc_naive_datetime_usec_value(:create)}, + {%Attribute{name: :joined, type: :utc_datetime, options: %{}}, + utc_datetime_value(:create)}, + {%Attribute{name: :joined, type: :utc_datetime_usec, options: %{}}, + utc_datetime_usec_value(:create)}, + {%Attribute{name: :meta, type: :map, options: %{virtual: true}}, %{}}, + {%Attribute{name: :name, type: :text, options: %{}}, "name value"}, + {%Attribute{name: :points, type: :integer, options: %{unique: true}}, 142}, + {%Attribute{ + name: :price, + type: :decimal, + options: %{precision: 10, scale: 5, unique: true} + }, "22.50000"}, + {%Attribute{ + name: :status, + type: :enum, + options: %{values: [:published, :unpublished]} + }, :published}, + {%Attribute{ + name: :status, + type: :enum, + options: %{values: [published: 1, unpublished: 2]} + }, :published}, + {%Attribute{name: :sum, type: :float, options: %{}}, 120.5}, + {%Attribute{name: :tags, type: {:array, :string}, options: %{}}, ["tags value"]}, + {%Attribute{name: :tags, type: {:array, :integer}, options: %{}}, [142]}, + {%Attribute{ + name: :tags, + type: {:array, :enum}, + options: %{values: [:published, :unpublished]} + }, [:published]}, + {%Attribute{ + name: :tags, + type: {:array, :enum}, + options: %{values: [published: 1, unpublished: 2]} + }, [:published]}, + {%Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true, virtual: true} + }, true}, + {%Attribute{name: :title, type: :string, options: %{required: true, unique: true}}, + "title value"}, + {%Attribute{name: :title_limited, type: :string, options: %{size: 10}}, + "title_limi"}, + {%Attribute{name: :token, type: :uuid, options: %{}}, + "7488a646-e31f-11e4-aace-600308960662"}, + {%Attribute{ + name: :author_id, + type: :references, + options: %{ + index: true, + association_name: :author, + referenced_column: :id, + referenced_type: :id, + referenced_table: "users", + on_delete: :nothing, + association_schema: TestApp.Accounts.User + } + }, "author.id"}, + {%Attribute{ + name: :book_id, + type: :references, + options: %{ + index: true, + association_name: :book, + referenced_column: :isbn, + referenced_type: :string, + referenced_table: "books", + on_delete: :nothing, + association_schema: TestApp.Blog.Book + } + }, "book.isbn"}, + {%Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_column: :id, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + }, "reservation.id"}, + {%Attribute{ + name: :post_id, + type: :references, + options: %{ + index: true, + association_name: :post, + referenced_column: :id, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, "post.id"} + ], + update: [ + {%Attribute{ + name: :agreed, + type: :boolean, + options: %{default: false, required: true} + }, false}, + {%Attribute{name: :data, type: :binary, options: %{}}, "updated data value"}, + {%Attribute{name: :data, type: :any, options: %{virtual: true}}, + "updated data value"}, + {%Attribute{name: :date_of_birth, type: :date, options: %{}}, date_value(:update)}, + {%Attribute{name: :happy_hour, type: :time, options: %{}}, ~T[15:01:01]}, + {%Attribute{name: :happy_hour, type: :time_usec, options: %{}}, + ~T[15:01:01.000000]}, + {%Attribute{name: :joined, type: :naive_datetime, options: %{}}, + utc_naive_datetime_value(:update)}, + {%Attribute{name: :joined, type: :naive_datetime_usec, options: %{}}, + utc_naive_datetime_usec_value(:update)}, + {%Attribute{name: :joined, type: :utc_datetime, options: %{}}, + utc_datetime_value(:update)}, + {%Attribute{name: :joined, type: :utc_datetime_usec, options: %{}}, + utc_datetime_usec_value(:update)}, + {%Attribute{name: :meta, type: :map, options: %{virtual: true}}, %{}}, + {%Attribute{name: :name, type: :text, options: %{}}, "updated name value"}, + {%Attribute{name: :points, type: :integer, options: %{unique: true}}, 303}, + {%Attribute{ + name: :price, + type: :decimal, + options: %{precision: 10, scale: 5, unique: true} + }, "18.70000"}, + {%Attribute{ + name: :status, + type: :enum, + options: %{values: [:published, :unpublished]} + }, :unpublished}, + {%Attribute{ + name: :status, + type: :enum, + options: %{values: [published: 1, unpublished: 2]} + }, :unpublished}, + {%Attribute{name: :sum, type: :float, options: %{}}, 456.7}, + {%Attribute{name: :tags, type: {:array, :string}, options: %{}}, + ["updated tags value"]}, + {%Attribute{name: :tags, type: {:array, :integer}, options: %{}}, [303]}, + {%Attribute{ + name: :tags, + type: {:array, :enum}, + options: %{values: [:published, :unpublished]} + }, [:unpublished]}, + {%Attribute{ + name: :tags, + type: {:array, :enum}, + options: %{values: [published: 1, unpublished: 2]} + }, [:unpublished]}, + {%Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true, virtual: true} + }, false}, + {%Attribute{ + name: :title, + type: :string, + options: %{required: true, unique: true} + }, "updated title value"}, + {%Attribute{name: :title_limited, type: :string, options: %{size: 10}}, + "updated ti"}, + {%Attribute{name: :token, type: :uuid, options: %{}}, + "7488a646-e31f-11e4-aace-600308960668"}, + {%Attribute{ + name: :author_id, + type: :references, + options: %{ + index: true, + association_name: :author, + referenced_column: :id, + referenced_type: :id, + referenced_table: "users", + on_delete: :nothing, + association_schema: TestApp.Accounts.User + } + }, "author.id"}, + {%Attribute{ + name: :book_id, + type: :references, + options: %{ + index: true, + association_name: :book, + referenced_column: :isbn, + referenced_type: :string, + referenced_table: "books", + on_delete: :nothing, + association_schema: TestApp.Blog.Book + } + }, "book.isbn"}, + {%Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_column: :id, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + }, "reservation.id"}, + {%Attribute{ + name: :post_id, + type: :references, + options: %{ + index: true, + association_name: :post, + referenced_column: :id, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, "post.id"} + ], + references_assigns: [ + "author = TestApp.AccountsFixtures.user_fixture()", + "book = book_fixture()", + "reservation = booking_fixture()", + "post = post_fixture()" + ] + } + end +end diff --git a/test/mix/phoenix/web_test.exs b/test/mix/phoenix/web_test.exs new file mode 100644 index 0000000000..92b11f8dd2 --- /dev/null +++ b/test/mix/phoenix/web_test.exs @@ -0,0 +1,281 @@ +defmodule Mix.Phoenix.WebTest do + use ExUnit.Case, async: true + + alias Mix.Phoenix.{Web, Schema, Attribute, TestData} + + @parsed_attrs [ + %Attribute{name: :points, options: %{unique: true}, type: :integer}, + %Attribute{name: :sum, options: %{}, type: :float}, + %Attribute{name: :price, options: %{precision: 10, scale: 5, unique: true}, type: :decimal}, + %Attribute{ + name: :the_cake_is_a_lie, + type: :boolean, + options: %{default: true, required: true} + }, + %Attribute{name: :title, type: :string, options: %{unique: true, required: true}}, + %Attribute{name: :name, options: %{}, type: :text}, + %Attribute{name: :data, type: :binary, options: %{}}, + %Attribute{name: :token, type: :uuid, options: %{}}, + %Attribute{name: :date_of_birth, options: %{}, type: :date}, + %Attribute{name: :happy_hour, options: %{}, type: :time}, + %Attribute{name: :happy_hour, type: :time_usec, options: %{}}, + %Attribute{name: :joined, options: %{}, type: :naive_datetime}, + %Attribute{name: :joined, options: %{}, type: :naive_datetime_usec}, + %Attribute{name: :joined, type: :utc_datetime, options: %{}}, + %Attribute{name: :joined, type: :utc_datetime_usec, options: %{}}, + %Attribute{name: :meta, type: :map, options: %{virtual: true}}, + %Attribute{ + name: :status, + type: :enum, + options: %{required: true, values: [:published, :unpublished]} + }, + %Attribute{name: :status, type: :enum, options: %{values: [published: 1, unpublished: 2]}}, + %Attribute{ + name: :post_id, + type: :references, + options: %{ + index: true, + association_name: :post, + referenced_column: :id, + referenced_type: :id, + referenced_table: "posts", + on_delete: :nothing, + association_schema: TestApp.Blog.Post + } + }, + %Attribute{ + name: :author_id, + type: :references, + options: %{ + required: true, + index: true, + association_name: :author, + referenced_column: :id, + referenced_type: :id, + referenced_table: "users", + on_delete: :nothing, + association_schema: TestApp.Accounts.User + } + }, + %Attribute{ + name: :booking_id, + type: :references, + options: %{ + index: true, + unique: true, + association_name: :reservation, + referenced_column: :id, + referenced_type: :id, + referenced_table: "bookings", + on_delete: :nothing, + association_schema: TestApp.Blog.Booking + } + }, + %Attribute{ + name: :book_id, + type: :references, + options: %{ + index: true, + association_name: :book, + referenced_column: :isbn, + referenced_type: :string, + referenced_table: "books", + on_delete: :nothing, + association_schema: TestApp.Blog.Book + } + }, + %Attribute{name: :data, type: :any, options: %{virtual: true}}, + %Attribute{name: :tags, type: {:array, :string}, options: %{}}, + %Attribute{name: :tags, type: {:array, :integer}, options: %{}}, + %Attribute{ + name: :tags, + type: {:array, :enum}, + options: %{values: [:published, :unpublished]} + }, + %Attribute{ + name: :tags, + type: {:array, :enum}, + options: %{values: [published: 1, unpublished: 2]} + } + ] + + test "live_table_columns/1 returns table columns for live index page, based on attributes, " <> + "for array attribute adds `array_values(...)` wrapper to render values in basic manner" do + schema = %Schema{singular: "comment", attrs: @parsed_attrs} + + assert Web.live_table_columns(schema) == + """ + + <:col :let={{_id, comment}} label="Points"><%= comment.points %> + <:col :let={{_id, comment}} label="Sum"><%= comment.sum %> + <:col :let={{_id, comment}} label="Price"><%= comment.price %> + <:col :let={{_id, comment}} label="The cake is a lie"><%= comment.the_cake_is_a_lie %> + <:col :let={{_id, comment}} label="Title"><%= comment.title %> + <:col :let={{_id, comment}} label="Name"><%= comment.name %> + <:col :let={{_id, comment}} label="Data"><%= comment.data %> + <:col :let={{_id, comment}} label="Token"><%= comment.token %> + <:col :let={{_id, comment}} label="Date of birth"><%= comment.date_of_birth %> + <:col :let={{_id, comment}} label="Happy hour"><%= comment.happy_hour %> + <:col :let={{_id, comment}} label="Happy hour"><%= comment.happy_hour %> + <:col :let={{_id, comment}} label="Joined"><%= comment.joined %> + <:col :let={{_id, comment}} label="Joined"><%= comment.joined %> + <:col :let={{_id, comment}} label="Joined"><%= comment.joined %> + <:col :let={{_id, comment}} label="Joined"><%= comment.joined %> + <:col :let={{_id, comment}} label="Meta"><%= comment.meta %> + <:col :let={{_id, comment}} label="Status"><%= comment.status %> + <:col :let={{_id, comment}} label="Status"><%= comment.status %> + <:col :let={{_id, comment}} label="Post"><%= comment.post_id %> + <:col :let={{_id, comment}} label="Author"><%= comment.author_id %> + <:col :let={{_id, comment}} label="Booking"><%= comment.booking_id %> + <:col :let={{_id, comment}} label="Book"><%= comment.book_id %> + <:col :let={{_id, comment}} label="Data"><%= comment.data %> + <:col :let={{_id, comment}} label="Tags"><%= array_values(comment.tags) %> + <:col :let={{_id, comment}} label="Tags"><%= array_values(comment.tags) %> + <:col :let={{_id, comment}} label="Tags"><%= array_values(comment.tags) %> + <:col :let={{_id, comment}} label="Tags"><%= array_values(comment.tags) %> + """ + |> String.trim_trailing("\n") + end + + test "table_columns/1 returns table columns for html index page, based on attributes, " <> + "for array attribute adds `array_values(...)` wrapper to render values in basic manner" do + schema = %Schema{singular: "comment", attrs: @parsed_attrs} + + assert Web.table_columns(schema) == + """ + + <:col :let={comment} label="Points"><%= comment.points %> + <:col :let={comment} label="Sum"><%= comment.sum %> + <:col :let={comment} label="Price"><%= comment.price %> + <:col :let={comment} label="The cake is a lie"><%= comment.the_cake_is_a_lie %> + <:col :let={comment} label="Title"><%= comment.title %> + <:col :let={comment} label="Name"><%= comment.name %> + <:col :let={comment} label="Data"><%= comment.data %> + <:col :let={comment} label="Token"><%= comment.token %> + <:col :let={comment} label="Date of birth"><%= comment.date_of_birth %> + <:col :let={comment} label="Happy hour"><%= comment.happy_hour %> + <:col :let={comment} label="Happy hour"><%= comment.happy_hour %> + <:col :let={comment} label="Joined"><%= comment.joined %> + <:col :let={comment} label="Joined"><%= comment.joined %> + <:col :let={comment} label="Joined"><%= comment.joined %> + <:col :let={comment} label="Joined"><%= comment.joined %> + <:col :let={comment} label="Meta"><%= comment.meta %> + <:col :let={comment} label="Status"><%= comment.status %> + <:col :let={comment} label="Status"><%= comment.status %> + <:col :let={comment} label="Post"><%= comment.post_id %> + <:col :let={comment} label="Author"><%= comment.author_id %> + <:col :let={comment} label="Booking"><%= comment.booking_id %> + <:col :let={comment} label="Book"><%= comment.book_id %> + <:col :let={comment} label="Data"><%= comment.data %> + <:col :let={comment} label="Tags"><%= array_values(comment.tags) %> + <:col :let={comment} label="Tags"><%= array_values(comment.tags) %> + <:col :let={comment} label="Tags"><%= array_values(comment.tags) %> + <:col :let={comment} label="Tags"><%= array_values(comment.tags) %> + """ + |> String.trim_trailing("\n") + end + + test "list_items/1 returns list items for html and live show pages, based on attributes, " <> + "for array attribute adds `array_values(...)` wrapper to render values in basic manner" do + schema = %Schema{singular: "comment", attrs: @parsed_attrs} + + assert Web.list_items(schema) == [ + "<:item title=\"Points\"><%= @comment.points %>", + "<:item title=\"Sum\"><%= @comment.sum %>", + "<:item title=\"Price\"><%= @comment.price %>", + "<:item title=\"The cake is a lie\"><%= @comment.the_cake_is_a_lie %>", + "<:item title=\"Title\"><%= @comment.title %>", + "<:item title=\"Name\"><%= @comment.name %>", + "<:item title=\"Data\"><%= @comment.data %>", + "<:item title=\"Token\"><%= @comment.token %>", + "<:item title=\"Date of birth\"><%= @comment.date_of_birth %>", + "<:item title=\"Happy hour\"><%= @comment.happy_hour %>", + "<:item title=\"Happy hour\"><%= @comment.happy_hour %>", + "<:item title=\"Joined\"><%= @comment.joined %>", + "<:item title=\"Joined\"><%= @comment.joined %>", + "<:item title=\"Joined\"><%= @comment.joined %>", + "<:item title=\"Joined\"><%= @comment.joined %>", + "<:item title=\"Meta\"><%= @comment.meta %>", + "<:item title=\"Status\"><%= @comment.status %>", + "<:item title=\"Status\"><%= @comment.status %>", + "<:item title=\"Post\"><%= @comment.post_id %>", + "<:item title=\"Author\"><%= @comment.author_id %>", + "<:item title=\"Booking\"><%= @comment.booking_id %>", + "<:item title=\"Book\"><%= @comment.book_id %>", + "<:item title=\"Data\"><%= @comment.data %>", + "<:item title=\"Tags\"><%= array_values(@comment.tags) %>", + "<:item title=\"Tags\"><%= array_values(@comment.tags) %>", + "<:item title=\"Tags\"><%= array_values(@comment.tags) %>", + "<:item title=\"Tags\"><%= array_values(@comment.tags) %>" + ] + end + + test "maybe_def_array_values/2 returns implementation of `array_values(...)` wrapper " <> + "to render values in basic manner, if there is an array attribute" do + schema = %Schema{attrs: @parsed_attrs} + + assert Web.maybe_def_array_values(schema) == + """ + + + defp array_values(values), do: (values || []) |> List.flatten() |> Enum.join(", ") + """ + |> String.trim_trailing("\n") + + assert Web.maybe_def_array_values(schema, :def) == + """ + + + def array_values(values), do: (values || []) |> List.flatten() |> Enum.join(", ") + """ + |> String.trim_trailing("\n") + + attrs_without_array = [ + %Attribute{name: :points, options: %{unique: true}, type: :integer}, + %Attribute{name: :sum, options: %{}, type: :float}, + %Attribute{name: :price, options: %{precision: 10, scale: 5, unique: true}, type: :decimal} + ] + + schema = %Schema{attrs: attrs_without_array} + + assert Web.maybe_def_array_values(schema) == nil + end + + test "form_inputs/1 returns form inputs for html and live, based on attributes, " <> + "takes into account types and options of attributes" do + attrs = @parsed_attrs + sample_values = TestData.sample_values(attrs, TestApp.Blog.Comment) + schema = %Schema{module: TestApp.Blog.Comment, attrs: attrs, sample_values: sample_values} + + assert Web.form_inputs(schema, "test_form") == + """ + <.input field={test_form[:points]} label="Points" type="number" /> + <.input field={test_form[:sum]} label="Sum" type="number" step="any" /> + <.input field={test_form[:price]} label="Price" type="number" step="any" /> + <.input field={test_form[:the_cake_is_a_lie]} label="The cake is a lie" type="checkbox" /> + <.input field={test_form[:title]} label="Title" type="text" required /> + <.input field={test_form[:name]} label="Name" type="textarea" /> + <.input field={test_form[:data]} label="Data" type="text" /> + <.input field={test_form[:token]} label="Token" type="text" /> + <.input field={test_form[:date_of_birth]} label="Date of birth" type="date" /> + <.input field={test_form[:happy_hour]} label="Happy hour" type="time" /> + <.input field={test_form[:happy_hour]} label="Happy hour" type="text" /> + <.input field={test_form[:joined]} label="Joined" type="datetime-local" /> + <.input field={test_form[:joined]} label="Joined" type="text" /> + <.input field={test_form[:joined]} label="Joined" type="datetime-local" /> + <.input field={test_form[:joined]} label="Joined" type="text" /> + <.input field={test_form[:status]} label="Status" type="select" options={Ecto.Enum.values(TestApp.Blog.Comment, :status)} prompt="Choose a value" required /> + <.input field={test_form[:status]} label="Status" type="select" options={Ecto.Enum.values(TestApp.Blog.Comment, :status)} prompt="Choose a value" /> + <.input field={test_form[:post_id]} label="Post" type="text" /> + <.input field={test_form[:author_id]} label="Author" type="text" required /> + <.input field={test_form[:booking_id]} label="Booking" type="text" /> + <.input field={test_form[:book_id]} label="Book" type="text" /> + <.input field={test_form[:data]} label="Data" type="text" /> + <.input field={test_form[:tags]} label="Tags" type="select" options={["tags value", "updated tags value"]} multiple /> + <.input field={test_form[:tags]} label="Tags" type="select" options={[142, 303]} multiple /> + <.input field={test_form[:tags]} label="Tags" type="select" options={Ecto.Enum.values(TestApp.Blog.Comment, :tags)} multiple /> + <.input field={test_form[:tags]} label="Tags" type="select" options={Ecto.Enum.values(TestApp.Blog.Comment, :tags)} multiple /> + """ + |> String.trim_trailing("\n") + end +end diff --git a/test/mix/phoenix_test.exs b/test/mix/phoenix_test.exs index cc2f976279..a2a72902c8 100644 --- a/test/mix/phoenix_test.exs +++ b/test/mix/phoenix_test.exs @@ -15,125 +15,75 @@ defmodule Mix.PhoenixTest do assert Phoenix.Router in Mix.Phoenix.modules() end - test "attrs/1 defaults each type" do - attrs = [ - "logins:array:string", - "lottery_numbers:array:integer", - "age:integer", - "temp:float", - "temp_2:decimal", - "admin:boolean", - "meta:map", - "name:text", - "date_of_birth:date", - "happy_hour:time", - "joined:naive_datetime", - "token:uuid" - ] - - assert Mix.Phoenix.Schema.attrs(attrs) == [ - logins: {:array, :string}, - lottery_numbers: {:array, :integer}, - age: :integer, - temp: :float, - temp_2: :decimal, - admin: :boolean, - meta: :map, - name: :text, - date_of_birth: :date, - happy_hour: :time, - joined: :naive_datetime, - token: :uuid - ] - end + describe "indent_text/2" do + test "indents text with spaces, and gaps (empty lines) on top and bottom" do + text = """ - test "attrs/1 raises with an unknown type" do - assert_raise(Mix.Error, ~r"Unknown type `:other` given to generator", fn -> - Mix.Phoenix.Schema.attrs(["other:other"]) - end) - end + def unique_post_price do + raise "implement the logic to generate a unique post price" + end - test "params/1 defaults each type" do - params = [ - logins: {:array, :string}, - age: :integer, - temp: :float, - temp_2: :decimal, - admin: :boolean, - meta: :map, - name: :text, - date_of_birth: :date, - happy_hour: :time, - happy_hour_usec: :time_usec, - joined: :naive_datetime, - joined_utc: :utc_datetime, - joined_utc_usec: :utc_datetime_usec, - token: :uuid, - other: :other - ] - - assert Mix.Phoenix.Schema.params(params) == %{ - logins: ["option1", "option2"], - age: 42, - temp: 120.5, - temp_2: "120.5", - admin: true, - meta: %{}, - name: "some name", - date_of_birth: Date.add(Date.utc_today(), -1), - happy_hour: ~T[14:00:00], - happy_hour_usec: ~T[14:00:00.000000], - joined: NaiveDateTime.truncate(build_utc_naive_datetime(), :second), - joined_utc: DateTime.truncate(build_utc_datetime(), :second), - joined_utc_usec: build_utc_datetime(), - token: "7488a646-e31f-11e4-aace-600308960662", - other: "some other" - } - end + def unique_post_published_at do + raise "implement the logic to generate a unique post published_at" + end - @one_day_in_seconds 24 * 3600 - defp build_utc_datetime, - do: DateTime.add(%{DateTime.utc_now() | second: 0, microsecond: {0, 6}}, -@one_day_in_seconds) + """ - defp build_utc_naive_datetime, - do: - NaiveDateTime.add( - %{NaiveDateTime.utc_now() | second: 0, microsecond: {0, 6}}, - -@one_day_in_seconds - ) + assert Mix.Phoenix.indent_text(text, spaces: 4, bottom: 1) == + """ + def unique_post_price do + raise "implement the logic to generate a unique post price" + end - test "live_form_value/1" do - assert Mix.Phoenix.Schema.live_form_value(~D[2020-10-09]) == "2020-10-09" - assert Mix.Phoenix.Schema.live_form_value(~T[14:00:00]) == "14:00" - assert Mix.Phoenix.Schema.live_form_value(~T[14:01:00]) == "14:01" - assert Mix.Phoenix.Schema.live_form_value(~T[14:15:40]) == "14:15" + def unique_post_published_at do + raise "implement the logic to generate a unique post published_at" + end + """ + end - assert Mix.Phoenix.Schema.live_form_value(~N[2020-10-09 14:00:00]) == "2020-10-09T14:00:00" + test "joins lines into indented text with spaces, and gaps (empty lines) on top and bottom" do + lines = [ + "line number 1", + "", + "", + "line number 4" + ] - assert Mix.Phoenix.Schema.live_form_value(~U[2020-10-09T14:00:00Z]) == - "2020-10-09T14:00:00Z" + assert Mix.Phoenix.indent_text(lines, spaces: 2, top: 2, bottom: 2) == + """ - assert Mix.Phoenix.Schema.live_form_value([1]) == [1] - assert Mix.Phoenix.Schema.live_form_value(["option1"]) == ["option1"] - assert Mix.Phoenix.Schema.live_form_value(:value) == :value - end + line number 1 + + + line number 4 - test "invalid_form_value/1" do - assert ~D[2020-10-09] - |> Mix.Phoenix.Schema.invalid_form_value() == "2022-00" + """ + end - assert ~T[14:00:00] - |> Mix.Phoenix.Schema.invalid_form_value() == %{hour: 14, minute: 00} + test "joins lines with given option" do + lines = [ + "first: :ready", + "second: :steady", + "third: :go!" + ] - assert ~N[2020-10-09 14:00:00] - |> Mix.Phoenix.Schema.invalid_form_value() == "2022-00" + assert Mix.Phoenix.indent_text(lines, spaces: 6, top: 1, new_line: ",\n") == + """ - assert ~U[2020-10-09T14:00:00Z] - |> Mix.Phoenix.Schema.invalid_form_value() == "2022-00" + first: :ready, + second: :steady, + third: :go! + """ + |> String.trim_trailing("\n") + end - assert Mix.Phoenix.Schema.invalid_form_value(true) == false - assert Mix.Phoenix.Schema.invalid_form_value(:anything) == nil + test "returns empty text for empty input" do + assert Mix.Phoenix.indent_text("", spaces: 6, top: 3, new_line: ",\n") == "" + assert Mix.Phoenix.indent_text(" \n\n ", spaces: 2, bottom: 8) == "" + assert Mix.Phoenix.indent_text([""], spaces: 6, top: 2, new_line: ",\n") == "" + assert Mix.Phoenix.indent_text([" \n ", " ", " "], spaces: 6, bottom: 4) == "" + end end end diff --git a/test/mix/tasks/phx.gen.context_test.exs b/test/mix/tasks/phx.gen.context_test.exs index f13c3421c8..bed8daefb1 100644 --- a/test/mix/tasks/phx.gen.context_test.exs +++ b/test/mix/tasks/phx.gen.context_test.exs @@ -1,8 +1,5 @@ Code.require_file("../../../installer/test/mix_helper.exs", __DIR__) -defmodule Phoenix.DupContext do -end - defmodule Mix.Tasks.Phx.Gen.ContextTest do use ExUnit.Case import MixHelper @@ -155,11 +152,14 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do test "generates context and handles existing contexts", config do in_tmp_project(config.test, fn -> - Gen.Context.run(~w(Blog Post posts slug:unique secret:redact title:string)) + # Accepts first attribute to be required. + send(self(), {:mix_shell_input, :yes?, true}) + Gen.Context.run(~w(Blog Post posts slug:string:unique secret:string:redact title)) assert_file("lib/phoenix/blog/post.ex", fn file -> - assert file =~ "field :title, :string" assert file =~ "field :secret, :string, redact: true" + assert file =~ "field :slug, :string" + assert file =~ "field :title, :string" end) assert_file("lib/phoenix/blog.ex", fn file -> @@ -180,20 +180,21 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do assert_file("test/support/fixtures/blog_fixtures.ex", fn file -> assert file =~ "defmodule Phoenix.BlogFixtures do" assert file =~ "def post_fixture(attrs \\\\ %{})" - assert file =~ "title: \"some title\"" + assert file =~ "title: \"title value\"" end) assert [path] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") assert_file(path, fn file -> - assert file =~ "create table(:posts)" + assert file =~ "create table(\"posts\")" assert file =~ "add :title, :string" assert file =~ "add :secret, :string" - assert file =~ "create unique_index(:posts, [:slug])" + assert file =~ "add :slug, :string, null: false" + assert file =~ "create index(\"posts\", [:slug], unique: true)" end) send(self(), {:mix_shell_input, :yes?, true}) - Gen.Context.run(~w(Blog Comment comments title:string)) + Gen.Context.run(~w(Blog Comment comments title:string:*)) assert_received {:mix_shell, :info, ["You are generating into an existing context" <> notice]} @@ -216,14 +217,14 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do assert_file("test/support/fixtures/blog_fixtures.ex", fn file -> assert file =~ "defmodule Phoenix.BlogFixtures do" assert file =~ "def comment_fixture(attrs \\\\ %{})" - assert file =~ "title: \"some title\"" + assert file =~ "title: \"title value\"" end) assert [path] = Path.wildcard("priv/repo/migrations/*_create_comments.exs") assert_file(path, fn file -> - assert file =~ "create table(:comments)" - assert file =~ "add :title, :string" + assert file =~ "create table(\"comments\")" + assert file =~ "add :title, :string, null: false" end) assert_file("lib/phoenix/blog.ex", fn file -> @@ -241,12 +242,12 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do in_tmp_project(config.test, fn -> Gen.Context.run(~w(Blog Post posts slug:string:unique - subject:unique + subject:string:unique body:text:unique order:integer:unique price:decimal:unique published_at:utc_datetime:unique - author:references:users:unique + author_id:references:table,users:column,id:type,id:Accounts.User:unique published?:boolean )) @@ -272,15 +273,15 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do assert file =~ ~S|def unique_post_order, do: System.unique_integer([:positive])| assert file =~ - ~S|def unique_post_slug, do: "some slug#{System.unique_integer([:positive])}"| + ~S|def unique_post_slug, do: "#{System.unique_integer([:positive])}slug value"| assert file =~ - ~S|def unique_post_body, do: "some body#{System.unique_integer([:positive])}"| + ~S|def unique_post_body, do: "#{System.unique_integer([:positive])}body value"| assert file =~ - ~S|def unique_post_subject, do: "some subject#{System.unique_integer([:positive])}"| + ~S|def unique_post_subject, do: "#{System.unique_integer([:positive])}subject value"| - refute file =~ ~S|def unique_post_author| + refute file =~ ~S|author = Accounts.UserFixtures.user_fixture()| assert file =~ """ def unique_post_price do @@ -292,21 +293,23 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do body: unique_post_body(), order: unique_post_order(), price: unique_post_price(), - published?: true, + published?: false, published_at: unique_post_published_at(), slug: unique_post_slug(), - subject: unique_post_subject() + subject: unique_post_subject(), + author_id: author.id """ end) assert [path] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") assert_file(path, fn file -> - assert file =~ "create table(:posts)" - assert file =~ "create unique_index(:posts, [:order])" - assert file =~ "create unique_index(:posts, [:price])" - assert file =~ "create unique_index(:posts, [:slug])" - assert file =~ "create unique_index(:posts, [:subject])" + assert file =~ "create table(\"posts\")" + assert file =~ "create index(\"posts\", [:author_id], unique: true)" + assert file =~ "create index(\"posts\", [:order], unique: true)" + assert file =~ "create index(\"posts\", [:price], unique: true)" + assert file =~ "create index(\"posts\", [:slug], unique: true)" + assert file =~ "create index(\"posts\", [:subject], unique: true)" end) end) end @@ -316,7 +319,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do in_tmp_project(config.test, fn -> Gen.Context.run(~w(Blog Post posts slug:string:unique - subject:unique + subject:string:unique:* body:text:unique order:integer:unique )) @@ -329,7 +332,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do test "generates into existing context without prompt with --merge-with-existing-context", config do in_tmp_project(config.test, fn -> - Gen.Context.run(~w(Blog Post posts title)) + Gen.Context.run(~w(Blog Post posts)) assert_file("lib/phoenix/blog.ex", fn file -> assert file =~ "def get_post!" @@ -340,7 +343,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do assert file =~ "def change_post" end) - Gen.Context.run(~w(Blog Comment comments message:string --merge-with-existing-context)) + Gen.Context.run(~w(Blog Comment comments --merge-with-existing-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _notice]} @@ -359,7 +362,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do test "when more than 50 attributes are given", config do in_tmp_project(config.test, fn -> long_attribute_list = Enum.map_join(0..55, " ", &"attribute#{&1}:string") - Gen.Context.run(~w(Blog Post posts title #{long_attribute_list})) + Gen.Context.run(~w(Blog Post posts title:string:* #{long_attribute_list})) assert_file("test/phoenix/blog_test.exs", fn file -> refute file =~ "...}" @@ -369,7 +372,9 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do test "generates context with no schema and repo option", config do in_tmp_project(config.test, fn -> - Gen.Context.run(~w(Blog Post posts title:string --no-schema --repo=Foo.RepoX)) + Gen.Context.run( + ~w(Blog Post posts title:string:*:unique content --no-schema --repo=Foo.RepoX) + ) refute_file("lib/phoenix/blog/post.ex") @@ -393,7 +398,8 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do assert_file("test/support/fixtures/blog_fixtures.ex", fn file -> assert file =~ "defmodule Phoenix.BlogFixtures do" assert file =~ "def post_fixture(attrs \\\\ %{})" - assert file =~ "title: \"some title\"" + assert file =~ "title: unique_post_title()" + assert file =~ "content: \"content value\"" end) assert Path.wildcard("priv/repo/migrations/*_create_posts.exs") == [] @@ -403,7 +409,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do test "generates context with enum", config do in_tmp_project(config.test, fn -> Gen.Context.run( - ~w(Accounts User users email:text:unique password:text:redact status:enum:verified:unverified:disabled) + ~w(Accounts User users email:text:*:unique password:text:redact status:enum:[verified,unverified,disabled]) ) assert_file("lib/phoenix/accounts/user.ex", fn file -> @@ -413,7 +419,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do assert [path] = Path.wildcard("priv/repo/migrations/*_create_users.exs") assert_file(path, fn file -> - assert file =~ "create table(:users)" + assert file =~ "create table(\"users\")" assert file =~ "add :status, :string" end) end) diff --git a/test/mix/tasks/phx.gen.embedded_test.exs b/test/mix/tasks/phx.gen.embedded_test.exs index 1bc02c67b4..ae0e61f945 100644 --- a/test/mix/tasks/phx.gen.embedded_test.exs +++ b/test/mix/tasks/phx.gen.embedded_test.exs @@ -4,7 +4,7 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do use ExUnit.Case import MixHelper alias Mix.Tasks.Phx.Gen - alias Mix.Phoenix.Schema + alias Mix.Phoenix.{Schema, Attribute} setup do Mix.Task.clear() @@ -13,22 +13,21 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "build" do in_tmp_project("embedded build", fn -> + # Accepts first attribute to be required. + send(self(), {:mix_shell_input, :yes?, true}) schema = Gen.Embedded.build(~w(Blog.Post title:string)) assert %Schema{ alias: Post, module: Phoenix.Blog.Post, repo: Phoenix.Repo, - migration?: false, - migration_defaults: %{title: ""}, plural: nil, singular: "post", human_plural: "Nil", human_singular: "Post", - attrs: [title: :string], - types: [title: :string], - embedded?: true, - defaults: %{title: ""} + attrs: [%Attribute{name: :title, type: :string, options: %{required: true}}], + migration?: false, + embedded?: true } = schema assert String.ends_with?(schema.file, "lib/phoenix/blog/post.ex") @@ -37,7 +36,7 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "generates embedded schema", config do in_tmp_project(config.test, fn -> - Gen.Embedded.run(~w(Blog.Post title:string)) + Gen.Embedded.run(~w(Blog.Post)) assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "embedded_schema do" @@ -47,7 +46,7 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "generates nested embedded schema", config do in_tmp_project(config.test, fn -> - Gen.Embedded.run(~w(Blog.Admin.User name:string)) + Gen.Embedded.run(~w(Blog.Admin.User)) assert_file("lib/phoenix/blog/admin/user.ex", fn file -> assert file =~ "defmodule Phoenix.Blog.Admin.User do" @@ -59,7 +58,7 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "generates embedded schema with proper datetime types", config do in_tmp_project(config.test, fn -> Gen.Embedded.run( - ~w(Blog.Comment title:string drafted_at:datetime published_at:naive_datetime edited_at:utc_datetime) + ~w(Blog.Comment title:string:* drafted_at:datetime published_at:naive_datetime edited_at:utc_datetime) ) assert_file("lib/phoenix/blog/comment.ex", fn file -> @@ -72,9 +71,7 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "generates embedded schema with enum", config do in_tmp_project(config.test, fn -> - Gen.Embedded.run( - ~w(Blog.Comment comments title:string status:enum:unpublished:published:deleted) - ) + Gen.Embedded.run(~w(Blog.Comment title status:enum:[unpublished,published,deleted]:*)) assert_file("lib/phoenix/blog/comment.ex", fn file -> assert file =~ "field :status, Ecto.Enum, values: [:unpublished, :published, :deleted]" @@ -84,7 +81,7 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "generates embedded schema with redact option", config do in_tmp_project(config.test, fn -> - Gen.Embedded.run(~w(Blog.Comment comments title:string secret:redact)) + Gen.Embedded.run(~w(Blog.Comment title secret:string:*:redact)) assert_file("lib/phoenix/blog/comment.ex", fn file -> assert file =~ "field :secret, :string, redact: true" @@ -95,13 +92,13 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "generates embedded schema with references", config do in_tmp_project(config.test, fn -> Gen.Embedded.run( - ~w(Blog.Comment comments body word_count:integer author_id:references:author) + ~w(Blog.Comment body word_count:integer author_id:references:*:table,users:column,id:type,string) ) assert_file("lib/phoenix/blog/comment.ex", fn file -> - assert file =~ "field :author_id, :id" assert file =~ "field :body, :string" assert file =~ "field :word_count, :integer" + assert file =~ "belongs_to :author, Phoenix.Blog.Author, type: :string" end) end) end diff --git a/test/mix/tasks/phx.gen.html_test.exs b/test/mix/tasks/phx.gen.html_test.exs index 450e0599b7..38f86472ce 100644 --- a/test/mix/tasks/phx.gen.html_test.exs +++ b/test/mix/tasks/phx.gen.html_test.exs @@ -31,10 +31,6 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do assert_raise Mix.Error, ~r/Invalid arguments/, fn -> Gen.Html.run(~w(Blog Post)) end - - assert_raise Mix.Error, ~r/Enum type requires at least one value/, fn -> - Gen.Html.run(~w(Blog Post posts status:enum)) - end end) end @@ -44,21 +40,29 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do datetime = %{DateTime.utc_now() | second: 0, microsecond: {0, 6}} in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Post posts title content:text slug:unique votes:integer cost:decimal - tags:array:text popular:boolean drafted_at:datetime - status:enum:unpublished:published:deleted + Gen.Html.run(~w(Blog Post posts + title + slug:string:unique + votes:integer cost:decimal + content:text + tags:[array,text] + popular:boolean drafted_at:datetime + status:enum:[unpublished,published,deleted] published_at:utc_datetime published_at_usec:utc_datetime_usec deleted_at:naive_datetime deleted_at_usec:naive_datetime_usec alarm:time alarm_usec:time_usec - secret:uuid:redact announcement_date:date alarm:time + secret:uuid:redact + announcement_date:date metadata:map - weight:float user_id:references:users)) + weight:float + user_id:references:table,users:column,id:type,id)) assert_file("lib/phoenix/blog/post.ex") assert_file("lib/phoenix/blog.ex") + assert_file("test/support/fixtures/blog_fixtures.ex") assert_file("test/phoenix/blog_test.exs", fn file -> assert file =~ "alarm: ~T[15:01:01]" @@ -71,7 +75,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do assert file =~ "deleted_at_usec: #{naive_datetime |> NaiveDateTime.add(-one_day_in_seconds) |> inspect()}" - assert file =~ "cost: \"120.5\"" + assert file =~ "cost: \"22.5\"" assert file =~ "published_at: #{datetime |> DateTime.add(-one_day_in_seconds) |> DateTime.truncate(:second) |> inspect()}" @@ -95,7 +99,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do assert file =~ "assert post.published_at_usec == #{inspect(datetime)}" assert file =~ "assert post.alarm == ~T[15:01:01]" assert file =~ "assert post.alarm_usec == ~T[15:01:01.000000]" - assert file =~ "assert post.cost == Decimal.new(\"120.5\")" + assert file =~ "assert post.cost == Decimal.new(\"22.5\")" assert file =~ "assert post.weight == 120.5" assert file =~ "assert post.status == :published" end) @@ -108,11 +112,12 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do assert [path] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") assert_file(path, fn file -> - assert file =~ "create table(:posts)" + assert file =~ "create table(\"posts\")" assert file =~ "add :title, :string" assert file =~ "add :content, :text" assert file =~ "add :status, :string" - assert file =~ "create unique_index(:posts, [:slug])" + assert file =~ "add :popular, :boolean, default: false, null: false" + assert file =~ "create index(\"posts\", [:slug], unique: true)" end) assert_file("lib/phoenix_web/controllers/post_controller.ex", fn file -> @@ -151,40 +156,35 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do assert_file("lib/phoenix_web/controllers/post_html/post_form.html.heex", fn file -> assert file =~ ~S(<.simple_form :let={f} for={@changeset} action={@action}>) - assert file =~ ~s(<.input field={f[:title]} type="text") - assert file =~ ~s(<.input field={f[:content]} type="textarea") - assert file =~ ~s(<.input field={f[:votes]} type="number") - assert file =~ ~s(<.input field={f[:cost]} type="number" label="Cost" step="any") - - assert file =~ """ - <.input - field={f[:tags]} - type="select" - multiple - """ - - assert file =~ ~s(<.input field={f[:popular]} type="checkbox") - assert file =~ ~s(<.input field={f[:drafted_at]} type="datetime-local") - assert file =~ ~s(<.input field={f[:published_at]} type="datetime-local") - assert file =~ ~s(<.input field={f[:deleted_at]} type="datetime-local") - assert file =~ ~s(<.input field={f[:announcement_date]} type="date") - assert file =~ ~s(<.input field={f[:alarm]} type="time") - assert file =~ ~s(<.input field={f[:secret]} type="text" label="Secret" />) - refute file =~ ~s(field={f[:metadata]}) - - assert file =~ """ - <.input - field={f[:status]} - type="select" - """ - assert file =~ ~s|Ecto.Enum.values(Phoenix.Blog.Post, :status)| + assert file =~ + """ + <.input field={f[:title]} label="Title" type="text" /> + <.input field={f[:slug]} label="Slug" type="text" /> + <.input field={f[:votes]} label="Votes" type="number" /> + <.input field={f[:cost]} label="Cost" type="number" step="any" /> + <.input field={f[:content]} label="Content" type="textarea" /> + <.input field={f[:tags]} label="Tags" type="select" options={["tags value", "updated tags value"]} multiple /> + <.input field={f[:popular]} label="Popular" type="checkbox" /> + <.input field={f[:drafted_at]} label="Drafted at" type="datetime-local" /> + <.input field={f[:status]} label="Status" type="select" options={Ecto.Enum.values(Phoenix.Blog.Post, :status)} prompt="Choose a value" /> + <.input field={f[:published_at]} label="Published at" type="datetime-local" /> + <.input field={f[:published_at_usec]} label="Published at usec" type="text" /> + <.input field={f[:deleted_at]} label="Deleted at" type="datetime-local" /> + <.input field={f[:deleted_at_usec]} label="Deleted at usec" type="text" /> + <.input field={f[:alarm]} label="Alarm" type="time" /> + <.input field={f[:alarm_usec]} label="Alarm usec" type="text" /> + <.input field={f[:secret]} label="Secret" type="text" /> + <.input field={f[:announcement_date]} label="Announcement date" type="date" /> + <.input field={f[:weight]} label="Weight" type="number" step="any" /> + <.input field={f[:user_id]} label="User" type="text" /> + """ - refute file =~ ~s(<.input field={f[:user_id]}) + refute file =~ ~s(field={f[:metadata]}) end) send(self(), {:mix_shell_input, :yes?, true}) - Gen.Html.run(~w(Blog Comment comments title:string)) + Gen.Html.run(~w(Blog Comment comments title:string:*)) assert_received {:mix_shell, :info, ["You are generating into an existing context" <> _]} assert_file("lib/phoenix/blog/comment.ex") @@ -196,8 +196,8 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do assert [path] = Path.wildcard("priv/repo/migrations/*_create_comments.exs") assert_file(path, fn file -> - assert file =~ "create table(:comments)" - assert file =~ "add :title, :string" + assert file =~ "create table(\"comments\")" + assert file =~ "add :title, :string, null: false" end) assert_file("lib/phoenix_web/controllers/comment_controller.ex", fn file -> @@ -227,7 +227,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "generates into existing context without prompt with --merge-with-existing-context", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Post posts title)) + Gen.Html.run(~w(Blog Post posts)) assert_file("lib/phoenix/blog.ex", fn file -> assert file =~ "def get_post!" @@ -238,7 +238,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do assert file =~ "def change_post" end) - Gen.Html.run(~w(Blog Comment comments message:string --merge-with-existing-context)) + Gen.Html.run(~w(Blog Comment comments --merge-with-existing-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _notice]} @@ -256,7 +256,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "with --web namespace generates namespaced web modules and directories", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Post posts title:string --web Blog)) + Gen.Html.run(~w(Blog Post posts --web Blog)) assert_file("test/phoenix_web/controllers/blog/post_controller_test.exs", fn file -> assert file =~ "defmodule PhoenixWeb.Blog.PostControllerTest" @@ -307,10 +307,12 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "with --no-context skips context and schema file generation", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Comment comments title:string --no-context)) + Gen.Html.run(~w(Blog Comment comments --no-context)) refute_file("lib/phoenix/blog.ex") refute_file("lib/phoenix/blog/comment.ex") + refute_file("test/phoenix/blog_test.ex") + refute_file("test/support/fixtures/blog_fixtures.ex") assert Path.wildcard("priv/repo/migrations/*.exs") == [] assert_file("test/phoenix_web/controllers/comment_controller_test.exs", fn file -> @@ -330,7 +332,8 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "with a matching plural and singular term", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Tracker Series series value:integer)) + Gen.Html.run(~w(Tracker Series series)) + assert_file("lib/phoenix_web/controllers/series_controller.ex", fn file -> assert file =~ "render(conn, :index, series_collection: series)" end) @@ -339,12 +342,12 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "with --no-context no warning is emitted when context exists", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Post posts title:string)) + Gen.Html.run(~w(Blog Post posts)) assert_file("lib/phoenix/blog.ex") assert_file("lib/phoenix/blog/post.ex") - Gen.Html.run(~w(Blog Comment comments title:string --no-context)) + Gen.Html.run(~w(Blog Comment comments --no-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _]} assert_file("test/phoenix_web/controllers/comment_controller_test.exs", fn file -> @@ -364,7 +367,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "with --no-schema skips schema file generation", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Comment comments title:string --no-schema)) + Gen.Html.run(~w(Blog Comment comments --no-schema)) assert_file("lib/phoenix/blog.ex") refute_file("lib/phoenix/blog/comment.ex") @@ -388,7 +391,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "when more than 50 arguments are given", config do in_tmp_project(config.test, fn -> long_attribute_list = Enum.map_join(0..55, " ", &"attribute#{&1}:string") - Gen.Html.run(~w(Blog Post posts #{long_attribute_list})) + Gen.Html.run(~w(Blog Post posts title:string:* #{long_attribute_list})) assert_file("test/phoenix_web/controllers/post_controller_test.exs", fn file -> refute file =~ "...}" @@ -400,7 +403,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "without context_app generators config uses web dir", config do in_tmp_umbrella_project(config.test, fn -> Application.put_env(:phoenix, :generators, context_app: nil) - Gen.Html.run(~w(Accounts User users name:string)) + Gen.Html.run(~w(Accounts User users)) assert_file("lib/phoenix/accounts.ex") assert_file("lib/phoenix/accounts/user.ex") @@ -435,7 +438,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do File.mkdir!("another_app") Application.put_env(:phoenix, :generators, context_app: {:another_app, "another_app"}) - Gen.Html.run(~w(Accounts User users name:string)) + Gen.Html.run(~w(Accounts User users)) assert_file("another_app/lib/another_app/accounts.ex") assert_file("another_app/lib/another_app/accounts/user.ex") @@ -457,7 +460,9 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "allows enum type with at least one value", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Post posts status:enum:new)) + # Accepts first attribute to be required. + send(self(), {:mix_shell_input, :yes?, true}) + Gen.Html.run(~w(Blog Post posts status:enum:[new])) assert_file("lib/phoenix_web/controllers/post_html/post_form.html.heex", fn file -> assert file =~ ~s|Ecto.Enum.values(Phoenix.Blog.Post, :status)| diff --git a/test/mix/tasks/phx.gen.json_test.exs b/test/mix/tasks/phx.gen.json_test.exs index 32fb2112f2..1402a7c47b 100644 --- a/test/mix/tasks/phx.gen.json_test.exs +++ b/test/mix/tasks/phx.gen.json_test.exs @@ -46,20 +46,29 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do |> DateTime.add(-one_day_in_seconds) in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Post posts title slug:unique votes:integer cost:decimal - tags:array:text popular:boolean drafted_at:datetime - params:map - published_at:utc_datetime - published_at_usec:utc_datetime_usec - deleted_at:naive_datetime - deleted_at_usec:naive_datetime_usec - alarm:time - alarm_usec:time_usec - secret:uuid:redact announcement_date:date - weight:float user_id:references:users)) + Gen.Json.run(~w(Blog Post posts + title + slug:string:unique + votes:integer + cost:decimal + tags:[array,text] + popular:boolean + drafted_at:datetime + params:map + published_at:utc_datetime + published_at_usec:utc_datetime_usec + deleted_at:naive_datetime + deleted_at_usec:naive_datetime_usec + alarm:time + alarm_usec:time_usec + secret:uuid:redact + announcement_date:date + weight:float + user_id:references:table,users:column,id:type,id)) assert_file("lib/phoenix/blog/post.ex") assert_file("lib/phoenix/blog.ex") + assert_file("test/support/fixtures/blog_fixtures.ex") assert_file("test/phoenix/blog_test.exs", fn file -> assert file =~ "use Phoenix.DataCase" @@ -74,7 +83,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do "alarm" => "14:00:00", "alarm_usec" => "14:00:00.000000", "announcement_date" => "#{Date.add(Date.utc_today(), -1)}", - "cost" => "120.5", + "cost" => "22.5", "deleted_at" => "#{naive_datetime |> NaiveDateTime.truncate(:second) |> NaiveDateTime.to_iso8601()}", "deleted_at_usec" => "#{NaiveDateTime.to_iso8601(naive_datetime)}", "drafted_at" => "#{datetime |> NaiveDateTime.truncate(:second) |> NaiveDateTime.to_iso8601()}", @@ -83,12 +92,15 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do "published_at" => "#{datetime |> DateTime.truncate(:second) |> DateTime.to_iso8601()}", "published_at_usec" => "#{DateTime.to_iso8601(datetime)}", "secret" => "7488a646-e31f-11e4-aace-600308960662", - "slug" => "some slug", - "tags" => [], - "title" => "some title", - "votes" => 42, - "weight" => 120.5 + "slug" => "slug value", + "tags" => ["tags value"], + "title" => "title value", + "votes" => 142, + "weight" => 120.5, + "user_id" => json_user_id } = json_response(conn, 200)["data"] + + assert json_user_id == user.id """ end) @@ -124,7 +136,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "generates into existing context without prompt with --merge-with-existing-context", config do in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Post posts title)) + Gen.Json.run(~w(Blog Post posts)) assert_file("lib/phoenix/blog.ex", fn file -> assert file =~ "def get_post!" @@ -135,7 +147,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do assert file =~ "def change_post" end) - Gen.Json.run(~w(Blog Comment comments message:string --merge-with-existing-context)) + Gen.Json.run(~w(Blog Comment comments --merge-with-existing-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _notice]} @@ -154,7 +166,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "when more than 50 arguments are given", config do in_tmp_project(config.test, fn -> long_attribute_list = Enum.map_join(0..55, " ", &"attribute#{&1}:string") - Gen.Json.run(~w(Blog Post posts #{long_attribute_list})) + Gen.Json.run(~w(Blog Post posts title:string:* #{long_attribute_list})) assert_file("test/phoenix_web/controllers/post_controller_test.exs", fn file -> refute file =~ "...}" @@ -164,7 +176,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "with json --web namespace generates namespaced web modules and directories", config do in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Post posts title:string --web Blog)) + Gen.Json.run(~w(Blog Post posts --web Blog)) assert_file("test/phoenix_web/controllers/blog/post_controller_test.exs", fn file -> assert file =~ "defmodule PhoenixWeb.Blog.PostControllerTest" @@ -203,10 +215,12 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "with --no-context skips context and schema file generation", config do in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Comment comments title:string --no-context)) + Gen.Json.run(~w(Blog Comment comments --no-context)) refute_file("lib/phoenix/blog.ex") refute_file("lib/phoenix/blog/comment.ex") + refute_file("test/phoenix/blog_test.ex") + refute_file("test/support/fixtures/blog_fixtures.ex") assert Path.wildcard("priv/repo/migrations/*.exs") == [] assert_file("test/phoenix_web/controllers/comment_controller_test.exs", fn file -> @@ -226,12 +240,12 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "with --no-context no warning is emitted when context exists", config do in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Post posts title:string)) + Gen.Json.run(~w(Blog Post posts)) assert_file("lib/phoenix/blog.ex") assert_file("lib/phoenix/blog/post.ex") - Gen.Json.run(~w(Blog Comment comments title:string --no-context)) + Gen.Json.run(~w(Blog Comment comments --no-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _]} assert_file("test/phoenix_web/controllers/comment_controller_test.exs", fn file -> @@ -251,7 +265,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "with --no-schema skips schema file generation", config do in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Comment comments title:string --no-schema)) + Gen.Json.run(~w(Blog Comment comments --no-schema)) assert_file("lib/phoenix/blog.ex") refute_file("lib/phoenix/blog/comment.ex") @@ -275,7 +289,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do describe "inside umbrella" do test "without context_app generators config uses web dir", config do in_tmp_umbrella_project(config.test, fn -> - Gen.Json.run(~w(Accounts User users name:string)) + Gen.Json.run(~w(Accounts User users)) assert_file("lib/phoenix/accounts.ex") assert_file("lib/phoenix/accounts/user.ex") @@ -310,7 +324,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do File.mkdir!("another_app") Application.put_env(:phoenix, :generators, context_app: {:another_app, "another_app"}) - Gen.Json.run(~w(Accounts User users name:string)) + Gen.Json.run(~w(Accounts User users)) assert_file("another_app/lib/another_app/accounts.ex") assert_file("another_app/lib/another_app/accounts/user.ex") @@ -342,7 +356,9 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do [{module, _}] = Code.compile_file("lib/phoenix_web/components/core_components.ex") - Gen.Json.run(~w(Blog Post posts title:string --web Blog)) + # Accepts first attribute to be required. + send(self(), {:mix_shell_input, :yes?, true}) + Gen.Json.run(~w(Blog Post posts title --web Blog)) assert_file("lib/phoenix_web/controllers/changeset_json.ex", fn file -> assert file =~ diff --git a/test/mix/tasks/phx.gen.live_test.exs b/test/mix/tasks/phx.gen.live_test.exs index dd5cb47564..7c108d4fb9 100644 --- a/test/mix/tasks/phx.gen.live_test.exs +++ b/test/mix/tasks/phx.gen.live_test.exs @@ -1,4 +1,4 @@ -Code.require_file "../../../installer/test/mix_helper.exs", __DIR__ +Code.require_file("../../../installer/test/mix_helper.exs", __DIR__) defmodule Mix.Tasks.Phx.Gen.LiveTest do use ExUnit.Case @@ -30,7 +30,7 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do end test "invalid mix arguments", config do - in_tmp_live_project config.test, fn -> + in_tmp_live_project(config.test, fn -> assert_raise Mix.Error, ~r/Expected the context, "blog", to be a valid module name/, fn -> Gen.Live.run(~w(blog Post posts title:string)) end @@ -50,387 +50,414 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do assert_raise Mix.Error, ~r/Invalid arguments/, fn -> Gen.Live.run(~w(Blog Post)) end - end + end) end test "generates live resource and handles existing contexts", config do - in_tmp_live_project config.test, fn -> - Gen.Live.run(~w(Blog Post posts title content:text slug:unique votes:integer cost:decimal - tags:array:text popular:boolean drafted_at:datetime - status:enum:unpublished:published:deleted + in_tmp_live_project(config.test, fn -> + Gen.Live.run(~w(Blog Post posts + title + slug:string:unique + votes:integer + cost:decimal + content:text + tags:[array,text] + popular:boolean + drafted_at:datetime + status:enum:[unpublished,published,deleted] published_at:utc_datetime published_at_usec:utc_datetime_usec deleted_at:naive_datetime deleted_at_usec:naive_datetime_usec alarm:time alarm_usec:time_usec - secret:uuid:redact announcement_date:date alarm:time + secret:uuid:redact + announcement_date:date metadata:map - weight:float user_id:references:users)) + weight:float + user_id:references:table,users:column,id:type,id)) - assert_file "lib/phoenix/blog/post.ex" - assert_file "lib/phoenix/blog.ex" - assert_file "test/phoenix/blog_test.exs" + assert_file("lib/phoenix/blog/post.ex") + assert_file("lib/phoenix/blog.ex") + assert_file("test/phoenix/blog_test.exs") + assert_file("test/support/fixtures/blog_fixtures.ex") - assert_file "lib/phoenix_web/live/post_live/index.ex", fn file -> + assert_file("lib/phoenix_web/live/post_live/index.ex", fn file -> assert file =~ "defmodule PhoenixWeb.PostLive.Index" - end + end) - assert_file "lib/phoenix_web/live/post_live/show.ex", fn file -> + assert_file("lib/phoenix_web/live/post_live/show.ex", fn file -> assert file =~ "defmodule PhoenixWeb.PostLive.Show" - end + end) - assert_file "lib/phoenix_web/live/post_live/form.ex", fn file -> + assert_file("lib/phoenix_web/live/post_live/form.ex", fn file -> assert file =~ "defmodule PhoenixWeb.PostLive.Form" - end + end) assert [path] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file path, fn file -> - assert file =~ "create table(:posts)" + + assert_file(path, fn file -> + assert file =~ "create table(\"posts\")" assert file =~ "add :title, :string" assert file =~ "add :content, :text" - assert file =~ "create unique_index(:posts, [:slug])" - end + assert file =~ "create index(\"posts\", [:slug], unique: true)" + end) - assert_file "lib/phoenix_web/live/post_live/index.ex", fn file -> + assert_file("lib/phoenix_web/live/post_live/index.ex", fn file -> assert file =~ ~S|~p"/posts/#{post}"| - end + end) - assert_file "lib/phoenix_web/live/post_live/show.ex", fn file -> + assert_file("lib/phoenix_web/live/post_live/show.ex", fn file -> assert file =~ ~S|~p"/posts"| - end + end) - assert_file "lib/phoenix_web/live/post_live/form.ex", fn file -> + assert_file("lib/phoenix_web/live/post_live/form.ex", fn file -> assert file =~ ~s(<.simple_form) - assert file =~ ~s(<.input field={@form[:title]} type="text") - assert file =~ ~s(<.input field={@form[:content]} type="textarea") - assert file =~ ~s(<.input field={@form[:votes]} type="number") - assert file =~ ~s(<.input field={@form[:cost]} type="number" label="Cost" step="any") - assert file =~ """ - <.input - field={@form[:tags]} - type="select" - multiple - """ - assert file =~ ~s(<.input field={@form[:popular]} type="checkbox") - assert file =~ ~s(<.input field={@form[:drafted_at]} type="datetime-local") - assert file =~ ~s(<.input field={@form[:published_at]} type="datetime-local") - assert file =~ ~s(<.input field={@form[:deleted_at]} type="datetime-local") - assert file =~ ~s(<.input field={@form[:announcement_date]} type="date") - assert file =~ ~s(<.input field={@form[:alarm]} type="time") - assert file =~ ~s(<.input field={@form[:secret]} type="text" label="Secret" />) + + assert file =~ + """ + <.input field={@form[:title]} label="Title" type="text" /> + <.input field={@form[:slug]} label="Slug" type="text" /> + <.input field={@form[:votes]} label="Votes" type="number" /> + <.input field={@form[:cost]} label="Cost" type="number" step="any" /> + <.input field={@form[:content]} label="Content" type="textarea" /> + <.input field={@form[:tags]} label="Tags" type="select" options={["tags value", "updated tags value"]} multiple /> + <.input field={@form[:popular]} label="Popular" type="checkbox" /> + <.input field={@form[:drafted_at]} label="Drafted at" type="datetime-local" /> + <.input field={@form[:status]} label="Status" type="select" options={Ecto.Enum.values(Phoenix.Blog.Post, :status)} prompt="Choose a value" /> + <.input field={@form[:published_at]} label="Published at" type="datetime-local" /> + <.input field={@form[:published_at_usec]} label="Published at usec" type="text" /> + <.input field={@form[:deleted_at]} label="Deleted at" type="datetime-local" /> + <.input field={@form[:deleted_at_usec]} label="Deleted at usec" type="text" /> + <.input field={@form[:alarm]} label="Alarm" type="time" /> + <.input field={@form[:alarm_usec]} label="Alarm usec" type="text" /> + <.input field={@form[:secret]} label="Secret" type="text" /> + <.input field={@form[:announcement_date]} label="Announcement date" type="date" /> + <.input field={@form[:weight]} label="Weight" type="number" step="any" /> + <.input field={@form[:user_id]} label="User" type="text" /> + """ + refute file =~ ~s( - assert file =~ ~r"@invalid_attrs.*popular: false" + assert_file("test/phoenix_web/live/post_live_test.exs", fn file -> + assert file =~ ~r"@invalid_attrs %\{[^\}]*popular: false" assert file =~ ~S|~p"/posts"| assert file =~ ~S|~p"/posts/new"| assert file =~ ~S|~p"/posts/#{post}"| assert file =~ ~S|~p"/posts/#{post}/edit"| - end + end) - send self(), {:mix_shell_input, :yes?, true} - Gen.Live.run(~w(Blog Comment comments title:string)) + send(self(), {:mix_shell_input, :yes?, true}) + Gen.Live.run(~w(Blog Comment comments title:string:*)) assert_received {:mix_shell, :info, ["You are generating into an existing context" <> _]} - assert_file "lib/phoenix/blog/comment.ex" - assert_file "test/phoenix_web/live/comment_live_test.exs", fn file -> + assert_file("lib/phoenix/blog/comment.ex") + + assert_file("test/phoenix_web/live/comment_live_test.exs", fn file -> assert file =~ "defmodule PhoenixWeb.CommentLiveTest" - end + end) assert [path] = Path.wildcard("priv/repo/migrations/*_create_comments.exs") - assert_file path, fn file -> - assert file =~ "create table(:comments)" - assert file =~ "add :title, :string" - end - assert_file "lib/phoenix_web/live/comment_live/index.ex", fn file -> + assert_file(path, fn file -> + assert file =~ "create table(\"comments\")" + assert file =~ "add :title, :string, null: false" + end) + + assert_file("lib/phoenix_web/live/comment_live/index.ex", fn file -> assert file =~ "defmodule PhoenixWeb.CommentLive.Index" - end + end) - assert_file "lib/phoenix_web/live/comment_live/show.ex", fn file -> + assert_file("lib/phoenix_web/live/comment_live/show.ex", fn file -> assert file =~ "defmodule PhoenixWeb.CommentLive.Show" - end + end) - assert_file "lib/phoenix_web/live/comment_live/form.ex", fn file -> + assert_file("lib/phoenix_web/live/comment_live/form.ex", fn file -> assert file =~ "defmodule PhoenixWeb.CommentLive.Form" - end - - assert_receive {:mix_shell, :info, [""" - - Add the live routes to your browser scope in lib/phoenix_web/router.ex: - - live "/comments", CommentLive.Index, :index - live "/comments/new", CommentLive.Form, :new - live "/comments/:id", CommentLive.Show, :show - live "/comments/:id/edit", CommentLive.Form, :edit - """]} - - assert_receive({:mix_shell, :info, [""" - - You must update :phoenix_live_view to v0.18 or later and - :phoenix_live_dashboard to v0.7 or later to use the features - in this generator. - """]}) - end + end) + + assert_receive {:mix_shell, :info, + [ + """ + + Add the live routes to your browser scope in lib/phoenix_web/router.ex: + + live "/comments", CommentLive.Index, :index + live "/comments/new", CommentLive.Form, :new + live "/comments/:id", CommentLive.Show, :show + live "/comments/:id/edit", CommentLive.Form, :edit + """ + ]} + + assert_receive( + {:mix_shell, :info, + [ + """ + + You must update :phoenix_live_view to v0.18 or later and + :phoenix_live_dashboard to v0.7 or later to use the features + in this generator. + """ + ]} + ) + end) end - test "generates into existing context without prompt with --merge-with-existing-context", config do - in_tmp_live_project config.test, fn -> - Gen.Live.run(~w(Blog Post posts title)) + test "generates into existing context without prompt with --merge-with-existing-context", + config do + in_tmp_live_project(config.test, fn -> + Gen.Live.run(~w(Blog Post posts)) - assert_file "lib/phoenix/blog.ex", fn file -> + assert_file("lib/phoenix/blog.ex", fn file -> assert file =~ "def get_post!" assert file =~ "def list_posts" assert file =~ "def create_post" assert file =~ "def update_post" assert file =~ "def delete_post" assert file =~ "def change_post" - end + end) - Gen.Live.run(~w(Blog Comment comments message:string --merge-with-existing-context)) + Gen.Live.run(~w(Blog Comment comments --merge-with-existing-context)) - refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _notice]} + refute_received {:mix_shell, :info, + ["You are generating into an existing context" <> _notice]} - assert_file "lib/phoenix/blog.ex", fn file -> + assert_file("lib/phoenix/blog.ex", fn file -> assert file =~ "def get_comment!" assert file =~ "def list_comments" assert file =~ "def create_comment" assert file =~ "def update_comment" assert file =~ "def delete_comment" assert file =~ "def change_comment" - end - end + end) + end) end test "with --web namespace generates namespaced web modules and directories", config do - in_tmp_live_project config.test, fn -> + in_tmp_live_project(config.test, fn -> + # Accepts first attribute to be required. + send(self(), {:mix_shell_input, :yes?, true}) Gen.Live.run(~w(Blog Post posts title:string --web Blog)) - assert_file "lib/phoenix/blog/post.ex" - assert_file "lib/phoenix/blog.ex" - assert_file "test/phoenix/blog_test.exs" + assert_file("lib/phoenix/blog/post.ex") + assert_file("lib/phoenix/blog.ex") + assert_file("test/phoenix/blog_test.exs") - assert_file "lib/phoenix_web/live/blog/post_live/index.ex", fn file -> + assert_file("lib/phoenix_web/live/blog/post_live/index.ex", fn file -> assert file =~ "defmodule PhoenixWeb.Blog.PostLive.Index" - end + end) - assert_file "lib/phoenix_web/live/blog/post_live/show.ex", fn file -> + assert_file("lib/phoenix_web/live/blog/post_live/show.ex", fn file -> assert file =~ "defmodule PhoenixWeb.Blog.PostLive.Show" - end + end) - assert_file "lib/phoenix_web/live/blog/post_live/form.ex", fn file -> + assert_file("lib/phoenix_web/live/blog/post_live/form.ex", fn file -> assert file =~ "defmodule PhoenixWeb.Blog.PostLive.Form" - end + end) assert [path] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file path, fn file -> - assert file =~ "create table(:posts)" - assert file =~ "add :title, :string" - end - assert_file "lib/phoenix_web/live/blog/post_live/index.ex", fn file -> + assert_file(path, fn file -> + assert file =~ "create table(\"posts\")" + assert file =~ "add :title, :string, null: false" + end) + + assert_file("lib/phoenix_web/live/blog/post_live/index.ex", fn file -> assert file =~ ~S|~p"/blog/posts/#{post}/edit"| assert file =~ ~S|~p"/blog/posts/new"| assert file =~ ~S|~p"/blog/posts/#{post}"| - end + end) - assert_file "lib/phoenix_web/live/blog/post_live/show.ex", fn file -> + assert_file("lib/phoenix_web/live/blog/post_live/show.ex", fn file -> assert file =~ ~S|~p"/blog/posts"| assert file =~ ~S|~p"/blog/posts/#{@post}/edit?return_to=show"| - end + end) - assert_file "test/phoenix_web/live/blog/post_live_test.exs", fn file -> + assert_file("test/phoenix_web/live/blog/post_live_test.exs", fn file -> assert file =~ ~S|~p"/blog/posts"| assert file =~ ~S|~p"/blog/posts/new"| assert file =~ ~S|~p"/blog/posts/#{post}/edit"| - end + end) - assert_receive {:mix_shell, :info, [""" + assert_receive {:mix_shell, :info, + [ + """ - Add the live routes to your Blog :browser scope in lib/phoenix_web/router.ex: + Add the live routes to your Blog :browser scope in lib/phoenix_web/router.ex: - scope "/blog", PhoenixWeb.Blog, as: :blog do - pipe_through :browser - ... + scope "/blog", PhoenixWeb.Blog, as: :blog do + pipe_through :browser + ... - live "/posts", PostLive.Index, :index - live "/posts/new", PostLive.Form, :new - live "/posts/:id", PostLive.Show, :show - live "/posts/:id/edit", PostLive.Form, :edit - end - """]} - end + live "/posts", PostLive.Index, :index + live "/posts/new", PostLive.Form, :new + live "/posts/:id", PostLive.Show, :show + live "/posts/:id/edit", PostLive.Form, :edit + end + """ + ]} + end) end test "with --no-context skips context and schema file generation", config do - in_tmp_live_project config.test, fn -> - Gen.Live.run(~w(Blog Post posts title:string --no-context)) + in_tmp_live_project(config.test, fn -> + Gen.Live.run(~w(Blog Post posts --no-context)) - refute_file "lib/phoenix/blog.ex" - refute_file "lib/phoenix/blog/post.ex" + refute_file("lib/phoenix/blog.ex") + refute_file("lib/phoenix/blog/post.ex") + refute_file("test/phoenix/blog_test.ex") + refute_file("test/support/fixtures/blog_fixtures.ex") assert Path.wildcard("priv/repo/migrations/*.exs") == [] - assert_file "lib/phoenix_web/live/post_live/index.ex" - assert_file "lib/phoenix_web/live/post_live/show.ex" - assert_file "lib/phoenix_web/live/post_live/form.ex" + assert_file("lib/phoenix_web/live/post_live/index.ex") + assert_file("lib/phoenix_web/live/post_live/show.ex") + assert_file("lib/phoenix_web/live/post_live/form.ex") - assert_file "test/phoenix_web/live/post_live_test.exs" - end + assert_file("test/phoenix_web/live/post_live_test.exs") + end) end test "with --no-schema skips schema file generation", config do - in_tmp_live_project config.test, fn -> - Gen.Live.run(~w(Blog Post posts title:string --no-schema)) + in_tmp_live_project(config.test, fn -> + Gen.Live.run(~w(Blog Post posts --no-schema)) - assert_file "lib/phoenix/blog.ex" - refute_file "lib/phoenix/blog/post.ex" + assert_file("lib/phoenix/blog.ex") + refute_file("lib/phoenix/blog/post.ex") assert Path.wildcard("priv/repo/migrations/*.exs") == [] - assert_file "lib/phoenix_web/live/post_live/index.ex" - assert_file "lib/phoenix_web/live/post_live/show.ex" - assert_file "lib/phoenix_web/live/post_live/form.ex" + assert_file("lib/phoenix_web/live/post_live/index.ex") + assert_file("lib/phoenix_web/live/post_live/show.ex") + assert_file("lib/phoenix_web/live/post_live/form.ex") - assert_file "test/phoenix_web/live/post_live_test.exs" - end + assert_file("test/phoenix_web/live/post_live_test.exs") + end) end test "with --no-context does not emit warning when context exists", config do - in_tmp_live_project config.test, fn -> - Gen.Live.run(~w(Blog Post posts title:string)) + in_tmp_live_project(config.test, fn -> + Gen.Live.run(~w(Blog Post posts)) - assert_file "lib/phoenix/blog.ex" - assert_file "lib/phoenix/blog/post.ex" + assert_file("lib/phoenix/blog.ex") + assert_file("lib/phoenix/blog/post.ex") - Gen.Live.run(~w(Blog Comment comments title:string --no-context)) + Gen.Live.run(~w(Blog Comment comments --no-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _]} - assert_file "lib/phoenix_web/live/comment_live/index.ex" - assert_file "lib/phoenix_web/live/comment_live/show.ex" - assert_file "lib/phoenix_web/live/comment_live/form.ex" + assert_file("lib/phoenix_web/live/comment_live/index.ex") + assert_file("lib/phoenix_web/live/comment_live/show.ex") + assert_file("lib/phoenix_web/live/comment_live/form.ex") - assert_file "test/phoenix_web/live/comment_live_test.exs" - end + assert_file("test/phoenix_web/live/comment_live_test.exs") + end) end test "with same singular and plural", config do - in_tmp_live_project config.test, fn -> - Gen.Live.run(~w(Tracker Series series value:integer)) + in_tmp_live_project(config.test, fn -> + Gen.Live.run(~w(Tracker Series series)) - assert_file "lib/phoenix/tracker.ex" - assert_file "lib/phoenix/tracker/series.ex" + assert_file("lib/phoenix/tracker.ex") + assert_file("lib/phoenix/tracker/series.ex") - assert_file "lib/phoenix_web/live/series_live/index.ex", fn file -> + assert_file("lib/phoenix_web/live/series_live/index.ex", fn file -> assert file =~ "|> stream(:series_collection, Tracker.list_series())" - end + end) - assert_file "lib/phoenix_web/live/series_live/show.ex" - assert_file "lib/phoenix_web/live/series_live/form.ex" + assert_file("lib/phoenix_web/live/series_live/show.ex") + assert_file("lib/phoenix_web/live/series_live/form.ex") - assert_file "lib/phoenix_web/live/series_live/index.ex", fn file -> + assert_file("lib/phoenix_web/live/series_live/index.ex", fn file -> assert file =~ "@streams.series_collection" - end + end) - assert_file "test/phoenix_web/live/series_live_test.exs" - end + assert_file("test/phoenix_web/live/series_live_test.exs") + end) end test "when more than 50 attributes are given", config do - in_tmp_live_project config.test, fn -> - long_attribute_list = Enum.map_join(0..55, " ", &("attribute#{&1}:string")) - Gen.Live.run(~w(Blog Post posts title #{long_attribute_list})) + in_tmp_live_project(config.test, fn -> + long_attribute_list = Enum.map_join(0..55, " ", &"attribute#{&1}:string") + Gen.Live.run(~w(Blog Post posts title:string:* #{long_attribute_list})) - assert_file "test/phoenix/blog_test.exs", fn file -> - refute file =~ "...}" - end - assert_file "test/phoenix_web/live/post_live_test.exs", fn file -> + assert_file("test/phoenix_web/live/post_live_test.exs", fn file -> refute file =~ "...}" - end - end + end) + end) end describe "inside umbrella" do test "without context_app generators config uses web dir", config do - in_tmp_live_umbrella_project config.test, fn -> + in_tmp_live_umbrella_project(config.test, fn -> File.cd!("phoenix_web") - Application.put_env(:phoenix, :generators, context_app: nil) - Gen.Live.run(~w(Accounts User users name:string)) - assert_file "lib/phoenix/accounts.ex" - assert_file "lib/phoenix/accounts/user.ex" + Gen.Live.run(~w(Accounts User users)) + + assert_file("lib/phoenix/accounts.ex") + assert_file("lib/phoenix/accounts/user.ex") - assert_file "lib/phoenix_web/live/user_live/index.ex", fn file -> + assert_file("lib/phoenix_web/live/user_live/index.ex", fn file -> assert file =~ "defmodule PhoenixWeb.UserLive.Index" assert file =~ "use PhoenixWeb, :live_view" - end + end) - assert_file "lib/phoenix_web/live/user_live/show.ex", fn file -> + assert_file("lib/phoenix_web/live/user_live/show.ex", fn file -> assert file =~ "defmodule PhoenixWeb.UserLive.Show" assert file =~ "use PhoenixWeb, :live_view" - end + end) - assert_file "lib/phoenix_web/live/user_live/form.ex", fn file -> + assert_file("lib/phoenix_web/live/user_live/form.ex", fn file -> assert file =~ "defmodule PhoenixWeb.UserLive.Form" assert file =~ "use PhoenixWeb, :live_view" - end + end) - assert_file "test/phoenix_web/live/user_live_test.exs", fn file -> + assert_file("test/phoenix_web/live/user_live_test.exs", fn file -> assert file =~ "defmodule PhoenixWeb.UserLiveTest" - end - end + end) + end) end test "raises with false context_app", config do - in_tmp_live_umbrella_project config.test, fn -> + in_tmp_live_umbrella_project(config.test, fn -> Application.put_env(:phoenix, :generators, context_app: false) + assert_raise Mix.Error, ~r/no context_app configured/, fn -> Gen.Live.run(~w(Accounts User users name:string)) end - end + end) end test "with context_app generators config does not use web dir", config do - in_tmp_live_umbrella_project config.test, fn -> + in_tmp_live_umbrella_project(config.test, fn -> File.mkdir!("another_app") Application.put_env(:phoenix, :generators, context_app: {:another_app, "another_app"}) File.cd!("phoenix") - Gen.Live.run(~w(Accounts User users name:string)) + Gen.Live.run(~w(Accounts User users)) - assert_file "another_app/lib/another_app/accounts.ex" - assert_file "another_app/lib/another_app/accounts/user.ex" + assert_file("another_app/lib/another_app/accounts.ex") + assert_file("another_app/lib/another_app/accounts/user.ex") - - assert_file "lib/phoenix/live/user_live/index.ex", fn file -> + assert_file("lib/phoenix/live/user_live/index.ex", fn file -> assert file =~ "defmodule Phoenix.UserLive.Index" assert file =~ "use Phoenix, :live_view" - end + end) - assert_file "lib/phoenix/live/user_live/show.ex", fn file -> + assert_file("lib/phoenix/live/user_live/show.ex", fn file -> assert file =~ "defmodule Phoenix.UserLive.Show" assert file =~ "use Phoenix, :live_view" - end + end) - assert_file "lib/phoenix/live/user_live/form.ex", fn file -> + assert_file("lib/phoenix/live/user_live/form.ex", fn file -> assert file =~ "defmodule Phoenix.UserLive.Form" assert file =~ "use Phoenix, :live_view" - end + end) - assert_file "test/phoenix/live/user_live_test.exs", fn file -> + assert_file("test/phoenix/live/user_live_test.exs", fn file -> assert file =~ "defmodule Phoenix.UserLiveTest" - end - end + end) + end) end end end diff --git a/test/mix/tasks/phx.gen.schema_test.exs b/test/mix/tasks/phx.gen.schema_test.exs index f808e8784a..ad2e337e28 100644 --- a/test/mix/tasks/phx.gen.schema_test.exs +++ b/test/mix/tasks/phx.gen.schema_test.exs @@ -1,13 +1,10 @@ -Code.require_file "../../../installer/test/mix_helper.exs", __DIR__ - -defmodule Phoenix.DupSchema do -end +Code.require_file("../../../installer/test/mix_helper.exs", __DIR__) defmodule Mix.Tasks.Phx.Gen.SchemaTest do use ExUnit.Case import MixHelper alias Mix.Tasks.Phx.Gen - alias Mix.Phoenix.Schema + alias Mix.Phoenix.{Schema, Attribute} setup do Mix.Task.clear() @@ -15,66 +12,51 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do end test "build" do - in_tmp_project "build", fn -> - schema = Gen.Schema.build(~w(Blog.Post posts title:string tags:map), []) + in_tmp_project("build", fn -> + # Accepts first attribute to be required. + send(self(), {:mix_shell_input, :yes?, true}) + schema = Gen.Schema.build(~w(Blog.Post posts title content:text tags:map), []) assert %Schema{ - alias: Post, - module: Phoenix.Blog.Post, - repo: Phoenix.Repo, - migration?: true, - migration_defaults: %{title: ""}, - plural: "posts", - singular: "post", - human_plural: "Posts", - human_singular: "Post", - attrs: [title: :string, tags: :map], - types: [title: :string, tags: :map], - optionals: [:tags], - route_helper: "post", - defaults: %{title: "", tags: ""}, - } = schema + alias: Post, + module: Phoenix.Blog.Post, + repo: Phoenix.Repo, + migration?: true, + plural: "posts", + singular: "post", + human_plural: "Posts", + human_singular: "Post", + attrs: [ + %Attribute{name: :title, type: :string, options: %{required: true}}, + %Attribute{name: :content, type: :text, options: %{}}, + %Attribute{name: :tags, type: :map, options: %{}} + ], + route_helper: "post" + } = schema + assert String.ends_with?(schema.file, "lib/phoenix/blog/post.ex") - end + end) end test "build with nested web namespace", config do - in_tmp_project config.test, fn -> - schema = Gen.Schema.build(~w(Blog.Post posts title:string --web API.V1), []) + in_tmp_project(config.test, fn -> + schema = Gen.Schema.build(~w(Blog.Post posts title:string:* --web API.V1), []) assert %Schema{ - alias: Post, - module: Phoenix.Blog.Post, - repo: Phoenix.Repo, - migration?: true, - migration_defaults: %{title: ""}, - plural: "posts", - singular: "post", - human_plural: "Posts", - human_singular: "Post", - attrs: [title: :string], - types: [title: :string], - route_helper: "api_v1_post", - defaults: %{title: ""}, - } = schema - assert String.ends_with?(schema.file, "lib/phoenix/blog/post.ex") - end - end + alias: Post, + module: Phoenix.Blog.Post, + repo: Phoenix.Repo, + migration?: true, + plural: "posts", + singular: "post", + human_plural: "Posts", + human_singular: "Post", + attrs: [%Attribute{name: :title, type: :string, options: %{required: true}}], + route_helper: "api_v1_post" + } = schema - test "table name missing from references", config do - in_tmp_project config.test, fn -> - assert_raise Mix.Error, ~r/expect the table to be given to user_id:references/, fn -> - Gen.Schema.run(~w(Blog.Post posts user_id:references)) - end - end - end - - test "type missing from array", config do - in_tmp_project config.test, fn -> - assert_raise Mix.Error, ~r/expect the type of the array to be given to settings:array/, fn -> - Gen.Schema.run(~w(Blog.Post posts settings:array)) - end - end + assert String.ends_with?(schema.file, "lib/phoenix/blog/post.ex") + end) end test "plural can't contain a colon" do @@ -94,297 +76,359 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do end test "table name omitted", config do - in_tmp_project config.test, fn -> + in_tmp_project(config.test, fn -> assert_raise Mix.Error, fn -> Gen.Schema.run(~w(Blog.Post)) end - end + end) end test "generates schema", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post blog_posts title:string)) - assert_file "lib/phoenix/blog/post.ex" + in_tmp_project(config.test, fn -> + Gen.Schema.run(~w(Blog.Post blog_posts)) + + assert_file("lib/phoenix/blog/post.ex") assert [migration] = Path.wildcard("priv/repo/migrations/*_create_blog_posts.exs") - assert_file migration, fn file -> - assert file =~ "create table(:blog_posts) do" - end - end + + assert_file(migration, fn file -> + assert file =~ "create table(\"blog_posts\") do" + end) + end) end test "allows a custom repo", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post blog_posts title:string --repo MyApp.CustomRepo)) + in_tmp_project(config.test, fn -> + Gen.Schema.run(~w(Blog.Post blog_posts --repo MyApp.CustomRepo)) assert [migration] = Path.wildcard("priv/custom_repo/migrations/*_create_blog_posts.exs") - assert_file migration, fn file -> + + assert_file(migration, fn file -> assert file =~ "defmodule MyApp.CustomRepo.Migrations.CreateBlogPosts do" - end - end + end) + end) end test "allows a custom migration dir", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post blog_posts title:string --migration-dir priv/custom_dir)) + in_tmp_project(config.test, fn -> + Gen.Schema.run(~w(Blog.Post blog_posts --migration-dir priv/custom_dir)) assert [migration] = Path.wildcard("priv/custom_dir/*_create_blog_posts.exs") - assert_file migration, fn file -> + + assert_file(migration, fn file -> assert file =~ "defmodule Phoenix.Repo.Migrations.CreateBlogPosts do" - end - end + end) + end) end test "custom migration_dir takes precedence over custom repo name", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post blog_posts title:string \ - --repo MyApp.CustomRepo --migration-dir priv/custom_dir)) + in_tmp_project(config.test, fn -> + Gen.Schema.run( + ~w(Blog.Post blog_posts --repo MyApp.CustomRepo --migration-dir priv/custom_dir) + ) assert [migration] = Path.wildcard("priv/custom_dir/*_create_blog_posts.exs") - assert_file migration, fn file -> + + assert_file(migration, fn file -> assert file =~ "defmodule MyApp.CustomRepo.Migrations.CreateBlogPosts do" - end - end + end) + end) end - test "does not add maps to the required list", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post blog_posts title:string tags:map published_at:naive_datetime)) - assert_file "lib/phoenix/blog/post.ex", fn file -> - assert file =~ "cast(attrs, [:title, :tags, :published_at]" - assert file =~ "validate_required([:title, :published_at]" - end - end + test "adds validation for required fields", config do + in_tmp_project(config.test, fn -> + Gen.Schema.run(~w(Blog.Post posts title:string:* tags:map:* published_at:naive_datetime)) + + assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") + + assert_file(migration, fn file -> + assert file =~ "defmodule Phoenix.Repo.Migrations.CreatePosts do" + assert file =~ "create table(\"posts\") do" + assert file =~ "add :title, :string, null: false" + assert file =~ "add :tags, :map, null: false" + assert file =~ "add :published_at, :naive_datetime" + end) + + assert_file("lib/phoenix/blog/post.ex", fn file -> + assert file =~ "cast(attrs, [:published_at, :tags, :title]" + assert file =~ "validate_required([:tags, :title]" + end) + end) end test "generates nested schema", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Admin.User users name:string)) + in_tmp_project(config.test, fn -> + Gen.Schema.run(~w(Blog.Admin.User users)) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_users.exs") - assert_file migration, fn file -> + + assert_file(migration, fn file -> assert file =~ "defmodule Phoenix.Repo.Migrations.CreateUsers do" - assert file =~ "create table(:users) do" - end + assert file =~ "create table(\"users\") do" + end) - assert_file "lib/phoenix/blog/admin/user.ex", fn file -> + assert_file("lib/phoenix/blog/admin/user.ex", fn file -> assert file =~ "defmodule Phoenix.Blog.Admin.User do" assert file =~ "schema \"users\" do" - end - end + end) + end) end test "generates custom table name", config do - in_tmp_project config.test, fn -> + in_tmp_project(config.test, fn -> Gen.Schema.run(~w(Blog.Post posts --table cms_posts)) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_cms_posts.exs") - assert_file migration, fn file -> - assert file =~ "create table(:cms_posts) do" - end - end + + assert_file(migration, fn file -> + assert file =~ "create table(\"cms_posts\") do" + end) + end) end - test "generates unique indices" , config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts title:unique secret:redact unique_int:integer:unique)) + test "generates unique indices", config do + in_tmp_project(config.test, fn -> + Gen.Schema.run( + ~w(Blog.Post posts title:string:*:unique secret:string:redact unique_int:integer:unique) + ) + assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file migration, fn file -> + assert_file(migration, fn file -> assert file =~ "defmodule Phoenix.Repo.Migrations.CreatePosts do" - assert file =~ "create table(:posts) do" - assert file =~ "add :title, :string" + assert file =~ "create table(\"posts\") do" + assert file =~ "add :title, :string, null: false" assert file =~ "add :unique_int, :integer" assert file =~ "add :secret, :string" - assert file =~ "create unique_index(:posts, [:title])" - assert file =~ "create unique_index(:posts, [:unique_int])" - end + assert file =~ "create index(\"posts\", [:title], unique: true)" + assert file =~ "create index(\"posts\", [:unique_int], unique: true)" + end) - assert_file "lib/phoenix/blog/post.ex", fn file -> + assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "defmodule Phoenix.Blog.Post do" assert file =~ "schema \"posts\" do" assert file =~ "field :title, :string" assert file =~ "field :unique_int, :integer" assert file =~ "field :secret, :string, redact: true" - end - end + end) + end) end test "generates references and belongs_to associations", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts title user_id:references:users)) + in_tmp_project(config.test, fn -> + Gen.Schema.run(~w(Blog.Post posts title user_id:references:*:table,users:column,id:type,id)) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file migration, fn file -> - assert file =~ "add :user_id, references(:users, on_delete: :nothing)" - assert file =~ "create index(:posts, [:user_id])" - end + assert_file(migration, fn file -> + assert file =~ "add :user_id, references(\"users\", on_delete: :nothing), null: false" + assert file =~ "create index(\"posts\", [:user_id])" + end) - assert_file "lib/phoenix/blog/post.ex", fn file -> - assert file =~ "field :user_id, :id" - end - end + assert_file("lib/phoenix/blog/post.ex", fn file -> + assert file =~ "belongs_to :user, Phoenix.Blog.User" + end) + end) end test "generates references with unique indexes", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts title user_id:references:users unique_post_id:references:posts:unique)) + in_tmp_project(config.test, fn -> + Gen.Schema.run( + ~w(Blog.Post posts + title + user_id:references:*:table,users:column,id:type,binary_id:assoc,author + unique_category_id:references:table,categories:column,id:type,id:Catalog.Category:unique) + ) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file migration, fn file -> + assert_file(migration, fn file -> assert file =~ "defmodule Phoenix.Repo.Migrations.CreatePosts do" - assert file =~ "create table(:posts) do" - assert file =~ "add :user_id, references(:users, on_delete: :nothing)" - assert file =~ "add :unique_post_id, references(:posts, on_delete: :nothing)" - assert file =~ "create index(:posts, [:user_id])" - assert file =~ "create unique_index(:posts, [:unique_post_id])" - end + assert file =~ "create table(\"posts\") do" + + assert file =~ + "add :user_id, references(\"users\", type: :binary_id, on_delete: :nothing), null: false" - assert_file "lib/phoenix/blog/post.ex", fn file -> + assert file =~ "add :unique_category_id, references(\"categories\", on_delete: :nothing)" + assert file =~ "create index(\"posts\", [:user_id])" + assert file =~ "create index(\"posts\", [:unique_category_id], unique: true)" + end) + + assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "defmodule Phoenix.Blog.Post do" - assert file =~ "field :user_id, :id" - assert file =~ "field :unique_post_id, :id" - end - end + assert file =~ "belongs_to :unique_category, Phoenix.Catalog.Category" + + assert file =~ + "belongs_to :author, Phoenix.Blog.User, foreign_key: :user_id, type: :binary_id" + end) + end) end test "generates schema with proper datetime types", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Comment comments title:string drafted_at:datetime published_at:naive_datetime edited_at:utc_datetime locked_at:naive_datetime_usec)) + in_tmp_project(config.test, fn -> + Gen.Schema.run( + ~w(Blog.Comment comments title:string:* drafted_at:datetime published_at:naive_datetime edited_at:utc_datetime locked_at:naive_datetime_usec) + ) - assert_file "lib/phoenix/blog/comment.ex", fn file -> + assert_file("lib/phoenix/blog/comment.ex", fn file -> assert file =~ "field :drafted_at, :naive_datetime" assert file =~ "field :published_at, :naive_datetime" assert file =~ "field :locked_at, :naive_datetime_usec" assert file =~ "field :edited_at, :utc_datetime" - end + end) assert [path] = Path.wildcard("priv/repo/migrations/*_create_comments.exs") - assert_file path, fn file -> - assert file =~ "create table(:comments)" + + assert_file(path, fn file -> + assert file =~ "create table(\"comments\")" assert file =~ "add :drafted_at, :naive_datetime" assert file =~ "add :published_at, :naive_datetime" assert file =~ "add :edited_at, :utc_datetime" - end - end + end) + end) end test "generates schema with enum", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Comment comments title:string status:enum:unpublished:published:deleted)) + in_tmp_project(config.test, fn -> + Gen.Schema.run( + ~w(Blog.Comment comments title:string status:enum:[unpublished,published,deleted]:*) + ) - assert_file "lib/phoenix/blog/comment.ex", fn file -> + assert_file("lib/phoenix/blog/comment.ex", fn file -> assert file =~ "field :status, Ecto.Enum, values: [:unpublished, :published, :deleted]" - end + end) assert [path] = Path.wildcard("priv/repo/migrations/*_create_comments.exs") - assert_file path, fn file -> - assert file =~ "create table(:comments)" - assert file =~ "add :status, :string" - end - end + + assert_file(path, fn file -> + assert file =~ "create table(\"comments\")" + assert file =~ "add :status, :string, null: false" + end) + end) end test "generates migration with binary_id", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts title user_id:references:users --binary-id)) + in_tmp_project(config.test, fn -> + Gen.Schema.run( + ~w(Blog.Post posts title user_id:references:*:table,users:column,id:type,binary_id --binary-id) + ) - assert_file "lib/phoenix/blog/post.ex", fn file -> - assert file =~ "field :user_id, :binary_id" - end + assert_file("lib/phoenix/blog/post.ex", fn file -> + assert file =~ "@primary_key {:id, :binary_id, autogenerate: true}" + assert file =~ "field :title, :string" + assert file =~ "belongs_to :user, Phoenix.Blog.User, type: :binary_id" + end) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file migration, fn file -> - assert file =~ "create table(:posts, primary_key: false) do" + + assert_file(migration, fn file -> + assert file =~ "create table(\"posts\", primary_key: false) do" assert file =~ "add :id, :binary_id, primary_key: true" - assert file =~ "add :user_id, references(:users, on_delete: :nothing, type: :binary_id)" - end - end + assert file =~ "add :title, :string" + + assert file =~ + "add :user_id, references(\"users\", type: :binary_id, on_delete: :nothing), null: false" + end) + end) end test "generates migration with custom primary key", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts title user_id:references:users --binary-id --primary-key post_id)) + in_tmp_project(config.test, fn -> + Gen.Schema.run( + ~w(Blog.Post posts title user_id:references:table,users:column,id:type,id:* --binary-id --primary-key post_id) + ) - assert_file "lib/phoenix/blog/post.ex", fn file -> + assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "@derive {Phoenix.Param, key: :post_id}" assert file =~ "@primary_key {:post_id, :binary_id, autogenerate: true}" - assert file =~ "field :user_id, :binary_id" - end + assert file =~ "belongs_to :user, Phoenix.Blog.User" + end) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file migration, fn file -> - assert file =~ "create table(:posts, primary_key: false) do" + + assert_file(migration, fn file -> + assert file =~ "create table(\"posts\", primary_key: false) do" assert file =~ "add :post_id, :binary_id, primary_key: true" - assert file =~ "add :user_id, references(:users, on_delete: :nothing, type: :binary_id)" - end - end + assert file =~ "add :title, :string" + + assert file =~ "add :user_id, references(\"users\", on_delete: :nothing), null: false" + end) + end) end test "generates schema and migration with prefix", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts title --prefix cms)) + in_tmp_project(config.test, fn -> + Gen.Schema.run(~w(Blog.Post posts --prefix cms)) - assert_file "lib/phoenix/blog/post.ex", fn file -> + assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "@schema_prefix :cms" - end + end) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file migration, fn file -> - assert file =~ "create table(:posts, prefix: :cms) do" - end - end + + assert_file(migration, fn file -> + assert file =~ "create table(\"posts\", prefix: :cms) do" + end) + end) end test "skips migration with --no-migration option", config do - in_tmp_project config.test, fn -> + in_tmp_project(config.test, fn -> Gen.Schema.run(~w(Blog.Post posts --no-migration)) assert [] = Path.wildcard("priv/repo/migrations/*") - end + end) end test "uses defaults from :generators configuration" do - in_tmp_project "uses defaults from generators configuration (migration)", fn -> - with_generator_env [migration: false], fn -> + in_tmp_project("uses defaults from generators configuration (migration)", fn -> + with_generator_env([migration: false], fn -> Gen.Schema.run(~w(Blog.Post posts)) assert [] = Path.wildcard("priv/repo/migrations/*") - end - end + end) + end) - in_tmp_project "uses defaults from generators configuration (binary_id)", fn -> - with_generator_env [binary_id: true], fn -> - Gen.Schema.run(~w(Blog.Post posts)) + in_tmp_project("uses defaults from generators configuration (binary_id)", fn -> + with_generator_env([binary_id: true], fn -> + Gen.Schema.run( + ~w(Blog.Post posts title user_id:references:*:table,users:column,id:type,id) + ) + + assert_file("lib/phoenix/blog/post.ex", fn file -> + assert file =~ "@primary_key {:id, :binary_id, autogenerate: true}" + assert file =~ "field :title, :string" + assert file =~ "belongs_to :user, Phoenix.Blog.User" + end) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file migration, fn file -> - assert file =~ "create table(:posts, primary_key: false) do" + assert_file(migration, fn file -> + assert file =~ "create table(\"posts\", primary_key: false) do" assert file =~ "add :id, :binary_id, primary_key: true" - end - end - end - - in_tmp_project "uses defaults from generators configuration (:utc_datetime)", fn -> - with_generator_env [timestamp_type: :utc_datetime], fn -> + assert file =~ "add :title, :string" + assert file =~ "add :user_id, references(\"users\", on_delete: :nothing), null: false" + end) + end) + end) + + in_tmp_project("uses defaults from generators configuration (:utc_datetime)", fn -> + with_generator_env([timestamp_type: :utc_datetime], fn -> Gen.Schema.run(~w(Blog.Post posts)) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file migration, fn file -> + assert_file(migration, fn file -> assert file =~ "timestamps(type: :utc_datetime)" - end + end) - assert_file "lib/phoenix/blog/post.ex", fn file -> + assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "timestamps(type: :utc_datetime)" - end - end - end + end) + end) + end) end test "generates migrations with a custom migration module", config do - in_tmp_project config.test, fn -> + in_tmp_project(config.test, fn -> try do Application.put_env(:ecto_sql, :migration_module, MyCustomApp.MigrationModule) @@ -392,57 +436,57 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") - assert_file migration, fn file -> + assert_file(migration, fn file -> assert file =~ "use MyCustomApp.MigrationModule" - assert file =~ "create table(:posts) do" - end + assert file =~ "create table(\"posts\") do" + end) after Application.delete_env(:ecto_sql, :migration_module) end - end + end) end test "generates schema without extra line break", config do - in_tmp_project config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts title)) + in_tmp_project(config.test, fn -> + Gen.Schema.run(~w(Blog.Post posts)) - assert_file "lib/phoenix/blog/post.ex", fn file -> + assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "import Ecto.Changeset\n\n schema" - end - end + end) + end) end describe "inside umbrella" do test "raises with false context_app", config do - in_tmp_umbrella_project config.test, fn -> + in_tmp_umbrella_project(config.test, fn -> Application.put_env(:phoenix, :generators, context_app: false) assert_raise Mix.Error, ~r/no context_app configured/, fn -> Gen.Schema.run(~w(Blog.Post blog_posts title:string)) end - end + end) end test "with context_app set to nil", config do - in_tmp_umbrella_project config.test, fn -> + in_tmp_umbrella_project(config.test, fn -> Application.put_env(:phoenix, :generators, context_app: nil) - Gen.Schema.run(~w(Blog.Post blog_posts title:string)) + Gen.Schema.run(~w(Blog.Post blog_posts title:string:*)) - assert_file "lib/phoenix/blog/post.ex" + assert_file("lib/phoenix/blog/post.ex") assert [_] = Path.wildcard("priv/repo/migrations/*_create_blog_posts.exs") - end + end) end test "with context_app", config do - in_tmp_umbrella_project config.test, fn -> + in_tmp_umbrella_project(config.test, fn -> Application.put_env(:phoenix, :generators, context_app: {:another_app, "another_app"}) - Gen.Schema.run(~w(Blog.Post blog_posts title:string)) + Gen.Schema.run(~w(Blog.Post blog_posts)) - assert_file "another_app/lib/another_app/blog/post.ex" + assert_file("another_app/lib/another_app/blog/post.ex") assert [_] = Path.wildcard("another_app/priv/repo/migrations/*_create_blog_posts.exs") - end + end) end end end diff --git a/test/mix/tasks/phx_test.exs b/test/mix/tasks/phx_test.exs index 8829b70e0a..0c9f5382a4 100644 --- a/test/mix/tasks/phx_test.exs +++ b/test/mix/tasks/phx_test.exs @@ -2,7 +2,7 @@ defmodule Mix.Tasks.Phx.Test do use ExUnit.Case test "provide a list of available phx mix tasks" do - Mix.Tasks.Phx.run [] + Mix.Tasks.Phx.run([]) assert_received {:mix_shell, :info, ["mix phx.digest" <> _]} assert_received {:mix_shell, :info, ["mix phx.digest.clean" <> _]} assert_received {:mix_shell, :info, ["mix phx.gen.channel" <> _]} @@ -12,11 +12,12 @@ defmodule Mix.Tasks.Phx.Test do assert_received {:mix_shell, :info, ["mix phx.gen.html" <> _]} assert_received {:mix_shell, :info, ["mix phx.gen.json" <> _]} assert_received {:mix_shell, :info, ["mix phx.gen.live" <> _]} + assert_received {:mix_shell, :info, ["mix phx.gen.schema" <> _]} end test "expects no arguments" do assert_raise Mix.Error, fn -> - Mix.Tasks.Phx.run ["invalid"] + Mix.Tasks.Phx.run(["invalid"]) end end end diff --git a/test/phoenix/verified_routes_test.exs b/test/phoenix/verified_routes_test.exs index 52723f2ae1..1134e5ac1c 100644 --- a/test/phoenix/verified_routes_test.exs +++ b/test/phoenix/verified_routes_test.exs @@ -521,7 +521,6 @@ defmodule Phoenix.VerifiedRoutesTest do assert warnings =~ ~r"test/phoenix/verified_routes_test.exs:#{line}:(\d+:)? Phoenix.VerifiedRoutesTest.Forwards.test/0" - after :code.purge(__MODULE__.Forwards) :code.delete(__MODULE__.Forwards) From 61395fb92d3192f8d95ffb665bfaa0dfae4f5af9 Mon Sep 17 00:00:00 2001 From: Pavel Shpak Date: Thu, 28 Nov 2024 15:09:55 +0200 Subject: [PATCH 2/6] Adjust decimal value preparation for old `String.slice` behavior, in elixir versions 1.11.4 and 1.12.3 --- lib/mix/phoenix/attribute.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/mix/phoenix/attribute.ex b/lib/mix/phoenix/attribute.ex index 056302960d..a9d11e0ce0 100644 --- a/lib/mix/phoenix/attribute.ex +++ b/lib/mix/phoenix/attribute.ex @@ -701,7 +701,8 @@ defmodule Mix.Phoenix.Attribute do scale = Map.get(options, :scale, scale_default) fractional_part = fractional_part |> String.slice(0, scale) |> String.pad_trailing(scale, "0") - whole_length = precision - scale + # NOTE: `min` applied to adjust for old `String.slice` behavior, in elixir versions 1.11.4 and 1.12.3 + whole_length = [String.length(whole_part), precision - scale] |> Enum.min() whole_part = whole_part |> String.slice(-whole_length, whole_length) "#{whole_part}.#{fractional_part}" From 043a62780bfde80058e8f52f2d8f6f6db051ee58 Mon Sep 17 00:00:00 2001 From: Pavel Shpak Date: Sun, 8 Dec 2024 16:49:58 +0200 Subject: [PATCH 3/6] Fix old original bug - missing application of prefix option to generated indexes. According to doc: https://hexdocs.pm/ecto_sql/Ecto.Migration.html#module-prefixes --- lib/mix/phoenix/migration.ex | 9 +++--- lib/mix/tasks/phx.gen.schema.ex | 1 + test/mix/phoenix/migration_test.exs | 44 ++++++++++++++++++-------- test/mix/tasks/phx.gen.schema_test.exs | 5 +-- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/lib/mix/phoenix/migration.ex b/lib/mix/phoenix/migration.ex index e14d7da500..03697cdde8 100644 --- a/lib/mix/phoenix/migration.ex +++ b/lib/mix/phoenix/migration.ex @@ -22,7 +22,7 @@ defmodule Mix.Phoenix.Migration do """ def table_options(%Schema{} = schema) do primary_key = if schema.binary_id || schema.opts[:primary_key], do: ", primary_key: false" - prefix = if schema.prefix, do: ", prefix: :#{schema.prefix}" + prefix = if schema.prefix, do: ~s/, prefix: "#{schema.prefix}"/ [primary_key, prefix] |> Enum.map_join(&(&1 || "")) end @@ -101,13 +101,14 @@ defmodule Mix.Phoenix.Migration do |> Attribute.indexed() |> Attribute.without_virtual() |> Attribute.sort() - |> Enum.map(&~s/create index("#{schema.table}", #{index_specifics(&1)})/) + |> Enum.map(&index_specifics(&1, schema)) |> Mix.Phoenix.indent_text(spaces: 4, top: 2) end - defp index_specifics(attr) do + defp index_specifics(attr, schema) do + prefix = if schema.prefix, do: ~s/, prefix: "#{schema.prefix}"/ unique = if attr.options[:unique], do: ", unique: true" - "[:#{attr.name}]#{unique}" + ~s/create index("#{schema.table}", [:#{attr.name}]#{prefix}#{unique})/ end end diff --git a/lib/mix/tasks/phx.gen.schema.ex b/lib/mix/tasks/phx.gen.schema.ex index b3c9f1d8c4..07f94f4127 100644 --- a/lib/mix/tasks/phx.gen.schema.ex +++ b/lib/mix/tasks/phx.gen.schema.ex @@ -123,6 +123,7 @@ defmodule Mix.Tasks.Phx.Gen.Schema do For PostgreSQL this sets the "SCHEMA" (typically set via `search_path`) and for MySQL it sets the database for the generated migration and schema. + Prefix is also applied to indexes in migration. The prefix can be used to thematically organize your tables on the database level. A prefix can be specified with the `--prefix` flags. For example: diff --git a/test/mix/phoenix/migration_test.exs b/test/mix/phoenix/migration_test.exs index ecdc58e16a..73b6541121 100644 --- a/test/mix/phoenix/migration_test.exs +++ b/test/mix/phoenix/migration_test.exs @@ -13,12 +13,12 @@ defmodule Mix.Phoenix.MigrationTest do test "table_options/1 returns possible table options" do assert Migration.table_options(%Schema{}) == "" - assert Migration.table_options(%Schema{prefix: "some_prefix"}) == ", prefix: :some_prefix" + assert Migration.table_options(%Schema{prefix: "some_prefix"}) == ", prefix: \"some_prefix\"" assert Migration.table_options(%Schema{binary_id: true}) == ", primary_key: false" assert Migration.table_options(%Schema{opts: [primary_key: "uuid"]}) == ", primary_key: false" schema = %Schema{prefix: "some_prefix", binary_id: true, opts: [primary_key: "uuid"]} - assert Migration.table_options(schema) == ", primary_key: false, prefix: :some_prefix" + assert Migration.table_options(schema) == ", primary_key: false, prefix: \"some_prefix\"" end test "maybe_specific_primary_key/1 returns specific primary key column by options " <> @@ -143,19 +143,37 @@ defmodule Mix.Phoenix.MigrationTest do assert Migration.timestamps_type(schema) == "type: :utc_datetime" end - test "indexes/1 returns formatted indexes" do - schema = %Schema{table: "comments", attrs: @parsed_attrs} + describe "indexes/1" do + test "returns formatted indexes" do + schema = %Schema{table: "comments", attrs: @parsed_attrs} - assert Migration.indexes(schema) == - """ + assert Migration.indexes(schema) == + """ - create index("comments", [:points], unique: true) - create index("comments", [:title]) - create index("comments", [:book_id]) - create index("comments", [:booking_id], unique: true) - create index("comments", [:post_id]) - """ - |> String.trim_trailing("\n") + create index("comments", [:points], unique: true) + create index("comments", [:title]) + create index("comments", [:book_id]) + create index("comments", [:booking_id], unique: true) + create index("comments", [:post_id]) + """ + |> String.trim_trailing("\n") + end + + test "applies prefix option" do + schema = %Schema{table: "comments", attrs: @parsed_attrs, prefix: "some_prefix"} + + assert Migration.indexes(schema) == + """ + + + create index("comments", [:points], prefix: "some_prefix", unique: true) + create index("comments", [:title], prefix: "some_prefix") + create index("comments", [:book_id], prefix: "some_prefix") + create index("comments", [:booking_id], prefix: "some_prefix", unique: true) + create index("comments", [:post_id], prefix: "some_prefix") + """ + |> String.trim_trailing("\n") + end end end diff --git a/test/mix/tasks/phx.gen.schema_test.exs b/test/mix/tasks/phx.gen.schema_test.exs index ad2e337e28..91ada1cc42 100644 --- a/test/mix/tasks/phx.gen.schema_test.exs +++ b/test/mix/tasks/phx.gen.schema_test.exs @@ -357,7 +357,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do test "generates schema and migration with prefix", config do in_tmp_project(config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts --prefix cms)) + Gen.Schema.run(~w(Blog.Post posts title:string:*:index --prefix cms)) assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "@schema_prefix :cms" @@ -366,7 +366,8 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") assert_file(migration, fn file -> - assert file =~ "create table(\"posts\", prefix: :cms) do" + assert file =~ "create table(\"posts\", prefix: \"cms\") do" + assert file =~ "create index(\"posts\", [:title], prefix: \"cms\")" end) end) end From 6a8cb9633f6eba2b2e635a207ebfbeb7286a5ac6 Mon Sep 17 00:00:00 2001 From: Pavel Shpak Date: Mon, 9 Dec 2024 21:40:02 +0200 Subject: [PATCH 4/6] Keep old behavior on missing default string type even when options are provided. --- .../app_with_defaults_test.exs | 12 +- .../app_with_mssql_adapter_test.exs | 14 +- .../app_with_mysql_adapter_test.exs | 6 +- .../app_with_sqlite3_adapter.exs | 6 +- .../umbrella_app_with_defaults_test.exs | 12 +- lib/mix/phoenix.ex | 9 +- lib/mix/phoenix/attribute.ex | 270 ++++++++---------- lib/mix/phoenix/migration.ex | 4 +- lib/mix/phoenix/schema.ex | 8 +- test/mix/phoenix/attribute_test.exs | 32 ++- test/mix/tasks/phx.gen.context_test.exs | 19 +- test/mix/tasks/phx.gen.embedded_test.exs | 8 +- test/mix/tasks/phx.gen.html_test.exs | 30 +- test/mix/tasks/phx.gen.json_test.exs | 22 +- test/mix/tasks/phx.gen.live_test.exs | 20 +- test/mix/tasks/phx.gen.schema_test.exs | 29 +- test/mix/tasks/phx_test.exs | 5 +- test/phoenix/verified_routes_test.exs | 1 + 18 files changed, 238 insertions(+), 269 deletions(-) diff --git a/integration_test/test/code_generation/app_with_defaults_test.exs b/integration_test/test/code_generation/app_with_defaults_test.exs index e3f2074900..9de011779a 100644 --- a/integration_test/test/code_generation/app_with_defaults_test.exs +++ b/integration_test/test/code_generation/app_with_defaults_test.exs @@ -28,7 +28,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog") mix_run!( - ~w(phx.gen.html Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.html Blog Post posts title:*:unique body:string status:enum:[unpublished,published,deleted]), app_root_path ) @@ -54,7 +54,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog") mix_run!( - ~w(phx.gen.html Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted] order:integer:unique), + ~w(phx.gen.html Blog Post posts title:*:unique body:string status:enum:[unpublished,published,deleted] order:integer:unique), app_root_path ) @@ -81,7 +81,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog") mix_run!( - ~w(phx.gen.json Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.json Blog Post posts title:*:unique body:string status:enum:[unpublished,published,deleted]), app_root_path ) @@ -107,7 +107,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog") mix_run!( - ~w(phx.gen.json Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.json Blog Post posts title:*:unique body:string status:enum:[unpublished,published,deleted]), app_root_path ) @@ -134,7 +134,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--live"]) mix_run!( - ~w(phx.gen.live Blog Post posts title:string:*:unique body:string p:boolean s:enum:[a,b,c]), + ~w(phx.gen.live Blog Post posts title:*:unique body:string p:boolean s:enum:[a,b,c]), app_root_path ) @@ -163,7 +163,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithDefaultsTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--live"]) mix_run!( - ~w(phx.gen.live Blog Post posts title:string:* body:string public:boolean status:enum:[unpublished,published,deleted]), + ~w(phx.gen.live Blog Post posts title:* body:string public:boolean status:enum:[unpublished,published,deleted]), app_root_path ) diff --git a/integration_test/test/code_generation/app_with_mssql_adapter_test.exs b/integration_test/test/code_generation/app_with_mssql_adapter_test.exs index ccf7906619..6b2b8adb63 100644 --- a/integration_test/test/code_generation/app_with_mssql_adapter_test.exs +++ b/integration_test/test/code_generation/app_with_mssql_adapter_test.exs @@ -9,7 +9,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do generate_phoenix_app(tmp_dir, "default_mssql_app", ["--database", "mssql"]) mix_run!( - ~w(phx.gen.html Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.html Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), app_root_path ) @@ -38,7 +38,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do generate_phoenix_app(tmp_dir, "default_mssql_app", ["--database", "mssql"]) mix_run!( - ~w(phx.gen.json Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.json Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), app_root_path ) @@ -67,7 +67,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do generate_phoenix_app(tmp_dir, "default_mssql_app", ["--database", "mssql", "--live"]) mix_run!( - ~w(phx.gen.live Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.live Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), app_root_path ) @@ -97,7 +97,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) - mix_run!(~w(phx.gen.html Accounts Group groups name:string:*), app_root_path) + mix_run!(~w(phx.gen.html Accounts Group groups name:*), app_root_path) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -125,7 +125,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) - mix_run!(~w(phx.gen.html Accounts Group groups name:string:*), app_root_path) + mix_run!(~w(phx.gen.html Accounts Group groups name:*), app_root_path) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -154,7 +154,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) - mix_run!(~w(phx.gen.html Accounts Group groups name:string:*), app_root_path) + mix_run!(~w(phx.gen.html Accounts Group groups name:*), app_root_path) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ @@ -183,7 +183,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMSSQLAdapterTest do {app_root_path, _} = generate_phoenix_app(tmp_dir, "phx_blog", ["--database", "mssql", "--live"]) - mix_run!(~w(phx.gen.html Accounts Group groups name:string:*), app_root_path) + mix_run!(~w(phx.gen.html Accounts Group groups name:*), app_root_path) modify_file(Path.join(app_root_path, "lib/phx_blog_web/router.ex"), fn file -> inject_before_final_end(file, """ diff --git a/integration_test/test/code_generation/app_with_mysql_adapter_test.exs b/integration_test/test/code_generation/app_with_mysql_adapter_test.exs index a62726ef14..c19f3ad4ac 100644 --- a/integration_test/test/code_generation/app_with_mysql_adapter_test.exs +++ b/integration_test/test/code_generation/app_with_mysql_adapter_test.exs @@ -9,7 +9,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMySqlAdapterTest do generate_phoenix_app(tmp_dir, "default_mysql_app", ["--database", "mysql"]) mix_run!( - ~w(phx.gen.html Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.html Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), app_root_path ) @@ -38,7 +38,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMySqlAdapterTest do generate_phoenix_app(tmp_dir, "default_mysql_app", ["--database", "mysql"]) mix_run!( - ~w(phx.gen.json Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.json Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), app_root_path ) @@ -67,7 +67,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithMySqlAdapterTest do generate_phoenix_app(tmp_dir, "default_mysql_app", ["--database", "mysql", "--live"]) mix_run!( - ~w(phx.gen.live Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.live Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), app_root_path ) diff --git a/integration_test/test/code_generation/app_with_sqlite3_adapter.exs b/integration_test/test/code_generation/app_with_sqlite3_adapter.exs index 0e6b9fc05c..8527786708 100644 --- a/integration_test/test/code_generation/app_with_sqlite3_adapter.exs +++ b/integration_test/test/code_generation/app_with_sqlite3_adapter.exs @@ -9,7 +9,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithSQLite3AdapterTest do generate_phoenix_app(tmp_dir, "default_sqlite3_app", ["--database", "sqlite3"]) mix_run!( - ~w(phx.gen.html Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.html Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), app_root_path ) @@ -38,7 +38,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithSQLite3AdapterTest do generate_phoenix_app(tmp_dir, "default_sqlite3_app", ["--database", "sqlite3"]) mix_run!( - ~w(phx.gen.json Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.json Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), app_root_path ) @@ -67,7 +67,7 @@ defmodule Phoenix.Integration.CodeGeneration.AppWithSQLite3AdapterTest do generate_phoenix_app(tmp_dir, "default_sqlite3_app", ["--database", "sqlite3", "--live"]) mix_run!( - ~w(phx.gen.live Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.live Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), app_root_path ) diff --git a/integration_test/test/code_generation/umbrella_app_with_defaults_test.exs b/integration_test/test/code_generation/umbrella_app_with_defaults_test.exs index 26656256b5..f0177dde30 100644 --- a/integration_test/test/code_generation/umbrella_app_with_defaults_test.exs +++ b/integration_test/test/code_generation/umbrella_app_with_defaults_test.exs @@ -30,7 +30,7 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do web_root_path = Path.join(app_root_path, "apps/rainy_day_web") mix_run!( - ~w(phx.gen.html Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.html Blog Post posts title:*:unique body:string status:enum:[unpublished,published,deleted]), web_root_path ) @@ -57,7 +57,7 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do web_root_path = Path.join(app_root_path, "apps/rainy_day_web") mix_run!( - ~w(phx.gen.html Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.html Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), web_root_path ) @@ -85,7 +85,7 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do web_root_path = Path.join(app_root_path, "apps/rainy_day_web") mix_run!( - ~w(phx.gen.json Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.json Blog Post posts title:*:unique body:string status:enum:[unpublished,published,deleted]), web_root_path ) @@ -112,7 +112,7 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do web_root_path = Path.join(app_root_path, "apps/rainy_day_web") mix_run!( - ~w(phx.gen.json Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.json Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), web_root_path ) @@ -140,7 +140,7 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do web_root_path = Path.join(app_root_path, "apps/rainy_day_web") mix_run!( - ~w(phx.gen.live Blog Post posts title:string:*:unique body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.live Blog Post posts title:*:unique body:string status:enum:[unpublished,published,deleted]), web_root_path ) @@ -170,7 +170,7 @@ defmodule Phoenix.Integration.CodeGeneration.UmbrellaAppWithDefaultsTest do web_root_path = Path.join(app_root_path, "apps/rainy_day_web") mix_run!( - ~w(phx.gen.live Blog Post posts title:string:* body:string status:enum:[unpublished,published,deleted]), + ~w(phx.gen.live Blog Post posts title:* body:string status:enum:[unpublished,published,deleted]), web_root_path ) diff --git a/lib/mix/phoenix.ex b/lib/mix/phoenix.ex index 8ec097c804..0fb2cca982 100644 --- a/lib/mix/phoenix.ex +++ b/lib/mix/phoenix.ex @@ -59,8 +59,11 @@ defmodule Mix.Phoenix do end end - defp to_app_source(path, source_dir) when is_binary(path), do: Path.join(path, source_dir) - defp to_app_source(app, source_dir) when is_atom(app), do: Application.app_dir(app, source_dir) + defp to_app_source(path, source_dir) when is_binary(path), + do: Path.join(path, source_dir) + + defp to_app_source(app, source_dir) when is_atom(app), + do: Application.app_dir(app, source_dir) @doc """ Inflects path, scope, alias and more from the given name. @@ -99,7 +102,7 @@ defmodule Mix.Phoenix do """ def inflect(singular) do - base = base() + base = Mix.Phoenix.base() web_module = base |> web_module() |> inspect() scoped = Phoenix.Naming.camelize(singular) path = Phoenix.Naming.underscore(scoped) diff --git a/lib/mix/phoenix/attribute.ex b/lib/mix/phoenix/attribute.ex index a9d11e0ce0..ccba0d93c6 100644 --- a/lib/mix/phoenix/attribute.ex +++ b/lib/mix/phoenix/attribute.ex @@ -95,7 +95,8 @@ defmodule Mix.Phoenix.Attribute do examples: [] }, "map" => %{ - details: "", + details: + "There is no trivial way to generate html input for map, so it is skipped for now.", examples: [] }, "enum" => %{ @@ -314,22 +315,22 @@ defmodule Mix.Phoenix.Attribute do defp raise_unknown_type_error(type, cli_attr) do Mix.raise(""" - Unknown type `#{type}` is given in CLI attribute `#{cli_attr}`. + CLI attribute `#{cli_attr}` has unknown type `#{type}`. #{supported_types()} """) end - defp raise_unknown_option_error({option, type, cli_attr}) do + defp raise_unknown_option_error(option, type, cli_attr) do Mix.raise(""" - Unknown option `#{option}` is given in CLI attribute `#{cli_attr}`. + CLI attribute `#{cli_attr}` of base type `#{type}` has unknown option `#{option}`. #{type_specs(type)} """) end - defp raise_type_error(type, cli_attr) do + defp raise_validation_error(type, cli_attr) do Mix.raise(""" - CLI attribute `#{cli_attr}` has issue related to its type `#{type}`. + CLI attribute `#{cli_attr}` of base type `#{type}` has an invalid option. #{type_specs(type)} """) end @@ -434,69 +435,73 @@ defmodule Mix.Phoenix.Attribute do end defp parse_attr(cli_attr, schema_details) when is_binary(cli_attr) do - cli_attr - |> String.split(":") - |> parse_name() - |> parse_type(cli_attr) - |> parse_options(cli_attr) - |> validate_attr(cli_attr) - |> prefill_data(schema_details) - |> new() - end - - defp new({name, type, %{} = options}) do - %Attribute{ - name: name, - type: type, - options: options - } - end + [name | attr_info] = String.split(cli_attr, ":") + {type, options} = parse_type_and_options(attr_info, cli_attr) - defp parse_name([name | rest]), do: {String.to_atom(name), rest} + attr = %Attribute{name: String.to_atom(name), type: type, options: options} - defp parse_type({name, []}, _cli_attr), do: {name, @default_type, %{}} - defp parse_type({name, [type]}, cli_attr), do: {name, string_to_type(type, cli_attr), %{}} + if not valid?(attr), do: raise_validation_error(base_type(attr.type), cli_attr) - defp parse_type({name, [type | options]}, cli_attr), - do: {name, string_to_type(type, cli_attr), options} + %{attr | options: prefill_options(attr, schema_details)} + end - defp string_to_type(type, _cli_attr) when type in @standard_types, do: String.to_atom(type) - defp string_to_type("datetime", _cli_attr), do: :naive_datetime - defp string_to_type("array", _cli_attr), do: {:array, :string} + defp base_type({:array, type}), do: base_type(type) + defp base_type(type), do: type - defp string_to_type(type, cli_attr) do - cond do - match = regex_match("[array,inner_type]", type, @specific_types_specs) -> - if match["inner_type"] == "references", do: raise_unknown_type_error(type, cli_attr) - {:array, string_to_type(match["inner_type"], cli_attr)} + defp parse_type_and_options([], _cli_attr), do: {@default_type, %{}} - true -> - raise_unknown_type_error(type, cli_attr) + # NOTE: To keep initial rule about possibility to skip default `string` type in CLI, + # we need to consider first item to be either type or option. + # As consequence of this, only compound type like `[array,inner_type]` can have + # invalid type case. General type is either given or default to string. + defp parse_type_and_options(attr_info, cli_attr) do + [type_or_option | options] = attr_info + + case parse_type(type_or_option, cli_attr) do + nil -> {@default_type, parse_options(attr_info, @default_type, cli_attr)} + type -> {type, parse_options(options, type, cli_attr)} end end - defp parse_options({name, type, options}, cli_attr) do - options = - Enum.reduce(options, %{}, fn option, parsed_options -> - Map.merge(parsed_options, string_to_options({option, type, cli_attr})) - end) + defp parse_type(type, cli_attr) do + parse_array_type(type, cli_attr) || parse_general_type(type) + end + + defp parse_array_type(type, cli_attr) do + if match = regex_match("[array,inner_type]", type, @specific_types_specs) do + inner_type = parse_type(match["inner_type"], cli_attr) + + if inner_type in [:references, nil], do: raise_unknown_type_error(type, cli_attr) - {name, type, options} + {:array, inner_type} + end end - defp string_to_options({"*", _, _}), do: %{required: true} + defp parse_general_type(type) when type in @standard_types, do: String.to_atom(type) + defp parse_general_type("datetime"), do: :naive_datetime + defp parse_general_type("array"), do: {:array, @default_type} + defp parse_general_type(_type), do: nil - defp string_to_options({"virtual", type, _}) when type not in [:references], - do: %{virtual: true} + # NOTE: General option case should be checked before type specific option case. + defp parse_options(options, type, cli_attr) do + type = base_type(type) + + Enum.into(options, %{}, fn option -> + parse_general_option(option, type) || + parse_type_specific_option(option, type) || + raise_unknown_option_error(option, type, cli_attr) + end) + end @flag_options ["unique", "index", "redact", "required"] - defp string_to_options({option, _, _}) when option in @flag_options, - do: %{String.to_atom(option) => true} + defp parse_general_option(option, _type) when option in @flag_options, + do: {String.to_atom(option), true} - defp string_to_options({option, {:array, inner_type}, cli_attr}), - do: string_to_options({option, inner_type, cli_attr}) + defp parse_general_option("*", _type), do: {:required, true} + defp parse_general_option("virtual", type) when type not in [:references], do: {:virtual, true} + defp parse_general_option(_option, _type), do: nil - defp string_to_options({option, :enum, _} = data) do + defp parse_type_specific_option(option, :enum) do cond do match = regex_match("[[one,1],[two,2]]", option) -> parsed_values = @@ -507,95 +512,93 @@ defmodule Mix.Phoenix.Attribute do {String.to_atom(value_name), String.to_integer(value_int)} end) - %{values: parsed_values} + {:values, parsed_values} match = regex_match("[one,two]", option) -> parsed_values = match["values"] |> String.split(",") |> Enum.map(&String.to_atom/1) - %{values: parsed_values} + {:values, parsed_values} true -> - raise_unknown_option_error(data) + nil end end - defp string_to_options({option, :decimal, _} = data) do + defp parse_type_specific_option(option, :decimal) do cond do match = regex_match("precision,value", option) -> - %{precision: String.to_integer(match["value"])} + {:precision, String.to_integer(match["value"])} match = regex_match("scale,value", option) -> - %{scale: String.to_integer(match["value"])} + {:scale, String.to_integer(match["value"])} match = regex_match("default,value", option) -> - %{default: match["value"] |> String.to_float() |> Float.to_string()} + {:default, match["value"] |> String.to_float() |> Float.to_string()} true -> - raise_unknown_option_error(data) + nil end end - defp string_to_options({option, :float, _} = data) do + defp parse_type_specific_option(option, :float) do cond do - match = regex_match("default,value", option) -> %{default: String.to_float(match["value"])} - true -> raise_unknown_option_error(data) + match = regex_match("default,value", option) -> {:default, String.to_float(match["value"])} + true -> nil end end - defp string_to_options({option, :integer, _} = data) do + defp parse_type_specific_option(option, :integer) do cond do match = regex_match("default,value", option) -> - %{default: String.to_integer(match["value"])} + {:default, String.to_integer(match["value"])} true -> - raise_unknown_option_error(data) + nil end end - defp string_to_options({option, :boolean, _} = data) do + defp parse_type_specific_option(option, :boolean) do cond do - match = regex_match("default,value", option) -> %{default: match["value"] in ["true", "1"]} - true -> raise_unknown_option_error(data) + match = regex_match("default,value", option) -> {:default, match["value"] in ["true", "1"]} + true -> nil end end - defp string_to_options({option, :string, _} = data) do + defp parse_type_specific_option(option, :string) do cond do - match = regex_match("size,value", option) -> %{size: String.to_integer(match["value"])} - true -> raise_unknown_option_error(data) + match = regex_match("size,value", option) -> {:size, String.to_integer(match["value"])} + true -> nil end end - defp string_to_options({option, :references, _} = data) do + @referenced_types ["id", "binary_id", "string"] + defp parse_type_specific_option(option, :references) do cond do match = regex_match("on_delete,value", option) -> - on_delete = references_on_delete(match["value"]) || raise_unknown_option_error(data) - %{on_delete: on_delete} + on_delete = references_on_delete(match["value"]) + if on_delete, do: {:on_delete, on_delete} match = regex_match("assoc,value", option) -> - %{association_name: String.to_atom(match["value"])} + {:association_name, String.to_atom(match["value"])} + + Schema.valid?(option) -> + {:association_schema, option} match = regex_match("column,value", option) -> - %{referenced_column: String.to_atom(match["value"])} + {:referenced_column, String.to_atom(match["value"])} match = regex_match("type,value", option) -> - type = references_type(match["value"]) || raise_unknown_option_error(data) - %{referenced_type: type} + if match["value"] in @referenced_types, + do: {:referenced_type, String.to_atom(match["value"])} match = regex_match("table,value", option) -> - %{referenced_table: match["value"]} - - Schema.valid?(option) -> - %{association_schema: option} + {:referenced_table, match["value"]} true -> - raise_unknown_option_error(data) + nil end end - defp string_to_options({_, _, _} = data), do: raise_unknown_option_error(data) - - @referenced_types ["id", "binary_id", "string"] - defp references_type(value), do: if(value in @referenced_types, do: String.to_atom(value)) + defp parse_type_specific_option(_option, _type), do: nil @references_on_delete_values ["nothing", "delete_all", "nilify_all", "restrict"] defp references_on_delete(value) when value in @references_on_delete_values, @@ -610,80 +613,45 @@ defmodule Mix.Phoenix.Attribute do defp regex_match(spec_key, value, spec \\ @supported_options_specs), do: Regex.named_captures(spec[spec_key].regex, value) - defp validate_attr({_name, :any, options} = attr, cli_attr) do - cond do - not Map.has_key?(options, :virtual) -> raise_type_error(:any, cli_attr) - true -> attr - end - end + # Validate attribute options. - defp validate_attr({_name, :string, options} = attr, cli_attr) do - cond do - Map.get(options, :size, 1) <= 0 -> raise_type_error(:string, cli_attr) - true -> attr - end + defp valid?(%Attribute{type: :decimal, options: options}) do + (not Map.has_key?(options, :scale) or Map.has_key?(options, :precision)) and + (Map.get(options, :precision, @precision_min) > + (scale = Map.get(options, :scale, @scale_min)) and scale > 0) end - defp validate_attr({_name, :decimal, options} = attr, cli_attr) do - cond do - Map.has_key?(options, :scale) and not Map.has_key?(options, :precision) -> - raise_type_error(:decimal, cli_attr) + defp valid?(%Attribute{type: :any} = attr), do: Map.has_key?(attr.options, :virtual) + defp valid?(%Attribute{type: :string} = attr), do: Map.get(attr.options, :size, 1) > 0 + defp valid?(%Attribute{type: :enum} = attr), do: Map.has_key?(attr.options, :values) + defp valid?(%Attribute{type: {:array, type}} = attr), do: valid?(%{attr | type: type}) + defp valid?(%Attribute{}), do: true - Map.get(options, :precision, @precision_min) <= - (scale = Map.get(options, :scale, @scale_min)) or scale <= 0 -> - raise_type_error(:decimal, cli_attr) + # Prefill attribute options. - true -> - attr - end + defp prefill_options(%Attribute{type: :boolean} = attr, _schema_details) do + attr.options + |> Map.put(:required, true) + |> Map.put_new(:default, false) end - defp validate_attr({_name, :enum, options} = attr, cli_attr) do - cond do - not Map.has_key?(options, :values) -> raise_type_error(:enum, cli_attr) - true -> attr - end + defp prefill_options(%Attribute{type: :decimal} = attr, _schema_details) do + attr.options + |> maybe_adjust_decimal_default() end - defp validate_attr({name, {:array, inner_type}, options} = attr, cli_attr) do - validate_attr({name, inner_type, options}, cli_attr) - attr - end - - defp validate_attr(attr, _cli_attr), do: attr - - defp prefill_data({name, :boolean, options}, _schema_details) do - options = - options - |> Map.put(:required, true) - |> Map.put_new(:default, false) - - {name, :boolean, options} - end - - defp prefill_data({name, :decimal, options}, _schema_details) do - options = - options - |> maybe_adjust_decimal_default() - - {name, :decimal, options} - end - - defp prefill_data({name, :references, options}, schema_details) do - options = - options - |> Map.put(:index, true) - |> Map.put_new(:on_delete, :nothing) - |> derive_association_name(name) - |> derive_association_schema(name, schema_details) - |> derive_referenced_table() - |> derive_referenced_column() - |> derive_referenced_type() - - {name, :references, options} + defp prefill_options(%Attribute{name: name, type: :references} = attr, schema_details) do + attr.options + |> Map.put(:index, true) + |> Map.put_new(:on_delete, :nothing) + |> derive_association_name(name) + |> derive_association_schema(name, schema_details) + |> derive_referenced_table() + |> derive_referenced_column() + |> derive_referenced_type() end - defp prefill_data(attr, _schema_details), do: attr + defp prefill_options(%Attribute{options: options}, _schema_details), do: options defp maybe_adjust_decimal_default(%{default: default} = options), do: Map.put(options, :default, adjust_decimal_value(default, options)) diff --git a/lib/mix/phoenix/migration.ex b/lib/mix/phoenix/migration.ex index 03697cdde8..4675a6d1c0 100644 --- a/lib/mix/phoenix/migration.ex +++ b/lib/mix/phoenix/migration.ex @@ -72,8 +72,8 @@ defmodule Mix.Phoenix.Migration do "#{type}#{precision_and_scale}#{size}#{default}" end - defp column_type(%Attribute{type: {:array, inner_type}} = attr), - do: {:array, column_type(%{attr | type: inner_type})} + defp column_type(%Attribute{type: {:array, type}} = attr), + do: {:array, column_type(%{attr | type: type})} defp column_type(%Attribute{type: :enum, options: %{values: [value | _rest]}}), do: if(is_atom(value), do: :string, else: :integer) diff --git a/lib/mix/phoenix/schema.ex b/lib/mix/phoenix/schema.ex index 302c71a083..925cb04e3b 100644 --- a/lib/mix/phoenix/schema.ex +++ b/lib/mix/phoenix/schema.ex @@ -181,8 +181,8 @@ defmodule Mix.Phoenix.Schema do "field #{name}, #{type}#{values}#{default}#{redact}#{virtual}" end - defp field_type(%Attribute{type: {:array, inner_type}} = attr), - do: {:array, field_type(%{attr | type: inner_type})} + defp field_type(%Attribute{type: {:array, type}} = attr), + do: {:array, field_type(%{attr | type: type})} defp field_type(%Attribute{type: :text}), do: :string defp field_type(%Attribute{type: :uuid}), do: Ecto.UUID @@ -192,8 +192,8 @@ defmodule Mix.Phoenix.Schema do defp enum_values_option(%Attribute{type: :enum} = attr), do: ", values: #{inspect(attr.options.values)}" - defp enum_values_option(%Attribute{type: {:array, inner_type}} = attr), - do: enum_values_option(%{attr | type: inner_type}) + defp enum_values_option(%Attribute{type: {:array, type}} = attr), + do: enum_values_option(%{attr | type: type}) defp enum_values_option(_attr), do: "" diff --git a/test/mix/phoenix/attribute_test.exs b/test/mix/phoenix/attribute_test.exs index cd3fe58796..4a23c6cf71 100644 --- a/test/mix/phoenix/attribute_test.exs +++ b/test/mix/phoenix/attribute_test.exs @@ -27,7 +27,7 @@ defmodule Mix.Phoenix.AttributeTest do "title:string:unique", "title:string:index", "title:string:required", - "title:string:*:size,40", + "title:*:size,40", "card_number:string:redact", "name:text", "data:binary", @@ -182,25 +182,27 @@ defmodule Mix.Phoenix.AttributeTest do ] end - test "raises with an unknown type, providing list of supported types" do + test "raises with an unknown type for compound cases, providing list of supported types" do assert_raise( Mix.Error, - ~r"Unknown type `other` is given in CLI attribute `some:other`", - fn -> parse_cli_attrs(["some:other"]) end + ~r"CLI attribute `some:\[array,other\]` has unknown type `\[array,other\]`", + fn -> parse_cli_attrs(["some:[array,other]"]) end ) assert_raise( Mix.Error, ~r"Supported attribute types", - fn -> parse_cli_attrs(["some:other"]) end + fn -> parse_cli_attrs(["some:[array,references]"]) end ) + + parse_cli_attrs(["some:*:unique"]) end test "raises with an unknown option, providing list of supported options for the type" do assert_raise( Mix.Error, - ~r"Unknown option `other` is given in CLI attribute `title:string:other`", - fn -> parse_cli_attrs(["title:string:other"]) end + ~r"CLI attribute `title:other` of base type `string` has unknown option `other`", + fn -> parse_cli_attrs(["title:other"]) end ) assert_raise( @@ -213,37 +215,37 @@ defmodule Mix.Phoenix.AttributeTest do test "raises with a type specific issue, providing list of supported options for the type" do assert_raise( Mix.Error, - ~r"CLI attribute `data:any` has issue related to its type `any`", + ~r"CLI attribute `data:any` of base type `any` has an invalid option", fn -> parse_cli_attrs(["data:any"]) end ) assert_raise( Mix.Error, - ~r"CLI attribute `city:string:size,0` has issue related to its type `string`", - fn -> parse_cli_attrs(["city:string:size,0"]) end + ~r"CLI attribute `city:size,0` of base type `string` has an invalid option", + fn -> parse_cli_attrs(["city:size,0"]) end ) assert_raise( Mix.Error, - ~r"CLI attribute `price:decimal:scale,1` has issue related to its type `decimal`", + ~r"CLI attribute `price:decimal:scale,1` of base type `decimal` has an invalid option", fn -> parse_cli_attrs(["price:decimal:scale,1"]) end ) assert_raise( Mix.Error, - ~r"CLI attribute `price:decimal:precision,10:scale,10` has issue related to its type `decimal`", + ~r"CLI attribute `price:decimal:precision,10:scale,10` of base type `decimal` has an invalid option", fn -> parse_cli_attrs(["price:decimal:precision,10:scale,10"]) end ) assert_raise( Mix.Error, - ~r"CLI attribute `status:enum` has issue related to its type `enum`", + ~r"CLI attribute `status:enum` of base type `enum` has an invalid option", fn -> parse_cli_attrs(["status:enum"]) end ) assert_raise( Mix.Error, - ~r"CLI attribute `status:\[array,enum\]` has issue related to its type `enum`", + ~r"CLI attribute `status:\[array,enum\]` of base type `enum` has an invalid option", fn -> parse_cli_attrs(["status:[array,enum]"]) end ) @@ -332,7 +334,7 @@ defmodule Mix.Phoenix.AttributeTest do points:integer points:integer:default,0 - * `map` + * `map` - There is no trivial way to generate html input for map, so it is skipped for now. * `naive_datetime` diff --git a/test/mix/tasks/phx.gen.context_test.exs b/test/mix/tasks/phx.gen.context_test.exs index bed8daefb1..6bcaae79d8 100644 --- a/test/mix/tasks/phx.gen.context_test.exs +++ b/test/mix/tasks/phx.gen.context_test.exs @@ -154,7 +154,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do in_tmp_project(config.test, fn -> # Accepts first attribute to be required. send(self(), {:mix_shell_input, :yes?, true}) - Gen.Context.run(~w(Blog Post posts slug:string:unique secret:string:redact title)) + Gen.Context.run(~w(Blog Post posts slug:unique secret:string:redact title)) assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "field :secret, :string, redact: true" @@ -242,7 +242,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do in_tmp_project(config.test, fn -> Gen.Context.run(~w(Blog Post posts slug:string:unique - subject:string:unique + subject:unique body:text:unique order:integer:unique price:decimal:unique @@ -319,7 +319,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do in_tmp_project(config.test, fn -> Gen.Context.run(~w(Blog Post posts slug:string:unique - subject:string:unique:* + subject:unique:* body:text:unique order:integer:unique )) @@ -332,7 +332,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do test "generates into existing context without prompt with --merge-with-existing-context", config do in_tmp_project(config.test, fn -> - Gen.Context.run(~w(Blog Post posts)) + Gen.Context.run(~w(Blog Post posts title:*)) assert_file("lib/phoenix/blog.ex", fn file -> assert file =~ "def get_post!" @@ -343,7 +343,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do assert file =~ "def change_post" end) - Gen.Context.run(~w(Blog Comment comments --merge-with-existing-context)) + Gen.Context.run(~w(Blog Comment comments message:string:* --merge-with-existing-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _notice]} @@ -362,7 +362,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do test "when more than 50 attributes are given", config do in_tmp_project(config.test, fn -> long_attribute_list = Enum.map_join(0..55, " ", &"attribute#{&1}:string") - Gen.Context.run(~w(Blog Post posts title:string:* #{long_attribute_list})) + Gen.Context.run(~w(Blog Post posts title:* #{long_attribute_list})) assert_file("test/phoenix/blog_test.exs", fn file -> refute file =~ "...}" @@ -372,9 +372,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do test "generates context with no schema and repo option", config do in_tmp_project(config.test, fn -> - Gen.Context.run( - ~w(Blog Post posts title:string:*:unique content --no-schema --repo=Foo.RepoX) - ) + Gen.Context.run(~w(Blog Post posts title:string:* --no-schema --repo=Foo.RepoX)) refute_file("lib/phoenix/blog/post.ex") @@ -398,8 +396,7 @@ defmodule Mix.Tasks.Phx.Gen.ContextTest do assert_file("test/support/fixtures/blog_fixtures.ex", fn file -> assert file =~ "defmodule Phoenix.BlogFixtures do" assert file =~ "def post_fixture(attrs \\\\ %{})" - assert file =~ "title: unique_post_title()" - assert file =~ "content: \"content value\"" + assert file =~ "title: \"title value\"" end) assert Path.wildcard("priv/repo/migrations/*_create_posts.exs") == [] diff --git a/test/mix/tasks/phx.gen.embedded_test.exs b/test/mix/tasks/phx.gen.embedded_test.exs index ae0e61f945..49a7815546 100644 --- a/test/mix/tasks/phx.gen.embedded_test.exs +++ b/test/mix/tasks/phx.gen.embedded_test.exs @@ -36,7 +36,7 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "generates embedded schema", config do in_tmp_project(config.test, fn -> - Gen.Embedded.run(~w(Blog.Post)) + Gen.Embedded.run(~w(Blog.Post title:string:*)) assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "embedded_schema do" @@ -46,7 +46,7 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "generates nested embedded schema", config do in_tmp_project(config.test, fn -> - Gen.Embedded.run(~w(Blog.Admin.User)) + Gen.Embedded.run(~w(Blog.Admin.User name:string:*)) assert_file("lib/phoenix/blog/admin/user.ex", fn file -> assert file =~ "defmodule Phoenix.Blog.Admin.User do" @@ -81,7 +81,7 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "generates embedded schema with redact option", config do in_tmp_project(config.test, fn -> - Gen.Embedded.run(~w(Blog.Comment title secret:string:*:redact)) + Gen.Embedded.run(~w(Blog.Comment comments title:string:* title secret:redact)) assert_file("lib/phoenix/blog/comment.ex", fn file -> assert file =~ "field :secret, :string, redact: true" @@ -92,7 +92,7 @@ defmodule Mix.Tasks.Phx.Gen.EmbeddedTest do test "generates embedded schema with references", config do in_tmp_project(config.test, fn -> Gen.Embedded.run( - ~w(Blog.Comment body word_count:integer author_id:references:*:table,users:column,id:type,string) + ~w(Blog.Comment comments body word_count:integer author_id:references:*:table,users:column,id:type,string) ) assert_file("lib/phoenix/blog/comment.ex", fn file -> diff --git a/test/mix/tasks/phx.gen.html_test.exs b/test/mix/tasks/phx.gen.html_test.exs index 38f86472ce..c32fe7af05 100644 --- a/test/mix/tasks/phx.gen.html_test.exs +++ b/test/mix/tasks/phx.gen.html_test.exs @@ -42,11 +42,13 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do in_tmp_project(config.test, fn -> Gen.Html.run(~w(Blog Post posts title - slug:string:unique - votes:integer cost:decimal + slug:unique + votes:integer + cost:decimal content:text tags:[array,text] - popular:boolean drafted_at:datetime + popular:boolean + drafted_at:datetime status:enum:[unpublished,published,deleted] published_at:utc_datetime published_at_usec:utc_datetime_usec @@ -227,7 +229,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "generates into existing context without prompt with --merge-with-existing-context", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Post posts)) + Gen.Html.run(~w(Blog Post posts title:*)) assert_file("lib/phoenix/blog.ex", fn file -> assert file =~ "def get_post!" @@ -238,7 +240,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do assert file =~ "def change_post" end) - Gen.Html.run(~w(Blog Comment comments --merge-with-existing-context)) + Gen.Html.run(~w(Blog Comment comments message:string:* --merge-with-existing-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _notice]} @@ -256,7 +258,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "with --web namespace generates namespaced web modules and directories", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Post posts --web Blog)) + Gen.Html.run(~w(Blog Post posts title:string:* --web Blog)) assert_file("test/phoenix_web/controllers/blog/post_controller_test.exs", fn file -> assert file =~ "defmodule PhoenixWeb.Blog.PostControllerTest" @@ -307,7 +309,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "with --no-context skips context and schema file generation", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Comment comments --no-context)) + Gen.Html.run(~w(Blog Comment comments title:string:* --no-context)) refute_file("lib/phoenix/blog.ex") refute_file("lib/phoenix/blog/comment.ex") @@ -332,7 +334,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "with a matching plural and singular term", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Tracker Series series)) + Gen.Html.run(~w(Tracker Series series value:integer:*)) assert_file("lib/phoenix_web/controllers/series_controller.ex", fn file -> assert file =~ "render(conn, :index, series_collection: series)" @@ -342,12 +344,12 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "with --no-context no warning is emitted when context exists", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Post posts)) + Gen.Html.run(~w(Blog Post posts title:string:*)) assert_file("lib/phoenix/blog.ex") assert_file("lib/phoenix/blog/post.ex") - Gen.Html.run(~w(Blog Comment comments --no-context)) + Gen.Html.run(~w(Blog Comment comments title:string:* --no-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _]} assert_file("test/phoenix_web/controllers/comment_controller_test.exs", fn file -> @@ -367,7 +369,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "with --no-schema skips schema file generation", config do in_tmp_project(config.test, fn -> - Gen.Html.run(~w(Blog Comment comments --no-schema)) + Gen.Html.run(~w(Blog Comment comments title:string:* --no-schema)) assert_file("lib/phoenix/blog.ex") refute_file("lib/phoenix/blog/comment.ex") @@ -391,7 +393,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "when more than 50 arguments are given", config do in_tmp_project(config.test, fn -> long_attribute_list = Enum.map_join(0..55, " ", &"attribute#{&1}:string") - Gen.Html.run(~w(Blog Post posts title:string:* #{long_attribute_list})) + Gen.Html.run(~w(Blog Post posts title:* #{long_attribute_list})) assert_file("test/phoenix_web/controllers/post_controller_test.exs", fn file -> refute file =~ "...}" @@ -403,7 +405,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do test "without context_app generators config uses web dir", config do in_tmp_umbrella_project(config.test, fn -> Application.put_env(:phoenix, :generators, context_app: nil) - Gen.Html.run(~w(Accounts User users)) + Gen.Html.run(~w(Accounts User users name:string:*)) assert_file("lib/phoenix/accounts.ex") assert_file("lib/phoenix/accounts/user.ex") @@ -438,7 +440,7 @@ defmodule Mix.Tasks.Phx.Gen.HtmlTest do File.mkdir!("another_app") Application.put_env(:phoenix, :generators, context_app: {:another_app, "another_app"}) - Gen.Html.run(~w(Accounts User users)) + Gen.Html.run(~w(Accounts User users name:string:*)) assert_file("another_app/lib/another_app/accounts.ex") assert_file("another_app/lib/another_app/accounts/user.ex") diff --git a/test/mix/tasks/phx.gen.json_test.exs b/test/mix/tasks/phx.gen.json_test.exs index 1402a7c47b..898d9adaca 100644 --- a/test/mix/tasks/phx.gen.json_test.exs +++ b/test/mix/tasks/phx.gen.json_test.exs @@ -48,7 +48,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do in_tmp_project(config.test, fn -> Gen.Json.run(~w(Blog Post posts title - slug:string:unique + slug:unique votes:integer cost:decimal tags:[array,text] @@ -136,7 +136,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "generates into existing context without prompt with --merge-with-existing-context", config do in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Post posts)) + Gen.Json.run(~w(Blog Post posts title:*)) assert_file("lib/phoenix/blog.ex", fn file -> assert file =~ "def get_post!" @@ -147,7 +147,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do assert file =~ "def change_post" end) - Gen.Json.run(~w(Blog Comment comments --merge-with-existing-context)) + Gen.Json.run(~w(Blog Comment comments message:string:* --merge-with-existing-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _notice]} @@ -166,7 +166,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "when more than 50 arguments are given", config do in_tmp_project(config.test, fn -> long_attribute_list = Enum.map_join(0..55, " ", &"attribute#{&1}:string") - Gen.Json.run(~w(Blog Post posts title:string:* #{long_attribute_list})) + Gen.Json.run(~w(Blog Post posts title:* #{long_attribute_list})) assert_file("test/phoenix_web/controllers/post_controller_test.exs", fn file -> refute file =~ "...}" @@ -176,7 +176,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "with json --web namespace generates namespaced web modules and directories", config do in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Post posts --web Blog)) + Gen.Json.run(~w(Blog Post posts title:string:* --web Blog)) assert_file("test/phoenix_web/controllers/blog/post_controller_test.exs", fn file -> assert file =~ "defmodule PhoenixWeb.Blog.PostControllerTest" @@ -215,7 +215,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "with --no-context skips context and schema file generation", config do in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Comment comments --no-context)) + Gen.Json.run(~w(Blog Comment comments title:string:* --no-context)) refute_file("lib/phoenix/blog.ex") refute_file("lib/phoenix/blog/comment.ex") @@ -240,12 +240,12 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "with --no-context no warning is emitted when context exists", config do in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Post posts)) + Gen.Json.run(~w(Blog Post posts title:string:*)) assert_file("lib/phoenix/blog.ex") assert_file("lib/phoenix/blog/post.ex") - Gen.Json.run(~w(Blog Comment comments --no-context)) + Gen.Json.run(~w(Blog Comment comments title:string:* --no-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _]} assert_file("test/phoenix_web/controllers/comment_controller_test.exs", fn file -> @@ -265,7 +265,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do test "with --no-schema skips schema file generation", config do in_tmp_project(config.test, fn -> - Gen.Json.run(~w(Blog Comment comments --no-schema)) + Gen.Json.run(~w(Blog Comment comments title:string:* --no-schema)) assert_file("lib/phoenix/blog.ex") refute_file("lib/phoenix/blog/comment.ex") @@ -289,7 +289,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do describe "inside umbrella" do test "without context_app generators config uses web dir", config do in_tmp_umbrella_project(config.test, fn -> - Gen.Json.run(~w(Accounts User users)) + Gen.Json.run(~w(Accounts User users name:string:*)) assert_file("lib/phoenix/accounts.ex") assert_file("lib/phoenix/accounts/user.ex") @@ -324,7 +324,7 @@ defmodule Mix.Tasks.Phx.Gen.JsonTest do File.mkdir!("another_app") Application.put_env(:phoenix, :generators, context_app: {:another_app, "another_app"}) - Gen.Json.run(~w(Accounts User users)) + Gen.Json.run(~w(Accounts User users name:string:*)) assert_file("another_app/lib/another_app/accounts.ex") assert_file("another_app/lib/another_app/accounts/user.ex") diff --git a/test/mix/tasks/phx.gen.live_test.exs b/test/mix/tasks/phx.gen.live_test.exs index 7c108d4fb9..e383e25b34 100644 --- a/test/mix/tasks/phx.gen.live_test.exs +++ b/test/mix/tasks/phx.gen.live_test.exs @@ -57,7 +57,7 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do in_tmp_live_project(config.test, fn -> Gen.Live.run(~w(Blog Post posts title - slug:string:unique + slug:unique votes:integer cost:decimal content:text @@ -207,7 +207,7 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do test "generates into existing context without prompt with --merge-with-existing-context", config do in_tmp_live_project(config.test, fn -> - Gen.Live.run(~w(Blog Post posts)) + Gen.Live.run(~w(Blog Post posts title:*)) assert_file("lib/phoenix/blog.ex", fn file -> assert file =~ "def get_post!" @@ -218,7 +218,7 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do assert file =~ "def change_post" end) - Gen.Live.run(~w(Blog Comment comments --merge-with-existing-context)) + Gen.Live.run(~w(Blog Comment comments message:string:* --merge-with-existing-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _notice]} @@ -302,7 +302,7 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do test "with --no-context skips context and schema file generation", config do in_tmp_live_project(config.test, fn -> - Gen.Live.run(~w(Blog Post posts --no-context)) + Gen.Live.run(~w(Blog Post posts title:string:* --no-context)) refute_file("lib/phoenix/blog.ex") refute_file("lib/phoenix/blog/post.ex") @@ -320,7 +320,7 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do test "with --no-schema skips schema file generation", config do in_tmp_live_project(config.test, fn -> - Gen.Live.run(~w(Blog Post posts --no-schema)) + Gen.Live.run(~w(Blog Post posts title:string:* --no-schema)) assert_file("lib/phoenix/blog.ex") refute_file("lib/phoenix/blog/post.ex") @@ -336,12 +336,12 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do test "with --no-context does not emit warning when context exists", config do in_tmp_live_project(config.test, fn -> - Gen.Live.run(~w(Blog Post posts)) + Gen.Live.run(~w(Blog Post posts title:string:*)) assert_file("lib/phoenix/blog.ex") assert_file("lib/phoenix/blog/post.ex") - Gen.Live.run(~w(Blog Comment comments --no-context)) + Gen.Live.run(~w(Blog Comment comments title:string:* --no-context)) refute_received {:mix_shell, :info, ["You are generating into an existing context" <> _]} assert_file("lib/phoenix_web/live/comment_live/index.ex") @@ -377,7 +377,7 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do test "when more than 50 attributes are given", config do in_tmp_live_project(config.test, fn -> long_attribute_list = Enum.map_join(0..55, " ", &"attribute#{&1}:string") - Gen.Live.run(~w(Blog Post posts title:string:* #{long_attribute_list})) + Gen.Live.run(~w(Blog Post posts title:* #{long_attribute_list})) assert_file("test/phoenix_web/live/post_live_test.exs", fn file -> refute file =~ "...}" @@ -391,7 +391,7 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do File.cd!("phoenix_web") Application.put_env(:phoenix, :generators, context_app: nil) - Gen.Live.run(~w(Accounts User users)) + Gen.Live.run(~w(Accounts User users name:string:*)) assert_file("lib/phoenix/accounts.ex") assert_file("lib/phoenix/accounts/user.ex") @@ -434,7 +434,7 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do File.cd!("phoenix") - Gen.Live.run(~w(Accounts User users)) + Gen.Live.run(~w(Accounts User users name:string:*)) assert_file("another_app/lib/another_app/accounts.ex") assert_file("another_app/lib/another_app/accounts/user.ex") diff --git a/test/mix/tasks/phx.gen.schema_test.exs b/test/mix/tasks/phx.gen.schema_test.exs index 91ada1cc42..daf46ebccb 100644 --- a/test/mix/tasks/phx.gen.schema_test.exs +++ b/test/mix/tasks/phx.gen.schema_test.exs @@ -15,7 +15,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do in_tmp_project("build", fn -> # Accepts first attribute to be required. send(self(), {:mix_shell_input, :yes?, true}) - schema = Gen.Schema.build(~w(Blog.Post posts title content:text tags:map), []) + schema = Gen.Schema.build(~w(Blog.Post posts title:string tags:map), []) assert %Schema{ alias: Post, @@ -28,7 +28,6 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do human_singular: "Post", attrs: [ %Attribute{name: :title, type: :string, options: %{required: true}}, - %Attribute{name: :content, type: :text, options: %{}}, %Attribute{name: :tags, type: :map, options: %{}} ], route_helper: "post" @@ -85,7 +84,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do test "generates schema", config do in_tmp_project(config.test, fn -> - Gen.Schema.run(~w(Blog.Post blog_posts)) + Gen.Schema.run(~w(Blog.Post blog_posts title:string:*)) assert_file("lib/phoenix/blog/post.ex") @@ -99,7 +98,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do test "allows a custom repo", config do in_tmp_project(config.test, fn -> - Gen.Schema.run(~w(Blog.Post blog_posts --repo MyApp.CustomRepo)) + Gen.Schema.run(~w(Blog.Post blog_posts title:string:* --repo MyApp.CustomRepo)) assert [migration] = Path.wildcard("priv/custom_repo/migrations/*_create_blog_posts.exs") @@ -111,7 +110,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do test "allows a custom migration dir", config do in_tmp_project(config.test, fn -> - Gen.Schema.run(~w(Blog.Post blog_posts --migration-dir priv/custom_dir)) + Gen.Schema.run(~w(Blog.Post blog_posts title:string:* --migration-dir priv/custom_dir)) assert [migration] = Path.wildcard("priv/custom_dir/*_create_blog_posts.exs") @@ -124,7 +123,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do test "custom migration_dir takes precedence over custom repo name", config do in_tmp_project(config.test, fn -> Gen.Schema.run( - ~w(Blog.Post blog_posts --repo MyApp.CustomRepo --migration-dir priv/custom_dir) + ~w(Blog.Post blog_posts title:string:* --repo MyApp.CustomRepo --migration-dir priv/custom_dir) ) assert [migration] = Path.wildcard("priv/custom_dir/*_create_blog_posts.exs") @@ -137,7 +136,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do test "adds validation for required fields", config do in_tmp_project(config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts title:string:* tags:map:* published_at:naive_datetime)) + Gen.Schema.run(~w(Blog.Post posts title:string:* tags:map published_at:naive_datetime)) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") @@ -145,20 +144,20 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do assert file =~ "defmodule Phoenix.Repo.Migrations.CreatePosts do" assert file =~ "create table(\"posts\") do" assert file =~ "add :title, :string, null: false" - assert file =~ "add :tags, :map, null: false" + assert file =~ "add :tags, :map" assert file =~ "add :published_at, :naive_datetime" end) assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "cast(attrs, [:published_at, :tags, :title]" - assert file =~ "validate_required([:tags, :title]" + assert file =~ "validate_required([:title]" end) end) end test "generates nested schema", config do in_tmp_project(config.test, fn -> - Gen.Schema.run(~w(Blog.Admin.User users)) + Gen.Schema.run(~w(Blog.Admin.User users name:string:*)) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_users.exs") @@ -188,9 +187,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do test "generates unique indices", config do in_tmp_project(config.test, fn -> - Gen.Schema.run( - ~w(Blog.Post posts title:string:*:unique secret:string:redact unique_int:integer:unique) - ) + Gen.Schema.run(~w(Blog.Post posts title:*:unique secret:redact unique_int:integer:unique)) assert [migration] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") @@ -357,7 +354,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do test "generates schema and migration with prefix", config do in_tmp_project(config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts title:string:*:index --prefix cms)) + Gen.Schema.run(~w(Blog.Post posts title:*:index --prefix cms)) assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "@schema_prefix :cms" @@ -449,7 +446,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do test "generates schema without extra line break", config do in_tmp_project(config.test, fn -> - Gen.Schema.run(~w(Blog.Post posts)) + Gen.Schema.run(~w(Blog.Post posts title:*)) assert_file("lib/phoenix/blog/post.ex", fn file -> assert file =~ "import Ecto.Changeset\n\n schema" @@ -483,7 +480,7 @@ defmodule Mix.Tasks.Phx.Gen.SchemaTest do in_tmp_umbrella_project(config.test, fn -> Application.put_env(:phoenix, :generators, context_app: {:another_app, "another_app"}) - Gen.Schema.run(~w(Blog.Post blog_posts)) + Gen.Schema.run(~w(Blog.Post blog_posts title:string:*)) assert_file("another_app/lib/another_app/blog/post.ex") assert [_] = Path.wildcard("another_app/priv/repo/migrations/*_create_blog_posts.exs") diff --git a/test/mix/tasks/phx_test.exs b/test/mix/tasks/phx_test.exs index 0c9f5382a4..8829b70e0a 100644 --- a/test/mix/tasks/phx_test.exs +++ b/test/mix/tasks/phx_test.exs @@ -2,7 +2,7 @@ defmodule Mix.Tasks.Phx.Test do use ExUnit.Case test "provide a list of available phx mix tasks" do - Mix.Tasks.Phx.run([]) + Mix.Tasks.Phx.run [] assert_received {:mix_shell, :info, ["mix phx.digest" <> _]} assert_received {:mix_shell, :info, ["mix phx.digest.clean" <> _]} assert_received {:mix_shell, :info, ["mix phx.gen.channel" <> _]} @@ -12,12 +12,11 @@ defmodule Mix.Tasks.Phx.Test do assert_received {:mix_shell, :info, ["mix phx.gen.html" <> _]} assert_received {:mix_shell, :info, ["mix phx.gen.json" <> _]} assert_received {:mix_shell, :info, ["mix phx.gen.live" <> _]} - assert_received {:mix_shell, :info, ["mix phx.gen.schema" <> _]} end test "expects no arguments" do assert_raise Mix.Error, fn -> - Mix.Tasks.Phx.run(["invalid"]) + Mix.Tasks.Phx.run ["invalid"] end end end diff --git a/test/phoenix/verified_routes_test.exs b/test/phoenix/verified_routes_test.exs index 1134e5ac1c..52723f2ae1 100644 --- a/test/phoenix/verified_routes_test.exs +++ b/test/phoenix/verified_routes_test.exs @@ -521,6 +521,7 @@ defmodule Phoenix.VerifiedRoutesTest do assert warnings =~ ~r"test/phoenix/verified_routes_test.exs:#{line}:(\d+:)? Phoenix.VerifiedRoutesTest.Forwards.test/0" + after :code.purge(__MODULE__.Forwards) :code.delete(__MODULE__.Forwards) From f50948e898b6263d3a972812defc2f916c8925a0 Mon Sep 17 00:00:00 2001 From: Pavel Shpak Date: Mon, 9 Dec 2024 23:08:56 +0200 Subject: [PATCH 5/6] Merge main into generators_attributes_improvement --- .gitignore | 1 + guides/components.md | 34 ++-- guides/contexts.md | 51 +++--- guides/plug.md | 2 +- guides/real_time/presence.md | 2 +- guides/request_lifecycle.md | 12 +- installer/lib/phx_new/generator.ex | 4 +- installer/templates/phx_single/mix.exs | 3 +- .../phx_umbrella/apps/app_name_web/mix.exs | 3 +- installer/templates/phx_umbrella/mix.exs | 3 +- .../phx_web/components/core_components.ex | 62 +++---- .../phx_web/components/layouts/app.html.heex | 4 +- .../phx_web/components/layouts/root.html.heex | 4 +- .../controllers/page_html/home.html.heex | 2 +- integration_test/mix.exs | 3 +- integration_test/mix.lock | 52 +++--- lib/mix/phoenix.ex | 14 +- lib/mix/phoenix/context.ex | 18 +- lib/mix/phoenix/web.ex | 6 +- lib/mix/tasks/phx.gen.auth.ex | 2 + lib/mix/tasks/phx.gen.auth/injector.ex | 2 +- lib/mix/tasks/phx.gen.html.ex | 2 + lib/mix/tasks/phx.gen.live.ex | 2 + .../phx.gen.auth/session_new.html.heex | 2 +- priv/templates/phx.gen.html/edit.html.heex | 2 +- priv/templates/phx.gen.html/index.html.heex | 6 +- priv/templates/phx.gen.html/show.html.heex | 2 +- .../templates/phx.gen.live/core_components.ex | 62 +++---- priv/templates/phx.gen.live/form.ex | 2 +- priv/templates/phx.gen.live/show.ex | 2 +- test/mix/phoenix/web_test.exs | 162 +++++++++--------- test/mix/tasks/phx.gen.auth/injector_test.exs | 40 ++--- test/mix/tasks/phx.gen.auth_test.exs | 4 +- 33 files changed, 290 insertions(+), 282 deletions(-) diff --git a/.gitignore b/.gitignore index 44fba26034..e46730c560 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ /installer/deps/ /installer/doc/ /installer/phx_new-*.ez +/installer/tmp/ /integration_test/_build/ /integration_test/deps/ diff --git a/guides/components.md b/guides/components.md index 83e6710e8c..5e98f92131 100644 --- a/guides/components.md +++ b/guides/components.md @@ -16,7 +16,7 @@ At the end of the Request life-cycle chapter, we created a template at `lib/hell ```heex
-

Hello World, from <%= @messenger %>!

+

Hello World, from {@messenger}!

``` @@ -34,7 +34,7 @@ That's simple enough. There's only two lines, `use HelloWeb, :html`. This line c All of the imports and aliases we make in our module will also be available in our templates. That's because templates are effectively compiled into functions inside their respective module. For example, if you define a function in your module, you will be able to invoke it directly from the template. Let's see this in practice. -Imagine we want to refactor our `show.html.heex` to move the rendering of `

Hello World, from <%= @messenger %>!

` to its own function. We can move it to a function component inside `HelloHTML`, let's do so: +Imagine we want to refactor our `show.html.heex` to move the rendering of `

Hello World, from {@messenger}!

` to its own function. We can move it to a function component inside `HelloHTML`, let's do so: ```elixir defmodule HelloWeb.HelloHTML do @@ -46,7 +46,7 @@ defmodule HelloWeb.HelloHTML do def greet(assigns) do ~H""" -

Hello World, from <%= @messenger %>!

+

Hello World, from {@messenger}!

""" end end @@ -89,19 +89,21 @@ Next, let's fully understand the expressive power behind the HEEx template langu ## HEEx -Function components and templates files are powered by [the HEEx template language](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#sigil_H/2), which stands for "HTML+EEx". EEx is an Elixir library that uses `<%= expression %>` to execute Elixir expressions and interpolate their results into the template. This is frequently used to display assigns we have set by way of the `@` shortcut. In your controller, if you invoke: +Function components and templates files are powered by [the HEEx template language](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#sigil_H/2), which stands for "HTML+EEx". EEx is an Elixir library that uses `<%= expression %>` to execute Elixir expressions and interpolate their results into arbitrary text templates. HEEx extends EEx for writing HTML templates mixed with Elixir interpolation. We can write Elixir code inside `{...}` for HTML-aware interpolation inside tag attributes and the body. We can also interpolate arbitrary HEEx blocks using EEx interpolation (`<%= ... %>`). We use `@name` to access the key `name` defined inside `assigns`. + +This is frequently used to display assigns we have set by way of the `@` shortcut. In your controller, if you invoke: ```elixir render(conn, :show, username: "joe") ``` -Then you can access said username in the templates as `<%= @username %>`. In addition to displaying assigns and functions, we can use pretty much any Elixir expression. For example, in order to have conditionals: +Then you can access said username in the templates as `{@username}`. In addition to displaying assigns and functions, we can use pretty much any Elixir expression. For example, in order to have conditionals: ```heex <%= if some_condition? do %> -

Some condition is true for user: <%= @username %>

+

Some condition is true for user: {@username}

<% else %> -

Some condition is false for user: <%= @username %>

+

Some condition is false for user: {@username}

<% end %> ``` @@ -115,8 +117,8 @@ or even loops: <%= for number <- 1..10 do %> - <%= number %> - <%= number * number %> + {number} + {number * number} <% end %> @@ -131,20 +133,20 @@ HEEx also comes with handy HTML extensions we will learn next. Besides allowing interpolation of Elixir expressions via `<%= %>`, `.heex` templates come with HTML-aware extensions. For example, let's see what happens if you try to interpolate a value with "<" or ">" in it, which would lead to HTML injection: ```heex -<%= "Bold?" %> +{"Bold?"} ``` Once you render the template, you will see the literal `` on the page. This means users cannot inject HTML content on the page. If you want to allow them to do so, you can call `raw`, but do so with extreme care: ```heex -<%= raw "Bold?" %> +{raw("Bold?")} ``` -Another super power of HEEx templates is validation of HTML and lean interpolation syntax of attributes. You can write: +Another super power of HEEx templates is validation of HTML and interpolation syntax of attributes. You can write: ```heex
-

Hello <%= @username %>

+

Hello {@username}

``` @@ -154,7 +156,7 @@ To interpolate a dynamic number of attributes in a keyword list or map, do: ```heex
-

Hello <%= @username %>

+

Hello {@username}

``` @@ -178,7 +180,7 @@ Likewise, for comprehensions may be written as: ```heex
    -
  • <%= item.name %>
  • +
  • {item.name}
``` @@ -189,7 +191,7 @@ Layouts are just function components. They are defined in a module, just like al You may be wondering how the string resulting from a rendered view ends up inside a layout. That's a great question! If we look at `lib/hello_web/components/layouts/root.html.heex`, just about at the end of the ``, we will see this. ```heex -<%= @inner_content %> +{@inner_content} ``` In other words, after rendering your page, the result is placed in the `@inner_content` assign. diff --git a/guides/contexts.md b/guides/contexts.md index 84481f7cfe..6fbface810 100644 --- a/guides/contexts.md +++ b/guides/contexts.md @@ -523,10 +523,9 @@ We added a `category_select` above our save button. Now let's try it out. Next, <.list> ... + <:item title="Categories"> -+ <%= for cat <- @product.categories do %> -+ <%= cat.title %> -+
-+ <% end %> ++
    ++
  • {cat.title}
  • ++
+ ``` @@ -933,29 +932,23 @@ We created a view to render our `show.html` template and aliased our `ShoppingCa Next we can create the template at `lib/hello_web/controllers/cart_html/show.html.heex`: ```heex -<%= if @cart.items == [] do %> - <.header> - My Cart - <:subtitle>Your cart is empty - -<% else %> - <.header> - My Cart - +<.header> + My Cart + <:subtitle :if={@cart.items == []}>Your cart is empty + +
<.simple_form :let={f} for={@changeset} action={~p"/cart"}> - <.inputs_for :let={item_form} field={f[:items]}> - <% item = item_form.data %> + <.inputs_for :let={%{data: item} = item_form} field={f[:items]}> <.input field={item_form[:quantity]} type="number" label={item.product.title} /> - <%= currency_to_str(ShoppingCart.total_item_price(item)) %> + {currency_to_str(ShoppingCart.total_item_price(item))} <:actions> <.button>Update cart - - Total: <%= currency_to_str(ShoppingCart.total_cart_price(@cart)) %> -<% end %> + Total: {currency_to_str(ShoppingCart.total_cart_price(@cart))} +
<.back navigate={~p"/products"}>Back to products ``` @@ -1290,20 +1283,20 @@ Next we can create the template at `lib/hello_web/controllers/order_html/show.ht <.header> Thank you for your order! <:subtitle> - User uuid: <%= @order.user_uuid %> + User uuid: {@order.user_uuid} <.table id="items" rows={@order.line_items}> - <:col :let={item} label="Title"><%= item.product.title %> - <:col :let={item} label="Quantity"><%= item.quantity %> + <:col :let={item} label="Title">{item.product.title} + <:col :let={item} label="Quantity">{item.quantity} <:col :let={item} label="Price"> - <%= HelloWeb.CartHTML.currency_to_str(item.price) %> + {HelloWeb.CartHTML.currency_to_str(item.price)} Total price: -<%= HelloWeb.CartHTML.currency_to_str(@order.total_price) %> +{HelloWeb.CartHTML.currency_to_str(@order.total_price)} <.back navigate={~p"/products"}>Back to products ``` @@ -1315,11 +1308,11 @@ Our last addition will be to add the "complete order" button to our cart page to ```diff <.header> My Cart -+ <:actions> -+ <.link href={~p"/orders"} method="post"> -+ <.button>Complete order -+ -+ ++ <:actions> ++ <.link href={~p"/orders"} method="post"> ++ <.button>Complete order ++ ++ ``` diff --git a/guides/plug.md b/guides/plug.md index 0c1d6a5175..04e502a48d 100644 --- a/guides/plug.md +++ b/guides/plug.md @@ -120,7 +120,7 @@ In the [`init/1`] callback, we pass a default locale to use if none is present i To see the assign in action, go to the template in `lib/hello_web/controllers/page_html/home.html.heex` and add the following code after the closing of the `` tag: ```heex -

Locale: <%= @locale %>

+

Locale: {@locale}

``` Go to [http://localhost:4000/](http://localhost:4000/) and you should see the locale exhibited. Visit [http://localhost:4000/?locale=fr](http://localhost:4000/?locale=fr) and you should see the assign changed to `"fr"`. Someone can use this information alongside [Gettext](https://hexdocs.pm/gettext/Gettext.html) to provide a fully internationalized web application. diff --git a/guides/real_time/presence.md b/guides/real_time/presence.md index 106ea41775..32271b855a 100644 --- a/guides/real_time/presence.md +++ b/guides/real_time/presence.md @@ -217,7 +217,7 @@ defmodule HelloWeb.OnlineLive do def render(assigns) do ~H"""
    -
  • <%= id %> (<%= length(metas) %>)
  • +
  • {id} ({length(metas)})
""" end diff --git a/guides/request_lifecycle.md b/guides/request_lifecycle.md index 177b9376ab..79ac1e9649 100644 --- a/guides/request_lifecycle.md +++ b/guides/request_lifecycle.md @@ -181,7 +181,7 @@ Now that we've got the route, controller, view, and template, we should be able There are a couple of interesting things to notice about what we just did. We didn't need to stop and restart the server while we made these changes. Yes, Phoenix has hot code reloading! Also, even though our `index.html.heex` file consists of only a single `section` tag, the page we get is a full HTML document. Our index template is actually rendered into layouts: first it renders `lib/hello_web/components/layouts/root.html.heex` which renders `lib/hello_web/components/layouts/app.html.heex` which finally includes our content. If you open those files, you'll see a line that looks like this at the bottom: ```heex -<%= @inner_content %> +{@inner_content} ``` This line injects our template into the layout before the HTML is sent off to the browser. We will talk more about layouts in the Controllers guide. @@ -273,15 +273,17 @@ It's good to remember that the keys of the `params` map will always be strings, For the last piece of this puzzle, we'll need a new template. Since it is for the `show` action of `HelloController`, it will go into the `lib/hello_web/controllers/hello_html` directory and be called `show.html.heex`. It will look surprisingly like our `index.html.heex` template, except that we will need to display the name of our messenger. -To do that, we'll use the special HEEx tags for executing Elixir expressions: `<%= %>`. Notice that the initial tag has an equals sign like this: `<%=` . That means that any Elixir code that goes between those tags will be executed, and the resulting value will replace the tag in the HTML output. If the equals sign were missing, the code would still be executed, but the value would not appear on the page. +To do that, we'll use the special HEEx tags for executing Elixir expressions: `{...}` and `<%= %>`. Notice that EEx tag has an equals sign like this: `<%=` . That means that any Elixir code that goes between those tags will be executed, and the resulting value will replace the tag in the HTML output. If the equals sign were missing, the code would still be executed, but the value would not appear on the page. -Remember our templates are written in HEEx (HTML+EEx). HEEx is a superset of EEx which is why it shares the `<%= %>` syntax. +Remember our templates are written in HEEx (HTML+EEx). HEEx is a superset of EEx, and thereby supports the EEx `<%= %>` interpolation syntax for interpolating arbitrary blocks of code. In general, the HEEx `{...}` interpolation syntax is preferred anytime there is HTML-aware intepolation to be done – such as within attributes or inline values with a body. -And this is what the template should look like: +The only times `EEx` `<%= %>` interpolation is necessary is for interpolationg arbitrary blocks of markup, such as branching logic that inects separate markup trees, or for interpolating values within ` - <%%= @inner_content %> + {@inner_content} diff --git a/installer/templates/phx_web/controllers/page_html/home.html.heex b/installer/templates/phx_web/controllers/page_html/home.html.heex index 071a7104d3..cd96076f08 100644 --- a/installer/templates/phx_web/controllers/page_html/home.html.heex +++ b/installer/templates/phx_web/controllers/page_html/home.html.heex @@ -50,7 +50,7 @@

Phoenix Framework - v<%%= Application.spec(:phoenix, :vsn) %> + v{Application.spec(:phoenix, :vsn)}

diff --git a/integration_test/mix.exs b/integration_test/mix.exs index 63cedf5317..a63f329ba0 100644 --- a/integration_test/mix.exs +++ b/integration_test/mix.exs @@ -41,8 +41,7 @@ defmodule Phoenix.Integration.MixProject do {:tds, ">= 0.0.0"}, {:ecto_sqlite3, ">= 0.0.0"}, {:phoenix_html, "~> 4.1"}, - # TODO bump on release to {:phoenix_live_view, "~> 1.0.0"}, - {:phoenix_live_view, "~> 1.0.0-rc.0", override: true}, + {:phoenix_live_view, "~> 1.0.0"}, {:dns_cluster, "~> 0.1.1"}, {:floki, ">= 0.30.0"}, {:phoenix_live_reload, "~> 1.2"}, diff --git a/integration_test/mix.lock b/integration_test/mix.lock index 6ce1c9a9ce..199ec925d0 100644 --- a/integration_test/mix.lock +++ b/integration_test/mix.lock @@ -1,51 +1,51 @@ %{ "argon2_elixir": {:hex, :argon2_elixir, "4.1.0", "2f242afe47c373663cb404eb75e792f749507075ed737b49685a9f2edcb401df", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ecb6f2ca2cca34b28e546224661bf2a85714516d2713c7313c5ffe8bdade7cf"}, - "bandit": {:hex, :bandit, "1.5.4", "8e56e7cfc06f3c57995be0d9bf4e45b972d8732f5c7e96ef8ec0735f52079527", [:mix], [{:hpax, "~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "04c2b38874769af67fe7f10034f606ad6dda1d8f80c4d7a0c616b347584d5aff"}, - "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"}, - "castore": {:hex, :castore, "1.0.9", "5cc77474afadf02c7c017823f460a17daa7908e991b0cc917febc90e466a375c", [:mix], [], "hexpm", "5ea956504f1ba6f2b4eb707061d8e17870de2bee95fb59d512872c2ef06925e7"}, + "bandit": {:hex, :bandit, "1.6.1", "9e01b93d72ddc21d8c576a704949e86ee6cde7d11270a1d3073787876527a48f", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5a904bf010ea24b67979835e0507688e31ac873d4ffc8ed0e5413e8d77455031"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.0", "feab711974beba4cb348147170346fe097eea2e840db4e012a145e180ed4ab75", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "563e92a6c77d667b19c5f4ba17ab6d440a085696bdf4c68b9b0f5b30bc5422b8"}, + "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, "comeonin": {:hex, :comeonin, "5.5.0", "364d00df52545c44a139bad919d7eacb55abf39e86565878e17cebb787977368", [:mix], [], "hexpm", "6287fc3ba0aad34883cbe3f7949fc1d1e738e5ccdce77165bc99490aa69f47fb"}, - "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, + "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, - "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, - "ecto_sql": {:hex, :ecto_sql, "3.11.3", "4eb7348ff8101fbc4e6bbc5a4404a24fecbe73a3372d16569526b0cf34ebc195", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5f36e3d736b99c7fee3e631333b8394ade4bafe9d96d35669fca2d81c2be928"}, - "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.16.0", "1cdc8ea6319e7cb1bc273a36db0ecde69ad56b4dea3037689ad8c0afc6a91e16", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "73c9dd56830d67c951bc254c082cb0a7f9fa139d44866bc3186c8859d1b4d787"}, - "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, - "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, + "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.17.5", "fbee5c17ff6afd8e9ded519b0abb363926c65d30b27577232bb066b2a79957b8", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "3b54734d998cbd032ac59403c36acf4e019670e8b6ceef9c6c33d8986c4e9704"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, - "exqlite": {:hex, :exqlite, "0.23.0", "6e851c937a033299d0784994c66da24845415072adbc455a337e20087bce9033", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "404341cceec5e6466aaed160cf0b58be2019b60af82588c215e1224ebd3ec831"}, - "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, - "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, - "floki": {:hex, :floki, "0.36.3", "1102f93b16a55bc5383b85ae3ec470f82dee056eaeff9195e8afdf0ef2a43c30", [:mix], [], "hexpm", "fe0158bff509e407735f6d40b3ee0d7deb47f3f3ee7c6c182ad28599f9f6b27a"}, - "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, + "exqlite": {:hex, :exqlite, "0.27.1", "73fc0b3dc3b058a77a2b3771f82a6af2ddcf370b069906968a34083d2ffd2884", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "79ef5756451cfb022e8013e1ed00d0f8f7d1333c19502c394dc16b15cfb4e9b4"}, + "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, - "hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"}, + "hpax": {:hex, :hpax, "1.0.1", "c857057f89e8bd71d97d9042e009df2a42705d6d690d54eca84c8b29af0787b0", [:mix], [], "hexpm", "4e2d5a4f76ae1e3048f35ae7adb1641c36265510a2d4638157fbcb53dda38445"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, - "myxql": {:hex, :myxql, "0.7.0", "3382f139b0b0da977a8fc33c8cded125e20df2e400f8d7b7e674fa62a7e077dd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "40e4b7ad4973c8b895e86a3de04ff7a79c2cf72b9f2bddef7717afb4ab36d8c0"}, + "myxql": {:hex, :myxql, "0.7.1", "7c7b75aa82227cd2bc9b7fbd4de774fb19a1cdb309c219f411f82ca8860f8e01", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a491cdff53353a09b5850ac2d472816ebe19f76c30b0d36a43317a67c9004936"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "2.2.0", "2ec4f7daae2bf74cb9e52df3554bbdcec8a38104a7f0ccaa4d45d5919e4c3f19", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "6c4af97f5cae925c56caded648520510ea583eebf1587e185b9f445762197aff"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.1", "96798325fab2fed5a824ca204e877b81f9afd2e480f581e81f7b4b64a5a477f2", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "0ae544ff99f3c482b0807c5cec2c8289e810ecacabc04959d82c3337f4703391"}, + "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "2.3.0", "7b3967797963e15312cde8beeac29136238bb020b1da60c4a0ede610dd9115c1", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "61a7bba2764ea03e68a1b8822fdf3e486149bf2caf8f7adf752e3e4aea4467bc"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.5", "d5f44d7dbd7cfacaa617b70c5a14b2b598d6f93b9caa8e350c51d56cd4350a9b", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1d73920515554d7d6c548aee0bf10a4780568b029d042eccb336db29ea0dad70"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0-rc.7", "d2abca526422adea88896769529addb6443390b1d4f1ff9cbe694312d8875fb2", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b82a4575f6f3eb5b97922ec6874b0c52b3ca0cc5dcb4b14ddc478cbfa135dd01"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0", "3a10dfce8f87b2ad4dc65de0732fc2a11e670b2779a19e8d3281f4619a85bce4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "254caef0028765965ca6bd104cc7d68dcc7d57cc42912bef92f6b03047251d99"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, - "postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"}, - "req": {:hex, :req, "0.5.4", "e375e4812adf83ffcf787871d7a124d873e983e3b77466e6608b973582f7f837", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a17998ffe2ef54f79bfdd782ef9f4cbf987d93851e89444cbc466a6a25eee494"}, - "swoosh": {:hex, :swoosh, "1.16.9", "20c6a32ea49136a4c19f538e27739bb5070558c0fa76b8a95f4d5d5ca7d319a1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "878b1a7a6c10ebbf725a3349363f48f79c5e3d792eb621643b0d276a38acc0a6"}, - "tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"}, + "postgrex": {:hex, :postgrex, "0.19.3", "a0bda6e3bc75ec07fca5b0a89bffd242ca209a4822a9533e7d3e84ee80707e19", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d31c28053655b78f47f948c85bb1cf86a9c1f8ead346ba1aa0d0df017fa05b61"}, + "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"}, + "swoosh": {:hex, :swoosh, "1.17.3", "5cda7bff6bc1121cc5b58db8ed90ef33261b373425ae3e32dd599688037a0482", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "14ad57cfbb70af57323e17f569f5840a33c01f8ebc531dd3846beef3c9c95e55"}, + "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, "tds": {:hex, :tds, "2.3.5", "fedfb96d53206f01eac62ead859e47e1541a62e1553e9eb7a8801c7dca59eae8", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "52e350f5dd5584bbcff9859e331be144d290b41bd4c749b936014a17660662f2"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, - "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, + "thousand_island": {:hex, :thousand_island, "1.3.7", "1da7598c0f4f5f50562c097a3f8af308ded48cd35139f0e6f17d9443e4d0c9c5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0139335079953de41d381a6134d8b618d53d084f558c734f2662d1a72818dd12"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, } diff --git a/lib/mix/phoenix.ex b/lib/mix/phoenix.ex index 0fb2cca982..b1b3a51fb0 100644 --- a/lib/mix/phoenix.ex +++ b/lib/mix/phoenix.ex @@ -159,7 +159,7 @@ defmodule Mix.Phoenix do defp app_base(app) do case Application.get_env(app, :namespace, app) do ^app -> app |> to_string() |> Phoenix.Naming.camelize() - mod -> inspect(mod) + mod -> mod |> inspect() end end @@ -412,6 +412,18 @@ defmodule Mix.Phoenix do def prepend_newline(string) when is_binary(string), do: "\n" <> string + @doc """ + Ensures user's LiveView is compatible with the current generators. + """ + def ensure_live_view_compat!(generator_mod) do + vsn = Application.spec(:phoenix_live_view)[:vsn] + + # if lv is not installed, such as in phoenix's own test env, do not raise + if vsn && Version.compare("#{vsn}", "1.0.0-rc.7") != :gt do + raise "#{inspect(generator_mod)} requires :phoenix_live_view >= 1.0.0, got: #{vsn}" + end + end + # In the context of a HEEx attribute value, transforms a given message into a # dynamic `gettext` call or a fixed-value string attribute, depending on the # `gettext?` parameter. diff --git a/lib/mix/phoenix/context.ex b/lib/mix/phoenix/context.ex index 27fbc65622..b4134de33c 100644 --- a/lib/mix/phoenix/context.ex +++ b/lib/mix/phoenix/context.ex @@ -27,15 +27,15 @@ defmodule Mix.Phoenix.Context do end def new(context_name, %Schema{} = schema, opts) do - ctx_app = opts[:context_app] || Mix.Phoenix.context_app() - base = Module.concat([Mix.Phoenix.context_base(ctx_app)]) - module = Module.concat(base, context_name) - alias = Module.concat([module |> Module.split() |> List.last()]) - basedir = Phoenix.Naming.underscore(context_name) - basename = Path.basename(basedir) - dir = Mix.Phoenix.context_lib_path(ctx_app, basedir) - file = dir <> ".ex" - test_dir = Mix.Phoenix.context_test_path(ctx_app, basedir) + ctx_app = opts[:context_app] || Mix.Phoenix.context_app() + base = Module.concat([Mix.Phoenix.context_base(ctx_app)]) + module = Module.concat(base, context_name) + alias = Module.concat([module |> Module.split() |> List.last()]) + basedir = Phoenix.Naming.underscore(context_name) + basename = Path.basename(basedir) + dir = Mix.Phoenix.context_lib_path(ctx_app, basedir) + file = dir <> ".ex" + test_dir = Mix.Phoenix.context_test_path(ctx_app, basedir) test_file = test_dir <> "_test.exs" test_fixtures_dir = Mix.Phoenix.context_app_path(ctx_app, "test/support/fixtures") test_fixtures_file = Path.join([test_fixtures_dir, basedir <> "_fixtures.ex"]) diff --git a/lib/mix/phoenix/web.ex b/lib/mix/phoenix/web.ex index 733e765601..9c91153c06 100644 --- a/lib/mix/phoenix/web.ex +++ b/lib/mix/phoenix/web.ex @@ -14,7 +14,7 @@ defmodule Mix.Phoenix.Web do |> Enum.map(fn attr -> value_expression = maybe_array_values(attr, "#{schema_singular}.#{attr.name}") - ~s(<:col :let={{_id, #{schema_singular}}} label="#{label(attr.name)}"><%= #{value_expression} %>) + ~s(<:col :let={{_id, #{schema_singular}}} label="#{label(attr.name)}">{#{value_expression}}) end) |> Mix.Phoenix.indent_text(spaces: 6, top: 1) end @@ -30,7 +30,7 @@ defmodule Mix.Phoenix.Web do |> Enum.map(fn attr -> value_expression = maybe_array_values(attr, "#{schema_singular}.#{attr.name}") - ~s(<:col :let={#{schema_singular}} label="#{label(attr.name)}"><%= #{value_expression} %>) + ~s(<:col :let={#{schema_singular}} label="#{label(attr.name)}">{#{value_expression}}) end) |> Mix.Phoenix.indent_text(spaces: 2, top: 1) end @@ -45,7 +45,7 @@ defmodule Mix.Phoenix.Web do schema.attrs |> Enum.map(fn attr -> value_expression = maybe_array_values(attr, "@#{schema_singular}.#{attr.name}") - ~s(<:item title="#{label(attr.name)}"><%= #{value_expression} %>) + ~s(<:item title="#{label(attr.name)}">{#{value_expression}}) end) end diff --git a/lib/mix/tasks/phx.gen.auth.ex b/lib/mix/tasks/phx.gen.auth.ex index 835fc357ba..7c486eabba 100644 --- a/lib/mix/tasks/phx.gen.auth.ex +++ b/lib/mix/tasks/phx.gen.auth.ex @@ -126,6 +126,8 @@ defmodule Mix.Tasks.Phx.Gen.Auth do Mix.raise("mix phx.gen.auth can only be run inside an application directory") end + Mix.Phoenix.ensure_live_view_compat!(__MODULE__) + {opts, parsed} = OptionParser.parse!(args, strict: @switches) validate_args!(parsed) hashing_library = build_hashing_library!(opts) diff --git a/lib/mix/tasks/phx.gen.auth/injector.ex b/lib/mix/tasks/phx.gen.auth/injector.ex index 48ae16782c..cc891bc70b 100644 --- a/lib/mix/tasks/phx.gen.auth/injector.ex +++ b/lib/mix/tasks/phx.gen.auth/injector.ex @@ -170,7 +170,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.Injector do

    <%= if @current_#{schema.singular} do %>
  • - <%= @current_#{schema.singular}.email %> + {@current_#{schema.singular}.email}
  • <.link diff --git a/lib/mix/tasks/phx.gen.html.ex b/lib/mix/tasks/phx.gen.html.ex index eff5be6c3c..7f15de15d5 100644 --- a/lib/mix/tasks/phx.gen.html.ex +++ b/lib/mix/tasks/phx.gen.html.ex @@ -102,6 +102,8 @@ defmodule Mix.Tasks.Phx.Gen.Html do ) end + Mix.Phoenix.ensure_live_view_compat!(__MODULE__) + {context, schema} = Gen.Context.build(args) Gen.Context.prompt_for_code_injection(context) diff --git a/lib/mix/tasks/phx.gen.live.ex b/lib/mix/tasks/phx.gen.live.ex index 39433d4e5e..d2f71a9243 100644 --- a/lib/mix/tasks/phx.gen.live.ex +++ b/lib/mix/tasks/phx.gen.live.ex @@ -113,6 +113,8 @@ defmodule Mix.Tasks.Phx.Gen.Live do ) end + Mix.Phoenix.ensure_live_view_compat!(__MODULE__) + {context, schema} = Gen.Context.build(args) Gen.Context.prompt_for_code_injection(context) diff --git a/priv/templates/phx.gen.auth/session_new.html.heex b/priv/templates/phx.gen.auth/session_new.html.heex index 1e7abc4fcd..8e19c74011 100644 --- a/priv/templates/phx.gen.auth/session_new.html.heex +++ b/priv/templates/phx.gen.auth/session_new.html.heex @@ -11,7 +11,7 @@ <.simple_form :let={f} for={@conn.params["<%= schema.singular %>"]} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/log-in"}> - <.error :if={@error_message}><%%= @error_message %> + <.error :if={@error_message}>{@error_message} <.input field={f[:email]} type="email" label="Email" autocomplete="username" required /> <.input diff --git a/priv/templates/phx.gen.html/edit.html.heex b/priv/templates/phx.gen.html/edit.html.heex index c9eaf288b5..a896b5d79f 100644 --- a/priv/templates/phx.gen.html/edit.html.heex +++ b/priv/templates/phx.gen.html/edit.html.heex @@ -1,5 +1,5 @@ <.header> - Edit <%= schema.human_singular %> <%%= @<%= schema.singular %>.id %> + Edit <%= schema.human_singular %> {@<%= schema.singular %>.id} <:subtitle>Use this form to manage <%= schema.singular %> records in your database. diff --git a/priv/templates/phx.gen.html/index.html.heex b/priv/templates/phx.gen.html/index.html.heex index e225886511..0c53c355f5 100644 --- a/priv/templates/phx.gen.html/index.html.heex +++ b/priv/templates/phx.gen.html/index.html.heex @@ -9,11 +9,7 @@ -<.table - id="<%= schema.plural %>" - rows={@<%= schema.collection %>} - row_click={&JS.navigate(~p"<%= schema.route_prefix %>/#{&1}")} -><%= Mix.Phoenix.Web.table_columns(schema) %> +<.table id="<%= schema.plural %>" rows={@<%= schema.collection %>} row_click={&JS.navigate(~p"<%= schema.route_prefix %>/#{&1}")}><%= Mix.Phoenix.Web.table_columns(schema) %> <:action :let={<%= schema.singular %>}>
    <.link navigate={~p"<%= schema.route_prefix %>/#{<%= schema.singular %>}"}>Show diff --git a/priv/templates/phx.gen.html/show.html.heex b/priv/templates/phx.gen.html/show.html.heex index a501d33d6b..db7853adf0 100644 --- a/priv/templates/phx.gen.html/show.html.heex +++ b/priv/templates/phx.gen.html/show.html.heex @@ -1,5 +1,5 @@ <.header> - <%= schema.human_singular %> <%%= @<%= schema.singular %>.id %> + <%= schema.human_singular %> {@<%= schema.singular %>.id} <:subtitle>This is a <%= schema.singular %> record from your database. <:actions> <.button phx-click={JS.dispatch("click", to: {:inner, "a"})}> diff --git a/priv/templates/phx.gen.live/core_components.ex b/priv/templates/phx.gen.live/core_components.ex index c604ef5533..aaf89f484f 100644 --- a/priv/templates/phx.gen.live/core_components.ex +++ b/priv/templates/phx.gen.live/core_components.ex @@ -54,9 +54,9 @@ defmodule <%= @web_namespace %>.CoreComponents do

    <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" /> <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" /> - <%%= @title %> + {@title}

    -

    <%%= msg %>

    +

    {msg}

    @@ -135,9 +135,9 @@ defmodule <%= @web_namespace %>.CoreComponents do ~H""" <.form :let={f} for={@for} {@as} {@rest}>
    - <%%= render_slot(@inner_block, f) %> + {render_slot(@inner_block, f)}
    - <%%= render_slot(action, f) %> + {render_slot(action, f)}
    @@ -169,7 +169,7 @@ defmodule <%= @web_namespace %>.CoreComponents do ]} {@rest} > - <%%= render_slot(@inner_block) %> + {render_slot(@inner_block)} """ end @@ -253,9 +253,9 @@ defmodule <%= @web_namespace %>.CoreComponents do class="rounded border-zinc-300 text-zinc-900 focus:ring-0" {@rest} /> - <%%= @label %> + {@label} - <.error :for={msg <- @errors}><%%= msg %> + <.error :for={msg <- @errors}>{msg}
    """ end @@ -263,7 +263,7 @@ defmodule <%= @web_namespace %>.CoreComponents do def input(%{type: "select"} = assigns) do ~H"""
    - <.label for={@id}><%%= @label %> + <.label for={@id}>{@label} - <.error :for={msg <- @errors}><%%= msg %> + <.error :for={msg <- @errors}>{msg}
    """ end @@ -282,7 +282,7 @@ defmodule <%= @web_namespace %>.CoreComponents do def input(%{type: "textarea"} = assigns) do ~H"""
    - <.label for={@id}><%%= @label %> + <.label for={@id}>{@label} - <.error :for={msg <- @errors}><%%= msg %> + >{Phoenix.HTML.Form.normalize_value("textarea", @value)} + <.error :for={msg <- @errors}>{msg}
    """ end @@ -302,7 +302,7 @@ defmodule <%= @web_namespace %>.CoreComponents do def input(assigns) do ~H"""
    - <.label for={@id}><%%= @label %> + <.label for={@id}>{@label} .CoreComponents do ]} {@rest} /> - <.error :for={msg <- @errors}><%%= msg %> + <.error :for={msg <- @errors}>{msg}
    """ end @@ -329,7 +329,7 @@ defmodule <%= @web_namespace %>.CoreComponents do def label(assigns) do ~H""" """ end @@ -343,7 +343,7 @@ defmodule <%= @web_namespace %>.CoreComponents do ~H"""

    <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> - <%%= render_slot(@inner_block) %> + {render_slot(@inner_block)}

    """ end @@ -362,13 +362,13 @@ defmodule <%= @web_namespace %>.CoreComponents do

    - <%%= render_slot(@inner_block) %> + {render_slot(@inner_block)}

    - <%%= render_slot(@subtitle) %> + {render_slot(@subtitle)}

    -
    <%%= render_slot(@actions) %>
    +
    {render_slot(@actions)}
    """ end @@ -379,8 +379,8 @@ defmodule <%= @web_namespace %>.CoreComponents do ## Examples <.table id="users" rows={@users}> - <:col :let={user} label="id"><%%= user.id %> - <:col :let={user} label="username"><%%= user.username %> + <:col :let={user} label="id">{user.id} + <:col :let={user} label="username">{user.username} """ attr :id, :string, required: true @@ -409,7 +409,7 @@ defmodule <%= @web_namespace %>.CoreComponents do - + @@ -429,7 +429,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
    - <%%= render_slot(col, @row_item.(row)) %> + {render_slot(col, @row_item.(row))}
    @@ -440,7 +440,7 @@ defmodule <%= @web_namespace %>.CoreComponents do :for={action <- @action} class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700" > - <%%= render_slot(action, @row_item.(row)) %> + {render_slot(action, @row_item.(row))} @@ -457,8 +457,8 @@ defmodule <%= @web_namespace %>.CoreComponents do ## Examples <.list> - <:item title="Title"><%%= @post.title %> - <:item title="Views"><%%= @post.views %> + <:item title="Title">{@post.title} + <:item title="Views">{@post.views} """ slot :item, required: true do @@ -470,8 +470,8 @@ defmodule <%= @web_namespace %>.CoreComponents do
    -
    <%%= item.title %>
    -
    <%%= render_slot(item) %>
    +
    {item.title}
    +
    {render_slot(item)}
    @@ -496,7 +496,7 @@ defmodule <%= @web_namespace %>.CoreComponents do class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" > <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> - <%%= render_slot(@inner_block) %> + {render_slot(@inner_block)} """ diff --git a/priv/templates/phx.gen.live/form.ex b/priv/templates/phx.gen.live/form.ex index e2b2676c27..17563484c4 100644 --- a/priv/templates/phx.gen.live/form.ex +++ b/priv/templates/phx.gen.live/form.ex @@ -8,7 +8,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web def render(assigns) do ~H""" <.header> - <%%= @page_title %> + {@page_title} <:subtitle>Use this form to manage <%= schema.singular %> records in your database. diff --git a/priv/templates/phx.gen.live/show.ex b/priv/templates/phx.gen.live/show.ex index abd5e211cf..60d23d546e 100644 --- a/priv/templates/phx.gen.live/show.ex +++ b/priv/templates/phx.gen.live/show.ex @@ -7,7 +7,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web def render(assigns) do ~H""" <.header> - <%= schema.human_singular %> <%%= @<%= schema.singular %>.<%= schema.opts[:primary_key] || :id %> %> + <%= schema.human_singular %> {@<%= schema.singular %>.<%= schema.opts[:primary_key] || :id %>} <:subtitle>This is a <%= schema.singular %> record from your database. <:actions> <.button phx-click={JS.dispatch("click", to: {:inner, "a"})}> diff --git a/test/mix/phoenix/web_test.exs b/test/mix/phoenix/web_test.exs index 92b11f8dd2..cd77d931c2 100644 --- a/test/mix/phoenix/web_test.exs +++ b/test/mix/phoenix/web_test.exs @@ -106,33 +106,33 @@ defmodule Mix.Phoenix.WebTest do assert Web.live_table_columns(schema) == """ - <:col :let={{_id, comment}} label="Points"><%= comment.points %> - <:col :let={{_id, comment}} label="Sum"><%= comment.sum %> - <:col :let={{_id, comment}} label="Price"><%= comment.price %> - <:col :let={{_id, comment}} label="The cake is a lie"><%= comment.the_cake_is_a_lie %> - <:col :let={{_id, comment}} label="Title"><%= comment.title %> - <:col :let={{_id, comment}} label="Name"><%= comment.name %> - <:col :let={{_id, comment}} label="Data"><%= comment.data %> - <:col :let={{_id, comment}} label="Token"><%= comment.token %> - <:col :let={{_id, comment}} label="Date of birth"><%= comment.date_of_birth %> - <:col :let={{_id, comment}} label="Happy hour"><%= comment.happy_hour %> - <:col :let={{_id, comment}} label="Happy hour"><%= comment.happy_hour %> - <:col :let={{_id, comment}} label="Joined"><%= comment.joined %> - <:col :let={{_id, comment}} label="Joined"><%= comment.joined %> - <:col :let={{_id, comment}} label="Joined"><%= comment.joined %> - <:col :let={{_id, comment}} label="Joined"><%= comment.joined %> - <:col :let={{_id, comment}} label="Meta"><%= comment.meta %> - <:col :let={{_id, comment}} label="Status"><%= comment.status %> - <:col :let={{_id, comment}} label="Status"><%= comment.status %> - <:col :let={{_id, comment}} label="Post"><%= comment.post_id %> - <:col :let={{_id, comment}} label="Author"><%= comment.author_id %> - <:col :let={{_id, comment}} label="Booking"><%= comment.booking_id %> - <:col :let={{_id, comment}} label="Book"><%= comment.book_id %> - <:col :let={{_id, comment}} label="Data"><%= comment.data %> - <:col :let={{_id, comment}} label="Tags"><%= array_values(comment.tags) %> - <:col :let={{_id, comment}} label="Tags"><%= array_values(comment.tags) %> - <:col :let={{_id, comment}} label="Tags"><%= array_values(comment.tags) %> - <:col :let={{_id, comment}} label="Tags"><%= array_values(comment.tags) %> + <:col :let={{_id, comment}} label="Points">{comment.points} + <:col :let={{_id, comment}} label="Sum">{comment.sum} + <:col :let={{_id, comment}} label="Price">{comment.price} + <:col :let={{_id, comment}} label="The cake is a lie">{comment.the_cake_is_a_lie} + <:col :let={{_id, comment}} label="Title">{comment.title} + <:col :let={{_id, comment}} label="Name">{comment.name} + <:col :let={{_id, comment}} label="Data">{comment.data} + <:col :let={{_id, comment}} label="Token">{comment.token} + <:col :let={{_id, comment}} label="Date of birth">{comment.date_of_birth} + <:col :let={{_id, comment}} label="Happy hour">{comment.happy_hour} + <:col :let={{_id, comment}} label="Happy hour">{comment.happy_hour} + <:col :let={{_id, comment}} label="Joined">{comment.joined} + <:col :let={{_id, comment}} label="Joined">{comment.joined} + <:col :let={{_id, comment}} label="Joined">{comment.joined} + <:col :let={{_id, comment}} label="Joined">{comment.joined} + <:col :let={{_id, comment}} label="Meta">{comment.meta} + <:col :let={{_id, comment}} label="Status">{comment.status} + <:col :let={{_id, comment}} label="Status">{comment.status} + <:col :let={{_id, comment}} label="Post">{comment.post_id} + <:col :let={{_id, comment}} label="Author">{comment.author_id} + <:col :let={{_id, comment}} label="Booking">{comment.booking_id} + <:col :let={{_id, comment}} label="Book">{comment.book_id} + <:col :let={{_id, comment}} label="Data">{comment.data} + <:col :let={{_id, comment}} label="Tags">{array_values(comment.tags)} + <:col :let={{_id, comment}} label="Tags">{array_values(comment.tags)} + <:col :let={{_id, comment}} label="Tags">{array_values(comment.tags)} + <:col :let={{_id, comment}} label="Tags">{array_values(comment.tags)} """ |> String.trim_trailing("\n") end @@ -144,33 +144,33 @@ defmodule Mix.Phoenix.WebTest do assert Web.table_columns(schema) == """ - <:col :let={comment} label="Points"><%= comment.points %> - <:col :let={comment} label="Sum"><%= comment.sum %> - <:col :let={comment} label="Price"><%= comment.price %> - <:col :let={comment} label="The cake is a lie"><%= comment.the_cake_is_a_lie %> - <:col :let={comment} label="Title"><%= comment.title %> - <:col :let={comment} label="Name"><%= comment.name %> - <:col :let={comment} label="Data"><%= comment.data %> - <:col :let={comment} label="Token"><%= comment.token %> - <:col :let={comment} label="Date of birth"><%= comment.date_of_birth %> - <:col :let={comment} label="Happy hour"><%= comment.happy_hour %> - <:col :let={comment} label="Happy hour"><%= comment.happy_hour %> - <:col :let={comment} label="Joined"><%= comment.joined %> - <:col :let={comment} label="Joined"><%= comment.joined %> - <:col :let={comment} label="Joined"><%= comment.joined %> - <:col :let={comment} label="Joined"><%= comment.joined %> - <:col :let={comment} label="Meta"><%= comment.meta %> - <:col :let={comment} label="Status"><%= comment.status %> - <:col :let={comment} label="Status"><%= comment.status %> - <:col :let={comment} label="Post"><%= comment.post_id %> - <:col :let={comment} label="Author"><%= comment.author_id %> - <:col :let={comment} label="Booking"><%= comment.booking_id %> - <:col :let={comment} label="Book"><%= comment.book_id %> - <:col :let={comment} label="Data"><%= comment.data %> - <:col :let={comment} label="Tags"><%= array_values(comment.tags) %> - <:col :let={comment} label="Tags"><%= array_values(comment.tags) %> - <:col :let={comment} label="Tags"><%= array_values(comment.tags) %> - <:col :let={comment} label="Tags"><%= array_values(comment.tags) %> + <:col :let={comment} label="Points">{comment.points} + <:col :let={comment} label="Sum">{comment.sum} + <:col :let={comment} label="Price">{comment.price} + <:col :let={comment} label="The cake is a lie">{comment.the_cake_is_a_lie} + <:col :let={comment} label="Title">{comment.title} + <:col :let={comment} label="Name">{comment.name} + <:col :let={comment} label="Data">{comment.data} + <:col :let={comment} label="Token">{comment.token} + <:col :let={comment} label="Date of birth">{comment.date_of_birth} + <:col :let={comment} label="Happy hour">{comment.happy_hour} + <:col :let={comment} label="Happy hour">{comment.happy_hour} + <:col :let={comment} label="Joined">{comment.joined} + <:col :let={comment} label="Joined">{comment.joined} + <:col :let={comment} label="Joined">{comment.joined} + <:col :let={comment} label="Joined">{comment.joined} + <:col :let={comment} label="Meta">{comment.meta} + <:col :let={comment} label="Status">{comment.status} + <:col :let={comment} label="Status">{comment.status} + <:col :let={comment} label="Post">{comment.post_id} + <:col :let={comment} label="Author">{comment.author_id} + <:col :let={comment} label="Booking">{comment.booking_id} + <:col :let={comment} label="Book">{comment.book_id} + <:col :let={comment} label="Data">{comment.data} + <:col :let={comment} label="Tags">{array_values(comment.tags)} + <:col :let={comment} label="Tags">{array_values(comment.tags)} + <:col :let={comment} label="Tags">{array_values(comment.tags)} + <:col :let={comment} label="Tags">{array_values(comment.tags)} """ |> String.trim_trailing("\n") end @@ -180,33 +180,33 @@ defmodule Mix.Phoenix.WebTest do schema = %Schema{singular: "comment", attrs: @parsed_attrs} assert Web.list_items(schema) == [ - "<:item title=\"Points\"><%= @comment.points %>", - "<:item title=\"Sum\"><%= @comment.sum %>", - "<:item title=\"Price\"><%= @comment.price %>", - "<:item title=\"The cake is a lie\"><%= @comment.the_cake_is_a_lie %>", - "<:item title=\"Title\"><%= @comment.title %>", - "<:item title=\"Name\"><%= @comment.name %>", - "<:item title=\"Data\"><%= @comment.data %>", - "<:item title=\"Token\"><%= @comment.token %>", - "<:item title=\"Date of birth\"><%= @comment.date_of_birth %>", - "<:item title=\"Happy hour\"><%= @comment.happy_hour %>", - "<:item title=\"Happy hour\"><%= @comment.happy_hour %>", - "<:item title=\"Joined\"><%= @comment.joined %>", - "<:item title=\"Joined\"><%= @comment.joined %>", - "<:item title=\"Joined\"><%= @comment.joined %>", - "<:item title=\"Joined\"><%= @comment.joined %>", - "<:item title=\"Meta\"><%= @comment.meta %>", - "<:item title=\"Status\"><%= @comment.status %>", - "<:item title=\"Status\"><%= @comment.status %>", - "<:item title=\"Post\"><%= @comment.post_id %>", - "<:item title=\"Author\"><%= @comment.author_id %>", - "<:item title=\"Booking\"><%= @comment.booking_id %>", - "<:item title=\"Book\"><%= @comment.book_id %>", - "<:item title=\"Data\"><%= @comment.data %>", - "<:item title=\"Tags\"><%= array_values(@comment.tags) %>", - "<:item title=\"Tags\"><%= array_values(@comment.tags) %>", - "<:item title=\"Tags\"><%= array_values(@comment.tags) %>", - "<:item title=\"Tags\"><%= array_values(@comment.tags) %>" + "<:item title=\"Points\">{@comment.points}", + "<:item title=\"Sum\">{@comment.sum}", + "<:item title=\"Price\">{@comment.price}", + "<:item title=\"The cake is a lie\">{@comment.the_cake_is_a_lie}", + "<:item title=\"Title\">{@comment.title}", + "<:item title=\"Name\">{@comment.name}", + "<:item title=\"Data\">{@comment.data}", + "<:item title=\"Token\">{@comment.token}", + "<:item title=\"Date of birth\">{@comment.date_of_birth}", + "<:item title=\"Happy hour\">{@comment.happy_hour}", + "<:item title=\"Happy hour\">{@comment.happy_hour}", + "<:item title=\"Joined\">{@comment.joined}", + "<:item title=\"Joined\">{@comment.joined}", + "<:item title=\"Joined\">{@comment.joined}", + "<:item title=\"Joined\">{@comment.joined}", + "<:item title=\"Meta\">{@comment.meta}", + "<:item title=\"Status\">{@comment.status}", + "<:item title=\"Status\">{@comment.status}", + "<:item title=\"Post\">{@comment.post_id}", + "<:item title=\"Author\">{@comment.author_id}", + "<:item title=\"Booking\">{@comment.booking_id}", + "<:item title=\"Book\">{@comment.book_id}", + "<:item title=\"Data\">{@comment.data}", + "<:item title=\"Tags\">{array_values(@comment.tags)}", + "<:item title=\"Tags\">{array_values(@comment.tags)}", + "<:item title=\"Tags\">{array_values(@comment.tags)}", + "<:item title=\"Tags\">{array_values(@comment.tags)}" ] end diff --git a/test/mix/tasks/phx.gen.auth/injector_test.exs b/test/mix/tasks/phx.gen.auth/injector_test.exs index 6f43d5d988..d526af98cb 100644 --- a/test/mix/tasks/phx.gen.auth/injector_test.exs +++ b/test/mix/tasks/phx.gen.auth/injector_test.exs @@ -668,7 +668,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do
      <%= if @current_user do %>
    • - <%= @current_user.email %> + {@current_user.email}
    • <.link @@ -762,7 +762,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do
        \r <%= if @current_user do %>\r
      • \r - <%= @current_user.email %>\r + {@current_user.email}\r
      • \r
      • \r <.link\r @@ -819,9 +819,9 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do
        - - - <%= @inner_content %> + + + {@inner_content}
        @@ -840,7 +840,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do
          <%= if @current_user do %>
        • - <%= @current_user.email %> + {@current_user.email}
        • <.link @@ -879,9 +879,9 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do <% end %>
        - - - <%= @inner_content %> + + + {@inner_content}
        @@ -899,9 +899,9 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do \r \r
        \r - \r - \r - <%= @inner_content %>\r + \r + \r + {@inner_content}\r
        \r \r \r @@ -920,7 +920,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do
          \r <%= if @current_user do %>\r
        • \r - <%= @current_user.email %>\r + {@current_user.email}\r
        • \r
        • \r <.link\r @@ -959,9 +959,9 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do <% end %>\r
        \r
        \r - \r - \r - <%= @inner_content %>\r + \r + \r + {@inner_content}\r
        \r \r \r @@ -981,7 +981,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do
          <%= if @current_user do %> -
        • <%= @current_user.email %>
        • +
        • {@current_user.email}
        • <.link href={~p"/users/settings"}>Settings
        • <.link href={~p"/users/log-out"} method="delete">Log out
        • <% else %> @@ -991,9 +991,9 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do
        - - - <%= @inner_content %> + + + {@inner_content}
        diff --git a/test/mix/tasks/phx.gen.auth_test.exs b/test/mix/tasks/phx.gen.auth_test.exs index 773ec9a27c..fb90b40fbf 100644 --- a/test/mix/tasks/phx.gen.auth_test.exs +++ b/test/mix/tasks/phx.gen.auth_test.exs @@ -1368,7 +1368,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do
          <%= if @current_user do %>
        • - <%= @current_user.email %> + {@current_user.email}
        • <.link @@ -1432,7 +1432,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do
            <%= if @current_user do %>
          • - <%= @current_user.email %> + {@current_user.email}
          • <.link From 4649875905d476d8ba654408c971f4438d8fe539 Mon Sep 17 00:00:00 2001 From: Pavel Shpak Date: Tue, 10 Dec 2024 13:51:02 +0200 Subject: [PATCH 6/6] Fix invalid attributes formatting issue, for cases when there are many attributes. --- lib/mix/phoenix/test_data.ex | 27 ++++- priv/templates/phx.gen.context/test_cases.exs | 3 +- .../phx.gen.html/controller_test.exs | 3 +- .../phx.gen.json/controller_test.exs | 3 +- test/mix/phoenix/test_data_test.exs | 108 +++++++++++++----- 5 files changed, 108 insertions(+), 36 deletions(-) diff --git a/lib/mix/phoenix/test_data.ex b/lib/mix/phoenix/test_data.ex index 4face9edcd..fb7e6b6393 100644 --- a/lib/mix/phoenix/test_data.ex +++ b/lib/mix/phoenix/test_data.ex @@ -95,6 +95,30 @@ defmodule Mix.Phoenix.TestData do defp fixture_attr_value(value, %Attribute{} = attr, _), do: Map.get(attr.options, :default, value) |> inspect() + @doc """ + Invalid attributes used in context. + """ + def context_invalid_attrs(%Schema{} = schema) do + schema + |> invalid_attrs() + |> Mix.Phoenix.indent_text(spaces: 6, top: 1, new_line: ",\n") + end + + @doc """ + Invalid attributes used in controller for html and json. + """ + def controller_invalid_attrs(%Schema{} = schema) do + schema + |> invalid_attrs() + |> Mix.Phoenix.indent_text(spaces: 4, top: 1, new_line: ",\n") + end + + defp invalid_attrs(%Schema{} = schema) do + schema.attrs + |> Attribute.sort() + |> Enum.map(&"#{&1.name}: nil") + end + @doc """ Invalid attributes used in live. """ @@ -267,15 +291,12 @@ defmodule Mix.Phoenix.TestData do attrs = Attribute.sort(attrs) %{ - invalid: invalid_attrs(attrs), create: sample_action_attrs(attrs, :create), update: sample_action_attrs(attrs, :update), references_assigns: references_assigns(attrs, schema_module) } end - defp invalid_attrs(attrs), do: Enum.map_join(attrs, ", ", &"#{&1.name}: nil") - defp sample_action_attrs(attrs, action) when action in [:create, :update], do: Enum.map(attrs, &{&1, sample_attr_value(&1, action)}) diff --git a/priv/templates/phx.gen.context/test_cases.exs b/priv/templates/phx.gen.context/test_cases.exs index 9da47ced2e..2f744ea69e 100644 --- a/priv/templates/phx.gen.context/test_cases.exs +++ b/priv/templates/phx.gen.context/test_cases.exs @@ -4,7 +4,8 @@ import <%= inspect(context.module) %>Fixtures - @invalid_attrs %{<%= schema.sample_values.invalid %>} + @invalid_attrs %{<%= Mix.Phoenix.TestData.context_invalid_attrs(schema) %> + } test "list_<%= schema.plural %>/0 returns all <%= schema.plural %>" do <%= schema.singular %> = <%= schema.singular %>_fixture() diff --git a/priv/templates/phx.gen.html/controller_test.exs b/priv/templates/phx.gen.html/controller_test.exs index ec8f91e8d3..befd7e1802 100644 --- a/priv/templates/phx.gen.html/controller_test.exs +++ b/priv/templates/phx.gen.html/controller_test.exs @@ -3,7 +3,8 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web import <%= inspect(context.module) %>Fixtures - @invalid_attrs %{<%= schema.sample_values.invalid %>} + @invalid_attrs %{<%= Mix.Phoenix.TestData.controller_invalid_attrs(schema) %> + } describe "index" do test "lists all <%= schema.plural %>", %{conn: conn} do diff --git a/priv/templates/phx.gen.json/controller_test.exs b/priv/templates/phx.gen.json/controller_test.exs index 6f7a0bbbc6..4eca2dcdc9 100644 --- a/priv/templates/phx.gen.json/controller_test.exs +++ b/priv/templates/phx.gen.json/controller_test.exs @@ -5,7 +5,8 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web alias <%= inspect schema.module %> - @invalid_attrs %{<%= schema.sample_values.invalid %>} + @invalid_attrs %{<%= Mix.Phoenix.TestData.controller_invalid_attrs(schema) %> + } setup %{conn: conn} do {:ok, conn: put_req_header(conn, "accept", "application/json")} diff --git a/test/mix/phoenix/test_data_test.exs b/test/mix/phoenix/test_data_test.exs index d1a0cabb78..d599a2a874 100644 --- a/test/mix/phoenix/test_data_test.exs +++ b/test/mix/phoenix/test_data_test.exs @@ -211,6 +211,84 @@ defmodule Mix.Phoenix.TestDataTest do } end + test "context_invalid_attrs/1 returns invalid attributes used in context" do + schema = %Schema{attrs: @parsed_attrs} + + assert TestData.context_invalid_attrs(schema) == + """ + + agreed: nil, + data: nil, + data: nil, + date_of_birth: nil, + happy_hour: nil, + happy_hour: nil, + joined: nil, + joined: nil, + joined: nil, + joined: nil, + meta: nil, + name: nil, + points: nil, + price: nil, + status: nil, + status: nil, + sum: nil, + tags: nil, + tags: nil, + tags: nil, + tags: nil, + the_cake_is_a_lie: nil, + title: nil, + title_limited: nil, + token: nil, + author_id: nil, + book_id: nil, + booking_id: nil, + post_id: nil + """ + |> String.trim_trailing("\n") + end + + test "controller_invalid_attrs/1 returns invalid attributes used in controller for html and json" do + schema = %Schema{attrs: @parsed_attrs} + + assert TestData.controller_invalid_attrs(schema) == + """ + + agreed: nil, + data: nil, + data: nil, + date_of_birth: nil, + happy_hour: nil, + happy_hour: nil, + joined: nil, + joined: nil, + joined: nil, + joined: nil, + meta: nil, + name: nil, + points: nil, + price: nil, + status: nil, + status: nil, + sum: nil, + tags: nil, + tags: nil, + tags: nil, + tags: nil, + the_cake_is_a_lie: nil, + title: nil, + title_limited: nil, + token: nil, + author_id: nil, + book_id: nil, + booking_id: nil, + post_id: nil + """ + |> String.trim_trailing("\n") + end + test "live_invalid_attrs/1 returns invalid attributes used in live" do sample_values = TestData.sample_values(@parsed_attrs, TestApp.Blog.Comment) schema = %Schema{sample_values: sample_values} @@ -605,36 +683,6 @@ defmodule Mix.Phoenix.TestDataTest do test "sample_values/1 returns map of base sample attrs to be used in test files, " <> "specific formatting logic is invoked per case when it needed only (based on these data)" do assert TestData.sample_values(@parsed_attrs, TestApp.Blog.Comment) == %{ - invalid: - "agreed: nil, " <> - "data: nil, " <> - "data: nil, " <> - "date_of_birth: nil, " <> - "happy_hour: nil, " <> - "happy_hour: nil, " <> - "joined: nil, " <> - "joined: nil, " <> - "joined: nil, " <> - "joined: nil, " <> - "meta: nil, " <> - "name: nil, " <> - "points: nil, " <> - "price: nil, " <> - "status: nil, " <> - "status: nil, " <> - "sum: nil, " <> - "tags: nil, " <> - "tags: nil, " <> - "tags: nil, " <> - "tags: nil, " <> - "the_cake_is_a_lie: nil, " <> - "title: nil, " <> - "title_limited: nil, " <> - "token: nil, " <> - "author_id: nil, " <> - "book_id: nil, " <> - "booking_id: nil, " <> - "post_id: nil", create: [ {%Attribute{ name: :agreed,
    <%%= col[:label] %>{col[:label]} <%= maybe_eex_gettext.("Actions", @gettext) %>