Skip to content

Commit

Permalink
Add new text pattern parameter
Browse files Browse the repository at this point in the history
  • Loading branch information
Ezra Odio committed Jul 24, 2024
1 parent c244e75 commit 142b872
Show file tree
Hide file tree
Showing 12 changed files with 205 additions and 0 deletions.
28 changes: 28 additions & 0 deletions client/app/components/EditParameterSettingsDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import QuerySelector from "@/components/QuerySelector";
import { Query } from "@/services/query";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
import "./EditParameterSettingsDialog.less";

const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
Expand Down Expand Up @@ -71,6 +72,8 @@ function EditParameterSettingsDialog(props) {
const [param, setParam] = useState(clone(props.parameter));
const [isNameValid, setIsNameValid] = useState(true);
const [initialQuery, setInitialQuery] = useState();
const [userInput, setUserInput] = useState(param.regex || "");
const [isValidRegex, setIsValidRegex] = useState(true);

const isNew = !props.parameter.name;

Expand Down Expand Up @@ -114,6 +117,17 @@ function EditParameterSettingsDialog(props) {

const paramFormId = useUniqueId("paramForm");

const handleRegexChange = e => {
setUserInput(e.target.value);
try {
new RegExp(e.target.value);
setParam({ ...param, regex: e.target.value });
setIsValidRegex(true);
} catch (error) {
setIsValidRegex(false);
}
};

return (
<Modal
{...props.dialog.props}
Expand Down Expand Up @@ -155,6 +169,7 @@ function EditParameterSettingsDialog(props) {
<Option value="text" data-test="TextParameterTypeOption">
Text
</Option>
<Option value="text-pattern">Text Pattern</Option>
<Option value="number" data-test="NumberParameterTypeOption">
Number
</Option>
Expand All @@ -180,6 +195,19 @@ function EditParameterSettingsDialog(props) {
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
</Select>
</Form.Item>
{param.type === "text-pattern" && (
<Form.Item
label="Regex"
help={!isValidRegex ? "Invalid Regex Pattern" : "Valid Regex Pattern"}
{...formItemProps}>
<Input
value={userInput}
onChange={handleRegexChange}
className={!isValidRegex ? "input-error" : ""}
data-test="RegexPatternInput"
/>
</Form.Item>
)}
{param.type === "enum" && (
<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
<Input.TextArea
Expand Down
3 changes: 3 additions & 0 deletions client/app/components/EditParameterSettingsDialog.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.input-error {
border-color: red !important;
}
1 change: 1 addition & 0 deletions client/app/components/ParameterMappingInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export class ParameterMappingInput extends React.Component {
queryId={mapping.param.queryId}
parameter={mapping.param}
onSelect={value => this.updateParamMapping({ value })}
regex={mapping.param.regex}
/>
);
}
Expand Down
23 changes: 23 additions & 0 deletions client/app/components/ParameterValueInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParamet
import QueryBasedParameterInput from "./QueryBasedParameterInput";

import "./ParameterValueInput.less";
import Tooltip from "./Tooltip";

const multipleValuesProps = {
maxTagCount: 3,
Expand All @@ -25,6 +26,7 @@ class ParameterValueInput extends React.Component {
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
className: PropTypes.string,
regex: PropTypes.string,
};

static defaultProps = {
Expand All @@ -35,6 +37,7 @@ class ParameterValueInput extends React.Component {
parameter: null,
onSelect: () => {},
className: "",
regex: "",
};

constructor(props) {
Expand Down Expand Up @@ -145,6 +148,24 @@ class ParameterValueInput extends React.Component {
);
}

renderTextPatternInput() {
const { className } = this.props;
const { value } = this.state;

return (
<React.Fragment>
<Tooltip title={`Regex to match: ${this.props.regex}`} placement="right">
<Input
className={className}
value={value}
aria-label="Parameter text pattern value"
onChange={e => this.onSelect(e.target.value)}
/>
</Tooltip>
</React.Fragment>
);
}

renderTextInput() {
const { className } = this.props;
const { value } = this.state;
Expand Down Expand Up @@ -177,6 +198,8 @@ class ParameterValueInput extends React.Component {
return this.renderQueryBasedInput();
case "number":
return this.renderNumberInput();
case "text-pattern":
return this.renderTextPatternInput();
default:
return this.renderTextInput();
}
Expand Down
1 change: 1 addition & 0 deletions client/app/components/Parameters.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export default class Parameters extends React.Component {
enumOptions={param.enumOptions}
queryId={param.queryId}
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)}
regex={param.regex}
/>
</div>
);
Expand Down
29 changes: 29 additions & 0 deletions client/app/services/parameters/TextPatternParameter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { toString, isNull } from "lodash";
import Parameter from "./Parameter";

class TextPatternParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.regex = parameter.regex;
this.setValue(parameter.value);
}

// eslint-disable-next-line class-methods-use-this
normalizeValue(value) {
const normalizedValue = toString(value);
if (isNull(normalizedValue)) {
return null;
}

var re = new RegExp(this.regex);

if (re !== null) {
if (re.test(normalizedValue)) {
return normalizedValue;
}
}
return null;
}
}

export default TextPatternParameter;
4 changes: 4 additions & 0 deletions client/app/services/parameters/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import EnumParameter from "./EnumParameter";
import QueryBasedDropdownParameter from "./QueryBasedDropdownParameter";
import DateParameter from "./DateParameter";
import DateRangeParameter from "./DateRangeParameter";
import TextPatternParameter from "./TextPatternParameter";

function createParameter(param, parentQueryId) {
switch (param.type) {
Expand All @@ -22,6 +23,8 @@ function createParameter(param, parentQueryId) {
case "datetime-range":
case "datetime-range-with-seconds":
return new DateRangeParameter(param, parentQueryId);
case "text-pattern":
return new TextPatternParameter({ ...param, type: "text-pattern" }, parentQueryId);
default:
return new TextParameter({ ...param, type: "text" }, parentQueryId);
}
Expand All @@ -34,6 +37,7 @@ function cloneParameter(param) {
export {
Parameter,
TextParameter,
TextPatternParameter,
NumberParameter,
EnumParameter,
QueryBasedDropdownParameter,
Expand Down
2 changes: 2 additions & 0 deletions client/app/services/parameters/tests/Parameter.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
createParameter,
TextParameter,
TextPatternParameter,
NumberParameter,
EnumParameter,
QueryBasedDropdownParameter,
Expand All @@ -12,6 +13,7 @@ describe("Parameter", () => {
describe("create", () => {
const parameterTypes = [
["text", TextParameter],
["text-pattern", TextPatternParameter],
["number", NumberParameter],
["enum", EnumParameter],
["query", QueryBasedDropdownParameter],
Expand Down
21 changes: 21 additions & 0 deletions client/app/services/parameters/tests/TextPatternParameter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createParameter } from "..";

describe("TextPatternParameter", () => {
let param;

beforeEach(() => {
param = createParameter({ name: "param", title: "Param", type: "text-pattern", regex: "a+" });
});

describe("noramlizeValue", () => {
test("converts matching strings", () => {
const normalizedValue = param.normalizeValue("art");
expect(normalizedValue).toBe("art");
});

test("returns null when string does not match pattern", () => {
const normalizedValue = param.normalizeValue("brt");
expect(normalizedValue).toBeNull();
});
});
});
65 changes: 65 additions & 0 deletions client/cypress/integration/query/parameter_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,71 @@ describe("Parameter", () => {
});
});

describe("Text Pattern Parameter", () => {
beforeEach(() => {
const queryData = {
name: "Text Pattern Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "text-pattern", regex: "a+" }],
},
};

cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
});

it("updates the results after clicking Apply", () => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("{selectall}art");

cy.getByTestId("ParameterApplyButton").click();

cy.getByTestId("TableVisualization").should("contain", "art");

cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("{selectall}around");

cy.getByTestId("ParameterApplyButton").click();

cy.getByTestId("TableVisualization").should("contain", "around");
});

it("throws error message with invalid query request", () => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("{selectall}art");

cy.getByTestId("ParameterApplyButton").click();

cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("{selectall}test");

cy.getByTestId("ParameterApplyButton").click();

cy.getByTestId("QueryExecutionStatus").should("exist");
});

it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("{selectall}art");
});
});

it("doesn't let user save invalid regex", () => {
cy.get(".fa-cog").click();
cy.getByTestId("RegexPatternInput").type("{selectall}[");
cy.contains("Invalid Regex Pattern").should("exist");
cy.getByTestId("SaveParameterSettings").click();
cy.get(".fa-cog").click();
cy.getByTestId("RegexPatternInput").should("not.equal", "[");
});
});

describe("Number Parameter", () => {
beforeEach(() => {
const queryData = {
Expand Down
13 changes: 13 additions & 0 deletions redash/models/parameterized_query.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from functools import partial
from numbers import Number

Expand Down Expand Up @@ -88,6 +89,16 @@ def _is_number(string):
return True


def _is_regex_pattern(value, regex):
try:
if re.compile(regex).fullmatch(value):
return True
else:
return False
except re.error:
return False


def _is_date(string):
parse(string)
return True
Expand Down Expand Up @@ -135,13 +146,15 @@ def _valid(self, name, value):

enum_options = definition.get("enumOptions")
query_id = definition.get("queryId")
regex = definition.get("regex")
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)

if isinstance(enum_options, str):
enum_options = enum_options.split("\n")

validators = {
"text": lambda value: isinstance(value, str),
"text-pattern": lambda value: _is_regex_pattern(value, regex),
"number": _is_number,
"enum": lambda value: _is_value_within_options(value, enum_options, allow_multiple_values),
"query": lambda value: _is_value_within_options(
Expand Down
15 changes: 15 additions & 0 deletions tests/models/test_parameterized_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ def test_validates_text_parameters(self):

self.assertEqual("foo baz", query.text)

def test_validates_text_pattern_parameters(self):
schema = [{"name": "bar", "type": "text-pattern", "regex": "a+"}]
query = ParameterizedQuery("foo {{bar}}", schema)

query.apply({"bar": "a"})

self.assertEqual("foo a", query.text)

def test_raises_on_invalid_text_pattern_parameters(self):
schema = schema = [{"name": "bar", "type": "text-pattern", "regex": "a+"}]
query = ParameterizedQuery("foo {{bar}}", schema)

with pytest.raises(InvalidParameterError):
query.apply({"bar": "b"})

def test_raises_on_invalid_number_parameters(self):
schema = [{"name": "bar", "type": "number"}]
query = ParameterizedQuery("foo", schema)
Expand Down

0 comments on commit 142b872

Please sign in to comment.