DRY for ActiveRecord w/PostgreSQL : defaults and validations

Lugovoi Nikolai
Mon, 19 Sep 2005 08:26:24 -0000

As I''ve already posted some ideas to this list
(see http://article.gmane.org/gmane.comp.lang.ruby.rails/22231),
  here''s continuation.

I think, that properly developed DB schema has to include all
(or almost all) constraints and validation rules for our particular
domain data model.
Rails gives very handy and useful ways to support various callbacks and
validations on data model objects, but as for me, some points and
solutions are neat when business rules and data integrity are held
rather in application code than in DBMS (as seen in many PHP+MySQL
apps). Part of such issues can be more effectively handled on DBMS
server side (I mean triggers, proper referential integrity support,
stored procedures etc.).

In PostgreSQL practically full information about schema, intertable
relationships and constraints can be extracted and injected to Rails app.

Why to repeat yourself? If data constraints are already in schema,
why not query DB to validate data before insert or update?
Why not query DB for record defaults defined in schema?
Why not retrieve associations defined by foreign keys from schema and
inject them?

So, here are my ideas.
---------------
1. ActiveRecord::Base.default_new - creates record, filled with
defaults, defined in DB.
It can be done in such manner:
         rows=exec(DEFAULTS_query) # see queries below
         new_record=find_by_sql("SELECT #{rows.join('','')}
")

Benefits:
         * no need to convert db defaults to Ruby expr,
         * values even for complex defaults with functions calls can be
easily retrieved
         * autogenerated serial ids come from db

--------------
2. Validation on INSERT OR UPDATE directly against database table
constraints:
         a) value type and NOT NULL constraints can be checked in app
from column definitions (and already are?)
         b) char(n)/varchar(n) size violations can be checked in app, as
part of type check when setting attr value
         c) CHECK, UNIQUE, PRIMARY KEY, FOREIGN KEY constraints can be
checked against "VIRTUAL" row, see below

Example:
CREATE TABLE Groups (
         id serial primary key,
	name	varchar(200) not null
);
INSERT INTO GROUPS VALUES(12);
CREATE TABLE Users (
         id serial,
         login varchar(64) not null,
         group_id integer not null,
         birth_date date,
         constraint pkey_user primary key (id),
         constraint fkey_user_group foreign key (group_id) references
groups (id) on delete cascade,
         constraint uniq_user_login unique(login),
         constraint chek_user_age   check (birth_date + ''18
years''::interval < now())
);

Let new record be (100, ''smith'', 12,
''1986-10-30'')

It can be checked against table constraints before really doing insert.
Step 1. Construct SQL query t represent a virtual record:
SELECT
         100::integer as id,
         ''smith''::character varying(64) as login,
         12::integer as  group_id,
         ''1986-10-30''::date as birth_date

Step 2. Construct predicate list query to check constraints:
SELECT
         (birth_date + ''18 years''::interval < now()) as
VALID_chek_user_age,
         NOT EXISTS (SELECT 1 FROM users u WHERE u.login = virt.login)
as VALID_uniq_user_login,
         EXISTS (SELECT 1 FROM groups g WHERE g.id = virt.group_id) as
VALID_user_group,
         NOT EXISTS (SELECT 1 FROM users u WHERE u.id = virt.id) as
VALID_pkey_user

Step 3. Joining above two :)
SELECT
         -- CHECK constraint
         (birth_date + ''18 years''::interval < now()) as
VALID_chek_user_age,
         -- UNIQUE constraint
         NOT EXISTS (SELECT 1 FROM users u WHERE u.login = virt.login)
as VALID_uniq_user_login,
         -- FOREIGN KEY constraint
         EXISTS (SELECT 1 FROM groups g WHERE g.id = virt.group_id) as
VALID_user_group,
         -- PRIMARY KEY constraint
         NOT EXISTS (SELECT 1 FROM users u WHERE u.id = virt.id) as
VALID_pkey_user
FROM
         (SELECT
                 100::integer as id,
                 ''smith''::character varying(64) as login,
                 12::integer as  group_id,
                 ''1986-10-30''::date as birth_date
         ) as VIRT;

So, we can check if record is valid and display all possible flaws that
will lead
DB to exception when doing INSERT.

Well, for UPDATE, some changes needed:
         1. predicate for PRIMARY KEY constraint is changed to EXISTS.
         2. UNIQUE constraints will look smth like this:
         NOT EXISTS (SELECT 1 FROM users u WHERE u.login = virt.login
                 AND u.id <> virt.id)
         3. FOREIGN and CHECK w/o changes.
A possible special cases here are excluding BYTEA fields and FKEY checks
where
fields with permitted NULL occur.

In Rails model code it could look like this:
class User < ActiveRecord::Base
         set_new_db_defaults true # see first idea on defaults

         # validate_against_db (when, constraint_list, messages)
         validate_against_db  :create, :all,
                 [:pkey_user => ''Already exists!'',
                  :uniq_user_login => ''Login is already
used!'',
                  :fkey_user_group => ''Cannot add user to
nonexistent
group'',
                  :chek_user_age   => ''Too young to die!'' ]
end

---------------------

3.REF_FROM, REF_TO associations.

Information about foreign key constraints can be used to generate
scaffold models with ref_from, ref_to associations that can be then
easily converted to more semantic has_one, has_many, belongs_to,
has_and_belongs_to_many.
A more detailed study may show that it can be possible to fish them out
directly?

---Basic Schema investigation queries:---

COLUMN DEFINITIONS:
         select  attname as name,
                 format_type(atttypid, atttypmod) as sql_type,
                 attnotnull as not_null,
                 pg_get_expr(adbin, adrelid) as default_def
         from pg_attribute left join pg_attrdef on (attrelid = adrelid
and adnum=attnum)
         where attrelid = :TABLE_NAME ::regclass and not attisdropped
and attnum>0;

DEFAULTS:
         select  pg_get_expr(adbin, adrelid) || ''::'' ||
format_type(atttypid, atttypmod)
                 || '' as '' || quote_ident(attname) as def_v
         from pg_attribute left  join pg_attrdef on (attrelid = adrelid
and adnum=attnum)
         where attrelid = :TABLE_NAME ::regclass and not attisdropped
and attnum>0 and atthasdef

TABLE CONSTRAINTS:
         select conname, contype, pg_get_constraintdef(oid) as condef
         from pg_constraint where conrelid = :TABLE_NAME ::regclass;

FOREIGN KEYS:
         select  conname, contype, pg_get_constraintdef(oid) as condef,
                 (select relname from pg_class c1 where c1.oid =
conrelid) as ref_from_table,
                 (select relname from pg_class c2 where c2.oid =
confrelid) as ref_to_table
         from pg_constraint where conrelid = :TABLE_NAME ::regclass
	or confrelid = :TABLE_NAME :: regclass
         and contype = ''f'';

------------------------------------

Probably, if this list isn''t the proper place to this post, suggest
another one  :)