Compare commits

..

2 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
23adf06cb0 Add post-merge hook to auto-install dependencies (#7248)
* [AI] Add post-merge hook to auto-install when yarn.lock changes

Mirrors the existing post-checkout hook behavior: after a git pull or
merge, if yarn.lock changed between ORIG_HEAD and HEAD, automatically
runs yarn install to keep dependencies in sync.

https://claude.ai/code/session_01JHoMhGANLTc1q67s1dUHrt

* Add release notes for PR #7248

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-20 22:59:05 +00:00
Matiss Janis Aboltins
a8a2d23e63 [AI] Improve CLI query command with new flags, subcommands, and docs (#7240)
* [AI] Improve CLI query command with new flags, subcommands, and docs

Add --last, --count, --where, --offset, --group-by flags and field:desc
order-by syntax to make the query command more usable for both humans
and AI agents. Add query tables/fields subcommands for schema discovery.
Expand CLI and ActualQL documentation with comprehensive examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Fix parseOrderBy empty field validation and misleading aggregate docs

Validate that order-by field names are non-empty (e.g. ":desc" now throws
a clear error). Move AVAILABLE_TABLES before first use. Update group-by
examples in CLI help text and docs to use --file with proper aggregate
JSON instead of misleading --select with plain field names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-20 19:01:20 +00:00
9 changed files with 743 additions and 102 deletions

View File

@@ -50,8 +50,6 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
checkName: web checkName: web
ref: ${{github.base_ref}} ref: ${{github.base_ref}}
timeoutSeconds: 1200
intervalSeconds: 30
- name: Wait for ${{github.base_ref}} API build to succeed - name: Wait for ${{github.base_ref}} API build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0 uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-api-build id: master-api-build
@@ -59,8 +57,6 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
checkName: api checkName: api
ref: ${{github.base_ref}} ref: ${{github.base_ref}}
timeoutSeconds: 1200
intervalSeconds: 30
- name: Wait for ${{github.base_ref}} CLI build to succeed - name: Wait for ${{github.base_ref}} CLI build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0 uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-cli-build id: master-cli-build
@@ -68,8 +64,6 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli checkName: cli
ref: ${{github.base_ref}} ref: ${{github.base_ref}}
timeoutSeconds: 1200
intervalSeconds: 30
- name: Wait for PR build to succeed - name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0 uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
@@ -78,8 +72,6 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
checkName: web checkName: web
ref: ${{github.event.pull_request.head.sha}} ref: ${{github.event.pull_request.head.sha}}
timeoutSeconds: 1200
intervalSeconds: 30
- name: Wait for API PR build to succeed - name: Wait for API PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0 uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-api-build id: wait-for-api-build
@@ -87,8 +79,6 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
checkName: api checkName: api
ref: ${{github.event.pull_request.head.sha}} ref: ${{github.event.pull_request.head.sha}}
timeoutSeconds: 1200
intervalSeconds: 30
- name: Wait for CLI PR build to succeed - name: Wait for CLI PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0 uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-cli-build id: wait-for-cli-build
@@ -96,32 +86,12 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli checkName: cli
ref: ${{github.event.pull_request.head.sha}} ref: ${{github.event.pull_request.head.sha}}
timeoutSeconds: 1200
intervalSeconds: 30
- name: Report build failure - name: Report build failure
if: | if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure'
steps.wait-for-web-build.outputs.conclusion == 'failure' ||
steps.wait-for-api-build.outputs.conclusion == 'failure' ||
steps.wait-for-cli-build.outputs.conclusion == 'failure' ||
steps.master-web-build.outputs.conclusion == 'failure' ||
steps.master-api-build.outputs.conclusion == 'failure' ||
steps.master-cli-build.outputs.conclusion == 'failure'
run: | run: |
echo "Build failed on PR branch or ${{github.base_ref}}" echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1 exit 1
- name: Warn on incomplete builds
if: |
steps.wait-for-web-build.outputs.conclusion != 'success' ||
steps.wait-for-api-build.outputs.conclusion != 'success' ||
steps.wait-for-cli-build.outputs.conclusion != 'success' ||
steps.master-web-build.outputs.conclusion != 'success' ||
steps.master-api-build.outputs.conclusion != 'success' ||
steps.master-cli-build.outputs.conclusion != 'success'
run: |
echo "::warning::Some builds did not complete successfully. Bundle stats may be incomplete."
echo "Base branch - web: ${{ steps.master-web-build.outputs.conclusion }}, api: ${{ steps.master-api-build.outputs.conclusion }}, cli: ${{ steps.master-cli-build.outputs.conclusion }}"
echo "PR - web: ${{ steps.wait-for-web-build.outputs.conclusion }}, api: ${{ steps.wait-for-api-build.outputs.conclusion }}, cli: ${{ steps.wait-for-cli-build.outputs.conclusion }}"
- name: Download web build artifact from ${{github.base_ref}} - name: Download web build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18 uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
@@ -132,7 +102,6 @@ jobs:
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats name: build-stats
path: base path: base
if_no_artifact_found: warn
- name: Download API build artifact from ${{github.base_ref}} - name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18 uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
id: pr-api-build id: pr-api-build
@@ -142,46 +111,41 @@ jobs:
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: api-build-stats name: api-build-stats
path: base path: base
if_no_artifact_found: warn
- name: Download build stats from PR - name: Download build stats from PR
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18 uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
with: with:
commit: ${{github.event.pull_request.head.sha}} pr: ${{github.event.pull_request.number}}
workflow: build.yml workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats name: build-stats
path: head path: head
allow_forks: true allow_forks: true
if_no_artifact_found: warn
- name: Download API stats from PR - name: Download API stats from PR
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18 uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
with: with:
commit: ${{github.event.pull_request.head.sha}} pr: ${{github.event.pull_request.number}}
workflow: build.yml workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: api-build-stats name: api-build-stats
path: head path: head
allow_forks: true allow_forks: true
if_no_artifact_found: warn
- name: Download CLI build artifact from ${{github.base_ref}} - name: Download CLI build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18 uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with: with:
branch: ${{github.base_ref}} branch: ${{github.base_ref}}
workflow: build.yml workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats name: cli-build-stats
path: base path: base
if_no_artifact_found: warn
- name: Download CLI stats from PR - name: Download CLI stats from PR
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18 uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with: with:
commit: ${{github.event.pull_request.head.sha}} pr: ${{github.event.pull_request.number}}
workflow: build.yml workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats name: cli-build-stats
path: head path: head
allow_forks: true allow_forks: true
if_no_artifact_found: warn
- name: Strip content hashes from stats files - name: Strip content hashes from stats files
run: | run: |
if [ -f ./head/web-stats.json ]; then if [ -f ./head/web-stats.json ]; then
@@ -198,31 +162,19 @@ jobs:
fi fi
done done
- name: Generate combined bundle stats comment - name: Generate combined bundle stats comment
if: ${{ !cancelled() }}
id: generate-comment
run: | run: |
ARGS=""
for bundle in "desktop-client=web-stats.json" "loot-core=loot-core-stats.json" "api=api-stats.json" "cli=cli-stats.json"; do
NAME="${bundle%%=*}"
FILE="${bundle#*=}"
if [ -f "./base/$FILE" ] && [ -f "./head/$FILE" ]; then
ARGS="$ARGS --base $NAME=./base/$FILE --head $NAME=./head/$FILE"
else
echo "::warning::Skipping $NAME: base or head stats file missing"
fi
done
if [ -z "$ARGS" ]; then
echo "::warning::No stats files available, skipping comment generation"
echo "has_comment=false" >> "$GITHUB_OUTPUT"
exit 0
fi
node packages/ci-actions/bin/bundle-stats-comment.mjs \ node packages/ci-actions/bin/bundle-stats-comment.mjs \
$ARGS \ --base desktop-client=./base/web-stats.json \
--base loot-core=./base/loot-core-stats.json \
--base api=./base/api-stats.json \
--base cli=./base/cli-stats.json \
--head desktop-client=./head/web-stats.json \
--head loot-core=./head/loot-core-stats.json \
--head api=./head/api-stats.json \
--head cli=./head/cli-stats.json \
--identifier combined \ --identifier combined \
--format pr-body > bundle-stats-comment.md --format pr-body > bundle-stats-comment.md
echo "has_comment=true" >> "$GITHUB_OUTPUT"
- name: Post combined bundle stats comment - name: Post combined bundle stats comment
if: ${{ !cancelled() && steps.generate-comment.outputs.has_comment == 'true' }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}

7
.husky/post-merge Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
# Run yarn install after pulling/merging (if yarn.lock changed)
if git diff --name-only ORIG_HEAD HEAD | grep -q "^yarn.lock$"; then
echo "yarn.lock changed — running yarn install..."
yarn install
fi

View File

@@ -5,7 +5,7 @@
* Heavily inspired by https://github.com/twk3/rollup-size-compare-action (MIT). * Heavily inspired by https://github.com/twk3/rollup-size-compare-action (MIT).
*/ */
import { access, readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
@@ -179,19 +179,8 @@ function parseArgs(argv) {
} }
async function loadStats(filePath) { async function loadStats(filePath) {
try {
const absolutePath = path.resolve(process.cwd(), filePath); const absolutePath = path.resolve(process.cwd(), filePath);
// Check if the file exists before trying to read it
try {
await access(absolutePath);
} catch {
console.error(
`[bundle-stats] Stats file not found: "${filePath}" — skipping`,
);
return null;
}
try {
const fileContents = await readFile(absolutePath, 'utf8'); const fileContents = await readFile(absolutePath, 'utf8');
const parsed = JSON.parse(fileContents); const parsed = JSON.parse(fileContents);
@@ -207,7 +196,7 @@ async function loadStats(filePath) {
? error.message ? error.message
: 'Unknown error while parsing stats file'; : 'Unknown error while parsing stats file';
console.error(`[bundle-stats] Failed to parse "${filePath}": ${message}`); console.error(`[bundle-stats] Failed to parse "${filePath}": ${message}`);
return null; throw new Error(`Failed to load stats file "${filePath}": ${message}`);
} }
} }
@@ -698,13 +687,6 @@ async function main() {
); );
const headStats = await loadStats(section.headPath); const headStats = await loadStats(section.headPath);
if (!baseStats || !headStats) {
console.error(
`[bundle-stats] Skipping section "${section.name}": missing ${!baseStats ? 'base' : 'head'} stats`,
);
continue;
}
const statsDiff = getStatsDiff(baseStats, headStats); const statsDiff = getStatsDiff(baseStats, headStats);
const chunkDiff = getChunkModuleDiff(baseStats, headStats); const chunkDiff = getChunkModuleDiff(baseStats, headStats);

View 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"',
);
});
});
});

View File

@@ -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) {
queryObj = queryObj.select(cmdOpts.select.split(',')); if (cmdOpts.table && cmdOpts.table !== 'transactions') {
throw new Error(
'--last implies --table transactions. Cannot use with --table ' +
cmdOpts.table,
);
} }
if (cmdOpts.filter) {
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
}
if (cmdOpts.orderBy) {
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
}
if (cmdOpts.limit) { if (cmdOpts.limit) {
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--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);
}
const filterStr = cmdOpts.filter ?? cmdOpts.where;
if (filterStr) {
queryObj = queryObj.filter(JSON.parse(filterStr));
}
const orderByStr =
cmdOpts.orderBy ??
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
if (orderByStr) {
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
}
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; 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 () => {
@@ -87,7 +305,40 @@ export function registerQueryCommand(program: Command) {
: buildQueryFromFlags(cmdOpts); : buildQueryFromFlags(cmdOpts);
const result = await api.aqlQuery(queryObj); const result = await api.aqlQuery(queryObj);
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);
} else {
printOutput(result, opts.format); 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);
});
} }

View File

@@ -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
```

View File

@@ -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

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
cli: improved aql support

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Add post-merge hook to automatically install dependencies when yarn.lock changes after merges.