├── priv ├── static │ ├── favicon.ico │ └── robots.txt └── repo │ ├── migrations │ ├── .formatter.exs │ └── 20200608002150_initdb.exs │ ├── seeds.exs │ └── chinook_ddl.sql ├── test ├── test_helper.exs ├── chinook_web │ └── controllers │ │ └── error_json_test.exs ├── chinook │ ├── graphql │ │ ├── artist_node_test.exs │ │ ├── cursor_test.exs │ │ ├── playlist_test.exs │ │ ├── artist_test.exs │ │ └── genre_test.exs │ └── album_test.exs └── support │ ├── conn_case.ex │ └── data_case.ex ├── lib ├── chinook │ ├── data │ │ ├── repo.ex │ │ ├── result.ex │ │ ├── media_type.ex │ │ ├── paging_options.ex │ │ ├── user.ex │ │ ├── loader.ex │ │ ├── genre.ex │ │ ├── artist.ex │ │ ├── album.ex │ │ ├── playlist.ex │ │ ├── track.ex │ │ ├── invoice.ex │ │ ├── customer.ex │ │ ├── employee.ex │ │ └── query_helpers.ex │ ├── application.ex │ └── api │ │ ├── genre.ex │ │ ├── scope.ex │ │ ├── playlist.ex │ │ ├── filter.ex │ │ ├── album.ex │ │ ├── artist.ex │ │ ├── track.ex │ │ ├── invoice.ex │ │ ├── customer.ex │ │ ├── employee.ex │ │ ├── schema.ex │ │ └── relay.ex ├── chinook_web │ ├── router.ex │ ├── controllers │ │ └── error_json.ex │ ├── graphql_context.ex │ ├── endpoint.ex │ └── telemetry.ex └── chinook_web.ex ├── presentations └── 5 solutions to the preload-top-n problem.pdf ├── .formatter.exs ├── docker-compose.yml ├── config ├── prod.exs ├── test.exs ├── config.exs ├── dev.exs └── runtime.exs ├── .gitignore ├── LICENSE ├── staff-basic-auth.txt ├── .github └── workflows │ └── elixir.yml ├── mix.exs ├── customer-basic-auth.txt ├── mix.lock └── README.md /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbuhot/chinook/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Chinook.Repo, :manual) 3 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/chinook/data/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Repo do 2 | use Ecto.Repo, 3 | otp_app: :chinook, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /presentations/5 solutions to the preload-top-n problem.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbuhot/chinook/HEAD/presentations/5 solutions to the preload-top-n problem.pdf -------------------------------------------------------------------------------- /lib/chinook/data/result.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Result do 2 | def ok(x), do: {:ok, x} 3 | def error(x), do: {:error, x} 4 | 5 | # todo: map, map_error, bind, apply 6 | end 7 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | subdirectories: ["priv/*/migrations"], 4 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"] 5 | ] 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | postgres: 4 | image: postgres:alpine 5 | ports: 6 | - '5432:5432' 7 | environment: 8 | POSTGRES_PASSWORD: postgres 9 | POSTGRES_USER: postgres 10 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Do not print debug messages in production 4 | config :logger, level: :info 5 | 6 | # Runtime production configuration, including reading 7 | # of environment variables, is done on config/runtime.exs. 8 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /lib/chinook/data/media_type.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.MediaType do 2 | use Ecto.Schema 3 | 4 | @primary_key {:media_type_id, :integer, source: :MediaTypeId} 5 | 6 | schema "MediaType" do 7 | field :name, :string, source: :Name 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/chinook/data/paging_options.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.PagingOptions do 2 | @type t :: %{ 3 | required(:by) => atom, 4 | optional(:after) => any, 5 | optional(:before) => any, 6 | optional(:first) => integer, 7 | optional(:last) => integer 8 | } 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200608002150_initdb.exs: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Repo.Migrations.Initdb do 2 | use Ecto.Migration 3 | 4 | def change do 5 | "priv/repo/chinook_ddl.sql" 6 | |> File.read!() 7 | |> String.split(";") 8 | |> Enum.each(fn stmt -> 9 | execute(stmt) 10 | end) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/chinook/data/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.User do 2 | def authenticate(email, _password) do 3 | found = 4 | Chinook.Repo.get_by(Chinook.Employee, email: email) || 5 | Chinook.Repo.get_by(Chinook.Customer, email: email) 6 | 7 | case found do 8 | nil -> :error 9 | _ -> {:ok, found} 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/chinook_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ChinookWeb.Router do 2 | use ChinookWeb, :router 3 | 4 | pipeline :api do 5 | plug :accepts, ["json"] 6 | plug ChinookWeb.GraphQLContext 7 | end 8 | 9 | scope "/api" do 10 | pipe_through :api 11 | forward "/graphiql", Absinthe.Plug.GraphiQL, schema: Chinook.API.Schema 12 | forward "/", Absinthe.Plug, schema: Chinook.API.Schema 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/chinook_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule ChinookWeb.ErrorJSON do 2 | # If you want to customize a particular status code, 3 | # you may add your own clauses, such as: 4 | # 5 | # def render("500.json", _assigns) do 6 | # %{errors: %{detail: "Internal Server Error"}} 7 | # end 8 | def render(template, _assigns) do 9 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/chinook_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Chinkook.API.ErrorJSONTest do 2 | use ChinookWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert ChinookWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert ChinookWeb.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/chinook/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | @impl true 6 | def start(_type, _args) do 7 | children = [ 8 | ChinookWeb.Telemetry, 9 | Chinook.Repo, 10 | {Phoenix.PubSub, name: Chinook.PubSub}, 11 | ChinookWeb.Endpoint 12 | ] 13 | 14 | opts = [strategy: :one_for_one, name: Chinook.Supervisor] 15 | Supervisor.start_link(children, opts) 16 | end 17 | 18 | @impl true 19 | def config_change(changed, _new, removed) do 20 | ChinookWeb.Endpoint.config_change(changed, removed) 21 | :ok 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/chinook_web/graphql_context.ex: -------------------------------------------------------------------------------- 1 | defmodule ChinookWeb.GraphQLContext do 2 | @behaviour Plug 3 | 4 | def init(opts), do: opts 5 | 6 | def call(conn, _) do 7 | context = build_context(conn) 8 | Absinthe.Plug.put_options(conn, context: context) 9 | end 10 | 11 | @doc """ 12 | Return the current user context based on the authorization header 13 | """ 14 | def build_context(conn) do 15 | with {email, pass} <- Plug.BasicAuth.parse_basic_auth(conn), 16 | {:ok, user} <- Chinook.User.authenticate(email, pass) do 17 | %{current_user: user} 18 | else 19 | :error -> %{} 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | chinook-*.tar 27 | 28 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Chinook.Repo.insert!(%Chinook.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | alias Chinook.Repo 13 | 14 | "priv/repo/chinook_genres_artists_albums.sql" 15 | |> File.read!() 16 | |> String.split(~r/;\n/) 17 | |> Enum.each(fn stmt -> 18 | {:ok, _} = Ecto.Adapters.SQL.query(Repo, stmt) 19 | end) 20 | 21 | "priv/repo/chinook_songs.sql" 22 | |> File.read!() 23 | |> String.split(~r/;\n/) 24 | |> Enum.each(fn stmt -> 25 | {:ok, _} = Ecto.Adapters.SQL.query(Repo, stmt) 26 | end) 27 | -------------------------------------------------------------------------------- /lib/chinook/data/loader.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Loader do 2 | alias Chinook.{Album, Artist, Customer, Employee, Genre, Invoice, Playlist, Track} 3 | 4 | def data() do 5 | Dataloader.new() 6 | |> Dataloader.add_source( 7 | Chinook.Loader, 8 | Dataloader.Ecto.new(Chinook.Repo, 9 | query: fn 10 | Album, args -> Album.Loader.query(args) 11 | Artist, args -> Artist.Loader.query(args) 12 | Customer, args -> Customer.Loader.query(args) 13 | Employee, args -> Employee.Loader.query(args) 14 | Genre, args -> Genre.Loader.query(args) 15 | Invoice, args -> Invoice.Loader.query(args) 16 | Invoice.Line, _args -> Invoice.Line 17 | Playlist, args -> Playlist.Loader.query(args) 18 | Track, args -> Track.Loader.query(args) 19 | end 20 | ) 21 | ) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :chinook, Chinook.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | hostname: "localhost", 12 | database: "chinook_test#{System.get_env("MIX_TEST_PARTITION")}", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: 10 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :chinook, ChinookWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "YtV2hAIe70pmIMiDQyEFrml6J7A8Syq6z5/f28GYbm/GiAZPIgT/kejE3U+LFWBi", 21 | server: false 22 | 23 | # Print only warnings and errors during test 24 | config :logger, level: :warning 25 | 26 | # Initialize plugs at runtime for faster test compilation 27 | config :phoenix, :plug_init_mode, :runtime 28 | -------------------------------------------------------------------------------- /lib/chinook/api/genre.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Schema.Genre do 2 | use Absinthe.Schema.Notation 3 | use Absinthe.Relay.Schema.Notation, :modern 4 | 5 | alias Chinook.API.Relay 6 | 7 | @desc "Genre sort order" 8 | enum :genre_sort_order do 9 | value(:id, as: :genre_id) 10 | value(:name, as: :name) 11 | end 12 | 13 | @desc "Genre filter" 14 | input_object :genre_filter do 15 | field :name, :string_filter 16 | end 17 | 18 | node object(:genre, id_fetcher: &Relay.id/2) do 19 | field :name, non_null(:string) 20 | 21 | connection field :tracks, node_type: :track do 22 | arg(:by, :track_sort_order, default_value: :track_id) 23 | arg(:filter, :track_filter, default_value: %{}) 24 | 25 | resolve(Relay.connection_dataloader(Chinook.Loader)) 26 | end 27 | end 28 | 29 | def resolve_node(id, resolution) do 30 | Relay.node_dataloader(Chinook.Loader, Chinook.Genre, id, resolution) 31 | end 32 | 33 | def resolve_connection do 34 | Relay.connection_from_query(&Chinook.Genre.Loader.query/1) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/chinook/api/scope.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Scope do 2 | @behaviour Absinthe.Middleware 3 | def call(res, read: :invoice), do: scope_with(res, Chinook.Invoice.Auth, :read, :invoice) 4 | def call(res, read: :employee), do: scope_with(res, Chinook.Employee.Auth, :read, :employee) 5 | def call(res, read: :customer), do: scope_with(res, Chinook.Customer.Auth, :read, :customer) 6 | 7 | defp scope_with(res, mod, action, resource) do 8 | with {:ok, current_user} <- Map.fetch(res.context, :current_user), 9 | {:ok, scope} <- mod.can?(current_user, action, resource) do 10 | put_scope(res, scope) 11 | else 12 | :error -> Absinthe.Resolution.put_result(res, {:error, :not_authorized}) 13 | {:error, err} -> Absinthe.Resolution.put_result(res, {:error, err}) 14 | end 15 | end 16 | 17 | defp put_scope(resolution = %{context: context, arguments: arguments}, scope) do 18 | %{ 19 | resolution 20 | | context: Map.put(context, :scope, scope), 21 | arguments: Map.put(arguments, :scope, scope) 22 | } 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :chinook, 11 | ecto_repos: [Chinook.Repo] 12 | 13 | # Configures the endpoint 14 | config :chinook, ChinookWeb.Endpoint, 15 | url: [host: "localhost"], 16 | render_errors: [ 17 | formats: [json: ChinookWeb.ErrorJSON], 18 | layout: false 19 | ], 20 | pubsub_server: Chinook.PubSub, 21 | live_view: [signing_salt: "5rsXl+hT"] 22 | 23 | # Configures Elixir's Logger 24 | config :logger, :console, 25 | format: "$time $metadata[$level] $message\n", 26 | metadata: [:request_id] 27 | 28 | # Use Jason for JSON parsing in Phoenix 29 | config :phoenix, :json_library, Jason 30 | 31 | # Import environment specific config. This must remain at the bottom 32 | # of this file so it overrides the configuration defined above. 33 | import_config "#{config_env()}.exs" 34 | -------------------------------------------------------------------------------- /lib/chinook/api/playlist.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Schema.Playlist do 2 | use Absinthe.Schema.Notation 3 | use Absinthe.Relay.Schema.Notation, :modern 4 | 5 | alias Chinook.API.Relay 6 | 7 | @desc "Playlist sort order" 8 | enum :playlist_sort_order do 9 | value(:id, as: :playlist_id) 10 | value(:name, as: :name) 11 | end 12 | 13 | @desc "Playlist filter" 14 | input_object :playlist_filter do 15 | field :name, :string_filter 16 | end 17 | 18 | node object(:playlist, id_fetcher: &Relay.id/2) do 19 | field :name, non_null(:string) 20 | 21 | connection field :tracks, node_type: :track do 22 | arg(:by, :track_sort_order, default_value: :track_id) 23 | arg(:filter, :track_filter, default_value: %{}) 24 | resolve(Relay.connection_dataloader(Chinook.Loader)) 25 | end 26 | end 27 | 28 | def resolve_node(id, resolution) do 29 | Relay.node_dataloader(Chinook.Loader, Chinook.Playlist, id, resolution) 30 | end 31 | 32 | def resolve_connection do 33 | Relay.connection_from_query(&Chinook.Playlist.Loader.query/1) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/chinook/api/filter.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Schema.Filter do 2 | use Absinthe.Schema.Notation 3 | 4 | @desc "String filter" 5 | input_object :string_filter do 6 | @desc "SQL style pattern with %" 7 | field :like, :string 8 | 9 | @desc "String starts with the given string" 10 | field :starts_with, :string 11 | 12 | @desc "String ends with the given string" 13 | field :ends_with, :string 14 | end 15 | 16 | @desc "Integer filter" 17 | input_object :int_filter do 18 | field :gt, :integer 19 | field :gte, :integer 20 | field :eq, :integer 21 | field :ne, :integer 22 | field :lt, :integer 23 | field :lte, :integer 24 | end 25 | 26 | @desc "Decimal filter" 27 | input_object :decimal_filter do 28 | field :gt, :decimal 29 | field :gte, :decimal 30 | field :eq, :decimal 31 | field :ne, :decimal 32 | field :lt, :decimal 33 | field :lte, :decimal 34 | end 35 | 36 | @desc "DateTime filter" 37 | input_object :datetime_filter do 38 | field :before, :datetime 39 | field :after, :datetime 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/chinook/graphql/artist_node_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Chinkook.API.ArtistNodeTest do 2 | use Chinook.DataCase, async: true 3 | 4 | @query """ 5 | query { 6 | node (id: "QXJ0aXN0OjE=") { 7 | id 8 | ... on Artist { 9 | name 10 | albums { 11 | edges { 12 | node { 13 | title 14 | } 15 | } 16 | } 17 | } 18 | } 19 | } 20 | """ 21 | 22 | @expected %{ 23 | data: %{ 24 | "node" => %{ 25 | "albums" => %{ 26 | "edges" => [ 27 | %{ 28 | "node" => %{ 29 | "title" => "For Those About To Rock We Salute You" 30 | } 31 | }, 32 | %{ 33 | "node" => %{ 34 | "title" => "Let There Be Rock" 35 | } 36 | } 37 | ] 38 | }, 39 | "id" => "QXJ0aXN0OjE=", 40 | "name" => "AC/DC" 41 | } 42 | } 43 | } 44 | 45 | test "query artist_node" do 46 | assert Absinthe.run!(@query, Chinook.API.Schema) == @expected 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mike Buhot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/chinook/data/genre.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Genre do 2 | use Ecto.Schema 3 | alias __MODULE__ 4 | alias Chinook.Track 5 | 6 | @type t :: %__MODULE__{} 7 | 8 | @primary_key {:genre_id, :integer, source: :GenreId} 9 | 10 | schema "Genre" do 11 | field(:name, :string, source: :Name) 12 | field :row_count, :integer, virtual: true 13 | 14 | has_many(:tracks, Track, foreign_key: :genre_id) 15 | end 16 | 17 | defmodule Loader do 18 | import Ecto.Query 19 | import Chinook.QueryHelpers 20 | 21 | @spec query(PagingOptions.t()) :: Ecto.Query.t() 22 | def query(args) do 23 | args = Map.put_new(args, :by, :genre_id) 24 | 25 | Genre 26 | |> from(as: :genre) 27 | |> select_fields(Genre, :genre, args[:fields]) 28 | |> paginate(Genre, :genre, args) 29 | |> filter(args[:filter]) 30 | end 31 | 32 | def filter(queryable, nil), do: queryable 33 | 34 | def filter(queryable, filters) do 35 | Enum.reduce(filters, queryable, fn 36 | {:name, name_filter}, queryable -> filter_string(queryable, :name, name_filter) 37 | end) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/chinook_web.ex: -------------------------------------------------------------------------------- 1 | defmodule ChinookWeb do 2 | @moduledoc false 3 | 4 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 5 | 6 | def router do 7 | quote do 8 | use Phoenix.Router, helpers: false 9 | 10 | # Import common connection and controller functions to use in pipelines 11 | import Plug.Conn 12 | import Phoenix.Controller 13 | end 14 | end 15 | 16 | def channel do 17 | quote do 18 | use Phoenix.Channel 19 | end 20 | end 21 | 22 | def controller do 23 | quote do 24 | use Phoenix.Controller, 25 | formats: [:html, :json], 26 | layouts: [html: ChinookWeb.Layouts] 27 | 28 | import Plug.Conn 29 | 30 | unquote(verified_routes()) 31 | end 32 | end 33 | 34 | def verified_routes do 35 | quote do 36 | use Phoenix.VerifiedRoutes, 37 | endpoint: ChinookWeb.Endpoint, 38 | router: ChinookWeb.Router, 39 | statics: ChinookWeb.static_paths() 40 | end 41 | end 42 | 43 | @doc """ 44 | When used, dispatch to the appropriate controller/view/etc. 45 | """ 46 | defmacro __using__(which) when is_atom(which) do 47 | apply(__MODULE__, which, []) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/chinook/api/album.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Schema.Album do 2 | use Absinthe.Schema.Notation 3 | use Absinthe.Relay.Schema.Notation, :modern 4 | 5 | # import Absinthe.Resolution.Helpers, only: [dataloader: 1] 6 | 7 | alias Chinook.API.Relay 8 | 9 | @desc "Album sort order" 10 | enum :album_sort_order do 11 | value(:id, as: :album_id) 12 | value(:title, as: :title) 13 | value(:artist_name, as: :artist_name) 14 | end 15 | 16 | @desc "Album filter" 17 | input_object :album_filter do 18 | field :title, :string_filter 19 | end 20 | 21 | node object(:album, id_fetcher: &Relay.id/2) do 22 | field :title, non_null(:string) 23 | field :artist, :artist, resolve: Relay.node_dataloader(Chinook.Loader) 24 | 25 | connection field :tracks, node_type: :track do 26 | arg(:by, :track_sort_order, default_value: :track_id) 27 | arg(:filter, :track_filter, default_value: %{}) 28 | resolve(Relay.connection_dataloader(Chinook.Loader)) 29 | end 30 | end 31 | 32 | def resolve_node(id, resolution) do 33 | Relay.node_dataloader(Chinook.Loader, Chinook.Album, id, resolution) 34 | end 35 | 36 | def resolve_connection do 37 | Relay.connection_from_query(&Chinook.Album.Loader.query/1) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/chinook/data/artist.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Artist do 2 | use Ecto.Schema 3 | alias __MODULE__ 4 | alias Chinook.Album 5 | 6 | @type t :: %__MODULE__{} 7 | 8 | @primary_key {:artist_id, :integer, source: :ArtistId} 9 | 10 | schema "Artist" do 11 | field(:name, :string, source: :Name) 12 | field :row_count, :integer, virtual: true 13 | 14 | has_many(:albums, Album, foreign_key: :artist_id) 15 | has_many(:tracks, through: [:albums, :tracks]) 16 | has_many(:fans, through: [:tracks, :purchasers]) 17 | end 18 | 19 | defmodule Loader do 20 | import Ecto.Query 21 | import Chinook.QueryHelpers 22 | 23 | @spec query(PagingOptions.t()) :: Ecto.Query.t() 24 | def query(args) do 25 | args = Map.put_new(args, :by, :artist_id) 26 | 27 | Artist 28 | |> from(as: :artist) 29 | |> select_fields(Artist, :artist, args[:fields]) 30 | |> paginate(Artist, :artist, args) 31 | |> filter(args[:filter]) 32 | end 33 | 34 | def filter(queryable, nil), do: queryable 35 | 36 | def filter(queryable, filters) do 37 | Enum.reduce(filters, queryable, fn 38 | {:name, name_filter}, queryable -> filter_string(queryable, :name, name_filter) 39 | end) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ChinookWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use ChinookWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint ChinookWeb.Endpoint 24 | 25 | use ChinookWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import ChinookWeb.ConnCase 31 | end 32 | end 33 | 34 | setup tags do 35 | Chinook.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /staff-basic-auth.txt: -------------------------------------------------------------------------------- 1 | [ 2 | %{ 3 | basic_auth: "Basic YW5kcmV3QGNoaW5vb2tjb3JwLmNvbTpwYXNzd29yZA==", 4 | email: "andrew@chinookcorp.com", 5 | title: "General Manager" 6 | }, 7 | %{ 8 | basic_auth: "Basic bmFuY3lAY2hpbm9va2NvcnAuY29tOnBhc3N3b3Jk", 9 | email: "nancy@chinookcorp.com", 10 | title: "Sales Manager" 11 | }, 12 | %{ 13 | basic_auth: "Basic amFuZUBjaGlub29rY29ycC5jb206cGFzc3dvcmQ=", 14 | email: "jane@chinookcorp.com", 15 | title: "Sales Support Agent" 16 | }, 17 | %{ 18 | basic_auth: "Basic bWFyZ2FyZXRAY2hpbm9va2NvcnAuY29tOnBhc3N3b3Jk", 19 | email: "margaret@chinookcorp.com", 20 | title: "Sales Support Agent" 21 | }, 22 | %{ 23 | basic_auth: "Basic c3RldmVAY2hpbm9va2NvcnAuY29tOnBhc3N3b3Jk", 24 | email: "steve@chinookcorp.com", 25 | title: "Sales Support Agent" 26 | }, 27 | %{ 28 | basic_auth: "Basic bWljaGFlbEBjaGlub29rY29ycC5jb206cGFzc3dvcmQ=", 29 | email: "michael@chinookcorp.com", 30 | title: "IT Manager" 31 | }, 32 | %{ 33 | basic_auth: "Basic cm9iZXJ0QGNoaW5vb2tjb3JwLmNvbTpwYXNzd29yZA==", 34 | email: "robert@chinookcorp.com", 35 | title: "IT Staff" 36 | }, 37 | %{ 38 | basic_auth: "Basic bGF1cmFAY2hpbm9va2NvcnAuY29tOnBhc3N3b3Jk", 39 | email: "laura@chinookcorp.com", 40 | title: "IT Staff" 41 | } 42 | ] -------------------------------------------------------------------------------- /test/chinook/graphql/cursor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Chinkook.API.CursorTest do 2 | use Chinook.DataCase, async: true 3 | 4 | @query """ 5 | query { 6 | artists(first:2, by:NAME, after:"bmFtZXxhcnRpc3RfaWR8Mg=="){ 7 | pageInfo { 8 | hasPreviousPage 9 | hasNextPage 10 | endCursor 11 | startCursor 12 | } 13 | edges { 14 | cursor 15 | node { 16 | name 17 | } 18 | } 19 | } 20 | } 21 | """ 22 | 23 | @expected %{ 24 | data: %{ 25 | "artists" => %{ 26 | "edges" => [ 27 | %{ 28 | "cursor" => "bmFtZXxhcnRpc3RfaWR8MjYw", 29 | "node" => %{ 30 | "name" => "Adrian Leaper & Doreen de Feis" 31 | } 32 | }, 33 | %{ 34 | "cursor" => "bmFtZXxhcnRpc3RfaWR8Mw==", 35 | "node" => %{"name" => "Aerosmith"} 36 | } 37 | ], 38 | "pageInfo" => %{ 39 | "endCursor" => "bmFtZXxhcnRpc3RfaWR8Mw==", 40 | "hasNextPage" => true, 41 | "hasPreviousPage" => true, 42 | "startCursor" => "bmFtZXxhcnRpc3RfaWR8MjYw" 43 | } 44 | } 45 | } 46 | } 47 | 48 | test "query artists with cursor" do 49 | assert Absinthe.run!(@query, Chinook.API.Schema) == @expected 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 13 | 14 | services: 15 | postgres: 16 | image: postgres:alpine 17 | ports: 18 | - 5432:5432 19 | env: 20 | POSTGRES_PASSWORD: postgres 21 | POSTGRES_USER: postgres 22 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 23 | 24 | strategy: 25 | matrix: 26 | otp: ['26.0'] 27 | elixir: ['1.15.3'] 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: erlef/setup-beam@v1 31 | with: 32 | otp-version: ${{matrix.otp}} 33 | elixir-version: ${{matrix.elixir}} 34 | 35 | - name: Cache hex deps 36 | uses: actions/cache@v2 37 | env: 38 | cache-name: cache-hex-deps 39 | with: 40 | path: ./deps 41 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('mix.lock') }} 42 | 43 | - name: Install dependencies 44 | run: mix deps.get 45 | 46 | - name: Setup Database 47 | env: 48 | MIX_ENV: test 49 | run: mix ecto.setup 50 | - run: mix test --trace 51 | -------------------------------------------------------------------------------- /lib/chinook_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ChinookWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :chinook 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_chinook_key", 10 | signing_salt: "6TyLJJs/", 11 | same_site: "Lax" 12 | ] 13 | 14 | # Serve at "/" the static files from "priv/static" directory. 15 | # 16 | # You should set gzip to true if you are running phx.digest 17 | # when deploying your static files in production. 18 | plug Plug.Static, 19 | at: "/", 20 | from: :chinook, 21 | gzip: false, 22 | only: ChinookWeb.static_paths() 23 | 24 | # Code reloading can be explicitly enabled under the 25 | # :code_reloader configuration of your endpoint. 26 | if code_reloading? do 27 | plug Phoenix.CodeReloader 28 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :chinook 29 | end 30 | 31 | plug Phoenix.LiveDashboard.RequestLogger, 32 | param_key: "request_logger", 33 | cookie_key: "request_logger" 34 | 35 | plug Plug.RequestId 36 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 37 | 38 | plug Plug.Parsers, 39 | parsers: [:urlencoded, :multipart, :json], 40 | pass: ["*/*"], 41 | json_decoder: Phoenix.json_library() 42 | 43 | plug Plug.MethodOverride 44 | plug Plug.Head 45 | plug Plug.Session, @session_options 46 | plug ChinookWeb.Router 47 | end 48 | -------------------------------------------------------------------------------- /lib/chinook/data/album.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Album do 2 | use Ecto.Schema 3 | alias __MODULE__ 4 | alias Chinook.Artist 5 | alias Chinook.Track 6 | 7 | @type t :: %__MODULE__{} 8 | 9 | @primary_key {:album_id, :integer, source: :AlbumId} 10 | 11 | schema "Album" do 12 | field :title, :string, source: :Title 13 | field :row_count, :integer, virtual: true 14 | 15 | belongs_to :artist, Artist, foreign_key: :artist_id, references: :artist_id, source: :ArtistId 16 | has_many :tracks, Track, foreign_key: :album_id 17 | end 18 | 19 | defmodule Loader do 20 | import Ecto.Query 21 | import Chinook.QueryHelpers 22 | 23 | @spec query(PagingOptions.t()) :: Ecto.Query.t() 24 | def query(args) do 25 | args = Map.put_new(args, :by, :album_id) 26 | 27 | Album 28 | |> from(as: :album) 29 | |> select_fields(Album, :album, args[:fields]) 30 | |> do_paginate(args) 31 | |> filter(args[:filter]) 32 | end 33 | 34 | defp do_paginate(query, args = %{by: :artist_name}) do 35 | query 36 | |> join(:inner, [album: a], assoc(a, :artist), as: :artist) 37 | |> paginate(Album, :album, :artist, %{args | by: :name}) 38 | end 39 | 40 | defp do_paginate(query, args), do: paginate(query, Album, :album, args) 41 | 42 | def filter(queryable, nil), do: queryable 43 | 44 | def filter(queryable, filters) do 45 | Enum.reduce(filters, queryable, fn 46 | {:title, title_filter}, queryable -> filter_string(queryable, :title, title_filter) 47 | end) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/chinook/api/artist.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Schema.Artist do 2 | use Absinthe.Schema.Notation 3 | use Absinthe.Relay.Schema.Notation, :modern 4 | 5 | alias Chinook.API.Relay 6 | alias Chinook.API.Scope 7 | 8 | @desc "Artist sort order" 9 | enum :artist_sort_order do 10 | value(:id, as: :artist_id) 11 | value(:name, as: :name) 12 | end 13 | 14 | @desc "Artist filter" 15 | input_object :artist_filter do 16 | field :name, :string_filter 17 | end 18 | 19 | node object(:artist, id_fetcher: &Relay.id/2) do 20 | field :name, non_null(:string) 21 | 22 | connection field(:albums, node_type: :album) do 23 | arg(:by, :album_sort_order, default_value: :album_id) 24 | arg(:filter, :album_filter, default_value: %{}) 25 | resolve(Relay.connection_dataloader(Chinook.Loader)) 26 | end 27 | 28 | connection field(:tracks, node_type: :track) do 29 | arg(:by, :track_sort_order, default_value: :track_id) 30 | arg(:filter, :track_filter, default_value: %{}) 31 | resolve(Relay.connection_dataloader(Chinook.Loader)) 32 | end 33 | 34 | connection field(:fans, node_type: :customer) do 35 | arg(:by, :customer_sort_order, default_value: :customer_id) 36 | arg(:filter, :customer_filter, default_value: %{}) 37 | 38 | middleware(Scope, read: :customer) 39 | resolve(Relay.connection_dataloader(Chinook.Loader)) 40 | end 41 | end 42 | 43 | def resolve_node(id, resolution) do 44 | Relay.node_dataloader(Chinook.Loader, Chinook.Artist, id, resolution) 45 | end 46 | 47 | def resolve_connection do 48 | Relay.connection_from_query(&Chinook.Artist.Loader.query/1) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/chinook/data/playlist.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.PlaylistTrack do 2 | use Ecto.Schema 3 | alias Chinook.Track 4 | alias Chinook.Playlist 5 | 6 | @type t :: %__MODULE__{} 7 | 8 | @primary_key false 9 | schema "PlaylistTrack" do 10 | belongs_to :track, Track, foreign_key: :track_id, references: :track_id, source: :TrackId 11 | 12 | belongs_to :playlist, Playlist, 13 | foreign_key: :playlist_id, 14 | references: :playlist_id, 15 | source: :PlaylistId 16 | end 17 | end 18 | 19 | defmodule Chinook.Playlist do 20 | use Ecto.Schema 21 | alias __MODULE__ 22 | alias Chinook.Track 23 | alias Chinook.PlaylistTrack 24 | 25 | @type t :: %__MODULE__{} 26 | 27 | @primary_key {:playlist_id, :integer, source: :PlaylistId} 28 | 29 | schema "Playlist" do 30 | field :name, :string, source: :Name 31 | field :row_count, :integer, virtual: true 32 | 33 | many_to_many :tracks, Track, 34 | join_through: PlaylistTrack, 35 | join_keys: [playlist_id: :playlist_id, track_id: :track_id] 36 | end 37 | 38 | defmodule Loader do 39 | import Ecto.Query 40 | import Chinook.QueryHelpers 41 | 42 | @spec query(PagingOptions.t()) :: Ecto.Query.t() 43 | def query(args) do 44 | args = Map.put_new(args, :by, :playlist_id) 45 | 46 | Playlist 47 | |> from(as: :playlist) 48 | |> select_fields(Playlist, :playlist, args[:fields]) 49 | |> paginate(Playlist, :playlist, args) 50 | |> filter(args[:filter]) 51 | end 52 | 53 | def filter(queryable, nil), do: queryable 54 | 55 | def filter(queryable, filters) do 56 | Enum.reduce(filters, queryable, fn 57 | {:name, name_filter}, queryable -> filter_string(queryable, :name, name_filter) 58 | end) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/chinook/graphql/playlist_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Chinkook.API.PlaylistTest do 2 | use Chinook.DataCase, async: true 3 | 4 | @query """ 5 | query { 6 | playlists(last:2) { 7 | edges { 8 | node { 9 | id 10 | name 11 | tracks(first:1) { 12 | edges { 13 | node { 14 | id 15 | name 16 | } 17 | } 18 | } 19 | } 20 | } 21 | } 22 | } 23 | """ 24 | 25 | @expected %{ 26 | data: %{ 27 | "playlists" => %{ 28 | "edges" => [ 29 | %{ 30 | "node" => %{ 31 | "id" => "UGxheWxpc3Q6MTc=", 32 | "name" => "Heavy Metal Classic", 33 | "tracks" => %{ 34 | "edges" => [ 35 | %{ 36 | "node" => %{ 37 | "id" => "VHJhY2s6MQ==", 38 | "name" => "For Those About To Rock (We Salute You)" 39 | } 40 | } 41 | ] 42 | } 43 | } 44 | }, 45 | %{ 46 | "node" => %{ 47 | "id" => "UGxheWxpc3Q6MTg=", 48 | "name" => "On-The-Go 1", 49 | "tracks" => %{ 50 | "edges" => [ 51 | %{ 52 | "node" => %{ 53 | "id" => "VHJhY2s6NTk3", 54 | "name" => "Now's The Time" 55 | } 56 | } 57 | ] 58 | } 59 | } 60 | } 61 | ] 62 | } 63 | } 64 | } 65 | 66 | test "query playlist" do 67 | assert Absinthe.run!(@query, Chinook.API.Schema) == @expected 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/chinook/api/track.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Schema.Track do 2 | use Absinthe.Schema.Notation 3 | use Absinthe.Relay.Schema.Notation, :modern 4 | 5 | alias Chinook.API.Relay 6 | alias Chinook.API.Scope 7 | 8 | @desc "Track sort order" 9 | enum :track_sort_order do 10 | value(:id, as: :track_id) 11 | value(:name, as: :name) 12 | value(:duration, as: :milliseconds) 13 | value(:artist_name, as: :artist_name) 14 | end 15 | 16 | @desc "Track filter" 17 | input_object :track_filter do 18 | field :name, :string_filter 19 | field :composer, :string_filter 20 | field :duration, :int_filter 21 | field :bytes, :int_filter 22 | field :unit_price, :decimal_filter 23 | end 24 | 25 | node object(:track, id_fetcher: &Relay.id/2) do 26 | field :name, non_null(:string) 27 | 28 | field :duration, non_null(:integer) do 29 | resolve(fn _args, %{source: track} -> {:ok, Map.get(track, :milliseconds)} end) 30 | end 31 | 32 | field :composer, :string 33 | field :bytes, non_null(:integer) 34 | field :unit_price, non_null(:decimal) 35 | 36 | field :genre, :genre, resolve: Relay.node_dataloader(Chinook.Loader) 37 | field :album, :album, resolve: Relay.node_dataloader(Chinook.Loader) 38 | field :artist, :artist, resolve: Relay.node_dataloader(Chinook.Loader) 39 | 40 | connection field :purchasers, node_type: :customer do 41 | arg(:by, :customer_sort_order, default_value: :customer_id) 42 | arg(:filter, :customer_filter, default_value: %{}) 43 | 44 | middleware(Scope, read: :customer) 45 | resolve(Relay.connection_dataloader(Chinook.Loader)) 46 | end 47 | end 48 | 49 | def resolve_node(id, resolution) do 50 | Relay.node_dataloader(Chinook.Loader, Chinook.Track, id, resolution) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use Chinook.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Chinook.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Chinook.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | Chinook.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Chinook.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Chinook.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :chinook, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {Chinook.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:absinthe_plug, ">= 0.0.0"}, 36 | {:absinthe_relay, ">= 0.0.0"}, 37 | {:absinthe, ">= 0.0.0"}, 38 | {:dataloader, ">= 0.0.0"}, 39 | {:ecto_sql, "~> 3.10"}, 40 | {:jason, "~> 1.2"}, 41 | {:phoenix_ecto, "~> 4.4"}, 42 | {:phoenix_live_dashboard, "~> 0.8.0"}, 43 | {:phoenix, "~> 1.7.7"}, 44 | {:plug_cowboy, "~> 2.5"}, 45 | {:postgrex, ">= 0.0.0"}, 46 | {:telemetry_metrics, "~> 0.6"}, 47 | {:telemetry_poller, "~> 1.0"} 48 | ] 49 | end 50 | 51 | # Aliases are shortcuts or tasks specific to the current project. 52 | # For example, to install project dependencies and perform other setup tasks, run: 53 | # 54 | # $ mix setup 55 | # 56 | # See the documentation for `Mix` for more info on aliases. 57 | defp aliases do 58 | [ 59 | setup: ["deps.get", "ecto.setup"], 60 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 61 | "ecto.reset": ["ecto.drop", "ecto.setup"], 62 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] 63 | ] 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :chinook, Chinook.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | hostname: "localhost", 8 | database: "chinook_dev", 9 | stacktrace: true, 10 | show_sensitive_data_on_connection_error: true, 11 | pool_size: 10 12 | 13 | # For development, we disable any cache and enable 14 | # debugging and code reloading. 15 | # 16 | # The watchers configuration can be used to run external 17 | # watchers to your application. For example, we can use it 18 | # to bundle .js and .css sources. 19 | config :chinook, ChinookWeb.Endpoint, 20 | # Binding to loopback ipv4 address prevents access from other machines. 21 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 22 | http: [ip: {127, 0, 0, 1}, port: 4000], 23 | check_origin: false, 24 | code_reloader: true, 25 | debug_errors: true, 26 | secret_key_base: "RFAXS34Nz6hQpkTMFH+W4ksDuU5cHiFuNz0PHzfGb+gE4TOXd3ERgMEe02MOtwco", 27 | watchers: [] 28 | 29 | # ## SSL Support 30 | # 31 | # In order to use HTTPS in development, a self-signed 32 | # certificate can be generated by running the following 33 | # Mix task: 34 | # 35 | # mix phx.gen.cert 36 | # 37 | # Run `mix help phx.gen.cert` for more information. 38 | # 39 | # The `http:` config above can be replaced with: 40 | # 41 | # https: [ 42 | # port: 4001, 43 | # cipher_suite: :strong, 44 | # keyfile: "priv/cert/selfsigned_key.pem", 45 | # certfile: "priv/cert/selfsigned.pem" 46 | # ], 47 | # 48 | # If desired, both `http:` and `https:` keys can be 49 | # configured to run both http and https servers on 50 | # different ports. 51 | 52 | # Enable dev routes for dashboard and mailbox 53 | config :chinook, dev_routes: true 54 | 55 | # Do not include metadata nor timestamps in development logs 56 | config :logger, :console, format: "[$level] $message\n" 57 | 58 | # Set a higher stacktrace during development. Avoid configuring such 59 | # in production as building large stacktraces may be expensive. 60 | config :phoenix, :stacktrace_depth, 20 61 | 62 | # Initialize plugs at runtime for faster development compilation 63 | config :phoenix, :plug_init_mode, :runtime 64 | -------------------------------------------------------------------------------- /lib/chinook/api/invoice.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Schema.Invoice do 2 | use Absinthe.Schema.Notation 3 | use Absinthe.Relay.Schema.Notation, :modern 4 | 5 | import Absinthe.Resolution.Helpers, only: [dataloader: 1] 6 | 7 | alias Chinook.API.Relay 8 | alias Chinook.API.Scope 9 | 10 | @desc "Invoice sort order" 11 | enum :invoice_sort_order do 12 | value(:id, as: :invoice_id) 13 | value(:invoice_date, as: :invoice_date) 14 | value(:total, as: :total) 15 | end 16 | 17 | @desc "Invoice filter" 18 | input_object :invoice_filter do 19 | field :invoice_date, :datetime_filter 20 | field :total, :decimal_filter 21 | end 22 | 23 | node object(:invoice, id_fetcher: &Relay.id/2) do 24 | field :invoice_date, :naive_datetime 25 | field :billing_address, :string 26 | field :billing_city, :string 27 | field :billing_state, :string 28 | field :billing_country, :string 29 | field :billing_postal_code, :string 30 | field :total, :decimal 31 | 32 | field :customer, :customer do 33 | middleware(Scope, read: :customer) 34 | resolve(Relay.node_dataloader(Chinook.Loader)) 35 | end 36 | 37 | # line_items is not a connection here, just a list that can be resolved along with the 38 | # invoice if needed by the client. 39 | field :line_items, list_of(:invoice_line), resolve: dataloader(Chinook.Loader) 40 | end 41 | 42 | # Using `node object` here for convenience of letting Relay generate the opaque ID 43 | # invoice_line is not a true node type, it can't be resolved using the Schema.node field. 44 | node object(:invoice_line, id_fetcher: &Relay.id/2) do 45 | field :unit_price, :decimal 46 | field :quantity, :integer 47 | field :track, :track, resolve: Relay.node_dataloader(Chinook.Loader) 48 | 49 | field :invoice, :invoice do 50 | middleware(Scope, read: :invoice) 51 | resolve(Relay.node_dataloader(Chinook.Loader)) 52 | end 53 | end 54 | 55 | def resolve_node(id, resolution = %{context: %{current_user: current_user}}) do 56 | with {:ok, scope} <- Chinook.Invoice.Auth.can?(current_user, :read, :invoice) do 57 | Relay.node_dataloader(Chinook.Loader, {Chinook.Invoice, %{scope: scope}}, id, resolution) 58 | end 59 | end 60 | 61 | def resolve_connection do 62 | Relay.connection_from_query(&Chinook.Invoice.Loader.query/1) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/chinook/api/customer.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Schema.Customer do 2 | use Absinthe.Schema.Notation 3 | use Absinthe.Relay.Schema.Notation, :modern 4 | 5 | alias Chinook.API.Relay 6 | alias Chinook.API.Scope 7 | 8 | @desc "Customer sort order" 9 | enum :customer_sort_order do 10 | value(:id, as: :customer_id) 11 | value(:last_name, as: :last_name) 12 | value(:email, as: :email) 13 | end 14 | 15 | @desc "Customer filter" 16 | input_object :customer_filter do 17 | field :first_name, :string_filter 18 | field :last_name, :string_filter 19 | field :company, :string_filter 20 | field :address, :string_filter 21 | field :city, :string_filter 22 | field :state, :string_filter 23 | field :country, :string_filter 24 | field :postal_code, :string_filter 25 | field :phone, :string_filter 26 | field :fax, :string_filter 27 | field :email, :string_filter 28 | end 29 | 30 | node object(:customer, id_fetcher: &Relay.id/2) do 31 | field :first_name, :string 32 | field :last_name, :string 33 | field :company, :string 34 | field :address, :string 35 | field :city, :string 36 | field :state, :string 37 | field :country, :string 38 | field :postal_code, :string 39 | field :phone, :string 40 | field :fax, :string 41 | field :email, :string 42 | 43 | field :support_rep, :employee do 44 | middleware(Scope, read: :employee) 45 | resolve(Relay.node_dataloader(Chinook.Loader)) 46 | end 47 | 48 | connection field :invoices, node_type: :invoice do 49 | arg(:by, :invoice_sort_order, default_value: :invoice_id) 50 | arg(:filter, :invoice_filter, default_value: %{}) 51 | middleware(Scope, read: :invoice) 52 | resolve(Relay.connection_dataloader(Chinook.Loader)) 53 | end 54 | 55 | connection field :tracks, node_type: :track do 56 | arg(:by, :track_sort_order, default_value: :track_id) 57 | arg(:filter, :track_filter, default_value: %{}) 58 | resolve(Relay.connection_dataloader(Chinook.Loader)) 59 | end 60 | end 61 | 62 | def resolve_node(id, resolution = %{context: %{current_user: current_user}}) do 63 | with {:ok, scope} <- Chinook.Customer.Auth.can?(current_user, :read, :customer) do 64 | Relay.node_dataloader(Chinook.Loader, {Chinook.Customer, %{scope: scope}}, id, resolution) 65 | end 66 | end 67 | 68 | def resolve_connection do 69 | Relay.connection_from_query(&Chinook.Customer.Loader.query/1) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/chinook/api/employee.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Schema.Employee do 2 | use Absinthe.Schema.Notation 3 | use Absinthe.Relay.Schema.Notation, :modern 4 | alias Absinthe.Relay.Node.ParseIDs 5 | 6 | alias Chinook.API.Relay 7 | alias Chinook.API.Scope 8 | 9 | @desc "Employee sort order" 10 | enum :employee_sort_order do 11 | value(:id, as: :employee_id) 12 | value(:last_name, as: :last_name) 13 | value(:hired_date, as: :hired_date) 14 | end 15 | 16 | @desc "Employee filter" 17 | input_object :employee_filter do 18 | field :reports_to, :id 19 | field :last_name, :string_filter 20 | field :first_name, :string_filter 21 | field :title, :string_filter 22 | field :birth_date, :datetime_filter 23 | field :hire_date, :datetime_filter 24 | field :address, :string_filter 25 | field :city, :string_filter 26 | field :state, :string_filter 27 | field :country, :string_filter 28 | field :postal_code, :string_filter 29 | field :phone, :string_filter 30 | field :fax, :string_filter 31 | field :email, :string_filter 32 | end 33 | 34 | node object(:employee, id_fetcher: &Relay.id/2) do 35 | field :last_name, :string 36 | field :first_name, :string 37 | field :title, :string 38 | field :birth_date, :datetime 39 | field :hire_date, :datetime 40 | field :address, :string 41 | field :city, :string 42 | field :state, :string 43 | field :country, :string 44 | field :postal_code, :string 45 | field :phone, :string 46 | field :fax, :string 47 | field :email, :string 48 | 49 | field :reports_to, :employee do 50 | middleware(Scope, read: :employee) 51 | resolve(Relay.node_dataloader(Chinook.Loader)) 52 | end 53 | 54 | connection field :reports, node_type: :employee do 55 | arg(:by, :employee_sort_order, default_value: :employee_id) 56 | arg(:filter, :employee_filter, default_value: %{}) 57 | 58 | middleware(ParseIDs, filter: [reports_to: :employee]) 59 | middleware(Scope, read: :employee) 60 | resolve(Relay.connection_dataloader(Chinook.Loader)) 61 | end 62 | 63 | connection field :customers, node_type: :customer do 64 | arg(:by, :customer_sort_order, default_value: :customer_id) 65 | arg(:filter, :customer_filter, default_value: %{}) 66 | 67 | middleware(Scope, read: :customer) 68 | resolve(Relay.connection_dataloader(Chinook.Loader)) 69 | end 70 | end 71 | 72 | def resolve_node(id, resolution = %{context: %{current_user: current_user}}) do 73 | with {:ok, scope} <- Chinook.Employee.Auth.can?(current_user, :read, :employee) do 74 | Relay.node_dataloader(Chinook.Loader, {Chinook.Employee, %{scope: scope}}, id, resolution) 75 | end 76 | end 77 | 78 | def resolve_connection do 79 | Relay.connection_from_query(&Chinook.Employee.Loader.query/1) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/chinook_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule ChinookWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_joined.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # Database Metrics 55 | summary("chinook.repo.query.total_time", 56 | unit: {:native, :millisecond}, 57 | description: "The sum of the other measurements" 58 | ), 59 | summary("chinook.repo.query.decode_time", 60 | unit: {:native, :millisecond}, 61 | description: "The time spent decoding the data received from the database" 62 | ), 63 | summary("chinook.repo.query.query_time", 64 | unit: {:native, :millisecond}, 65 | description: "The time spent executing the query" 66 | ), 67 | summary("chinook.repo.query.queue_time", 68 | unit: {:native, :millisecond}, 69 | description: "The time spent waiting for a database connection" 70 | ), 71 | summary("chinook.repo.query.idle_time", 72 | unit: {:native, :millisecond}, 73 | description: 74 | "The time the connection spent waiting before being checked out for the query" 75 | ), 76 | 77 | # VM Metrics 78 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 79 | summary("vm.total_run_queue_lengths.total"), 80 | summary("vm.total_run_queue_lengths.cpu"), 81 | summary("vm.total_run_queue_lengths.io") 82 | ] 83 | end 84 | 85 | defp periodic_measurements do 86 | [ 87 | # A module, function and arguments to be invoked periodically. 88 | # This function must call :telemetry.execute/3 and a metric must be added above. 89 | # {ChinookWeb, :count_users, []} 90 | ] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/chinook/graphql/artist_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Chinkook.API.ArtistTest do 2 | use Chinook.DataCase, async: true 3 | 4 | @query """ 5 | query { 6 | artists(first: 2) { 7 | edges { 8 | node { 9 | id 10 | name 11 | albums(last: 1) { 12 | edges { 13 | node { 14 | id 15 | title 16 | tracks(first: 2) { 17 | edges { 18 | node { 19 | id 20 | name 21 | genre { 22 | id 23 | name 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | """ 36 | 37 | @expected %{ 38 | data: %{ 39 | "artists" => %{ 40 | "edges" => [ 41 | %{ 42 | "node" => %{ 43 | "albums" => %{ 44 | "edges" => [ 45 | %{ 46 | "node" => %{ 47 | "id" => "QWxidW06NA==", 48 | "title" => "Let There Be Rock", 49 | "tracks" => %{ 50 | "edges" => [ 51 | %{ 52 | "node" => %{ 53 | "genre" => %{"id" => "R2VucmU6MQ==", "name" => "Rock"}, 54 | "id" => "VHJhY2s6MTU=", 55 | "name" => "Go Down" 56 | } 57 | }, 58 | %{ 59 | "node" => %{ 60 | "genre" => %{"id" => "R2VucmU6MQ==", "name" => "Rock"}, 61 | "id" => "VHJhY2s6MTY=", 62 | "name" => "Dog Eat Dog" 63 | } 64 | } 65 | ] 66 | } 67 | } 68 | } 69 | ] 70 | }, 71 | "id" => "QXJ0aXN0OjE=", 72 | "name" => "AC/DC" 73 | } 74 | }, 75 | %{ 76 | "node" => %{ 77 | "albums" => %{ 78 | "edges" => [ 79 | %{ 80 | "node" => %{ 81 | "id" => "QWxidW06Mw==", 82 | "title" => "Restless and Wild", 83 | "tracks" => %{ 84 | "edges" => [ 85 | %{ 86 | "node" => %{ 87 | "genre" => %{"id" => "R2VucmU6MQ==", "name" => "Rock"}, 88 | "id" => "VHJhY2s6Mw==", 89 | "name" => "Fast As a Shark" 90 | } 91 | }, 92 | %{ 93 | "node" => %{ 94 | "genre" => %{"id" => "R2VucmU6MQ==", "name" => "Rock"}, 95 | "id" => "VHJhY2s6NA==", 96 | "name" => "Restless and Wild" 97 | } 98 | } 99 | ] 100 | } 101 | } 102 | } 103 | ] 104 | }, 105 | "id" => "QXJ0aXN0OjI=", 106 | "name" => "Accept" 107 | } 108 | } 109 | ] 110 | } 111 | } 112 | } 113 | 114 | test "query artists" do 115 | assert Absinthe.run!(@query, Chinook.API.Schema) == @expected 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/chinook/data/track.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Track do 2 | use Ecto.Schema 3 | 4 | alias __MODULE__ 5 | alias Chinook.Album 6 | alias Chinook.Genre 7 | alias Chinook.MediaType 8 | 9 | @type t :: %__MODULE__{} 10 | 11 | @primary_key {:track_id, :integer, source: :TrackId} 12 | schema "Track" do 13 | field :name, :string, source: :Name 14 | field :composer, :string, source: :Composer 15 | field :milliseconds, :integer, source: :Milliseconds 16 | field :bytes, :integer, source: :Bytes 17 | field :unit_price, :decimal, source: :UnitPrice 18 | field :row_count, :integer, virtual: true 19 | 20 | belongs_to :media_type, MediaType, 21 | foreign_key: :media_type_id, 22 | references: :media_type_id, 23 | source: :MediaTypeId 24 | 25 | belongs_to :genre, Genre, foreign_key: :genre_id, references: :genre_id, source: :GenreId 26 | belongs_to :album, Album, foreign_key: :album_id, references: :album_id, source: :AlbumId 27 | has_one :artist, through: [:album, :artist] 28 | has_many :invoice_lines, Chinook.Invoice.Line, foreign_key: :track_id, references: :track_id 29 | has_many :purchasers, through: [:invoice_lines, :invoice, :customer] 30 | end 31 | 32 | defmodule Loader do 33 | import Ecto.Query 34 | import Chinook.QueryHelpers 35 | 36 | @spec query(PagingOptions.t()) :: Ecto.Query.t() 37 | def query(args) do 38 | args = Map.put_new(args, :by, :track_id) 39 | 40 | Track 41 | |> from(as: :track) 42 | |> select_fields(Track, :track, args[:fields]) 43 | |> do_paginate(args) 44 | |> filter(args[:filter]) 45 | end 46 | 47 | defp do_paginate(query, %{by: :artist_name} = args) do 48 | query 49 | |> join(:inner, [track: t], assoc(t, :artist), as: :artist) 50 | |> paginate(Track, :track, :artist, %{args | by: :name}) 51 | end 52 | 53 | defp do_paginate(query, args) do 54 | query 55 | |> paginate(Track, :track, args) 56 | end 57 | 58 | def filter(queryable, nil), do: queryable 59 | 60 | def filter(queryable, filters) do 61 | Enum.reduce(filters, queryable, fn 62 | {:name, name_filter}, queryable -> 63 | filter_string(queryable, :name, name_filter) 64 | 65 | {:composer, composer_filter}, queryable -> 66 | filter_string(queryable, :composer, composer_filter) 67 | 68 | {:duration, duration_filter}, queryable -> 69 | filter_number(queryable, :milliseconds, duration_filter) 70 | 71 | {:bytes, bytes_filter}, queryable -> 72 | filter_number(queryable, :bytes, bytes_filter) 73 | 74 | {:unit_price, price_filter}, queryable -> 75 | filter_number(queryable, :unit_price, price_filter) 76 | end) 77 | end 78 | 79 | # This code no longer needed - Dataloader.Ecto can take care of it 80 | # # Handle playlist batches specially due to the join table 81 | # defp run_batch(Track, query, :playlist_id, playlist_ids, repo_opts) do 82 | # groups = 83 | # from(track in query, 84 | # join: playlist_track in PlaylistTrack, 85 | # as: :playlist_track, 86 | # on: track.track_id == playlist_track.track_id 87 | # ) 88 | # |> batch_by(:playlist_track, :playlist_id, playlist_ids) 89 | # |> select([playlist, track], {playlist.id, track}) 90 | # |> Repo.all(repo_opts) 91 | # |> Enum.group_by(fn {playlist_id, _} -> playlist_id end, fn {_, track} -> track end) 92 | 93 | # for playlist_id <- playlist_ids do 94 | # Map.get(groups, playlist_id, []) 95 | # end 96 | # end 97 | 98 | # # album/genre batches can use the default run_batch 99 | # defp run_batch(Track, query, key_field, inputs, repo_opts) do 100 | # Dataloader.Ecto.run_batch(Repo, Track, query, key_field, inputs, repo_opts) 101 | # end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/chinook start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :chinook, ChinookWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_url = 25 | System.get_env("DATABASE_URL") || 26 | raise """ 27 | environment variable DATABASE_URL is missing. 28 | For example: ecto://USER:PASS@HOST/DATABASE 29 | """ 30 | 31 | maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] 32 | 33 | config :chinook, Chinook.Repo, 34 | # ssl: true, 35 | url: database_url, 36 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 37 | socket_options: maybe_ipv6 38 | 39 | # The secret key base is used to sign/encrypt cookies and other secrets. 40 | # A default value is used in config/dev.exs and config/test.exs but you 41 | # want to use a different value for prod and you most likely don't want 42 | # to check this value into version control, so we use an environment 43 | # variable instead. 44 | secret_key_base = 45 | System.get_env("SECRET_KEY_BASE") || 46 | raise """ 47 | environment variable SECRET_KEY_BASE is missing. 48 | You can generate one by calling: mix phx.gen.secret 49 | """ 50 | 51 | host = System.get_env("PHX_HOST") || "example.com" 52 | port = String.to_integer(System.get_env("PORT") || "4000") 53 | 54 | config :chinook, ChinookWeb.Endpoint, 55 | url: [host: host, port: 443, scheme: "https"], 56 | http: [ 57 | # Enable IPv6 and bind on all interfaces. 58 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 59 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 60 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 61 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 62 | port: port 63 | ], 64 | secret_key_base: secret_key_base 65 | 66 | # ## SSL Support 67 | # 68 | # To get SSL working, you will need to add the `https` key 69 | # to your endpoint configuration: 70 | # 71 | # config :chinook, ChinookWeb.Endpoint, 72 | # https: [ 73 | # ..., 74 | # port: 443, 75 | # cipher_suite: :strong, 76 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 77 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 78 | # ] 79 | # 80 | # The `cipher_suite` is set to `:strong` to support only the 81 | # latest and more secure SSL ciphers. This means old browsers 82 | # and clients may not be supported. You can set it to 83 | # `:compatible` for wider support. 84 | # 85 | # `:keyfile` and `:certfile` expect an absolute path to the key 86 | # and cert in disk or a relative path inside priv, for example 87 | # "priv/ssl/server.key". For all supported SSL configuration 88 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 89 | # 90 | # We also recommend setting `force_ssl` in your endpoint, ensuring 91 | # no data is ever sent via http, always redirecting to https: 92 | # 93 | # config :chinook, ChinookWeb.Endpoint, 94 | # force_ssl: [hsts: true] 95 | # 96 | # Check `Plug.SSL` for all available options in `force_ssl`. 97 | end 98 | -------------------------------------------------------------------------------- /lib/chinook/data/invoice.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Invoice do 2 | use Ecto.Schema 3 | alias __MODULE__ 4 | alias Chinook.Customer 5 | 6 | @type t :: %__MODULE__{} 7 | 8 | @primary_key {:invoice_id, :integer, source: :InvoiceId} 9 | 10 | schema "Invoice" do 11 | field :invoice_date, :naive_datetime, source: :InvoiceDate 12 | field :billing_address, :string, source: :BillingAddress 13 | field :billing_city, :string, source: :BillingCity 14 | field :billing_state, :string, source: :BillingState 15 | field :billing_country, :string, source: :BillingCountry 16 | field :billing_postal_code, :string, source: :BillingPostalCode 17 | field :total, :decimal, source: :Total 18 | field :row_count, :integer, virtual: true 19 | 20 | belongs_to :customer, Customer, source: :CustomerId, references: :customer_id 21 | has_many :line_items, Invoice.Line, foreign_key: :invoice_id, references: :invoice_id 22 | has_many :tracks, through: [:line_items, :track] 23 | end 24 | 25 | defmodule Line do 26 | use Ecto.Schema 27 | alias Chinook.Track 28 | 29 | @type t :: %__MODULE__{} 30 | 31 | @primary_key {:invoice_line_id, :integer, source: :InvoiceLineId} 32 | 33 | schema "InvoiceLine" do 34 | field :unit_price, :decimal, source: :UnitPrice 35 | field :quantity, :integer, source: :Quantity 36 | belongs_to :invoice, Invoice, source: :InvoiceId, references: :invoice_id 37 | belongs_to :track, Track, source: :TrackId, references: :track_id 38 | end 39 | end 40 | 41 | defmodule Loader do 42 | import Ecto.Query 43 | import Chinook.QueryHelpers 44 | 45 | alias Chinook.Employee 46 | 47 | @spec query(PagingOptions.t()) :: Ecto.Query.t() 48 | def query(args) do 49 | args = Map.put_new(args, :by, :invoice_id) 50 | 51 | Invoice 52 | |> from(as: :invoice) 53 | |> select_fields(Invoice, :invoice, args[:invoice]) 54 | |> paginate(Invoice, :invoice, args) 55 | |> filter(args[:filter]) 56 | |> scope(args[:scope]) 57 | end 58 | 59 | def filter(queryable, nil), do: queryable 60 | 61 | def filter(queryable, filters) do 62 | Enum.reduce(filters, queryable, fn 63 | {:invoice_date, date_filter}, queryable -> 64 | filter_datetime(queryable, :invoice_date, date_filter) 65 | 66 | {:total, total_filter}, queryable -> 67 | filter_number(queryable, :total, total_filter) 68 | end) 69 | end 70 | 71 | def scope(queryable, :all) do 72 | queryable 73 | end 74 | 75 | def scope(queryable, customer_id: customer_id) do 76 | queryable 77 | |> where([invoice: i], i.customer_id == ^customer_id) 78 | end 79 | 80 | def scope(queryable, support_rep_id: support_rep_id) do 81 | queryable 82 | |> ensure_customer_binding() 83 | |> Chinook.Customer.Loader.scope(support_rep_id: support_rep_id) 84 | end 85 | 86 | defp ensure_customer_binding(queryable) do 87 | if has_named_binding?(queryable, :customer) do 88 | queryable 89 | else 90 | queryable |> join(:inner, [invoice: i], c in assoc(i, :customer), as: :customer) 91 | end 92 | end 93 | end 94 | 95 | defmodule Auth do 96 | alias Chinook.{Employee, Customer} 97 | 98 | @type scope :: :all | [support_rep_id: integer()] | [customer_id: integer()] 99 | @type user :: Employee.t() | Customer.t() 100 | @type action :: :read 101 | @type resource :: :invoice 102 | 103 | @spec can?(user, action, resource) :: {:ok, scope} | {:error, atom} 104 | def can?(%Employee{title: "General Manager"}, :read, :invoice) do 105 | {:ok, :all} 106 | end 107 | 108 | def can?(%Employee{title: "Sales Manager"}, :read, :invoice) do 109 | {:ok, :all} 110 | end 111 | 112 | def can?(%Employee{title: "Sales Support Agent"} = e, :read, :invoice) do 113 | {:ok, [support_rep_id: e.employee_id]} 114 | end 115 | 116 | def can?(%Employee{}, :read, :invoice) do 117 | {:error, :not_authorized} 118 | end 119 | 120 | def can?(%Customer{} = c, :read, :invoice) do 121 | {:ok, [customer_id: c.customer_id]} 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/chinook/data/customer.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Customer do 2 | use Ecto.Schema 3 | alias __MODULE__ 4 | alias Chinook.Employee 5 | alias Chinook.Invoice 6 | 7 | @type t :: %__MODULE__{} 8 | 9 | @primary_key {:customer_id, :integer, source: :CustomerId} 10 | 11 | schema "Customer" do 12 | field :first_name, :string, source: :FirstName 13 | field :last_name, :string, source: :LastName 14 | field :company, :string, source: :Company 15 | field :address, :string, source: :Address 16 | field :city, :string, source: :City 17 | field :state, :string, source: :State 18 | field :country, :string, source: :Country 19 | field :postal_code, :string, source: :PostalCode 20 | field :phone, :string, source: :Phone 21 | field :fax, :string, source: :Fax 22 | field :email, :string, source: :Email 23 | field :row_count, :integer, virtual: true 24 | 25 | belongs_to :support_rep, Employee, source: :SupportRepId, references: :employee_id 26 | has_many :invoices, Invoice, foreign_key: :customer_id, references: :customer_id 27 | has_many :tracks, through: [:invoices, :tracks] 28 | end 29 | 30 | defmodule Loader do 31 | import Ecto.Query 32 | import Chinook.QueryHelpers 33 | 34 | @spec query(PagingOptions.t()) :: Ecto.Query.t() 35 | def query(args) do 36 | args = Map.put_new(args, :by, :customer_id) 37 | 38 | Customer 39 | |> from(as: :customer) 40 | |> select_fields(Customer, :customer, args[:fields]) 41 | |> paginate(Customer, :customer, args) 42 | |> filter(args[:filter]) 43 | |> scope(args[:scope]) 44 | end 45 | 46 | def filter(queryable, nil), do: queryable 47 | 48 | def filter(queryable, filters) do 49 | Enum.reduce(filters, queryable, fn 50 | {:last_name, last_name_filter}, queryable -> 51 | filter_string(queryable, :last_name, last_name_filter) 52 | 53 | {:first_name, first_name_filter}, queryable -> 54 | filter_string(queryable, :first_name, first_name_filter) 55 | 56 | {:company, company_filter}, queryable -> 57 | filter_string(queryable, :company, company_filter) 58 | 59 | {:address, address_filter}, queryable -> 60 | filter_string(queryable, :address, address_filter) 61 | 62 | {:city, city_filter}, queryable -> 63 | filter_string(queryable, :city, city_filter) 64 | 65 | {:state, state_filter}, queryable -> 66 | filter_string(queryable, :state, state_filter) 67 | 68 | {:country, country_filter}, queryable -> 69 | filter_string(queryable, :country, country_filter) 70 | 71 | {:postal_code, postal_code_filter}, queryable -> 72 | filter_string(queryable, :postal_code, postal_code_filter) 73 | 74 | {:phone, phone_filter}, queryable -> 75 | filter_string(queryable, :phone, phone_filter) 76 | 77 | {:fax, fax_filter}, queryable -> 78 | filter_string(queryable, :fax, fax_filter) 79 | 80 | {:email, email_filter}, queryable -> 81 | filter_string(queryable, :email, email_filter) 82 | 83 | {:support_rep, support_rep_id}, queryable -> 84 | queryable |> where(^[support_rep_id: support_rep_id]) 85 | end) 86 | end 87 | 88 | @spec scope(Ecto.Queryable.t(), Customer.Auth.scope()) :: Ecto.Queryable.t() 89 | def scope(queryable, :all), do: queryable 90 | 91 | def scope(queryable, customer_id: customer_id) do 92 | queryable 93 | |> where([customer: c], c.customer_id == ^customer_id) 94 | end 95 | 96 | def scope(queryable, support_rep_id: employee_id) do 97 | queryable 98 | |> where([customer: c], c.support_rep_id == ^employee_id) 99 | end 100 | 101 | def scope(queryable, _), do: queryable |> where(false) 102 | end 103 | 104 | defmodule Auth do 105 | @type scope :: :all | [support_rep_id: integer()] | [customer_id: integer()] 106 | @type user :: Employee.t() | Customer.t() 107 | @type action :: :read 108 | @type resource :: :customer 109 | 110 | @spec can?(user, action, resource) :: {:ok, scope} | {:error, atom} 111 | def can?(%Employee{title: "General Manager"}, :read, :customer) do 112 | {:ok, :all} 113 | end 114 | 115 | def can?(%Employee{title: "Sales Manager"}, :read, :customer) do 116 | {:ok, :all} 117 | end 118 | 119 | def can?(%Employee{title: "Sales Support Agent"} = e, :read, :customer) do 120 | {:ok, [support_rep_id: e.employee_id]} 121 | end 122 | 123 | def can?(%Employee{}, :read, :customer) do 124 | {:error, :not_authorized} 125 | end 126 | 127 | def can?(%Customer{} = c, :read, :customer) do 128 | {:ok, [customer_id: c.customer_id]} 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/chinook/api/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Schema do 2 | use Absinthe.Schema 3 | use Absinthe.Relay.Schema, :modern 4 | 5 | alias Absinthe.Relay.Node.ParseIDs 6 | alias Chinook.API.Schema.Album 7 | alias Chinook.API.Schema.Artist 8 | alias Chinook.API.Schema.Customer 9 | alias Chinook.API.Schema.Employee 10 | alias Chinook.API.Schema.Filter 11 | alias Chinook.API.Schema.Genre 12 | alias Chinook.API.Schema.Invoice 13 | alias Chinook.API.Schema.Playlist 14 | alias Chinook.API.Schema.Track 15 | alias Chinook.API.Scope 16 | 17 | def context(ctx) do 18 | ctx 19 | |> Map.put(:loader, Chinook.Loader.data()) 20 | |> Map.put(:repo, Chinook.Repo) 21 | end 22 | 23 | def plugins do 24 | [Absinthe.Middleware.Dataloader] ++ Absinthe.Plugin.defaults() 25 | end 26 | 27 | import_types(Absinthe.Type.Custom) 28 | import_types(Album) 29 | import_types(Artist) 30 | import_types(Customer) 31 | import_types(Employee) 32 | import_types(Filter) 33 | import_types(Genre) 34 | import_types(Invoice) 35 | import_types(Playlist) 36 | import_types(Track) 37 | 38 | node interface do 39 | resolve_type(fn 40 | %Chinook.Album{}, _ -> :album 41 | %Chinook.Artist{}, _ -> :artist 42 | %Chinook.Customer{}, _ -> :customer 43 | %Chinook.Employee{}, _ -> :employee 44 | %Chinook.Genre{}, _ -> :genre 45 | %Chinook.Invoice{}, _ -> :invoice 46 | %Chinook.Playlist{}, _ -> :playlist 47 | %Chinook.Track{}, _ -> :track 48 | _, _ -> nil 49 | end) 50 | end 51 | 52 | connection(node_type: :album) 53 | connection(node_type: :artist) 54 | connection(node_type: :customer) 55 | connection(node_type: :employee) 56 | connection(node_type: :genre) 57 | connection(node_type: :invoice) 58 | connection(node_type: :playlist) 59 | connection(node_type: :track) 60 | 61 | query do 62 | node field do 63 | resolve(fn 64 | %{type: :album, id: id}, resolution -> Album.resolve_node(id, resolution) 65 | %{type: :artist, id: id}, resolution -> Artist.resolve_node(id, resolution) 66 | %{type: :customer, id: id}, resolution -> Customer.resolve_node(id, resolution) 67 | %{type: :employee, id: id}, resolution -> Employee.resolve_node(id, resolution) 68 | %{type: :genre, id: id}, resolution -> Genre.resolve_node(id, resolution) 69 | %{type: :invoice, id: id}, resolution -> Invoice.resolve_node(id, resolution) 70 | %{type: :playlist, id: id}, resolution -> Playlist.resolve_node(id, resolution) 71 | %{type: :track, id: id}, resolution -> Track.resolve_node(id, resolution) 72 | end) 73 | end 74 | 75 | @desc "Paginate artists" 76 | connection field :artists, node_type: :artist do 77 | arg(:by, :artist_sort_order, default_value: :artist_id) 78 | arg(:filter, :artist_filter, default_value: %{}) 79 | resolve(Artist.resolve_connection()) 80 | end 81 | 82 | @desc "Paginate albums" 83 | connection field :albums, node_type: :album do 84 | arg(:by, :album_sort_order, default_value: :album_id) 85 | arg(:filter, :album_filter, default_value: %{}) 86 | resolve(Album.resolve_connection()) 87 | end 88 | 89 | @desc "Paginate customers" 90 | connection field :customers, node_type: :customer do 91 | arg(:by, :customer_sort_order, default_value: :customer_id) 92 | arg(:filter, :customer_filter, default_value: %{}) 93 | 94 | middleware(Scope, read: :customer) 95 | resolve(Customer.resolve_connection()) 96 | end 97 | 98 | @desc "Paginate employees" 99 | connection field :employees, node_type: :employee do 100 | arg(:by, :employee_sort_order, default_value: :employee_id) 101 | arg(:filter, :employee_filter, default_value: %{}) 102 | 103 | middleware(ParseIDs, filter: [reports_to: :employee]) 104 | middleware(Scope, read: :employee) 105 | resolve(Employee.resolve_connection()) 106 | end 107 | 108 | @desc "Paginate genres" 109 | connection field :genres, node_type: :genre do 110 | arg(:by, :genre_sort_order, default_value: :genre_id) 111 | arg(:filter, :genre_filter, default_value: %{}) 112 | resolve(Genre.resolve_connection()) 113 | end 114 | 115 | @desc "Paginate invoices" 116 | connection field :invoices, node_type: :invoice do 117 | arg(:by, :invoice_sort_order, default_value: :invoice_id) 118 | arg(:filter, :invoice_filter, default_value: %{}) 119 | 120 | middleware(Scope, read: :invoice) 121 | resolve(Invoice.resolve_connection()) 122 | end 123 | 124 | @desc "Paginate playlists" 125 | connection field :playlists, node_type: :playlist do 126 | arg(:by, :playlist_sort_order, default_value: :playlist_id) 127 | arg(:filter, :playlist_filter, default_value: %{}) 128 | resolve(Playlist.resolve_connection()) 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /test/chinook/graphql/genre_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Chinkook.API.GenreTest do 2 | use Chinook.DataCase, async: true 3 | 4 | test "query genre" do 5 | query = """ 6 | query { 7 | genres(first: 1){ 8 | edges{ 9 | node { 10 | name 11 | tracks(first:3){ 12 | edges { 13 | node{ 14 | name 15 | album { 16 | title 17 | artist { 18 | name 19 | } 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | """ 29 | 30 | expected = %{ 31 | data: %{ 32 | "genres" => %{ 33 | "edges" => [ 34 | %{ 35 | "node" => %{ 36 | "name" => "Rock", 37 | "tracks" => %{ 38 | "edges" => [ 39 | %{ 40 | "node" => %{ 41 | "album" => %{ 42 | "artist" => %{"name" => "AC/DC"}, 43 | "title" => "For Those About To Rock We Salute You" 44 | }, 45 | "name" => "For Those About To Rock (We Salute You)" 46 | } 47 | }, 48 | %{ 49 | "node" => %{ 50 | "album" => %{ 51 | "artist" => %{"name" => "Accept"}, 52 | "title" => "Balls to the Wall" 53 | }, 54 | "name" => "Balls to the Wall" 55 | } 56 | }, 57 | %{ 58 | "node" => %{ 59 | "album" => %{ 60 | "artist" => %{"name" => "Accept"}, 61 | "title" => "Restless and Wild" 62 | }, 63 | "name" => "Fast As a Shark" 64 | } 65 | } 66 | ] 67 | } 68 | } 69 | } 70 | ] 71 | } 72 | } 73 | } 74 | 75 | assert Absinthe.run!(query, Chinook.API.Schema) == expected 76 | end 77 | 78 | test "query genre with tracks sorted by artist name" do 79 | query = """ 80 | query { 81 | genres(first: 1){ 82 | edges{ 83 | node { 84 | name 85 | tracks(last:5, by: ARTIST_NAME, before:"YXJ0aXN0X25hbWV8dHJhY2tfaWR8MzEwNg=="){ 86 | edges { 87 | cursor 88 | node { 89 | name 90 | artist { 91 | name 92 | } 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | """ 101 | 102 | expected = %{ 103 | data: %{ 104 | "genres" => %{ 105 | "edges" => [ 106 | %{ 107 | "node" => %{ 108 | "name" => "Rock", 109 | "tracks" => %{ 110 | "edges" => [ 111 | %{ 112 | "node" => %{"name" => "Primary", "artist" => %{"name" => "Van Halen"}}, 113 | "cursor" => "YXJ0aXN0X25hbWV8dHJhY2tfaWR8MzEwMQ==" 114 | }, 115 | %{ 116 | "node" => %{ 117 | "name" => "Ballot or the Bullet", 118 | "artist" => %{"name" => "Van Halen"} 119 | }, 120 | "cursor" => "YXJ0aXN0X25hbWV8dHJhY2tfaWR8MzEwMg==" 121 | }, 122 | %{ 123 | "node" => %{ 124 | "name" => "How Many Say I", 125 | "artist" => %{"name" => "Van Halen"} 126 | }, 127 | "cursor" => "YXJ0aXN0X25hbWV8dHJhY2tfaWR8MzEwMw==" 128 | }, 129 | %{ 130 | "cursor" => "YXJ0aXN0X25hbWV8dHJhY2tfaWR8MzEwNA==", 131 | "node" => %{ 132 | "artist" => %{"name" => "Velvet Revolver"}, 133 | "name" => "Sucker Train Blues" 134 | } 135 | }, 136 | %{ 137 | "cursor" => "YXJ0aXN0X25hbWV8dHJhY2tfaWR8MzEwNQ==", 138 | "node" => %{ 139 | "artist" => %{"name" => "Velvet Revolver"}, 140 | "name" => "Do It For The Kids" 141 | } 142 | } 143 | ] 144 | } 145 | } 146 | } 147 | ] 148 | } 149 | } 150 | } 151 | 152 | assert Absinthe.run!(query, Chinook.API.Schema) == expected 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/chinook/data/employee.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.Employee do 2 | use Ecto.Schema 3 | alias __MODULE__ 4 | alias Chinook.Customer 5 | 6 | @type t :: %__MODULE__{} 7 | 8 | @primary_key {:employee_id, :integer, source: :EmployeeId} 9 | 10 | schema "Employee" do 11 | field :last_name, :string, source: :LastName 12 | field :first_name, :string, source: :FirstName 13 | field :title, :string, source: :Title 14 | field :birth_date, :utc_datetime, source: :BirthDate 15 | field :hire_date, :utc_datetime, source: :HireDate 16 | field :address, :string, source: :Address 17 | field :city, :string, source: :City 18 | field :state, :string, source: :State 19 | field :country, :string, source: :Country 20 | field :postal_code, :string, source: :PostalCode 21 | field :phone, :string, source: :Phone 22 | field :fax, :string, source: :Fax 23 | field :email, :string, source: :Email 24 | field :row_count, :integer, virtual: true 25 | 26 | belongs_to :reports_to, Employee, source: :ReportsTo, references: :employee_id 27 | has_many :reports, Employee, foreign_key: :reports_to_id, references: :employee_id 28 | has_many :customers, Customer, foreign_key: :support_rep_id, references: :employee_id 29 | end 30 | 31 | defmodule Loader do 32 | import Ecto.Query 33 | import Chinook.QueryHelpers 34 | 35 | @spec query(PagingOptions.t()) :: Ecto.Query.t() 36 | def query(args) do 37 | args = Map.put_new(args, :by, :employee_id) 38 | 39 | Employee 40 | |> from(as: :employee) 41 | |> select_fields(Employee, :employee, args[:fields]) 42 | |> paginate(Employee, :employee, args) 43 | |> filter(args[:filter]) 44 | |> scope(args[:scope]) 45 | end 46 | 47 | def filter(queryable, nil), do: queryable 48 | 49 | def filter(queryable, filters) do 50 | Enum.reduce(filters, queryable, fn 51 | {:reports_to, manager_id}, queryable -> 52 | queryable |> where(^[reports_to_id: manager_id]) 53 | 54 | {:last_name, last_name_filter}, queryable -> 55 | filter_string(queryable, :last_name, last_name_filter) 56 | 57 | {:first_name, first_name_filter}, queryable -> 58 | filter_string(queryable, :first_name, first_name_filter) 59 | 60 | {:title, title_filter}, queryable -> 61 | filter_string(queryable, :title, title_filter) 62 | 63 | {:birth_date, birth_date_filter}, queryable -> 64 | filter_datetime(queryable, :birth_date, birth_date_filter) 65 | 66 | {:hire_date, hire_date_filter}, queryable -> 67 | filter_datetime(queryable, :hire_date, hire_date_filter) 68 | 69 | {:address, address_filter}, queryable -> 70 | filter_string(queryable, :address, address_filter) 71 | 72 | {:city, city_filter}, queryable -> 73 | filter_string(queryable, :city, city_filter) 74 | 75 | {:state, state_filter}, queryable -> 76 | filter_string(queryable, :state, state_filter) 77 | 78 | {:country, country_filter}, queryable -> 79 | filter_string(queryable, :country, country_filter) 80 | 81 | {:postal_code, postal_code_filter}, queryable -> 82 | filter_string(queryable, :postal_code, postal_code_filter) 83 | 84 | {:phone, phone_filter}, queryable -> 85 | filter_string(queryable, :phone, phone_filter) 86 | 87 | {:fax, fax_filter}, queryable -> 88 | filter_string(queryable, :fax, fax_filter) 89 | 90 | {:email, email_filter}, queryable -> 91 | filter_string(queryable, :email, email_filter) 92 | end) 93 | end 94 | 95 | def scope(queryable, :all) do 96 | queryable 97 | end 98 | 99 | def scope(queryable, employee_id: employee_id, reports_to_id: reports_to_id) do 100 | queryable 101 | |> where( 102 | [employee: e], 103 | # employee can access their own records 104 | # manager can access employee records 105 | # employe can access manager records - TODO: limit this access 106 | e.employee_id == ^employee_id or 107 | e.reports_to_id == ^employee_id or 108 | e.employee_id == ^reports_to_id 109 | ) 110 | end 111 | 112 | def scope(queryable, employee_id: employee_id) do 113 | queryable 114 | |> where([employee: e], e.employee_id == ^employee_id) 115 | end 116 | end 117 | 118 | defmodule Auth do 119 | alias Chinook.{Customer, Employee} 120 | 121 | @type scope :: 122 | :all 123 | | [employee_id: integer, reports_to_id: integer()] 124 | | [employee_id: integer] 125 | 126 | @type user :: Employee.t() | Customer.t() 127 | @type action :: :read 128 | @type resource :: :employee 129 | 130 | def can?(%Employee{title: "General Manager"}, :read, :employee) do 131 | {:ok, :all} 132 | end 133 | 134 | def can?(%Employee{} = e, :read, :employee) do 135 | {:ok, [employee_id: e.employee_id, reports_to_id: e.reports_to_id]} 136 | end 137 | 138 | def can?(%Customer{} = c, :read, :employee) do 139 | {:ok, [employee_id: c.support_rep_id]} 140 | end 141 | 142 | def can?(_, :read, :employee) do 143 | {:error, :not_authorized} 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /priv/repo/chinook_ddl.sql: -------------------------------------------------------------------------------- 1 | 2 | /******************************************************************************* 3 | Chinook Database - Version 1.4 4 | Script: Chinook_PostgreSql.sql 5 | Description: Creates and populates the Chinook database. 6 | DB Server: PostgreSql 7 | Author: Luis Rocha 8 | License: http://www.codeplex.com/ChinookDatabase/license 9 | ********************************************************************************/ 10 | SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE; 11 | 12 | 13 | /******************************************************************************* 14 | Create Tables 15 | ********************************************************************************/ 16 | CREATE TABLE "Album" 17 | ( 18 | "AlbumId" INT NOT NULL, 19 | "Title" VARCHAR(160) NOT NULL, 20 | "ArtistId" INT NOT NULL, 21 | CONSTRAINT "PK_Album" PRIMARY KEY ("AlbumId") 22 | ); 23 | 24 | CREATE TABLE "Artist" 25 | ( 26 | "ArtistId" INT NOT NULL, 27 | "Name" VARCHAR(120), 28 | CONSTRAINT "PK_Artist" PRIMARY KEY ("ArtistId") 29 | ); 30 | 31 | CREATE TABLE "Customer" 32 | ( 33 | "CustomerId" INT NOT NULL, 34 | "FirstName" VARCHAR(40) NOT NULL, 35 | "LastName" VARCHAR(20) NOT NULL, 36 | "Company" VARCHAR(80), 37 | "Address" VARCHAR(70), 38 | "City" VARCHAR(40), 39 | "State" VARCHAR(40), 40 | "Country" VARCHAR(40), 41 | "PostalCode" VARCHAR(10), 42 | "Phone" VARCHAR(24), 43 | "Fax" VARCHAR(24), 44 | "Email" VARCHAR(60) NOT NULL, 45 | "SupportRepId" INT, 46 | CONSTRAINT "PK_Customer" PRIMARY KEY ("CustomerId") 47 | ); 48 | 49 | CREATE TABLE "Employee" 50 | ( 51 | "EmployeeId" INT NOT NULL, 52 | "LastName" VARCHAR(20) NOT NULL, 53 | "FirstName" VARCHAR(20) NOT NULL, 54 | "Title" VARCHAR(30), 55 | "ReportsTo" INT, 56 | "BirthDate" TIMESTAMP, 57 | "HireDate" TIMESTAMP, 58 | "Address" VARCHAR(70), 59 | "City" VARCHAR(40), 60 | "State" VARCHAR(40), 61 | "Country" VARCHAR(40), 62 | "PostalCode" VARCHAR(10), 63 | "Phone" VARCHAR(24), 64 | "Fax" VARCHAR(24), 65 | "Email" VARCHAR(60), 66 | CONSTRAINT "PK_Employee" PRIMARY KEY ("EmployeeId") 67 | ); 68 | 69 | CREATE TABLE "Genre" 70 | ( 71 | "GenreId" INT NOT NULL, 72 | "Name" VARCHAR(120), 73 | CONSTRAINT "PK_Genre" PRIMARY KEY ("GenreId") 74 | ); 75 | 76 | CREATE TABLE "Invoice" 77 | ( 78 | "InvoiceId" INT NOT NULL, 79 | "CustomerId" INT NOT NULL, 80 | "InvoiceDate" TIMESTAMP NOT NULL, 81 | "BillingAddress" VARCHAR(70), 82 | "BillingCity" VARCHAR(40), 83 | "BillingState" VARCHAR(40), 84 | "BillingCountry" VARCHAR(40), 85 | "BillingPostalCode" VARCHAR(10), 86 | "Total" NUMERIC(10,2) NOT NULL, 87 | CONSTRAINT "PK_Invoice" PRIMARY KEY ("InvoiceId") 88 | ); 89 | 90 | CREATE TABLE "InvoiceLine" 91 | ( 92 | "InvoiceLineId" INT NOT NULL, 93 | "InvoiceId" INT NOT NULL, 94 | "TrackId" INT NOT NULL, 95 | "UnitPrice" NUMERIC(10,2) NOT NULL, 96 | "Quantity" INT NOT NULL, 97 | CONSTRAINT "PK_InvoiceLine" PRIMARY KEY ("InvoiceLineId") 98 | ); 99 | 100 | CREATE TABLE "MediaType" 101 | ( 102 | "MediaTypeId" INT NOT NULL, 103 | "Name" VARCHAR(120), 104 | CONSTRAINT "PK_MediaType" PRIMARY KEY ("MediaTypeId") 105 | ); 106 | 107 | CREATE TABLE "Playlist" 108 | ( 109 | "PlaylistId" INT NOT NULL, 110 | "Name" VARCHAR(120), 111 | CONSTRAINT "PK_Playlist" PRIMARY KEY ("PlaylistId") 112 | ); 113 | 114 | CREATE TABLE "PlaylistTrack" 115 | ( 116 | "PlaylistId" INT NOT NULL, 117 | "TrackId" INT NOT NULL, 118 | CONSTRAINT "PK_PlaylistTrack" PRIMARY KEY ("PlaylistId", "TrackId") 119 | ); 120 | 121 | CREATE TABLE "Track" 122 | ( 123 | "TrackId" INT NOT NULL, 124 | "Name" VARCHAR(200) NOT NULL, 125 | "AlbumId" INT, 126 | "MediaTypeId" INT NOT NULL, 127 | "GenreId" INT, 128 | "Composer" VARCHAR(220), 129 | "Milliseconds" INT NOT NULL, 130 | "Bytes" INT, 131 | "UnitPrice" NUMERIC(10,2) NOT NULL, 132 | CONSTRAINT "PK_Track" PRIMARY KEY ("TrackId") 133 | ); 134 | 135 | 136 | 137 | /******************************************************************************* 138 | Create Primary Key Unique Indexes 139 | ********************************************************************************/ 140 | 141 | /******************************************************************************* 142 | Create Foreign Keys 143 | ********************************************************************************/ 144 | ALTER TABLE "Album" ADD CONSTRAINT "FK_AlbumArtistId" 145 | FOREIGN KEY ("ArtistId") REFERENCES "Artist" ("ArtistId") ON DELETE NO ACTION ON UPDATE NO ACTION; 146 | 147 | CREATE INDEX "IFK_AlbumArtistId" ON "Album" ("ArtistId"); 148 | 149 | ALTER TABLE "Customer" ADD CONSTRAINT "FK_CustomerSupportRepId" 150 | FOREIGN KEY ("SupportRepId") REFERENCES "Employee" ("EmployeeId") ON DELETE NO ACTION ON UPDATE NO ACTION; 151 | 152 | CREATE INDEX "IFK_CustomerSupportRepId" ON "Customer" ("SupportRepId"); 153 | 154 | ALTER TABLE "Employee" ADD CONSTRAINT "FK_EmployeeReportsTo" 155 | FOREIGN KEY ("ReportsTo") REFERENCES "Employee" ("EmployeeId") ON DELETE NO ACTION ON UPDATE NO ACTION; 156 | 157 | CREATE INDEX "IFK_EmployeeReportsTo" ON "Employee" ("ReportsTo"); 158 | 159 | ALTER TABLE "Invoice" ADD CONSTRAINT "FK_InvoiceCustomerId" 160 | FOREIGN KEY ("CustomerId") REFERENCES "Customer" ("CustomerId") ON DELETE NO ACTION ON UPDATE NO ACTION; 161 | 162 | CREATE INDEX "IFK_InvoiceCustomerId" ON "Invoice" ("CustomerId"); 163 | 164 | ALTER TABLE "InvoiceLine" ADD CONSTRAINT "FK_InvoiceLineInvoiceId" 165 | FOREIGN KEY ("InvoiceId") REFERENCES "Invoice" ("InvoiceId") ON DELETE NO ACTION ON UPDATE NO ACTION; 166 | 167 | CREATE INDEX "IFK_InvoiceLineInvoiceId" ON "InvoiceLine" ("InvoiceId"); 168 | 169 | ALTER TABLE "InvoiceLine" ADD CONSTRAINT "FK_InvoiceLineTrackId" 170 | FOREIGN KEY ("TrackId") REFERENCES "Track" ("TrackId") ON DELETE NO ACTION ON UPDATE NO ACTION; 171 | 172 | CREATE INDEX "IFK_InvoiceLineTrackId" ON "InvoiceLine" ("TrackId"); 173 | 174 | ALTER TABLE "PlaylistTrack" ADD CONSTRAINT "FK_PlaylistTrackPlaylistId" 175 | FOREIGN KEY ("PlaylistId") REFERENCES "Playlist" ("PlaylistId") ON DELETE NO ACTION ON UPDATE NO ACTION; 176 | 177 | ALTER TABLE "PlaylistTrack" ADD CONSTRAINT "FK_PlaylistTrackTrackId" 178 | FOREIGN KEY ("TrackId") REFERENCES "Track" ("TrackId") ON DELETE NO ACTION ON UPDATE NO ACTION; 179 | 180 | CREATE INDEX "IFK_PlaylistTrackTrackId" ON "PlaylistTrack" ("TrackId"); 181 | 182 | ALTER TABLE "Track" ADD CONSTRAINT "FK_TrackAlbumId" 183 | FOREIGN KEY ("AlbumId") REFERENCES "Album" ("AlbumId") ON DELETE NO ACTION ON UPDATE NO ACTION; 184 | 185 | CREATE INDEX "IFK_TrackAlbumId" ON "Track" ("AlbumId"); 186 | 187 | ALTER TABLE "Track" ADD CONSTRAINT "FK_TrackGenreId" 188 | FOREIGN KEY ("GenreId") REFERENCES "Genre" ("GenreId") ON DELETE NO ACTION ON UPDATE NO ACTION; 189 | 190 | CREATE INDEX "IFK_TrackGenreId" ON "Track" ("GenreId"); 191 | 192 | ALTER TABLE "Track" ADD CONSTRAINT "FK_TrackMediaTypeId" 193 | FOREIGN KEY ("MediaTypeId") REFERENCES "MediaType" ("MediaTypeId") ON DELETE NO ACTION ON UPDATE NO ACTION; 194 | 195 | CREATE INDEX "IFK_TrackMediaTypeId" ON "Track" ("MediaTypeId"); 196 | 197 | 198 | 199 | -------------------------------------------------------------------------------- /lib/chinook/api/relay.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.API.Relay do 2 | @doc """ 3 | Resolve an `id` field from the primary key of an Ecto schema 4 | """ 5 | @spec id(Ecto.Schema.t(), Absinthe.Resolution.t()) :: any 6 | def id(x, _resolution) do 7 | [{_, id}] = Ecto.primary_key!(x) 8 | id 9 | end 10 | 11 | @doc """ 12 | Load a top-level Relay node, given an ID 13 | 14 | ## Example 15 | 16 | node field do 17 | resolve(fn 18 | %{type: :album, id: id}, resolution -> 19 | Relay.node_dataloader(Chinook.Loader, Chinook.Album, id, resolution) 20 | 21 | %{type: :customer, id: id}, resolution = %{context: %{current_user: current_user}} -> 22 | with {:ok, scope} <- Chinook.Customer.Auth.can?(current_user, :read, :customer) do 23 | Relay.node_dataloader(Chinook.Loader, {Chinook.Customer, %{scope: scope}}, id, resolution) 24 | end 25 | end) 26 | end 27 | """ 28 | def node_dataloader(source, {schema, args}, id, %{context: %{loader: loader}}) do 29 | loader 30 | |> Dataloader.load(source, {schema, args}, id) 31 | |> Absinthe.Resolution.Helpers.on_load(fn loader -> 32 | result = Dataloader.get(loader, source, {schema, args}, id) 33 | {:ok, result} 34 | end) 35 | end 36 | 37 | def node_dataloader(source, schema, id, res) do 38 | node_dataloader(source, {schema, %{}}, id, res) 39 | end 40 | 41 | @doc """ 42 | Resolve a node using dataloader and an Ecto association that 43 | matches the name of the field being resolved. 44 | 45 | # Example 46 | 47 | field :artist, :artist, resolve: Relay.node_dataloader(Chinook.Loader) 48 | """ 49 | def node_dataloader(source) do 50 | fn parent, args, res = %{context: %{loader: loader}} -> 51 | assoc = res.definition.schema_node.identifier 52 | args = node_fields(args, res) 53 | 54 | loader 55 | |> Dataloader.load(source, {assoc, args}, parent) 56 | |> Absinthe.Resolution.Helpers.on_load(fn loader -> 57 | result = Dataloader.get(loader, source, {assoc, args}, parent) 58 | {:ok, result} 59 | end) 60 | end 61 | end 62 | 63 | defp node_fields(args, resolution) do 64 | query_fields = resolution |> Absinthe.Resolution.project() 65 | selected_node_fields = Enum.map(query_fields, fn x -> x.schema_node.identifier end) 66 | Map.put(args, :fields, selected_node_fields) 67 | end 68 | 69 | @doc """ 70 | Resolve a Relay connection using a query function 71 | 72 | Note this should only be used for top-level fields in the schema. 73 | Connection fields defined within object types should use `connection_dataloader`. 74 | 75 | Requires a `:repo` in the resolution context to use for executing the query. 76 | 77 | ## Example 78 | 79 | connection field :artists, node_type: :artist do 80 | arg :by, :artist_sort_order, default_value: :artist_id 81 | arg :filter, :artist_filter, default_value: %{} 82 | 83 | resolve Relay.connection_from_query(&Artist.Loader.query/1) 84 | end 85 | """ 86 | def connection_from_query(queryfn) do 87 | fn args, res = %{context: %{repo: repo}} -> 88 | args = args |> connection_fields(res) |> decode_cursor() 89 | data = args |> queryfn.() |> repo.all() 90 | connection_from_slice(data, args) 91 | end 92 | end 93 | 94 | defp connection_fields(args, resolution) do 95 | query_fields = resolution |> Absinthe.Resolution.project() 96 | 97 | with edge_fields = %Absinthe.Blueprint.Document.Field{} <- 98 | Enum.find(query_fields, fn x -> x.schema_node.identifier == :edges end), 99 | node_fields = %Absinthe.Blueprint.Document.Field{} <- 100 | Enum.find(edge_fields.selections, fn x -> x.schema_node.identifier == :node end) do 101 | selected_node_fields = 102 | Enum.map(node_fields.selections, fn x -> x.schema_node.identifier end) 103 | 104 | Map.put(args, :fields, selected_node_fields) 105 | else 106 | _ -> args 107 | end 108 | end 109 | 110 | @doc """ 111 | Resolve a Relay Connection from a Dataloader.Ecto source. 112 | 113 | Parameters: 114 | 115 | - source: The name of the Dataloader source to use 116 | - argsfn: callback receiving (parent, arg, resolution) and returning {schema, args, [{foreign_key, value}]} or {association, args, parent} 117 | 118 | ## Using explicit foreign key 119 | 120 | connection field :invoices, node_type: :invoice do 121 | arg :by, :invoice_sort_order, default_value: :invoice_id 122 | arg :filter, :invoice_filter, default_value: %{} 123 | middleware Scope, [read: :invoice] 124 | resolve Relay.connection_dataloader( 125 | Chinook.Loader, 126 | fn customer, args, _res -> {Chinook.Invoice, args, customer_id: customer.customer_id} end 127 | ) 128 | end 129 | 130 | ## Using association 131 | 132 | connection field :invoices, node_type: :invoice do 133 | arg :by, :invoice_sort_order, default_value: :invoice_id 134 | arg :filter, :invoice_filter, default_value: %{} 135 | middleware Scope, [read: :invoice] 136 | resolve Relay.connection_dataloader(Chinook.Loader, fn customer, args, _res -> {:invoices, args, customer} end) 137 | end 138 | """ 139 | def connection_dataloader(source, argsfn) when is_function(argsfn) do 140 | fn parent, args, res = %{context: %{loader: loader}} -> 141 | args = decode_cursor(args) 142 | 143 | {batch_key, batch_value} = 144 | case argsfn.(parent, args, res) do 145 | {schema, args, [{foreign_key, val}]} -> 146 | args = connection_fields(args, res) 147 | {{{:many, schema}, args}, [{foreign_key, val}]} 148 | 149 | {assoc, args, parent} when is_atom(assoc) and is_struct(parent) -> 150 | args = connection_fields(args, res) 151 | {{assoc, args}, parent} 152 | end 153 | 154 | loader 155 | |> Dataloader.load(source, batch_key, batch_value) 156 | |> Absinthe.Resolution.Helpers.on_load(fn loader -> 157 | loader 158 | |> Dataloader.get(source, batch_key, batch_value) 159 | |> connection_from_slice(args) 160 | end) 161 | end 162 | end 163 | 164 | @doc """ 165 | Resolve a connection using dataloader and an Ecto association that 166 | matches the name of the field being resolved. 167 | 168 | # Example 169 | 170 | connection field :invoices, node_type: :invoice do 171 | arg :by, :invoice_sort_order, default_value: :invoice_id 172 | arg :filter, :invoice_filter, default_value: %{} 173 | middleware Scope, [read: :invoice] 174 | resolve Relay.connection_dataloader(Chinook.Loader) 175 | end 176 | """ 177 | def connection_dataloader(source) do 178 | connection_dataloader(source, fn parent, args, res -> 179 | resource = res.definition.schema_node.identifier 180 | {resource, args, parent} 181 | end) 182 | end 183 | 184 | defp decode_cursor(pagination_args) do 185 | pagination_args 186 | |> decode_cursor_arg(:after) 187 | |> decode_cursor_arg(:before) 188 | end 189 | 190 | defp decode_cursor_arg(pagination_args, arg) do 191 | case pagination_args do 192 | %{^arg => cursor} -> 193 | [by, pk, id] = cursor |> Base.decode64!() |> String.split("|", parts: 3) 194 | 195 | pagination_args 196 | |> Map.put(:by, String.to_existing_atom(by)) 197 | |> Map.put(arg, [{String.to_existing_atom(pk), id}]) 198 | 199 | _ -> 200 | pagination_args 201 | end 202 | end 203 | 204 | def connection_from_slice(items, pagination_args) do 205 | items = 206 | case pagination_args do 207 | %{last: _} -> Enum.reverse(items) 208 | _ -> items 209 | end 210 | 211 | count = Enum.count(items) 212 | {edges, first, last} = build_cursors(items, pagination_args) 213 | 214 | # TODO: use a protocol for `row_count` instead of assuming field available 215 | row_count = 216 | case items do 217 | [] -> 0 218 | [%{row_count: n} | _rest] -> n 219 | end 220 | 221 | page_info = %{ 222 | start_cursor: first, 223 | end_cursor: last, 224 | has_previous_page: 225 | case pagination_args do 226 | %{after: _} -> true 227 | %{last: ^count} -> row_count > count 228 | _ -> false 229 | end, 230 | has_next_page: 231 | case pagination_args do 232 | %{before: _} -> true 233 | %{first: ^count} -> row_count > count 234 | _ -> false 235 | end 236 | } 237 | 238 | {:ok, %{edges: edges, page_info: page_info}} 239 | end 240 | 241 | defp build_cursors([], _pagination_args), do: {[], nil, nil} 242 | 243 | defp build_cursors([item | items], pagination_args) do 244 | first = item_cursor(item, pagination_args) 245 | edge = build_edge(item, first) 246 | {edges, last} = do_build_cursors(items, pagination_args, [edge], first) 247 | {edges, first, last} 248 | end 249 | 250 | defp do_build_cursors([], _pagination_args, edges, last), do: {Enum.reverse(edges), last} 251 | 252 | defp do_build_cursors([item | rest], pagination_args, edges, _last) do 253 | cursor = item_cursor(item, pagination_args) 254 | edge = build_edge(item, cursor) 255 | do_build_cursors(rest, pagination_args, [edge | edges], cursor) 256 | end 257 | 258 | defp item_cursor(item, %{by: field}) do 259 | [pk] = item.__struct__.__schema__(:primary_key) 260 | "#{field}|#{pk}|#{Map.get(item, pk)}" |> Base.encode64() 261 | end 262 | 263 | defp build_edge(item, cursor) do 264 | %{ 265 | node: item, 266 | cursor: cursor 267 | } 268 | end 269 | end 270 | -------------------------------------------------------------------------------- /lib/chinook/data/query_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Chinook.QueryHelpers do 2 | import Ecto.Query 3 | 4 | alias Chinook.PagingOptions 5 | 6 | @doc """ 7 | Builds a select clause from requested fields. 8 | """ 9 | @spec select_fields( 10 | Ecto.Queryable.t(), 11 | schema :: module, 12 | binding :: atom, 13 | fields :: [atom] | nil 14 | ) :: Ecto.Query.t() 15 | def select_fields(query, _schema, _binding, nil) do 16 | query 17 | end 18 | 19 | def select_fields(query, schema, binding, fields) do 20 | pk_fields = schema.__schema__(:primary_key) 21 | 22 | fk_fields = 23 | schema.__schema__(:associations) 24 | |> Enum.map(&schema.__schema__(:association, &1)) 25 | |> Enum.filter(&match?(%Ecto.Association.BelongsTo{}, &1)) 26 | |> Enum.map(& &1.owner_key) 27 | 28 | basic_fields = schema.__schema__(:fields) 29 | fields = Enum.filter(fields, &(&1 in basic_fields)) 30 | selected_fields = Enum.uniq(fields ++ pk_fields ++ fk_fields) 31 | 32 | query 33 | |> select([{^binding, x}], struct(x, ^selected_fields)) 34 | end 35 | 36 | @doc """ 37 | Apply pagination to a query using a named binding. 38 | 39 | The named binding is useful when the cursor field is not on the first 40 | binding in the queryable. 41 | 42 | ## Example 43 | 44 | from(playlist_track in PlaylistTrack, as: :playlist_track, 45 | join: track in assoc(playlist_track, :track), as: :track, 46 | select: track 47 | ) 48 | |> paginate(Track, :track, args) 49 | """ 50 | @spec paginate(Ecto.Queryable.t(), module, binding :: atom, PagingOptions.t()) :: Ecto.Query.t() 51 | def paginate(query, schema, binding, args) do 52 | paginate(query, schema, binding, binding, args) 53 | end 54 | 55 | @doc """ 56 | Similar to paginate/4, but allows for sorting by a joined association. 57 | 58 | ## Example 59 | 60 | # Last 10 tracks by artist name 61 | Track 62 | |> from(as: :track) 63 | |> join(:inner, [track: t], assoc(t, :artist), as: :artist) 64 | |> paginate(Track, :track, :artist, %{last: 10, by: :name}) 65 | """ 66 | @spec paginate( 67 | Ecto.Queryable.t(), 68 | module, 69 | key_binding :: atom, 70 | sort_binding :: atom, 71 | PagingOptions.t() 72 | ) :: Ecto.Query.t() 73 | def paginate(query, schema, key_binding, sort_binding, args) do 74 | query 75 | |> paginate_where(key_binding, sort_binding, args) 76 | |> paginate_order_limit(schema, key_binding, sort_binding, args) 77 | |> select_row_count(args) 78 | end 79 | 80 | @doc """ 81 | Run the given query as a inner lateral join for each value of batch_ids 82 | 83 | The returned query is a join with two named bindings, `:batch` and `:batch_data` 84 | 85 | ## Example 86 | 87 | from(playlist_track in PlaylistTrack, as: :playlist_track, 88 | join: track in assoc(playlist_track, :track), as: :track, 89 | select: track 90 | ) 91 | |> paginate(:track, args) 92 | |> batch_by(:playlist_track, :playlist_id, playlist_ids) 93 | |> select([playlist, track], {playlist.id, track}) 94 | """ 95 | def batch_by(queryable, binding, batch_field, batch_ids) do 96 | batch_cte = 97 | "batch" 98 | |> with_cte("batch", as: fragment("select unnest(? :: int[]) as id", ^batch_ids)) 99 | 100 | queryable = 101 | queryable 102 | |> where( 103 | [{^binding, batch_child}], 104 | field(batch_child, ^batch_field) == parent_as(:batch).id 105 | ) 106 | 107 | from(batch in batch_cte, 108 | as: :batch, 109 | inner_lateral_join: batch_child in subquery(queryable), 110 | on: true, 111 | as: :batch_data 112 | ) 113 | end 114 | 115 | @doc """ 116 | Apply filters to a string field 117 | """ 118 | def filter_string(queryable, field, filters) do 119 | Enum.reduce(filters, queryable, fn 120 | {:like, pattern}, queryable -> 121 | where(queryable, [x], like(field(x, ^field), ^pattern)) 122 | 123 | {:starts_with, prefix}, queryable -> 124 | where(queryable, [x], like(field(x, ^field), ^"#{prefix}%")) 125 | 126 | {:ends_with, suffix}, queryable -> 127 | where(queryable, [x], like(field(x, ^field), ^"%#{suffix}")) 128 | end) 129 | end 130 | 131 | @doc """ 132 | Apply filters to a numeric field 133 | """ 134 | def filter_number(queryable, field, filters) do 135 | Enum.reduce(filters, queryable, fn 136 | {:gt, val}, queryable -> where(queryable, [x], field(x, ^field) > ^val) 137 | {:gte, val}, queryable -> where(queryable, [x], field(x, ^field) >= ^val) 138 | {:eq, val}, queryable -> where(queryable, [x], field(x, ^field) == ^val) 139 | {:ne, val}, queryable -> where(queryable, [x], field(x, ^field) != ^val) 140 | {:lt, val}, queryable -> where(queryable, [x], field(x, ^field) < ^val) 141 | {:lte, val}, queryable -> where(queryable, [x], field(x, ^field) <= ^val) 142 | end) 143 | end 144 | 145 | @doc """ 146 | Apply filters to a datetime field 147 | """ 148 | def filter_datetime(queryable, field, filters) do 149 | Enum.reduce(filters, queryable, fn 150 | {:before, val}, queryable -> where(queryable, [x], field(x, ^field) < ^val) 151 | {:after, val}, queryable -> where(queryable, [x], field(x, ^field) >= ^val) 152 | end) 153 | end 154 | 155 | # Adds the order_by, limit, and where clauses for a paginated query 156 | defp paginate_order_limit( 157 | queryable, 158 | schema, 159 | key_binding, 160 | sort_binding, 161 | args = %{by: cursor_field} 162 | ) do 163 | [pk] = schema.__schema__(:primary_key) 164 | 165 | case args do 166 | %{last: n} -> 167 | queryable 168 | |> order_by([{^key_binding, x}, {^sort_binding, y}], 169 | desc: field(y, ^cursor_field), 170 | desc: field(x, ^pk) 171 | ) 172 | |> limit(^n) 173 | 174 | %{first: n} -> 175 | queryable 176 | |> order_by([{^key_binding, x}, {^sort_binding, y}], 177 | asc: field(y, ^cursor_field), 178 | asc: field(x, ^pk) 179 | ) 180 | |> limit(^n) 181 | 182 | _ -> 183 | queryable 184 | |> order_by([{^key_binding, x}, {^sort_binding, y}], 185 | asc: field(y, ^cursor_field), 186 | asc: field(x, ^pk) 187 | ) 188 | end 189 | end 190 | 191 | defp paginate_where(queryable, key_binding, sort_binding, args = %{by: by}) do 192 | case args do 193 | %{after: [{key_field, lower}], before: [{key_field, upper}]} -> 194 | queryable 195 | |> add_lower_bound(key_binding, sort_binding, lower, key_field, by) 196 | |> add_upper_bound(key_binding, sort_binding, upper, key_field, by) 197 | 198 | %{after: [{key_field, lower}]} -> 199 | queryable 200 | |> add_lower_bound(key_binding, sort_binding, lower, key_field, by) 201 | 202 | %{before: [{key_field, upper}]} -> 203 | queryable 204 | |> add_upper_bound(key_binding, sort_binding, upper, key_field, by) 205 | 206 | _ -> 207 | queryable 208 | end 209 | end 210 | 211 | defp add_upper_bound(queryable, key_binding, _sort_binding, upper_id, key_field, key_field) do 212 | queryable 213 | |> where([{^key_binding, x}], field(x, ^key_field) < ^upper_id) 214 | end 215 | 216 | defp add_upper_bound(queryable, key_binding, sort_binding, upper_id, key_field, sort_field) do 217 | agg_query = bound_query(queryable) 218 | 219 | upper_bound = 220 | agg_query 221 | |> where([{^key_binding, x}], field(x, ^key_field) == ^upper_id) 222 | |> select([{^key_binding, x}], map(x, ^[key_field])) 223 | |> select_merge([{^sort_binding, y}], map(y, ^[sort_field])) 224 | 225 | queryable 226 | |> with_cte("upper_bound", as: ^upper_bound) 227 | |> join(:inner, [], "upper_bound", as: :upper_bound, on: true) 228 | |> where( 229 | [{^key_binding, x}, {^sort_binding, y}, {:upper_bound, ub}], 230 | field(y, ^sort_field) < field(ub, ^sort_field) or 231 | (field(y, ^sort_field) == field(ub, ^sort_field) and 232 | field(x, ^key_field) < field(ub, ^key_field)) 233 | ) 234 | end 235 | 236 | defp add_lower_bound(queryable, key_binding, _sort_binding, lower_id, key_field, key_field) do 237 | queryable 238 | |> where([{^key_binding, x}], field(x, ^key_field) > ^lower_id) 239 | end 240 | 241 | defp add_lower_bound(queryable, key_binding, sort_binding, lower_id, key_field, sort_field) do 242 | agg_query = bound_query(queryable) 243 | 244 | lower_bound = 245 | agg_query 246 | |> where([{^key_binding, x}], field(x, ^key_field) == ^lower_id) 247 | |> select([{^key_binding, x}], map(x, ^[key_field])) 248 | |> select_merge([{^sort_binding, y}], map(y, ^[sort_field])) 249 | 250 | queryable 251 | |> with_cte("lower_bound", as: ^lower_bound) 252 | |> join(:inner, [], "lower_bound", as: :lower_bound, on: true) 253 | |> where( 254 | [{^key_binding, x}, {^sort_binding, y}, {:lower_bound, lb}], 255 | field(y, ^sort_field) > field(lb, ^sort_field) or 256 | (field(y, ^sort_field) == field(lb, ^sort_field) and 257 | field(x, ^key_field) > field(lb, ^key_field)) 258 | ) 259 | end 260 | 261 | defp bound_query(queryable) do 262 | queryable 263 | |> Ecto.Queryable.to_query() 264 | |> exclude(:select) 265 | |> exclude(:limit) 266 | |> exclude(:offset) 267 | |> exclude(:where) 268 | |> limit(1) 269 | end 270 | 271 | defp select_row_count(queryable, args) do 272 | limit = args[:first] || args[:last] 273 | 274 | case limit do 275 | nil -> 276 | queryable 277 | 278 | _ -> 279 | queryable 280 | |> select_merge([x], %{x | row_count: count() |> over()}) 281 | end 282 | end 283 | end 284 | -------------------------------------------------------------------------------- /customer-basic-auth.txt: -------------------------------------------------------------------------------- 1 | %{ 2 | basic_auth: "Basic bHVpc2dAZW1icmFlci5jb20uYnI6cGFzc3dvcmQ=", 3 | email: "luisg@embraer.com.br", 4 | support_rep_email: "jane@chinookcorp.com" 5 | } 6 | %{ 7 | basic_auth: "Basic bGVvbmVrb2hsZXJAc3VyZmV1LmRlOnBhc3N3b3Jk", 8 | email: "leonekohler@surfeu.de", 9 | support_rep_email: "steve@chinookcorp.com" 10 | } 11 | %{ 12 | basic_auth: "Basic ZnRyZW1ibGF5QGdtYWlsLmNvbTpwYXNzd29yZA==", 13 | email: "ftremblay@gmail.com", 14 | support_rep_email: "jane@chinookcorp.com" 15 | } 16 | %{ 17 | basic_auth: "Basic Ympvcm4uaGFuc2VuQHlhaG9vLm5vOnBhc3N3b3Jk", 18 | email: "bjorn.hansen@yahoo.no", 19 | support_rep_email: "margaret@chinookcorp.com" 20 | } 21 | %{ 22 | basic_auth: "Basic ZnJhbnRpc2Vrd0BqZXRicmFpbnMuY29tOnBhc3N3b3Jk", 23 | email: "frantisekw@jetbrains.com", 24 | support_rep_email: "margaret@chinookcorp.com" 25 | } 26 | %{ 27 | basic_auth: "Basic aGhvbHlAZ21haWwuY29tOnBhc3N3b3Jk", 28 | email: "hholy@gmail.com", 29 | support_rep_email: "steve@chinookcorp.com" 30 | } 31 | %{ 32 | basic_auth: "Basic YXN0cmlkLmdydWJlckBhcHBsZS5hdDpwYXNzd29yZA==", 33 | email: "astrid.gruber@apple.at", 34 | support_rep_email: "steve@chinookcorp.com" 35 | } 36 | %{ 37 | basic_auth: "Basic ZGFhbl9wZWV0ZXJzQGFwcGxlLmJlOnBhc3N3b3Jk", 38 | email: "daan_peeters@apple.be", 39 | support_rep_email: "margaret@chinookcorp.com" 40 | } 41 | %{ 42 | basic_auth: "Basic a2FyYS5uaWVsc2VuQGp1YmlpLmRrOnBhc3N3b3Jk", 43 | email: "kara.nielsen@jubii.dk", 44 | support_rep_email: "margaret@chinookcorp.com" 45 | } 46 | %{ 47 | basic_auth: "Basic ZWR1YXJkb0B3b29kc3RvY2suY29tLmJyOnBhc3N3b3Jk", 48 | email: "eduardo@woodstock.com.br", 49 | support_rep_email: "margaret@chinookcorp.com" 50 | } 51 | %{ 52 | basic_auth: "Basic YWxlcm9AdW9sLmNvbS5icjpwYXNzd29yZA==", 53 | email: "alero@uol.com.br", 54 | support_rep_email: "steve@chinookcorp.com" 55 | } 56 | %{ 57 | basic_auth: "Basic cm9iZXJ0by5hbG1laWRhQHJpb3R1ci5nb3YuYnI6cGFzc3dvcmQ=", 58 | email: "roberto.almeida@riotur.gov.br", 59 | support_rep_email: "jane@chinookcorp.com" 60 | } 61 | %{ 62 | basic_auth: "Basic ZmVybmFkYXJhbW9zNEB1b2wuY29tLmJyOnBhc3N3b3Jk", 63 | email: "fernadaramos4@uol.com.br", 64 | support_rep_email: "margaret@chinookcorp.com" 65 | } 66 | %{ 67 | basic_auth: "Basic bXBoaWxpcHMxMkBzaGF3LmNhOnBhc3N3b3Jk", 68 | email: "mphilips12@shaw.ca", 69 | support_rep_email: "steve@chinookcorp.com" 70 | } 71 | %{ 72 | basic_auth: "Basic amVubmlmZXJwQHJvZ2Vycy5jYTpwYXNzd29yZA==", 73 | email: "jenniferp@rogers.ca", 74 | support_rep_email: "jane@chinookcorp.com" 75 | } 76 | %{ 77 | basic_auth: "Basic ZmhhcnJpc0Bnb29nbGUuY29tOnBhc3N3b3Jk", 78 | email: "fharris@google.com", 79 | support_rep_email: "margaret@chinookcorp.com" 80 | } 81 | %{ 82 | basic_auth: "Basic amFja3NtaXRoQG1pY3Jvc29mdC5jb206cGFzc3dvcmQ=", 83 | email: "jacksmith@microsoft.com", 84 | support_rep_email: "steve@chinookcorp.com" 85 | } 86 | %{ 87 | basic_auth: "Basic bWljaGVsbGViQGFvbC5jb206cGFzc3dvcmQ=", 88 | email: "michelleb@aol.com", 89 | support_rep_email: "jane@chinookcorp.com" 90 | } 91 | %{ 92 | basic_auth: "Basic dGdveWVyQGFwcGxlLmNvbTpwYXNzd29yZA==", 93 | email: "tgoyer@apple.com", 94 | support_rep_email: "jane@chinookcorp.com" 95 | } 96 | %{ 97 | basic_auth: "Basic ZG1pbGxlckBjb21jYXN0LmNvbTpwYXNzd29yZA==", 98 | email: "dmiller@comcast.com", 99 | support_rep_email: "margaret@chinookcorp.com" 100 | } 101 | %{ 102 | basic_auth: "Basic a2FjaGFzZUBob3RtYWlsLmNvbTpwYXNzd29yZA==", 103 | email: "kachase@hotmail.com", 104 | support_rep_email: "steve@chinookcorp.com" 105 | } 106 | %{ 107 | basic_auth: "Basic aGxlYWNvY2tAZ21haWwuY29tOnBhc3N3b3Jk", 108 | email: "hleacock@gmail.com", 109 | support_rep_email: "margaret@chinookcorp.com" 110 | } 111 | %{ 112 | basic_auth: "Basic am9obmdvcmRvbjIyQHlhaG9vLmNvbTpwYXNzd29yZA==", 113 | email: "johngordon22@yahoo.com", 114 | support_rep_email: "margaret@chinookcorp.com" 115 | } 116 | %{ 117 | basic_auth: "Basic ZnJhbHN0b25AZ21haWwuY29tOnBhc3N3b3Jk", 118 | email: "fralston@gmail.com", 119 | support_rep_email: "jane@chinookcorp.com" 120 | } 121 | %{ 122 | basic_auth: "Basic dnN0ZXZlbnNAeWFob28uY29tOnBhc3N3b3Jk", 123 | email: "vstevens@yahoo.com", 124 | support_rep_email: "steve@chinookcorp.com" 125 | } 126 | %{ 127 | basic_auth: "Basic cmljdW5uaW5naGFtQGhvdG1haWwuY29tOnBhc3N3b3Jk", 128 | email: "ricunningham@hotmail.com", 129 | support_rep_email: "margaret@chinookcorp.com" 130 | } 131 | %{ 132 | basic_auth: "Basic cGF0cmljay5ncmF5QGFvbC5jb206cGFzc3dvcmQ=", 133 | email: "patrick.gray@aol.com", 134 | support_rep_email: "margaret@chinookcorp.com" 135 | } 136 | %{ 137 | basic_auth: "Basic anViYXJuZXR0QGdtYWlsLmNvbTpwYXNzd29yZA==", 138 | email: "jubarnett@gmail.com", 139 | support_rep_email: "steve@chinookcorp.com" 140 | } 141 | %{ 142 | basic_auth: "Basic cm9iYnJvd25Ac2hhdy5jYTpwYXNzd29yZA==", 143 | email: "robbrown@shaw.ca", 144 | support_rep_email: "jane@chinookcorp.com" 145 | } 146 | %{ 147 | basic_auth: "Basic ZWRmcmFuY2lzQHlhY2hvby5jYTpwYXNzd29yZA==", 148 | email: "edfrancis@yachoo.ca", 149 | support_rep_email: "jane@chinookcorp.com" 150 | } 151 | %{ 152 | basic_auth: "Basic bWFydGhhc2lsa0BnbWFpbC5jb206cGFzc3dvcmQ=", 153 | email: "marthasilk@gmail.com", 154 | support_rep_email: "steve@chinookcorp.com" 155 | } 156 | %{ 157 | basic_auth: "Basic YWFyb25taXRjaGVsbEB5YWhvby5jYTpwYXNzd29yZA==", 158 | email: "aaronmitchell@yahoo.ca", 159 | support_rep_email: "margaret@chinookcorp.com" 160 | } 161 | %{ 162 | basic_auth: "Basic ZWxsaWUuc3VsbGl2YW5Ac2hhdy5jYTpwYXNzd29yZA==", 163 | email: "ellie.sullivan@shaw.ca", 164 | support_rep_email: "jane@chinookcorp.com" 165 | } 166 | %{ 167 | basic_auth: "Basic amZlcm5hbmRlc0B5YWhvby5wdDpwYXNzd29yZA==", 168 | email: "jfernandes@yahoo.pt", 169 | support_rep_email: "margaret@chinookcorp.com" 170 | } 171 | %{ 172 | basic_auth: "Basic bWFzYW1wYWlvQHNhcG8ucHQ6cGFzc3dvcmQ=", 173 | email: "masampaio@sapo.pt", 174 | support_rep_email: "margaret@chinookcorp.com" 175 | } 176 | %{ 177 | basic_auth: "Basic aGFubmFoLnNjaG5laWRlckB5YWhvby5kZTpwYXNzd29yZA==", 178 | email: "hannah.schneider@yahoo.de", 179 | support_rep_email: "steve@chinookcorp.com" 180 | } 181 | %{ 182 | basic_auth: "Basic ZnppbW1lcm1hbm5AeWFob28uZGU6cGFzc3dvcmQ=", 183 | email: "fzimmermann@yahoo.de", 184 | support_rep_email: "jane@chinookcorp.com" 185 | } 186 | %{ 187 | basic_auth: "Basic bnNjaHJvZGVyQHN1cmZldS5kZTpwYXNzd29yZA==", 188 | email: "nschroder@surfeu.de", 189 | support_rep_email: "jane@chinookcorp.com" 190 | } 191 | %{ 192 | basic_auth: "Basic Y2FtaWxsZS5iZXJuYXJkQHlhaG9vLmZyOnBhc3N3b3Jk", 193 | email: "camille.bernard@yahoo.fr", 194 | support_rep_email: "margaret@chinookcorp.com" 195 | } 196 | %{ 197 | basic_auth: "Basic ZG9taW5pcXVlbGVmZWJ2cmVAZ21haWwuY29tOnBhc3N3b3Jk", 198 | email: "dominiquelefebvre@gmail.com", 199 | support_rep_email: "margaret@chinookcorp.com" 200 | } 201 | %{ 202 | basic_auth: "Basic bWFyYy5kdWJvaXNAaG90bWFpbC5jb206cGFzc3dvcmQ=", 203 | email: "marc.dubois@hotmail.com", 204 | support_rep_email: "steve@chinookcorp.com" 205 | } 206 | %{ 207 | basic_auth: "Basic d3lhdHQuZ2lyYXJkQHlhaG9vLmZyOnBhc3N3b3Jk", 208 | email: "wyatt.girard@yahoo.fr", 209 | support_rep_email: "jane@chinookcorp.com" 210 | } 211 | %{ 212 | basic_auth: "Basic aXNhYmVsbGVfbWVyY2llckBhcHBsZS5mcjpwYXNzd29yZA==", 213 | email: "isabelle_mercier@apple.fr", 214 | support_rep_email: "jane@chinookcorp.com" 215 | } 216 | %{ 217 | basic_auth: "Basic dGVyaGkuaGFtYWxhaW5lbkBhcHBsZS5maTpwYXNzd29yZA==", 218 | email: "terhi.hamalainen@apple.fi", 219 | support_rep_email: "jane@chinookcorp.com" 220 | } 221 | %{ 222 | basic_auth: "Basic bGFkaXNsYXZfa292YWNzQGFwcGxlLmh1OnBhc3N3b3Jk", 223 | email: "ladislav_kovacs@apple.hu", 224 | support_rep_email: "jane@chinookcorp.com" 225 | } 226 | %{ 227 | basic_auth: "Basic aHVnaG9yZWlsbHlAYXBwbGUuaWU6cGFzc3dvcmQ=", 228 | email: "hughoreilly@apple.ie", 229 | support_rep_email: "jane@chinookcorp.com" 230 | } 231 | %{ 232 | basic_auth: "Basic bHVjYXMubWFuY2luaUB5YWhvby5pdDpwYXNzd29yZA==", 233 | email: "lucas.mancini@yahoo.it", 234 | support_rep_email: "steve@chinookcorp.com" 235 | } 236 | %{ 237 | basic_auth: "Basic am9oYXZhbmRlcmJlcmdAeWFob28ubmw6cGFzc3dvcmQ=", 238 | email: "johavanderberg@yahoo.nl", 239 | support_rep_email: "steve@chinookcorp.com" 240 | } 241 | %{ 242 | basic_auth: "Basic c3RhbmlzbGF3LnfDs2pjaWtAd3AucGw6cGFzc3dvcmQ=", 243 | email: "stanislaw.wójcik@wp.pl", 244 | support_rep_email: "margaret@chinookcorp.com" 245 | } 246 | %{ 247 | basic_auth: "Basic ZW5yaXF1ZV9tdW5vekB5YWhvby5lczpwYXNzd29yZA==", 248 | email: "enrique_munoz@yahoo.es", 249 | support_rep_email: "steve@chinookcorp.com" 250 | } 251 | %{ 252 | basic_auth: "Basic am9ha2ltLmpvaGFuc3NvbkB5YWhvby5zZTpwYXNzd29yZA==", 253 | email: "joakim.johansson@yahoo.se", 254 | support_rep_email: "steve@chinookcorp.com" 255 | } 256 | %{ 257 | basic_auth: "Basic ZW1tYV9qb25lc0Bob3RtYWlsLmNvbTpwYXNzd29yZA==", 258 | email: "emma_jones@hotmail.com", 259 | support_rep_email: "jane@chinookcorp.com" 260 | } 261 | %{ 262 | basic_auth: "Basic cGhpbC5odWdoZXNAZ21haWwuY29tOnBhc3N3b3Jk", 263 | email: "phil.hughes@gmail.com", 264 | support_rep_email: "jane@chinookcorp.com" 265 | } 266 | %{ 267 | basic_auth: "Basic c3RldmUubXVycmF5QHlhaG9vLnVrOnBhc3N3b3Jk", 268 | email: "steve.murray@yahoo.uk", 269 | support_rep_email: "steve@chinookcorp.com" 270 | } 271 | %{ 272 | basic_auth: "Basic bWFyay50YXlsb3JAeWFob28uYXU6cGFzc3dvcmQ=", 273 | email: "mark.taylor@yahoo.au", 274 | support_rep_email: "margaret@chinookcorp.com" 275 | } 276 | %{ 277 | basic_auth: "Basic ZGllZ28uZ3V0aWVycmV6QHlhaG9vLmFyOnBhc3N3b3Jk", 278 | email: "diego.gutierrez@yahoo.ar", 279 | support_rep_email: "margaret@chinookcorp.com" 280 | } 281 | %{ 282 | basic_auth: "Basic bHVpc3JvamFzQHlhaG9vLmNsOnBhc3N3b3Jk", 283 | email: "luisrojas@yahoo.cl", 284 | support_rep_email: "steve@chinookcorp.com" 285 | } 286 | %{ 287 | basic_auth: "Basic bWFub2oucGFyZWVrQHJlZGlmZi5jb206cGFzc3dvcmQ=", 288 | email: "manoj.pareek@rediff.com", 289 | support_rep_email: "jane@chinookcorp.com" 290 | } 291 | %{ 292 | basic_auth: "Basic cHVqYV9zcml2YXN0YXZhQHlhaG9vLmluOnBhc3N3b3Jk", 293 | email: "puja_srivastava@yahoo.in", 294 | support_rep_email: "jane@chinookcorp.com" 295 | } -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "absinthe": {:hex, :absinthe, "1.7.5", "a15054f05738e766f7cc7fd352887dfd5e61cec371fb4741cca37c3359ff74ac", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "22a9a38adca26294ad0ee91226168f5d215b401efd770b8a1b8fd9c9b21ec316"}, 3 | "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, 4 | "absinthe_relay": {:hex, :absinthe_relay, "1.5.2", "cfb8aed70f4e4c7718d3f1c212332d2ea728f17c7fc0f68f1e461f0f5f0c4b9a", [:mix], [{:absinthe, "~> 1.5.0 or ~> 1.6.0 or ~> 1.7.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "0587ee913afa31512e1457a5064ee88427f8fe7bcfbeeecd41c71d9cff0b62b6"}, 5 | "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, 6 | "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, 7 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 8 | "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, 9 | "dataloader": {:hex, :dataloader, "2.0.0", "49b42d60b9bb06d761a71d7b034c4b34787957e713d4fae15387a25fcd639112", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:opentelemetry_process_propagator, "~> 0.2.1", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "09d61781b76ce216e395cdbc883ff00d00f46a503e215c22722dba82507dfef0"}, 10 | "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, 11 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 12 | "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, 13 | "ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, 14 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 15 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 16 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 17 | "phoenix": {:hex, :phoenix, "1.7.7", "4cc501d4d823015007ba3cdd9c41ecaaf2ffb619d6fb283199fa8ddba89191e0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "8966e15c395e5e37591b6ed0bd2ae7f48e961f0f60ac4c733f9566b519453085"}, 18 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.2", "b21bd01fdeffcfe2fab49e4942aa938b6d3e89e93a480d4aee58085560a0bc0d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "70242edd4601d50b69273b057ecf7b684644c19ee750989fd555625ae4ce8f5d"}, 19 | "phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"}, 20 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.0", "0b3158b5b198aa444473c91d23d79f52fb077e807ffad80dacf88ce078fa8df2", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "87785a54474fed91a67a1227a741097eb1a42c2e49d3c0d098b588af65cd410d"}, 21 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.19.5", "6e730595e8e9b8c5da230a814e557768828fd8dfeeb90377d2d8dbb52d4ec00a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eaa0dd3cfb9bd7fb949b88217df9f25aed915e986a28ad5c8a0d054e7ca9d3"}, 22 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 23 | "phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"}, 24 | "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, 25 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, 26 | "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, 27 | "postgrex": {:hex, :postgrex, "0.17.2", "a3ec9e3239d9b33f1e5841565c4eb200055c52cc0757a22b63ca2d529bbe764c", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "80a918a9e9531d39f7bd70621422f3ebc93c01618c645f2d91306f50041ed90c"}, 28 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 29 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 30 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 31 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 32 | "websock": {:hex, :websock, "0.5.2", "b3c08511d8d79ed2c2f589ff430bd1fe799bb389686dafce86d28801783d8351", [:mix], [], "hexpm", "925f5de22fca6813dfa980fb62fd542ec43a2d1a1f83d2caec907483fe66ff05"}, 33 | "websock_adapter": {:hex, :websock_adapter, "0.5.3", "4908718e42e4a548fc20e00e70848620a92f11f7a6add8cf0886c4232267498d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "cbe5b814c1f86b6ea002b52dd99f345aeecf1a1a6964e209d208fb404d930d3d"}, 34 | } 35 | -------------------------------------------------------------------------------- /test/chinook/album_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Chinook.AlbumTest do 2 | use Chinook.DataCase, async: true 3 | import Ecto.Query 4 | 5 | alias Chinook.Repo 6 | alias Chinook.Artist 7 | alias Chinook.Album 8 | alias Chinook.Track 9 | @album_ids [50, 60, 70, 80, 90, 100, 110, 120, 130, 141, 147] 10 | 11 | describe "Preload with joins" do 12 | test "Preload tracks with join and subquery condition" do 13 | query = 14 | from album in Album, 15 | left_join: track in assoc(album, :tracks), 16 | where: 17 | track.track_id in fragment( 18 | """ 19 | SELECT "TrackId" 20 | FROM "Track" 21 | WHERE "AlbumId" = ? 22 | ORDER BY "Milliseconds" DESC 23 | LIMIT 3 24 | """, 25 | album.album_id 26 | ), 27 | preload: [tracks: {track, :genre}], 28 | where: album.album_id in ^@album_ids, 29 | select: album 30 | 31 | [a1, a2 | _rest] = Repo.all(query) 32 | assert length(a1.tracks) == 3 33 | assert length(a2.tracks) == 3 34 | end 35 | 36 | test "Preload tracks with lateral join" do 37 | query = 38 | from album in Album, 39 | left_lateral_join: 40 | top_tracks in fragment( 41 | """ 42 | ( 43 | SELECT "TrackId" as track_id 44 | FROM "Track" 45 | WHERE "AlbumId" = ? 46 | ORDER BY "Milliseconds" DESC 47 | LIMIT 3 48 | ) 49 | """, 50 | album.album_id 51 | ), 52 | on: true, 53 | left_join: track in Track, 54 | on: track.track_id == top_tracks.track_id, 55 | preload: [tracks: {track, :genre}], 56 | where: album.album_id in ^@album_ids, 57 | select: album 58 | 59 | [a1, a2 | _rest] = Repo.all(query) 60 | assert length(a1.tracks) == 3 61 | assert length(a2.tracks) == 3 62 | end 63 | end 64 | 65 | def default_opts(opts, default_opts) do 66 | Enum.reduce(opts, Map.new(default_opts), fn {k, v}, acc -> %{acc | k => v} end) 67 | end 68 | 69 | def partition_limit(queryable, opts) do 70 | {partition_by, opts} = Keyword.pop!(opts, :partition_by) 71 | {order_by, opts} = Keyword.pop!(opts, :order_by) 72 | {limit, opts} = Keyword.pop!(opts, :limit) 73 | {where, []} = Keyword.pop(opts, :where, []) 74 | 75 | %{from: %{source: {_, schema}}} = from(queryable) 76 | [primary_key] = schema.__schema__(:primary_key) 77 | 78 | ranking_query = 79 | from r in queryable, 80 | where: ^where, 81 | select: map(r, ^[primary_key]), 82 | select_merge: %{rank: row_number() |> over(:w)}, 83 | windows: [w: [partition_by: ^partition_by, order_by: ^order_by]] 84 | 85 | from row in schema, 86 | join: top_rows in subquery(ranking_query), 87 | on: field(row, ^primary_key) == field(top_rows, ^primary_key), 88 | where: top_rows.rank <= ^limit, 89 | select: row 90 | end 91 | 92 | def preload_limit(query, association, opts) do 93 | {order_by, opts} = Keyword.pop!(opts, :order_by) 94 | {limit, opts} = Keyword.pop!(opts, :limit) 95 | {repo, []} = Keyword.pop(opts, :repo, nil) 96 | 97 | %{from: %{source: {_, source_schema}}} = query 98 | 99 | %{queryable: related_queryable, related_key: related_key} = 100 | source_schema.__schema__(:association, association) 101 | 102 | preloader = 103 | case repo do 104 | nil -> 105 | related_queryable 106 | |> partition_limit( 107 | partition_by: related_key, 108 | order_by: order_by, 109 | limit: limit 110 | ) 111 | 112 | repo -> 113 | fn ids -> 114 | preload_query = 115 | related_queryable 116 | |> partition_limit( 117 | where: dynamic([x], field(x, ^related_key) in ^ids), 118 | partition_by: related_key, 119 | order_by: order_by, 120 | limit: limit 121 | ) 122 | 123 | repo.all(preload_query) 124 | end 125 | end 126 | 127 | query |> preload([{^association, ^preloader}]) 128 | end 129 | 130 | def top_n(schema, association, opts) do 131 | {where, opts} = Keyword.pop(opts, :where, []) 132 | {order_by, opts} = Keyword.pop!(opts, :order_by) 133 | {limit, []} = Keyword.pop!(opts, :limit) 134 | 135 | assoc_info = schema.__schema__(:association, association) 136 | assoc_schema = assoc_info.queryable 137 | [assoc_primary_key] = assoc_schema.__schema__(:primary_key) 138 | related_key = assoc_info.related_key 139 | 140 | from associated in assoc_schema, 141 | as: :associated, 142 | inner_lateral_join: 143 | top_associated in subquery( 144 | from top_associated in assoc_schema, 145 | where: 146 | field(top_associated, ^related_key) == field(parent_as(:associated), ^related_key), 147 | where: ^where, 148 | order_by: ^order_by, 149 | limit: ^limit, 150 | select: ^[assoc_primary_key] 151 | ), 152 | on: field(top_associated, ^assoc_primary_key) == field(associated, ^assoc_primary_key), 153 | select: associated 154 | end 155 | 156 | describe "Preload with Query" do 157 | test "Preload tracks with generic inner_lateral_join" do 158 | query = 159 | from artist in Artist, 160 | order_by: artist.artist_id, 161 | limit: 10, 162 | select: artist, 163 | preload: [ 164 | albums: 165 | ^top_n(Artist, :albums, 166 | order_by: :title, 167 | limit: 1 168 | ) 169 | ], 170 | preload: [ 171 | albums: [ 172 | tracks: 173 | ^top_n(Album, :tracks, 174 | order_by: :name, 175 | limit: 3 176 | ) 177 | ] 178 | ] 179 | 180 | [a1 | _rest] = Repo.all(query) 181 | album1 = hd(a1.albums) 182 | assert length(album1.tracks) == 3 183 | end 184 | 185 | test "Preload tracks with query using windows" do 186 | tracks_query = 187 | from track in Track, 188 | join: 189 | top_track in subquery( 190 | from t in Track, 191 | select: %{track_id: t.track_id, rank: row_number() |> over(:album)}, 192 | windows: [album: [partition_by: :album_id, order_by: [desc: :milliseconds]]] 193 | ), 194 | on: track.track_id == top_track.track_id, 195 | where: top_track.rank <= 3, 196 | select: track, 197 | preload: :genre 198 | 199 | query = 200 | from album in Album, 201 | where: album.album_id in ^@album_ids, 202 | preload: [tracks: ^tracks_query], 203 | select: album 204 | 205 | [a1, a2 | _rest] = Repo.all(query) 206 | assert length(a1.tracks) == 3 207 | assert length(a2.tracks) == 3 208 | end 209 | 210 | test "Preload tracks with generic query using windows" do 211 | tracks_query = 212 | partition_limit(Track, partition_by: :album_id, order_by: [desc: :milliseconds], limit: 3) 213 | 214 | query = 215 | from album in Album, 216 | where: album.album_id in ^@album_ids, 217 | preload: [tracks: ^tracks_query], 218 | select: album 219 | 220 | [a1, a2 | _rest] = Repo.all(query) 221 | assert length(a1.tracks) == 3 222 | assert length(a2.tracks) == 3 223 | end 224 | 225 | test "Preload tracks with lateral join query" do 226 | album_ids = @album_ids 227 | 228 | tracks_query = 229 | from track in Track, 230 | join: album in assoc(track, :album), 231 | as: :album, 232 | inner_lateral_join: 233 | top_track in subquery( 234 | from Track, 235 | where: [album_id: parent_as(:album).album_id], 236 | order_by: [desc: :milliseconds], 237 | limit: 3, 238 | select: [:track_id] 239 | ), 240 | on: top_track.track_id == track.track_id 241 | 242 | query = 243 | from album in Album, 244 | where: album.album_id in ^album_ids, 245 | preload: [tracks: ^tracks_query], 246 | select: album 247 | 248 | assert length(Repo.all(query)) == 11 249 | end 250 | 251 | test "Preload tracks with generic helper" do 252 | query = 253 | Album 254 | |> where([album], album.album_id in ^@album_ids) 255 | |> preload_limit(:tracks, order_by: [desc: :milliseconds], limit: 3) 256 | |> select([album], album) 257 | 258 | [a1, a2 | _rest] = Repo.all(query) 259 | assert length(a1.tracks) == 3 260 | assert length(a2.tracks) == 3 261 | end 262 | 263 | test "Preload tracks with optimised generic helper" do 264 | query = 265 | Album 266 | |> where([album], album.album_id in ^@album_ids) 267 | |> preload_limit(:tracks, order_by: [desc: :milliseconds], limit: 3, repo: Repo) 268 | |> select([album], album) 269 | 270 | [a1, a2 | _rest] = Repo.all(query) 271 | assert length(a1.tracks) == 3 272 | assert length(a2.tracks) == 3 273 | end 274 | 275 | test "Preload multiple levels with optimised generic helper" do 276 | alias Chinook.Artist 277 | 278 | query = 279 | Artist 280 | |> where([artist], artist.artist_id in ^[100, 105]) 281 | |> preload( 282 | albums: 283 | ^(Album |> partition_limit(partition_by: :artist_id, order_by: :title, limit: 2)) 284 | ) 285 | |> preload( 286 | albums: [ 287 | tracks: 288 | ^partition_limit(Track, partition_by: :album_id, order_by: :milliseconds, limit: 2) 289 | ] 290 | ) 291 | |> preload(albums: [tracks: :genre]) 292 | |> select([album], album) 293 | 294 | [a1 | _rest] = Repo.all(query) 295 | assert length(a1.albums) == 1 296 | assert length(hd(a1.albums).tracks) == 2 297 | end 298 | end 299 | 300 | describe "Preload with function" do 301 | test "Preload tracks with custom function" do 302 | tracks_func = fn album_ids -> 303 | tracks_query = 304 | from track in Track, 305 | join: 306 | top_track in subquery( 307 | from t in Track, 308 | select: %{track_id: t.track_id, rank: row_number() |> over(:album)}, 309 | windows: [album: [partition_by: :album_id, order_by: [desc: :milliseconds]]], 310 | where: t.album_id in ^album_ids 311 | ), 312 | on: track.track_id == top_track.track_id, 313 | where: top_track.rank <= 3, 314 | select: track, 315 | preload: :genre 316 | 317 | Repo.all(tracks_query) 318 | end 319 | 320 | query = 321 | from album in Album, 322 | where: album.album_id in ^@album_ids, 323 | preload: [tracks: ^tracks_func], 324 | select: album 325 | 326 | [a1, a2 | _rest] = Repo.all(query) 327 | assert length(a1.tracks) == 3 328 | assert length(a2.tracks) == 3 329 | end 330 | 331 | test "Preload tracks with raw SQL and function" do 332 | tracks_func = fn limit -> 333 | fn ids -> 334 | result = 335 | Repo.query!( 336 | """ 337 | SELECT * 338 | FROM ( 339 | SELECT 340 | top_track.*, row_number() OVER "w" AS "rank" 341 | FROM "Track" AS top_track 342 | WINDOW "w" AS (PARTITION BY top_track."AlbumId" ORDER BY top_track."Milliseconds" DESC) 343 | ) as t 344 | WHERE (t."rank" <= $1) AND t."AlbumId" = ANY($2) 345 | """, 346 | [limit, ids] 347 | ) 348 | 349 | Enum.map(result.rows, &Repo.load(Track, {result.columns, &1})) 350 | end 351 | end 352 | 353 | query = 354 | from album in Album, 355 | where: album.album_id in ^@album_ids, 356 | preload: [tracks: ^tracks_func.(3)], 357 | preload: [tracks: :genre], 358 | select: album 359 | 360 | [a1, a2 | _rest] = Repo.all(query) 361 | assert length(a1.tracks) == 3 362 | assert length(a2.tracks) == 3 363 | end 364 | 365 | def longest_tracks_per_album(limit: limit) do 366 | fn album_ids -> 367 | %{rows: rows, columns: cols} = Repo.query!( 368 | """ 369 | SELECT track.* 370 | FROM unnest($1::int[]) as album(album_id) 371 | LEFT JOIN LATERAL ( 372 | SELECT * 373 | FROM "Track" 374 | WHERE "AlbumId" = album.album_id 375 | ORDER BY "Name" DESC 376 | LIMIT $2) track ON true 377 | """, 378 | [album_ids, limit] 379 | ) 380 | Enum.map(rows, &Repo.load(Track, {cols, &1})) 381 | end 382 | end 383 | 384 | test "Preload tracks with lateral join raw SQL and function" do 385 | query = 386 | from album in Album, 387 | where: album.album_id in ^@album_ids, 388 | preload: [tracks: ^longest_tracks_per_album(limit: 3)], 389 | preload: [tracks: :genre], 390 | select: album 391 | 392 | [a1, a2 | _rest] = Repo.all(query) 393 | assert length(a1.tracks) == 3 394 | assert length(a2.tracks) == 3 395 | end 396 | end 397 | end 398 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Elixir CI](https://github.com/mbuhot/chinook/workflows/Elixir%20CI/badge.svg)](https://github.com/mbuhot/chinook/actions?query=workflow%3A%22Elixir+CI%22) 2 | 3 | # Chinook 4 | 5 | Exploring the Chinook dataset with Elixir and Ecto. 6 | 7 | ## Problem: Select top N per category 8 | 9 | Let's start with a problem: We want to retrieve the top 10 Artists, their first album, and the first 3 tracks for those albums. 10 | We'd like to avoid making too many SQL queries to get the data. 11 | 12 | 13 | ## Ecto associations and preloads 14 | 15 | Ecto has a wonderful feature for working with related tables: [associations](https://hexdocs.pm/ecto/Ecto.html#module-associations). I use it to simplify having to write out join conditions, and to fetch related data in queries. 16 | 17 | ```elixir 18 | # associations make joins easy! 19 | from album in Album, 20 | join: track in assoc(album, :tracks), 21 | join: genre in assoc(track, :genre), 22 | select: %{album.title, track.name, genre.name, track.milliseconds} 23 | ``` 24 | 25 | ```elixir 26 | # associations make fetching related data easy! 27 | query = 28 | from album in Album, 29 | preload: [:artist, tracks: :genre], 30 | limit: 3 31 | 32 | albums = Repo.all(query) 33 | album = hd(albums) 34 | artist = album.artist 35 | track1 = hd(album.tracks) 36 | genre = track1.genre 37 | ``` 38 | 39 | ## Limiting Preloads 40 | 41 | If we tell ecto to preload albums and tracks for each artist, it will fetch much more data than we require. 42 | 43 | ```elixir 44 | query = 45 | from artist in Artist, 46 | order_by: artist.name, 47 | preload: [albums: :tracks], 48 | limit: 10 49 | 50 | Repo.all(query) 51 | ``` 52 | 53 | Ecto allows us to customize the preload in 3 ways: 54 | 55 | - Using a query 56 | - Using a join 57 | - Using a function 58 | 59 | Lets try using a query: 60 | 61 | ```elixir 62 | top_albums = 63 | from a in Album, 64 | limit: 1 65 | 66 | query = 67 | from artist in Artist, 68 | order_by: artist.name, 69 | preload: [albums: ^top_albums], 70 | limit: 10 71 | 72 | iex(53)> Repo.all(query) 73 | 74 | [debug] QUERY OK source="Artist" db=3.7ms idle=1338.9ms 75 | SELECT A0."ArtistId", A0."Name" FROM "Artist" AS A0 ORDER BY A0."Name" LIMIT 10 [] 76 | 77 | [debug] QUERY OK source="Album" db=2.7ms idle=1342.8ms 78 | SELECT A0."AlbumId", A0."Title", A0."ArtistId", A0."ArtistId" 79 | FROM "Album" AS A0 80 | WHERE (A0."ArtistId" = ANY($1)) 81 | ORDER BY A0."ArtistId" 82 | LIMIT 1 83 | [[43, 1, 2, 239, 257, 214, 222, 215, 202, 230]] 84 | 85 | [ 86 | %Chinook.Artist{ 87 | albums: [], 88 | artist_id: 230, 89 | name: "Aaron Copland & London Symphony Orchestra" 90 | }, 91 | %Chinook.Artist{ 92 | albums: [], 93 | artist_id: 202, 94 | name: "Aaron Goldberg" 95 | }, 96 | ... 97 | %Chinook.Artist{ 98 | albums: [ 99 | %Chinook.Album{ 100 | album_id: 4, 101 | artist_id: 1, 102 | title: "Let There Be Rock" 103 | } 104 | ], 105 | artist_id: 1, 106 | name: "AC/DC" 107 | } 108 | ] 109 | ``` 110 | 111 | We can see from the `LIMIT 1` in the SQL that was executed, only 1 album was loaded. 112 | Not what we wanted - we need 1 album from each artist. 113 | 114 | This is called out in the Ecto docs: 115 | 116 | > Note: keep in mind operations like limit and offset in the preload query will affect the whole result set and not each association. For example, the query below: 117 | 118 | ```elixir 119 | comments_query = from c in Comment, order_by: c.popularity, limit: 5 120 | Repo.all from p in Post, preload: [comments: ^comments_query] 121 | ``` 122 | 123 | > won't bring the top of comments per post. Rather, it will only bring the 5 top comments across all posts. 124 | 125 | ## Solution 1: Where-in subquery 126 | 127 | We can solve this problem using joins and subqueries like so: 128 | 129 | ```elixir 130 | query = 131 | from artist in Artist, as: :artist, 132 | join: album in assoc(artist, :albums), as: :album, 133 | join: track in assoc(album, :tracks), 134 | where: artist.artist_id in subquery( 135 | from a in Artist, 136 | order_by: a.artist_id, 137 | limit: 10, 138 | select: a.artist_id 139 | ), 140 | where: album.album_id in subquery( 141 | from a in Album, 142 | where: a.artist_id == parent_as(:artist).artist_id, 143 | order_by: :title, 144 | limit: 1, 145 | select: a.album_id 146 | ), 147 | where: track.track_id in subquery( 148 | from t in Track, 149 | where: t.album_id == parent_as(:album).album_id, 150 | order_by: :name, 151 | limit: 3, 152 | select: t.track_id 153 | ), 154 | order_by: [artist.artist_id, album.album_id, track.track_id], 155 | select: artist, 156 | preload: [albums: {album, tracks: track}] 157 | 158 | data = Repo.all(query) 159 | ``` 160 | 161 | Note the usage of [named bindings](https://hexdocs.pm/ecto/Ecto.Query.html#module-named-bindings) `as: :album` and `parent_as(:album)` to propagate bindings from the outer query into the subquery. There's several older stackoverflow and forum posts that recommend using fragments for correllated subqueries, but it's no-longer necessary! 162 | 163 | What's the performance like? 164 | 165 | ``` 166 | [debug] QUERY OK source="Artist" db=53.6ms queue=2.9ms idle=1719.9ms 167 | ``` 168 | 169 | Not great, let's see if we can do better than 50ms. 170 | 171 | 172 | ## Solution 2: Lateral Joins 173 | 174 | ```elixir 175 | query = 176 | from(artist in Artist, as: :artist, 177 | join: album in assoc(artist, :albums), as: :album, 178 | join: track in assoc(album, :tracks), 179 | join: top_artist in subquery( 180 | from Artist, 181 | order_by: :artist_id, 182 | limit: 10, 183 | select: [:artist_id] 184 | ), 185 | on: artist.artist_id == top_artist.artist_id, 186 | inner_lateral_join: top_album in subquery( 187 | from Album, 188 | where: [artist_id: parent_as(:artist).artist_id], 189 | limit: 1, 190 | order_by: :title, 191 | select: [:album_id] 192 | ), 193 | on: album.album_id == top_album.album_id, 194 | 195 | inner_lateral_join: top_track in subquery( 196 | from Track, 197 | where: [album_id: parent_as(:album).album_id], 198 | limit: 3, 199 | order_by: :name, 200 | select: [:track_id] 201 | ), 202 | on: track.track_id == top_track.track_id, 203 | 204 | order_by: [artist.artist_id, album.album_id, track.track_id], 205 | select: artist, 206 | preload: [albums: {album, tracks: track}] 207 | ) 208 | 209 | data = Repo.all(query) 210 | ``` 211 | 212 | ``` 213 | [debug] QUERY OK source="Artist" db=3.5ms idle=1144.0ms 214 | ``` 215 | 216 | Woah! a 10x improvement by putting the limit conditions into `inner_lateral_join`! 217 | 218 | 219 | ## Solution 3: Joins with Window Functions 220 | 221 | Window functions are great once you wrap your head around them. 222 | In our case, we can use the `row_number over(partition by "AlbumId" order by "TrackId")` to get the rank of each track, and use it to filter the data. 223 | 224 | 225 | ```elixir 226 | query = 227 | from artist in Artist, 228 | join: album in assoc(artist, :albums), 229 | join: track in assoc(album, :tracks), 230 | 231 | join: top_artist in subquery( 232 | from Artist, 233 | order_by: [:artist_id], 234 | limit: 10, 235 | select: [:artist_id] 236 | ), 237 | on: artist.artist_id == top_artist.artist_id, 238 | 239 | join: top_album in subquery( 240 | from a in Album, 241 | windows: [artist_partition: [partition_by: :artist_id, order_by: :title]], 242 | select: %{album_id: a.album_id, rank: row_number() |> over(:artist_partition)} 243 | ), 244 | on: (album.album_id == top_album.album_id and top_album.rank == 1), 245 | 246 | join: top_track in subquery( 247 | from t in Track, 248 | windows: [album_partition: [partition_by: :album_id, order_by: :name]], 249 | select: %{track_id: t.track_id, rank: row_number() |> over(:album_partition)} 250 | ), 251 | on: (track.track_id == top_track.track_id and top_track.rank <= 3), 252 | 253 | order_by: [artist.artist_id, album.album_id, track.track_id], 254 | select: artist, 255 | preload: [albums: {album, tracks: track}] 256 | 257 | data = Repo.all(query) 258 | ``` 259 | 260 | ``` 261 | [debug] QUERY OK source="Artist" db=8.6ms idle=1384.9ms 262 | ``` 263 | 264 | Performance isn't as good as the lateral join solution, but maybe we can use windows for the next solution... 265 | 266 | 267 | ## Solution 4: Preload Queries with Window 268 | 269 | Sometimes joins are not ideal for preloads, since the rows returned from the DB now contain columns from all the tables. 270 | We can pull out the preload queries and let ecto fetch the associated data in a separate call. 271 | 272 | ```elixir 273 | album_query = 274 | from album in Album, 275 | join: top_album in subquery( 276 | from a in Album, 277 | windows: [artist_partition: [partition_by: :artist_id, order_by: :title]], 278 | select: %{album_id: a.album_id, rank: row_number() |> over(:artist_partition)} 279 | ), on: (album.album_id == top_album.album_id and top_album.rank == 1), 280 | order_by: [:title], 281 | select: album 282 | 283 | track_query = 284 | from track in Track, 285 | join: top_track in subquery( 286 | from t in Track, 287 | windows: [album_partition: [partition_by: :album_id, order_by: :name]], 288 | select: %{track_id: t.track_id, rank: row_number() |> over(:album_partition)} 289 | ), on: (track.track_id == top_track.track_id and top_track.rank <= 3), 290 | order_by: [:name], 291 | select: track 292 | 293 | query = 294 | from artist in Artist, 295 | order_by: artist.artist_id, 296 | limit: 10, 297 | preload: [albums: ^album_query], 298 | preload: [albums: [tracks: ^track_query]] 299 | 300 | data = Repo.all(query) 301 | ``` 302 | 303 | Maybe we can even make a helper function to build the window query? 304 | 305 | ```elixir 306 | defmodule QueryHelper do 307 | import Ecto.Query 308 | 309 | def partition_limit(queryable, opts) when is_atom(queryable) do 310 | partition_limit(from(x in queryable), opts) 311 | end 312 | 313 | def partition_limit(queryable, partition_by: p, order_by: o, limit: l) do 314 | %{from: %{source: {_, schema}}} = queryable 315 | [primary_key] = schema.__schema__(:primary_key) 316 | 317 | ranking_query = 318 | from r in queryable, 319 | select: %{id: field(r, ^primary_key), rank: row_number() |> over(:w)}, 320 | windows: [w: [partition_by: ^p, order_by: ^o]] 321 | 322 | from row in schema, 323 | join: top_rows in subquery(ranking_query), 324 | on: (field(row, ^primary_key) == top_rows.id and top_rows.rank <= ^l), 325 | select: row 326 | end 327 | end 328 | 329 | query = 330 | from artist in Artist, 331 | order_by: artist.artist_id, 332 | limit: 10, 333 | select: artist, 334 | preload: [albums: ^QueryHelper.partition_limit(Album, partition_by: :artist_id, order_by: :title, limit: 1)], 335 | preload: [albums: [tracks: ^QueryHelper.partition_limit(Track, partition_by: :album_id, order_by: :name, limit: 3)]] 336 | 337 | Repo.all(query) 338 | ``` 339 | 340 | How does it perform? 341 | 342 | ``` 343 | [debug] QUERY OK source="Artist" db=2.2ms idle=1513.9ms 344 | [debug] QUERY OK source="Album" db=6.9ms idle=1473.7ms 345 | [debug] QUERY OK source="Track" db=7.3ms idle=1478.2ms 346 | ``` 347 | 348 | Not as good as the joins, but it's nice to have a generic helper for quick queries. 349 | 350 | 351 | ## Solution 5: Preload Query with lateral join 352 | 353 | We can also use lateral joins again with preload queries. 354 | The trick here is to start the query with the child schema first, 355 | then join to the parent schema, then laterally to get the top N row ids. 356 | 357 | 358 | ```elixir 359 | album_query = 360 | from album in Album, as: :album, 361 | inner_lateral_join: top_album in subquery( 362 | from Album, 363 | where: [artist_id: parent_as(:album).artist_id], 364 | order_by: :title, 365 | limit: 1, 366 | select: [:album_id] 367 | ), on: album.album_id == top_album.album_id 368 | 369 | track_query = 370 | from track in Track, as: :track, 371 | inner_lateral_join: top_track in subquery( 372 | from Track, 373 | where: [album_id: parent_as(:track).album_id], 374 | order_by: :name, 375 | limit: 3, 376 | select: [:track_id] 377 | ), on: (track.track_id == top_track.track_id) 378 | 379 | query = 380 | from artist in Artist, 381 | order_by: artist.artist_id, 382 | limit: 10, 383 | select: artist, 384 | preload: [albums: ^album_query], 385 | preload: [albums: [tracks: ^track_query]] 386 | 387 | data = Repo.all(query) 388 | ``` 389 | 390 | 391 | How does it perform? 392 | 393 | ``` 394 | [debug] QUERY OK source="Artist" db=4.2ms idle=1482.5ms 395 | [debug] QUERY OK source="Album" db=2.1ms idle=1073.3ms 396 | [debug] QUERY OK source="Track" db=3.1ms idle=1071.6ms 397 | ``` 398 | 399 | Slightly better than the window function. 400 | 401 | 402 | ## Solution 6: Preload Functions 403 | 404 | While preload queries can work well, there's another approach to take using lateral joins and CTEs: 405 | 406 | ```elixir 407 | defmodule Preloads do 408 | alias Chinook.{Artist, Album, Track, Repo} 409 | 410 | def albums_for_artist(order_by: order_by, limit: limit) do 411 | fn artist_ids -> 412 | cte_query = 413 | "artist" 414 | |> with_cte("artist", as: fragment("select unnest(? :: int[]) as artist_id", ^artist_ids)) 415 | 416 | query = 417 | from artist in cte_query, as: :artist, 418 | inner_lateral_join: album in subquery( 419 | from a in Album, 420 | where: a.artist_id == parent_as(:artist).artist_id, 421 | order_by: ^order_by, 422 | limit: ^limit, 423 | select: a 424 | ), 425 | select: album 426 | 427 | Repo.all(query) 428 | end 429 | end 430 | 431 | def longest_tracks_per_album(limit: limit) do 432 | fn album_ids -> 433 | Repo.query!( 434 | """ 435 | SELECT track.* 436 | FROM unnest($1::int[]) as album(album_id) 437 | LEFT JOIN LATERAL ( 438 | SELECT * 439 | FROM "Track" 440 | WHERE "AlbumId" = album.album_id 441 | ORDER BY "Name" DESC 442 | LIMIT $2) track ON true 443 | """, 444 | [album_ids, limit] 445 | ) 446 | |> case do 447 | %{rows: rows, columns: cols} -> Enum.map(rows, &Repo.load(Track, {cols, &1})) 448 | end 449 | end 450 | end 451 | end 452 | 453 | query = 454 | from artist in Artist, 455 | order_by: artist.artist_id, 456 | limit: 10, 457 | select: artist, 458 | preload: [albums: ^Preloads.albums_for_artist(order_by: :title, limit: 1)], 459 | preload: [albums: [tracks: ^Preloads.tracks_for_album(order_by: :name, limit: 3)]] 460 | 461 | Repo.all(query) 462 | 463 | 464 | cte_query = 465 | from a in Artist, 466 | where: a.artist_id < 10, 467 | select: a 468 | 469 | query = 470 | Album 471 | |> with_cte("artist", as: ^cte_query) 472 | |> join(:inner, [album], a in "artist", on: album.artist_id == a.artist_id) 473 | |> select([album, artist], %{title: album.title, name: artist.name}) 474 | 475 | ``` 476 | 477 | ``` 478 | [debug] QUERY OK source="Artist" db=2.9ms idle=872.6ms 479 | [debug] QUERY OK db=2.0ms queue=2.3ms idle=875.8ms 480 | [debug] QUERY OK db=2.2ms queue=1.8ms idle=880.3ms 481 | ``` 482 | Each query runs very fast, and altogether under 10 ms. 483 | 484 | ## Conclusion 485 | 486 | While the Ecto docs don't tell us exactly how to solve the preload-limit problem*, there are several approaches within the Ecto DSL. 487 | For good performance, using lateral joins with named bindings is the way to go. 488 | If joins are problematic, preload functions using CTEs and lateral joins also gives good performance. 489 | If you need a stand-alone ranking query, then window functions work well, but probably shouldn't be the first option. 490 | --------------------------------------------------------------------------------