mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Compare commits
2 Commits
master
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
689b6f761f | ||
|
|
7318015a1f |
76
.github/workflows/size-compare.yml
vendored
76
.github/workflows/size-compare.yml
vendored
@@ -50,6 +50,8 @@ 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
|
||||||
@@ -57,6 +59,8 @@ 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
|
||||||
@@ -64,6 +68,8 @@ 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
|
||||||
@@ -72,6 +78,8 @@ 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
|
||||||
@@ -79,6 +87,8 @@ 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
|
||||||
@@ -86,12 +96,32 @@ 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: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure'
|
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.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
|
||||||
@@ -102,6 +132,7 @@ 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
|
||||||
@@ -111,41 +142,46 @@ 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:
|
||||||
pr: ${{github.event.pull_request.number}}
|
commit: ${{github.event.pull_request.head.sha}}
|
||||||
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:
|
||||||
pr: ${{github.event.pull_request.number}}
|
commit: ${{github.event.pull_request.head.sha}}
|
||||||
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@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||||
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@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||||
with:
|
with:
|
||||||
pr: ${{github.event.pull_request.number}}
|
commit: ${{github.event.pull_request.head.sha}}
|
||||||
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
|
||||||
@@ -162,19 +198,31 @@ 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 \
|
||||||
--base desktop-client=./base/web-stats.json \
|
$ARGS \
|
||||||
--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 }}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -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 { readFile } from 'node:fs/promises';
|
import { access, 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,8 +179,19 @@ function parseArgs(argv) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadStats(filePath) {
|
async function loadStats(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 {
|
try {
|
||||||
const absolutePath = path.resolve(process.cwd(), filePath);
|
|
||||||
const fileContents = await readFile(absolutePath, 'utf8');
|
const fileContents = await readFile(absolutePath, 'utf8');
|
||||||
const parsed = JSON.parse(fileContents);
|
const parsed = JSON.parse(fileContents);
|
||||||
|
|
||||||
@@ -196,7 +207,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}`);
|
||||||
throw new Error(`Failed to load stats file "${filePath}": ${message}`);
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,6 +698,13 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@@ -1,346 +0,0 @@
|
|||||||
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,115 +10,6 @@ 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,
|
||||||
@@ -136,125 +27,34 @@ 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>) {
|
||||||
const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined;
|
if (!cmdOpts.table) {
|
||||||
|
throw new Error('--table is required (or use --file)');
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let queryObj = api.q(cmdOpts.table);
|
||||||
|
|
||||||
const table =
|
if (cmdOpts.select) {
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterStr = cmdOpts.filter ?? cmdOpts.where;
|
if (cmdOpts.filter) {
|
||||||
if (filterStr) {
|
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
|
||||||
queryObj = queryObj.filter(JSON.parse(filterStr));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderByStr =
|
if (cmdOpts.orderBy) {
|
||||||
cmdOpts.orderBy ??
|
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
|
||||||
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
|
|
||||||
if (orderByStr) {
|
|
||||||
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const limitVal =
|
if (cmdOpts.limit) {
|
||||||
last ??
|
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit'));
|
||||||
(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')
|
||||||
@@ -265,34 +65,16 @@ export function registerQueryCommand(program: Command) {
|
|||||||
.description('Execute an AQL query')
|
.description('Execute an AQL query')
|
||||||
.option(
|
.option(
|
||||||
'--table <table>',
|
'--table <table>',
|
||||||
'Table to query (use "actual query tables" to list available tables)',
|
'Table to query (transactions, accounts, categories, payees)',
|
||||||
)
|
)
|
||||||
.option('--select <fields>', 'Comma-separated fields to select')
|
.option('--select <fields>', 'Comma-separated fields to select')
|
||||||
.option('--filter <json>', 'Filter as JSON (e.g. \'{"amount":{"$lt":0}}\')')
|
.option('--filter <json>', 'Filter expression as JSON')
|
||||||
.option(
|
.option('--order-by <fields>', 'Comma-separated fields to order by')
|
||||||
'--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 () => {
|
||||||
@@ -305,40 +87,7 @@ export function registerQueryCommand(program: Command) {
|
|||||||
: buildQueryFromFlags(cmdOpts);
|
: buildQueryFromFlags(cmdOpts);
|
||||||
|
|
||||||
const result = await api.aqlQuery(queryObj);
|
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 <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,30 +101,3 @@ 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,80 +274,16 @@ 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
|
||||||
# Show last 5 transactions (convenience shortcut)
|
# Run a query (inline)
|
||||||
actual query run --last 5
|
actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
|
||||||
|
|
||||||
# Override default columns with --last
|
# Run a query (from file)
|
||||||
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
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
---
|
|
||||||
category: Enhancements
|
|
||||||
authors: [MatissJanis]
|
|
||||||
---
|
|
||||||
|
|
||||||
cli: improved aql support
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
---
|
|
||||||
category: Maintenance
|
|
||||||
authors: [MatissJanis]
|
|
||||||
---
|
|
||||||
|
|
||||||
Add post-merge hook to automatically install dependencies when yarn.lock changes after merges.
|
|
||||||
Reference in New Issue
Block a user