Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ jobs:
- run: uv run --group dev ruff check .
- run: uv run --group dev mypy
- run: uv run --group dev pytest
- name: The PostgreSQL file agrees with spec/vectors.json
if: matrix.python == '3.14'
working-directory: .
run: |
sudo systemctl start postgresql.service
sudo -u postgres createuser --superuser "$USER"
PGDATABASE=postgres python3 scripts/sql_conformance.py postgres
- name: The DuckDB file agrees with spec/vectors.json
if: matrix.python == '3.14'
working-directory: .
run: uv run --no-project --with duckdb==1.5.5 python scripts/sql_conformance.py duckdb

mcp:
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ A postcode has 11 characters in five segments, written `EK-01-A03-FK-01`: state,

Each package has its own README with full usage.

For databases, [`sql/`](sql) holds the postcode as a PostgreSQL domain and as DuckDB macros, in plain SQL that runs the same shared cases.

Two names are easy to get wrong. On npm the library is `ng-postcode-js`, because `ng-postcode` there is another project. `ng-address-resolver` is imported as `ng_address`.

For online stores, [ng-postcode-woocommerce](https://github.com/Adeniyikayodee/ng-postcode-woocommerce) is a WooCommerce plugin built on the same rules and the same shared test cases: it checks and tidies the postcode at checkout, finds it from the customer's location, and makes shipping zones match on postcode prefixes.
Expand Down
62 changes: 62 additions & 0 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Since 1 October 2026 a Nigerian building's postcode is this 11-character code. A
- [ng-postcode-mcp](https://pypi.org/project/ng-postcode-mcp/): MCP server, run with `uvx ng-postcode-mcp`, or with `npx -y ng-postcode-mcp` for the Node edition, which has every tool except `resolve_address`. Tools: `validate_postcode`, `lookup_postcode`, `autocomplete_postcode`, `find_postcode_at_location`, `resolve_address`. Registry name `io.github.Adeniyikayodee/ng-postcode`.
- [ng-address-resolver](https://pypi.org/project/ng-address-resolver/): `pip install ng-address-resolver`. Resolves free-text Nigerian addresses to postcodes, only as precisely as the evidence allows. Pre-release.
- [WooCommerce plugin](https://github.com/Adeniyikayodee/ng-postcode-woocommerce): checks and tidies the postcode at checkout, finds it from the customer's location, and makes shipping zones match on postcode prefixes.
- [SQL](https://github.com/Adeniyikayodee/ng-postcode/tree/main/sql): the postcode as a PostgreSQL domain and DuckDB macros, with `ng_postcode_parse`, `ng_postcode_format`, and `ng_postcode_truncate` for grouping by state, LGA, district, or area.

## Docs

Expand Down Expand Up @@ -74,6 +75,8 @@ A postcode has 11 characters in five segments, written `EK-01-A03-FK-01`: state,

Each package has its own README with full usage.

For databases, [`sql/`](sql) holds the postcode as a PostgreSQL domain and as DuckDB macros, in plain SQL that runs the same shared cases.

Two names are easy to get wrong. On npm the library is `ng-postcode-js`, because `ng-postcode` there is another project. `ng-address-resolver` is imported as `ng_address`.

For online stores, [ng-postcode-woocommerce](https://github.com/Adeniyikayodee/ng-postcode-woocommerce) is a WooCommerce plugin built on the same rules and the same shared test cases: it checks and tidies the postcode at checkout, finds it from the customer's location, and makes shipping zones match on postcode prefixes.
Expand Down Expand Up @@ -857,3 +860,62 @@ Each address uses at most one Claude call at low effort, up to three geocoder se
## License

MIT

---

<!-- sql/README.md -->

# ng-postcode for SQL

Nigeria's NDAPS postcode as a column type in PostgreSQL and DuckDB. Both files are plain SQL with no extension to build, so they load on managed services, and both give the same answers as the libraries: [`scripts/sql_conformance.py`](../scripts/sql_conformance.py) runs the shared cases in [`spec/vectors.json`](../spec/vectors.json) against them.

```sh
psql -f sql/postgres.sql # PostgreSQL, tested on 14 and 17
duckdb my.db < sql/duckdb.sql # DuckDB, tested on 1.5
```

## Store and check

```sql
CREATE TABLE customers (
id bigint PRIMARY KEY,
postcode ng_postcode -- refuses anything that is not a compact code
);

INSERT INTO customers VALUES (1, ng_postcode_parse('ek 01 a03 fk 01'));

SELECT postcode, ng_postcode_format(postcode) FROM customers;
-- EK01A03FK01 | EK-01-A03-FK-01
```

`ng_postcode_parse` accepts a hyphenated, spaced, or compact code in either case, and returns NULL for one that is not well formed. DuckDB has no domains, so declare the column as `text` with `CHECK (ng_postcode_parse(postcode) IS NOT DISTINCT FROM postcode)`. A plain `=` would let a malformed code through, because comparing with NULL is not false.

## Group by state, LGA, district, or area

A prefix is a code cut off after one of its segments. It needs no map: every building in a district shares that district's prefix.

```sql
SELECT ng_postcode_format(ng_postcode_truncate(postcode, 'district')) AS district, count(*)
FROM customers
GROUP BY 1;
-- EK-01-A03 | 1
```

To select one district, compare a range so that the column's index is used:

```sql
SELECT * FROM customers WHERE postcode >= 'EK01A03' AND postcode < 'EK01A03' || '~';
```

## Functions

| Function | Returns |
| --- | --- |
| `ng_postcode_parse(text)` | The compact code, or NULL |
| `ng_postcode_prefix_parse(text)` | The compact prefix, such as `EK01A03`, or NULL |
| `ng_postcode_format(code or prefix)` | The hyphenated form |
| `ng_postcode_truncate(code, segment)` | The prefix through `state`, `lga`, `district`, `area`, or `unit` |
| `ng_postcode_parent(prefix)` | The prefix one segment shorter, or NULL for a state |
| `ng_postcode_within(code, prefix)` | Whether the code starts with the prefix |

A well-formed code is not necessarily assigned to a building. Only the NIPOST API can confirm that, through one of the [libraries](../README.md).
1 change: 1 addition & 0 deletions docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Since 1 October 2026 a Nigerian building's postcode is this 11-character code. A
- [ng-postcode-mcp](https://pypi.org/project/ng-postcode-mcp/): MCP server, run with `uvx ng-postcode-mcp`, or with `npx -y ng-postcode-mcp` for the Node edition, which has every tool except `resolve_address`. Tools: `validate_postcode`, `lookup_postcode`, `autocomplete_postcode`, `find_postcode_at_location`, `resolve_address`. Registry name `io.github.Adeniyikayodee/ng-postcode`.
- [ng-address-resolver](https://pypi.org/project/ng-address-resolver/): `pip install ng-address-resolver`. Resolves free-text Nigerian addresses to postcodes, only as precisely as the evidence allows. Pre-release.
- [WooCommerce plugin](https://github.com/Adeniyikayodee/ng-postcode-woocommerce): checks and tidies the postcode at checkout, finds it from the customer's location, and makes shipping zones match on postcode prefixes.
- [SQL](https://github.com/Adeniyikayodee/ng-postcode/tree/main/sql): the postcode as a PostgreSQL domain and DuckDB macros, with `ng_postcode_parse`, `ng_postcode_format`, and `ng_postcode_truncate` for grouping by state, LGA, district, or area.

## Docs

Expand Down
1 change: 1 addition & 0 deletions scripts/llms_full.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"mcp/README.md",
"js/packages/ng-postcode-mcp/README.md",
"agent/README.md",
"sql/README.md",
]


Expand Down
99 changes: 99 additions & 0 deletions scripts/sql_conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Run the cases in `spec/vectors.json` against the SQL in `sql/`.

python3 scripts/sql_conformance.py postgres # through psql, using PG* from the environment
python3 scripts/sql_conformance.py duckdb # needs the duckdb package

SQL has no error values, so a rejected input is NULL. Each case becomes one expression that
is true when the SQL agrees with the spec.
"""

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def text(value: str | None) -> str:
return "NULL" if value is None else "'" + value.replace("'", "''") + "'"


def checks() -> list[tuple[str, str]]:
"""A name and an expression per case, comparing with a NULL-safe test."""
spec = json.loads((ROOT / "spec" / "vectors.json").read_text(encoding="utf-8"))

def same(expression: str, expected: str | None) -> str:
return f"({expression}) IS NOT DISTINCT FROM {text(expected)}"

found: list[tuple[str, str]] = []
for case in spec["parse"]["valid"]:
parsed = f"ng_postcode_parse({text(case['input'])})"
found.append((f"parse {case['input']!r}", same(parsed, case["compact"])))
found.append((f"format {case['input']!r}", same(f"ng_postcode_format({parsed})", case["canonical"])))
for case in spec["parse"]["invalid"]:
found.append((f"reject {case['input'][:30]!r}", same(f"ng_postcode_parse({text(case['input'])})", None)))
for case in spec["prefix"]:
cut = f"ng_postcode_truncate(ng_postcode_parse({text(case['input'])}), {text(case['through'])})"
found.append((f"truncate {case['through']}", same(f"ng_postcode_format({cut})", case["prefix"])))
for case in spec["parse_prefix"]["valid"]:
parsed = f"ng_postcode_prefix_parse({text(case['input'])})"
found.append((f"prefix {case['input']!r}", same(parsed, case["compact"])))
found.append((f"prefix format {case['input']!r}", same(f"ng_postcode_format({parsed})", case["canonical"])))
for case in spec["parse_prefix"]["invalid"]:
parsed = f"ng_postcode_prefix_parse({text(case['input'])})"
found.append((f"prefix reject {case['input']!r}", same(parsed, None)))
for case in spec["prefix_contains"]:
code, prefix = (f"ng_postcode_{kind}({text(case[key])})" for kind, key in (("parse", "code"), ("prefix_parse", "prefix")))
inside = "true" if case["contains"] else "false"
found.append((f"{case['prefix']} contains", f"ng_postcode_within({code}, {prefix}) IS NOT DISTINCT FROM {inside}"))
for case in spec["prefix_parent"]:
parent = f"ng_postcode_format(ng_postcode_parent(ng_postcode_prefix_parse({text(case['prefix'])})))"
found.append((f"parent of {case['prefix']}", same(parent, case["parent"])))
# Inputs that a locale-aware upper() or a Unicode-aware class would let through.
for sneaky in ("ek01a03fk0ı", "EK01A03FK01", "EK01A03FK01", "EK01A03FK01\n"):
found.append((f"reject {sneaky!r}", same(f"ng_postcode_parse({text(sneaky)})", None)))
found.append(("unknown segment", same("ng_postcode_truncate(ng_postcode_parse('EK01A03FK01'), 'zone')", None)))
return found


def postgres(expressions: list[str]) -> list[bool]:
script = (ROOT / "sql" / "postgres.sql").read_text(encoding="utf-8")
# In a transaction that is rolled back, so the database is left as it was found.
queries = "".join(f"SELECT {expression};\n" for expression in expressions)
run = subprocess.run(
["psql", "-X", "-q", "-At", "-v", "ON_ERROR_STOP=1"],
input=f"BEGIN;\n{script}\n{queries}ROLLBACK;\n",
capture_output=True,
text=True,
check=False,
)
if run.returncode != 0:
sys.exit(run.stderr)
return [line == "t" for line in run.stdout.splitlines()]


def duckdb(expressions: list[str]) -> list[bool]:
import duckdb as engine

with engine.connect() as db:
db.execute((ROOT / "sql" / "duckdb.sql").read_text(encoding="utf-8"))
return [bool(db.execute(f"SELECT {expression}").fetchone()[0]) for expression in expressions]


def main() -> None:
engine = {"postgres": postgres, "duckdb": duckdb}[sys.argv[1]]
cases = checks()
answers = engine([expression for _, expression in cases])
if len(answers) != len(cases):
sys.exit(f"{sys.argv[1]}: {len(answers)} answers for {len(cases)} cases")
failed = [name for (name, _), agreed in zip(cases, answers, strict=True) if not agreed]
if failed:
sys.exit(f"{sys.argv[1]} disagrees with spec/vectors.json:\n " + "\n ".join(failed))
print(f"{sys.argv[1]}: {len(cases)} cases agree")


if __name__ == "__main__":
main()
54 changes: 54 additions & 0 deletions sql/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# ng-postcode for SQL

Nigeria's NDAPS postcode as a column type in PostgreSQL and DuckDB. Both files are plain SQL with no extension to build, so they load on managed services, and both give the same answers as the libraries: [`scripts/sql_conformance.py`](../scripts/sql_conformance.py) runs the shared cases in [`spec/vectors.json`](../spec/vectors.json) against them.

```sh
psql -f sql/postgres.sql # PostgreSQL, tested on 14 and 17
duckdb my.db < sql/duckdb.sql # DuckDB, tested on 1.5
```

## Store and check

```sql
CREATE TABLE customers (
id bigint PRIMARY KEY,
postcode ng_postcode -- refuses anything that is not a compact code
);

INSERT INTO customers VALUES (1, ng_postcode_parse('ek 01 a03 fk 01'));

SELECT postcode, ng_postcode_format(postcode) FROM customers;
-- EK01A03FK01 | EK-01-A03-FK-01
```

`ng_postcode_parse` accepts a hyphenated, spaced, or compact code in either case, and returns NULL for one that is not well formed. DuckDB has no domains, so declare the column as `text` with `CHECK (ng_postcode_parse(postcode) IS NOT DISTINCT FROM postcode)`. A plain `=` would let a malformed code through, because comparing with NULL is not false.

## Group by state, LGA, district, or area

A prefix is a code cut off after one of its segments. It needs no map: every building in a district shares that district's prefix.

```sql
SELECT ng_postcode_format(ng_postcode_truncate(postcode, 'district')) AS district, count(*)
FROM customers
GROUP BY 1;
-- EK-01-A03 | 1
```

To select one district, compare a range so that the column's index is used:

```sql
SELECT * FROM customers WHERE postcode >= 'EK01A03' AND postcode < 'EK01A03' || '~';
```

## Functions

| Function | Returns |
| --- | --- |
| `ng_postcode_parse(text)` | The compact code, or NULL |
| `ng_postcode_prefix_parse(text)` | The compact prefix, such as `EK01A03`, or NULL |
| `ng_postcode_format(code or prefix)` | The hyphenated form |
| `ng_postcode_truncate(code, segment)` | The prefix through `state`, `lga`, `district`, `area`, or `unit` |
| `ng_postcode_parent(prefix)` | The prefix one segment shorter, or NULL for a state |
| `ng_postcode_within(code, prefix)` | Whether the code starts with the prefix |

A well-formed code is not necessarily assigned to a building. Only the NIPOST API can confirm that, through one of the [libraries](../README.md).
45 changes: 45 additions & 0 deletions sql/duckdb.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- Nigeria's NDAPS postcode for DuckDB: macros with the same names and answers as
-- sql/postgres.sql.
--
-- .read sql/duckdb.sql
--
-- DuckDB has no domains, so keep a column to the compact form with a constraint:
-- CHECK (ng_postcode_parse(code) IS NOT DISTINCT FROM code). scripts/sql_conformance.py
-- runs the cases in spec/vectors.json against this file.

-- The letters and digits of a hyphenated, spaced or compact code, in upper case, or NULL
-- if it holds anything else.
CREATE OR REPLACE MACRO ng_postcode_compact(t) AS
CASE WHEN regexp_full_match(t, '[A-Za-z0-9 -]*') THEN translate(
t, 'abcdefghijklmnopqrstuvwxyz -', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') END;

-- The code in any style in its compact form, EK01A03FK01, or NULL if it is not well formed.
CREATE OR REPLACE MACRO ng_postcode_parse(t) AS
CASE WHEN regexp_full_match(ng_postcode_compact(t),
'[A-Z]{2}(0[1-9]|[1-9][0-9])[A-Z0-9]{3}[A-Z]{2}(0[1-9]|[1-9][0-9])')
THEN ng_postcode_compact(t) END;

-- A code cut off after one of its segments, such as the district EK01A03, or NULL.
CREATE OR REPLACE MACRO ng_postcode_prefix_parse(t) AS
CASE WHEN regexp_full_match(ng_postcode_compact(t),
'[A-Z]{2}((0[1-9]|[1-9][0-9])([A-Z0-9]{3}([A-Z]{2}(0[1-9]|[1-9][0-9])?)?)?)?')
THEN ng_postcode_compact(t) END;

-- The hyphenated form of a code or a prefix: EK-01-A03-FK-01, or EK-01-A03.
-- concat_ws() skips NULLs, so NULL is passed through by hand.
CREATE OR REPLACE MACRO ng_postcode_format(c) AS
CASE WHEN c IS NOT NULL THEN concat_ws('-', substr(c, 1, 2), nullif(substr(c, 3, 2), ''),
nullif(substr(c, 5, 3), ''), nullif(substr(c, 8, 2), ''), nullif(substr(c, 10, 2), '')) END;

-- The code cut off after a segment: 'state', 'lga', 'district', 'area' or 'unit'.
-- NULL for any other name.
CREATE OR REPLACE MACRO ng_postcode_truncate(c, through) AS
left(c, CASE through WHEN 'state' THEN 2 WHEN 'lga' THEN 4 WHEN 'district' THEN 7
WHEN 'area' THEN 9 WHEN 'unit' THEN 11 END);

-- The prefix one segment shorter, or NULL for a state.
CREATE OR REPLACE MACRO ng_postcode_parent(p) AS
left(p, CASE length(p) WHEN 11 THEN 9 WHEN 9 THEN 7 WHEN 7 THEN 4 WHEN 4 THEN 2 END);

-- Whether a code starts with a prefix.
CREATE OR REPLACE MACRO ng_postcode_within(c, p) AS starts_with(c, p);
Loading
Loading