Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 14 additions & 1 deletion assets/js/hooks/advancedQueryEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,8 @@ export default {
this.activeSuggestions = []
this.activeIndex = -1

this.editor.textContent = this.el.dataset.value || ""
this.appliedValue = this.el.dataset.value || ""
this.editor.textContent = this.appliedValue
this.highlight()
this.updateClearButtonVisibility()
this.applyRestingWidth({ animate: false })
Expand Down Expand Up @@ -424,6 +425,18 @@ export default {
updated() {
this.loadSchema()

// The server may apply a different query than what was typed (free text
// is rewritten to its `search like "%text%"` form) - re-sync the editor
// with the applied value when it changes, unless the user is mid-edit.
const applied = this.el.dataset.value || ""
if (applied !== this.appliedValue) {
this.appliedValue = applied
if (document.activeElement !== this.editor && applied !== this.value()) {
this.render(applied)
this.updateClearButtonVisibility()
}
}

// Keep the resting width in sync when the server patches the field (e.g.
// a query is applied or cleared) while it isn't being actively edited.
if (document.activeElement !== this.editor) {
Expand Down
87 changes: 81 additions & 6 deletions lib/nerves_hub/devices/advanced_query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,77 @@ defmodule NervesHub.Devices.AdvancedQuery do
Deliberately kept separate from `NervesHub.Devices.DeviceFiltering` (the
sidebar/basic search filters) so this can be tested and evolved in
isolation.

Input that doesn't look like a query expression at all (e.g. a pasted
device identifier) is treated as a free-text search rather than an error -
see `interpret/2`.
"""

alias NervesHub.Devices.AdvancedQuery.Compiler
alias NervesHub.Devices.AdvancedQuery.Parser
alias NervesHub.Devices.AdvancedQuery.Schema

@type ast :: Parser.ast()

@doc "Parses a raw query string into an AST, scoped to a product for value validation."
@spec parse(String.t(), pos_integer()) :: {:ok, ast} | {:error, String.t(), non_neg_integer()}
def parse(input, product_id), do: Parser.parse(input, product_id)

@doc """
Interprets raw search input as either a query expression or free text.

Input that parses is a query as usual. Input that fails to parse but still
looks like an attempted query expression (starts with a whitelisted column,
`not`, or `(`, or contains an operator symbol or quote) keeps its parse
error, so typos in real queries surface instead of silently matching
nothing. Anything else - a pasted device identifier, a tag, a firmware
version - is treated as free text and rewritten to `search like "%input%"`,
matching across the device's textual fields.

Returns `{:ok, canonical_query, ast}` where `canonical_query` is the input
itself for query expressions, or the rewritten `search like` form for free
text - callers surface the rewritten form so users see the query their
free text became.
"""
@spec interpret(String.t(), pos_integer()) :: {:ok, String.t(), ast} | {:error, String.t(), non_neg_integer()}
def interpret(input, product_id) when is_binary(input) do
input = String.trim(input)

case parse(input, product_id) do
{:ok, ast} ->
{:ok, input, ast}

{:error, _message, _position} = error ->
if query_attempt?(input) do
error
else
free_text_query(input, product_id)
end
end
end

@doc """
Applies a raw query string to a query as an additional `where` clause.

Assumes the query already has a `latest_connection` named binding (see
`NervesHub.Devices.common_filter_query/1`). If the query string is blank
or fails to parse, the query is returned unchanged - invalid advanced
`NervesHub.Devices.common_filter_query/1`). Free-text input is applied via
the `interpret/2` fallback; if the query string is blank or is an invalid
query expression, the query is returned unchanged - invalid advanced
queries don't affect the rest of the filter results.
"""
@spec apply_to_query(Ecto.Query.t(), String.t() | nil, pos_integer()) :: Ecto.Query.t()
def apply_to_query(query, nil, _product_id), do: query
def apply_to_query(query, "", _product_id), do: query

def apply_to_query(query, input, product_id) when is_binary(input) do
case parse(input, product_id) do
{:ok, ast} -> Compiler.apply_query(query, ast)
{:error, _message, _position} -> query
case String.trim(input) do
"" ->
query

input ->
case interpret(input, product_id) do
{:ok, _canonical, ast} -> Compiler.apply_query(query, ast)
{:error, _message, _position} -> query
end
end
end

Expand All @@ -59,4 +103,35 @@ defmodule NervesHub.Devices.AdvancedQuery do

defp ast_references?({:not, expr}, column), do: ast_references?(expr, column)
defp ast_references?({:comparison, col, _op, _value}, column), do: col == column

# Whether input that failed to parse still reads as an attempted query expression
# (whose error should surface rather than fall back to free text): it starts
# the only ways a valid expression can - with a whitelisted column, the NOT
# keyword, or an opening parenthesis - or it contains an operator symbol or
# quoted string, the telltale shape of a query with a typo.
defp query_attempt?(input) do
first_word =
input
|> String.split(~r/[\s=!<>]/, parts: 2)
|> hd()
|> String.downcase()

String.starts_with?(input, "(") or
first_word == "not" or
Schema.column?(first_word) or
String.contains?(input, ["=", "<", ">", "\""])
end

# Free text is wrapped in a quoted string (escaped per the lexer's rules) and
# surrounded with `%` wildcards so it matches as a case-insensitive substring.
defp free_text_query(input, product_id) do
escaped =
input
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")

query = ~s|search like "%#{escaped}%"|
{:ok, ast} = parse(query, product_id)
{:ok, query, ast}
end
end
21 changes: 21 additions & 0 deletions lib/nerves_hub/devices/advanced_query/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,27 @@ defmodule NervesHub.Devices.AdvancedQuery.Compiler do
defp comparison_dynamic("identifier", "like", value), do: dynamic([d], ilike(d.identifier, ^value))
defp comparison_dynamic("identifier", "not like", value), do: dynamic([d], not ilike(d.identifier, ^value))

# The virtual `search` column matches when any of the device's textual fields
# matches the pattern - the same fields the sidebar free-text search covers
# (see `NervesHub.Devices.DeviceFiltering`'s `:search` filter).
defp comparison_dynamic("search", "like", value) do
dynamic(
[d],
ilike(d.identifier, ^value) or
ilike(fragment("COALESCE(?->>'version', '')", d.firmware_metadata), ^value) or
ilike(fragment("COALESCE(?->>'platform', '')", d.firmware_metadata), ^value) or
fragment(
"string_array_to_string(COALESCE(?, ARRAY[]::text[]), ' ', ' ') ILIKE ?",
d.tags,
^value
)
)
end

defp comparison_dynamic("search", "not like", value) do
dynamic(not (^comparison_dynamic("search", "like", value)))
end

defp comparison_dynamic("platform", "=", value), do: dynamic([d], d.firmware_metadata["platform"] == ^value)
defp comparison_dynamic("platform", "!=", value), do: dynamic([d], d.firmware_metadata["platform"] != ^value)

Expand Down
10 changes: 9 additions & 1 deletion lib/nerves_hub/devices/advanced_query/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,21 @@ defmodule NervesHub.Devices.AdvancedQuery.Schema do
# Columns whose value is freeform text (a SQL LIKE pattern) rather than one of
# a predefined set - so any non-empty value is accepted and there is nothing
# to autosuggest.
@freeform_text_columns ["identifier"]
@freeform_text_columns ["identifier", "search"]

@columns %{
"identifier" => %{
operators: ["like", "not like"],
values: &__MODULE__.no_values/1
},
# Virtual column matching across the device's textual fields (identifier,
# firmware version, platform, tags). Also the target of the free-text
# fallback: input that isn't a query expression is rewritten to
# `search like "%input%"` (see `NervesHub.Devices.AdvancedQuery.interpret/2`).
"search" => %{
operators: ["like", "not like"],
values: &__MODULE__.no_values/1
},
"platform" => %{
operators: ["=", "!="],
values: &Devices.platforms/1
Expand Down
6 changes: 5 additions & 1 deletion lib/nerves_hub_web/components/advanced_search.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ defmodule NervesHubWeb.Components.AdvancedSearch do
`NervesHub.Devices.AdvancedQuery.Schema`. On focus it expands and reveals
hints/suggestions below, similar to a command palette. Collapses back to a
normal-looking search box on blur. Validation is always re-checked
server-side by `NervesHub.Devices.AdvancedQuery.parse/2` on apply - the
server-side by `NervesHub.Devices.AdvancedQuery.interpret/2` on apply - the
client-side tokenizer is for highlighting/suggestions only.

Plain text that isn't a query expression (e.g. a pasted device identifier)
is applied as a free-text search: the server rewrites it to
`search like "%text%"` and the editor re-syncs to show that form.
"""

use NervesHubWeb, :component
Expand Down
13 changes: 8 additions & 5 deletions lib/nerves_hub_web/live/devices/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -252,15 +252,18 @@ defmodule NervesHubWeb.Live.Devices.Index do
|> noreply()
end

# Free-text input is applied as its rewritten `search like "%text%"` form
# (the canonical query from `AdvancedQuery.interpret/2`), so the search box
# shows the query language equivalent of what was typed.
def handle_event("apply-advanced-query", %{"query" => raw_query}, %{assigns: %{current_scope: scope}} = socket) do
query = String.trim(raw_query)

case query != "" && AdvancedQuery.parse(query, scope.product.id) do
case query != "" && AdvancedQuery.interpret(query, scope.product.id) do
false ->
apply_advanced_query(socket, query)

{:ok, _ast} ->
apply_advanced_query(socket, query)
{:ok, canonical_query, _ast} ->
apply_advanced_query(socket, canonical_query)

{:error, message, _position} ->
socket
Expand Down Expand Up @@ -773,9 +776,9 @@ defmodule NervesHubWeb.Live.Devices.Index do
end

defp advanced_query_error(query, product_id) do
case query != "" && AdvancedQuery.parse(query, product_id) do
case query != "" && AdvancedQuery.interpret(query, product_id) do
false -> nil
{:ok, _ast} -> nil
{:ok, _canonical_query, _ast} -> nil
{:error, message, _position} -> message
end
end
Expand Down
21 changes: 21 additions & 0 deletions test/nerves_hub/devices/advanced_query/compiler_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,27 @@ defmodule NervesHub.Devices.AdvancedQuery.CompilerTest do
assert run(product, ~s|identifier not like "%nect%"|) == ["tagged", "untagged"]
end

test "search matches across identifier, firmware version, platform, and tags", %{
product: product,
platform: platform
} do
# identifier substring, case-insensitively
assert run(product, ~s|search like "%nect%"|) == ["connected", "never_connected"]
# "prod" only appears as a tag, on "tagged"
assert run(product, ~s|search like "%prod%"|) == ["tagged"]
assert run(product, ~s|search like "%PROD%"|) == ["tagged"]
# platform and firmware version are shared by every fixture device
all = ["connected", "never_connected", "tagged", "untagged"]
assert run(product, ~s|search like "%#{platform}%"|) == all
assert run(product, ~s|search like "%1.0.0%"|) == all
assert run(product, ~s|search like "%no-such-text%"|) == []
end

test "search not like excludes matches from any field, including devices with no tags", %{product: product} do
assert run(product, ~s|search not like "%nect%"|) == ["tagged", "untagged"]
assert run(product, ~s|search not like "%prod%"|) == ["connected", "never_connected", "untagged"]
end

test "tags contains", %{product: product} do
assert run(product, ~s|tags contains "prod"|) == ["tagged"]
end
Expand Down
4 changes: 4 additions & 0 deletions test/nerves_hub/devices/advanced_query/schema_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule NervesHub.Devices.AdvancedQuery.SchemaTest do
assert Schema.column?("platform")
assert Schema.column?("tags")
assert Schema.column?("deployment_group")
assert Schema.column?("search")
refute Schema.column?("bogus")
refute Schema.column?("")
end
Expand All @@ -28,6 +29,7 @@ defmodule NervesHub.Devices.AdvancedQuery.SchemaTest do
assert Schema.operators("tags") == ["contains", "not_contains"]
assert Schema.operators("update_status") == ["is", "is not"]
assert Schema.operators("identifier") == ["like", "not like"]
assert Schema.operators("search") == ["like", "not like"]
end

test "returns numeric operators for metric columns" do
Expand Down Expand Up @@ -59,6 +61,8 @@ defmodule NervesHub.Devices.AdvancedQuery.SchemaTest do
assert Schema.value?("identifier", "anything", 1)
assert Schema.value?("identifier", "%wild_card%", 1)
refute Schema.value?("identifier", "", 1)
assert Schema.value?("search", "%anything%", 1)
refute Schema.value?("search", "", 1)
end

test "connection_type accepts the network interfaces and unknown" do
Expand Down
Loading
Loading