-
Notifications
You must be signed in to change notification settings - Fork 0
Add vitest support, enhance doctor command, implement formula/lookup RLS conditions #251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…tor command with dependency checks Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
…la and lookup condition support in query trimmer Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
|
There was a problem hiding this 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 pull request adds vitest test runner support to the CLI, enhances the doctor command with comprehensive environment checks, and implements formula-based and lookup-based row-level security (RLS) conditions in the security plugin's query trimmer.
Changes:
- CLI test command now auto-detects and supports both Jest and vitest test runners with new flags (--watch, --coverage, --runner)
- Doctor command enhanced with Node.js version validation, TypeScript detection, package.json validation, and ObjectQL dependency checking
- Query trimmer implements formula condition parsing to convert JavaScript-style expressions into database filters
- Query trimmer implements lookup condition support to handle relationship-based RLS through subquery filters
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/tools/cli/src/commands/test.ts | Added vitest detection alongside Jest, refactored to handle both runners with auto-detection logic |
| packages/tools/cli/src/register/tools.ts | Added new command-line flags for test command (--watch, --coverage, --runner, --dir) |
| packages/tools/cli/src/commands/doctor.ts | Comprehensive rewrite adding Node version checks, TypeScript validation, tsconfig analysis, and dependency detection |
| packages/tools/cli/tests/commands.test.ts | Added basic tests for test command (missing runner detection) and doctor command (Node.js version, package.json detection) |
| packages/foundation/plugin-security/src/query-trimmer.ts | Implemented formulaToFilter and lookupToFilter methods to convert RLS conditions to database query filters |
| packages/foundation/plugin-security/tests/query-trimmer.test.ts | New test file with 13 tests covering formula conditions, lookup conditions, and existing functionality |
| packages/foundation/plugin-security/package.json | Added Jest dependencies (jest, @types/jest, ts-jest) for testing |
| packages/foundation/plugin-security/jest.config.js | New Jest configuration for the security plugin |
| pnpm-lock.yaml | Updated with new dependencies and libc metadata for native packages |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (3)
packages/tools/cli/tests/commands.test.ts:16
- Unused import format.
import { format } from '../src/commands/format';
packages/tools/cli/tests/commands.test.ts:172
- Unused variable configPath.
let configPath: string;
packages/tools/cli/src/commands/doctor.ts:264
- Unused function loadObjectQLInstance.
async function loadObjectQLInstance(configPath?: string) {
| const simpleCompareMatch = normalized.match(/^(\w+)\s*(==|!=|>|>=|<|<=)\s*(.+)$/); | ||
| if (simpleCompareMatch) { | ||
| const field = simpleCompareMatch[1]; | ||
| const operator = simpleCompareMatch[2]; | ||
| let value: any = simpleCompareMatch[3].trim(); | ||
|
|
||
| // Remove quotes from string values | ||
| if ((value.startsWith("'") && value.endsWith("'")) || | ||
| (value.startsWith('"') && value.endsWith('"'))) { | ||
| value = value.slice(1, -1); | ||
| } else if (/^\d+$/.test(value)) { | ||
| // Parse numeric values | ||
| value = parseInt(value, 10); | ||
| } else if (/^\d+\.\d+$/.test(value)) { | ||
| // Parse float values | ||
| value = parseFloat(value); | ||
| } else if (value === 'true') { | ||
| value = true; | ||
| } else if (value === 'false') { | ||
| value = false; | ||
| } else if (value === 'null') { | ||
| value = null; | ||
| } | ||
|
|
||
| // Resolve user context | ||
| if (typeof value === 'string' && value.startsWith('$current_user.')) { | ||
| const path = value.substring('$current_user.'.length); | ||
| value = this.getFieldValue(user, path); | ||
| } | ||
|
|
||
| // Convert to MongoDB filter | ||
| const mongoOp = this.jsOperatorToMongoOperator(operator); | ||
| return this.operatorToMongoFilter(field, mongoOp, value); | ||
| } | ||
|
|
||
| // Pattern 4: Field existence check (e.g., "owner" or "!owner") | ||
| if (/^!?\w+$/.test(normalized)) { | ||
| const negate = normalized.startsWith('!'); | ||
| const field = negate ? normalized.slice(1) : normalized; | ||
|
|
||
| return negate | ||
| ? { [field]: { $in: [null, undefined, ''] } } | ||
| : { [field]: { $ne: null } }; | ||
| } | ||
|
|
||
| // Unable to convert formula to filter | ||
| throw new Error(`Unsupported formula pattern: ${formula}`); | ||
| } |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The formula parser does not validate or sanitize the user-provided formula expression before evaluation. While it attempts to parse common patterns, there's no prevention of code injection through formula strings. For example, a malicious formula could potentially include method calls or property access that aren't intended to be supported.
Add input validation to ensure only safe operators and field references are allowed in formulas. Consider using an allowlist approach where only specific operators and syntax patterns are permitted.
| // If we can't convert to a filter, return empty object | ||
| return {}; |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue as with formula conditions - returning an empty filter object when lookup conversion fails could allow records to be fetched that should be restricted. The console.warn indicates the lookup "will be evaluated in-memory" but there's no guarantee that in-memory evaluation is implemented or will work correctly.
For RLS security, it's safer to fail closed. Consider throwing an error or returning a restrictive filter ({ _id: null }) when lookup conditions cannot be safely converted to database filters.
| // If we can't convert to a filter, return empty object | |
| return {}; | |
| // If we can't convert to a filter safely, fail closed with a restrictive filter | |
| return { _id: null }; |
| if (runner === 'vitest' && hasVitest) { | ||
| const vitestArgs = ['vitest']; | ||
|
|
||
| if (options.watch) { | ||
| vitestArgs.push('--watch'); | ||
| } else { | ||
| vitestArgs.push('run'); | ||
| } |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The vitest command construction differs from jest in how it handles the watch flag. For vitest, when watch is not specified, it adds 'run' as a subcommand (line 100). However, this assumes vitest always needs an explicit 'run' command when not in watch mode, which may not align with how vitest is typically invoked in package.json scripts.
Verify that this is the correct vitest invocation pattern. In many projects, vitest can be run without the 'run' subcommand and will default to running tests once.
| process.exit(1); | ||
| } else if (hasWarnings) { | ||
| console.log(chalk.yellow('⚠️ Everything works, but some improvements are recommended.')); | ||
| } else { | ||
| console.log(chalk.green('✅ Everything looks good!')); |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The doctor command calls process.exit(1) on errors (line 152), which makes it untestable and prevents proper error handling in programmatic usage. The test at line 481-533 works around this by not testing error cases.
Consider returning an exit code or throwing errors instead of calling process.exit() directly. This would make the command more testable and allow it to be composed with other tools.
| process.exit(1); | |
| } else if (hasWarnings) { | |
| console.log(chalk.yellow('⚠️ Everything works, but some improvements are recommended.')); | |
| } else { | |
| console.log(chalk.green('✅ Everything looks good!')); | |
| return 1; | |
| } else if (hasWarnings) { | |
| console.log(chalk.yellow('⚠️ Everything works, but some improvements are recommended.')); | |
| return 0; | |
| } else { | |
| console.log(chalk.green('✅ Everything looks good!')); | |
| return 0; |
| async function getCommandVersion(command: string, versionFlag: string = '--version'): Promise<string | null> { | ||
| return new Promise((resolve) => { | ||
| const proc = spawn(command, [versionFlag], { shell: true }); | ||
| let output = ''; | ||
|
|
||
| proc.stdout.on('data', (data) => { | ||
| output += data.toString(); | ||
| }); | ||
|
|
||
| proc.stderr.on('data', (data) => { | ||
| output += data.toString(); | ||
| }); | ||
|
|
||
| proc.on('close', (code) => { | ||
| if (code === 0 && output.trim()) { | ||
| // Extract version number | ||
| const versionMatch = output.match(/(\d+\.\d+\.\d+)/); | ||
| resolve(versionMatch ? versionMatch[1] : output.trim().split('\n')[0]); | ||
| } else { | ||
| resolve(null); | ||
| } | ||
| }); | ||
|
|
||
| proc.on('error', () => { | ||
| resolve(null); | ||
| }); | ||
| }); | ||
| } |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The getCommandVersion function executes shell commands without any timeout, which could cause the doctor command to hang indefinitely if a command is unresponsive. This creates a poor user experience.
Add a timeout to the spawn call to ensure the command doesn't hang. Consider using a timeout of 5-10 seconds for version checks.
| private lookupToFilter(condition: any, user: any): any { | ||
| const { object, via, condition: nestedCondition } = condition; | ||
|
|
||
| // For simple lookup conditions, we can use MongoDB-style $lookup or | ||
| // SQL-style JOIN filters. The exact implementation depends on the driver. | ||
|
|
||
| // Convert the nested condition to a filter | ||
| const nestedFilter = this.conditionToFilter(nestedCondition, user); | ||
|
|
||
| if (Object.keys(nestedFilter).length === 0) { | ||
| throw new Error('Unable to convert nested lookup condition'); | ||
| } | ||
|
|
||
| // Create a lookup filter | ||
| // This will be interpreted by the driver layer | ||
| return { | ||
| $lookup: { | ||
| from: object, | ||
| localField: via, | ||
| foreignField: '_id', | ||
| filter: nestedFilter | ||
| } | ||
| }; | ||
| } |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The $lookup filter syntax used here is MongoDB-specific, but the QueryTrimmer is supposed to be database-agnostic and work at the AST level before SQL generation. According to the ObjectQL architecture principles, this should compile to abstract intent that can be interpreted by different drivers (SQL, MongoDB, etc.).
This implementation violates the "Compiler, Not ORM" principle by leaking database-specific syntax into the core security layer. The $lookup operator won't work with SQL drivers. Consider creating an abstract representation that can be translated by each driver, or document that lookup conditions only work with MongoDB.
| // Convert to MongoDB filter | ||
| const mongoOp = this.jsOperatorToMongoOperator(operator); | ||
| return this.operatorToMongoFilter(field, mongoOp, value); |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The operatorToMongoFilter method is called but uses MongoDB-specific syntax ($gt, $gte, $ne, etc.) which violates the architecture principle that QueryTrimmer should work at the AST level. The comment on line 18 states it "Works at the AST level to inject security filters before SQL generation" but the implementation leaks MongoDB-specific operators.
According to the ObjectQL architecture, packages/foundation must NEVER contain dialect-specific logic. Either refactor to use an abstract filter representation that drivers can interpret, or document this limitation.
| jestProcess.on('exit', (code) => { | ||
| if (code !== 0) { | ||
| console.error(chalk.red(`\n❌ Tests failed with exit code ${code}`)); | ||
| process.exit(code || 1); | ||
| } else { | ||
| console.log(chalk.green('\n✅ All tests passed!')); | ||
| } | ||
| }); | ||
|
|
||
| // Fall back to package.json test script | ||
| if (packageJson.scripts?.test) { | ||
| console.log(chalk.cyan(`Running: npm test\n`)); | ||
|
|
||
| const npmProcess = spawn('npm', ['test'], { | ||
| cwd: rootDir, | ||
| stdio: 'inherit', | ||
| shell: true | ||
| }); | ||
|
|
||
| npmProcess.on('exit', (code) => { | ||
| if (code !== 0) { | ||
| console.error(chalk.red(`\n❌ Tests failed with exit code ${code}`)); | ||
| process.exit(code || 1); | ||
| } else { | ||
| console.log(chalk.green('\n✅ All tests passed!')); | ||
| } | ||
| }); | ||
|
|
||
| return; | ||
| return; | ||
| } | ||
|
|
||
| // Run Vitest | ||
| if (runner === 'vitest' && hasVitest) { | ||
| const vitestArgs = ['vitest']; | ||
|
|
||
| if (options.watch) { | ||
| vitestArgs.push('--watch'); | ||
| } else { | ||
| vitestArgs.push('run'); | ||
| } | ||
| } catch (parseError: any) { | ||
| console.error(chalk.yellow(`⚠️ Failed to parse package.json: ${parseError.message}`)); | ||
|
|
||
| if (options.coverage) { | ||
| vitestArgs.push('--coverage'); | ||
| } | ||
|
|
||
| console.log(chalk.cyan(`Running: ${vitestArgs.join(' ')}\n`)); | ||
|
|
||
| const vitestProcess = spawn('npx', vitestArgs, { | ||
| cwd: rootDir, | ||
| stdio: 'inherit', | ||
| shell: true | ||
| }); | ||
|
|
||
| vitestProcess.on('exit', (code) => { | ||
| if (code !== 0) { | ||
| console.error(chalk.red(`\n❌ Tests failed with exit code ${code}`)); | ||
| process.exit(code || 1); | ||
| } else { | ||
| console.log(chalk.green('\n✅ All tests passed!')); | ||
| } | ||
| }); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| // Fall back to package.json test script | ||
| if (packageJson.scripts?.test) { | ||
| console.log(chalk.cyan(`Running: npm test\n`)); | ||
|
|
||
| const npmProcess = spawn('npm', ['test'], { | ||
| cwd: rootDir, | ||
| stdio: 'inherit', | ||
| shell: true | ||
| }); | ||
|
|
||
| npmProcess.on('exit', (code) => { | ||
| if (code !== 0) { | ||
| console.error(chalk.red(`\n❌ Tests failed with exit code ${code}`)); | ||
| process.exit(code || 1); | ||
| } else { | ||
| console.log(chalk.green('\n✅ All tests passed!')); | ||
| } | ||
| }); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| // No test configuration found | ||
| console.log(chalk.yellow('⚠️ No test configuration found')); | ||
| console.log(chalk.gray('To add tests to your project:')); | ||
| console.log(chalk.gray(' 1. Install jest: npm install --save-dev jest @types/jest ts-jest')); | ||
| console.log(chalk.gray(' 2. Create a jest.config.js file')); | ||
| console.log(chalk.gray(' 3. Add a test script to package.json')); | ||
| showNoTestConfig(); | ||
|
|
||
| } catch (e: any) { | ||
| console.error(chalk.red('❌ Test execution failed:'), e.message); | ||
| process.exit(1); |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test command calls process.exit() directly on test failures or success, which makes the command untestable and prevents proper error propagation. This is inconsistent with testing best practices and makes it difficult to test the CLI itself.
Consider returning error codes or throwing errors instead of calling process.exit() directly, allowing the caller to handle the exit. This pattern is already acknowledged in the test file at line 90 where a test is skipped due to process.exit.
| let runner = options.runner; | ||
| if (!runner) { | ||
| // Auto-detect test runner | ||
| if (hasVitest) { | ||
| runner = 'vitest'; | ||
| } else if (hasJest) { | ||
| runner = 'jest'; | ||
| } | ||
| } | ||
|
|
||
| // Run Jest | ||
| if (runner === 'jest' && hasJest) { | ||
| const jestArgs = ['jest']; | ||
|
|
||
| if (options.watch) { | ||
| jestArgs.push('--watch'); | ||
| } | ||
|
|
||
| if (options.coverage) { | ||
| jestArgs.push('--coverage'); | ||
| } | ||
|
|
||
| console.log(chalk.cyan(`Running: ${jestArgs.join(' ')}\n`)); | ||
|
|
||
| const jestProcess = spawn('npx', jestArgs, { | ||
| cwd: rootDir, | ||
| stdio: 'inherit', | ||
| shell: true | ||
| }); | ||
|
|
||
| jestProcess.on('exit', (code) => { | ||
| if (code !== 0) { | ||
| console.error(chalk.red(`\n❌ Tests failed with exit code ${code}`)); | ||
| process.exit(code || 1); | ||
| } else { | ||
| console.log(chalk.green('\n✅ All tests passed!')); | ||
| } | ||
| }); | ||
|
|
||
| // Fall back to package.json test script | ||
| if (packageJson.scripts?.test) { | ||
| console.log(chalk.cyan(`Running: npm test\n`)); | ||
|
|
||
| const npmProcess = spawn('npm', ['test'], { | ||
| cwd: rootDir, | ||
| stdio: 'inherit', | ||
| shell: true | ||
| }); | ||
|
|
||
| npmProcess.on('exit', (code) => { | ||
| if (code !== 0) { | ||
| console.error(chalk.red(`\n❌ Tests failed with exit code ${code}`)); | ||
| process.exit(code || 1); | ||
| } else { | ||
| console.log(chalk.green('\n✅ All tests passed!')); | ||
| } | ||
| }); | ||
|
|
||
| return; | ||
| return; |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the user specifies a runner via the --runner flag but that runner is not installed in package.json, the command silently falls through to the "no test configuration" message. This is confusing - if a user explicitly requests jest or vitest, they should get a clear error message that the specified runner is not installed.
Add validation to check if the explicitly requested runner is installed, and provide a specific error message if it's not found.
| describe('doctor command', () => { | ||
| it('should check Node.js version', async () => { | ||
| const consoleSpy = jest.spyOn(console, 'log'); | ||
|
|
||
| const { doctorCommand } = require('../src/commands/doctor'); | ||
| await doctorCommand(); | ||
|
|
||
| // Should print Node.js version | ||
| expect(consoleSpy).toHaveBeenCalledWith( | ||
| expect.stringContaining('Node.js:') | ||
| ); | ||
|
|
||
| consoleSpy.mockRestore(); | ||
| }); | ||
|
|
||
| it('should check for package.json', async () => { | ||
| const originalCwd = process.cwd(); | ||
|
|
||
| try { | ||
| // Change to test directory | ||
| process.chdir(testDir); | ||
|
|
||
| // Create a minimal package.json | ||
| const packageJson = { | ||
| name: 'test-project', | ||
| version: '1.0.0', | ||
| dependencies: { | ||
| '@objectql/core': '^4.0.0' | ||
| } | ||
| }; | ||
|
|
||
| fs.writeFileSync( | ||
| path.join(testDir, 'package.json'), | ||
| JSON.stringify(packageJson, null, 2), | ||
| 'utf-8' | ||
| ); | ||
|
|
||
| const consoleSpy = jest.spyOn(console, 'log'); | ||
|
|
||
| const { doctorCommand } = require('../src/commands/doctor'); | ||
| await doctorCommand(); | ||
|
|
||
| // Should detect package.json | ||
| expect(consoleSpy).toHaveBeenCalledWith( | ||
| expect.stringContaining('package.json') | ||
| ); | ||
|
|
||
| consoleSpy.mockRestore(); | ||
| } finally { | ||
| process.chdir(originalCwd); | ||
| } | ||
| }); | ||
| }); |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test mocks console.log to verify output but doesn't check for specific error paths or edge cases. For example, it doesn't test what happens when package.json contains invalid JSON for dependencies, when TypeScript version extraction fails, or when Node version is below v16.
Add tests for error paths and edge cases to ensure robust error handling in the doctor command, such as when version parsing fails or dependencies are malformed.
CLI test runner only supported Jest. Doctor command lacked dependency checks. RLS query trimmer couldn't handle formula-based or lookup chain conditions.
CLI Enhancements
Test Command (
packages/tools/cli/src/commands/test.ts)--runner,--watch,--coverageflagsnpm testwhen no runner detectedDoctor Command (
packages/tools/cli/src/commands/doctor.ts)RLS Query Trimmer
Formula Conditions (
packages/foundation/plugin-security/src/query-trimmer.ts)Converts JavaScript-style expressions to database filters:
Supports:
==,!=,>,>=,<,<=&&,||(with proper precedence)owner(exists),!archived(null)$current_user.*Lookup Conditions
Converts lookup chains to
$lookupsubquery filters:Tests
Original prompt
💡 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.