From ed6ef8002e58b503195a1cd93821db362d043190 Mon Sep 17 00:00:00 2001 From: Pavel Shpak Date: Sun, 24 Nov 2024 21:18:09 +0200 Subject: [PATCH] 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)