├── .formatter.exs ├── .github ├── FUNDING.yml └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── VERSION ├── coveralls.json ├── guides └── images │ ├── logo.png │ ├── logo.svg │ ├── logo_name.png │ └── your_logo_here.png ├── lib ├── sql_fmt.ex └── sql_fmt │ ├── format_options.ex │ ├── formatter.ex │ ├── helpers.ex │ └── native.ex ├── mix.exs ├── mix.lock ├── native └── sql_fmt_nif │ ├── .cargo │ └── config.toml │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── Cross.toml │ └── src │ └── lib.rs └── test ├── sql_fmt ├── formatter_test.exs └── helpers_test.exs ├── sql_fmt_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | line_length: 120, 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [akoutmos] 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: SqlFmt CI 2 | 3 | env: 4 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 5 | SHELL: sh 6 | RUSTLER_FORCE_BUILD: "true" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | branches: [master] 13 | 14 | jobs: 15 | static_analysis: 16 | name: Static Analysis 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v2 22 | - name: Set up Elixir 23 | uses: erlef/setup-beam@v1 24 | with: 25 | elixir-version: "1.17.3" 26 | otp-version: "27.1" 27 | - name: Restore dependencies cache 28 | uses: actions/cache@v4 29 | with: 30 | path: deps 31 | key: ${{ runner.os }}-mix-v2-${{ hashFiles('**/mix.lock') }} 32 | restore-keys: ${{ runner.os }}-mix-v2- 33 | - name: Install dependencies 34 | run: mix deps.get 35 | - name: Restore PLT cache 36 | uses: actions/cache@v4 37 | with: 38 | path: priv/plts 39 | key: ${{ runner.os }}-mix-v2-${{ hashFiles('**/mix.lock') }} 40 | restore-keys: ${{ runner.os }}-mix-v2- 41 | - name: Mix Formatter 42 | run: mix format --check-formatted 43 | - name: Check for compiler warnings 44 | run: mix compile --warnings-as-errors 45 | - name: Credo strict checks 46 | run: mix credo --strict 47 | - name: Doctor documentation checks 48 | run: mix doctor 49 | 50 | unit_test: 51 | name: Run ExUnit tests 52 | runs-on: ubuntu-latest 53 | 54 | strategy: 55 | matrix: 56 | elixir: ["1.17"] 57 | otp: ["25", "26", "27"] 58 | 59 | steps: 60 | - name: Checkout code 61 | uses: actions/checkout@v2 62 | - name: Set up Elixir 63 | uses: erlef/setup-beam@v1 64 | with: 65 | elixir-version: ${{ matrix.elixir }} 66 | otp-version: ${{ matrix.otp }} 67 | - name: Restore dependencies cache 68 | uses: actions/cache@v4 69 | with: 70 | path: deps 71 | key: ${{ runner.os }}-mix-v2-${{ hashFiles('**/mix.lock') }} 72 | restore-keys: ${{ runner.os }}-mix-v2- 73 | - name: Install dependencies 74 | run: mix deps.get 75 | - name: ExUnit tests 76 | run: mix coveralls.github 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build precompiled NIFs 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build_release: 10 | name: NIF ${{ matrix.nif }} - ${{ matrix.job.target }} (${{ matrix.job.os }}) 11 | runs-on: ${{ matrix.job.os }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | nif: ["2.16", "2.15"] 16 | job: 17 | - { 18 | target: arm-unknown-linux-gnueabihf, 19 | os: ubuntu-20.04, 20 | use-cross: true, 21 | } 22 | - { 23 | target: aarch64-unknown-linux-gnu, 24 | os: ubuntu-20.04, 25 | use-cross: true, 26 | } 27 | - { 28 | target: aarch64-unknown-linux-musl, 29 | os: ubuntu-20.04, 30 | use-cross: true, 31 | } 32 | - { target: aarch64-apple-darwin, os: macos-13 } 33 | - { 34 | target: riscv64gc-unknown-linux-gnu, 35 | os: ubuntu-20.04, 36 | use-cross: true, 37 | } 38 | - { target: x86_64-apple-darwin, os: macos-13 } 39 | - { target: x86_64-unknown-linux-gnu, os: ubuntu-20.04 } 40 | - { 41 | target: x86_64-unknown-linux-musl, 42 | os: ubuntu-20.04, 43 | use-cross: true, 44 | } 45 | - { target: x86_64-pc-windows-gnu, os: windows-2019 } 46 | - { target: x86_64-pc-windows-msvc, os: windows-2019 } 47 | 48 | steps: 49 | - name: Checkout source code 50 | uses: actions/checkout@v3 51 | 52 | - name: Extract project version 53 | shell: bash 54 | run: | 55 | # Get the project version from mix.exs 56 | echo "PROJECT_VERSION=$(cat VERSION)" >> $GITHUB_ENV 57 | 58 | - name: Install Rust toolchain 59 | uses: dtolnay/rust-toolchain@stable 60 | with: 61 | toolchain: stable 62 | target: ${{ matrix.job.target }} 63 | 64 | - name: Build the project 65 | id: build-crate 66 | uses: philss/rustler-precompiled-action@v1.0.1 67 | with: 68 | project-name: sql_fmt_nif 69 | project-version: ${{ env.PROJECT_VERSION }} 70 | target: ${{ matrix.job.target }} 71 | nif-version: ${{ matrix.nif }} 72 | use-cross: ${{ matrix.job.use-cross }} 73 | project-dir: "native/sql_fmt_nif" 74 | 75 | - name: Artifact upload 76 | uses: actions/upload-artifact@v4 77 | with: 78 | name: ${{ steps.build-crate.outputs.file-name }} 79 | path: ${{ steps.build-crate.outputs.file-path }} 80 | 81 | - name: Publish archives and packages 82 | uses: softprops/action-gh-release@v1 83 | with: 84 | files: | 85 | ${{ steps.build-crate.outputs.file-path }} 86 | if: startsWith(github.ref, 'refs/tags/') 87 | -------------------------------------------------------------------------------- /.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 | sql_fmt-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Shared objects build by Rust. 29 | *.so 30 | 31 | # Checksum file generated by RustlerPrecompiled 32 | checksum-* 33 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.4.0] - 2025-3-24 11 | 12 | ### Fixed 13 | 14 | - Updated to a newer version of `sqlformat-rs`. 15 | 16 | ## [0.3.0] - 2024-11-04 17 | 18 | ### Fixed 19 | 20 | - Updated to a newer version of `sqlformat-rs` to avoid splitting Postgres operators. 21 | 22 | ## [0.2.0] - 2024-10-08 23 | 24 | ### Added 25 | 26 | - The `~SQL` sigil to format SQL strings in Elixir code [#3](https://github.com/akoutmos/sql_fmt/pull/3). 27 | - A Mix Formatter plugin so that SQL files and `~SQL` sigil statements are formatted via Mix [#3](https://github.com/akoutmos/sql_fmt/pull/3). 28 | 29 | ## [0.1.0] - 2024-10-06 30 | 31 | ### Added 32 | 33 | - Initial release for SqlFmt. 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alexander Koutmos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | sql_fmt Logo 3 | sql_fmt title 4 |

5 | 6 |

7 | Format and pretty print SQL queries 8 |

9 | 10 |

11 | 12 | Hex.pm 13 | 14 | 15 | 16 | GitHub Workflow Status (master) 18 | 19 | 20 | 21 | Coveralls master branch 22 | 23 | 24 | 25 | Support the project 26 | 27 |

28 | 29 |
30 | 31 | # Contents 32 | 33 | - [Installation](#installation) 34 | - [Example Output](#example-output) 35 | - [Mix Formatter](#mix-formatter) 36 | - [Supporting SqlFmt](#supporting-sqlfmt) 37 | - [Attribution](#attribution) 38 | 39 | ## Installation 40 | 41 | [Available in Hex](https://hex.pm/packages/sql_fmt), the package can be installed by adding `sql_fmt` to your list of 42 | dependencies in `mix.exs`: 43 | 44 | ```elixir 45 | def deps do 46 | [ 47 | {:sql_fmt, "~> 0.4.0"} 48 | ] 49 | end 50 | ``` 51 | 52 | Documentation can be found at [https://hexdocs.pm/sql_fmt](https://hexdocs.pm/sql_fmt). 53 | 54 | ## Example Output 55 | 56 | After setting up SqlFmt in your application you can use the SqlFmt functions in order to format queries. Here are a 57 | couple examples of queries with having parameters inline and with passing in the parameters separately: 58 | 59 | ```elixir 60 | iex(1)> {:ok, formatted_sql} = SqlFmt.format_query("select * from businesses where id in ('c6f5c5f1-a1fc-4c9a-91f7-6aa40f1e233d', 'f339d4ce-96b6-4440-a541-28a0fb611139');") 61 | {:ok, "SELECT\n *\nFROM\n businesses\nWHERE\n id IN (\n 'c6f5c5f1-a1fc-4c9a-91f7-6aa40f1e233d',\n 'f339d4ce-96b6-4440-a541-28a0fb611139'\n );"} 62 | 63 | iex(2)> IO.puts(formatted_sql) 64 | SELECT 65 | * 66 | FROM 67 | businesses 68 | WHERE 69 | id IN ( 70 | 'c6f5c5f1-a1fc-4c9a-91f7-6aa40f1e233d', 71 | 'f339d4ce-96b6-4440-a541-28a0fb611139' 72 | ); 73 | :ok 74 | ``` 75 | 76 | ```elixir 77 | iex(1)> {:ok, formatted_sql} = SqlFmt.format_query_with_params("select * from help where help.\"col\" in $1;", ["'asdf'"]) 78 | {:ok, "SELECT\n *\nFROM\n help\nWHERE\n help.\"col\" IN 'asdf';"} 79 | 80 | iex(2)> IO.puts(formatted_sql) 81 | SELECT 82 | * 83 | FROM 84 | help 85 | WHERE 86 | help."col" IN 'asdf'; 87 | :ok 88 | ``` 89 | 90 | Be sure to checkout the HexDocs as you can also provide formatting options to the functions to tailor the output to your 91 | liking. 92 | 93 | ## Mix Formatter 94 | 95 | SqlFmt also provides you with the `~SQL` sigil that can be used to format SQL via Mix Formatter plugin. To set up the 96 | Mix Formatter plugin, simply install this package and add update your `.formatter.exs` file as follows: 97 | 98 | ```elixir 99 | [ 100 | plugins: [SqlFmt.MixFormatter], 101 | inputs: ["**/*.sql"], 102 | # ... 103 | ] 104 | ``` 105 | 106 | With this configuration, the SqlFmt Mix Format plugin will now format all `~SQL` sigils and all files ending in `.sql`. 107 | This can be particularly useful in Ecto migrations where you have large `execute` statements and you want to make sure 108 | that your code is readable. Check out the `SqlFmt.MixFormatter` module docs for more information. 109 | 110 | ## Supporting SqlFmt 111 | 112 | If you rely on this library help you debug your Ecto/SQL queries, it would much appreciated if you can give back 113 | to the project in order to help ensure its continued development. 114 | 115 | Checkout my [GitHub Sponsorship page](https://github.com/sponsors/akoutmos) if you want to help out! 116 | 117 | ### Gold Sponsors 118 | 119 | 120 | Support the project 121 | 122 | 123 | ### Silver Sponsors 124 | 125 | 126 | Support the project 127 | 128 | 129 | ### Bronze Sponsors 130 | 131 | 132 | Support the project 133 | 134 | 135 | ## Attribution 136 | 137 | - The logo for the project is an edited version of an SVG image from the [unDraw project](https://undraw.co/). 138 | - The SqlFmt library leans on the Rust library [sqlformat-rs](https://github.com/shssoichiro/sqlformat-rs) for SQL 139 | statement formatting. 140 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.4.0 2 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/sql_fmt/native.ex" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /guides/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoutmos/sql_fmt/df67df4acb800c2be53594564d8919c7d05ef880/guides/images/logo.png -------------------------------------------------------------------------------- /guides/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | SELECT 11 | 12 | 13 | FROM 14 | 15 | 16 | SQL_FMT 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /guides/images/logo_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoutmos/sql_fmt/df67df4acb800c2be53594564d8919c7d05ef880/guides/images/logo_name.png -------------------------------------------------------------------------------- /guides/images/your_logo_here.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoutmos/sql_fmt/df67df4acb800c2be53594564d8919c7d05ef880/guides/images/your_logo_here.png -------------------------------------------------------------------------------- /lib/sql_fmt.ex: -------------------------------------------------------------------------------- 1 | defmodule SqlFmt do 2 | @moduledoc """ 3 | This library provides an Elixir wrapper around the Rust 4 | [sqlformat](https://github.com/shssoichiro/sqlformat-rs) library. This allows you 5 | to efficiently format and pretty print SQL queries. 6 | """ 7 | 8 | alias SqlFmt.FormatOptions 9 | alias SqlFmt.Native 10 | 11 | @doc """ 12 | This function takes a query as a string along with optional formatting 13 | settings and returns the formatted query. 14 | 15 | For formatting settings look at the `SqlFmt.FormatOptions` module docs. 16 | """ 17 | @spec format_query(query :: String.t()) :: {:ok, String.t()} 18 | @spec format_query(query :: String.t(), fmt_opts :: keyword()) :: {:ok, String.t()} 19 | def format_query(query, fmt_opts \\ []) do 20 | format_options = FormatOptions.new(fmt_opts) 21 | 22 | Native.format(query, format_options) 23 | end 24 | 25 | @doc """ 26 | This function takes a query as a string along with its parameters as a list of strings 27 | and optional formatting settings and returns the formatted query. As a note, when using this 28 | function all of the element in `query_params` must be strings and they all must be 29 | extrapolated out to their appropriate SQL values. For example query params of `["my_id"]` 30 | should be `["'my_id'"]` so that it is valid SQL when the parameter substitution takes place. 31 | 32 | For formatting settings look at the `SqlFmt.FormatOptions` module docs. 33 | """ 34 | @spec format_query_with_params(query :: String.t(), query_params :: list(String.t())) :: {:ok, String.t()} 35 | @spec format_query_with_params(query :: String.t(), query_params :: list(String.t()), fmt_opts :: keyword()) :: 36 | {:ok, String.t()} 37 | def format_query_with_params(query, query_params, fmt_opts \\ []) do 38 | format_options = FormatOptions.new(fmt_opts) 39 | 40 | Native.format(query, query_params, format_options) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/sql_fmt/format_options.ex: -------------------------------------------------------------------------------- 1 | defmodule SqlFmt.FormatOptions do 2 | @moduledoc """ 3 | These options are used to control how the SQL statement is formatted. The 4 | available formatting options are: 5 | 6 | * `:indent` – Specifies how many spaces are used for indents 7 | Defaults to `2`. 8 | 9 | * `:uppercase` – Configures whether SQL reserved words are capitalized. 10 | Defaults to `true`. 11 | 12 | * `:lines_between_queries` – Specifies how many line breaks should be 13 | present after a query. 14 | Defaults to `1`. 15 | 16 | * `:ignore_case_convert` – Configures whether certain strings should 17 | not be case converted. 18 | Defaults to `[]`. 19 | """ 20 | 21 | defstruct indent: 2, 22 | uppercase: true, 23 | lines_between_queries: 1, 24 | ignore_case_convert: [] 25 | 26 | @typedoc "The available formatting options." 27 | @type t :: %__MODULE__{ 28 | indent: non_neg_integer(), 29 | uppercase: boolean(), 30 | lines_between_queries: non_neg_integer(), 31 | ignore_case_convert: list(String.t()) 32 | } 33 | 34 | @doc """ 35 | Create an instance of the `FormatOptions` struct. 36 | """ 37 | @spec new(keyword) :: t 38 | def new(opts \\ []) do 39 | struct!(__MODULE__, opts) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/sql_fmt/formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule SqlFmt.MixFormatter do 2 | @moduledoc """ 3 | Format SQL queries from `.sql` files or `~SQL` sigils. 4 | 5 | This is a `mix format` [plugin](https://hexdocs.pm/mix/main/Mix.Tasks.Format.html#module-plugins). 6 | 7 | ## Setup 8 | 9 | Add it as a plugin to your `.formatter.exs` file. If you want it to run automatically for 10 | all your project's `sql` files make sure to put the proper pattern in the `inputs` option. 11 | 12 | ```elixir 13 | [ 14 | plugins: [SqlFmt.MixFormatter], 15 | inputs: ["**/*.sql"], 16 | # ... 17 | ] 18 | ``` 19 | 20 | ## Options 21 | 22 | The following options are supported. You should specify them under a `:sql_fmt` key in 23 | your `.formatter.exs`. 24 | 25 | * `:indent` - specifies how many spaces are used for indents. Defaults to `2`. 26 | 27 | * `:uppercase` - specifies if the SQL reserved words will be capitalized. Defaults 28 | to `true`. 29 | 30 | * `:lines_between_queries` - specifies how many line breaks should be 31 | present after a query. Applicable only for input containing multiple queries. 32 | Defaults to `1`. 33 | 34 | * `:ignore_case_convert` - comma separated list of strings that should 35 | not be case converted. If not set all reserved keywords are capitalized. 36 | 37 | * `:extensions` - change the default file extensions to be considered. Defaults to 38 | `[".sql"]` 39 | 40 | An example configuration object with all supported options set follows. 41 | 42 | [ 43 | # ...omitted 44 | sql_fmt: [ 45 | indent: 4, 46 | uppercase: true, 47 | lines_between_queries: 2, 48 | ignore_case_convert: ["where", "into"], 49 | extensions: [".sql", ".query"] 50 | ] 51 | ] 52 | 53 | ## Formatting 54 | 55 | The formatter uses `SqlFmt.format_query/2` in order to format the input `SQL` 56 | queries or inline `~SQL` sigils. 57 | 58 | > #### Single line `~SQL` sigils {: .info} 59 | > 60 | > Notice that if an `~SQL` sigil with a single delimiter is used then this is 61 | > respected and only the reserved keyowrds are capitalized. For example: 62 | > 63 | > ```elixir 64 | > query = ~SQL"select * from users" 65 | > ``` 66 | > 67 | > would be formatted as: 68 | > 69 | > ```elixir 70 | > query = ~SQL"SELECT * FROM users" 71 | > ``` 72 | """ 73 | @behaviour Mix.Tasks.Format 74 | 75 | @impl Mix.Tasks.Format 76 | def features(opts) do 77 | extensions = get_in(opts, [:sql_fmt, :extensions]) || [".sql"] 78 | [sigils: [:SQL], extensions: extensions] 79 | end 80 | 81 | @impl Mix.Tasks.Format 82 | def format(source, opts) do 83 | formatter_opts = 84 | opts 85 | |> Keyword.get(:sql_fmt, []) 86 | |> Keyword.drop([:extensions]) 87 | |> Keyword.validate!(indent: 2, uppercase: true, lines_between_queries: 1, ignore_case_convert: []) 88 | 89 | {:ok, formatted} = SqlFmt.format_query(source, formatter_opts) 90 | 91 | # we need some special handling for the sigils: 92 | # 93 | # - If we are handling an SQL sigil and the opening delimiter is a single 94 | # character we should not add a trailing line 95 | # - If the sigil's optening delimiter is a single character we need 96 | # to remove all newlines introduced by the formatter 97 | 98 | formatted 99 | |> maybe_remove_newlines(opts[:sigil], opts[:opening_delimiter]) 100 | |> maybe_add_newline(opts[:sigil], opts[:opening_delimiter]) 101 | end 102 | 103 | defp maybe_remove_newlines(query, :SQL, <<_>>), do: String.replace(query, ~r/\s+/, " ") |> String.trim() 104 | defp maybe_remove_newlines(query, _, _), do: query 105 | 106 | defp maybe_add_newline(query, :SQL, <<_, _, _>>), do: query <> "\n" 107 | defp maybe_add_newline(query, _, _), do: query 108 | end 109 | -------------------------------------------------------------------------------- /lib/sql_fmt/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule SqlFmt.Helpers do 2 | @moduledoc ~S""" 3 | This module contains the `~SQL` sigil implementation. 4 | 5 | You can wrap your inline sql queries with the `~SQL` sigil and the formatter 6 | will format them using the `SqlFmt.format_query/2` function. 7 | 8 | > #### Sigil Caveats {: .info} 9 | > 10 | > Sigils with capitalized letters do not interpolate strings. So your `~SQL` 11 | > queries must be complete queries without any `#{...}` entries as those will 12 | > not be expanded. Take a look at the [Elixir Docs](https://hexdocs.pm/elixir/1.12/Macro.html#module-custom-sigils) 13 | > for more information. 14 | """ 15 | 16 | @doc """ 17 | Indicates that a string is an SQL query. 18 | 19 | This is currently used only by the `SqlFmt.MixFormatter` `mix format` plugin 20 | for formatting inling `SQL` in your elixir code. 21 | """ 22 | def sigil_SQL(query, _modifiers) do 23 | query 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/sql_fmt/native.ex: -------------------------------------------------------------------------------- 1 | defmodule SqlFmt.Native do 2 | @moduledoc false 3 | 4 | version = Mix.Project.config()[:version] 5 | 6 | use RustlerPrecompiled, 7 | otp_app: :sql_fmt, 8 | crate: :sql_fmt_nif, 9 | base_url: "https://github.com/akoutmos/sql_fmt/releases/download/v#{version}", 10 | force_build: System.get_env("RUSTLER_FORCE_BUILD") in ["1", "true"], 11 | targets: Enum.uniq(["aarch64-unknown-linux-musl" | RustlerPrecompiled.Config.default_targets()]), 12 | version: version 13 | 14 | @doc false 15 | def format(_query, _format_options), do: :erlang.nif_error(:nif_not_loaded) 16 | def format(_query, _query_params, _format_options), do: :erlang.nif_error(:nif_not_loaded) 17 | end 18 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SqlFmt.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :sql_fmt, 7 | name: "SqlFmt", 8 | version: project_version(), 9 | elixir: "~> 1.16", 10 | start_permanent: Mix.env() == :prod, 11 | source_url: "https://github.com/akoutmos/sql_fmt", 12 | homepage_url: "https://hex.pm/packages/sql_fmt", 13 | description: "Pretty print SQL queries", 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | test_coverage: [tool: ExCoveralls], 16 | preferred_cli_env: [ 17 | coveralls: :test, 18 | "coveralls.detail": :test, 19 | "coveralls.post": :test, 20 | "coveralls.html": :test, 21 | "coveralls.github": :test 22 | ], 23 | dialyzer: [ 24 | plt_add_apps: [:mix], 25 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"} 26 | ], 27 | package: package(), 28 | deps: deps(), 29 | docs: docs(), 30 | aliases: aliases() 31 | ] 32 | end 33 | 34 | defp project_version do 35 | "VERSION" 36 | |> File.read!() 37 | |> String.trim() 38 | end 39 | 40 | # Run "mix help compile.app" to learn about applications. 41 | def application do 42 | [ 43 | extra_applications: [:logger] 44 | ] 45 | end 46 | 47 | defp elixirc_paths(:test), do: ["lib", "test/support"] 48 | defp elixirc_paths(_), do: ["lib"] 49 | 50 | # Run "mix help deps" to learn about dependencies. 51 | defp deps do 52 | [ 53 | # Production deps 54 | {:rustler_precompiled, "~> 0.4"}, 55 | {:rustler, ">= 0.0.0", optional: true}, 56 | 57 | # Dev deps 58 | {:doctor, "~> 0.21", only: :dev}, 59 | {:ex_doc, "~> 0.34", only: :dev}, 60 | {:credo, "~> 1.7", only: :dev}, 61 | {:dialyxir, "~> 1.4", only: :dev, runtime: false}, 62 | 63 | # Test deps 64 | {:excoveralls, "~> 0.18", only: :test, runtime: false} 65 | ] 66 | end 67 | 68 | defp docs do 69 | [ 70 | main: "readme", 71 | source_ref: "master", 72 | logo: "guides/images/logo.png", 73 | extras: [ 74 | "README.md" 75 | ] 76 | ] 77 | end 78 | 79 | defp package do 80 | [ 81 | name: "sql_fmt", 82 | files: 83 | ~w(lib mix.exs README.md LICENSE CHANGELOG.md native/sql_fmt_nif/.cargo native/sql_fmt_nif/src native/sql_fmt_nif/Cargo.* VERSION checksum-*.exs), 84 | licenses: ["MIT"], 85 | maintainers: ["Alex Koutmos"], 86 | links: %{ 87 | "GitHub" => "https://github.com/akoutmos/sql_fmt", 88 | "Sponsor" => "https://github.com/sponsors/akoutmos" 89 | } 90 | ] 91 | end 92 | 93 | defp aliases do 94 | [ 95 | docs: ["docs", ©_files/1] 96 | ] 97 | end 98 | 99 | defp copy_files(_) do 100 | # Set up directory structure 101 | File.mkdir_p!("./doc/guides/images") 102 | 103 | # Copy over image files 104 | "./guides/images/" 105 | |> File.ls!() 106 | |> Enum.each(fn image_file -> 107 | File.cp!("./guides/images/#{image_file}", "./doc/guides/images/#{image_file}") 108 | end) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, 4 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 5 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 6 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 7 | "doctor": {:hex, :doctor, "0.22.0", "223e1cace1f16a38eda4113a5c435fa9b10d804aa72d3d9f9a71c471cc958fe7", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "96e22cf8c0df2e9777dc55ebaa5798329b9028889c4023fed3305688d902cd5b"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 9 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 10 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 11 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 12 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 13 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 14 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 17 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 18 | "rustler": {:hex, :rustler, "0.36.1", "2d4b1ff57ea2789a44756a40dbb5fbb73c6ee0a13d031dcba96d0a5542598a6a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "f3fba4ad272970e0d1bc62972fc4a99809651e54a125c5242de9bad4574b2d02"}, 19 | "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.2", "5f25cbe220a8fac3e7ad62e6f950fcdca5a5a5f8501835d2823e8c74bf4268d5", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "63d1bd5f8e23096d1ff851839923162096364bac8656a4a3c00d1fff8e83ee0a"}, 20 | "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, 21 | } 22 | -------------------------------------------------------------------------------- /native/sql_fmt_nif/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-apple-darwin] 2 | rustflags = [ 3 | "-C", "link-arg=-undefined", 4 | "-C", "link-arg=dynamic_lookup", 5 | ] 6 | 7 | [target.aarch64-apple-darwin] 8 | rustflags = [ 9 | "-C", "link-arg=-undefined", 10 | "-C", "link-arg=dynamic_lookup", 11 | ] 12 | 13 | # See https://github.com/rust-lang/rust/issues/59302 14 | [target.x86_64-unknown-linux-musl] 15 | rustflags = [ 16 | "-C", "target-feature=-crt-static" 17 | ] 18 | 19 | # See https://github.com/rust-lang/rust/issues/59302 20 | [target.aarch64-unknown-linux-musl] 21 | rustflags = [ 22 | "-C", "target-feature=-crt-static" 23 | ] 24 | 25 | [profile.release] 26 | lto = true 27 | -------------------------------------------------------------------------------- /native/sql_fmt_nif/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /native/sql_fmt_nif/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "cfg-if" 7 | version = "1.0.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 10 | 11 | [[package]] 12 | name = "heck" 13 | version = "0.5.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 16 | 17 | [[package]] 18 | name = "inventory" 19 | version = "0.3.15" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "f958d3d68f4167080a18141e10381e7634563984a537f2a49a30fd8e53ac5767" 22 | 23 | [[package]] 24 | name = "libloading" 25 | version = "0.8.5" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" 28 | dependencies = [ 29 | "cfg-if", 30 | "windows-targets", 31 | ] 32 | 33 | [[package]] 34 | name = "memchr" 35 | version = "2.7.4" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 38 | 39 | [[package]] 40 | name = "proc-macro2" 41 | version = "1.0.89" 42 | source = "registry+https://github.com/rust-lang/crates.io-index" 43 | checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" 44 | dependencies = [ 45 | "unicode-ident", 46 | ] 47 | 48 | [[package]] 49 | name = "quote" 50 | version = "1.0.37" 51 | source = "registry+https://github.com/rust-lang/crates.io-index" 52 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 53 | dependencies = [ 54 | "proc-macro2", 55 | ] 56 | 57 | [[package]] 58 | name = "regex-lite" 59 | version = "0.1.6" 60 | source = "registry+https://github.com/rust-lang/crates.io-index" 61 | checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" 62 | 63 | [[package]] 64 | name = "rustler" 65 | version = "0.35.0" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "b705f2c3643cc170d8888cb6bad589155d9c0248f3104ef7a04c2b7ffbaf13fc" 68 | dependencies = [ 69 | "inventory", 70 | "libloading", 71 | "regex-lite", 72 | "rustler_codegen", 73 | ] 74 | 75 | [[package]] 76 | name = "rustler_codegen" 77 | version = "0.35.0" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "3ad56caff00562948bd6ac33c18dbc579e5a1bbee2d7f2f54073307e57f6b57a" 80 | dependencies = [ 81 | "heck", 82 | "inventory", 83 | "proc-macro2", 84 | "quote", 85 | "syn", 86 | ] 87 | 88 | [[package]] 89 | name = "sql_fmt_nif" 90 | version = "0.4.0" 91 | dependencies = [ 92 | "rustler", 93 | "sqlformat", 94 | ] 95 | 96 | [[package]] 97 | name = "sqlformat" 98 | version = "0.3.5" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "a0d7b3e8a3b6f2ee93ac391a0f757c13790caa0147892e3545cd549dd5b54bc0" 101 | dependencies = [ 102 | "unicode_categories", 103 | "winnow", 104 | ] 105 | 106 | [[package]] 107 | name = "syn" 108 | version = "2.0.87" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" 111 | dependencies = [ 112 | "proc-macro2", 113 | "quote", 114 | "unicode-ident", 115 | ] 116 | 117 | [[package]] 118 | name = "unicode-ident" 119 | version = "1.0.13" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 122 | 123 | [[package]] 124 | name = "unicode_categories" 125 | version = "0.1.1" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" 128 | 129 | [[package]] 130 | name = "windows-targets" 131 | version = "0.52.6" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 134 | dependencies = [ 135 | "windows_aarch64_gnullvm", 136 | "windows_aarch64_msvc", 137 | "windows_i686_gnu", 138 | "windows_i686_gnullvm", 139 | "windows_i686_msvc", 140 | "windows_x86_64_gnu", 141 | "windows_x86_64_gnullvm", 142 | "windows_x86_64_msvc", 143 | ] 144 | 145 | [[package]] 146 | name = "windows_aarch64_gnullvm" 147 | version = "0.52.6" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 150 | 151 | [[package]] 152 | name = "windows_aarch64_msvc" 153 | version = "0.52.6" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 156 | 157 | [[package]] 158 | name = "windows_i686_gnu" 159 | version = "0.52.6" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 162 | 163 | [[package]] 164 | name = "windows_i686_gnullvm" 165 | version = "0.52.6" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 168 | 169 | [[package]] 170 | name = "windows_i686_msvc" 171 | version = "0.52.6" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 174 | 175 | [[package]] 176 | name = "windows_x86_64_gnu" 177 | version = "0.52.6" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 180 | 181 | [[package]] 182 | name = "windows_x86_64_gnullvm" 183 | version = "0.52.6" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 186 | 187 | [[package]] 188 | name = "windows_x86_64_msvc" 189 | version = "0.52.6" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 192 | 193 | [[package]] 194 | name = "winnow" 195 | version = "0.6.26" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" 198 | dependencies = [ 199 | "memchr", 200 | ] 201 | -------------------------------------------------------------------------------- /native/sql_fmt_nif/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sql_fmt_nif" 3 | version = "0.4.0" 4 | authors = ["Alex Koutmos"] 5 | edition = "2021" 6 | 7 | [lib] 8 | name = "sql_fmt_nif" 9 | path = "src/lib.rs" 10 | crate-type = ["cdylib"] 11 | 12 | [dependencies] 13 | rustler = "0.35.0" 14 | sqlformat = "0.3.5" 15 | 16 | [features] 17 | default = ["nif_version_2_15"] 18 | nif_version_2_15 = ["rustler/nif_version_2_15"] 19 | nif_version_2_16 = ["rustler/nif_version_2_16"] 20 | -------------------------------------------------------------------------------- /native/sql_fmt_nif/Cross.toml: -------------------------------------------------------------------------------- 1 | [build.env] 2 | passthrough = [ 3 | "RUSTLER_NIF_VERSION" 4 | ] 5 | -------------------------------------------------------------------------------- /native/sql_fmt_nif/src/lib.rs: -------------------------------------------------------------------------------- 1 | use rustler::Atom; 2 | use rustler::NifStruct; 3 | use rustler::NifTuple; 4 | 5 | use sqlformat::{FormatOptions, Indent, QueryParams}; 6 | 7 | mod atoms { 8 | rustler::atoms! { 9 | ok, 10 | error 11 | } 12 | } 13 | 14 | #[derive(NifTuple)] 15 | struct StringResultTuple { 16 | lhs: Atom, 17 | rhs: String, 18 | } 19 | 20 | #[derive(NifStruct)] 21 | #[module = "SqlFmt.FormatOptions"] 22 | pub struct ElixirFormatOptions<'a> { 23 | pub indent: u8, 24 | pub uppercase: bool, 25 | pub lines_between_queries: u8, 26 | pub ignore_case_convert: Vec<&'a str>, 27 | } 28 | 29 | #[rustler::nif(schedule = "DirtyCpu")] 30 | fn format(sql_query: String, format_options: ElixirFormatOptions) -> StringResultTuple { 31 | let options = FormatOptions { 32 | indent: Indent::Spaces(format_options.indent), 33 | uppercase: Some(format_options.uppercase), 34 | lines_between_queries: format_options.lines_between_queries, 35 | ignore_case_convert: Some(format_options.ignore_case_convert), 36 | }; 37 | 38 | let formatted_sql = sqlformat::format(sql_query.as_str(), &QueryParams::None, &options); 39 | 40 | return StringResultTuple { 41 | lhs: atoms::ok(), 42 | rhs: formatted_sql.to_string(), 43 | }; 44 | } 45 | 46 | #[rustler::nif(schedule = "DirtyCpu")] 47 | fn format( 48 | sql_query: String, 49 | query_params: Vec, 50 | format_options: ElixirFormatOptions, 51 | ) -> StringResultTuple { 52 | let options = FormatOptions { 53 | indent: Indent::Spaces(format_options.indent), 54 | uppercase: Some(format_options.uppercase), 55 | lines_between_queries: format_options.lines_between_queries, 56 | ignore_case_convert: Some(format_options.ignore_case_convert), 57 | }; 58 | 59 | let formatted_sql = sqlformat::format( 60 | sql_query.as_str(), 61 | &QueryParams::Indexed(query_params), 62 | &options, 63 | ); 64 | 65 | return StringResultTuple { 66 | lhs: atoms::ok(), 67 | rhs: formatted_sql.to_string(), 68 | }; 69 | } 70 | 71 | rustler::init!("Elixir.SqlFmt.Native"); 72 | -------------------------------------------------------------------------------- /test/sql_fmt/formatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SqlFmt.MixFormatterTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mix.Tasks.Format 5 | 6 | test "formats matching files", context do 7 | in_tmp(context.test, fn -> 8 | write_formatter_config(plugins: [SqlFmt.MixFormatter]) 9 | sql_fixture("query.sql") 10 | 11 | Format.run(["query.sql"]) 12 | assert File.read!("query.sql") == "SELECT\n *\nFROM\n users" 13 | end) 14 | end 15 | 16 | test "with :indent option set", context do 17 | in_tmp(context.test, fn -> 18 | write_formatter_config(plugins: [SqlFmt.MixFormatter], sql_fmt: [indent: 4]) 19 | sql_fixture("query.sql") 20 | 21 | Format.run(["query.sql"]) 22 | assert File.read!("query.sql") == "SELECT\n *\nFROM\n users" 23 | end) 24 | end 25 | 26 | test "with :uppercase set to false", context do 27 | in_tmp(context.test, fn -> 28 | write_formatter_config(plugins: [SqlFmt.MixFormatter], sql_fmt: [uppercase: false]) 29 | sql_fixture("query.sql") 30 | 31 | Format.run(["query.sql"]) 32 | assert File.read!("query.sql") == "select\n *\nfrom\n users" 33 | end) 34 | end 35 | 36 | test "with multiple queries", context do 37 | in_tmp(context.test, fn -> 38 | write_formatter_config(plugins: [SqlFmt.MixFormatter]) 39 | content = "select * from users; select * from groups;" 40 | sql_fixture("query.sql", content) 41 | 42 | Format.run(["query.sql"]) 43 | 44 | assert File.read!("query.sql") == 45 | """ 46 | SELECT 47 | * 48 | FROM 49 | users; 50 | SELECT 51 | * 52 | FROM 53 | groups;\ 54 | """ 55 | 56 | # with :lines-between-queries set to a different value 57 | write_formatter_config(plugins: [SqlFmt.MixFormatter], sql_fmt: [lines_between_queries: 2]) 58 | Format.run(["query.sql"]) 59 | 60 | assert File.read!("query.sql") == 61 | """ 62 | SELECT 63 | * 64 | FROM 65 | users; 66 | 67 | SELECT 68 | * 69 | FROM 70 | groups;\ 71 | """ 72 | end) 73 | end 74 | 75 | test "with :ignore-case-convert set", context do 76 | in_tmp(context.test, fn -> 77 | write_formatter_config(plugins: [SqlFmt.MixFormatter], sql_fmt: [ignore_case_convert: ["select", "where"]]) 78 | sql_fixture("query.sql", "select * from users where age > 18;") 79 | 80 | Format.run(["query.sql"]) 81 | assert File.read!("query.sql") == "select\n *\nFROM\n users\nwhere\n age > 18;" 82 | 83 | write_formatter_config(plugins: [SqlFmt.MixFormatter], sql_fmt: [ignore_case_convert: ["select"]]) 84 | Format.run(["query.sql"]) 85 | assert File.read!("query.sql") == "select\n *\nFROM\n users\nWHERE\n age > 18;" 86 | end) 87 | end 88 | 89 | test "with sql path in formatter inputs", context do 90 | in_tmp(context.test, fn -> 91 | write_formatter_config(plugins: [SqlFmt.MixFormatter], inputs: ["sql/**/*.sql"]) 92 | sql_fixture("sql/path_a/query.sql") 93 | sql_fixture("sql/path_a/query2.sql") 94 | sql_fixture("sql/path_b/query.sql") 95 | sql_fixture("queries/query.sql") 96 | 97 | Format.run(["sql/**/*.sql", "queries/query.sql"]) 98 | assert File.read!("sql/path_a/query.sql") == "SELECT\n *\nFROM\n users" 99 | assert File.read!("sql/path_a/query2.sql") == "SELECT\n *\nFROM\n users" 100 | assert File.read!("sql/path_b/query.sql") == "SELECT\n *\nFROM\n users" 101 | assert File.read!("queries/query.sql") == "SELECT\n *\nFROM\n users" 102 | end) 103 | end 104 | 105 | test "with --check-formatted", context do 106 | in_tmp(context.test, fn -> 107 | write_formatter_config(plugins: [SqlFmt.MixFormatter], inputs: ["*.sql"]) 108 | sql_fixture("query.sql") 109 | 110 | assert_raise Mix.Error, ~r"mix format failed due to --check-formatted", fn -> 111 | Format.run(["--check-formatted"]) 112 | end 113 | 114 | # the file has not changed 115 | assert File.read!("query.sql") == "select * from users" 116 | 117 | # format it and run with --check-formatted again 118 | assert Format.run([]) == :ok 119 | assert Format.run(["--check-formatted"]) == :ok 120 | 121 | assert File.read!("query.sql") == "SELECT\n *\nFROM\n users" 122 | end) 123 | end 124 | 125 | test "with different extensions", context do 126 | in_tmp(context.test, fn -> 127 | write_formatter_config(plugins: [SqlFmt.MixFormatter], sql_fmt: [extensions: [".sqlite"]]) 128 | sql_fixture("query.sql") 129 | sql_fixture("query.sqlite") 130 | 131 | assert Format.run(["query.sql", "query.sqlite"]) == :ok 132 | assert File.read!("query.sql") == "select * from users" 133 | assert File.read!("query.sqlite") == "SELECT\n *\nFROM\n users" 134 | end) 135 | end 136 | 137 | test "with single-line ~SQL sigil", context do 138 | in_tmp(context.test, fn -> 139 | write_formatter_config(plugins: [SqlFmt.MixFormatter], sql_fmt: [extensions: [".sqlite"]]) 140 | 141 | elixir_fixture("query.exs", """ 142 | import SqlFmt.Helpers 143 | 144 | query = ~SQL"select * from users" 145 | """) 146 | 147 | assert Format.run(["query.exs"]) == :ok 148 | 149 | assert File.read!("query.exs") == """ 150 | import SqlFmt.Helpers 151 | 152 | query = ~SQL"SELECT * FROM users" 153 | """ 154 | end) 155 | end 156 | 157 | test "with multi-line ~SQL sigil", context do 158 | in_tmp(context.test, fn -> 159 | write_formatter_config(plugins: [SqlFmt.MixFormatter], sql_fmt: [extensions: [".sqlite"]]) 160 | 161 | elixir_fixture("query.exs", ~s''' 162 | import SqlFmt.Helpers 163 | 164 | query = ~SQL""" 165 | select * from users 166 | where age > 23 167 | """ 168 | ''') 169 | 170 | assert Format.run(["query.exs"]) == :ok 171 | 172 | assert File.read!("query.exs") == ~s''' 173 | import SqlFmt.Helpers 174 | 175 | query = ~SQL""" 176 | SELECT 177 | * 178 | FROM 179 | users 180 | WHERE 181 | age > 23 182 | """ 183 | ''' 184 | end) 185 | end 186 | 187 | def in_tmp(which, function) do 188 | path = tmp_path(which) 189 | File.rm_rf!(path) 190 | File.mkdir_p!(path) 191 | File.cd!(path, function) 192 | end 193 | 194 | defp tmp_path(path), do: Path.join("../tmp", remove_colons(path)) |> Path.expand() 195 | 196 | defp remove_colons(term) do 197 | term 198 | |> to_string() 199 | |> String.replace(":", "") 200 | end 201 | 202 | defp write_formatter_config(config) when is_list(config), do: write_formatter_config(inspect(config)) 203 | defp write_formatter_config(config) when is_binary(config), do: File.write!(".formatter.exs", config) 204 | 205 | defp sql_fixture(path, query \\ "select * from users") do 206 | File.mkdir_p!(Path.dirname(path)) 207 | File.write!(path, query) 208 | end 209 | 210 | defp elixir_fixture(path, content) do 211 | File.write!(path, content) 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /test/sql_fmt/helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SqlFmt.HelpersTest do 2 | use ExUnit.Case 3 | import SqlFmt.Helpers 4 | 5 | test "sigil_SQL" do 6 | assert ~SQL"SELECT *" == "SELECT *" 7 | 8 | assert ~SQL""" 9 | SELECT * 10 | FROM users 11 | """ == 12 | """ 13 | SELECT * 14 | FROM users 15 | """ 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/sql_fmt_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SqlFmtTest do 2 | use ExUnit.Case 3 | 4 | describe "SqlFmt.format_query/2" do 5 | test "should format the query with default formatting options even if special characters are present" do 6 | query = """ 7 | SELECT 8 | left <@ right, 9 | left << right, 10 | left >> right, 11 | left &< right, 12 | left &> right, 13 | left -|- right, 14 | @@ left, 15 | @-@ left, 16 | left <-> right, 17 | left <<| right, 18 | left |>> right, 19 | left &<| right, 20 | left |>& right, 21 | left <^ right, 22 | left >^ right, 23 | ?- left, 24 | left ?-| right, 25 | left ?|| right, 26 | left ~= right 27 | """ 28 | 29 | assert {:ok, result} = query |> String.trim() |> SqlFmt.format_query() 30 | 31 | assert String.trim(""" 32 | SELECT 33 | left <@ right, 34 | left << right, 35 | left >> right, 36 | left &< right, 37 | left &> right, 38 | left -|- right, 39 | @@ left, 40 | @-@ left, 41 | left <-> right, 42 | left <<| right, 43 | left |>> right, 44 | left &<| right, 45 | left |>& right, 46 | left <^ right, 47 | left >^ right, 48 | ?- left, 49 | left ?-| right, 50 | left ?|| right, 51 | left ~= right 52 | """) == result 53 | end 54 | 55 | test "should format the query with default formatting options" do 56 | assert {:ok, result} = SqlFmt.format_query("select * from hello_world;") 57 | 58 | assert String.trim(""" 59 | SELECT 60 | * 61 | FROM 62 | hello_world; 63 | """) == result 64 | end 65 | 66 | test "should format the query with custom formatting options" do 67 | assert {:ok, result} = SqlFmt.format_query("select * from hello_world;", indent: 4, uppercase: false) 68 | 69 | assert String.trim(""" 70 | select 71 | * 72 | from 73 | hello_world; 74 | """) == result 75 | 76 | assert {:ok, result} = 77 | SqlFmt.format_query("select * from hello_world;", indent: 4, ignore_case_convert: ["select"]) 78 | 79 | assert String.trim(""" 80 | select 81 | * 82 | FROM 83 | hello_world; 84 | """) == result 85 | end 86 | end 87 | 88 | describe "SqlFmt.format_query_with_params/2" do 89 | test "should format the query with default formatting options even if special characters are present" do 90 | query = """ 91 | SELECT 92 | left <@ right, 93 | left << right, 94 | left >> right, 95 | left &< right, 96 | left &> right, 97 | left -|- right, 98 | @@ left, 99 | @-@ left, 100 | left <-> right, 101 | left <<| right, 102 | left |>> right, 103 | left &<| right, 104 | left |>& right, 105 | left <^ right, 106 | left >^ right, 107 | left ~= ? 108 | """ 109 | 110 | assert {:ok, result} = query |> String.trim() |> SqlFmt.format_query_with_params(["right"]) 111 | 112 | assert String.trim(""" 113 | SELECT 114 | left <@ right, 115 | left << right, 116 | left >> right, 117 | left &< right, 118 | left &> right, 119 | left -|- right, 120 | @@ left, 121 | @-@ left, 122 | left <-> right, 123 | left <<| right, 124 | left |>> right, 125 | left &<| right, 126 | left |>& right, 127 | left <^ right, 128 | left >^ right, 129 | left ~= right 130 | """) == result 131 | end 132 | 133 | test "should format the query with default formatting options" do 134 | assert {:ok, result} = SqlFmt.format_query_with_params("select * from hello_world where thing = ?;", ["1"]) 135 | 136 | assert String.trim(""" 137 | SELECT 138 | * 139 | FROM 140 | hello_world 141 | WHERE 142 | thing = 1; 143 | """) == result 144 | end 145 | 146 | test "should format the query with custom formatting options" do 147 | assert {:ok, result} = 148 | SqlFmt.format_query_with_params("select * from hello_world where thing = ?;", ["1"], 149 | indent: 4, 150 | uppercase: false 151 | ) 152 | 153 | assert String.trim(""" 154 | select 155 | * 156 | from 157 | hello_world 158 | where 159 | thing = 1; 160 | """) == result 161 | 162 | assert {:ok, result} = 163 | SqlFmt.format_query_with_params("select * from hello_world where thing = ?;", ["1"], 164 | indent: 4, 165 | ignore_case_convert: ["select"] 166 | ) 167 | 168 | assert String.trim(""" 169 | select 170 | * 171 | FROM 172 | hello_world 173 | WHERE 174 | thing = 1; 175 | """) == result 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------