Skip to content

Commit f90b00d

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. 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 (ORDER BY before UNION is a syntax error) - Query has DISTINCT ON (requires matching ORDER BY) - Query is a subquery (outer query handles ordering) - 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. For SELECT table.* (e.g., many_to_many associations), it uses unqualified id since only one table's columns are in the result.
1 parent b27da11 commit f90b00d

5 files changed

Lines changed: 198 additions & 4 deletions

File tree

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

0 commit comments

Comments
 (0)