Task S5T02: stats.country_pair_flows Table¶
Sprint Context¶
- Sprint index: Sprint 5 Task Index
- Tags: database, database-refactor, sprint-5, task-index
Source¶
- DBA source:
task-S5T02.dba.md - Specification source:
task-S5T02.specification.md
Purpose & Scope¶
Creates stats.country_pair_flows, which tracks how often GeoKrety travel from one country to another within a calendar month. A row (2026-03-01, 'PL', 'DE', 47, 12) means: "in March 2026, 47 qualifying moves crossed from Poland to Germany, involving 12 distinct GeoKrety."
This powers:
- UC7: Country flow diagram / Sankey chart ("Where do GeoKrety travel to/from?")
- Cross-border movement analytics
"From country" = country of the previous qualifying move on the same GeoKret.
"To country" = country of the current qualifying move.
Same-country transitions (self-loops) are excluded (from_country <> to_country).
Monthly aggregate table tracking cross-country GeoKret movements. One row per (month, from_country, to_country) triplet. "From" is the country of the GK's previous qualifying move; "To" is the country of the current move. Enables the UC7 Sankey diagram showing where GeoKrety travel internationally.
Scope: DDL only. Trigger population in S5T07. Backfill in Sprint 6.
Requirements¶
| ID | Description | MoSCoW |
|---|---|---|
| REQ-530 | Table stats.country_pair_flows exists |
MUST |
| REQ-531 | 3-part composite PK: (year_month DATE, from_country CHAR(2), to_country CHAR(2)) |
MUST |
| REQ-532 | CHECK (from_country <> to_country) — no self-loops |
MUST |
| REQ-533 | CHECK (from_country = UPPER(from_country)) — uppercase invariant |
MUST |
| REQ-534 | CHECK (to_country = UPPER(to_country)) — uppercase invariant |
MUST |
| REQ-535 | move_count BIGINT DEFAULT 0 NOT NULL |
MUST |
| REQ-536 | unique_gk_count BIGINT DEFAULT 0 NOT NULL |
MUST |
| REQ-537 | year_month represents the first day of the month (convention, enforced by inserter) |
MUST |
| REQ-538 | Table is empty after DDL creation | MUST |
| REQ-539 | phinx rollback drops table cleanly |
MUST |
Acceptance Criteria¶
| # | Criterion | How to Verify |
|---|---|---|
| 1 | Table created in stats schema |
\d stats.country_pair_flows |
| 2 | 3-part composite PK | PK visible in description |
| 3 | Self-loop rejected by CHECK | INSERT ('2026-01-01','PL','PL',0,0) → exception |
| 4 | Lowercase country code rejected | INSERT ('2026-01-01','pl','DE',0,0) → exception |
| 5 | Both count columns default to 0 | INSERT without counts → both = 0 |
| 6 | Table empty after DDL | 0 rows |
| 7 | Rollback drops table | Table absent |
Migration File¶
20260310500100_create_country_pair_flows.php
Full SQL DDL¶
CREATE TABLE stats.country_pair_flows (
year_month DATE NOT NULL,
from_country CHAR(2) NOT NULL,
to_country CHAR(2) NOT NULL,
move_count BIGINT NOT NULL DEFAULT 0,
unique_gk_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (year_month, from_country, to_country),
CONSTRAINT chk_cpf_different_countries
CHECK (from_country <> to_country),
CONSTRAINT chk_cpf_from_country_upper
CHECK (from_country = UPPER(from_country)),
CONSTRAINT chk_cpf_to_country_upper
CHECK (to_country = UPPER(to_country))
);
COMMENT ON TABLE stats.country_pair_flows
IS 'Monthly GK flow counts between country pairs; powers UC7 Sankey/flow chart';
COMMENT ON COLUMN stats.country_pair_flows.year_month
IS 'First day of the month (e.g. 2026-03-01)';
COMMENT ON COLUMN stats.country_pair_flows.from_country
IS 'ISO 3166-1 alpha-2 country code of origin (uppercase)';
COMMENT ON COLUMN stats.country_pair_flows.to_country
IS 'ISO 3166-1 alpha-2 country code of destination (uppercase)';
COMMENT ON COLUMN stats.country_pair_flows.move_count
IS 'Number of qualifying moves this month that crossed from→to';
COMMENT ON COLUMN stats.country_pair_flows.unique_gk_count
IS 'Distinct GeoKrety that crossed from→to at least once this month';
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateCountryPairFlows extends AbstractMigration
{
public function up(): void
{
$this->execute(<<<'SQL'
CREATE TABLE stats.country_pair_flows (
year_month DATE NOT NULL,
from_country CHAR(2) NOT NULL,
to_country CHAR(2) NOT NULL,
move_count BIGINT NOT NULL DEFAULT 0,
unique_gk_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (year_month, from_country, to_country),
CONSTRAINT chk_cpf_different_countries CHECK (from_country <> to_country),
CONSTRAINT chk_cpf_from_country_upper CHECK (from_country = UPPER(from_country)),
CONSTRAINT chk_cpf_to_country_upper CHECK (to_country = UPPER(to_country))
);
COMMENT ON TABLE stats.country_pair_flows
IS 'Monthly GK flow counts between country pairs; powers UC7 Sankey/flow chart';
SQL
);
}
public function down(): void
{
$this->execute('DROP TABLE IF EXISTS stats.country_pair_flows;');
}
}
Data Contract¶
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
year_month |
DATE |
NOT NULL | — | PK (part 1) — First day of month |
from_country |
CHAR(2) |
NOT NULL | — | PK (part 2) — Origin ISO code |
to_country |
CHAR(2) |
NOT NULL | — | PK (part 3) — Destination ISO code |
move_count |
BIGINT |
NOT NULL | 0 |
Qualifying moves crossing this pair |
unique_gk_count |
BIGINT |
NOT NULL | 0 |
Distinct GeoKrety crossing this pair |
Constraints:
chk_cpf_different_countries:from_country <> to_country(no self-loops)chk_cpf_from_country_upper: country codes stored uppercasechk_cpf_to_country_upper: country codes stored uppercaseyear_month= first day of month (enforced by application/trigger logic usingDATE_TRUNC('month', move_date))
SQL Usage Examples¶
-- UC7: Top flows in last 12 months
SELECT from_country, to_country, SUM(move_count) AS total
FROM stats.country_pair_flows
WHERE year_month >= DATE_TRUNC('month', NOW() - INTERVAL '12 months')
GROUP BY from_country, to_country
ORDER BY total DESC
LIMIT 50;
-- UC7: All flows for a specific country
SELECT
CASE WHEN from_country = 'PL' THEN to_country ELSE from_country END AS partner,
CASE WHEN from_country = 'PL' THEN 'outflow' ELSE 'inflow' END AS direction,
SUM(move_count) AS total
FROM stats.country_pair_flows
WHERE (from_country = 'PL' OR to_country = 'PL')
AND year_month >= NOW() - INTERVAL '12 months'
GROUP BY 1, 2
ORDER BY total DESC;
-- Monthly trend for PL→DE
SELECT year_month, move_count, unique_gk_count
FROM stats.country_pair_flows
WHERE from_country = 'PL' AND to_country = 'DE'
ORDER BY year_month DESC;
Graph / Visualization Specification¶
UC7: Sankey diagram (country flows)
Data query:
SELECT from_country, to_country, SUM(move_count) AS total_flows
FROM stats.country_pair_flows
WHERE year_month >= DATE_TRUNC('month', NOW() - INTERVAL '12 months')
GROUP BY from_country, to_country
ORDER BY total_flows DESC
LIMIT 100;
ASCII Example:
Germany (DE) ──────────── 1,240 flows ──────────── Poland (PL)
Germany (DE) ──────────── 890 flows ──────────── Czech (CZ)
Poland (PL) ──────────── 670 flows ──────────── Germany (DE)
TimescaleDB Assessment¶
CANDIDATE for hypertable (time dimension: year_month). One row per month per country pair → low cardinality growth. Chunk by 6-month intervals.
pgTAP Unit Tests¶
| Test ID | Assertion | Expected |
|---|---|---|
| T-5.2.001 | has_table('stats', 'country_pair_flows') |
pass |
| T-5.2.002 | col_is_pk('stats', 'country_pair_flows', ARRAY['year_month','from_country','to_country']) |
pass |
| T-5.2.003 | col_type_is('stats', 'country_pair_flows', 'from_country', 'character') |
pass |
| T-5.2.004 | col_type_is('stats', 'country_pair_flows', 'move_count', 'bigint') |
pass |
| T-5.2.005 | Same-country INSERT (from_country = to_country) → CHECK violation |
exception |
| T-5.2.006 | Lowercase country INSERT → CHECK violation | exception |
| T-5.2.007 | Table is empty after creation | pass |
| T-5.2.008 | phinx rollback drops table |
pass |
| Test ID | Assertion | Pass Condition |
|---|---|---|
| T-5.2.001 | Table exists | has_table() |
| T-5.2.002 | 3-part PK | col_is_pk() |
| T-5.2.003 | from_country is CHAR(2) |
col_type_is() |
| T-5.2.004 | move_count is BIGINT |
col_type_is() |
| T-5.2.005 | Self-loop CHECK violation | Exception raised |
| T-5.2.006 | Lowercase CHECK violation | Exception raised |
| T-5.2.007 | Table empty | is_empty() |
| T-5.2.008 | Rollback drops table | hasnt_table() |
Implementation Checklist¶
- 1. Create
20260310500100_create_country_pair_flows.php - 2.
phinx migrate— no errors - 3.
\d stats.country_pair_flows— 5 columns, 3-part PK, 3 CHECKs - 4. Test self-loop violation:
('2026-01-01','PL','PL',0,0)→ exception - 5. Test lowercase:
('2026-01-01','pl','DE',0,0)→ exception - 6. Run pgTAP T-5.2.001 through T-5.2.008 — all pass
-
7.
phinx rollback— table gone -
1. Write
20260310500100_create_country_pair_flows.php - 2.
phinx migrate— no errors - 3. 5 columns, 3-part PK, 3 CHECK constraints
- 4. Verify self-loop and lowercase violations
- 5. Run pgTAP T-5.2.001 through T-5.2.008 — all pass
- 6.
phinx rollback— table gone
Table Created¶
| Column | Type | Constraints |
|---|---|---|
year_month |
DATE | PK (part 1), NOT NULL |
from_country |
CHAR(2) | PK (part 2), NOT NULL, uppercase, ≠ to_country |
to_country |
CHAR(2) | PK (part 3), NOT NULL, uppercase, ≠ from_country |
move_count |
BIGINT | NOT NULL, DEFAULT 0 |
unique_gk_count |
BIGINT | NOT NULL, DEFAULT 0 |