├── .credo.exs ├── .gitignore ├── .tool-versions ├── LICENSE.md ├── README.md ├── circle.yml ├── config └── config.exs ├── lib ├── mix │ └── tasks │ │ └── dialyzer │ │ └── plt.ex ├── searchql.ex └── searchql │ ├── logical_parser.ex │ ├── parser.ex │ ├── querier.ex │ └── quote_parser.ex ├── mix.exs ├── mix.lock └── test ├── lib └── searchql │ ├── logical_parser_test.exs │ └── quote_parser_test.exs ├── searchql_test.exs ├── support └── string_querier.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{included: ["lib/"]}, 6 | checks: [ 7 | {Credo.Check.Design.AliasUsage, false}, 8 | {Credo.Check.Readability.MaxLineLength, ignore_definitions: true, ignore_specs: true} 9 | ] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.3.4 2 | erlang 19.2 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Jonathan Clem 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SearchQL [![CircleCI](https://circleci.com/gh/usecanvas/searchql.svg?style=svg)](https://circleci.com/gh/usecanvas/searchql) 2 | 3 | SearchQL is a search query parser written in Elixir. Given a query such as: 4 | 5 | ``` 6 | foo AND bar OR "qux AND quux" OR corge AND NOT grault AND garply 7 | ``` 8 | 9 | SearchQL can generate a data representation of the query: 10 | 11 | ```elixir 12 | [or: { 13 | [or: { 14 | [data: "foo", data: "bar"], 15 | [quote: "qux AND quux"]}], 16 | [data: "corge", not: {[data: "grault"]}, data: "garply"]}] 17 | ``` 18 | 19 | Notice that in the query parsing, `AND` binds tighter than `OR`, and both 20 | operators are left-associative. This seems to give the best interpretation of 21 | what a human would mean when typing such a query. 22 | 23 | A programmer can use SearchQL to determine whether a search query matches a 24 | piece of data by providing a module such as 25 | [SearchQL.StringQuerier][string_querier] that implements the 26 | [SearchQL.Querier behaviour][querier_behaviour]: 27 | 28 | ```elixir 29 | SearchQL.matches?( 30 | ~s(foo and bar or baz), 31 | ~s(baz), 32 | SearchQL.StringQuerier) # true 33 | ``` 34 | 35 | For more info, see the [SearchQL documentation][searchql_documentation]. 36 | 37 | ## Installation 38 | 39 | 1. Add `searchql` to your list of dependencies in `mix.exs`: 40 | 41 | ```elixir 42 | def deps do 43 | [{:searchql, "~> 1.0.0"}] 44 | end 45 | ``` 46 | 47 | 2. Ensure `searchql` is started before your application: 48 | 49 | ```elixir 50 | def application do 51 | [applications: [:searchql]] 52 | end 53 | ``` 54 | 55 | [querier_behaviour]: https://github.com/usecanvas/searchql/blob/master/lib/searchql/querier.ex 56 | [searchql_documentation]: https://hexdocs.pm/searchql/readme.html 57 | [string_querier]: https://github.com/usecanvas/searchql/blob/master/test/support/string_querier.ex 58 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | environment: 3 | PATH: "$HOME/.asdf/bin:$HOME/.asdf/shims:$PATH" 4 | 5 | dependencies: 6 | pre: 7 | - if ! asdf | grep version; then git clone https://github.com/HashNuke/asdf.git ~/.asdf; fi 8 | - asdf plugin-list | grep erlang || asdf plugin-add erlang https://github.com/HashNuke/asdf-erlang.git 9 | - asdf plugin-list | grep elixir || asdf plugin-add elixir https://github.com/HashNuke/asdf-elixir.git 10 | - erlang_version=$(awk '/erlang/ { print $2 }' .tool-versions) && asdf install erlang ${erlang_version} 11 | - elixir_version=$(awk '/elixir/ { print $2 }' .tool-versions) && asdf install elixir ${elixir_version} 12 | - mix local.hex --force 13 | - mix local.rebar --force 14 | - mix deps.get 15 | - MIX_ENV=test mix compile 16 | - MIX_ENV=test mix dialyzer.plt 17 | cache_directories: 18 | - ~/.asdf 19 | - deps 20 | - _build 21 | 22 | test: 23 | override: 24 | - MIX_ENV=test mix ci 25 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :searchql, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:searchql, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/mix/tasks/dialyzer/plt.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Dialyzer.Plt do 2 | @shortdoc "Runs dialyzer.plt" 3 | 4 | @moduledoc """ 5 | Runs `mix dialyzer.plt`. Most of this code is yanked from Dialyxir, and is 6 | a stopgap until it includes `mix dialyzer.plt` in its Mix dependency form. 7 | """ 8 | 9 | use Mix.Task 10 | 11 | alias Dialyxir.{Plt, Project} 12 | 13 | @spec run([binary]) :: any 14 | def run(_) do 15 | {apps, hash} = dependency_hash 16 | 17 | unless check_hash?(hash) do 18 | apps |> Project.plts_list() |> Plt.check() 19 | File.write(plt_hash_file, hash) 20 | end 21 | end 22 | 23 | @spec check_hash?(binary) :: boolean 24 | defp check_hash?(hash) do 25 | case File.read(plt_hash_file) do 26 | {:ok, stored_hash} -> hash == stored_hash 27 | _ -> false 28 | end 29 | end 30 | 31 | @spec plt_hash_file() :: String.t 32 | defp plt_hash_file, do: Project.plt_file() <> ".hash" 33 | 34 | @spec dependency_hash :: {[atom], binary} 35 | def dependency_hash do 36 | lock_file = Mix.Dep.Lock.read |> :erlang.term_to_binary 37 | apps = Project.cons_apps 38 | hash = :crypto.hash(:sha, lock_file <> :erlang.term_to_binary(apps)) 39 | {apps, hash} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/searchql.ex: -------------------------------------------------------------------------------- 1 | defmodule SearchQL do 2 | @moduledoc """ 3 | A parsed representation of a human-written search query. 4 | """ 5 | 6 | alias SearchQL.{LogicalParser, QuoteParser} 7 | 8 | @type token :: {atom, String.t | {[token], [token]}} 9 | 10 | @doc """ 11 | Return whether a query matches data using a given module. 12 | """ 13 | @spec matches?(String.t, any, atom) :: boolean 14 | def matches?(query_string, data, mod), 15 | do: query_string |> parse |> do_matches?(data, mod) 16 | 17 | @spec do_matches?([SearchQL.token], any, atom) :: boolean 18 | defp do_matches?(tokens, data, mod) do 19 | tokens 20 | |> Enum.reduce_while(true, fn (token, _) -> 21 | if token_matches?(token, data, mod) do 22 | {:cont, true} 23 | else 24 | {:halt, false} 25 | end 26 | end) 27 | end 28 | 29 | @spec token_matches?(token, any, atom) :: boolean 30 | defp token_matches?(tokens, data, mod) when is_list(tokens), 31 | do: do_matches?(tokens, data, mod) 32 | defp token_matches?({:or, {tok_a, tok_b}}, data, mod), 33 | do: token_matches?(tok_a, data, mod) or token_matches?(tok_b, data, mod) 34 | defp token_matches?({:not, {tokens}}, data, mod), 35 | do: !token_matches?(tokens, data, mod) 36 | defp token_matches?({func, args}, data, mod), 37 | do: apply(mod, func, [args, data]) 38 | 39 | @doc """ 40 | Parse a query string into a tree that can be iterated over in order to 41 | evaluate the query against data. 42 | 43 | iex> SearchQL.parse(~s(foo bar baz)) 44 | [{:data, "foo bar baz"}] 45 | """ 46 | @spec parse(String.t) :: [token] 47 | def parse(query_string), 48 | do: [{:data, query_string}] |> QuoteParser.parse |> LogicalParser.parse 49 | end 50 | -------------------------------------------------------------------------------- /lib/searchql/logical_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule SearchQL.LogicalParser do 2 | @moduledoc """ 3 | Parses logical expressions in a SearchQL query. 4 | """ 5 | 6 | @behaviour SearchQL.Parser 7 | 8 | @doc """ 9 | Parses a list of tokens and returns that list with logical expressions 10 | replaced by logical tokens. Note that logical parsing is case-**in**sensitive. 11 | 12 | iex> SearchQL.LogicalParser.parse([data: "foo and bar"]) 13 | [data: "foo", data: "bar"] 14 | 15 | This function parses tokens in reverse order, "OR"s before "AND"s. This 16 | results in a parsing where "AND" has the higher precedence, and where operator 17 | precedence is left-associative: 18 | 19 | iex> SearchQL.LogicalParser.parse([data: "foo or bar and baz and qux"]) 20 | [or: { 21 | [data: "foo"], 22 | [data: "bar", data: "baz", data: "qux"]}] 23 | """ 24 | @spec parse([SearchQL.token]) :: [SearchQL.token] 25 | def parse(tokens), do: tokens |> Enum.reduce([], &make_words/2) |> parse_or 26 | 27 | @spec parse_or([SearchQL.token], [SearchQL.token]) :: [SearchQL.token] 28 | defp parse_or(tokens, result \\ []) 29 | defp parse_or([], result), do: result |> Enum.reverse |> parse_and 30 | defp parse_or([{:word, word} | tokens], result) when word in ~w(or OR), 31 | do: [{:or, {parse_or(tokens), parse_or([], result)}}] 32 | defp parse_or([token | tokens], result), 33 | do: parse_or(tokens, [token | result]) 34 | 35 | @spec parse_and([SearchQL.token], [SearchQL.token]) :: [SearchQL.token] 36 | defp parse_and(tokens, result \\ []) 37 | defp parse_and([], result), do: result |> Enum.reverse |> parse_not 38 | defp parse_and([{:word, word} | tokens], result) when word in ~w(and AND), 39 | do: parse_and(tokens) ++ parse_and([], result) 40 | defp parse_and([token | tokens], result), 41 | do: parse_and(tokens, [token | result]) 42 | 43 | @spec parse_not([SearchQL.token], [SearchQL.token]) :: [SearchQL.token] 44 | defp parse_not(tokens, result \\ []) 45 | defp parse_not([], result), 46 | do: result |> Enum.reduce([], &join_words/2) |> Enum.reverse 47 | defp parse_not([{:word, word} | tokens], result) when word in ~w(not NOT), 48 | do: [{:not, {parse_not(result)}} | parse_not(tokens)] 49 | defp parse_not([token | tokens], result), 50 | do: parse_not(tokens, [token | result]) 51 | 52 | @spec make_words(SearchQL.token, [SearchQL.token]) :: [SearchQL.token] 53 | defp make_words({:data, data}, tokens) do 54 | data 55 | |> String.split(~r/\s+/, trim: true) 56 | |> Enum.map(&({:word, &1})) 57 | |> Enum.reverse 58 | |> Enum.concat(tokens) 59 | end 60 | 61 | defp make_words(token, tokens), do: [token | tokens] 62 | 63 | @spec join_words(SearchQL.token, [SearchQL.token]) :: [SearchQL.token] 64 | defp join_words({:word, word}, [{type, head_word} | tokens]) when type in [:data, :word], 65 | do: [{:data, "#{head_word} #{word}"} | tokens] 66 | defp join_words({:word, word}, tokens), do: [{:data, word} | tokens] 67 | defp join_words(token, tokens), do: [token | tokens] 68 | end 69 | -------------------------------------------------------------------------------- /lib/searchql/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule SearchQL.Parser do 2 | @moduledoc """ 3 | A behaviour that defines how to build parsers for SearchQL. A SearchQL parser 4 | must expose a single `parse/1` function that accepts an array of SearchQL 5 | tokens and returns an array of SearchQL tokens. 6 | """ 7 | 8 | @callback parse([SearchQL.token]) :: [SearchQL.token] 9 | end 10 | -------------------------------------------------------------------------------- /lib/searchql/querier.ex: -------------------------------------------------------------------------------- 1 | defmodule SearchQL.Querier do 2 | @moduledoc """ 3 | A behaviour that defines how a type piece of data can be queried. A SearchQL 4 | querier exposes a `data/2` and a `quote/2` function that parse a pure data 5 | string and query the data, and query the data for a quote, respectively. 6 | """ 7 | 8 | @callback data(String.t, any) :: boolean 9 | @callback quote(String.t, any) :: boolean 10 | end 11 | -------------------------------------------------------------------------------- /lib/searchql/quote_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule SearchQL.QuoteParser do 2 | @moduledoc """ 3 | A parser for quotes in SearchQL queries. 4 | """ 5 | 6 | @behaviour SearchQL.Parser 7 | 8 | @doc """ 9 | Parse a list of tokens for quotes 10 | 11 | iex> SearchQL.QuoteParser.parse([{:data, ~s("foo")}]) 12 | [{:quote, "foo"}] 13 | """ 14 | @spec parse([SearchQL.token]) :: [SearchQL.token] 15 | def parse(tokens), do: do_parse(tokens) 16 | 17 | @spec do_parse([SearchQL.token], [SearchQL.token]) :: [SearchQL.token] 18 | defp do_parse(tokens, result \\ []) 19 | defp do_parse([], result), do: Enum.reverse(result) 20 | defp do_parse([{:data, data} | tokens], result) do 21 | new_tokens = parse_quote(data, []) 22 | do_parse(tokens, new_tokens ++ result) 23 | end 24 | defp do_parse([token | tokens], result), 25 | do: do_parse(tokens, [token | result]) 26 | 27 | @spec parse_quote(String.t, [SearchQL.token], SearchQL.token) 28 | :: [SearchQL.token] 29 | defp parse_quote(string, tokens, state \\ {:data, ""}) 30 | defp parse_quote("", tokens, {_, ""}), do: tokens 31 | defp parse_quote("", tokens, state), do: [state | tokens] 32 | defp parse_quote(~s(") <> string, tokens, token = {:data, data}) do 33 | tokens = if String.length(data) < 1, do: tokens, else: [token | tokens] 34 | parse_quote(string, tokens, {:quote, ""}) 35 | end 36 | defp parse_quote(<>, tokens, {:data, data}), 37 | do: parse_quote(string, tokens, {:data, data <> char}) 38 | defp parse_quote(~s(") <> string, tokens, qte = {:quote, _}), 39 | do: parse_quote(string, [qte | tokens], {:data, ""}) 40 | defp parse_quote(<>, tokens, {:quote, qte}), 41 | do: parse_quote(string, tokens, {:quote, qte <> char}) 42 | end 43 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SearchQL.Mixfile do 2 | use Mix.Project 3 | 4 | @version "2.1.2" 5 | @github_url "https://github.com/usecanvas/searchql" 6 | 7 | def project do 8 | [app: :searchql, 9 | description: "A natural-ish language query parser", 10 | package: package, 11 | docs: docs, 12 | source_url: @github_url, 13 | homepage_url: @github_url, 14 | version: @version, 15 | elixir: "~> 1.3", 16 | build_embedded: Mix.env == :prod, 17 | start_permanent: Mix.env == :prod, 18 | deps: deps(), 19 | aliases: aliases, 20 | dialyzer: [plt_add_apps: [:dialyxir, :mix]]] 21 | end 22 | 23 | # Configuration for the OTP application 24 | # 25 | # Type "mix help compile.app" for more information 26 | def application do 27 | [applications: [:logger]] 28 | end 29 | 30 | # Dependencies can be Hex packages: 31 | # 32 | # {:mydep, "~> 0.3.0"} 33 | # 34 | # Or git/path repositories: 35 | # 36 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 37 | # 38 | # Type "mix help deps" for more examples and options 39 | defp deps do 40 | [{:ex_doc, "> 0.0.0", only: [:dev]}, 41 | {:dialyxir, "~> 0.4", only: [:dev, :test], runtime: false}, 42 | {:credo, "~> 0.5", only: [:dev, :test]}] 43 | end 44 | 45 | defp aliases do 46 | [ci: ["test", "credo --strict", "dialyzer --halt-exit-status"]] 47 | end 48 | 49 | defp package do 50 | [maintainers: ["Jonathan Clem "], 51 | licenses: ["MIT"], 52 | links: %{GitHub: @github_url}, 53 | files: ~w(lib mix.exs LICENSE.md README.md)] 54 | end 55 | 56 | defp docs do 57 | [source_ref: "v#{@version}", 58 | main: "readme", 59 | extras: ~w(README.md LICENSE.md)] 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"bunt": {:hex, :bunt, "0.1.6", "5d95a6882f73f3b9969fdfd1953798046664e6f77ec4e486e6fafc7caad97c6f", [:mix], []}, 2 | "credo": {:hex, :credo, "0.5.3", "0c405b36e7651245a8ed63c09e2d52c2e2b89b6d02b1570c4d611e0fcbecf4a2", [:mix], [{:bunt, "~> 0.1.6", [hex: :bunt, optional: false]}]}, 3 | "dialyxir": {:hex, :dialyxir, "0.4.1", "236056d6acd25f740f336756c0f3b5dd6e2f0156074bc15f3b779aeee15390c8", [:mix], []}, 4 | "earmark": {:hex, :earmark, "1.0.3", "89bdbaf2aca8bbb5c97d8b3b55c5dd0cff517ecc78d417e87f1d0982e514557b", [:mix], []}, 5 | "ex_doc": {:hex, :ex_doc, "0.14.5", "c0433c8117e948404d93ca69411dd575ec6be39b47802e81ca8d91017a0cf83c", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}} 6 | -------------------------------------------------------------------------------- /test/lib/searchql/logical_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SearchQL.LogicalParserTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest SearchQL.LogicalParser 5 | 6 | alias SearchQL.{LogicalParser, QuoteParser} 7 | 8 | test "ignores non-logical expressions" do 9 | assert LogicalParser.parse([data: "foo bar"]) == [data: "foo bar"] 10 | end 11 | 12 | test "parses OR expressions" do 13 | ~s(foo "bar baz" qux OR hi) 14 | assert LogicalParser.parse([{:data, "foo OR bar baz"}]) == 15 | [or: {[data: "foo"], [data: "bar baz"]}] 16 | end 17 | 18 | test "parses OR as left-associative" do 19 | assert LogicalParser.parse([{:data, "foo bar OR baz OR qux"}]) == 20 | [or: { 21 | [or: {[data: "foo bar"], [data: "baz"]}], 22 | [data: "qux"]}] 23 | end 24 | 25 | test "parses AND expressions" do 26 | assert LogicalParser.parse([{:data, "foo AND bar"}]) == 27 | [data: "foo", data: "bar"] 28 | end 29 | 30 | test "parses AND as left-associative" do 31 | assert LogicalParser.parse([{:data, "foo AND bar AND baz"}]) == 32 | [data: "foo", data: "bar", data: "baz"] 33 | end 34 | 35 | test "parses AND with higher precedence than OR" do 36 | assert LogicalParser.parse([{:data, "foo OR bar AND baz qux"}]) == 37 | [or: { 38 | [data: "foo"], 39 | [data: "bar", data: "baz qux"]}] 40 | end 41 | 42 | test "parses already-parsed expressions" do 43 | query = 44 | [{:data, ~s(foo "bar baz" OR qux "quux corge")}] 45 | |> QuoteParser.parse 46 | 47 | assert LogicalParser.parse(query) == 48 | [or: { 49 | [data: "foo", quote: "bar baz"], 50 | [data: "qux", quote: "quux corge"]}] 51 | end 52 | 53 | test "parses NOT" do 54 | assert LogicalParser.parse([data: "foo AND NOT bar"]) == 55 | [data: "foo", not: {[data: "bar"]}] 56 | end 57 | 58 | test "parses NOT with higher binding than OR" do 59 | assert LogicalParser.parse([data: "foo AND NOT bar OR baz"]) == 60 | [or: { 61 | [data: "foo", not: {[data: "bar"]}], 62 | [data: "baz"]}] 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/lib/searchql/quote_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SearchQL.QuoteParserTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest SearchQL.QuoteParser 5 | 6 | alias SearchQL.QuoteParser 7 | 8 | test "parses basic quotes" do 9 | assert QuoteParser.parse([data: ~s("foo bar")]) == 10 | [quote: ~s(foo bar)] 11 | [{:quote, ~s(foo bar)}] 12 | end 13 | 14 | test "parses quotes and non-quotes" do 15 | assert QuoteParser.parse([data: ~s(foo "bar baz" qux)]) == 16 | [data: "foo ", quote: "bar baz", data: " qux"] 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/searchql_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SearchQLTest do 2 | use ExUnit.Case 3 | 4 | doctest SearchQL 5 | 6 | alias SearchQL.StringQuerier 7 | 8 | describe ".matches?/2" do 9 | test "matches basic string" do 10 | assert SearchQL.matches?(~s(foo bar), "foo bar baz", StringQuerier) 11 | refute SearchQL.matches?(~s(foo bar), "foo baz baz", StringQuerier) 12 | end 13 | 14 | test "matches basic OR" do 15 | assert SearchQL.matches?(~s(foo OR bar), "foo", StringQuerier) 16 | assert SearchQL.matches?(~s(foo OR bar), "bar", StringQuerier) 17 | refute SearchQL.matches?(~s(foo OR bar), "baz", StringQuerier) 18 | end 19 | 20 | test "matches multiple OR" do 21 | assert SearchQL.matches?(~s(foo OR bar OR baz), "foo", StringQuerier) 22 | assert SearchQL.matches?(~s(foo OR bar OR baz), "bar", StringQuerier) 23 | refute SearchQL.matches?(~s(foo OR bar OR baz), "qux", StringQuerier) 24 | end 25 | 26 | test "matches basic AND" do 27 | assert SearchQL.matches?(~s(foo AND bar), "foo bar", StringQuerier) 28 | refute SearchQL.matches?(~s(foo AND bar), "foo baz", StringQuerier) 29 | end 30 | 31 | test "matches multiple AND" do 32 | assert SearchQL.matches?(~s(foo AND bar AND baz), "foo bar baz", StringQuerier) 33 | refute SearchQL.matches?(~s(foo AND bar AND baz), "foo bar qux", StringQuerier) 34 | end 35 | 36 | test "matches combination AND and OR" do 37 | assert SearchQL.matches?( 38 | ~s(foo OR bar AND baz AND qux OR foo2 OR bar2), 39 | ~s(bar lala baz qux), 40 | StringQuerier) 41 | end 42 | 43 | test "matches quotes" do 44 | assert SearchQL.matches?( 45 | ~s(foo "bar baz"), 46 | ~s(foo bar baz qux), 47 | StringQuerier) 48 | 49 | refute SearchQL.matches?( 50 | ~s(foo "bar baz qux" fooo), 51 | ~s(foo bar baz qux), 52 | StringQuerier) 53 | end 54 | 55 | test "matches mixed quotes and bools" do 56 | query = ~s("foo bar" OR "foo baz" AND qux) 57 | assert SearchQL.matches?(query, "foo bar qux", StringQuerier) 58 | end 59 | 60 | test "matches NOT" do 61 | query = ~s(foo OR NOT bar) 62 | assert SearchQL.matches?(query, "foo", StringQuerier) 63 | assert SearchQL.matches?(query, "baz", StringQuerier) 64 | refute SearchQL.matches?(query, "bar", StringQuerier) 65 | end 66 | end 67 | 68 | describe ".parse/1" do 69 | test "parses combined logical and quotes" do 70 | assert SearchQL.parse(~s("foo bar" AND baz)) == 71 | [quote: "foo bar", data: "baz"] 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/support/string_querier.ex: -------------------------------------------------------------------------------- 1 | defmodule SearchQL.StringQuerier do 2 | @behaviour SearchQL.Querier 3 | 4 | @spec data(String.t, String.t) :: boolean 5 | def data(string, data), do: String.contains?(data, string) 6 | 7 | @spec quote(String.t, String.t) :: boolean 8 | def quote(string, data), do: data(string, data) 9 | end 10 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | File.ls!("./test/support") 4 | |> Enum.each(&(Code.require_file("support/#{&1}", __DIR__))) 5 | --------------------------------------------------------------------------------