Skip to content

Commit f2a61b1

Browse files
authored
Merge pull request #134 from defog-ai/comext-sql-five-countries
Split EU trade SQL for more than 5 countries into several statements
2 parents 159de2b + c489abe commit f2a61b1

6 files changed

Lines changed: 230 additions & 61 deletions

File tree

‎.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "factiq",
33
"description": "Answer economic and financial data questions with real data from FactIQ — official statistics, market data, earnings-call transcripts, executive media appearances, central-bank speeches and minutes, a curated business-news feed, and satellite-derived data (nighttime lights, shipping and port activity, reservoir levels, crop fires and wildfires with mappable footprints, NO2/SO2/CO activity, smoke and dust aerosol, monsoon rainfall) — return sourced answers, chart JSON, or report JSON.",
4-
"version": "0.43.0",
4+
"version": "0.44.0",
55
"author": {
66
"name": "FactIQ",
77
"url": "https://www.factiq.com"

‎.codex-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "factiq",
33
"description": "Answer economic and financial data questions with real FactIQ data — official data, markets, earnings calls, executive media appearances, central-bank documents, and satellite-derived signals including crop fires and wildfires — then return sourced answers, chart JSON, or report JSON.",
4-
"version": "0.43.0",
4+
"version": "0.44.0",
55
"author": {
66
"name": "FactIQ",
77
"url": "https://www.factiq.com"

‎references/data/sql-guide.md‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,9 @@ Exact series IDs are mandatory here, not just faster. Each country catalog
252252
holds millions of series, and the text collation is not byte-ordered, so
253253
`series_id LIKE 'prefix%'` cannot run on the index (it scans the table and
254254
hits the 30-second timeout) and `>=`/`<` string ranges over `series_id`
255-
return wrong or empty results. Equality joins on constructed IDs are index
256-
probes and stay fast at any scale.
255+
return wrong or empty results. Equality joins on constructed IDs avoid those
256+
scans. Their time still grows with the date range and with the number of
257+
reporter schemas in one statement, so keep both small.
257258

258259
Use these rules:
259260

@@ -262,8 +263,8 @@ Use these rules:
262263
`product_name`, `hs2_code`, `hs4_code`, or `hs6_code`.
263264
It is a complete product-code lookup, including historical codes and their
264265
first and last observed dates.
265-
- Build exact series IDs and join them to `data_points`, whose `series_id`
266-
index makes the fetch fast. The detailed ID shape is
266+
- Build exact series IDs and join them to `data_points`; an exact
267+
`series_id` match avoids the pattern scan. The detailed ID shape is
267268
`eu_comext_{M|X}_{reporter}_{partner}_{te|ti|tl}_p1_cn8_{product_code}_{eur|kg|su}`.
268269
`M` means imports and `X` exports. Reporter and partner are lowercase ISO2
269270
codes, except that `xi` is Eurostat's code for Northern Ireland. The stored
@@ -282,10 +283,13 @@ Use these rules:
282283
every CN8 product.
283284
- For several reporters, either run one already-aggregated query per reporter
284285
schema and combine the small results locally, or `UNION ALL` the
285-
per-reporter aggregations in a single call — a union of exact-ID joins
286-
across all 27 member schemas completes well within the 30-second limit.
287-
What times out is pattern scanning, not combining: every branch must use
288-
the exact-ID join, never a `series_id` pattern.
286+
per-reporter aggregations in a single call. The API accepts at most 5
287+
Comext country schemas in one statement that reads `data_points`, and
288+
refuses a statement with more. For more reporters (or all 27), send
289+
further groups of at most 5 one after another, not at the same time, and
290+
combine the results locally. `comext_sql.py` splits a longer reporter list
291+
into such groups for you. Every branch must use the exact-ID join, never a
292+
`series_id` pattern.
289293

290294
To inspect product mappings, qualify the shared table from any country query:
291295

@@ -333,8 +337,8 @@ WHERE series_id = 'eu_comext_M_de_cn_te_p1_cn6_total_eur'
333337
ORDER BY time;
334338
```
335339

336-
For several reporters in one call, `UNION ALL` the same exact-ID aggregation
337-
per country schema. This tested example returns 2025 German- and
340+
For several reporters (at most 5) in one call, `UNION ALL` the same exact-ID
341+
aggregation per country schema. This tested example returns 2025 German- and
338342
French-reported electronics (HS chapter 85) imports from China; run it with
339343
`schema="eu_comext_de"` (any Comext schema works as the default):
340344

‎references/report-patterns/bilateral-trade.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ monthly rows for short windows, annual or YTD summaries for long ones.
6464
only sets the default search path; qualified cross-schema references (for
6565
example `korea_trade.series` from an `india_trade` call) do execute, so a
6666
`UNION ALL` of per-reporter aggregations in one call is allowed when every
67-
branch converts to a common unit. The reason to keep reporters separate is
67+
branch converts to a common unit (for Eurostat Comext, at most 5 country
68+
schemas per statement). The reason to keep reporters separate is
6869
the data, not the tool: mirror sources differ on units, valuation, timing,
6970
and definitions, so never mix raw values from two reporters in a single
7071
expression. Fetch comparable aggregates, normalize units, then compare.

‎scripts/comext_sql.py‎

Lines changed: 131 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
Comext series IDs must be constructed exactly (pattern scans time out; string
55
ranges return wrong rows — see references/data/sql-guide.md). This emits the
66
correct exact-ID SQL mechanically: the cn6_total series for all-goods totals,
7-
lookup-table joins for product breakdowns, and UNION ALL across any set of
8-
the 27 reporter schemas. Run the output with the run_sql tool using the
7+
lookup-table joins for product breakdowns, and UNION ALL across reporter
8+
schemas. One statement may read at most 5 reporter schemas, so a request for
9+
more reporters (or 'all') prints several statements, each with the schema to
10+
run it with; run them one after another and combine the results as the
11+
header comment says. Otherwise run the output with the run_sql tool using the
912
schema printed in the header comment.
1013
1114
ID grammar it encodes:
@@ -38,6 +41,7 @@
3841
import json
3942
import re
4043
import sys
44+
from collections.abc import Callable
4145

4246
EU_MEMBERS = [
4347
"at",
@@ -90,6 +94,12 @@
9094
GB_EXTRA_EU_MONTH = "2020-02-01"
9195
NORTHERN_IRELAND_START = "2021-01-01"
9296

97+
# The API refuses a statement that reads data_points in more than 5 Comext
98+
# reporter schemas, so larger reporter lists are split into statements.
99+
MAX_REPORTERS_PER_STATEMENT = 5
100+
# run_sql returns at most 50 rows.
101+
MAX_ROWS = 50
102+
93103
METRICS = {"value": "eur", "weight": "kg", "supplementary": "su"}
94104
METRIC_UNITS = {"value": "EUR", "weight": "kg", "supplementary": "supplementary units"}
95105

@@ -277,17 +287,66 @@ def product_id_expr(
277287
return f"'{prefix}' || lower(p.product_code) || '_{suffix}'"
278288

279289

290+
def generated_line(args: argparse.Namespace) -> str:
291+
return (
292+
f"-- generated by comext_sql.py {args.command}: "
293+
f"{args.flow} vs partner '{args.partner}', {args.start}..{args.end}, metric={args.metric}"
294+
)
295+
296+
280297
def header(reporters: list[str], args: argparse.Namespace, extra: str = "") -> str:
281298
lines = [
282-
f"-- generated by comext_sql.py {args.command}: "
283-
f"{args.flow} vs partner '{args.partner}', {args.start}..{args.end}, metric={args.metric}",
299+
generated_line(args),
284300
f'-- run with the run_sql tool, schema="eu_comext_{reporters[0]}"',
285301
]
286302
if extra:
287303
lines.append(f"-- {extra}")
288304
return "\n".join(lines)
289305

290306

307+
def reporter_groups(reporters: list[str]) -> list[list[str]]:
308+
"""Split reporters into the fewest groups of at most 5, sizes within one."""
309+
count = -(-len(reporters) // MAX_REPORTERS_PER_STATEMENT)
310+
size, extra = divmod(len(reporters), count)
311+
groups, start = [], 0
312+
for index in range(count):
313+
end = start + size + (1 if index < extra else 0)
314+
groups.append(reporters[start:end])
315+
start = end
316+
return groups
317+
318+
319+
def statements(
320+
reporters: list[str],
321+
args: argparse.Namespace,
322+
note: str,
323+
build: Callable[[list[str]], str],
324+
combine: str,
325+
) -> str:
326+
"""One statement for up to 5 reporters; otherwise one per group of at most
327+
5, with `combine` saying how to put the results together."""
328+
if len(reporters) <= MAX_REPORTERS_PER_STATEMENT:
329+
return header(reporters, args, note) + "\n" + build(reporters)
330+
groups = reporter_groups(reporters)
331+
lines = [generated_line(args)]
332+
if note:
333+
lines.append(f"-- {note}")
334+
lines.append(
335+
f"-- the API reads at most {MAX_REPORTERS_PER_STATEMENT} reporter schemas "
336+
f"in one statement, so this request is {len(groups)} statements: run them "
337+
f"one after another (not at the same time), each with the schema named "
338+
f"above it, then {combine}"
339+
)
340+
for number, group in enumerate(groups, 1):
341+
lines += [
342+
"",
343+
f"-- statement {number} of {len(groups)}: reporters {', '.join(group)}",
344+
f'-- run with the run_sql tool, schema="eu_comext_{group[0]}"',
345+
build(group),
346+
]
347+
return "\n".join(lines)
348+
349+
291350
def sql_total(args: argparse.Namespace) -> str:
292351
reporters = resolve_reporters(args.reporters)
293352
partner = resolve_partner(args.partner)
@@ -302,39 +361,46 @@ def sql_total(args: argparse.Namespace) -> str:
302361
lo, hi, coverage_note = comext_date_bounds(args.start, args.end)
303362
periods, period_note = trade_periods(partner, lo, hi)
304363

305-
# Each reporter schema holds only its own series in data_points, so every
306-
# reporter needs its own qualified branch — a flat IN list against one
307-
# schema's table would silently drop the other 26.
308-
branches = []
309-
for r in reporters:
310-
for period_partner, token, period_lo, period_hi in periods:
311-
table = (
312-
"data_points" if len(reporters) == 1 else f"eu_comext_{r}.data_points"
313-
)
314-
branches.append(
315-
f" SELECT '{r}' AS reporter, time AS t, value AS v\n"
316-
f" FROM {table}\n"
317-
f" WHERE series_id = "
318-
f"'{total_series_id(flow, r, period_partner, token, suffix)}'\n"
319-
f" AND time >= DATE '{period_lo}' AND time < DATE '{period_hi}'"
320-
)
321-
union = "\nUNION ALL\n".join(branches)
322364
if args.monthly:
323365
select, group = (
324366
f"t::date AS month, SUM(v) AS total_{suffix}",
325367
"GROUP BY t\nORDER BY t",
326368
)
369+
combine = f"add total_{suffix} across the statements for each month"
327370
else:
328371
select, group = (
329372
f"reporter, SUM(v) AS total_{suffix}",
330373
"GROUP BY reporter\nORDER BY 2 DESC",
331374
)
332-
body = f"SELECT {select}\nFROM (\n{union}\n) u\n{group};"
375+
combine = "put the reporter rows of all statements together"
376+
377+
def build(group_reporters: list[str]) -> str:
378+
# Each reporter schema holds only its own series in data_points, so
379+
# every reporter needs its own qualified branch — a flat IN list
380+
# against one schema's table would silently drop the others.
381+
branches = []
382+
for r in group_reporters:
383+
for period_partner, token, period_lo, period_hi in periods:
384+
table = (
385+
"data_points"
386+
if len(group_reporters) == 1
387+
else f"eu_comext_{r}.data_points"
388+
)
389+
branches.append(
390+
f" SELECT '{r}' AS reporter, time AS t, value AS v\n"
391+
f" FROM {table}\n"
392+
f" WHERE series_id = "
393+
f"'{total_series_id(flow, r, period_partner, token, suffix)}'\n"
394+
f" AND time >= DATE '{period_lo}' AND time < DATE '{period_hi}'"
395+
)
396+
union = "\nUNION ALL\n".join(branches)
397+
return f"SELECT {select}\nFROM (\n{union}\n) u\n{group};"
398+
333399
note = f"all-goods total via the exact cn6_total series; values in {METRIC_UNITS[args.metric]}"
334400
note = add_note(note, domestic_note)
335401
note = add_note(note, coverage_note)
336402
note = add_note(note, period_note)
337-
return header(reporters, args, note) + "\n" + body
403+
return statements(reporters, args, note, build, combine)
338404

339405

340406
def product_branches(
@@ -395,13 +461,24 @@ def sql_products(args: argparse.Namespace) -> str:
395461
"cn8": "lower(p.product_code)",
396462
}[args.group_by]
397463
inner = f"{group_col} AS code, dp.value AS v"
398-
union = product_branches(reporters, flow, suffix, periods, inner, "")
399-
body = (
400-
f"SELECT code, SUM(v) AS total_{suffix}\n"
401-
f"FROM (\n{union}\n) t\n"
402-
f"GROUP BY code\n"
403-
f"ORDER BY 2 DESC\n"
404-
f"LIMIT {args.top};"
464+
# Split statements each return their top 50 so the combined ranking can
465+
# be built from them; a code missing from a statement's list is not zero.
466+
limit = args.top if len(reporters) <= MAX_REPORTERS_PER_STATEMENT else MAX_ROWS
467+
468+
def build(group_reporters: list[str]) -> str:
469+
union = product_branches(group_reporters, flow, suffix, periods, inner, "")
470+
return (
471+
f"SELECT code, SUM(v) AS total_{suffix}\n"
472+
f"FROM (\n{union}\n) t\n"
473+
f"GROUP BY code\n"
474+
f"ORDER BY 2 DESC\n"
475+
f"LIMIT {limit};"
476+
)
477+
478+
combine = (
479+
f"add total_{suffix} across the statements for each code and keep the "
480+
f"top {args.top}; each statement lists only its top {MAX_ROWS} codes, so "
481+
f"a code missing from some statements has a total that is a lower bound"
405482
)
406483
label_hint = (
407484
"name the codes locally with the bundled hs_codes.py helper"
@@ -415,7 +492,7 @@ def sql_products(args: argparse.Namespace) -> str:
415492
note = add_note(note, domestic_note)
416493
note = add_note(note, coverage_note)
417494
note = add_note(note, period_note)
418-
return header(reporters, args, note) + "\n" + body
495+
return statements(reporters, args, note, build, combine)
419496

420497

421498
def sql_trend(args: argparse.Namespace) -> str:
@@ -452,31 +529,37 @@ def sql_trend(args: argparse.Namespace) -> str:
452529
if include_unit
453530
else "dp.time AS t, dp.value AS v"
454531
)
455-
union = product_branches(
456-
reporters,
457-
flow,
458-
suffix,
459-
periods,
460-
inner,
461-
where_extra,
462-
include_unit=include_unit,
463-
)
464-
if include_unit:
465-
body = (
466-
f"SELECT unit AS measurement_units, t::date AS month, SUM(v) AS total_{suffix}\n"
467-
f"FROM (\n{union}\n) u\nGROUP BY unit, t\nORDER BY t, unit;"
532+
533+
def build(group_reporters: list[str]) -> str:
534+
union = product_branches(
535+
group_reporters,
536+
flow,
537+
suffix,
538+
periods,
539+
inner,
540+
where_extra,
541+
include_unit=include_unit,
468542
)
469-
note = f"monthly trend for HS {code}; units returned with each row"
470-
else:
471-
body = (
543+
if include_unit:
544+
return (
545+
f"SELECT unit AS measurement_units, t::date AS month, SUM(v) AS total_{suffix}\n"
546+
f"FROM (\n{union}\n) u\nGROUP BY unit, t\nORDER BY t, unit;"
547+
)
548+
return (
472549
f"SELECT t::date AS month, SUM(v) AS total_{suffix}\n"
473550
f"FROM (\n{union}\n) u\nGROUP BY t\nORDER BY t;"
474551
)
552+
553+
if include_unit:
554+
note = f"monthly trend for HS {code}; units returned with each row"
555+
combine = f"add total_{suffix} across the statements for each month and unit"
556+
else:
475557
note = f"monthly trend for HS {code}; values in {METRIC_UNITS[args.metric]}"
558+
combine = f"add total_{suffix} across the statements for each month"
476559
note = add_note(note, domestic_note)
477560
note = add_note(note, coverage_note)
478561
note = add_note(note, period_note)
479-
return header(reporters, args, note) + "\n" + body
562+
return statements(reporters, args, note, build, combine)
480563

481564

482565
def main() -> None:

0 commit comments

Comments
 (0)