User-specified ordering with fractions
Requirement: given that things can be in categories, allow the position of a thing in the display order to be specified by the user or application (i.e. arbitrarily, rather than according to some stable attribute of the data).
For example, something that displays a list and allows the user to shuffle the items around.
There are a number of possible approaches. Using integers is simple but tends to require frequent renumberings. Using floats and picking the midpoints between adjacent values also runs out of space rapidly (you only need 50-odd inserts at the wrong spot to start hitting problems). So this approach uses integer fractions, choosing the values (from the Stern–Brocot tree) such that they can be sorted using (p::float8/q)
but renumbering values is only rarely required.
create table categories (id integer primary key, name text); create table things (id integer primary key, name text); create table category_member ( category_id integer not null references categories, thing_id integer not null references things on delete cascade, primary key (category_id,thing_id), p integer not null, q integer not null ); create unique index on category_member (category_id, (p::float8/q)); -- find an intermediate fraction between p1/q1 and p2/q2. -- -- The fraction chosen is the highest fraction in the Stern-Brocot -- tree which falls strictly between the specified values. This is -- intended to avoid going deeper in the tree unnecessarily when the -- list is already sparse due to deletion or moving of items, but in -- fact the case when the two items are already adjacent in the tree -- is common so we shortcut it. As a bonus, this method always -- generates fractions in lowest terms, so there is no need for GCD -- calculations anywhere. -- -- Inputs must not be null (caller's responsibility), but the value -- p2=1 q2=0 is allowed for the upper bound without causing any -- division by zero errors. create or replace function find_intermediate(p1 integer, q1 integer, p2 integer, q2 integer, OUT p integer, OUT q integer) language plpgsql immutable strict as $f$ declare pl integer := 0; ql integer := 1; ph integer := 1; qh integer := 0; begin if (p1::bigint*q2 + 1) <> (p2::bigint*q1) then loop p := pl + ph; q := ql + qh; if (p::bigint*q1 <= q::bigint*p1) then pl := p; ql := q; elsif (p2::bigint*q <= q2::bigint*p) then ph := p; qh := q; else exit; end if; end loop; else p := p1 + p2; q := q1 + q2; end if; end; $f$; -- insert or move item ACT_ID in category CAT_ID next to REL_ID, -- before it if IS_BEFORE is true, otherwise after. REL_ID may -- be null to indicate a position off the end of the list. create or replace function cat_place_item(cat_id integer, act_id integer, rel_id integer, is_before boolean) returns void language plpgsql volatile called on null input as $f$ declare p1 integer; q1 integer; -- fraction below insert position p2 integer; q2 integer; -- fraction above insert position r_rel double precision; -- p/q of the rel_id row np integer; nq integer; -- new insert position fraction begin -- lock the category perform 1 from categories c where c.id=cat_id for update; -- moving a record to its own position is a no-op if rel_id=act_id then return; end if; -- if we're positioning next to a specified row, it must exist if rel_id is not null then select cm.p, cm.q into strict p1, q1 from category_member cm where cm.category_id=cat_id and cm.thing_id=rel_id; r_rel := p1::float8 / q1; end if; -- find the next adjacent row in the desired direction -- (might not exist). if is_before then p2 := p1; q2 := q1; select cm2.p, cm2.q into p1, q1 from category_member cm2 where cm2.category_id=cat_id and cm2.thing_id <> act_id and (p::float8/q) < coalesce(r_rel,'infinity') order by (p::float8/q) desc limit 1; else select cm2.p, cm2.q into p2, q2 from category_member cm2 where cm2.category_id=cat_id and cm2.thing_id <> act_id and (p::float8/q) > coalesce(r_rel,0) order by (p::float8/q) asc limit 1; end if; -- compute insert fraction select * into np,nq from find_intermediate(coalesce(p1,0),coalesce(q1,1), coalesce(p2,1),coalesce(q2,0)); -- move or insert the specified row update category_member set (p,q) = (np,nq) where category_id=cat_id and thing_id=act_id; if not found then insert into category_member values (cat_id, act_id, np, nq); end if; -- want to renormalize both to avoid possibility of integer overflow -- and to ensure that distinct fraction values map to distinct float8 -- values. Bounding to 10 million gives us reasonable headroom while -- not requiring frequent normalization. if (np > 10000000) or (nq > 10000000) then perform cat_renormalize(cat_id); end if; end; $f$; -- Renormalize the fractions of items in CAT_ID, preserving the -- existing order. The new fractions are not strictly optimal, but -- doing better would require much more complex calculations. -- -- the purpose of the complex update is as follows: we want to assign -- a new series of values 1/2, 3/2, 5/2, ... to the existing rows, -- maintaining the existing order, but because the unique expression -- index is not deferrable, we want to avoid assigning any new value -- that collides with an existing one. -- -- We do this by calculating, for each existing row with an x/2 value, -- which position in the new sequence it would appear at. This is done -- by adjusting the value of p downwards according to the number of -- earlier values in sequence. To see why, consider: -- -- existing values: 3, 9,13,15,23 -- new simple values: 1, 3, 5, 7, 9,11,13,15,17,19,21 -- * * * * -- adjusted values: 1, 5, 7,11,17,19,21,25,27,29,31 -- -- points of adjustment: 3, 7 (9-2), 9 (13-4, 15-6), 15 (23-8) -- -- The * mark the places where an adjustment has to be applied. -- -- Having calculated the adjustment points, the adjusted value is -- simply the simple value adjusted upwards according to the number of -- points passed (counting multiplicity). create or replace function cat_renormalize(cat_id integer) returns void language plpgsql volatile strict as $f$ begin perform 1 from categories c where c.id=cat_id for update; update category_member cm set p=s2.new_rnum, q=2 from (select thing_id, is_existing = 0 as is_new, -- increase the current value according to the -- number of adjustment points passed rnum + 2*(sum(is_existing) over (order by rnum)) as new_rnum from ( -- assign the initial simple values to every item -- in order select thing_id, 2*(row_number() over (order by p::float8/q)) - 1 as rnum, 0 as is_existing from category_member cm2 where cm2.category_id=cat_id union all -- and merge in the adjustment points required to -- skip over existing x/2 values select thing_id, p + 2 - 2*(count(*) over (order by p)) as rnum, 1 as is_existing from category_member cm3 where cm3.category_id=cat_id and cm3.q=2 ) s1 ) s2 where s2.thing_id=cm.thing_id and s2.is_new and cm.category_id=cat_id; end; $f$; -- -- example -- insert into categories values (1,'Animals'),(2,'Plants'); insert into things values (1,'foo'),(2,'bar'),(3,'baz'),(4,'fred'),(5,'jim'); select cat_place_item(1,1,null,false); -- insert 'foo' at start select cat_place_item(1,2,1,false); -- insert 'bar' after 'foo' select cat_place_item(1,3,2,false); -- insert 'baz' after 'bar' select cat_place_item(1,4,3,true); -- insert 'fred' before 'baz' select cat_place_item(1,5,null,true); -- insert 'jim' at end select * from category_member order by (p::float8/q);