Skip to content

Commit 8e2f836

Browse files
committed
Add default ORDER BY id to Sequel model queries
Adds a Sequel extension that automatically appends ORDER BY id to model queries, ensuring deterministic results for consistent API responses and reliable test behavior in parallel runs. The extension hooks into data fetching methods (all, each, first) to add ORDER BY id just before execution. This ensures the order is only added to the final query, not to subqueries or UNION parts. Skips adding the default order when: - Query already has an explicit ORDER BY clause - Query has GROUP BY (aggregated results don't have individual ids) - Query is schema introspection (LIMIT 0) - Query has UNION/INTERSECT/EXCEPT (compounds) - Query has DISTINCT ON (requires matching ORDER BY) - Query is a subquery (from_self) - Model doesn't have id as primary key - id is not in the select list For JOIN queries with SELECT *, it uses a qualified column (table.id) to avoid ambiguity.
1 parent b27da11 commit 8e2f836

File tree

5 files changed

+240
-4
lines changed

5 files changed

+240
-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: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# frozen_string_literal: true
2+
3+
# This Sequel extension adds a default ORDER BY id to model queries.
4+
#
5+
# It hooks into data fetching methods (all, each, first) to add ORDER BY id
6+
# just before execution. This ensures the order is only added to the final
7+
# query, not to subqueries or UNION parts.
8+
#
9+
# It skips adding the default order when:
10+
# - Query already has an explicit ORDER BY clause
11+
# - Query has GROUP BY (aggregated results don't have individual ids)
12+
# - Query is schema introspection (LIMIT 0)
13+
# - Query has UNION/INTERSECT/EXCEPT (compounds)
14+
# - Query has DISTINCT ON (requires matching ORDER BY)
15+
# - Query is a subquery (from_self)
16+
# - Model doesn't have id as primary key
17+
# - id is not in the select list
18+
#
19+
# For JOIN queries with SELECT *, it uses a qualified column (table.id)
20+
# to avoid ambiguity.
21+
#
22+
# This ensures deterministic query results, which is important for:
23+
# - Consistent API responses
24+
# - Reliable test behavior (especially in parallel test runs)
25+
#
26+
# Usage:
27+
# DB.extension(:default_order_by_id)
28+
#
29+
module Sequel
30+
module DefaultOrderById
31+
module DatasetMethods
32+
def all(*args, &block)
33+
ds = with_default_order_if_needed
34+
return super if ds.equal?(self)
35+
36+
ds.all(*args, &block)
37+
end
38+
39+
def each(*args, &block)
40+
ds = with_default_order_if_needed
41+
return super if ds.equal?(self)
42+
43+
ds.each(*args, &block)
44+
end
45+
46+
def first(*args, &block)
47+
ds = with_default_order_if_needed
48+
return super if ds.equal?(self)
49+
50+
ds.first(*args, &block)
51+
end
52+
53+
private
54+
55+
def with_default_order_if_needed
56+
id_column = find_id_column
57+
return self unless id_column
58+
59+
order(id_column)
60+
end
61+
62+
def find_id_column
63+
return nil if should_skip_default_order?
64+
65+
find_id_in_select_list
66+
end
67+
68+
def should_skip_default_order?
69+
opts[:order] || # Already has explicit order
70+
opts[:group] || # Aggregated results don't have individual ids
71+
opts[:limit] == 0 || # Schema introspection, not a real query
72+
opts[:compounds] || # UNION/INTERSECT/EXCEPT
73+
distinct_on? || # DISTINCT ON requires matching ORDER BY
74+
subquery? || # Outer query handles ordering
75+
!model_has_id_primary_key? # No id column to order by
76+
end
77+
78+
def distinct_on?
79+
opts[:distinct].is_a?(Array) && opts[:distinct].any?
80+
end
81+
82+
def subquery?
83+
opts[:from].is_a?(Array) && opts[:from].any? { |f| f.is_a?(Sequel::SQL::AliasedExpression) }
84+
end
85+
86+
def model_has_id_primary_key?
87+
return false unless respond_to?(:model) && model
88+
89+
model.primary_key == :id
90+
rescue StandardError
91+
false
92+
end
93+
94+
def find_id_in_select_list
95+
select_cols = opts[:select]
96+
97+
# SELECT * includes all columns including id
98+
if select_cols.nil? || select_cols.empty?
99+
return qualified_id_column if opts[:join]
100+
101+
return :id
102+
end
103+
104+
# Find id column in select list
105+
select_cols.each do |col|
106+
# ColumnAll (e.g., SELECT table.*) includes all columns including id
107+
# Use unqualified :id since only this table's columns are selected
108+
return :id if col.is_a?(Sequel::SQL::ColumnAll) && col.table == model.table_name
109+
110+
# Check for :id, :table__id, or aliased id
111+
id_col = extract_id_column(col)
112+
return id_col if id_col
113+
end
114+
115+
nil
116+
end
117+
118+
def qualified_id_column
119+
Sequel.qualify(model.table_name, :id)
120+
end
121+
122+
def extract_id_column(col)
123+
case col
124+
when Symbol
125+
col if col == :id || col.to_s.end_with?('__id')
126+
when Sequel::SQL::Identifier
127+
col if col.value == :id
128+
when Sequel::SQL::QualifiedIdentifier
129+
col if col.column == :id
130+
when Sequel::SQL::AliasedExpression
131+
col.alias if col.alias == :id # Use alias symbol (not the full expression with AS)
132+
end
133+
end
134+
end
135+
end
136+
137+
Dataset.register_extension(:default_order_by_id, DefaultOrderById::DatasetMethods)
138+
end
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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+
# Use an existing model for testing
8+
let(:model_class) { VCAP::CloudController::Organization }
9+
let(:db) { model_class.db }
10+
11+
# Capture executed SQL
12+
def capture_sql(&block)
13+
sqls = []
14+
db.loggers << (logger = Class.new do
15+
define_method(:info) { |msg| sqls << msg if msg.include?('SELECT') }
16+
define_method(:debug) { |_| }
17+
define_method(:error) { |_| }
18+
end.new)
19+
block.call
20+
db.loggers.delete(logger)
21+
sqls.last
22+
end
23+
24+
describe 'adding default order' do
25+
it 'adds ORDER BY id to model queries' do
26+
sql = capture_sql { model_class.dataset.first }
27+
expect(sql).to match(/ORDER BY .id./)
28+
end
29+
30+
it 'preserves explicit order' do
31+
sql = capture_sql { model_class.dataset.order(:name).first }
32+
expect(sql).to match(/ORDER BY .name./)
33+
expect(sql).not_to match(/ORDER BY .id./)
34+
end
35+
end
36+
37+
describe 'skipping default order' do
38+
it 'skips for queries with GROUP BY' do
39+
# Just check that SQL is generated correctly, don't execute since it's invalid SQL
40+
ds = model_class.dataset.group(:status)
41+
expect(ds.sql).not_to match(/ORDER BY/)
42+
end
43+
44+
it 'skips for UNION queries' do
45+
ds1 = model_class.dataset.where(name: 'a')
46+
ds2 = model_class.dataset.where(name: 'b')
47+
sql = capture_sql { ds1.union(ds2, all: true, from_self: false).all }
48+
# No ORDER BY at the outer level
49+
expect(sql).not_to match(/\) ORDER BY/)
50+
end
51+
52+
it 'skips for DISTINCT ON queries' do
53+
sql = capture_sql { model_class.dataset.distinct(:guid).all }
54+
# DISTINCT ON requires ORDER BY to match the distinct columns
55+
expect(sql).not_to match(/ORDER BY/)
56+
end
57+
58+
it 'skips for subqueries (from_self)' do
59+
sql = capture_sql { model_class.dataset.where(name: 'a').from_self.all }
60+
# Outer query should not have ORDER BY after the subquery alias
61+
expect(sql).to match(/AS .t1.$/)
62+
end
63+
64+
it 'skips for queries where id is not in select list' do
65+
sql = capture_sql { model_class.dataset.select(:guid, :name).all }
66+
expect(sql).not_to match(/ORDER BY/)
67+
end
68+
end
69+
70+
describe 'handling JOIN queries' do
71+
it 'uses qualified column for SELECT * to avoid ambiguity' do
72+
sql = capture_sql { model_class.dataset.join(:spaces, organization_id: :id).first }
73+
expect(sql).to match(/ORDER BY .organizations.\..id./)
74+
end
75+
76+
it 'uses unqualified column for SELECT table.* (many_to_many pattern)' do
77+
# many_to_many associations use SELECT table.* which creates a ColumnAll
78+
# Unqualified id is safe here since only one table's columns are selected
79+
sql = capture_sql { model_class.dataset.select(Sequel::SQL::ColumnAll.new(:organizations)).join(:spaces, organization_id: :id).first }
80+
expect(sql).to match(/ORDER BY .id./)
81+
expect(sql).not_to match(/ORDER BY .organizations.\..id./)
82+
end
83+
end
84+
85+
describe 'handling qualified id columns' do
86+
it 'uses qualified column when present in select' do
87+
sql = capture_sql { model_class.dataset.select(:organizations__id, :organizations__name).first }
88+
expect(sql).to match(/ORDER BY .organizations.\..id./)
89+
end
90+
end
91+
92+
describe 'handling aliased id columns' do
93+
it 'orders by alias when id is aliased' do
94+
sql = capture_sql { model_class.dataset.select(Sequel.as(:organizations__id, :id), :name).first }
95+
expect(sql).to match(/ORDER BY .id./)
96+
end
97+
end
98+
end

0 commit comments

Comments
 (0)