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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,119 @@
/**
* @vitest-environment node
*
* Converter unit tests for the table query builder. Cover the operator
* round-trips — UI rule → Filter object → UI rule — with attention to the
* valueless `$empty` operator that maps to two distinct UI operators.
*/
import { describe, expect, it } from 'vitest'
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'
import type { FilterRule } from '@/lib/table/types'
function rule(overrides: Partial<FilterRule>): FilterRule {
return {
id: 'rule-1',
logicalOperator: 'and',
column: 'name',
operator: 'eq',
value: '',
...overrides,
}
}
describe('filterRulesToFilter', () => {
it('emits a bare value for eq (containment shorthand)', () => {
expect(filterRulesToFilter([rule({ operator: 'eq', value: 'John' })])).toEqual({ name: 'John' })
})
it('wraps non-eq operators in a $-prefixed operator object', () => {
expect(
filterRulesToFilter([rule({ column: 'email', operator: 'startsWith', value: 'a' })])
).toEqual({ email: { $startsWith: 'a' } })
expect(
filterRulesToFilter([rule({ column: 'email', operator: 'ncontains', value: 'x' })])
).toEqual({ email: { $ncontains: 'x' } })
})
it('parses comma-separated values into arrays for in / nin', () => {
expect(
filterRulesToFilter([rule({ column: 'status', operator: 'nin', value: 'a, b' })])
).toEqual({ status: { $nin: ['a', 'b'] } })
})
it('serializes isEmpty / isNotEmpty to $empty without a value', () => {
expect(filterRulesToFilter([rule({ column: 'phone', operator: 'isEmpty' })])).toEqual({
phone: { $empty: true },
})
expect(filterRulesToFilter([rule({ column: 'phone', operator: 'isNotEmpty' })])).toEqual({
phone: { $empty: false },
})
})
it('merges two AND rules on the same column into one operator object', () => {
const filter = filterRulesToFilter([
rule({ id: 'a', column: 'age', operator: 'gt', value: '18' }),
rule({ id: 'b', column: 'age', operator: 'lt', value: '65' }),
])
expect(filter).toEqual({ age: { $gt: 18, $lt: 65 } })
})
it('normalizes a bare-equality shorthand when merging with an operator', () => {
const filter = filterRulesToFilter([
rule({ id: 'a', column: 'name', operator: 'eq', value: 'John' }),
rule({ id: 'b', column: 'name', operator: 'contains', value: 'oh' }),
])
expect(filter).toEqual({ name: { $eq: 'John', $contains: 'oh' } })
})
it('keeps same-column rules across an OR boundary in separate groups', () => {
const filter = filterRulesToFilter([
rule({ id: 'a', column: 'age', operator: 'gt', value: '18' }),
rule({ id: 'b', logicalOperator: 'or', column: 'age', operator: 'lt', value: '5' }),
])
expect(filter).toEqual({ $or: [{ age: { $gt: 18 } }, { age: { $lt: 5 } }] })
})
})
describe('filterToRules', () => {
it('maps $empty: true back to isEmpty and $empty: false back to isNotEmpty', () => {
const empty = filterToRules({ phone: { $empty: true } })
expect(empty).toHaveLength(1)
expect(empty[0]).toMatchObject({ column: 'phone', operator: 'isEmpty', value: '' })
const notEmpty = filterToRules({ phone: { $empty: false } })
expect(notEmpty[0]).toMatchObject({ column: 'phone', operator: 'isNotEmpty', value: '' })
})
it("treats the string '$empty' operand the same as the boolean (no predicate flip)", () => {
const empty = filterToRules({ phone: { $empty: 'true' } } as unknown as Parameters<
typeof filterToRules
>[0])
expect(empty[0]).toMatchObject({ column: 'phone', operator: 'isEmpty', value: '' })
const notEmpty = filterToRules({ phone: { $empty: 'false' } } as unknown as Parameters<
typeof filterToRules
>[0])
expect(notEmpty[0]).toMatchObject({ column: 'phone', operator: 'isNotEmpty', value: '' })
})
it('round-trips string-pattern operators', () => {
for (const operator of ['contains', 'ncontains', 'startsWith', 'endsWith'] as const) {
const filter = filterRulesToFilter([rule({ column: 'name', operator, value: 'abc' })])
const back = filterToRules(filter)
expect(back[0]).toMatchObject({ column: 'name', operator, value: 'abc' })
}
})
it('round-trips isEmpty through filterRulesToFilter', () => {
const filter = filterRulesToFilter([rule({ column: 'name', operator: 'isEmpty' })])
const back = filterToRules(filter)
expect(back[0]).toMatchObject({ column: 'name', operator: 'isEmpty', value: '' })
})
it('round-trips a multi-operator column (Filter → rules → Filter) without loss', () => {
const original = { age: { $gte: 18, $lte: 65 } }
const rules = filterToRules(original)
expect(rules).toHaveLength(2)
expect(filterRulesToFilter(rules)).toEqual(original)
})
})
@@ -0,0 +1,39 @@
/**
* Constants for table query builder UI (filtering and sorting).
*/
export type { FilterRule, SortRule } from '@/lib/table/types'
export const COMPARISON_OPERATORS = [
{ value: 'eq', label: 'equals' },
{ value: 'ne', label: 'not equals' },
{ value: 'contains', label: 'contains' },
{ value: 'ncontains', label: 'does not contain' },
{ value: 'startsWith', label: 'starts with' },
{ value: 'endsWith', label: 'ends with' },
{ value: 'gt', label: 'greater than' },
{ value: 'gte', label: 'greater or equal' },
{ value: 'lt', label: 'less than' },
{ value: 'lte', label: 'less or equal' },
{ value: 'in', label: 'in array' },
{ value: 'nin', label: 'not in array' },
{ value: 'isEmpty', label: 'is empty' },
{ value: 'isNotEmpty', label: 'is not empty' },
] as const
/**
* Operators that take no value — the filter is fully specified by column +
* operator alone. The UI hides the value input and skips the value-required
* check for these, and the converter serializes them to `{ $empty: bool }`.
*/
export const VALUELESS_OPERATORS = new Set<string>(['isEmpty', 'isNotEmpty'])
export const LOGICAL_OPERATORS = [
{ value: 'and', label: 'and' },
{ value: 'or', label: 'or' },
] as const
export const SORT_DIRECTIONS = [
{ value: 'asc', label: 'ascending' },
{ value: 'desc', label: 'descending' },
] as const
@@ -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'
}
@@ -0,0 +1,7 @@
/**
* Query builder UI utilities for filtering and sorting tables.
*/
export * from '@/lib/table/query-builder/constants'
export * from '@/lib/table/query-builder/converters'
export * from '@/lib/table/query-builder/use-query-builder'
@@ -0,0 +1,154 @@
/**
* Hooks for query builder UI state management (filters and sorting).
*/
import { useCallback } from 'react'
import { generateShortId } from '@sim/utils/id'
import {
COMPARISON_OPERATORS,
type FilterRule,
LOGICAL_OPERATORS,
SORT_DIRECTIONS,
type SortRule,
} from '@/lib/table/query-builder/constants'
import type { ColumnOption } from '@/lib/table/types'
const comparisonOptions: ColumnOption[] = COMPARISON_OPERATORS.map((op) => ({
value: op.value,
label: op.label,
}))
const logicalOptions: ColumnOption[] = LOGICAL_OPERATORS.map((op) => ({
value: op.value,
label: op.label,
}))
const sortDirectionOptions: ColumnOption[] = SORT_DIRECTIONS.map((d) => ({
value: d.value,
label: d.label,
}))
/** Manages filter rule state with add/remove/update operations. */
export function useFilterBuilder({
columns,
rules,
setRules,
isReadOnly = false,
}: UseFilterBuilderProps): UseFilterBuilderReturn {
const createDefaultRule = useCallback((): FilterRule => {
return {
id: generateShortId(),
logicalOperator: 'and',
column: columns[0]?.value || '',
operator: 'eq',
value: '',
}
}, [columns])
const addRule = useCallback(() => {
if (isReadOnly) return
setRules([...rules, createDefaultRule()])
}, [isReadOnly, rules, setRules, createDefaultRule])
const removeRule = useCallback(
(id: string) => {
if (isReadOnly) return
setRules(rules.filter((r) => r.id !== id))
},
[isReadOnly, rules, setRules]
)
const updateRule = useCallback(
(id: string, field: keyof FilterRule, value: string) => {
if (isReadOnly) return
setRules(rules.map((r) => (r.id === id ? { ...r, [field]: value } : r)))
},
[isReadOnly, rules, setRules]
)
return {
comparisonOptions,
logicalOptions,
sortDirectionOptions,
addRule,
removeRule,
updateRule,
createDefaultRule,
}
}
/** Manages sort rule state with add/remove/update operations. */
export function useSortBuilder({
columns,
sortRule,
setSortRule,
}: UseSortBuilderProps): UseSortBuilderReturn {
const addSort = useCallback(() => {
setSortRule({
id: generateShortId(),
column: columns[0]?.value || '',
direction: 'asc',
})
}, [columns, setSortRule])
const removeSort = useCallback(() => {
setSortRule(null)
}, [setSortRule])
const updateSortColumn = useCallback(
(column: string) => {
if (sortRule) {
setSortRule({ ...sortRule, column })
}
},
[sortRule, setSortRule]
)
const updateSortDirection = useCallback(
(direction: 'asc' | 'desc') => {
if (sortRule) {
setSortRule({ ...sortRule, direction })
}
},
[sortRule, setSortRule]
)
return {
sortDirectionOptions,
addSort,
removeSort,
updateSortColumn,
updateSortDirection,
}
}
export interface UseFilterBuilderProps {
columns: ColumnOption[]
rules: FilterRule[]
setRules: (rules: FilterRule[]) => void
isReadOnly?: boolean
}
export interface UseFilterBuilderReturn {
comparisonOptions: ColumnOption[]
logicalOptions: ColumnOption[]
sortDirectionOptions: ColumnOption[]
addRule: () => void
removeRule: (id: string) => void
updateRule: (id: string, field: keyof FilterRule, value: string) => void
createDefaultRule: () => FilterRule
}
interface UseSortBuilderProps {
columns: ColumnOption[]
sortRule: SortRule | null
setSortRule: (sort: SortRule | null) => void
}
interface UseSortBuilderReturn {
sortDirectionOptions: ColumnOption[]
addSort: () => void
removeSort: () => void
updateSortColumn: (column: string) => void
updateSortDirection: (direction: 'asc' | 'desc') => void
}