Notes and Talks/UJEP/RDBS/02

A constrained (“tightened-up” 🔩🪛) data model

Let's start again from the beginning:

sqldrop table if exists continent, country, place, weather;

Data validation functions:

sql-- A name must not be empty or have leading or trailing spaces.
create function is_name(val text)
returns boolean as $$ begin
  return val != '' and val !~ '^\s' and val !~ '\s$';
end; $$ language plpgsql;

Stronger model with integrity constraints:

sqlcreate table continent (
  id   integer      primary key     check (0 < id),
  name varchar(100) not null unique check (is_name(name))
);

create table country (
  code         char(2)      primary key check (code ~ '^[A-Z]{2}$'),
  id_continent integer      not null references continent(id),
  name         varchar(100) not null unique check (is_name(name))
);

create table place (
  id           serial       primary key,

  code_country char(2)      references country(code), -- check not needed?
  name         varchar(100) not null check (is_name(name)),
  unique(code_country, name),

  latitude     decimal(8,5) not null check (latitude  between  -90 and 90),
  longitude    decimal(8,5) not null check (longitude between -180 and 180),
  elevation    int          not null check (-430 <= elevation and elevation <= 8000) -- Dead Sea shore: -430m
);

create table weather (
    id       serial primary key,
    id_place int    references place(id),
    date     date   not null,

    temp_lo  decimal(4,1) check (temp_lo between -90 and 90),
    temp_hi  decimal(4,1) check (temp_hi between -90 and 90),
    precip   decimal(6,2) check (precip is null or 0 <= precip and precip < 5000),

    check (temp_lo is null or temp_hi is null or temp_lo <= temp_hi),
    unique(id_place, date)
);

Let's try inserting the invalid test data again.

We haven't handled letter case yet. This should not be accepted:

sqlinsert into continent (id, name) values
  (9,  'Europe');

insert into country (code, id_continent, name) values
  ('QZ', 9, 'Queezee');
insert into country (code, id_continent, name) values
  ('QY', 9, 'QueeZee');

Letter case should not matter when comparing country or place names. This can be achieved with citext. Also, the use of varchar could be dropped in favour of the built-in type [text](https://www.postgresql.org/docs/current/datatype-character.html) or citext.

DRY – Don't Repeat Yourself

Domains restrict common data types. Refined model that uses domains:

sql-- case insensitive text
create extension if not exists citext;

drop table if exists continent, country, place, weather;

-- validation functions

create or replace function is_name(val text)
returns boolean as $$ begin
  return val != '' and val !~ '^\s' and val !~ '\s$';
end; $$ language plpgsql;

-- custom domains

-- name, max. length 100 chars
drop domain if exists d_name;
create domain d_name as citext not null check (length(value) <= 100 and is_name(value));

-- temperature
drop domain if exists d_temp;
create domain d_temp as numeric(4,1) check (value between -90 and 90);

-- 8-region, continent-like world model, weather data

create table continent (
  id   integer  primary key check (0 < id),
  name d_name   unique
);

create table country (
  code         char(2)  primary key check (code ~ '^[A-Z]{2}$'),
  id_continent integer  not null references continent(id),
  name         d_name   unique
);

create table place (
  id           serial  primary key,

  code_country char(2) references country(code),
  name         d_name,
  unique(code_country, name),

  -- Also domains?
  latitude     numeric(8,5) not null check (latitude  between  -90 and 90),
  longitude    numeric(8,5) not null check (longitude between -180 and 180),
  elevation    int          not null check (-430 <= elevation and elevation <= 8000) -- Dead Sea shore -430m
);

create table weather (
    id       serial primary key,
    id_place int    references place(id),
    date     date   not null,

    temp_lo  d_temp,
    temp_hi  d_temp,
    precip   decimal(6,2) check (precip is null or 0 <= precip and precip < 5000),

    check (temp_lo is null or temp_hi is null or temp_lo <= temp_hi),
    unique(id_place, date)
);

Test data:

sqlinsert into continent (id, name) values
  (1, 'Africa'),
  (2, 'Antarctica'),
  (3, 'Arctic'),
  (4, 'Asia'),
  (5, 'Europe'),
  (6, 'North America'),
  (7, 'South America'),
  (8, 'Oceania');

insert into country (code, id_continent, name) values
  ('ZA', 1, 'South Africa'),
  ('EG', 1, 'Egypt'),
  ('KE', 1, 'Kenya'),

  ('AQ', 2, 'Antarctica'),

  ('GL', 3, 'Greenland'),
  ('SJ', 3, 'Svalbard and Jan Mayen'),

  ('CN', 4, 'China'),
  ('IN', 4, 'India'),
  ('JP', 4, 'Japan'),
  ('SA', 4, 'Saudi Arabia'),

  ('DE', 5, 'Germany'),
  ('FR', 5, 'France'),
  ('IT', 5, 'Italy'),
  ('ES', 5, 'Spain'),
  ('CZ', 5, 'Czechia'),
  ('SK', 5, 'Slovakia'),
  ('HU', 5, 'Hungary'),
  ('SI', 5, 'Slovenia'),
  ('RS', 5, 'Serbia'),
  ('ME', 5, 'Montenegro'),
  ('IS', 5, 'Iceland'),
  ('EE', 5, 'Estonia'),
  ('LV', 5, 'Latvia'),
  ('LT', 5, 'Lithuania'),
  ('UA', 5, 'Ukraine'),
  ('BY', 5, 'Belarus'),
  ('PL', 5, 'Poland'),
  ('GB', 5, 'United Kingdom'),
  ('IE', 5, 'Ireland'),
  ('NO', 5, 'Norway'),
  ('SE', 5, 'Sweden'),
  ('FI', 5, 'Finland'),
  ('RU', 5, 'Russia'),

  ('US', 6, 'United States'),
  ('CA', 6, 'Canada'),
  ('MX', 6, 'Mexico'),

  ('BR', 7, 'Brazil'),
  ('AR', 7, 'Argentina'),
  ('CL', 7, 'Chile'),
  ('CO', 7, 'Colombia'),
  ('PE', 7, 'Peru'),

  ('AU', 8, 'Australia'),
  ('NZ', 8, 'New Zealand'),
  ('FJ', 8, 'Fiji'),
  ('PG', 8, 'Papua New Guinea');

insert into place (name, code_country, latitude, longitude, elevation) values
  ('Praha', 'CZ', 50.07554, 14.43780, 235),
  ('Brno', 'CZ', 49.19506, 16.60684, 190),
  ('Ostrava', 'CZ', 49.82092, 18.26252, 214),
  ('Plzeň', 'CZ', 49.73843, 13.37364, 310),
  ('Liberec', 'CZ', 50.76711, 15.05619, 374),
  ('Olomouc', 'CZ', 49.59378, 17.25088, 219),
  ('České Budějovice',  'CZ', 48.97473, 14.47433, 381),
  ('Hradec Králové', 'CZ', 50.20923, 15.83275, 235),
  ('Pardubice', 'CZ', 50.03431, 15.78120, 225),
  ('Ústí nad Labem', 'CZ', 50.66070, 14.03227, 218),
  ('Berlin', 'DE', 52.52000, 13.40500, 34),
  ('Paris', 'FR', 48.85661, 2.35222, 35),
  ('New York', 'US', 40.71278, -74.00597, 10),
  ('Los Angeles', 'US', 34.05223, -118.24368, 71),
  ('Toronto', 'CA', 43.65323, -79.38318, 76),
  ('Mexico City', 'MX', 19.43261, -99.13321, 2250),
  ('Tokyo', 'JP', 35.68949, 139.69171, 40),
  ('Sydney', 'AU', -33.86882, 151.20930, 58),
  ('Sao Paulo', 'BR', -23.55052, -46.63331, 760),
  ('Nairobi', 'KE', -1.29207, 36.82195, 1795),
  ('Cairo', 'EG', 30.04442, 31.23571, 23),
  ('Reykjavik', 'IS', 64.14658, -21.94264, 46),
  ('Cape Town', 'ZA', -33.92487, 18.42406, 25),
  ('Auckland', 'NZ', -36.84846, 174.76333, 79);

insert into weather (id_place, date, temp_lo, temp_hi, precip)
select p.id, w.dt, w.temp_lo, w.temp_hi, w.precip
from (
  values
    ('Praha', date '2024-01-01', -3.2, 2.5, 0.00),
    ('Praha', date '2024-01-02', -4.0, 1.8, 0.50),
    ('Praha', date '2024-07-15', 16.4, 28.3, 2.20),
    ('London', date '2024-01-01', 3.2, 8.1, 1.80),
    ('London', date '2024-07-15', 14.5, 22.0, 0.40),
    ('New York', date '2024-01-01', -2.3, 4.0, 3.10),
    ('New York', date '2024-07-15', 22.1, 30.5, 5.60),
    ('Tokyo', date '2024-01-01', 2.4, 10.2, 0.00),
    ('Tokyo', date '2024-07-15', 25.0, 31.8, 6.30),
    ('Sydney', date '2024-01-01', 18.2, 26.5, 1.20),
    ('Sydney', date '2024-07-15', 8.8, 17.2, 0.70),
    ('Nairobi', date '2024-01-01', 15.1, 26.8, 0.00),
    ('Reykjavik', date '2024-01-01', -4.6, 1.2, 2.50),
    ('Mexico City', date '2024-07-15', 12.9, 24.7, 8.10),
    ('Cape Town', date '2024-07-15', 8.2, 16.3, 3.70),
    ('Auckland', date '2024-01-01', 16.5, 23.0, 0.90)
) as w(name, dt, temp_lo, temp_hi, precip)
join place p on p.name = w.name;

Is there still anything not quite right with our model?