diff --git a/packages/cli/src/commands/query.test.ts b/packages/cli/src/commands/query.test.ts new file mode 100644 index 0000000000..71e3de03c6 --- /dev/null +++ b/packages/cli/src/commands/query.test.ts @@ -0,0 +1,346 @@ +import * as api from '@actual-app/api'; +import { Command } from 'commander'; + +import { printOutput } from '../output'; + +import { parseOrderBy, registerQueryCommand } from './query'; + +vi.mock('@actual-app/api', () => { + const queryObj = { + select: vi.fn().mockReturnThis(), + filter: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + offset: vi.fn().mockReturnThis(), + groupBy: vi.fn().mockReturnThis(), + calculate: vi.fn().mockReturnThis(), + }; + return { + q: vi.fn().mockReturnValue(queryObj), + aqlQuery: vi.fn().mockResolvedValue({ data: [] }), + }; +}); + +vi.mock('../connection', () => ({ + withConnection: vi.fn((_opts, fn) => fn()), +})); + +vi.mock('../output', () => ({ + printOutput: vi.fn(), +})); + +function createProgram(): Command { + const program = new Command(); + program.option('--format '); + program.option('--server-url '); + program.option('--password '); + program.option('--session-token '); + program.option('--sync-id '); + program.option('--data-dir '); + program.option('--verbose'); + program.exitOverride(); + registerQueryCommand(program); + return program; +} + +async function run(args: string[]) { + const program = createProgram(); + await program.parseAsync(['node', 'test', ...args]); +} + +function getQueryObj() { + return vi.mocked(api.q).mock.results[0]?.value; +} + +describe('parseOrderBy', () => { + it('parses plain field names', () => { + expect(parseOrderBy('date')).toEqual(['date']); + }); + + it('parses field:desc', () => { + expect(parseOrderBy('date:desc')).toEqual([{ date: 'desc' }]); + }); + + it('parses field:asc', () => { + expect(parseOrderBy('amount:asc')).toEqual([{ amount: 'asc' }]); + }); + + it('parses multiple mixed fields', () => { + expect(parseOrderBy('date:desc,amount:asc,id')).toEqual([ + { date: 'desc' }, + { amount: 'asc' }, + 'id', + ]); + }); + + it('throws on invalid direction', () => { + expect(() => parseOrderBy('date:backwards')).toThrow( + 'Invalid order direction "backwards"', + ); + }); + + it('throws on empty field', () => { + expect(() => parseOrderBy('date,,amount')).toThrow('empty field'); + }); +}); + +describe('query commands', () => { + let stderrSpy: ReturnType; + let stdoutSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + stdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); + }); + + describe('run', () => { + it('builds a basic query from flags', async () => { + await run([ + 'query', + 'run', + '--table', + 'transactions', + '--select', + 'date,amount', + '--limit', + '5', + ]); + + expect(api.q).toHaveBeenCalledWith('transactions'); + const qObj = getQueryObj(); + expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']); + expect(qObj.limit).toHaveBeenCalledWith(5); + }); + + it('rejects unknown table name', async () => { + await expect( + run(['query', 'run', '--table', 'nonexistent']), + ).rejects.toThrow('Unknown table "nonexistent"'); + }); + + it('parses order-by with desc direction', async () => { + await run([ + 'query', + 'run', + '--table', + 'transactions', + '--order-by', + 'date:desc,amount:asc', + ]); + + const qObj = getQueryObj(); + expect(qObj.orderBy).toHaveBeenCalledWith([ + { date: 'desc' }, + { amount: 'asc' }, + ]); + }); + + it('passes --filter as JSON', async () => { + await run([ + 'query', + 'run', + '--table', + 'transactions', + '--filter', + '{"amount":{"$lt":0}}', + ]); + + const qObj = getQueryObj(); + expect(qObj.filter).toHaveBeenCalledWith({ amount: { $lt: 0 } }); + }); + }); + + describe('--last flag', () => { + it('sets default table, select, orderBy, and limit', async () => { + await run(['query', 'run', '--last', '10']); + + expect(api.q).toHaveBeenCalledWith('transactions'); + const qObj = getQueryObj(); + expect(qObj.select).toHaveBeenCalledWith([ + 'date', + 'account.name', + 'payee.name', + 'category.name', + 'amount', + 'notes', + ]); + expect(qObj.orderBy).toHaveBeenCalledWith([{ date: 'desc' }]); + expect(qObj.limit).toHaveBeenCalledWith(10); + }); + + it('allows explicit --select override', async () => { + await run(['query', 'run', '--last', '5', '--select', 'date,amount']); + + const qObj = getQueryObj(); + expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']); + }); + + it('allows explicit --order-by override', async () => { + await run(['query', 'run', '--last', '5', '--order-by', 'amount:asc']); + + const qObj = getQueryObj(); + expect(qObj.orderBy).toHaveBeenCalledWith([{ amount: 'asc' }]); + }); + + it('allows --table transactions explicitly', async () => { + await run(['query', 'run', '--last', '5', '--table', 'transactions']); + + expect(api.q).toHaveBeenCalledWith('transactions'); + }); + + it('errors if --table is not transactions', async () => { + await expect( + run(['query', 'run', '--last', '5', '--table', 'accounts']), + ).rejects.toThrow('--last implies --table transactions'); + }); + + it('errors if --limit is also set', async () => { + await expect( + run(['query', 'run', '--last', '5', '--limit', '10']), + ).rejects.toThrow('--last and --limit are mutually exclusive'); + }); + }); + + describe('--count flag', () => { + it('uses calculate with $count', async () => { + vi.mocked(api.aqlQuery).mockResolvedValueOnce({ data: 42 }); + + await run(['query', 'run', '--table', 'transactions', '--count']); + + const qObj = getQueryObj(); + expect(qObj.calculate).toHaveBeenCalledWith({ $count: '*' }); + expect(printOutput).toHaveBeenCalledWith({ count: 42 }, undefined); + }); + + it('errors if --select is also set', async () => { + await expect( + run([ + 'query', + 'run', + '--table', + 'transactions', + '--count', + '--select', + 'date', + ]), + ).rejects.toThrow('--count and --select are mutually exclusive'); + }); + }); + + describe('--where alias', () => { + it('works the same as --filter', async () => { + await run([ + 'query', + 'run', + '--table', + 'transactions', + '--where', + '{"amount":{"$gt":0}}', + ]); + + const qObj = getQueryObj(); + expect(qObj.filter).toHaveBeenCalledWith({ amount: { $gt: 0 } }); + }); + + it('errors if both --where and --filter are provided', async () => { + await expect( + run([ + 'query', + 'run', + '--table', + 'transactions', + '--where', + '{}', + '--filter', + '{}', + ]), + ).rejects.toThrow('--where and --filter are mutually exclusive'); + }); + }); + + describe('--offset flag', () => { + it('passes offset through to query', async () => { + await run([ + 'query', + 'run', + '--table', + 'transactions', + '--offset', + '20', + '--limit', + '10', + ]); + + const qObj = getQueryObj(); + expect(qObj.offset).toHaveBeenCalledWith(20); + expect(qObj.limit).toHaveBeenCalledWith(10); + }); + }); + + describe('--group-by flag', () => { + it('passes group-by through to query', async () => { + await run([ + 'query', + 'run', + '--table', + 'transactions', + '--group-by', + 'category.name', + '--select', + 'category.name,amount', + ]); + + const qObj = getQueryObj(); + expect(qObj.groupBy).toHaveBeenCalledWith(['category.name']); + }); + }); + + describe('tables subcommand', () => { + it('lists available tables', async () => { + await run(['query', 'tables']); + + expect(printOutput).toHaveBeenCalledWith( + expect.arrayContaining([ + { name: 'transactions' }, + { name: 'accounts' }, + { name: 'categories' }, + { name: 'payees' }, + ]), + undefined, + ); + }); + }); + + describe('fields subcommand', () => { + it('lists fields for a known table', async () => { + await run(['query', 'fields', 'accounts']); + + const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{ + name: string; + type: string; + }>; + expect(output).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'id', type: 'id' }), + expect.objectContaining({ name: 'name', type: 'string' }), + ]), + ); + }); + + it('errors on unknown table', async () => { + await expect(run(['query', 'fields', 'unknown'])).rejects.toThrow( + 'Unknown table "unknown"', + ); + }); + }); +}); diff --git a/packages/cli/src/commands/query.ts b/packages/cli/src/commands/query.ts index eaca33729e..45626f5db4 100644 --- a/packages/cli/src/commands/query.ts +++ b/packages/cli/src/commands/query.ts @@ -10,6 +10,115 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Parse order-by strings like "date:desc,amount:asc,id" into + * AQL orderBy format: [{ date: 'desc' }, { amount: 'asc' }, 'id'] + */ +export function parseOrderBy( + input: string, +): Array> { + return input.split(',').map(part => { + const trimmed = part.trim(); + if (!trimmed) { + throw new Error('--order-by contains an empty field'); + } + const colonIndex = trimmed.indexOf(':'); + if (colonIndex === -1) { + return trimmed; + } + const field = trimmed.slice(0, colonIndex).trim(); + if (!field) { + throw new Error( + `Invalid order field in "${trimmed}". Field name cannot be empty.`, + ); + } + const direction = trimmed.slice(colonIndex + 1); + if (direction !== 'asc' && direction !== 'desc') { + throw new Error( + `Invalid order direction "${direction}" for field "${field}". Expected "asc" or "desc".`, + ); + } + return { [field]: direction }; + }); +} + +// TODO: Import schema from API once it exposes table/field metadata +const TABLE_SCHEMA: Record< + string, + Record +> = { + transactions: { + id: { type: 'id' }, + account: { type: 'id', ref: 'accounts' }, + date: { type: 'date' }, + amount: { type: 'integer' }, + payee: { type: 'id', ref: 'payees' }, + category: { type: 'id', ref: 'categories' }, + notes: { type: 'string' }, + imported_id: { type: 'string' }, + transfer_id: { type: 'id' }, + cleared: { type: 'boolean' }, + reconciled: { type: 'boolean' }, + starting_balance_flag: { type: 'boolean' }, + imported_payee: { type: 'string' }, + is_parent: { type: 'boolean' }, + is_child: { type: 'boolean' }, + parent_id: { type: 'id' }, + sort_order: { type: 'float' }, + schedule: { type: 'id', ref: 'schedules' }, + 'account.name': { type: 'string', ref: 'accounts' }, + 'payee.name': { type: 'string', ref: 'payees' }, + 'category.name': { type: 'string', ref: 'categories' }, + 'category.group.name': { type: 'string', ref: 'category_groups' }, + }, + accounts: { + id: { type: 'id' }, + name: { type: 'string' }, + offbudget: { type: 'boolean' }, + closed: { type: 'boolean' }, + sort_order: { type: 'float' }, + }, + categories: { + id: { type: 'id' }, + name: { type: 'string' }, + is_income: { type: 'boolean' }, + group_id: { type: 'id', ref: 'category_groups' }, + sort_order: { type: 'float' }, + hidden: { type: 'boolean' }, + 'group.name': { type: 'string', ref: 'category_groups' }, + }, + payees: { + id: { type: 'id' }, + name: { type: 'string' }, + transfer_acct: { type: 'id', ref: 'accounts' }, + }, + rules: { + id: { type: 'id' }, + stage: { type: 'string' }, + conditions_op: { type: 'string' }, + conditions: { type: 'json' }, + actions: { type: 'json' }, + }, + schedules: { + id: { type: 'id' }, + name: { type: 'string' }, + rule: { type: 'id', ref: 'rules' }, + next_date: { type: 'date' }, + completed: { type: 'boolean' }, + }, +}; + +const AVAILABLE_TABLES = Object.keys(TABLE_SCHEMA).join(', '); + +const LAST_DEFAULT_SELECT = [ + 'date', + 'account.name', + 'payee.name', + 'category.name', + 'amount', + 'notes', +]; + function buildQueryFromFile( parsed: Record, fallbackTable: string | undefined, @@ -27,34 +136,125 @@ function buildQueryFromFile( queryObj = queryObj.orderBy(parsed.orderBy); } if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit); + if (typeof parsed.offset === 'number') { + queryObj = queryObj.offset(parsed.offset); + } + if (Array.isArray(parsed.groupBy)) { + queryObj = queryObj.groupBy(parsed.groupBy); + } return queryObj; } function buildQueryFromFlags(cmdOpts: Record) { - if (!cmdOpts.table) { - throw new Error('--table is required (or use --file)'); - } - let queryObj = api.q(cmdOpts.table); + const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined; - if (cmdOpts.select) { + if (last !== undefined) { + if (cmdOpts.table && cmdOpts.table !== 'transactions') { + throw new Error( + '--last implies --table transactions. Cannot use with --table ' + + cmdOpts.table, + ); + } + if (cmdOpts.limit) { + throw new Error('--last and --limit are mutually exclusive'); + } + } + + const table = + cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined); + if (!table) { + throw new Error('--table is required (or use --file or --last)'); + } + + if (!(table in TABLE_SCHEMA)) { + throw new Error( + `Unknown table "${table}". Available tables: ${AVAILABLE_TABLES}`, + ); + } + + if (cmdOpts.where && cmdOpts.filter) { + throw new Error('--where and --filter are mutually exclusive'); + } + + if (cmdOpts.count && cmdOpts.select) { + throw new Error('--count and --select are mutually exclusive'); + } + + let queryObj = api.q(table); + + if (cmdOpts.count) { + queryObj = queryObj.calculate({ $count: '*' }); + } else if (cmdOpts.select) { queryObj = queryObj.select(cmdOpts.select.split(',')); + } else if (last !== undefined) { + queryObj = queryObj.select(LAST_DEFAULT_SELECT); } - if (cmdOpts.filter) { - queryObj = queryObj.filter(JSON.parse(cmdOpts.filter)); + const filterStr = cmdOpts.filter ?? cmdOpts.where; + if (filterStr) { + queryObj = queryObj.filter(JSON.parse(filterStr)); } - if (cmdOpts.orderBy) { - queryObj = queryObj.orderBy(cmdOpts.orderBy.split(',')); + const orderByStr = + cmdOpts.orderBy ?? + (last !== undefined && !cmdOpts.count ? 'date:desc' : undefined); + if (orderByStr) { + queryObj = queryObj.orderBy(parseOrderBy(orderByStr)); } - if (cmdOpts.limit) { - queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit')); + const limitVal = + last ?? + (cmdOpts.limit ? parseIntFlag(cmdOpts.limit, '--limit') : undefined); + if (limitVal !== undefined) { + queryObj = queryObj.limit(limitVal); + } + + if (cmdOpts.offset) { + queryObj = queryObj.offset(parseIntFlag(cmdOpts.offset, '--offset')); + } + + if (cmdOpts.groupBy) { + queryObj = queryObj.groupBy(cmdOpts.groupBy.split(',')); } return queryObj; } +const RUN_EXAMPLES = ` +Examples: + # Show last 5 transactions (shortcut) + actual query run --last 5 + + # Transactions ordered by date descending + actual query run --table transactions --select "date,amount,payee.name" --order-by "date:desc" --limit 10 + + # Filter with JSON (negative amounts = expenses) + actual query run --table transactions --filter '{"amount":{"$lt":0}}' --limit 5 + + # Count transactions + actual query run --table transactions --count + + # Group by category (use --file for aggregate expressions) + echo '{"table":"transactions","groupBy":["category.name"],"select":["category.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file - + + # Pagination + actual query run --table transactions --order-by "date:desc" --limit 10 --offset 20 + + # Use --where (alias for --filter) + actual query run --table transactions --where '{"payee.name":"Grocery Store"}' --limit 5 + + # Read query from a JSON file + actual query run --file query.json + + # Pipe query from stdin + echo '{"table":"transactions","limit":5}' | actual query run --file - + +Available tables: ${AVAILABLE_TABLES} +Use "actual query tables" and "actual query fields " for schema info. + +Common filter operators: $eq, $ne, $lt, $lte, $gt, $gte, $like, $and, $or +See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/`; + export function registerQueryCommand(program: Command) { const query = program .command('query') @@ -65,16 +265,34 @@ export function registerQueryCommand(program: Command) { .description('Execute an AQL query') .option( '--table
', - 'Table to query (transactions, accounts, categories, payees)', + 'Table to query (use "actual query tables" to list available tables)', ) .option('--select ', 'Comma-separated fields to select') - .option('--filter ', 'Filter expression as JSON') - .option('--order-by ', 'Comma-separated fields to order by') + .option('--filter ', 'Filter as JSON (e.g. \'{"amount":{"$lt":0}}\')') + .option( + '--where ', + 'Alias for --filter (cannot be used together with --filter)', + ) + .option( + '--order-by ', + 'Fields with optional direction: field1:desc,field2 (default: asc)', + ) .option('--limit ', 'Limit number of results') + .option('--offset ', 'Skip first N results (for pagination)') + .option( + '--last ', + 'Show last N transactions (implies --table transactions, --order-by date:desc)', + ) + .option('--count', 'Count matching rows instead of returning them') + .option( + '--group-by ', + 'Comma-separated fields to group by (use with aggregate selects)', + ) .option( '--file ', 'Read full query object from JSON file (use - for stdin)', ) + .addHelpText('after', RUN_EXAMPLES) .action(async cmdOpts => { const opts = program.opts(); await withConnection(opts, async () => { @@ -87,7 +305,40 @@ export function registerQueryCommand(program: Command) { : buildQueryFromFlags(cmdOpts); const result = await api.aqlQuery(queryObj); - printOutput(result, opts.format); + + if (cmdOpts.count) { + printOutput({ count: result.data }, opts.format); + } else { + printOutput(result, opts.format); + } }); }); + + query + .command('tables') + .description('List available tables for querying') + .action(() => { + const opts = program.opts(); + const tables = Object.keys(TABLE_SCHEMA).map(name => ({ name })); + printOutput(tables, opts.format); + }); + + query + .command('fields
') + .description('List fields for a given table') + .action((table: string) => { + const opts = program.opts(); + const schema = TABLE_SCHEMA[table]; + if (!schema) { + throw new Error( + `Unknown table "${table}". Available tables: ${Object.keys(TABLE_SCHEMA).join(', ')}`, + ); + } + const fields = Object.entries(schema).map(([name, info]) => ({ + name, + type: info.type, + ...(info.ref ? { ref: info.ref } : {}), + })); + printOutput(fields, opts.format); + }); } diff --git a/packages/docs/docs/api/actual-ql/examples.md b/packages/docs/docs/api/actual-ql/examples.md index 39d5910c83..f2fdaefe83 100644 --- a/packages/docs/docs/api/actual-ql/examples.md +++ b/packages/docs/docs/api/actual-ql/examples.md @@ -101,3 +101,30 @@ or ); }); ``` + +## CLI Usage + +The examples above are shown in JavaScript. If you're using the [CLI tool](../cli.md), you can express many of the same queries with command-line flags. Here's how the JS patterns translate: + +```bash +# Select specific fields (JS: .select(['date', 'amount', 'payee.name'])) +actual query run --table transactions --select "date,amount,payee.name" + +# Filter by condition (JS: .filter({ amount: { $lt: 0 } })) +actual query run --table transactions --filter '{"amount":{"$lt":0}}' + +# Order by field descending (JS: .orderBy([{ date: 'desc' }])) +actual query run --table transactions --order-by "date:desc" + +# Search by month (JS: .filter({ date: { $transform: '$month', $eq: '2021-01' } })) +actual query run --table transactions --filter '{"date":{"$transform":"$month","$eq":"2021-01"}}' + +# Group by payee with sum — use --file for aggregate queries +echo '{"table":"transactions","groupBy":["payee.name"],"select":["payee.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file - + +# Count transactions (JS: .calculate({ $count: '*' })) +actual query run --table transactions --count + +# Quick shortcut: last 10 transactions +actual query run --last 10 +``` diff --git a/packages/docs/docs/api/cli.md b/packages/docs/docs/api/cli.md index aeee9c82e8..24cbc55712 100644 --- a/packages/docs/docs/api/cli.md +++ b/packages/docs/docs/api/cli.md @@ -274,16 +274,80 @@ actual schedules delete ### Query (ActualQL) -Run queries using [ActualQL](./actual-ql/index.md): +Run queries using [ActualQL](./actual-ql/index.md). + +#### Subcommands + +| Subcommand | Description | +| ---------------------- | --------------------------------- | +| `query run` | Execute an AQL query | +| `query tables` | List available tables | +| `query fields
` | List fields and types for a table | + +#### `query run` Options + +| Option | Description | +| --------------------- | ------------------------------------------------------------------------------------------- | +| `--table
` | Table to query (use `actual query tables` to list) | +| `--select ` | Comma-separated fields to select | +| `--filter ` | Filter as JSON (e.g. `'{"amount":{"$lt":0}}'`) | +| `--where ` | Alias for `--filter` (cannot be used together) | +| `--order-by ` | Fields with optional direction: `field1:desc,field2` (default: asc) | +| `--limit ` | Limit number of results | +| `--offset ` | Skip first N results (for pagination) | +| `--last ` | Show last N transactions (shortcut: implies `--table transactions`, `--order-by date:desc`) | +| `--count` | Count matching rows instead of returning them | +| `--group-by ` | Comma-separated fields to group by | +| `--file ` | Read query from JSON file (use `-` for stdin) | + +#### Examples ```bash -# Run a query (inline) -actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10 +# Show last 5 transactions (convenience shortcut) +actual query run --last 5 -# Run a query (from file) +# Override default columns with --last +actual query run --last 10 --select "date,amount,notes" + +# Transactions ordered by date descending with limit +actual query run --table transactions --select "date,amount,payee.name" --order-by "date:desc" --limit 10 + +# Filter with JSON — negative amounts (expenses) +actual query run --table transactions --filter '{"amount":{"$lt":0}}' --limit 5 + +# Use --where (alias for --filter, more intuitive for SQL users) +actual query run --table transactions --where '{"payee.name":"Grocery Store"}' --limit 5 + +# Count all transactions +actual query run --table transactions --count + +# Count with a filter +actual query run --table transactions --filter '{"category.name":"Groceries"}' --count + +# Group by category with aggregate (use --file for aggregate expressions) +echo '{"table":"transactions","groupBy":["category.name"],"select":["category.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file - + +# Pagination: skip first 20, show next 10 +actual query run --table transactions --order-by "date:desc" --limit 10 --offset 20 + +# Multi-field ordering +actual query run --table transactions --order-by "date:desc,amount:asc" --limit 10 + +# Run a query from a JSON file actual query run --file query.json + +# Pipe query from stdin +echo '{"table":"transactions","select":["date","amount"],"limit":5}' | actual query run --file - + +# List available tables +actual query tables + +# List fields for a table +actual query fields transactions ``` +See [ActualQL](./actual-ql/index.md) for full filter/function reference including `$transform`, `$month`, `$year`, and aggregate functions. + ### Server ```bash diff --git a/upcoming-release-notes/7240.md b/upcoming-release-notes/7240.md new file mode 100644 index 0000000000..93d8c85fc8 --- /dev/null +++ b/upcoming-release-notes/7240.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [MatissJanis] +--- + +cli: improved aql support