Create validation function for SQL domains and for phones

When i wrote the functions to import contact, i already created a couple
of “temporary” functions to validate whether the input given from the
Excel files was correct according to the various domains used in the
relations, so i can know whether i can import that data.

I realized that i could do exactly the same when validating forms: check
that the value conforms to the domain, in the exact same way, so i can
make sure that the value will be accepted without duplicating the logic,
at the expense of a call to the database.

In an ideal world, i would use pg_input_is_valid, but this function is
only available in PostgreSQL 16 and Debian 12 uses PostgreSQL 15.

These functions are in the public schema because initially i wanted to
use them to also validate email, which is needed in the login form, but
then i recanted and kept the same email validation in Go, because
something felt off about using the database for that particular form,
but i do not know why.
This commit is contained in:
jordi fita mas 2023-07-03 11:31:59 +02:00
parent 2320cae3f4
commit ef8f40e734
13 changed files with 189 additions and 59 deletions

View File

@ -8,6 +8,8 @@
-- requires: contact_iban
-- requires: contact_swift
-- requires: contact_tax_details
-- requires: input_is_valid
-- requires: input_is_valid_phone
begin;
@ -82,21 +84,6 @@ begin
set tags = array_cat(contact.tags, excluded.tags)
;
-- TODO: use pg_input_is_valid with PostgreSQL 16
create or replace function pg_temp.input_is_valid(input text, typename text) returns bool as
$func$
begin
begin
execute format('select %L::%s', input, typename);
return true;
exception when others then
return false;
end;
end;
$func$
language plpgsql
immutable;
insert into contact_tax_details (contact_id, business_name, vatin, address, city, province, postal_code, country_code)
select contact_id, imported_contact.name, (country_code || vatin)::vatin, address, city, province, postal_code, country_code
from imported_contact
@ -106,7 +93,7 @@ begin
and length(city) > 1
and length(province) > 1
and postal_code ~ postal_code_regex
and pg_temp.input_is_valid(country_code || vatin, 'vatin')
and input_is_valid(country_code || vatin, 'vatin')
on conflict (contact_id) do update
set business_name = excluded.business_name
, vatin = excluded.vatin
@ -121,7 +108,7 @@ begin
select contact_id, email::email
from imported_contact
where contact_id is not null
and pg_temp.input_is_valid(email, 'email')
and input_is_valid(email, 'email')
on conflict (contact_id) do update
set email = excluded.email
;
@ -130,7 +117,7 @@ begin
select contact_id, web::uri
from imported_contact
where contact_id is not null
and pg_temp.input_is_valid(web, 'uri')
and input_is_valid(web, 'uri')
and length(web) > 1
on conflict (contact_id) do update
set uri = excluded.uri
@ -140,7 +127,7 @@ begin
select contact_id, iban::iban
from imported_contact
where contact_id is not null
and pg_temp.input_is_valid(iban, 'iban')
and input_is_valid(iban, 'iban')
on conflict (contact_id) do update
set iban = excluded.iban
;
@ -149,31 +136,16 @@ begin
select contact_id, bic::bic
from imported_contact
where contact_id is not null
and pg_temp.input_is_valid(bic, 'bic')
and input_is_valid(bic, 'bic')
on conflict (contact_id) do update
set bic = excluded.bic
;
-- TODO: use pg_input_is_valid with PostgreSQL 16
create or replace function pg_temp.phone_is_valid(phone text, country text) returns bool as
$func$
begin
begin
perform parse_packed_phone_number(phone, country);
return true;
exception when others then
return false;
end;
end;
$func$
language plpgsql
immutable;
insert into contact_phone (contact_id, phone)
select contact_id, parse_packed_phone_number(phone, case when country_code = '' then 'ES' else country_code end)
from imported_contact
where contact_id is not null
and pg_temp.phone_is_valid(phone, case when country_code = '' then 'ES' else country_code end)
and input_is_valid_phone(phone, case when country_code = '' then 'ES' else country_code end)
on conflict (contact_id) do update
set phone = excluded.phone
;

23
deploy/input_is_valid.sql Normal file
View File

@ -0,0 +1,23 @@
-- Deploy numerus:input_is_valid to pg
-- requires: schema_numerus
-- requires: roles
begin;
set search_path to public;
create or replace function input_is_valid(input text, domname text) returns boolean as
$$
begin
begin
execute format('select %L::%s', input, domname);
return true;
exception when others then
return false;
end;
end;
$$
language plpgsql
stable;
commit;

View File

@ -0,0 +1,24 @@
-- Deploy numerus:input_is_valid_phone to pg
-- requires: schema_numerus
-- requires: roles
-- requires: extension_pg_libphonenumber
begin;
set search_path to public;
create or replace function input_is_valid_phone(phone text, country text) returns boolean as
$$
begin
begin
perform parse_packed_phone_number(phone, country);
return true;
exception when others then
return false;
end;
end;
$$
language plpgsql
stable;
commit;

View File

@ -275,10 +275,10 @@ func (form *taxDetailsForm) Validate(ctx context.Context, conn *Conn) bool {
validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale))
validator.CheckInputMinLength(form.BusinessName, 2, gettext("Business name must have at least two letters.", form.locale))
if validator.CheckRequiredInput(form.VATIN, gettext("VAT number can not be empty.", form.locale)) {
validator.CheckValidVATINInput(form.VATIN, country, gettext("This value is not a valid VAT number.", form.locale))
validator.CheckValidVATINInput(ctx, conn, form.VATIN, country, gettext("This value is not a valid VAT number.", form.locale))
}
if validator.CheckRequiredInput(form.Phone, gettext("Phone can not be empty.", form.locale)) {
validator.CheckValidPhoneInput(form.Phone, country, gettext("This value is not a valid phone number.", form.locale))
validator.CheckValidPhoneInput(ctx, conn, form.Phone, country, gettext("This value is not a valid phone number.", form.locale))
}
if validator.CheckRequiredInput(form.Email, gettext("Email can not be empty.", form.locale)) {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale))

View File

@ -380,10 +380,11 @@ func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool {
if validator.CheckValidSelectOption(form.Country, gettext("Selected country is not valid.", form.locale)) {
country = form.Country.Selected[0]
}
validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale))
validator.CheckInputMinLength(form.BusinessName, 2, gettext("Business name must have at least two letters.", form.locale))
if validator.CheckRequiredInput(form.BusinessName, gettext("Business name can not be empty.", form.locale)) {
validator.CheckInputMinLength(form.BusinessName, 2, gettext("Business name must have at least two letters.", form.locale))
}
if validator.CheckRequiredInput(form.VATIN, gettext("VAT number can not be empty.", form.locale)) {
validator.CheckValidVATINInput(form.VATIN, country, gettext("This value is not a valid VAT number.", form.locale))
validator.CheckValidVATINInput(ctx, conn, form.VATIN, country, gettext("This value is not a valid VAT number.", form.locale))
}
validator.CheckRequiredInput(form.Address, gettext("Address can not be empty.", form.locale))
validator.CheckRequiredInput(form.City, gettext("City can not be empty.", form.locale))
@ -394,11 +395,12 @@ func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool {
}
}
validator.CheckRequiredInput(form.Name, gettext("Name can not be empty.", form.locale))
validator.CheckInputMinLength(form.Name, 2, gettext("Name must have at least two letters.", form.locale))
if validator.CheckRequiredInput(form.Name, gettext("Name can not be empty.", form.locale)) {
validator.CheckInputMinLength(form.Name, 2, gettext("Name must have at least two letters.", form.locale))
}
if form.Phone.Val != "" {
validator.CheckValidPhoneInput(form.Phone, country, gettext("This value is not a valid phone number.", form.locale))
validator.CheckValidPhoneInput(ctx, conn, form.Phone, country, gettext("This value is not a valid phone number.", form.locale))
}
if form.Email.Val != "" {
validator.CheckValidEmailInput(form.Email, gettext("This value is not a valid email. It should be like name@domain.com.", form.locale))
@ -407,10 +409,10 @@ func (form *contactForm) Validate(ctx context.Context, conn *Conn) bool {
validator.CheckValidURL(form.Web, gettext("This value is not a valid web address. It should be like https://domain.com/.", form.locale))
}
if form.IBAN.Val != "" {
validator.CheckValidIBANInput(form.IBAN, gettext("This values is not a valid IBAN.", form.locale))
validator.CheckValidIBANInput(ctx, conn, form.IBAN, gettext("This values is not a valid IBAN.", form.locale))
}
if form.BIC.Val != "" {
validator.CheckValidBICInput(form.IBAN, gettext("This values is not a valid BIC.", form.locale))
validator.CheckValidBICInput(ctx, conn, form.BIC, gettext("This values is not a valid BIC.", form.locale))
}
return validator.AllOK()

View File

@ -441,24 +441,20 @@ func (v *FormValidator) CheckValidEmailInput(field *InputField, message string)
return v.checkInput(field, err == nil, message)
}
func (v *FormValidator) CheckValidVATINInput(field *InputField, country string, message string) bool {
// TODO: actual VATIN validation
return v.checkInput(field, true, message)
func (v *FormValidator) CheckValidVATINInput(ctx context.Context, conn *Conn, field *InputField, country string, message string) bool {
return v.checkInput(field, conn.MustGetBool(ctx, "select input_is_valid($1 || $2, 'vatin')", country, field.Val), message)
}
func (v *FormValidator) CheckValidPhoneInput(field *InputField, country string, message string) bool {
// TODO: actual phone validation
return v.checkInput(field, true, message)
func (v *FormValidator) CheckValidPhoneInput(ctx context.Context, conn *Conn, field *InputField, country string, message string) bool {
return v.checkInput(field, conn.MustGetBool(ctx, "select input_is_valid_phone($1, $2)", field.Val, country), message)
}
func (v *FormValidator) CheckValidIBANInput(field *InputField, message string) bool {
// TODO: actual IBAN validation
return v.checkInput(field, true, message)
func (v *FormValidator) CheckValidIBANInput(ctx context.Context, conn *Conn, field *InputField, message string) bool {
return v.checkInput(field, conn.MustGetBool(ctx, "select input_is_valid($1, 'iban')", field.Val), message)
}
func (v *FormValidator) CheckValidBICInput(field *InputField, message string) bool {
// TODO: actual BIC validation
return v.checkInput(field, true, message)
func (v *FormValidator) CheckValidBICInput(ctx context.Context, conn *Conn, field *InputField, message string) bool {
return v.checkInput(field, conn.MustGetBool(ctx, "select input_is_valid($1, 'bic')", field.Val), message)
}
func (v *FormValidator) CheckPasswordConfirmation(password *InputField, confirm *InputField, message string) bool {

View File

@ -0,0 +1,7 @@
-- Revert numerus:input_is_valid from pg
begin;
drop function if exists public.input_is_valid(text, text);
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:input_is_valid_phone from pg
begin;
drop function if exists public.input_is_valid_phone(text, text);
commit;

View File

@ -111,4 +111,6 @@ bic [schema_numerus] 2023-07-01T22:46:30Z jordi fita mas <jordi@tandem.blog> # A
contact_swift [schema_numerus roles contact bic] 2023-07-01T23:03:13Z jordi fita mas <jordi@tandem.blog> # Add relation for contacts SWIFT-BIC
add_contact [add_contact@v0 tax_details contact_web contact_email contact_phone contact_iban contact_swift] 2023-06-29T11:10:15Z jordi fita mas <jordi@tandem.blog> # Change add contact to accept a tax_detail parameter and use the new relations for web, email, phone, iban, and swift
edit_contact [edit_contact@v0 tax_details contact_web contact_email contact_phone contact_iban contact_swift] 2023-06-29T11:50:41Z jordi fita mas <jordi@tandem.blog> # Change edit_contact to require tax_details parameter and to use new relations for web, email, phone, iban, and swift
import_contact [schema_numerus roles contact contact_web contact_phone contact_email contact_iban contact_swift contact_tax_details] 2023-07-02T18:50:22Z jordi fita mas <jordi@tandem.blog> # Add functions to massively import customer data
input_is_valid [schema_public roles] 2023-07-03T08:42:46Z jordi fita mas <jordi@tandem.blog> # add function to check if input is valid for a domain
input_is_valid_phone [schema_public roles extension_pg_libphonenumber] 2023-07-03T08:59:36Z jordi fita mas <jordi@tandem.blog> # add function to validate phone number inputs
import_contact [schema_numerus roles contact contact_web contact_phone contact_email contact_iban contact_swift contact_tax_details input_is_valid input_is_valid_phone] 2023-07-02T18:50:22Z jordi fita mas <jordi@tandem.blog> # Add functions to massively import customer data

53
test/input_is_valid.sql Normal file
View File

@ -0,0 +1,53 @@
-- Test input_is_valid
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(36);
set search_path to numerus, public;
select has_function('public', 'input_is_valid', array ['text', 'text']);
select function_lang_is('public', 'input_is_valid', array ['text', 'text'], 'plpgsql');
select function_returns('public', 'input_is_valid', array ['text', 'text'], 'boolean');
select isnt_definer('public', 'input_is_valid', array ['text', 'text']);
select volatility_is('public', 'input_is_valid', array ['text', 'text'], 'stable');
select function_privs_are('public', 'input_is_valid', array ['text', 'text'], 'guest', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid', array ['text', 'text'], 'invoicer', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid', array ['text', 'text'], 'admin', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid', array ['text', 'text'], 'authenticator', array ['EXECUTE']);
select is( input_is_valid('123', 'integer'), true );
select is( input_is_valid('abc', 'integer'), false );
select is( input_is_valid('abc', 'email'), false );
select is( input_is_valid('ESabc', 'vatin'), false );
select is( input_is_valid('abc', 'iban'), false );
select is( input_is_valid('abc', 'bic'), false );
select is( input_is_valid('abc', 'text'), true );
select is( input_is_valid('ES44444444A', 'vatin'), true );
select is( input_is_valid('ES44444444A', 'text'), true );
select is( input_is_valid('ES44444444A', 'email'), false );
select is( input_is_valid('ES44444444A', 'iban'), false );
select is( input_is_valid('ES44444444A', 'bic'), false );
select is( input_is_valid('NL04RABO9373475770', 'iban'), true );
select is( input_is_valid('NL04RABO9373475770', 'text'), true );
select is( input_is_valid('ESNL04RABO9373475770', 'vatin'), false );
select is( input_is_valid('NL04RABO9373475770', 'email'), false );
select is( input_is_valid('NL04RABO9373475770', 'bic'), false );
select is( input_is_valid('ARBNNL22', 'bic'), true );
select is( input_is_valid('ARBNNL22', 'text'), true );
select is( input_is_valid('ESARBNNL22', 'vatin'), false );
select is( input_is_valid('ARBNNL22', 'email'), false );
select is( input_is_valid('ARBNNL22', 'iban'), false );
select is( input_is_valid('2023-05-12', 'text'), true );
select is( input_is_valid('2023-05-12', 'date'), true );
select is( input_is_valid('2023-05-12', 'integer'), false );
select is( input_is_valid('', 'text'), true );
select is( input_is_valid('', 'inexistent'), false );
select *
from finish();
rollback;

View File

@ -0,0 +1,30 @@
-- Test input_is_valid_phone
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(12);
set search_path to numerus, public;
select has_function('public', 'input_is_valid_phone', array ['text', 'text']);
select function_lang_is('public', 'input_is_valid_phone', array ['text', 'text'], 'plpgsql');
select function_returns('public', 'input_is_valid_phone', array ['text', 'text'], 'boolean');
select isnt_definer('public', 'input_is_valid_phone', array ['text', 'text']);
select volatility_is('public', 'input_is_valid_phone', array ['text', 'text'], 'stable');
select function_privs_are('public', 'input_is_valid_phone', array ['text', 'text'], 'guest', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid_phone', array ['text', 'text'], 'invoicer', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid_phone', array ['text', 'text'], 'admin', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid_phone', array ['text', 'text'], 'authenticator', array ['EXECUTE']);
select is( input_is_valid_phone('555-555-5555', 'US'), true );
select is( input_is_valid_phone('555-555-5555555555', 'US'), false );
select is( input_is_valid_phone('555-555-55555555555', 'US'), false );
select *
from finish();
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:input_is_valid on pg
begin;
select has_function_privilege('public.input_is_valid(text, text)', 'execute');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:input_is_valid_phone on pg
begin;
select has_function_privilege('public.input_is_valid_phone(text, text)', 'execute');
rollback;