From f4e546755101d774124006239179fe89de66d6de Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:25:51 +0100 Subject: [PATCH 1/3] feat(sql): add the postcode as a PostgreSQL domain with functions --- .github/workflows/ci.yml | 7 +++ scripts/sql_conformance.py | 99 ++++++++++++++++++++++++++++++++++++++ sql/postgres.sql | 70 +++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 scripts/sql_conformance.py create mode 100644 sql/postgres.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ef0806..c133be1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,13 @@ 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 mcp: runs-on: ubuntu-latest diff --git a/scripts/sql_conformance.py b/scripts/sql_conformance.py new file mode 100644 index 0000000..ee0ff50 --- /dev/null +++ b/scripts/sql_conformance.py @@ -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() diff --git a/sql/postgres.sql b/sql/postgres.sql new file mode 100644 index 0000000..08d31e9 --- /dev/null +++ b/sql/postgres.sql @@ -0,0 +1,70 @@ +-- Nigeria's NDAPS postcode for PostgreSQL (tested on 14 and 17): two domains and a few functions. +-- Plain SQL, so it loads on managed services that accept no extensions: +-- +-- psql -f sql/postgres.sql +-- +-- Store the compact form in an ng_postcode column. Behaviour follows spec/vectors.json, +-- as the libraries do; scripts/sql_conformance.py runs those cases against this file. + +-- A well-formed code in its compact form, EK01A03FK01. Well formed is not assigned: +-- only the NIPOST API knows whether a code belongs to a building. +CREATE DOMAIN ng_postcode AS text COLLATE "C" + CHECK (VALUE ~ '^[A-Z]{2}(0[1-9]|[1-9][0-9])[A-Z0-9]{3}[A-Z]{2}(0[1-9]|[1-9][0-9])$'); + +-- A code cut off after one of its segments, such as the district EK01A03. A whole code +-- is the narrowest prefix. +CREATE DOMAIN ng_postcode_prefix AS text COLLATE "C" + CHECK (VALUE ~ '^[A-Z]{2}((0[1-9]|[1-9][0-9])([A-Z0-9]{3}([A-Z]{2}(0[1-9]|[1-9][0-9])?)?)?)?$'); + +-- The letters and digits of a hyphenated, spaced or compact code, in upper case, or NULL +-- if it holds anything else. translate() is used because upper() follows the locale. +CREATE FUNCTION ng_postcode_compact(text) RETURNS text + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT CASE WHEN $1 COLLATE "C" ~ '^[A-Za-z0-9 -]*$' THEN translate( + $1, 'abcdefghijklmnopqrstuvwxyz -', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') END +$$; + +-- The code in any style as an ng_postcode, or NULL if it is not well formed. +CREATE FUNCTION ng_postcode_parse(text) RETURNS ng_postcode + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT CASE WHEN c ~ '^[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 c::ng_postcode END + FROM (SELECT ng_postcode_compact($1) COLLATE "C") AS scanned(c) +$$; + +-- The prefix in any style as an ng_postcode_prefix, or NULL if it is not well formed. +CREATE FUNCTION ng_postcode_prefix_parse(text) RETURNS ng_postcode_prefix + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT CASE WHEN c ~ '^[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 c::ng_postcode_prefix END + FROM (SELECT ng_postcode_compact($1) COLLATE "C") AS scanned(c) +$$; + +-- The hyphenated form of a code or a prefix: EK-01-A03-FK-01, or EK-01-A03. +CREATE FUNCTION ng_postcode_format(ng_postcode_prefix) RETURNS text + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT concat_ws('-', substr($1, 1, 2), nullif(substr($1, 3, 2), ''), + nullif(substr($1, 5, 3), ''), nullif(substr($1, 8, 2), ''), nullif(substr($1, 10, 2), '')) +$$; + +-- The code cut off after a segment: 'state', 'lga', 'district', 'area' or 'unit'. +-- NULL for any other name. +CREATE FUNCTION ng_postcode_truncate(ng_postcode, through text) RETURNS ng_postcode_prefix + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT left($1, CASE through WHEN 'state' THEN 2 WHEN 'lga' THEN 4 WHEN 'district' THEN 7 + WHEN 'area' THEN 9 WHEN 'unit' THEN 11 END)::ng_postcode_prefix +$$; + +-- The prefix one segment shorter, or NULL for a state. +CREATE FUNCTION ng_postcode_parent(ng_postcode_prefix) RETURNS ng_postcode_prefix + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT left($1, CASE length($1) WHEN 11 THEN 9 WHEN 9 THEN 7 WHEN 7 THEN 4 WHEN 4 THEN 2 END) + ::ng_postcode_prefix +$$; + +-- Whether a code starts with a prefix. In a WHERE clause, a range on the column uses its +-- index: code >= prefix AND code < prefix || '~'. +CREATE FUNCTION ng_postcode_within(ng_postcode, ng_postcode_prefix) RETURNS boolean + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT left($1, length($2)) = $2 +$$; From 85e1ce8ecd4e441f155bb67f9faa391d52480890 Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:25:51 +0100 Subject: [PATCH 2/3] feat(sql): add the same functions as DuckDB macros --- .github/workflows/ci.yml | 4 ++++ sql/duckdb.sql | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 sql/duckdb.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c133be1..4f52e5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,10 @@ jobs: 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 diff --git a/sql/duckdb.sql b/sql/duckdb.sql new file mode 100644 index 0000000..ae59722 --- /dev/null +++ b/sql/duckdb.sql @@ -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); From 50fea81dd44fb4d2995bbc1beea84d0167c026fe Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:25:51 +0100 Subject: [PATCH 3/3] docs(sql): show how to store, check, and group codes by prefix --- README.md | 2 ++ docs/llms-full.txt | 62 ++++++++++++++++++++++++++++++++++++++++++++ docs/llms.txt | 1 + scripts/llms_full.py | 1 + sql/README.md | 54 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+) create mode 100644 sql/README.md diff --git a/README.md b/README.md index 1b7d6b0..35bd28f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index a67934d..d5e948e 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -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 @@ -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. @@ -857,3 +860,62 @@ Each address uses at most one Claude call at low effort, up to three geocoder se ## License MIT + +--- + + + +# 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). diff --git a/docs/llms.txt b/docs/llms.txt index 7ec1c1f..4a005df 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -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 diff --git a/scripts/llms_full.py b/scripts/llms_full.py index 61b9895..70815af 100644 --- a/scripts/llms_full.py +++ b/scripts/llms_full.py @@ -23,6 +23,7 @@ "mcp/README.md", "js/packages/ng-postcode-mcp/README.md", "agent/README.md", + "sql/README.md", ] diff --git a/sql/README.md b/sql/README.md new file mode 100644 index 0000000..111eceb --- /dev/null +++ b/sql/README.md @@ -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).