chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
Reference in New Issue
Block a user