Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ List all user segments.
| Option | Description |
|---|---|
| `--title` | Segment title |
| `--filters` | JSON array of canonical `{field,op,value}` filter objects |
| `--filters` | JSON array of canonical `{field,op,value}` filter objects. Array string members cannot contain `|` |

### `segments delete <segmentId>`
Delete a user segment.
Expand Down Expand Up @@ -421,7 +421,7 @@ Pre-built analytics pipes — the same data that powers the Formo dashboard —
|---|---|
| `--date-from` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--date-to`) |
| `--date-to` | Inclusive end date `YYYY-MM-DD` (default: today) |
| `--filters` | JSON array of `[{field,op,value}]`. For `in`/`nin`, array values are preferred; pipe-delimited strings are also accepted |
| `--filters` | JSON array of `[{field,op,value}]`. For `in`/`nin`, array values are preferred; pipe-delimited strings are also accepted. Array string members cannot contain `|` |
| `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) |

```bash
Expand Down
4 changes: 2 additions & 2 deletions SKILLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ formo analytics <pipe> [options]
|---|---|
| `--date-from` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--date-to`) |
| `--date-to` | Inclusive end date `YYYY-MM-DD` (default: today) |
| `--filters` | JSON array of `[{field,op,value}]`. For `in`/`nin`, array values are preferred; pipe-delimited strings are also accepted |
| `--filters` | JSON array of `[{field,op,value}]`. For `in`/`nin`, array values are preferred; pipe-delimited strings are also accepted. Array string members cannot contain `|` |
| `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) |

**Examples:**
Expand Down Expand Up @@ -575,7 +575,7 @@ formo segments create --title <title> --filters '<json>'
| Option | Description |
|---|---|
| `--title` | Segment title |
| `--filters` | JSON array of canonical `{field,op,value}` filter objects |
| `--filters` | JSON array of canonical `{field,op,value}` filter objects. Array string members cannot contain `|` |

> Requires `segments:write` scope.

Expand Down
76 changes: 75 additions & 1 deletion src/commands/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Cli, z } from 'incur'
import { createClient, requireApiKey } from '../lib/client'
import {
hasTinybirdMembershipDelimiter,
isCanonicalFilterOperator,
isCanonicalFilterValue,
isValuelessFilterOperator,
} from '../lib/filters'
import { parseJsonObject } from '../lib/json'

export const analytics = Cli.create('analytics', {
Expand Down Expand Up @@ -51,6 +57,71 @@ const RESERVED_PARAM_KEYS = new Set([
'filters',
])

const ANALYTICS_FILTER_KEYS = new Set(['field', 'op', 'value', 'filters'])
const ANALYTICS_NESTED_FILTER_KEYS = new Set(['field', 'op', 'value'])

function validateAnalyticsFilter(
filter: unknown,
path: string,
allowNested: boolean,
): void {
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error(`${path} must be a {field, op, value} object`)
}

const record = filter as Record<string, unknown>
if (!allowNested && record.filters !== undefined) {
throw new Error(`${path}.filters must be a one-level array of leaf filters`)
}
const allowedKeys = allowNested
? ANALYTICS_FILTER_KEYS
: ANALYTICS_NESTED_FILTER_KEYS
if (Object.keys(record).some((key) => !allowedKeys.has(key))) {
throw new Error(
`${path} may only contain field, op, value${allowNested ? ', and filters' : ''}`,
)
}
if (typeof record.field !== 'string' || record.field.length === 0) {
throw new Error(`${path} requires a non-empty string "field"`)
}
if (!isCanonicalFilterOperator(record.op)) {
throw new Error(`${path} requires a canonical "op"`)
}
if (
!isValuelessFilterOperator(record.op) &&
(record.value === undefined ||
record.value === null ||
!isCanonicalFilterValue(record.value))
) {
throw new Error(
`${path}: "value" is required for every operator except notEmpty/isEmpty`,
)
}
if (
record.value !== undefined &&
record.value !== null &&
!isCanonicalFilterValue(record.value)
) {
throw new Error(
`${path}: "value" must be a string, number, boolean, or string/number array`,
)
}
if (hasTinybirdMembershipDelimiter(record.value)) {
throw new Error(
`${path}: array string members cannot contain "|" because it is the Tinybird membership separator`,
)
}

if (record.filters !== undefined) {
if (!allowNested || !Array.isArray(record.filters)) {
throw new Error(`${path}.filters must be a one-level array of leaf filters`)
}
record.filters.forEach((nested, index) =>
validateAnalyticsFilter(nested, `${path}.filters[${index}]`, false),
)
}
}

/**
* Build the query-string params for an analytics pipe request.
*
Expand Down Expand Up @@ -106,6 +177,9 @@ export function buildAnalyticsParams(
'--filters must be a valid JSON array of {field,op,value} objects',
)
}
parsed.forEach((filter, index) =>
validateAnalyticsFilter(filter, `--filters[${index}]`, true),
)
Comment on lines +180 to +182

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate filters carried inside analytics params

When filters are supplied through pipe-specific --params—for example funnel's steps[*].filters, or flow's global_filters and step filters—this validation is never invoked because the --params branch serializes nested objects unchanged. Consequently a membership value such as ["alpha|beta"] is still sent to Tinybird and produces the API 400 this change is intended to catch locally; apply the delimiter validation to these documented filter-bearing params as well.

Useful? React with 👍 / 👎.

out.filters = JSON.stringify(parsed)
}

Expand All @@ -132,7 +206,7 @@ const sharedOptions = z.object({
.optional()
.describe(
'JSON array of filter conditions: [{"field","op","value"}]. ' +
'Use op "in"/"nin" with an array value (e.g. ["chrome","firefox"]); pipe-delimited strings are also accepted.',
'Use op "in"/"nin" with an array value (e.g. ["chrome","firefox"]); pipe-delimited strings are also accepted. Array string members cannot contain "|".',
),
params: z
.string()
Expand Down
25 changes: 10 additions & 15 deletions src/commands/segments.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Cli, z } from 'incur'
import { createClient, requireApiKey } from '../lib/client'
import {
hasTinybirdMembershipDelimiter,
isCanonicalFilterValue,
isCanonicalFilterOperator,
isValuelessFilterOperator,
} from '../lib/filters'
Expand Down Expand Up @@ -44,18 +46,6 @@ export interface CreateSegmentOptions {

const SEGMENT_FILTER_KEYS = new Set(['field', 'op', 'value'])

function isSegmentFilterValue(value: unknown): boolean {
return (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
(Array.isArray(value) &&
value.every(
(item) => typeof item === 'string' || typeof item === 'number',
))
)
}

export function buildCreateSegmentBody(options: CreateSegmentOptions) {
const filters = parseJsonArray(options.filters, '--filters')
if (filters.length === 0) {
Expand Down Expand Up @@ -84,7 +74,7 @@ export function buildCreateSegmentBody(options: CreateSegmentOptions) {
!isValuelessFilterOperator(record.op) &&
(record.value === undefined ||
record.value === null ||
!isSegmentFilterValue(record.value))
!isCanonicalFilterValue(record.value))
) {
throw new Error(
'--filters: "value" is required for every operator except notEmpty/isEmpty',
Expand All @@ -93,12 +83,17 @@ export function buildCreateSegmentBody(options: CreateSegmentOptions) {
if (
record.value !== undefined &&
record.value !== null &&
!isSegmentFilterValue(record.value)
!isCanonicalFilterValue(record.value)
) {
throw new Error(
'--filters: "value" must be a string, number, boolean, or string/number array',
)
}
if (hasTinybirdMembershipDelimiter(record.value)) {
throw new Error(
'--filters: array string members cannot contain "|" because it is the Tinybird membership separator',
)
}
}

return {
Expand All @@ -120,7 +115,7 @@ segments.command('create', {
filters: z
.string()
.describe(
'JSON array of canonical filter objects: [{"field","op","value"}]',
'JSON array of canonical filter objects: [{"field","op","value"}]. Array string members cannot contain "|".',
),
}),
examples: [
Expand Down
19 changes: 19 additions & 0 deletions src/lib/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,22 @@ export function isCanonicalFilterOperator(op: unknown): op is string {
export function isValuelessFilterOperator(op: unknown): boolean {
return op === 'notEmpty' || op === 'isEmpty'
}

export function isCanonicalFilterValue(value: unknown): boolean {
return (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
(Array.isArray(value) &&
value.every(
(item) => typeof item === 'string' || typeof item === 'number',
))
)
}

export function hasTinybirdMembershipDelimiter(value: unknown): boolean {
return (
Array.isArray(value) &&
value.some((item) => typeof item === 'string' && item.includes('|'))
)
}
79 changes: 79 additions & 0 deletions test/commands/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,85 @@ describe('commands/analytics', function () {
);
});

it('validates every canonical filter entry', function () {
expect(() =>
buildAnalyticsParams({
filters: '[{"operand":"location","operator":"eq","value":"US"}]',
}),
).to.throw(/field, op, value/);
expect(() =>
buildAnalyticsParams({
filters: '[{"field":"location","op":"equals","value":"US"}]',
}),
).to.throw(/canonical "op"/);
expect(() =>
buildAnalyticsParams({
filters: '[{"field":"location","op":"contains"}]',
}),
).to.throw(/"value" is required/);
});

it('accepts value-less and one-level nested canonical filters', function () {
const filters = [
{ field: 'referrer', op: 'notEmpty' },
{
field: 'event',
op: 'eq',
value: 'purchase',
filters: [{ field: 'amount', op: 'gte', value: 100 }],
},
];
expect(
buildAnalyticsParams({ filters: JSON.stringify(filters) }).filters,
).to.equal(JSON.stringify(filters));
});

it('rejects recursive nested filters', function () {
expect(() =>
buildAnalyticsParams({
filters: JSON.stringify([
{
field: 'event',
op: 'eq',
value: 'purchase',
filters: [
{
field: 'amount',
op: 'gte',
value: 100,
filters: [{ field: 'currency', op: 'eq', value: 'USD' }],
},
],
},
]),
}),
).to.throw(/one-level array of leaf filters/);
});

it('rejects literal pipes in membership array members', function () {
expect(() =>
buildAnalyticsParams({
filters: JSON.stringify([
{ field: 'browser', op: 'in', value: ['Chrome|Mobile', 'Safari'] },
]),
}),
).to.throw(/cannot contain "\|"/);
expect(() =>
buildAnalyticsParams({
filters: JSON.stringify([
{
field: 'event',
op: 'eq',
value: 'purchase',
filters: [
{ field: 'sku', op: 'in', value: ['alpha|beta', 'gamma'] },
],
},
]),
}),
).to.throw(/cannot contain "\|"/);
});

it('merges primitive params through unchanged', function () {
const params = buildAnalyticsParams({
params: '{"limit":10,"group_by":"device"}',
Expand Down
10 changes: 10 additions & 0 deletions test/commands/bodyBuilders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,16 @@ describe('commands / body builders', function () {
{ field: 'browser', op: 'notEmpty' },
]);
});

it('rejects literal pipes in membership array members', function () {
expect(() =>
buildCreateSegmentBody({
title: 'x',
filters:
'[{"field":"browser","op":"in","value":["Chrome|Mobile","Safari"]}]',
}),
).to.throw(/cannot contain "\|"/);
});
});

describe('buildImportBody() mutually exclusive flags', function () {
Expand Down