From 27e4d6c2370182355df1b19f2bc525153f48d8ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:41:20 +0000 Subject: [PATCH 01/28] Add Python and TypeScript SDKs with workflows, update gitignore and README Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/47b9b2eb-25e1-4072-9064-c9ef69d5ba78 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- .github/workflows/python-delivery.yml | 58 + .github/workflows/python-integration.yml | 56 + .github/workflows/typescript-delivery.yml | 57 + .github/workflows/typescript-integration.yml | 59 + .gitignore | 13 + README.md | 59 + src/python/.flake8 | 3 + src/python/datafilters/__init__.py | 44 + src/python/datafilters/builder.py | 129 + src/python/datafilters/expressions.py | 130 + src/python/datafilters/parser.py | 162 + src/python/datafilters/serializers.py | 89 + src/python/pyproject.toml | 53 + src/python/requirements-dev.txt | 5 + src/python/setup.py | 4 + src/python/tests/__init__.py | 1 + src/python/tests/test_builder.py | 84 + src/python/tests/test_expressions.py | 124 + src/python/tests/test_parser.py | 129 + src/typescript/.eslintrc.js | 17 + src/typescript/.npmignore | 5 + src/typescript/jest.config.js | 12 + src/typescript/package-lock.json | 5151 ++++++++++++++++++ src/typescript/package.json | 40 + src/typescript/src/builder.ts | 131 + src/typescript/src/expressions.ts | 107 + src/typescript/src/index.ts | 27 + src/typescript/src/parser.ts | 164 + src/typescript/src/serializers.ts | 74 + src/typescript/test/builder.test.ts | 89 + src/typescript/test/expressions.test.ts | 128 + src/typescript/test/parser.test.ts | 136 + src/typescript/tsconfig.json | 22 + 33 files changed, 7362 insertions(+) create mode 100644 .github/workflows/python-delivery.yml create mode 100644 .github/workflows/python-integration.yml create mode 100644 .github/workflows/typescript-delivery.yml create mode 100644 .github/workflows/typescript-integration.yml create mode 100644 src/python/.flake8 create mode 100644 src/python/datafilters/__init__.py create mode 100644 src/python/datafilters/builder.py create mode 100644 src/python/datafilters/expressions.py create mode 100644 src/python/datafilters/parser.py create mode 100644 src/python/datafilters/serializers.py create mode 100644 src/python/pyproject.toml create mode 100644 src/python/requirements-dev.txt create mode 100644 src/python/setup.py create mode 100644 src/python/tests/__init__.py create mode 100644 src/python/tests/test_builder.py create mode 100644 src/python/tests/test_expressions.py create mode 100644 src/python/tests/test_parser.py create mode 100644 src/typescript/.eslintrc.js create mode 100644 src/typescript/.npmignore create mode 100644 src/typescript/jest.config.js create mode 100644 src/typescript/package-lock.json create mode 100644 src/typescript/package.json create mode 100644 src/typescript/src/builder.ts create mode 100644 src/typescript/src/expressions.ts create mode 100644 src/typescript/src/index.ts create mode 100644 src/typescript/src/parser.ts create mode 100644 src/typescript/src/serializers.ts create mode 100644 src/typescript/test/builder.test.ts create mode 100644 src/typescript/test/expressions.test.ts create mode 100644 src/typescript/test/parser.test.ts create mode 100644 src/typescript/tsconfig.json diff --git a/.github/workflows/python-delivery.yml b/.github/workflows/python-delivery.yml new file mode 100644 index 00000000..4f3d66d7 --- /dev/null +++ b/.github/workflows/python-delivery.yml @@ -0,0 +1,58 @@ +name: python-delivery + +on: + push: + branches: + - main + - 'release/*' + paths: + - 'src/python/**' + - '.github/workflows/python-*.yml' + pull_request: + paths: + - 'src/python/**' + - '.github/workflows/python-*.yml' + +jobs: + python-deploy: + name: Build and publish Python package + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: 'src/python/requirements-dev.txt' + + - name: Install dependencies + run: | + cd src/python + pip install -e ".[dev]" + + - name: Lint with flake8 + run: | + cd src/python + flake8 datafilters tests + + - name: Test with pytest + run: | + cd src/python + pytest tests/ + + - name: Install build tools + run: python -m pip install build twine + + - name: Build package + run: | + cd src/python + python -m build + + - name: Publish to PyPI + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: src/python/dist/ diff --git a/.github/workflows/python-integration.yml b/.github/workflows/python-integration.yml new file mode 100644 index 00000000..df8433db --- /dev/null +++ b/.github/workflows/python-integration.yml @@ -0,0 +1,56 @@ +name: python-integration + +on: + push: + branches-ignore: + - main + paths: + - 'src/python/**' + - '.github/workflows/python-*.yml' + pull_request: + paths: + - 'src/python/**' + - '.github/workflows/python-*.yml' + +jobs: + python: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-24.04 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'src/python/requirements-dev.txt' + + - name: Install dependencies + run: | + cd src/python + pip install -e ".[dev]" + + - name: Lint with flake8 + run: | + cd src/python + flake8 datafilters tests + + - name: Test with pytest + run: | + cd src/python + pytest --cov=datafilters --cov-report=xml tests/ + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: ./src/python/coverage.xml + flags: python + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/typescript-delivery.yml b/.github/workflows/typescript-delivery.yml new file mode 100644 index 00000000..c9ce5732 --- /dev/null +++ b/.github/workflows/typescript-delivery.yml @@ -0,0 +1,57 @@ +name: typescript-delivery + +on: + push: + branches: + - main + - 'release/*' + paths: + - 'src/typescript/**' + - '.github/workflows/typescript-*.yml' + pull_request: + paths: + - 'src/typescript/**' + - '.github/workflows/typescript-*.yml' + +jobs: + typescript-deploy: + name: Build and publish TypeScript package + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'src/typescript/package-lock.json' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: | + cd src/typescript + npm ci + + - name: Lint + run: | + cd src/typescript + npm run lint + + - name: Build + run: | + cd src/typescript + npm run build + + - name: Test + run: | + cd src/typescript + npm test + + - name: Publish to npm + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) + run: | + cd src/typescript + npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/typescript-integration.yml b/.github/workflows/typescript-integration.yml new file mode 100644 index 00000000..11e4a2dd --- /dev/null +++ b/.github/workflows/typescript-integration.yml @@ -0,0 +1,59 @@ +name: typescript-integration + +on: + push: + branches-ignore: + - main + paths: + - 'src/typescript/**' + - '.github/workflows/typescript-*.yml' + pull_request: + paths: + - 'src/typescript/**' + - '.github/workflows/typescript-*.yml' + +jobs: + typescript: + name: Node ${{ matrix.node-version }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + node-version: ['18', '20', '22'] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: 'src/typescript/package-lock.json' + + - name: Install dependencies + run: | + cd src/typescript + npm ci + + - name: Lint + run: | + cd src/typescript + npm run lint + + - name: Build + run: | + cd src/typescript + npm run build + + - name: Test + run: | + cd src/typescript + npm run test:cov + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: ./src/typescript/coverage/lcov.info + flags: typescript + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 5ee126ad..903096b6 100644 --- a/.gitignore +++ b/.gitignore @@ -260,6 +260,19 @@ paket-files/ __pycache__/ *.pyc +# Python SDK +*.egg-info/ +.pytest_cache/ +.coverage +src/python/dist/ +src/python/build/ + +# TypeScript/Node SDK +src/typescript/node_modules/ +src/typescript/dist/ +src/typescript/coverage/ +src/typescript/.eslintcache + # VS code .vscode/ output/ diff --git a/README.md b/README.md index d521e42a..0fa23bde 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ The public API SHOULD NOT be considered stable. - [Using Dev Container](#using-dev-container) - [Local setup](#local-setup) - [Build](#build) +- [Monorepo structure](#monorepo-structure) + - [Python SDK](#python-sdk) + - [TypeScript SDK](#typescript-sdk) The idea came to me when working on a set of REST APIs and trying to build `/search` endpoints. I wanted to have a uniform way to query a collection of resources whilst abstracting away underlying datasources. @@ -610,6 +613,62 @@ You can find more info on that directly in the Github repository. | [![Nuget](https://img.shields.io/nuget/v/DataFilters.Expressions?label=Datafilters.Expressions&color=blue)](https://www.nuget.org/packages/DataFilters.Expressions) | ![DataFilters.Expressions download count](https://img.shields.io/nuget/dt/Datafilters.Expressions?label=&color=blue) | adds `ToExpression()` extension method on top of [IFilter][class-ifilter] instance to convert it to an equivalent `System.Linq.Expressions.Expression>` instance. | | [![Nuget](https://img.shields.io/nuget/v/Datafilters.Queries?label=DataFilters.Queries&color=blue)](https://www.nuget.org/packages/DataFilters.Queries) | ![DataFilters.Queries download count](https://img.shields.io/nuget/dt/Datafilters.Queries?label=&color=blue) | adds `ToWhere()` extension method on top of [IFilter][class-ifilter] instance to convert it to an equivalent [`IWhereClause`](https://github.com/candoumbe/Queries/blob/develop/src/Queries.Core/Parts/Clauses/IWhereClause.cs) instance. | +## Monorepo structure + +This repository is organised as a monorepo that hosts independent SDKs for multiple languages alongside the original C# library. + +``` +src/ +├── DataFilters/ # C# library (NuGet) +├── python/ # Python SDK (PyPI) +│ ├── datafilters/ +│ ├── tests/ +│ └── pyproject.toml +└── typescript/ # TypeScript SDK (npm) + ├── src/ + ├── test/ + └── package.json +``` + +Each language has its own independent CI/CD pipelines triggered only when its files change: + +| Language | Integration workflow | Delivery workflow | +|------------|----------------------------------|-------------------------------| +| C# | `integration.yml` | `delivery.yml` | +| Python | `python-integration.yml` | `python-delivery.yml` | +| TypeScript | `typescript-integration.yml` | `typescript-delivery.yml` | + +### Python SDK + +```bash +# Install for development +cd src/python +pip install -e ".[dev]" + +# Run tests +pytest tests/ + +# Lint +flake8 datafilters tests +``` + +### TypeScript SDK + +```bash +# Install dependencies +cd src/typescript +npm ci + +# Build +npm run build + +# Run tests +npm test + +# Lint +npm run lint +``` + ## Getting started (Development) ### Prerequisites diff --git a/src/python/.flake8 b/src/python/.flake8 new file mode 100644 index 00000000..de2bbb09 --- /dev/null +++ b/src/python/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 100 +extend-ignore = E203,W503 diff --git a/src/python/datafilters/__init__.py b/src/python/datafilters/__init__.py new file mode 100644 index 00000000..f80e3f90 --- /dev/null +++ b/src/python/datafilters/__init__.py @@ -0,0 +1,44 @@ +"""DataFilters Python SDK. + +A library to generate filter expressions compatible with the DataFilters C# library, +using an Elasticsearch-inspired query syntax. +""" + +from .builder import FilterBuilder +from .expressions import ( + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + IFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +) +from .parser import parse +from .serializers import to_dict, to_json + +__all__ = [ + "IFilter", + "EqualsFilter", + "ContainsFilter", + "StartsWithFilter", + "EndsWithFilter", + "GreaterThanFilter", + "GreaterThanOrEqualFilter", + "LessThanFilter", + "LessThanOrEqualFilter", + "AndFilter", + "OrFilter", + "NotFilter", + "FilterBuilder", + "parse", + "to_dict", + "to_json", +] + +__version__ = "0.1.0" diff --git a/src/python/datafilters/builder.py b/src/python/datafilters/builder.py new file mode 100644 index 00000000..a807a678 --- /dev/null +++ b/src/python/datafilters/builder.py @@ -0,0 +1,129 @@ +"""Fluent builder API for constructing DataFilters filter expressions.""" + +from typing import Any, List + +from .expressions import ( + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + IFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +) + + +class FilterBuilder: + """Fluent builder for constructing compound filter expressions. + + Example usage:: + + builder = FilterBuilder() + f = (builder + .where("name").contains("bat") + .and_where("age").gte(18) + .build()) + """ + + def __init__(self) -> None: + self._filters: List[IFilter] = [] + + def where(self, field: str) -> "_FieldBuilder": + """Start a filter condition for the given field. + + Args: + field: The name of the field to filter on. + + Returns: + A ``_FieldBuilder`` scoped to *field*. + """ + return _FieldBuilder(self, field) + + def _add(self, filter: IFilter) -> "FilterBuilder": + """Append a filter to the current list.""" + self._filters.append(filter) + return self + + def and_where(self, field: str) -> "_FieldBuilder": + """Chain an additional AND condition on a new field. + + Args: + field: The name of the field for the next condition. + + Returns: + A ``_FieldBuilder`` scoped to *field*. + """ + return _FieldBuilder(self, field) + + def build(self) -> IFilter: + """Construct and return the final IFilter. + + Returns: + A single IFilter or an AndFilter combining all accumulated conditions. + + Raises: + ValueError: If no conditions have been added. + """ + if not self._filters: + raise ValueError("No filter conditions have been added.") + + if len(self._filters) == 1: + result = self._filters[0] + else: + result = AndFilter(self._filters) + + return result + + +class _FieldBuilder: + """Internal helper that provides filter-condition methods for a single field.""" + + def __init__(self, parent: FilterBuilder, field: str) -> None: + self._parent = parent + self._field = field + + def eq(self, value: Any) -> FilterBuilder: + """Add an equals condition.""" + return self._parent._add(EqualsFilter(self._field, value)) + + def contains(self, value: str) -> FilterBuilder: + """Add a contains condition.""" + return self._parent._add(ContainsFilter(self._field, value)) + + def starts_with(self, value: str) -> FilterBuilder: + """Add a starts-with condition.""" + return self._parent._add(StartsWithFilter(self._field, value)) + + def ends_with(self, value: str) -> FilterBuilder: + """Add an ends-with condition.""" + return self._parent._add(EndsWithFilter(self._field, value)) + + def gt(self, value: Any) -> FilterBuilder: + """Add a greater-than condition.""" + return self._parent._add(GreaterThanFilter(self._field, value)) + + def gte(self, value: Any) -> FilterBuilder: + """Add a greater-than-or-equal condition.""" + return self._parent._add(GreaterThanOrEqualFilter(self._field, value)) + + def lt(self, value: Any) -> FilterBuilder: + """Add a less-than condition.""" + return self._parent._add(LessThanFilter(self._field, value)) + + def lte(self, value: Any) -> FilterBuilder: + """Add a less-than-or-equal condition.""" + return self._parent._add(LessThanOrEqualFilter(self._field, value)) + + def not_(self, value: Any) -> FilterBuilder: + """Add a not-equals condition.""" + return self._parent._add(NotFilter(EqualsFilter(self._field, value))) + + def or_(self, *values: Any) -> FilterBuilder: + """Add an OR condition matching any of the given values.""" + or_filters: List[IFilter] = [EqualsFilter(self._field, v) for v in values] + return self._parent._add(OrFilter(or_filters)) diff --git a/src/python/datafilters/expressions.py b/src/python/datafilters/expressions.py new file mode 100644 index 00000000..947f5038 --- /dev/null +++ b/src/python/datafilters/expressions.py @@ -0,0 +1,130 @@ +"""Filter expression classes compatible with the DataFilters C# library.""" + +from abc import ABC, abstractmethod +from typing import Any, List + + +class IFilter(ABC): + """Base interface for all filter expressions.""" + + @abstractmethod + def to_dict(self) -> dict: + """Serialize the filter to a dictionary representation.""" + + +class EqualsFilter(IFilter): + """Matches records where a property equals a specific value.""" + + def __init__(self, field: str, value: Any) -> None: + self.field = field + self.value = value + + def to_dict(self) -> dict: + return {"field": self.field, "op": "eq", "value": self.value} + + +class ContainsFilter(IFilter): + """Matches records where a property contains a substring.""" + + def __init__(self, field: str, value: str) -> None: + self.field = field + self.value = value + + def to_dict(self) -> dict: + return {"field": self.field, "op": "contains", "value": self.value} + + +class StartsWithFilter(IFilter): + """Matches records where a property starts with a specific string.""" + + def __init__(self, field: str, value: str) -> None: + self.field = field + self.value = value + + def to_dict(self) -> dict: + return {"field": self.field, "op": "startswith", "value": self.value} + + +class EndsWithFilter(IFilter): + """Matches records where a property ends with a specific string.""" + + def __init__(self, field: str, value: str) -> None: + self.field = field + self.value = value + + def to_dict(self) -> dict: + return {"field": self.field, "op": "endswith", "value": self.value} + + +class GreaterThanFilter(IFilter): + """Matches records where a property is greater than a value.""" + + def __init__(self, field: str, value: Any) -> None: + self.field = field + self.value = value + + def to_dict(self) -> dict: + return {"field": self.field, "op": "gt", "value": self.value} + + +class GreaterThanOrEqualFilter(IFilter): + """Matches records where a property is greater than or equal to a value.""" + + def __init__(self, field: str, value: Any) -> None: + self.field = field + self.value = value + + def to_dict(self) -> dict: + return {"field": self.field, "op": "gte", "value": self.value} + + +class LessThanFilter(IFilter): + """Matches records where a property is less than a value.""" + + def __init__(self, field: str, value: Any) -> None: + self.field = field + self.value = value + + def to_dict(self) -> dict: + return {"field": self.field, "op": "lt", "value": self.value} + + +class LessThanOrEqualFilter(IFilter): + """Matches records where a property is less than or equal to a value.""" + + def __init__(self, field: str, value: Any) -> None: + self.field = field + self.value = value + + def to_dict(self) -> dict: + return {"field": self.field, "op": "lte", "value": self.value} + + +class AndFilter(IFilter): + """Combines multiple filters with a logical AND.""" + + def __init__(self, filters: List[IFilter]) -> None: + self.filters = filters + + def to_dict(self) -> dict: + return {"logic": "and", "filters": [f.to_dict() for f in self.filters]} + + +class OrFilter(IFilter): + """Combines multiple filters with a logical OR.""" + + def __init__(self, filters: List[IFilter]) -> None: + self.filters = filters + + def to_dict(self) -> dict: + return {"logic": "or", "filters": [f.to_dict() for f in self.filters]} + + +class NotFilter(IFilter): + """Negates a filter expression.""" + + def __init__(self, filter: IFilter) -> None: + self.filter = filter + + def to_dict(self) -> dict: + return {"logic": "not", "filters": [self.filter.to_dict()]} diff --git a/src/python/datafilters/parser.py b/src/python/datafilters/parser.py new file mode 100644 index 00000000..be2c559a --- /dev/null +++ b/src/python/datafilters/parser.py @@ -0,0 +1,162 @@ +"""Parser for DataFilters query syntax strings. + +Parses an Elasticsearch-inspired filter syntax string into IFilter objects. + +Supported syntax examples: + - ``field=value`` → EqualsFilter + - ``field=value*`` → StartsWithFilter + - ``field=*value`` → EndsWithFilter + - ``field=*value*`` → ContainsFilter + - ``field=!value`` → NotFilter(EqualsFilter) + - ``field=[min,max]`` → AndFilter(gte, lte) + - ``field=[min,]`` → GreaterThanOrEqualFilter + - ``field=[,max]`` → LessThanOrEqualFilter + - ``field=(min,max)`` → AndFilter(gt, lt) + - ``expr1,expr2`` → AndFilter of multiple criteria + - ``expr1|expr2`` → OrFilter of multiple criteria +""" + +import re +from typing import List, Optional + +from .expressions import ( + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + IFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +) + +_RANGE_PATTERN = re.compile( + r"^(?P[\[\(])(?P[^,]*),(?P[^)\]]*)?(?P[\]\)])$" +) + + +def _parse_value_expression(field: str, value: str) -> IFilter: + """Parse a single field=value expression into the appropriate IFilter.""" + negated = value.startswith("!") + if negated: + value = value[1:] + + range_match = _RANGE_PATTERN.match(value) + if range_match: + result = _parse_range(field, range_match) + elif value.startswith("*") and value.endswith("*") and len(value) > 1: + result = ContainsFilter(field, value[1:-1]) + elif value.startswith("*"): + result = EndsWithFilter(field, value[1:]) + elif value.endswith("*"): + result = StartsWithFilter(field, value[:-1]) + else: + result = EqualsFilter(field, value) + + if negated: + result = NotFilter(result) + + return result + + +def _parse_range(field: str, match: re.Match) -> IFilter: + """Parse a range expression like [min,max], (min,max), [min,], [,max] into IFilter(s).""" + open_bracket = match.group("open") + close_bracket = match.group("close") + min_val: Optional[str] = match.group("min") or None + max_val: Optional[str] = match.group("max") or None + + filters: List[IFilter] = [] + + if min_val is not None: + if open_bracket == "[": + filters.append(GreaterThanOrEqualFilter(field, min_val)) + else: + filters.append(GreaterThanFilter(field, min_val)) + + if max_val is not None: + if close_bracket == "]": + filters.append(LessThanOrEqualFilter(field, max_val)) + else: + filters.append(LessThanFilter(field, max_val)) + + if len(filters) == 1: + return filters[0] + + return AndFilter(filters) + + +def parse(expression: str) -> IFilter: + """Parse a DataFilters query string into an IFilter. + + Args: + expression: A filter expression string, e.g. ``"name=*bat*,age=[18,]"``. + + Returns: + An IFilter representing the parsed expression. + + Raises: + ValueError: If the expression cannot be parsed. + """ + if not expression or not expression.strip(): + raise ValueError("Expression must not be empty") + + expression = expression.strip() + + or_parts = expression.split("|") + if len(or_parts) > 1: + parsed_parts = [_parse_and_expression(part.strip()) for part in or_parts] + return OrFilter(parsed_parts) + + return _parse_and_expression(expression) + + +def _split_and_parts(expression: str) -> list: + """Split an expression on commas that are not inside brackets.""" + parts = [] + depth = 0 + current: list = [] + for char in expression: + if char in "([": + depth += 1 + current.append(char) + elif char in ")]": + depth -= 1 + current.append(char) + elif char == "," and depth == 0: + parts.append("".join(current)) + current = [] + else: + current.append(char) + if current: + parts.append("".join(current)) + return parts + + +def _parse_and_expression(expression: str) -> IFilter: + """Parse a comma-separated AND expression.""" + and_parts = _split_and_parts(expression) + if len(and_parts) > 1: + parsed_parts = [_parse_single(part.strip()) for part in and_parts] + return AndFilter(parsed_parts) + + return _parse_single(expression) + + +def _parse_single(expression: str) -> IFilter: + """Parse a single field=value pair.""" + if "=" not in expression: + raise ValueError(f"Invalid filter expression: '{expression}'. Expected 'field=value'.") + + field, _, value = expression.partition("=") + field = field.strip() + value = value.strip() + + if not field: + raise ValueError(f"Field name must not be empty in expression: '{expression}'") + + return _parse_value_expression(field, value) diff --git a/src/python/datafilters/serializers.py b/src/python/datafilters/serializers.py new file mode 100644 index 00000000..0e497bf5 --- /dev/null +++ b/src/python/datafilters/serializers.py @@ -0,0 +1,89 @@ +"""Serialization utilities for DataFilters expressions.""" + +import json + +from .expressions import IFilter + + +def to_dict(filter: IFilter) -> dict: + """Serialize an IFilter to a plain Python dictionary. + + Args: + filter: The filter expression to serialize. + + Returns: + A dictionary representation of the filter. + """ + return filter.to_dict() + + +def to_json(filter: IFilter, **kwargs) -> str: + """Serialize an IFilter to a JSON string. + + Args: + filter: The filter expression to serialize. + **kwargs: Additional keyword arguments forwarded to ``json.dumps``. + + Returns: + A JSON string representation of the filter. + """ + return json.dumps(to_dict(filter), **kwargs) + + +def from_dict(data: dict) -> IFilter: + """Deserialize a dictionary into an IFilter. + + Args: + data: A dictionary produced by :func:`to_dict`. + + Returns: + The reconstructed IFilter. + + Raises: + ValueError: If the dictionary does not represent a known filter type. + """ + from .expressions import ( # noqa: PLC0415 (local import to avoid circular refs) + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, + ) + + if "logic" in data: + logic = data["logic"] + children = [from_dict(f) for f in data.get("filters", [])] + + if logic == "and": + result = AndFilter(children) + elif logic == "or": + result = OrFilter(children) + elif logic == "not": + result = NotFilter(children[0]) + else: + raise ValueError(f"Unknown logic operator: '{logic}'") + + return result + + op_map = { + "eq": EqualsFilter, + "contains": ContainsFilter, + "startswith": StartsWithFilter, + "endswith": EndsWithFilter, + "gt": GreaterThanFilter, + "gte": GreaterThanOrEqualFilter, + "lt": LessThanFilter, + "lte": LessThanOrEqualFilter, + } + + op = data.get("op") + if op not in op_map: + raise ValueError(f"Unknown filter operator: '{op}'") + + return op_map[op](data["field"], data["value"]) diff --git a/src/python/pyproject.toml b/src/python/pyproject.toml new file mode 100644 index 00000000..e1dc9bbf --- /dev/null +++ b/src/python/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "datafilters" +version = "0.1.0" +description = "Generate filter expressions compatible with the DataFilters C# library" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.8" +keywords = ["filters", "query", "elasticsearch", "datafilters"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] +dependencies = [] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "flake8>=6.0", + "black>=23.0", + "isort>=5.0", +] + +[build-system] +requires = ["setuptools>=65", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["datafilters*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.black] +line-length = 100 +target-version = ["py38", "py39", "py310", "py311", "py312"] + +[tool.isort] +profile = "black" +line_length = 100 + +[tool.flake8] +max-line-length = 100 +extend-ignore = ["E203", "W503"] diff --git a/src/python/requirements-dev.txt b/src/python/requirements-dev.txt new file mode 100644 index 00000000..20c0532d --- /dev/null +++ b/src/python/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest>=7.0 +pytest-cov>=4.0 +flake8>=6.0 +black>=23.0 +isort>=5.0 diff --git a/src/python/setup.py b/src/python/setup.py new file mode 100644 index 00000000..1ed95b77 --- /dev/null +++ b/src/python/setup.py @@ -0,0 +1,4 @@ +"""Legacy setup.py for compatibility with older tools.""" +from setuptools import setup + +setup() diff --git a/src/python/tests/__init__.py b/src/python/tests/__init__.py new file mode 100644 index 00000000..e7230ea6 --- /dev/null +++ b/src/python/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for DataFilters Python SDK.""" diff --git a/src/python/tests/test_builder.py b/src/python/tests/test_builder.py new file mode 100644 index 00000000..d4c34823 --- /dev/null +++ b/src/python/tests/test_builder.py @@ -0,0 +1,84 @@ +"""Tests for the FilterBuilder fluent API.""" + +import pytest + +from datafilters.builder import FilterBuilder +from datafilters.expressions import ( + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +) + + +class TestFilterBuilderSingleCondition: + def test_eq(self): + result = FilterBuilder().where("name").eq("Batman").build() + assert isinstance(result, EqualsFilter) + assert result.field == "name" + assert result.value == "Batman" + + def test_contains(self): + result = FilterBuilder().where("name").contains("bat").build() + assert isinstance(result, ContainsFilter) + assert result.value == "bat" + + def test_starts_with(self): + result = FilterBuilder().where("name").starts_with("Bat").build() + assert isinstance(result, StartsWithFilter) + + def test_ends_with(self): + result = FilterBuilder().where("name").ends_with("man").build() + assert isinstance(result, EndsWithFilter) + + def test_gt(self): + result = FilterBuilder().where("age").gt(18).build() + assert isinstance(result, GreaterThanFilter) + assert result.value == 18 + + def test_gte(self): + result = FilterBuilder().where("age").gte(18).build() + assert isinstance(result, GreaterThanOrEqualFilter) + + def test_lt(self): + result = FilterBuilder().where("age").lt(65).build() + assert isinstance(result, LessThanFilter) + + def test_lte(self): + result = FilterBuilder().where("age").lte(65).build() + assert isinstance(result, LessThanOrEqualFilter) + + def test_not(self): + result = FilterBuilder().where("deleted").not_(True).build() + assert isinstance(result, NotFilter) + assert isinstance(result.filter, EqualsFilter) + + +class TestFilterBuilderMultipleConditions: + def test_two_conditions_produce_and_filter(self): + result = ( + FilterBuilder() + .where("name").contains("bat") + .and_where("age").gte(18) + .build() + ) + assert isinstance(result, AndFilter) + assert len(result.filters) == 2 + + def test_or_condition(self): + result = FilterBuilder().where("status").or_("active", "pending").build() + assert isinstance(result, OrFilter) + assert len(result.filters) == 2 + + +class TestFilterBuilderErrors: + def test_build_without_conditions_raises(self): + with pytest.raises(ValueError): + FilterBuilder().build() diff --git a/src/python/tests/test_expressions.py b/src/python/tests/test_expressions.py new file mode 100644 index 00000000..82961bbc --- /dev/null +++ b/src/python/tests/test_expressions.py @@ -0,0 +1,124 @@ +"""Tests for filter expression classes.""" + +import pytest + +from datafilters.expressions import ( + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +) + + +class TestEqualsFilter: + def test_to_dict(self): + f = EqualsFilter("name", "Batman") + assert f.to_dict() == {"field": "name", "op": "eq", "value": "Batman"} + + def test_field_and_value(self): + f = EqualsFilter("age", 30) + assert f.field == "age" + assert f.value == 30 + + +class TestContainsFilter: + def test_to_dict(self): + f = ContainsFilter("name", "bat") + assert f.to_dict() == {"field": "name", "op": "contains", "value": "bat"} + + +class TestStartsWithFilter: + def test_to_dict(self): + f = StartsWithFilter("name", "Bat") + assert f.to_dict() == {"field": "name", "op": "startswith", "value": "Bat"} + + +class TestEndsWithFilter: + def test_to_dict(self): + f = EndsWithFilter("name", "man") + assert f.to_dict() == {"field": "name", "op": "endswith", "value": "man"} + + +class TestGreaterThanFilter: + def test_to_dict(self): + f = GreaterThanFilter("age", 18) + assert f.to_dict() == {"field": "age", "op": "gt", "value": 18} + + +class TestGreaterThanOrEqualFilter: + def test_to_dict(self): + f = GreaterThanOrEqualFilter("age", 18) + assert f.to_dict() == {"field": "age", "op": "gte", "value": 18} + + +class TestLessThanFilter: + def test_to_dict(self): + f = LessThanFilter("age", 65) + assert f.to_dict() == {"field": "age", "op": "lt", "value": 65} + + +class TestLessThanOrEqualFilter: + def test_to_dict(self): + f = LessThanOrEqualFilter("age", 65) + assert f.to_dict() == {"field": "age", "op": "lte", "value": 65} + + +class TestAndFilter: + def test_to_dict(self): + f = AndFilter([EqualsFilter("a", 1), EqualsFilter("b", 2)]) + result = f.to_dict() + assert result["logic"] == "and" + assert len(result["filters"]) == 2 + + def test_nested(self): + inner = AndFilter([GreaterThanOrEqualFilter("age", 18), LessThanOrEqualFilter("age", 65)]) + result = inner.to_dict() + assert result["logic"] == "and" + assert result["filters"][0]["op"] == "gte" + assert result["filters"][1]["op"] == "lte" + + +class TestOrFilter: + def test_to_dict(self): + f = OrFilter([EqualsFilter("status", "active"), EqualsFilter("status", "pending")]) + result = f.to_dict() + assert result["logic"] == "or" + assert len(result["filters"]) == 2 + + +class TestNotFilter: + def test_to_dict(self): + f = NotFilter(EqualsFilter("deleted", True)) + result = f.to_dict() + assert result["logic"] == "not" + assert len(result["filters"]) == 1 + assert result["filters"][0]["op"] == "eq" + + def test_wraps_filter(self): + inner = EqualsFilter("active", False) + f = NotFilter(inner) + assert f.filter is inner + + +@pytest.mark.parametrize( + "filter_instance, expected_op", + [ + (EqualsFilter("f", "v"), "eq"), + (ContainsFilter("f", "v"), "contains"), + (StartsWithFilter("f", "v"), "startswith"), + (EndsWithFilter("f", "v"), "endswith"), + (GreaterThanFilter("f", 0), "gt"), + (GreaterThanOrEqualFilter("f", 0), "gte"), + (LessThanFilter("f", 0), "lt"), + (LessThanOrEqualFilter("f", 0), "lte"), + ], +) +def test_filter_operators(filter_instance, expected_op): + assert filter_instance.to_dict()["op"] == expected_op diff --git a/src/python/tests/test_parser.py b/src/python/tests/test_parser.py new file mode 100644 index 00000000..3ee9154e --- /dev/null +++ b/src/python/tests/test_parser.py @@ -0,0 +1,129 @@ +"""Tests for the DataFilters query string parser.""" + +import pytest + +from datafilters.expressions import ( + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +) +from datafilters.parser import parse + + +class TestParseEquals: + def test_simple_equals(self): + result = parse("name=Batman") + assert isinstance(result, EqualsFilter) + assert result.field == "name" + assert result.value == "Batman" + + +class TestParseContains: + def test_contains(self): + result = parse("name=*bat*") + assert isinstance(result, ContainsFilter) + assert result.field == "name" + assert result.value == "bat" + + +class TestParseStartsWith: + def test_starts_with(self): + result = parse("name=Bat*") + assert isinstance(result, StartsWithFilter) + assert result.value == "Bat" + + +class TestParseEndsWith: + def test_ends_with(self): + result = parse("name=*man") + assert isinstance(result, EndsWithFilter) + assert result.value == "man" + + +class TestParseNegation: + def test_not_equals(self): + result = parse("name=!Batman") + assert isinstance(result, NotFilter) + assert isinstance(result.filter, EqualsFilter) + assert result.filter.value == "Batman" + + +class TestParseRange: + def test_closed_range(self): + result = parse("age=[18,65]") + assert isinstance(result, AndFilter) + assert isinstance(result.filters[0], GreaterThanOrEqualFilter) + assert isinstance(result.filters[1], LessThanOrEqualFilter) + assert result.filters[0].value == "18" + assert result.filters[1].value == "65" + + def test_open_range(self): + result = parse("age=(18,65)") + assert isinstance(result, AndFilter) + assert isinstance(result.filters[0], GreaterThanFilter) + assert isinstance(result.filters[1], LessThanFilter) + + def test_half_open_min(self): + result = parse("age=[18,]") + assert isinstance(result, GreaterThanOrEqualFilter) + assert result.value == "18" + + def test_half_open_max(self): + result = parse("age=[,65]") + assert isinstance(result, LessThanOrEqualFilter) + assert result.value == "65" + + +class TestParseAnd: + def test_and_combination(self): + result = parse("name=*bat*,age=[18,]") + assert isinstance(result, AndFilter) + assert len(result.filters) == 2 + + +class TestParseOr: + def test_or_combination(self): + result = parse("status=active|status=pending") + assert isinstance(result, OrFilter) + assert len(result.filters) == 2 + + +class TestParseErrors: + def test_empty_expression_raises(self): + with pytest.raises(ValueError): + parse("") + + def test_whitespace_expression_raises(self): + with pytest.raises(ValueError): + parse(" ") + + def test_missing_field_raises(self): + with pytest.raises(ValueError): + parse("=value") + + def test_missing_equals_raises(self): + with pytest.raises(ValueError): + parse("noequals") + + +@pytest.mark.parametrize( + "expression, expected_type", + [ + ("name=Batman", EqualsFilter), + ("name=*bat*", ContainsFilter), + ("name=Bat*", StartsWithFilter), + ("name=*man", EndsWithFilter), + ("name=!Batman", NotFilter), + ], +) +def test_parse_single_expressions(expression, expected_type): + result = parse(expression) + assert isinstance(result, expected_type) diff --git a/src/typescript/.eslintrc.js b/src/typescript/.eslintrc.js new file mode 100644 index 00000000..25fe1fb5 --- /dev/null +++ b/src/typescript/.eslintrc.js @@ -0,0 +1,17 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + ], + env: { + node: true, + es2019: true, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/explicit-function-return-type': 'warn', + }, +}; diff --git a/src/typescript/.npmignore b/src/typescript/.npmignore new file mode 100644 index 00000000..23ae3566 --- /dev/null +++ b/src/typescript/.npmignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +coverage/ +*.log +.eslintcache diff --git a/src/typescript/jest.config.js b/src/typescript/jest.config.js new file mode 100644 index 00000000..a9c2694e --- /dev/null +++ b/src/typescript/jest.config.js @@ -0,0 +1,12 @@ +/** @type {import('jest').Config} */ +const config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'clover'], +}; + +module.exports = config; diff --git a/src/typescript/package-lock.json b/src/typescript/package-lock.json new file mode 100644 index 00000000..ebcda565 --- /dev/null +++ b/src/typescript/package-lock.json @@ -0,0 +1,5151 @@ +{ + "name": "datafilters", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "datafilters", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.57.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.0", + "typescript": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/src/typescript/package.json b/src/typescript/package.json new file mode 100644 index 00000000..e848de82 --- /dev/null +++ b/src/typescript/package.json @@ -0,0 +1,40 @@ +{ + "name": "datafilters", + "version": "0.1.0", + "description": "Generate filter expressions compatible with the DataFilters C# library", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "lint": "eslint src/**/*.ts test/**/*.ts", + "test": "jest", + "test:cov": "jest --coverage", + "prepublishOnly": "npm run lint && npm run build && npm test" + }, + "keywords": [ + "filters", + "query", + "elasticsearch", + "datafilters" + ], + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.57.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.0", + "typescript": "^5.4.0" + } +} diff --git a/src/typescript/src/builder.ts b/src/typescript/src/builder.ts new file mode 100644 index 00000000..41c8c174 --- /dev/null +++ b/src/typescript/src/builder.ts @@ -0,0 +1,131 @@ +/** + * Fluent builder API for constructing DataFilters filter expressions. + */ + +import { + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + IFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +} from './expressions'; + +/** Internal helper that provides filter-condition methods for a single field. */ +export class FieldBuilder { + constructor( + private readonly parent: FilterBuilder, + private readonly field: string, + ) {} + + /** Add an equals condition. */ + eq(value: unknown): FilterBuilder { + return this.parent['add'](new EqualsFilter(this.field, value)); + } + + /** Add a contains condition. */ + contains(value: string): FilterBuilder { + return this.parent['add'](new ContainsFilter(this.field, value)); + } + + /** Add a starts-with condition. */ + startsWith(value: string): FilterBuilder { + return this.parent['add'](new StartsWithFilter(this.field, value)); + } + + /** Add an ends-with condition. */ + endsWith(value: string): FilterBuilder { + return this.parent['add'](new EndsWithFilter(this.field, value)); + } + + /** Add a greater-than condition. */ + gt(value: unknown): FilterBuilder { + return this.parent['add'](new GreaterThanFilter(this.field, value)); + } + + /** Add a greater-than-or-equal condition. */ + gte(value: unknown): FilterBuilder { + return this.parent['add'](new GreaterThanOrEqualFilter(this.field, value)); + } + + /** Add a less-than condition. */ + lt(value: unknown): FilterBuilder { + return this.parent['add'](new LessThanFilter(this.field, value)); + } + + /** Add a less-than-or-equal condition. */ + lte(value: unknown): FilterBuilder { + return this.parent['add'](new LessThanOrEqualFilter(this.field, value)); + } + + /** Add a not-equals condition. */ + not(value: unknown): FilterBuilder { + return this.parent['add'](new NotFilter(new EqualsFilter(this.field, value))); + } + + /** Add an OR condition matching any of the given values. */ + or(...values: unknown[]): FilterBuilder { + const orFilters: IFilter[] = values.map((v) => new EqualsFilter(this.field, v)); + return this.parent['add'](new OrFilter(orFilters)); + } +} + +/** + * Fluent builder for constructing compound filter expressions. + * + * @example + * ```ts + * const filter = new FilterBuilder() + * .where('name').contains('bat') + * .andWhere('age').gte(18) + * .build(); + * ``` + */ +export class FilterBuilder { + private readonly filters: IFilter[] = []; + + private add(filter: IFilter): FilterBuilder { + this.filters.push(filter); + return this; + } + + /** + * Start a filter condition for the given field. + * @param field - The name of the field to filter on. + */ + where(field: string): FieldBuilder { + return new FieldBuilder(this, field); + } + + /** + * Chain an additional AND condition on a new field. + * @param field - The name of the field for the next condition. + */ + andWhere(field: string): FieldBuilder { + return new FieldBuilder(this, field); + } + + /** + * Construct and return the final IFilter. + * + * @returns A single IFilter or an AndFilter combining all accumulated conditions. + * @throws {Error} If no conditions have been added. + */ + build(): IFilter { + if (this.filters.length === 0) { + throw new Error('No filter conditions have been added.'); + } + + if (this.filters.length === 1) { + return this.filters[0]; + } + + return new AndFilter(this.filters); + } +} diff --git a/src/typescript/src/expressions.ts b/src/typescript/src/expressions.ts new file mode 100644 index 00000000..651f2d14 --- /dev/null +++ b/src/typescript/src/expressions.ts @@ -0,0 +1,107 @@ +/** + * Filter expression types compatible with the DataFilters C# library. + */ + +/** Base interface for all filter expressions. */ +export interface IFilter { + toDict(): Record; +} + +/** Matches records where a property equals a specific value. */ +export class EqualsFilter implements IFilter { + constructor(public readonly field: string, public readonly value: unknown) {} + + toDict(): Record { + return { field: this.field, op: 'eq', value: this.value }; + } +} + +/** Matches records where a property contains a substring. */ +export class ContainsFilter implements IFilter { + constructor(public readonly field: string, public readonly value: string) {} + + toDict(): Record { + return { field: this.field, op: 'contains', value: this.value }; + } +} + +/** Matches records where a property starts with a specific string. */ +export class StartsWithFilter implements IFilter { + constructor(public readonly field: string, public readonly value: string) {} + + toDict(): Record { + return { field: this.field, op: 'startswith', value: this.value }; + } +} + +/** Matches records where a property ends with a specific string. */ +export class EndsWithFilter implements IFilter { + constructor(public readonly field: string, public readonly value: string) {} + + toDict(): Record { + return { field: this.field, op: 'endswith', value: this.value }; + } +} + +/** Matches records where a property is greater than a value. */ +export class GreaterThanFilter implements IFilter { + constructor(public readonly field: string, public readonly value: unknown) {} + + toDict(): Record { + return { field: this.field, op: 'gt', value: this.value }; + } +} + +/** Matches records where a property is greater than or equal to a value. */ +export class GreaterThanOrEqualFilter implements IFilter { + constructor(public readonly field: string, public readonly value: unknown) {} + + toDict(): Record { + return { field: this.field, op: 'gte', value: this.value }; + } +} + +/** Matches records where a property is less than a value. */ +export class LessThanFilter implements IFilter { + constructor(public readonly field: string, public readonly value: unknown) {} + + toDict(): Record { + return { field: this.field, op: 'lt', value: this.value }; + } +} + +/** Matches records where a property is less than or equal to a value. */ +export class LessThanOrEqualFilter implements IFilter { + constructor(public readonly field: string, public readonly value: unknown) {} + + toDict(): Record { + return { field: this.field, op: 'lte', value: this.value }; + } +} + +/** Combines multiple filters with a logical AND. */ +export class AndFilter implements IFilter { + constructor(public readonly filters: IFilter[]) {} + + toDict(): Record { + return { logic: 'and', filters: this.filters.map((f) => f.toDict()) }; + } +} + +/** Combines multiple filters with a logical OR. */ +export class OrFilter implements IFilter { + constructor(public readonly filters: IFilter[]) {} + + toDict(): Record { + return { logic: 'or', filters: this.filters.map((f) => f.toDict()) }; + } +} + +/** Negates a filter expression. */ +export class NotFilter implements IFilter { + constructor(public readonly filter: IFilter) {} + + toDict(): Record { + return { logic: 'not', filters: [this.filter.toDict()] }; + } +} diff --git a/src/typescript/src/index.ts b/src/typescript/src/index.ts new file mode 100644 index 00000000..961655ee --- /dev/null +++ b/src/typescript/src/index.ts @@ -0,0 +1,27 @@ +/** + * DataFilters TypeScript SDK. + * + * Generate filter expressions compatible with the DataFilters C# library, + * using an Elasticsearch-inspired query syntax. + */ + +export { + IFilter, + EqualsFilter, + ContainsFilter, + StartsWithFilter, + EndsWithFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + AndFilter, + OrFilter, + NotFilter, +} from './expressions'; + +export { FilterBuilder, FieldBuilder } from './builder'; + +export { parse } from './parser'; + +export { toDict, toJson, fromDict } from './serializers'; diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts new file mode 100644 index 00000000..e10466b6 --- /dev/null +++ b/src/typescript/src/parser.ts @@ -0,0 +1,164 @@ +/** + * Parser for DataFilters query syntax strings. + * + * Supported syntax: + * - `field=value` → EqualsFilter + * - `field=value*` → StartsWithFilter + * - `field=*value` → EndsWithFilter + * - `field=*value*` → ContainsFilter + * - `field=!value` → NotFilter(EqualsFilter) + * - `field=[min,max]` → AndFilter(gte, lte) + * - `field=[min,]` → GreaterThanOrEqualFilter + * - `field=[,max]` → LessThanOrEqualFilter + * - `field=(min,max)` → AndFilter(gt, lt) + * - `expr1,expr2` → AndFilter of multiple criteria + * - `expr1|expr2` → OrFilter of multiple criteria + */ + +import { + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + IFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +} from './expressions'; + +const RANGE_PATTERN = /^(?[[(])(?[^,]*),(?[^)\]]*)?(?[)\]])$/; + +function parseRange(field: string, value: string): IFilter { + const match = RANGE_PATTERN.exec(value); + if (!match || !match.groups) { + throw new Error(`Invalid range expression: '${value}'`); + } + + const openBracket = match.groups['open']; + const closeBracket = match.groups['close']; + const minVal = match.groups['min'] || null; + const maxVal = match.groups['max'] || null; + + const filters: IFilter[] = []; + + if (minVal !== null && minVal !== '') { + if (openBracket === '[') { + filters.push(new GreaterThanOrEqualFilter(field, minVal)); + } else { + filters.push(new GreaterThanFilter(field, minVal)); + } + } + + if (maxVal !== null && maxVal !== '') { + if (closeBracket === ']') { + filters.push(new LessThanOrEqualFilter(field, maxVal)); + } else { + filters.push(new LessThanFilter(field, maxVal)); + } + } + + if (filters.length === 1) { + return filters[0]; + } + + return new AndFilter(filters); +} + +function parseValueExpression(field: string, value: string): IFilter { + const negated = value.startsWith('!'); + const actualValue = negated ? value.slice(1) : value; + + let result: IFilter; + + if (RANGE_PATTERN.test(actualValue)) { + result = parseRange(field, actualValue); + } else if (actualValue.startsWith('*') && actualValue.endsWith('*') && actualValue.length > 1) { + result = new ContainsFilter(field, actualValue.slice(1, -1)); + } else if (actualValue.startsWith('*')) { + result = new EndsWithFilter(field, actualValue.slice(1)); + } else if (actualValue.endsWith('*')) { + result = new StartsWithFilter(field, actualValue.slice(0, -1)); + } else { + result = new EqualsFilter(field, actualValue); + } + + return negated ? new NotFilter(result) : result; +} + +function parseSingle(expression: string): IFilter { + const eqIndex = expression.indexOf('='); + if (eqIndex === -1) { + throw new Error(`Invalid filter expression: '${expression}'. Expected 'field=value'.`); + } + + const field = expression.slice(0, eqIndex).trim(); + const value = expression.slice(eqIndex + 1).trim(); + + if (!field) { + throw new Error(`Field name must not be empty in expression: '${expression}'`); + } + + return parseValueExpression(field, value); +} + +function splitAndParts(expression: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + + for (const char of expression) { + if (char === '(' || char === '[') { + depth++; + current += char; + } else if (char === ')' || char === ']') { + depth--; + current += char; + } else if (char === ',' && depth === 0) { + parts.push(current); + current = ''; + } else { + current += char; + } + } + + if (current) { + parts.push(current); + } + + return parts; +} + +function parseAndExpression(expression: string): IFilter { + const parts = splitAndParts(expression); + if (parts.length > 1) { + return new AndFilter(parts.map((p) => parseSingle(p.trim()))); + } + + return parseSingle(expression); +} + +/** + * Parse a DataFilters query string into an IFilter. + * + * @param expression - A filter expression string, e.g. `"name=*bat*,age=[18,]"`. + * @returns An IFilter representing the parsed expression. + * @throws {Error} If the expression cannot be parsed. + */ +export function parse(expression: string): IFilter { + if (!expression || !expression.trim()) { + throw new Error('Expression must not be empty'); + } + + const trimmed = expression.trim(); + const orParts = trimmed.split('|'); + + if (orParts.length > 1) { + return new OrFilter(orParts.map((p) => parseAndExpression(p.trim()))); + } + + return parseAndExpression(trimmed); +} diff --git a/src/typescript/src/serializers.ts b/src/typescript/src/serializers.ts new file mode 100644 index 00000000..adc0074a --- /dev/null +++ b/src/typescript/src/serializers.ts @@ -0,0 +1,74 @@ +/** + * Serialization utilities for DataFilters expressions. + */ + +import { IFilter } from './expressions'; +import { + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +} from './expressions'; + +/** Serialize an IFilter to a plain JavaScript object. */ +export function toDict(filter: IFilter): Record { + return filter.toDict(); +} + +/** Serialize an IFilter to a JSON string. */ +export function toJson(filter: IFilter, space?: number): string { + return JSON.stringify(toDict(filter), null, space); +} + +const OP_MAP: Record IFilter> = { + eq: EqualsFilter, + contains: ContainsFilter as new (field: string, value: unknown) => IFilter, + startswith: StartsWithFilter as new (field: string, value: unknown) => IFilter, + endswith: EndsWithFilter as new (field: string, value: unknown) => IFilter, + gt: GreaterThanFilter, + gte: GreaterThanOrEqualFilter, + lt: LessThanFilter, + lte: LessThanOrEqualFilter, +}; + +/** + * Deserialize a plain object into an IFilter. + * + * @param data - An object produced by {@link toDict}. + * @returns The reconstructed IFilter. + * @throws {Error} If the object does not represent a known filter type. + */ +export function fromDict(data: Record): IFilter { + if ('logic' in data) { + const logic = data['logic'] as string; + const children = (data['filters'] as Record[]).map(fromDict); + + if (logic === 'and') { + return new AndFilter(children); + } + if (logic === 'or') { + return new OrFilter(children); + } + if (logic === 'not') { + return new NotFilter(children[0]); + } + + throw new Error(`Unknown logic operator: '${logic}'`); + } + + const op = data['op'] as string; + const Ctor = OP_MAP[op]; + + if (!Ctor) { + throw new Error(`Unknown filter operator: '${op}'`); + } + + return new Ctor(data['field'] as string, data['value']); +} diff --git a/src/typescript/test/builder.test.ts b/src/typescript/test/builder.test.ts new file mode 100644 index 00000000..38ee2658 --- /dev/null +++ b/src/typescript/test/builder.test.ts @@ -0,0 +1,89 @@ +import { + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +} from '../src/expressions'; +import { FilterBuilder } from '../src/builder'; + +describe('FilterBuilder - single conditions', () => { + it('should build equals filter', () => { + const result = new FilterBuilder().where('name').eq('Batman').build(); + expect(result).toBeInstanceOf(EqualsFilter); + expect((result as EqualsFilter).field).toBe('name'); + expect((result as EqualsFilter).value).toBe('Batman'); + }); + + it('should build contains filter', () => { + const result = new FilterBuilder().where('name').contains('bat').build(); + expect(result).toBeInstanceOf(ContainsFilter); + expect((result as ContainsFilter).value).toBe('bat'); + }); + + it('should build startsWith filter', () => { + const result = new FilterBuilder().where('name').startsWith('Bat').build(); + expect(result).toBeInstanceOf(StartsWithFilter); + }); + + it('should build endsWith filter', () => { + const result = new FilterBuilder().where('name').endsWith('man').build(); + expect(result).toBeInstanceOf(EndsWithFilter); + }); + + it('should build gt filter', () => { + const result = new FilterBuilder().where('age').gt(18).build(); + expect(result).toBeInstanceOf(GreaterThanFilter); + expect((result as GreaterThanFilter).value).toBe(18); + }); + + it('should build gte filter', () => { + const result = new FilterBuilder().where('age').gte(18).build(); + expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); + }); + + it('should build lt filter', () => { + const result = new FilterBuilder().where('age').lt(65).build(); + expect(result).toBeInstanceOf(LessThanFilter); + }); + + it('should build lte filter', () => { + const result = new FilterBuilder().where('age').lte(65).build(); + expect(result).toBeInstanceOf(LessThanOrEqualFilter); + }); + + it('should build not filter', () => { + const result = new FilterBuilder().where('deleted').not(true).build(); + expect(result).toBeInstanceOf(NotFilter); + expect((result as NotFilter).filter).toBeInstanceOf(EqualsFilter); + }); +}); + +describe('FilterBuilder - multiple conditions', () => { + it('should combine two conditions as AND', () => { + const result = new FilterBuilder() + .where('name').contains('bat') + .andWhere('age').gte(18) + .build(); + expect(result).toBeInstanceOf(AndFilter); + expect((result as AndFilter).filters.length).toBe(2); + }); + + it('should build OR filter', () => { + const result = new FilterBuilder().where('status').or('active', 'pending').build(); + expect(result).toBeInstanceOf(OrFilter); + expect((result as OrFilter).filters.length).toBe(2); + }); +}); + +describe('FilterBuilder - errors', () => { + it('should throw when no conditions added', () => { + expect(() => new FilterBuilder().build()).toThrow(); + }); +}); diff --git a/src/typescript/test/expressions.test.ts b/src/typescript/test/expressions.test.ts new file mode 100644 index 00000000..03ff6f3d --- /dev/null +++ b/src/typescript/test/expressions.test.ts @@ -0,0 +1,128 @@ +import { + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +} from '../src/expressions'; + +describe('EqualsFilter', () => { + it('should serialize to correct dict', () => { + const f = new EqualsFilter('name', 'Batman'); + expect(f.toDict()).toEqual({ field: 'name', op: 'eq', value: 'Batman' }); + }); + + it('should store field and value', () => { + const f = new EqualsFilter('age', 30); + expect(f.field).toBe('age'); + expect(f.value).toBe(30); + }); +}); + +describe('ContainsFilter', () => { + it('should serialize to correct dict', () => { + const f = new ContainsFilter('name', 'bat'); + expect(f.toDict()).toEqual({ field: 'name', op: 'contains', value: 'bat' }); + }); +}); + +describe('StartsWithFilter', () => { + it('should serialize to correct dict', () => { + const f = new StartsWithFilter('name', 'Bat'); + expect(f.toDict()).toEqual({ field: 'name', op: 'startswith', value: 'Bat' }); + }); +}); + +describe('EndsWithFilter', () => { + it('should serialize to correct dict', () => { + const f = new EndsWithFilter('name', 'man'); + expect(f.toDict()).toEqual({ field: 'name', op: 'endswith', value: 'man' }); + }); +}); + +describe('GreaterThanFilter', () => { + it('should serialize to correct dict', () => { + const f = new GreaterThanFilter('age', 18); + expect(f.toDict()).toEqual({ field: 'age', op: 'gt', value: 18 }); + }); +}); + +describe('GreaterThanOrEqualFilter', () => { + it('should serialize to correct dict', () => { + const f = new GreaterThanOrEqualFilter('age', 18); + expect(f.toDict()).toEqual({ field: 'age', op: 'gte', value: 18 }); + }); +}); + +describe('LessThanFilter', () => { + it('should serialize to correct dict', () => { + const f = new LessThanFilter('age', 65); + expect(f.toDict()).toEqual({ field: 'age', op: 'lt', value: 65 }); + }); +}); + +describe('LessThanOrEqualFilter', () => { + it('should serialize to correct dict', () => { + const f = new LessThanOrEqualFilter('age', 65); + expect(f.toDict()).toEqual({ field: 'age', op: 'lte', value: 65 }); + }); +}); + +describe('AndFilter', () => { + it('should serialize with logic and nested filters', () => { + const f = new AndFilter([new EqualsFilter('a', 1), new EqualsFilter('b', 2)]); + const result = f.toDict(); + expect(result['logic']).toBe('and'); + expect((result['filters'] as unknown[]).length).toBe(2); + }); +}); + +describe('OrFilter', () => { + it('should serialize with logic and nested filters', () => { + const f = new OrFilter([ + new EqualsFilter('status', 'active'), + new EqualsFilter('status', 'pending'), + ]); + const result = f.toDict(); + expect(result['logic']).toBe('or'); + expect((result['filters'] as unknown[]).length).toBe(2); + }); +}); + +describe('NotFilter', () => { + it('should serialize with logic not', () => { + const f = new NotFilter(new EqualsFilter('deleted', true)); + const result = f.toDict(); + expect(result['logic']).toBe('not'); + expect((result['filters'] as unknown[]).length).toBe(1); + }); + + it('should wrap the inner filter', () => { + const inner = new EqualsFilter('active', false); + const f = new NotFilter(inner); + expect(f.filter).toBe(inner); + }); +}); + +describe('filter operator values', () => { + const cases: [ReturnType<(typeof EqualsFilter.prototype)['toDict']>, string][] = [ + [new EqualsFilter('f', 'v').toDict(), 'eq'], + [new ContainsFilter('f', 'v').toDict(), 'contains'], + [new StartsWithFilter('f', 'v').toDict(), 'startswith'], + [new EndsWithFilter('f', 'v').toDict(), 'endswith'], + [new GreaterThanFilter('f', 0).toDict(), 'gt'], + [new GreaterThanOrEqualFilter('f', 0).toDict(), 'gte'], + [new LessThanFilter('f', 0).toDict(), 'lt'], + [new LessThanOrEqualFilter('f', 0).toDict(), 'lte'], + ]; + + test.each(cases)('op field should be %s', (dict, expectedOp) => { + expect(dict['op']).toBe(expectedOp); + }); +}); diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts new file mode 100644 index 00000000..e0447b2e --- /dev/null +++ b/src/typescript/test/parser.test.ts @@ -0,0 +1,136 @@ +import { + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +} from '../src/expressions'; +import { parse } from '../src/parser'; + +describe('parse - equals', () => { + it('should parse simple equals expression', () => { + const result = parse('name=Batman'); + expect(result).toBeInstanceOf(EqualsFilter); + const f = result as EqualsFilter; + expect(f.field).toBe('name'); + expect(f.value).toBe('Batman'); + }); +}); + +describe('parse - contains', () => { + it('should parse contains expression', () => { + const result = parse('name=*bat*'); + expect(result).toBeInstanceOf(ContainsFilter); + expect((result as ContainsFilter).value).toBe('bat'); + }); +}); + +describe('parse - startsWith', () => { + it('should parse starts-with expression', () => { + const result = parse('name=Bat*'); + expect(result).toBeInstanceOf(StartsWithFilter); + expect((result as StartsWithFilter).value).toBe('Bat'); + }); +}); + +describe('parse - endsWith', () => { + it('should parse ends-with expression', () => { + const result = parse('name=*man'); + expect(result).toBeInstanceOf(EndsWithFilter); + expect((result as EndsWithFilter).value).toBe('man'); + }); +}); + +describe('parse - negation', () => { + it('should parse not-equals expression', () => { + const result = parse('name=!Batman'); + expect(result).toBeInstanceOf(NotFilter); + const inner = (result as NotFilter).filter; + expect(inner).toBeInstanceOf(EqualsFilter); + expect((inner as EqualsFilter).value).toBe('Batman'); + }); +}); + +describe('parse - range', () => { + it('should parse closed range [min,max]', () => { + const result = parse('age=[18,65]'); + expect(result).toBeInstanceOf(AndFilter); + const f = result as AndFilter; + expect(f.filters[0]).toBeInstanceOf(GreaterThanOrEqualFilter); + expect(f.filters[1]).toBeInstanceOf(LessThanOrEqualFilter); + }); + + it('should parse open range (min,max)', () => { + const result = parse('age=(18,65)'); + expect(result).toBeInstanceOf(AndFilter); + const f = result as AndFilter; + expect(f.filters[0]).toBeInstanceOf(GreaterThanFilter); + expect(f.filters[1]).toBeInstanceOf(LessThanFilter); + }); + + it('should parse half-open range [min,]', () => { + const result = parse('age=[18,]'); + expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); + expect((result as GreaterThanOrEqualFilter).value).toBe('18'); + }); + + it('should parse half-open range [,max]', () => { + const result = parse('age=[,65]'); + expect(result).toBeInstanceOf(LessThanOrEqualFilter); + expect((result as LessThanOrEqualFilter).value).toBe('65'); + }); +}); + +describe('parse - AND combination', () => { + it('should parse comma-separated expressions as AND', () => { + const result = parse('name=*bat*,age=[18,]'); + expect(result).toBeInstanceOf(AndFilter); + expect((result as AndFilter).filters.length).toBe(2); + }); +}); + +describe('parse - OR combination', () => { + it('should parse pipe-separated expressions as OR', () => { + const result = parse('status=active|status=pending'); + expect(result).toBeInstanceOf(OrFilter); + expect((result as OrFilter).filters.length).toBe(2); + }); +}); + +describe('parse - errors', () => { + it('should throw on empty expression', () => { + expect(() => parse('')).toThrow(); + }); + + it('should throw on whitespace-only expression', () => { + expect(() => parse(' ')).toThrow(); + }); + + it('should throw on missing field', () => { + expect(() => parse('=value')).toThrow(); + }); + + it('should throw on missing equals sign', () => { + expect(() => parse('noequals')).toThrow(); + }); +}); + +describe('parse - parameterized', () => { + const cases: [string, unknown][] = [ + ['name=Batman', EqualsFilter], + ['name=*bat*', ContainsFilter], + ['name=Bat*', StartsWithFilter], + ['name=*man', EndsWithFilter], + ['name=!Batman', NotFilter], + ]; + + test.each(cases)('parse("%s") should return expected type', (expression, expectedType) => { + expect(parse(expression)).toBeInstanceOf(expectedType as unknown as new () => unknown); + }); +}); diff --git a/src/typescript/tsconfig.json b/src/typescript/tsconfig.json new file mode 100644 index 00000000..20718ab0 --- /dev/null +++ b/src/typescript/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2019", + "module": "commonjs", + "lib": ["ES2019"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "removeComments": false, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} From f70bf2b8f5479796b621e53b6766fbdc606d8ff7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:42:22 +0000 Subject: [PATCH 02/28] devcontainer: move C# config into .devcontainer/csharp/ Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/47b9b2eb-25e1-4072-9064-c9ef69d5ba78 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- .devcontainer/{ => csharp}/devcontainer.json | 6 +- .../{ => csharp}/scripts/post-create.sh | 6 +- .../{ => csharp}/scripts/post-start.sh | 6 +- .devcontainer/scripts/README.md | 103 ------------------ 4 files changed, 9 insertions(+), 112 deletions(-) rename .devcontainer/{ => csharp}/devcontainer.json (83%) rename .devcontainer/{ => csharp}/scripts/post-create.sh (89%) rename .devcontainer/{ => csharp}/scripts/post-start.sh (96%) delete mode 100644 .devcontainer/scripts/README.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/csharp/devcontainer.json similarity index 83% rename from .devcontainer/devcontainer.json rename to .devcontainer/csharp/devcontainer.json index 3696588a..82793d25 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/csharp/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "DataFilters", + "name": "DataFilters (C#)", "image": "mcr.microsoft.com/devcontainers/dotnet:10.0", "features": { "ghcr.io/devcontainers/features/git:1": {}, @@ -29,7 +29,7 @@ } } }, - "postCreateCommand": "./.devcontainer/scripts/post-create.sh", - "postStartCommand": "./.devcontainer/scripts/post-start.sh", + "postCreateCommand": "./.devcontainer/csharp/scripts/post-create.sh", + "postStartCommand": "./.devcontainer/csharp/scripts/post-start.sh", "remoteUser": "vscode" } diff --git a/.devcontainer/scripts/post-create.sh b/.devcontainer/csharp/scripts/post-create.sh similarity index 89% rename from .devcontainer/scripts/post-create.sh rename to .devcontainer/csharp/scripts/post-create.sh index 20846f9c..1ac9995e 100755 --- a/.devcontainer/scripts/post-create.sh +++ b/.devcontainer/csharp/scripts/post-create.sh @@ -26,9 +26,9 @@ log_error() { } # Main configuration section -log_info "==========================================" -log_info "DataFilters DevContainer Post-Create Setup" -log_info "==========================================" +log_info "==============================================" +log_info "DataFilters (C#) DevContainer Post-Create Setup" +log_info "==============================================" # Update package manager log_info "Updating package manager..." diff --git a/.devcontainer/scripts/post-start.sh b/.devcontainer/csharp/scripts/post-start.sh similarity index 96% rename from .devcontainer/scripts/post-start.sh rename to .devcontainer/csharp/scripts/post-start.sh index 03002489..bfc3913e 100755 --- a/.devcontainer/scripts/post-start.sh +++ b/.devcontainer/csharp/scripts/post-start.sh @@ -47,9 +47,9 @@ command_exists() { } # Main configuration section -log_info "==========================================" -log_info "DataFilters DevContainer Post-Start Setup" -log_info "==========================================" +log_info "==============================================" +log_info "DataFilters (C#) DevContainer Post-Start Setup" +log_info "==============================================" # Update package manager log_info "Updating package manager..." diff --git a/.devcontainer/scripts/README.md b/.devcontainer/scripts/README.md deleted file mode 100644 index 41dee38f..00000000 --- a/.devcontainer/scripts/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# DevContainer Scripts - -This directory contains initialization scripts for the DataFilters development container. - -## Scripts Overview - -### `post-create.sh` -**When**: Runs once when the container is first created -**Purpose**: Initial environment setup and dependencies installation -**Tasks**: -- Updates package manager -- Installs `xdg-utils` (for opening links in default browser) -- Installs/updates Node.js and npm -- Installs squad CLI globally - -### `post-start.sh` -**When**: Runs every time the container starts -**Purpose**: Ensures environment is ready and project is configured -**Tasks**: -- Ensures GitHub CLI (`gh`) is installed -- Updates npm to latest version -- Ensures squad CLI is installed/updated to latest -- Restores NuGet packages and builds the DataFilters project -- Checks Squad initialization status (`.squad/` directory) -- Verifies GitHub authentication status -- Provides setup instructions if needed - -## Setup Workflow - -``` -Container Created → post-create.sh → Container Started → post-start.sh - ↓ ↓ ↓ ↓ - Fresh OS Core tools Deps up-to-date Ready to work - installed Build prepared -``` - -## Squad Configuration - -After the container starts, you can initialize Squad for your project: - -```bash -# Initialize a new team -squad init - -# Authenticate with GitHub (required for issues, PRs, triage) -gh auth login - -# Verify setup -squad doctor - -# Start working with your team -squad -``` - -## Available Commands - -Once Squad is initialized: - -- `squad init` - Initialize Squad in current directory -- `squad upgrade` - Update Squad to latest version -- `squad status` - Show active squad status -- `squad triage` - Watch issues and auto-triage -- `squad copilot` - Manage Copilot coding agent -- `squad doctor` - Diagnose setup issues -- `squad` - Interactive shell to work with your team -- `gh auth login` - Authenticate with GitHub -- `gh auth status` - Check GitHub authentication - -## Troubleshooting - -### GitHub CLI not working -```bash -gh auth login -``` - -### Squad CLI outdated -```bash -npm install -g @bradygaster/squad-cli@latest -squad upgrade -``` - -### Project build issues -```bash -./build.sh restore -./build.sh -``` - -### Check environment -```bash -squad doctor -node --version -npm --version -git --version -gh --version -``` - -## Notes - -- Both scripts use colored output for better readability -- Scripts include error handling and graceful degradation -- Empty `.squad/` directory indicates Squad hasn't been initialized yet -- GitHub authentication is optional but required for full Squad features -- The build system uses Nuke - see `build/Build.cs` From 58e42aa9a58a4b44b8e75a1ab956782fddc65908 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:43:19 +0000 Subject: [PATCH 03/28] devcontainer: add Python devcontainer in .devcontainer/python/ Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/47b9b2eb-25e1-4072-9064-c9ef69d5ba78 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- .devcontainer/python/devcontainer.json | 48 +++++++++ .devcontainer/python/scripts/post-create.sh | 68 +++++++++++++ .devcontainer/python/scripts/post-start.sh | 102 ++++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 .devcontainer/python/devcontainer.json create mode 100755 .devcontainer/python/scripts/post-create.sh create mode 100755 .devcontainer/python/scripts/post-start.sh diff --git a/.devcontainer/python/devcontainer.json b/.devcontainer/python/devcontainer.json new file mode 100644 index 00000000..f67cc046 --- /dev/null +++ b/.devcontainer/python/devcontainer.json @@ -0,0 +1,48 @@ +{ + "name": "DataFilters (Python)", + "image": "mcr.microsoft.com/devcontainers/python:3.12", + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/copilot-cli": {}, + "ghcr.io/devcontainers/features/node:1": {} + }, + "runArgs": [ + "--userns=keep-id" + ], + "containerUser": "vscode", + "updateRemoteUserUID": true, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.pylance", + "ms-python.flake8", + "ms-python.black-formatter", + "ms-python.isort", + "ms-python.vscode-pytest", + "editorconfig.editorconfig", + "GitHub.copilot", + "GitHub.vscode-pull-request-github", + "bierner.markdown-mermaid", + "DavidAnson.vscode-markdownlint" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "editor.formatOnSave": true, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "isort.args": ["--profile", "black"], + "flake8.args": ["--max-line-length=100"], + "dev.containers.dockerPath": "podman" + } + } + }, + "postCreateCommand": "./.devcontainer/python/scripts/post-create.sh", + "postStartCommand": "./.devcontainer/python/scripts/post-start.sh", + "remoteUser": "vscode" +} diff --git a/.devcontainer/python/scripts/post-create.sh b/.devcontainer/python/scripts/post-create.sh new file mode 100755 index 00000000..0dc0cb66 --- /dev/null +++ b/.devcontainer/python/scripts/post-create.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +log_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +log_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +log_error() { + echo -e "${RED}❌ $1${NC}" +} + +log_info "================================================" +log_info "DataFilters (Python) DevContainer Post-Create Setup" +log_info "================================================" + +# Update package manager +log_info "Updating package manager..." +sudo apt-get update > /dev/null 2>&1 +log_success "Package manager updated." + +log_info "Python version: $(python --version)" +log_info "pip version: $(pip --version)" + +# Install xdg-utils for opening links in the default browser +log_info "Installing xdg-utils..." +sudo apt-get install -y xdg-utils > /dev/null 2>&1 +log_success "xdg-utils installed successfully." + +# Install Python development dependencies +log_info "Installing Python development dependencies..." +cd src/python +pip install -e ".[dev]" > /dev/null 2>&1 +cd ../.. +log_success "Python development dependencies installed." + +# Install squad CLI globally +log_info "Installing squad CLI globally..." +npm install -g @bradygaster/squad-cli > /dev/null 2>&1 +log_success "squad CLI installed successfully." + +log_info "Initialize squad configuration..." +squad init +log_success "squad CLI initialized successfully." + +log_info "" +log_success "Post-create setup complete!" +log_info "" +log_info "Additional setup will run on container start (post-start)." +log_info "This includes:" +log_info " • GitHub CLI checks" +log_info " • Python dependency checks" +log_info " • Squad configuration checks" +log_info "" diff --git a/.devcontainer/python/scripts/post-start.sh b/.devcontainer/python/scripts/post-start.sh new file mode 100755 index 00000000..f16a6eae --- /dev/null +++ b/.devcontainer/python/scripts/post-start.sh @@ -0,0 +1,102 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +log_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +log_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +log_error() { + echo -e "${RED}❌ $1${NC}" +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +log_info "================================================" +log_info "DataFilters (Python) DevContainer Post-Start Setup" +log_info "================================================" + +# Update package manager +log_info "Updating package manager..." +sudo apt-get update > /dev/null 2>&1 +log_success "Package manager updated." + +# Install GitHub CLI if not present +if ! command_exists gh; then + log_info "Installing GitHub CLI (gh)..." + if sudo apt-get install -y gh > /dev/null 2>&1; then + log_success "GitHub CLI installed successfully." + else + log_warning "Could not install GitHub CLI. Some Squad features may be limited." + fi +else + log_success "GitHub CLI already installed." +fi + +# Ensure Python dependencies are up to date +log_info "Ensuring Python development dependencies are up to date..." +cd src/python +pip install -e ".[dev]" -q +cd ../.. +log_success "Python dependencies up to date." + +# Ensure squad CLI is installed or updated +if command_exists squad; then + log_info "Updating squad CLI to latest version..." + npm install --ignore-scripts -g @bradygaster/squad-cli@latest > /dev/null 2>&1 + log_success "squad CLI updated to latest version." +else + log_info "Installing squad CLI..." + npm install --ignore-scripts -g @bradygaster/squad-cli > /dev/null 2>&1 + log_success "squad CLI installed successfully." +fi + +# Check if Squad is already initialized in the workspace +if [ -d ".squad" ]; then + log_success "Squad is already initialized in this workspace." +else + log_info "Squad is not yet initialized." + log_info "" + log_info "To set up your Squad team, run:" + log_info " squad init" +fi + +# Check GitHub authentication status +log_info "" +log_info "Checking GitHub authentication status..." +if gh auth status > /dev/null 2>&1; then + log_success "You are authenticated with GitHub." + GH_USERNAME=$(gh api user -q '.login' 2>/dev/null || echo "unknown") + log_info "Logged in as: $GH_USERNAME" +else + log_warning "You are not authenticated with GitHub." + log_info "To authenticate, run: gh auth login" +fi + +log_info "" +log_info "========================================" +log_success "Post-start setup complete!" +log_info "========================================" +log_info "" +log_info "Run Python tests:" +log_info " cd src/python && pytest tests/" +log_info "" +log_info "Run linter:" +log_info " cd src/python && flake8 datafilters tests" +log_info "" From 6ae1e537f2ed46cd18fbcaa0858e04626d686654 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:44:14 +0000 Subject: [PATCH 04/28] devcontainer: add TypeScript devcontainer in .devcontainer/typescript/ Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/47b9b2eb-25e1-4072-9064-c9ef69d5ba78 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- .devcontainer/typescript/devcontainer.json | 44 +++++++ .../typescript/scripts/post-create.sh | 68 +++++++++++ .../typescript/scripts/post-start.sh | 110 ++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 .devcontainer/typescript/devcontainer.json create mode 100755 .devcontainer/typescript/scripts/post-create.sh create mode 100755 .devcontainer/typescript/scripts/post-start.sh diff --git a/.devcontainer/typescript/devcontainer.json b/.devcontainer/typescript/devcontainer.json new file mode 100644 index 00000000..438cee7b --- /dev/null +++ b/.devcontainer/typescript/devcontainer.json @@ -0,0 +1,44 @@ +{ + "name": "DataFilters (TypeScript)", + "image": "mcr.microsoft.com/devcontainers/javascript-node:20", + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/copilot-cli": {} + }, + "runArgs": [ + "--userns=keep-id" + ], + "containerUser": "vscode", + "updateRemoteUserUID": true, + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-vscode.vscode-typescript-next", + "Orta.vscode-jest", + "editorconfig.editorconfig", + "GitHub.copilot", + "GitHub.vscode-pull-request-github", + "bierner.markdown-mermaid", + "DavidAnson.vscode-markdownlint" + ], + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "typescript.preferences.importModuleSpecifier": "relative", + "eslint.workingDirectories": ["src/typescript"], + "jest.jestCommandLine": "npx jest", + "jest.rootPath": "src/typescript", + "dev.containers.dockerPath": "podman" + } + } + }, + "postCreateCommand": "./.devcontainer/typescript/scripts/post-create.sh", + "postStartCommand": "./.devcontainer/typescript/scripts/post-start.sh", + "remoteUser": "vscode" +} diff --git a/.devcontainer/typescript/scripts/post-create.sh b/.devcontainer/typescript/scripts/post-create.sh new file mode 100755 index 00000000..410ae63b --- /dev/null +++ b/.devcontainer/typescript/scripts/post-create.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +log_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +log_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +log_error() { + echo -e "${RED}❌ $1${NC}" +} + +log_info "===================================================" +log_info "DataFilters (TypeScript) DevContainer Post-Create Setup" +log_info "===================================================" + +# Update package manager +log_info "Updating package manager..." +sudo apt-get update > /dev/null 2>&1 +log_success "Package manager updated." + +log_info "Node.js version: $(node -v)" +log_info "npm version: $(npm -v)" + +# Install xdg-utils for opening links in the default browser +log_info "Installing xdg-utils..." +sudo apt-get install -y xdg-utils > /dev/null 2>&1 +log_success "xdg-utils installed successfully." + +# Install TypeScript project dependencies +log_info "Installing TypeScript project dependencies..." +cd src/typescript +npm ci > /dev/null 2>&1 +cd ../.. +log_success "TypeScript dependencies installed." + +# Install squad CLI globally +log_info "Installing squad CLI globally..." +npm install -g @bradygaster/squad-cli > /dev/null 2>&1 +log_success "squad CLI installed successfully." + +log_info "Initialize squad configuration..." +squad init +log_success "squad CLI initialized successfully." + +log_info "" +log_success "Post-create setup complete!" +log_info "" +log_info "Additional setup will run on container start (post-start)." +log_info "This includes:" +log_info " • GitHub CLI checks" +log_info " • TypeScript dependency checks" +log_info " • Squad configuration checks" +log_info "" diff --git a/.devcontainer/typescript/scripts/post-start.sh b/.devcontainer/typescript/scripts/post-start.sh new file mode 100755 index 00000000..52f00a84 --- /dev/null +++ b/.devcontainer/typescript/scripts/post-start.sh @@ -0,0 +1,110 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +log_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +log_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +log_error() { + echo -e "${RED}❌ $1${NC}" +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +log_info "===================================================" +log_info "DataFilters (TypeScript) DevContainer Post-Start Setup" +log_info "===================================================" + +# Update package manager +log_info "Updating package manager..." +sudo apt-get update > /dev/null 2>&1 +log_success "Package manager updated." + +# Install GitHub CLI if not present +if ! command_exists gh; then + log_info "Installing GitHub CLI (gh)..." + if sudo apt-get install -y gh > /dev/null 2>&1; then + log_success "GitHub CLI installed successfully." + else + log_warning "Could not install GitHub CLI. Some Squad features may be limited." + fi +else + log_success "GitHub CLI already installed." +fi + +# Update npm to latest version +log_info "Updating npm to latest version..." +npm install --ignore-scripts -g npm@latest > /dev/null 2>&1 +log_success "npm updated." + +# Ensure TypeScript dependencies are up to date +log_info "Ensuring TypeScript project dependencies are up to date..." +cd src/typescript +npm ci > /dev/null 2>&1 +cd ../.. +log_success "TypeScript dependencies up to date." + +# Ensure squad CLI is installed or updated +if command_exists squad; then + log_info "Updating squad CLI to latest version..." + npm install --ignore-scripts -g @bradygaster/squad-cli@latest > /dev/null 2>&1 + log_success "squad CLI updated to latest version." +else + log_info "Installing squad CLI..." + npm install --ignore-scripts -g @bradygaster/squad-cli > /dev/null 2>&1 + log_success "squad CLI installed successfully." +fi + +# Check if Squad is already initialized in the workspace +if [ -d ".squad" ]; then + log_success "Squad is already initialized in this workspace." +else + log_info "Squad is not yet initialized." + log_info "" + log_info "To set up your Squad team, run:" + log_info " squad init" +fi + +# Check GitHub authentication status +log_info "" +log_info "Checking GitHub authentication status..." +if gh auth status > /dev/null 2>&1; then + log_success "You are authenticated with GitHub." + GH_USERNAME=$(gh api user -q '.login' 2>/dev/null || echo "unknown") + log_info "Logged in as: $GH_USERNAME" +else + log_warning "You are not authenticated with GitHub." + log_info "To authenticate, run: gh auth login" +fi + +log_info "" +log_info "========================================" +log_success "Post-start setup complete!" +log_info "========================================" +log_info "" +log_info "Build the TypeScript project:" +log_info " cd src/typescript && npm run build" +log_info "" +log_info "Run TypeScript tests:" +log_info " cd src/typescript && npm test" +log_info "" +log_info "Run linter:" +log_info " cd src/typescript && npm run lint" +log_info "" From 6f8f82939252b43a7a45b3b87177af9b1ee249cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:44:45 +0000 Subject: [PATCH 05/28] devcontainer: update README to document the 3 devcontainer configurations Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/47b9b2eb-25e1-4072-9064-c9ef69d5ba78 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0fa23bde..b09f3624 100644 --- a/README.md +++ b/README.md @@ -679,7 +679,13 @@ npm run lint ### Using Dev Container -The repository includes a [Dev Container](https://containers.dev/) configuration that provides a ready-to-use development environment with all the required tools pre-installed. +The repository includes three [Dev Container](https://containers.dev/) configurations — one per language — so you only start the toolchain you need. + +| Dev Container | Base image | Optimised for | +|---------------------------|-----------------------------------------------|-----------------------| +| **DataFilters (C#)** | `mcr.microsoft.com/devcontainers/dotnet:10.0` | C# / .NET SDK | +| **DataFilters (Python)** | `mcr.microsoft.com/devcontainers/python:3.12` | Python 3.12 + pytest | +| **DataFilters (TypeScript)** | `mcr.microsoft.com/devcontainers/javascript-node:20` | Node 20 + Jest | 1. Clone the repository: @@ -694,11 +700,11 @@ The repository includes a [Dev Container](https://containers.dev/) configuration code . ``` -3. When prompted, click **"Reopen in Container"** (or run the command `Dev Containers: Reopen in Container` from the Command Palette). +3. When prompted to select a Dev Container configuration, choose the one matching your language (or run **Dev Containers: Reopen in Container** from the Command Palette and pick from the list). - VS Code will build the container, install the .NET SDK and extensions, then restore the dotnet local tools and NuGet packages automatically. + VS Code will build the selected container, install the required SDK and extensions, and restore all dependencies automatically. -> **Tip**: You can also open this repository directly in [GitHub Codespaces](https://github.com/codespaces) for a cloud-hosted development environment. +> **Tip**: You can also open this repository directly in [GitHub Codespaces](https://github.com/codespaces) and select your preferred Dev Container configuration at startup. ### Local setup From 7d162023c62019ad35a8ae8a2be0554d0aa88f22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:46:45 +0000 Subject: [PATCH 06/28] ci: add explicit permissions blocks to all new workflows Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/47b9b2eb-25e1-4072-9064-c9ef69d5ba78 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- .github/workflows/python-delivery.yml | 2 ++ .github/workflows/typescript-delivery.yml | 2 ++ .github/workflows/typescript-integration.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/python-delivery.yml b/.github/workflows/python-delivery.yml index 4f3d66d7..76b551c9 100644 --- a/.github/workflows/python-delivery.yml +++ b/.github/workflows/python-delivery.yml @@ -17,6 +17,8 @@ jobs: python-deploy: name: Build and publish Python package runs-on: ubuntu-24.04 + permissions: + contents: read steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/typescript-delivery.yml b/.github/workflows/typescript-delivery.yml index c9ce5732..b3b5fad3 100644 --- a/.github/workflows/typescript-delivery.yml +++ b/.github/workflows/typescript-delivery.yml @@ -17,6 +17,8 @@ jobs: typescript-deploy: name: Build and publish TypeScript package runs-on: ubuntu-24.04 + permissions: + contents: read steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/typescript-integration.yml b/.github/workflows/typescript-integration.yml index 11e4a2dd..3c9342b7 100644 --- a/.github/workflows/typescript-integration.yml +++ b/.github/workflows/typescript-integration.yml @@ -16,6 +16,8 @@ jobs: typescript: name: Node ${{ matrix.node-version }} runs-on: ubuntu-24.04 + permissions: + contents: read strategy: fail-fast: false matrix: From 5ee2e1c60d9d83508db8a4920b94c000121b8ab2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:52:36 +0000 Subject: [PATCH 07/28] remove Python SDK, workflows, devcontainer and related docs Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/24d85729-2d44-4275-bbdf-724101301a1b Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- .devcontainer/python/devcontainer.json | 48 ------ .devcontainer/python/scripts/post-create.sh | 68 -------- .devcontainer/python/scripts/post-start.sh | 102 ------------ .github/workflows/python-delivery.yml | 60 -------- .github/workflows/python-integration.yml | 56 ------- .gitignore | 7 - README.md | 23 +-- src/python/.flake8 | 3 - src/python/datafilters/__init__.py | 44 ------ src/python/datafilters/builder.py | 129 ---------------- src/python/datafilters/expressions.py | 130 ---------------- src/python/datafilters/parser.py | 162 -------------------- src/python/datafilters/serializers.py | 89 ----------- src/python/pyproject.toml | 53 ------- src/python/requirements-dev.txt | 5 - src/python/setup.py | 4 - src/python/tests/__init__.py | 1 - src/python/tests/test_builder.py | 84 ---------- src/python/tests/test_expressions.py | 124 --------------- src/python/tests/test_parser.py | 129 ---------------- 20 files changed, 1 insertion(+), 1320 deletions(-) delete mode 100644 .devcontainer/python/devcontainer.json delete mode 100755 .devcontainer/python/scripts/post-create.sh delete mode 100755 .devcontainer/python/scripts/post-start.sh delete mode 100644 .github/workflows/python-delivery.yml delete mode 100644 .github/workflows/python-integration.yml delete mode 100644 src/python/.flake8 delete mode 100644 src/python/datafilters/__init__.py delete mode 100644 src/python/datafilters/builder.py delete mode 100644 src/python/datafilters/expressions.py delete mode 100644 src/python/datafilters/parser.py delete mode 100644 src/python/datafilters/serializers.py delete mode 100644 src/python/pyproject.toml delete mode 100644 src/python/requirements-dev.txt delete mode 100644 src/python/setup.py delete mode 100644 src/python/tests/__init__.py delete mode 100644 src/python/tests/test_builder.py delete mode 100644 src/python/tests/test_expressions.py delete mode 100644 src/python/tests/test_parser.py diff --git a/.devcontainer/python/devcontainer.json b/.devcontainer/python/devcontainer.json deleted file mode 100644 index f67cc046..00000000 --- a/.devcontainer/python/devcontainer.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "DataFilters (Python)", - "image": "mcr.microsoft.com/devcontainers/python:3.12", - "features": { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/copilot-cli": {}, - "ghcr.io/devcontainers/features/node:1": {} - }, - "runArgs": [ - "--userns=keep-id" - ], - "containerUser": "vscode", - "updateRemoteUserUID": true, - "customizations": { - "vscode": { - "extensions": [ - "ms-python.python", - "ms-python.pylance", - "ms-python.flake8", - "ms-python.black-formatter", - "ms-python.isort", - "ms-python.vscode-pytest", - "editorconfig.editorconfig", - "GitHub.copilot", - "GitHub.vscode-pull-request-github", - "bierner.markdown-mermaid", - "DavidAnson.vscode-markdownlint" - ], - "settings": { - "python.defaultInterpreterPath": "/usr/local/bin/python", - "editor.formatOnSave": true, - "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter", - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - } - }, - "isort.args": ["--profile", "black"], - "flake8.args": ["--max-line-length=100"], - "dev.containers.dockerPath": "podman" - } - } - }, - "postCreateCommand": "./.devcontainer/python/scripts/post-create.sh", - "postStartCommand": "./.devcontainer/python/scripts/post-start.sh", - "remoteUser": "vscode" -} diff --git a/.devcontainer/python/scripts/post-create.sh b/.devcontainer/python/scripts/post-create.sh deleted file mode 100755 index 0dc0cb66..00000000 --- a/.devcontainer/python/scripts/post-create.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/bin/bash -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -log_info() { - echo -e "${BLUE}ℹ️ $1${NC}" -} - -log_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -log_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -log_error() { - echo -e "${RED}❌ $1${NC}" -} - -log_info "================================================" -log_info "DataFilters (Python) DevContainer Post-Create Setup" -log_info "================================================" - -# Update package manager -log_info "Updating package manager..." -sudo apt-get update > /dev/null 2>&1 -log_success "Package manager updated." - -log_info "Python version: $(python --version)" -log_info "pip version: $(pip --version)" - -# Install xdg-utils for opening links in the default browser -log_info "Installing xdg-utils..." -sudo apt-get install -y xdg-utils > /dev/null 2>&1 -log_success "xdg-utils installed successfully." - -# Install Python development dependencies -log_info "Installing Python development dependencies..." -cd src/python -pip install -e ".[dev]" > /dev/null 2>&1 -cd ../.. -log_success "Python development dependencies installed." - -# Install squad CLI globally -log_info "Installing squad CLI globally..." -npm install -g @bradygaster/squad-cli > /dev/null 2>&1 -log_success "squad CLI installed successfully." - -log_info "Initialize squad configuration..." -squad init -log_success "squad CLI initialized successfully." - -log_info "" -log_success "Post-create setup complete!" -log_info "" -log_info "Additional setup will run on container start (post-start)." -log_info "This includes:" -log_info " • GitHub CLI checks" -log_info " • Python dependency checks" -log_info " • Squad configuration checks" -log_info "" diff --git a/.devcontainer/python/scripts/post-start.sh b/.devcontainer/python/scripts/post-start.sh deleted file mode 100755 index f16a6eae..00000000 --- a/.devcontainer/python/scripts/post-start.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/bin/bash -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -log_info() { - echo -e "${BLUE}ℹ️ $1${NC}" -} - -log_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -log_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -log_error() { - echo -e "${RED}❌ $1${NC}" -} - -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -log_info "================================================" -log_info "DataFilters (Python) DevContainer Post-Start Setup" -log_info "================================================" - -# Update package manager -log_info "Updating package manager..." -sudo apt-get update > /dev/null 2>&1 -log_success "Package manager updated." - -# Install GitHub CLI if not present -if ! command_exists gh; then - log_info "Installing GitHub CLI (gh)..." - if sudo apt-get install -y gh > /dev/null 2>&1; then - log_success "GitHub CLI installed successfully." - else - log_warning "Could not install GitHub CLI. Some Squad features may be limited." - fi -else - log_success "GitHub CLI already installed." -fi - -# Ensure Python dependencies are up to date -log_info "Ensuring Python development dependencies are up to date..." -cd src/python -pip install -e ".[dev]" -q -cd ../.. -log_success "Python dependencies up to date." - -# Ensure squad CLI is installed or updated -if command_exists squad; then - log_info "Updating squad CLI to latest version..." - npm install --ignore-scripts -g @bradygaster/squad-cli@latest > /dev/null 2>&1 - log_success "squad CLI updated to latest version." -else - log_info "Installing squad CLI..." - npm install --ignore-scripts -g @bradygaster/squad-cli > /dev/null 2>&1 - log_success "squad CLI installed successfully." -fi - -# Check if Squad is already initialized in the workspace -if [ -d ".squad" ]; then - log_success "Squad is already initialized in this workspace." -else - log_info "Squad is not yet initialized." - log_info "" - log_info "To set up your Squad team, run:" - log_info " squad init" -fi - -# Check GitHub authentication status -log_info "" -log_info "Checking GitHub authentication status..." -if gh auth status > /dev/null 2>&1; then - log_success "You are authenticated with GitHub." - GH_USERNAME=$(gh api user -q '.login' 2>/dev/null || echo "unknown") - log_info "Logged in as: $GH_USERNAME" -else - log_warning "You are not authenticated with GitHub." - log_info "To authenticate, run: gh auth login" -fi - -log_info "" -log_info "========================================" -log_success "Post-start setup complete!" -log_info "========================================" -log_info "" -log_info "Run Python tests:" -log_info " cd src/python && pytest tests/" -log_info "" -log_info "Run linter:" -log_info " cd src/python && flake8 datafilters tests" -log_info "" diff --git a/.github/workflows/python-delivery.yml b/.github/workflows/python-delivery.yml deleted file mode 100644 index 76b551c9..00000000 --- a/.github/workflows/python-delivery.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: python-delivery - -on: - push: - branches: - - main - - 'release/*' - paths: - - 'src/python/**' - - '.github/workflows/python-*.yml' - pull_request: - paths: - - 'src/python/**' - - '.github/workflows/python-*.yml' - -jobs: - python-deploy: - name: Build and publish Python package - runs-on: ubuntu-24.04 - permissions: - contents: read - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - cache: 'pip' - cache-dependency-path: 'src/python/requirements-dev.txt' - - - name: Install dependencies - run: | - cd src/python - pip install -e ".[dev]" - - - name: Lint with flake8 - run: | - cd src/python - flake8 datafilters tests - - - name: Test with pytest - run: | - cd src/python - pytest tests/ - - - name: Install build tools - run: python -m pip install build twine - - - name: Build package - run: | - cd src/python - python -m build - - - name: Publish to PyPI - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} - packages-dir: src/python/dist/ diff --git a/.github/workflows/python-integration.yml b/.github/workflows/python-integration.yml deleted file mode 100644 index df8433db..00000000 --- a/.github/workflows/python-integration.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: python-integration - -on: - push: - branches-ignore: - - main - paths: - - 'src/python/**' - - '.github/workflows/python-*.yml' - pull_request: - paths: - - 'src/python/**' - - '.github/workflows/python-*.yml' - -jobs: - python: - name: Python ${{ matrix.python-version }} - runs-on: ubuntu-24.04 - permissions: - contents: read - strategy: - fail-fast: false - matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: 'src/python/requirements-dev.txt' - - - name: Install dependencies - run: | - cd src/python - pip install -e ".[dev]" - - - name: Lint with flake8 - run: | - cd src/python - flake8 datafilters tests - - - name: Test with pytest - run: | - cd src/python - pytest --cov=datafilters --cov-report=xml tests/ - - - name: Upload coverage - uses: codecov/codecov-action@v4 - with: - files: ./src/python/coverage.xml - flags: python - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 903096b6..ec83e764 100644 --- a/.gitignore +++ b/.gitignore @@ -260,13 +260,6 @@ paket-files/ __pycache__/ *.pyc -# Python SDK -*.egg-info/ -.pytest_cache/ -.coverage -src/python/dist/ -src/python/build/ - # TypeScript/Node SDK src/typescript/node_modules/ src/typescript/dist/ diff --git a/README.md b/README.md index b09f3624..463f064f 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,6 @@ The public API SHOULD NOT be considered stable. - [Local setup](#local-setup) - [Build](#build) - [Monorepo structure](#monorepo-structure) - - [Python SDK](#python-sdk) - [TypeScript SDK](#typescript-sdk) The idea came to me when working on a set of REST APIs and trying to build `/search` endpoints. @@ -620,10 +619,6 @@ This repository is organised as a monorepo that hosts independent SDKs for multi ``` src/ ├── DataFilters/ # C# library (NuGet) -├── python/ # Python SDK (PyPI) -│ ├── datafilters/ -│ ├── tests/ -│ └── pyproject.toml └── typescript/ # TypeScript SDK (npm) ├── src/ ├── test/ @@ -635,23 +630,8 @@ Each language has its own independent CI/CD pipelines triggered only when its fi | Language | Integration workflow | Delivery workflow | |------------|----------------------------------|-------------------------------| | C# | `integration.yml` | `delivery.yml` | -| Python | `python-integration.yml` | `python-delivery.yml` | | TypeScript | `typescript-integration.yml` | `typescript-delivery.yml` | -### Python SDK - -```bash -# Install for development -cd src/python -pip install -e ".[dev]" - -# Run tests -pytest tests/ - -# Lint -flake8 datafilters tests -``` - ### TypeScript SDK ```bash @@ -679,12 +659,11 @@ npm run lint ### Using Dev Container -The repository includes three [Dev Container](https://containers.dev/) configurations — one per language — so you only start the toolchain you need. +The repository includes two [Dev Container](https://containers.dev/) configurations — one per language — so you only start the toolchain you need. | Dev Container | Base image | Optimised for | |---------------------------|-----------------------------------------------|-----------------------| | **DataFilters (C#)** | `mcr.microsoft.com/devcontainers/dotnet:10.0` | C# / .NET SDK | -| **DataFilters (Python)** | `mcr.microsoft.com/devcontainers/python:3.12` | Python 3.12 + pytest | | **DataFilters (TypeScript)** | `mcr.microsoft.com/devcontainers/javascript-node:20` | Node 20 + Jest | 1. Clone the repository: diff --git a/src/python/.flake8 b/src/python/.flake8 deleted file mode 100644 index de2bbb09..00000000 --- a/src/python/.flake8 +++ /dev/null @@ -1,3 +0,0 @@ -[flake8] -max-line-length = 100 -extend-ignore = E203,W503 diff --git a/src/python/datafilters/__init__.py b/src/python/datafilters/__init__.py deleted file mode 100644 index f80e3f90..00000000 --- a/src/python/datafilters/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -"""DataFilters Python SDK. - -A library to generate filter expressions compatible with the DataFilters C# library, -using an Elasticsearch-inspired query syntax. -""" - -from .builder import FilterBuilder -from .expressions import ( - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - IFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, -) -from .parser import parse -from .serializers import to_dict, to_json - -__all__ = [ - "IFilter", - "EqualsFilter", - "ContainsFilter", - "StartsWithFilter", - "EndsWithFilter", - "GreaterThanFilter", - "GreaterThanOrEqualFilter", - "LessThanFilter", - "LessThanOrEqualFilter", - "AndFilter", - "OrFilter", - "NotFilter", - "FilterBuilder", - "parse", - "to_dict", - "to_json", -] - -__version__ = "0.1.0" diff --git a/src/python/datafilters/builder.py b/src/python/datafilters/builder.py deleted file mode 100644 index a807a678..00000000 --- a/src/python/datafilters/builder.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Fluent builder API for constructing DataFilters filter expressions.""" - -from typing import Any, List - -from .expressions import ( - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - IFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, -) - - -class FilterBuilder: - """Fluent builder for constructing compound filter expressions. - - Example usage:: - - builder = FilterBuilder() - f = (builder - .where("name").contains("bat") - .and_where("age").gte(18) - .build()) - """ - - def __init__(self) -> None: - self._filters: List[IFilter] = [] - - def where(self, field: str) -> "_FieldBuilder": - """Start a filter condition for the given field. - - Args: - field: The name of the field to filter on. - - Returns: - A ``_FieldBuilder`` scoped to *field*. - """ - return _FieldBuilder(self, field) - - def _add(self, filter: IFilter) -> "FilterBuilder": - """Append a filter to the current list.""" - self._filters.append(filter) - return self - - def and_where(self, field: str) -> "_FieldBuilder": - """Chain an additional AND condition on a new field. - - Args: - field: The name of the field for the next condition. - - Returns: - A ``_FieldBuilder`` scoped to *field*. - """ - return _FieldBuilder(self, field) - - def build(self) -> IFilter: - """Construct and return the final IFilter. - - Returns: - A single IFilter or an AndFilter combining all accumulated conditions. - - Raises: - ValueError: If no conditions have been added. - """ - if not self._filters: - raise ValueError("No filter conditions have been added.") - - if len(self._filters) == 1: - result = self._filters[0] - else: - result = AndFilter(self._filters) - - return result - - -class _FieldBuilder: - """Internal helper that provides filter-condition methods for a single field.""" - - def __init__(self, parent: FilterBuilder, field: str) -> None: - self._parent = parent - self._field = field - - def eq(self, value: Any) -> FilterBuilder: - """Add an equals condition.""" - return self._parent._add(EqualsFilter(self._field, value)) - - def contains(self, value: str) -> FilterBuilder: - """Add a contains condition.""" - return self._parent._add(ContainsFilter(self._field, value)) - - def starts_with(self, value: str) -> FilterBuilder: - """Add a starts-with condition.""" - return self._parent._add(StartsWithFilter(self._field, value)) - - def ends_with(self, value: str) -> FilterBuilder: - """Add an ends-with condition.""" - return self._parent._add(EndsWithFilter(self._field, value)) - - def gt(self, value: Any) -> FilterBuilder: - """Add a greater-than condition.""" - return self._parent._add(GreaterThanFilter(self._field, value)) - - def gte(self, value: Any) -> FilterBuilder: - """Add a greater-than-or-equal condition.""" - return self._parent._add(GreaterThanOrEqualFilter(self._field, value)) - - def lt(self, value: Any) -> FilterBuilder: - """Add a less-than condition.""" - return self._parent._add(LessThanFilter(self._field, value)) - - def lte(self, value: Any) -> FilterBuilder: - """Add a less-than-or-equal condition.""" - return self._parent._add(LessThanOrEqualFilter(self._field, value)) - - def not_(self, value: Any) -> FilterBuilder: - """Add a not-equals condition.""" - return self._parent._add(NotFilter(EqualsFilter(self._field, value))) - - def or_(self, *values: Any) -> FilterBuilder: - """Add an OR condition matching any of the given values.""" - or_filters: List[IFilter] = [EqualsFilter(self._field, v) for v in values] - return self._parent._add(OrFilter(or_filters)) diff --git a/src/python/datafilters/expressions.py b/src/python/datafilters/expressions.py deleted file mode 100644 index 947f5038..00000000 --- a/src/python/datafilters/expressions.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Filter expression classes compatible with the DataFilters C# library.""" - -from abc import ABC, abstractmethod -from typing import Any, List - - -class IFilter(ABC): - """Base interface for all filter expressions.""" - - @abstractmethod - def to_dict(self) -> dict: - """Serialize the filter to a dictionary representation.""" - - -class EqualsFilter(IFilter): - """Matches records where a property equals a specific value.""" - - def __init__(self, field: str, value: Any) -> None: - self.field = field - self.value = value - - def to_dict(self) -> dict: - return {"field": self.field, "op": "eq", "value": self.value} - - -class ContainsFilter(IFilter): - """Matches records where a property contains a substring.""" - - def __init__(self, field: str, value: str) -> None: - self.field = field - self.value = value - - def to_dict(self) -> dict: - return {"field": self.field, "op": "contains", "value": self.value} - - -class StartsWithFilter(IFilter): - """Matches records where a property starts with a specific string.""" - - def __init__(self, field: str, value: str) -> None: - self.field = field - self.value = value - - def to_dict(self) -> dict: - return {"field": self.field, "op": "startswith", "value": self.value} - - -class EndsWithFilter(IFilter): - """Matches records where a property ends with a specific string.""" - - def __init__(self, field: str, value: str) -> None: - self.field = field - self.value = value - - def to_dict(self) -> dict: - return {"field": self.field, "op": "endswith", "value": self.value} - - -class GreaterThanFilter(IFilter): - """Matches records where a property is greater than a value.""" - - def __init__(self, field: str, value: Any) -> None: - self.field = field - self.value = value - - def to_dict(self) -> dict: - return {"field": self.field, "op": "gt", "value": self.value} - - -class GreaterThanOrEqualFilter(IFilter): - """Matches records where a property is greater than or equal to a value.""" - - def __init__(self, field: str, value: Any) -> None: - self.field = field - self.value = value - - def to_dict(self) -> dict: - return {"field": self.field, "op": "gte", "value": self.value} - - -class LessThanFilter(IFilter): - """Matches records where a property is less than a value.""" - - def __init__(self, field: str, value: Any) -> None: - self.field = field - self.value = value - - def to_dict(self) -> dict: - return {"field": self.field, "op": "lt", "value": self.value} - - -class LessThanOrEqualFilter(IFilter): - """Matches records where a property is less than or equal to a value.""" - - def __init__(self, field: str, value: Any) -> None: - self.field = field - self.value = value - - def to_dict(self) -> dict: - return {"field": self.field, "op": "lte", "value": self.value} - - -class AndFilter(IFilter): - """Combines multiple filters with a logical AND.""" - - def __init__(self, filters: List[IFilter]) -> None: - self.filters = filters - - def to_dict(self) -> dict: - return {"logic": "and", "filters": [f.to_dict() for f in self.filters]} - - -class OrFilter(IFilter): - """Combines multiple filters with a logical OR.""" - - def __init__(self, filters: List[IFilter]) -> None: - self.filters = filters - - def to_dict(self) -> dict: - return {"logic": "or", "filters": [f.to_dict() for f in self.filters]} - - -class NotFilter(IFilter): - """Negates a filter expression.""" - - def __init__(self, filter: IFilter) -> None: - self.filter = filter - - def to_dict(self) -> dict: - return {"logic": "not", "filters": [self.filter.to_dict()]} diff --git a/src/python/datafilters/parser.py b/src/python/datafilters/parser.py deleted file mode 100644 index be2c559a..00000000 --- a/src/python/datafilters/parser.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Parser for DataFilters query syntax strings. - -Parses an Elasticsearch-inspired filter syntax string into IFilter objects. - -Supported syntax examples: - - ``field=value`` → EqualsFilter - - ``field=value*`` → StartsWithFilter - - ``field=*value`` → EndsWithFilter - - ``field=*value*`` → ContainsFilter - - ``field=!value`` → NotFilter(EqualsFilter) - - ``field=[min,max]`` → AndFilter(gte, lte) - - ``field=[min,]`` → GreaterThanOrEqualFilter - - ``field=[,max]`` → LessThanOrEqualFilter - - ``field=(min,max)`` → AndFilter(gt, lt) - - ``expr1,expr2`` → AndFilter of multiple criteria - - ``expr1|expr2`` → OrFilter of multiple criteria -""" - -import re -from typing import List, Optional - -from .expressions import ( - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - IFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, -) - -_RANGE_PATTERN = re.compile( - r"^(?P[\[\(])(?P[^,]*),(?P[^)\]]*)?(?P[\]\)])$" -) - - -def _parse_value_expression(field: str, value: str) -> IFilter: - """Parse a single field=value expression into the appropriate IFilter.""" - negated = value.startswith("!") - if negated: - value = value[1:] - - range_match = _RANGE_PATTERN.match(value) - if range_match: - result = _parse_range(field, range_match) - elif value.startswith("*") and value.endswith("*") and len(value) > 1: - result = ContainsFilter(field, value[1:-1]) - elif value.startswith("*"): - result = EndsWithFilter(field, value[1:]) - elif value.endswith("*"): - result = StartsWithFilter(field, value[:-1]) - else: - result = EqualsFilter(field, value) - - if negated: - result = NotFilter(result) - - return result - - -def _parse_range(field: str, match: re.Match) -> IFilter: - """Parse a range expression like [min,max], (min,max), [min,], [,max] into IFilter(s).""" - open_bracket = match.group("open") - close_bracket = match.group("close") - min_val: Optional[str] = match.group("min") or None - max_val: Optional[str] = match.group("max") or None - - filters: List[IFilter] = [] - - if min_val is not None: - if open_bracket == "[": - filters.append(GreaterThanOrEqualFilter(field, min_val)) - else: - filters.append(GreaterThanFilter(field, min_val)) - - if max_val is not None: - if close_bracket == "]": - filters.append(LessThanOrEqualFilter(field, max_val)) - else: - filters.append(LessThanFilter(field, max_val)) - - if len(filters) == 1: - return filters[0] - - return AndFilter(filters) - - -def parse(expression: str) -> IFilter: - """Parse a DataFilters query string into an IFilter. - - Args: - expression: A filter expression string, e.g. ``"name=*bat*,age=[18,]"``. - - Returns: - An IFilter representing the parsed expression. - - Raises: - ValueError: If the expression cannot be parsed. - """ - if not expression or not expression.strip(): - raise ValueError("Expression must not be empty") - - expression = expression.strip() - - or_parts = expression.split("|") - if len(or_parts) > 1: - parsed_parts = [_parse_and_expression(part.strip()) for part in or_parts] - return OrFilter(parsed_parts) - - return _parse_and_expression(expression) - - -def _split_and_parts(expression: str) -> list: - """Split an expression on commas that are not inside brackets.""" - parts = [] - depth = 0 - current: list = [] - for char in expression: - if char in "([": - depth += 1 - current.append(char) - elif char in ")]": - depth -= 1 - current.append(char) - elif char == "," and depth == 0: - parts.append("".join(current)) - current = [] - else: - current.append(char) - if current: - parts.append("".join(current)) - return parts - - -def _parse_and_expression(expression: str) -> IFilter: - """Parse a comma-separated AND expression.""" - and_parts = _split_and_parts(expression) - if len(and_parts) > 1: - parsed_parts = [_parse_single(part.strip()) for part in and_parts] - return AndFilter(parsed_parts) - - return _parse_single(expression) - - -def _parse_single(expression: str) -> IFilter: - """Parse a single field=value pair.""" - if "=" not in expression: - raise ValueError(f"Invalid filter expression: '{expression}'. Expected 'field=value'.") - - field, _, value = expression.partition("=") - field = field.strip() - value = value.strip() - - if not field: - raise ValueError(f"Field name must not be empty in expression: '{expression}'") - - return _parse_value_expression(field, value) diff --git a/src/python/datafilters/serializers.py b/src/python/datafilters/serializers.py deleted file mode 100644 index 0e497bf5..00000000 --- a/src/python/datafilters/serializers.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Serialization utilities for DataFilters expressions.""" - -import json - -from .expressions import IFilter - - -def to_dict(filter: IFilter) -> dict: - """Serialize an IFilter to a plain Python dictionary. - - Args: - filter: The filter expression to serialize. - - Returns: - A dictionary representation of the filter. - """ - return filter.to_dict() - - -def to_json(filter: IFilter, **kwargs) -> str: - """Serialize an IFilter to a JSON string. - - Args: - filter: The filter expression to serialize. - **kwargs: Additional keyword arguments forwarded to ``json.dumps``. - - Returns: - A JSON string representation of the filter. - """ - return json.dumps(to_dict(filter), **kwargs) - - -def from_dict(data: dict) -> IFilter: - """Deserialize a dictionary into an IFilter. - - Args: - data: A dictionary produced by :func:`to_dict`. - - Returns: - The reconstructed IFilter. - - Raises: - ValueError: If the dictionary does not represent a known filter type. - """ - from .expressions import ( # noqa: PLC0415 (local import to avoid circular refs) - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, - ) - - if "logic" in data: - logic = data["logic"] - children = [from_dict(f) for f in data.get("filters", [])] - - if logic == "and": - result = AndFilter(children) - elif logic == "or": - result = OrFilter(children) - elif logic == "not": - result = NotFilter(children[0]) - else: - raise ValueError(f"Unknown logic operator: '{logic}'") - - return result - - op_map = { - "eq": EqualsFilter, - "contains": ContainsFilter, - "startswith": StartsWithFilter, - "endswith": EndsWithFilter, - "gt": GreaterThanFilter, - "gte": GreaterThanOrEqualFilter, - "lt": LessThanFilter, - "lte": LessThanOrEqualFilter, - } - - op = data.get("op") - if op not in op_map: - raise ValueError(f"Unknown filter operator: '{op}'") - - return op_map[op](data["field"], data["value"]) diff --git a/src/python/pyproject.toml b/src/python/pyproject.toml deleted file mode 100644 index e1dc9bbf..00000000 --- a/src/python/pyproject.toml +++ /dev/null @@ -1,53 +0,0 @@ -[project] -name = "datafilters" -version = "0.1.0" -description = "Generate filter expressions compatible with the DataFilters C# library" -readme = "README.md" -license = { text = "MIT" } -requires-python = ">=3.8" -keywords = ["filters", "query", "elasticsearch", "datafilters"] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Software Development :: Libraries", -] -dependencies = [] - -[project.optional-dependencies] -dev = [ - "pytest>=7.0", - "pytest-cov>=4.0", - "flake8>=6.0", - "black>=23.0", - "isort>=5.0", -] - -[build-system] -requires = ["setuptools>=65", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.setuptools.packages.find] -where = ["."] -include = ["datafilters*"] - -[tool.pytest.ini_options] -testpaths = ["tests"] - -[tool.black] -line-length = 100 -target-version = ["py38", "py39", "py310", "py311", "py312"] - -[tool.isort] -profile = "black" -line_length = 100 - -[tool.flake8] -max-line-length = 100 -extend-ignore = ["E203", "W503"] diff --git a/src/python/requirements-dev.txt b/src/python/requirements-dev.txt deleted file mode 100644 index 20c0532d..00000000 --- a/src/python/requirements-dev.txt +++ /dev/null @@ -1,5 +0,0 @@ -pytest>=7.0 -pytest-cov>=4.0 -flake8>=6.0 -black>=23.0 -isort>=5.0 diff --git a/src/python/setup.py b/src/python/setup.py deleted file mode 100644 index 1ed95b77..00000000 --- a/src/python/setup.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Legacy setup.py for compatibility with older tools.""" -from setuptools import setup - -setup() diff --git a/src/python/tests/__init__.py b/src/python/tests/__init__.py deleted file mode 100644 index e7230ea6..00000000 --- a/src/python/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for DataFilters Python SDK.""" diff --git a/src/python/tests/test_builder.py b/src/python/tests/test_builder.py deleted file mode 100644 index d4c34823..00000000 --- a/src/python/tests/test_builder.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Tests for the FilterBuilder fluent API.""" - -import pytest - -from datafilters.builder import FilterBuilder -from datafilters.expressions import ( - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, -) - - -class TestFilterBuilderSingleCondition: - def test_eq(self): - result = FilterBuilder().where("name").eq("Batman").build() - assert isinstance(result, EqualsFilter) - assert result.field == "name" - assert result.value == "Batman" - - def test_contains(self): - result = FilterBuilder().where("name").contains("bat").build() - assert isinstance(result, ContainsFilter) - assert result.value == "bat" - - def test_starts_with(self): - result = FilterBuilder().where("name").starts_with("Bat").build() - assert isinstance(result, StartsWithFilter) - - def test_ends_with(self): - result = FilterBuilder().where("name").ends_with("man").build() - assert isinstance(result, EndsWithFilter) - - def test_gt(self): - result = FilterBuilder().where("age").gt(18).build() - assert isinstance(result, GreaterThanFilter) - assert result.value == 18 - - def test_gte(self): - result = FilterBuilder().where("age").gte(18).build() - assert isinstance(result, GreaterThanOrEqualFilter) - - def test_lt(self): - result = FilterBuilder().where("age").lt(65).build() - assert isinstance(result, LessThanFilter) - - def test_lte(self): - result = FilterBuilder().where("age").lte(65).build() - assert isinstance(result, LessThanOrEqualFilter) - - def test_not(self): - result = FilterBuilder().where("deleted").not_(True).build() - assert isinstance(result, NotFilter) - assert isinstance(result.filter, EqualsFilter) - - -class TestFilterBuilderMultipleConditions: - def test_two_conditions_produce_and_filter(self): - result = ( - FilterBuilder() - .where("name").contains("bat") - .and_where("age").gte(18) - .build() - ) - assert isinstance(result, AndFilter) - assert len(result.filters) == 2 - - def test_or_condition(self): - result = FilterBuilder().where("status").or_("active", "pending").build() - assert isinstance(result, OrFilter) - assert len(result.filters) == 2 - - -class TestFilterBuilderErrors: - def test_build_without_conditions_raises(self): - with pytest.raises(ValueError): - FilterBuilder().build() diff --git a/src/python/tests/test_expressions.py b/src/python/tests/test_expressions.py deleted file mode 100644 index 82961bbc..00000000 --- a/src/python/tests/test_expressions.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Tests for filter expression classes.""" - -import pytest - -from datafilters.expressions import ( - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, -) - - -class TestEqualsFilter: - def test_to_dict(self): - f = EqualsFilter("name", "Batman") - assert f.to_dict() == {"field": "name", "op": "eq", "value": "Batman"} - - def test_field_and_value(self): - f = EqualsFilter("age", 30) - assert f.field == "age" - assert f.value == 30 - - -class TestContainsFilter: - def test_to_dict(self): - f = ContainsFilter("name", "bat") - assert f.to_dict() == {"field": "name", "op": "contains", "value": "bat"} - - -class TestStartsWithFilter: - def test_to_dict(self): - f = StartsWithFilter("name", "Bat") - assert f.to_dict() == {"field": "name", "op": "startswith", "value": "Bat"} - - -class TestEndsWithFilter: - def test_to_dict(self): - f = EndsWithFilter("name", "man") - assert f.to_dict() == {"field": "name", "op": "endswith", "value": "man"} - - -class TestGreaterThanFilter: - def test_to_dict(self): - f = GreaterThanFilter("age", 18) - assert f.to_dict() == {"field": "age", "op": "gt", "value": 18} - - -class TestGreaterThanOrEqualFilter: - def test_to_dict(self): - f = GreaterThanOrEqualFilter("age", 18) - assert f.to_dict() == {"field": "age", "op": "gte", "value": 18} - - -class TestLessThanFilter: - def test_to_dict(self): - f = LessThanFilter("age", 65) - assert f.to_dict() == {"field": "age", "op": "lt", "value": 65} - - -class TestLessThanOrEqualFilter: - def test_to_dict(self): - f = LessThanOrEqualFilter("age", 65) - assert f.to_dict() == {"field": "age", "op": "lte", "value": 65} - - -class TestAndFilter: - def test_to_dict(self): - f = AndFilter([EqualsFilter("a", 1), EqualsFilter("b", 2)]) - result = f.to_dict() - assert result["logic"] == "and" - assert len(result["filters"]) == 2 - - def test_nested(self): - inner = AndFilter([GreaterThanOrEqualFilter("age", 18), LessThanOrEqualFilter("age", 65)]) - result = inner.to_dict() - assert result["logic"] == "and" - assert result["filters"][0]["op"] == "gte" - assert result["filters"][1]["op"] == "lte" - - -class TestOrFilter: - def test_to_dict(self): - f = OrFilter([EqualsFilter("status", "active"), EqualsFilter("status", "pending")]) - result = f.to_dict() - assert result["logic"] == "or" - assert len(result["filters"]) == 2 - - -class TestNotFilter: - def test_to_dict(self): - f = NotFilter(EqualsFilter("deleted", True)) - result = f.to_dict() - assert result["logic"] == "not" - assert len(result["filters"]) == 1 - assert result["filters"][0]["op"] == "eq" - - def test_wraps_filter(self): - inner = EqualsFilter("active", False) - f = NotFilter(inner) - assert f.filter is inner - - -@pytest.mark.parametrize( - "filter_instance, expected_op", - [ - (EqualsFilter("f", "v"), "eq"), - (ContainsFilter("f", "v"), "contains"), - (StartsWithFilter("f", "v"), "startswith"), - (EndsWithFilter("f", "v"), "endswith"), - (GreaterThanFilter("f", 0), "gt"), - (GreaterThanOrEqualFilter("f", 0), "gte"), - (LessThanFilter("f", 0), "lt"), - (LessThanOrEqualFilter("f", 0), "lte"), - ], -) -def test_filter_operators(filter_instance, expected_op): - assert filter_instance.to_dict()["op"] == expected_op diff --git a/src/python/tests/test_parser.py b/src/python/tests/test_parser.py deleted file mode 100644 index 3ee9154e..00000000 --- a/src/python/tests/test_parser.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Tests for the DataFilters query string parser.""" - -import pytest - -from datafilters.expressions import ( - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, -) -from datafilters.parser import parse - - -class TestParseEquals: - def test_simple_equals(self): - result = parse("name=Batman") - assert isinstance(result, EqualsFilter) - assert result.field == "name" - assert result.value == "Batman" - - -class TestParseContains: - def test_contains(self): - result = parse("name=*bat*") - assert isinstance(result, ContainsFilter) - assert result.field == "name" - assert result.value == "bat" - - -class TestParseStartsWith: - def test_starts_with(self): - result = parse("name=Bat*") - assert isinstance(result, StartsWithFilter) - assert result.value == "Bat" - - -class TestParseEndsWith: - def test_ends_with(self): - result = parse("name=*man") - assert isinstance(result, EndsWithFilter) - assert result.value == "man" - - -class TestParseNegation: - def test_not_equals(self): - result = parse("name=!Batman") - assert isinstance(result, NotFilter) - assert isinstance(result.filter, EqualsFilter) - assert result.filter.value == "Batman" - - -class TestParseRange: - def test_closed_range(self): - result = parse("age=[18,65]") - assert isinstance(result, AndFilter) - assert isinstance(result.filters[0], GreaterThanOrEqualFilter) - assert isinstance(result.filters[1], LessThanOrEqualFilter) - assert result.filters[0].value == "18" - assert result.filters[1].value == "65" - - def test_open_range(self): - result = parse("age=(18,65)") - assert isinstance(result, AndFilter) - assert isinstance(result.filters[0], GreaterThanFilter) - assert isinstance(result.filters[1], LessThanFilter) - - def test_half_open_min(self): - result = parse("age=[18,]") - assert isinstance(result, GreaterThanOrEqualFilter) - assert result.value == "18" - - def test_half_open_max(self): - result = parse("age=[,65]") - assert isinstance(result, LessThanOrEqualFilter) - assert result.value == "65" - - -class TestParseAnd: - def test_and_combination(self): - result = parse("name=*bat*,age=[18,]") - assert isinstance(result, AndFilter) - assert len(result.filters) == 2 - - -class TestParseOr: - def test_or_combination(self): - result = parse("status=active|status=pending") - assert isinstance(result, OrFilter) - assert len(result.filters) == 2 - - -class TestParseErrors: - def test_empty_expression_raises(self): - with pytest.raises(ValueError): - parse("") - - def test_whitespace_expression_raises(self): - with pytest.raises(ValueError): - parse(" ") - - def test_missing_field_raises(self): - with pytest.raises(ValueError): - parse("=value") - - def test_missing_equals_raises(self): - with pytest.raises(ValueError): - parse("noequals") - - -@pytest.mark.parametrize( - "expression, expected_type", - [ - ("name=Batman", EqualsFilter), - ("name=*bat*", ContainsFilter), - ("name=Bat*", StartsWithFilter), - ("name=*man", EndsWithFilter), - ("name=!Batman", NotFilter), - ], -) -def test_parse_single_expressions(expression, expected_type): - result = parse(expression) - assert isinstance(result, expected_type) From 5af0db0aedb28b0e51612b62e8e0b23750370da5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:32:54 +0000 Subject: [PATCH 08/28] refactor: split expressions into individual files, rename to *Expression, add public access modifiers, apply SESE, add AAA test sections Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/3b0d73fe-0823-45fb-9b59-4f2ea2d58396 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- src/typescript/src/AndFilterExpression.ts | 10 + .../src/ContainsFilterExpression.ts | 10 + .../src/EndsWithFilterExpression.ts | 10 + src/typescript/src/EqualsFilterExpression.ts | 10 + .../src/GreaterThanFilterExpression.ts | 10 + .../src/GreaterThanOrEqualFilterExpression.ts | 10 + src/typescript/src/IFilter.ts | 4 + .../src/LessThanFilterExpression.ts | 10 + .../src/LessThanOrEqualFilterExpression.ts | 10 + src/typescript/src/NotFilterExpression.ts | 10 + src/typescript/src/OrFilterExpression.ts | 10 + .../src/StartsWithFilterExpression.ts | 10 + src/typescript/src/builder.ts | 82 ++++---- src/typescript/src/expressions.ts | 116 ++--------- src/typescript/src/index.ts | 22 +-- src/typescript/src/parser.ts | 90 ++++----- src/typescript/src/serializers.ts | 72 +++---- .../test/AndFilterExpression.test.ts | 18 ++ .../test/ContainsFilterExpression.test.ts | 22 +++ .../test/EndsWithFilterExpression.test.ts | 22 +++ .../test/EqualsFilterExpression.test.ts | 31 +++ .../test/GreaterThanFilterExpression.test.ts | 22 +++ ...GreaterThanOrEqualFilterExpression.test.ts | 22 +++ .../test/LessThanFilterExpression.test.ts | 22 +++ .../LessThanOrEqualFilterExpression.test.ts | 22 +++ .../test/NotFilterExpression.test.ts | 26 +++ .../test/OrFilterExpression.test.ts | 18 ++ .../test/StartsWithFilterExpression.test.ts | 22 +++ src/typescript/test/builder.test.ts | 58 +++--- src/typescript/test/expressions.test.ts | 128 ------------ src/typescript/test/parser.test.ts | 186 ++++++++++++------ 31 files changed, 664 insertions(+), 451 deletions(-) create mode 100644 src/typescript/src/AndFilterExpression.ts create mode 100644 src/typescript/src/ContainsFilterExpression.ts create mode 100644 src/typescript/src/EndsWithFilterExpression.ts create mode 100644 src/typescript/src/EqualsFilterExpression.ts create mode 100644 src/typescript/src/GreaterThanFilterExpression.ts create mode 100644 src/typescript/src/GreaterThanOrEqualFilterExpression.ts create mode 100644 src/typescript/src/IFilter.ts create mode 100644 src/typescript/src/LessThanFilterExpression.ts create mode 100644 src/typescript/src/LessThanOrEqualFilterExpression.ts create mode 100644 src/typescript/src/NotFilterExpression.ts create mode 100644 src/typescript/src/OrFilterExpression.ts create mode 100644 src/typescript/src/StartsWithFilterExpression.ts create mode 100644 src/typescript/test/AndFilterExpression.test.ts create mode 100644 src/typescript/test/ContainsFilterExpression.test.ts create mode 100644 src/typescript/test/EndsWithFilterExpression.test.ts create mode 100644 src/typescript/test/EqualsFilterExpression.test.ts create mode 100644 src/typescript/test/GreaterThanFilterExpression.test.ts create mode 100644 src/typescript/test/GreaterThanOrEqualFilterExpression.test.ts create mode 100644 src/typescript/test/LessThanFilterExpression.test.ts create mode 100644 src/typescript/test/LessThanOrEqualFilterExpression.test.ts create mode 100644 src/typescript/test/NotFilterExpression.test.ts create mode 100644 src/typescript/test/OrFilterExpression.test.ts create mode 100644 src/typescript/test/StartsWithFilterExpression.test.ts delete mode 100644 src/typescript/test/expressions.test.ts diff --git a/src/typescript/src/AndFilterExpression.ts b/src/typescript/src/AndFilterExpression.ts new file mode 100644 index 00000000..918ad44e --- /dev/null +++ b/src/typescript/src/AndFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Combines multiple filters with a logical AND. */ +export class AndFilterExpression implements IFilter { + public constructor(public readonly filters: IFilter[]) {} + + public toDict(): Record { + return { logic: 'and', filters: this.filters.map((f) => f.toDict()) }; + } +} diff --git a/src/typescript/src/ContainsFilterExpression.ts b/src/typescript/src/ContainsFilterExpression.ts new file mode 100644 index 00000000..be5dcbfc --- /dev/null +++ b/src/typescript/src/ContainsFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Matches records where a property contains a substring. */ +export class ContainsFilterExpression implements IFilter { + public constructor(public readonly field: string, public readonly value: string) {} + + public toDict(): Record { + return { field: this.field, op: 'contains', value: this.value }; + } +} diff --git a/src/typescript/src/EndsWithFilterExpression.ts b/src/typescript/src/EndsWithFilterExpression.ts new file mode 100644 index 00000000..dd92270a --- /dev/null +++ b/src/typescript/src/EndsWithFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Matches records where a property ends with a specific string. */ +export class EndsWithFilterExpression implements IFilter { + public constructor(public readonly field: string, public readonly value: string) {} + + public toDict(): Record { + return { field: this.field, op: 'endswith', value: this.value }; + } +} diff --git a/src/typescript/src/EqualsFilterExpression.ts b/src/typescript/src/EqualsFilterExpression.ts new file mode 100644 index 00000000..3ed1c4b1 --- /dev/null +++ b/src/typescript/src/EqualsFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Matches records where a property equals a specific value. */ +export class EqualsFilterExpression implements IFilter { + public constructor(public readonly field: string, public readonly value: unknown) {} + + public toDict(): Record { + return { field: this.field, op: 'eq', value: this.value }; + } +} diff --git a/src/typescript/src/GreaterThanFilterExpression.ts b/src/typescript/src/GreaterThanFilterExpression.ts new file mode 100644 index 00000000..21b4b4c0 --- /dev/null +++ b/src/typescript/src/GreaterThanFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Matches records where a property is greater than a value. */ +export class GreaterThanFilterExpression implements IFilter { + public constructor(public readonly field: string, public readonly value: unknown) {} + + public toDict(): Record { + return { field: this.field, op: 'gt', value: this.value }; + } +} diff --git a/src/typescript/src/GreaterThanOrEqualFilterExpression.ts b/src/typescript/src/GreaterThanOrEqualFilterExpression.ts new file mode 100644 index 00000000..7c9cd5a2 --- /dev/null +++ b/src/typescript/src/GreaterThanOrEqualFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Matches records where a property is greater than or equal to a value. */ +export class GreaterThanOrEqualFilterExpression implements IFilter { + public constructor(public readonly field: string, public readonly value: unknown) {} + + public toDict(): Record { + return { field: this.field, op: 'gte', value: this.value }; + } +} diff --git a/src/typescript/src/IFilter.ts b/src/typescript/src/IFilter.ts new file mode 100644 index 00000000..4da6470e --- /dev/null +++ b/src/typescript/src/IFilter.ts @@ -0,0 +1,4 @@ +/** Base interface for all filter expressions. */ +export interface IFilter { + toDict(): Record; +} diff --git a/src/typescript/src/LessThanFilterExpression.ts b/src/typescript/src/LessThanFilterExpression.ts new file mode 100644 index 00000000..aefb5030 --- /dev/null +++ b/src/typescript/src/LessThanFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Matches records where a property is less than a value. */ +export class LessThanFilterExpression implements IFilter { + public constructor(public readonly field: string, public readonly value: unknown) {} + + public toDict(): Record { + return { field: this.field, op: 'lt', value: this.value }; + } +} diff --git a/src/typescript/src/LessThanOrEqualFilterExpression.ts b/src/typescript/src/LessThanOrEqualFilterExpression.ts new file mode 100644 index 00000000..d3006f8e --- /dev/null +++ b/src/typescript/src/LessThanOrEqualFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Matches records where a property is less than or equal to a value. */ +export class LessThanOrEqualFilterExpression implements IFilter { + public constructor(public readonly field: string, public readonly value: unknown) {} + + public toDict(): Record { + return { field: this.field, op: 'lte', value: this.value }; + } +} diff --git a/src/typescript/src/NotFilterExpression.ts b/src/typescript/src/NotFilterExpression.ts new file mode 100644 index 00000000..1b3707a6 --- /dev/null +++ b/src/typescript/src/NotFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Negates a filter expression. */ +export class NotFilterExpression implements IFilter { + public constructor(public readonly filter: IFilter) {} + + public toDict(): Record { + return { logic: 'not', filters: [this.filter.toDict()] }; + } +} diff --git a/src/typescript/src/OrFilterExpression.ts b/src/typescript/src/OrFilterExpression.ts new file mode 100644 index 00000000..99235414 --- /dev/null +++ b/src/typescript/src/OrFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Combines multiple filters with a logical OR. */ +export class OrFilterExpression implements IFilter { + public constructor(public readonly filters: IFilter[]) {} + + public toDict(): Record { + return { logic: 'or', filters: this.filters.map((f) => f.toDict()) }; + } +} diff --git a/src/typescript/src/StartsWithFilterExpression.ts b/src/typescript/src/StartsWithFilterExpression.ts new file mode 100644 index 00000000..eafbcaea --- /dev/null +++ b/src/typescript/src/StartsWithFilterExpression.ts @@ -0,0 +1,10 @@ +import { IFilter } from './IFilter'; + +/** Matches records where a property starts with a specific string. */ +export class StartsWithFilterExpression implements IFilter { + public constructor(public readonly field: string, public readonly value: string) {} + + public toDict(): Record { + return { field: this.field, op: 'startswith', value: this.value }; + } +} diff --git a/src/typescript/src/builder.ts b/src/typescript/src/builder.ts index 41c8c174..c814a432 100644 --- a/src/typescript/src/builder.ts +++ b/src/typescript/src/builder.ts @@ -3,76 +3,76 @@ */ import { - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, + AndFilterExpression, + ContainsFilterExpression, + EndsWithFilterExpression, + EqualsFilterExpression, + GreaterThanFilterExpression, + GreaterThanOrEqualFilterExpression, IFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, + LessThanFilterExpression, + LessThanOrEqualFilterExpression, + NotFilterExpression, + OrFilterExpression, + StartsWithFilterExpression, } from './expressions'; /** Internal helper that provides filter-condition methods for a single field. */ export class FieldBuilder { - constructor( + public constructor( private readonly parent: FilterBuilder, private readonly field: string, ) {} /** Add an equals condition. */ - eq(value: unknown): FilterBuilder { - return this.parent['add'](new EqualsFilter(this.field, value)); + public eq(value: unknown): FilterBuilder { + return this.parent['add'](new EqualsFilterExpression(this.field, value)); } /** Add a contains condition. */ - contains(value: string): FilterBuilder { - return this.parent['add'](new ContainsFilter(this.field, value)); + public contains(value: string): FilterBuilder { + return this.parent['add'](new ContainsFilterExpression(this.field, value)); } /** Add a starts-with condition. */ - startsWith(value: string): FilterBuilder { - return this.parent['add'](new StartsWithFilter(this.field, value)); + public startsWith(value: string): FilterBuilder { + return this.parent['add'](new StartsWithFilterExpression(this.field, value)); } /** Add an ends-with condition. */ - endsWith(value: string): FilterBuilder { - return this.parent['add'](new EndsWithFilter(this.field, value)); + public endsWith(value: string): FilterBuilder { + return this.parent['add'](new EndsWithFilterExpression(this.field, value)); } /** Add a greater-than condition. */ - gt(value: unknown): FilterBuilder { - return this.parent['add'](new GreaterThanFilter(this.field, value)); + public gt(value: unknown): FilterBuilder { + return this.parent['add'](new GreaterThanFilterExpression(this.field, value)); } /** Add a greater-than-or-equal condition. */ - gte(value: unknown): FilterBuilder { - return this.parent['add'](new GreaterThanOrEqualFilter(this.field, value)); + public gte(value: unknown): FilterBuilder { + return this.parent['add'](new GreaterThanOrEqualFilterExpression(this.field, value)); } /** Add a less-than condition. */ - lt(value: unknown): FilterBuilder { - return this.parent['add'](new LessThanFilter(this.field, value)); + public lt(value: unknown): FilterBuilder { + return this.parent['add'](new LessThanFilterExpression(this.field, value)); } /** Add a less-than-or-equal condition. */ - lte(value: unknown): FilterBuilder { - return this.parent['add'](new LessThanOrEqualFilter(this.field, value)); + public lte(value: unknown): FilterBuilder { + return this.parent['add'](new LessThanOrEqualFilterExpression(this.field, value)); } /** Add a not-equals condition. */ - not(value: unknown): FilterBuilder { - return this.parent['add'](new NotFilter(new EqualsFilter(this.field, value))); + public not(value: unknown): FilterBuilder { + return this.parent['add'](new NotFilterExpression(new EqualsFilterExpression(this.field, value))); } /** Add an OR condition matching any of the given values. */ - or(...values: unknown[]): FilterBuilder { - const orFilters: IFilter[] = values.map((v) => new EqualsFilter(this.field, v)); - return this.parent['add'](new OrFilter(orFilters)); + public or(...values: unknown[]): FilterBuilder { + const orFilters: IFilter[] = values.map((v) => new EqualsFilterExpression(this.field, v)); + return this.parent['add'](new OrFilterExpression(orFilters)); } } @@ -99,7 +99,7 @@ export class FilterBuilder { * Start a filter condition for the given field. * @param field - The name of the field to filter on. */ - where(field: string): FieldBuilder { + public where(field: string): FieldBuilder { return new FieldBuilder(this, field); } @@ -107,25 +107,23 @@ export class FilterBuilder { * Chain an additional AND condition on a new field. * @param field - The name of the field for the next condition. */ - andWhere(field: string): FieldBuilder { + public andWhere(field: string): FieldBuilder { return new FieldBuilder(this, field); } /** * Construct and return the final IFilter. * - * @returns A single IFilter or an AndFilter combining all accumulated conditions. + * @returns A single IFilter or an AndFilterExpression combining all accumulated conditions. * @throws {Error} If no conditions have been added. */ - build(): IFilter { + public build(): IFilter { if (this.filters.length === 0) { throw new Error('No filter conditions have been added.'); } - if (this.filters.length === 1) { - return this.filters[0]; - } - - return new AndFilter(this.filters); + const result: IFilter = + this.filters.length === 1 ? this.filters[0] : new AndFilterExpression(this.filters); + return result; } } diff --git a/src/typescript/src/expressions.ts b/src/typescript/src/expressions.ts index 651f2d14..e0a79b0d 100644 --- a/src/typescript/src/expressions.ts +++ b/src/typescript/src/expressions.ts @@ -1,107 +1,17 @@ /** * Filter expression types compatible with the DataFilters C# library. + * This file re-exports all expression types from their individual modules. */ -/** Base interface for all filter expressions. */ -export interface IFilter { - toDict(): Record; -} - -/** Matches records where a property equals a specific value. */ -export class EqualsFilter implements IFilter { - constructor(public readonly field: string, public readonly value: unknown) {} - - toDict(): Record { - return { field: this.field, op: 'eq', value: this.value }; - } -} - -/** Matches records where a property contains a substring. */ -export class ContainsFilter implements IFilter { - constructor(public readonly field: string, public readonly value: string) {} - - toDict(): Record { - return { field: this.field, op: 'contains', value: this.value }; - } -} - -/** Matches records where a property starts with a specific string. */ -export class StartsWithFilter implements IFilter { - constructor(public readonly field: string, public readonly value: string) {} - - toDict(): Record { - return { field: this.field, op: 'startswith', value: this.value }; - } -} - -/** Matches records where a property ends with a specific string. */ -export class EndsWithFilter implements IFilter { - constructor(public readonly field: string, public readonly value: string) {} - - toDict(): Record { - return { field: this.field, op: 'endswith', value: this.value }; - } -} - -/** Matches records where a property is greater than a value. */ -export class GreaterThanFilter implements IFilter { - constructor(public readonly field: string, public readonly value: unknown) {} - - toDict(): Record { - return { field: this.field, op: 'gt', value: this.value }; - } -} - -/** Matches records where a property is greater than or equal to a value. */ -export class GreaterThanOrEqualFilter implements IFilter { - constructor(public readonly field: string, public readonly value: unknown) {} - - toDict(): Record { - return { field: this.field, op: 'gte', value: this.value }; - } -} - -/** Matches records where a property is less than a value. */ -export class LessThanFilter implements IFilter { - constructor(public readonly field: string, public readonly value: unknown) {} - - toDict(): Record { - return { field: this.field, op: 'lt', value: this.value }; - } -} - -/** Matches records where a property is less than or equal to a value. */ -export class LessThanOrEqualFilter implements IFilter { - constructor(public readonly field: string, public readonly value: unknown) {} - - toDict(): Record { - return { field: this.field, op: 'lte', value: this.value }; - } -} - -/** Combines multiple filters with a logical AND. */ -export class AndFilter implements IFilter { - constructor(public readonly filters: IFilter[]) {} - - toDict(): Record { - return { logic: 'and', filters: this.filters.map((f) => f.toDict()) }; - } -} - -/** Combines multiple filters with a logical OR. */ -export class OrFilter implements IFilter { - constructor(public readonly filters: IFilter[]) {} - - toDict(): Record { - return { logic: 'or', filters: this.filters.map((f) => f.toDict()) }; - } -} - -/** Negates a filter expression. */ -export class NotFilter implements IFilter { - constructor(public readonly filter: IFilter) {} - - toDict(): Record { - return { logic: 'not', filters: [this.filter.toDict()] }; - } -} +export { IFilter } from './IFilter'; +export { EqualsFilterExpression } from './EqualsFilterExpression'; +export { ContainsFilterExpression } from './ContainsFilterExpression'; +export { StartsWithFilterExpression } from './StartsWithFilterExpression'; +export { EndsWithFilterExpression } from './EndsWithFilterExpression'; +export { GreaterThanFilterExpression } from './GreaterThanFilterExpression'; +export { GreaterThanOrEqualFilterExpression } from './GreaterThanOrEqualFilterExpression'; +export { LessThanFilterExpression } from './LessThanFilterExpression'; +export { LessThanOrEqualFilterExpression } from './LessThanOrEqualFilterExpression'; +export { AndFilterExpression } from './AndFilterExpression'; +export { OrFilterExpression } from './OrFilterExpression'; +export { NotFilterExpression } from './NotFilterExpression'; diff --git a/src/typescript/src/index.ts b/src/typescript/src/index.ts index 961655ee..991f62be 100644 --- a/src/typescript/src/index.ts +++ b/src/typescript/src/index.ts @@ -7,17 +7,17 @@ export { IFilter, - EqualsFilter, - ContainsFilter, - StartsWithFilter, - EndsWithFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - AndFilter, - OrFilter, - NotFilter, + EqualsFilterExpression, + ContainsFilterExpression, + StartsWithFilterExpression, + EndsWithFilterExpression, + GreaterThanFilterExpression, + GreaterThanOrEqualFilterExpression, + LessThanFilterExpression, + LessThanOrEqualFilterExpression, + AndFilterExpression, + OrFilterExpression, + NotFilterExpression, } from './expressions'; export { FilterBuilder, FieldBuilder } from './builder'; diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index e10466b6..8d94f07b 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -2,32 +2,32 @@ * Parser for DataFilters query syntax strings. * * Supported syntax: - * - `field=value` → EqualsFilter - * - `field=value*` → StartsWithFilter - * - `field=*value` → EndsWithFilter - * - `field=*value*` → ContainsFilter - * - `field=!value` → NotFilter(EqualsFilter) - * - `field=[min,max]` → AndFilter(gte, lte) - * - `field=[min,]` → GreaterThanOrEqualFilter - * - `field=[,max]` → LessThanOrEqualFilter - * - `field=(min,max)` → AndFilter(gt, lt) - * - `expr1,expr2` → AndFilter of multiple criteria - * - `expr1|expr2` → OrFilter of multiple criteria + * - `field=value` → EqualsFilterExpression + * - `field=value*` → StartsWithFilterExpression + * - `field=*value` → EndsWithFilterExpression + * - `field=*value*` → ContainsFilterExpression + * - `field=!value` → NotFilterExpression(EqualsFilterExpression) + * - `field=[min,max]` → AndFilterExpression(gte, lte) + * - `field=[min,]` → GreaterThanOrEqualFilterExpression + * - `field=[,max]` → LessThanOrEqualFilterExpression + * - `field=(min,max)` → AndFilterExpression(gt, lt) + * - `expr1,expr2` → AndFilterExpression of multiple criteria + * - `expr1|expr2` → OrFilterExpression of multiple criteria */ import { - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, + AndFilterExpression, + ContainsFilterExpression, + EndsWithFilterExpression, + EqualsFilterExpression, + GreaterThanFilterExpression, + GreaterThanOrEqualFilterExpression, IFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, + LessThanFilterExpression, + LessThanOrEqualFilterExpression, + NotFilterExpression, + OrFilterExpression, + StartsWithFilterExpression, } from './expressions'; const RANGE_PATTERN = /^(?[[(])(?[^,]*),(?[^)\]]*)?(?[)\]])$/; @@ -47,25 +47,22 @@ function parseRange(field: string, value: string): IFilter { if (minVal !== null && minVal !== '') { if (openBracket === '[') { - filters.push(new GreaterThanOrEqualFilter(field, minVal)); + filters.push(new GreaterThanOrEqualFilterExpression(field, minVal)); } else { - filters.push(new GreaterThanFilter(field, minVal)); + filters.push(new GreaterThanFilterExpression(field, minVal)); } } if (maxVal !== null && maxVal !== '') { if (closeBracket === ']') { - filters.push(new LessThanOrEqualFilter(field, maxVal)); + filters.push(new LessThanOrEqualFilterExpression(field, maxVal)); } else { - filters.push(new LessThanFilter(field, maxVal)); + filters.push(new LessThanFilterExpression(field, maxVal)); } } - if (filters.length === 1) { - return filters[0]; - } - - return new AndFilter(filters); + const result: IFilter = filters.length === 1 ? filters[0] : new AndFilterExpression(filters); + return result; } function parseValueExpression(field: string, value: string): IFilter { @@ -77,16 +74,16 @@ function parseValueExpression(field: string, value: string): IFilter { if (RANGE_PATTERN.test(actualValue)) { result = parseRange(field, actualValue); } else if (actualValue.startsWith('*') && actualValue.endsWith('*') && actualValue.length > 1) { - result = new ContainsFilter(field, actualValue.slice(1, -1)); + result = new ContainsFilterExpression(field, actualValue.slice(1, -1)); } else if (actualValue.startsWith('*')) { - result = new EndsWithFilter(field, actualValue.slice(1)); + result = new EndsWithFilterExpression(field, actualValue.slice(1)); } else if (actualValue.endsWith('*')) { - result = new StartsWithFilter(field, actualValue.slice(0, -1)); + result = new StartsWithFilterExpression(field, actualValue.slice(0, -1)); } else { - result = new EqualsFilter(field, actualValue); + result = new EqualsFilterExpression(field, actualValue); } - return negated ? new NotFilter(result) : result; + return negated ? new NotFilterExpression(result) : result; } function parseSingle(expression: string): IFilter { @@ -134,11 +131,11 @@ function splitAndParts(expression: string): string[] { function parseAndExpression(expression: string): IFilter { const parts = splitAndParts(expression); - if (parts.length > 1) { - return new AndFilter(parts.map((p) => parseSingle(p.trim()))); - } - - return parseSingle(expression); + const result: IFilter = + parts.length > 1 + ? new AndFilterExpression(parts.map((p) => parseSingle(p.trim()))) + : parseSingle(expression); + return result; } /** @@ -155,10 +152,9 @@ export function parse(expression: string): IFilter { const trimmed = expression.trim(); const orParts = trimmed.split('|'); - - if (orParts.length > 1) { - return new OrFilter(orParts.map((p) => parseAndExpression(p.trim()))); - } - - return parseAndExpression(trimmed); + const result: IFilter = + orParts.length > 1 + ? new OrFilterExpression(orParts.map((p) => parseAndExpression(p.trim()))) + : parseAndExpression(trimmed); + return result; } diff --git a/src/typescript/src/serializers.ts b/src/typescript/src/serializers.ts index adc0074a..8392b856 100644 --- a/src/typescript/src/serializers.ts +++ b/src/typescript/src/serializers.ts @@ -2,19 +2,19 @@ * Serialization utilities for DataFilters expressions. */ -import { IFilter } from './expressions'; import { - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, + AndFilterExpression, + ContainsFilterExpression, + EndsWithFilterExpression, + EqualsFilterExpression, + GreaterThanFilterExpression, + GreaterThanOrEqualFilterExpression, + IFilter, + LessThanFilterExpression, + LessThanOrEqualFilterExpression, + NotFilterExpression, + OrFilterExpression, + StartsWithFilterExpression, } from './expressions'; /** Serialize an IFilter to a plain JavaScript object. */ @@ -28,14 +28,14 @@ export function toJson(filter: IFilter, space?: number): string { } const OP_MAP: Record IFilter> = { - eq: EqualsFilter, - contains: ContainsFilter as new (field: string, value: unknown) => IFilter, - startswith: StartsWithFilter as new (field: string, value: unknown) => IFilter, - endswith: EndsWithFilter as new (field: string, value: unknown) => IFilter, - gt: GreaterThanFilter, - gte: GreaterThanOrEqualFilter, - lt: LessThanFilter, - lte: LessThanOrEqualFilter, + eq: EqualsFilterExpression, + contains: ContainsFilterExpression as new (field: string, value: unknown) => IFilter, + startswith: StartsWithFilterExpression as new (field: string, value: unknown) => IFilter, + endswith: EndsWithFilterExpression as new (field: string, value: unknown) => IFilter, + gt: GreaterThanFilterExpression, + gte: GreaterThanOrEqualFilterExpression, + lt: LessThanFilterExpression, + lte: LessThanOrEqualFilterExpression, }; /** @@ -46,29 +46,31 @@ const OP_MAP: Record IFilter> = { * @throws {Error} If the object does not represent a known filter type. */ export function fromDict(data: Record): IFilter { + let result: IFilter; + if ('logic' in data) { const logic = data['logic'] as string; const children = (data['filters'] as Record[]).map(fromDict); if (logic === 'and') { - return new AndFilter(children); - } - if (logic === 'or') { - return new OrFilter(children); - } - if (logic === 'not') { - return new NotFilter(children[0]); + result = new AndFilterExpression(children); + } else if (logic === 'or') { + result = new OrFilterExpression(children); + } else if (logic === 'not') { + result = new NotFilterExpression(children[0]); + } else { + throw new Error(`Unknown logic operator: '${logic}'`); } + } else { + const op = data['op'] as string; + const Ctor = OP_MAP[op]; - throw new Error(`Unknown logic operator: '${logic}'`); - } - - const op = data['op'] as string; - const Ctor = OP_MAP[op]; + if (!Ctor) { + throw new Error(`Unknown filter operator: '${op}'`); + } - if (!Ctor) { - throw new Error(`Unknown filter operator: '${op}'`); + result = new Ctor(data['field'] as string, data['value']); } - return new Ctor(data['field'] as string, data['value']); + return result; } diff --git a/src/typescript/test/AndFilterExpression.test.ts b/src/typescript/test/AndFilterExpression.test.ts new file mode 100644 index 00000000..eca0ea69 --- /dev/null +++ b/src/typescript/test/AndFilterExpression.test.ts @@ -0,0 +1,18 @@ +import { AndFilterExpression, EqualsFilterExpression } from '../src/expressions'; + +describe('AndFilterExpression', () => { + it('should serialize with logic and nested filters', () => { + // Arrange + const f = new AndFilterExpression([ + new EqualsFilterExpression('a', 1), + new EqualsFilterExpression('b', 2), + ]); + + // Act + const result = f.toDict(); + + // Assert + expect(result['logic']).toBe('and'); + expect((result['filters'] as unknown[]).length).toBe(2); + }); +}); diff --git a/src/typescript/test/ContainsFilterExpression.test.ts b/src/typescript/test/ContainsFilterExpression.test.ts new file mode 100644 index 00000000..645f83fb --- /dev/null +++ b/src/typescript/test/ContainsFilterExpression.test.ts @@ -0,0 +1,22 @@ +import { ContainsFilterExpression } from '../src/expressions'; + +describe('ContainsFilterExpression', () => { + it('should serialize to correct dict', () => { + // Arrange + const f = new ContainsFilterExpression('name', 'bat'); + + // Act + const result = f.toDict(); + + // Assert + expect(result).toEqual({ field: 'name', op: 'contains', value: 'bat' }); + }); + + it('op field should be contains', () => { + // Arrange / Act + const dict = new ContainsFilterExpression('f', 'v').toDict(); + + // Assert + expect(dict['op']).toBe('contains'); + }); +}); diff --git a/src/typescript/test/EndsWithFilterExpression.test.ts b/src/typescript/test/EndsWithFilterExpression.test.ts new file mode 100644 index 00000000..da227386 --- /dev/null +++ b/src/typescript/test/EndsWithFilterExpression.test.ts @@ -0,0 +1,22 @@ +import { EndsWithFilterExpression } from '../src/expressions'; + +describe('EndsWithFilterExpression', () => { + it('should serialize to correct dict', () => { + // Arrange + const f = new EndsWithFilterExpression('name', 'man'); + + // Act + const result = f.toDict(); + + // Assert + expect(result).toEqual({ field: 'name', op: 'endswith', value: 'man' }); + }); + + it('op field should be endswith', () => { + // Arrange / Act + const dict = new EndsWithFilterExpression('f', 'v').toDict(); + + // Assert + expect(dict['op']).toBe('endswith'); + }); +}); diff --git a/src/typescript/test/EqualsFilterExpression.test.ts b/src/typescript/test/EqualsFilterExpression.test.ts new file mode 100644 index 00000000..26b16722 --- /dev/null +++ b/src/typescript/test/EqualsFilterExpression.test.ts @@ -0,0 +1,31 @@ +import { EqualsFilterExpression } from '../src/expressions'; + +describe('EqualsFilterExpression', () => { + it('should serialize to correct dict', () => { + // Arrange + const f = new EqualsFilterExpression('name', 'Batman'); + + // Act + const result = f.toDict(); + + // Assert + expect(result).toEqual({ field: 'name', op: 'eq', value: 'Batman' }); + }); + + it('should store field and value', () => { + // Arrange / Act + const f = new EqualsFilterExpression('age', 30); + + // Assert + expect(f.field).toBe('age'); + expect(f.value).toBe(30); + }); + + it('op field should be eq', () => { + // Arrange / Act + const dict = new EqualsFilterExpression('f', 'v').toDict(); + + // Assert + expect(dict['op']).toBe('eq'); + }); +}); diff --git a/src/typescript/test/GreaterThanFilterExpression.test.ts b/src/typescript/test/GreaterThanFilterExpression.test.ts new file mode 100644 index 00000000..6f25bef1 --- /dev/null +++ b/src/typescript/test/GreaterThanFilterExpression.test.ts @@ -0,0 +1,22 @@ +import { GreaterThanFilterExpression } from '../src/expressions'; + +describe('GreaterThanFilterExpression', () => { + it('should serialize to correct dict', () => { + // Arrange + const f = new GreaterThanFilterExpression('age', 18); + + // Act + const result = f.toDict(); + + // Assert + expect(result).toEqual({ field: 'age', op: 'gt', value: 18 }); + }); + + it('op field should be gt', () => { + // Arrange / Act + const dict = new GreaterThanFilterExpression('f', 0).toDict(); + + // Assert + expect(dict['op']).toBe('gt'); + }); +}); diff --git a/src/typescript/test/GreaterThanOrEqualFilterExpression.test.ts b/src/typescript/test/GreaterThanOrEqualFilterExpression.test.ts new file mode 100644 index 00000000..db1ec4c6 --- /dev/null +++ b/src/typescript/test/GreaterThanOrEqualFilterExpression.test.ts @@ -0,0 +1,22 @@ +import { GreaterThanOrEqualFilterExpression } from '../src/expressions'; + +describe('GreaterThanOrEqualFilterExpression', () => { + it('should serialize to correct dict', () => { + // Arrange + const f = new GreaterThanOrEqualFilterExpression('age', 18); + + // Act + const result = f.toDict(); + + // Assert + expect(result).toEqual({ field: 'age', op: 'gte', value: 18 }); + }); + + it('op field should be gte', () => { + // Arrange / Act + const dict = new GreaterThanOrEqualFilterExpression('f', 0).toDict(); + + // Assert + expect(dict['op']).toBe('gte'); + }); +}); diff --git a/src/typescript/test/LessThanFilterExpression.test.ts b/src/typescript/test/LessThanFilterExpression.test.ts new file mode 100644 index 00000000..c6c8b9f5 --- /dev/null +++ b/src/typescript/test/LessThanFilterExpression.test.ts @@ -0,0 +1,22 @@ +import { LessThanFilterExpression } from '../src/expressions'; + +describe('LessThanFilterExpression', () => { + it('should serialize to correct dict', () => { + // Arrange + const f = new LessThanFilterExpression('age', 65); + + // Act + const result = f.toDict(); + + // Assert + expect(result).toEqual({ field: 'age', op: 'lt', value: 65 }); + }); + + it('op field should be lt', () => { + // Arrange / Act + const dict = new LessThanFilterExpression('f', 0).toDict(); + + // Assert + expect(dict['op']).toBe('lt'); + }); +}); diff --git a/src/typescript/test/LessThanOrEqualFilterExpression.test.ts b/src/typescript/test/LessThanOrEqualFilterExpression.test.ts new file mode 100644 index 00000000..6a517c84 --- /dev/null +++ b/src/typescript/test/LessThanOrEqualFilterExpression.test.ts @@ -0,0 +1,22 @@ +import { LessThanOrEqualFilterExpression } from '../src/expressions'; + +describe('LessThanOrEqualFilterExpression', () => { + it('should serialize to correct dict', () => { + // Arrange + const f = new LessThanOrEqualFilterExpression('age', 65); + + // Act + const result = f.toDict(); + + // Assert + expect(result).toEqual({ field: 'age', op: 'lte', value: 65 }); + }); + + it('op field should be lte', () => { + // Arrange / Act + const dict = new LessThanOrEqualFilterExpression('f', 0).toDict(); + + // Assert + expect(dict['op']).toBe('lte'); + }); +}); diff --git a/src/typescript/test/NotFilterExpression.test.ts b/src/typescript/test/NotFilterExpression.test.ts new file mode 100644 index 00000000..b484e9b5 --- /dev/null +++ b/src/typescript/test/NotFilterExpression.test.ts @@ -0,0 +1,26 @@ +import { EqualsFilterExpression, NotFilterExpression } from '../src/expressions'; + +describe('NotFilterExpression', () => { + it('should serialize with logic not', () => { + // Arrange + const f = new NotFilterExpression(new EqualsFilterExpression('deleted', true)); + + // Act + const result = f.toDict(); + + // Assert + expect(result['logic']).toBe('not'); + expect((result['filters'] as unknown[]).length).toBe(1); + }); + + it('should wrap the inner filter', () => { + // Arrange + const inner = new EqualsFilterExpression('active', false); + + // Act + const f = new NotFilterExpression(inner); + + // Assert + expect(f.filter).toBe(inner); + }); +}); diff --git a/src/typescript/test/OrFilterExpression.test.ts b/src/typescript/test/OrFilterExpression.test.ts new file mode 100644 index 00000000..10f53a91 --- /dev/null +++ b/src/typescript/test/OrFilterExpression.test.ts @@ -0,0 +1,18 @@ +import { EqualsFilterExpression, OrFilterExpression } from '../src/expressions'; + +describe('OrFilterExpression', () => { + it('should serialize with logic and nested filters', () => { + // Arrange + const f = new OrFilterExpression([ + new EqualsFilterExpression('status', 'active'), + new EqualsFilterExpression('status', 'pending'), + ]); + + // Act + const result = f.toDict(); + + // Assert + expect(result['logic']).toBe('or'); + expect((result['filters'] as unknown[]).length).toBe(2); + }); +}); diff --git a/src/typescript/test/StartsWithFilterExpression.test.ts b/src/typescript/test/StartsWithFilterExpression.test.ts new file mode 100644 index 00000000..0b4c6cbe --- /dev/null +++ b/src/typescript/test/StartsWithFilterExpression.test.ts @@ -0,0 +1,22 @@ +import { StartsWithFilterExpression } from '../src/expressions'; + +describe('StartsWithFilterExpression', () => { + it('should serialize to correct dict', () => { + // Arrange + const f = new StartsWithFilterExpression('name', 'Bat'); + + // Act + const result = f.toDict(); + + // Assert + expect(result).toEqual({ field: 'name', op: 'startswith', value: 'Bat' }); + }); + + it('op field should be startswith', () => { + // Arrange / Act + const dict = new StartsWithFilterExpression('f', 'v').toDict(); + + // Assert + expect(dict['op']).toBe('startswith'); + }); +}); diff --git a/src/typescript/test/builder.test.ts b/src/typescript/test/builder.test.ts index 38ee2658..15ec19dc 100644 --- a/src/typescript/test/builder.test.ts +++ b/src/typescript/test/builder.test.ts @@ -1,67 +1,67 @@ import { - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, + AndFilterExpression, + ContainsFilterExpression, + EndsWithFilterExpression, + EqualsFilterExpression, + GreaterThanFilterExpression, + GreaterThanOrEqualFilterExpression, + LessThanFilterExpression, + LessThanOrEqualFilterExpression, + NotFilterExpression, + OrFilterExpression, + StartsWithFilterExpression, } from '../src/expressions'; import { FilterBuilder } from '../src/builder'; describe('FilterBuilder - single conditions', () => { it('should build equals filter', () => { const result = new FilterBuilder().where('name').eq('Batman').build(); - expect(result).toBeInstanceOf(EqualsFilter); - expect((result as EqualsFilter).field).toBe('name'); - expect((result as EqualsFilter).value).toBe('Batman'); + expect(result).toBeInstanceOf(EqualsFilterExpression); + expect((result as EqualsFilterExpression).field).toBe('name'); + expect((result as EqualsFilterExpression).value).toBe('Batman'); }); it('should build contains filter', () => { const result = new FilterBuilder().where('name').contains('bat').build(); - expect(result).toBeInstanceOf(ContainsFilter); - expect((result as ContainsFilter).value).toBe('bat'); + expect(result).toBeInstanceOf(ContainsFilterExpression); + expect((result as ContainsFilterExpression).value).toBe('bat'); }); it('should build startsWith filter', () => { const result = new FilterBuilder().where('name').startsWith('Bat').build(); - expect(result).toBeInstanceOf(StartsWithFilter); + expect(result).toBeInstanceOf(StartsWithFilterExpression); }); it('should build endsWith filter', () => { const result = new FilterBuilder().where('name').endsWith('man').build(); - expect(result).toBeInstanceOf(EndsWithFilter); + expect(result).toBeInstanceOf(EndsWithFilterExpression); }); it('should build gt filter', () => { const result = new FilterBuilder().where('age').gt(18).build(); - expect(result).toBeInstanceOf(GreaterThanFilter); - expect((result as GreaterThanFilter).value).toBe(18); + expect(result).toBeInstanceOf(GreaterThanFilterExpression); + expect((result as GreaterThanFilterExpression).value).toBe(18); }); it('should build gte filter', () => { const result = new FilterBuilder().where('age').gte(18).build(); - expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); + expect(result).toBeInstanceOf(GreaterThanOrEqualFilterExpression); }); it('should build lt filter', () => { const result = new FilterBuilder().where('age').lt(65).build(); - expect(result).toBeInstanceOf(LessThanFilter); + expect(result).toBeInstanceOf(LessThanFilterExpression); }); it('should build lte filter', () => { const result = new FilterBuilder().where('age').lte(65).build(); - expect(result).toBeInstanceOf(LessThanOrEqualFilter); + expect(result).toBeInstanceOf(LessThanOrEqualFilterExpression); }); it('should build not filter', () => { const result = new FilterBuilder().where('deleted').not(true).build(); - expect(result).toBeInstanceOf(NotFilter); - expect((result as NotFilter).filter).toBeInstanceOf(EqualsFilter); + expect(result).toBeInstanceOf(NotFilterExpression); + expect((result as NotFilterExpression).filter).toBeInstanceOf(EqualsFilterExpression); }); }); @@ -71,14 +71,14 @@ describe('FilterBuilder - multiple conditions', () => { .where('name').contains('bat') .andWhere('age').gte(18) .build(); - expect(result).toBeInstanceOf(AndFilter); - expect((result as AndFilter).filters.length).toBe(2); + expect(result).toBeInstanceOf(AndFilterExpression); + expect((result as AndFilterExpression).filters.length).toBe(2); }); it('should build OR filter', () => { const result = new FilterBuilder().where('status').or('active', 'pending').build(); - expect(result).toBeInstanceOf(OrFilter); - expect((result as OrFilter).filters.length).toBe(2); + expect(result).toBeInstanceOf(OrFilterExpression); + expect((result as OrFilterExpression).filters.length).toBe(2); }); }); diff --git a/src/typescript/test/expressions.test.ts b/src/typescript/test/expressions.test.ts deleted file mode 100644 index 03ff6f3d..00000000 --- a/src/typescript/test/expressions.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, -} from '../src/expressions'; - -describe('EqualsFilter', () => { - it('should serialize to correct dict', () => { - const f = new EqualsFilter('name', 'Batman'); - expect(f.toDict()).toEqual({ field: 'name', op: 'eq', value: 'Batman' }); - }); - - it('should store field and value', () => { - const f = new EqualsFilter('age', 30); - expect(f.field).toBe('age'); - expect(f.value).toBe(30); - }); -}); - -describe('ContainsFilter', () => { - it('should serialize to correct dict', () => { - const f = new ContainsFilter('name', 'bat'); - expect(f.toDict()).toEqual({ field: 'name', op: 'contains', value: 'bat' }); - }); -}); - -describe('StartsWithFilter', () => { - it('should serialize to correct dict', () => { - const f = new StartsWithFilter('name', 'Bat'); - expect(f.toDict()).toEqual({ field: 'name', op: 'startswith', value: 'Bat' }); - }); -}); - -describe('EndsWithFilter', () => { - it('should serialize to correct dict', () => { - const f = new EndsWithFilter('name', 'man'); - expect(f.toDict()).toEqual({ field: 'name', op: 'endswith', value: 'man' }); - }); -}); - -describe('GreaterThanFilter', () => { - it('should serialize to correct dict', () => { - const f = new GreaterThanFilter('age', 18); - expect(f.toDict()).toEqual({ field: 'age', op: 'gt', value: 18 }); - }); -}); - -describe('GreaterThanOrEqualFilter', () => { - it('should serialize to correct dict', () => { - const f = new GreaterThanOrEqualFilter('age', 18); - expect(f.toDict()).toEqual({ field: 'age', op: 'gte', value: 18 }); - }); -}); - -describe('LessThanFilter', () => { - it('should serialize to correct dict', () => { - const f = new LessThanFilter('age', 65); - expect(f.toDict()).toEqual({ field: 'age', op: 'lt', value: 65 }); - }); -}); - -describe('LessThanOrEqualFilter', () => { - it('should serialize to correct dict', () => { - const f = new LessThanOrEqualFilter('age', 65); - expect(f.toDict()).toEqual({ field: 'age', op: 'lte', value: 65 }); - }); -}); - -describe('AndFilter', () => { - it('should serialize with logic and nested filters', () => { - const f = new AndFilter([new EqualsFilter('a', 1), new EqualsFilter('b', 2)]); - const result = f.toDict(); - expect(result['logic']).toBe('and'); - expect((result['filters'] as unknown[]).length).toBe(2); - }); -}); - -describe('OrFilter', () => { - it('should serialize with logic and nested filters', () => { - const f = new OrFilter([ - new EqualsFilter('status', 'active'), - new EqualsFilter('status', 'pending'), - ]); - const result = f.toDict(); - expect(result['logic']).toBe('or'); - expect((result['filters'] as unknown[]).length).toBe(2); - }); -}); - -describe('NotFilter', () => { - it('should serialize with logic not', () => { - const f = new NotFilter(new EqualsFilter('deleted', true)); - const result = f.toDict(); - expect(result['logic']).toBe('not'); - expect((result['filters'] as unknown[]).length).toBe(1); - }); - - it('should wrap the inner filter', () => { - const inner = new EqualsFilter('active', false); - const f = new NotFilter(inner); - expect(f.filter).toBe(inner); - }); -}); - -describe('filter operator values', () => { - const cases: [ReturnType<(typeof EqualsFilter.prototype)['toDict']>, string][] = [ - [new EqualsFilter('f', 'v').toDict(), 'eq'], - [new ContainsFilter('f', 'v').toDict(), 'contains'], - [new StartsWithFilter('f', 'v').toDict(), 'startswith'], - [new EndsWithFilter('f', 'v').toDict(), 'endswith'], - [new GreaterThanFilter('f', 0).toDict(), 'gt'], - [new GreaterThanOrEqualFilter('f', 0).toDict(), 'gte'], - [new LessThanFilter('f', 0).toDict(), 'lt'], - [new LessThanOrEqualFilter('f', 0).toDict(), 'lte'], - ]; - - test.each(cases)('op field should be %s', (dict, expectedOp) => { - expect(dict['op']).toBe(expectedOp); - }); -}); diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index e0447b2e..f521ac7e 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -1,23 +1,29 @@ import { - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, + AndFilterExpression, + ContainsFilterExpression, + EndsWithFilterExpression, + EqualsFilterExpression, + GreaterThanFilterExpression, + GreaterThanOrEqualFilterExpression, + LessThanFilterExpression, + LessThanOrEqualFilterExpression, + NotFilterExpression, + OrFilterExpression, + StartsWithFilterExpression, } from '../src/expressions'; import { parse } from '../src/parser'; describe('parse - equals', () => { it('should parse simple equals expression', () => { - const result = parse('name=Batman'); - expect(result).toBeInstanceOf(EqualsFilter); - const f = result as EqualsFilter; + // Arrange + const expression = 'name=Batman'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(EqualsFilterExpression); + const f = result as EqualsFilterExpression; expect(f.field).toBe('name'); expect(f.value).toBe('Batman'); }); @@ -25,112 +31,180 @@ describe('parse - equals', () => { describe('parse - contains', () => { it('should parse contains expression', () => { - const result = parse('name=*bat*'); - expect(result).toBeInstanceOf(ContainsFilter); - expect((result as ContainsFilter).value).toBe('bat'); + // Arrange + const expression = 'name=*bat*'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(ContainsFilterExpression); + expect((result as ContainsFilterExpression).value).toBe('bat'); }); }); describe('parse - startsWith', () => { it('should parse starts-with expression', () => { - const result = parse('name=Bat*'); - expect(result).toBeInstanceOf(StartsWithFilter); - expect((result as StartsWithFilter).value).toBe('Bat'); + // Arrange + const expression = 'name=Bat*'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(StartsWithFilterExpression); + expect((result as StartsWithFilterExpression).value).toBe('Bat'); }); }); describe('parse - endsWith', () => { it('should parse ends-with expression', () => { - const result = parse('name=*man'); - expect(result).toBeInstanceOf(EndsWithFilter); - expect((result as EndsWithFilter).value).toBe('man'); + // Arrange + const expression = 'name=*man'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(EndsWithFilterExpression); + expect((result as EndsWithFilterExpression).value).toBe('man'); }); }); describe('parse - negation', () => { it('should parse not-equals expression', () => { - const result = parse('name=!Batman'); - expect(result).toBeInstanceOf(NotFilter); - const inner = (result as NotFilter).filter; - expect(inner).toBeInstanceOf(EqualsFilter); - expect((inner as EqualsFilter).value).toBe('Batman'); + // Arrange + const expression = 'name=!Batman'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(NotFilterExpression); + const inner = (result as NotFilterExpression).filter; + expect(inner).toBeInstanceOf(EqualsFilterExpression); + expect((inner as EqualsFilterExpression).value).toBe('Batman'); }); }); describe('parse - range', () => { it('should parse closed range [min,max]', () => { - const result = parse('age=[18,65]'); - expect(result).toBeInstanceOf(AndFilter); - const f = result as AndFilter; - expect(f.filters[0]).toBeInstanceOf(GreaterThanOrEqualFilter); - expect(f.filters[1]).toBeInstanceOf(LessThanOrEqualFilter); + // Arrange + const expression = 'age=[18,65]'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilterExpression); + const f = result as AndFilterExpression; + expect(f.filters[0]).toBeInstanceOf(GreaterThanOrEqualFilterExpression); + expect(f.filters[1]).toBeInstanceOf(LessThanOrEqualFilterExpression); }); it('should parse open range (min,max)', () => { - const result = parse('age=(18,65)'); - expect(result).toBeInstanceOf(AndFilter); - const f = result as AndFilter; - expect(f.filters[0]).toBeInstanceOf(GreaterThanFilter); - expect(f.filters[1]).toBeInstanceOf(LessThanFilter); + // Arrange + const expression = 'age=(18,65)'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilterExpression); + const f = result as AndFilterExpression; + expect(f.filters[0]).toBeInstanceOf(GreaterThanFilterExpression); + expect(f.filters[1]).toBeInstanceOf(LessThanFilterExpression); }); it('should parse half-open range [min,]', () => { - const result = parse('age=[18,]'); - expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); - expect((result as GreaterThanOrEqualFilter).value).toBe('18'); + // Arrange + const expression = 'age=[18,]'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(GreaterThanOrEqualFilterExpression); + expect((result as GreaterThanOrEqualFilterExpression).value).toBe('18'); }); it('should parse half-open range [,max]', () => { - const result = parse('age=[,65]'); - expect(result).toBeInstanceOf(LessThanOrEqualFilter); - expect((result as LessThanOrEqualFilter).value).toBe('65'); + // Arrange + const expression = 'age=[,65]'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(LessThanOrEqualFilterExpression); + expect((result as LessThanOrEqualFilterExpression).value).toBe('65'); }); }); describe('parse - AND combination', () => { it('should parse comma-separated expressions as AND', () => { - const result = parse('name=*bat*,age=[18,]'); - expect(result).toBeInstanceOf(AndFilter); - expect((result as AndFilter).filters.length).toBe(2); + // Arrange + const expression = 'name=*bat*,age=[18,]'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilterExpression); + expect((result as AndFilterExpression).filters.length).toBe(2); }); }); describe('parse - OR combination', () => { it('should parse pipe-separated expressions as OR', () => { - const result = parse('status=active|status=pending'); - expect(result).toBeInstanceOf(OrFilter); - expect((result as OrFilter).filters.length).toBe(2); + // Arrange + const expression = 'status=active|status=pending'; + + // Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(OrFilterExpression); + expect((result as OrFilterExpression).filters.length).toBe(2); }); }); describe('parse - errors', () => { it('should throw on empty expression', () => { + // Arrange / Act / Assert expect(() => parse('')).toThrow(); }); it('should throw on whitespace-only expression', () => { + // Arrange / Act / Assert expect(() => parse(' ')).toThrow(); }); it('should throw on missing field', () => { + // Arrange / Act / Assert expect(() => parse('=value')).toThrow(); }); it('should throw on missing equals sign', () => { + // Arrange / Act / Assert expect(() => parse('noequals')).toThrow(); }); }); describe('parse - parameterized', () => { const cases: [string, unknown][] = [ - ['name=Batman', EqualsFilter], - ['name=*bat*', ContainsFilter], - ['name=Bat*', StartsWithFilter], - ['name=*man', EndsWithFilter], - ['name=!Batman', NotFilter], + ['name=Batman', EqualsFilterExpression], + ['name=*bat*', ContainsFilterExpression], + ['name=Bat*', StartsWithFilterExpression], + ['name=*man', EndsWithFilterExpression], + ['name=!Batman', NotFilterExpression], ]; test.each(cases)('parse("%s") should return expected type', (expression, expectedType) => { - expect(parse(expression)).toBeInstanceOf(expectedType as unknown as new () => unknown); + // Arrange / Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf(expectedType as unknown as new () => unknown); }); }); From 0c60ab5d232c2e6a31ec6629df889dff1450bad2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:53:24 +0000 Subject: [PATCH 09/28] refactor: rename compound filters (And/Or/Not drop Expression suffix), convert all file names to camelCase Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/7b2af4ef-fe6c-42f7-a2a9-5bc80f7a5b68 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- .../{AndFilterExpression.ts => andFilter.ts} | 4 +-- src/typescript/src/builder.ts | 12 ++++---- ...ression.ts => containsFilterExpression.ts} | 2 +- ...ression.ts => endsWithFilterExpression.ts} | 2 +- ...xpression.ts => equalsFilterExpression.ts} | 2 +- src/typescript/src/expressions.ts | 24 ++++++++-------- ...sion.ts => greaterThanFilterExpression.ts} | 2 +- ... => greaterThanOrEqualFilterExpression.ts} | 2 +- src/typescript/src/{IFilter.ts => iFilter.ts} | 0 src/typescript/src/index.ts | 6 ++-- ...ression.ts => lessThanFilterExpression.ts} | 2 +- ....ts => lessThanOrEqualFilterExpression.ts} | 2 +- .../{NotFilterExpression.ts => notFilter.ts} | 4 +-- .../{OrFilterExpression.ts => orFilter.ts} | 4 +-- src/typescript/src/parser.ts | 24 ++++++++-------- src/typescript/src/serializers.ts | 12 ++++---- ...ssion.ts => startsWithFilterExpression.ts} | 2 +- ...erExpression.test.ts => andFilter.test.ts} | 6 ++-- src/typescript/test/builder.test.ts | 18 ++++++------ ...st.ts => containsFilterExpression.test.ts} | 0 ...st.ts => endsWithFilterExpression.test.ts} | 0 ...test.ts => equalsFilterExpression.test.ts} | 0 ...ts => greaterThanFilterExpression.test.ts} | 0 ...reaterThanOrEqualFilterExpression.test.ts} | 0 ...st.ts => lessThanFilterExpression.test.ts} | 0 ...> lessThanOrEqualFilterExpression.test.ts} | 0 ...erExpression.test.ts => notFilter.test.ts} | 8 +++--- ...terExpression.test.ts => orFilter.test.ts} | 6 ++-- src/typescript/test/parser.test.ts | 28 +++++++++---------- ....ts => startsWithFilterExpression.test.ts} | 0 30 files changed, 86 insertions(+), 86 deletions(-) rename src/typescript/src/{AndFilterExpression.ts => andFilter.ts} (72%) rename src/typescript/src/{ContainsFilterExpression.ts => containsFilterExpression.ts} (89%) rename src/typescript/src/{EndsWithFilterExpression.ts => endsWithFilterExpression.ts} (90%) rename src/typescript/src/{EqualsFilterExpression.ts => equalsFilterExpression.ts} (89%) rename src/typescript/src/{GreaterThanFilterExpression.ts => greaterThanFilterExpression.ts} (89%) rename src/typescript/src/{GreaterThanOrEqualFilterExpression.ts => greaterThanOrEqualFilterExpression.ts} (90%) rename src/typescript/src/{IFilter.ts => iFilter.ts} (100%) rename src/typescript/src/{LessThanFilterExpression.ts => lessThanFilterExpression.ts} (89%) rename src/typescript/src/{LessThanOrEqualFilterExpression.ts => lessThanOrEqualFilterExpression.ts} (90%) rename src/typescript/src/{NotFilterExpression.ts => notFilter.ts} (69%) rename src/typescript/src/{OrFilterExpression.ts => orFilter.ts} (72%) rename src/typescript/src/{StartsWithFilterExpression.ts => startsWithFilterExpression.ts} (90%) rename src/typescript/test/{AndFilterExpression.test.ts => andFilter.test.ts} (67%) rename src/typescript/test/{ContainsFilterExpression.test.ts => containsFilterExpression.test.ts} (100%) rename src/typescript/test/{EndsWithFilterExpression.test.ts => endsWithFilterExpression.test.ts} (100%) rename src/typescript/test/{EqualsFilterExpression.test.ts => equalsFilterExpression.test.ts} (100%) rename src/typescript/test/{GreaterThanFilterExpression.test.ts => greaterThanFilterExpression.test.ts} (100%) rename src/typescript/test/{GreaterThanOrEqualFilterExpression.test.ts => greaterThanOrEqualFilterExpression.test.ts} (100%) rename src/typescript/test/{LessThanFilterExpression.test.ts => lessThanFilterExpression.test.ts} (100%) rename src/typescript/test/{LessThanOrEqualFilterExpression.test.ts => lessThanOrEqualFilterExpression.test.ts} (100%) rename src/typescript/test/{NotFilterExpression.test.ts => notFilter.test.ts} (62%) rename src/typescript/test/{OrFilterExpression.test.ts => orFilter.test.ts} (69%) rename src/typescript/test/{StartsWithFilterExpression.test.ts => startsWithFilterExpression.test.ts} (100%) diff --git a/src/typescript/src/AndFilterExpression.ts b/src/typescript/src/andFilter.ts similarity index 72% rename from src/typescript/src/AndFilterExpression.ts rename to src/typescript/src/andFilter.ts index 918ad44e..c6f83579 100644 --- a/src/typescript/src/AndFilterExpression.ts +++ b/src/typescript/src/andFilter.ts @@ -1,7 +1,7 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Combines multiple filters with a logical AND. */ -export class AndFilterExpression implements IFilter { +export class AndFilter implements IFilter { public constructor(public readonly filters: IFilter[]) {} public toDict(): Record { diff --git a/src/typescript/src/builder.ts b/src/typescript/src/builder.ts index c814a432..633a857d 100644 --- a/src/typescript/src/builder.ts +++ b/src/typescript/src/builder.ts @@ -3,7 +3,7 @@ */ import { - AndFilterExpression, + AndFilter, ContainsFilterExpression, EndsWithFilterExpression, EqualsFilterExpression, @@ -12,8 +12,8 @@ import { IFilter, LessThanFilterExpression, LessThanOrEqualFilterExpression, - NotFilterExpression, - OrFilterExpression, + NotFilter, + OrFilter, StartsWithFilterExpression, } from './expressions'; @@ -66,13 +66,13 @@ export class FieldBuilder { /** Add a not-equals condition. */ public not(value: unknown): FilterBuilder { - return this.parent['add'](new NotFilterExpression(new EqualsFilterExpression(this.field, value))); + return this.parent['add'](new NotFilter(new EqualsFilterExpression(this.field, value))); } /** Add an OR condition matching any of the given values. */ public or(...values: unknown[]): FilterBuilder { const orFilters: IFilter[] = values.map((v) => new EqualsFilterExpression(this.field, v)); - return this.parent['add'](new OrFilterExpression(orFilters)); + return this.parent['add'](new OrFilter(orFilters)); } } @@ -123,7 +123,7 @@ export class FilterBuilder { } const result: IFilter = - this.filters.length === 1 ? this.filters[0] : new AndFilterExpression(this.filters); + this.filters.length === 1 ? this.filters[0] : new AndFilter(this.filters); return result; } } diff --git a/src/typescript/src/ContainsFilterExpression.ts b/src/typescript/src/containsFilterExpression.ts similarity index 89% rename from src/typescript/src/ContainsFilterExpression.ts rename to src/typescript/src/containsFilterExpression.ts index be5dcbfc..62a2758e 100644 --- a/src/typescript/src/ContainsFilterExpression.ts +++ b/src/typescript/src/containsFilterExpression.ts @@ -1,4 +1,4 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Matches records where a property contains a substring. */ export class ContainsFilterExpression implements IFilter { diff --git a/src/typescript/src/EndsWithFilterExpression.ts b/src/typescript/src/endsWithFilterExpression.ts similarity index 90% rename from src/typescript/src/EndsWithFilterExpression.ts rename to src/typescript/src/endsWithFilterExpression.ts index dd92270a..d2622a26 100644 --- a/src/typescript/src/EndsWithFilterExpression.ts +++ b/src/typescript/src/endsWithFilterExpression.ts @@ -1,4 +1,4 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Matches records where a property ends with a specific string. */ export class EndsWithFilterExpression implements IFilter { diff --git a/src/typescript/src/EqualsFilterExpression.ts b/src/typescript/src/equalsFilterExpression.ts similarity index 89% rename from src/typescript/src/EqualsFilterExpression.ts rename to src/typescript/src/equalsFilterExpression.ts index 3ed1c4b1..3370ac62 100644 --- a/src/typescript/src/EqualsFilterExpression.ts +++ b/src/typescript/src/equalsFilterExpression.ts @@ -1,4 +1,4 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Matches records where a property equals a specific value. */ export class EqualsFilterExpression implements IFilter { diff --git a/src/typescript/src/expressions.ts b/src/typescript/src/expressions.ts index e0a79b0d..ff79374f 100644 --- a/src/typescript/src/expressions.ts +++ b/src/typescript/src/expressions.ts @@ -3,15 +3,15 @@ * This file re-exports all expression types from their individual modules. */ -export { IFilter } from './IFilter'; -export { EqualsFilterExpression } from './EqualsFilterExpression'; -export { ContainsFilterExpression } from './ContainsFilterExpression'; -export { StartsWithFilterExpression } from './StartsWithFilterExpression'; -export { EndsWithFilterExpression } from './EndsWithFilterExpression'; -export { GreaterThanFilterExpression } from './GreaterThanFilterExpression'; -export { GreaterThanOrEqualFilterExpression } from './GreaterThanOrEqualFilterExpression'; -export { LessThanFilterExpression } from './LessThanFilterExpression'; -export { LessThanOrEqualFilterExpression } from './LessThanOrEqualFilterExpression'; -export { AndFilterExpression } from './AndFilterExpression'; -export { OrFilterExpression } from './OrFilterExpression'; -export { NotFilterExpression } from './NotFilterExpression'; +export { IFilter } from './iFilter'; +export { EqualsFilterExpression } from './equalsFilterExpression'; +export { ContainsFilterExpression } from './containsFilterExpression'; +export { StartsWithFilterExpression } from './startsWithFilterExpression'; +export { EndsWithFilterExpression } from './endsWithFilterExpression'; +export { GreaterThanFilterExpression } from './greaterThanFilterExpression'; +export { GreaterThanOrEqualFilterExpression } from './greaterThanOrEqualFilterExpression'; +export { LessThanFilterExpression } from './lessThanFilterExpression'; +export { LessThanOrEqualFilterExpression } from './lessThanOrEqualFilterExpression'; +export { AndFilter } from './andFilter'; +export { OrFilter } from './orFilter'; +export { NotFilter } from './notFilter'; diff --git a/src/typescript/src/GreaterThanFilterExpression.ts b/src/typescript/src/greaterThanFilterExpression.ts similarity index 89% rename from src/typescript/src/GreaterThanFilterExpression.ts rename to src/typescript/src/greaterThanFilterExpression.ts index 21b4b4c0..95c90862 100644 --- a/src/typescript/src/GreaterThanFilterExpression.ts +++ b/src/typescript/src/greaterThanFilterExpression.ts @@ -1,4 +1,4 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Matches records where a property is greater than a value. */ export class GreaterThanFilterExpression implements IFilter { diff --git a/src/typescript/src/GreaterThanOrEqualFilterExpression.ts b/src/typescript/src/greaterThanOrEqualFilterExpression.ts similarity index 90% rename from src/typescript/src/GreaterThanOrEqualFilterExpression.ts rename to src/typescript/src/greaterThanOrEqualFilterExpression.ts index 7c9cd5a2..5daa893b 100644 --- a/src/typescript/src/GreaterThanOrEqualFilterExpression.ts +++ b/src/typescript/src/greaterThanOrEqualFilterExpression.ts @@ -1,4 +1,4 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Matches records where a property is greater than or equal to a value. */ export class GreaterThanOrEqualFilterExpression implements IFilter { diff --git a/src/typescript/src/IFilter.ts b/src/typescript/src/iFilter.ts similarity index 100% rename from src/typescript/src/IFilter.ts rename to src/typescript/src/iFilter.ts diff --git a/src/typescript/src/index.ts b/src/typescript/src/index.ts index 991f62be..e99aa071 100644 --- a/src/typescript/src/index.ts +++ b/src/typescript/src/index.ts @@ -15,9 +15,9 @@ export { GreaterThanOrEqualFilterExpression, LessThanFilterExpression, LessThanOrEqualFilterExpression, - AndFilterExpression, - OrFilterExpression, - NotFilterExpression, + AndFilter, + OrFilter, + NotFilter, } from './expressions'; export { FilterBuilder, FieldBuilder } from './builder'; diff --git a/src/typescript/src/LessThanFilterExpression.ts b/src/typescript/src/lessThanFilterExpression.ts similarity index 89% rename from src/typescript/src/LessThanFilterExpression.ts rename to src/typescript/src/lessThanFilterExpression.ts index aefb5030..16a4a6d6 100644 --- a/src/typescript/src/LessThanFilterExpression.ts +++ b/src/typescript/src/lessThanFilterExpression.ts @@ -1,4 +1,4 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Matches records where a property is less than a value. */ export class LessThanFilterExpression implements IFilter { diff --git a/src/typescript/src/LessThanOrEqualFilterExpression.ts b/src/typescript/src/lessThanOrEqualFilterExpression.ts similarity index 90% rename from src/typescript/src/LessThanOrEqualFilterExpression.ts rename to src/typescript/src/lessThanOrEqualFilterExpression.ts index d3006f8e..5049bea5 100644 --- a/src/typescript/src/LessThanOrEqualFilterExpression.ts +++ b/src/typescript/src/lessThanOrEqualFilterExpression.ts @@ -1,4 +1,4 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Matches records where a property is less than or equal to a value. */ export class LessThanOrEqualFilterExpression implements IFilter { diff --git a/src/typescript/src/NotFilterExpression.ts b/src/typescript/src/notFilter.ts similarity index 69% rename from src/typescript/src/NotFilterExpression.ts rename to src/typescript/src/notFilter.ts index 1b3707a6..19930250 100644 --- a/src/typescript/src/NotFilterExpression.ts +++ b/src/typescript/src/notFilter.ts @@ -1,7 +1,7 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Negates a filter expression. */ -export class NotFilterExpression implements IFilter { +export class NotFilter implements IFilter { public constructor(public readonly filter: IFilter) {} public toDict(): Record { diff --git a/src/typescript/src/OrFilterExpression.ts b/src/typescript/src/orFilter.ts similarity index 72% rename from src/typescript/src/OrFilterExpression.ts rename to src/typescript/src/orFilter.ts index 99235414..36109eb2 100644 --- a/src/typescript/src/OrFilterExpression.ts +++ b/src/typescript/src/orFilter.ts @@ -1,7 +1,7 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Combines multiple filters with a logical OR. */ -export class OrFilterExpression implements IFilter { +export class OrFilter implements IFilter { public constructor(public readonly filters: IFilter[]) {} public toDict(): Record { diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index 8d94f07b..bff36240 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -6,17 +6,17 @@ * - `field=value*` → StartsWithFilterExpression * - `field=*value` → EndsWithFilterExpression * - `field=*value*` → ContainsFilterExpression - * - `field=!value` → NotFilterExpression(EqualsFilterExpression) - * - `field=[min,max]` → AndFilterExpression(gte, lte) + * - `field=!value` → NotFilter(EqualsFilterExpression) + * - `field=[min,max]` → AndFilter(gte, lte) * - `field=[min,]` → GreaterThanOrEqualFilterExpression * - `field=[,max]` → LessThanOrEqualFilterExpression - * - `field=(min,max)` → AndFilterExpression(gt, lt) - * - `expr1,expr2` → AndFilterExpression of multiple criteria - * - `expr1|expr2` → OrFilterExpression of multiple criteria + * - `field=(min,max)` → AndFilter(gt, lt) + * - `expr1,expr2` → AndFilter of multiple criteria + * - `expr1|expr2` → OrFilter of multiple criteria */ import { - AndFilterExpression, + AndFilter, ContainsFilterExpression, EndsWithFilterExpression, EqualsFilterExpression, @@ -25,8 +25,8 @@ import { IFilter, LessThanFilterExpression, LessThanOrEqualFilterExpression, - NotFilterExpression, - OrFilterExpression, + NotFilter, + OrFilter, StartsWithFilterExpression, } from './expressions'; @@ -61,7 +61,7 @@ function parseRange(field: string, value: string): IFilter { } } - const result: IFilter = filters.length === 1 ? filters[0] : new AndFilterExpression(filters); + const result: IFilter = filters.length === 1 ? filters[0] : new AndFilter(filters); return result; } @@ -83,7 +83,7 @@ function parseValueExpression(field: string, value: string): IFilter { result = new EqualsFilterExpression(field, actualValue); } - return negated ? new NotFilterExpression(result) : result; + return negated ? new NotFilter(result) : result; } function parseSingle(expression: string): IFilter { @@ -133,7 +133,7 @@ function parseAndExpression(expression: string): IFilter { const parts = splitAndParts(expression); const result: IFilter = parts.length > 1 - ? new AndFilterExpression(parts.map((p) => parseSingle(p.trim()))) + ? new AndFilter(parts.map((p) => parseSingle(p.trim()))) : parseSingle(expression); return result; } @@ -154,7 +154,7 @@ export function parse(expression: string): IFilter { const orParts = trimmed.split('|'); const result: IFilter = orParts.length > 1 - ? new OrFilterExpression(orParts.map((p) => parseAndExpression(p.trim()))) + ? new OrFilter(orParts.map((p) => parseAndExpression(p.trim()))) : parseAndExpression(trimmed); return result; } diff --git a/src/typescript/src/serializers.ts b/src/typescript/src/serializers.ts index 8392b856..cc1ac118 100644 --- a/src/typescript/src/serializers.ts +++ b/src/typescript/src/serializers.ts @@ -3,7 +3,7 @@ */ import { - AndFilterExpression, + AndFilter, ContainsFilterExpression, EndsWithFilterExpression, EqualsFilterExpression, @@ -12,8 +12,8 @@ import { IFilter, LessThanFilterExpression, LessThanOrEqualFilterExpression, - NotFilterExpression, - OrFilterExpression, + NotFilter, + OrFilter, StartsWithFilterExpression, } from './expressions'; @@ -53,11 +53,11 @@ export function fromDict(data: Record): IFilter { const children = (data['filters'] as Record[]).map(fromDict); if (logic === 'and') { - result = new AndFilterExpression(children); + result = new AndFilter(children); } else if (logic === 'or') { - result = new OrFilterExpression(children); + result = new OrFilter(children); } else if (logic === 'not') { - result = new NotFilterExpression(children[0]); + result = new NotFilter(children[0]); } else { throw new Error(`Unknown logic operator: '${logic}'`); } diff --git a/src/typescript/src/StartsWithFilterExpression.ts b/src/typescript/src/startsWithFilterExpression.ts similarity index 90% rename from src/typescript/src/StartsWithFilterExpression.ts rename to src/typescript/src/startsWithFilterExpression.ts index eafbcaea..41339550 100644 --- a/src/typescript/src/StartsWithFilterExpression.ts +++ b/src/typescript/src/startsWithFilterExpression.ts @@ -1,4 +1,4 @@ -import { IFilter } from './IFilter'; +import { IFilter } from './iFilter'; /** Matches records where a property starts with a specific string. */ export class StartsWithFilterExpression implements IFilter { diff --git a/src/typescript/test/AndFilterExpression.test.ts b/src/typescript/test/andFilter.test.ts similarity index 67% rename from src/typescript/test/AndFilterExpression.test.ts rename to src/typescript/test/andFilter.test.ts index eca0ea69..5b8b8bf8 100644 --- a/src/typescript/test/AndFilterExpression.test.ts +++ b/src/typescript/test/andFilter.test.ts @@ -1,9 +1,9 @@ -import { AndFilterExpression, EqualsFilterExpression } from '../src/expressions'; +import { AndFilter, EqualsFilterExpression } from '../src/expressions'; -describe('AndFilterExpression', () => { +describe('AndFilter', () => { it('should serialize with logic and nested filters', () => { // Arrange - const f = new AndFilterExpression([ + const f = new AndFilter([ new EqualsFilterExpression('a', 1), new EqualsFilterExpression('b', 2), ]); diff --git a/src/typescript/test/builder.test.ts b/src/typescript/test/builder.test.ts index 15ec19dc..729f4a4d 100644 --- a/src/typescript/test/builder.test.ts +++ b/src/typescript/test/builder.test.ts @@ -1,5 +1,5 @@ import { - AndFilterExpression, + AndFilter, ContainsFilterExpression, EndsWithFilterExpression, EqualsFilterExpression, @@ -7,8 +7,8 @@ import { GreaterThanOrEqualFilterExpression, LessThanFilterExpression, LessThanOrEqualFilterExpression, - NotFilterExpression, - OrFilterExpression, + NotFilter, + OrFilter, StartsWithFilterExpression, } from '../src/expressions'; import { FilterBuilder } from '../src/builder'; @@ -60,8 +60,8 @@ describe('FilterBuilder - single conditions', () => { it('should build not filter', () => { const result = new FilterBuilder().where('deleted').not(true).build(); - expect(result).toBeInstanceOf(NotFilterExpression); - expect((result as NotFilterExpression).filter).toBeInstanceOf(EqualsFilterExpression); + expect(result).toBeInstanceOf(NotFilter); + expect((result as NotFilter).filter).toBeInstanceOf(EqualsFilterExpression); }); }); @@ -71,14 +71,14 @@ describe('FilterBuilder - multiple conditions', () => { .where('name').contains('bat') .andWhere('age').gte(18) .build(); - expect(result).toBeInstanceOf(AndFilterExpression); - expect((result as AndFilterExpression).filters.length).toBe(2); + expect(result).toBeInstanceOf(AndFilter); + expect((result as AndFilter).filters.length).toBe(2); }); it('should build OR filter', () => { const result = new FilterBuilder().where('status').or('active', 'pending').build(); - expect(result).toBeInstanceOf(OrFilterExpression); - expect((result as OrFilterExpression).filters.length).toBe(2); + expect(result).toBeInstanceOf(OrFilter); + expect((result as OrFilter).filters.length).toBe(2); }); }); diff --git a/src/typescript/test/ContainsFilterExpression.test.ts b/src/typescript/test/containsFilterExpression.test.ts similarity index 100% rename from src/typescript/test/ContainsFilterExpression.test.ts rename to src/typescript/test/containsFilterExpression.test.ts diff --git a/src/typescript/test/EndsWithFilterExpression.test.ts b/src/typescript/test/endsWithFilterExpression.test.ts similarity index 100% rename from src/typescript/test/EndsWithFilterExpression.test.ts rename to src/typescript/test/endsWithFilterExpression.test.ts diff --git a/src/typescript/test/EqualsFilterExpression.test.ts b/src/typescript/test/equalsFilterExpression.test.ts similarity index 100% rename from src/typescript/test/EqualsFilterExpression.test.ts rename to src/typescript/test/equalsFilterExpression.test.ts diff --git a/src/typescript/test/GreaterThanFilterExpression.test.ts b/src/typescript/test/greaterThanFilterExpression.test.ts similarity index 100% rename from src/typescript/test/GreaterThanFilterExpression.test.ts rename to src/typescript/test/greaterThanFilterExpression.test.ts diff --git a/src/typescript/test/GreaterThanOrEqualFilterExpression.test.ts b/src/typescript/test/greaterThanOrEqualFilterExpression.test.ts similarity index 100% rename from src/typescript/test/GreaterThanOrEqualFilterExpression.test.ts rename to src/typescript/test/greaterThanOrEqualFilterExpression.test.ts diff --git a/src/typescript/test/LessThanFilterExpression.test.ts b/src/typescript/test/lessThanFilterExpression.test.ts similarity index 100% rename from src/typescript/test/LessThanFilterExpression.test.ts rename to src/typescript/test/lessThanFilterExpression.test.ts diff --git a/src/typescript/test/LessThanOrEqualFilterExpression.test.ts b/src/typescript/test/lessThanOrEqualFilterExpression.test.ts similarity index 100% rename from src/typescript/test/LessThanOrEqualFilterExpression.test.ts rename to src/typescript/test/lessThanOrEqualFilterExpression.test.ts diff --git a/src/typescript/test/NotFilterExpression.test.ts b/src/typescript/test/notFilter.test.ts similarity index 62% rename from src/typescript/test/NotFilterExpression.test.ts rename to src/typescript/test/notFilter.test.ts index b484e9b5..11a18298 100644 --- a/src/typescript/test/NotFilterExpression.test.ts +++ b/src/typescript/test/notFilter.test.ts @@ -1,9 +1,9 @@ -import { EqualsFilterExpression, NotFilterExpression } from '../src/expressions'; +import { EqualsFilterExpression, NotFilter } from '../src/expressions'; -describe('NotFilterExpression', () => { +describe('NotFilter', () => { it('should serialize with logic not', () => { // Arrange - const f = new NotFilterExpression(new EqualsFilterExpression('deleted', true)); + const f = new NotFilter(new EqualsFilterExpression('deleted', true)); // Act const result = f.toDict(); @@ -18,7 +18,7 @@ describe('NotFilterExpression', () => { const inner = new EqualsFilterExpression('active', false); // Act - const f = new NotFilterExpression(inner); + const f = new NotFilter(inner); // Assert expect(f.filter).toBe(inner); diff --git a/src/typescript/test/OrFilterExpression.test.ts b/src/typescript/test/orFilter.test.ts similarity index 69% rename from src/typescript/test/OrFilterExpression.test.ts rename to src/typescript/test/orFilter.test.ts index 10f53a91..72d4ed73 100644 --- a/src/typescript/test/OrFilterExpression.test.ts +++ b/src/typescript/test/orFilter.test.ts @@ -1,9 +1,9 @@ -import { EqualsFilterExpression, OrFilterExpression } from '../src/expressions'; +import { EqualsFilterExpression, OrFilter } from '../src/expressions'; -describe('OrFilterExpression', () => { +describe('OrFilter', () => { it('should serialize with logic and nested filters', () => { // Arrange - const f = new OrFilterExpression([ + const f = new OrFilter([ new EqualsFilterExpression('status', 'active'), new EqualsFilterExpression('status', 'pending'), ]); diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index f521ac7e..717afd79 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -1,5 +1,5 @@ import { - AndFilterExpression, + AndFilter, ContainsFilterExpression, EndsWithFilterExpression, EqualsFilterExpression, @@ -7,8 +7,8 @@ import { GreaterThanOrEqualFilterExpression, LessThanFilterExpression, LessThanOrEqualFilterExpression, - NotFilterExpression, - OrFilterExpression, + NotFilter, + OrFilter, StartsWithFilterExpression, } from '../src/expressions'; import { parse } from '../src/parser'; @@ -80,8 +80,8 @@ describe('parse - negation', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(NotFilterExpression); - const inner = (result as NotFilterExpression).filter; + expect(result).toBeInstanceOf(NotFilter); + const inner = (result as NotFilter).filter; expect(inner).toBeInstanceOf(EqualsFilterExpression); expect((inner as EqualsFilterExpression).value).toBe('Batman'); }); @@ -96,8 +96,8 @@ describe('parse - range', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(AndFilterExpression); - const f = result as AndFilterExpression; + expect(result).toBeInstanceOf(AndFilter); + const f = result as AndFilter; expect(f.filters[0]).toBeInstanceOf(GreaterThanOrEqualFilterExpression); expect(f.filters[1]).toBeInstanceOf(LessThanOrEqualFilterExpression); }); @@ -110,8 +110,8 @@ describe('parse - range', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(AndFilterExpression); - const f = result as AndFilterExpression; + expect(result).toBeInstanceOf(AndFilter); + const f = result as AndFilter; expect(f.filters[0]).toBeInstanceOf(GreaterThanFilterExpression); expect(f.filters[1]).toBeInstanceOf(LessThanFilterExpression); }); @@ -150,8 +150,8 @@ describe('parse - AND combination', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(AndFilterExpression); - expect((result as AndFilterExpression).filters.length).toBe(2); + expect(result).toBeInstanceOf(AndFilter); + expect((result as AndFilter).filters.length).toBe(2); }); }); @@ -164,8 +164,8 @@ describe('parse - OR combination', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(OrFilterExpression); - expect((result as OrFilterExpression).filters.length).toBe(2); + expect(result).toBeInstanceOf(OrFilter); + expect((result as OrFilter).filters.length).toBe(2); }); }); @@ -197,7 +197,7 @@ describe('parse - parameterized', () => { ['name=*bat*', ContainsFilterExpression], ['name=Bat*', StartsWithFilterExpression], ['name=*man', EndsWithFilterExpression], - ['name=!Batman', NotFilterExpression], + ['name=!Batman', NotFilter], ]; test.each(cases)('parse("%s") should return expected type', (expression, expectedType) => { diff --git a/src/typescript/test/StartsWithFilterExpression.test.ts b/src/typescript/test/startsWithFilterExpression.test.ts similarity index 100% rename from src/typescript/test/StartsWithFilterExpression.test.ts rename to src/typescript/test/startsWithFilterExpression.test.ts From 86ebed03ca4477c88839f33e99de2fe59dd8bcce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:01:56 +0000 Subject: [PATCH 10/28] refactor: drop Expression suffix from all IFilter classes, sync file names to camelCase Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/93350477-6e52-41dd-937a-1a2544a85765 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- src/typescript/src/builder.ts | 36 +++++------ ...sFilterExpression.ts => containsFilter.ts} | 2 +- ...hFilterExpression.ts => endsWithFilter.ts} | 2 +- ...alsFilterExpression.ts => equalsFilter.ts} | 2 +- src/typescript/src/expressions.ts | 16 ++--- ...lterExpression.ts => greaterThanFilter.ts} | 2 +- ...ression.ts => greaterThanOrEqualFilter.ts} | 2 +- src/typescript/src/index.ts | 16 ++--- ...nFilterExpression.ts => lessThanFilter.ts} | 2 +- ...Expression.ts => lessThanOrEqualFilter.ts} | 2 +- src/typescript/src/parser.ts | 46 +++++++------- src/typescript/src/serializers.ts | 32 +++++----- ...ilterExpression.ts => startsWithFilter.ts} | 2 +- src/typescript/test/andFilter.test.ts | 6 +- src/typescript/test/builder.test.ts | 42 ++++++------- ...ression.test.ts => containsFilter.test.ts} | 8 +-- ...ression.test.ts => endsWithFilter.test.ts} | 8 +-- ...xpression.test.ts => equalsFilter.test.ts} | 10 ++-- ...sion.test.ts => greaterThanFilter.test.ts} | 8 +-- ...st.ts => greaterThanOrEqualFilter.test.ts} | 8 +-- ...ression.test.ts => lessThanFilter.test.ts} | 8 +-- ....test.ts => lessThanOrEqualFilter.test.ts} | 8 +-- src/typescript/test/notFilter.test.ts | 6 +- src/typescript/test/orFilter.test.ts | 6 +- src/typescript/test/parser.test.ts | 60 +++++++++---------- ...ssion.test.ts => startsWithFilter.test.ts} | 8 +-- 26 files changed, 174 insertions(+), 174 deletions(-) rename src/typescript/src/{containsFilterExpression.ts => containsFilter.ts} (83%) rename src/typescript/src/{endsWithFilterExpression.ts => endsWithFilter.ts} (84%) rename src/typescript/src/{equalsFilterExpression.ts => equalsFilter.ts} (84%) rename src/typescript/src/{greaterThanFilterExpression.ts => greaterThanFilter.ts} (83%) rename src/typescript/src/{greaterThanOrEqualFilterExpression.ts => greaterThanOrEqualFilter.ts} (82%) rename src/typescript/src/{lessThanFilterExpression.ts => lessThanFilter.ts} (83%) rename src/typescript/src/{lessThanOrEqualFilterExpression.ts => lessThanOrEqualFilter.ts} (82%) rename src/typescript/src/{startsWithFilterExpression.ts => startsWithFilter.ts} (83%) rename src/typescript/test/{containsFilterExpression.test.ts => containsFilter.test.ts} (59%) rename src/typescript/test/{endsWithFilterExpression.test.ts => endsWithFilter.test.ts} (59%) rename src/typescript/test/{equalsFilterExpression.test.ts => equalsFilter.test.ts} (62%) rename src/typescript/test/{greaterThanFilterExpression.test.ts => greaterThanFilter.test.ts} (56%) rename src/typescript/test/{greaterThanOrEqualFilterExpression.test.ts => greaterThanOrEqualFilter.test.ts} (54%) rename src/typescript/test/{lessThanFilterExpression.test.ts => lessThanFilter.test.ts} (58%) rename src/typescript/test/{lessThanOrEqualFilterExpression.test.ts => lessThanOrEqualFilter.test.ts} (55%) rename src/typescript/test/{startsWithFilterExpression.test.ts => startsWithFilter.test.ts} (58%) diff --git a/src/typescript/src/builder.ts b/src/typescript/src/builder.ts index 633a857d..cb6dd5bf 100644 --- a/src/typescript/src/builder.ts +++ b/src/typescript/src/builder.ts @@ -4,17 +4,17 @@ import { AndFilter, - ContainsFilterExpression, - EndsWithFilterExpression, - EqualsFilterExpression, - GreaterThanFilterExpression, - GreaterThanOrEqualFilterExpression, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, IFilter, - LessThanFilterExpression, - LessThanOrEqualFilterExpression, + LessThanFilter, + LessThanOrEqualFilter, NotFilter, OrFilter, - StartsWithFilterExpression, + StartsWithFilter, } from './expressions'; /** Internal helper that provides filter-condition methods for a single field. */ @@ -26,52 +26,52 @@ export class FieldBuilder { /** Add an equals condition. */ public eq(value: unknown): FilterBuilder { - return this.parent['add'](new EqualsFilterExpression(this.field, value)); + return this.parent['add'](new EqualsFilter(this.field, value)); } /** Add a contains condition. */ public contains(value: string): FilterBuilder { - return this.parent['add'](new ContainsFilterExpression(this.field, value)); + return this.parent['add'](new ContainsFilter(this.field, value)); } /** Add a starts-with condition. */ public startsWith(value: string): FilterBuilder { - return this.parent['add'](new StartsWithFilterExpression(this.field, value)); + return this.parent['add'](new StartsWithFilter(this.field, value)); } /** Add an ends-with condition. */ public endsWith(value: string): FilterBuilder { - return this.parent['add'](new EndsWithFilterExpression(this.field, value)); + return this.parent['add'](new EndsWithFilter(this.field, value)); } /** Add a greater-than condition. */ public gt(value: unknown): FilterBuilder { - return this.parent['add'](new GreaterThanFilterExpression(this.field, value)); + return this.parent['add'](new GreaterThanFilter(this.field, value)); } /** Add a greater-than-or-equal condition. */ public gte(value: unknown): FilterBuilder { - return this.parent['add'](new GreaterThanOrEqualFilterExpression(this.field, value)); + return this.parent['add'](new GreaterThanOrEqualFilter(this.field, value)); } /** Add a less-than condition. */ public lt(value: unknown): FilterBuilder { - return this.parent['add'](new LessThanFilterExpression(this.field, value)); + return this.parent['add'](new LessThanFilter(this.field, value)); } /** Add a less-than-or-equal condition. */ public lte(value: unknown): FilterBuilder { - return this.parent['add'](new LessThanOrEqualFilterExpression(this.field, value)); + return this.parent['add'](new LessThanOrEqualFilter(this.field, value)); } /** Add a not-equals condition. */ public not(value: unknown): FilterBuilder { - return this.parent['add'](new NotFilter(new EqualsFilterExpression(this.field, value))); + return this.parent['add'](new NotFilter(new EqualsFilter(this.field, value))); } /** Add an OR condition matching any of the given values. */ public or(...values: unknown[]): FilterBuilder { - const orFilters: IFilter[] = values.map((v) => new EqualsFilterExpression(this.field, v)); + const orFilters: IFilter[] = values.map((v) => new EqualsFilter(this.field, v)); return this.parent['add'](new OrFilter(orFilters)); } } diff --git a/src/typescript/src/containsFilterExpression.ts b/src/typescript/src/containsFilter.ts similarity index 83% rename from src/typescript/src/containsFilterExpression.ts rename to src/typescript/src/containsFilter.ts index 62a2758e..259ce262 100644 --- a/src/typescript/src/containsFilterExpression.ts +++ b/src/typescript/src/containsFilter.ts @@ -1,7 +1,7 @@ import { IFilter } from './iFilter'; /** Matches records where a property contains a substring. */ -export class ContainsFilterExpression implements IFilter { +export class ContainsFilter implements IFilter { public constructor(public readonly field: string, public readonly value: string) {} public toDict(): Record { diff --git a/src/typescript/src/endsWithFilterExpression.ts b/src/typescript/src/endsWithFilter.ts similarity index 84% rename from src/typescript/src/endsWithFilterExpression.ts rename to src/typescript/src/endsWithFilter.ts index d2622a26..b702506f 100644 --- a/src/typescript/src/endsWithFilterExpression.ts +++ b/src/typescript/src/endsWithFilter.ts @@ -1,7 +1,7 @@ import { IFilter } from './iFilter'; /** Matches records where a property ends with a specific string. */ -export class EndsWithFilterExpression implements IFilter { +export class EndsWithFilter implements IFilter { public constructor(public readonly field: string, public readonly value: string) {} public toDict(): Record { diff --git a/src/typescript/src/equalsFilterExpression.ts b/src/typescript/src/equalsFilter.ts similarity index 84% rename from src/typescript/src/equalsFilterExpression.ts rename to src/typescript/src/equalsFilter.ts index 3370ac62..7d424d9d 100644 --- a/src/typescript/src/equalsFilterExpression.ts +++ b/src/typescript/src/equalsFilter.ts @@ -1,7 +1,7 @@ import { IFilter } from './iFilter'; /** Matches records where a property equals a specific value. */ -export class EqualsFilterExpression implements IFilter { +export class EqualsFilter implements IFilter { public constructor(public readonly field: string, public readonly value: unknown) {} public toDict(): Record { diff --git a/src/typescript/src/expressions.ts b/src/typescript/src/expressions.ts index ff79374f..7fdc2fb8 100644 --- a/src/typescript/src/expressions.ts +++ b/src/typescript/src/expressions.ts @@ -4,14 +4,14 @@ */ export { IFilter } from './iFilter'; -export { EqualsFilterExpression } from './equalsFilterExpression'; -export { ContainsFilterExpression } from './containsFilterExpression'; -export { StartsWithFilterExpression } from './startsWithFilterExpression'; -export { EndsWithFilterExpression } from './endsWithFilterExpression'; -export { GreaterThanFilterExpression } from './greaterThanFilterExpression'; -export { GreaterThanOrEqualFilterExpression } from './greaterThanOrEqualFilterExpression'; -export { LessThanFilterExpression } from './lessThanFilterExpression'; -export { LessThanOrEqualFilterExpression } from './lessThanOrEqualFilterExpression'; +export { EqualsFilter } from './equalsFilter'; +export { ContainsFilter } from './containsFilter'; +export { StartsWithFilter } from './startsWithFilter'; +export { EndsWithFilter } from './endsWithFilter'; +export { GreaterThanFilter } from './greaterThanFilter'; +export { GreaterThanOrEqualFilter } from './greaterThanOrEqualFilter'; +export { LessThanFilter } from './lessThanFilter'; +export { LessThanOrEqualFilter } from './lessThanOrEqualFilter'; export { AndFilter } from './andFilter'; export { OrFilter } from './orFilter'; export { NotFilter } from './notFilter'; diff --git a/src/typescript/src/greaterThanFilterExpression.ts b/src/typescript/src/greaterThanFilter.ts similarity index 83% rename from src/typescript/src/greaterThanFilterExpression.ts rename to src/typescript/src/greaterThanFilter.ts index 95c90862..f88d6883 100644 --- a/src/typescript/src/greaterThanFilterExpression.ts +++ b/src/typescript/src/greaterThanFilter.ts @@ -1,7 +1,7 @@ import { IFilter } from './iFilter'; /** Matches records where a property is greater than a value. */ -export class GreaterThanFilterExpression implements IFilter { +export class GreaterThanFilter implements IFilter { public constructor(public readonly field: string, public readonly value: unknown) {} public toDict(): Record { diff --git a/src/typescript/src/greaterThanOrEqualFilterExpression.ts b/src/typescript/src/greaterThanOrEqualFilter.ts similarity index 82% rename from src/typescript/src/greaterThanOrEqualFilterExpression.ts rename to src/typescript/src/greaterThanOrEqualFilter.ts index 5daa893b..b6412892 100644 --- a/src/typescript/src/greaterThanOrEqualFilterExpression.ts +++ b/src/typescript/src/greaterThanOrEqualFilter.ts @@ -1,7 +1,7 @@ import { IFilter } from './iFilter'; /** Matches records where a property is greater than or equal to a value. */ -export class GreaterThanOrEqualFilterExpression implements IFilter { +export class GreaterThanOrEqualFilter implements IFilter { public constructor(public readonly field: string, public readonly value: unknown) {} public toDict(): Record { diff --git a/src/typescript/src/index.ts b/src/typescript/src/index.ts index e99aa071..961655ee 100644 --- a/src/typescript/src/index.ts +++ b/src/typescript/src/index.ts @@ -7,14 +7,14 @@ export { IFilter, - EqualsFilterExpression, - ContainsFilterExpression, - StartsWithFilterExpression, - EndsWithFilterExpression, - GreaterThanFilterExpression, - GreaterThanOrEqualFilterExpression, - LessThanFilterExpression, - LessThanOrEqualFilterExpression, + EqualsFilter, + ContainsFilter, + StartsWithFilter, + EndsWithFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, AndFilter, OrFilter, NotFilter, diff --git a/src/typescript/src/lessThanFilterExpression.ts b/src/typescript/src/lessThanFilter.ts similarity index 83% rename from src/typescript/src/lessThanFilterExpression.ts rename to src/typescript/src/lessThanFilter.ts index 16a4a6d6..1406a400 100644 --- a/src/typescript/src/lessThanFilterExpression.ts +++ b/src/typescript/src/lessThanFilter.ts @@ -1,7 +1,7 @@ import { IFilter } from './iFilter'; /** Matches records where a property is less than a value. */ -export class LessThanFilterExpression implements IFilter { +export class LessThanFilter implements IFilter { public constructor(public readonly field: string, public readonly value: unknown) {} public toDict(): Record { diff --git a/src/typescript/src/lessThanOrEqualFilterExpression.ts b/src/typescript/src/lessThanOrEqualFilter.ts similarity index 82% rename from src/typescript/src/lessThanOrEqualFilterExpression.ts rename to src/typescript/src/lessThanOrEqualFilter.ts index 5049bea5..fc2db154 100644 --- a/src/typescript/src/lessThanOrEqualFilterExpression.ts +++ b/src/typescript/src/lessThanOrEqualFilter.ts @@ -1,7 +1,7 @@ import { IFilter } from './iFilter'; /** Matches records where a property is less than or equal to a value. */ -export class LessThanOrEqualFilterExpression implements IFilter { +export class LessThanOrEqualFilter implements IFilter { public constructor(public readonly field: string, public readonly value: unknown) {} public toDict(): Record { diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index bff36240..c3636039 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -2,14 +2,14 @@ * Parser for DataFilters query syntax strings. * * Supported syntax: - * - `field=value` → EqualsFilterExpression - * - `field=value*` → StartsWithFilterExpression - * - `field=*value` → EndsWithFilterExpression - * - `field=*value*` → ContainsFilterExpression - * - `field=!value` → NotFilter(EqualsFilterExpression) + * - `field=value` → EqualsFilter + * - `field=value*` → StartsWithFilter + * - `field=*value` → EndsWithFilter + * - `field=*value*` → ContainsFilter + * - `field=!value` → NotFilter(EqualsFilter) * - `field=[min,max]` → AndFilter(gte, lte) - * - `field=[min,]` → GreaterThanOrEqualFilterExpression - * - `field=[,max]` → LessThanOrEqualFilterExpression + * - `field=[min,]` → GreaterThanOrEqualFilter + * - `field=[,max]` → LessThanOrEqualFilter * - `field=(min,max)` → AndFilter(gt, lt) * - `expr1,expr2` → AndFilter of multiple criteria * - `expr1|expr2` → OrFilter of multiple criteria @@ -17,17 +17,17 @@ import { AndFilter, - ContainsFilterExpression, - EndsWithFilterExpression, - EqualsFilterExpression, - GreaterThanFilterExpression, - GreaterThanOrEqualFilterExpression, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, IFilter, - LessThanFilterExpression, - LessThanOrEqualFilterExpression, + LessThanFilter, + LessThanOrEqualFilter, NotFilter, OrFilter, - StartsWithFilterExpression, + StartsWithFilter, } from './expressions'; const RANGE_PATTERN = /^(?[[(])(?[^,]*),(?[^)\]]*)?(?[)\]])$/; @@ -47,17 +47,17 @@ function parseRange(field: string, value: string): IFilter { if (minVal !== null && minVal !== '') { if (openBracket === '[') { - filters.push(new GreaterThanOrEqualFilterExpression(field, minVal)); + filters.push(new GreaterThanOrEqualFilter(field, minVal)); } else { - filters.push(new GreaterThanFilterExpression(field, minVal)); + filters.push(new GreaterThanFilter(field, minVal)); } } if (maxVal !== null && maxVal !== '') { if (closeBracket === ']') { - filters.push(new LessThanOrEqualFilterExpression(field, maxVal)); + filters.push(new LessThanOrEqualFilter(field, maxVal)); } else { - filters.push(new LessThanFilterExpression(field, maxVal)); + filters.push(new LessThanFilter(field, maxVal)); } } @@ -74,13 +74,13 @@ function parseValueExpression(field: string, value: string): IFilter { if (RANGE_PATTERN.test(actualValue)) { result = parseRange(field, actualValue); } else if (actualValue.startsWith('*') && actualValue.endsWith('*') && actualValue.length > 1) { - result = new ContainsFilterExpression(field, actualValue.slice(1, -1)); + result = new ContainsFilter(field, actualValue.slice(1, -1)); } else if (actualValue.startsWith('*')) { - result = new EndsWithFilterExpression(field, actualValue.slice(1)); + result = new EndsWithFilter(field, actualValue.slice(1)); } else if (actualValue.endsWith('*')) { - result = new StartsWithFilterExpression(field, actualValue.slice(0, -1)); + result = new StartsWithFilter(field, actualValue.slice(0, -1)); } else { - result = new EqualsFilterExpression(field, actualValue); + result = new EqualsFilter(field, actualValue); } return negated ? new NotFilter(result) : result; diff --git a/src/typescript/src/serializers.ts b/src/typescript/src/serializers.ts index cc1ac118..aa8ee70a 100644 --- a/src/typescript/src/serializers.ts +++ b/src/typescript/src/serializers.ts @@ -4,17 +4,17 @@ import { AndFilter, - ContainsFilterExpression, - EndsWithFilterExpression, - EqualsFilterExpression, - GreaterThanFilterExpression, - GreaterThanOrEqualFilterExpression, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, IFilter, - LessThanFilterExpression, - LessThanOrEqualFilterExpression, + LessThanFilter, + LessThanOrEqualFilter, NotFilter, OrFilter, - StartsWithFilterExpression, + StartsWithFilter, } from './expressions'; /** Serialize an IFilter to a plain JavaScript object. */ @@ -28,14 +28,14 @@ export function toJson(filter: IFilter, space?: number): string { } const OP_MAP: Record IFilter> = { - eq: EqualsFilterExpression, - contains: ContainsFilterExpression as new (field: string, value: unknown) => IFilter, - startswith: StartsWithFilterExpression as new (field: string, value: unknown) => IFilter, - endswith: EndsWithFilterExpression as new (field: string, value: unknown) => IFilter, - gt: GreaterThanFilterExpression, - gte: GreaterThanOrEqualFilterExpression, - lt: LessThanFilterExpression, - lte: LessThanOrEqualFilterExpression, + eq: EqualsFilter, + contains: ContainsFilter as new (field: string, value: unknown) => IFilter, + startswith: StartsWithFilter as new (field: string, value: unknown) => IFilter, + endswith: EndsWithFilter as new (field: string, value: unknown) => IFilter, + gt: GreaterThanFilter, + gte: GreaterThanOrEqualFilter, + lt: LessThanFilter, + lte: LessThanOrEqualFilter, }; /** diff --git a/src/typescript/src/startsWithFilterExpression.ts b/src/typescript/src/startsWithFilter.ts similarity index 83% rename from src/typescript/src/startsWithFilterExpression.ts rename to src/typescript/src/startsWithFilter.ts index 41339550..7736a244 100644 --- a/src/typescript/src/startsWithFilterExpression.ts +++ b/src/typescript/src/startsWithFilter.ts @@ -1,7 +1,7 @@ import { IFilter } from './iFilter'; /** Matches records where a property starts with a specific string. */ -export class StartsWithFilterExpression implements IFilter { +export class StartsWithFilter implements IFilter { public constructor(public readonly field: string, public readonly value: string) {} public toDict(): Record { diff --git a/src/typescript/test/andFilter.test.ts b/src/typescript/test/andFilter.test.ts index 5b8b8bf8..87800cb3 100644 --- a/src/typescript/test/andFilter.test.ts +++ b/src/typescript/test/andFilter.test.ts @@ -1,11 +1,11 @@ -import { AndFilter, EqualsFilterExpression } from '../src/expressions'; +import { AndFilter, EqualsFilter } from '../src/expressions'; describe('AndFilter', () => { it('should serialize with logic and nested filters', () => { // Arrange const f = new AndFilter([ - new EqualsFilterExpression('a', 1), - new EqualsFilterExpression('b', 2), + new EqualsFilter('a', 1), + new EqualsFilter('b', 2), ]); // Act diff --git a/src/typescript/test/builder.test.ts b/src/typescript/test/builder.test.ts index 729f4a4d..38ee2658 100644 --- a/src/typescript/test/builder.test.ts +++ b/src/typescript/test/builder.test.ts @@ -1,67 +1,67 @@ import { AndFilter, - ContainsFilterExpression, - EndsWithFilterExpression, - EqualsFilterExpression, - GreaterThanFilterExpression, - GreaterThanOrEqualFilterExpression, - LessThanFilterExpression, - LessThanOrEqualFilterExpression, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, NotFilter, OrFilter, - StartsWithFilterExpression, + StartsWithFilter, } from '../src/expressions'; import { FilterBuilder } from '../src/builder'; describe('FilterBuilder - single conditions', () => { it('should build equals filter', () => { const result = new FilterBuilder().where('name').eq('Batman').build(); - expect(result).toBeInstanceOf(EqualsFilterExpression); - expect((result as EqualsFilterExpression).field).toBe('name'); - expect((result as EqualsFilterExpression).value).toBe('Batman'); + expect(result).toBeInstanceOf(EqualsFilter); + expect((result as EqualsFilter).field).toBe('name'); + expect((result as EqualsFilter).value).toBe('Batman'); }); it('should build contains filter', () => { const result = new FilterBuilder().where('name').contains('bat').build(); - expect(result).toBeInstanceOf(ContainsFilterExpression); - expect((result as ContainsFilterExpression).value).toBe('bat'); + expect(result).toBeInstanceOf(ContainsFilter); + expect((result as ContainsFilter).value).toBe('bat'); }); it('should build startsWith filter', () => { const result = new FilterBuilder().where('name').startsWith('Bat').build(); - expect(result).toBeInstanceOf(StartsWithFilterExpression); + expect(result).toBeInstanceOf(StartsWithFilter); }); it('should build endsWith filter', () => { const result = new FilterBuilder().where('name').endsWith('man').build(); - expect(result).toBeInstanceOf(EndsWithFilterExpression); + expect(result).toBeInstanceOf(EndsWithFilter); }); it('should build gt filter', () => { const result = new FilterBuilder().where('age').gt(18).build(); - expect(result).toBeInstanceOf(GreaterThanFilterExpression); - expect((result as GreaterThanFilterExpression).value).toBe(18); + expect(result).toBeInstanceOf(GreaterThanFilter); + expect((result as GreaterThanFilter).value).toBe(18); }); it('should build gte filter', () => { const result = new FilterBuilder().where('age').gte(18).build(); - expect(result).toBeInstanceOf(GreaterThanOrEqualFilterExpression); + expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); }); it('should build lt filter', () => { const result = new FilterBuilder().where('age').lt(65).build(); - expect(result).toBeInstanceOf(LessThanFilterExpression); + expect(result).toBeInstanceOf(LessThanFilter); }); it('should build lte filter', () => { const result = new FilterBuilder().where('age').lte(65).build(); - expect(result).toBeInstanceOf(LessThanOrEqualFilterExpression); + expect(result).toBeInstanceOf(LessThanOrEqualFilter); }); it('should build not filter', () => { const result = new FilterBuilder().where('deleted').not(true).build(); expect(result).toBeInstanceOf(NotFilter); - expect((result as NotFilter).filter).toBeInstanceOf(EqualsFilterExpression); + expect((result as NotFilter).filter).toBeInstanceOf(EqualsFilter); }); }); diff --git a/src/typescript/test/containsFilterExpression.test.ts b/src/typescript/test/containsFilter.test.ts similarity index 59% rename from src/typescript/test/containsFilterExpression.test.ts rename to src/typescript/test/containsFilter.test.ts index 645f83fb..7beebd5a 100644 --- a/src/typescript/test/containsFilterExpression.test.ts +++ b/src/typescript/test/containsFilter.test.ts @@ -1,9 +1,9 @@ -import { ContainsFilterExpression } from '../src/expressions'; +import { ContainsFilter } from '../src/expressions'; -describe('ContainsFilterExpression', () => { +describe('ContainsFilter', () => { it('should serialize to correct dict', () => { // Arrange - const f = new ContainsFilterExpression('name', 'bat'); + const f = new ContainsFilter('name', 'bat'); // Act const result = f.toDict(); @@ -14,7 +14,7 @@ describe('ContainsFilterExpression', () => { it('op field should be contains', () => { // Arrange / Act - const dict = new ContainsFilterExpression('f', 'v').toDict(); + const dict = new ContainsFilter('f', 'v').toDict(); // Assert expect(dict['op']).toBe('contains'); diff --git a/src/typescript/test/endsWithFilterExpression.test.ts b/src/typescript/test/endsWithFilter.test.ts similarity index 59% rename from src/typescript/test/endsWithFilterExpression.test.ts rename to src/typescript/test/endsWithFilter.test.ts index da227386..e1f3fc71 100644 --- a/src/typescript/test/endsWithFilterExpression.test.ts +++ b/src/typescript/test/endsWithFilter.test.ts @@ -1,9 +1,9 @@ -import { EndsWithFilterExpression } from '../src/expressions'; +import { EndsWithFilter } from '../src/expressions'; -describe('EndsWithFilterExpression', () => { +describe('EndsWithFilter', () => { it('should serialize to correct dict', () => { // Arrange - const f = new EndsWithFilterExpression('name', 'man'); + const f = new EndsWithFilter('name', 'man'); // Act const result = f.toDict(); @@ -14,7 +14,7 @@ describe('EndsWithFilterExpression', () => { it('op field should be endswith', () => { // Arrange / Act - const dict = new EndsWithFilterExpression('f', 'v').toDict(); + const dict = new EndsWithFilter('f', 'v').toDict(); // Assert expect(dict['op']).toBe('endswith'); diff --git a/src/typescript/test/equalsFilterExpression.test.ts b/src/typescript/test/equalsFilter.test.ts similarity index 62% rename from src/typescript/test/equalsFilterExpression.test.ts rename to src/typescript/test/equalsFilter.test.ts index 26b16722..882d3b81 100644 --- a/src/typescript/test/equalsFilterExpression.test.ts +++ b/src/typescript/test/equalsFilter.test.ts @@ -1,9 +1,9 @@ -import { EqualsFilterExpression } from '../src/expressions'; +import { EqualsFilter } from '../src/expressions'; -describe('EqualsFilterExpression', () => { +describe('EqualsFilter', () => { it('should serialize to correct dict', () => { // Arrange - const f = new EqualsFilterExpression('name', 'Batman'); + const f = new EqualsFilter('name', 'Batman'); // Act const result = f.toDict(); @@ -14,7 +14,7 @@ describe('EqualsFilterExpression', () => { it('should store field and value', () => { // Arrange / Act - const f = new EqualsFilterExpression('age', 30); + const f = new EqualsFilter('age', 30); // Assert expect(f.field).toBe('age'); @@ -23,7 +23,7 @@ describe('EqualsFilterExpression', () => { it('op field should be eq', () => { // Arrange / Act - const dict = new EqualsFilterExpression('f', 'v').toDict(); + const dict = new EqualsFilter('f', 'v').toDict(); // Assert expect(dict['op']).toBe('eq'); diff --git a/src/typescript/test/greaterThanFilterExpression.test.ts b/src/typescript/test/greaterThanFilter.test.ts similarity index 56% rename from src/typescript/test/greaterThanFilterExpression.test.ts rename to src/typescript/test/greaterThanFilter.test.ts index 6f25bef1..f3e0e797 100644 --- a/src/typescript/test/greaterThanFilterExpression.test.ts +++ b/src/typescript/test/greaterThanFilter.test.ts @@ -1,9 +1,9 @@ -import { GreaterThanFilterExpression } from '../src/expressions'; +import { GreaterThanFilter } from '../src/expressions'; -describe('GreaterThanFilterExpression', () => { +describe('GreaterThanFilter', () => { it('should serialize to correct dict', () => { // Arrange - const f = new GreaterThanFilterExpression('age', 18); + const f = new GreaterThanFilter('age', 18); // Act const result = f.toDict(); @@ -14,7 +14,7 @@ describe('GreaterThanFilterExpression', () => { it('op field should be gt', () => { // Arrange / Act - const dict = new GreaterThanFilterExpression('f', 0).toDict(); + const dict = new GreaterThanFilter('f', 0).toDict(); // Assert expect(dict['op']).toBe('gt'); diff --git a/src/typescript/test/greaterThanOrEqualFilterExpression.test.ts b/src/typescript/test/greaterThanOrEqualFilter.test.ts similarity index 54% rename from src/typescript/test/greaterThanOrEqualFilterExpression.test.ts rename to src/typescript/test/greaterThanOrEqualFilter.test.ts index db1ec4c6..aab9bad2 100644 --- a/src/typescript/test/greaterThanOrEqualFilterExpression.test.ts +++ b/src/typescript/test/greaterThanOrEqualFilter.test.ts @@ -1,9 +1,9 @@ -import { GreaterThanOrEqualFilterExpression } from '../src/expressions'; +import { GreaterThanOrEqualFilter } from '../src/expressions'; -describe('GreaterThanOrEqualFilterExpression', () => { +describe('GreaterThanOrEqualFilter', () => { it('should serialize to correct dict', () => { // Arrange - const f = new GreaterThanOrEqualFilterExpression('age', 18); + const f = new GreaterThanOrEqualFilter('age', 18); // Act const result = f.toDict(); @@ -14,7 +14,7 @@ describe('GreaterThanOrEqualFilterExpression', () => { it('op field should be gte', () => { // Arrange / Act - const dict = new GreaterThanOrEqualFilterExpression('f', 0).toDict(); + const dict = new GreaterThanOrEqualFilter('f', 0).toDict(); // Assert expect(dict['op']).toBe('gte'); diff --git a/src/typescript/test/lessThanFilterExpression.test.ts b/src/typescript/test/lessThanFilter.test.ts similarity index 58% rename from src/typescript/test/lessThanFilterExpression.test.ts rename to src/typescript/test/lessThanFilter.test.ts index c6c8b9f5..8dbbbdc6 100644 --- a/src/typescript/test/lessThanFilterExpression.test.ts +++ b/src/typescript/test/lessThanFilter.test.ts @@ -1,9 +1,9 @@ -import { LessThanFilterExpression } from '../src/expressions'; +import { LessThanFilter } from '../src/expressions'; -describe('LessThanFilterExpression', () => { +describe('LessThanFilter', () => { it('should serialize to correct dict', () => { // Arrange - const f = new LessThanFilterExpression('age', 65); + const f = new LessThanFilter('age', 65); // Act const result = f.toDict(); @@ -14,7 +14,7 @@ describe('LessThanFilterExpression', () => { it('op field should be lt', () => { // Arrange / Act - const dict = new LessThanFilterExpression('f', 0).toDict(); + const dict = new LessThanFilter('f', 0).toDict(); // Assert expect(dict['op']).toBe('lt'); diff --git a/src/typescript/test/lessThanOrEqualFilterExpression.test.ts b/src/typescript/test/lessThanOrEqualFilter.test.ts similarity index 55% rename from src/typescript/test/lessThanOrEqualFilterExpression.test.ts rename to src/typescript/test/lessThanOrEqualFilter.test.ts index 6a517c84..9be98522 100644 --- a/src/typescript/test/lessThanOrEqualFilterExpression.test.ts +++ b/src/typescript/test/lessThanOrEqualFilter.test.ts @@ -1,9 +1,9 @@ -import { LessThanOrEqualFilterExpression } from '../src/expressions'; +import { LessThanOrEqualFilter } from '../src/expressions'; -describe('LessThanOrEqualFilterExpression', () => { +describe('LessThanOrEqualFilter', () => { it('should serialize to correct dict', () => { // Arrange - const f = new LessThanOrEqualFilterExpression('age', 65); + const f = new LessThanOrEqualFilter('age', 65); // Act const result = f.toDict(); @@ -14,7 +14,7 @@ describe('LessThanOrEqualFilterExpression', () => { it('op field should be lte', () => { // Arrange / Act - const dict = new LessThanOrEqualFilterExpression('f', 0).toDict(); + const dict = new LessThanOrEqualFilter('f', 0).toDict(); // Assert expect(dict['op']).toBe('lte'); diff --git a/src/typescript/test/notFilter.test.ts b/src/typescript/test/notFilter.test.ts index 11a18298..b017d716 100644 --- a/src/typescript/test/notFilter.test.ts +++ b/src/typescript/test/notFilter.test.ts @@ -1,9 +1,9 @@ -import { EqualsFilterExpression, NotFilter } from '../src/expressions'; +import { EqualsFilter, NotFilter } from '../src/expressions'; describe('NotFilter', () => { it('should serialize with logic not', () => { // Arrange - const f = new NotFilter(new EqualsFilterExpression('deleted', true)); + const f = new NotFilter(new EqualsFilter('deleted', true)); // Act const result = f.toDict(); @@ -15,7 +15,7 @@ describe('NotFilter', () => { it('should wrap the inner filter', () => { // Arrange - const inner = new EqualsFilterExpression('active', false); + const inner = new EqualsFilter('active', false); // Act const f = new NotFilter(inner); diff --git a/src/typescript/test/orFilter.test.ts b/src/typescript/test/orFilter.test.ts index 72d4ed73..5842f4de 100644 --- a/src/typescript/test/orFilter.test.ts +++ b/src/typescript/test/orFilter.test.ts @@ -1,11 +1,11 @@ -import { EqualsFilterExpression, OrFilter } from '../src/expressions'; +import { EqualsFilter, OrFilter } from '../src/expressions'; describe('OrFilter', () => { it('should serialize with logic and nested filters', () => { // Arrange const f = new OrFilter([ - new EqualsFilterExpression('status', 'active'), - new EqualsFilterExpression('status', 'pending'), + new EqualsFilter('status', 'active'), + new EqualsFilter('status', 'pending'), ]); // Act diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index 717afd79..154f757a 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -1,15 +1,15 @@ import { AndFilter, - ContainsFilterExpression, - EndsWithFilterExpression, - EqualsFilterExpression, - GreaterThanFilterExpression, - GreaterThanOrEqualFilterExpression, - LessThanFilterExpression, - LessThanOrEqualFilterExpression, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, NotFilter, OrFilter, - StartsWithFilterExpression, + StartsWithFilter, } from '../src/expressions'; import { parse } from '../src/parser'; @@ -22,8 +22,8 @@ describe('parse - equals', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(EqualsFilterExpression); - const f = result as EqualsFilterExpression; + expect(result).toBeInstanceOf(EqualsFilter); + const f = result as EqualsFilter; expect(f.field).toBe('name'); expect(f.value).toBe('Batman'); }); @@ -38,8 +38,8 @@ describe('parse - contains', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(ContainsFilterExpression); - expect((result as ContainsFilterExpression).value).toBe('bat'); + expect(result).toBeInstanceOf(ContainsFilter); + expect((result as ContainsFilter).value).toBe('bat'); }); }); @@ -52,8 +52,8 @@ describe('parse - startsWith', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(StartsWithFilterExpression); - expect((result as StartsWithFilterExpression).value).toBe('Bat'); + expect(result).toBeInstanceOf(StartsWithFilter); + expect((result as StartsWithFilter).value).toBe('Bat'); }); }); @@ -66,8 +66,8 @@ describe('parse - endsWith', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(EndsWithFilterExpression); - expect((result as EndsWithFilterExpression).value).toBe('man'); + expect(result).toBeInstanceOf(EndsWithFilter); + expect((result as EndsWithFilter).value).toBe('man'); }); }); @@ -82,8 +82,8 @@ describe('parse - negation', () => { // Assert expect(result).toBeInstanceOf(NotFilter); const inner = (result as NotFilter).filter; - expect(inner).toBeInstanceOf(EqualsFilterExpression); - expect((inner as EqualsFilterExpression).value).toBe('Batman'); + expect(inner).toBeInstanceOf(EqualsFilter); + expect((inner as EqualsFilter).value).toBe('Batman'); }); }); @@ -98,8 +98,8 @@ describe('parse - range', () => { // Assert expect(result).toBeInstanceOf(AndFilter); const f = result as AndFilter; - expect(f.filters[0]).toBeInstanceOf(GreaterThanOrEqualFilterExpression); - expect(f.filters[1]).toBeInstanceOf(LessThanOrEqualFilterExpression); + expect(f.filters[0]).toBeInstanceOf(GreaterThanOrEqualFilter); + expect(f.filters[1]).toBeInstanceOf(LessThanOrEqualFilter); }); it('should parse open range (min,max)', () => { @@ -112,8 +112,8 @@ describe('parse - range', () => { // Assert expect(result).toBeInstanceOf(AndFilter); const f = result as AndFilter; - expect(f.filters[0]).toBeInstanceOf(GreaterThanFilterExpression); - expect(f.filters[1]).toBeInstanceOf(LessThanFilterExpression); + expect(f.filters[0]).toBeInstanceOf(GreaterThanFilter); + expect(f.filters[1]).toBeInstanceOf(LessThanFilter); }); it('should parse half-open range [min,]', () => { @@ -124,8 +124,8 @@ describe('parse - range', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(GreaterThanOrEqualFilterExpression); - expect((result as GreaterThanOrEqualFilterExpression).value).toBe('18'); + expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); + expect((result as GreaterThanOrEqualFilter).value).toBe('18'); }); it('should parse half-open range [,max]', () => { @@ -136,8 +136,8 @@ describe('parse - range', () => { const result = parse(expression); // Assert - expect(result).toBeInstanceOf(LessThanOrEqualFilterExpression); - expect((result as LessThanOrEqualFilterExpression).value).toBe('65'); + expect(result).toBeInstanceOf(LessThanOrEqualFilter); + expect((result as LessThanOrEqualFilter).value).toBe('65'); }); }); @@ -193,10 +193,10 @@ describe('parse - errors', () => { describe('parse - parameterized', () => { const cases: [string, unknown][] = [ - ['name=Batman', EqualsFilterExpression], - ['name=*bat*', ContainsFilterExpression], - ['name=Bat*', StartsWithFilterExpression], - ['name=*man', EndsWithFilterExpression], + ['name=Batman', EqualsFilter], + ['name=*bat*', ContainsFilter], + ['name=Bat*', StartsWithFilter], + ['name=*man', EndsWithFilter], ['name=!Batman', NotFilter], ]; diff --git a/src/typescript/test/startsWithFilterExpression.test.ts b/src/typescript/test/startsWithFilter.test.ts similarity index 58% rename from src/typescript/test/startsWithFilterExpression.test.ts rename to src/typescript/test/startsWithFilter.test.ts index 0b4c6cbe..5d8e77fc 100644 --- a/src/typescript/test/startsWithFilterExpression.test.ts +++ b/src/typescript/test/startsWithFilter.test.ts @@ -1,9 +1,9 @@ -import { StartsWithFilterExpression } from '../src/expressions'; +import { StartsWithFilter } from '../src/expressions'; -describe('StartsWithFilterExpression', () => { +describe('StartsWithFilter', () => { it('should serialize to correct dict', () => { // Arrange - const f = new StartsWithFilterExpression('name', 'Bat'); + const f = new StartsWithFilter('name', 'Bat'); // Act const result = f.toDict(); @@ -14,7 +14,7 @@ describe('StartsWithFilterExpression', () => { it('op field should be startswith', () => { // Arrange / Act - const dict = new StartsWithFilterExpression('f', 'v').toDict(); + const dict = new StartsWithFilter('f', 'v').toDict(); // Assert expect(dict['op']).toBe('startswith'); From 9ee0d2d572f116462b17e85f44ddd27ab27a381b Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:26:59 +0000 Subject: [PATCH 11/28] fix: address SonarCloud quality gate issues in TypeScript SDK Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/a9a0170e-156f-43cb-b738-863447a83e0c Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- src/typescript/src/builder.ts | 23 ++++++++++++----------- src/typescript/src/parser.ts | 27 +++++++++++++++++++-------- src/typescript/src/serializers.ts | 30 +++++++++++++++++++++++++----- 3 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/typescript/src/builder.ts b/src/typescript/src/builder.ts index cb6dd5bf..57abde42 100644 --- a/src/typescript/src/builder.ts +++ b/src/typescript/src/builder.ts @@ -26,53 +26,53 @@ export class FieldBuilder { /** Add an equals condition. */ public eq(value: unknown): FilterBuilder { - return this.parent['add'](new EqualsFilter(this.field, value)); + return this.parent.addFilter(new EqualsFilter(this.field, value)); } /** Add a contains condition. */ public contains(value: string): FilterBuilder { - return this.parent['add'](new ContainsFilter(this.field, value)); + return this.parent.addFilter(new ContainsFilter(this.field, value)); } /** Add a starts-with condition. */ public startsWith(value: string): FilterBuilder { - return this.parent['add'](new StartsWithFilter(this.field, value)); + return this.parent.addFilter(new StartsWithFilter(this.field, value)); } /** Add an ends-with condition. */ public endsWith(value: string): FilterBuilder { - return this.parent['add'](new EndsWithFilter(this.field, value)); + return this.parent.addFilter(new EndsWithFilter(this.field, value)); } /** Add a greater-than condition. */ public gt(value: unknown): FilterBuilder { - return this.parent['add'](new GreaterThanFilter(this.field, value)); + return this.parent.addFilter(new GreaterThanFilter(this.field, value)); } /** Add a greater-than-or-equal condition. */ public gte(value: unknown): FilterBuilder { - return this.parent['add'](new GreaterThanOrEqualFilter(this.field, value)); + return this.parent.addFilter(new GreaterThanOrEqualFilter(this.field, value)); } /** Add a less-than condition. */ public lt(value: unknown): FilterBuilder { - return this.parent['add'](new LessThanFilter(this.field, value)); + return this.parent.addFilter(new LessThanFilter(this.field, value)); } /** Add a less-than-or-equal condition. */ public lte(value: unknown): FilterBuilder { - return this.parent['add'](new LessThanOrEqualFilter(this.field, value)); + return this.parent.addFilter(new LessThanOrEqualFilter(this.field, value)); } /** Add a not-equals condition. */ public not(value: unknown): FilterBuilder { - return this.parent['add'](new NotFilter(new EqualsFilter(this.field, value))); + return this.parent.addFilter(new NotFilter(new EqualsFilter(this.field, value))); } /** Add an OR condition matching any of the given values. */ public or(...values: unknown[]): FilterBuilder { const orFilters: IFilter[] = values.map((v) => new EqualsFilter(this.field, v)); - return this.parent['add'](new OrFilter(orFilters)); + return this.parent.addFilter(new OrFilter(orFilters)); } } @@ -90,7 +90,8 @@ export class FieldBuilder { export class FilterBuilder { private readonly filters: IFilter[] = []; - private add(filter: IFilter): FilterBuilder { + /** @internal */ + public addFilter(filter: IFilter): FilterBuilder { this.filters.push(filter); return this; } diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index c3636039..be2bc8da 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -31,6 +31,9 @@ import { } from './expressions'; const RANGE_PATTERN = /^(?[[(])(?[^,]*),(?[^)\]]*)?(?[)\]])$/; +const WILDCARD_CHAR = '*'; +const NEGATION_CHAR = '!'; +const MIN_WILDCARD_LENGTH = 2; function parseRange(field: string, value: string): IFilter { const match = RANGE_PATTERN.exec(value); @@ -40,12 +43,12 @@ function parseRange(field: string, value: string): IFilter { const openBracket = match.groups['open']; const closeBracket = match.groups['close']; - const minVal = match.groups['min'] || null; - const maxVal = match.groups['max'] || null; + const minVal = match.groups['min']; + const maxVal = match.groups['max']; const filters: IFilter[] = []; - if (minVal !== null && minVal !== '') { + if (minVal && minVal.trim() !== '') { if (openBracket === '[') { filters.push(new GreaterThanOrEqualFilter(field, minVal)); } else { @@ -53,7 +56,7 @@ function parseRange(field: string, value: string): IFilter { } } - if (maxVal !== null && maxVal !== '') { + if (maxVal && maxVal.trim() !== '') { if (closeBracket === ']') { filters.push(new LessThanOrEqualFilter(field, maxVal)); } else { @@ -61,23 +64,31 @@ function parseRange(field: string, value: string): IFilter { } } + if (filters.length === 0) { + throw new Error(`Range expression must have at least one bound: '${value}'`); + } + const result: IFilter = filters.length === 1 ? filters[0] : new AndFilter(filters); return result; } function parseValueExpression(field: string, value: string): IFilter { - const negated = value.startsWith('!'); + const negated = value.startsWith(NEGATION_CHAR); const actualValue = negated ? value.slice(1) : value; let result: IFilter; if (RANGE_PATTERN.test(actualValue)) { result = parseRange(field, actualValue); - } else if (actualValue.startsWith('*') && actualValue.endsWith('*') && actualValue.length > 1) { + } else if ( + actualValue.startsWith(WILDCARD_CHAR) && + actualValue.endsWith(WILDCARD_CHAR) && + actualValue.length > MIN_WILDCARD_LENGTH + ) { result = new ContainsFilter(field, actualValue.slice(1, -1)); - } else if (actualValue.startsWith('*')) { + } else if (actualValue.startsWith(WILDCARD_CHAR)) { result = new EndsWithFilter(field, actualValue.slice(1)); - } else if (actualValue.endsWith('*')) { + } else if (actualValue.endsWith(WILDCARD_CHAR)) { result = new StartsWithFilter(field, actualValue.slice(0, -1)); } else { result = new EqualsFilter(field, actualValue); diff --git a/src/typescript/src/serializers.ts b/src/typescript/src/serializers.ts index aa8ee70a..0284f104 100644 --- a/src/typescript/src/serializers.ts +++ b/src/typescript/src/serializers.ts @@ -49,27 +49,47 @@ export function fromDict(data: Record): IFilter { let result: IFilter; if ('logic' in data) { - const logic = data['logic'] as string; - const children = (data['filters'] as Record[]).map(fromDict); + const logic = data['logic']; + if (typeof logic !== 'string') { + throw new Error(`Invalid logic type: expected string, got ${typeof logic}`); + } + + const filtersData = data['filters']; + if (!Array.isArray(filtersData)) { + throw new Error(`Invalid filters type: expected array, got ${typeof filtersData}`); + } + + const children = filtersData.map(fromDict); if (logic === 'and') { result = new AndFilter(children); } else if (logic === 'or') { result = new OrFilter(children); } else if (logic === 'not') { + if (children.length === 0) { + throw new Error('Not filter requires at least one child filter'); + } result = new NotFilter(children[0]); } else { throw new Error(`Unknown logic operator: '${logic}'`); } } else { - const op = data['op'] as string; - const Ctor = OP_MAP[op]; + const op = data['op']; + if (typeof op !== 'string') { + throw new Error(`Invalid op type: expected string, got ${typeof op}`); + } + const Ctor = OP_MAP[op]; if (!Ctor) { throw new Error(`Unknown filter operator: '${op}'`); } - result = new Ctor(data['field'] as string, data['value']); + const field = data['field']; + if (typeof field !== 'string') { + throw new Error(`Invalid field type: expected string, got ${typeof field}`); + } + + result = new Ctor(field, data['value']); } return result; From 2958f16747e6de41369780130a51db35951a4ee0 Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Wed, 8 Apr 2026 11:08:45 +0000 Subject: [PATCH 12/28] refactor: update parser and tests to support new range syntax and improve readability --- src/typescript/src/parser.ts | 252 ++++++++++--------- src/typescript/test/parser.test.ts | 375 +++++++++++++++-------------- 2 files changed, 330 insertions(+), 297 deletions(-) diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index be2bc8da..678b0aa9 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -7,146 +7,165 @@ * - `field=*value` → EndsWithFilter * - `field=*value*` → ContainsFilter * - `field=!value` → NotFilter(EqualsFilter) - * - `field=[min,max]` → AndFilter(gte, lte) - * - `field=[min,]` → GreaterThanOrEqualFilter - * - `field=[,max]` → LessThanOrEqualFilter - * - `field=(min,max)` → AndFilter(gt, lt) + * - `field=[min TO max]` → AndFilter(gte, lte) + * - `field=[min TO *[` → GreaterThanOrEqualFilter + * - `field=]* TO max]` → LessThanOrEqualFilter + * - `field=]min TO max[` → AndFilter(gt, lt) * - `expr1,expr2` → AndFilter of multiple criteria * - `expr1|expr2` → OrFilter of multiple criteria */ import { - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - IFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, -} from './expressions'; - -const RANGE_PATTERN = /^(?[[(])(?[^,]*),(?[^)\]]*)?(?[)\]])$/; -const WILDCARD_CHAR = '*'; -const NEGATION_CHAR = '!'; + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + IFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +} from "./expressions"; + +const RANGE_PATTERN = + /^(?[[\]])(?.*?)\s*TO\s*(?.*?)(?[[\]])$/; +const WILDCARD_CHAR = "*"; +const NEGATION_CHAR = "!"; const MIN_WILDCARD_LENGTH = 2; function parseRange(field: string, value: string): IFilter { - const match = RANGE_PATTERN.exec(value); - if (!match || !match.groups) { - throw new Error(`Invalid range expression: '${value}'`); - } - - const openBracket = match.groups['open']; - const closeBracket = match.groups['close']; - const minVal = match.groups['min']; - const maxVal = match.groups['max']; - - const filters: IFilter[] = []; + const match = RANGE_PATTERN.exec(value); + if (!match || !match.groups) { + throw new Error(`Invalid range expression: '${value}'`); + } - if (minVal && minVal.trim() !== '') { - if (openBracket === '[') { - filters.push(new GreaterThanOrEqualFilter(field, minVal)); - } else { - filters.push(new GreaterThanFilter(field, minVal)); + const openBracket = match.groups["open"]; + const closeBracket = match.groups["close"]; + const minVal = match.groups["min"]; + const maxVal = match.groups["max"]; + + const filters: IFilter[] = []; + const trimmedMin = minVal ? minVal.trim() : ""; + const trimmedMax = maxVal ? maxVal.trim() : ""; + const hasMin = trimmedMin !== "" && trimmedMin !== WILDCARD_CHAR; + const hasMax = trimmedMax !== "" && trimmedMax !== WILDCARD_CHAR; + + if (hasMin) { + if (openBracket === "[") { + filters.push(new GreaterThanOrEqualFilter(field, trimmedMin)); + } else { + filters.push(new GreaterThanFilter(field, trimmedMin)); + } } - } - if (maxVal && maxVal.trim() !== '') { - if (closeBracket === ']') { - filters.push(new LessThanOrEqualFilter(field, maxVal)); - } else { - filters.push(new LessThanFilter(field, maxVal)); + if (hasMax) { + if (closeBracket === "]") { + filters.push(new LessThanOrEqualFilter(field, trimmedMax)); + } else { + filters.push(new LessThanFilter(field, trimmedMax)); + } } - } - if (filters.length === 0) { - throw new Error(`Range expression must have at least one bound: '${value}'`); - } + if (filters.length === 0) { + throw new Error( + `Range expression must have at least one bound: '${value}'`, + ); + } - const result: IFilter = filters.length === 1 ? filters[0] : new AndFilter(filters); - return result; + const result: IFilter = + filters.length === 1 ? filters[0] : new AndFilter(filters); + return result; } function parseValueExpression(field: string, value: string): IFilter { - const negated = value.startsWith(NEGATION_CHAR); - const actualValue = negated ? value.slice(1) : value; - - let result: IFilter; - - if (RANGE_PATTERN.test(actualValue)) { - result = parseRange(field, actualValue); - } else if ( - actualValue.startsWith(WILDCARD_CHAR) && - actualValue.endsWith(WILDCARD_CHAR) && - actualValue.length > MIN_WILDCARD_LENGTH - ) { - result = new ContainsFilter(field, actualValue.slice(1, -1)); - } else if (actualValue.startsWith(WILDCARD_CHAR)) { - result = new EndsWithFilter(field, actualValue.slice(1)); - } else if (actualValue.endsWith(WILDCARD_CHAR)) { - result = new StartsWithFilter(field, actualValue.slice(0, -1)); - } else { - result = new EqualsFilter(field, actualValue); - } - - return negated ? new NotFilter(result) : result; + const negated = value.startsWith(NEGATION_CHAR); + const actualValue = negated ? value.slice(1) : value; + + let result: IFilter; + + if (RANGE_PATTERN.test(actualValue)) { + result = parseRange(field, actualValue); + } else if ( + actualValue.startsWith(WILDCARD_CHAR) && + actualValue.endsWith(WILDCARD_CHAR) && + actualValue.length > MIN_WILDCARD_LENGTH + ) { + result = new ContainsFilter(field, actualValue.slice(1, -1)); + } else if (actualValue.startsWith(WILDCARD_CHAR)) { + result = new EndsWithFilter(field, actualValue.slice(1)); + } else if (actualValue.endsWith(WILDCARD_CHAR)) { + result = new StartsWithFilter(field, actualValue.slice(0, -1)); + } else { + result = new EqualsFilter(field, actualValue); + } + + return negated ? new NotFilter(result) : result; } function parseSingle(expression: string): IFilter { - const eqIndex = expression.indexOf('='); - if (eqIndex === -1) { - throw new Error(`Invalid filter expression: '${expression}'. Expected 'field=value'.`); - } + const eqIndex = expression.indexOf("="); + if (eqIndex === -1) { + throw new Error( + `Invalid filter expression: '${expression}'. Expected 'field=value'.`, + ); + } - const field = expression.slice(0, eqIndex).trim(); - const value = expression.slice(eqIndex + 1).trim(); + const field = expression.slice(0, eqIndex).trim(); + const value = expression.slice(eqIndex + 1).trim(); - if (!field) { - throw new Error(`Field name must not be empty in expression: '${expression}'`); - } + if (!field) { + throw new Error( + `Field name must not be empty in expression: '${expression}'`, + ); + } - return parseValueExpression(field, value); + const orParts = value.split("|"); + const result: IFilter = + orParts.length > 1 + ? new OrFilter( + orParts.map((v) => parseValueExpression(field, v.trim())), + ) + : parseValueExpression(field, value); + return result; } function splitAndParts(expression: string): string[] { - const parts: string[] = []; - let depth = 0; - let current = ''; - - for (const char of expression) { - if (char === '(' || char === '[') { - depth++; - current += char; - } else if (char === ')' || char === ']') { - depth--; - current += char; - } else if (char === ',' && depth === 0) { - parts.push(current); - current = ''; - } else { - current += char; + const parts: string[] = []; + let depth = 0; + let current = ""; + + for (const char of expression) { + if (char === "(" || char === "[") { + depth++; + current += char; + } else if (char === ")" || char === "]") { + depth--; + current += char; + } else if (char === "," && depth === 0) { + parts.push(current); + current = ""; + } else { + current += char; + } } - } - if (current) { - parts.push(current); - } + if (current) { + parts.push(current); + } - return parts; + return parts; } function parseAndExpression(expression: string): IFilter { - const parts = splitAndParts(expression); - const result: IFilter = - parts.length > 1 - ? new AndFilter(parts.map((p) => parseSingle(p.trim()))) - : parseSingle(expression); - return result; + const parts = splitAndParts(expression); + const result: IFilter = + parts.length > 1 + ? new AndFilter(parts.map((p) => parseSingle(p.trim()))) + : parseSingle(expression); + return result; } /** @@ -157,15 +176,10 @@ function parseAndExpression(expression: string): IFilter { * @throws {Error} If the expression cannot be parsed. */ export function parse(expression: string): IFilter { - if (!expression || !expression.trim()) { - throw new Error('Expression must not be empty'); - } - - const trimmed = expression.trim(); - const orParts = trimmed.split('|'); - const result: IFilter = - orParts.length > 1 - ? new OrFilter(orParts.map((p) => parseAndExpression(p.trim()))) - : parseAndExpression(trimmed); - return result; + if (!expression || !expression.trim()) { + throw new Error("Expression must not be empty"); + } + + const trimmed = expression.trim(); + return parseAndExpression(trimmed); } diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index 154f757a..c21d02fb 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -1,210 +1,229 @@ import { - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, -} from '../src/expressions'; -import { parse } from '../src/parser'; - -describe('parse - equals', () => { - it('should parse simple equals expression', () => { - // Arrange - const expression = 'name=Batman'; - - // Act - const result = parse(expression); - - // Assert - expect(result).toBeInstanceOf(EqualsFilter); - const f = result as EqualsFilter; - expect(f.field).toBe('name'); - expect(f.value).toBe('Batman'); - }); + IFilter, + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, +} from "../src/expressions"; +import { parse } from "../src/parser"; + +describe("parse - equals", () => { + it("should parse simple equals expression", () => { + // Arrange + const expression = "name=Batman"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(EqualsFilter); + const f = result as EqualsFilter; + expect(f.field).toBe("name"); + expect(f.value).toBe("Batman"); + }); }); -describe('parse - contains', () => { - it('should parse contains expression', () => { - // Arrange - const expression = 'name=*bat*'; +describe("parse - contains", () => { + it("should parse contains expression", () => { + // Arrange + const expression = "name=*bat*"; - // Act - const result = parse(expression); + // Act + const result: IFilter = parse(expression); - // Assert - expect(result).toBeInstanceOf(ContainsFilter); - expect((result as ContainsFilter).value).toBe('bat'); - }); + // Assert + expect(result).toBeInstanceOf(ContainsFilter); + expect((result as ContainsFilter).value).toBe("bat"); + }); }); -describe('parse - startsWith', () => { - it('should parse starts-with expression', () => { - // Arrange - const expression = 'name=Bat*'; +describe("parse - startsWith", () => { + it("should parse starts-with expression", () => { + // Arrange + const expression = "name=Bat*"; - // Act - const result = parse(expression); + // Act + const result: IFilter = parse(expression); - // Assert - expect(result).toBeInstanceOf(StartsWithFilter); - expect((result as StartsWithFilter).value).toBe('Bat'); - }); + // Assert + expect(result).toBeInstanceOf(StartsWithFilter); + expect((result as StartsWithFilter).value).toBe("Bat"); + }); }); -describe('parse - endsWith', () => { - it('should parse ends-with expression', () => { - // Arrange - const expression = 'name=*man'; +describe("parse - endsWith", () => { + it("should parse ends-with expression", () => { + // Arrange + const expression = "name=*man"; - // Act - const result = parse(expression); + // Act + const result: IFilter = parse(expression); - // Assert - expect(result).toBeInstanceOf(EndsWithFilter); - expect((result as EndsWithFilter).value).toBe('man'); - }); + // Assert + expect(result).toBeInstanceOf(EndsWithFilter); + expect((result as EndsWithFilter).value).toBe("man"); + }); }); -describe('parse - negation', () => { - it('should parse not-equals expression', () => { - // Arrange - const expression = 'name=!Batman'; +describe("parse - negation", () => { + it("should parse not-equals expression", () => { + // Arrange + const expression = "name=!Batman"; - // Act - const result = parse(expression); + // Act + const result: IFilter = parse(expression); - // Assert - expect(result).toBeInstanceOf(NotFilter); - const inner = (result as NotFilter).filter; - expect(inner).toBeInstanceOf(EqualsFilter); - expect((inner as EqualsFilter).value).toBe('Batman'); - }); + // Assert + expect(result).toBeInstanceOf(NotFilter); + const inner = (result as NotFilter).filter; + expect(inner).toBeInstanceOf(EqualsFilter); + expect((inner as EqualsFilter).value).toBe("Batman"); + }); }); -describe('parse - range', () => { - it('should parse closed range [min,max]', () => { - // Arrange - const expression = 'age=[18,65]'; - - // Act - const result = parse(expression); - - // Assert - expect(result).toBeInstanceOf(AndFilter); - const f = result as AndFilter; - expect(f.filters[0]).toBeInstanceOf(GreaterThanOrEqualFilter); - expect(f.filters[1]).toBeInstanceOf(LessThanOrEqualFilter); - }); - - it('should parse open range (min,max)', () => { - // Arrange - const expression = 'age=(18,65)'; - - // Act - const result = parse(expression); - - // Assert - expect(result).toBeInstanceOf(AndFilter); - const f = result as AndFilter; - expect(f.filters[0]).toBeInstanceOf(GreaterThanFilter); - expect(f.filters[1]).toBeInstanceOf(LessThanFilter); - }); - - it('should parse half-open range [min,]', () => { - // Arrange - const expression = 'age=[18,]'; - - // Act - const result = parse(expression); - - // Assert - expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); - expect((result as GreaterThanOrEqualFilter).value).toBe('18'); - }); - - it('should parse half-open range [,max]', () => { - // Arrange - const expression = 'age=[,65]'; - - // Act - const result = parse(expression); - - // Assert - expect(result).toBeInstanceOf(LessThanOrEqualFilter); - expect((result as LessThanOrEqualFilter).value).toBe('65'); - }); +describe("parse - range", () => { + it("should parse closed range [min TO max]", () => { + // Arrange + const expression = "age=[18 TO 65]"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const f = result as AndFilter; + expect(f.filters[0]).toBeInstanceOf(GreaterThanOrEqualFilter); + expect(f.filters[1]).toBeInstanceOf(LessThanOrEqualFilter); + }); + + it("should parse open range ]min TO max[", () => { + // Arrange + const expression = "age=]18 TO 65["; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const f = result as AndFilter; + expect(f.filters[0]).toBeInstanceOf(GreaterThanFilter); + expect(f.filters[1]).toBeInstanceOf(LessThanFilter); + }); + + it("should parse half-open range [min TO *[", () => { + // Arrange + const expression = "age=[18 TO *["; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); + expect((result as GreaterThanOrEqualFilter).value).toBe("18"); + }); + + it("should parse half-open range ]* TO max]", () => { + // Arrange + const expression = "age=]* TO 65]"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(LessThanOrEqualFilter); + expect((result as LessThanOrEqualFilter).value).toBe("65"); + }); }); -describe('parse - AND combination', () => { - it('should parse comma-separated expressions as AND', () => { - // Arrange - const expression = 'name=*bat*,age=[18,]'; +describe("parse - AND combination", () => { + it("should parse comma-separated expressions as AND", () => { + // Arrange + const expression = "name=*bat*,age=[18 TO *["; - // Act - const result = parse(expression); + // Act + const result: IFilter = parse(expression); - // Assert - expect(result).toBeInstanceOf(AndFilter); - expect((result as AndFilter).filters.length).toBe(2); - }); + // Assert + expect(result).toBeInstanceOf(AndFilter); + const andFilter = result as AndFilter; + + expect(andFilter.filters.length).toBe(2); + expect(andFilter.filters[0]).toBeInstanceOf(ContainsFilter); + const containsFilter = andFilter.filters[0] as ContainsFilter; + expect(containsFilter.field).toBe("name"); + expect(containsFilter.value).toBe("bat"); + + expect(andFilter.filters[1]).toBeInstanceOf(GreaterThanOrEqualFilter); + const greatThanOrEqualToFilter = andFilter + .filters[1] as GreaterThanOrEqualFilter; + + expect(greatThanOrEqualToFilter.field).toBe("age"); + expect(greatThanOrEqualToFilter.value).toBe("18"); + }); }); -describe('parse - OR combination', () => { - it('should parse pipe-separated expressions as OR', () => { - // Arrange - const expression = 'status=active|status=pending'; +describe("parse - OR combination", () => { + it("should parse pipe-separated expressions as OR", () => { + // Arrange + const expression = "status=active|pending"; - // Act - const result = parse(expression); + // Act + const result: IFilter = parse(expression); - // Assert - expect(result).toBeInstanceOf(OrFilter); - expect((result as OrFilter).filters.length).toBe(2); - }); + // Assert + expect(result).toBeInstanceOf(OrFilter); + expect((result as OrFilter).filters.length).toBe(2); + }); }); -describe('parse - errors', () => { - it('should throw on empty expression', () => { - // Arrange / Act / Assert - expect(() => parse('')).toThrow(); - }); - - it('should throw on whitespace-only expression', () => { - // Arrange / Act / Assert - expect(() => parse(' ')).toThrow(); - }); - - it('should throw on missing field', () => { - // Arrange / Act / Assert - expect(() => parse('=value')).toThrow(); - }); - - it('should throw on missing equals sign', () => { - // Arrange / Act / Assert - expect(() => parse('noequals')).toThrow(); - }); +describe("parse - errors", () => { + it("should throw on empty expression", () => { + // Arrange / Act / Assert + expect(() => parse("")).toThrow(); + }); + + it("should throw on whitespace-only expression", () => { + // Arrange / Act / Assert + expect(() => parse(" ")).toThrow(); + }); + + it("should throw on missing field", () => { + // Arrange / Act / Assert + expect(() => parse("=value")).toThrow(); + }); + + it("should throw on missing equals sign", () => { + // Arrange / Act / Assert + expect(() => parse("noequals")).toThrow(); + }); }); -describe('parse - parameterized', () => { - const cases: [string, unknown][] = [ - ['name=Batman', EqualsFilter], - ['name=*bat*', ContainsFilter], - ['name=Bat*', StartsWithFilter], - ['name=*man', EndsWithFilter], - ['name=!Batman', NotFilter], - ]; - - test.each(cases)('parse("%s") should return expected type', (expression, expectedType) => { - // Arrange / Act - const result = parse(expression); - - // Assert - expect(result).toBeInstanceOf(expectedType as unknown as new () => unknown); - }); +describe("parse - parameterized", () => { + const cases: [string, unknown][] = [ + ["name=Batman", EqualsFilter], + ["name=*bat*", ContainsFilter], + ["name=Bat*", StartsWithFilter], + ["name=*man", EndsWithFilter], + ["name=!Batman", NotFilter], + ]; + + test.each(cases)( + 'parse("%s") should return expected type', + (expression, expectedType) => { + // Arrange / Act + const result = parse(expression); + + // Assert + expect(result).toBeInstanceOf( + expectedType as unknown as new () => unknown, + ); + }, + ); }); From 43ef00e61e0e68a923a6db8286349d8da6e85cf4 Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Wed, 8 Apr 2026 11:09:26 +0000 Subject: [PATCH 13/28] chore: update devcontainer configuration, package dependencies, and add project conventions documentation --- .devcontainer/typescript/devcontainer.json | 18 +++---- .squad/skills/project-conventions/SKILL.md | 56 ++++++++++++++++++++++ src/typescript/package-lock.json | 2 +- src/typescript/package.json | 2 +- src/typescript/tsconfig.json | 3 +- 5 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 .squad/skills/project-conventions/SKILL.md diff --git a/.devcontainer/typescript/devcontainer.json b/.devcontainer/typescript/devcontainer.json index 438cee7b..ea298178 100644 --- a/.devcontainer/typescript/devcontainer.json +++ b/.devcontainer/typescript/devcontainer.json @@ -4,13 +4,10 @@ "features": { "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/copilot-cli": {} + "ghcr.io/devcontainers/features/copilot-cli": {}, + "ghcr.io/nils-geistmann/devcontainers-features/zsh": {}, + "ghcr.io/devcontainers-extra/features/jest": {} }, - "runArgs": [ - "--userns=keep-id" - ], - "containerUser": "vscode", - "updateRemoteUserUID": true, "customizations": { "vscode": { "extensions": [ @@ -19,7 +16,7 @@ "ms-vscode.vscode-typescript-next", "Orta.vscode-jest", "editorconfig.editorconfig", - "GitHub.copilot", + "github.copilot-chat", "GitHub.vscode-pull-request-github", "bierner.markdown-mermaid", "DavidAnson.vscode-markdownlint" @@ -30,7 +27,7 @@ "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "typescript.preferences.importModuleSpecifier": "relative", + "ts.preferences.importModuleSpecifier": "relative", "eslint.workingDirectories": ["src/typescript"], "jest.jestCommandLine": "npx jest", "jest.rootPath": "src/typescript", @@ -38,7 +35,6 @@ } } }, - "postCreateCommand": "./.devcontainer/typescript/scripts/post-create.sh", - "postStartCommand": "./.devcontainer/typescript/scripts/post-start.sh", - "remoteUser": "vscode" + "postCreateCommand": "zsh ./.devcontainer/typescript/scripts/post-create.sh", + "postStartCommand": "zsh ./.devcontainer/typescript/scripts/post-start.sh" } diff --git a/.squad/skills/project-conventions/SKILL.md b/.squad/skills/project-conventions/SKILL.md new file mode 100644 index 00000000..48a1861d --- /dev/null +++ b/.squad/skills/project-conventions/SKILL.md @@ -0,0 +1,56 @@ +--- +name: "project-conventions" +description: "Core conventions and patterns for this codebase" +domain: "project-conventions" +confidence: "medium" +source: "template" +--- + +## Context + +> **This is a starter template.** Replace the placeholder patterns below with your actual project conventions. Skills train agents on codebase-specific practices — accurate documentation here improves agent output quality. + +## Patterns + +### [Pattern Name] + +Describe a key convention or practice used in this codebase. Be specific about what to do and why. + +### Error Handling + + + + + + +### Testing + + + + + + +### Code Style + + + + + + +### File Structure + + + + + + +## Examples + +``` +// Add code examples that demonstrate your conventions +``` + +## Anti-Patterns + + +- **[Anti-pattern]** — Explanation of what not to do and why. diff --git a/src/typescript/package-lock.json b/src/typescript/package-lock.json index ebcda565..bf5286a2 100644 --- a/src/typescript/package-lock.json +++ b/src/typescript/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { - "@types/jest": "^29.5.0", + "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.57.0", diff --git a/src/typescript/package.json b/src/typescript/package.json index e848de82..e7a8894f 100644 --- a/src/typescript/package.json +++ b/src/typescript/package.json @@ -29,7 +29,7 @@ ], "license": "MIT", "devDependencies": { - "@types/jest": "^29.5.0", + "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.57.0", diff --git a/src/typescript/tsconfig.json b/src/typescript/tsconfig.json index 20718ab0..e7a9c2ca 100644 --- a/src/typescript/tsconfig.json +++ b/src/typescript/tsconfig.json @@ -15,7 +15,8 @@ "noImplicitAny": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noImplicitReturns": true + "noImplicitReturns": true, + "types": ["jest"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "test"] From a42b6d38b826ba2ae6a6da00bdd64a0e6e10771a Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Thu, 9 Apr 2026 16:24:44 +0000 Subject: [PATCH 14/28] refactor: enhance range parsing and add support for comma-separated AND expressions --- src/typescript/src/parser.ts | 33 +++++++++++++++++++++++------- src/typescript/test/parser.test.ts | 23 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index 678b0aa9..2b3d15e5 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -48,8 +48,8 @@ function parseRange(field: string, value: string): IFilter { const maxVal = match.groups["max"]; const filters: IFilter[] = []; - const trimmedMin = minVal ? minVal.trim() : ""; - const trimmedMax = maxVal ? maxVal.trim() : ""; + const trimmedMin = minVal?.trim() ?? ""; + const trimmedMax = maxVal?.trim() ?? ""; const hasMin = trimmedMin !== "" && trimmedMin !== WILDCARD_CHAR; const hasMax = trimmedMax !== "" && trimmedMax !== WILDCARD_CHAR; @@ -161,11 +161,30 @@ function splitAndParts(expression: string): string[] { function parseAndExpression(expression: string): IFilter { const parts = splitAndParts(expression); - const result: IFilter = - parts.length > 1 - ? new AndFilter(parts.map((p) => parseSingle(p.trim()))) - : parseSingle(expression); - return result; + + if (parts.length <= 1) { + return parseSingle(expression); + } + + let inheritedField: string | null = null; + const filters: IFilter[] = parts.map((p) => { + const trimmed = p.trim(); + if (trimmed.includes("=")) { + const eqIndex = trimmed.indexOf("="); + inheritedField = trimmed.slice(0, eqIndex).trim(); + return parseSingle(trimmed); + } + + if (!inheritedField) { + throw new Error( + `Cannot determine field for expression part: '${trimmed}'`, + ); + } + + return parseValueExpression(inheritedField, trimmed); + }); + + return new AndFilter(filters); } /** diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index c21d02fb..a9a728ee 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -167,6 +167,29 @@ describe("parse - AND combination", () => { expect(greatThanOrEqualToFilter.field).toBe("age"); expect(greatThanOrEqualToFilter.value).toBe("18"); }); + + it("should parse comma-separated expressions on same property as AND", () => { + // Arrange + const expression = "name=*an,bat*"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const andFilter = result as AndFilter; + + expect(andFilter.filters.length).toBe(2); + expect(andFilter.filters[0]).toBeInstanceOf(EndsWithFilter); + const endsWithFilter = andFilter.filters[0] as EndsWithFilter; + expect(endsWithFilter.field).toBe("name"); + expect(endsWithFilter.value).toBe("an"); + + expect(andFilter.filters[1]).toBeInstanceOf(StartsWithFilter); + const startsWithFilter = andFilter.filters[1] as StartsWithFilter; + expect(startsWithFilter.field).toBe("name"); + expect(startsWithFilter.value).toBe("bat"); + }); }); describe("parse - OR combination", () => { From f17881289fce88a7cf1117290531e6c96791c170 Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Thu, 9 Apr 2026 16:47:41 +0000 Subject: [PATCH 15/28] ref(tests): expand assertions and variables expand assertions and variables to facilitate reading them --- src/typescript/test/parser.test.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index a9a728ee..ea1f2553 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -114,7 +114,15 @@ describe("parse - range", () => { expect(result).toBeInstanceOf(AndFilter); const f = result as AndFilter; expect(f.filters[0]).toBeInstanceOf(GreaterThanFilter); + + const greaterThanFilter = f.filters[0] as GreaterThanFilter; + expect(greaterThanFilter.field).toBe("age"); + expect(greaterThanFilter.value).toBe("18"); + expect(f.filters[1]).toBeInstanceOf(LessThanFilter); + const lessThanFilter = f.filters[1] as LessThanFilter; + expect(lessThanFilter.field).toBe("age"); + expect(lessThanFilter.value).toBe("65"); }); it("should parse half-open range [min TO *[", () => { @@ -126,7 +134,9 @@ describe("parse - range", () => { // Assert expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); - expect((result as GreaterThanOrEqualFilter).value).toBe("18"); + const greaterThanOrEqualFilter = result as GreaterThanOrEqualFilter; + expect(greaterThanOrEqualFilter.field).toBe("age"); + expect(greaterThanOrEqualFilter.value).toBe("18"); }); it("should parse half-open range ]* TO max]", () => { @@ -138,7 +148,9 @@ describe("parse - range", () => { // Assert expect(result).toBeInstanceOf(LessThanOrEqualFilter); - expect((result as LessThanOrEqualFilter).value).toBe("65"); + const lessThanOrEqualFilter = result as LessThanOrEqualFilter; + expect(lessThanOrEqualFilter.field).toBe("age"); + expect(lessThanOrEqualFilter.value).toBe("65"); }); }); @@ -202,7 +214,17 @@ describe("parse - OR combination", () => { // Assert expect(result).toBeInstanceOf(OrFilter); - expect((result as OrFilter).filters.length).toBe(2); + const orFilter = result as OrFilter; + expect(orFilter.filters.length).toBe(2); + expect(orFilter.filters[0]).toBeInstanceOf(EqualsFilter); + const equalsFilter1 = orFilter.filters[0] as EqualsFilter; + expect(equalsFilter1.field).toBe("status"); + expect(equalsFilter1.value).toBe("active"); + + expect(orFilter.filters[1]).toBeInstanceOf(EqualsFilter); + const equalsFilter2 = orFilter.filters[1] as EqualsFilter; + expect(equalsFilter2.field).toBe("status"); + expect(equalsFilter2.value).toBe("pending"); }); }); From 081231851bcd411272150364bb1ebda69b721ca7 Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Thu, 9 Apr 2026 20:32:59 +0000 Subject: [PATCH 16/28] feat: add FilterLogic and FilterOptions for customizable filter parsing --- src/typescript/src/filterOptions.ts | 35 +++++++++ src/typescript/src/index.ts | 34 ++++---- src/typescript/src/parser.ts | 52 +++++++++++- src/typescript/test/parser.test.ts | 118 ++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 src/typescript/src/filterOptions.ts diff --git a/src/typescript/src/filterOptions.ts b/src/typescript/src/filterOptions.ts new file mode 100644 index 00000000..99e4f9d0 --- /dev/null +++ b/src/typescript/src/filterOptions.ts @@ -0,0 +1,35 @@ +/** + * Logic applied when combining multiple filter criteria. + * Mirrors the C# `FilterLogic` enum. + */ +export enum FilterLogic { + And = "and", + Or = "or", +} + +/** + * Options for customizing filter parsing behaviour. + * + * Mirrors the C# `FilterOptions` record, providing the same resilience / + * handling strategy at parse time. + * + * @example + * ```typescript + * // comma-separated parts will be combined with OR instead of AND + * const options = new FilterOptions({ logic: FilterLogic.Or }); + * const filter = parse("name=Batman,age=30", options); + * // → OrFilter([EqualsFilter("name","Batman"), EqualsFilter("age","30")]) + * ``` + */ +export class FilterOptions { + /** + * Logic to apply when combining multiple comma-separated filter criteria. + * + * Defaults to `FilterLogic.And`. + */ + readonly logic: FilterLogic; + + constructor({ logic = FilterLogic.And }: { logic?: FilterLogic } = {}) { + this.logic = logic; + } +} diff --git a/src/typescript/src/index.ts b/src/typescript/src/index.ts index 961655ee..5f886d1f 100644 --- a/src/typescript/src/index.ts +++ b/src/typescript/src/index.ts @@ -6,22 +6,24 @@ */ export { - IFilter, - EqualsFilter, - ContainsFilter, - StartsWithFilter, - EndsWithFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - AndFilter, - OrFilter, - NotFilter, -} from './expressions'; + IFilter, + EqualsFilter, + ContainsFilter, + StartsWithFilter, + EndsWithFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + AndFilter, + OrFilter, + NotFilter, +} from "./expressions"; -export { FilterBuilder, FieldBuilder } from './builder'; +export { FilterBuilder, FieldBuilder } from "./builder"; -export { parse } from './parser'; +export { parse } from "./parser"; -export { toDict, toJson, fromDict } from './serializers'; +export { FilterLogic, FilterOptions } from "./filterOptions"; + +export { toDict, toJson, fromDict } from "./serializers"; diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index 2b3d15e5..51ae8059 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -29,6 +29,7 @@ import { OrFilter, StartsWithFilter, } from "./expressions"; +import { FilterLogic, FilterOptions } from "./filterOptions"; const RANGE_PATTERN = /^(?[[\]])(?.*?)\s*TO\s*(?.*?)(?[[\]])$/; @@ -36,6 +37,46 @@ const WILDCARD_CHAR = "*"; const NEGATION_CHAR = "!"; const MIN_WILDCARD_LENGTH = 2; +function decodeQueryComponent(value: string): string { + const normalized = value.replace(/\+/g, " "); + + let result: string; + try { + result = decodeURIComponent(normalized); + } catch { + result = normalized; + } + + return result; +} + +function normalizeExpression(expression: string): string { + const withoutPrefix = expression.startsWith("?") + ? expression.slice(1) + : expression; + const queryParts = withoutPrefix + .split("&") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + if (queryParts.length <= 1) { + return decodeQueryComponent(withoutPrefix); + } + + const normalizedParts = queryParts.map((part) => { + const eqIndex = part.indexOf("="); + if (eqIndex === -1) { + return decodeQueryComponent(part); + } + + const field = decodeQueryComponent(part.slice(0, eqIndex)); + const value = decodeQueryComponent(part.slice(eqIndex + 1)); + return `${field}=${value}`; + }); + + return normalizedParts.join(","); +} + function parseRange(field: string, value: string): IFilter { const match = RANGE_PATTERN.exec(value); if (!match || !match.groups) { @@ -159,7 +200,7 @@ function splitAndParts(expression: string): string[] { return parts; } -function parseAndExpression(expression: string): IFilter { +function parseAndExpression(expression: string, options?: FilterOptions): IFilter { const parts = splitAndParts(expression); if (parts.length <= 1) { @@ -184,21 +225,24 @@ function parseAndExpression(expression: string): IFilter { return parseValueExpression(inheritedField, trimmed); }); - return new AndFilter(filters); + const logic = options?.logic ?? FilterLogic.And; + return logic === FilterLogic.Or ? new OrFilter(filters) : new AndFilter(filters); } /** * Parse a DataFilters query string into an IFilter. * * @param expression - A filter expression string, e.g. `"name=*bat*,age=[18,]"`. + * @param options - Optional parsing options that control how multiple criteria are combined. * @returns An IFilter representing the parsed expression. * @throws {Error} If the expression cannot be parsed. */ -export function parse(expression: string): IFilter { +export function parse(expression: string, options?: FilterOptions): IFilter { if (!expression || !expression.trim()) { throw new Error("Expression must not be empty"); } const trimmed = expression.trim(); - return parseAndExpression(trimmed); + const normalizedExpression = normalizeExpression(trimmed); + return parseAndExpression(normalizedExpression, options); } diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index ea1f2553..df20dd47 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -13,6 +13,7 @@ import { StartsWithFilter, } from "../src/expressions"; import { parse } from "../src/parser"; +import { FilterLogic, FilterOptions } from "../src/filterOptions"; describe("parse - equals", () => { it("should parse simple equals expression", () => { @@ -272,3 +273,120 @@ describe("parse - parameterized", () => { }, ); }); + +describe("parse - FilterOptions", () => { + it("should use AndFilter by default (no options)", () => { + // Arrange + const expression = "name=Batman,age=30"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + }); + + it("should use AndFilter when options.logic is And", () => { + // Arrange + const expression = "name=Batman,age=30"; + const options = new FilterOptions({ logic: FilterLogic.And }); + + // Act + const result: IFilter = parse(expression, options); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const andFilter = result as AndFilter; + expect(andFilter.filters.length).toBe(2); + expect(andFilter.filters[0]).toBeInstanceOf(EqualsFilter); + expect(andFilter.filters[1]).toBeInstanceOf(EqualsFilter); + }); + + it("should use OrFilter when options.logic is Or", () => { + // Arrange + const expression = "name=Batman&age=30"; + const options = new FilterOptions({ logic: FilterLogic.Or }); + + // Act + const result: IFilter = parse(expression, options); + + // Assert + expect(result).toBeInstanceOf(OrFilter); + const orFilter = result as OrFilter; + expect(orFilter.filters.length).toBe(2); + expect(orFilter.filters[0]).toBeInstanceOf(EqualsFilter); + expect(orFilter.filters[1]).toBeInstanceOf(EqualsFilter); + }); + + it("should parse query string with '&' as AndFilter by default", () => { + // Arrange + const expression = "name=*bat*&age=[18 TO *["; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const andFilter = result as AndFilter; + expect(andFilter.filters.length).toBe(2); + + expect(andFilter.filters[0]).toBeInstanceOf(ContainsFilter); + const containsFilter = andFilter.filters[0] as ContainsFilter; + expect(containsFilter.field).toBe("name"); + expect(containsFilter.value).toBe("bat"); + + expect(andFilter.filters[1]).toBeInstanceOf(GreaterThanOrEqualFilter); + const greaterThanOrEqualFilter = andFilter + .filters[1] as GreaterThanOrEqualFilter; + expect(greaterThanOrEqualFilter.field).toBe("age"); + expect(greaterThanOrEqualFilter.value).toBe("18"); + }); + + it("should parse URL-encoded query string with '&' as AndFilter", () => { + // Arrange + const expression = "name=%2Abat%2A&age=%5B18%20TO%20*%5B"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const andFilter = result as AndFilter; + expect(andFilter.filters.length).toBe(2); + + expect(andFilter.filters[0]).toBeInstanceOf(ContainsFilter); + const containsFilter = andFilter.filters[0] as ContainsFilter; + expect(containsFilter.field).toBe("name"); + expect(containsFilter.value).toBe("bat"); + + expect(andFilter.filters[1]).toBeInstanceOf(GreaterThanOrEqualFilter); + const greaterThanOrEqualFilter = andFilter + .filters[1] as GreaterThanOrEqualFilter; + expect(greaterThanOrEqualFilter.field).toBe("age"); + expect(greaterThanOrEqualFilter.value).toBe("18"); + }); + + it("should default logic to And when constructed without arguments", () => { + // Arrange + const options = new FilterOptions(); + + // Assert + expect(options.logic).toBe(FilterLogic.And); + }); + + it("should apply Or logic on inherited-field comma parts", () => { + // Arrange + const expression = "name=*an,bat*"; + const options = new FilterOptions({ logic: FilterLogic.Or }); + + // Act + const result: IFilter = parse(expression, options); + + // Assert + expect(result).toBeInstanceOf(OrFilter); + const orFilter = result as AndFilter; + expect(orFilter.filters.length).toBe(2); + expect(orFilter.filters[0]).toBeInstanceOf(EndsWithFilter); + expect(orFilter.filters[1]).toBeInstanceOf(StartsWithFilter); + }); +}); From b377ce6a71207898da6c29d839674bd0eac55a2c Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Thu, 9 Apr 2026 20:42:45 +0000 Subject: [PATCH 17/28] chore(config): add Jest configuration for TypeScript tests and update ESLint overrides --- src/typescript/.eslintrc.js | 35 ++++++++++++++++++------------- src/typescript/test/tsconfig.json | 10 +++++++++ 2 files changed, 30 insertions(+), 15 deletions(-) create mode 100644 src/typescript/test/tsconfig.json diff --git a/src/typescript/.eslintrc.js b/src/typescript/.eslintrc.js index 25fe1fb5..331a5c1d 100644 --- a/src/typescript/.eslintrc.js +++ b/src/typescript/.eslintrc.js @@ -1,17 +1,22 @@ module.exports = { - root: true, - parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - ], - env: { - node: true, - es2019: true, - }, - rules: { - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/explicit-function-return-type': 'warn', - }, + root: true, + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint"], + extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + env: { + node: true, + es2019: true, + }, + overrides: [ + { + files: ["test/**/*.ts"], + env: { + jest: true, + }, + }, + ], + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/explicit-function-return-type": "warn", + }, }; diff --git a/src/typescript/test/tsconfig.json b/src/typescript/test/tsconfig.json new file mode 100644 index 00000000..0fdc9b4b --- /dev/null +++ b/src/typescript/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "types": ["jest", "node"] + }, + "include": ["./**/*.ts", "../src/**/*.ts"], + "exclude": ["../dist", "../node_modules"] +} From ef644e53183fea6a48129799ac71b030f2cb9c41 Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Thu, 9 Apr 2026 21:11:27 +0000 Subject: [PATCH 18/28] refactor(docs): update FilterOptions documentation update documentation and examples for clarity on filter criteria combination --- src/typescript/src/filterOptions.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/typescript/src/filterOptions.ts b/src/typescript/src/filterOptions.ts index 99e4f9d0..e2f95749 100644 --- a/src/typescript/src/filterOptions.ts +++ b/src/typescript/src/filterOptions.ts @@ -9,21 +9,19 @@ export enum FilterLogic { /** * Options for customizing filter parsing behaviour. - * - * Mirrors the C# `FilterOptions` record, providing the same resilience / - * handling strategy at parse time. + * Allows specifying how multiple criteria separated by `&` should be combined (AND vs OR). * * @example * ```typescript * // comma-separated parts will be combined with OR instead of AND * const options = new FilterOptions({ logic: FilterLogic.Or }); - * const filter = parse("name=Batman,age=30", options); + * const filter = parse("name=Batman&age=30", options); * // → OrFilter([EqualsFilter("name","Batman"), EqualsFilter("age","30")]) * ``` */ export class FilterOptions { /** - * Logic to apply when combining multiple comma-separated filter criteria. + * Logic to apply when combining multiple filter criteria separated by `&` delimiter. * * Defaults to `FilterLogic.And`. */ From 95a2b808ee1d519b78348242d0fd39834e732f84 Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Thu, 9 Apr 2026 21:40:49 +0000 Subject: [PATCH 19/28] fix(tests): update FilterOptions tests to use '&' --- src/typescript/test/parser.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index df20dd47..c7759a76 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -277,7 +277,7 @@ describe("parse - parameterized", () => { describe("parse - FilterOptions", () => { it("should use AndFilter by default (no options)", () => { // Arrange - const expression = "name=Batman,age=30"; + const expression = "name=Batman&age=30"; // Act const result: IFilter = parse(expression); @@ -288,7 +288,7 @@ describe("parse - FilterOptions", () => { it("should use AndFilter when options.logic is And", () => { // Arrange - const expression = "name=Batman,age=30"; + const expression = "name=Batman&age=30"; const options = new FilterOptions({ logic: FilterLogic.And }); // Act @@ -299,7 +299,14 @@ describe("parse - FilterOptions", () => { const andFilter = result as AndFilter; expect(andFilter.filters.length).toBe(2); expect(andFilter.filters[0]).toBeInstanceOf(EqualsFilter); + const left = andFilter.filters[0] as EqualsFilter; + expect(left.field).toBe("name"); + expect(left.value).toBe("Batman"); + expect(andFilter.filters[1]).toBeInstanceOf(EqualsFilter); + const right = andFilter.filters[1] as EqualsFilter; + expect(right.field).toBe("age"); + expect(right.value).toBe("30"); }); it("should use OrFilter when options.logic is Or", () => { @@ -315,7 +322,14 @@ describe("parse - FilterOptions", () => { const orFilter = result as OrFilter; expect(orFilter.filters.length).toBe(2); expect(orFilter.filters[0]).toBeInstanceOf(EqualsFilter); + const left = orFilter.filters[0] as EqualsFilter; + expect(left.field).toBe("name"); + expect(left.value).toBe("Batman"); + expect(orFilter.filters[1]).toBeInstanceOf(EqualsFilter); + const right = orFilter.filters[1] as EqualsFilter; + expect(right.field).toBe("age"); + expect(right.value).toBe("30"); }); it("should parse query string with '&' as AndFilter by default", () => { From 69542c394038b3bf1be235d9ce3f82cea945fff2 Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Thu, 9 Apr 2026 21:41:38 +0000 Subject: [PATCH 20/28] feat(devcontainer): add zsh plugins for enhanced shell functionality --- .devcontainer/typescript/devcontainer.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.devcontainer/typescript/devcontainer.json b/.devcontainer/typescript/devcontainer.json index ea298178..8275c617 100644 --- a/.devcontainer/typescript/devcontainer.json +++ b/.devcontainer/typescript/devcontainer.json @@ -5,7 +5,9 @@ "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/copilot-cli": {}, - "ghcr.io/nils-geistmann/devcontainers-features/zsh": {}, + "ghcr.io/nils-geistmann/devcontainers-features/zsh": { + "plugins": "git npm gh git-prompt branch" + }, "ghcr.io/devcontainers-extra/features/jest": {} }, "customizations": { From 00b8862a5e41db8be12b7947735100b47aed3623 Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Thu, 9 Apr 2026 23:33:44 +0000 Subject: [PATCH 21/28] feat(editorconfig): add .editconfig --- src/typescript/.editorconfig | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/typescript/.editorconfig diff --git a/src/typescript/.editorconfig b/src/typescript/.editorconfig new file mode 100644 index 00000000..88a01b20 --- /dev/null +++ b/src/typescript/.editorconfig @@ -0,0 +1,6 @@ +[{*.ts,*.js}] +indent_style = space +indent_size = 2 + +[*.ts] +trim_trailing_whitespace = true \ No newline at end of file From a5249597fa41d15229718eb3b82e2c347da02e22 Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Thu, 9 Apr 2026 23:35:14 +0000 Subject: [PATCH 22/28] refactor(typescript): improve expression handling and add support for escaped characters - enhanced the parsing logic to correctly handle escaped characters in filter expressions. - updated the `normalizeExpression` function to utilize a new `splitByUnescapedSeparator` method for better separation of expressions. - improved the `parseValueExpression` function to handle leading and trailing wildcards more effectively. - refactored range parsing to ensure proper handling of different range formats. - added comprehensive unit tests to cover new functionality and edge cases, including escaped symbols and various logical combinations. --- src/typescript/src/notFilter.ts | 4 +- src/typescript/src/parser.ts | 443 ++++++++++------ src/typescript/test/parser.test.ts | 825 ++++++++++++++++------------- 3 files changed, 738 insertions(+), 534 deletions(-) diff --git a/src/typescript/src/notFilter.ts b/src/typescript/src/notFilter.ts index 19930250..fa06253b 100644 --- a/src/typescript/src/notFilter.ts +++ b/src/typescript/src/notFilter.ts @@ -1,10 +1,10 @@ -import { IFilter } from './iFilter'; +import { IFilter } from "./iFilter"; /** Negates a filter expression. */ export class NotFilter implements IFilter { public constructor(public readonly filter: IFilter) {} public toDict(): Record { - return { logic: 'not', filters: [this.filter.toDict()] }; + return { logic: "not", filters: [this.filter.toDict()] }; } } diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index 51ae8059..c85716f9 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -11,238 +11,343 @@ * - `field=[min TO *[` → GreaterThanOrEqualFilter * - `field=]* TO max]` → LessThanOrEqualFilter * - `field=]min TO max[` → AndFilter(gt, lt) - * - `expr1,expr2` → AndFilter of multiple criteria - * - `expr1|expr2` → OrFilter of multiple criteria + * - `field=expr1,expr2` → AndFilter(expr1, expr2) + * - `field=expr1|expr2` → OrFilter(expr1, expr2) */ import { - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - IFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + IFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, } from "./expressions"; import { FilterLogic, FilterOptions } from "./filterOptions"; const RANGE_PATTERN = - /^(?[[\]])(?.*?)\s*TO\s*(?.*?)(?[[\]])$/; + /^(?[[\]])(?.*?)\s*TO\s*(?.*?)(?[[\]])$/; const WILDCARD_CHAR = "*"; const NEGATION_CHAR = "!"; +const ESCAPE_CHAR = "\\"; const MIN_WILDCARD_LENGTH = 2; function decodeQueryComponent(value: string): string { - const normalized = value.replace(/\+/g, " "); + const normalized = value.replace(/\+/g, " "); - let result: string; - try { - result = decodeURIComponent(normalized); - } catch { - result = normalized; - } + let result: string; + try { + result = decodeURIComponent(normalized); + } catch { + result = normalized; + } - return result; + return result; } -function normalizeExpression(expression: string): string { - const withoutPrefix = expression.startsWith("?") - ? expression.slice(1) - : expression; - const queryParts = withoutPrefix - .split("&") - .map((part) => part.trim()) - .filter((part) => part.length > 0); - - if (queryParts.length <= 1) { - return decodeQueryComponent(withoutPrefix); - } +function unescapeValue(value: string): string { + let result = ""; - const normalizedParts = queryParts.map((part) => { - const eqIndex = part.indexOf("="); - if (eqIndex === -1) { - return decodeQueryComponent(part); - } + for (let i = 0; i < value.length; i++) { + const char = value[i]; - const field = decodeQueryComponent(part.slice(0, eqIndex)); - const value = decodeQueryComponent(part.slice(eqIndex + 1)); - return `${field}=${value}`; - }); + if (char === ESCAPE_CHAR) { + const nextChar = value[i + 1]; + if (nextChar !== undefined) { + result += nextChar; + i++; + } else { + result += char; + } + } else { + result += char; + } + } - return normalizedParts.join(","); + return result; } -function parseRange(field: string, value: string): IFilter { - const match = RANGE_PATTERN.exec(value); - if (!match || !match.groups) { - throw new Error(`Invalid range expression: '${value}'`); - } +function isEscaped(value: string, index: number): boolean { + let backslashCount = 0; + let cursor = index - 1; - const openBracket = match.groups["open"]; - const closeBracket = match.groups["close"]; - const minVal = match.groups["min"]; - const maxVal = match.groups["max"]; - - const filters: IFilter[] = []; - const trimmedMin = minVal?.trim() ?? ""; - const trimmedMax = maxVal?.trim() ?? ""; - const hasMin = trimmedMin !== "" && trimmedMin !== WILDCARD_CHAR; - const hasMax = trimmedMax !== "" && trimmedMax !== WILDCARD_CHAR; - - if (hasMin) { - if (openBracket === "[") { - filters.push(new GreaterThanOrEqualFilter(field, trimmedMin)); - } else { - filters.push(new GreaterThanFilter(field, trimmedMin)); - } - } + while (cursor >= 0 && value[cursor] === ESCAPE_CHAR) { + backslashCount++; + cursor--; + } - if (hasMax) { - if (closeBracket === "]") { - filters.push(new LessThanOrEqualFilter(field, trimmedMax)); - } else { - filters.push(new LessThanFilter(field, trimmedMax)); - } - } + return backslashCount % 2 === 1; +} + +function findFirstUnescaped(value: string, target: string): number { + let escaped = false; + let result = -1; - if (filters.length === 0) { - throw new Error( - `Range expression must have at least one bound: '${value}'`, - ); + for (let i = 0; i < value.length && result === -1; i++) { + const char = value[i]; + + if (escaped) { + escaped = false; + } else if (char === ESCAPE_CHAR) { + escaped = true; + } else if (char === target) { + result = i; } + } - const result: IFilter = - filters.length === 1 ? filters[0] : new AndFilter(filters); - return result; + return result; } -function parseValueExpression(field: string, value: string): IFilter { - const negated = value.startsWith(NEGATION_CHAR); - const actualValue = negated ? value.slice(1) : value; - - let result: IFilter; - - if (RANGE_PATTERN.test(actualValue)) { - result = parseRange(field, actualValue); - } else if ( - actualValue.startsWith(WILDCARD_CHAR) && - actualValue.endsWith(WILDCARD_CHAR) && - actualValue.length > MIN_WILDCARD_LENGTH - ) { - result = new ContainsFilter(field, actualValue.slice(1, -1)); - } else if (actualValue.startsWith(WILDCARD_CHAR)) { - result = new EndsWithFilter(field, actualValue.slice(1)); - } else if (actualValue.endsWith(WILDCARD_CHAR)) { - result = new StartsWithFilter(field, actualValue.slice(0, -1)); +function splitByUnescapedSeparator( + expression: string, + separator: string, +): string[] { + const parts: string[] = []; + let depth = 0; + let escaped = false; + let current = ""; + + for (const char of expression) { + if (escaped) { + current += char; + escaped = false; + } else if (char === ESCAPE_CHAR) { + current += char; + escaped = true; + } else if (char === "(" || char === "[") { + depth++; + current += char; + } else if (char === ")" || char === "]") { + depth--; + current += char; + } else if (char === separator && depth === 0) { + parts.push(current); + current = ""; } else { - result = new EqualsFilter(field, actualValue); + current += char; } + } + + if (current) { + parts.push(current); + } - return negated ? new NotFilter(result) : result; + return parts; } -function parseSingle(expression: string): IFilter { - const eqIndex = expression.indexOf("="); - if (eqIndex === -1) { - throw new Error( - `Invalid filter expression: '${expression}'. Expected 'field=value'.`, - ); - } +function hasLeadingWildcard(value: string): boolean { + return value.startsWith(WILDCARD_CHAR); +} - const field = expression.slice(0, eqIndex).trim(); - const value = expression.slice(eqIndex + 1).trim(); +function hasTrailingWildcard(value: string): boolean { + const lastIndex = value.length - 1; + const hasWildcard = value.endsWith(WILDCARD_CHAR); + let result = false; - if (!field) { - throw new Error( - `Field name must not be empty in expression: '${expression}'`, - ); - } + if (hasWildcard && lastIndex >= 0) { + result = !isEscaped(value, lastIndex); + } - const orParts = value.split("|"); - const result: IFilter = - orParts.length > 1 - ? new OrFilter( - orParts.map((v) => parseValueExpression(field, v.trim())), - ) - : parseValueExpression(field, value); - return result; + return result; } -function splitAndParts(expression: string): string[] { - const parts: string[] = []; - let depth = 0; - let current = ""; - - for (const char of expression) { - if (char === "(" || char === "[") { - depth++; - current += char; - } else if (char === ")" || char === "]") { - depth--; - current += char; - } else if (char === "," && depth === 0) { - parts.push(current); - current = ""; - } else { - current += char; - } +function normalizeExpression(expression: string): string { + const withoutPrefix = expression.startsWith("?") + ? expression.slice(1) + : expression; + const queryParts = splitByUnescapedSeparator(withoutPrefix, "&") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + let result: string; + + if (queryParts.length <= 1) { + result = decodeQueryComponent(withoutPrefix); + } else { + const normalizedParts = queryParts.map((part) => { + const eqIndex = part.indexOf("="); + let normalizedPart: string; + + if (eqIndex === -1) { + normalizedPart = decodeQueryComponent(part); + } else { + const field = decodeQueryComponent(part.slice(0, eqIndex)); + const value = decodeQueryComponent(part.slice(eqIndex + 1)); + normalizedPart = `${field}=${value}`; + } + + return normalizedPart; + }); + + result = normalizedParts.join(","); + } + + return result; +} + +function parseRange(field: string, value: string): IFilter { + const match = RANGE_PATTERN.exec(value); + if (!match || !match.groups) { + throw new Error(`Invalid range expression: '${value}'`); + } + + const openBracket = match.groups["open"]; + const closeBracket = match.groups["close"]; + const minVal = match.groups["min"]; + const maxVal = match.groups["max"]; + + const filters: IFilter[] = []; + const trimmedMin = minVal?.trim() ?? ""; + const trimmedMax = maxVal?.trim() ?? ""; + const hasMin = trimmedMin !== "" && trimmedMin !== WILDCARD_CHAR; + const hasMax = trimmedMax !== "" && trimmedMax !== WILDCARD_CHAR; + + if (hasMin) { + if (openBracket === "[") { + filters.push(new GreaterThanOrEqualFilter(field, trimmedMin)); + } else { + filters.push(new GreaterThanFilter(field, trimmedMin)); } + } - if (current) { - parts.push(current); + if (hasMax) { + if (closeBracket === "]") { + filters.push(new LessThanOrEqualFilter(field, trimmedMax)); + } else { + filters.push(new LessThanFilter(field, trimmedMax)); } + } + + if (filters.length === 0) { + throw new Error( + `Range expression must have at least one bound: '${value}'`, + ); + } - return parts; + const result: IFilter = + filters.length === 1 ? filters[0] : new AndFilter(filters); + return result; } -function parseAndExpression(expression: string, options?: FilterOptions): IFilter { - const parts = splitAndParts(expression); +function parseValueExpression(field: string, value: string): IFilter { + const negated = value.startsWith(NEGATION_CHAR); + const actualValue = negated ? value.slice(1) : value; + const startsWithWildcard = hasLeadingWildcard(actualValue); + const endsWithWildcard = hasTrailingWildcard(actualValue); + + let result: IFilter; + + if (RANGE_PATTERN.test(actualValue)) { + result = parseRange(field, actualValue); + } else if ( + startsWithWildcard && + endsWithWildcard && + actualValue.length > MIN_WILDCARD_LENGTH + ) { + result = new ContainsFilter(field, unescapeValue(actualValue.slice(1, -1))); + } else if (startsWithWildcard) { + result = new EndsWithFilter(field, unescapeValue(actualValue.slice(1))); + } else if (endsWithWildcard) { + result = new StartsWithFilter( + field, + unescapeValue(actualValue.slice(0, -1)), + ); + } else { + result = new EqualsFilter(field, unescapeValue(actualValue)); + } + + return negated ? new NotFilter(result) : result; +} - if (parts.length <= 1) { - return parseSingle(expression); - } +function parseSingle(expression: string): IFilter { + const eqIndex = findFirstUnescaped(expression, "="); + if (eqIndex === -1) { + throw new Error( + `Invalid filter expression: '${expression}'. Expected 'field=value'.`, + ); + } + + const field = unescapeValue(expression.slice(0, eqIndex).trim()); + const value = expression.slice(eqIndex + 1).trim(); + + if (!field) { + throw new Error( + `Field name must not be empty in expression: '${expression}'`, + ); + } + + const orParts = splitByUnescapedSeparator(value, "|"); + const result: IFilter = + orParts.length > 1 + ? new OrFilter(orParts.map((v) => parseValueExpression(field, v.trim()))) + : parseValueExpression(field, value); + return result; +} + +function splitAndParts(expression: string): string[] { + return splitByUnescapedSeparator(expression, ","); +} + +function parseAndExpression( + expression: string, + options?: FilterOptions, +): IFilter { + const parts = splitAndParts(expression); + let result: IFilter; + if (parts.length <= 1) { + result = parseSingle(expression); + } else { let inheritedField: string | null = null; const filters: IFilter[] = parts.map((p) => { - const trimmed = p.trim(); - if (trimmed.includes("=")) { - const eqIndex = trimmed.indexOf("="); - inheritedField = trimmed.slice(0, eqIndex).trim(); - return parseSingle(trimmed); - } - + const trimmed = p.trim(); + const eqIndex = findFirstUnescaped(trimmed, "="); + let parsedFilter: IFilter; + + if (eqIndex !== -1) { + inheritedField = trimmed.slice(0, eqIndex).trim(); + parsedFilter = parseSingle(trimmed); + } else { if (!inheritedField) { - throw new Error( - `Cannot determine field for expression part: '${trimmed}'`, - ); + throw new Error( + `Cannot determine field for expression part: '${trimmed}'`, + ); } - return parseValueExpression(inheritedField, trimmed); + parsedFilter = parseValueExpression(inheritedField, trimmed); + } + + return parsedFilter; }); const logic = options?.logic ?? FilterLogic.And; - return logic === FilterLogic.Or ? new OrFilter(filters) : new AndFilter(filters); + result = + logic === FilterLogic.Or ? new OrFilter(filters) : new AndFilter(filters); + } + + return result; } /** * Parse a DataFilters query string into an IFilter. * - * @param expression - A filter expression string, e.g. `"name=*bat*,age=[18,]"`. + * @param expression - A filter expression string, e.g. `"name=*bat*` or `"name=*bat*&age=[18 TO *["`. * @param options - Optional parsing options that control how multiple criteria are combined. * @returns An IFilter representing the parsed expression. * @throws {Error} If the expression cannot be parsed. */ export function parse(expression: string, options?: FilterOptions): IFilter { - if (!expression || !expression.trim()) { - throw new Error("Expression must not be empty"); - } + if (!expression || !expression.trim()) { + throw new Error("Expression must not be empty"); + } - const trimmed = expression.trim(); - const normalizedExpression = normalizeExpression(trimmed); - return parseAndExpression(normalizedExpression, options); + const trimmed = expression.trim(); + const normalizedExpression = normalizeExpression(trimmed); + return parseAndExpression(normalizedExpression, options); } diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index c7759a76..cdf76fb7 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -1,406 +1,505 @@ import { - IFilter, - AndFilter, - ContainsFilter, - EndsWithFilter, - EqualsFilter, - GreaterThanFilter, - GreaterThanOrEqualFilter, - LessThanFilter, - LessThanOrEqualFilter, - NotFilter, - OrFilter, - StartsWithFilter, + IFilter, + AndFilter, + ContainsFilter, + EndsWithFilter, + EqualsFilter, + GreaterThanFilter, + GreaterThanOrEqualFilter, + LessThanFilter, + LessThanOrEqualFilter, + NotFilter, + OrFilter, + StartsWithFilter, } from "../src/expressions"; import { parse } from "../src/parser"; import { FilterLogic, FilterOptions } from "../src/filterOptions"; describe("parse - equals", () => { - it("should parse simple equals expression", () => { - // Arrange - const expression = "name=Batman"; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(EqualsFilter); - const f = result as EqualsFilter; - expect(f.field).toBe("name"); - expect(f.value).toBe("Batman"); - }); + const cases: [string, unknown, { field: string; value: unknown }][] = [ + ["name=Batman", EqualsFilter, { field: "name", value: "Batman" }], + ["name=*bat*", ContainsFilter, { field: "name", value: "bat" }], + ["name=Bat*", StartsWithFilter, { field: "name", value: "Bat" }], + ["name=Bat\\*", EqualsFilter, { field: "name", value: "Bat*" }], + ]; + + test.each(cases)( + 'parse("%s") should return expected type with correct field and value', + (expression, expectedType, expected) => { + // Arrange / Act + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf( + expectedType as unknown as new () => unknown, + ); + const f = result as unknown as { field: string; value: unknown }; + expect(f.field).toBe(expected.field); + expect(f.value).toBe(expected.value); + }, + ); }); describe("parse - contains", () => { - it("should parse contains expression", () => { - // Arrange - const expression = "name=*bat*"; + it("should parse contains expression", () => { + // Arrange + const expression = "name=*bat*"; - // Act - const result: IFilter = parse(expression); + // Act + const result: IFilter = parse(expression); - // Assert - expect(result).toBeInstanceOf(ContainsFilter); - expect((result as ContainsFilter).value).toBe("bat"); - }); + // Assert + expect(result).toBeInstanceOf(ContainsFilter); + expect((result as ContainsFilter).value).toBe("bat"); + }); }); describe("parse - startsWith", () => { - it("should parse starts-with expression", () => { - // Arrange - const expression = "name=Bat*"; + it("should parse starts-with expression", () => { + // Arrange + const expression = "name=Bat*"; - // Act - const result: IFilter = parse(expression); + // Act + const result: IFilter = parse(expression); - // Assert - expect(result).toBeInstanceOf(StartsWithFilter); - expect((result as StartsWithFilter).value).toBe("Bat"); - }); + // Assert + expect(result).toBeInstanceOf(StartsWithFilter); + expect((result as StartsWithFilter).value).toBe("Bat"); + }); }); describe("parse - endsWith", () => { - it("should parse ends-with expression", () => { - // Arrange - const expression = "name=*man"; + it("should parse ends-with expression", () => { + // Arrange + const expression = "name=*man"; - // Act - const result: IFilter = parse(expression); + // Act + const result: IFilter = parse(expression); - // Assert - expect(result).toBeInstanceOf(EndsWithFilter); - expect((result as EndsWithFilter).value).toBe("man"); - }); + // Assert + expect(result).toBeInstanceOf(EndsWithFilter); + expect((result as EndsWithFilter).value).toBe("man"); + }); }); describe("parse - negation", () => { - it("should parse not-equals expression", () => { - // Arrange - const expression = "name=!Batman"; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(NotFilter); - const inner = (result as NotFilter).filter; - expect(inner).toBeInstanceOf(EqualsFilter); - expect((inner as EqualsFilter).value).toBe("Batman"); - }); + it("should parse not-equals expression", () => { + // Arrange + const expression = "name=!Batman"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(NotFilter); + const inner = (result as NotFilter).filter; + expect(inner).toBeInstanceOf(EqualsFilter); + expect((inner as EqualsFilter).value).toBe("Batman"); + }); }); describe("parse - range", () => { - it("should parse closed range [min TO max]", () => { - // Arrange - const expression = "age=[18 TO 65]"; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(AndFilter); - const f = result as AndFilter; - expect(f.filters[0]).toBeInstanceOf(GreaterThanOrEqualFilter); - expect(f.filters[1]).toBeInstanceOf(LessThanOrEqualFilter); - }); - - it("should parse open range ]min TO max[", () => { - // Arrange - const expression = "age=]18 TO 65["; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(AndFilter); - const f = result as AndFilter; - expect(f.filters[0]).toBeInstanceOf(GreaterThanFilter); - - const greaterThanFilter = f.filters[0] as GreaterThanFilter; - expect(greaterThanFilter.field).toBe("age"); - expect(greaterThanFilter.value).toBe("18"); - - expect(f.filters[1]).toBeInstanceOf(LessThanFilter); - const lessThanFilter = f.filters[1] as LessThanFilter; - expect(lessThanFilter.field).toBe("age"); - expect(lessThanFilter.value).toBe("65"); - }); - - it("should parse half-open range [min TO *[", () => { - // Arrange - const expression = "age=[18 TO *["; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); - const greaterThanOrEqualFilter = result as GreaterThanOrEqualFilter; - expect(greaterThanOrEqualFilter.field).toBe("age"); - expect(greaterThanOrEqualFilter.value).toBe("18"); - }); - - it("should parse half-open range ]* TO max]", () => { - // Arrange - const expression = "age=]* TO 65]"; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(LessThanOrEqualFilter); - const lessThanOrEqualFilter = result as LessThanOrEqualFilter; - expect(lessThanOrEqualFilter.field).toBe("age"); - expect(lessThanOrEqualFilter.value).toBe("65"); - }); + it("should parse closed range [min TO max]", () => { + // Arrange + const expression = "age=[18 TO 65]"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const f = result as AndFilter; + expect(f.filters[0]).toBeInstanceOf(GreaterThanOrEqualFilter); + expect(f.filters[1]).toBeInstanceOf(LessThanOrEqualFilter); + }); + + it("should parse open range ]min TO max[", () => { + // Arrange + const expression = "age=]18 TO 65["; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const f = result as AndFilter; + expect(f.filters[0]).toBeInstanceOf(GreaterThanFilter); + + const greaterThanFilter = f.filters[0] as GreaterThanFilter; + expect(greaterThanFilter.field).toBe("age"); + expect(greaterThanFilter.value).toBe("18"); + + expect(f.filters[1]).toBeInstanceOf(LessThanFilter); + const lessThanFilter = f.filters[1] as LessThanFilter; + expect(lessThanFilter.field).toBe("age"); + expect(lessThanFilter.value).toBe("65"); + }); + + it("should parse half-open range [min TO *[", () => { + // Arrange + const expression = "age=[18 TO *["; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); + const greaterThanOrEqualFilter = result as GreaterThanOrEqualFilter; + expect(greaterThanOrEqualFilter.field).toBe("age"); + expect(greaterThanOrEqualFilter.value).toBe("18"); + }); + + it("should parse half-open range ]* TO max]", () => { + // Arrange + const expression = "age=]* TO 65]"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(LessThanOrEqualFilter); + const lessThanOrEqualFilter = result as LessThanOrEqualFilter; + expect(lessThanOrEqualFilter.field).toBe("age"); + expect(lessThanOrEqualFilter.value).toBe("65"); + }); }); describe("parse - AND combination", () => { - it("should parse comma-separated expressions as AND", () => { - // Arrange - const expression = "name=*bat*,age=[18 TO *["; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(AndFilter); - const andFilter = result as AndFilter; - - expect(andFilter.filters.length).toBe(2); - expect(andFilter.filters[0]).toBeInstanceOf(ContainsFilter); - const containsFilter = andFilter.filters[0] as ContainsFilter; - expect(containsFilter.field).toBe("name"); - expect(containsFilter.value).toBe("bat"); - - expect(andFilter.filters[1]).toBeInstanceOf(GreaterThanOrEqualFilter); - const greatThanOrEqualToFilter = andFilter - .filters[1] as GreaterThanOrEqualFilter; - - expect(greatThanOrEqualToFilter.field).toBe("age"); - expect(greatThanOrEqualToFilter.value).toBe("18"); - }); - - it("should parse comma-separated expressions on same property as AND", () => { - // Arrange - const expression = "name=*an,bat*"; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(AndFilter); - const andFilter = result as AndFilter; - - expect(andFilter.filters.length).toBe(2); - expect(andFilter.filters[0]).toBeInstanceOf(EndsWithFilter); - const endsWithFilter = andFilter.filters[0] as EndsWithFilter; - expect(endsWithFilter.field).toBe("name"); - expect(endsWithFilter.value).toBe("an"); - - expect(andFilter.filters[1]).toBeInstanceOf(StartsWithFilter); - const startsWithFilter = andFilter.filters[1] as StartsWithFilter; - expect(startsWithFilter.field).toBe("name"); - expect(startsWithFilter.value).toBe("bat"); - }); + it("should parse expressions separated by & as AND", () => { + // Arrange + const expression = "name=*bat*&age=[18 TO *["; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const andFilter = result as AndFilter; + + expect(andFilter.filters.length).toBe(2); + expect(andFilter.filters[0]).toBeInstanceOf(ContainsFilter); + const containsFilter = andFilter.filters[0] as ContainsFilter; + expect(containsFilter.field).toBe("name"); + expect(containsFilter.value).toBe("bat"); + + expect(andFilter.filters[1]).toBeInstanceOf(GreaterThanOrEqualFilter); + const greatThanOrEqualToFilter = andFilter + .filters[1] as GreaterThanOrEqualFilter; + + expect(greatThanOrEqualToFilter.field).toBe("age"); + expect(greatThanOrEqualToFilter.value).toBe("18"); + }); + + it("should parse comma-separated expressions on same property as AND", () => { + // Arrange + const expression = "name=*an,bat*"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const andFilter = result as AndFilter; + + expect(andFilter.filters.length).toBe(2); + expect(andFilter.filters[0]).toBeInstanceOf(EndsWithFilter); + const endsWithFilter = andFilter.filters[0] as EndsWithFilter; + expect(endsWithFilter.field).toBe("name"); + expect(endsWithFilter.value).toBe("an"); + + expect(andFilter.filters[1]).toBeInstanceOf(StartsWithFilter); + const startsWithFilter = andFilter.filters[1] as StartsWithFilter; + expect(startsWithFilter.field).toBe("name"); + expect(startsWithFilter.value).toBe("bat"); + }); }); describe("parse - OR combination", () => { - it("should parse pipe-separated expressions as OR", () => { - // Arrange - const expression = "status=active|pending"; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(OrFilter); - const orFilter = result as OrFilter; - expect(orFilter.filters.length).toBe(2); - expect(orFilter.filters[0]).toBeInstanceOf(EqualsFilter); - const equalsFilter1 = orFilter.filters[0] as EqualsFilter; - expect(equalsFilter1.field).toBe("status"); - expect(equalsFilter1.value).toBe("active"); - - expect(orFilter.filters[1]).toBeInstanceOf(EqualsFilter); - const equalsFilter2 = orFilter.filters[1] as EqualsFilter; - expect(equalsFilter2.field).toBe("status"); - expect(equalsFilter2.value).toBe("pending"); - }); + it("should parse pipe-separated expressions as OR", () => { + // Arrange + const expression = "status=active|pending"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(OrFilter); + const orFilter = result as OrFilter; + expect(orFilter.filters.length).toBe(2); + expect(orFilter.filters[0]).toBeInstanceOf(EqualsFilter); + const left = orFilter.filters[0] as EqualsFilter; + expect(left.field).toBe("status"); + expect(left.value).toBe("active"); + + expect(orFilter.filters[1]).toBeInstanceOf(EqualsFilter); + const right = orFilter.filters[1] as EqualsFilter; + expect(right.field).toBe("status"); + expect(right.value).toBe("pending"); + }); + + it("should keep escaped pipe as literal value", () => { + // Arrange + const expression = "status=active\\|pending"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(EqualsFilter); + const equalsFilter = result as EqualsFilter; + expect(equalsFilter.field).toBe("status"); + expect(equalsFilter.value).toBe("active|pending"); + }); +}); + +describe("parse - escaped reserved symbols", () => { + it("should keep escaped top-level comma as literal", () => { + // Arrange + const expression = "name=foo\\,bar"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(EqualsFilter); + const equalsFilter = result as EqualsFilter; + expect(equalsFilter.field).toBe("name"); + expect(equalsFilter.value).toBe("foo,bar"); + }); + + it("should keep escaped ampersand as literal", () => { + // Arrange + const expression = "name=foo\\&bar"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(EqualsFilter); + const equalsFilter = result as EqualsFilter; + expect(equalsFilter.field).toBe("name"); + expect(equalsFilter.value).toBe("foo&bar"); + }); + + it("should not create NotFilter when negation char is escaped", () => { + // Arrange + const expression = "name=\\!abc"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(EqualsFilter); + const equalsFilter = result as EqualsFilter; + expect(equalsFilter.field).toBe("name"); + expect(equalsFilter.value).toBe("!abc"); + }); + + it("should parse escaped wildcard as literal value", () => { + // Arrange + const expression = "name=foo\\*"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(EqualsFilter); + const equalsFilter = result as EqualsFilter; + expect(equalsFilter.field).toBe("name"); + expect(equalsFilter.value).toBe("foo*"); + }); + + it("should parse escaped backslash as literal backslash", () => { + // Arrange + const expression = "name=foo\\\\bar"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(EqualsFilter); + const equalsFilter = result as EqualsFilter; + expect(equalsFilter.field).toBe("name"); + expect(equalsFilter.value).toBe("foo\\bar"); + }); }); describe("parse - errors", () => { - it("should throw on empty expression", () => { - // Arrange / Act / Assert - expect(() => parse("")).toThrow(); - }); - - it("should throw on whitespace-only expression", () => { - // Arrange / Act / Assert - expect(() => parse(" ")).toThrow(); - }); - - it("should throw on missing field", () => { - // Arrange / Act / Assert - expect(() => parse("=value")).toThrow(); - }); - - it("should throw on missing equals sign", () => { - // Arrange / Act / Assert - expect(() => parse("noequals")).toThrow(); - }); + it("should throw on empty expression", () => { + // Arrange / Act / Assert + expect(() => parse("")).toThrow(); + }); + + it("should throw on whitespace-only expression", () => { + // Arrange / Act / Assert + expect(() => parse(" ")).toThrow(); + }); + + it("should throw on missing field", () => { + // Arrange / Act / Assert + expect(() => parse("=value")).toThrow(); + }); + + it("should throw on missing equals sign", () => { + // Arrange / Act / Assert + expect(() => parse("noequals")).toThrow(); + }); }); describe("parse - parameterized", () => { - const cases: [string, unknown][] = [ - ["name=Batman", EqualsFilter], - ["name=*bat*", ContainsFilter], - ["name=Bat*", StartsWithFilter], - ["name=*man", EndsWithFilter], - ["name=!Batman", NotFilter], - ]; - - test.each(cases)( - 'parse("%s") should return expected type', - (expression, expectedType) => { - // Arrange / Act - const result = parse(expression); - - // Assert - expect(result).toBeInstanceOf( - expectedType as unknown as new () => unknown, - ); - }, - ); + const cases: [string, FilterOptions | undefined, unknown][] = [ + ["name=Batman", undefined, EqualsFilter], + ["name=*bat*", undefined, ContainsFilter], + ["name=Bat*", undefined, StartsWithFilter], + ["name=*man", undefined, EndsWithFilter], + ["name=!Batman", undefined, NotFilter], + ["name=!Batman&age=30", { logic: FilterLogic.Or }, OrFilter], + ["name=!Batman&age=30", undefined, AndFilter], + ]; + + test.each(cases)( + 'parse("%s" with options "%o") should return expected type', + (expression, options, expectedType) => { + // Arrange / Act + const result = parse(expression, options); + + // Assert + expect(result).toBeInstanceOf( + expectedType as unknown as new () => unknown, + ); + }, + ); }); describe("parse - FilterOptions", () => { - it("should use AndFilter by default (no options)", () => { - // Arrange - const expression = "name=Batman&age=30"; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(AndFilter); - }); - - it("should use AndFilter when options.logic is And", () => { - // Arrange - const expression = "name=Batman&age=30"; - const options = new FilterOptions({ logic: FilterLogic.And }); - - // Act - const result: IFilter = parse(expression, options); - - // Assert - expect(result).toBeInstanceOf(AndFilter); - const andFilter = result as AndFilter; - expect(andFilter.filters.length).toBe(2); - expect(andFilter.filters[0]).toBeInstanceOf(EqualsFilter); - const left = andFilter.filters[0] as EqualsFilter; - expect(left.field).toBe("name"); - expect(left.value).toBe("Batman"); - - expect(andFilter.filters[1]).toBeInstanceOf(EqualsFilter); - const right = andFilter.filters[1] as EqualsFilter; - expect(right.field).toBe("age"); - expect(right.value).toBe("30"); - }); - - it("should use OrFilter when options.logic is Or", () => { - // Arrange - const expression = "name=Batman&age=30"; - const options = new FilterOptions({ logic: FilterLogic.Or }); - - // Act - const result: IFilter = parse(expression, options); - - // Assert - expect(result).toBeInstanceOf(OrFilter); - const orFilter = result as OrFilter; - expect(orFilter.filters.length).toBe(2); - expect(orFilter.filters[0]).toBeInstanceOf(EqualsFilter); - const left = orFilter.filters[0] as EqualsFilter; - expect(left.field).toBe("name"); - expect(left.value).toBe("Batman"); - - expect(orFilter.filters[1]).toBeInstanceOf(EqualsFilter); - const right = orFilter.filters[1] as EqualsFilter; - expect(right.field).toBe("age"); - expect(right.value).toBe("30"); - }); - - it("should parse query string with '&' as AndFilter by default", () => { - // Arrange - const expression = "name=*bat*&age=[18 TO *["; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(AndFilter); - const andFilter = result as AndFilter; - expect(andFilter.filters.length).toBe(2); - - expect(andFilter.filters[0]).toBeInstanceOf(ContainsFilter); - const containsFilter = andFilter.filters[0] as ContainsFilter; - expect(containsFilter.field).toBe("name"); - expect(containsFilter.value).toBe("bat"); - - expect(andFilter.filters[1]).toBeInstanceOf(GreaterThanOrEqualFilter); - const greaterThanOrEqualFilter = andFilter - .filters[1] as GreaterThanOrEqualFilter; - expect(greaterThanOrEqualFilter.field).toBe("age"); - expect(greaterThanOrEqualFilter.value).toBe("18"); - }); - - it("should parse URL-encoded query string with '&' as AndFilter", () => { - // Arrange - const expression = "name=%2Abat%2A&age=%5B18%20TO%20*%5B"; - - // Act - const result: IFilter = parse(expression); - - // Assert - expect(result).toBeInstanceOf(AndFilter); - const andFilter = result as AndFilter; - expect(andFilter.filters.length).toBe(2); - - expect(andFilter.filters[0]).toBeInstanceOf(ContainsFilter); - const containsFilter = andFilter.filters[0] as ContainsFilter; - expect(containsFilter.field).toBe("name"); - expect(containsFilter.value).toBe("bat"); - - expect(andFilter.filters[1]).toBeInstanceOf(GreaterThanOrEqualFilter); - const greaterThanOrEqualFilter = andFilter - .filters[1] as GreaterThanOrEqualFilter; - expect(greaterThanOrEqualFilter.field).toBe("age"); - expect(greaterThanOrEqualFilter.value).toBe("18"); - }); - - it("should default logic to And when constructed without arguments", () => { - // Arrange - const options = new FilterOptions(); - - // Assert - expect(options.logic).toBe(FilterLogic.And); - }); - - it("should apply Or logic on inherited-field comma parts", () => { - // Arrange - const expression = "name=*an,bat*"; - const options = new FilterOptions({ logic: FilterLogic.Or }); - - // Act - const result: IFilter = parse(expression, options); - - // Assert - expect(result).toBeInstanceOf(OrFilter); - const orFilter = result as AndFilter; - expect(orFilter.filters.length).toBe(2); - expect(orFilter.filters[0]).toBeInstanceOf(EndsWithFilter); - expect(orFilter.filters[1]).toBeInstanceOf(StartsWithFilter); - }); + it("should use AndFilter by default (no options)", () => { + // Arrange + const expression = "name=Batman&age=30"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + }); + + it("should use AndFilter when options.logic is And", () => { + // Arrange + const expression = "name=Batman&age=30"; + const options = new FilterOptions({ logic: FilterLogic.And }); + + // Act + const result: IFilter = parse(expression, options); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const andFilter = result as AndFilter; + expect(andFilter.filters.length).toBe(2); + expect(andFilter.filters[0]).toBeInstanceOf(EqualsFilter); + const left = andFilter.filters[0] as EqualsFilter; + expect(left.field).toBe("name"); + expect(left.value).toBe("Batman"); + + expect(andFilter.filters[1]).toBeInstanceOf(EqualsFilter); + const right = andFilter.filters[1] as EqualsFilter; + expect(right.field).toBe("age"); + expect(right.value).toBe("30"); + }); + + it("should use OrFilter when options.logic is Or", () => { + // Arrange + const expression = "name=Batman&age=30"; + const options = new FilterOptions({ logic: FilterLogic.Or }); + + // Act + const result: IFilter = parse(expression, options); + + // Assert + expect(result).toBeInstanceOf(OrFilter); + const orFilter = result as OrFilter; + expect(orFilter.filters.length).toBe(2); + expect(orFilter.filters[0]).toBeInstanceOf(EqualsFilter); + const left = orFilter.filters[0] as EqualsFilter; + expect(left.field).toBe("name"); + expect(left.value).toBe("Batman"); + + expect(orFilter.filters[1]).toBeInstanceOf(EqualsFilter); + const right = orFilter.filters[1] as EqualsFilter; + expect(right.field).toBe("age"); + expect(right.value).toBe("30"); + }); + + it("should parse query string with '&' as AndFilter by default", () => { + // Arrange + const expression = "name=*bat*&age=[18 TO *["; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const andFilter = result as AndFilter; + expect(andFilter.filters.length).toBe(2); + + expect(andFilter.filters[0]).toBeInstanceOf(ContainsFilter); + const containsFilter = andFilter.filters[0] as ContainsFilter; + expect(containsFilter.field).toBe("name"); + expect(containsFilter.value).toBe("bat"); + + expect(andFilter.filters[1]).toBeInstanceOf(GreaterThanOrEqualFilter); + const greaterThanOrEqualFilter = andFilter + .filters[1] as GreaterThanOrEqualFilter; + expect(greaterThanOrEqualFilter.field).toBe("age"); + expect(greaterThanOrEqualFilter.value).toBe("18"); + }); + + it("should parse URL-encoded query string with '&' as AndFilter", () => { + // Arrange + const expression = "name=%2Abat%2A&age=%5B18%20TO%20*%5B"; + + // Act + const result: IFilter = parse(expression); + + // Assert + expect(result).toBeInstanceOf(AndFilter); + const andFilter = result as AndFilter; + expect(andFilter.filters.length).toBe(2); + + expect(andFilter.filters[0]).toBeInstanceOf(ContainsFilter); + const containsFilter = andFilter.filters[0] as ContainsFilter; + expect(containsFilter.field).toBe("name"); + expect(containsFilter.value).toBe("bat"); + + expect(andFilter.filters[1]).toBeInstanceOf(GreaterThanOrEqualFilter); + const greaterThanOrEqualFilter = andFilter + .filters[1] as GreaterThanOrEqualFilter; + expect(greaterThanOrEqualFilter.field).toBe("age"); + expect(greaterThanOrEqualFilter.value).toBe("18"); + }); + + it("should default logic to And when constructed without arguments", () => { + // Arrange + const options = new FilterOptions(); + + // Assert + expect(options.logic).toBe(FilterLogic.And); + }); + + it("should apply Or logic on inherited-field comma parts", () => { + // Arrange + const expression = "name=*an,bat*"; + const options = new FilterOptions({ logic: FilterLogic.Or }); + + // Act + const result: IFilter = parse(expression, options); + + // Assert + expect(result).toBeInstanceOf(OrFilter); + const orFilter = result as OrFilter; + expect(orFilter.filters.length).toBe(2); + expect(orFilter.filters[0]).toBeInstanceOf(EndsWithFilter); + expect(orFilter.filters[1]).toBeInstanceOf(StartsWithFilter); + }); }); From 02867f8dde235ac938b93fddeedd905352d6507a Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Sat, 11 Apr 2026 12:27:22 +0000 Subject: [PATCH 23/28] feat(filters): add OneOfFilter and enhance FilterBuilder with new conditions --- src/typescript/src/builder.ts | 28 ++++++-- src/typescript/src/containsFilter.ts | 28 +++++++- src/typescript/src/expressions.ts | 25 +++---- src/typescript/src/iFilter.ts | 1 + src/typescript/src/oneOfFilter.ts | 11 +++ src/typescript/src/orFilter.ts | 10 +-- src/typescript/src/parser.ts | 37 ++++++++-- src/typescript/test/builder.test.ts | 83 +++++++++++++--------- src/typescript/test/containsFilter.test.ts | 60 +++++++++++++--- src/typescript/test/orFilter.test.ts | 18 ++--- src/typescript/test/parser.test.ts | 30 ++++---- 11 files changed, 235 insertions(+), 96 deletions(-) create mode 100644 src/typescript/src/oneOfFilter.ts diff --git a/src/typescript/src/builder.ts b/src/typescript/src/builder.ts index 57abde42..fc281c01 100644 --- a/src/typescript/src/builder.ts +++ b/src/typescript/src/builder.ts @@ -13,9 +13,10 @@ import { LessThanFilter, LessThanOrEqualFilter, NotFilter, + OneOfFilter, OrFilter, StartsWithFilter, -} from './expressions'; +} from "./expressions"; /** Internal helper that provides filter-condition methods for a single field. */ export class FieldBuilder { @@ -51,7 +52,9 @@ export class FieldBuilder { /** Add a greater-than-or-equal condition. */ public gte(value: unknown): FilterBuilder { - return this.parent.addFilter(new GreaterThanOrEqualFilter(this.field, value)); + return this.parent.addFilter( + new GreaterThanOrEqualFilter(this.field, value), + ); } /** Add a less-than condition. */ @@ -66,13 +69,24 @@ export class FieldBuilder { /** Add a not-equals condition. */ public not(value: unknown): FilterBuilder { - return this.parent.addFilter(new NotFilter(new EqualsFilter(this.field, value))); + return this.parent.addFilter( + new NotFilter(new EqualsFilter(this.field, value)), + ); + } + + /** Add a OneOf condition matching any of the given values. */ + public oneOf(...values: unknown[]): FilterBuilder { + const orFilters: IFilter[] = values.map( + (v) => new EqualsFilter(this.field, v), + ); + return this.parent.addFilter(new OneOfFilter(orFilters)); } /** Add an OR condition matching any of the given values. */ - public or(...values: unknown[]): FilterBuilder { - const orFilters: IFilter[] = values.map((v) => new EqualsFilter(this.field, v)); - return this.parent.addFilter(new OrFilter(orFilters)); + public or(leftValue: unknown, rightValue: unknown): FilterBuilder { + const left = new EqualsFilter(this.field, leftValue); + const right = new EqualsFilter(this.field, rightValue); + return this.parent.addFilter(new OrFilter(left, right)); } } @@ -120,7 +134,7 @@ export class FilterBuilder { */ public build(): IFilter { if (this.filters.length === 0) { - throw new Error('No filter conditions have been added.'); + throw new Error("No filter conditions have been added."); } const result: IFilter = diff --git a/src/typescript/src/containsFilter.ts b/src/typescript/src/containsFilter.ts index 259ce262..022f4c84 100644 --- a/src/typescript/src/containsFilter.ts +++ b/src/typescript/src/containsFilter.ts @@ -1,10 +1,32 @@ -import { IFilter } from './iFilter'; +import { IFilter } from "./iFilter"; /** Matches records where a property contains a substring. */ export class ContainsFilter implements IFilter { - public constructor(public readonly field: string, public readonly value: string) {} + public constructor( + public readonly field: string, + public readonly value: string, + ) {} public toDict(): Record { - return { field: this.field, op: 'contains', value: this.value }; + return { field: this.field, op: "contains", value: this.value }; + } + + /** + * Creates a ContainsFilter from a dictionary. + * @param dict The dictionary to create the filter from. Must have "field", "op", and "value" keys. + * @returns A new ContainsFilter instance. + * @throws Error if the dictionary does not have the correct keys or types. + */ + public static fromDict(dict: Record): ContainsFilter { + if (dict["op"] !== "contains") { + throw new Error(`Invalid operator for ContainsFilter: ${dict["op"]}`); + } + if ( + typeof dict["field"] !== "string" || + typeof dict["value"] !== "string" + ) { + throw new Error("Invalid field or value type for ContainsFilter"); + } + return new ContainsFilter(dict["field"], dict["value"]); } } diff --git a/src/typescript/src/expressions.ts b/src/typescript/src/expressions.ts index 7fdc2fb8..87d40aa0 100644 --- a/src/typescript/src/expressions.ts +++ b/src/typescript/src/expressions.ts @@ -3,15 +3,16 @@ * This file re-exports all expression types from their individual modules. */ -export { IFilter } from './iFilter'; -export { EqualsFilter } from './equalsFilter'; -export { ContainsFilter } from './containsFilter'; -export { StartsWithFilter } from './startsWithFilter'; -export { EndsWithFilter } from './endsWithFilter'; -export { GreaterThanFilter } from './greaterThanFilter'; -export { GreaterThanOrEqualFilter } from './greaterThanOrEqualFilter'; -export { LessThanFilter } from './lessThanFilter'; -export { LessThanOrEqualFilter } from './lessThanOrEqualFilter'; -export { AndFilter } from './andFilter'; -export { OrFilter } from './orFilter'; -export { NotFilter } from './notFilter'; +export { IFilter } from "./iFilter"; +export { EqualsFilter } from "./equalsFilter"; +export { ContainsFilter } from "./containsFilter"; +export { StartsWithFilter } from "./startsWithFilter"; +export { EndsWithFilter } from "./endsWithFilter"; +export { GreaterThanFilter } from "./greaterThanFilter"; +export { GreaterThanOrEqualFilter } from "./greaterThanOrEqualFilter"; +export { LessThanFilter } from "./lessThanFilter"; +export { LessThanOrEqualFilter } from "./lessThanOrEqualFilter"; +export { AndFilter } from "./andFilter"; +export { OrFilter } from "./orFilter"; +export { OneOfFilter } from "./oneOfFilter"; +export { NotFilter } from "./notFilter"; diff --git a/src/typescript/src/iFilter.ts b/src/typescript/src/iFilter.ts index 4da6470e..98f7ad1b 100644 --- a/src/typescript/src/iFilter.ts +++ b/src/typescript/src/iFilter.ts @@ -1,4 +1,5 @@ /** Base interface for all filter expressions. */ export interface IFilter { + /** Converts the filter to a dictionary representation. */ toDict(): Record; } diff --git a/src/typescript/src/oneOfFilter.ts b/src/typescript/src/oneOfFilter.ts new file mode 100644 index 00000000..630627af --- /dev/null +++ b/src/typescript/src/oneOfFilter.ts @@ -0,0 +1,11 @@ +import { IFilter } from "./iFilter"; + +/** Combines multiple filters with a logical OR. */ + +export class OneOfFilter implements IFilter { + public constructor(public readonly filters: IFilter[]) {} + + public toDict(): Record { + return { logic: "or", filters: this.filters.map((f) => f.toDict()) }; + } +} diff --git a/src/typescript/src/orFilter.ts b/src/typescript/src/orFilter.ts index 36109eb2..c6971c11 100644 --- a/src/typescript/src/orFilter.ts +++ b/src/typescript/src/orFilter.ts @@ -1,10 +1,12 @@ -import { IFilter } from './iFilter'; +import { IFilter } from "./iFilter"; -/** Combines multiple filters with a logical OR. */ export class OrFilter implements IFilter { - public constructor(public readonly filters: IFilter[]) {} + public constructor( + public readonly left: IFilter, + public readonly right: IFilter, + ) {} public toDict(): Record { - return { logic: 'or', filters: this.filters.map((f) => f.toDict()) }; + return { logic: "or", filters: [this.left.toDict(), this.right.toDict()] }; } } diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index c85716f9..5a715697 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -26,6 +26,7 @@ import { LessThanFilter, LessThanOrEqualFilter, NotFilter, + OneOfFilter, OrFilter, StartsWithFilter, } from "./expressions"; @@ -283,10 +284,27 @@ function parseSingle(expression: string): IFilter { } const orParts = splitByUnescapedSeparator(value, "|"); - const result: IFilter = - orParts.length > 1 - ? new OrFilter(orParts.map((v) => parseValueExpression(field, v.trim()))) - : parseValueExpression(field, value); + + let result; + switch (orParts.length) { + case 0: + throw new Error(`Invalid filter expression: '${expression}'`); + case 1: + result = parseValueExpression(field, value); + break; + case 2: + result = new OrFilter( + parseValueExpression(field, orParts[0].trim()), + parseValueExpression(field, orParts[1].trim()), + ); + break; + default: + result = new OneOfFilter( + orParts.map((v) => parseValueExpression(field, v.trim())), + ); + break; + } + return result; } @@ -327,8 +345,15 @@ function parseAndExpression( }); const logic = options?.logic ?? FilterLogic.And; - result = - logic === FilterLogic.Or ? new OrFilter(filters) : new AndFilter(filters); + if (logic === FilterLogic.Or) { + if (filters.length === 2) { + result = new OrFilter(filters[0], filters[1]); + } else { + result = new OneOfFilter(filters); + } + } else { + result = new AndFilter(filters); + } } return result; diff --git a/src/typescript/test/builder.test.ts b/src/typescript/test/builder.test.ts index 38ee2658..977a7400 100644 --- a/src/typescript/test/builder.test.ts +++ b/src/typescript/test/builder.test.ts @@ -10,80 +10,97 @@ import { NotFilter, OrFilter, StartsWithFilter, -} from '../src/expressions'; -import { FilterBuilder } from '../src/builder'; +} from "../src/expressions"; +import { FilterBuilder } from "../src/builder"; -describe('FilterBuilder - single conditions', () => { - it('should build equals filter', () => { - const result = new FilterBuilder().where('name').eq('Batman').build(); +describe("FilterBuilder - single conditions", () => { + it("should build equals filter", () => { + const result = new FilterBuilder().where("name").eq("Batman").build(); expect(result).toBeInstanceOf(EqualsFilter); - expect((result as EqualsFilter).field).toBe('name'); - expect((result as EqualsFilter).value).toBe('Batman'); + expect((result as EqualsFilter).field).toBe("name"); + expect((result as EqualsFilter).value).toBe("Batman"); }); - it('should build contains filter', () => { - const result = new FilterBuilder().where('name').contains('bat').build(); + it("should build contains filter", () => { + const result = new FilterBuilder().where("name").contains("bat").build(); expect(result).toBeInstanceOf(ContainsFilter); - expect((result as ContainsFilter).value).toBe('bat'); + expect((result as ContainsFilter).value).toBe("bat"); }); - it('should build startsWith filter', () => { - const result = new FilterBuilder().where('name').startsWith('Bat').build(); + it("should build startsWith filter", () => { + const result = new FilterBuilder().where("name").startsWith("Bat").build(); expect(result).toBeInstanceOf(StartsWithFilter); }); - it('should build endsWith filter', () => { - const result = new FilterBuilder().where('name').endsWith('man').build(); + it("should build endsWith filter", () => { + const result = new FilterBuilder().where("name").endsWith("man").build(); expect(result).toBeInstanceOf(EndsWithFilter); }); - it('should build gt filter', () => { - const result = new FilterBuilder().where('age').gt(18).build(); + it("should build gt filter", () => { + const result = new FilterBuilder().where("age").gt(18).build(); expect(result).toBeInstanceOf(GreaterThanFilter); expect((result as GreaterThanFilter).value).toBe(18); }); - it('should build gte filter', () => { - const result = new FilterBuilder().where('age').gte(18).build(); + it("should build gte filter", () => { + const result = new FilterBuilder().where("age").gte(18).build(); expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); }); - it('should build lt filter', () => { - const result = new FilterBuilder().where('age').lt(65).build(); + it("should build lt filter", () => { + const result = new FilterBuilder().where("age").lt(65).build(); expect(result).toBeInstanceOf(LessThanFilter); }); - it('should build lte filter', () => { - const result = new FilterBuilder().where('age').lte(65).build(); + it("should build lte filter", () => { + const result = new FilterBuilder().where("age").lte(65).build(); expect(result).toBeInstanceOf(LessThanOrEqualFilter); }); - it('should build not filter', () => { - const result = new FilterBuilder().where('deleted').not(true).build(); + it("should build not filter", () => { + const result = new FilterBuilder().where("deleted").not(true).build(); expect(result).toBeInstanceOf(NotFilter); expect((result as NotFilter).filter).toBeInstanceOf(EqualsFilter); }); }); -describe('FilterBuilder - multiple conditions', () => { - it('should combine two conditions as AND', () => { +describe("FilterBuilder - multiple conditions", () => { + it("should combine two conditions as AND", () => { const result = new FilterBuilder() - .where('name').contains('bat') - .andWhere('age').gte(18) + .where("name") + .contains("bat") + .andWhere("age") + .gte(18) .build(); expect(result).toBeInstanceOf(AndFilter); expect((result as AndFilter).filters.length).toBe(2); }); - it('should build OR filter', () => { - const result = new FilterBuilder().where('status').or('active', 'pending').build(); + it("should build OR filter", () => { + // Act + const result = new FilterBuilder() + .where("status") + .or("active", "pending") + .build(); + + // Assert expect(result).toBeInstanceOf(OrFilter); - expect((result as OrFilter).filters.length).toBe(2); + const orFilter = result as OrFilter; + expect(orFilter.left).toBeInstanceOf(EqualsFilter); + + const left = orFilter.left as EqualsFilter; + expect(left.field).toBe("status"); + expect(left.value).toBe("active"); + + const right = orFilter.right as EqualsFilter; + expect(right.field).toBe("status"); + expect(right.value).toBe("pending"); }); }); -describe('FilterBuilder - errors', () => { - it('should throw when no conditions added', () => { +describe("FilterBuilder - errors", () => { + it("should throw when no conditions added", () => { expect(() => new FilterBuilder().build()).toThrow(); }); }); diff --git a/src/typescript/test/containsFilter.test.ts b/src/typescript/test/containsFilter.test.ts index 7beebd5a..1a87ed40 100644 --- a/src/typescript/test/containsFilter.test.ts +++ b/src/typescript/test/containsFilter.test.ts @@ -1,22 +1,66 @@ -import { ContainsFilter } from '../src/expressions'; +import { ContainsFilter } from "../src/expressions"; -describe('ContainsFilter', () => { - it('should serialize to correct dict', () => { +describe("ContainsFilter", () => { + it("should serialize to correct dict", () => { // Arrange - const f = new ContainsFilter('name', 'bat'); + const f = new ContainsFilter("name", "bat"); // Act const result = f.toDict(); // Assert - expect(result).toEqual({ field: 'name', op: 'contains', value: 'bat' }); + expect(result).toEqual({ field: "name", op: "contains", value: "bat" }); }); - it('op field should be contains', () => { + it("op field should be contains", () => { // Arrange / Act - const dict = new ContainsFilter('f', 'v').toDict(); + const dict = new ContainsFilter("f", "v").toDict(); // Assert - expect(dict['op']).toBe('contains'); + expect(dict["op"]).toBe("contains"); + }); + + it("should create from dict", () => { + // Arrange + const dict = { field: "name", op: "contains", value: "bat" }; + + // Act + const filter = ContainsFilter.fromDict(dict); + + // Assert + expect(filter).toBeInstanceOf(ContainsFilter); + expect(filter.field).toBe("name"); + expect(filter.value).toBe("bat"); + }); + + it("should throw error for invalid operator in fromDict", () => { + // Arrange + const dict = { field: "name", op: "equals", value: "bat" }; + + // Act / Assert + expect(() => ContainsFilter.fromDict(dict)).toThrow( + "Invalid operator for ContainsFilter: equals", + ); + }); + + describe("ContainsFilter", () => { + // Arrange + const cases: { + input: { field: unknown; op: unknown; value: unknown }; + reason: string; + }[] = [ + { + input: { field: 123, op: "contains", value: "bat" }, + reason: "field is not a string", + }, + ]; + + test.each(cases)( + "fromDict(%j) should throw an exception when input is not a valid ContainsFilter", + (input) => { + // Act / Assert + expect(() => ContainsFilter.fromDict(input)).toThrow(); + }, + ); }); }); diff --git a/src/typescript/test/orFilter.test.ts b/src/typescript/test/orFilter.test.ts index 5842f4de..90af1422 100644 --- a/src/typescript/test/orFilter.test.ts +++ b/src/typescript/test/orFilter.test.ts @@ -1,18 +1,18 @@ -import { EqualsFilter, OrFilter } from '../src/expressions'; +import { EqualsFilter, OrFilter } from "../src/expressions"; -describe('OrFilter', () => { - it('should serialize with logic and nested filters', () => { +describe("OrFilter", () => { + it("should serialize with logic and nested filters", () => { // Arrange - const f = new OrFilter([ - new EqualsFilter('status', 'active'), - new EqualsFilter('status', 'pending'), - ]); + const f = new OrFilter( + new EqualsFilter("status", "active"), + new EqualsFilter("status", "pending"), + ); // Act const result = f.toDict(); // Assert - expect(result['logic']).toBe('or'); - expect((result['filters'] as unknown[]).length).toBe(2); + expect(result["logic"]).toBe("or"); + expect((result["filters"] as unknown[]).length).toBe(2); }); }); diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index cdf76fb7..acfdc13a 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -227,14 +227,11 @@ describe("parse - OR combination", () => { // Assert expect(result).toBeInstanceOf(OrFilter); const orFilter = result as OrFilter; - expect(orFilter.filters.length).toBe(2); - expect(orFilter.filters[0]).toBeInstanceOf(EqualsFilter); - const left = orFilter.filters[0] as EqualsFilter; + const left = orFilter.left as EqualsFilter; expect(left.field).toBe("status"); expect(left.value).toBe("active"); - expect(orFilter.filters[1]).toBeInstanceOf(EqualsFilter); - const right = orFilter.filters[1] as EqualsFilter; + const right = orFilter.right as EqualsFilter; expect(right.field).toBe("status"); expect(right.value).toBe("pending"); }); @@ -419,14 +416,12 @@ describe("parse - FilterOptions", () => { // Assert expect(result).toBeInstanceOf(OrFilter); const orFilter = result as OrFilter; - expect(orFilter.filters.length).toBe(2); - expect(orFilter.filters[0]).toBeInstanceOf(EqualsFilter); - const left = orFilter.filters[0] as EqualsFilter; + + const left = orFilter.left as EqualsFilter; expect(left.field).toBe("name"); expect(left.value).toBe("Batman"); - expect(orFilter.filters[1]).toBeInstanceOf(EqualsFilter); - const right = orFilter.filters[1] as EqualsFilter; + const right = orFilter.right as EqualsFilter; expect(right.field).toBe("age"); expect(right.value).toBe("30"); }); @@ -489,7 +484,7 @@ describe("parse - FilterOptions", () => { it("should apply Or logic on inherited-field comma parts", () => { // Arrange - const expression = "name=*an,bat*"; + const expression = "name=*bat*&age=30"; const options = new FilterOptions({ logic: FilterLogic.Or }); // Act @@ -498,8 +493,15 @@ describe("parse - FilterOptions", () => { // Assert expect(result).toBeInstanceOf(OrFilter); const orFilter = result as OrFilter; - expect(orFilter.filters.length).toBe(2); - expect(orFilter.filters[0]).toBeInstanceOf(EndsWithFilter); - expect(orFilter.filters[1]).toBeInstanceOf(StartsWithFilter); + expect(orFilter.left).toBeInstanceOf(ContainsFilter); + const left = orFilter.left as ContainsFilter; + expect(left.field).toBe("name"); + expect(left.value).toBe("bat"); + + expect(orFilter.right).toBeInstanceOf(EqualsFilter); + + const right = orFilter.right as EqualsFilter; + expect(right.field).toBe("age"); + expect(right.value).toBe("30"); }); }); From 9480711f798447d6dd6f982517a028f18d3f76d5 Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Sat, 11 Apr 2026 16:34:23 +0000 Subject: [PATCH 24/28] feat(parser): enhance parseAndExpression to support grouped filters and improve logic handling --- src/typescript/src/parser.ts | 50 ++++++++++++++---- src/typescript/test/parser.test.ts | 82 ++++++++++++++++++++++-------- 2 files changed, 102 insertions(+), 30 deletions(-) diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index 5a715697..a06e8155 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -323,13 +323,34 @@ function parseAndExpression( result = parseSingle(expression); } else { let inheritedField: string | null = null; - const filters: IFilter[] = parts.map((p) => { + const encounteredFields = new Set(); + const groupedFilters: IFilter[] = []; + let currentGroup: IFilter[] = []; + + const flushCurrentGroup = (): void => { + if (currentGroup.length === 1) { + groupedFilters.push(currentGroup[0]); + } else if (currentGroup.length > 1) { + groupedFilters.push(new AndFilter(currentGroup)); + } + + currentGroup = []; + }; + + for (const p of parts) { const trimmed = p.trim(); const eqIndex = findFirstUnescaped(trimmed, "="); let parsedFilter: IFilter; if (eqIndex !== -1) { - inheritedField = trimmed.slice(0, eqIndex).trim(); + const field = unescapeValue(trimmed.slice(0, eqIndex).trim()); + + if (inheritedField !== null && field !== inheritedField) { + flushCurrentGroup(); + } + + inheritedField = field; + encounteredFields.add(field); parsedFilter = parseSingle(trimmed); } else { if (!inheritedField) { @@ -341,18 +362,27 @@ function parseAndExpression( parsedFilter = parseValueExpression(inheritedField, trimmed); } - return parsedFilter; - }); + currentGroup.push(parsedFilter); + } + + flushCurrentGroup(); + // Multiple values targeting the same field are always AND-ed together. + // The global logic only applies when combining different field groups. + const isSameField = encounteredFields.size <= 1; const logic = options?.logic ?? FilterLogic.And; - if (logic === FilterLogic.Or) { - if (filters.length === 2) { - result = new OrFilter(filters[0], filters[1]); + + if (isSameField || logic === FilterLogic.And) { + result = + groupedFilters.length === 1 + ? groupedFilters[0] + : new AndFilter(groupedFilters); + } else { + if (groupedFilters.length === 2) { + result = new OrFilter(groupedFilters[0], groupedFilters[1]); } else { - result = new OneOfFilter(filters); + result = new OneOfFilter(groupedFilters); } - } else { - result = new AndFilter(filters); } } diff --git a/src/typescript/test/parser.test.ts b/src/typescript/test/parser.test.ts index acfdc13a..442f2846 100644 --- a/src/typescript/test/parser.test.ts +++ b/src/typescript/test/parser.test.ts @@ -353,6 +353,7 @@ describe("parse - parameterized", () => { ["name=*man", undefined, EndsWithFilter], ["name=!Batman", undefined, NotFilter], ["name=!Batman&age=30", { logic: FilterLogic.Or }, OrFilter], + ["name=bat*,*man*", { logic: FilterLogic.Or }, AndFilter], ["name=!Batman&age=30", undefined, AndFilter], ]; @@ -482,26 +483,67 @@ describe("parse - FilterOptions", () => { expect(options.logic).toBe(FilterLogic.And); }); - it("should apply Or logic on inherited-field comma parts", () => { - // Arrange - const expression = "name=*bat*&age=30"; - const options = new FilterOptions({ logic: FilterLogic.Or }); - - // Act - const result: IFilter = parse(expression, options); - - // Assert - expect(result).toBeInstanceOf(OrFilter); - const orFilter = result as OrFilter; - expect(orFilter.left).toBeInstanceOf(ContainsFilter); - const left = orFilter.left as ContainsFilter; - expect(left.field).toBe("name"); - expect(left.value).toBe("bat"); + const inheritedFieldOrCases: [ + string, + FilterOptions, + (result: IFilter) => void, + ][] = [ + [ + "name=*bat*&age=30", + new FilterOptions({ logic: FilterLogic.Or }), + (result: IFilter): void => { + expect(result).toBeInstanceOf(OrFilter); + const orFilter = result as OrFilter; + expect(orFilter.left).toBeInstanceOf(ContainsFilter); + const left = orFilter.left as ContainsFilter; + expect(left.field).toBe("name"); + expect(left.value).toBe("bat"); + + expect(orFilter.right).toBeInstanceOf(EqualsFilter); + + const right = orFilter.right as EqualsFilter; + expect(right.field).toBe("age"); + expect(right.value).toBe("30"); + }, + ], + [ + "name=bat*,*man*&age=30", + new FilterOptions({ logic: FilterLogic.Or }), + (result: IFilter): void => { + expect(result).toBeInstanceOf(OrFilter); + const orFilter = result as OrFilter; + + expect(orFilter.left).toBeInstanceOf(AndFilter); + const left = orFilter.left as AndFilter; + { + expect(left.filters.length).toBe(2); + + expect(left.filters[0]).toBeInstanceOf(StartsWithFilter); + const startsWith = left.filters[0] as StartsWithFilter; + expect(startsWith.field).toBe("name"); + expect(startsWith.value).toBe("bat"); + + expect(left.filters[1]).toBeInstanceOf(ContainsFilter); + const containsFilter = left.filters[1] as ContainsFilter; + expect(containsFilter.field).toBe("name"); + expect(containsFilter.value).toBe("man"); + } + expect(orFilter.right).toBeInstanceOf(EqualsFilter); + const right = orFilter.right as EqualsFilter; + expect(right.field).toBe("age"); + expect(right.value).toBe("30"); + }, + ], + ]; - expect(orFilter.right).toBeInstanceOf(EqualsFilter); + test.each(inheritedFieldOrCases)( + "should apply Or logic on inherited-field comma parts", + (expression, options, assertion) => { + // Arrange / Act + const result: IFilter = parse(expression, options); - const right = orFilter.right as EqualsFilter; - expect(right.field).toBe("age"); - expect(right.value).toBe("30"); - }); + // Assert + assertion(result); + }, + ); }); From a26b222fc617aeda6ebbd44888983cb08c045bbd Mon Sep 17 00:00:00 2001 From: Cyrille NDOUMBE Date: Sat, 11 Apr 2026 16:39:48 +0000 Subject: [PATCH 25/28] fix(serializers): add OneOfFilter support in fromDict logic handling --- src/typescript/src/serializers.ts | 51 +++++++++++++++++++------------ 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/src/typescript/src/serializers.ts b/src/typescript/src/serializers.ts index 0284f104..9f341f52 100644 --- a/src/typescript/src/serializers.ts +++ b/src/typescript/src/serializers.ts @@ -13,9 +13,10 @@ import { LessThanFilter, LessThanOrEqualFilter, NotFilter, + OneOfFilter, OrFilter, StartsWithFilter, -} from './expressions'; +} from "./expressions"; /** Serialize an IFilter to a plain JavaScript object. */ export function toDict(filter: IFilter): Record { @@ -30,7 +31,10 @@ export function toJson(filter: IFilter, space?: number): string { const OP_MAP: Record IFilter> = { eq: EqualsFilter, contains: ContainsFilter as new (field: string, value: unknown) => IFilter, - startswith: StartsWithFilter as new (field: string, value: unknown) => IFilter, + startswith: StartsWithFilter as new ( + field: string, + value: unknown, + ) => IFilter, endswith: EndsWithFilter as new (field: string, value: unknown) => IFilter, gt: GreaterThanFilter, gte: GreaterThanOrEqualFilter, @@ -48,34 +52,41 @@ const OP_MAP: Record IFilter> = { export function fromDict(data: Record): IFilter { let result: IFilter; - if ('logic' in data) { - const logic = data['logic']; - if (typeof logic !== 'string') { - throw new Error(`Invalid logic type: expected string, got ${typeof logic}`); + if ("logic" in data) { + const logic = data["logic"]; + if (typeof logic !== "string") { + throw new Error( + `Invalid logic type: expected string, got ${typeof logic}`, + ); } - const filtersData = data['filters']; + const filtersData = data["filters"]; if (!Array.isArray(filtersData)) { - throw new Error(`Invalid filters type: expected array, got ${typeof filtersData}`); + throw new Error( + `Invalid filters type: expected array, got ${typeof filtersData}`, + ); } const children = filtersData.map(fromDict); - if (logic === 'and') { + if (logic === "and") { result = new AndFilter(children); - } else if (logic === 'or') { - result = new OrFilter(children); - } else if (logic === 'not') { + } else if (logic === "or") { + result = + children.length === 2 + ? new OrFilter(children[0], children[1]) + : new OneOfFilter(children); + } else if (logic === "not") { if (children.length === 0) { - throw new Error('Not filter requires at least one child filter'); + throw new Error("Not filter requires at least one child filter"); } result = new NotFilter(children[0]); } else { throw new Error(`Unknown logic operator: '${logic}'`); } } else { - const op = data['op']; - if (typeof op !== 'string') { + const op = data["op"]; + if (typeof op !== "string") { throw new Error(`Invalid op type: expected string, got ${typeof op}`); } @@ -84,12 +95,14 @@ export function fromDict(data: Record): IFilter { throw new Error(`Unknown filter operator: '${op}'`); } - const field = data['field']; - if (typeof field !== 'string') { - throw new Error(`Invalid field type: expected string, got ${typeof field}`); + const field = data["field"]; + if (typeof field !== "string") { + throw new Error( + `Invalid field type: expected string, got ${typeof field}`, + ); } - result = new Ctor(field, data['value']); + result = new Ctor(field, data["value"]); } return result; From 4de4007cb38617876273b23a379834149591220f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:42:44 +0000 Subject: [PATCH 26/28] feat: replace hand-written parser with Chevrotain-based implementation Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/1f0ef57d-c61d-4d8c-b034-50a421596596 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- .gitignore | 1 + src/typescript/jest.config.js | 6 + src/typescript/jest.global-setup.cjs | 29 + src/typescript/package-lock.json | 541 +++++++++++++++ src/typescript/package.json | 4 + src/typescript/src/parser.ts | 952 ++++++++++++++++++--------- 6 files changed, 1229 insertions(+), 304 deletions(-) create mode 100644 src/typescript/jest.global-setup.cjs diff --git a/.gitignore b/.gitignore index ec83e764..22994d63 100644 --- a/.gitignore +++ b/.gitignore @@ -285,3 +285,4 @@ output/ .squad/sessions/ # Squad: SubSquad activation file (local to this machine) .squad-workstream +src/typescript/.jest-cache/ diff --git a/src/typescript/jest.config.js b/src/typescript/jest.config.js index a9c2694e..4da1fc2b 100644 --- a/src/typescript/jest.config.js +++ b/src/typescript/jest.config.js @@ -7,6 +7,12 @@ const config = { collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'], coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov', 'clover'], + // chevrotain is a pure-ESM package. We pre-bundle it to CJS in the global + // setup step and redirect imports to the generated bundle. + globalSetup: '/jest.global-setup.cjs', + moduleNameMapper: { + '^chevrotain$': '/.jest-cache/chevrotain-cjs.js', + }, }; module.exports = config; diff --git a/src/typescript/jest.global-setup.cjs b/src/typescript/jest.global-setup.cjs new file mode 100644 index 00000000..1d9c7139 --- /dev/null +++ b/src/typescript/jest.global-setup.cjs @@ -0,0 +1,29 @@ +/** + * Jest global setup: pre-bundle chevrotain (a pure-ESM package) to CommonJS + * so that Jest's CJS test runner can require() it normally. + */ +"use strict"; + +const esbuild = require("esbuild"); +const path = require("path"); +const fs = require("fs"); + +const OUTPUT_DIR = path.join(__dirname, ".jest-cache"); +const OUTPUT_FILE = path.join(OUTPUT_DIR, "chevrotain-cjs.js"); + +async function setup() { + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + await esbuild.build({ + entryPoints: [require.resolve("chevrotain")], + bundle: true, + format: "cjs", + platform: "node", + outfile: OUTPUT_FILE, + logLevel: "silent", + }); +} + +module.exports = setup; diff --git a/src/typescript/package-lock.json b/src/typescript/package-lock.json index bf5286a2..eeb47b1a 100644 --- a/src/typescript/package-lock.json +++ b/src/typescript/package-lock.json @@ -8,10 +8,14 @@ "name": "datafilters", "version": "0.1.0", "license": "MIT", + "dependencies": { + "chevrotain": "^12.0.0" + }, "devDependencies": { "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", + "esbuild": "^0.28.0", "eslint": "^8.57.0", "jest": "^29.7.0", "ts-jest": "^29.1.0", @@ -534,6 +538,485 @@ "dev": true, "license": "MIT" }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", + "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "12.0.0", + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", + "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", + "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", + "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", + "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1961,6 +2444,22 @@ "node": ">=10" } }, + "node_modules/chevrotain": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", + "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "12.0.0", + "@chevrotain/gast": "12.0.0", + "@chevrotain/regexp-to-ast": "12.0.0", + "@chevrotain/types": "12.0.0", + "@chevrotain/utils": "12.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -2221,6 +2720,48 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/src/typescript/package.json b/src/typescript/package.json index e7a8894f..ffc6c4b4 100644 --- a/src/typescript/package.json +++ b/src/typescript/package.json @@ -32,9 +32,13 @@ "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", + "esbuild": "^0.28.0", "eslint": "^8.57.0", "jest": "^29.7.0", "ts-jest": "^29.1.0", "typescript": "^5.4.0" + }, + "dependencies": { + "chevrotain": "^12.0.0" } } diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index a06e8155..79b9a204 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -1,20 +1,32 @@ /** * Parser for DataFilters query syntax strings. * + * Uses Chevrotain for lexer and parser implementation, + * mirroring the C# FilterTokenParser grammar. + * * Supported syntax: - * - `field=value` → EqualsFilter - * - `field=value*` → StartsWithFilter - * - `field=*value` → EndsWithFilter - * - `field=*value*` → ContainsFilter - * - `field=!value` → NotFilter(EqualsFilter) - * - `field=[min TO max]` → AndFilter(gte, lte) - * - `field=[min TO *[` → GreaterThanOrEqualFilter - * - `field=]* TO max]` → LessThanOrEqualFilter - * - `field=]min TO max[` → AndFilter(gt, lt) - * - `field=expr1,expr2` → AndFilter(expr1, expr2) - * - `field=expr1|expr2` → OrFilter(expr1, expr2) + * - `field=value` → EqualsFilter + * - `field=value*` → StartsWithFilter + * - `field=*value` → EndsWithFilter + * - `field=*value*` → ContainsFilter + * - `field=!value` → NotFilter(EqualsFilter) + * - `field=[min TO max]` → AndFilter(gte, lte) + * - `field=[min TO *[` → GreaterThanOrEqualFilter + * - `field=]* TO max]` → LessThanOrEqualFilter + * - `field=]min TO max[` → AndFilter(gt, lt) + * - `field=expr1,expr2` → AndFilter (same field) + * - `field=expr1|expr2` → OrFilter + * - `field1=v1&field2=v2` → AndFilter or OrFilter based on options */ +import { + createToken, + Lexer, + EmbeddedActionsParser, + IToken, + TokenType, +} from "chevrotain"; + import { AndFilter, ContainsFilter, @@ -32,159 +44,141 @@ import { } from "./expressions"; import { FilterLogic, FilterOptions } from "./filterOptions"; -const RANGE_PATTERN = - /^(?[[\]])(?.*?)\s*TO\s*(?.*?)(?[[\]])$/; -const WILDCARD_CHAR = "*"; -const NEGATION_CHAR = "!"; -const ESCAPE_CHAR = "\\"; -const MIN_WILDCARD_LENGTH = 2; +// ===== Token Definitions ===== +// +// Text must be defined before TO so that longer_alt can reference it. +// The token array order governs tie-breaking: TO is listed before Text so +// that the exact string "TO" tokenises as the keyword. For longer strings +// such as "TORONTO", longer_alt ensures Text wins via maximum munch. + +/** Backslash followed by any character — the leading backslash is stripped at parse time. */ +const Escaped = createToken({ name: "Escaped", pattern: /\\[\s\S]/ }); + +/** Any run of non-reserved characters. Excludes: \\ = , | ! * [ ] and whitespace. */ +const Text = createToken({ name: "Text", pattern: /[^\\=,|!*[\]\s]+/ }); + +/** Range separator keyword. longer_alt ensures "TORONTO" stays a single Text token. */ +const TO = createToken({ name: "TO", pattern: /TO/, longer_alt: [Text] }); + +const Comma = createToken({ name: "Comma", pattern: /,/ }); +const Pipe = createToken({ name: "Pipe", pattern: /\|/ }); +const Bang = createToken({ name: "Bang", pattern: /!/ }); +const Asterisk = createToken({ name: "Asterisk", pattern: /\*/ }); +const LeftSquare = createToken({ name: "LeftSquare", pattern: /\[/ }); +const RightSquare = createToken({ name: "RightSquare", pattern: /\]/ }); +const Equals = createToken({ name: "Equals", pattern: /=/ }); + +/** Whitespace is silently skipped everywhere, including around TO in ranges. */ +const Whitespace = createToken({ + name: "Whitespace", + pattern: /\s+/, + group: Lexer.SKIPPED, +}); + +const ALL_TOKENS: TokenType[] = [ + Escaped, + TO, + Comma, + Pipe, + Bang, + Asterisk, + LeftSquare, + RightSquare, + Equals, + Whitespace, + Text, +]; + +const FILTER_LEXER = new Lexer(ALL_TOKENS); + +// ===== Helpers ===== + +/** Return the semantic value of a token, stripping the leading backslash from Escaped tokens. */ +function tokenValue(token: IToken): string { + let result: string; + if (token.tokenType === Escaped) { + result = token.image.slice(1); + } else { + result = token.image; + } + return result; +} + +// ===== Pre-processing: URL-decode and normalise ===== function decodeQueryComponent(value: string): string { const normalized = value.replace(/\+/g, " "); - let result: string; try { result = decodeURIComponent(normalized); } catch { result = normalized; } - return result; } -function unescapeValue(value: string): string { - let result = ""; +/** + * Split on unencoded ampersands first (so %26 inside values is preserved), + * URL-decode each field=value segment independently, then rejoin with commas + * so the Chevrotain grammar only needs a single separator token. + * + * Backslash-escaped ampersands (\&) are treated as literal ampersand + * characters and do NOT trigger a split. An even number of leading + * backslashes means the last backslash is itself escaped, so the `&` IS + * a separator (e.g. "foo\\&bar" → two parts, because `\\` is an escaped + * backslash and `&` is the field separator). + */ +function normalizeExpression(expression: string): string { + const withoutPrefix = expression.startsWith("?") + ? expression.slice(1) + : expression; - for (let i = 0; i < value.length; i++) { - const char = value[i]; + const rawParts: string[] = []; + let current = ""; - if (char === ESCAPE_CHAR) { - const nextChar = value[i + 1]; - if (nextChar !== undefined) { - result += nextChar; - i++; + for (let i = 0; i < withoutPrefix.length; i++) { + const char = withoutPrefix[i]; + if (char === "&") { + // Count how many backslashes immediately precede this `&`. + let backslashCount = 0; + let j = i - 1; + while (j >= 0 && withoutPrefix[j] === "\\") { + backslashCount++; + j--; + } + // An odd number of backslashes means this `&` is escaped. + if (backslashCount % 2 === 1) { + current += char; } else { - result += char; + rawParts.push(current); + current = ""; } - } else { - result += char; - } - } - - return result; -} - -function isEscaped(value: string, index: number): boolean { - let backslashCount = 0; - let cursor = index - 1; - - while (cursor >= 0 && value[cursor] === ESCAPE_CHAR) { - backslashCount++; - cursor--; - } - - return backslashCount % 2 === 1; -} - -function findFirstUnescaped(value: string, target: string): number { - let escaped = false; - let result = -1; - - for (let i = 0; i < value.length && result === -1; i++) { - const char = value[i]; - - if (escaped) { - escaped = false; - } else if (char === ESCAPE_CHAR) { - escaped = true; - } else if (char === target) { - result = i; - } - } - - return result; -} - -function splitByUnescapedSeparator( - expression: string, - separator: string, -): string[] { - const parts: string[] = []; - let depth = 0; - let escaped = false; - let current = ""; - - for (const char of expression) { - if (escaped) { - current += char; - escaped = false; - } else if (char === ESCAPE_CHAR) { - current += char; - escaped = true; - } else if (char === "(" || char === "[") { - depth++; - current += char; - } else if (char === ")" || char === "]") { - depth--; - current += char; - } else if (char === separator && depth === 0) { - parts.push(current); - current = ""; } else { current += char; } } - - if (current) { - parts.push(current); - } - - return parts; -} - -function hasLeadingWildcard(value: string): boolean { - return value.startsWith(WILDCARD_CHAR); -} - -function hasTrailingWildcard(value: string): boolean { - const lastIndex = value.length - 1; - const hasWildcard = value.endsWith(WILDCARD_CHAR); - let result = false; - - if (hasWildcard && lastIndex >= 0) { - result = !isEscaped(value, lastIndex); - } - - return result; -} - -function normalizeExpression(expression: string): string { - const withoutPrefix = expression.startsWith("?") - ? expression.slice(1) - : expression; - const queryParts = splitByUnescapedSeparator(withoutPrefix, "&") - .map((part) => part.trim()) - .filter((part) => part.length > 0); + rawParts.push(current); let result: string; - if (queryParts.length <= 1) { + if (rawParts.length <= 1) { result = decodeQueryComponent(withoutPrefix); } else { - const normalizedParts = queryParts.map((part) => { - const eqIndex = part.indexOf("="); - let normalizedPart: string; - - if (eqIndex === -1) { - normalizedPart = decodeQueryComponent(part); - } else { - const field = decodeQueryComponent(part.slice(0, eqIndex)); - const value = decodeQueryComponent(part.slice(eqIndex + 1)); - normalizedPart = `${field}=${value}`; - } - - return normalizedPart; - }); + const normalizedParts = rawParts + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => { + const eqIndex = part.indexOf("="); + let normalizedPart: string; + if (eqIndex === -1) { + normalizedPart = decodeQueryComponent(part); + } else { + const field = decodeQueryComponent(part.slice(0, eqIndex)); + const value = decodeQueryComponent(part.slice(eqIndex + 1)); + normalizedPart = `${field}=${value}`; + } + return normalizedPart; + }); result = normalizedParts.join(","); } @@ -192,208 +186,558 @@ function normalizeExpression(expression: string): string { return result; } -function parseRange(field: string, value: string): IFilter { - const match = RANGE_PATTERN.exec(value); - if (!match || !match.groups) { - throw new Error(`Invalid range expression: '${value}'`); - } - - const openBracket = match.groups["open"]; - const closeBracket = match.groups["close"]; - const minVal = match.groups["min"]; - const maxVal = match.groups["max"]; +// ===== Parser ===== - const filters: IFilter[] = []; - const trimmedMin = minVal?.trim() ?? ""; - const trimmedMax = maxVal?.trim() ?? ""; - const hasMin = trimmedMin !== "" && trimmedMin !== WILDCARD_CHAR; - const hasMax = trimmedMax !== "" && trimmedMax !== WILDCARD_CHAR; +interface FilterPart { + filter: IFilter; + field: string; +} - if (hasMin) { - if (openBracket === "[") { - filters.push(new GreaterThanOrEqualFilter(field, trimmedMin)); - } else { - filters.push(new GreaterThanFilter(field, trimmedMin)); - } +class DataFilterParser extends EmbeddedActionsParser { + /** Field name set by the most recent fieldAssignment rule. */ + private currentField: string = ""; + /** Options supplied for this parse call. */ + private currentOptions: FilterOptions | undefined; + /** Accumulated (filter, field) pairs, one per comma-separated input part. */ + private parsedParts: FilterPart[] = []; + /** Every distinct field name explicitly introduced in this parse call. */ + private encounteredFields: Set = new Set(); + + public constructor() { + super(ALL_TOKENS, { recoveryEnabled: false }); + this.performSelfAnalysis(); } - if (hasMax) { - if (closeBracket === "]") { - filters.push(new LessThanOrEqualFilter(field, trimmedMax)); - } else { - filters.push(new LessThanFilter(field, trimmedMax)); - } - } + // --------------------------------------------------------------------------- + // Grammar rules + // --------------------------------------------------------------------------- - if (filters.length === 0) { - throw new Error( - `Range expression must have at least one bound: '${value}'`, - ); - } + /** + * Top-level rule: one or more comma-separated field-or-value parts. + * Returns the combined IFilter. + */ + public readonly filterInput = this.RULE("filterInput", (): IFilter => { + this.SUBRULE(this.fieldOrValuePart); - const result: IFilter = - filters.length === 1 ? filters[0] : new AndFilter(filters); - return result; -} + this.MANY(() => { + this.CONSUME(Comma); + this.SUBRULE2(this.fieldOrValuePart); + }); -function parseValueExpression(field: string, value: string): IFilter { - const negated = value.startsWith(NEGATION_CHAR); - const actualValue = negated ? value.slice(1) : value; - const startsWithWildcard = hasLeadingWildcard(actualValue); - const endsWithWildcard = hasTrailingWildcard(actualValue); - - let result: IFilter; - - if (RANGE_PATTERN.test(actualValue)) { - result = parseRange(field, actualValue); - } else if ( - startsWithWildcard && - endsWithWildcard && - actualValue.length > MIN_WILDCARD_LENGTH - ) { - result = new ContainsFilter(field, unescapeValue(actualValue.slice(1, -1))); - } else if (startsWithWildcard) { - result = new EndsWithFilter(field, unescapeValue(actualValue.slice(1))); - } else if (endsWithWildcard) { - result = new StartsWithFilter( - field, - unescapeValue(actualValue.slice(0, -1)), + return this.ACTION(() => this.buildResult()); + }); + + /** + * Dispatches to fieldAssignment when the upcoming tokens look like + * "fieldName =", otherwise falls through to inheritedValuePart. + */ + private readonly fieldOrValuePart = this.RULE( + "fieldOrValuePart", + (): void => { + this.OR([ + { + GATE: () => this.isFieldAssignmentStart(), + ALT: () => { + this.SUBRULE(this.fieldAssignment); + }, + }, + { + ALT: () => { + this.SUBRULE(this.inheritedValuePart); + }, + }, + ]); + }, + ); + + /** Parses "fieldName = valueExpression" and records the result. */ + private readonly fieldAssignment = this.RULE( + "fieldAssignment", + (): void => { + const field = this.SUBRULE(this.fieldNameRule); + this.CONSUME(Equals); + this.ACTION(() => { + this.currentField = field; + this.encounteredFields.add(field); + }); + const filter = this.SUBRULE(this.valueExpression); + this.ACTION(() => { + this.parsedParts.push({ filter, field }); + }); + }, + ); + + /** Parses a value expression that inherits the field from the last assignment. */ + private readonly inheritedValuePart = this.RULE( + "inheritedValuePart", + (): void => { + const field = this.currentField; + this.ACTION(() => { + if (!field) { + throw new Error( + "Cannot determine field for expression part: no inherited field available", + ); + } + }); + const filter = this.SUBRULE(this.valueExpression); + this.ACTION(() => { + this.parsedParts.push({ filter, field }); + }); + }, + ); + + /** + * A value expression: either a range ([min TO max]) or a + * pipe-separated expression. + */ + private readonly valueExpression = this.RULE( + "valueExpression", + (): IFilter => { + let result: IFilter; + this.OR([ + { + GATE: () => { + const tt = this.LA(1).tokenType; + return tt === LeftSquare || tt === RightSquare; + }, + ALT: () => { + result = this.SUBRULE(this.rangeExpression); + }, + }, + { + ALT: () => { + result = this.SUBRULE(this.pipeExpression); + }, + }, + ]); + return result!; + }, + ); + + /** + * Pipe-separated alternatives: "a|b" produces OrFilter, "a|b|c" produces + * OneOfFilter. + */ + private readonly pipeExpression = this.RULE( + "pipeExpression", + (): IFilter => { + const first = this.SUBRULE(this.negatedExpr); + const parts: IFilter[] = [first]; + + this.MANY(() => { + this.CONSUME(Pipe); + parts.push(this.SUBRULE2(this.negatedExpr)); + }); + + let result: IFilter; + if (parts.length === 1) { + result = parts[0]; + } else if (parts.length === 2) { + result = new OrFilter(parts[0], parts[1]); + } else { + result = new OneOfFilter(parts); + } + return result; + }, + ); + + /** Optional "!" negation prefix followed by a primary expression. */ + private readonly negatedExpr = this.RULE("negatedExpr", (): IFilter => { + let negated = false; + this.OPTION(() => { + this.CONSUME(Bang); + this.ACTION(() => { + negated = true; + }); + }); + const inner = this.SUBRULE(this.primaryExpr); + const result: IFilter = this.ACTION(() => + negated ? new NotFilter(inner) : inner, ); - } else { - result = new EqualsFilter(field, unescapeValue(actualValue)); - } + return result; + }); + + /** + * Primary value expression: + * - "*val*" ContainsFilter + * - "*val" EndsWithFilter + * - "val*" StartsWithFilter + * - "val" EqualsFilter + */ + private readonly primaryExpr = this.RULE("primaryExpr", (): IFilter => { + const field = this.currentField; + let hasLeadingAsterisk = false; + let hasTrailingAsterisk = false; + + this.OPTION(() => { + this.CONSUME(Asterisk); + this.ACTION(() => { + hasLeadingAsterisk = true; + }); + }); - return negated ? new NotFilter(result) : result; -} + const val = this.SUBRULE(this.valueTextChunks); -function parseSingle(expression: string): IFilter { - const eqIndex = findFirstUnescaped(expression, "="); - if (eqIndex === -1) { - throw new Error( - `Invalid filter expression: '${expression}'. Expected 'field=value'.`, - ); - } - - const field = unescapeValue(expression.slice(0, eqIndex).trim()); - const value = expression.slice(eqIndex + 1).trim(); + this.OPTION2(() => { + this.CONSUME2(Asterisk); + this.ACTION(() => { + hasTrailingAsterisk = true; + }); + }); - if (!field) { - throw new Error( - `Field name must not be empty in expression: '${expression}'`, - ); - } + const result: IFilter = this.ACTION(() => { + if (hasLeadingAsterisk && hasTrailingAsterisk) { + return new ContainsFilter(field, val); + } + if (hasLeadingAsterisk) { + return new EndsWithFilter(field, val); + } + if (hasTrailingAsterisk) { + return new StartsWithFilter(field, val); + } + return new EqualsFilter(field, val); + }); - const orParts = splitByUnescapedSeparator(value, "|"); - - let result; - switch (orParts.length) { - case 0: - throw new Error(`Invalid filter expression: '${expression}'`); - case 1: - result = parseValueExpression(field, value); - break; - case 2: - result = new OrFilter( - parseValueExpression(field, orParts[0].trim()), - parseValueExpression(field, orParts[1].trim()), - ); - break; - default: - result = new OneOfFilter( - orParts.map((v) => parseValueExpression(field, v.trim())), - ); - break; - } + return result; + }); + + /** + * Range expression: ([|]) rangeValue TO rangeValue (]|[) + * + * Opening "[" means inclusive lower bound; opening "]" means exclusive. + * Closing "]" means inclusive upper bound; closing "[" means exclusive. + * "*" as a bound value means unbounded (no filter generated for that side). + */ + private readonly rangeExpression = this.RULE( + "rangeExpression", + (): IFilter => { + const field = this.currentField; + let openInclusive = true; + + this.OR([ + { + ALT: () => { + this.CONSUME(LeftSquare); + this.ACTION(() => { + openInclusive = true; + }); + }, + }, + { + ALT: () => { + this.CONSUME(RightSquare); + this.ACTION(() => { + openInclusive = false; + }); + }, + }, + ]); + + let minValue: string | undefined; + this.OR2([ + { + ALT: () => { + this.CONSUME(Asterisk); + }, + }, + { + ALT: () => { + minValue = this.SUBRULE(this.rangeValueChunks); + }, + }, + ]); + + this.CONSUME(TO); + + let maxValue: string | undefined; + this.OR3([ + { + ALT: () => { + this.CONSUME2(Asterisk); + }, + }, + { + ALT: () => { + maxValue = this.SUBRULE2(this.rangeValueChunks); + }, + }, + ]); + + let closeInclusive = true; + this.OR4([ + { + ALT: () => { + this.CONSUME2(RightSquare); + this.ACTION(() => { + closeInclusive = true; + }); + }, + }, + { + ALT: () => { + this.CONSUME2(LeftSquare); + this.ACTION(() => { + closeInclusive = false; + }); + }, + }, + ]); + + const result: IFilter = this.ACTION(() => { + const filters: IFilter[] = []; + + if (minValue !== undefined) { + filters.push( + openInclusive + ? new GreaterThanOrEqualFilter(field, minValue) + : new GreaterThanFilter(field, minValue), + ); + } - return result; -} + if (maxValue !== undefined) { + filters.push( + closeInclusive + ? new LessThanOrEqualFilter(field, maxValue) + : new LessThanFilter(field, maxValue), + ); + } -function splitAndParts(expression: string): string[] { - return splitByUnescapedSeparator(expression, ","); -} + if (filters.length === 0) { + throw new Error("Range expression must have at least one bound"); + } -function parseAndExpression( - expression: string, - options?: FilterOptions, -): IFilter { - const parts = splitAndParts(expression); - let result: IFilter; + return filters.length === 1 ? filters[0] : new AndFilter(filters); + }); + + return result; + }, + ); + + /** + * One or more text/escaped tokens for range bound values. + * The TO keyword is intentionally excluded so it acts as the separator. + * + * NOTE: CONSUME must remain at the top level of each ALT body (not inside + * ACTION) so that Chevrotain can discover the FIRST sets during the + * grammar recording phase. + */ + private readonly rangeValueChunks = this.RULE( + "rangeValueChunks", + (): string => { + let text = ""; + this.AT_LEAST_ONE(() => { + this.OR([ + { + ALT: () => { + text += tokenValue(this.CONSUME(Text)); + }, + }, + { + ALT: () => { + text += tokenValue(this.CONSUME(Escaped)); + }, + }, + ]); + }); + return text; + }, + ); + + /** + * One or more text/escaped/TO tokens for wildcard and literal values. + * TO is permitted as literal value text (e.g. "city=TO"). + * + * NOTE: CONSUME must remain at the top level of each ALT body (not inside + * ACTION) so that Chevrotain can discover the FIRST sets during the + * grammar recording phase. + */ + private readonly valueTextChunks = this.RULE( + "valueTextChunks", + (): string => { + let text = ""; + this.AT_LEAST_ONE(() => { + this.OR([ + { + ALT: () => { + text += tokenValue(this.CONSUME(Text)); + }, + }, + { + ALT: () => { + text += tokenValue(this.CONSUME(Escaped)); + }, + }, + { + ALT: () => { + text += this.CONSUME(TO).image; + }, + }, + ]); + }); + return text; + }, + ); + + /** + * One or more tokens forming a field name (Text, Escaped, or literal TO). + * + * NOTE: CONSUME must remain at the top level of each ALT body (not inside + * ACTION) so that Chevrotain can discover the FIRST sets during the + * grammar recording phase. + */ + private readonly fieldNameRule = this.RULE("fieldNameRule", (): string => { + let name = ""; + this.AT_LEAST_ONE(() => { + this.OR([ + { + ALT: () => { + name += tokenValue(this.CONSUME(Text)); + }, + }, + { + ALT: () => { + name += tokenValue(this.CONSUME(Escaped)); + }, + }, + { + ALT: () => { + name += this.CONSUME(TO).image; + }, + }, + ]); + }); + return name; + }); + + // --------------------------------------------------------------------------- + // Lookahead helpers + // --------------------------------------------------------------------------- + + /** + * Returns true when the upcoming tokens look like "fieldName =", i.e. at + * least one Text/Escaped/TO token followed immediately by Equals. + */ + private isFieldAssignmentStart(): boolean { + let i = 1; + let tt = this.LA(i).tokenType; + let hasFieldToken = false; + + while (tt === Text || tt === TO || tt === Escaped) { + hasFieldToken = true; + i++; + tt = this.LA(i).tokenType; + } - if (parts.length <= 1) { - result = parseSingle(expression); - } else { - let inheritedField: string | null = null; - const encounteredFields = new Set(); - const groupedFilters: IFilter[] = []; - let currentGroup: IFilter[] = []; - - const flushCurrentGroup = (): void => { - if (currentGroup.length === 1) { - groupedFilters.push(currentGroup[0]); - } else if (currentGroup.length > 1) { - groupedFilters.push(new AndFilter(currentGroup)); - } + return hasFieldToken && tt === Equals; + } - currentGroup = []; - }; + // --------------------------------------------------------------------------- + // Result assembly + // --------------------------------------------------------------------------- + + /** + * Combines all accumulated parsedParts into a single IFilter: + * - consecutive parts sharing the same field are AND-ed within a group + * - groups are then combined with AND or OR per options.logic + */ + private buildResult(): IFilter { + if (this.parsedParts.length === 0) { + throw new Error("No filter parts parsed"); + } - for (const p of parts) { - const trimmed = p.trim(); - const eqIndex = findFirstUnescaped(trimmed, "="); - let parsedFilter: IFilter; + if (this.parsedParts.length === 1) { + return this.parsedParts[0].filter; + } - if (eqIndex !== -1) { - const field = unescapeValue(trimmed.slice(0, eqIndex).trim()); + // Group consecutive same-field parts. + const groups: Array<{ filters: IFilter[]; field: string }> = []; + let currentGroup: { filters: IFilter[]; field: string } | null = null; - if (inheritedField !== null && field !== inheritedField) { - flushCurrentGroup(); + for (const part of this.parsedParts) { + if (currentGroup === null || part.field !== currentGroup.field) { + if (currentGroup !== null) { + groups.push(currentGroup); } - - inheritedField = field; - encounteredFields.add(field); - parsedFilter = parseSingle(trimmed); + currentGroup = { filters: [part.filter], field: part.field }; } else { - if (!inheritedField) { - throw new Error( - `Cannot determine field for expression part: '${trimmed}'`, - ); - } - - parsedFilter = parseValueExpression(inheritedField, trimmed); + currentGroup.filters.push(part.filter); } + } - currentGroup.push(parsedFilter); + if (currentGroup !== null) { + groups.push(currentGroup); } - flushCurrentGroup(); + // AND-combine filters within each group. + const groupFilters: IFilter[] = groups.map((g) => + g.filters.length === 1 ? g.filters[0] : new AndFilter(g.filters), + ); + + const isSameField = this.encounteredFields.size <= 1; + const logic = this.currentOptions?.logic ?? FilterLogic.And; - // Multiple values targeting the same field are always AND-ed together. - // The global logic only applies when combining different field groups. - const isSameField = encounteredFields.size <= 1; - const logic = options?.logic ?? FilterLogic.And; + let result: IFilter; if (isSameField || logic === FilterLogic.And) { result = - groupedFilters.length === 1 - ? groupedFilters[0] - : new AndFilter(groupedFilters); + groupFilters.length === 1 + ? groupFilters[0] + : new AndFilter(groupFilters); + } else if (groupFilters.length === 2) { + result = new OrFilter(groupFilters[0], groupFilters[1]); } else { - if (groupedFilters.length === 2) { - result = new OrFilter(groupedFilters[0], groupedFilters[1]); - } else { - result = new OneOfFilter(groupedFilters); - } + result = new OneOfFilter(groupFilters); } + + return result; } - return result; + // --------------------------------------------------------------------------- + // Public parsing entry point + // --------------------------------------------------------------------------- + + /** + * Reset all per-call state, lex the expression, run the parser and return + * the assembled IFilter. Throws on lexer or parser errors. + */ + public parseExpression( + expression: string, + options?: FilterOptions, + ): IFilter { + this.currentField = ""; + this.currentOptions = options; + this.parsedParts = []; + this.encounteredFields = new Set(); + + const lexResult = FILTER_LEXER.tokenize(expression); + + if (lexResult.errors.length > 0) { + throw new Error( + `Lexer error at offset ${lexResult.errors[0].offset}: ${lexResult.errors[0].message}`, + ); + } + + this.input = lexResult.tokens; + const result = this.filterInput(); + + if (this.errors.length > 0) { + throw new Error(`Parse error: ${this.errors[0].message}`); + } + + return result; + } } +// Single shared instance — performSelfAnalysis is expensive and runs only once. +const PARSER_INSTANCE = new DataFilterParser(); + /** * Parse a DataFilters query string into an IFilter. * - * @param expression - A filter expression string, e.g. `"name=*bat*` or `"name=*bat*&age=[18 TO *["`. - * @param options - Optional parsing options that control how multiple criteria are combined. + * @param expression - A filter expression string, e.g. "name=*bat*" or + * "name=*bat*&age=[18 TO *[". + * @param options - Optional parsing options that control how multiple + * criteria from different fields are combined. * @returns An IFilter representing the parsed expression. * @throws {Error} If the expression cannot be parsed. */ @@ -403,6 +747,6 @@ export function parse(expression: string, options?: FilterOptions): IFilter { } const trimmed = expression.trim(); - const normalizedExpression = normalizeExpression(trimmed); - return parseAndExpression(normalizedExpression, options); + const normalized = normalizeExpression(trimmed); + return PARSER_INSTANCE.parseExpression(normalized, options); } From b18efc7863a1439912c7c44a1c9c93c06b88742b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:44:13 +0000 Subject: [PATCH 27/28] refactor: rename DataFilterParser to InternalFilterParser for clarity Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/1f0ef57d-c61d-4d8c-b034-50a421596596 Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- src/typescript/src/parser.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index 79b9a204..024e0f0c 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -193,7 +193,7 @@ interface FilterPart { field: string; } -class DataFilterParser extends EmbeddedActionsParser { +class InternalFilterParser extends EmbeddedActionsParser { /** Field name set by the most recent fieldAssignment rule. */ private currentField: string = ""; /** Options supplied for this parse call. */ @@ -729,7 +729,7 @@ class DataFilterParser extends EmbeddedActionsParser { } // Single shared instance — performSelfAnalysis is expensive and runs only once. -const PARSER_INSTANCE = new DataFilterParser(); +const PARSER_INSTANCE = new InternalFilterParser(); /** * Parse a DataFilters query string into an IFilter. From d73989136e461ad78c0d74f72bbfcd4e39f88e35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 06:36:08 +0000 Subject: [PATCH 28/28] fix: single-exit in primaryExpr ACTION + add Arrange/Act/Assert to builder tests Agent-Logs-Url: https://github.com/candoumbe/DataFilters/sessions/7433b24c-48ff-4666-99d9-6685981aa6ca Co-authored-by: candoumbe <13941078+candoumbe@users.noreply.github.com> --- src/typescript/src/parser.ts | 17 ++--- src/typescript/test/builder.test.ts | 97 ++++++++++++++++++++++++----- 2 files changed, 89 insertions(+), 25 deletions(-) diff --git a/src/typescript/src/parser.ts b/src/typescript/src/parser.ts index 024e0f0c..971051ce 100644 --- a/src/typescript/src/parser.ts +++ b/src/typescript/src/parser.ts @@ -386,16 +386,17 @@ class InternalFilterParser extends EmbeddedActionsParser { }); const result: IFilter = this.ACTION(() => { + let filter: IFilter; if (hasLeadingAsterisk && hasTrailingAsterisk) { - return new ContainsFilter(field, val); - } - if (hasLeadingAsterisk) { - return new EndsWithFilter(field, val); - } - if (hasTrailingAsterisk) { - return new StartsWithFilter(field, val); + filter = new ContainsFilter(field, val); + } else if (hasLeadingAsterisk) { + filter = new EndsWithFilter(field, val); + } else if (hasTrailingAsterisk) { + filter = new StartsWithFilter(field, val); + } else { + filter = new EqualsFilter(field, val); } - return new EqualsFilter(field, val); + return filter; }); return result; diff --git a/src/typescript/test/builder.test.ts b/src/typescript/test/builder.test.ts index 977a7400..d80433b9 100644 --- a/src/typescript/test/builder.test.ts +++ b/src/typescript/test/builder.test.ts @@ -15,51 +15,105 @@ import { FilterBuilder } from "../src/builder"; describe("FilterBuilder - single conditions", () => { it("should build equals filter", () => { - const result = new FilterBuilder().where("name").eq("Batman").build(); + // Arrange + const builder = new FilterBuilder().where("name").eq("Batman"); + + // Act + const result = builder.build(); + + // Assert expect(result).toBeInstanceOf(EqualsFilter); expect((result as EqualsFilter).field).toBe("name"); expect((result as EqualsFilter).value).toBe("Batman"); }); it("should build contains filter", () => { - const result = new FilterBuilder().where("name").contains("bat").build(); + // Arrange + const builder = new FilterBuilder().where("name").contains("bat"); + + // Act + const result = builder.build(); + + // Assert expect(result).toBeInstanceOf(ContainsFilter); expect((result as ContainsFilter).value).toBe("bat"); }); it("should build startsWith filter", () => { - const result = new FilterBuilder().where("name").startsWith("Bat").build(); + // Arrange + const builder = new FilterBuilder().where("name").startsWith("Bat"); + + // Act + const result = builder.build(); + + // Assert expect(result).toBeInstanceOf(StartsWithFilter); }); it("should build endsWith filter", () => { - const result = new FilterBuilder().where("name").endsWith("man").build(); + // Arrange + const builder = new FilterBuilder().where("name").endsWith("man"); + + // Act + const result = builder.build(); + + // Assert expect(result).toBeInstanceOf(EndsWithFilter); }); it("should build gt filter", () => { - const result = new FilterBuilder().where("age").gt(18).build(); + // Arrange + const builder = new FilterBuilder().where("age").gt(18); + + // Act + const result = builder.build(); + + // Assert expect(result).toBeInstanceOf(GreaterThanFilter); expect((result as GreaterThanFilter).value).toBe(18); }); it("should build gte filter", () => { - const result = new FilterBuilder().where("age").gte(18).build(); + // Arrange + const builder = new FilterBuilder().where("age").gte(18); + + // Act + const result = builder.build(); + + // Assert expect(result).toBeInstanceOf(GreaterThanOrEqualFilter); }); it("should build lt filter", () => { - const result = new FilterBuilder().where("age").lt(65).build(); + // Arrange + const builder = new FilterBuilder().where("age").lt(65); + + // Act + const result = builder.build(); + + // Assert expect(result).toBeInstanceOf(LessThanFilter); }); it("should build lte filter", () => { - const result = new FilterBuilder().where("age").lte(65).build(); + // Arrange + const builder = new FilterBuilder().where("age").lte(65); + + // Act + const result = builder.build(); + + // Assert expect(result).toBeInstanceOf(LessThanOrEqualFilter); }); it("should build not filter", () => { - const result = new FilterBuilder().where("deleted").not(true).build(); + // Arrange + const builder = new FilterBuilder().where("deleted").not(true); + + // Act + const result = builder.build(); + + // Assert expect(result).toBeInstanceOf(NotFilter); expect((result as NotFilter).filter).toBeInstanceOf(EqualsFilter); }); @@ -67,22 +121,27 @@ describe("FilterBuilder - single conditions", () => { describe("FilterBuilder - multiple conditions", () => { it("should combine two conditions as AND", () => { - const result = new FilterBuilder() + // Arrange + const builder = new FilterBuilder() .where("name") .contains("bat") .andWhere("age") - .gte(18) - .build(); + .gte(18); + + // Act + const result = builder.build(); + + // Assert expect(result).toBeInstanceOf(AndFilter); expect((result as AndFilter).filters.length).toBe(2); }); it("should build OR filter", () => { + // Arrange + const builder = new FilterBuilder().where("status").or("active", "pending"); + // Act - const result = new FilterBuilder() - .where("status") - .or("active", "pending") - .build(); + const result = builder.build(); // Assert expect(result).toBeInstanceOf(OrFilter); @@ -101,6 +160,10 @@ describe("FilterBuilder - multiple conditions", () => { describe("FilterBuilder - errors", () => { it("should throw when no conditions added", () => { - expect(() => new FilterBuilder().build()).toThrow(); + // Arrange + const builder = new FilterBuilder(); + + // Act & Assert + expect(() => builder.build()).toThrow(); }); });