Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d57e342
Start parser snapshot test
MareStare May 12, 2025
93e61d4
Begin Philomena Query Language parser snapshot tests
MareStare May 13, 2025
d65a232
Rename "query" to "phiql"
MareStare May 13, 2025
9eddec0
Remove debugging remnants and typespec fixes
MareStare May 13, 2025
6768533
Rename options -> context
MareStare May 13, 2025
99d4610
Revert some unnecessary changes
MareStare May 13, 2025
4fbf75d
Improve `run-test` script a bit
MareStare May 13, 2025
5510239
Change naming "PhiQL" -> "Philomena Search Syntax"
MareStare May 14, 2025
72164a0
Fix context typespec
MareStare May 14, 2025
2e37dfc
Add some test cases for `filter_id`
MareStare May 14, 2025
7621387
Improve the update script
MareStare May 14, 2025
68cec44
Deponify the test data and fix user ID cardinality problem
MareStare May 14, 2025
73429a7
Don't wait in diftool
MareStare May 14, 2025
9c6d70c
Ensure the stability of context value lists by sorting them.
MareStare May 14, 2025
cd00851
Fix the diftool. The `--wait` is required
MareStare May 14, 2025
062be42
Add a comment about refactoring in philomena.sh
MareStare May 14, 2025
6b29860
Don't store the users in DB unnecessarily in tests
MareStare May 14, 2025
f3cebcc
Self-review
MareStare May 14, 2025
eab364e
Fix CI
MareStare May 14, 2025
f913715
Fix dialyzer warnings
MareStare May 15, 2025
e400b94
Add more comments
MareStare May 15, 2025
78fe2b5
Fux type mismatch
MareStare May 15, 2025
4f90db5
Use the pipeline operator
MareStare May 21, 2025
8baeb95
Use the fixed 0.10.5 version of assert_value
MareStare May 21, 2025
bdcf848
Link to the Port API hexdocs.pm/elixir/1.18.3/Port.html#module-example
MareStare May 21, 2025
d0314f8
Add https://
MareStare May 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ services:
# be a bare `level` which will be used as a catch-all for all other log
# events that do not match any of the previous filters.
- PHILOMENA_LOG=${PHILOMENA_LOG-Ecto=debug,Exq=none,PhilomenaMedia.Objects=info,debug}
# Set this env var to `y` to regenerate the test snapshots
- ASSERT_VALUE_ACCEPT_DIFFS
- MIX_ENV=dev
- PGPASSWORD=postgres
- ANONYMOUS_NAME_SALT=2fmJRo0OgMFe65kyAJBxPT0QtkVes/jnKDdtP21fexsRqiw8TlSY7yO+uFyMZycp
Expand Down
39 changes: 24 additions & 15 deletions docker/app/run-test
Original file line number Diff line number Diff line change
@@ -1,35 +1,44 @@
#!/usr/bin/env sh
set -e
#!/usr/bin/env bash

set -euo pipefail

. "$(dirname "${BASH_SOURCE[0]}")/../../scripts/lib.sh"

export MIX_ENV=test

cd /srv/philomena

# Always install mix dependencies
(cd /srv/philomena && mix deps.get)
step mix deps.get

# Some tests depend on prettier
step npm ci --no-scripts

# Run formatting check
mix format --check-formatted
step mix format --check-formatted

# Sleep to allow OpenSearch to finish initializing
# if it's not done doing whatever it does yet
echo -n "Waiting for OpenSearch"
info "Waiting for OpenSearch"

until wget -qO - opensearch:9200; do
echo -n "."
until step wget -qO - http://opensearch:9200; do
sleep 2
done

echo

# Create the database
mix ecto.create
mix ecto.load
# Create the database, or migrate if one already exists from a previous run
if step createdb -h postgres -U postgres philomena_test; then
step mix ecto.create
step mix ecto.load
elif [[ "$(mix ecto.migrations)" == *" down "* ]]; then
step mix ecto.migrate --all
fi

# Test the application
mix test
step mix test "$@"

# Security lint
mix sobelow --config
mix deps.audit
step mix sobelow --config
step mix deps.audit

# Static analysis
exec mix dialyzer
8 changes: 8 additions & 0 deletions lib/philomena/images/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ defmodule Philomena.Images.Query do
end
end

@type context :: [
user: map(),
watch: boolean(),
filter: boolean()
]

@spec parse(Parser.options(), map(), String.t()) :: Parser.result()
defp parse(fields, context, query_string) do
case prepare_context(context, query_string) do
{:ok, context} ->
Expand All @@ -214,6 +221,7 @@ defmodule Philomena.Images.Query do
defp fields_for(%{role: role}) when role in ~W(moderator admin), do: moderator_fields()
defp fields_for(_), do: raise(ArgumentError, "Unknown user role.")

@spec compile(String.t(), context()) :: Parser.result()
def compile(query_string, opts \\ []) do
user = Keyword.get(opts, :user)
watch = Keyword.get(opts, :watch, false)
Expand Down
73 changes: 52 additions & 21 deletions lib/philomena_query/parse/parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ defmodule PhilomenaQuery.Parse.Parser do
@typedoc "Query in the search engine JSON query language."
@type query :: map()

@typedoc "Result of calling the `parse/3` function."
@type result :: {:ok, query()} | {:error, String.t()}

@typedoc "Whether the default field is `:term` (not analyzed) or `:ngram` (analyzed)."
@type default_field_type :: :term | :ngram

Expand Down Expand Up @@ -126,25 +129,55 @@ defmodule PhilomenaQuery.Parse.Parser do

@max_clause_count 512

@typedoc "Options for the new/1 function."
@type options :: [
# Booleans
bool_fields: [String.t()],

# Dates
date_fields: [String.t()],

# Floating point numbers
float_fields: [String.t()],

# Signed integers
int_fields: [String.t()],

# Numeric values (unsigned integers without fuzzing
# or range queries)
numeric_fields: [String.t()],

# IP CIDR masks
ip_fields: [String.t()],

# Wildcardable fields which are searched as the exact value
literal_fields: [String.t()],

# Wildcardable fields which are searched as stemmed values
ngram_fields: [String.t()],

# Fields which do not exist on the document and are created by a callback
custom_fields: [String.t()],

# A map of custom field names to transform functions
transforms: %{String.t() => transform()},

# A map of field names to the names they should have in the search engine
aliases: %{String.t() => String.t()},

# A list of field names which do not have string downcasing applied
no_downcase_fields: [String.t()],

# a map of field names to normalization functions (see `t:normalizer/0`)
normalizations: %{String.t() => normalizer()}
]

@doc """
Creates a `Parser` suitable for safely parsing user-input queries.

Fields refer to attributes of the indexed document which will be searchable with
`m:PhilomenaQuery.Search`.

Available options:
- `bool_fields` - a list of field names parsed as booleans
- `float_fields` - a list of field names parsed as floats
- `int_fields` - a list of field names parsed as signed integers
- `numeric_fields` - a list of field names parsed as numeric values (unsigned integers without fuzzing or range queries)
- `ip_fields` - a list of field names parsed as IP CIDR masks
- `literal_fields` - wildcardable fields which are searched as the exact value
- `ngram_fields` - wildcardable fields which are searched as stemmed values
- `custom_fields` - fields which do not exist on the document and are created by a callback
- `transforms` - a map of custom field names to transform functions
- `aliases` - a map of field names to the names they should have in the search engine
- `no_downcase_fields` - a list of field names which do not have string downcasing applied
- `normalizations` - a map of field names to normalization functions (see `t:normalizer/0`)
Fields refer to attributes of the indexed document which will be searchable
with `m:PhilomenaQuery.Search`. See the available options described in the
`t:options/0` typespec.

## Example

Expand All @@ -160,7 +193,7 @@ defmodule PhilomenaQuery.Parse.Parser do
Parser.new(options)

"""
@spec new(keyword()) :: t()
@spec new(options()) :: t()
def new(options) do
parser = struct(Parser, options)

Expand Down Expand Up @@ -205,10 +238,8 @@ defmodule PhilomenaQuery.Parse.Parser do
{:error, "Imbalanced parentheses."}

"""
@spec parse(t(), String.t(), context()) :: {:ok, query()} | {:error, String.t()}
def parse(parser, input, context \\ nil)

def parse(%Parser{} = parser, input, context) do
@spec parse(t(), String.t(), context()) :: result()
def parse(%Parser{} = parser, input, context \\ nil) do
parser = %{parser | __data__: context}

with {:ok, input} <- coerce_string(input),
Expand Down
5 changes: 4 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ defmodule Philomena.MixProject do

# Fixes for Elixir v1.15+
{:canary, "~> 1.1",
github: "marcinkoziej/canary", ref: "704debde7a2c0600f78c687807884bf37c45bd79"}
github: "marcinkoziej/canary", ref: "704debde7a2c0600f78c687807884bf37c45bd79"},

# Automated testing
{:assert_value, "~> 0.10.5", only: [:dev, :test]}
]
end

Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
%{
"assert_value": {:hex, :assert_value, "0.10.5", "b1d68243194ba3da490674ff53a9d9b8dfff411d5dec9fc113c9b82be76eeddf", [:mix], [], "hexpm", "ba89aecb2e886e55b2c7ef9973a153838976b2abd10a931fa2d41b74dfb27de6"},
"bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.1", "9f2e7e00f661a6acfae1431f1bc590e28698aaecc962c2a7b33150dfe9289c3d", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9f539e9d3828fad4ffc8152dadc0d27c6d78cb2853a9a1d6518cfe8a5adb7f50"},
"briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"},
Expand Down
38 changes: 38 additions & 0 deletions scripts/philomena.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,43 @@ function init {
"$(dirname "${BASH_SOURCE[0]}")/init.sh"
}

# Update the `queries.json` test snapshots after the implementation changes.
function update_search_syntax_tests {
# TODO: refactor this after the devcontainer PR is merged:
# https://github.com/philomena-dev/philomena/pull/528
# shellcheck disable=SC2016
test='
set -euo pipefail

. scripts/lib.sh

export MIX_ENV=test

step mix deps.get

if step createdb -h postgres -U postgres philomena_test; then
step mix ecto.create
step mix ecto.load
elif [[ "$(mix ecto.migrations)" == *" down "* ]]; then
step mix ecto.migrate --all
fi

step mix test test/philomena/images/query_test.exs
'

ASSERT_VALUE_ACCEPT_DIFFS=y step docker compose run \
--remove-orphans \
app bash -c "$test"

# See the git diff for the updated snapshot in vscode if it's available.
if command -v code &>/dev/null; then
step git difftool \
--no-prompt \
--extcmd "code --wait --diff" \
-- test/philomena/images/search-syntax.json
fi
}

subcommand="${1:-}"
shift || true

Expand All @@ -118,6 +155,7 @@ case "$subcommand" in
# Shortcut for `philomena exec docker compose`
compose) docker compose "$@" ;;

update-search-syntax-tests) update_search_syntax_tests "$@" ;;
*)
die "See the available sub-commands in ${BASH_SOURCE[0]}"
;;
Expand Down
80 changes: 80 additions & 0 deletions test/philomena/images/query_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
defmodule Philomena.Images.QueryTest do
alias Philomena.Labeled
alias Philomena.Users.User
alias Philomena.Filters.Filter
import Philomena.FiltersFixtures
import Philomena.TagsFixtures

use Philomena.SearchSyntaxCase

defp make_user(attrs) do
%User{
# Pretend that all users have the same ID. This doesn't influence the parser
# logic because it doesn't load the users from the DB.
id: 1,
watched_tag_ids: [],
watched_images_query_str: "",
watched_images_exclude_str: "",
no_spoilered_in_watched: false,
current_filter: %Filter{spoilered_tag_ids: [], spoilered_complex_str: ""}
}
|> Map.merge(attrs)
end

test "search syntax" do
users = [
Labeled.new(:anon, nil),
Labeled.new(:user, make_user(%{role: "user"})),
Labeled.new(:assistant, make_user(%{role: "assistant"})),
Labeled.new(:moderator, make_user(%{role: "moderator"})),
Labeled.new(:admin, make_user(%{role: "admin"}))
]

for id <- 10..14 do
tag_fixture(%{id: id, name: "tag#{id}"})
end

system_filter =
system_filter_fixture(%{
id: 100,
name: "System Filter",
spoilered_tag_list: "tag10,tag11",
hidden_tag_list: "tag12,tag13",
hidden_complex_str: "truly AND complex"
})

assert_search_syntax(%{
compile: &Philomena.Images.Query.compile/2,
snapshot: "#{__DIR__}/search-syntax.json",
contexts: %{
user: users,
watch: [true, false],
filter: [true, false]
},
test_cases: [
wildcard: [
"*",
"artist:*",
"artist:artist1"
],
operators: [
"tag1 OR tag2",
"tag1 AND tag2",
"tag1 AND (tag2 OR tag3)"
],
authenticated_user: [
"my:faves",
"my:watched"
],
system_filter: [
"filter_id:#{system_filter.id}"
],
invalid_filters: [
"filter_id:invalid_id",
# non-existent filter
"filter_id:9999"
]
]
})
end
end
Loading