DEV Community

HAP
HAP

Posted on • Edited on

Compare table structures in two schemata

Bugs are the bane of programmers' existence. But like death and taxes... well, death, anyway*; you can't get away from them entirely. This is especially true of large application endeavors including those with multiple schemata, which is what I'll be discussing here.

So, our application is using multiple tenant schemata for our clients and the structure must match. We encountered a situation that possibly could have included checking table structures between our template schema and a customer schema. This led to the development of this query.

The query utilizes the information_schema which should be present in most RDBMS engines, so this should be fairly portable.

To use this query, you'll need to set two to three parameters (depending on the number of tables you wish to compare).

Let's now examine the query:

with check_params as ( select 'my-schema-to-check' as check_schema, -- put schema to check here! 'my-known-good-schema' as main_schema, -- put the known good schema here null::text as check_table -- put table name to check here (or null for all)! ), display_table as ( select coalesce(mn.table_name, ck.table_name) as table_name, case when mn.table_schema is null then ck.table_name || ' <null>' when ck.table_schema is null then 'null ' || ' <' || mn.table_name || '>' else mn.table_name end::text as disp_table_name from ( select table_schema, table_name from information_schema.tables cross join check_params cp where table_schema = cp.main_schema ) as mn full join ( select table_schema, table_name from information_schema.tables cross join check_params cp where table_schema = cp.check_schema ) as ck on ck.table_name = mn.table_name ), full_table_compare as ( select coalesce(t.check_schema, c.check_schema) || ' <' || coalesce(t.main_schema, c.main_schema) || '>' as "schema_name", (select dt.disp_table_name from display_table dt where dt.table_name = coalesce(t.table_name, c.table_name)) as "table_name", case when c.column_name is distinct from t.column_name then coalesce(c.column_name, 'null') || ' <' || coalesce(t.column_name, 'null') || '>' else t.column_name end::text as "column_name", case when c.udt_name is distinct from t.udt_name then coalesce(c.udt_name, 'null') || ' <' || coalesce(t.udt_name, 'null') || '>' else t.udt_name end::text as column_type, case when c.character_maximum_length is distinct from t.character_maximum_length then coalesce(c.character_maximum_length, 'null') || ' <' || coalesce(t.character_maximum_length, 'null') || '>' else t.character_maximum_length end::text as text_length, case when c.numeric_precision is distinct from t.numeric_precision then coalesce(c.numeric_precision, 'null') || ' <' || coalesce(t.numeric_precision, 'null') || '>' else t.numeric_precision end::text as precision, case when c.numeric_scale is distinct from t.numeric_scale then coalesce(c.numeric_scale, 'null') || ' <' || coalesce(t.numeric_scale, 'null') || '>' else t.numeric_scale end::text as scale, case when c.is_nullable is distinct from t.is_nullable then coalesce(c.is_nullable, 'null') || ' <' || coalesce(t.is_nullable, 'null') || '>' else t.is_nullable end::text as is_nullable, case when c.column_default is distinct from t.column_default then coalesce(c.column_default, 'null') || ' <' || coalesce(t.column_default, 'null') || '>' else t.column_default end::text as col_default from ( -- Get info from the "good" schema select cl.table_schema, cl.table_name, cl.column_name, cl.udt_name, coalesce(cl.character_maximum_length::text, 'null')::text as character_maximum_length, coalesce(cl.numeric_precision::text, 'null')::text as numeric_precision, coalesce(cl.numeric_scale::text, 'null')::text as numeric_scale, coalesce(cl.is_nullable::text, 'null')::text as is_nullable, regexp_replace(cl.column_default, cp.main_schema || '\.', '', 'g') as column_default, cp.check_schema, cp.main_schema from information_schema.columns cl cross join check_params cp where cl.table_schema = cp.main_schema and cl.table_name = coalesce(cp.check_table, cl.table_name) ) as t full join ( -- Get info from "suspect" schema select cl.table_schema, cl.table_name, cl.column_name, cl.udt_name, coalesce(cl.character_maximum_length::text, 'null')::text as character_maximum_length, coalesce(cl.numeric_precision::text, 'null')::text as numeric_precision, coalesce(cl.numeric_scale::text, 'null')::text as numeric_scale, coalesce(cl.is_nullable::text, 'null')::text as is_nullable, regexp_replace(cl.column_default, cp.check_schema || '\.', '', 'g') as column_default, cp.check_schema, cp.main_schema from information_schema.columns cl cross join check_params cp where cl.table_schema = cp.check_schema and cl.table_name = coalesce(cp.check_table, table_name) ) as c on c.table_name = t.table_name and c.column_name = t.column_name ) select schema_name, table_name, column_name, column_type, text_length, precision, scale, is_nullable, col_default from full_table_compare -- only report the differences where table_name ~ '<' or column_name ~ '<' or column_type ~ '<' or text_length ~ '<' or precision ~ '<' or scale ~ '<' or is_nullable ~ '<' or col_default ~ '<' order by table_name, column_name; 
Enter fullscreen mode Exit fullscreen mode

Wow.

Let's break it down.

check_params

with check_params as ( select 'my-schema-to-check' as check_schema, -- put schema to check here! 'my-known-good-schema' as main_schema, -- put the known good schema here null::text as check_table -- put table name to check here (or null for all)! ) 
Enter fullscreen mode Exit fullscreen mode

This is the CTE that will have our parameters. This was created so that we wouldn't have to attempt to try script variables and to only have to define the values once. Just replace the strings with the requisite schema names to compare all tables in both schemas. Replace the null::text for the check_table param with a table name to constrain to that one table.

display_table

display_table as ( select coalesce(mn.table_name, ck.table_name) as table_name, case when mn.table_schema is null then ck.table_name || ' <null>' when ck.table_schema is null then 'null ' || ' <' || mn.table_name || '>' else mn.table_name end::text as disp_table_name from ( select table_schema, table_name from information_schema.tables cross join check_params cp where table_schema = cp.main_schema ) as mn full join ( select table_schema, table_name from information_schema.tables cross join check_params cp where table_schema = cp.check_schema ) as ck on ck.table_name = mn.table_name ) 
Enter fullscreen mode Exit fullscreen mode

This CTE is designed to help with output. Because we are comparing if a table does or does not exist as well as its column existing or not, we have an issue. The table name is replicated on the information_schema.columns record for each column. So we want to display it correctly regardless of presence/absence of the table itself or for a column mismatch.

Why a full join? We want to compare both ways.

full_table_compare

full_table_compare as ( select coalesce(t.check_schema, c.check_schema) || ' <' || coalesce(t.main_schema, c.main_schema) || '>' as "schema_name", (select dt.disp_table_name from display_table dt where dt.table_name = coalesce(t.table_name, c.table_name)) as "table_name", case when c.column_name is distinct from t.column_name then coalesce(c.column_name, 'null') || ' <' || coalesce(t.column_name, 'null') || '>' else t.column_name end::text as "column_name", case when c.udt_name is distinct from t.udt_name then coalesce(c.udt_name, 'null') || ' <' || coalesce(t.udt_name, 'null') || '>' else t.udt_name end::text as column_type, case when c.character_maximum_length is distinct from t.character_maximum_length then coalesce(c.character_maximum_length, 'null') || ' <' || coalesce(t.character_maximum_length, 'null') || '>' else t.character_maximum_length end::text as text_length, case when c.numeric_precision is distinct from t.numeric_precision then coalesce(c.numeric_precision, 'null') || ' <' || coalesce(t.numeric_precision, 'null') || '>' else t.numeric_precision end::text as precision, case when c.numeric_scale is distinct from t.numeric_scale then coalesce(c.numeric_scale, 'null') || ' <' || coalesce(t.numeric_scale, 'null') || '>' else t.numeric_scale end::text as scale, case when c.is_nullable is distinct from t.is_nullable then coalesce(c.is_nullable, 'null') || ' <' || coalesce(t.is_nullable, 'null') || '>' else t.is_nullable end::text as is_nullable, case when c.column_default is distinct from t.column_default then coalesce(c.column_default, 'null') || ' <' || coalesce(t.column_default, 'null') || '>' else t.column_default end::text as col_default from ( -- Get info from the "good" schema select cl.table_schema, cl.table_name, cl.column_name, cl.udt_name, coalesce(cl.character_maximum_length::text, 'null')::text as character_maximum_length, coalesce(cl.numeric_precision::text, 'null')::text as numeric_precision, coalesce(cl.numeric_scale::text, 'null')::text as numeric_scale, coalesce(cl.is_nullable::text, 'null')::text as is_nullable, regexp_replace(cl.column_default, cp.main_schema || '\.', '', 'g') as column_default, cp.check_schema, cp.main_schema from information_schema.columns cl cross join check_params cp where cl.table_schema = cp.main_schema and cl.table_name = coalesce(cp.check_table, cl.table_name) ) as t full join ( -- Get info from "suspect" schema select cl.table_schema, cl.table_name, cl.column_name, cl.udt_name, coalesce(cl.character_maximum_length::text, 'null')::text as character_maximum_length, coalesce(cl.numeric_precision::text, 'null')::text as numeric_precision, coalesce(cl.numeric_scale::text, 'null')::text as numeric_scale, coalesce(cl.is_nullable::text, 'null')::text as is_nullable, regexp_replace(cl.column_default, cp.check_schema || '\.', '', 'g') as column_default, cp.check_schema, cp.main_schema from information_schema.columns cl cross join check_params cp where cl.table_schema = cp.check_schema and cl.table_name = coalesce(cp.check_table, table_name) ) as c on c.table_name = t.table_name and c.column_name = t.column_name ) 
Enter fullscreen mode Exit fullscreen mode

So there's two main parts to this query.

schema information

 select cl.table_schema, cl.table_name, cl.column_name, cl.udt_name, coalesce(cl.character_maximum_length::text, 'null')::text as character_maximum_length, coalesce(cl.numeric_precision::text, 'null')::text as numeric_precision, coalesce(cl.numeric_scale::text, 'null')::text as numeric_scale, coalesce(cl.is_nullable::text, 'null')::text as is_nullable, regexp_replace(cl.column_default, cp.main_schema || '\.', '', 'g') as column_default, cp.check_schema, cp.main_schema from information_schema.columns cl cross join check_params cp where cl.table_schema = cp.main_schema and cl.table_name = coalesce(cp.check_table, cl.table_name) 
Enter fullscreen mode Exit fullscreen mode

We need to get specific information from the information_schema regarding the columns for the target table(s) in the main or good or master or template schema (whatever you with to call it). This is by far not exhaustive, but it's a good place to start for basic structure comparisons. We also need to get the same information for the target, suspect, bad, or check schema (again, whatever you with to call it).

This information is:

  • table_schema : Namespace of the table
  • table_name : Name of the table
  • column_name : Name of the column
  • udt_name : Name of the (internal) data type of the column
  • character_maximum_length : Len of a char or varchar column. This will be null for a text type column.
  • numeric_precision : Precision of a numeric/decimal column
  • numeric_scale : Scale of a numeric/decimal column
  • is_nullable : YES if it can contain null else NO
  • column_default : The default value for the column

Some operations are done at these queries to make the comparison logic easier such as type changes or string scrubbing.

Full joins are again used to allow for bi-directional comparisons.

comparison logic

select coalesce(t.check_schema, c.check_schema) || ' <' || coalesce(t.main_schema, c.main_schema) || '>' as "schema_name", (select dt.disp_table_name from display_table dt where dt.table_name = coalesce(t.table_name, c.table_name)) as "table_name", case when c.column_name is distinct from t.column_name then coalesce(c.column_name, 'null') || ' <' || coalesce(t.column_name, 'null') || '>' else t.column_name end::text as "column_name", case when c.udt_name is distinct from t.udt_name then coalesce(c.udt_name, 'null') || ' <' || coalesce(t.udt_name, 'null') || '>' else t.udt_name end::text as column_type, case when c.character_maximum_length is distinct from t.character_maximum_length then coalesce(c.character_maximum_length, 'null') || ' <' || coalesce(t.character_maximum_length, 'null') || '>' else t.character_maximum_length end::text as text_length, case when c.numeric_precision is distinct from t.numeric_precision then coalesce(c.numeric_precision, 'null') || ' <' || coalesce(t.numeric_precision, 'null') || '>' else t.numeric_precision end::text as precision, case when c.numeric_scale is distinct from t.numeric_scale then coalesce(c.numeric_scale, 'null') || ' <' || coalesce(t.numeric_scale, 'null') || '>' else t.numeric_scale end::text as scale, case when c.is_nullable is distinct from t.is_nullable then coalesce(c.is_nullable, 'null') || ' <' || coalesce(t.is_nullable, 'null') || '>' else t.is_nullable end::text as is_nullable, case when c.column_default is distinct from t.column_default then coalesce(c.column_default, 'null') || ' <' || coalesce(t.column_default, 'null') || '>' else t.column_default end::text as col_default 
Enter fullscreen mode Exit fullscreen mode

The rubber meets the road here. This is where all of the comparisons of the records from the two schemata are done. Basically, it checks for differences and reports them in a specified manner. Any difference will be reported as
suspect_schema_value <good_schema_value> meaning that the "good" values will be surrounded by < and > characters.

If a column has a null value it will appear as null or <null> depending if the value originated from the "suspect" or "good" schema.

If the values are identical, then there will only be the plaintext value listed as no other indicators are necessary.

The use of IS DISTINCT FROM was crucial to simplify the comparisons. This is an operator that evaluates null as if it were a value instead of, well... null. So if we consider null to have a value then x IS DISTINCT FROM y would be similar to coalesce(x, 'x') != coalesce(y, 'y') and x IS NOT DISTINCT FROM y would be equivalent to coalesce(x, 'x') != coalesce(y, 'y'). Check out the document link for the official explanation.

assembly

select schema_name, table_name, column_name, column_type, text_length, precision, scale, is_nullable, col_default from full_table_compare -- only report the differences where table_name ~ '<' or column_name ~ '<' or column_type ~ '<' or text_length ~ '<' or precision ~ '<' or scale ~ '<' or is_nullable ~ '<' or col_default ~ '<' order by table_name, column_name; 
Enter fullscreen mode Exit fullscreen mode

This is a lot of data and, on a good day, it will be mostly if not all in sync. So we're really only interested in the differences. This final query only pulls the records from the output of the full_table_compare CTE but only those records where any column contains a < character denoting a difference. We're checking every column except schema_name as it will always have the format suspect_schema <good_schema> as an indicator of the origin of reported values.


Demo time!

Let's start by creating two schemata:

postgres=# create schema eep; CREATE SCHEMA postgres=# create schema opp; CREATE SCHEMA 
Enter fullscreen mode Exit fullscreen mode

Now we'll create some sample tables to play with.

postgres=# create table eep.ork postgres-# ( postgres(# id bigserial primary key, postgres(# label text, postgres(# created_ts timestamptz, postgres(# data jsonb postgres(# ); CREATE TABLE postgres=# create table eep.oof postgres-# ( postgres(# uniq_label text primary key postgres(# ); CREATE TABLE postgres=# create table opp.ork postgres-# ( postgres(# id bigserial primary key, postgres(# label text, postgres(# created_ts timestamptz, postgres(# data jsonb postgres(# ); CREATE TABLE postgres=# create table opp.ah_ah postgres-# ( postgres(# id uuid primary key, postgres(# authentication_header jsonb postgres(# ); CREATE TABLE 
Enter fullscreen mode Exit fullscreen mode

We'll change our query parameters to say the eep schema is "good" and opp is "suspect". We'll also set it to only look at the ork table.

with check_params as ( select 'opp' as check_schema, -- put schema to check here! 'eep' as main_schema, -- put the known good schema here 'ork'::text as check_table -- put table name to check here (or null for all)! ), 
Enter fullscreen mode Exit fullscreen mode

Running the query, we see the following output:

 schema_name | table_name | column_name | column_type | text_length | precision | scale | is_nullable | col_default -------------+------------+-------------+-------------+-------------+-----------+-------+-------------+------------- (0 rows) 
Enter fullscreen mode Exit fullscreen mode

素晴らしい! No differences!

Let's expand to all of the tables.

with check_params as ( select 'opp' as check_schema, -- put schema to check here! 'eep' as main_schema, -- put the known good schema here null::text as check_table -- put table name to check here (or null for all)! ), 
Enter fullscreen mode Exit fullscreen mode

Running the query, we now see:

 schema_name | table_name | column_name | column_type | text_length | precision | scale | is_nullable | col_default -------------+--------------+------------------------------+--------------+-------------+-------------+-------------+-------------+------------- opp <eep> | ah_ah <null> | authentication_header <null> | jsonb <null> | null <null> | null <null> | null <null> | YES <null> | opp <eep> | ah_ah <null> | id <null> | uuid <null> | null <null> | null <null> | null <null> | YES <null> | opp <eep> | null <oof> | null <uniq_label> | null <text> | null <null> | null <null> | null <null> | null <NO> | (3 rows) 
Enter fullscreen mode Exit fullscreen mode

So, let's examine the output.

First, there's the schema_name column. This shows the suspect schema followed by the good schema (surrounded by < and > chars).

Next we have the table_name column. Note there there is an ah_ah table that is in the opp schema, but not in the eep schema. Also, tere is an oof table that is in the eep schema that is not in the opp schema.

The rest of the columns show the differences at each column: type, length, precision, scale, nullable, default.

So we can tell missing tables, but how about actual differences?

Let's change opp.ork a bit.

  1. Change the label from text to varchar(256)

    postgres=# alter table opp.ork alter column label set data type varchar(256); ALTER TABLE 
  2. Add a new column updated_ts

    postgres=# alter table opp.ork add column updated_ts timestamptz; ALTER TABLE 

Just for fun, let's also alter eep.ork.

  1. Add an audited column

    postgres=# alter table eep.ork add column audited boolean not null default false; ALTER TABLE 

Now let's constrain the query back to only the ork table:

with check_params as ( select 'opp' as check_schema, -- put schema to check here! 'eep' as main_schema, -- put the known good schema here 'ork'::text as check_table -- put table name to check here (or null for all)! ), 
Enter fullscreen mode Exit fullscreen mode

Now, running the query, we see:

 schema_name | table_name | column_name | column_type | text_length | precision | scale | is_nullable | col_default -------------+------------+-------------------+--------------------+-------------+-------------+-------------+-------------+-------------- opp <eep> | ork | label | varchar <text> | 256 <null> | null | null | YES | opp <eep> | ork | null <audited> | null <bool> | null <null> | null <null> | null <null> | null <NO> | null <false> opp <eep> | ork | updated_ts <null> | timestamptz <null> | null <null> | null <null> | null <null> | YES <null> | (3 rows) 
Enter fullscreen mode Exit fullscreen mode

So we've caught the extra column audited in eep.ork, the extra column updated_ts in opp.ork and the change to the common label column.


In closing, this could prove a useful tool to compare tables between schemata. This could be the basis for extending comparisons across databases on the same engine as well.

Hopefully this will prove useful and be helpful to database developers.


*I always pay my tax. Really!

Top comments (0)