Skip to content

Commit aa8c28e

Browse files
srousseyclaude
andcommitted
feat(cli-v2): implement entity query command
Add queryEntities function with support for CIK lookup, SIC/state filtering, partial name search, sorting, and pagination. Wire into the query entities CLI subcommand with table/json/csv output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bb11cbd commit aa8c28e

3 files changed

Lines changed: 348 additions & 5 deletions

File tree

src/cli/groups/query.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { Command } from "commander";
2+
import { queryEntities } from "../queries/EntityQuery";
3+
import { renderTable } from "../output/TableRenderer";
24

35
export function addQueryCommands(program: Command): void {
46
const query = program
@@ -11,12 +13,38 @@ export function addQueryCommands(program: Command): void {
1113
.option("--cik <cik>", "Filter by CIK")
1214
.option("--sic <sic>", "Filter by SIC code")
1315
.option("--state <state>", "Filter by state")
14-
.option("--limit <n>", "Limit results")
15-
.option("--offset <n>", "Offset results")
16+
.option("--limit <n>", "Limit results", "25")
17+
.option("--offset <n>", "Offset results", "0")
1618
.option("--sort <field>", "Sort by field")
17-
.option("--format <format>", "Output format (table, json, csv)")
18-
.action(async () => {
19-
console.log("not yet implemented");
19+
.option("--format <format>", "Output format (table, json, csv)", "table")
20+
.action(async (search: string | undefined, options: Record<string, string>) => {
21+
const limit = parseInt(options.limit);
22+
const offset = parseInt(options.offset);
23+
const result = await queryEntities({
24+
search,
25+
cik: options.cik ? parseInt(options.cik) : undefined,
26+
sic: options.sic ? parseInt(options.sic) : undefined,
27+
state: options.state,
28+
limit,
29+
offset,
30+
sort: options.sort,
31+
});
32+
33+
const columns = [
34+
{ key: "cik", header: "CIK", width: 10 },
35+
{ key: "name", header: "Name", width: 30 },
36+
{ key: "sic", header: "SIC", width: 6 },
37+
{ key: "state_incorporation", header: "State", width: 5 },
38+
];
39+
40+
console.log(
41+
renderTable(result.rows as Record<string, unknown>[], columns, {
42+
format: options.format as "table" | "csv" | "json",
43+
total: result.total,
44+
offset,
45+
limit,
46+
})
47+
);
2048
});
2149

2250
query
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { beforeEach, describe, expect, it } from "bun:test";
2+
import { resetDependencyInjectionsForTesting } from "../../config/TestingDI";
3+
import { globalServiceRegistry } from "@workglow/util";
4+
import { ENTITY_REPOSITORY_TOKEN } from "../../storage/entity/EntitySchema";
5+
import { queryEntities } from "./EntityQuery";
6+
7+
describe("queryEntities", () => {
8+
beforeEach(() => {
9+
resetDependencyInjectionsForTesting();
10+
});
11+
12+
it("returns empty array and total 0 for empty DB", async () => {
13+
const result = await queryEntities({});
14+
expect(result.rows).toEqual([]);
15+
expect(result.total).toBe(0);
16+
});
17+
18+
it("returns entities after insertion", async () => {
19+
const repo = globalServiceRegistry.get(ENTITY_REPOSITORY_TOKEN);
20+
await repo.put({
21+
cik: 1318605,
22+
name: "Tesla, Inc.",
23+
type: null,
24+
sic: 3711,
25+
ein: null,
26+
description: null,
27+
website: null,
28+
investor_website: null,
29+
category: null,
30+
fiscal_year: null,
31+
state_incorporation: "TX",
32+
state_incorporation_desc: null,
33+
});
34+
35+
const result = await queryEntities({});
36+
expect(result.rows.length).toBe(1);
37+
expect(result.total).toBe(1);
38+
expect(result.rows[0].cik).toBe(1318605);
39+
expect(result.rows[0].name).toBe("Tesla, Inc.");
40+
});
41+
42+
it("filters by CIK (exact match)", async () => {
43+
const repo = globalServiceRegistry.get(ENTITY_REPOSITORY_TOKEN);
44+
await repo.put({
45+
cik: 1318605,
46+
name: "Tesla, Inc.",
47+
type: null,
48+
sic: 3711,
49+
ein: null,
50+
description: null,
51+
website: null,
52+
investor_website: null,
53+
category: null,
54+
fiscal_year: null,
55+
state_incorporation: "TX",
56+
state_incorporation_desc: null,
57+
});
58+
await repo.put({
59+
cik: 320193,
60+
name: "Apple Inc.",
61+
type: null,
62+
sic: 3571,
63+
ein: null,
64+
description: null,
65+
website: null,
66+
investor_website: null,
67+
category: null,
68+
fiscal_year: null,
69+
state_incorporation: "CA",
70+
state_incorporation_desc: null,
71+
});
72+
73+
const result = await queryEntities({ cik: 1318605 });
74+
expect(result.rows.length).toBe(1);
75+
expect(result.total).toBe(1);
76+
expect(result.rows[0].name).toBe("Tesla, Inc.");
77+
});
78+
79+
it("filters by name search (partial, case-insensitive)", async () => {
80+
const repo = globalServiceRegistry.get(ENTITY_REPOSITORY_TOKEN);
81+
await repo.put({
82+
cik: 1318605,
83+
name: "Tesla, Inc.",
84+
type: null,
85+
sic: 3711,
86+
ein: null,
87+
description: null,
88+
website: null,
89+
investor_website: null,
90+
category: null,
91+
fiscal_year: null,
92+
state_incorporation: "TX",
93+
state_incorporation_desc: null,
94+
});
95+
await repo.put({
96+
cik: 320193,
97+
name: "Apple Inc.",
98+
type: null,
99+
sic: 3571,
100+
ein: null,
101+
description: null,
102+
website: null,
103+
investor_website: null,
104+
category: null,
105+
fiscal_year: null,
106+
state_incorporation: "CA",
107+
state_incorporation_desc: null,
108+
});
109+
110+
const result = await queryEntities({ search: "tesla" });
111+
expect(result.rows.length).toBe(1);
112+
expect(result.total).toBe(1);
113+
expect(result.rows[0].name).toBe("Tesla, Inc.");
114+
});
115+
116+
it("respects limit and offset", async () => {
117+
const repo = globalServiceRegistry.get(ENTITY_REPOSITORY_TOKEN);
118+
for (let i = 1; i <= 5; i++) {
119+
await repo.put({
120+
cik: i,
121+
name: `Entity ${i}`,
122+
type: null,
123+
sic: null,
124+
ein: null,
125+
description: null,
126+
website: null,
127+
investor_website: null,
128+
category: null,
129+
fiscal_year: null,
130+
state_incorporation: null,
131+
state_incorporation_desc: null,
132+
});
133+
}
134+
135+
const result = await queryEntities({ limit: 2, offset: 1 });
136+
expect(result.rows.length).toBe(2);
137+
expect(result.total).toBe(5);
138+
});
139+
140+
it("total count reflects unsliced results", async () => {
141+
const repo = globalServiceRegistry.get(ENTITY_REPOSITORY_TOKEN);
142+
for (let i = 1; i <= 10; i++) {
143+
await repo.put({
144+
cik: i,
145+
name: `Entity ${i}`,
146+
type: null,
147+
sic: null,
148+
ein: null,
149+
description: null,
150+
website: null,
151+
investor_website: null,
152+
category: null,
153+
fiscal_year: null,
154+
state_incorporation: null,
155+
state_incorporation_desc: null,
156+
});
157+
}
158+
159+
const result = await queryEntities({ limit: 3 });
160+
expect(result.rows.length).toBe(3);
161+
expect(result.total).toBe(10);
162+
});
163+
164+
it("filters by SIC code", async () => {
165+
const repo = globalServiceRegistry.get(ENTITY_REPOSITORY_TOKEN);
166+
await repo.put({
167+
cik: 1318605,
168+
name: "Tesla, Inc.",
169+
type: null,
170+
sic: 3711,
171+
ein: null,
172+
description: null,
173+
website: null,
174+
investor_website: null,
175+
category: null,
176+
fiscal_year: null,
177+
state_incorporation: "TX",
178+
state_incorporation_desc: null,
179+
});
180+
await repo.put({
181+
cik: 320193,
182+
name: "Apple Inc.",
183+
type: null,
184+
sic: 3571,
185+
ein: null,
186+
description: null,
187+
website: null,
188+
investor_website: null,
189+
category: null,
190+
fiscal_year: null,
191+
state_incorporation: "CA",
192+
state_incorporation_desc: null,
193+
});
194+
195+
const result = await queryEntities({ sic: 3711 });
196+
expect(result.rows.length).toBe(1);
197+
expect(result.rows[0].name).toBe("Tesla, Inc.");
198+
});
199+
200+
it("filters by state", async () => {
201+
const repo = globalServiceRegistry.get(ENTITY_REPOSITORY_TOKEN);
202+
await repo.put({
203+
cik: 1318605,
204+
name: "Tesla, Inc.",
205+
type: null,
206+
sic: 3711,
207+
ein: null,
208+
description: null,
209+
website: null,
210+
investor_website: null,
211+
category: null,
212+
fiscal_year: null,
213+
state_incorporation: "TX",
214+
state_incorporation_desc: null,
215+
});
216+
await repo.put({
217+
cik: 320193,
218+
name: "Apple Inc.",
219+
type: null,
220+
sic: 3571,
221+
ein: null,
222+
description: null,
223+
website: null,
224+
investor_website: null,
225+
category: null,
226+
fiscal_year: null,
227+
state_incorporation: "CA",
228+
state_incorporation_desc: null,
229+
});
230+
231+
const result = await queryEntities({ state: "CA" });
232+
expect(result.rows.length).toBe(1);
233+
expect(result.rows[0].name).toBe("Apple Inc.");
234+
});
235+
});

src/cli/queries/EntityQuery.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { Entity } from "../../storage/entity/EntitySchema";
2+
import { EntityRepo } from "../../storage/entity/EntityRepo";
3+
4+
export interface EntityQueryParams {
5+
readonly search?: string;
6+
readonly cik?: number;
7+
readonly sic?: number;
8+
readonly state?: string;
9+
readonly limit?: number;
10+
readonly offset?: number;
11+
readonly sort?: string;
12+
}
13+
14+
export interface QueryResult<T> {
15+
readonly rows: T[];
16+
readonly total: number;
17+
}
18+
19+
export async function queryEntities(params: EntityQueryParams): Promise<QueryResult<Entity>> {
20+
const repo = new EntityRepo();
21+
const limit = params.limit ?? 25;
22+
const offset = params.offset ?? 0;
23+
24+
let entities: Entity[];
25+
26+
if (params.cik !== undefined) {
27+
const entity = await repo.getEntity(params.cik);
28+
entities = entity ? [entity] : [];
29+
} else if (
30+
params.sic !== undefined ||
31+
params.state !== undefined
32+
) {
33+
const criteria: Partial<Entity> = {};
34+
if (params.sic !== undefined) criteria.sic = params.sic;
35+
if (params.state !== undefined) criteria.state_incorporation = params.state;
36+
entities = await repo.searchEntities(criteria);
37+
} else {
38+
entities = await repo.getAllEntities();
39+
}
40+
41+
// Apply search filter (partial, case-insensitive name match)
42+
if (params.search) {
43+
const searchLower = params.search.toLowerCase();
44+
entities = entities.filter(
45+
(e) => e.name !== null && e.name.toLowerCase().includes(searchLower)
46+
);
47+
}
48+
49+
// Apply additional filters when fetched via a broader method
50+
if (params.cik === undefined && params.sic !== undefined && params.state !== undefined) {
51+
// searchEntities only takes one set of criteria; both are already applied above
52+
} else if (params.cik !== undefined) {
53+
// If we fetched by CIK, also filter by sic/state if provided
54+
if (params.sic !== undefined) {
55+
entities = entities.filter((e) => e.sic === params.sic);
56+
}
57+
if (params.state !== undefined) {
58+
entities = entities.filter((e) => e.state_incorporation === params.state);
59+
}
60+
}
61+
62+
// Sort
63+
if (params.sort) {
64+
const sortKey = params.sort as keyof Entity;
65+
entities.sort((a, b) => {
66+
const aVal = a[sortKey];
67+
const bVal = b[sortKey];
68+
if (aVal === null || aVal === undefined) return 1;
69+
if (bVal === null || bVal === undefined) return -1;
70+
if (aVal < bVal) return -1;
71+
if (aVal > bVal) return 1;
72+
return 0;
73+
});
74+
}
75+
76+
const total = entities.length;
77+
const rows = entities.slice(offset, offset + limit);
78+
79+
return { rows, total };
80+
}

0 commit comments

Comments
 (0)