├── test ├── test_helper.exs ├── db │ ├── docker-compose.yml │ ├── 01-dummy-data.sql │ └── 00-schema.sql └── postgrestex_test.exs ├── .formatter.exs ├── .gitignore ├── .github └── workflows │ └── action.yml ├── mix.exs ├── CONTRIBUTING.md ├── README.md ├── LICENSE └── lib └── postgrestex.ex /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/db/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | 3 | version: '3' 4 | services: 5 | rest: 6 | image: postgrest/postgrest:v7.0.1 7 | ports: 8 | - '3000:3000' 9 | environment: 10 | PGRST_DB_URI: postgres://postgres:postgres@db:5432/postgres 11 | PGRST_DB_SCHEMA: public, personal 12 | PGRST_DB_ANON_ROLE: postgres 13 | depends_on: 14 | - db 15 | db: 16 | image: postgres:12 17 | ports: 18 | - '5432:5432' 19 | volumes: 20 | - .:/docker-entrypoint-initdb.d/ 21 | environment: 22 | POSTGRES_DB: postgres 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: postgres 25 | POSTGRES_PORT: 5432 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | postgrestex-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /.github/workflows/action.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: erlef/setup-elixir@v1.7 9 | with: 10 | otp-version: '22.2' 11 | elixir-version: '1.11' 12 | - name: Login to Docker Hub 13 | uses: docker/login-action@v1 14 | with: 15 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 16 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 17 | - name: Build Docker image 18 | run: | 19 | cd test/db 20 | docker-compose down 21 | docker-compose up -d 22 | - name: Sleep for 5 seconds 23 | uses: jakejarvis/wait-action@master 24 | with: 25 | time: "5s" 26 | - run: mix deps.get 27 | - run: mix test 28 | -------------------------------------------------------------------------------- /test/db/01-dummy-data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO 2 | public.users (username, status, age_range, catchphrase) 3 | VALUES 4 | ('supabot', 'ONLINE', '[1,2)'::int4range, 'fat cat'::tsvector), 5 | ('kiwicopple', 'OFFLINE', '[25,35)'::int4range, 'cat bat'::tsvector), 6 | ('awailas', 'ONLINE', '[25,35)'::int4range, 'bat rat'::tsvector), 7 | ('dragarcia', 'ONLINE', '[20,30)'::int4range, 'rat fat'::tsvector); 8 | 9 | INSERT INTO 10 | public.channels (slug) 11 | VALUES 12 | ('public'), 13 | ('random'); 14 | 15 | INSERT INTO 16 | public.messages (message, channel_id, username) 17 | VALUES 18 | ('Hello World 👋', 1, 'supabot'), 19 | ('Perfection is attained, not when there is nothing more to add, but when there is nothing left to take away.', 2, 'supabot'); 20 | 21 | INSERT INTO 22 | personal.users (username, status, age_range) 23 | VALUES 24 | ('supabot', 'ONLINE', '[1,2)'::int4range), 25 | ('kiwicopple', 'OFFLINE', '[25,35)'::int4range), 26 | ('awailas', 'ONLINE', '[25,35)'::int4range), 27 | ('dragarcia', 'ONLINE', '[20,30)'::int4range), 28 | ('leroyjenkins', 'ONLINE', '[20,40)'::int4range); -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Postgrestex.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :postgrestex, 7 | version: "0.1.2", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | description: description(), 11 | package: package(), 12 | deps: deps(), 13 | name: "Postgrestex", 14 | source_url: "https://github.com/j0/postgrestex" 15 | ] 16 | end 17 | 18 | # Run "mix help compile.app" to learn about applications. 19 | def application do 20 | [ 21 | extra_applications: [:logger] 22 | ] 23 | end 24 | 25 | defp description() do 26 | "This is a elixir client library for PostgREST, built with the intention of supporting Supabase" 27 | end 28 | 29 | defp package() do 30 | [ 31 | # This option is only needed when you don't want to use the OTP application name 32 | name: "postgrestex", 33 | # These are the default files included in the package 34 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* 35 | ), 36 | licenses: ["Apache-2.0"], 37 | links: %{"GitHub" => "https://github.com/j0/postgrestex"} 38 | ] 39 | end 40 | 41 | # Run "mix help deps" to learn about dependencies. 42 | defp deps do 43 | [ 44 | {:httpoison, "~> 1.7"}, 45 | {:jason, "~> 1.2"}, 46 | 47 | # Dev dependencies 48 | {:ex_doc, "~> 0.13", only: :dev, runtime: false}, 49 | {:dialyxir, "~> 0.5", only: :dev, runtime: false} 50 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 51 | ] 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We highly appreciate feedback and contributions from the community! If you'd like to contribute to this project, please make sure to review and follow the guidelines below. 4 | 5 | ## Code of conduct 6 | 7 | In the interest of fostering an open and welcoming environment, please review and follow our [code of conduct](./CODE_OF_CONDUCT.md). 8 | 9 | ## Code and copy reviews 10 | 11 | All submissions, including submissions by project members, require review. We 12 | use GitHub pull requests for this purpose. Consult 13 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 14 | information on using pull requests. 15 | 16 | ## Report an issue 17 | 18 | Report all issues through [GitHub Issues](./issues). 19 | 20 | ## File a feature request 21 | 22 | File your feature request through [GitHub Issues](./issues). 23 | 24 | ## Create a pull request 25 | 26 | When making pull requests to the repository, make sure to follow these guidelines for both bug fixes and new features: 27 | 28 | - Before creating a pull request, file a GitHub Issue so that maintainers and the community can discuss the problem and potential solutions before you spend time on an implementation. 29 | - In your PR's description, link to any related issues or pull requests to give reviewers the full context of your change. 30 | - For commit messages, follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format. 31 | - For example, if you update documentation for a specific extension, your commit message might be: `docs(extension-name) updated installation documentation`. 32 | diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Postgrestex 2 | 3 | ## Maintenance mode 4 | 5 | This repository is moving into maintenance mode. Please use the new [Postgrest-ex](https://github.com/supabase-community/postgrest-ex) library. We will archive the hex.pm library as well. Thank you for your usage over the past few years. 6 | 7 | **Status: POC** 8 | 9 | Elixir Postgrestex library for Postgrest. The design mirrors that of [postgrest-py](https://github.com/supabase/postgrest-py) 10 | 11 | ## Installation 12 | 13 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 14 | by adding `postgrestex` to your list of dependencies in `mix.exs`: 15 | 16 | ```elixir 17 | def deps do 18 | [ 19 | {:postgrestex, "~> 0.1.2"} 20 | ] 21 | end 22 | ``` 23 | 24 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 25 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 26 | be found at [https://hexdocs.pm/postgrestex](https://hexdocs.pm/postgrestex). 27 | 28 | ## Getting Started 29 | 30 | ## Initialize and read from a table 31 | 32 | First, `import Postgrestex` 33 | 34 | Then do any one of the following options: 35 | 36 | ### Create 37 | 38 | Example usage: 39 | 40 | ``` 41 | init("public") \ 42 | |> from("users") \ 43 | |> insert( 44 | %{username: "nevergonna", age_range: "[1,2)", status: "ONLINE", catchphrase: "giveyouup"}, 45 | false 46 | ) \ 47 | |> call() 48 | ``` 49 | 50 | ### Read 51 | 52 | Example usage: 53 | 54 | ``` 55 | init("public") \ 56 | |> from("messages") \ 57 | |> select(["id", "username"]) \ 58 | |> call() 59 | ``` 60 | 61 | ### Update 62 | 63 | Example usage: 64 | 65 | ``` 66 | init("public") \ 67 | |> from("users") \ 68 | |> eq("username", "supabot") \ 69 | |> update(%{status: "OFFLINE"}) \ 70 | |> call() 71 | ``` 72 | 73 | ### Delete 74 | 75 | Example usage: 76 | 77 | ``` 78 | init("public") \ 79 | |> from("users") \ 80 | |> eq("username", "nevergonna") \ 81 | |> eq("status", "ONLINE") \ 82 | |> delete() \ 83 | |> call() 84 | ``` 85 | 86 | ## Testing 87 | 88 | Run `mix test` 89 | -------------------------------------------------------------------------------- /test/db/00-schema.sql: -------------------------------------------------------------------------------- 1 | -- Create the Replication publication 2 | CREATE PUBLICATION supabase_realtime FOR ALL TABLES; 3 | 4 | -- Create a second schema 5 | CREATE SCHEMA personal; 6 | 7 | -- USERS 8 | CREATE TYPE public.user_status AS ENUM ('ONLINE', 'OFFLINE'); 9 | CREATE TABLE public.users ( 10 | username text primary key, 11 | data jsonb DEFAULT null, 12 | age_range int4range DEFAULT null, 13 | status user_status DEFAULT 'ONLINE'::public.user_status, 14 | catchphrase tsvector DEFAULT null 15 | ); 16 | ALTER TABLE public.users REPLICA IDENTITY FULL; -- Send "previous data" to supabase 17 | COMMENT ON COLUMN public.users.data IS 'For unstructured data and prototyping.'; 18 | 19 | -- CHANNELS 20 | CREATE TABLE public.channels ( 21 | id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 22 | data jsonb DEFAULT null, 23 | slug text 24 | ); 25 | ALTER TABLE public.users REPLICA IDENTITY FULL; -- Send "previous data" to supabase 26 | COMMENT ON COLUMN public.channels.data IS 'For unstructured data and prototyping.'; 27 | 28 | -- MESSAGES 29 | CREATE TABLE public.messages ( 30 | id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 31 | data jsonb DEFAULT null, 32 | message text, 33 | username text REFERENCES users NOT NULL, 34 | channel_id bigint REFERENCES channels NOT NULL 35 | ); 36 | ALTER TABLE public.messages REPLICA IDENTITY FULL; -- Send "previous data" to supabase 37 | COMMENT ON COLUMN public.messages.data IS 'For unstructured data and prototyping.'; 38 | 39 | -- STORED FUNCTION 40 | CREATE FUNCTION public.get_status(name_param text) 41 | RETURNS user_status AS $$ 42 | SELECT status from users WHERE username=name_param; 43 | $$ LANGUAGE SQL IMMUTABLE; 44 | 45 | CREATE FUNCTION public.get_username_and_status(name_param text) 46 | RETURNS TABLE(username text, status user_status) AS $$ 47 | SELECT username, status from users WHERE username=name_param; 48 | $$ LANGUAGE SQL IMMUTABLE; 49 | 50 | -- SECOND SCHEMA USERS 51 | CREATE TYPE personal.user_status AS ENUM ('ONLINE', 'OFFLINE'); 52 | CREATE TABLE personal.users( 53 | username text primary key, 54 | data jsonb DEFAULT null, 55 | age_range int4range DEFAULT null, 56 | status user_status DEFAULT 'ONLINE'::public.user_status 57 | ); 58 | 59 | -- SECOND SCHEMA STORED FUNCTION 60 | CREATE FUNCTION personal.get_status(name_param text) 61 | RETURNS user_status AS $$ 62 | SELECT status from users WHERE username=name_param; 63 | $$ LANGUAGE SQL IMMUTABLE; 64 | -------------------------------------------------------------------------------- /test/postgrestex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PostgrestexTest do 2 | use ExUnit.Case 3 | doctest Postgrestex 4 | import Postgrestex 5 | 6 | test "constructor creates default headers" do 7 | session_headers = MapSet.new(init("public").headers) 8 | 9 | default_headers = 10 | MapSet.new(%{ 11 | Accept: "application/json", 12 | "Content-Type": "application/json", 13 | "Accept-Profile": "public", 14 | "Content-Profile": "public" 15 | }) 16 | 17 | assert MapSet.subset?(default_headers, session_headers) 18 | end 19 | 20 | test "create query" do 21 | {:ok, resp} = 22 | init("public") 23 | |> from("users") 24 | |> insert( 25 | %{username: "nevergonna", age_range: "[1,2)", status: "ONLINE", catchphrase: "giveyouup"}, 26 | false 27 | ) 28 | |> call() 29 | 30 | assert(resp.request.body =~ "nevergonna") 31 | end 32 | 33 | test "create query returning" do 34 | {:ok, resp} = 35 | init("public") 36 | |> from("users") 37 | |> insert( 38 | %{username: "new user", age_range: "[1,2)", status: "ONLINE", catchphrase: "giveyouup"}, 39 | false, 40 | returning: true 41 | ) 42 | |> call() 43 | 44 | assert(resp.body =~ "new user") 45 | assert(resp.status_code == 201) 46 | 47 | on_exit(fn -> 48 | init("public") |> from("users") |> delete() |> eq("username", "new user") |> call() 49 | end) 50 | end 51 | 52 | test "read query" do 53 | {:ok, %HTTPoison.Response{status_code: status_code, body: body}} = 54 | init("public") |> from("messages") |> select(["id", "username"]) |> call() 55 | 56 | [row | _rest] = Jason.decode!(body, keys: :atoms) 57 | assert(status_code == 200) 58 | assert(Map.keys(row) |> Enum.sort() == [:id, :username]) 59 | end 60 | 61 | test "multivalued params work" do 62 | {:ok, resp} = init("public") |> from("messages") |> lte("id", "1") |> gte("id", "1") |> call() 63 | assert(resp.status_code == 200 && resp.request.params == [{"id", "gte.1"}, {"id", "lte.1"}]) 64 | end 65 | 66 | test "update query" do 67 | init("public") 68 | |> from("users") 69 | |> eq("username", "dragarcia") 70 | |> update(%{username: "supabase"}) 71 | |> call() 72 | 73 | {:ok, resp} = init("public") |> from("users") |> select(["status", "username"]) |> call() 74 | assert(resp.body =~ "supabase") 75 | end 76 | 77 | test "delete query" do 78 | {:ok, resp} = 79 | init("public") 80 | |> from("users") 81 | |> eq("username", "awailas") 82 | |> eq("status", "ONLINE") 83 | |> delete() 84 | |> call() 85 | 86 | assert(resp.status_code == 204) 87 | end 88 | 89 | test "delete returns row" do 90 | {:ok, %HTTPoison.Response{status_code: status_code, body: body}} = 91 | init("public") 92 | |> from("users") 93 | |> eq("username", "nevergonna") 94 | |> delete(returning: true) 95 | |> call() 96 | 97 | assert(status_code == 200) 98 | body = Jason.decode!(body, keys: :atoms) 99 | assert(length(body) == 1) 100 | [user] = body 101 | assert(user.username == "nevergonna") 102 | end 103 | 104 | test "update headers inserts a header" do 105 | assert(update_headers(init("api"), %{new_header: "header"}).headers.new_header == "header") 106 | end 107 | 108 | describe "Test schema change" do 109 | req = init("public") 110 | session = schema(req, "private") 111 | session_headers = MapSet.new(session.headers) 112 | 113 | subheaders = 114 | MapSet.new(%{ 115 | "Accept-Profile": "private", 116 | "Content-Profile": "private" 117 | }) 118 | 119 | assert(MapSet.subset?(subheaders, session_headers)) 120 | end 121 | 122 | test "Test Upsert after regular insertion" do 123 | init("public") 124 | |> from("users") 125 | |> insert( 126 | %{username: "nevergonna", age_range: "[1,2)", status: "ONLINE", catchphrase: "giveyouup"}, 127 | false 128 | ) 129 | |> call() 130 | 131 | {:ok, resp} = 132 | init("public") 133 | |> from("users") 134 | |> insert( 135 | %{ 136 | username: "nevergonna", 137 | age_range: "[1,2)", 138 | status: "ONLINE", 139 | catchphrase: "giveyouout" 140 | }, 141 | true 142 | ) 143 | |> call() 144 | 145 | assert(resp.request.body =~ "giveyouout") 146 | assert(resp.status_code == 201) 147 | end 148 | 149 | test "Test Update" do 150 | req = 151 | init("public") 152 | |> from("users") 153 | |> eq("username", "supabot") 154 | |> update(%{status: "OFFLINE"}) 155 | 156 | assert(req.params == [{"username", "eq.supabot"}]) 157 | assert(req.method == "PATCH") 158 | assert(req.headers[:Prefer] == "return=representation") 159 | end 160 | 161 | describe "Authentication tests" do 162 | test "test auth with json web token" do 163 | req = init("public") |> auth("t0ps3cr3t") 164 | assert(req.headers[:Authorization] == "Bearer t0ps3cr3t") 165 | end 166 | 167 | test "test auth basic" do 168 | req = init("public") |> auth(nil, "admin", "t0ps3cr3t") 169 | assert(req.options == [hackney: [basic_auth: {"admin", "t0ps3cr3t"}]]) 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Supabase 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /lib/postgrestex.ex: -------------------------------------------------------------------------------- 1 | defmodule Postgrestex do 2 | @moduledoc """ 3 | `Postgrestex` is a client library which provides elixir bindings to interact with PostgREST. PostgREST in turn 4 | is a standalone web server that turns your PostgreSQL database directly into a RESTful API. 5 | """ 6 | @moduledoc since: "0.1.0" 7 | 8 | defmodule NoMethodException do 9 | defexception message: "No method found!" 10 | end 11 | 12 | @doc """ 13 | Creates an initial request in the form of a map that the user can work with. 14 | """ 15 | @doc since: "0.1.0" 16 | @spec init(map(), String.t()) :: map() 17 | def init(schema, path \\ "http://localhost:3000") do 18 | %{ 19 | headers: %{ 20 | Accept: "application/json", 21 | "Content-Type": "application/json", 22 | "Accept-Profile": schema, 23 | "Content-Profile": schema 24 | }, 25 | path: path, 26 | schema: schema, 27 | method: "GET", 28 | negate_next: false, 29 | body: %{}, 30 | params: [] 31 | } 32 | end 33 | 34 | @doc """ 35 | Authenticate the client with either the bearer token or basic authentication. 36 | """ 37 | @spec auth(map(), String.t(), String.t(), String.t()) :: map() 38 | def auth(req, token, username \\ "", password \\ "") do 39 | if username != "" do 40 | Map.merge( 41 | req, 42 | %{options: [hackney: [basic_auth: {username, password}]]} 43 | ) 44 | else 45 | update_headers(req, %{Authorization: "Bearer #{token}"}) 46 | end 47 | end 48 | 49 | @doc """ 50 | Switch to another schema. 51 | """ 52 | @spec schema(map(), String.t()) :: map() 53 | def schema(req, schema) do 54 | update_headers(req, %{"Accept-Profile": schema, "Content-Profile": schema}) 55 | |> Map.merge(%{schema: schema, method: "GET"}) 56 | end 57 | 58 | @doc """ 59 | Select table to obtain data from/perform operations on 60 | """ 61 | @spec from(map(), String.t()) :: map() 62 | def from(req, table) do 63 | Map.merge(req, %{path: "#{req.path}/#{table}"}) 64 | end 65 | 66 | @doc """ 67 | Execute a Stored Procedure Call 68 | """ 69 | @doc since: "0.1.0" 70 | @spec rpc(map(), String.t(), map()) :: map() 71 | def rpc(req, func, params) do 72 | Map.merge(req, %{path: "#{req.path}/#{func}", body: params, method: "POST"}) 73 | end 74 | 75 | @doc """ 76 | Take in and execute a request. Doesn't return an exception if an error is thrown. 77 | """ 78 | @doc since: "0.1.0" 79 | @spec call(map()) :: 80 | {:ok, HTTPoison.Response.t() | HTTPoison.AsyncResponse.t()} 81 | | {:error, HTTPoison.Error.t()} 82 | def call(req) do 83 | url = req.path 84 | headers = req.headers 85 | body = Jason.encode!(Map.get(req, :body, %{})) 86 | params = Map.get(req, :params, []) 87 | options = Map.get(req, :options, []) 88 | 89 | Task.async(fn -> 90 | case req.method do 91 | "POST" -> HTTPoison.post(url, body, headers, params: params, options: options) 92 | "GET" -> HTTPoison.get(url, headers, params: params, options: options) 93 | "PATCH" -> HTTPoison.patch(url, body, headers, params: params, options: options) 94 | "DELETE" -> HTTPoison.delete(url, headers, params: params, options: options) 95 | end 96 | end) 97 | |> Task.await() 98 | end 99 | 100 | @doc """ 101 | Take in and execute a request. Raises an exception if an error occurs. 102 | """ 103 | @doc since: "0.1.0" 104 | @spec call!(map()) :: 105 | {:ok, HTTPoison.Response.t() | HTTPoison.AsyncResponse.t()} | NoMethodException 106 | def call!(req) do 107 | url = req.path 108 | headers = req.headers 109 | body = Jason.encode!(Map.get(req, :body, %{})) 110 | params = Map.get(req, :params, []) 111 | options = Map.get(req, :options, []) 112 | 113 | Task.async(fn -> 114 | case req.method do 115 | "POST" -> 116 | HTTPoison.post!(url, body, headers, params: params, options: options) 117 | 118 | "GET" -> 119 | HTTPoison.get!(url, headers, params: params, options: options) 120 | 121 | "PATCH" -> 122 | HTTPoison.patch!(url, body, headers, params: params, options: options) 123 | 124 | "DELETE" -> 125 | HTTPoison.delete!(url, headers, params: params, options: options) 126 | 127 | _ -> 128 | raise NoMethodException 129 | end 130 | end) 131 | |> Task.await() 132 | end 133 | 134 | @spec select(map(), list()) :: map() 135 | def select(req, columns) do 136 | %{req | params: [{:select, Enum.join(columns, ",")} | req.params], method: "GET"} 137 | end 138 | 139 | @doc """ 140 | Insert a row into currently selected table. Does an insert and update if upsert is set to True 141 | """ 142 | @doc since: "0.1.0" 143 | def insert(req, json, options) when is_list(options), 144 | do: insert(req, json, false, options) 145 | 146 | def insert(req, json, upsert) when is_boolean(upsert), 147 | do: insert(req, json, upsert, []) 148 | 149 | @spec insert(map(), map(), true | false, keyword()) :: map() 150 | def insert(req, json, upsert \\ false, options \\ []) do 151 | prefer_option = if upsert, do: ",resolution=merge-duplicates", else: "" 152 | 153 | prefer_option = 154 | if Keyword.get(options, :returning, false), 155 | do: "return=representation#{prefer_option}", 156 | else: prefer_option 157 | 158 | headers = update_headers(req, %{Prefer: prefer_option}) 159 | req |> Map.merge(headers) |> Map.merge(%{body: json, method: "POST"}) 160 | end 161 | 162 | @doc """ 163 | Update an existing value in the currently selected table. 164 | """ 165 | @doc since: "0.1.0" 166 | @spec update(map(), map()) :: map() 167 | def update(req, json) do 168 | update_headers(req, %{Prefer: "return=representation"}) 169 | |> Map.merge(%{method: "PATCH", body: json}) 170 | end 171 | 172 | @doc """ 173 | Delete an existing value in the currently selected table. 174 | """ 175 | @doc since: "0.1.0" 176 | @spec delete(map(), keyword()) :: map() 177 | def delete(req, options \\ []) do 178 | apply_returning(req, options) 179 | |> Map.merge(%{method: "DELETE"}) 180 | end 181 | 182 | @spec order(map(), String.t(), true | false, true | false) :: map() 183 | def order(req, column, desc \\ false, nullsfirst \\ false) do 184 | desc = if desc, do: ".desc", else: "" 185 | nullsfirst = if nullsfirst, do: ".nullsfirst", else: "" 186 | update_headers(req, %{order: "#{column} #{desc} #{nullsfirst}"}) 187 | end 188 | 189 | @spec limit(map(), integer(), integer()) :: map() 190 | def limit(req, size, start) do 191 | update_headers(req, %{Range: "#{start}-#{start + size - 1}", "Range-Unit": "items"}) 192 | end 193 | 194 | @spec range(map(), integer(), integer()) :: map() 195 | def range(req, start, end_) do 196 | update_headers(req, %{Range: "#{start}-#{end_ - 1}", "Range-Unit": "items"}) 197 | end 198 | 199 | @spec single(map()) :: map() 200 | def single(req) do 201 | # Modify this to use a session header 202 | update_headers(req, %{Accept: "application/vnd.pgrst.object+json"}) 203 | end 204 | 205 | @doc """ 206 | Remove reserved characters from the parameter string. 207 | """ 208 | @doc since: "0.1.0" 209 | @spec sanitize_params(String.t()) :: String.t() 210 | def sanitize_params(str) do 211 | reserved_chars = String.graphemes(",.:()") 212 | if String.contains?(str, reserved_chars), do: str, else: "#{str}" 213 | end 214 | 215 | @spec sanitize_pattern_params(String.t()) :: String.t() 216 | def sanitize_pattern_params(str) do 217 | str |> String.replace("%", "*") 218 | end 219 | 220 | @doc """ 221 | Either filter in or filter out based on negate_next. 222 | """ 223 | @doc since: "0.1.0" 224 | @spec filter(map(), String.t(), String.t(), String.t()) :: map() 225 | def filter(req, column, operator, criteria) do 226 | {req, operator} = 227 | if req.negate_next do 228 | {Map.update!(req, :negate_next, fn negate_next -> !negate_next end), "not.#{operator}"} 229 | else 230 | {req, operator} 231 | end 232 | 233 | val = "#{operator}.#{criteria}" 234 | key = sanitize_params(column) 235 | Kernel.put_in(req[:params], [{key, val} | req[:params]]) 236 | end 237 | 238 | @doc """ 239 | Toggle between filtering in or filtering out. 240 | """ 241 | @doc since: "0.1.0" 242 | @spec not map() :: map() 243 | def not req do 244 | Map.merge(req, %{negate_next: true}) 245 | end 246 | 247 | @spec eq(map(), String.t(), String.t()) :: map() 248 | def eq(req, column, value) do 249 | filter(req, column, "eq", sanitize_params(value)) 250 | end 251 | 252 | @spec neq(map(), String.t(), String.t()) :: map() 253 | def neq(req, column, value) do 254 | filter(req, column, "neq", sanitize_params(value)) 255 | end 256 | 257 | @spec gt(map(), String.t(), String.t()) :: map() 258 | def gt(req, column, value) do 259 | filter(req, column, "gt", sanitize_params(value)) 260 | end 261 | 262 | @spec lt(map(), String.t(), String.t()) :: map() 263 | def lt(req, column, value) do 264 | filter(req, column, "lt", sanitize_params(value)) 265 | end 266 | 267 | @spec lte(map(), String.t(), String.t()) :: map() 268 | def lte(req, column, value) do 269 | filter(req, column, "lte", sanitize_params(value)) 270 | end 271 | 272 | @spec gte(map(), String.t(), String.t()) :: map() 273 | def gte(req, column, value) do 274 | filter(req, column, "gte", sanitize_params(value)) 275 | end 276 | 277 | @spec is_(map(), String.t(), String.t()) :: map() 278 | def is_(req, column, value) do 279 | filter(req, column, "is", sanitize_params(value)) 280 | end 281 | 282 | @spec like(map(), String.t(), String.t()) :: map() 283 | def like(req, column, pattern) do 284 | filter(req, column, "like", sanitize_pattern_params(pattern)) 285 | end 286 | 287 | @spec ilike(map(), String.t(), String.t()) :: map() 288 | def ilike(req, column, pattern) do 289 | filter(req, column, "is", sanitize_params(pattern)) 290 | end 291 | 292 | @spec fts(map(), String.t(), String.t()) :: map() 293 | def fts(req, column, query) do 294 | filter(req, column, "fts", sanitize_params(query)) 295 | end 296 | 297 | @spec plfts(map(), String.t(), String.t()) :: map() 298 | def plfts(req, column, query) do 299 | filter(req, column, "plfts", sanitize_params(query)) 300 | end 301 | 302 | @spec phfts(map(), String.t(), String.t()) :: map() 303 | def phfts(req, column, query) do 304 | filter(req, column, "phfts", sanitize_params(query)) 305 | end 306 | 307 | @spec wfts(map(), String.t(), String.t()) :: map() 308 | def wfts(req, column, query) do 309 | filter(req, column, "wfts", sanitize_params(query)) 310 | end 311 | 312 | def in_(req, column, values) do 313 | values = Enum.map(fn param -> sanitize_params(param) end, values) |> Enum.join(",") 314 | filter(req, column, "in", "(#{values})") 315 | end 316 | 317 | def cs(req, column, values) do 318 | values = Enum.map(fn param -> sanitize_params(param) end, values) |> Enum.join(",") 319 | filter(req, column, "cs", "{#{values}}") 320 | end 321 | 322 | def cd(req, column, values) do 323 | values = Enum.map(fn param -> sanitize_params(param) end, values) |> Enum.join(",") 324 | filter(req, column, "cd", "{#{values}}") 325 | end 326 | 327 | def ov(req, column, values) do 328 | values = Enum.map(fn param -> sanitize_params(param) end, values) |> Enum.join(",") 329 | filter(req, column, "ov", "{#{values}}") 330 | end 331 | 332 | @spec sl(map(), String.t(), integer()) :: map() 333 | def sl(req, column, range) do 334 | filter(req, column, "sl", "(#{Enum.at(range, 0)},#{Enum.at(range, 1)})") 335 | end 336 | 337 | @spec sr(map(), String.t(), integer()) :: map() 338 | def sr(req, column, range) do 339 | filter(req, column, "sr", "(#{Enum.at(range, 0)},#{Enum.at(range, 1)})") 340 | end 341 | 342 | @spec nxl(map(), String.t(), integer()) :: map() 343 | def nxl(req, column, range) do 344 | filter(req, column, "nxl", "(#{Enum.at(range, 0)},#{Enum.at(range, 1)})") 345 | end 346 | 347 | @spec nxr(map(), String.t(), integer()) :: map() 348 | def nxr(req, column, range) do 349 | filter(req, column, "nxr", "(#{Enum.at(range, 0)},#{Enum.at(range, 1)})") 350 | end 351 | 352 | @spec adj(map(), String.t(), integer()) :: map() 353 | def adj(req, column, range) do 354 | filter(req, column, "adj", "(#{Enum.at(range, 0)},#{Enum.at(range, 1)})") 355 | end 356 | 357 | @spec update_headers(map(), map()) :: map() 358 | def update_headers(req, updates) do 359 | Kernel.update_in(req.headers, &Map.merge(&1, updates)) 360 | end 361 | 362 | defp apply_returning(req, options) do 363 | if Keyword.get(options, :returning) do 364 | update_headers(req, %{Prefer: "return=representation"}) 365 | else 366 | req 367 | end 368 | end 369 | end 370 | --------------------------------------------------------------------------------