用户指定排序和分数
来自 PostgreSQL 维基
跳转到导航跳转到搜索需求:假设事物可以归类,允许用户或应用程序指定事物在显示顺序中的位置(即任意,而不是根据数据的某些稳定属性)。
例如,某些内容显示一个列表,并允许用户对项目进行排序。
有许多可能的方法。使用整数很简单,但往往需要频繁地重新编号。使用浮点数并选择相邻值的中点也会很快耗尽空间(你只需要在错误的位置插入 50 多个条目就会开始出现问题)。因此,这种方法使用整数分数,选择值(来自 Stern–Brocot 树),这样它们可以使用 (p::float8/q)
进行排序,但重新编号值只需要很少的次数。
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);