diff --git a/.github/actions/cached-node-modules/action.yml b/.github/actions/cached-node-modules/action.yml index 38d6e1e35c..bddc11a9dd 100644 --- a/.github/actions/cached-node-modules/action.yml +++ b/.github/actions/cached-node-modules/action.yml @@ -45,5 +45,6 @@ runs: npm run build -w packages/parameters & \ npm run build -w packages/idempotency & \ npm run build -w packages/batch & \ - npm run build -w packages/testing + npm run build -w packages/testing & \ + npm run build -w packages/jmespath shell: bash \ No newline at end of file diff --git a/.github/workflows/reusable-run-linting-check-and-unit-tests.yml b/.github/workflows/reusable-run-linting-check-and-unit-tests.yml index 519ab4845f..0bea6c8fca 100644 --- a/.github/workflows/reusable-run-linting-check-and-unit-tests.yml +++ b/.github/workflows/reusable-run-linting-check-and-unit-tests.yml @@ -28,9 +28,9 @@ jobs: with: nodeVersion: ${{ matrix.version }} - name: Run linting - run: npm run lint -w packages/commons -w packages/logger -w packages/tracer -w packages/metrics -w packages/parameters -w packages/idempotency -w packages/batch + run: npm run lint -w packages/commons -w packages/logger -w packages/tracer -w packages/metrics -w packages/parameters -w packages/idempotency -w packages/batch -w packages/jmespath - name: Run unit tests - run: npm t -w packages/commons -w packages/logger -w packages/tracer -w packages/metrics -w packages/parameters -w packages/idempotency -w packages/batch + run: npm t -w packages/commons -w packages/logger -w packages/tracer -w packages/metrics -w packages/parameters -w packages/idempotency -w packages/batch -w packages/jmespath check-examples: runs-on: ubuntu-latest env: diff --git a/docs/requirements.txt b/docs/requirements.txt index dd8d23a0bb..9a961348e6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -462,4 +462,4 @@ watchdog==3.0.0 \ --hash=sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64 \ --hash=sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44 \ --hash=sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33 - # via mkdocs + # via mkdocs \ No newline at end of file diff --git a/docs/snippets/jmespath/extractDataFromBuiltinEnvelope.json b/docs/snippets/jmespath/extractDataFromBuiltinEnvelope.json new file mode 100644 index 0000000000..9357e9d4b6 --- /dev/null +++ b/docs/snippets/jmespath/extractDataFromBuiltinEnvelope.json @@ -0,0 +1,20 @@ +{ + "Records": [ + { + "messageId": "19dd0b57-b21e-4ac1-bd88-01bbb068cb78", + "receiptHandle": "MessageReceiptHandle", + "body": "{\"customerId\":\"dd4649e6-2484-4993-acb8-0f9123103394\",\"booking\":{\"id\":\"5b2c4803-330b-42b7-811a-c68689425de1\",\"reference\":\"ySz7oA\",\"outboundFlightId\":\"20c0d2f2-56a3-4068-bf20-ff7703db552d\"},\"payment\":{\"receipt\":\"https://pay.stripe.com/receipts/acct_1Dvn7pF4aIiftV70/ch_3JTC14F4aIiftV700iFq2CHB/rcpt_K7QsrFln9FgFnzUuBIiNdkkRYGxUL0X\",\"amount\":100}}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1523232000000", + "SenderId": "123456789012", + "ApproximateFirstReceiveTimestamp": "1523232000001" + }, + "messageAttributes": {}, + "md5OfBody": "7b270e59b47ff90a553787216d55d91d", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:MyQueue", + "awsRegion": "us-east-1" + } + ] +} diff --git a/docs/snippets/jmespath/extractDataFromBuiltinEnvelope.ts b/docs/snippets/jmespath/extractDataFromBuiltinEnvelope.ts new file mode 100644 index 0000000000..6cd3102f7a --- /dev/null +++ b/docs/snippets/jmespath/extractDataFromBuiltinEnvelope.ts @@ -0,0 +1,21 @@ +import { + extractDataFromEnvelope, + SQS, +} from '@aws-lambda-powertools/jmespath/envelopes'; +import { Logger } from '@aws-lambda-powertools/logger'; +import type { SQSEvent } from 'aws-lambda'; + +const logger = new Logger(); + +type MessageBody = { + customerId: string; +}; + +export const handler = async (event: SQSEvent): Promise => { + const records = extractDataFromEnvelope>(event, SQS); + for (const record of records) { + // records is now a list containing the deserialized body of each message + const { customerId } = record; + logger.appendKeys({ customerId }); + } +}; diff --git a/docs/snippets/jmespath/extractDataFromEnvelope.json b/docs/snippets/jmespath/extractDataFromEnvelope.json new file mode 100644 index 0000000000..a802778bf7 --- /dev/null +++ b/docs/snippets/jmespath/extractDataFromEnvelope.json @@ -0,0 +1,8 @@ +{ + "body": "{\"customerId\":\"dd4649e6-2484-4993-acb8-0f9123103394\"}", + "deeplyNested": [ + { + "someData": [1, 2, 3] + } + ] +} diff --git a/docs/snippets/jmespath/extractDataFromEnvelope.ts b/docs/snippets/jmespath/extractDataFromEnvelope.ts new file mode 100644 index 0000000000..2d0f9bccf5 --- /dev/null +++ b/docs/snippets/jmespath/extractDataFromEnvelope.ts @@ -0,0 +1,31 @@ +import { extractDataFromEnvelope } from '@aws-lambda-powertools/jmespath/envelopes'; + +type MyEvent = { + body: string; // "{\"customerId\":\"dd4649e6-2484-4993-acb8-0f9123103394\"}" + deeplyNested: Array<{ someData: number[] }>; +}; + +type MessageBody = { + customerId: string; +}; + +export const handler = async (event: MyEvent): Promise => { + const payload = extractDataFromEnvelope( + event, + 'powertools_json(body)' + ); + const { customerId } = payload; // now deserialized + + // also works for fetching and flattening deeply nested data + const someData = extractDataFromEnvelope( + event, + 'deeplyNested[*].someData[]' + ); + + return { + customerId, + message: 'success', + context: someData, + statusCode: 200, + }; +}; diff --git a/docs/snippets/jmespath/powertoolsBase64GzipJmespath.ts b/docs/snippets/jmespath/powertoolsBase64GzipJmespath.ts new file mode 100644 index 0000000000..5400b9059a --- /dev/null +++ b/docs/snippets/jmespath/powertoolsBase64GzipJmespath.ts @@ -0,0 +1,13 @@ +import { extractDataFromEnvelope } from '@aws-lambda-powertools/jmespath/envelopes'; +import { Logger } from '@aws-lambda-powertools/logger'; + +const logger = new Logger(); + +export const handler = async (event: { payload: string }): Promise => { + const logGroup = extractDataFromEnvelope( + event, + 'powertools_base64_gzip(payload) | powertools_json(@).logGroup' + ); + + logger.info('Log group name', { logGroup }); +}; diff --git a/docs/snippets/jmespath/powertoolsBase64GzipJmespathPayload.json b/docs/snippets/jmespath/powertoolsBase64GzipJmespathPayload.json new file mode 100644 index 0000000000..470fb13c2e --- /dev/null +++ b/docs/snippets/jmespath/powertoolsBase64GzipJmespathPayload.json @@ -0,0 +1,3 @@ +{ + "payload": "H4sIACZAXl8C/52PzUrEMBhFX2UILpX8tPbHXWHqIOiq3Q1F0ubrWEiakqTWofTdTYYB0YWL2d5zvnuTFellBIOedoiyKH5M0iwnlKH7HZL6dDB6ngLDfLFYctUKjie9gHFaS/sAX1xNEq525QxwFXRGGMEkx4Th491rUZdV3YiIZ6Ljfd+lfSyAtZloacQgAkqSJCGhxM6t7cwwuUGPz4N0YKyvO6I9WDeMPMSo8Z4Ca/kJ6vMEYW5f1MX7W1lVxaG8vqX8hNFdjlc0iCBBSF4ERT/3Pl7RbMGMXF2KZMh/C+gDpNS7RRsp0OaRGzx0/t8e0jgmcczyLCWEePhni/23JWalzjdu0a3ZvgEaNLXeugEAAA==" +} diff --git a/docs/snippets/jmespath/powertoolsBase64Jmespath.ts b/docs/snippets/jmespath/powertoolsBase64Jmespath.ts new file mode 100644 index 0000000000..46e0d7bbf3 --- /dev/null +++ b/docs/snippets/jmespath/powertoolsBase64Jmespath.ts @@ -0,0 +1,13 @@ +import { extractDataFromEnvelope } from '@aws-lambda-powertools/jmespath/envelopes'; +import { Logger } from '@aws-lambda-powertools/logger'; + +const logger = new Logger(); + +export const handler = async (event: { payload: string }): Promise => { + const data = extractDataFromEnvelope( + event, + 'powertools_json(powertools_base64(payload))' + ); + + logger.info('Decoded payload', { data }); +}; diff --git a/docs/snippets/jmespath/powertoolsBase64JmespathPayload.json b/docs/snippets/jmespath/powertoolsBase64JmespathPayload.json new file mode 100644 index 0000000000..eae0118a5c --- /dev/null +++ b/docs/snippets/jmespath/powertoolsBase64JmespathPayload.json @@ -0,0 +1,3 @@ +{ + "payload": "eyJ1c2VyX2lkIjogMTIzLCAicHJvZHVjdF9pZCI6IDEsICJxdWFudGl0eSI6IDIsICJwcmljZSI6IDEwLjQwLCAiY3VycmVuY3kiOiAiVVNEIn0=" +} diff --git a/docs/snippets/jmespath/powertoolsCustomFunction.json b/docs/snippets/jmespath/powertoolsCustomFunction.json new file mode 100644 index 0000000000..0d098b0c78 --- /dev/null +++ b/docs/snippets/jmespath/powertoolsCustomFunction.json @@ -0,0 +1,9 @@ +{ + "Records": [ + { + "application": "app", + "datetime": "2022-01-01T00:00:00.000Z", + "notification": "GyYA+AXhZKk/K5DkanoQSTYpSKMwwxXh8DRWVo9A1hLqAQ==" + } + ] +} diff --git a/docs/snippets/jmespath/powertoolsCustomFunction.ts b/docs/snippets/jmespath/powertoolsCustomFunction.ts new file mode 100644 index 0000000000..6328cb3ca1 --- /dev/null +++ b/docs/snippets/jmespath/powertoolsCustomFunction.ts @@ -0,0 +1,31 @@ +import { fromBase64 } from '@aws-lambda-powertools/commons/utils/base64'; +import { extractDataFromEnvelope } from '@aws-lambda-powertools/jmespath/envelopes'; +import { PowertoolsFunctions } from '@aws-lambda-powertools/jmespath/functions'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { brotliDecompressSync } from 'node:zlib'; + +const logger = new Logger(); + +// prettier-ignore +class CustomFunctions extends PowertoolsFunctions { + @PowertoolsFunctions.signature({ // (1)! + argumentsSpecs: [['string']], + variadic: false, + }) + public funcDecodeBrotliCompression(value: string): string { // (2)! + const encoded = fromBase64(value, 'base64'); + const uncompressed = brotliDecompressSync(encoded); + + return uncompressed.toString(); + } +} + +export const handler = async (event: { payload: string }): Promise => { + const message = extractDataFromEnvelope( + event, + 'Records[*].decode_brotli_compression(notification) | [*].powertools_json(@).message', + { customFunctions: new CustomFunctions() } + ); + + logger.info('Decoded message', { message }); +}; diff --git a/docs/snippets/jmespath/powertoolsJsonIdempotencyJmespath.json b/docs/snippets/jmespath/powertoolsJsonIdempotencyJmespath.json new file mode 100644 index 0000000000..0534d6bacd --- /dev/null +++ b/docs/snippets/jmespath/powertoolsJsonIdempotencyJmespath.json @@ -0,0 +1,30 @@ +{ + "version": "2.0", + "routeKey": "ANY /createpayment", + "rawPath": "/createpayment", + "rawQueryString": "", + "headers": { + "Header1": "value1", + "Header2": "value2" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "api-id", + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "http": { + "method": "POST", + "path": "/createpayment", + "protocol": "HTTP/1.1", + "sourceIp": "ip", + "userAgent": "agent" + }, + "requestId": "id", + "routeKey": "ANY /createpayment", + "stage": "$default", + "time": "10/Feb/2021:13:40:43 +0000", + "timeEpoch": 1612964443723 + }, + "body": "{\"user\":\"xyz\",\"product_id\":\"123456789\"}", + "isBase64Encoded": false +} diff --git a/docs/snippets/jmespath/powertoolsJsonIdempotencyJmespath.ts b/docs/snippets/jmespath/powertoolsJsonIdempotencyJmespath.ts new file mode 100644 index 0000000000..5ce144a109 --- /dev/null +++ b/docs/snippets/jmespath/powertoolsJsonIdempotencyJmespath.ts @@ -0,0 +1,51 @@ +import { + IdempotencyConfig, + makeIdempotent, +} from '@aws-lambda-powertools/idempotency'; +import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb'; +import type { APIGatewayEvent } from 'aws-lambda'; +import { randomUUID } from 'node:crypto'; + +const persistenceStore = new DynamoDBPersistenceLayer({ + tableName: 'IdempotencyTable', +}); + +export const handler = makeIdempotent( + async (event: APIGatewayEvent) => { + const body = JSON.parse(event.body || '{}'); + const { user, productId } = body; + + const result = await createSubscriptionPayment(user, productId); + + return { + statusCode: 200, + body: JSON.stringify({ + paymentId: result.id, + message: 'success', + }), + }; + }, + { + persistenceStore, + config: new IdempotencyConfig({ + eventKeyJmesPath: 'powertools_json(body)', + }), + } +); + +const createSubscriptionPayment = async ( + user: string, + productId: string +): Promise<{ id: string; message: string }> => { + const payload = { user, productId }; + const response = await fetch('https://httpbin.org/anything', { + method: 'POST', + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error('Failed to create subscription payment'); + } + + return { id: randomUUID(), message: 'paid' }; +}; diff --git a/docs/snippets/package.json b/docs/snippets/package.json index 23938b565c..2f53ed698f 100644 --- a/docs/snippets/package.json +++ b/docs/snippets/package.json @@ -40,4 +40,4 @@ "axios": "^1.6.7", "hashi-vault-js": "^0.4.14" } -} +} \ No newline at end of file diff --git a/docs/snippets/tsconfig.json b/docs/snippets/tsconfig.json index 1a3fe8b171..d6aec30ce7 100644 --- a/docs/snippets/tsconfig.json +++ b/docs/snippets/tsconfig.json @@ -27,7 +27,11 @@ "@aws-lambda-powertools/idempotency/middleware": [ "../../packages/idempotency/lib/middleware" ], - "@aws-lambda-powertools/batch": ["../../packages/batch/lib"] + "@aws-lambda-powertools/batch": ["../../packages/batch/lib"], + "@aws-lambda-powertools/jmespath": ["../../packages/jmespath/lib"], + "@aws-lambda-powertools/jmespath/envelopes": [ + "../../packages/jmespath/lib/envelopes" + ] } } } diff --git a/docs/utilities/jmespath.md b/docs/utilities/jmespath.md new file mode 100644 index 0000000000..4cf9fe3b5f --- /dev/null +++ b/docs/utilities/jmespath.md @@ -0,0 +1,168 @@ +--- +title: JMESPath Functions +description: Utility +--- + +???+ tip + JMESPath is a query language for JSON used by tools like the AWS CLI and Powertools for AWS Lambda (TypeScript). + +Built-in [JMESPath](https://jmespath.org/){target="_blank" rel="nofollow"} Functions to easily deserialize common encoded JSON payloads in Lambda functions. + +## Key features + +* Deserialize JSON from JSON strings, base64, and compressed data +* Use JMESPath to extract and combine data recursively +* Provides commonly used JMESPath expression with popular event sources + +## Getting started + +You might have events that contains encoded JSON payloads as string, base64, or even in compressed format. It is a common use case to decode and extract them partially or fully as part of your Lambda function invocation. + +Powertools for AWS Lambda (TypeScript) also have utilities like [idempotency](idempotency.md){target="_blank"} where you might need to extract a portion of your data before using them. + +???+ info "Terminology" + **Envelope** is the terminology we use for the **JMESPath expression** to extract your JSON object from your data input. We might use those two terms interchangeably. + +### Extracting data + +You can use the `extractDataFromEnvelope` function with any [JMESPath expression](https://jmespath.org/tutorial.html){target="_blank" rel="nofollow"}. + +???+ tip + Another common use case is to fetch deeply nested data, filter, flatten, and more. + +=== "extractDataFromBuiltinEnvelope.ts" + ```typescript hl_lines="1 13 17 20 22" + --8<-- "docs/snippets/jmespath/extractDataFromEnvelope.ts" + ``` + +=== "extractDataFromEnvelope.json" + + ```json + --8<-- "docs/snippets/jmespath/extractDataFromEnvelope.json" + ``` + +### Built-in envelopes + +We provide built-in envelopes for popular AWS Lambda event sources to easily decode and/or deserialize JSON objects. + +=== "extractDataFromBuiltinEnvelope.ts" + ```typescript hl_lines="2-3 15" + --8<-- "docs/snippets/jmespath/extractDataFromBuiltinEnvelope.ts" + ``` + +=== "extractDataFromBuiltinEnvelope.json" + + ```json hl_lines="6 15" + --8<-- "docs/snippets/jmespath/extractDataFromBuiltinEnvelope.json" + ``` + +These are all built-in envelopes you can use along with their expression as a reference: + +| Envelope | JMESPath expression | +| --------------------------------- | ----------------------------------------------------------------------------------------- | +| **`API_GATEWAY_HTTP`** | `powertools_json(body)` | +| **`API_GATEWAY_REST`** | `powertools_json(body)` | +| **`CLOUDWATCH_EVENTS_SCHEDULED`** | `detail` | +| **`CLOUDWATCH_LOGS`** | `awslogs.powertools_base64_gzip(data) | powertools_json(@).logEvents[*]` | +| **`EVENTBRIDGE`** | `detail` | +| **`KINESIS_DATA_STREAM`** | `Records[*].kinesis.powertools_json(powertools_base64(data))` | +| **`S3_EVENTBRIDGE_SQS`** | `Records[*].powertools_json(body).detail` | +| **`S3_KINESIS_FIREHOSE`** | `records[*].powertools_json(powertools_base64(data)).Records[0]` | +| **`S3_SNS_KINESIS_FIREHOSE`** | `records[*].powertools_json(powertools_base64(data)).powertools_json(Message).Records[0]` | +| **`S3_SNS_SQS`** | `Records[*].powertools_json(body).powertools_json(Message).Records[0]` | +| **`S3_SQS`** | `Records[*].powertools_json(body).Records[0]` | +| **`SNS`** | `Records[0].Sns.Message | powertools_json(@)` | +| **`SQS`** | `Records[*].powertools_json(body)` | + +???+ tip "Using SNS?" + If you don't require SNS metadata, enable [raw message delivery](https://docs.aws.amazon.com/sns/latest/dg/sns-large-payload-raw-message-delivery.html). It will reduce multiple payload layers and size, when using SNS in combination with other services (_e.g., SQS, S3, etc_). + +## Advanced + +### Built-in JMESPath functions + +You can use our built-in JMESPath functions within your envelope expression. They handle deserialization for common data formats found in AWS Lambda event sources such as JSON strings, base64, and uncompress gzip data. + +#### powertools_json function + +Use `powertools_json` function to decode any JSON string anywhere a JMESPath expression is allowed. + +> **Idempotency scenario** + +This sample will deserialize the JSON string within the `body` key before [Idempotency](./idempotency.md){target="_blank"} processes it. + +=== "powertoolsJsonIdempotencyJmespath.ts" + + ```ts hl_lines="31" + --8<-- "docs/snippets/jmespath/powertoolsJsonIdempotencyJmespath.ts" + ``` + +=== "powertoolsJsonIdempotencyJmespath.json" + + ```json hl_lines="28" + --8<-- "docs/snippets/jmespath/powertoolsJsonIdempotencyJmespath.json" + ``` + +#### powertools_base64 function + +Use `powertools_base64` function to decode any base64 data. + +This sample will decode the base64 value within the `data` key, and deserialize the JSON string before processing. + +=== "powertoolsBase64Jmespath.ts" + + ```ts hl_lines="9" + --8<-- "docs/snippets/jmespath/powertoolsBase64Jmespath.ts" + ``` + +=== "powertoolsBase64JmespathPayload.json" + + ```json + --8<-- "docs/snippets/jmespath/powertoolsBase64JmespathPayload.json" + ``` + +#### powertools_base64_gzip function + +Use `powertools_base64_gzip` function to decompress and decode base64 data. + +This sample will decompress and decode base64 data from Cloudwatch Logs, then use JMESPath pipeline expression to pass the result for decoding its JSON string. + +=== "powertoolsBase64GzipJmespath.ts" + + ```ts hl_lines="9" + --8<-- "docs/snippets/jmespath/powertoolsBase64GzipJmespath.ts" + ``` + +=== "powertoolsBase64GzipJmespathPayload.json" + + ```json + --8<-- "docs/snippets/jmespath/powertoolsBase64GzipJmespathPayload.json" + ``` + +### Bring your own JMESPath function + +???+ warning + This should only be used for advanced use cases where you have special formats not covered by the built-in functions. + +For special binary formats that you want to decode before processing, you can bring your own JMESPath function by extending the `PowertoolsFunctions` class. + +Here is an example of how to decompress messages compressed using the [Brotli compression algorithm](https://nodejs.org/api/zlib.html#zlibbrotlidecompressbuffer-options-callback){target="_blank" rel="nofollow"}: + +=== "PowertoolsCustomFunction.ts" + + ```ts hl_lines="3 9 25-26" + --8<-- + docs/snippets/jmespath/powertoolsCustomFunction.ts::8 + docs/snippets/jmespath/powertoolsCustomFunction.ts:10: + + --8<-- + ``` + + 1. The function signature can be enforced at runtime by using the `@Functions.signature` decorator. + 2. The name of the function must start with the `func` prefix. + +=== "powertoolsCustomFunction.json" + + ```json + --8<-- "docs/snippets/jmespath/powertoolsCustomFunction.json" + ``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index e7556ff610..107ca2b45f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ nav: - utilities/parameters.md - utilities/idempotency.md - utilities/batch.md + - utilities/jmespath.md - Processes: - Roadmap: roadmap.md - Versioning policy: versioning.md @@ -100,6 +101,7 @@ plugins: glob: - snippets/node_modules/* - snippets/package.json + - snippets/CHANGELOG.md extra_css: - stylesheets/extra.css diff --git a/package-lock.json b/package-lock.json index 00a8fdd26d..b275ac1d3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "docs/snippets", "layers", "examples/cdk", - "examples/sam" + "examples/sam", + "packages/jmespath" ], "devDependencies": { "@middy/core": "^4.7.0", diff --git a/package.json b/package.json index e33d997322..4c60510663 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "docs/snippets", "layers", "examples/cdk", - "examples/sam" + "examples/sam", + "packages/jmespath" ], "scripts": { "test": "npm t -ws", diff --git a/packages/jmespath/README.md b/packages/jmespath/README.md new file mode 100644 index 0000000000..4d5ab664b3 --- /dev/null +++ b/packages/jmespath/README.md @@ -0,0 +1,74 @@ +# Powertools for AWS Lambda (TypeScript) - JMESPath Utility + +Powertools for AWS Lambda (TypeScript) is a developer toolkit to implement Serverless [best practices and increase developer velocity](https://docs.powertools.aws.dev/lambda/typescript/latest/#features). + +You can use the package in both TypeScript and JavaScript code bases. + +- [Intro](#intro) +- [Usage](#usage) +- [Contribute](#contribute) +- [Roadmap](#roadmap) +- [Connect](#connect) +- [How to support Powertools for AWS Lambda (TypeScript)?](#how-to-support-powertools-for-aws-lambda-typescript) + - [Becoming a reference customer](#becoming-a-reference-customer) + - [Sharing your work](#sharing-your-work) + - [Using Lambda Layer](#using-lambda-layer) +- [License](#license) + +## Intro + +The JMESPath utility is a high-level function to parse and extract data from JSON objects using JMESPath expressions. + +## Usage + +To get started, install the library by running: + +```sh +npm i @aws-lambda-powertools/jmespath +``` + +## Contribute + +If you are interested in contributing to this project, please refer to our [Contributing Guidelines](https://github.com/aws-powertools/powertools-lambda-typescript/blob/main/CONTRIBUTING.md). + +## Roadmap + +The roadmap of Powertools for AWS Lambda (TypeScript) is driven by customers’ demand. +Help us prioritize upcoming functionalities or utilities by [upvoting existing RFCs and feature requests](https://github.com/aws-powertools/powertools-lambda-typescript/issues), or [creating new ones](https://github.com/aws-powertools/powertools-lambda-typescript/issues/new/choose), in this GitHub repository. + +## Connect + +* **Powertools for AWS Lambda on Discord**: `#typescript` - **[Invite link](https://discord.gg/B8zZKbbyET)** +* **Email**: aws-lambda-powertools-feedback@amazon.com + +## How to support Powertools for AWS Lambda (TypeScript)? + +### Becoming a reference customer + +Knowing which companies are using this library is important to help prioritize the project internally. If your company is using Powertools for AWS Lambda (TypeScript), you can request to have your name and logo added to the README file by raising a [Support Powertools for AWS Lambda (TypeScript) (become a reference)](https://github.com/aws-powertools/powertools-lambda-typescript/issues/new?assignees=&labels=customer-reference&template=support_powertools.yml&title=%5BSupport+Lambda+Powertools%5D%3A+%3Cyour+organization+name%3E) issue. + +The following companies, among others, use Powertools: + +* [Hashnode](https://hashnode.com/) +* [Trek10](https://www.trek10.com/) +* [Elva](https://elva-group.com) +* [globaldatanet](https://globaldatanet.com/) +* [Bailey Nelson](https://www.baileynelson.com.au) +* [Perfect Post](https://www.perfectpost.fr) +* [Sennder](https://sennder.com/) +* [Certible](https://www.certible.com/) +* [tecRacer GmbH & Co. KG](https://www.tecracer.com/) +* [AppYourself](https://appyourself.net) +* [Alma Media](https://www.almamedia.fi) + +### Sharing your work + +Share what you did with Powertools for AWS Lambda (TypeScript) 💞💞. Blog post, workshops, presentation, sample apps and others. Check out what the community has already shared about Powertools for AWS Lambda (TypeScript) [here](https://docs.powertools.aws.dev/lambda/typescript/latest/we_made_this). + +### Using Lambda Layer + +This helps us understand who uses Powertools for AWS Lambda (TypeScript) in a non-intrusive way, and helps us gain future investments for other Powertools for AWS Lambda languages. When [using Layers](https://docs.powertools.aws.dev/lambda/typescript/latest/#lambda-layer), you can add Powertools as a dev dependency to not impact the development process. + +## License + +This library is licensed under the MIT-0 License. See the LICENSE file. \ No newline at end of file diff --git a/packages/jmespath/src/Expression.ts b/packages/jmespath/src/Expression.ts new file mode 100644 index 0000000000..94c69ef3c4 --- /dev/null +++ b/packages/jmespath/src/Expression.ts @@ -0,0 +1,28 @@ +import type { TreeInterpreter } from './TreeInterpreter.js'; +import type { JSONObject, Node } from './types.js'; + +/** + * Apply a JMESPath expression to a JSON value. + */ +class Expression { + readonly #expression: Node; + readonly #interpreter: TreeInterpreter; + + public constructor(expression: Node, interpreter: TreeInterpreter) { + this.#expression = expression; + this.#interpreter = interpreter; + } + + /** + * Evaluate the expression against a JSON value. + * + * @param value The JSON value to apply the expression to. + * @param node The node to visit. + * @returns The result of applying the expression to the value. + */ + public visit(value: JSONObject, node?: Node): JSONObject { + return this.#interpreter.visit(node ?? this.#expression, value); + } +} + +export { Expression }; diff --git a/packages/jmespath/src/Functions.ts b/packages/jmespath/src/Functions.ts index 5a657b2e9a..e16e0a4e8f 100644 --- a/packages/jmespath/src/Functions.ts +++ b/packages/jmespath/src/Functions.ts @@ -1,4 +1,605 @@ -// This is a placeholder for the real class. The actual implementation will be added in a subsequent PR. -export class Functions { - public iAmAPlaceholder = true; +import type { + JSONArray, + JSONObject, + JSONValue, +} from '@aws-lambda-powertools/commons/types'; +import { + getType, + isNumber, + isRecord, +} from '@aws-lambda-powertools/commons/typeutils'; +import type { Expression } from './Expression.js'; +import { JMESPathTypeError } from './errors.js'; +import type { + FunctionSignatureDecorator, + FunctionSignatureOptions, + JSONObject as JSONObjectType, +} from './types.js'; +import { arityCheck, typeCheck } from './utils.js'; + +/** + * A class that contains the built-in JMESPath functions. + * + * The built-in functions are implemented as methods on the Functions class. + * Each method is decorated with the `@Function.signature()` decorator to enforce the + * arity and types of the arguments passed to the function at runtime. + * + * You can extend the Functions class to add custom functions by creating a new class + * that extends Functions and adding new methods to it. + * + * @example + * ```typescript + * import { search } from '@aws-lambda-powertools/jmespath'; + * import { Functions } from '@aws-lambda-powertools/jmespath/functions'; + * + * class MyFunctions extends Functions { + * ⁣@Functions.signature({ + * argumentsSpecs: [['number'], ['number']], + * variadic: true, + * }) + * public funcMyMethod(args: Array): unknown { + * // ... + * } + * } + * + * const myFunctions = new MyFunctions(); + * + * search('myMethod(@)', {}, { customFunctions: new MyFunctions() }); + * ``` + */ +class Functions { + public methods: Set = new Set(); + /** + * Get the absolute value of the provided number. + * + * @param args The number to get the absolute value of + */ + @Functions.signature({ argumentsSpecs: [['number']] }) + public funcAbs(args: number): number { + return Math.abs(args); + } + + /** + * Calculate the average of the numbers in the provided array. + * + * @param args The numbers to average + */ + @Functions.signature({ + argumentsSpecs: [['array-number']], + }) + public funcAvg(args: Array): number { + return args.reduce((a, b) => a + b, 0) / args.length; + } + + /** + * Get the ceiling of the provided number. + * + * @param args The number to get the ceiling of + */ + @Functions.signature({ argumentsSpecs: [['number']] }) + public funcCeil(args: number): number { + return Math.ceil(args); + } + + /** + * Determine if the given value is contained in the provided array or string. + * + * @param haystack The array or string to check + * @param needle The value to check for + */ + @Functions.signature({ + argumentsSpecs: [['array', 'string'], ['any']], + }) + public funcContains(haystack: string, needle: string): boolean { + return haystack.includes(needle); + } + + /** + * Determines if the provided string ends with the provided suffix. + * + * @param str The string to check + * @param suffix The suffix to check for + */ + @Functions.signature({ + argumentsSpecs: [['string'], ['string']], + }) + public funcEndsWith(str: string, suffix: string): boolean { + return str.endsWith(suffix); + } + + /** + * Get the floor of the provided number. + * + * @param args The number to get the floor of + */ + @Functions.signature({ argumentsSpecs: [['number']] }) + public funcFloor(args: number): number { + return Math.floor(args); + } + + /** + * Join the provided array into a single string. + * + * @param separator The separator to use + * @param items The array of itmes to join + */ + @Functions.signature({ + argumentsSpecs: [['string'], ['array-string']], + }) + public funcJoin(separator: string, items: Array): string { + return items.join(separator); + } + + /** + * Get the keys of the provided object. + * + * @param arg The object to get the keys of + */ + @Functions.signature({ + argumentsSpecs: [['object']], + }) + public funcKeys(arg: JSONObject): string[] { + return Object.keys(arg); + } + + /** + * Get the number of items in the provided item. + * + * @param arg The array to get the length of + */ + @Functions.signature({ + argumentsSpecs: [['array', 'string', 'object']], + }) + public funcLength( + arg: string | Array | Record + ): number { + if (isRecord(arg)) { + return Object.keys(arg).length; + } else { + return arg.length; + } + } + + /** + * Map the provided function over the provided array. + * + * @param expression The expression to map over the array + * @param args The array to map the expression over + */ + @Functions.signature({ + argumentsSpecs: [['any'], ['array']], + }) + public funcMap( + expression: Expression, + args: JSONArray + ): JSONArray | Array { + return args.map((arg: JSONObjectType) => { + return expression.visit(arg) || null; + }); + } + + /** + * Get the maximum value in the provided array. + * + * @param arg The array to get the maximum value of + */ + @Functions.signature({ + argumentsSpecs: [['array-number', 'array-string']], + }) + public funcMax(arg: Array): number | string | null { + if (arg.length === 0) { + return null; + // The signature decorator already enforces that all elements are of the same type + } else if (isNumber(arg[0])) { + return Math.max(...(arg as number[])); + } else { + // local compare function to handle string comparison + return arg.reduce((a, b) => (a > b ? a : b)); + } + } + + /** + * Get the item in the provided array that has the maximum value when the provided expression is evaluated. + * + * @param args The array of items to get the maximum value of + * @param expression The expression to evaluate for each item in the array + */ + @Functions.signature({ + argumentsSpecs: [['array'], ['expression']], + }) + public funcMaxBy( + args: Array, + expression: Expression + ): JSONObject | null { + if (args.length === 0) { + return null; + } + + const visitedArgs = args.map((arg) => ({ + arg, + visited: expression.visit(arg), + })); + + const max = visitedArgs.reduce((max, current) => { + const type = getType(current.visited); + if (type !== 'string' && type !== 'number') { + throw new JMESPathTypeError({ + currentValue: current.visited, + expectedTypes: ['string'], + actualType: type, + }); + } + + if (max.visited === current.visited) { + return max; + } else { + // We can safely cast visited to number | string here because we've already + // checked the type at runtime above and we know that it's either a number or a string + return (max.visited as number | string) > + (current.visited as number | string) + ? max + : current; + } + }, visitedArgs[0]); + + return max.arg; + } + + /** + * Merge the provided objects into a single object. + * + * Note that this is a shallow merge and will not merge nested objects. + * + * @param args The objects to merge + */ + @Functions.signature({ + argumentsSpecs: [['object']], + variadic: true, + }) + public funcMerge(...args: Array): JSONObject { + return args.reduce((a, b) => ({ ...a, ...b }), {}); + } + + /** + * Get the minimum value in the provided array. + * + * @param arg The array to get the minimum value of + */ + @Functions.signature({ + argumentsSpecs: [['array-number', 'array-string']], + }) + public funcMin(arg: Array): number | string | null { + if (arg.length === 0) { + return null; + // The signature decorator already enforces that all elements are of the same type + } else if (isNumber(arg[0])) { + return Math.min(...arg); + } else { + return arg.reduce((a, b) => (a < b ? a : b)); + } + } + + /** + * Get the item in the provided array that has the minimum value when the provided expression is evaluated. + * + * @param args The array of items to get the minimum value of + * @param expression The expression to evaluate for each item in the array + */ + @Functions.signature({ + argumentsSpecs: [['array'], ['expression']], + }) + public funcMinBy( + args: Array, + expression: Expression + ): JSONObject | null { + if (args.length === 0) { + return null; + } + + const visitedArgs = args.map((arg) => ({ + arg, + visited: expression.visit(arg), + })); + + const min = visitedArgs.reduce((min, current) => { + const type = getType(current.visited); + if (type !== 'string' && type !== 'number') { + throw new JMESPathTypeError({ + currentValue: current.visited, + expectedTypes: ['string'], + actualType: type, + }); + } + + if (min.visited === current.visited) { + return min; + } else { + // We can safely cast visited to number | string here because we've already + // checked the type at runtime above and we know that it's either a number or a string + return (min.visited as string | number) < + (current.visited as string | number) + ? min + : current; + } + }, visitedArgs[0]); + + return min.arg; + } + + /** + * Get the first argument that does not evaluate to null. + * If all arguments evaluate to null, then null is returned. + * + * @param args The keys of the items to check + */ + @Functions.signature({ + argumentsSpecs: [[]], + variadic: true, + }) + public funcNotNull(...args: Array): JSONValue | null { + return args.find((arg) => !Object.is(arg, null)) || null; + } + + /** + * Reverses the provided string or array. + * + * @param arg The string or array to reverse + */ + @Functions.signature({ + argumentsSpecs: [['string', 'array']], + }) + public funcReverse(arg: string | Array): string | Array { + return Array.isArray(arg) + ? arg.reverse() + : arg.split('').reverse().join(''); + } + + /** + * Sort the provided array. + * + * @param arg The array to sort + */ + @Functions.signature({ + argumentsSpecs: [['array-number', 'array-string']], + }) + public funcSort(arg: Array | Array): Array { + return arg.sort((a: string | number, b: string | number): number => { + if (typeof a === 'string') { + // We can safely cast a and b to string here because the signature decorator + // already enforces that all elements are of the same type + return a.localeCompare(b as string); + } + + // We can safely cast a and b to number here because the signature decorator + // already enforces that all elements are of the same type, so if they're not strings + // then they must be numbers + return (a as number) - (b as number); + }); + } + + /** + * Sort the provided array by the provided expression. + * + * @param args The array to sort + * @param expression The expression to sort by + */ + @Functions.signature({ + argumentsSpecs: [['array'], ['expression']], + }) + public funcSortBy( + args: Array, + expression: Expression + ): Array { + return args + .map((value, index) => { + const visited = expression.visit(value); + const type = getType(visited); + if (type !== 'string' && type !== 'number') { + throw new JMESPathTypeError({ + currentValue: visited, + expectedTypes: ['string'], + actualType: getType(visited), + }); + } + + return { + value, + index, + visited, + }; + }) + .sort((a, b) => { + if (a.visited === b.visited) { + return a.index - b.index; // Make the sort stable + } else { + // We can safely cast visited to number | string here because we've already + // checked the type at runtime above and we know that it's either a number or a string + return (a.visited as string | number) > (b.visited as string | number) + ? 1 + : -1; + } + }) + .map(({ value }) => value); // Extract the original values + } + + /** + * Determines if the provided string starts with the provided prefix. + * + * @param str The string to check + * @param prefix The prefix to check for + */ + @Functions.signature({ + argumentsSpecs: [['string'], ['string']], + }) + public funcStartsWith(str: string, prefix: string): boolean { + return str.startsWith(prefix); + } + + /** + * Sum the provided numbers. + * + * @param args The numbers to sum + */ + @Functions.signature({ + argumentsSpecs: [['array-number']], + }) + public funcSum(args: Array): number { + return args.reduce((a, b) => a + b, 0); + } + + /** + * Convert the provided value to an array. + * + * If the provided value is an array, then it is returned. + * Otherwise, the value is wrapped in an array and returned. + * + * @param arg The items to convert to an array + */ + @Functions.signature({ + argumentsSpecs: [['any']], + }) + public funcToArray( + arg: JSONArray | Array + ): Array | JSONArray { + return Array.isArray(arg) ? arg : [arg]; + } + + /** + * Convert the provided value to a number. + * + * If the provided value is a number, then it is returned. + * Otherwise, the value is converted to a number and returned. + * + * If the value cannot be converted to a number, then null is returned. + * + * @param arg The value to convert to a number + */ + @Functions.signature({ + argumentsSpecs: [['any']], + }) + public funcToNumber(arg: JSONValue): number | null { + if (typeof arg === 'number') { + return arg; + } else if (typeof arg === 'string') { + const num = Number(arg); + + return Number.isNaN(num) ? null : num; + } else { + return null; + } + } + + /** + * Convert the provided value to a string. + * + * If the provided value is a string, then it is returned. + * Otherwise, the value is converted to a string and returned. + * + * @param arg The value to convert to a string + */ + @Functions.signature({ + argumentsSpecs: [['any']], + }) + public funcToString(arg: JSONValue): string { + return typeof arg === 'string' ? arg : JSON.stringify(arg); + } + + /** + * Get the type of the provided value. + * + * @param arg The value to check the type of + */ + @Functions.signature({ + argumentsSpecs: [['any']], + }) + public funcType(arg: JSONValue): string { + return getType(arg); + } + + /** + * Get the values of the provided object. + * + * @param arg The object to get the values of + */ + @Functions.signature({ + argumentsSpecs: [['object']], + }) + public funcValues(arg: JSONObject): JSONValue[] { + return Object.values(arg); + } + + public introspectMethods(scope?: Functions): Set { + const prototype = Object.getPrototypeOf(this); + const ownName = prototype.constructor.name; + const methods = new Set(); + if (ownName !== 'Functions') { + for (const method of prototype.introspectMethods(scope)) { + methods.add(method); + } + } + + // This block is executed for every class in the inheritance chain + for (const method of Object.getOwnPropertyNames( + Object.getPrototypeOf(this) + )) { + method !== 'constructor' && + method.startsWith('func') && + methods.add(method); + } + + // This block will be executed only if the scope is the outermost class + if (this.methods) { + for (const method of methods) { + this.methods.add(method); + } + } + + return methods; + } + + /** + * Decorator to enforce the signature of a function at runtime. + * + * The signature decorator enforces the arity and types of the arguments + * passed to a function at runtime. If the arguments do not match the + * expected arity or types errors are thrown. + * + * @example + * ```typescript + * import { PowertoolsFunctions } from '@aws-lambda-powertools/jmespath/functions'; + * + * class MyFunctions extends Functions { + * ⁣@Functions.signature({ + * argumentsSpecs: [['number'], ['number']], + * variadic: true, + * }) + * public funcMyMethod(args: Array): unknown { + * // ... + * } + * } + * ``` + * + * @param options The options for the signature decorator + */ + public static signature( + options: FunctionSignatureOptions + ): FunctionSignatureDecorator { + return (_target, _propertyKey, descriptor) => { + const originalMethod = descriptor.value; + + // Use a function() {} instead of an () => {} arrow function so that we can + // access `myClass` as `this` in a decorated `myClass.myMethod()`. + descriptor.value = function (args: unknown[]) { + const { variadic, argumentsSpecs } = options; + arityCheck(args, argumentsSpecs, variadic); + typeCheck(args, argumentsSpecs); + + return originalMethod.apply(this, args); + }; + + return descriptor; + }; + } } + +export { Functions }; diff --git a/packages/jmespath/src/Lexer.ts b/packages/jmespath/src/Lexer.ts new file mode 100644 index 0000000000..e4cdf1aaa2 --- /dev/null +++ b/packages/jmespath/src/Lexer.ts @@ -0,0 +1,368 @@ +import { + SIMPLE_TOKENS, + START_IDENTIFIER, + VALID_IDENTIFIER, + VALID_NUMBER, + WHITESPACE, +} from './constants.js'; +import { EmptyExpressionError, LexerError } from './errors.js'; +import type { Token } from './types.js'; + +/** + * A lexer for JMESPath expressions. + * + * This lexer tokenizes a JMESPath expression into a sequence of tokens. + */ +class Lexer { + #position!: number; + #expression!: string; + #chars!: string[]; + #current!: string; + #length!: number; + + /** + * Tokenize a JMESPath expression. + * + * This method is a generator that yields tokens for the given expression. + * + * @param expression The JMESPath expression to tokenize. + */ + public *tokenize(expression: string): Generator { + this.#initializeForExpression(expression); + while (this.#current !== '' && this.#current !== undefined) { + if (SIMPLE_TOKENS.has(this.#current)) { + yield { + // We know that SIMPLE_TOKENS has this.#current as a key because + // we checked for that above. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + type: SIMPLE_TOKENS.get(this.#current)!, + value: this.#current, + start: this.#position, + end: this.#position + 1, + }; + + this.#next(); + } else if (START_IDENTIFIER.has(this.#current)) { + yield this.#consumeIdentifier(); + } else if (WHITESPACE.has(this.#current)) { + this.#next(); + } else if (this.#current === '[') { + yield this.#consumeSquareBracket(); + } else if (this.#current === `'`) { + yield this.#consumeRawStringLiteral(); + } else if (this.#current === '`') { + yield this.#consumeLiteral(); + } else if (VALID_NUMBER.has(this.#current)) { + const start = this.#position; + const buff = this.#consumeNumber(); + yield { + type: 'number', + value: parseInt(buff), + start: start, + end: start + buff.length, + }; + } else if (this.#current === '-') { + yield this.#consumeNegativeNumber(); + } else if (this.#current === '"') { + yield this.#consumeQuotedIdentifier(); + } else if (['<', '>', '!', '=', '|', '&'].includes(this.#current)) { + yield this.#consumeComparatorSigns( + this.#current as '<' | '>' | '!' | '=' | '|' | '&' + ); + } else { + throw new LexerError(this.#position, this.#current); + } + } + yield { type: 'eof', value: '', start: this.#length, end: this.#length }; + } + + /** + * Consume a comparator sign. + * + * This method is called when the lexer encounters a comparator sign. + * + * @param current The current character + */ + #consumeComparatorSigns = ( + current: '<' | '>' | '!' | '=' | '|' | '&' + ): Token => { + switch (current) { + case '<': + return this.#matchOrElse('=', 'lte', 'lt'); + case '>': + return this.#matchOrElse('=', 'gte', 'gt'); + case '!': + return this.#matchOrElse('=', 'ne', 'not'); + case '|': + return this.#matchOrElse('|', 'or', 'pipe'); + case '&': + return this.#matchOrElse('&', 'and', 'expref'); + default: + return this.#consumeEqualSign(); + } + }; + + /** + * Consume an equal sign. + * + * This method is called when the lexer encounters an equal sign. + * It checks if the next character is also an equal sign and returns + * the corresponding token. + */ + #consumeEqualSign(): Token { + if (this.#next() === '=') { + this.#next(); + + return { + type: 'eq', + value: '==', + start: this.#position - 1, + end: this.#position, + }; + } else { + throw new LexerError(this.#position - 1, '='); + } + } + + /** + * Consume an unquoted identifier. + * + * This method is called when the lexer encounters a character that is a valid + * identifier. It advances the lexer until it finds a character that is not a + * valid identifier and returns the corresponding token. + */ + #consumeIdentifier(): Token { + const start = this.#position; + let buff = this.#current; + while (VALID_IDENTIFIER.has(this.#next())) { + buff += this.#current; + } + + return { + type: 'unquoted_identifier', + value: buff, + start, + end: start + buff.length, + }; + } + + /** + * Consume a negative number. + * + * This method is called when the lexer encounters a negative sign. + * It checks if the next character is a number and returns the corresponding token. + */ + #consumeNegativeNumber(): Token { + const start = this.#position; + const buff = this.#consumeNumber(); + if (buff.length > 1) { + return { + type: 'number', + value: parseInt(buff), + start: start, + end: start + buff.length, + }; + } else { + // If the negative sign is not followed by a number, it is an error. + throw new LexerError(start, 'Unknown token after "-"'); + } + } + + /** + * Consume a raw string that is a number. + * + * It takes the current position and advances + * the lexer until it finds a character that + * is not a number. + */ + #consumeNumber(): string { + let buff = this.#current; + while (VALID_NUMBER.has(this.#next())) { + buff += this.#current; + } + + return buff; + } + + /** + * Consume a square bracket. + * + * This method is called when the lexer encounters a square bracket. + * It checks if the next character is a question mark or a closing + * square bracket and returns the corresponding token. + */ + #consumeSquareBracket(): Token { + const start = this.#position; + const nextChar = this.#next(); + if (nextChar == ']') { + this.#next(); + + return { type: 'flatten', value: '[]', start: start, end: start + 2 }; + } else if (nextChar == '?') { + this.#next(); + + return { type: 'filter', value: '[?', start: start, end: start + 2 }; + } else { + return { type: 'lbracket', value: '[', start: start, end: start + 1 }; + } + } + + /** + * Initializes the lexer for the given expression. + * + * We use a separate method for this instead of the constructor + * because we want to be able to reuse the same lexer instance + * and also because we want to be able to expose a public API + * for tokenizing expressions like `new Lexer().tokenize(expression)`. + * + * @param expression The JMESPath expression to tokenize. + */ + #initializeForExpression(expression: string): void { + if (typeof expression !== 'string') { + throw new EmptyExpressionError(); + } + + this.#position = 0; + this.#expression = expression; + this.#chars = Array.from(expression); + this.#current = this.#chars[0]; + this.#length = this.#expression.length; + } + + /** + * Advance the lexer to the next character in the expression. + */ + #next(): string { + if (this.#position === this.#length - 1) { + this.#current = ''; + } else { + this.#position += 1; + this.#current = this.#chars[this.#position]; + } + + return this.#current; + } + + /** + * Consume until the given delimiter is reached allowing + * for escaping of the delimiter with a backslash (`\`). + * + * @param delimiter The delimiter to consume until. + */ + #consumeUntil(delimiter: string): string { + const start = this.#position; + let buff = ''; + this.#next(); + while (this.#current !== delimiter) { + if (this.#current === '\\') { + buff += '\\'; + this.#next(); + } + if (this.#current === '') { + // We've reached the end of the expression (EOF) before + // we found the delimiter. This is an error. + throw new LexerError(start, this.#expression.substring(start)); + } + buff += this.#current; + this.#next(); + } + // Skip the closing delimiter + this.#next(); + + return buff; + } + + /** + * Process a literal. + * + * A literal is a JSON string that is enclosed in backticks. + */ + #consumeLiteral(): Token { + const start = this.#position; + const lexeme = this.#consumeUntil('`').replace('\\`', '`'); + try { + const parsedJson = JSON.parse(lexeme); + + return { + type: 'literal', + value: parsedJson, + start, + end: this.#position - start, + }; + } catch (error) { + throw new LexerError(start, lexeme); + } + } + + /** + * Process a quoted identifier. + * + * A quoted identifier is a string that is enclosed in double quotes. + */ + #consumeQuotedIdentifier(): Token { + const start = this.#position; + const lexeme = '"' + this.#consumeUntil('"') + '"'; + const tokenLen = this.#position - start; + + return { + type: 'quoted_identifier', + value: JSON.parse(lexeme), + start, + end: tokenLen, + }; + } + + /** + * Process a raw string literal. + * + * A raw string literal is a string that is enclosed in single quotes. + */ + #consumeRawStringLiteral(): Token { + const start = this.#position; + const lexeme = this.#consumeUntil(`'`).replace(`\\'`, `'`); + const tokenLen = this.#position - start; + + return { + type: 'literal', + value: lexeme, + start, + end: tokenLen, + }; + } + + /** + * Match the expected character and return the corresponding token type. + * + * @param expected The expected character + * @param matchType The token type to return if the expected character is found + * @param elseType The token type to return if the expected character is not found + */ + #matchOrElse( + expected: string, + matchType: Token['type'], + elseType: Token['type'] + ): Token { + const start = this.#position; + const current = this.#current; + const nextChar = this.#next(); + if (nextChar === expected) { + this.#next(); + + return { + type: matchType, + value: current + nextChar, + start, + end: start + 2, + }; + } + + return { + type: elseType, + value: current, + start, + end: start, + }; + } +} + +export { Lexer }; diff --git a/packages/jmespath/src/ParsedResult.ts b/packages/jmespath/src/ParsedResult.ts new file mode 100644 index 0000000000..63610bff31 --- /dev/null +++ b/packages/jmespath/src/ParsedResult.ts @@ -0,0 +1,44 @@ +import { TreeInterpreter } from './TreeInterpreter.js'; +import { + ArityError, + JMESPathTypeError, + UnknownFunctionError, + VariadicArityError, +} from './errors.js'; +import type { Node, ParsingOptions, JSONObject } from './types.js'; + +class ParsedResult { + public expression: string; + public parsed: Node; + + public constructor(expression: string, parsed: Node) { + this.expression = expression; + this.parsed = parsed; + } + + /** + * Perform a JMESPath search on a JSON value. + * + * @param value The JSON value to search + * @param options The parsing options to use + */ + public search(value: JSONObject, options?: ParsingOptions): unknown { + const interpreter = new TreeInterpreter(options); + + try { + return interpreter.visit(this.parsed, value); + } catch (error) { + if ( + error instanceof JMESPathTypeError || + error instanceof UnknownFunctionError || + error instanceof ArityError || + error instanceof VariadicArityError + ) { + error.setExpression(this.expression); + } + throw error; + } + } +} + +export { ParsedResult }; diff --git a/packages/jmespath/src/Parser.ts b/packages/jmespath/src/Parser.ts new file mode 100644 index 0000000000..0047de8952 --- /dev/null +++ b/packages/jmespath/src/Parser.ts @@ -0,0 +1,834 @@ +import { randomInt } from 'node:crypto'; +import { Lexer } from './Lexer.js'; +import { ParsedResult } from './ParsedResult.js'; +import { + andExpression, + comparator, + currentNode, + expref, + field, + filterProjection, + flatten, + functionExpression, + identity, + index, + indexExpression, + keyValPair, + literal, + multiSelectObject, + multiSelectList, + notExpression, + orExpression, + pipe, + projection, + slice, + subexpression, + valueProjection, +} from './ast.js'; +import { BINDING_POWER } from './constants.js'; +import { IncompleteExpressionError, LexerError, ParseError } from './errors.js'; +import type { Node, Token } from './types.js'; + +/** + * Top down operaotr precedence parser for JMESPath. + * + * ## References + * The implementation of this Parser is based on the implementation of + * [jmespath.py](https://github.com/jmespath/jmespath.py/), which in turn + * is based on [Vaughan R. Pratt's "Top Down Operator Precedence"](http://dl.acm.org/citation.cfm?doid=512927.512931). + * + * If you don't want to read the full paper, there are some other good + * overviews that explain the general idea: + * - [Pratt Parsers: Expression Parsing Made Easy](https://journal.stuffwithstuff.com/2011/03/19/pratt-parsers-expression-parsing-made-easy/) + * - [Simple Top-Down Parsing in Python](https://11l-lang.org/archive/simple-top-down-parsing/) + * - [Top Down Operator Precedence](http://javascript.crockford.com/tdop/tdop.html) + */ +class Parser { + /** + * The maximum binding power for a token + * that can stop a projection. + */ + #projectionStop = 10; + /** + * Cache object + */ + #cache: Record = {}; + /** + * The maximum size of the cache. + */ + #maxCacheSize = 128; + #tokenizer?: Lexer; + #tokens: Token[]; + #index = 0; + + public constructor(lookahead = 2) { + this.#tokens = Array.from({ length: lookahead }); + } + + /** + * Parse a JMESPath expression and return the Abstract Syntax Tree (AST) + * that represents the expression. + * + * The AST is cached, so if you parse the same expression multiple times, + * the AST will be returned from the cache. + * + * @param expression The JMESPath expression to parse. + */ + public parse(expression: string): ParsedResult { + const cached = this.#cache[expression]; + if (cached) { + return cached; + } + const parsedResult = this.#doParse(expression); + this.#cache[expression] = parsedResult; + if (Object.keys(this.#cache).length > this.#maxCacheSize) { + this.#evictCache(); + } + + return parsedResult; + } + + /** + * Purge the entire cache. + */ + public purgeCache(): void { + this.#cache = {}; + } + + /** + * Do the actual parsing of the expression. + * + * @param expression The JMESPath expression to parse. + */ + #doParse(expression: string): ParsedResult { + try { + return this.#parse(expression); + } catch (error) { + if ( + error instanceof LexerError || + error instanceof IncompleteExpressionError || + error instanceof ParseError + ) { + error.setExpression(expression); + throw error; + } + + throw error; + } + } + + /** + * Parse a JMESPath expression and return the parsed result. + */ + #parse(expression: string): ParsedResult { + this.#tokenizer = new Lexer(); + this.#tokens = [...this.#tokenizer.tokenize(expression)]; + this.#index = 0; + const parsed = this.#expression(0); + if (this.#currentToken() !== 'eof') { + this.#throwParseError(); + } + + return new ParsedResult(expression, parsed); + } + + /** + * Process an expression. + */ + #expression(bindingPower = 0): Node { + const leftToken = this.#lookaheadToken(0); + this.#advance(); + let left = this.#getNudFunction(leftToken); + let currentToken = this.#currentToken(); + while (bindingPower < BINDING_POWER[currentToken]) { + this.#advance(); + left = this.#getLedFunction(currentToken, left); + currentToken = this.#currentToken(); + } + + return left; + } + + /** + * Get the nud function for a token. This is the function that + * is called when a token is found at the beginning of an expression. + * + * @param tokenType The type of token to get the nud function for. + */ + #getNudFunction(token: Token): Node { + const { type: tokenType } = token; + switch (tokenType) { + case 'literal': + return literal(token.value); + case 'unquoted_identifier': + return field(token.value); + case 'quoted_identifier': + return this.#processQuotedIdentifier(token); + case 'star': + return this.#processStarToken(); + case 'filter': + return this.#getLedFunction(tokenType, identity()); + case 'lbrace': + return this.#parseMultiSelectHash(); + case 'lparen': + return this.#processLParenTokenNud(); + case 'flatten': + return this.#processFlattenTokenNud(); + case 'not': + return notExpression(this.#expression(BINDING_POWER['not'])); + case 'lbracket': + return this.#processLBracketTokenNud(); + case 'current': + return currentNode(); + case 'expref': + return expref(this.#expression(BINDING_POWER['expref'])); + default: + return this.#processDefaultToken(token); + } + } + + /** + * Process a quoted identifier. + * + * A quoted identifier is a string that is enclosed in double quotes. + * + * @example s."foo" + * + * @param token The token to process + */ + #processQuotedIdentifier(token: Token): Node { + const fieldValue = field(token.value); + if (this.#currentToken() === 'lparen') { + this.#throwParseError({ + lexPosition: 0, + reason: 'quoted identifiers cannot be used as a function name', + }); + } + + return fieldValue; + } + + /** + * Process a star token. + * + * A star token is a syntax that allows you to project all the + * elements in a list or dictionary. + * + * @example foo[*] + */ + #processStarToken(): Node { + const left = identity(); + let right; + if (this.#currentToken() === 'rbracket') { + right = identity(); + } else { + right = this.#parseProjectionRhs(BINDING_POWER['star']); + } + + return valueProjection(left, right); + } + + /** + * Process a left parenthesis token. + * + * A left parenthesis token is a syntax that allows you to group + * expressions together. + * + * @example (foo.bar) + */ + #processLParenTokenNud(): Node { + const expression = this.#expression(); + this.#match('rparen'); + + return expression; + } + + /** + * Process a flatten token. + * + * A flatten token is a syntax that allows you to flatten the + * results of a subexpression. + * + * @example foo[].bar + */ + #processFlattenTokenNud(): Node { + const left = flatten(identity()); + const right = this.#parseProjectionRhs(BINDING_POWER['flatten']); + + return projection(left, right); + } + + /** + * Process a left bracket token. + * + * A left bracket token is a syntax that allows you to access + * elements in a list or dictionary. + * + * @example foo[0] + */ + #processLBracketTokenNud(): Node { + if (['number', 'colon'].includes(this.#currentToken())) { + const right = this.#parseIndexExpression(); + + return this.#projectIfSlice(identity(), right); + } else if ( + this.#currentToken() === 'star' && + this.#lookahead(1) === 'rbracket' + ) { + this.#advance(); + this.#advance(); + const right = this.#parseProjectionRhs(BINDING_POWER['star']); + + return projection(identity(), right); + } else { + return this.#parseMultiSelectList(); + } + } + + /** + * Process a default token. + * + * A default token is a syntax that allows you to access + * elements in a list or dictionary. + * + * @param token The token to process + */ + #processDefaultToken(token: Token): Node { + if (token.type === 'eof') { + throw new IncompleteExpressionError({ + lexPosition: token.start, + tokenValue: token.value, + tokenType: token.type, + }); + } + throw new ParseError({ + lexPosition: token.start, + tokenValue: token.value, + tokenType: token.type, + }); + } + + /** + * Get the led function for a token. This is the function that + * is called when a token is found in the middle of an expression. + * + * @param tokenType The type of token to get the led function for. + * @param leftNode The left hand side of the expression. + */ + #getLedFunction(tokenType: Token['type'], leftNode: Node): Node { + switch (tokenType) { + case 'dot': + return this.#processDotToken(leftNode); + case 'pipe': + return this.#processPipeToken(leftNode); + case 'or': + return this.#processOrToken(leftNode); + case 'and': + return this.#processAndToken(leftNode); + case 'lparen': + return this.#processLParenToken(leftNode); + case 'filter': + return this.#processFilterToken(leftNode); + case 'eq': + case 'ne': + case 'gt': + case 'gte': + case 'lt': + case 'lte': + return this.#parseComparator(leftNode, tokenType); + case 'flatten': + return this.#processFlattenToken(leftNode); + case 'lbracket': + return this.#processLBracketToken(leftNode); + default: + return this.#throwParseError(); + } + } + + /** + * Process a dot token. + * + * A dot token is a syntax that allows you to access + * fields in a dictionary or elements in a list. + * + * @example foo.bar + * + * @param leftNode The left hand side of the expression. + */ + #processDotToken(leftNode: Node): Node { + if (this.#currentToken() !== 'star') { + const right = this.#parseDotRhs(BINDING_POWER['dot']); + if (leftNode.type === 'subexpression') { + leftNode.children.push(right); + + return leftNode; + } else { + return subexpression([leftNode, right]); + } + } else { + // We are creating a value projection + this.#advance(); + const right = this.#parseProjectionRhs(BINDING_POWER['dot']); + + return valueProjection(leftNode, right); + } + } + + /** + * Process a pipe token. + * + * A pipe token is a syntax that allows you to combine two + * expressions using the pipe operator. + * + * @example foo | bar + * + * @param leftNode The left hand side of the expression. + */ + #processPipeToken(leftNode: Node): Node { + const right = this.#expression(BINDING_POWER['pipe']); + + return pipe(leftNode, right); + } + + /** + * Process an or token. + * + * An or token is a syntax that allows you to combine two + * expressions using the logical or operator. + * + * @example foo || bar + * + * @param leftNode The left hand side of the expression. + */ + #processOrToken(leftNode: Node): Node { + const right = this.#expression(BINDING_POWER['or']); + + return orExpression(leftNode, right); + } + + /** + * Process an and token. + * + * An and token is a syntax that allows you to combine two + * expressions using the logical and operator. + * + * @example foo && bar + * + * @param leftNode The left hand side of the expression. + */ + #processAndToken(leftNode: Node): Node { + const right = this.#expression(BINDING_POWER['and']); + + return andExpression(leftNode, right); + } + + #processLParenToken(leftNode: Node): Node { + const name = leftNode.value as string; + const args = []; + while (this.#currentToken() !== 'rparen') { + const expression = this.#expression(); + if (this.#currentToken() === 'comma') { + this.#match('comma'); + } + args.push(expression); + } + this.#match('rparen'); + + return functionExpression(name, args); + } + + #processFilterToken(leftNode: Node): Node { + // Filters are projections + const condition = this.#expression(0); + this.#match('rbracket'); + let right: Node; + if (this.#currentToken() === 'flatten') { + right = identity(); + } else { + right = this.#parseProjectionRhs(BINDING_POWER['flatten']); + } + + return filterProjection(leftNode, right, condition); + } + + #processFlattenToken(leftNode: Node): Node { + const left = flatten(leftNode); + const right = this.#parseProjectionRhs(BINDING_POWER['flatten']); + + return projection(left, right); + } + + #processLBracketToken(leftNode: Node): Node { + const token = this.#lookaheadToken(0); + if (['number', 'colon'].includes(token.type)) { + const right = this.#parseIndexExpression(); + if (leftNode.type === 'index_expression') { + // Optimization: if the left node is an index expression + // we can avoid creating another node and instead just + // add the right node as a child of the left node. + leftNode.children.push(right); + + return leftNode; + } else { + return this.#projectIfSlice(leftNode, right); + } + } else { + // We have a projection + this.#match('star'); + this.#match('rbracket'); + const right = this.#parseProjectionRhs(BINDING_POWER['star']); + + return projection(leftNode, right); + } + } + + /** + * Throw a parse error. + * + * This type of error indicates that the parser encountered + * a syntax error while processing the expression. + * + * The error includes the position in the expression where + * the error occurred, the value of the token that caused + * the error, the type of the token, and an optional reason. + * + * @param options The options to use when throwing the error. + */ + #throwParseError(options?: { + lexPosition?: number; + tokenValue?: Token['value']; + tokenType?: Token['type']; + reason?: string; + }): never { + const token = this.#lookaheadToken(0); + throw new ParseError({ + lexPosition: options?.lexPosition ?? token.start, + tokenValue: options?.tokenValue ?? token.value, + tokenType: options?.tokenType ?? token.type, + reason: options?.reason, + }); + } + + /** + * Process an index expression. + * + * An index expression is a syntax that allows you to + * access elements in a list or dictionary. For example + * `foo[0]` accesses the first element in the list `foo`. + */ + #parseIndexExpression(): Node { + // We're here: + // [ + // ^ + // | (currentToken) + if (this.#lookahead(0) === 'colon' || this.#lookahead(1) === 'colon') { + return this.#parseSliceExpression(); + } else { + // Parse the syntax [number] + const node = index(this.#lookaheadToken(0).value); + this.#advance(); + this.#match('rbracket'); + + return node; + } + } + + /** + * Process a slice expression. + * + * A slice expression is a syntax that allows you to + * access a range of elements in a list. For example + * `foo[0:10:2]` accesses every other element in the + * list `foo` from index 0 to 10. + * + * In a slice expression, the first index represents the + * start of the slice, the second index represents the + * end of the slice, and the third index represents the + * step. + * + * If the first index is omitted, it defaults to 0. + * If the second index is omitted, it defaults to the + * length of the list. If the third index is omitted, it + * defaults to 1. If the last colon is omitted, it defaults + * to a single index. + */ + #parseSliceExpression(): Node { + // [start:end:step] + // Where start, end, and step are optional. + // The last colon is optional as well. + const parts = []; + let index = 0; + let currentToken = this.#currentToken(); + while (currentToken !== 'rbracket' && index < 3) { + if (currentToken === 'colon') { + index += 1; + if (index === 3) { + this.#throwParseError(); + } + this.#advance(); + } else if (currentToken === 'number') { + parts[index] = this.#lookaheadToken(0).value; + this.#advance(); + } else { + this.#throwParseError(); + } + currentToken = this.#currentToken(); + } + this.#match('rbracket'); + + return slice(parts[0], parts[1], parts[2]); + } + + /** + * Process a projection if the right hand side of the + * projection is a slice. + * + * @param left The left hand side of the projection. + * @param right The right hand side of the projection. + */ + #projectIfSlice(left: Node, right: Node): Node { + const idxExpression = indexExpression([left, right]); + if (right.type === 'slice') { + return projection( + idxExpression, + this.#parseProjectionRhs(BINDING_POWER['star']) + ); + } else { + return idxExpression; + } + } + + /** + * Process a comparator. + * + * A comparator is a syntax that allows you to compare + * two values. For example `foo == bar` compares the + * value of `foo` with the value of `bar`. + * + * @param left The left hand side of the comparator. + * @param comparatorChar The comparator character. + */ + #parseComparator(left: Node, comparatorChar: Token['type']): Node { + return comparator( + comparatorChar, + left, + this.#expression(BINDING_POWER[comparatorChar]) + ); + } + + /** + * Process a multi-select list. + * + * A multi-select list is a syntax that allows you to + * select multiple elements from a list. For example + * `foo[*]` selects all elements in the list `foo`. + */ + #parseMultiSelectList(): Node { + const expressions = []; + while (true) { + const expression = this.#expression(); + expressions.push(expression); + if (this.#currentToken() === 'rbracket') { + break; + } else { + this.#match('comma'); + } + } + this.#match('rbracket'); + + return multiSelectList(expressions); + } + + /** + * Process a multi-select hash. + * + * A multi-select hash is a syntax that allows you to + * select multiple key-value pairs from a dictionary. + * For example `foo{a: a, b: b}` selects the keys `a` + * and `b` from the dictionary `foo`. + */ + #parseMultiSelectHash(): Node { + const pairs = []; + while (true) { + const keyToken = this.#lookaheadToken(0); + // Before getting the token value, verify it's + // an identifier. + this.#matchMultipleTokens(['quoted_identifier', 'unquoted_identifier']); // token types + const keyName = keyToken['value']; + this.#match('colon'); + const value = this.#expression(0); + const node = keyValPair(keyName, value); + pairs.push(node); + if (this.#currentToken() == 'comma') { + this.#match('comma'); + } else if (this.#currentToken() == 'rbrace') { + this.#match('rbrace'); + break; + } + } + + return multiSelectObject(pairs); + } + + /** + * Process the right hand side of a projection. + * + * @param bindingPower The binding power of the current token. + */ + #parseProjectionRhs(bindingPower: number): Node { + // Parse the right hand side of the projection. + let right; + if (BINDING_POWER[this.#currentToken()] < this.#projectionStop) { + // BP of 10 are all the tokens that stop a projection. + right = identity(); + } else if (this.#currentToken() == 'lbracket') { + right = this.#expression(bindingPower); + } else if (this.#currentToken() == 'filter') { + right = this.#expression(bindingPower); + } else if (this.#currentToken() == 'dot') { + this.#match('dot'); + right = this.#parseDotRhs(bindingPower); + } else { + this.#throwParseError(); + } + + return right; + } + + /** + * Process the right hand side of a dot expression. + * + * @param bindingPower The binding power of the current token. + */ + #parseDotRhs(bindingPower: number): Node { + // From the grammar: + // expression '.' ( identifier / + // multi-select-list / + // multi-select-hash / + // function-expression / + // * + // In terms of tokens that means that after a '.', + // you can have: + const lookahead = this.#currentToken(); + // Common case "foo.bar", so first check for an identifier. + if ( + ['quoted_identifier', 'unquoted_identifier', 'star'].includes(lookahead) + ) { + return this.#expression(bindingPower); + } else if (lookahead == 'lbracket') { + this.#match('lbracket'); + + return this.#parseMultiSelectList(); + } else if (lookahead == 'lbrace') { + this.#match('lbrace'); + + return this.#parseMultiSelectHash(); + } else { + this.#throwParseError(); + } + } + + /** + * Process a token and throw an error if it doesn't match the expected token. + * + * @param tokenType The expected token type. + */ + #match(tokenType: Token['type']): void { + const currentToken = this.#currentToken(); + if (currentToken === tokenType) { + this.#advance(); + } else { + const token = this.#lookaheadToken(0); + if (token.type === 'eof') { + throw new IncompleteExpressionError({ + lexPosition: token.start, + tokenValue: token.value, + tokenType: token.type, + }); + } else { + throw new ParseError({ + lexPosition: token.start, + tokenValue: token.value, + tokenType: token.type, + }); + } + } + } + + /** + * Process a token and throw an error if it doesn't match the expected token. + * + * @param tokenTypes The expected token types. + */ + #matchMultipleTokens(tokenTypes: Token['type'][]): void { + const currentToken = this.#currentToken(); + if (!tokenTypes.includes(currentToken)) { + const token = this.#lookaheadToken(0); + if (token.type === 'eof') { + throw new IncompleteExpressionError({ + lexPosition: token.start, + tokenValue: token.value, + tokenType: token.type, + }); + } else { + throw new ParseError({ + lexPosition: token.start, + tokenValue: token.value, + tokenType: token.type, + }); + } + } + this.#advance(); + } + + /** + * Advance the index to the next token. + */ + #advance(): void { + this.#index += 1; + } + + /** + * Get the current token type. + */ + #currentToken(): Token['type'] { + return this.#tokens[this.#index].type; + } + + /** + * Look ahead in the token stream and get the type of the token + * + * @param number The number of tokens to look ahead. + */ + #lookahead(number: number): Token['type'] { + return this.#tokens[this.#index + number].type; + } + + /** + * Look ahead in the token stream and get the token + * + * @param number The number of tokens to look ahead. + */ + #lookaheadToken(number: number): Token { + return this.#tokens[this.#index + number]; + } + + /** + * Remove half of the cached expressions randomly. + */ + #evictCache(): void { + const newCache = Object.keys(this.#cache).reduce( + (acc: { [key: string]: ParsedResult }, key: string) => { + if (randomInt(0, 100) > 50) { + acc[key] = this.#cache[key]; + } + + return acc; + }, + {} + ); + this.#cache = newCache; + } +} + +export { Parser }; diff --git a/packages/jmespath/src/PowertoolsFunctions.ts b/packages/jmespath/src/PowertoolsFunctions.ts new file mode 100644 index 0000000000..3cba848f73 --- /dev/null +++ b/packages/jmespath/src/PowertoolsFunctions.ts @@ -0,0 +1,61 @@ +import { gunzipSync } from 'node:zlib'; +import type { JSONValue } from '@aws-lambda-powertools/commons/types'; +import { fromBase64 } from '@aws-lambda-powertools/commons/utils/base64'; +import { Functions } from './Functions.js'; + +const decoder = new TextDecoder('utf-8'); + +/** + * Custom functions for the Powertools for AWS Lambda JMESPath module. + * + * Built-in JMESPath functions include: `powertools_json`, `powertools_base64`, `powertools_base64_gzip` + * + * You can use these functions to decode and/or deserialize JSON objects when using the {@link index.search | search} function. + * + * @example + * ```typescript + * import { search } from '@aws-lambda-powertools/jmespath'; + * import { PowertoolsFunctions } from '@aws-lambda-powertools/jmespath/functions'; + * + * const data = { + * body: "{\"foo\": \"bar\"}" + * }; + * + * const result = search( + * 'powertools_json(body)', + * data, + * { customFunctions: new PowertoolsFunctions() } + * ); + * console.log(result); // { foo: 'bar' } + * ``` + * + * When using the {@link extractDataFromEnvelope} function, the PowertoolsFunctions class is automatically used. + * + */ +class PowertoolsFunctions extends Functions { + @Functions.signature({ + argumentsSpecs: [['string']], + }) + public funcPowertoolsBase64(value: string): string { + return decoder.decode(fromBase64(value, 'base64')); + } + + @Functions.signature({ + argumentsSpecs: [['string']], + }) + public funcPowertoolsBase64Gzip(value: string): string { + const encoded = fromBase64(value, 'base64'); + const uncompressed = gunzipSync(encoded); + + return uncompressed.toString(); + } + + @Functions.signature({ + argumentsSpecs: [['string']], + }) + public funcPowertoolsJson(value: string): JSONValue { + return JSON.parse(value); + } +} + +export { PowertoolsFunctions }; diff --git a/packages/jmespath/src/TreeInterpreter.ts b/packages/jmespath/src/TreeInterpreter.ts new file mode 100644 index 0000000000..28d2c531bc --- /dev/null +++ b/packages/jmespath/src/TreeInterpreter.ts @@ -0,0 +1,496 @@ +import { + isIntegerNumber, + isRecord, + isStrictEqual, +} from '@aws-lambda-powertools/commons/typeutils'; +import { + ArityError, + JMESPathError, + JMESPathTypeError, + UnknownFunctionError, + VariadicArityError, +} from './errors.js'; +import { Expression } from './Expression.js'; +import { Functions } from './Functions.js'; +import type { Node, TreeInterpreterOptions, JSONObject } from './types.js'; +import { isTruthy, sliceArray } from './utils.js'; + +/** + * + * A tree interpreter for JMESPath ASTs. + * + * The tree interpreter is responsible for visiting nodes in the AST and + * evaluating them to produce a result. + * + * @internal + */ +class TreeInterpreter { + #functions: Functions; + + /** + * @param options The options to use for the interpreter. + */ + public constructor(options?: TreeInterpreterOptions) { + if (options?.customFunctions) { + this.#functions = options.customFunctions; + } else { + this.#functions = new Functions(); + } + this.#functions.introspectMethods(); + } + + /** + * Visit a node in the AST. + * + * The function will call the appropriate method to visit the node based on its type. + * + * @param node The node to visit. + * @param value The current value to visit. + */ + public visit(node: Node, value: JSONObject): JSONObject | null { + const nodeType = node.type; + const visitMethods: { + [key: string]: (node: Node, value: JSONObject) => JSONObject | null; + } = { + subexpression: this.#visitSubexpressionOrIndexExpressionOrPipe, + field: this.#visitField, + comparator: this.#visitComparator, + current: this.#visitCurrent, + expref: this.#visitExpref, + function_expression: this.#visitFunctionExpression, + filter_projection: this.#visitFilterProjection, + flatten: this.#visitFlatten, + identity: this.#visitIdentity, + index: this.#visitIndex, + index_expression: this.#visitSubexpressionOrIndexExpressionOrPipe, + slice: this.#visitSlice, + key_val_pair: this.#visitKeyValPair, + literal: this.#visitLiteral, + multi_select_object: this.#visitMultiSelectObject, + multi_select_list: this.#visitMultiSelectList, + or_expression: this.#visitOrExpression, + and_expression: this.#visitAndExpression, + not_expression: this.#visitNotExpression, + pipe: this.#visitSubexpressionOrIndexExpressionOrPipe, + projection: this.#visitProjection, + value_projection: this.#visitValueProjection, + }; + + const visitMethod = visitMethods[nodeType]; + if (visitMethod) { + return visitMethod.call(this, node, value); + } else { + throw new JMESPathError( + `Not Implemented: Invalid node type: ${node.type}` + ); + } + } + + /** + * Visit a subexpression, index expression, or pipe node. + * + * This method is shared between subexpression, index expression, and pipe + * since they all behave the same way in the context of an expression. + * + * They all visit their children and return the result of the last child. + * + * @param node The node to visit. + * @param value The current value to visit. + */ + #visitSubexpressionOrIndexExpressionOrPipe( + node: Node, + value: JSONObject + ): JSONObject { + let result = value; + for (const child of node.children) { + result = this.visit(child, result); + } + + return result; + } + /** + * Visit a field node. + * + * @param node The field node to visit. + * @param value The current value to visit. + */ + #visitField(node: Node, value: JSONObject): JSONObject { + if (!node.value) return null; + if ( + isRecord(value) && + typeof node.value === 'string' && + node.value in value + ) { + return value[node.value] as JSONObject; + } else { + return null; + } + } + + /** + * Visit a comparator node. + * + * @param node The comparator node to visit. + * @param value The current value to visit. + */ + #visitComparator(node: Node, value: JSONObject): JSONObject { + const comparator = node.value; + const left = this.visit(node.children[0], value); + const right = this.visit(node.children[1], value); + if ( + typeof comparator === 'string' && + ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(comparator) + ) { + // Common cases: comparator is == or != + if (comparator === 'eq') { + return isStrictEqual(left, right); + } else if (comparator === 'ne') { + return !isStrictEqual(left, right); + } else if (typeof left === 'number' && typeof right === 'number') { + // Ordering operators only work on numbers. Evaluating them on other + // types will return null. + if (comparator === 'lt') { + return left < right; + } else if (comparator === 'lte') { + return left <= right; + } else if (comparator === 'gt') { + return left > right; + } else { + return left >= right; + } + } + } else { + throw new JMESPathError(`Invalid comparator: ${comparator}`); + } + } + + /** + * Visit a current node. + * + * @param node The current node to visit. + * @param value The current value to visit. + */ + #visitCurrent(_node: Node, value: JSONObject): JSONObject { + return value; + } + + /** + * Visit an expref node. + * + * @param node The expref node to visit. + * @param value The current value to visit. + */ + #visitExpref(node: Node, _value: JSONObject): Expression { + return new Expression(node.children[0], this); + } + + /** + * Visit a function expression node. + * + * @param node The function expression node to visit. + * @param value The current value to visit. + */ + #visitFunctionExpression(node: Node, value: JSONObject): JSONObject { + const args = []; + for (const child of node.children) { + args.push(this.visit(child, value)); + } + // check that method name is a string + if (typeof node.value !== 'string') { + throw new JMESPathError( + `Function name must be a string, got ${node.value}` + ); + } + // convert snake_case to camelCase + const normalizedFunctionName = node.value.replace(/_([a-z])/g, (g) => + g[1].toUpperCase() + ); + // capitalize first letter & add `func` prefix + const funcName = `func${ + normalizedFunctionName.charAt(0).toUpperCase() + + normalizedFunctionName.slice(1) + }`; + if (!this.#functions.methods.has(funcName)) { + throw new UnknownFunctionError(node.value); + } + + try { + // We know that methodName is a key of this.#functions, but TypeScript + // doesn't know that, so we have to use @ts-ignore to tell it that it's + // okay. We could use a type assertion like `as keyof Functions`, but + // we also want to keep the args generic, so for now we'll just ignore it. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-next-line + return this.#functions[funcName](args); + } catch (error) { + if ( + error instanceof JMESPathTypeError || + error instanceof VariadicArityError || + error instanceof ArityError + ) { + error.setEvaluatedFunctionName(node.value); + throw error; + } + } + } + + /** + * Visit a filter projection node. + * + * @param node The filter projection node to visit. + * @param value The current value to visit. + */ + #visitFilterProjection(node: Node, value: JSONObject): JSONObject { + const base = this.visit(node.children[0], value); + if (!Array.isArray(base)) { + return null; + } + const comparatorNode = node.children[2]; + const collected = []; + for (const item of base) { + if (isTruthy(this.visit(comparatorNode, item))) { + const current = this.visit(node.children[1], item); + if (current !== null) { + collected.push(current); + } + } + } + + return collected; + } + + /** + * Visit a flatten node. + * + * @param node The flatten node to visit. + * @param value The current value to visit. + */ + #visitFlatten(node: Node, value: JSONObject): JSONObject { + const base = this.visit(node.children[0], value); + if (!Array.isArray(base)) { + return null; + } + const mergedList = []; + for (const item of base) { + if (Array.isArray(item)) { + mergedList.push(...item); + } else { + mergedList.push(item); + } + } + + return mergedList; + } + + /** + * Visit an identity node. + * + * @param node The identity node to visit. + * @param value The current value to visit. + */ + #visitIdentity(_node: Node, value: JSONObject): JSONObject { + return value; + } + + /** + * Visit an index node. + * + * @param node The index node to visit. + * @param value The current value to visit. + */ + #visitIndex(node: Node, value: JSONObject): JSONObject { + if (!Array.isArray(value)) { + return null; + } + // The Python implementation doesn't support string indexing + // even though we could, so we won't either for now. + if (typeof node.value !== 'number') { + throw new JMESPathError(`Invalid index: ${node.value}`); + } + const index = node.value < 0 ? value.length + node.value : node.value; + const found = value[index]; + if (found === undefined) { + return null; + } + + return found; + } + + /** + * Visit a slice node. + * + * @param node The slice node to visit. + * @param value The current value to visit. + */ + #visitSlice(node: Node, value: JSONObject): JSONObject { + const step = isIntegerNumber(node.children[2]) ? node.children[2] : 1; + if (step === 0) { + throw new Error('Invalid slice, step cannot be 0'); + } + if (!Array.isArray(value)) { + return null; + } + if (value.length === 0) { + return []; + } + + return sliceArray({ + array: value, + start: node.children[0] as unknown as number, + end: node.children[1] as unknown as number, + step, + }); + } + + /** + * Visit a key-value pair node. + * + * @param node The key-value pair node to visit. + * @param value The current value to visit. + */ + #visitKeyValPair(node: Node, value: JSONObject): JSONObject { + return this.visit(node.children[0], value); + } + + /** + * Visit a literal node. + * + * @param node The literal node to visit. + * @param value The current value to visit. + */ + #visitLiteral(node: Node, _value: JSONObject): JSONObject { + return node.value; + } + + /** + * Visit a multi-select object node. + * + * @param node The multi-select object node to visit. + * @param value The current value to visit. + */ + #visitMultiSelectObject(node: Node, value: JSONObject): JSONObject { + if (Object.is(value, null)) { + return null; + } + const collected: Record = {}; + for (const child of node.children) { + if (typeof child.value === 'string') { + collected[child.value] = this.visit(child, value); + } + } + + return collected; + } + + /** + * Visit a multi-select list node. + * + * @param node The multi-select list node to visit. + * @param value The current value to visit. + */ + #visitMultiSelectList(node: Node, value: JSONObject): JSONObject { + if (Object.is(value, null)) { + return null; + } + const collected = []; + for (const child of node.children) { + collected.push(this.visit(child, value)); + } + + return collected; + } + + /** + * Visit an or expression node. + * + * @param node The or expression node to visit. + * @param value The current value to visit. + */ + #visitOrExpression(node: Node, value: JSONObject): JSONObject { + const matched = this.visit(node.children[0], value); + if (!isTruthy(matched)) { + return this.visit(node.children[1], value); + } + + return matched; + } + + /** + * Visit an and expression node. + * + * @param node The and expression node to visit. + * @param value The current value to visit. + */ + #visitAndExpression(node: Node, value: JSONObject): JSONObject { + const matched = this.visit(node.children[0], value); + if (!isTruthy(matched)) { + return matched; + } + + return this.visit(node.children[1], value); + } + + /** + * Visit a not expression node. + * + * @param node The not expression node to visit. + * @param value The current value to visit. + */ + #visitNotExpression(node: Node, value: JSONObject): JSONObject { + const originalResult = this.visit(node.children[0], value); + if (typeof originalResult === 'number' && originalResult === 0) { + // Special case for 0, !0 should be false, not true. + // 0 is not a special cased integer in jmespath. + return false; + } + + return !isTruthy(originalResult); + } + + /** + * Visit a projection node. + * + * @param node The projection node to visit. + * @param value The current value to visit. + */ + #visitProjection(node: Node, value: JSONObject): JSONObject { + const base = this.visit(node.children[0], value); + if (!Array.isArray(base)) { + return null; + } + const collected = []; + for (const item of base) { + const current = this.visit(node.children[1], item); + if (current !== null) { + collected.push(current); + } + } + + return collected; + } + + /** + * Visit a value projection node. + * + * @param node The value projection node to visit. + * @param value The current value to visit. + */ + #visitValueProjection(node: Node, value: JSONObject): JSONObject { + const base = this.visit(node.children[0], value); + if (!isRecord(base)) { + return null; + } + const values = Object.values(base); + const collected = []; + for (const item of values) { + const current = this.visit(node.children[1], item as JSONObject[]); + if (current !== null) { + collected.push(current); + } + } + + return collected; + } +} + +export { TreeInterpreter }; diff --git a/packages/jmespath/src/ast.ts b/packages/jmespath/src/ast.ts new file mode 100644 index 0000000000..c0f2eff39f --- /dev/null +++ b/packages/jmespath/src/ast.ts @@ -0,0 +1,299 @@ +import type { JSONValue } from '@aws-lambda-powertools/commons/types'; +import type { Node } from './types.js'; + +/** + * AST node representing a comparator expression. + * + * A comparator expression is a binary expression that compares two values. + * + * @param name The name of the comparator + * @param first The left-hand side of the comparator + * @param second The right-hand side of the comparator + */ +const comparator = (name: string, first: Node, second: Node): Node => ({ + type: 'comparator', + children: [first, second], + value: name, +}); + +/** + * AST node representing the current node. + * + * The current node is a reference to the current value being processed. + * In JMESPath, the current node is represented by the `@` symbol. + */ +const currentNode = (): Node => ({ + type: 'current', + children: [], +}); + +/** + * AST node representing an expression reference. + * + * An expression reference is a reference to another expression. + * In JMESPath, an expression reference is represented by the `&` symbol. + * + * @param expression The expression to reference + */ +const expref = (expression: Node): Node => ({ + type: 'expref', + children: [expression], +}); + +/** + * AST node representing a function expression. + * + * A function expression is a reference to a function and its arguments. + * The JMESPath specification defines a set of built-in functions that can + * be used in expressions like `length(@)`, `map(@, &foo)`, etc. + * + * Custom functions can be added by extending the `Functions` class. + * + * @param name The name of the function + * @param args The arguments to the function + */ +const functionExpression = (name: string, args: Node[]): Node => ({ + type: 'function_expression', + children: args, + value: name, +}); + +/** + * AST node representing a field reference. + * + * A field reference is a reference to a field in an object. + */ +const field = (name: JSONValue): Node => ({ + type: 'field', + children: [], + value: name, +}); + +/** + * AST node representing a filter projection. + * + * A filter projection is a binary expression that filters the left-hand side + * based on the right-hand side. + * + * In JMESPath, a filter projection is represented by the `[]` operator. + * For example, `people[?age > 18]` filters the `people` array based on the + * `age` field. + * + * @param left The left-hand side of the filter projection + * @param right The right-hand side of the filter projection + * @param comparator The comparator to use for the filter + */ +const filterProjection = (left: Node, right: Node, comparator: Node): Node => ({ + type: 'filter_projection', + children: [left, right, comparator], +}); + +/** + * AST node representing a flatten expression. + * + * A flatten expression is a unary expression that flattens an array of arrays + * into a single array. + * + * In JMESPath, a flatten expression is represented by the `[]` operator. + * For example, `people[].name` flattens the `people` array and returns the + * `name` field of each object in the array. + * + * @param node The node to flatten + */ +const flatten = (node: Node): Node => ({ + type: 'flatten', + children: [node], +}); + +/** + * AST node representing an identity expression. + */ +const identity = (): Node => ({ type: 'identity', children: [] }); + +/** + * AST node representing an index reference. + * + * An index reference is a reference to an index in an array. + * For example, `people[0]` references the first element in the `people` array. + * + * @param index The index to reference + */ +const index = (index: JSONValue): Node => ({ + type: 'index', + value: index, + children: [], +}); + +/** + * AST node representing an index expression. + * + * An index expression holds the index and the children of the expression. + * + * @param children The children of the index expression + */ +const indexExpression = (children: Node[]): Node => ({ + type: 'index_expression', + children: children, +}); + +/** + * AST node representing a key-value pair. + * + * @param keyName The name of the key + * @param node The value of the key + */ +const keyValPair = (keyName: JSONValue, node: Node): Node => ({ + type: 'key_val_pair', + children: [node], + value: keyName, +}); + +/** + * AST node representing a literal value. + * + * A literal value is a value that is not a reference to another node. + * + * @param literalValue The value of the literal + */ +const literal = (literalValue: JSONValue): Node => ({ + type: 'literal', + value: literalValue, + children: [], +}); + +/** + * AST node representing a multi-select object. + * + * A multi-select object is a reference to multiple nodes in an object. + * + * @param nodes + */ +const multiSelectObject = (nodes: Node[]): Node => ({ + type: 'multi_select_object', + children: nodes, +}); + +/** + * AST node representing a multi-select list. + * + * @param nodes + */ +const multiSelectList = (nodes: Node[]): Node => ({ + type: 'multi_select_list', + children: nodes, +}); + +/** + * AST node representing an or expression. + * + * @param left The left-hand side of the or expression + * @param right The right-hand side of the or expression + */ +const orExpression = (left: Node, right: Node): Node => ({ + type: 'or_expression', + children: [left, right], +}); + +/** + * AST node representing an and expression. + * + * @param left The left-hand side of the and expression + * @param right The right-hand side of the and expression + */ +const andExpression = (left: Node, right: Node): Node => ({ + type: 'and_expression', + children: [left, right], +}); + +/** + * AST node representing a not expression. + * + * @param left The left-hand side of the not expression + * @param right The right-hand side of the not expression + */ +const notExpression = (expr: Node): Node => ({ + type: 'not_expression', + children: [expr], +}); + +/** + * AST node representing a pipe expression. + * + * @param left The left-hand side of the pipe expression + * @param right The right-hand side of the pipe expression + */ +const pipe = (left: Node, right: Node): Node => ({ + type: 'pipe', + children: [left, right], +}); + +/** + * AST node representing a projection. + * + * @param left The left-hand side of the projection + * @param right The right-hand side of the projection + */ +const projection = (left: Node, right: Node): Node => ({ + type: 'projection', + children: [left, right], +}); + +/** + * AST node representing a subexpression. + * + * @param children The children of the subexpression + */ +const subexpression = (children: Node[]): Node => ({ + type: 'subexpression', + children: children, +}); + +/** + * AST node representing a slice. + * + * A slice is a reference to a range of values in an array. + * + * @param start The start of the slice + * @param end The end of the slice + * @param step The step of the slice + */ +const slice = (start: JSONValue, end: JSONValue, step: JSONValue): Node => ({ + type: 'slice', + children: [start as Node, end as Node, step as Node], +}); + +/** + * AST node representing a value projection. + * + * @param left The left-hand side of the value projection + * @param right The right-hand side of the value projection + */ +const valueProjection = (left: Node, right: Node): Node => ({ + type: 'value_projection', + children: [left, right], +}); + +export { + andExpression, + comparator, + currentNode, + expref, + field, + filterProjection, + flatten, + functionExpression, + identity, + index, + indexExpression, + keyValPair, + literal, + multiSelectObject, + multiSelectList, + notExpression, + orExpression, + pipe, + projection, + slice, + subexpression, + valueProjection, +}; diff --git a/packages/jmespath/src/envelopes.ts b/packages/jmespath/src/envelopes.ts new file mode 100644 index 0000000000..84cf06c383 --- /dev/null +++ b/packages/jmespath/src/envelopes.ts @@ -0,0 +1,103 @@ +import { search } from './search.js'; +import { PowertoolsFunctions } from './PowertoolsFunctions.js'; +import type { ParsingOptions, JSONObject } from './types.js'; + +/** + * Searches and extracts data using JMESPath + * + * Envelope being the JMESPath expression to extract the data you're after + * + * Built-in JMESPath functions include: `powertools_json`, `powertools_base64`, `powertools_base64_gzip` + * + * @example + * ```typescript + * import { extractDataFromEnvelope } from '@aws-lambda-powertools/jmespath/envelopes'; + * + * type CustomEvent = { + * body: string; // "{\"customerId\":\"dd4649e6-2484-4993-acb8-0f9123103394\"}" + * }; + * + * type EventBody = { + * customerId: string; + * }; + * + * export const handler = async (event: CustomEvent): Promise => { + * const payload = extractDataFromEnvelope(event, "powertools_json(body)"); + * const { customerId } = payload; // now deserialized + * // ... + * }; + * ``` + * + * We provide built-in envelopes for popular AWS Lambda event sources to easily decode and/or deserialize JSON objects. + * + * @example + * ```typescript + * import { + * extractDataFromEnvelope, + * SQS, + * } from '@aws-lambda-powertools/jmespath/envelopes'; + * import type { SQSEvent } from 'aws-lambda'; + * + * type MessageBody = { + * customerId: string; + * }; + * + * export const handler = async (event: SQSEvent): Promise => { + * const records = extractDataFromEnvelope>(event, SQS); + * for (const record in records) { // records is now a list containing the deserialized body of each message + * const { customerId } = record; + * } + * }; + * ``` + * + * @param data The JSON object to search + * @param envelope The JMESPath expression to use + * @param options The parsing options to use + */ +const extractDataFromEnvelope = ( + data: JSONObject, + envelope: string, + options?: ParsingOptions +): T => { + if (!options) { + options = { customFunctions: new PowertoolsFunctions() }; + } + + return search(envelope, data, options) as T; +}; + +const API_GATEWAY_REST = 'powertools_json(body)'; +const API_GATEWAY_HTTP = 'powertools_json(body)'; +const SQS = 'Records[*].powertools_json(body)'; +const SNS = 'Records[0].Sns.Message | powertools_json(@)'; +const EVENTBRIDGE = 'detail'; +const CLOUDWATCH_EVENTS_SCHEDULED = 'detail'; +const KINESIS_DATA_STREAM = + 'Records[*].kinesis.powertools_json(powertools_base64(data))'; +const CLOUDWATCH_LOGS = + 'awslogs.powertools_base64_gzip(data) | powertools_json(@).logEvents[*]'; +const S3_SNS_SQS = + 'Records[*].powertools_json(body).powertools_json(Message).Records[0]'; +const S3_SQS = 'Records[*].powertools_json(body).Records[0]'; +const S3_SNS_KINESIS_FIREHOSE = + 'records[*].powertools_json(powertools_base64(data)).powertools_json(Message).Records[0]'; +const S3_KINESIS_FIREHOSE = + 'records[*].powertools_json(powertools_base64(data)).Records[0]'; +const S3_EVENTBRIDGE_SQS = 'Records[*].powertools_json(body).detail'; + +export { + extractDataFromEnvelope, + API_GATEWAY_REST, + API_GATEWAY_HTTP, + SQS, + SNS, + EVENTBRIDGE, + CLOUDWATCH_EVENTS_SCHEDULED, + KINESIS_DATA_STREAM, + CLOUDWATCH_LOGS, + S3_SNS_SQS, + S3_SQS, + S3_SNS_KINESIS_FIREHOSE, + S3_KINESIS_FIREHOSE, + S3_EVENTBRIDGE_SQS, +}; diff --git a/packages/jmespath/src/index.ts b/packages/jmespath/src/index.ts new file mode 100644 index 0000000000..a0f2545f85 --- /dev/null +++ b/packages/jmespath/src/index.ts @@ -0,0 +1,12 @@ +export { search } from './search.js'; +export { + JMESPathError, + LexerError, + ParseError, + IncompleteExpressionError, + ArityError, + VariadicArityError, + JMESPathTypeError, + EmptyExpressionError, + UnknownFunctionError, +} from './errors.js'; diff --git a/packages/jmespath/src/search.ts b/packages/jmespath/src/search.ts new file mode 100644 index 0000000000..8f53775fa5 --- /dev/null +++ b/packages/jmespath/src/search.ts @@ -0,0 +1,60 @@ +import { Parser } from './Parser.js'; +import type { ParsingOptions, JSONObject } from './types.js'; + +const parser = new Parser(); + +/** + * Search for data in a JSON object using a JMESPath expression. + * + * @example + * ```typescript + * import { search } from '@aws-lambda-powertools/jmespath'; + * + * const data = { + * foo: { + * bar: { + * baz: 1 + * } + * } + * }; + * + * const result = search('foo.bar.baz', data); + * console.log(result); // 1 + * ``` + * + * By default the search function will use all the built-in functions + * present in the [JMESPath specification](https://jmespath.org/specification.html). + * + * Powertools for AWS Lambda provides some additional functions that can be used + * by passing them in the `customFunctions` option. + * + * @example + * ```typescript + * import { search } from '@aws-lambda-powertools/jmespath'; + * import { PowertoolsFunctions } from '@aws-lambda-powertools/jmespath/functions'; + * + * const data = { + * body: "{\"foo\": \"bar\"}" + * }; + * + * const result = search( + * 'powertools_json(body)', + * data, + * { customFunctions: new PowertoolsFunctions() } + * ); + * console.log(result); // { foo: 'bar' } + * ``` + * + * @param expression The JMESPath expression to use + * @param data The JSON object to search + * @param options The parsing options to use + */ +const search = ( + expression: string, + data: JSONObject, + options?: ParsingOptions +): unknown => { + return parser.parse(expression).search(data, options); +}; + +export { search }; diff --git a/packages/jmespath/src/utils.ts b/packages/jmespath/src/utils.ts new file mode 100644 index 0000000000..fe115f3098 --- /dev/null +++ b/packages/jmespath/src/utils.ts @@ -0,0 +1,334 @@ +import { + getType, + isIntegerNumber, + isRecord, + isTruthy as isTruthyJS, + isNumber, +} from '@aws-lambda-powertools/commons/typeutils'; +import { Expression } from './Expression.js'; +import { ArityError, JMESPathTypeError, VariadicArityError } from './errors.js'; + +/** + * Check if a value is truthy. + * + * In JavaScript, zero is falsy while all other non-zero numbers are truthy. + * In JMESPath however, zero is truthy as well as all other non-zero numbers. For + * this reason we wrap the original isTruthy function from the commons package + * and add a check for numbers. + * + * @param value The value to check + */ +const isTruthy = (value: unknown): boolean => { + if (isNumber(value)) { + return true; + } else { + return isTruthyJS(value); + } +}; + +/** + * @internal + * Cap a slice range value to the length of an array, taking into account + * negative values and whether the step is negative. + * + * @param arrayLength The length of the array + * @param value The value to cap + * @param isStepNegative Whether the step is negative + */ +const capSliceRange = ( + arrayLength: number, + value: number, + isStepNegative: boolean +): number => { + if (value < 0) { + value += arrayLength; + if (value < 0) { + value = isStepNegative ? -1 : 0; + } + } else if (value >= arrayLength) { + value = isStepNegative ? arrayLength - 1 : arrayLength; + } + + return value; +}; + +/** + * Given a start, stop, and step value, the sub elements in an array are extracted as follows: + * * The first element in the extracted array is the index denoted by start. + * * The last element in the extracted array is the index denoted by end - 1. + * * The step value determines how many indices to skip after each element is selected from the array. An array of 1 (the default step) will not skip any indices. A step value of 2 will skip every other index while extracting elements from an array. A step value of -1 will extract values in reverse order from the array. + * + * Slice expressions adhere to the following rules: + * * If a negative start position is given, it is calculated as the total length of the array plus the given start position. + * * If no start position is given, it is assumed to be 0 if the given step is greater than 0 or the end of the array if the given step is less than 0. + * * If a negative stop position is given, it is calculated as the total length of the array plus the given stop position. + * * If no stop position is given, it is assumed to be the length of the array if the given step is greater than 0 or 0 if the given step is less than 0. + * * If the given step is omitted, it it assumed to be 1. + * * If the given step is 0, an invalid-value error MUST be raised (thrown before calling the function) + * * If the element being sliced is not an array, the result is null (returned before calling the function) + * * If the element being sliced is an array and yields no results, the result MUST be an empty array. + * + * @param array The array to slice + * @param start The start index + * @param end The end index + * @param step The step value + */ +const sliceArray = ({ + array, + start, + end, + step, +}: { + array: T[]; + start?: number; + end?: number; + step: number; +}): T[] | null => { + const isStepNegative = step < 0; + const length = array.length; + const defaultStart = isStepNegative ? length - 1 : 0; + const defaultEnd = isStepNegative ? -1 : length; + + start = isIntegerNumber(start) + ? capSliceRange(length, start, isStepNegative) + : defaultStart; + + end = isIntegerNumber(end) + ? capSliceRange(length, end, isStepNegative) + : defaultEnd; + + const result: T[] = []; + if (step > 0) { + for (let i = start; i < end; i += step) { + result.push(array[i]); + } + } else { + for (let i = start; i > end; i += step) { + result.push(array[i]); + } + } + + return result; +}; + +/** + * Checks if the number of arguments passed to a function matches the expected arity. + * If the number of arguments does not match the expected arity, an ArityError is thrown. + * + * If the function is variadic, then the number of arguments passed to the function must be + * greater than or equal to the expected arity. If the number of arguments passed to the function + * is less than the expected arity, a `VariadicArityError` is thrown. + * + * @param args The arguments passed to the function + * @param argumentsSpecs The expected types for each argument + * @param decoratedFuncName The name of the function being called + * @param variadic Whether the function is variadic + */ +const arityCheck = ( + args: unknown[], + argumentsSpecs: Array>, + variadic?: boolean +): void => { + if (variadic) { + if (args.length < argumentsSpecs.length) { + throw new VariadicArityError({ + expectedArity: argumentsSpecs.length, + actualArity: args.length, + }); + } + } else if (args.length !== argumentsSpecs.length) { + throw new ArityError({ + expectedArity: argumentsSpecs.length, + actualArity: args.length, + }); + } +}; + +/** + * Type checks the arguments passed to a function against the expected types. + * + * Type checking at runtime involves checking the top level type, + * and in the case of arrays, potentially checking the types of + * the elements in the array. + * + * If the list of types includes 'any', then the type check is a + * no-op. + * + * If the list of types includes more than one type, then the + * argument is checked against each type in the list. If the + * argument matches any of the types, then the type check + * passes. If the argument does not match any of the types, then + * a JMESPathTypeError is thrown. + * + * @param args The arguments passed to the function + * @param argumentsSpecs The expected types for each argument + */ +const typeCheck = ( + args: unknown[], + argumentsSpecs: Array> +): void => { + for (const [index, argumentSpec] of argumentsSpecs.entries()) { + if (argumentSpec[0] === 'any') continue; + typeCheckArgument(args[index], argumentSpec); + } +}; + +/** + * Type checks an argument against a list of types. + * + * If the list of types includes more than one type, then the + * argument is checked against each type in the list. If the + * argument matches any of the types, then the type check + * passes. If the argument does not match any of the types, then + * a JMESPathTypeError is thrown. + * + * @param arg + * @param argumentSpec + */ +const typeCheckArgument = (arg: unknown, argumentSpec: Array): void => { + let valid = false; + argumentSpec.forEach((type, index) => { + if (valid) return; + valid = checkIfArgumentTypeIsValid(arg, type, index, argumentSpec); + }); +}; + +/** + * Check if the argument is of the expected type. + * + * @param arg The argument to check + * @param type The expected type + * @param index The index of the type we are checking + * @param argumentSpec The list of types to check against + */ +const checkIfArgumentTypeIsValid = ( + arg: unknown, + type: string, + index: number, + argumentSpec: string[] +): boolean => { + const hasMoreTypesToCheck = index < argumentSpec.length - 1; + if (type.startsWith('array')) { + if (!Array.isArray(arg)) { + if (hasMoreTypesToCheck) { + return false; + } + throw new JMESPathTypeError({ + currentValue: arg, + expectedTypes: argumentSpec, + actualType: getType(arg), + }); + } + checkComplexArrayType(arg, type, hasMoreTypesToCheck); + + return true; + } + if (type === 'expression') { + checkExpressionType(arg, argumentSpec, hasMoreTypesToCheck); + + return true; + } else if (['string', 'number', 'boolean'].includes(type)) { + typeCheckType(arg, type, argumentSpec, hasMoreTypesToCheck); + if (typeof arg === type) return true; + } else if (type === 'object') { + checkObjectType(arg, argumentSpec, hasMoreTypesToCheck); + + return true; + } + + return false; +}; + +/** + * Check if the argument is of the expected type. + * + * @param arg The argument to check + * @param type The type to check against + * @param argumentSpec The list of types to check against + * @param hasMoreTypesToCheck Whether there are more types to check + */ +const typeCheckType = ( + arg: unknown, + type: string, + argumentSpec: string[], + hasMoreTypesToCheck: boolean +): void => { + if (typeof arg !== type && !hasMoreTypesToCheck) { + throw new JMESPathTypeError({ + currentValue: arg, + expectedTypes: argumentSpec, + actualType: getType(arg), + }); + } +}; + +/** + * Check if the argument is an array of complex types. + * + * @param arg The argument to check + * @param type The type to check against + * @param hasMoreTypesToCheck Whether there are more types to check + */ +const checkComplexArrayType = ( + arg: unknown[], + type: string, + hasMoreTypesToCheck: boolean +): void => { + if (!type.includes('-')) return; + const arrayItemsType = type.slice(6); + let actualType: string | undefined; + for (const element of arg) { + try { + typeCheckArgument(element, [arrayItemsType]); + actualType = arrayItemsType; + } catch (error) { + if (!hasMoreTypesToCheck || actualType !== undefined) { + throw error; + } + } + } +}; + +/** + * Check if the argument is an expression. + * + * @param arg The argument to check + * @param type The type to check against + * @param hasMoreTypesToCheck Whether there are more types to check + */ +const checkExpressionType = ( + arg: unknown, + type: string[], + hasMoreTypesToCheck: boolean +): void => { + if (!(arg instanceof Expression) && !hasMoreTypesToCheck) { + throw new JMESPathTypeError({ + currentValue: arg, + expectedTypes: type, + actualType: getType(arg), + }); + } +}; + +/** + * Check if the argument is an object. + * + * @param arg The argument to check + * @param type The type to check against + * @param hasMoreTypesToCheck Whether there are more types to check + */ +const checkObjectType = ( + arg: unknown, + type: string[], + hasMoreTypesToCheck: boolean +): void => { + if (!isRecord(arg) && !hasMoreTypesToCheck) { + throw new JMESPathTypeError({ + currentValue: arg, + expectedTypes: type, + actualType: getType(arg), + }); + } +}; + +export { isTruthy, arityCheck, sliceArray, typeCheck, typeCheckArgument }; diff --git a/packages/jmespath/tests/unit/compliance/base.test.ts b/packages/jmespath/tests/unit/compliance/base.test.ts new file mode 100644 index 0000000000..d0b7b72114 --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/base.test.ts @@ -0,0 +1,137 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/base + */ +import { search } from '../../../src'; + +describe('Base tests', () => { + it.each([ + { + expression: 'foo', + expected: { bar: { baz: 'correct' } }, + }, + { + expression: 'foo.bar', + expected: { baz: 'correct' }, + }, + { + expression: 'foo.bar.baz', + expected: 'correct', + }, + { + expression: 'foo\n.\nbar\n.baz', + expected: 'correct', + }, + { + expression: 'foo.bar.baz.bad', + expected: null, + }, + { + expression: 'foo.bar.bad', + expected: null, + }, + { + expression: 'foo.bad', + expected: null, + }, + { + expression: 'bad', + expected: null, + }, + { + expression: 'bad.morebad.morebad', + expected: null, + }, + ])( + 'should parse a multi-level nested object: $expression', + ({ expression, expected }) => { + // Prepare + const data = { foo: { bar: { baz: 'correct' } } }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo', + expected: { bar: ['one', 'two', 'three'] }, + }, + { + expression: 'foo.bar', + expected: ['one', 'two', 'three'], + }, + ])( + 'should parse multi-level objects with arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { foo: { bar: ['one', 'two', 'three'] } }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'one', + expected: null, + }, + { + expression: 'two', + expected: null, + }, + { + expression: 'three', + expected: null, + }, + { + expression: 'one.two', + expected: null, + }, + ])('should parse an array: $expression', ({ expression, expected }) => { + // Prepare + const data = ['one', 'two', 'three']; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'foo."1"', + expected: ['one', 'two', 'three'], + }, + { + expression: 'foo."1"[0]', + expected: 'one', + }, + { + expression: 'foo."-1"', + expected: 'bar', + }, + ])( + 'should parse an object with arrays and numeric values as keys: $expression', + ({ expression, expected }) => { + // Prepare + const data = { foo: { '1': ['one', 'two', 'three'], '-1': 'bar' } }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/boolean.test.ts b/packages/jmespath/tests/unit/compliance/boolean.test.ts new file mode 100644 index 0000000000..ae91b16f4e --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/boolean.test.ts @@ -0,0 +1,304 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/boolean + */ +import { search } from '../../../src'; + +describe('Boolean tests', () => { + it.each([ + { + expression: 'outer.foo || outer.bar', + expected: 'foo', + }, + { + expression: 'outer.foo||outer.bar', + expected: 'foo', + }, + { + expression: 'outer.bar || outer.baz', + expected: 'bar', + }, + { + expression: 'outer.bar||outer.baz', + expected: 'bar', + }, + { + expression: 'outer.bad || outer.foo', + expected: 'foo', + }, + { + expression: 'outer.bad||outer.foo', + expected: 'foo', + }, + { + expression: 'outer.foo || outer.bad', + expected: 'foo', + }, + { + expression: 'outer.foo||outer.bad', + expected: 'foo', + }, + { + expression: 'outer.bad || outer.alsobad', + expected: null, + }, + { + expression: 'outer.bad||outer.alsobad', + expected: null, + }, + ])( + 'should support boolean OR comparison: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + outer: { + foo: 'foo', + bar: 'bar', + baz: 'baz', + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'outer.empty_string || outer.foo', + expected: 'foo', + }, + { + expression: + 'outer.nokey || outer.bool || outer.empty_list || outer.empty_string || outer.foo', + expected: 'foo', + }, + ])( + 'should support multiple boolean OR comparisons: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + outer: { + foo: 'foo', + bool: false, + empty_list: [], + empty_string: '', + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'True && False', + expected: false, + }, + { + expression: 'False && True', + expected: false, + }, + { + expression: 'True && True', + expected: true, + }, + { + expression: 'False && False', + expected: false, + }, + { + expression: 'True && Number', + expected: 5, + }, + { + expression: 'Number && True', + expected: true, + }, + { + expression: 'Number && False', + expected: false, + }, + { + expression: 'Number && EmptyList', + expected: [], + }, + { + expression: 'Number && True', + expected: true, + }, + { + expression: 'EmptyList && True', + expected: [], + }, + { + expression: 'EmptyList && False', + expected: [], + }, + { + expression: 'True || False', + expected: true, + }, + { + expression: 'True || True', + expected: true, + }, + { + expression: 'False || True', + expected: true, + }, + { + expression: 'False || False', + expected: false, + }, + { + expression: 'Number || EmptyList', + expected: 5, + }, + { + expression: 'Number || True', + expected: 5, + }, + { + expression: 'Number || True && False', + expected: 5, + }, + { + expression: '(Number || True) && False', + expected: false, + }, + { + expression: 'Number || (True && False)', + expected: 5, + }, + { + expression: '!True', + expected: false, + }, + { + expression: '!False', + expected: true, + }, + { + expression: '!Number', + expected: false, + }, + { + expression: '!EmptyList', + expected: true, + }, + { + expression: 'True && !False', + expected: true, + }, + { + expression: 'True && !EmptyList', + expected: true, + }, + { + expression: '!False && !EmptyList', + expected: true, + }, + { + expression: '!(True && False)', + expected: true, + }, + { + expression: '!Zero', + expected: false, + }, + { + expression: '!!Zero', + expected: true, + }, + ])( + 'should support boolean AND comparison: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + True: true, + False: false, + Number: 5, + EmptyList: [], + Zero: 0, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'one < two', + expected: true, + }, + { + expression: 'one <= two', + expected: true, + }, + { + expression: 'one == one', + expected: true, + }, + { + expression: 'one == two', + expected: false, + }, + { + expression: 'one > two', + expected: false, + }, + { + expression: 'one >= two', + expected: false, + }, + { + expression: 'one != two', + expected: true, + }, + { + expression: 'one < two && three > one', + expected: true, + }, + { + expression: 'one < two || three > one', + expected: true, + }, + { + expression: 'one < two || three < one', + expected: true, + }, + { + expression: 'two < one || three < one', + expected: false, + }, + ])( + 'should support lesser and equal comparison: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + one: 1, + two: 2, + three: 3, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/current.test.ts b/packages/jmespath/tests/unit/compliance/current.test.ts new file mode 100644 index 0000000000..273b8fb43a --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/current.test.ts @@ -0,0 +1,41 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/current + */ +import { search } from '../../../src'; + +describe('Current operator tests', () => { + it.each([ + { + expression: '@', + expected: { + foo: [{ name: 'a' }, { name: 'b' }], + bar: { baz: 'qux' }, + }, + }, + { + expression: '@.bar', + expected: { baz: 'qux' }, + }, + { + expression: '@.foo[0]', + expected: { name: 'a' }, + }, + ])( + 'should support the current operator: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [{ name: 'a' }, { name: 'b' }], + bar: { baz: 'qux' }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/escape.test.ts b/packages/jmespath/tests/unit/compliance/escape.test.ts new file mode 100644 index 0000000000..259d4fcae5 --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/escape.test.ts @@ -0,0 +1,64 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/escape + */ +import { search } from '../../../src'; + +describe('Escape characters tests', () => { + it.each([ + { + expression: '"foo.bar"', + expected: 'dot', + }, + { + expression: '"foo bar"', + expected: 'space', + }, + { + expression: '"foo\\nbar"', + expected: 'newline', + }, + { + expression: '"foo\\"bar"', + expected: 'doublequote', + }, + { + expression: '"c:\\\\\\\\windows\\\\path"', + expected: 'windows', + }, + { + expression: '"/unix/path"', + expected: 'unix', + }, + { + expression: '"\\"\\"\\""', + expected: 'threequotes', + }, + { + expression: '"bar"."baz"', + expected: 'qux', + }, + ])( + 'should support escaping characters: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + 'foo.bar': 'dot', + 'foo bar': 'space', + 'foo\nbar': 'newline', + 'foo"bar': 'doublequote', + 'c:\\\\windows\\path': 'windows', + '/unix/path': 'unix', + '"""': 'threequotes', + bar: { baz: 'qux' }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/filters.test.ts b/packages/jmespath/tests/unit/compliance/filters.test.ts new file mode 100644 index 0000000000..a85ee40a2a --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/filters.test.ts @@ -0,0 +1,927 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/filters + */ +import { search } from '../../../src'; + +describe('Filer operator tests', () => { + it.each([ + { + comment: 'Matching a literal', + expression: `foo[?name == 'a']`, + expected: [{ name: 'a' }], + }, + ])('should match a literal: $expression', ({ expression, expected }) => { + // Prepare + const data = { foo: [{ name: 'a' }, { name: 'b' }] }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: '*[?[0] == `0`]', + expected: [[], []], + }, + ])( + 'should match a literal in arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { foo: [0, 1], bar: [2, 3] }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[?first == last]', + expected: [{ first: 'foo', last: 'foo' }], + }, + { + comment: 'Verify projection created from filter', + expression: 'foo[?first == last].first', + expected: ['foo'], + }, + ])('should match an expression: $expression', ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { first: 'foo', last: 'bar' }, + { first: 'foo', last: 'foo' }, + { first: 'foo', last: 'baz' }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + comment: 'Greater than with a number', + expression: 'foo[?age > `25`]', + expected: [{ age: 30 }], + }, + { + expression: 'foo[?age >= `25`]', + expected: [{ age: 25 }, { age: 30 }], + }, + { + comment: 'Greater than with a number', + expression: 'foo[?age > `30`]', + expected: [], + }, + { + comment: 'Greater than with a number', + expression: 'foo[?age < `25`]', + expected: [{ age: 20 }], + }, + { + comment: 'Greater than with a number', + expression: 'foo[?age <= `25`]', + expected: [{ age: 20 }, { age: 25 }], + }, + { + comment: 'Greater than with a number', + expression: 'foo[?age < `20`]', + expected: [], + }, + { + expression: 'foo[?age == `20`]', + expected: [{ age: 20 }], + }, + { + expression: 'foo[?age != `20`]', + expected: [{ age: 25 }, { age: 30 }], + }, + ])( + 'should match an expression with operators: $expression', + ({ expression, expected }) => { + // Prepare + const data = { foo: [{ age: 20 }, { age: 25 }, { age: 30 }] }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Greater than with a number', + expression: 'foo[?weight > `44.4`]', + expected: [{ weight: 55.5 }], + }, + { + expression: 'foo[?weight >= `44.4`]', + expected: [{ weight: 44.4 }, { weight: 55.5 }], + }, + { + comment: 'Greater than with a number', + expression: 'foo[?weight > `55.5`]', + expected: [], + }, + { + comment: 'Greater than with a number', + expression: 'foo[?weight < `44.4`]', + expected: [{ weight: 33.3 }], + }, + { + comment: 'Greater than with a number', + expression: 'foo[?weight <= `44.4`]', + expected: [{ weight: 33.3 }, { weight: 44.4 }], + }, + { + comment: 'Greater than with a number', + expression: 'foo[?weight < `33.3`]', + expected: [], + }, + { + expression: 'foo[?weight == `33.3`]', + expected: [{ weight: 33.3 }], + }, + { + expression: 'foo[?weight != `33.3`]', + expected: [{ weight: 44.4 }, { weight: 55.5 }], + }, + ])( + 'should match an expression with comparisons: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [{ weight: 33.3 }, { weight: 44.4 }, { weight: 55.5 }], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: `foo[?top.name == 'a']`, + expected: [{ top: { name: 'a' } }], + }, + ])( + 'should match with subexpression: $expression', + ({ expression, expected }) => { + // Prepare + const data = { foo: [{ top: { name: 'a' } }, { top: { name: 'b' } }] }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Matching an expression', + expression: 'foo[?top.first == top.last]', + expected: [{ top: { first: 'foo', last: 'foo' } }], + }, + { + comment: 'Matching a JSON array', + expression: 'foo[?top == `{"first": "foo", "last": "bar"}`]', + expected: [{ top: { first: 'foo', last: 'bar' } }], + }, + ])('should match with arrays: $expression', ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { top: { first: 'foo', last: 'bar' } }, + { top: { first: 'foo', last: 'foo' } }, + { top: { first: 'foo', last: 'baz' } }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'foo[?key == `true`]', + expected: [{ key: true }], + }, + { + expression: 'foo[?key == `false`]', + expected: [{ key: false }], + }, + { + expression: 'foo[?key == `0`]', + expected: [{ key: 0 }], + }, + { + expression: 'foo[?key == `1`]', + expected: [{ key: 1 }], + }, + { + expression: 'foo[?key == `[0]`]', + expected: [{ key: [0] }], + }, + { + expression: 'foo[?key == `{"bar": [0]}`]', + expected: [{ key: { bar: [0] } }], + }, + { + expression: 'foo[?key == `null`]', + expected: [{ key: null }], + }, + { + expression: 'foo[?key == `[1]`]', + expected: [{ key: [1] }], + }, + { + expression: 'foo[?key == `{"a":2}`]', + expected: [{ key: { a: 2 } }], + }, + { + expression: 'foo[?`true` == key]', + expected: [{ key: true }], + }, + { + expression: 'foo[?`false` == key]', + expected: [{ key: false }], + }, + { + expression: 'foo[?`0` == key]', + expected: [{ key: 0 }], + }, + { + expression: 'foo[?`1` == key]', + expected: [{ key: 1 }], + }, + { + expression: 'foo[?`[0]` == key]', + expected: [{ key: [0] }], + }, + { + expression: 'foo[?`{"bar": [0]}` == key]', + expected: [{ key: { bar: [0] } }], + }, + { + expression: 'foo[?`null` == key]', + expected: [{ key: null }], + }, + { + expression: 'foo[?`[1]` == key]', + expected: [{ key: [1] }], + }, + { + expression: 'foo[?`{"a":2}` == key]', + expected: [{ key: { a: 2 } }], + }, + { + expression: 'foo[?key != `true`]', + expected: [ + { key: false }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?key != `false`]', + expected: [ + { key: true }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?key != `0`]', + expected: [ + { key: true }, + { key: false }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?key != `1`]', + expected: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?key != `null`]', + expected: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?key != `[1]`]', + expected: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?key != `{"a":2}`]', + expected: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + ], + }, + { + expression: 'foo[?`true` != key]', + expected: [ + { key: false }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?`false` != key]', + expected: [ + { key: true }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?`0` != key]', + expected: [ + { key: true }, + { key: false }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?`1` != key]', + expected: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?`null` != key]', + expected: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?`[1]` != key]', + expected: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[?`{"a":2}` != key]', + expected: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + ], + }, + ])( + 'should match with object that have mixed types as values: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: 1 }, + { key: [0] }, + { key: { bar: [0] } }, + { key: null }, + { key: [1] }, + { key: { a: 2 } }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[?key == `true`]', + expected: [{ key: true }], + }, + { + expression: 'foo[?key == `false`]', + expected: [{ key: false }], + }, + { + expression: 'foo[?key]', + expected: [ + { key: true }, + { key: 0 }, + { key: 0.0 }, + { key: 1 }, + { key: 1.0 }, + { key: [0] }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[? !key]', + expected: [{ key: false }, { key: null }, { key: [] }, { key: {} }], + }, + { + expression: 'foo[? !!key]', + expected: [ + { key: true }, + { key: 0 }, + { key: 0.0 }, + { key: 1 }, + { key: 1.0 }, + { key: [0] }, + { key: [1] }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[? `true`]', + expected: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: 0.0 }, + { key: 1 }, + { key: 1.0 }, + { key: [0] }, + { key: null }, + { key: [1] }, + { key: [] }, + { key: {} }, + { key: { a: 2 } }, + ], + }, + { + expression: 'foo[? `false`]', + expected: [], + }, + ])( + 'should match with falsy values: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { key: true }, + { key: false }, + { key: 0 }, + { key: 0.0 }, + { key: 1 }, + { key: 1.0 }, + { key: [0] }, + { key: null }, + { key: [1] }, + { key: [] }, + { key: {} }, + { key: { a: 2 } }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'reservations[].instances[?bar==`1`]', + expected: [[{ foo: 2, bar: 1 }]], + }, + { + expression: 'reservations[*].instances[?bar==`1`]', + expected: [[{ foo: 2, bar: 1 }]], + }, + { + expression: 'reservations[].instances[?bar==`1`][]', + expected: [{ foo: 2, bar: 1 }], + }, + ])( + 'should match with nested objects and arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + reservations: [ + { + instances: [ + { foo: 1, bar: 2 }, + { foo: 1, bar: 3 }, + { foo: 1, bar: 2 }, + { foo: 2, bar: 1 }, + ], + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[?bar==`1`].bar[0]', + expected: [], + }, + ])( + 'should match with nested objects and arrays with different structures: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + baz: 'other', + foo: [ + { bar: 1 }, + { bar: 2 }, + { bar: 3 }, + { bar: 4 }, + { bar: 1, baz: 2 }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[?a==`1`].b.c', + expected: ['x', 'y', 'z'], + }, + ])( + 'should support filter in indexes: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { a: 1, b: { c: 'x' } }, + { a: 1, b: { c: 'y' } }, + { a: 1, b: { c: 'z' } }, + { a: 2, b: { c: 'z' } }, + { a: 1, baz: 2 }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Filter with or expression', + expression: `foo[?name == 'a' || name == 'b']`, + expected: [{ name: 'a' }, { name: 'b' }], + }, + { + expression: `foo[?name == 'a' || name == 'e']`, + expected: [{ name: 'a' }], + }, + { + expression: `foo[?name == 'a' || name == 'b' || name == 'c']`, + expected: [{ name: 'a' }, { name: 'b' }, { name: 'c' }], + }, + ])( + 'should support filter with or expressions: $expression', + ({ expression, expected }) => { + // Prepare + const data = { foo: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Filter with and expression', + expression: 'foo[?a == `1` && b == `2`]', + expected: [{ a: 1, b: 2 }], + }, + { + expression: 'foo[?a == `1` && b == `4`]', + expected: [], + }, + ])( + 'should support filter and expressions: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { a: 1, b: 2 }, + { a: 1, b: 3 }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Filter with Or and And expressions', + expression: 'foo[?c == `3` || a == `1` && b == `4`]', + expected: [{ a: 1, b: 2, c: 3 }], + }, + { + expression: 'foo[?b == `2` || a == `3` && b == `4`]', + expected: [ + { a: 1, b: 2, c: 3 }, + { a: 3, b: 4 }, + ], + }, + { + expression: 'foo[?a == `3` && b == `4` || b == `2`]', + expected: [ + { a: 1, b: 2, c: 3 }, + { a: 3, b: 4 }, + ], + }, + { + expression: 'foo[?(a == `3` && b == `4`) || b == `2`]', + expected: [ + { a: 1, b: 2, c: 3 }, + { a: 3, b: 4 }, + ], + }, + { + expression: 'foo[?((a == `3` && b == `4`)) || b == `2`]', + expected: [ + { a: 1, b: 2, c: 3 }, + { a: 3, b: 4 }, + ], + }, + { + expression: 'foo[?a == `3` && (b == `4` || b == `2`)]', + expected: [{ a: 3, b: 4 }], + }, + { + expression: 'foo[?a == `3` && ((b == `4` || b == `2`))]', + expected: [{ a: 3, b: 4 }], + }, + ])( + 'should support filter with or & and expressions', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { a: 1, b: 2, c: 3 }, + { a: 3, b: 4 }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Verify precedence of or/and expressions', + expression: 'foo[?a == `1` || b ==`2` && c == `5`]', + expected: [{ a: 1, b: 2, c: 3 }], + }, + { + comment: 'Parentheses can alter precedence', + expression: 'foo[?(a == `1` || b ==`2`) && c == `5`]', + expected: [], + }, + { + comment: 'Not expressions combined with and/or', + expression: 'foo[?!(a == `1` || b ==`2`)]', + expected: [{ a: 3, b: 4 }], + }, + ])( + 'should support filter with expressions and respect precedence: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { a: 1, b: 2, c: 3 }, + { a: 3, b: 4 }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Unary filter expression', + expression: 'foo[?key]', + expected: [ + { key: true }, + { key: [0] }, + { key: { a: 'b' } }, + { key: 0 }, + { key: 1 }, + ], + }, + { + comment: 'Unary not filter expression', + expression: 'foo[?!key]', + expected: [ + { key: false }, + { key: [] }, + { key: {} }, + { key: null }, + { notkey: true }, + ], + }, + { + comment: 'Equality with null RHS', + expression: 'foo[?key == `null`]', + expected: [{ key: null }, { notkey: true }], + }, + ])( + 'should support unary expressions: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { key: true }, + { key: false }, + { key: [] }, + { key: {} }, + { key: [0] }, + { key: { a: 'b' } }, + { key: 0 }, + { key: 1 }, + { key: null }, + { notkey: true }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Using @ in a filter expression', + expression: 'foo[?@ < `5`]', + expected: [0, 1, 2, 3, 4], + }, + { + comment: 'Using @ in a filter expression', + expression: 'foo[?`5` > @]', + expected: [0, 1, 2, 3, 4], + }, + { + comment: 'Using @ in a filter expression', + expression: 'foo[?@ == @]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + ])( + 'should support using current in a filter: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/functions.test.ts b/packages/jmespath/tests/unit/compliance/functions.test.ts new file mode 100644 index 0000000000..eb77d30d03 --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/functions.test.ts @@ -0,0 +1,2415 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/functions + */ +import { search } from '../../../src'; + +describe('Functions tests', () => { + it.each([ + { + expression: 'abs(foo)', + expected: 1, + }, + { + expression: 'abs(foo)', + expected: 1, + }, + { + expression: 'abs(array[1])', + expected: 3, + }, + { + expression: 'abs(array[1])', + expected: 3, + }, + { + expression: 'abs(`-24`)', + expected: 24, + }, + { + expression: 'abs(`-24`)', + expected: 24, + }, + ])( + 'should support the abs() function: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'abs(str)', + error: + 'Invalid argument type for function abs(), expected "number" but found "string" in expression: abs(str)', + }, + { + expression: 'abs(`false`)', + error: + 'Invalid argument type for function abs(), expected "number" but found "boolean" in expression: abs(`false`)', + }, + { + expression: 'abs(`1`, `2`)', + error: + 'Expected at most 1 argument for function abs(), received 2 in expression: abs(`1`, `2`)', + }, + { + expression: 'abs()', + error: + 'Expected at least 1 argument for function abs(), received 0 in expression: abs()', + }, + ])('abs() function errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'unknown_function(`1`, `2`)', + error: + 'Unknown function: unknown_function() in expression: unknown_function(`1`, `2`)', + }, + ])('unknown function errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'avg(numbers)', + expected: 2.75, + }, + ])( + 'should support the avg() function: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'avg(array)', + error: + 'Invalid argument type for function avg(), expected "number" but found "string" in expression: avg(array)', + }, + { + expression: `avg('abc')`, + error: `Invalid argument type for function avg(), expected "array-number" but found "string" in expression: avg('abc')`, + }, + { + expression: 'avg(foo)', + error: + 'Invalid argument type for function avg(), expected "array-number" but found "number" in expression: avg(foo)', + }, + { + expression: 'avg(@)', + error: + 'Invalid argument type for function avg(), expected "array-number" but found "object" in expression: avg(@)', + }, + { + expression: 'avg(strings)', + error: + 'Invalid argument type for function avg(), expected "number" but found "string" in expression: avg(strings)', + }, + ])('avg() function errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'ceil(`1.2`)', + expected: 2, + }, + { + expression: 'ceil(decimals[0])', + expected: 2, + }, + { + expression: 'ceil(decimals[1])', + expected: 2, + }, + { + expression: 'ceil(decimals[2])', + expected: -1, + }, + ])( + 'should support the ceil() function: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: `ceil('string')`, + error: `Invalid argument type for function ceil(), expected "number" but found "string" in expression: ceil('string')`, + }, + ])('ceil() function errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: `contains('abc', 'a')`, + expected: true, + }, + { + expression: `contains('abc', 'd')`, + expected: false, + }, + { + // prettier-ignore + expression: 'contains(strings, \'a\')', + expected: true, + }, + { + expression: 'contains(decimals, `1.2`)', + expected: true, + }, + { + expression: 'contains(decimals, `false`)', + expected: false, + }, + ])( + 'should support the contains() function: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'contains(`false`, "d")', + error: + 'Invalid argument type for function contains(), expected one of "array", "string" but found "boolean" in expression: contains(`false`, "d")', + }, + ])('contains() function errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: `ends_with(str, 'r')`, + expected: true, + }, + { + expression: `ends_with(str, 'tr')`, + expected: true, + }, + { + expression: `ends_with(str, 'Str')`, + expected: true, + }, + { + expression: `ends_with(str, 'SStr')`, + expected: false, + }, + { + expression: `ends_with(str, 'foo')`, + expected: false, + }, + ])( + 'should support the ends_with() function: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'ends_with(str, `0`)', + error: + 'Invalid argument type for function ends_with(), expected "string" but found "number" in expression: ends_with(str, `0`)', + }, + ])('ends_with() function errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'floor(`1.2`)', + expected: 1, + }, + { + expression: 'floor(decimals[0])', + expected: 1, + }, + { + expression: 'floor(foo)', + expected: -1, + }, + ])( + 'should support the floor() function: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: `floor('string')`, + error: `Invalid argument type for function floor(), expected "number" but found "string" in expression: floor('string')`, + }, + { + expression: 'floor(str)', + error: + 'Invalid argument type for function floor(), expected "number" but found "string" in expression: floor(str)', + }, + ])('floor() function errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: `length('abc')`, + expected: 3, + }, + { + expression: `length('✓foo')`, + expected: 4, + }, + { + expression: `length('')`, + expected: 0, + }, + { + expression: 'length(@)', + expected: 12, + }, + { + expression: 'length(strings[0])', + expected: 1, + }, + { + expression: 'length(str)', + expected: 3, + }, + { + expression: 'length(array)', + expected: 6, + }, + { + expression: 'length(objects)', + expected: 2, + }, + { + expression: 'length(strings[0])', + expected: 1, + }, + ])( + 'should support the length() function: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'length(`false`)', + error: + 'Invalid argument type for function length(), expected one of "array", "string", "object" but found "boolean" in expression: length(`false`)', + }, + { + expression: 'length(foo)', + error: + 'Invalid argument type for function length(), expected one of "array", "string", "object" but found "number" in expression: length(foo)', + }, + ])('length() function errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'max(numbers)', + expected: 5, + }, + { + expression: 'max(decimals)', + expected: 1.2, + }, + { + expression: 'max(strings)', + expected: 'c', + }, + { + expression: 'max(decimals)', + expected: 1.2, + }, + { + expression: 'max(empty_list)', + expected: null, + }, + ])( + 'should support the max() function: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'max(abc)', + error: + 'Invalid argument type for function max(), expected one of "array-number", "array-string" but found "null" in expression: max(abc)', + }, + { + expression: 'max(array)', + error: + 'Invalid argument type for function max(), expected "number" but found "string" in expression: max(array)', + }, + ])('max() function errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'merge(`{}`)', + expected: {}, + }, + { + expression: 'merge(`{}`, `{}`)', + expected: {}, + }, + { + expression: 'merge(`{"a": 1}`, `{"b": 2}`)', + expected: { + a: 1, + b: 2, + }, + }, + { + expression: 'merge(`{"a": 1}`, `{"a": 2}`)', + expected: { + a: 2, + }, + }, + { + expression: 'merge(`{"a": 1, "b": 2}`, `{"a": 2, "c": 3}`, `{"d": 4}`)', + expected: { + a: 2, + b: 2, + c: 3, + d: 4, + }, + }, + ])( + 'should support the merge() function: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'min(numbers)', + expected: -1, + }, + { + expression: 'min(decimals)', + expected: -1.5, + }, + { + expression: 'min(empty_list)', + expected: null, + }, + { + expression: 'min(decimals)', + expected: -1.5, + }, + { + expression: 'min(strings)', + expected: 'a', + }, + ])( + 'should support the min() function: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'min(abc)', + error: + 'Invalid argument type for function min(), expected one of "array-number", "array-string" but found "null" in expression: min(abc)', + }, + { + expression: 'min(array)', + error: + 'Invalid argument type for function min(), expected "number" but found "string" in expression: min(array)', + }, + ])('min() function errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: `type('abc')`, + expected: 'string', + }, + { + expression: 'type(`1.0`)', + expected: 'number', + }, + { + expression: 'type(`2`)', + expected: 'number', + }, + { + expression: 'type(`true`)', + expected: 'boolean', + }, + { + expression: 'type(`false`)', + expected: 'boolean', + }, + { + expression: 'type(`null`)', + expected: 'null', + }, + { + expression: 'type(`[0]`)', + expected: 'array', + }, + { + expression: 'type(`{"a": "b"}`)', + expected: 'object', + }, + { + expression: 'type(@)', + expected: 'object', + }, + ])('should support the type() function', ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'sort(keys(objects))', + expected: ['bar', 'foo'], + }, + { + expression: 'sort(values(objects))', + expected: ['bar', 'baz'], + }, + { + expression: 'keys(empty_hash)', + expected: [], + }, + { + expression: 'sort(numbers)', + expected: [-1, 3, 4, 5], + }, + { + expression: 'sort(strings)', + expected: ['a', 'b', 'c'], + }, + { + expression: 'sort(decimals)', + expected: [-1.5, 1.01, 1.2], + }, + { + expression: 'sort(empty_list)', + expected: [], + }, + ])( + 'should support the sort(), key(), and values() functions', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'keys(foo)', + error: + 'Invalid argument type for function keys(), expected "object" but found "number" in expression: keys(foo)', + }, + { + expression: 'keys(strings)', + error: + 'Invalid argument type for function keys(), expected "object" but found "array" in expression: keys(strings)', + }, + { + expression: 'keys(`false`)', + error: + 'Invalid argument type for function keys(), expected "object" but found "boolean" in expression: keys(`false`)', + }, + { + expression: 'values(foo)', + error: + 'Invalid argument type for function values(), expected "object" but found "number" in expression: values(foo)', + }, + { + expression: 'sort(array)', + error: + 'Invalid argument type for function sort(), expected "number" but found "string" in expression: sort(array)', + }, + { + expression: 'sort(abc)', + error: + 'Invalid argument type for function sort(), expected one of "array-number", "array-string" but found "null" in expression: sort(abc)', + }, + { + expression: 'sort(@)', + error: + 'Invalid argument type for function sort(), expected one of "array-number", "array-string" but found "object" in expression: sort(@)', + }, + ])( + 'sort(), keys(), and values() function errors', + ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + } + ); + + it.each([ + { + expression: `join(', ', strings)`, + expected: 'a, b, c', + }, + { + expression: `join(', ', strings)`, + expected: 'a, b, c', + }, + { + expression: 'join(\',\', `["a", "b"]`)', + expected: 'a,b', + }, + { + expression: `join('|', strings)`, + expected: 'a|b|c', + }, + { + expression: `join('|', decimals[].to_string(@))`, + expected: '1.01|1.2|-1.5', + }, + { + expression: `join('|', empty_list)`, + expected: '', + }, + ])('should support the join() function', ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'join(\',\', `["a", 0]`)', + error: + 'Invalid argument type for function join(), expected "string" but found "number" in expression: join(\',\', `["a", 0]`)', + }, + { + expression: `join(', ', str)`, + error: `Invalid argument type for function join(), expected "array-string" but found "string" in expression: join(', ', str)`, + }, + { + expression: 'join(`2`, strings)', + error: + 'Invalid argument type for function join(), expected "string" but found "number" in expression: join(`2`, strings)', + }, + { + expression: `join('|', decimals)`, + error: + 'Invalid argument type for function join(), expected "string" but found "number" in expression: join(\'|\', decimals)', + }, + ])('join() function errors', ({ expression, error }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'reverse(numbers)', + expected: [5, 4, 3, -1], + }, + { + expression: 'reverse(array)', + expected: ['100', 'a', 5, 4, 3, -1], + }, + { + expression: 'reverse(`[]`)', + expected: [], + }, + { + expression: `reverse('')`, + expected: '', + }, + { + expression: `reverse('hello world')`, + expected: 'dlrow olleh', + }, + ])('should support the reverse() function', ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: `starts_with(str, 'S')`, + expected: true, + }, + { + expression: `starts_with(str, 'St')`, + expected: true, + }, + { + expression: `starts_with(str, 'Str')`, + expected: true, + }, + { + expression: `starts_with(str, 'String')`, + expected: false, + }, + ])( + 'should support the starts_with() function', + ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'starts_with(str, `0`)', + error: + 'Invalid argument type for function starts_with(), expected "string" but found "null" in expression: starts_with(str, `0`)', + }, + ])('starts_with() function errors', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'sum(numbers)', + expected: 11, + }, + { + expression: 'sum(decimals)', + expected: 0.71, + }, + { + expression: 'sum(array[].to_number(@))', + expected: 111, + }, + { + expression: 'sum(`[]`)', + expected: 0, + }, + ])('should support the sum() function', ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'sum(array)', + error: + 'Invalid argument type for function sum(), expected "array-number" but found "null" in expression: sum(array)', + }, + ])('sum() function errors', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: `to_array('foo')`, + expected: ['foo'], + }, + { + expression: 'to_array(`0`)', + expected: [0], + }, + { + expression: 'to_array(objects)', + expected: [ + { + foo: 'bar', + bar: 'baz', + }, + ], + }, + { + expression: 'to_array(`[1, 2, 3]`)', + expected: [1, 2, 3], + }, + { + expression: 'to_array(false)', + expected: [false], + }, + ])('should support the to_array() function', ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: `to_string('foo')`, + expected: 'foo', + }, + { + expression: 'to_string(`1.2`)', + expected: '1.2', + }, + { + expression: 'to_string(`[0, 1]`)', + expected: '[0,1]', + }, + { + description: 'function projection on single arg function', + expression: 'numbers[].to_string(@)', + expected: ['-1', '3', '4', '5'], + }, + ])('should support the to_string() function', ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: `to_number('1.0')`, + expected: 1.0, + }, + { + expression: `to_number('1.1')`, + expected: 1.1, + }, + { + expression: `to_number('4')`, + expected: 4, + }, + { + expression: `to_number('notanumber')`, + expected: null, + }, + { + expression: 'to_number(`false`)', + expected: null, + }, + { + expression: 'to_number(`null`)', + expected: null, + }, + { + expression: 'to_number(`[0]`)', + expected: null, + }, + { + expression: 'to_number(`{"foo": 0}`)', + expected: null, + }, + { + description: 'function projection on single arg function', + expression: 'array[].to_number(@)', + expected: [-1, 3, 4, 5, 100], + }, + ])('should support the to_number() function', ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: '"to_string"(`1.0`)', + error: + 'Invalid jmespath expression: parse error at column 0, quoted identifiers cannot be used as a function name in expression: "to_string"(`1.0`)', + }, + ])('to_number() function errors', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'not_null(unknown_key, str)', + expected: 'Str', + }, + { + expression: 'not_null(unknown_key, foo.bar, empty_list, str)', + expected: [], + }, + { + expression: 'not_null(unknown_key, null_key, empty_list, str)', + expected: [], + }, + { + expression: 'not_null(all, expressions, are_null)', + expected: null, + }, + ])('should support the not_null() function', ({ expression, expected }) => { + // Prepare + const data = { + foo: -1, + zero: 0, + numbers: [-1, 3, 4, 5], + array: [-1, 3, 4, 5, 'a', '100'], + strings: ['a', 'b', 'c'], + decimals: [1.01, 1.2, -1.5], + str: 'Str', + false: false, + empty_list: [], + empty_hash: {}, + objects: { + foo: 'bar', + bar: 'baz', + }, + null_key: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'not_null()', + error: + 'Expected 1 argument for function not_null(), received 0 in expression: not_null()', + }, + ])('not_null() function errors', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + description: 'function projection on variadic function', + expression: 'foo[].not_null(f, e, d, c, b, a)', + expected: ['b', 'c', 'd', 'e', 'f'], + }, + ])( + 'should support function projection on variadic function', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { + b: 'b', + a: 'a', + }, + { + c: 'c', + b: 'b', + }, + { + d: 'd', + c: 'c', + }, + { + e: 'e', + d: 'd', + }, + { + f: 'f', + e: 'e', + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + description: 'sort by field expression', + expression: 'sort_by(people, &age)', + expected: [ + { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + { + age: 20, + age_str: '20', + bool: true, + name: 'a', + extra: 'foo', + }, + { + age: 30, + age_str: '30', + bool: true, + name: 'c', + }, + { + age: 40, + age_str: '40', + bool: false, + name: 'b', + extra: 'bar', + }, + { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + ], + }, + { + expression: 'sort_by(people, &age_str)', + expected: [ + { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + { + age: 20, + age_str: '20', + bool: true, + name: 'a', + extra: 'foo', + }, + { + age: 30, + age_str: '30', + bool: true, + name: 'c', + }, + { + age: 40, + age_str: '40', + bool: false, + name: 'b', + extra: 'bar', + }, + { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + ], + }, + { + description: 'sort by function expression', + expression: 'sort_by(people, &to_number(age_str))', + expected: [ + { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + { + age: 20, + age_str: '20', + bool: true, + name: 'a', + extra: 'foo', + }, + { + age: 30, + age_str: '30', + bool: true, + name: 'c', + }, + { + age: 40, + age_str: '40', + bool: false, + name: 'b', + extra: 'bar', + }, + { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + ], + }, + { + description: 'function projection on sort_by function', + expression: 'sort_by(people, &age)[].name', + expected: [3, 'a', 'c', 'b', 'd'], + }, + + { + expression: 'sort_by(people, &age)[].extra', + expected: ['foo', 'bar'], + }, + { + expression: 'sort_by(`[]`, &age)', + expected: [], + }, + { + expression: 'sort_by(people, &name)', + expected: [ + { age: 10, age_str: '10', bool: true, name: 3 }, + { age: 20, age_str: '20', bool: true, name: 'a', extra: 'foo' }, + { age: 40, age_str: '40', bool: false, name: 'b', extra: 'bar' }, + { age: 30, age_str: '30', bool: true, name: 'c' }, + { age: 50, age_str: '50', bool: false, name: 'd' }, + ], + }, + ])('should support sorty_by() special cases', ({ expression, expected }) => { + // Prepare + const data = { + people: [ + { + age: 20, + age_str: '20', + bool: true, + name: 'a', + extra: 'foo', + }, + { + age: 40, + age_str: '40', + bool: false, + name: 'b', + extra: 'bar', + }, + { + age: 30, + age_str: '30', + bool: true, + name: 'c', + }, + { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'sort_by(people, &extra)', + error: + 'Invalid argument type for function sort_by(), expected "string" but found "null" in expression: sort_by(people, &extra)', + }, + { + expression: 'sort_by(people, &bool)', + error: + 'Invalid argument type for function sort_by(), expected "string" but found "boolean" in expression: sort_by(people, &bool)', + }, + { + expression: 'sort_by(people, name)', + error: + 'Invalid argument type for function sort_by(), expected "expression" but found "null" in expression: sort_by(people, name)', + }, + ])('sort_by() function special cases errors', ({ expression, error }) => { + // Prepare + const data = { + people: [ + { + age: 20, + age_str: '20', + bool: true, + name: 'a', + extra: 'foo', + }, + { + age: 40, + age_str: '40', + bool: false, + name: 'b', + extra: 'bar', + }, + { + age: 30, + age_str: '30', + bool: true, + name: 'c', + }, + { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + ], + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'max_by(people, &age)', + expected: { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + }, + { + expression: 'max_by(people, &age_str)', + expected: { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + }, + { + expression: 'max_by(people, &to_number(age_str))', + expected: { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + }, + ])('should support max_by() special cases', ({ expression, expected }) => { + // Prepare + const data = { + people: [ + { + age: 20, + age_str: '20', + bool: true, + name: 'a', + extra: 'foo', + }, + { + age: 40, + age_str: '40', + bool: false, + name: 'b', + extra: 'bar', + }, + { + age: 30, + age_str: '30', + bool: true, + name: 'c', + }, + { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'max_by(people, &bool)', + error: + 'Invalid argument type for function max_by(), expected "string" but found "boolean" in expression: max_by(people, &bool)', + }, + { + expression: 'max_by(people, &extra)', + error: + 'Invalid argument type for function max_by(), expected "string" but found "null" in expression: max_by(people, &extra)', + }, + ])('max_by() function special cases errors', ({ expression, error }) => { + // Prepare + const data = { + people: [ + { + age: 20, + age_str: '20', + bool: true, + name: 'a', + extra: 'foo', + }, + { + age: 40, + age_str: '40', + bool: false, + name: 'b', + extra: 'bar', + }, + { + age: 30, + age_str: '30', + bool: true, + name: 'c', + }, + { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + ], + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'min_by(people, &age)', + expected: { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + }, + { + expression: 'min_by(people, &age_str)', + expected: { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + }, + { + expression: 'min_by(people, &to_number(age_str))', + expected: { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + }, + ])('should support min_by() special cases', ({ expression, expected }) => { + // Prepare + const data = { + people: [ + { + age: 20, + age_str: '20', + bool: true, + name: 'a', + extra: 'foo', + }, + { + age: 40, + age_str: '40', + bool: false, + name: 'b', + extra: 'bar', + }, + { + age: 30, + age_str: '30', + bool: true, + name: 'c', + }, + { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'min_by(people, &bool)', + error: + 'Invalid argument type for function min_by(), expected "string" but found "boolean" in expression: min_by(people, &bool)', + }, + { + expression: 'min_by(people, &extra)', + error: + 'Invalid argument type for function min_by(), expected "string" but found "null" in expression: min_by(people, &extra)', + }, + ])('min_by() function special cases errors', ({ expression, error }) => { + // Prepare + const data = { + people: [ + { + age: 20, + age_str: '20', + bool: true, + name: 'a', + extra: 'foo', + }, + { + age: 40, + age_str: '40', + bool: false, + name: 'b', + extra: 'bar', + }, + { + age: 30, + age_str: '30', + bool: true, + name: 'c', + }, + { + age: 50, + age_str: '50', + bool: false, + name: 'd', + }, + { + age: 10, + age_str: '10', + bool: true, + name: 3, + }, + ], + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + description: 'stable sort order', + expression: 'sort_by(people, &age)', + expected: [ + { + age: 10, + order: '1', + }, + { + age: 10, + order: '2', + }, + { + age: 10, + order: '3', + }, + { + age: 10, + order: '4', + }, + { + age: 10, + order: '5', + }, + { + age: 10, + order: '6', + }, + { + age: 10, + order: '7', + }, + { + age: 10, + order: '8', + }, + { + age: 10, + order: '9', + }, + { + age: 10, + order: '10', + }, + { + age: 10, + order: '11', + }, + ], + }, + ])('should support stable sort_by() order', ({ expression, expected }) => { + // Prepare + const data = { + people: [ + { + age: 10, + order: '1', + }, + { + age: 10, + order: '2', + }, + { + age: 10, + order: '3', + }, + { + age: 10, + order: '4', + }, + { + age: 10, + order: '5', + }, + { + age: 10, + order: '6', + }, + { + age: 10, + order: '7', + }, + { + age: 10, + order: '8', + }, + { + age: 10, + order: '9', + }, + { + age: 10, + order: '10', + }, + { + age: 10, + order: '11', + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'map(&a, people)', + expected: [10, 10, 10, 10, 10, 10, 10, 10, 10], + }, + { + expression: 'map(&c, people)', + expected: ['z', null, null, 'z', null, null, 'z', null, null], + }, + { + expression: 'map(&foo, empty)', + expected: [], + }, + ])('should support map() special cases', ({ expression, expected }) => { + // Prepare + const data = { + people: [ + { + a: 10, + b: 1, + c: 'z', + }, + { + a: 10, + b: 2, + c: null, + }, + { + a: 10, + b: 3, + }, + { + a: 10, + b: 4, + c: 'z', + }, + { + a: 10, + b: 5, + c: null, + }, + { + a: 10, + b: 6, + }, + { + a: 10, + b: 7, + c: 'z', + }, + { + a: 10, + b: 8, + c: null, + }, + { + a: 10, + b: 9, + }, + ], + empty: [], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'map(&a, badkey)', + error: + 'Invalid argument type for function map(), expected "array" but found "null" in expression: map(&a, badkey)', + }, + ])('map() function special cases errors', ({ expression, error }) => { + // Prepare + const data = { + people: [ + { + a: 10, + b: 1, + c: 'z', + }, + { + a: 10, + b: 2, + c: null, + }, + { + a: 10, + b: 3, + }, + { + a: 10, + b: 4, + c: 'z', + }, + { + a: 10, + b: 5, + c: null, + }, + { + a: 10, + b: 6, + }, + { + a: 10, + b: 7, + c: 'z', + }, + { + a: 10, + b: 8, + c: null, + }, + { + a: 10, + b: 9, + }, + ], + empty: [], + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'map(&foo.bar, array)', + expected: ['yes1', 'yes2', null], + }, + { + expression: 'map(&foo1.bar, array)', + expected: [null, null, 'no'], + }, + { + expression: 'map(&foo.bar.baz, array)', + expected: [null, null, null], + }, + ])( + 'should support map() with the `&` expression cases', + ({ expression, expected }) => { + // Prepare + const data = { + array: [ + { + foo: { + bar: 'yes1', + }, + }, + { + foo: { + bar: 'yes2', + }, + }, + { + foo1: { + bar: 'no', + }, + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'map(&[], array)', + expected: [ + [1, 2, 3, 4], + [5, 6, 7, 8, 9], + ], + }, + ])('should support map() with `&` and `[]`', ({ expression, expected }) => { + // Prepare + const data = { + array: [ + [1, 2, 3, [4]], + [5, 6, 7, [8, 9]], + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); +}); diff --git a/packages/jmespath/tests/unit/compliance/identifiers.test.ts b/packages/jmespath/tests/unit/compliance/identifiers.test.ts new file mode 100644 index 0000000000..9b33997bc6 --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/identifiers.test.ts @@ -0,0 +1,895 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/identifiers + */ +import { search } from '../../../src'; + +describe('Identifiers tests', () => { + it.each([ + { + data: { + __L: true, + }, + expression: '__L', + expected: true, + }, + { + data: { + '!\r': true, + }, + expression: '"!\\r"', + expected: true, + }, + { + data: { + Y_1623: true, + }, + expression: 'Y_1623', + expected: true, + }, + { + data: { + x: true, + }, + expression: 'x', + expected: true, + }, + { + data: { + '\tF\uCebb': true, + }, + expression: '"\\tF\\uCebb"', + expected: true, + }, + { + data: { + ' \t': true, + }, + expression: '" \\t"', + expected: true, + }, + { + data: { + ' ': true, + }, + expression: '" "', + expected: true, + }, + { + data: { + v2: true, + }, + expression: 'v2', + expected: true, + }, + { + data: { + '\t': true, + }, + expression: '"\\t"', + expected: true, + }, + { + data: { + _X: true, + }, + expression: '_X', + expected: true, + }, + { + data: { + '\t4\ud9da\udd15': true, + }, + expression: '"\\t4\\ud9da\\udd15"', + expected: true, + }, + { + data: { + v24_W: true, + }, + expression: 'v24_W', + expected: true, + }, + { + data: { + H: true, + }, + expression: '"H"', + expected: true, + }, + { + data: { + '\f': true, + }, + expression: '"\\f"', + expected: true, + }, + { + data: { + E4: true, + }, + expression: '"E4"', + expected: true, + }, + { + data: { + '!': true, + }, + expression: '"!"', + expected: true, + }, + { + data: { + tM: true, + }, + expression: 'tM', + expected: true, + }, + { + data: { + ' [': true, + }, + expression: '" ["', + expected: true, + }, + { + data: { + 'R!': true, + }, + expression: '"R!"', + expected: true, + }, + { + data: { + _6W: true, + }, + expression: '_6W', + expected: true, + }, + { + data: { + '\uaBA1\r': true, + }, + expression: '"\\uaBA1\\r"', + expected: true, + }, + { + data: { + tL7: true, + }, + expression: 'tL7', + expected: true, + }, + { + data: { + '<': true, + }, + expression: '">"', + expected: true, + }, + { + data: { + hvu: true, + }, + expression: 'hvu', + expected: true, + }, + { + data: { + '; !': true, + }, + expression: '"; !"', + expected: true, + }, + { + data: { + hU: true, + }, + expression: 'hU', + expected: true, + }, + { + data: { + '!I\n/': true, + }, + expression: '"!I\\n\\/"', + expected: true, + }, + { + data: { + '\uEEbF': true, + }, + expression: '"\\uEEbF"', + expected: true, + }, + { + data: { + 'U)\t': true, + }, + expression: '"U)\\t"', + expected: true, + }, + { + data: { + fa0_9: true, + }, + expression: 'fa0_9', + expected: true, + }, + { + data: { + '/': true, + }, + expression: '"/"', + expected: true, + }, + { + data: { + Gy: true, + }, + expression: 'Gy', + expected: true, + }, + { + data: { + '\b': true, + }, + expression: '"\\b"', + expected: true, + }, + { + data: { + '<': true, + }, + expression: '"<"', + expected: true, + }, + { + data: { + '\t': true, + }, + expression: '"\\t"', + expected: true, + }, + { + data: { + '\t&\\\r': true, + }, + expression: '"\\t&\\\\\\r"', + expected: true, + }, + { + data: { + '#': true, + }, + expression: '"#"', + expected: true, + }, + { + data: { + B__: true, + }, + expression: 'B__', + expected: true, + }, + { + data: { + '\nS \n': true, + }, + expression: '"\\nS \\n"', + expected: true, + }, + { + data: { + Bp: true, + }, + expression: 'Bp', + expected: true, + }, + { + data: { + ',\t;': true, + }, + expression: '",\\t;"', + expected: true, + }, + { + data: { + B_q: true, + }, + expression: 'B_q', + expected: true, + }, + { + data: { + '/+\t\n\b!Z': true, + }, + expression: '"\\/+\\t\\n\\b!Z"', + expected: true, + }, + { + data: { + '\udadd\udfc7\\ueFAc': true, + }, + expression: '"\udadd\udfc7\\\\ueFAc"', + expected: true, + }, + { + data: { + ':\f': true, + }, + expression: '":\\f"', + expected: true, + }, + { + data: { + '/': true, + }, + expression: '"\\/"', + expected: true, + }, + { + data: { + _BW_6Hg_Gl: true, + }, + expression: '_BW_6Hg_Gl', + expected: true, + }, + { + data: { + '\udbcf\udc02': true, + }, + expression: '"\udbcf\udc02"', + expected: true, + }, + { + data: { + zs1DC: true, + }, + expression: 'zs1DC', + expected: true, + }, + { + data: { + __434: true, + }, + expression: '__434', + expected: true, + }, + { + data: { + '\udb94\udd41': true, + }, + expression: '"\udb94\udd41"', + expected: true, + }, + { + data: { + Z_5: true, + }, + expression: 'Z_5', + expected: true, + }, + { + data: { + z_M_: true, + }, + expression: 'z_M_', + expected: true, + }, + { + data: { + YU_2: true, + }, + expression: 'YU_2', + expected: true, + }, + { + data: { + _0: true, + }, + expression: '_0', + expected: true, + }, + { + data: { + '\b+': true, + }, + expression: '"\\b+"', + expected: true, + }, + { + data: { + '"': true, + }, + expression: '"\\""', + expected: true, + }, + { + data: { + D7: true, + }, + expression: 'D7', + expected: true, + }, + { + data: { + _62L: true, + }, + expression: '_62L', + expected: true, + }, + { + data: { + '\tK\t': true, + }, + expression: '"\\tK\\t"', + expected: true, + }, + { + data: { + '\n\\\f': true, + }, + expression: '"\\n\\\\\\f"', + expected: true, + }, + { + data: { + I_: true, + }, + expression: 'I_', + expected: true, + }, + { + data: { + W_a0_: true, + }, + expression: 'W_a0_', + expected: true, + }, + { + data: { + BQ: true, + }, + expression: 'BQ', + expected: true, + }, + { + data: { + '\tX$\uABBb': true, + }, + expression: '"\\tX$\\uABBb"', + expected: true, + }, + { + data: { + Z9: true, + }, + expression: 'Z9', + expected: true, + }, + { + data: { + '\b%"\uda38\udd0f': true, + }, + expression: '"\\b%\\"\uda38\udd0f"', + expected: true, + }, + { + data: { + _F: true, + }, + expression: '_F', + expected: true, + }, + { + data: { + '!,': true, + }, + expression: '"!,"', + expected: true, + }, + { + data: { + '"!': true, + }, + expression: '"\\"!"', + expected: true, + }, + { + data: { + Hh: true, + }, + expression: 'Hh', + expected: true, + }, + { + data: { + '&': true, + }, + expression: '"&"', + expected: true, + }, + { + data: { + '9\r\\R': true, + }, + expression: '"9\\r\\\\R"', + expected: true, + }, + { + data: { + M_k: true, + }, + expression: 'M_k', + expected: true, + }, + { + data: { + '!\b\n\udb06\ude52""': true, + }, + expression: '"!\\b\\n\udb06\ude52\\"\\""', + expected: true, + }, + { + data: { + '6': true, + }, + expression: '"6"', + expected: true, + }, + { + data: { + _7: true, + }, + expression: '_7', + expected: true, + }, + { + data: { + '0': true, + }, + expression: '"0"', + expected: true, + }, + { + data: { + '\\8\\': true, + }, + expression: '"\\\\8\\\\"', + expected: true, + }, + { + data: { + b7eo: true, + }, + expression: 'b7eo', + expected: true, + }, + { + data: { + xIUo9: true, + }, + expression: 'xIUo9', + expected: true, + }, + { + data: { + '5': true, + }, + expression: '"5"', + expected: true, + }, + { + data: { + '?': true, + }, + expression: '"?"', + expected: true, + }, + { + data: { + sU: true, + }, + expression: 'sU', + expected: true, + }, + { + data: { + 'VH2&H\\/': true, + }, + expression: '"VH2&H\\\\\\/"', + expected: true, + }, + { + data: { + _C: true, + }, + expression: '_C', + expected: true, + }, + { + data: { + _: true, + }, + expression: '_', + expected: true, + }, + { + data: { + '<\t': true, + }, + expression: '"<\\t"', + expected: true, + }, + { + data: { + '\uD834\uDD1E': true, + }, + expression: '"\\uD834\\uDD1E"', + expected: true, + }, + ])( + 'should handle different identifiers: $expression', + ({ data, expression, expected }) => { + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/indices.test.ts b/packages/jmespath/tests/unit/compliance/indices.test.ts new file mode 100644 index 0000000000..75353adf7e --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/indices.test.ts @@ -0,0 +1,526 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/indices + */ +import { search } from '../../../src'; + +describe('Indices tests', () => { + it.each([ + { + expression: 'foo.bar[0]', + expected: 'zero', + }, + { + expression: 'foo.bar[1]', + expected: 'one', + }, + { + expression: 'foo.bar[2]', + expected: 'two', + }, + { + expression: 'foo.bar[3]', + expected: null, + }, + { + expression: 'foo.bar[-1]', + expected: 'two', + }, + { + expression: 'foo.bar[-2]', + expected: 'one', + }, + { + expression: 'foo.bar[-3]', + expected: 'zero', + }, + { + expression: 'foo.bar[-4]', + expected: null, + }, + ])( + 'should support indices on arrays in a nested object: $expression', + ({ expression, expected }) => { + // Prepare + const data = { foo: { bar: ['zero', 'one', 'two'] } }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo.bar', + expected: null, + }, + { + expression: 'foo[0].bar', + expected: 'one', + }, + { + expression: 'foo[1].bar', + expected: 'two', + }, + { + expression: 'foo[2].bar', + expected: 'three', + }, + { + expression: 'foo[3].notbar', + expected: 'four', + }, + { + expression: 'foo[3].bar', + expected: null, + }, + { + expression: 'foo[0]', + expected: { bar: 'one' }, + }, + { + expression: 'foo[1]', + expected: { bar: 'two' }, + }, + { + expression: 'foo[2]', + expected: { bar: 'three' }, + }, + { + expression: 'foo[3]', + expected: { notbar: 'four' }, + }, + { + expression: 'foo[4]', + expected: null, + }, + ])( + 'should support indices in an array with objects inside: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { bar: 'one' }, + { bar: 'two' }, + { bar: 'three' }, + { notbar: 'four' }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: '[0]', + expected: 'one', + }, + { + expression: '[1]', + expected: 'two', + }, + { + expression: '[2]', + expected: 'three', + }, + { + expression: '[-1]', + expected: 'three', + }, + { + expression: '[-2]', + expected: 'two', + }, + { + expression: '[-3]', + expected: 'one', + }, + ])( + 'should support indices in an array: $expression', + ({ expression, expected }) => { + // Prepare + const data = ['one', 'two', 'three']; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'reservations[].instances[].foo', + expected: [1, 2], + }, + { + expression: 'reservations[].instances[].bar', + expected: [], + }, + { + expression: 'reservations[].notinstances[].foo', + expected: [], + }, + { + expression: 'reservations[].notinstances[].foo', + expected: [], + }, + ])( + 'should support indices in multi-level nested arrays & objects: $expression', + ({ expression, expected }) => { + // Prepare + const data = { reservations: [{ instances: [{ foo: 1 }, { foo: 2 }] }] }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'reservations[].instances[].foo[].bar', + expected: [1, 2, 4, 5, 6, 8], + }, + { + expression: 'reservations[].instances[].foo[].baz', + expected: [], + }, + { + expression: 'reservations[].instances[].notfoo[].bar', + expected: [20, 21, 22, 23, 24, 25], + }, + { + expression: 'reservations[].instances[].notfoo[].notbar', + expected: [[7], [7]], + }, + { + expression: 'reservations[].notinstances[].foo', + expected: [], + }, + { + expression: 'reservations[].instances[].foo[].notbar', + expected: [3, [7]], + }, + { + expression: 'reservations[].instances[].bar[].baz', + expected: [[1], [2], [3], [4]], + }, + { + expression: 'reservations[].instances[].baz[].baz', + expected: [[1, 2], [], [], [3, 4]], + }, + { + expression: 'reservations[].instances[].qux[].baz', + expected: [[], [1, 2, 3], [4], [], [], [1, 2, 3], [4], []], + }, + { + expression: 'reservations[].instances[].qux[].baz[]', + expected: [1, 2, 3, 4, 1, 2, 3, 4], + }, + ])( + 'should support indices in large mixed objects and arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + reservations: [ + { + instances: [ + { foo: [{ bar: 1 }, { bar: 2 }, { notbar: 3 }, { bar: 4 }] }, + { foo: [{ bar: 5 }, { bar: 6 }, { notbar: [7] }, { bar: 8 }] }, + { foo: 'bar' }, + { + notfoo: [ + { bar: 20 }, + { bar: 21 }, + { notbar: [7] }, + { bar: 22 }, + ], + }, + { bar: [{ baz: [1] }, { baz: [2] }, { baz: [3] }, { baz: [4] }] }, + { + baz: [ + { baz: [1, 2] }, + { baz: [] }, + { baz: [] }, + { baz: [3, 4] }, + ], + }, + { + qux: [ + { baz: [] }, + { baz: [1, 2, 3] }, + { baz: [4] }, + { baz: [] }, + ], + }, + ], + otherkey: { + foo: [{ bar: 1 }, { bar: 2 }, { notbar: 3 }, { bar: 4 }], + }, + }, + { + instances: [ + { a: [{ bar: 1 }, { bar: 2 }, { notbar: 3 }, { bar: 4 }] }, + { b: [{ bar: 5 }, { bar: 6 }, { notbar: [7] }, { bar: 8 }] }, + { c: 'bar' }, + { + notfoo: [ + { bar: 23 }, + { bar: 24 }, + { notbar: [7] }, + { bar: 25 }, + ], + }, + { + qux: [ + { baz: [] }, + { baz: [1, 2, 3] }, + { baz: [4] }, + { baz: [] }, + ], + }, + ], + otherkey: { + foo: [{ bar: 1 }, { bar: 2 }, { notbar: 3 }, { bar: 4 }], + }, + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[]', + expected: [ + ['one', 'two'], + ['three', 'four'], + ['five', 'six'], + ['seven', 'eight'], + ['nine'], + ['ten'], + ], + }, + { + expression: 'foo[][0]', + expected: ['one', 'three', 'five', 'seven', 'nine', 'ten'], + }, + { + expression: 'foo[][1]', + expected: ['two', 'four', 'six', 'eight'], + }, + { + expression: 'foo[][0][0]', + expected: [], + }, + { + expression: 'foo[][2][2]', + expected: [], + }, + { + expression: 'foo[][0][0][100]', + expected: [], + }, + ])( + 'should support indices in objects containing an array of matrixes: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + [ + ['one', 'two'], + ['three', 'four'], + ], + [ + ['five', 'six'], + ['seven', 'eight'], + ], + [['nine'], ['ten']], + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo', + expected: [ + { + bar: [ + { qux: 2, baz: 1 }, + { qux: 4, baz: 3 }, + ], + }, + { + bar: [ + { qux: 6, baz: 5 }, + { qux: 8, baz: 7 }, + ], + }, + ], + }, + { + expression: 'foo[]', + expected: [ + { + bar: [ + { qux: 2, baz: 1 }, + { qux: 4, baz: 3 }, + ], + }, + { + bar: [ + { qux: 6, baz: 5 }, + { qux: 8, baz: 7 }, + ], + }, + ], + }, + { + expression: 'foo[].bar', + expected: [ + [ + { qux: 2, baz: 1 }, + { qux: 4, baz: 3 }, + ], + [ + { qux: 6, baz: 5 }, + { qux: 8, baz: 7 }, + ], + ], + }, + { + expression: 'foo[].bar[]', + expected: [ + { qux: 2, baz: 1 }, + { qux: 4, baz: 3 }, + { qux: 6, baz: 5 }, + { qux: 8, baz: 7 }, + ], + }, + { + expression: 'foo[].bar[].baz', + expected: [1, 3, 5, 7], + }, + ])( + 'should support indices with nested arrays and objects at different levels: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { + bar: [ + { + qux: 2, + baz: 1, + }, + { + qux: 4, + baz: 3, + }, + ], + }, + { + bar: [ + { + qux: 6, + baz: 5, + }, + { + qux: 8, + baz: 7, + }, + ], + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'string[]', + expected: null, + }, + { + expression: 'hash[]', + expected: null, + }, + { + expression: 'number[]', + expected: null, + }, + { + expression: 'nullvalue[]', + expected: null, + }, + { + expression: 'string[].foo', + expected: null, + }, + { + expression: 'hash[].foo', + expected: null, + }, + { + expression: 'number[].foo', + expected: null, + }, + { + expression: 'nullvalue[].foo', + expected: null, + }, + { + expression: 'nullvalue[].foo[].bar', + expected: null, + }, + ])( + 'should support indices in objects having special names as keys: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + string: 'string', + hash: { foo: 'bar', bar: 'baz' }, + number: 23, + nullvalue: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/literal.test.ts b/packages/jmespath/tests/unit/compliance/literal.test.ts new file mode 100644 index 0000000000..465702c4be --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/literal.test.ts @@ -0,0 +1,255 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/literal + */ +import { search } from '../../../src'; + +describe('Literal expressions tests', () => { + it.each([ + { + expression: '`"foo"`', + expected: 'foo', + }, + { + comment: 'Interpret escaped unicode.', + expression: '`"\\u03a6"`', + expected: 'Φ', + }, + { + expression: '`"✓"`', + expected: '✓', + }, + { + expression: '`[1, 2, 3]`', + expected: [1, 2, 3], + }, + { + expression: '`{"a": "b"}`', + expected: { + a: 'b', + }, + }, + { + expression: '`true`', + expected: true, + }, + { + expression: '`false`', + expected: false, + }, + { + expression: '`null`', + expected: null, + }, + { + expression: '`0`', + expected: 0, + }, + { + expression: '`1`', + expected: 1, + }, + { + expression: '`2`', + expected: 2, + }, + { + expression: '`3`', + expected: 3, + }, + { + expression: '`4`', + expected: 4, + }, + { + expression: '`5`', + expected: 5, + }, + { + expression: '`6`', + expected: 6, + }, + { + expression: '`7`', + expected: 7, + }, + { + expression: '`8`', + expected: 8, + }, + { + expression: '`9`', + expected: 9, + }, + { + comment: 'Escaping a backtick in quotes', + expression: '`"foo\\`bar"`', + expected: 'foo`bar', + }, + { + comment: 'Double quote in literal', + expression: '`"foo\\"bar"`', + expected: 'foo"bar', + }, + { + expression: '`"1\\`"`', + expected: '1`', + }, + { + comment: 'Multiple literal expressions with escapes', + expression: '`"\\\\"`.{a:`"b"`}', + expected: { + a: 'b', + }, + }, + { + comment: 'literal . identifier', + expression: '`{"a": "b"}`.a', + expected: 'b', + }, + { + comment: 'literal . identifier . identifier', + expression: '`{"a": {"b": "c"}}`.a.b', + expected: 'c', + }, + { + comment: 'literal . identifier bracket-expr', + expression: '`[0, 1, 2]`[1]', + expected: 1, + }, + ])( + 'should support literal expressions: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { + name: 'a', + }, + { + name: 'b', + }, + ], + bar: { + baz: 'qux', + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Literal with leading whitespace', + expression: '` {"foo": true}`', + expected: { + foo: true, + }, + }, + { + comment: 'Literal with trailing whitespace', + expression: '`{"foo": true} `', + expected: { + foo: true, + }, + }, + ])( + 'should support literals with other special characters: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Literal on RHS of subexpr not allowed', + expression: 'foo.`"bar"`', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "bar" (literal) in expression: foo.`"bar"`', + }, + ])('literals errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: `'foo'`, + expected: 'foo', + }, + { + expression: `' foo '`, + expected: ' foo ', + }, + { + expression: `'0'`, + expected: '0', + }, + { + expression: `'newline\n'`, + expected: 'newline\n', + }, + { + expression: `'\n'`, + expected: '\n', + }, + { + expression: `'✓'`, + expected: '✓', + }, + { + expression: `'𝄞'`, + expected: '𝄞', + }, + { + expression: `' [foo] '`, + expected: ' [foo] ', + }, + { + expression: `'[foo]'`, + expected: '[foo]', + }, + { + comment: 'Do not interpret escaped unicode.', + expression: `'\\u03a6'`, + expected: '\\u03a6', + }, + { + comment: 'Can escape the single quote', + expression: `'foo\\'bar'`, + expected: `foo'bar`, + }, + ])( + 'should support raw string literals: $expression', + ({ expression, expected }) => { + // Prepare + const data = {}; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/multiselect.test.ts b/packages/jmespath/tests/unit/compliance/multiselect.test.ts new file mode 100644 index 0000000000..eddf2adc56 --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/multiselect.test.ts @@ -0,0 +1,583 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/multiselect + */ +import { search } from '../../../src'; + +describe('Multiselect expressions tests', () => { + it.each([ + { + expression: 'foo.{bar: bar}', + expected: { bar: 'bar' }, + }, + { + expression: 'foo.{"bar": bar}', + expected: { bar: 'bar' }, + }, + { + expression: 'foo.{"foo.bar": bar}', + expected: { 'foo.bar': 'bar' }, + }, + { + expression: 'foo.{bar: bar, baz: baz}', + expected: { bar: 'bar', baz: 'baz' }, + }, + { + expression: 'foo.{"bar": bar, "baz": baz}', + expected: { bar: 'bar', baz: 'baz' }, + }, + { + expression: '{"baz": baz, "qux\\"": "qux\\""}', + expected: { baz: 2, 'qux"': 3 }, + }, + { + expression: 'foo.{bar:bar,baz:baz}', + expected: { bar: 'bar', baz: 'baz' }, + }, + { + expression: 'foo.{bar: bar,qux: qux}', + expected: { bar: 'bar', qux: 'qux' }, + }, + { + expression: 'foo.{bar: bar, noexist: noexist}', + expected: { bar: 'bar', noexist: null }, + }, + { + expression: 'foo.{noexist: noexist, alsonoexist: alsonoexist}', + expected: { noexist: null, alsonoexist: null }, + }, + { + expression: 'foo.badkey.{nokey: nokey, alsonokey: alsonokey}', + expected: null, + }, + { + expression: 'foo.nested.*.{a: a,b: b}', + expected: [ + { a: 'first', b: 'second' }, + { a: 'first', b: 'second' }, + { a: 'first', b: 'second' }, + ], + }, + { + expression: 'foo.nested.three.{a: a, cinner: c.inner}', + expected: { a: 'first', cinner: 'third' }, + }, + { + expression: 'foo.nested.three.{a: a, c: c.inner.bad.key}', + expected: { a: 'first', c: null }, + }, + { + expression: 'foo.{a: nested.one.a, b: nested.two.b}', + expected: { a: 'first', b: 'second' }, + }, + { + expression: '{bar: bar, baz: baz}', + expected: { bar: 1, baz: 2 }, + }, + { + expression: '{bar: bar}', + expected: { bar: 1 }, + }, + { + expression: '{otherkey: bar}', + expected: { otherkey: 1 }, + }, + { + expression: '{no: no, exist: exist}', + expected: { no: null, exist: null }, + }, + { + expression: 'foo.[bar]', + expected: ['bar'], + }, + { + expression: 'foo.[bar,baz]', + expected: ['bar', 'baz'], + }, + { + expression: 'foo.[bar,qux]', + expected: ['bar', 'qux'], + }, + { + expression: 'foo.[bar,noexist]', + expected: ['bar', null], + }, + { + expression: 'foo.[noexist,alsonoexist]', + expected: [null, null], + }, + ])( + 'should support expression on large nested objects: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + bar: 'bar', + baz: 'baz', + qux: 'qux', + nested: { + one: { + a: 'first', + b: 'second', + c: 'third', + }, + two: { + a: 'first', + b: 'second', + c: 'third', + }, + three: { + a: 'first', + b: 'second', + c: { inner: 'third' }, + }, + }, + }, + bar: 1, + baz: 2, + 'qux"': 3, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo.{bar:bar,baz:baz}', + expected: { bar: 1, baz: [2, 3, 4] }, + }, + { + expression: 'foo.[bar,baz[0]]', + expected: [1, 2], + }, + { + expression: 'foo.[bar,baz[1]]', + expected: [1, 3], + }, + { + expression: 'foo.[bar,baz[2]]', + expected: [1, 4], + }, + { + expression: 'foo.[bar,baz[3]]', + expected: [1, null], + }, + { + expression: 'foo.[bar[0],baz[3]]', + expected: [null, null], + }, + ])( + 'should support the expression on objects containing arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { bar: 1, baz: [2, 3, 4] }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo.{bar: bar, baz: baz}', + expected: { bar: 1, baz: 2 }, + }, + { + expression: 'foo.[bar,baz]', + expected: [1, 2], + }, + ])( + 'should support the expression using both array and object syntax: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { bar: 1, baz: 2 }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo.{bar: bar.baz[1],includeme: includeme}', + expected: { bar: { common: 'second', two: 2 }, includeme: true }, + }, + { + expression: 'foo.{"bar.baz.two": bar.baz[1].two, includeme: includeme}', + expected: { 'bar.baz.two': 2, includeme: true }, + }, + { + expression: 'foo.[includeme, bar.baz[*].common]', + expected: [true, ['first', 'second']], + }, + { + expression: 'foo.[includeme, bar.baz[*].none]', + expected: [true, []], + }, + { + expression: 'foo.[includeme, bar.baz[].common]', + expected: [true, ['first', 'second']], + }, + ])( + 'should support the expression using mixed array and object syntax: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + bar: { + baz: [ + { common: 'first', one: 1 }, + { common: 'second', two: 2 }, + ], + }, + ignoreme: 1, + includeme: true, + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'reservations[*].instances[*].{id: id, name: name}', + expected: [ + [ + { id: 'id1', name: 'first' }, + { id: 'id2', name: 'second' }, + ], + [ + { id: 'id3', name: 'third' }, + { id: 'id4', name: 'fourth' }, + ], + ], + }, + { + expression: 'reservations[].instances[].{id: id, name: name}', + expected: [ + { id: 'id1', name: 'first' }, + { id: 'id2', name: 'second' }, + { id: 'id3', name: 'third' }, + { id: 'id4', name: 'fourth' }, + ], + }, + { + expression: 'reservations[].instances[].[id, name]', + expected: [ + ['id1', 'first'], + ['id2', 'second'], + ['id3', 'third'], + ['id4', 'fourth'], + ], + }, + ])( + 'should support the expression with wildcards: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + reservations: [ + { + instances: [ + { id: 'id1', name: 'first' }, + { id: 'id2', name: 'second' }, + ], + }, + { + instances: [ + { id: 'id3', name: 'third' }, + { id: 'id4', name: 'fourth' }, + ], + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo', + expected: [ + { + bar: [ + { qux: 2, baz: 1 }, + { qux: 4, baz: 3 }, + ], + }, + { + bar: [ + { qux: 6, baz: 5 }, + { qux: 8, baz: 7 }, + ], + }, + ], + }, + { + expression: 'foo[]', + expected: [ + { + bar: [ + { qux: 2, baz: 1 }, + { qux: 4, baz: 3 }, + ], + }, + { + bar: [ + { qux: 6, baz: 5 }, + { qux: 8, baz: 7 }, + ], + }, + ], + }, + { + expression: 'foo[].bar', + expected: [ + [ + { qux: 2, baz: 1 }, + { qux: 4, baz: 3 }, + ], + [ + { qux: 6, baz: 5 }, + { qux: 8, baz: 7 }, + ], + ], + }, + { + expression: 'foo[].bar[]', + expected: [ + { qux: 2, baz: 1 }, + { qux: 4, baz: 3 }, + { qux: 6, baz: 5 }, + { qux: 8, baz: 7 }, + ], + }, + { + expression: 'foo[].bar[].[baz, qux]', + expected: [ + [1, 2], + [3, 4], + [5, 6], + [7, 8], + ], + }, + { + expression: 'foo[].bar[].[baz]', + expected: [[1], [3], [5], [7]], + }, + { + expression: 'foo[].bar[].[baz, qux][]', + expected: [1, 2, 3, 4, 5, 6, 7, 8], + }, + ])( + 'should support expression with the flatten operator: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { + bar: [ + { + qux: 2, + baz: 1, + }, + { + qux: 4, + baz: 3, + }, + ], + }, + { + bar: [ + { + qux: 6, + baz: 5, + }, + { + qux: 8, + baz: 7, + }, + ], + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo.[baz[*].bar, qux[0]]', + expected: [['abc', 'def'], 'zero'], + }, + ])( + 'should support the expression with slicing: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + baz: [ + { + bar: 'abc', + }, + { + bar: 'def', + }, + ], + qux: ['zero'], + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo.[baz[*].[bar, boo], qux[0]]', + expected: [ + [ + ['a', 'c'], + ['d', 'f'], + ], + 'zero', + ], + }, + ])( + 'should support the expression with wildcard slicing: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + baz: [ + { + bar: 'a', + bam: 'b', + boo: 'c', + }, + { + bar: 'd', + bam: 'e', + boo: 'f', + }, + ], + qux: ['zero'], + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo.[baz[*].not_there || baz[*].bar, qux[0]]', + expected: [['a', 'd'], 'zero'], + }, + ])( + 'should support multiselect with inexistent values: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + baz: [ + { + bar: 'a', + bam: 'b', + boo: 'c', + }, + { + bar: 'd', + bam: 'e', + boo: 'f', + }, + ], + qux: ['zero'], + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Nested multiselect', + expression: '[[*],*]', + expected: [null, ['object']], + }, + ])( + 'should support nested multiselect: $expression', + ({ expression, expected }) => { + // Prepare + const data = { type: 'object' }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: '[[*]]', + expected: [[]], + }, + ])( + 'should handle nested multiselect with empty arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data: string[] = []; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/pipe.test.ts b/packages/jmespath/tests/unit/compliance/pipe.test.ts new file mode 100644 index 0000000000..752b5895dc --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/pipe.test.ts @@ -0,0 +1,187 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/pipe + */ +import { search } from '../../../src'; + +describe('Pipe expressions tests', () => { + it.each([ + { + expression: 'foo.*.baz | [0]', + expected: 'subkey', + }, + { + expression: 'foo.*.baz | [1]', + expected: 'subkey', + }, + { + expression: 'foo.*.baz | [2]', + expected: 'subkey', + }, + { + expression: 'foo.bar.* | [0]', + expected: 'subkey', + }, + { + expression: 'foo.*.notbaz | [*]', + expected: [ + ['a', 'b', 'c'], + ['a', 'b', 'c'], + ], + }, + { + expression: '{"a": foo.bar, "b": foo.other} | *.baz', + expected: ['subkey', 'subkey'], + }, + ])( + 'should support piping a multi-level nested object with arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + bar: { + baz: 'subkey', + }, + other: { + baz: 'subkey', + }, + other2: { + baz: 'subkey', + }, + other3: { + notbaz: ['a', 'b', 'c'], + }, + other4: { + notbaz: ['a', 'b', 'c'], + }, + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo | bar', + expected: { baz: 'one' }, + }, + { + expression: 'foo | bar | baz', + expected: 'one', + }, + { + expression: 'foo|bar| baz', + expected: 'one', + }, + { + expression: 'not_there | [0]', + expected: null, + }, + { + expression: 'not_there | [0]', + expected: null, + }, + { + expression: '[foo.bar, foo.other] | [0]', + expected: { baz: 'one' }, + }, + { + expression: '{"a": foo.bar, "b": foo.other} | a', + expected: { baz: 'one' }, + }, + { + expression: '{"a": foo.bar, "b": foo.other} | b', + expected: { baz: 'two' }, + }, + { + expression: 'foo.bam || foo.bar | baz', + expected: 'one', + }, + { + expression: 'foo | not_there || bar', + expected: { baz: 'one' }, + }, + ])( + 'should support piping with boolean conditions: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + bar: { + baz: 'one', + }, + other: { + baz: 'two', + }, + other2: { + baz: 'three', + }, + other3: { + notbaz: ['a', 'b', 'c'], + }, + other4: { + notbaz: ['d', 'e', 'f'], + }, + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[*].bar[*] | [0][0]', + expected: { baz: 'one' }, + }, + { + expression: '`null`|[@]', + expected: null, + }, + ])( + 'should support piping with wildcard and current operators: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { + bar: [ + { + baz: 'one', + }, + { + baz: 'two', + }, + ], + }, + { + bar: [ + { + baz: 'three', + }, + { + baz: 'four', + }, + ], + }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/slice.test.ts b/packages/jmespath/tests/unit/compliance/slice.test.ts new file mode 100644 index 0000000000..bc797d88b2 --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/slice.test.ts @@ -0,0 +1,243 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/slice + */ +import { search } from '../../../src'; + +describe('Slices tests', () => { + it.each([ + { + expression: 'bar[0:10]', + expected: null, + }, + { + expression: 'foo[0:10:1]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[0:10]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[0:10:]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[0::1]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[0::]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[0:]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[:10:1]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[::1]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[:10:]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[::]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[:]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[1:9]', + expected: [1, 2, 3, 4, 5, 6, 7, 8], + }, + { + expression: 'foo[0:10:2]', + expected: [0, 2, 4, 6, 8], + }, + { + expression: 'foo[5:]', + expected: [5, 6, 7, 8, 9], + }, + { + expression: 'foo[5::2]', + expected: [5, 7, 9], + }, + { + expression: 'foo[::2]', + expected: [0, 2, 4, 6, 8], + }, + { + expression: 'foo[::-1]', + expected: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0], + }, + { + expression: 'foo[1::2]', + expected: [1, 3, 5, 7, 9], + }, + { + expression: 'foo[10:0:-1]', + expected: [9, 8, 7, 6, 5, 4, 3, 2, 1], + }, + { + expression: 'foo[10:5:-1]', + expected: [9, 8, 7, 6], + }, + { + expression: 'foo[8:2:-2]', + expected: [8, 6, 4], + }, + { + expression: 'foo[0:20]', + expected: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + { + expression: 'foo[10:-20:-1]', + expected: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0], + }, + { + expression: 'foo[10:-20]', + expected: [], + }, + { + expression: 'foo[-4:-1]', + expected: [6, 7, 8], + }, + { + expression: 'foo[:-5:-1]', + expected: [9, 8, 7, 6], + }, + ])( + 'should support slicing arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + bar: { + baz: 1, + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[8:2:0]', + error: 'Invalid slice, step cannot be 0', + }, + { + expression: 'foo[8:2:0:1]', + error: + 'Invalid jmespath expression: parse error at column 9, found unexpected token ":" (colon) in expression: foo[8:2:0:1]', + }, + { + expression: 'foo[8:2&]', + error: + 'Invalid jmespath expression: parse error at column 7, found unexpected token "&" (expref) in expression: foo[8:2&]', + }, + { + expression: 'foo[2:a:3]', + error: + 'Invalid jmespath expression: parse error at column 6, found unexpected token "a" (unquoted_identifier) in expression: foo[2:a:3]', + }, + ])( + 'slicing objects with arrays errors: $expression', + ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + } + ); + + it.each([ + { + expression: 'foo[:2].a', + expected: [1, 2], + }, + { + expression: 'foo[:2].b', + expected: [], + }, + { + expression: 'foo[:2].a.b', + expected: [], + }, + { + expression: 'bar[::-1].a.b', + expected: [3, 2, 1], + }, + { + expression: 'bar[:2].a.b', + expected: [1, 2], + }, + { + expression: 'baz[:2].a', + expected: null, + }, + ])( + 'should support slicing an object with nested arrays with objects in them: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [{ a: 1 }, { a: 2 }, { a: 3 }], + bar: [{ a: { b: 1 } }, { a: { b: 2 } }, { a: { b: 3 } }], + baz: 50, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: '[:]', + expected: [{ a: 1 }, { a: 2 }, { a: 3 }], + }, + { + expression: '[:2].a', + expected: [1, 2], + }, + { + expression: '[::-1].a', + expected: [3, 2, 1], + }, + { + expression: '[:2].b', + expected: [], + }, + ])( + 'should support slicing an array with objects in it: $expression', + ({ expression, expected }) => { + // Prepare + const data = [{ a: 1 }, { a: 2 }, { a: 3 }]; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/syntax.test.ts b/packages/jmespath/tests/unit/compliance/syntax.test.ts new file mode 100644 index 0000000000..96a7e9fee8 --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/syntax.test.ts @@ -0,0 +1,887 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/syntax + */ +import { search } from '../../../src'; + +describe('Syntax tests', () => { + it.each([ + { + expression: 'foo.bar', + expected: null, + }, + { + expression: 'foo', + expected: null, + }, + ])('should support dot syntax: $expression', ({ expression, expected }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: 'foo.1', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "1" (number) in expression: foo.1', + }, + { + expression: 'foo.-11', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "-11" (number) in expression: foo.-11', + }, + { + expression: 'foo.', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected end of expression (EOF) in expression: foo.', + }, + { + expression: '.foo', + error: + 'Invalid jmespath expression: parse error at column 0, found unexpected token "." (dot) in expression: .foo', + }, + { + expression: 'foo..bar', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "." (dot) in expression: foo..bar', + }, + { + expression: 'foo.bar.', + error: + 'Invalid jmespath expression: parse error at column 8, found unexpected end of expression (EOF) in expression: foo.', + }, + { + expression: 'foo[.]', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "." (dot) in expression: foo[.]', + }, + ])('dot syntax errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: '.', + error: + 'Invalid jmespath expression: parse error at column 0, found unexpected token "." (dot) in expression: .', + }, + { + expression: ':', + error: + 'Invalid jmespath expression: parse error at column 0, found unexpected token ":" (colon) in expression: :', + }, + { + expression: ',', + error: + 'Invalid jmespath expression: parse error at column 0, found unexpected token "," (comma) in expression: ,', + }, + { + expression: ']', + error: + 'Invalid jmespath expression: parse error at column 0, found unexpected token "]" (rbracket) in expression: ]', + }, + { + expression: '[', + error: + 'Invalid jmespath expression: parse error at column 1, found unexpected end of expression (EOF) in expression: [', + }, + { + expression: '}', + error: + 'Invalid jmespath expression: parse error at column 0, found unexpected token "}" (rbrace) in expression: }', + }, + { + expression: '{', + error: + 'Invalid jmespath expression: parse error at column 1, found unexpected end of expression (EOF) in expression: {', + }, + { + expression: ')', + error: + 'Invalid jmespath expression: parse error at column 0, found unexpected token ")" (rparen) in expression: )', + }, + { + expression: '(', + error: + 'Invalid jmespath expression: parse error at column 1, found unexpected end of expression (EOF) in expression: (', + }, + { + expression: '((&', + error: + 'Invalid jmespath expression: parse error at column 3, found unexpected end of expression (EOF) in expression: ((&', + }, + { + expression: 'a[', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected end of expression (EOF) in expression: a[', + }, + { + expression: 'a]', + error: + 'Invalid jmespath expression: parse error at column 1, found unexpected token "]" (rbracket) in expression: a]', + }, + { + expression: 'a][', + error: + 'Invalid jmespath expression: parse error at column 1, found unexpected token "]" (rbracket) in expression: a]', + }, + { + expression: '!', + error: + 'Invalid jmespath expression: parse error at column 1, found unexpected end of expression (EOF) in expression: !', + }, + ])('simple token errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: '![!(!', + error: + 'Invalid jmespath expression: parse error at column 5, found unexpected end of expression (EOF) in expression: ![!(!', + }, + ])('boolean token errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: '*', + expected: ['object'], + }, + { + expression: '*.*', + expected: [], + }, + { + expression: '*.foo', + expected: [], + }, + { + expression: '*[0]', + expected: [], + }, + ])( + 'should support wildcard syntax: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: '.*', + error: + 'Invalid jmespath expression: parse error at column 0, found unexpected token "." (dot) in expression: .*', + }, + { + expression: '*foo', + error: + 'Invalid jmespath expression: parse error at column 1, found unexpected token "foo" (unquoted_identifier) in expression: *foo', + }, + { + expression: '*0', + error: + 'Invalid jmespath expression: parse error at column 1, found unexpected token "0" (number) in expression: *0', + }, + { + expression: 'foo[*]bar', + error: + 'Invalid jmespath expression: parse error at column 6, found unexpected token "bar" (unquoted_identifier) in expression: foo[*]bar', + }, + { + expression: 'foo[*]*', + error: + 'Invalid jmespath expression: parse error at column 6, found unexpected token "*" (star) in expression: foo[*]*', + }, + ])('wildcard token errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: '[]', + expected: null, + }, + ])( + 'should support flatten syntax: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: '[0]', + expected: null, + }, + { + expression: '[*]', + expected: null, + }, + { + expression: '*.["0"]', + expected: [[null]], + }, + { + expression: '[*].bar', + expected: null, + }, + { + expression: '[*][0]', + expected: null, + }, + ])('simple bracket syntax: $expression', ({ expression, expected }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: '*.[0]', + error: + 'Invalid jmespath expression: parse error at column 3, found unexpected token "0" (number) in expression: *.[0]', + }, + { + expression: 'foo[#]', + error: + 'Bad jmespath expression: unknown token "#" at column 4 in expression: foo[#]', + }, + ])('simple breacket errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'foo[0]', + expected: null, + }, + { + expression: 'foo.[*]', + expected: null, + }, + { + comment: 'Valid multi-select of a hash using an identifier index', + expression: 'foo.[abc]', + expected: null, + }, + { + comment: 'Valid multi-select of a hash', + expression: 'foo.[abc, def]', + expected: null, + }, + ])( + 'should support multi-select list syntax: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'Valid multi-select of a list', + expression: 'foo[0, 1]', + error: + 'Invalid jmespath expression: parse error at column 5, found unexpected token "," (comma) in expression: foo[0, 1]', + }, + { + expression: 'foo.[0]', + error: + 'Invalid jmespath expression: parse error at column 5, found unexpected token "0" (number) in expression: foo.[0]', + }, + { + comment: 'Multi-select of a list with trailing comma', + expression: 'foo[0, ]', + error: + 'Invalid jmespath expression: parse error at column 5, found unexpected token "," (comma) in expression: foo[0, ]', + }, + { + comment: 'Multi-select of a list with trailing comma and no close', + expression: 'foo[0,', + error: + 'Invalid jmespath expression: parse error at column 5, found unexpected token "," (comma) in expression: foo[0,', + }, + { + comment: 'Multi-select of a list with trailing comma and no close', + expression: 'foo.[a', + error: + 'Invalid jmespath expression: parse error at column 6, found unexpected end of expression (EOF) in expression: foo.[a', + }, + { + comment: 'Multi-select of a list with extra comma', + expression: 'foo[0,, 1]', + error: + 'Invalid jmespath expression: parse error at column 5, found unexpected token "," (comma) in expression: foo[0,, 1]', + }, + { + comment: 'Multi-select of a list using an identifier index', + expression: 'foo[abc]', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "abc" (unquoted_identifier) in expression: foo[abc]', + }, + { + comment: 'Multi-select of a list using identifier indices', + expression: 'foo[abc, def]', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "abc" (unquoted_identifier) in expression: foo[abc, def]', + }, + { + comment: 'Multi-select of a list using an identifier index', + expression: 'foo[abc, 1]', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "abc" (unquoted_identifier) in expression: foo[abc, 1]', + }, + { + comment: + 'Multi-select of a list using an identifier index with trailing comma', + expression: 'foo[abc, ]', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "abc" (unquoted_identifier) in expression: foo[abc, ]', + }, + { + comment: 'Multi-select of a hash using a numeric index', + expression: 'foo.[abc, 1]', + error: + 'Invalid jmespath expression: parse error at column 10, found unexpected token "1" (number) in expression: foo.[abc, 1]', + }, + { + comment: 'Multi-select of a hash with a trailing comma', + expression: 'foo.[abc, ]', + error: + 'Invalid jmespath expression: parse error at column 10, found unexpected token "]" (rbracket) in expression: foo.[abc, ]', + }, + { + comment: 'Multi-select of a hash with extra commas', + expression: 'foo.[abc,, def]', + error: + 'Invalid jmespath expression: parse error at column 9, found unexpected token "," (comma) in expression: foo.[abc,, def]', + }, + { + comment: 'Multi-select of a hash using number indices', + expression: 'foo.[0, 1]', + error: + 'Invalid jmespath expression: parse error at column 5, found unexpected token "0" (number) in expression: foo.[0, 1]', + }, + ])('multi-select list errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + comment: 'Valid multi-select hash extraction', + expression: 'a.{foo: bar}', + expected: null, + }, + { + comment: 'Valid multi-select hash extraction', + expression: 'a.{foo: bar, baz: bam}', + expected: null, + }, + { + comment: 'Nested multi select', + expression: '{"\\\\":{" ":*}}', + expected: { + '\\': { + ' ': ['object'], + }, + }, + }, + ])( + 'should support multy-select hash syntax: $expression', + ({ expression, expected }) => { + // Prepare + const data = { type: 'object' }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + comment: 'No key or value', + expression: 'a{}', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected token "}" (rbrace) in expression: a{}', + }, + { + comment: 'No closing token', + expression: 'a{', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected end of expression (EOF) in expression: a{', + }, + { + comment: 'Not a key value pair', + expression: 'a{foo}', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected token "foo" (unquoted_identifier) in expression: a{foo}', + }, + { + comment: 'Missing value and closing character', + expression: 'a{foo:', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected token "foo" (unquoted_identifier) in expression: a{foo:', + }, + { + comment: 'Missing closing character', + expression: 'a{foo: 0', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected token "foo" (unquoted_identifier) in expression: a{foo: 0', + }, + { + comment: 'Missing value', + expression: 'a{foo:}', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected token "foo" (unquoted_identifier) in expression: a{foo:}', + }, + { + comment: 'Trailing comma and no closing character', + expression: 'a{foo: 0, ', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected token "foo" (unquoted_identifier) in expression: a{foo: 0, ', + }, + { + comment: 'Missing value with trailing comma', + expression: 'a{foo: ,}', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected token "foo" (unquoted_identifier) in expression: a{foo: ,}', + }, + { + comment: 'Accessing Array using an identifier', + expression: 'a{foo: bar}', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected token "foo" (unquoted_identifier) in expression: a{foo: bar}', + }, + { + expression: 'a{foo: 0}', + error: + 'Invalid jmespath expression: parse error at column 2, found unexpected token "foo" (unquoted_identifier) in expression: a{foo: 0}', + }, + { + comment: 'Missing key-value pair', + expression: 'a.{}', + error: + 'Invalid jmespath expression: parse error at column 3, found unexpected token "}" (rbrace) in expression: a.{}', + }, + { + comment: 'Not a key-value pair', + expression: 'a.{foo}', + error: + 'Invalid jmespath expression: parse error at column 6, found unexpected token "}" (rbrace) in expression: a.{foo}', + }, + { + comment: 'Missing value', + expression: 'a.{foo:}', + error: + 'Invalid jmespath expression: parse error at column 7, found unexpected token "}" (rbrace) in expression: a.{foo:}', + }, + { + comment: 'Missing value with trailing comma', + expression: 'a.{foo: ,}', + error: + 'Invalid jmespath expression: parse error at column 8, found unexpected token "," (comma) in expression: a.{foo: ,}', + }, + { + comment: 'Trailing comma', + expression: 'a.{foo: bar, }', + error: + 'Invalid jmespath expression: parse error at column 13, found unexpected token "}" (rbrace) in expression: a.{foo: bar, }', + }, + { + comment: 'Missing key in second key-value pair', + expression: 'a.{foo: bar, baz}', + error: + 'Invalid jmespath expression: parse error at column 16, found unexpected token "}" (rbrace) in expression: a.{foo: bar, baz}', + }, + { + comment: 'Missing value in second key-value pair', + expression: 'a.{foo: bar, baz:}', + error: + 'Invalid jmespath expression: parse error at column 17, found unexpected token "}" (rbrace) in expression: a.{foo: bar, baz:}', + }, + { + comment: 'Trailing comma', + expression: 'a.{foo: bar, baz: bam, }', + error: + 'Invalid jmespath expression: parse error at column 23, found unexpected token "}" (rbrace) in expression: a.{foo: bar, baz: bam, }', + }, + ])('multi-select hash errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'foo || bar', + expected: null, + }, + { + expression: 'foo.[a || b]', + expected: null, + }, + ])( + 'should support boolean OR syntax: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo ||', + error: + 'Invalid jmespath expression: parse error at column 6, found unexpected end of expression (EOF) in expression: foo ||', + }, + { + expression: 'foo.|| bar', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "||" (or) in expression: foo.|| bar', + }, + { + expression: ' || foo', + error: + 'Invalid jmespath expression: parse error at column 1, found unexpected token "||" (or) in expression: || foo', + }, + { + expression: 'foo || || foo', + error: + 'Invalid jmespath expression: parse error at column 7, found unexpected token "||" (or) in expression: foo || || foo', + }, + { + expression: 'foo.[a ||]', + error: + 'Invalid jmespath expression: parse error at column 9, found unexpected token "]" (rbracket) in expression: foo.[a ||]', + }, + { + expression: '"foo', + error: + 'Bad jmespath expression: unknown token ""foo" at column 0 in expression: "foo', + }, + ])('boolean OR errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'foo[?bar==`"baz"`]', + expected: null, + }, + { + expression: 'foo[? bar == `"baz"` ]', + expected: null, + }, + { + expression: 'foo[?a.b.c==d.e.f]', + expected: null, + }, + { + expression: 'foo[?bar==`[0, 1, 2]`]', + expected: null, + }, + { + expression: 'foo[?bar==`["a", "b", "c"]`]', + expected: null, + }, + { + comment: 'Literal char escaped', + expression: 'foo[?bar==`["foo\\`bar"]`]', + expected: null, + }, + { + comment: 'Quoted identifier in filter expression no spaces', + expression: '[?"\\\\">`"foo"`]', + expected: null, + }, + { + comment: 'Quoted identifier in filter expression with spaces', + expression: '[?"\\\\" > `"foo"`]', + expected: null, + }, + ])( + 'should support filter syntax: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[ ?bar==`"baz"`]', + error: + 'Bad jmespath expression: unknown token "?" at column 5 in expression: foo[ ?bar==`"baz"`]', + }, + { + expression: 'foo[?bar==]', + error: + 'Invalid jmespath expression: parse error at column 10, found unexpected token "]" (rbracket) in expression: foo[?bar==]', + }, + { + expression: 'foo[?==]', + error: + 'Invalid jmespath expression: parse error at column 6, found unexpected token "==" (eq) in expression: foo[?==]', + }, + { + expression: 'foo[?==bar]', + error: + 'Invalid jmespath expression: parse error at column 6, found unexpected token "==" (eq) in expression: foo[?==bar]', + }, + { + expression: 'foo[?bar==baz?]', + error: + 'Bad jmespath expression: unknown token "?" at column 13 in expression: foo[?bar==baz?]', + }, + { + comment: 'Literal char not escaped', + expression: 'foo[?bar==`["foo`bar"]`]', + error: + 'Bad jmespath expression: unknown token "["foo" at column 10 in expression: foo[?bar==`["foo`bar"]`]', + }, + { + comment: 'Unknown comparator', + expression: 'foo[?bar<>baz]', + error: + 'Invalid jmespath expression: parse error at column 9, found unexpected token ">" (gt) in expression: foo[?bar<>baz]', + }, + { + comment: 'Unknown comparator', + expression: 'foo[?bar^baz]', + error: + 'Bad jmespath expression: unknown token "^" at column 8 in expression: foo[?bar^baz]', + }, + { + expression: 'foo[bar==baz]', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "bar" (unquoted_identifier) in expression: foo[bar==baz]', + }, + { + expression: 'bar.`"anything"`', + error: + 'Invalid jmespath expression: parse error at column 4, found unexpected token "anything" (literal) in expression: bar.`"anything"`', + }, + { + expression: 'bar.baz.noexists.`"literal"`', + error: + 'Invalid jmespath expression: parse error at column 17, found unexpected token "literal" (literal) in expression: bar.baz.noexists.`"literal"`', + }, + { + comment: 'Literal wildcard projection', + expression: 'foo[*].`"literal"`', + error: + 'Invalid jmespath expression: parse error at column 7, found unexpected token "literal" (literal) in expression: foo[*].`"literal"`', + }, + { + expression: 'foo[*].name.`"literal"`', + error: + 'Invalid jmespath expression: parse error at column 12, found unexpected token "literal" (literal) in expression: foo[*].name.`"literal"`', + }, + { + expression: 'foo[].name.`"literal"`', + error: + 'Invalid jmespath expression: parse error at column 11, found unexpected token "literal" (literal) in expression: foo[].name.`"literal"`', + }, + { + expression: 'foo[].name.`"literal"`.`"subliteral"`', + error: + 'Invalid jmespath expression: parse error at column 11, found unexpected token "literal" (literal) in expression: foo[].name.`"literal"`.`"subliteral"`', + }, + { + comment: 'Projecting a literal onto an empty list', + expression: 'foo[*].name.noexist.`"literal"`', + error: + 'Invalid jmespath expression: parse error at column 20, found unexpected token "literal" (literal) in expression: foo[*].name.noexist.`"literal"`', + }, + { + expression: 'foo[].name.noexist.`"literal"`', + error: + 'Invalid jmespath expression: parse error at column 19, found unexpected token "literal" (literal) in expression: foo[].name.noexist.`"literal"`', + }, + { + expression: 'twolen[*].`"foo"`', + error: + 'Invalid jmespath expression: parse error at column 10, found unexpected token "foo" (literal) in expression: twolen[*].`"foo"`', + }, + { + comment: 'Two level projection of a literal', + expression: 'twolen[*].threelen[*].`"bar"`', + error: + 'Invalid jmespath expression: parse error at column 22, found unexpected token "bar" (literal) in expression: twolen[*].threelen[*].`"bar"`', + }, + { + comment: 'Two level flattened projection of a literal', + expression: 'twolen[].threelen[].`"bar"`', + error: + 'Invalid jmespath expression: parse error at column 20, found unexpected token "bar" (literal) in expression: twolen[].threelen[].`"bar"`', + }, + ])('filter errors: $expression', ({ expression, error }) => { + // Prepare + const data = { + type: 'object', + }; + + // Act & Assess + expect(() => search(expression, data)).toThrow(error); + }); + + it.each([ + { + expression: 'foo', + expected: null, + }, + { + expression: '"foo"', + expected: null, + }, + { + expression: '"\\\\"', + expected: null, + }, + ])('should support identifiers: $expression', ({ expression, expected }) => { + // Prepare + const data = { type: 'object' }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + }); + + it.each([ + { + expression: '*||*|*|*', + expected: null, + }, + { + expression: '*[]||[*]', + expected: [], + }, + { + expression: '[*.*]', + expected: [null], + }, + ])( + 'should support combined syntax: $expression', + ({ expression, expected }) => { + // Prepare + const data: string[] = []; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/unicode.test.ts b/packages/jmespath/tests/unit/compliance/unicode.test.ts new file mode 100644 index 0000000000..4f1f201dc6 --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/unicode.test.ts @@ -0,0 +1,69 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/unicode + */ +import { search } from '../../../src'; + +describe('Unicode tests', () => { + it.each([ + { + expression: 'foo[]."✓"', + expected: ['✓', '✗'], + }, + ])( + 'should parse an object with unicode chars as keys and values: $expression', + ({ expression, expected }) => { + // Prepare + const data = { foo: [{ '✓': '✓' }, { '✓': '✗' }] }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: '"☯"', + expected: true, + }, + { + expression: '"☃"', + expected: null, + }, + ])( + 'should parse an object with unicode chars as keys: $expression', + ({ expression, expected }) => { + // Prepare + const data = { '☯': true }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: '"♪♫•*¨*•.¸¸❤¸¸.•*¨*•♫♪"', + expected: true, + }, + ])( + 'should parse an object with mulitple unicode chars as keys: $expression', + ({ expression, expected }) => { + // Prepare + const data = { '♪♫•*¨*•.¸¸❤¸¸.•*¨*•♫♪': true }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/compliance/wildcard.test.ts b/packages/jmespath/tests/unit/compliance/wildcard.test.ts new file mode 100644 index 0000000000..d8e8a478e7 --- /dev/null +++ b/packages/jmespath/tests/unit/compliance/wildcard.test.ts @@ -0,0 +1,670 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/compliance/wildcard + */ +import { search } from '../../../src'; + +describe('Wildcard tests', () => { + it.each([ + { + expression: 'foo.*.baz', + expected: ['val', 'val', 'val'], + }, + { + expression: 'foo.bar.*', + expected: ['val'], + }, + { + expression: 'foo.*.notbaz', + expected: [ + ['a', 'b', 'c'], + ['a', 'b', 'c'], + ], + }, + { + expression: 'foo.*.notbaz[0]', + expected: ['a', 'a'], + }, + { + expression: 'foo.*.notbaz[-1]', + expected: ['c', 'c'], + }, + ])( + 'should parse the wildcard operator with an object containing multiple keys at different levels: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + bar: { + baz: 'val', + }, + other: { + baz: 'val', + }, + other2: { + baz: 'val', + }, + other3: { + notbaz: ['a', 'b', 'c'], + }, + other4: { + notbaz: ['a', 'b', 'c'], + }, + other5: { + other: { + a: 1, + b: 1, + c: 1, + }, + }, + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo.*', + expected: [ + { 'second-1': 'val' }, + { 'second-1': 'val' }, + { 'second-1': 'val' }, + ], + }, + { + expression: 'foo.*.*', + expected: [['val'], ['val'], ['val']], + }, + { + expression: 'foo.*.*.*', + expected: [[], [], []], + }, + { + expression: 'foo.*.*.*.*', + expected: [[], [], []], + }, + ])( + 'should parse the wildcard operator with an object containing keys with hyphens: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + 'first-1': { + 'second-1': 'val', + }, + 'first-2': { + 'second-1': 'val', + }, + 'first-3': { + 'second-1': 'val', + }, + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: '*.bar', + expected: ['one', 'one'], + }, + ])( + 'should parse the wildcard operator with an object containing multiple keys: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + bar: 'one', + }, + other: { + bar: 'one', + }, + nomatch: { + notbar: 'three', + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: '*', + expected: [{ sub1: { foo: 'one' } }, { sub1: { foo: 'one' } }], + }, + { + expression: '*.sub1', + expected: [{ foo: 'one' }, { foo: 'one' }], + }, + { + expression: '*.*', + expected: [[{ foo: 'one' }], [{ foo: 'one' }]], + }, + { + expression: '*.*.foo[]', + expected: ['one', 'one'], + }, + { + expression: '*.sub1.foo', + expected: ['one', 'one'], + }, + ])( + 'should parse the wildcard operator with an object containing nested objects: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + top1: { + sub1: { foo: 'one' }, + }, + top2: { + sub1: { foo: 'one' }, + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[*].bar', + expected: ['one', 'two', 'three'], + }, + { + expression: 'foo[*].notbar', + expected: ['four'], + }, + ])( + 'should parse the wildcard operator with an object containing an array of objects: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { bar: 'one' }, + { bar: 'two' }, + { bar: 'three' }, + { notbar: 'four' }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: '[*]', + expected: [ + { bar: 'one' }, + { bar: 'two' }, + { bar: 'three' }, + { notbar: 'four' }, + ], + }, + { + expression: '[*].bar', + expected: ['one', 'two', 'three'], + }, + { + expression: '[*].notbar', + expected: ['four'], + }, + ])( + 'should parse the wildcard operator with an array of objects: $expression', + ({ expression, expected }) => { + // Prepare + const data = [ + { bar: 'one' }, + { bar: 'two' }, + { bar: 'three' }, + { notbar: 'four' }, + ]; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo.bar[*].baz', + expected: [ + ['one', 'two', 'three'], + ['four', 'five', 'six'], + ['seven', 'eight', 'nine'], + ], + }, + { + expression: 'foo.bar[*].baz[0]', + expected: ['one', 'four', 'seven'], + }, + { + expression: 'foo.bar[*].baz[1]', + expected: ['two', 'five', 'eight'], + }, + { + expression: 'foo.bar[*].baz[2]', + expected: ['three', 'six', 'nine'], + }, + { + expression: 'foo.bar[*].baz[3]', + expected: [], + }, + ])( + 'should parse the wildcard operator with an object with nested objects containing arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + bar: [ + { baz: ['one', 'two', 'three'] }, + { baz: ['four', 'five', 'six'] }, + { baz: ['seven', 'eight', 'nine'] }, + ], + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo.bar[*]', + expected: [ + ['one', 'two'], + ['three', 'four'], + ], + }, + { + expression: 'foo.bar[0]', + expected: ['one', 'two'], + }, + { + expression: 'foo.bar[0][0]', + expected: 'one', + }, + { + expression: 'foo.bar[0][0][0]', + expected: null, + }, + { + expression: 'foo.bar[0][0][0][0]', + expected: null, + }, + { + expression: 'foo[0][0]', + expected: null, + }, + ])( + 'should parse the wildcard operator with an object with nested arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: { + bar: [ + ['one', 'two'], + ['three', 'four'], + ], + }, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[*].bar[*].kind', + expected: [ + ['basic', 'intermediate'], + ['advanced', 'expert'], + ], + }, + { + expression: 'foo[*].bar[0].kind', + expected: ['basic', 'advanced'], + }, + ])( + 'should parse the wildcard operator with an array of objects with nested arrays or strings: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { bar: [{ kind: 'basic' }, { kind: 'intermediate' }] }, + { bar: [{ kind: 'advanced' }, { kind: 'expert' }] }, + { bar: 'string' }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[*].bar.kind', + expected: ['basic', 'intermediate', 'advanced', 'expert'], + }, + ])( + 'should parse the wildcard operator with an array of objects: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { bar: { kind: 'basic' } }, + { bar: { kind: 'intermediate' } }, + { bar: { kind: 'advanced' } }, + { bar: { kind: 'expert' } }, + { bar: 'string' }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[*].bar[0]', + expected: ['one', 'three', 'five'], + }, + { + expression: 'foo[*].bar[1]', + expected: ['two', 'four'], + }, + { + expression: 'foo[*].bar[2]', + expected: [], + }, + ])( + 'should parse the wildcard operator with an array of objects with arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + { bar: ['one', 'two'] }, + { bar: ['three', 'four'] }, + { bar: ['five'] }, + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[*].bar[0]', + expected: [], + }, + ])( + 'should parse the wildcard operator with an array of objects with empty arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [{ bar: [] }, { bar: [] }, { bar: [] }], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[*][0]', + expected: ['one', 'three', 'five'], + }, + { + expression: 'foo[*][1]', + expected: ['two', 'four'], + }, + ])( + 'should parse the wildcard operator with an array of arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [['one', 'two'], ['three', 'four'], ['five']], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'foo[*][0]', + expected: [['one', 'two'], ['five', 'six'], ['nine']], + }, + { + expression: 'foo[*][1]', + expected: [['three', 'four'], ['seven', 'eight'], ['ten']], + }, + { + expression: 'foo[*][0][0]', + expected: ['one', 'five', 'nine'], + }, + { + expression: 'foo[*][1][0]', + expected: ['three', 'seven', 'ten'], + }, + { + expression: 'foo[*][0][1]', + expected: ['two', 'six'], + }, + { + expression: 'foo[*][1][1]', + expected: ['four', 'eight'], + }, + { + expression: 'foo[*][2]', + expected: [], + }, + { + expression: 'foo[*][2][2]', + expected: [], + }, + { + expression: 'bar[*]', + expected: null, + }, + { + expression: 'bar[*].baz[*]', + expected: null, + }, + ])( + 'should parse a nested array of arrays: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + foo: [ + [ + ['one', 'two'], + ['three', 'four'], + ], + [ + ['five', 'six'], + ['seven', 'eight'], + ], + [['nine'], ['ten']], + ], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'string[*]', + expected: null, + }, + { + expression: 'hash[*]', + expected: null, + }, + { + expression: 'number[*]', + expected: null, + }, + { + expression: 'nullvalue[*]', + expected: null, + }, + { + expression: 'string[*].foo', + expected: null, + }, + { + expression: 'hash[*].foo', + expected: null, + }, + { + expression: 'number[*].foo', + expected: null, + }, + { + expression: 'nullvalue[*].foo', + expected: null, + }, + { + expression: 'nullvalue[*].foo[*].bar', + expected: null, + }, + ])( + 'should parse an object with different value types: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + string: 'string', + hash: { foo: 'bar', bar: 'baz' }, + number: 23, + nullvalue: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + expression: 'string.*', + expected: null, + }, + { + expression: 'hash.*', + expected: ['val', 'val'], + }, + { + expression: 'number.*', + expected: null, + }, + { + expression: 'array.*', + expected: null, + }, + { + expression: 'nullvalue.*', + expected: null, + }, + ])( + 'should parse an object with different value types: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + string: 'string', + hash: { foo: 'val', bar: 'val' }, + number: 23, + array: [1, 2, 3], + nullvalue: null, + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); + it.each([{ expression: '*[0]', expected: [0, 0] }])( + 'should get the first element of each array: $expression', + ({ expression, expected }) => { + // Prepare + const data = { + a: [0, 1, 2], + b: [0, 1, 2], + }; + + // Act + const result = search(expression, data); + + // Assess + expect(result).toStrictEqual(expected); + } + ); +}); diff --git a/packages/jmespath/tests/unit/index.test.ts b/packages/jmespath/tests/unit/index.test.ts new file mode 100644 index 0000000000..04d1019453 --- /dev/null +++ b/packages/jmespath/tests/unit/index.test.ts @@ -0,0 +1,388 @@ +/** + * Test Compliance with the JMESPath specification + * + * @group unit/jmespath/coverage + */ +import { fromBase64 } from '@aws-lambda-powertools/commons/utils/base64'; +import { + search, + EmptyExpressionError, + ArityError, + LexerError, + JMESPathError, + VariadicArityError, +} from '../../src'; +import { Functions } from '../../src/Functions.js'; +import { Parser } from '../../src/Parser.js'; +import { TreeInterpreter } from '../../src/TreeInterpreter.js'; +import { brotliDecompressSync } from 'node:zlib'; +import { PowertoolsFunctions } from '../../src/PowertoolsFunctions.js'; +import { extractDataFromEnvelope, SQS } from '../../src/envelopes.js'; + +describe('Coverage tests', () => { + // These expressions tests are not part of the compliance suite, but are added to ensure coverage + describe('expressions', () => { + it('throws an error if the index is an invalid value', () => { + // Prepare + const invalidIndexExpression = 'foo.*.notbaz[-a]'; + + // Act & Assess + expect(() => search(invalidIndexExpression, {})).toThrow(LexerError); + }); + + it('throws an error if the expression is not a string', () => { + // Prepare + const notAStringExpression = 3; + + // Act & Assess + expect(() => + search(notAStringExpression as unknown as string, {}) + ).toThrow(EmptyExpressionError); + }); + + it('throws a lexer error when encounteirng a single equal for equality', () => { + // Prepare + const expression = '='; + + // Act & Assess + expect(() => { + search(expression, {}); + }).toThrow(LexerError); + }); + + it('returns null when max_by is called with an empty list', () => { + // Prepare + const expression = 'max_by(@, &foo)'; + + // Act + const result = search(expression, []); + + // Assess + expect(result).toBe(null); + }); + + it('returns null when min_by is called with an empty list', () => { + // Prepare + const expression = 'min_by(@, &foo)'; + + // Act + const result = search(expression, []); + + // Assess + expect(result).toBe(null); + }); + + it('returns the correct max value', () => { + // Prepare + const expression = 'max(@)'; + + // Act + const result = search(expression, ['z', 'b']); + + // Assess + expect(result).toBe('z'); + }); + + it('returns the correct min value', () => { + // Prepare + const expression = 'min(@)'; + + // Act + const result = search(expression, ['z', 'b']); + + // Assess + expect(result).toBe('b'); + }); + }); + + describe('type checking', () => { + class TestFunctions extends Functions { + @TestFunctions.signature({ + argumentsSpecs: [['any'], ['any']], + }) + public funcTest(): void { + return; + } + + @TestFunctions.signature({ + argumentsSpecs: [['any'], ['any']], + variadic: true, + }) + public funcTestArityError(): void { + return; + } + } + + it('throws an arity error if the function is called with the wrong number of arguments', () => { + // Prepare + const expression = 'test(@, @, @)'; + + // Act & Assess + expect(() => + search(expression, {}, { customFunctions: new TestFunctions() }) + ).toThrow(ArityError); + }); + + it('throws an arity error if the function is called with the wrong number of arguments', () => { + // Prepare + const expression = 'test_arity_error(@)'; + + // Act & Assess + expect(() => + search(expression, {}, { customFunctions: new TestFunctions() }) + ).toThrow(VariadicArityError); + }); + }); + + describe('class: Parser', () => { + it('clears the cache when purgeCache is called', () => { + // Prepare + const parser = new Parser(); + + // Act + const parsedResultA = parser.parse('test(@, @)'); + parser.purgeCache(); + const parsedResultB = parser.parse('test(@, @)'); + + // Assess + expect(parsedResultA).not.toBe(parsedResultB); + }); + }); + + describe('class: TreeInterpreter', () => { + it('throws an error when visiting an invalid node', () => { + // Prepare + const interpreter = new TreeInterpreter(); + + // Act & Assess + expect(() => { + interpreter.visit( + { + type: 'invalid', + value: 'invalid', + children: [], + }, + {} + ); + }).toThrow(JMESPathError); + }); + + it('returns null when visiting a field with no value', () => { + // Prepare + const interpreter = new TreeInterpreter(); + + // Act + const result = interpreter.visit( + { + type: 'field', + value: undefined, + children: [], + }, + {} + ); + + // Assess + expect(result).toBe(null); + }); + + it('throws an error when receiving an invalid comparator', () => { + // Prepare + const interpreter = new TreeInterpreter(); + + // Act & Assess + expect(() => { + interpreter.visit( + { + type: 'comparator', + value: 'invalid', + children: [ + { + type: 'field', + value: 'a', + children: [], + }, + { + type: 'field', + value: 'b', + children: [], + }, + ], + }, + {} + ); + }).toThrow(JMESPathError); + }); + + it('throws an error when receiving a function with an invalid name', () => { + // Prepare + const interpreter = new TreeInterpreter(); + + // Act & Assess + expect(() => { + interpreter.visit( + { + type: 'function_expression', + value: 1, // function name must be a string + children: [], + }, + {} + ); + }).toThrow(JMESPathError); + }); + + it('throws an error when receiving an index expression with an invalid index', () => { + // Prepare + const interpreter = new TreeInterpreter(); + + // Act & Assess + expect(() => { + interpreter.visit( + { + type: 'index', + value: 'invalid', // index must be a number + children: [], + }, + [] + ); + }).toThrow(JMESPathError); + }); + + it('returns an empty array when slicing an empty array', () => { + // Prepare + const interpreter = new TreeInterpreter(); + + // Act + const result = interpreter.visit( + { + type: 'slice', + value: [0, 0, 1], + children: [], + }, + [] + ); + + // Assess + expect(result).toEqual([]); + }); + }); + + describe('function: extractDataFromEnvelope', () => { + it('extracts the data from a known envelope', () => { + // Prepare + const event = { + Records: [ + { + body: '{"foo":"bar"}', + }, + ], + }; + + // Act + const data = extractDataFromEnvelope(event, SQS); + + // Assess + expect(data).toStrictEqual([{ foo: 'bar' }]); + }); + }); + + describe('class: PowertoolsFunctions', () => { + it('decodes a json string', () => { + // Prepare + const event = '{"user":"xyz","product_id":"123456789"}'; + + // Act + const data = extractDataFromEnvelope(event, 'powertools_json(@)', { + customFunctions: new PowertoolsFunctions(), + }); + + // Assess + expect(data).toStrictEqual({ + user: 'xyz', + product_id: '123456789', + }); + }); + + it('decodes a base64 gzip string', () => { + // Prepare + const event = { + payload: + 'H4sIACZAXl8C/52PzUrEMBhFX2UILpX8tPbHXWHqIOiq3Q1F0ubrWEiakqTWofTdTYYB0YWL2d5zvnuTFellBIOedoiyKH5M0iwnlKH7HZL6dDB6ngLDfLFYctUKjie9gHFaS/sAX1xNEq525QxwFXRGGMEkx4Th491rUZdV3YiIZ6Ljfd+lfSyAtZloacQgAkqSJCGhxM6t7cwwuUGPz4N0YKyvO6I9WDeMPMSo8Z4Ca/kJ6vMEYW5f1MX7W1lVxaG8vqX8hNFdjlc0iCBBSF4ERT/3Pl7RbMGMXF2KZMh/C+gDpNS7RRsp0OaRGzx0/t8e0jgmcczyLCWEePhni/23JWalzjdu0a3ZvgEaNLXeugEAAA==', + }; + + // Act + const data = extractDataFromEnvelope( + event, + 'powertools_base64_gzip(payload) | powertools_json(@).logGroup', + { + customFunctions: new PowertoolsFunctions(), + } + ); + + // Assess + expect(data).toStrictEqual('/aws/lambda/powertools-example'); + }); + + it('decodes a base64 string', () => { + // Prepare + const event = { + payload: + 'eyJ1c2VyX2lkIjogMTIzLCAicHJvZHVjdF9pZCI6IDEsICJxdWFudGl0eSI6IDIsICJwcmljZSI6IDEwLjQwLCAiY3VycmVuY3kiOiAiVVNEIn0=', + }; + + // Act + const data = extractDataFromEnvelope( + event, + 'powertools_json(powertools_base64(payload))', + { + customFunctions: new PowertoolsFunctions(), + } + ); + + // Assess + expect(data).toStrictEqual({ + user_id: 123, + product_id: 1, + quantity: 2, + price: 10.4, + currency: 'USD', + }); + }); + + it('uses the custom function extending the powertools custom functions', () => { + // Prepare + class CustomFunctions extends PowertoolsFunctions { + public constructor() { + super(); + } + @PowertoolsFunctions.signature({ + argumentsSpecs: [['string']], + }) + public funcDecodeBrotliCompression(value: string): string { + const encoded = fromBase64(value, 'base64'); + const uncompressed = brotliDecompressSync(encoded); + + return uncompressed.toString(); + } + } + const event = { + Records: [ + { + application: 'messaging-app', + datetime: '2022-01-01T00:00:00.000Z', + notification: 'GyYA+AXhZKk/K5DkanoQSTYpSKMwwxXh8DRWVo9A1hLqAQ==', + }, + ], + }; + + // Act + const messages = extractDataFromEnvelope( + event, + 'Records[*].decode_brotli_compression(notification) | [*].powertools_json(@).message', + { customFunctions: new CustomFunctions() } + ); + + // Assess + expect(messages).toStrictEqual(['hello world']); + }); + }); +});