d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
230 lines
7.1 KiB
TypeScript
230 lines
7.1 KiB
TypeScript
/**
|
|
* Converters for transforming between UI builder state and API filter/sort objects.
|
|
*/
|
|
|
|
import { generateShortId } from '@sim/utils/id'
|
|
import { isRecordLike } from '@sim/utils/object'
|
|
import type {
|
|
Filter,
|
|
FilterRule,
|
|
JsonValue,
|
|
Sort,
|
|
SortDirection,
|
|
SortRule,
|
|
} from '@/lib/table/types'
|
|
|
|
/** Converts UI filter rules to a Filter object for API queries. */
|
|
export function filterRulesToFilter(rules: FilterRule[]): Filter | null {
|
|
if (rules.length === 0) return null
|
|
|
|
const orGroups: Filter[] = []
|
|
let currentGroup: Filter = {}
|
|
|
|
for (const rule of rules) {
|
|
// Honor the OR boundary before skipping incomplete rows, so an incomplete
|
|
// `or` row between two valid conditions still starts a new group.
|
|
const isOr = rule.logicalOperator === 'or'
|
|
if (isOr && Object.keys(currentGroup).length > 0) {
|
|
orGroups.push({ ...currentGroup })
|
|
currentGroup = {}
|
|
}
|
|
|
|
// Skip incomplete rows (no column selected) so a blank builder row never
|
|
// serializes to a `{ '': ... }` predicate. The OR boundary above is still
|
|
// applied; the row just contributes no condition.
|
|
if (!rule.column) continue
|
|
|
|
const ruleValue = toRuleValue(rule.operator, rule.value)
|
|
const existing = currentGroup[rule.column]
|
|
currentGroup[rule.column] =
|
|
existing === undefined
|
|
? (ruleValue as Filter[string])
|
|
: (mergeConditions(existing, ruleValue) as Filter[string])
|
|
}
|
|
|
|
if (Object.keys(currentGroup).length > 0) {
|
|
orGroups.push(currentGroup)
|
|
}
|
|
|
|
return orGroups.length > 1 ? { $or: orGroups } : orGroups[0] || null
|
|
}
|
|
|
|
/** Converts a Filter object back to UI filter rules. */
|
|
export function filterToRules(filter: Filter | null): FilterRule[] {
|
|
if (!filter) return []
|
|
|
|
if (filter.$or && Array.isArray(filter.$or)) {
|
|
const groups = filter.$or
|
|
.map((orGroup) => parseFilterGroup(orGroup as Filter))
|
|
.filter((group) => group.length > 0)
|
|
return applyLogicalOperators(groups)
|
|
}
|
|
|
|
return parseFilterGroup(filter)
|
|
}
|
|
|
|
/** Converts a single UI sort rule to a Sort object for API queries. */
|
|
export function sortRuleToSort(rule: SortRule | null): Sort | null {
|
|
if (!rule || !rule.column) return null
|
|
return { [rule.column]: rule.direction }
|
|
}
|
|
|
|
/** Converts multiple UI sort rules to a Sort object. */
|
|
export function sortRulesToSort(rules: SortRule[]): Sort | null {
|
|
if (rules.length === 0) return null
|
|
|
|
const sort: Sort = {}
|
|
for (const rule of rules) {
|
|
if (rule.column) {
|
|
sort[rule.column] = rule.direction
|
|
}
|
|
}
|
|
|
|
return Object.keys(sort).length > 0 ? sort : null
|
|
}
|
|
|
|
/** Converts a Sort object back to UI sort rules. */
|
|
export function sortToRules(sort: Sort | null): SortRule[] {
|
|
if (!sort) return []
|
|
|
|
return Object.entries(sort).map(([column, direction]) => ({
|
|
id: generateShortId(),
|
|
column,
|
|
direction: normalizeSortDirection(direction),
|
|
}))
|
|
}
|
|
|
|
function toRuleValue(operator: string, value: string): JsonValue {
|
|
if (operator === 'isEmpty') return { $empty: true }
|
|
if (operator === 'isNotEmpty') return { $empty: false }
|
|
const parsedValue = parseValue(value, operator)
|
|
return operator === 'eq' ? parsedValue : { [`$${operator}`]: parsedValue }
|
|
}
|
|
|
|
/**
|
|
* Merges two conditions targeting the same column within one AND group into a
|
|
* single operator object, so `age > 18 AND age < 65` becomes
|
|
* `{ age: { $gt: 18, $lt: 65 } }` instead of the second rule clobbering the
|
|
* first. Bare-equality shorthands are normalized to `{ $eq: value }` so they
|
|
* can coexist with operators. On a same-operator collision (e.g. two
|
|
* `$contains`) the later rule wins.
|
|
*/
|
|
function mergeConditions(existing: unknown, incoming: unknown): Record<string, JsonValue> {
|
|
return { ...toOperatorObject(existing), ...toOperatorObject(incoming) }
|
|
}
|
|
|
|
function toOperatorObject(value: unknown): Record<string, JsonValue> {
|
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
return { ...(value as Record<string, JsonValue>) }
|
|
}
|
|
return { $eq: value as JsonValue }
|
|
}
|
|
|
|
function applyLogicalOperators(groups: FilterRule[][]): FilterRule[] {
|
|
const rules: FilterRule[] = []
|
|
|
|
groups.forEach((group, groupIndex) => {
|
|
group.forEach((rule, ruleIndex) => {
|
|
rules.push({
|
|
...rule,
|
|
logicalOperator:
|
|
groupIndex === 0 && ruleIndex === 0
|
|
? 'and'
|
|
: groupIndex > 0 && ruleIndex === 0
|
|
? 'or'
|
|
: 'and',
|
|
})
|
|
})
|
|
})
|
|
|
|
return rules
|
|
}
|
|
|
|
const ARRAY_OPERATORS = new Set(['in', 'nin'])
|
|
const TEXT_MATCH_OPERATORS = new Set(['contains', 'ncontains', 'startsWith', 'endsWith'])
|
|
|
|
function parseValue(value: string, operator: string): JsonValue {
|
|
if (ARRAY_OPERATORS.has(operator)) {
|
|
return value
|
|
.split(',')
|
|
.map((part) => part.trim())
|
|
.map((part) => parseScalar(part))
|
|
}
|
|
|
|
// Substring/prefix/suffix matches are textual — keep the raw string so a value
|
|
// like "123" isn't coerced to a number the SQL builder's ILIKE path can't use.
|
|
if (TEXT_MATCH_OPERATORS.has(operator)) {
|
|
return value
|
|
}
|
|
|
|
return parseScalar(value)
|
|
}
|
|
|
|
function parseScalar(value: string): JsonValue {
|
|
if (value === 'true') return true
|
|
if (value === 'false') return false
|
|
if (value === 'null') return null
|
|
if (!Number.isNaN(Number(value)) && value !== '') return Number(value)
|
|
return value
|
|
}
|
|
|
|
function parseFilterGroup(group: Filter): FilterRule[] {
|
|
if (!group || typeof group !== 'object' || Array.isArray(group)) return []
|
|
|
|
const rules: FilterRule[] = []
|
|
|
|
for (const [column, value] of Object.entries(group)) {
|
|
if (column === '$or' || column === '$and') continue
|
|
|
|
if (isRecordLike(value)) {
|
|
for (const [op, opValue] of Object.entries(value)) {
|
|
if (!op.startsWith('$')) continue
|
|
// `$empty` is a valueless boolean operator — map it back to the two
|
|
// distinct UI operators rather than exposing a raw `empty` operator.
|
|
// Accept the string forms `'true'`/`'false'` too, matching the lenient
|
|
// coercion in the SQL builder's `coerceEmptyFlag` so a filter authored
|
|
// via the raw API doesn't flip its predicate when re-opened in the UI.
|
|
if (op === '$empty') {
|
|
rules.push({
|
|
id: generateShortId(),
|
|
logicalOperator: 'and',
|
|
column,
|
|
operator: opValue === true || opValue === 'true' ? 'isEmpty' : 'isNotEmpty',
|
|
value: '',
|
|
})
|
|
continue
|
|
}
|
|
rules.push({
|
|
id: generateShortId(),
|
|
logicalOperator: 'and',
|
|
column,
|
|
operator: op.substring(1),
|
|
value: formatValueForBuilder(opValue as JsonValue),
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
|
|
rules.push({
|
|
id: generateShortId(),
|
|
logicalOperator: 'and',
|
|
column,
|
|
operator: 'eq',
|
|
value: formatValueForBuilder(value as JsonValue),
|
|
})
|
|
}
|
|
|
|
return rules
|
|
}
|
|
|
|
function formatValueForBuilder(value: JsonValue): string {
|
|
if (value === null) return 'null'
|
|
if (typeof value === 'boolean') return String(value)
|
|
if (Array.isArray(value)) return value.map(formatValueForBuilder).join(', ')
|
|
return String(value)
|
|
}
|
|
|
|
function normalizeSortDirection(direction: string): SortDirection {
|
|
return direction === 'desc' ? 'desc' : 'asc'
|
|
}
|