mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 06:58:47 -05:00
Compare commits
5 Commits
claude/fix
...
claude/bro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
645623a6c9 | ||
|
|
1442747896 | ||
|
|
3e2303e5dc | ||
|
|
332db28e2e | ||
|
|
a8a2d23e63 |
25
packages/api/index.web.ts
Normal file
25
packages/api/index.web.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { init as initLootCore } from '@actual-app/core/server/main';
|
||||||
|
import type { InitConfig, lib } from '@actual-app/core/server/main';
|
||||||
|
|
||||||
|
export * from './methods';
|
||||||
|
export * as utils from './utils';
|
||||||
|
|
||||||
|
let internal: typeof lib | null = null;
|
||||||
|
|
||||||
|
export async function init(config: InitConfig = {}) {
|
||||||
|
internal = await initLootCore(config);
|
||||||
|
return internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdown() {
|
||||||
|
if (internal) {
|
||||||
|
try {
|
||||||
|
await internal.send('sync');
|
||||||
|
} catch {
|
||||||
|
// most likely that no budget is loaded, so the sync failed
|
||||||
|
}
|
||||||
|
|
||||||
|
await internal.send('close-budget');
|
||||||
|
internal = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,23 +8,39 @@
|
|||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
"module": "dist/browser.js",
|
||||||
"types": "@types/index.d.ts",
|
"types": "@types/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"development": "./index.ts",
|
"development": "./index.ts",
|
||||||
"default": "./dist/index.js"
|
"browser": {
|
||||||
}
|
"types": "./@types/index.d.ts",
|
||||||
},
|
"default": "./dist/browser.js"
|
||||||
"publishConfig": {
|
},
|
||||||
"exports": {
|
"default": {
|
||||||
".": {
|
|
||||||
"types": "./@types/index.d.ts",
|
"types": "./@types/index.d.ts",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"browser": {
|
||||||
|
"types": "./@types/index.d.ts",
|
||||||
|
"default": "./dist/browser.js"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"types": "./@types/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "yarn build:node && yarn build:browser",
|
||||||
|
"build:node": "vite build",
|
||||||
|
"build:browser": "vite build --config vite.browser.config.ts",
|
||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
"typecheck": "tsgo -b && tsc-strict"
|
"typecheck": "tsgo -b && tsc-strict"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,5 +18,12 @@
|
|||||||
},
|
},
|
||||||
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
|
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
|
||||||
"include": ["."],
|
"include": ["."],
|
||||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts", "*.config.ts"]
|
"exclude": [
|
||||||
|
"**/node_modules/*",
|
||||||
|
"dist",
|
||||||
|
"@types",
|
||||||
|
"*.test.ts",
|
||||||
|
"*.config.ts",
|
||||||
|
"*.browser.config.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
26
packages/api/vite.browser.config.ts
Normal file
26
packages/api/vite.browser.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||||
|
|
||||||
|
const distDir = path.resolve(__dirname, 'dist');
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
target: 'esnext',
|
||||||
|
outDir: distDir,
|
||||||
|
emptyOutDir: false,
|
||||||
|
sourcemap: true,
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, 'index.web.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'browser.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [peggyLoader()],
|
||||||
|
resolve: {
|
||||||
|
// Default extensions — picks up browser implementations (index.ts)
|
||||||
|
// instead of .api.ts (which resolves to Node.js/Electron code)
|
||||||
|
extensions: ['.js', '.ts', '.tsx', '.json'],
|
||||||
|
},
|
||||||
|
});
|
||||||
346
packages/cli/src/commands/query.test.ts
Normal file
346
packages/cli/src/commands/query.test.ts
Normal file
@@ -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 <format>');
|
||||||
|
program.option('--server-url <url>');
|
||||||
|
program.option('--password <pw>');
|
||||||
|
program.option('--session-token <token>');
|
||||||
|
program.option('--sync-id <id>');
|
||||||
|
program.option('--data-dir <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<typeof vi.spyOn>;
|
||||||
|
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,115 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
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<string | Record<string, string>> {
|
||||||
|
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<string, { type: string; ref?: string }>
|
||||||
|
> = {
|
||||||
|
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(
|
function buildQueryFromFile(
|
||||||
parsed: Record<string, unknown>,
|
parsed: Record<string, unknown>,
|
||||||
fallbackTable: string | undefined,
|
fallbackTable: string | undefined,
|
||||||
@@ -27,34 +136,125 @@ function buildQueryFromFile(
|
|||||||
queryObj = queryObj.orderBy(parsed.orderBy);
|
queryObj = queryObj.orderBy(parsed.orderBy);
|
||||||
}
|
}
|
||||||
if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit);
|
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;
|
return queryObj;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
|
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
|
||||||
if (!cmdOpts.table) {
|
const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined;
|
||||||
throw new Error('--table is required (or use --file)');
|
|
||||||
}
|
|
||||||
let queryObj = api.q(cmdOpts.table);
|
|
||||||
|
|
||||||
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(','));
|
queryObj = queryObj.select(cmdOpts.select.split(','));
|
||||||
|
} else if (last !== undefined) {
|
||||||
|
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdOpts.filter) {
|
const filterStr = cmdOpts.filter ?? cmdOpts.where;
|
||||||
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
|
if (filterStr) {
|
||||||
|
queryObj = queryObj.filter(JSON.parse(filterStr));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdOpts.orderBy) {
|
const orderByStr =
|
||||||
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
|
cmdOpts.orderBy ??
|
||||||
|
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
|
||||||
|
if (orderByStr) {
|
||||||
|
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdOpts.limit) {
|
const limitVal =
|
||||||
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit'));
|
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;
|
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 <table>" 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) {
|
export function registerQueryCommand(program: Command) {
|
||||||
const query = program
|
const query = program
|
||||||
.command('query')
|
.command('query')
|
||||||
@@ -65,16 +265,34 @@ export function registerQueryCommand(program: Command) {
|
|||||||
.description('Execute an AQL query')
|
.description('Execute an AQL query')
|
||||||
.option(
|
.option(
|
||||||
'--table <table>',
|
'--table <table>',
|
||||||
'Table to query (transactions, accounts, categories, payees)',
|
'Table to query (use "actual query tables" to list available tables)',
|
||||||
)
|
)
|
||||||
.option('--select <fields>', 'Comma-separated fields to select')
|
.option('--select <fields>', 'Comma-separated fields to select')
|
||||||
.option('--filter <json>', 'Filter expression as JSON')
|
.option('--filter <json>', 'Filter as JSON (e.g. \'{"amount":{"$lt":0}}\')')
|
||||||
.option('--order-by <fields>', 'Comma-separated fields to order by')
|
.option(
|
||||||
|
'--where <json>',
|
||||||
|
'Alias for --filter (cannot be used together with --filter)',
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--order-by <fields>',
|
||||||
|
'Fields with optional direction: field1:desc,field2 (default: asc)',
|
||||||
|
)
|
||||||
.option('--limit <n>', 'Limit number of results')
|
.option('--limit <n>', 'Limit number of results')
|
||||||
|
.option('--offset <n>', 'Skip first N results (for pagination)')
|
||||||
|
.option(
|
||||||
|
'--last <n>',
|
||||||
|
'Show last N transactions (implies --table transactions, --order-by date:desc)',
|
||||||
|
)
|
||||||
|
.option('--count', 'Count matching rows instead of returning them')
|
||||||
|
.option(
|
||||||
|
'--group-by <fields>',
|
||||||
|
'Comma-separated fields to group by (use with aggregate selects)',
|
||||||
|
)
|
||||||
.option(
|
.option(
|
||||||
'--file <path>',
|
'--file <path>',
|
||||||
'Read full query object from JSON file (use - for stdin)',
|
'Read full query object from JSON file (use - for stdin)',
|
||||||
)
|
)
|
||||||
|
.addHelpText('after', RUN_EXAMPLES)
|
||||||
.action(async cmdOpts => {
|
.action(async cmdOpts => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
await withConnection(opts, async () => {
|
await withConnection(opts, async () => {
|
||||||
@@ -86,8 +304,41 @@ export function registerQueryCommand(program: Command) {
|
|||||||
? buildQueryFromFile(parsed, cmdOpts.table)
|
? buildQueryFromFile(parsed, cmdOpts.table)
|
||||||
: buildQueryFromFlags(cmdOpts);
|
: buildQueryFromFlags(cmdOpts);
|
||||||
|
|
||||||
const result = await api.aqlQuery(queryObj);
|
const result = (await api.aqlQuery(queryObj)) as { data: unknown };
|
||||||
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 <table>')
|
||||||
|
.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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -274,16 +274,80 @@ actual schedules delete <id>
|
|||||||
|
|
||||||
### Query (ActualQL)
|
### 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 <table>` | List fields and types for a table |
|
||||||
|
|
||||||
|
#### `query run` Options
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| `--table <table>` | Table to query (use `actual query tables` to list) |
|
||||||
|
| `--select <fields>` | Comma-separated fields to select |
|
||||||
|
| `--filter <json>` | Filter as JSON (e.g. `'{"amount":{"$lt":0}}'`) |
|
||||||
|
| `--where <json>` | Alias for `--filter` (cannot be used together) |
|
||||||
|
| `--order-by <fields>` | Fields with optional direction: `field1:desc,field2` (default: asc) |
|
||||||
|
| `--limit <n>` | Limit number of results |
|
||||||
|
| `--offset <n>` | Skip first N results (for pagination) |
|
||||||
|
| `--last <n>` | Show last N transactions (shortcut: implies `--table transactions`, `--order-by date:desc`) |
|
||||||
|
| `--count` | Count matching rows instead of returning them |
|
||||||
|
| `--group-by <fields>` | Comma-separated fields to group by |
|
||||||
|
| `--file <path>` | Read query from JSON file (use `-` for stdin) |
|
||||||
|
|
||||||
|
#### Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run a query (inline)
|
# Show last 5 transactions (convenience shortcut)
|
||||||
actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
|
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
|
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
|
### Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
6
upcoming-release-notes/7240.md
Normal file
6
upcoming-release-notes/7240.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Enhancements
|
||||||
|
authors: [MatissJanis]
|
||||||
|
---
|
||||||
|
|
||||||
|
cli: improved aql support
|
||||||
Reference in New Issue
Block a user