Skip to content

Commit 75b7198

Browse files
committed
Add default ORDER BY id to Sequel model queries
Adds a Sequel extension that hooks into fetch methods (all, each, first) to add ORDER BY id just before execution, ensuring deterministic query results for consistent API responses and reliable test behavior. Skips ordering when incompatible (GROUP BY, compounds, DISTINCT ON, from_self) or unnecessary (explicit ORDER BY, no id column).
1 parent b27da11 commit 75b7198

File tree

5 files changed

+256
-4
lines changed

5 files changed

+256
-4
lines changed

app/models/runtime/buildpack_lifecycle_data_model.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ class BuildpackLifecycleDataModel < Sequel::Model(:buildpack_lifecycle_data)
2828
one_to_many :buildpack_lifecycle_buildpacks,
2929
class: '::VCAP::CloudController::BuildpackLifecycleBuildpackModel',
3030
key: :buildpack_lifecycle_data_guid,
31-
primary_key: :guid,
32-
order: :id
31+
primary_key: :guid
3332
plugin :nested_attributes
3433
nested_attributes :buildpack_lifecycle_buildpacks, destroy: true
3534
add_association_dependencies buildpack_lifecycle_buildpacks: :destroy

app/models/runtime/cnb_lifecycle_data_model.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ class CNBLifecycleDataModel < Sequel::Model(:cnb_lifecycle_data)
2727
one_to_many :buildpack_lifecycle_buildpacks,
2828
class: '::VCAP::CloudController::BuildpackLifecycleBuildpackModel',
2929
key: :cnb_lifecycle_data_guid,
30-
primary_key: :guid,
31-
order: :id
30+
primary_key: :guid
3231
plugin :nested_attributes
3332
nested_attributes :buildpack_lifecycle_buildpacks, destroy: true
3433
add_association_dependencies buildpack_lifecycle_buildpacks: :destroy

lib/cloud_controller/db.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require 'cloud_controller/execution_context'
66
require 'sequel/extensions/query_length_logging'
77
require 'sequel/extensions/request_query_metrics'
8+
require 'sequel/extensions/default_order_by_id'
89

910
module VCAP::CloudController
1011
class DB
@@ -45,6 +46,7 @@ def self.connect(opts, logger)
4546
add_connection_expiration_extension(db, opts)
4647
add_connection_validator_extension(db, opts)
4748
db.extension(:requires_unique_column_names_in_subquery)
49+
db.extension(:default_order_by_id)
4850
add_connection_metrics_extension(db)
4951
db
5052
end
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# frozen_string_literal: true
2+
3+
# Sequel extension that adds default ORDER BY id to model queries.
4+
#
5+
# Hooks into fetch methods (all, each, first) to add ORDER BY id just before
6+
# execution. This ensures ordering is only added to the final query, not to
7+
# subqueries or compound query parts.
8+
#
9+
# Skips default ordering when:
10+
# - Query already has explicit ORDER BY
11+
# - Query is incompatible (GROUP BY, compounds, DISTINCT ON, from_self)
12+
# - Query is schema introspection (LIMIT 0)
13+
# - Model doesn't have id as primary key
14+
# - id is not in the select list
15+
#
16+
# For JOIN queries with SELECT *, uses qualified column (table.id) to avoid
17+
# ambiguity.
18+
#
19+
# Ensures deterministic query results for consistent API responses and
20+
# reliable test behavior.
21+
#
22+
# Usage:
23+
# DB.extension(:default_order_by_id)
24+
#
25+
module Sequel
26+
module DefaultOrderById
27+
module DatasetMethods
28+
def all(*, &)
29+
id_col = id_column_for_order
30+
return super unless id_col
31+
32+
order(id_col).all(*, &)
33+
end
34+
35+
def each(*, &)
36+
id_col = id_column_for_order
37+
return super unless id_col
38+
39+
order(id_col).each(*, &)
40+
end
41+
42+
def first(*, &)
43+
id_col = id_column_for_order
44+
return super unless id_col
45+
46+
order(id_col).first(*, &)
47+
end
48+
49+
private
50+
51+
def id_column_for_order
52+
return if already_ordered? || incompatible_with_order? || not_a_data_query? || !model_has_id_primary_key?
53+
54+
find_id_column
55+
end
56+
57+
def already_ordered?
58+
opts[:order]
59+
end
60+
61+
def incompatible_with_order?
62+
opts[:group] || # Aggregated results don't have individual ids
63+
opts[:compounds] || # Compound queries (e.g. UNION) have own ordering
64+
distinct_on? || # DISTINCT ON requires matching ORDER BY
65+
from_self? # Outer query handles ordering
66+
end
67+
68+
def distinct_on?
69+
opts[:distinct].is_a?(Array) && opts[:distinct].any?
70+
end
71+
72+
def from_self?
73+
opts[:from].is_a?(Array) && opts[:from].any? { |f| f.is_a?(Sequel::SQL::AliasedExpression) }
74+
end
75+
76+
def not_a_data_query?
77+
opts[:limit] == 0 # Schema introspection query
78+
end
79+
80+
def model_has_id_primary_key?
81+
return false unless respond_to?(:model) && model
82+
83+
model.primary_key == :id
84+
end
85+
86+
def find_id_column
87+
select_cols = opts[:select]
88+
89+
if select_cols.nil? || select_cols.empty?
90+
# SELECT * includes id
91+
if opts[:join]
92+
# Qualify to avoid ambiguity with joined tables
93+
return Sequel.qualify(model.table_name, :id)
94+
end
95+
96+
return :id
97+
end
98+
99+
select_cols.each do |col|
100+
# SELECT table.* includes id
101+
return :id if col.is_a?(Sequel::SQL::ColumnAll) && col.table == model.table_name
102+
103+
id_col = extract_id_column(col)
104+
return id_col if id_col
105+
end
106+
107+
nil
108+
end
109+
110+
def extract_id_column(col)
111+
return col if id_expression?(col)
112+
113+
return col.alias if col.is_a?(Sequel::SQL::AliasedExpression) && id_expression?(col.expression)
114+
115+
nil
116+
end
117+
118+
def id_expression?(expr)
119+
case expr
120+
when Symbol
121+
expr == :id || expr.to_s.end_with?('__id')
122+
when Sequel::SQL::Identifier
123+
expr.value == :id
124+
when Sequel::SQL::QualifiedIdentifier
125+
expr.column == :id
126+
else
127+
false
128+
end
129+
end
130+
end
131+
end
132+
133+
Dataset.register_extension(:default_order_by_id, DefaultOrderById::DatasetMethods)
134+
end
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require 'sequel/extensions/default_order_by_id'
5+
6+
RSpec.describe 'Sequel::DefaultOrderById' do
7+
let(:model_class) { VCAP::CloudController::Organization }
8+
let(:db) { model_class.db }
9+
10+
def capture_sql(&)
11+
sqls = []
12+
db.loggers << (logger = Class.new do
13+
define_method(:info) { |msg| sqls << msg if msg.include?('SELECT') }
14+
define_method(:debug) { |_| }
15+
define_method(:error) { |_| }
16+
end.new)
17+
yield
18+
db.loggers.delete(logger)
19+
sqls.last
20+
end
21+
22+
describe 'default ordering' do
23+
it 'adds ORDER BY id to model queries' do
24+
sql = capture_sql { model_class.dataset.all }
25+
expect(sql).to match(/ORDER BY .id./)
26+
end
27+
end
28+
29+
describe 'already_ordered?' do
30+
it 'preserves explicit ORDER BY' do
31+
sql = capture_sql { model_class.dataset.order(:name).all }
32+
expect(sql).to match(/ORDER BY .name./)
33+
end
34+
end
35+
36+
describe 'incompatible_with_order?' do
37+
it 'skips for GROUP BY' do
38+
sql = capture_sql { model_class.dataset.select_group(:status).select_append(Sequel.function(:max, :id).as(:id)).all }
39+
expect(sql).not_to match(/ORDER BY/)
40+
end
41+
42+
it 'skips for compound queries (UNION)' do
43+
ds1 = model_class.dataset.where(name: 'a')
44+
ds2 = model_class.dataset.where(name: 'b')
45+
sql = capture_sql { ds1.union(ds2, all: true, from_self: false).all }
46+
expect(sql).not_to match(/ORDER BY/)
47+
end
48+
49+
it 'skips for DISTINCT ON' do
50+
sql = capture_sql { model_class.dataset.distinct(:guid).all }
51+
expect(sql).not_to match(/ORDER BY/)
52+
end
53+
54+
it 'skips for from_self (subquery)' do
55+
sql = capture_sql { model_class.dataset.where(name: 'a').from_self.all }
56+
expect(sql).not_to match(/ORDER BY/)
57+
end
58+
end
59+
60+
describe 'not_a_data_query?' do
61+
it 'skips for schema introspection (columns!)' do
62+
sql = capture_sql { model_class.dataset.columns! }
63+
expect(sql).not_to match(/ORDER BY/)
64+
end
65+
end
66+
67+
describe 'model_has_id_primary_key?' do
68+
it 'skips for models with non-id primary key' do
69+
guid_pk_model = Class.new(Sequel::Model(db[:organizations])) do
70+
set_primary_key :guid
71+
end
72+
sql = capture_sql { guid_pk_model.dataset.all }
73+
expect(sql).not_to match(/ORDER BY/)
74+
end
75+
end
76+
77+
describe 'find_id_column' do
78+
context 'with SELECT *' do
79+
it 'uses unqualified :id' do
80+
sql = capture_sql { model_class.dataset.all }
81+
expect(sql).to match(/ORDER BY .id./)
82+
end
83+
84+
it 'uses qualified column for JOIN to avoid ambiguity' do
85+
sql = capture_sql { model_class.dataset.join(:spaces, organization_id: :id).all }
86+
expect(sql).to match(/ORDER BY .organizations.\..id./)
87+
end
88+
end
89+
90+
context 'with SELECT table.*' do
91+
it 'uses unqualified :id' do
92+
sql = capture_sql { model_class.dataset.select(Sequel::SQL::ColumnAll.new(:organizations)).join(:spaces, organization_id: :id).all }
93+
expect(sql).to match(/ORDER BY .id./)
94+
end
95+
end
96+
97+
context 'with qualified id in select list' do
98+
it 'uses the qualified column' do
99+
sql = capture_sql { model_class.dataset.select(:organizations__id, :organizations__name).all }
100+
expect(sql).to match(/ORDER BY .organizations.\..id./)
101+
end
102+
end
103+
104+
context 'with aliased id in select list' do
105+
it 'uses the alias' do
106+
sql = capture_sql { model_class.dataset.select(Sequel.as(:organizations__id, :org_id), :name).all }
107+
expect(sql).to match(/ORDER BY .org_id./)
108+
end
109+
end
110+
111+
context 'without id in select list' do
112+
it 'skips ordering' do
113+
sql = capture_sql { model_class.dataset.select(:guid, :name).all }
114+
expect(sql).not_to match(/ORDER BY/)
115+
end
116+
end
117+
end
118+
end

0 commit comments

Comments
 (0)