Skip to content

Add type-safe query builder and React hooks package#415

Merged
hotlong merged 4 commits intomainfrom
copilot/add-type-safe-queries
Jan 31, 2026
Merged

Add type-safe query builder and React hooks package#415
hotlong merged 4 commits intomainfrom
copilot/add-type-safe-queries

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Jan 31, 2026

Implements P0 client SDK features: type-safe query construction and declarative React hooks for data fetching/mutations.

Changes

Type-Safe Query Builder (@objectstack/client)

  • Added fluent API for building queries with TypeScript generics
  • Filter builder with type-safe field names: equals(), greaterThan(), in(), like(), etc.
  • Query builder with select(), where(), orderBy(), limit(), paginate()
  • Compiles to existing QueryAST schema - no runtime changes

React Hooks Package (@objectstack/client-react)

New package with:

  • Data hooks: useQuery, useMutation, usePagination, useInfiniteQuery
  • Metadata hooks: useObject, useView, useFields, useMetadata
  • Context: ObjectStackProvider, useClient

Auto-handles loading/error states, refetching, caching. ETag support for metadata.

Examples

Updated msw-react-crud with hooks-based components demonstrating pagination and mutations.

Usage

import { ObjectStackProvider, usePagination, useMutation } from '@objectstack/client-react';

function TaskList() {
  const { data, page, nextPage, previousPage } = usePagination<Task>('todo_task', {
    pageSize: 10,
    sort: ['priority', '-created_at']
  });

  const { mutate: deleteTask } = useMutation('todo_task', 'delete', {
    onSuccess: () => refetch()
  });

  return (
    <div>
      {data?.value.map(task => <TaskItem task={task} onDelete={() => deleteTask({ id: task.id })} />)}
      <Pagination page={page} onNext={nextPage} onPrev={previousPage} />
    </div>
  );
}

// Type-safe query builder
const query = createQuery<Task>('todo_task')
  .select('id', 'subject', 'priority')
  .where(b => b.equals('is_completed', false).greaterThan('priority', 2))
  .orderBy('created_at', 'desc')
  .limit(10)
  .build();

Package Details

  • @objectstack/client-react@0.6.1 - peer dep: react >=18.0.0
  • Zero breaking changes, all additive
  • CodeQL: 0 vulnerabilities
Original prompt

client 客户端 SDK / Client SDK

5.1 当前状态 / Current State

已实现功能:

✅ 基础 CRUD 操作
✅ 元数据访问 (Meta API)
✅ 批量操作 (Batch Operations)
✅ 视图存储 (View Storage)
✅ 元数据缓存 (ETag-based caching)
✅ 标准化错误处理
文件结构:

packages/client/src/
└── index.ts # 客户端实现
5.2 待实现功能清单 / Features Roadmap

P0: 类型安全查询 (Type-Safe Queries)

类型生成器

Schema → TypeScript 类型
自动类型推导
泛型支持
类型校验
类型安全查询构建器

Fluent API
编译时类型检查
智能代码补全
类型安全的过滤器
运行时类型验证

Zod 集成
请求验证
响应验证
错误类型
P0: React Hooks

数据查询 Hooks

useQuery - 查询数据
useMutation - 修改数据
useInfiniteQuery - 无限滚动
usePagination - 分页查询
元数据 Hooks

useObject - 获取对象schema
useView - 获取视图配置
useFields - 获取字段列表
useMetadata - 通用元数据
实时数据 Hooks


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@vercel
Copy link
Copy Markdown

vercel bot commented Jan 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jan 31, 2026 1:29am

Request Review

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI and others added 2 commits January 31, 2026 01:24
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Add type-safe queries and React hooks for client SDK Add type-safe query builder and React hooks package Jan 31, 2026
Copilot AI requested a review from hotlong January 31, 2026 01:29
@hotlong hotlong marked this pull request as ready for review January 31, 2026 01:32
Copilot AI review requested due to automatic review settings January 31, 2026 01:33
@hotlong hotlong merged commit a09d236 into main Jan 31, 2026
5 of 6 checks passed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds type-safe query building capabilities and a comprehensive React hooks package for the ObjectStack client SDK. The implementation introduces @objectstack/client-react with data and metadata hooks, plus a query builder for type-safe query construction.

Changes:

  • Type-safe query builder with fluent API for filter construction
  • New @objectstack/client-react package with data hooks (useQuery, useMutation, usePagination, useInfiniteQuery) and metadata hooks (useObject, useView, useFields, useMetadata)
  • Example components demonstrating hooks usage with MSW-based mocking

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
packages/client/src/query-builder.ts Type-safe query builder with FilterBuilder and QueryBuilder classes
packages/client/src/index.ts Re-exports query builder utilities
packages/client-react/package.json Package configuration for new React hooks library
packages/client-react/tsconfig.json TypeScript configuration with JSX support
packages/client-react/src/context.tsx React context provider for ObjectStackClient
packages/client-react/src/data-hooks.tsx Data query and mutation hooks implementation
packages/client-react/src/metadata-hooks.tsx Metadata fetching hooks implementation
packages/client-react/src/index.tsx Main exports for the React hooks package
packages/client-react/README.md Comprehensive documentation with usage examples
packages/client-react/CHANGELOG.md Initial release changelog
examples/msw-react-crud/src/main.tsx Toggle between traditional and hooks approaches
examples/msw-react-crud/src/components/TaskListWithHooks.tsx Hooks-based task list component
examples/msw-react-crud/src/components/TaskFormWithHooks.tsx Hooks-based task form component
examples/msw-react-crud/src/AppWithHooks.tsx Demo app using React hooks
examples/msw-react-crud/package.json Added client-react dependency
examples/msw-react-crud/README.md Documentation for both approaches
pnpm-lock.yaml Updated lockfile with new dependencies
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

Comment on lines +86 to +128
const fetchMetadata = useCallback(async () => {
if (!enabled) return;

try {
setIsLoading(true);
setError(null);
setFromCache(false);

if (useCache) {
// Use cached metadata endpoint
const result = await client.meta.getCached(objectName, {
ifNoneMatch: ifNoneMatch || etag,
ifModifiedSince
});

if (result.notModified) {
setFromCache(true);
} else {
setData(result.data);
if (result.etag) {
setEtag(result.etag.value);
}
}

onSuccess?.(result.data || data);
} else {
// Direct fetch without cache
const result = await client.meta.getObject(objectName);
setData(result);
onSuccess?.(result);
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to fetch object metadata');
setError(error);
onError?.(error);
} finally {
setIsLoading(false);
}
}, [client, objectName, enabled, useCache, ifNoneMatch, ifModifiedSince, etag, data, onSuccess, onError]);

useEffect(() => {
fetchMetadata();
}, [fetchMetadata]);
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The fetchMetadata callback includes data in its dependency array on line 124, which creates a circular dependency. When data changes, fetchMetadata is recreated, which triggers the useEffect on line 126-128, which calls fetchMetadata, which may update data, causing an infinite loop.

Additionally, including onSuccess and onError callbacks in the dependency array will cause the hook to refetch whenever these callbacks change, which happens on every render if they're not memoized by the consumer. This is a common React hooks anti-pattern.

Consider:

  1. Remove data and etag from the dependency array (they're used internally but shouldn't trigger refetches)
  2. Either remove onSuccess and onError from dependencies or document that they must be memoized
  3. Use a ref to store the latest callbacks if you need to call them without including them in dependencies

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +145
const fetchData = useCallback(async (isRefetch = false) => {
if (!enabled) return;

try {
if (isRefetch) {
setIsRefetching(true);
} else {
setIsLoading(true);
}
setError(null);

let result: PaginatedResult<T>;

if (query) {
// Use advanced query API
result = await client.data.query<T>(object, query);
} else {
// Use simplified find API
result = await client.data.find<T>(object, {
select,
filters: filters as any,
sort,
top,
skip
});
}

setData(result);
onSuccess?.(result);
} catch (err) {
const error = err instanceof Error ? err : new Error('Query failed');
setError(error);
onError?.(error);
} finally {
setIsLoading(false);
setIsRefetching(false);
}
}, [client, object, query, select, filters, sort, top, skip, enabled, onSuccess, onError]);

// Initial fetch and dependency-based refetch
useEffect(() => {
fetchData();
}, [fetchData]);
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The fetchData callback includes onSuccess and onError in its dependency array (line 140), which will cause the query to refetch whenever these callbacks change. If the consumer passes inline arrow functions, this will happen on every render, causing infinite loops or unnecessary API calls.

This is the same circular dependency issue as in the metadata hooks. The typical React Query pattern uses refs to store the latest callbacks without including them in dependencies.

Consider using useRef to store callback references or document that callbacks must be memoized with useCallback.

Copilot uses AI. Check for mistakes.
Comment on lines +547 to +548
const fetchedCount = result.value.length;
const hasMore = fetchedCount === pageSize;
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The hasNextPage logic is overly simplistic and may produce incorrect results. It only checks if fetchedCount === pageSize, which will incorrectly show "has next page" when the total count happens to be an exact multiple of pageSize.

For example, if there are exactly 100 records with pageSize=20, after fetching all 5 pages (0-19, 20-39, ... 80-99), the last page will have 20 items, so hasNextPage will be true, but there's actually no next page.

Consider using the count field from PaginatedResult to accurately determine if there are more pages:

const totalFetched = pages.reduce((sum, p) => sum + p.value.length, 0) + result.value.length;
const hasMore = result.count > totalFetched;
Suggested change
const fetchedCount = result.value.length;
const hasMore = fetchedCount === pageSize;
const totalFetched = skip + result.value.length;
const hasMore =
typeof (result as any).count === 'number'
? (result as any).count > totalFetched
: result.value.length === pageSize;

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +45
const { mutate: deleteTask } = useMutation('todo_task', 'delete', {
onSuccess: () => {
refetch();
}
});

const { mutate: updateTask } = useMutation('todo_task', 'update', {
onSuccess: () => {
refetch();
}
});
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The inline onSuccess callbacks passed to useMutation will cause infinite loops due to the dependency array issue in the hooks. Every render creates new function references, which will recreate the mutation callbacks.

This should either be:

  1. Wrapped in useCallback: const handleSuccess = useCallback(() => { refetch(); }, [refetch]);
  2. Or the hooks should be fixed to not include callbacks in their dependency arrays

This demonstrates a fundamental issue with the hooks API design that needs to be addressed.

Copilot uses AI. Check for mistakes.
* Provides ObjectStackClient instance to React components via Context API
*/

import * as React from 'react';
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

Redundant React import. Line 7 imports * as React but it's not used anywhere in the file - all React APIs are imported as named imports on line 8. With modern React (17+) and the jsx transform configured in tsconfig.json, the namespace import is unnecessary.

Remove line 7 to simplify the imports.

Suggested change
import * as React from 'react';

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +127
export class FilterBuilder<T = any> {
private conditions: FilterCondition[] = [];

/**
* Equality filter: field = value
*/
equals<K extends keyof T>(field: K, value: T[K]): this {
this.conditions.push([field as string, '=', value]);
return this;
}

/**
* Not equals filter: field != value
*/
notEquals<K extends keyof T>(field: K, value: T[K]): this {
this.conditions.push([field as string, '!=', value]);
return this;
}

/**
* Greater than filter: field > value
*/
greaterThan<K extends keyof T>(field: K, value: T[K]): this {
this.conditions.push([field as string, '>', value]);
return this;
}

/**
* Greater than or equal filter: field >= value
*/
greaterThanOrEqual<K extends keyof T>(field: K, value: T[K]): this {
this.conditions.push([field as string, '>=', value]);
return this;
}

/**
* Less than filter: field < value
*/
lessThan<K extends keyof T>(field: K, value: T[K]): this {
this.conditions.push([field as string, '<', value]);
return this;
}

/**
* Less than or equal filter: field <= value
*/
lessThanOrEqual<K extends keyof T>(field: K, value: T[K]): this {
this.conditions.push([field as string, '<=', value]);
return this;
}

/**
* IN filter: field IN (value1, value2, ...)
*/
in<K extends keyof T>(field: K, values: T[K][]): this {
this.conditions.push([field as string, 'in', values]);
return this;
}

/**
* NOT IN filter: field NOT IN (value1, value2, ...)
*/
notIn<K extends keyof T>(field: K, values: T[K][]): this {
this.conditions.push([field as string, 'not_in', values]);
return this;
}

/**
* LIKE filter: field LIKE pattern
*/
like<K extends keyof T>(field: K, pattern: string): this {
this.conditions.push([field as string, 'like', pattern]);
return this;
}

/**
* IS NULL filter: field IS NULL
*/
isNull<K extends keyof T>(field: K): this {
this.conditions.push([field as string, 'is_null', null]);
return this;
}

/**
* IS NOT NULL filter: field IS NOT NULL
*/
isNotNull<K extends keyof T>(field: K): this {
this.conditions.push([field as string, 'is_not_null', null]);
return this;
}

/**
* Build the filter condition
*/
build(): FilterCondition {
if (this.conditions.length === 0) {
throw new Error('Filter builder has no conditions');
}
if (this.conditions.length === 1) {
return this.conditions[0];
}
// Combine multiple conditions with AND
return ['and', ...this.conditions];
}

/**
* Get raw conditions array
*/
getConditions(): FilterCondition[] {
return this.conditions;
}
}
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The FilterBuilder is generating filter conditions in an incompatible format. It creates tuple-based conditions like ['field', '=', 'value'] and ['and', ...conditions], but the FilterCondition type from @objectstack/spec uses an object-based format with $and, $or, $eq, $gt, etc. operators.

For example, the current implementation creates:

['and', ['priority', '>', 2], ['is_completed', '=', false]]

But the correct format based on FilterConditionSchema should be:

{
  $and: [
    { priority: { $gt: 2 } },
    { is_completed: { $eq: false } }
  ]
}

This incompatibility means queries built with this builder will not work with the actual ObjectStack backend that expects FilterCondition format. All filter-building methods (equals, greaterThan, in, like, etc.) need to be rewritten to generate the correct object-based format.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants