typical_sort 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9039e103d0aab48801238a87171c7b5294780714acc3df4251f3b4e72cab03d1
4
- data.tar.gz: 5717da3c8409651f5b96b2627ceeb419228cf1c643347ab7b9df0583406eb362
3
+ metadata.gz: 00ba97e42f71527115a64ddefd6d292678f9be903dca92dc416a6735f5efc38a
4
+ data.tar.gz: a62285eca9c248219e50d0ba36b0393a851b67cbc07ca754f1aaf7cf465449e2
5
5
  SHA512:
6
- metadata.gz: a15dce023ef4db25e7b0b362681db3b7fa95c088141dd9d09c0d3d882b6d9b3f45518901cd7536a3abf3cde629815b4fc8c125b3cee1f0cb74c718f0603d8ab3
7
- data.tar.gz: 1947b566ed290db9894688c3025b336b2b98138db56503f8e651c1dea90ce82694a5f9152da03a7baacb1429f038c1db1257689c11bb6ecfbbe0f600775f0d10
6
+ metadata.gz: 259eccd6a42e98a2b95911874404d264600233c0c4d0220ab042ef9cd549b7778a6cd896a785f04be8e9547f663c265e689af95889f2e6a95c3b1e1eae395612
7
+ data.tar.gz: a4e3ad12e56cd73c329ea2590fb0fdb770e56ac0832ceded3bf23bd19a6c2120852e09269f1a51935c2722264e4f3970f9a289a509860f5481f2acba46894cba
data/README.md CHANGED
@@ -104,7 +104,15 @@ Supported aggregate values:
104
104
  - `:max`
105
105
  - `:sum`
106
106
  - `:avg`
107
- - `:count`
107
+ - `:count` — counts associated records, including records whose declared sort column is null; parents without associated records count as zero
108
+
109
+ ### Aggregate query composition
110
+
111
+ Aggregate sorting preserves filtering, deduplicates parents, and retains base-table column selections and ordering, readonly state, and preloads. Existing ordering takes priority over requested sorts, as it does for base-column sorting.
112
+
113
+ Filtering joins remain in an ID subquery. Associations requested through `eager_load` or `includes` are loaded separately with `preload`; these contain the full associated collections, not only the rows matching the filtering join.
114
+
115
+ Input selections and ordering must use base-table columns (symbols, plain column names, or Arel column/order nodes). Aggregate sorting raises `ArgumentError` for SQL expressions, joined-table projections or ordering, `GROUP BY` / `HAVING`, `DISTINCT`, custom `FROM`, or pagination applied before sorting. Use an explicit sorting scope for unsupported query shapes, and paginate after `sort_records`.
108
116
 
109
117
  ## Scope sorting
110
118
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "typical_sort/ordering"
4
+
3
5
  module TypicalSort
4
6
  class AggregateSorter
5
7
  VALID_AGGREGATES = %i[min max sum avg count directional].freeze
@@ -8,11 +10,44 @@ module TypicalSort
8
10
 
9
11
  # Collapse collection joins once, before applying any requested ordering.
10
12
  def self.prepare(records)
13
+ validate_relation!(records)
14
+
11
15
  table = records.klass.arel_table
12
- base_ids = records.except(:select, :order).select(table[records.klass.primary_key])
13
- records.klass.where(records.klass.primary_key => base_ids)
16
+ primary_key = records.klass.primary_key
17
+ base_ids = records.except(:select, :order).select(table[primary_key])
18
+ prepared = records.except(:where, :joins, :left_outer_joins, :references, :eager_load, :includes)
19
+ preloads = records.eager_load_values | records.includes_values
20
+ prepared = prepared.preload(*preloads) unless preloads.empty?
21
+ prepared.where(primary_key => base_ids)
22
+ end
23
+
24
+ def self.validate_relation!(records)
25
+ unsupported = records.group_values.any? || !records.having_clause.empty? ||
26
+ records.distinct_value || records.limit_value || records.offset_value ||
27
+ !records.from_clause.empty?
28
+ columns = records.select_values + records.order_values
29
+ return if !unsupported && columns.all? { |column| base_column?(column, records) }
30
+
31
+ raise ArgumentError, "Unsupported aggregate sort relation: use base-table column selections and ordering without grouping, DISTINCT, custom FROM, or pagination; use an explicit sorting scope for other query shapes"
32
+ end
33
+
34
+ def self.base_column?(column, records)
35
+ case column
36
+ when Arel::Nodes::Ascending, Arel::Nodes::Descending, Arel::Nodes::NullsFirst, Arel::Nodes::NullsLast
37
+ base_column?(column.expr, records)
38
+ when Arel::Attributes::Attribute
39
+ column.relation == records.klass.arel_table
40
+ when Symbol, String
41
+ column = column.to_s
42
+ names = ["*", *records.klass.column_names]
43
+ names.any? { |name| column == name || column == "#{records.klass.table_name}.#{name}" }
44
+ else
45
+ false
46
+ end
14
47
  end
15
48
 
49
+ private_class_method :validate_relation!, :base_column?
50
+
16
51
  def initialize(records:, relation_name:, reflection:, column_name:, direction:, definition:, configuration:, table_alias: "typical_sort_scope")
17
52
  @records = records
18
53
  @relation_name = relation_name.to_sym
@@ -52,8 +87,12 @@ module TypicalSort
52
87
  Arel::Nodes::On.new(sort_table[:typical_sort_record_id].eq(records.klass.arel_table[base_pk]))
53
88
  )
54
89
 
55
- records.joins(join)
56
- .order(Arel.sql("#{table_alias}.typical_sort_value #{sql_direction} NULLS #{nulls_position}"))
90
+ ordering = Ordering.build(
91
+ sort_table[:typical_sort_value],
92
+ direction: direction,
93
+ nulls: definition.nulls || configuration.nulls.fetch(direction)
94
+ )
95
+ records.joins(join).order(ordering)
57
96
  end
58
97
 
59
98
  private
@@ -71,22 +110,10 @@ module TypicalSort
71
110
  end
72
111
 
73
112
  def value_sql(connection)
74
- return "*" if definition.aggregate == :count
75
-
113
+ column = (definition.aggregate == :count) ? reflection.klass.primary_key : column_name
76
114
  related_table = connection.quote_table_name(reflection.klass.table_name)
77
- related_column = connection.quote_column_name(column_name)
115
+ related_column = connection.quote_column_name(column)
78
116
  "#{related_table}.#{related_column}"
79
117
  end
80
-
81
- def sql_direction
82
- (direction == :desc) ? "DESC" : "ASC"
83
- end
84
-
85
- def nulls_position
86
- nulls = definition.nulls || configuration.nulls.fetch(direction)
87
- raise ArgumentError, "Unsupported nulls position: #{nulls.inspect}" unless %i[first last].include?(nulls)
88
-
89
- nulls.to_s.upcase
90
- end
91
118
  end
92
119
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypicalSort
4
+ module Ordering
5
+ def self.build(expression, direction:, nulls:)
6
+ ordered = case direction
7
+ when :asc then expression.asc
8
+ when :desc then expression.desc
9
+ else
10
+ raise ArgumentError, "Unsupported direction: #{direction.inspect}"
11
+ end
12
+
13
+ case nulls
14
+ when :first then ordered.nulls_first
15
+ when :last then ordered.nulls_last
16
+ else
17
+ raise ArgumentError, "Unsupported nulls position: #{nulls.inspect}"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "typical_sort/sorter"
4
+
5
+ module TypicalSort
6
+ class SortQuery
7
+ def initialize(records:, sorts:, definitions:, configuration:)
8
+ @records = records
9
+ @sorts = sorts
10
+ @definitions = definitions
11
+ @configuration = configuration
12
+ end
13
+
14
+ def call
15
+ sorts = resolve_sorts
16
+ sorted = Sorter.prepare(records: @records, definitions: sorts.map(&:first))
17
+ sorts.each_with_index do |(definition, direction), index|
18
+ sorted = Sorter.new(
19
+ records: sorted,
20
+ definition: definition,
21
+ direction: direction,
22
+ configuration: @configuration,
23
+ sort_index: index
24
+ ).call
25
+ end
26
+
27
+ append_tiebreaker(sorted)
28
+ end
29
+
30
+ private
31
+
32
+ def resolve_sorts
33
+ @sorts.filter_map do |attribute, direction|
34
+ definition = @definitions.fetch(attribute)
35
+ if definition
36
+ [definition, direction]
37
+ elsif @configuration.invalid_sort == :raise
38
+ raise InvalidSort, "Invalid sort: #{attribute}"
39
+ end
40
+ end
41
+ end
42
+
43
+ def append_tiebreaker(records)
44
+ return records unless @configuration.tie_breaker == :primary_key
45
+
46
+ table = records.klass.arel_table
47
+ records.order(table[records.klass.primary_key].asc)
48
+ end
49
+ end
50
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "typical_sort/aggregate_sorter"
4
4
  require "typical_sort/path_resolver"
5
+ require "typical_sort/ordering"
5
6
 
6
7
  module TypicalSort
7
8
  class Sorter
@@ -55,9 +56,18 @@ module TypicalSort
55
56
  end
56
57
 
57
58
  def sort_relation_association(resolver)
58
- table = resolver.reflection.klass.arel_table
59
- node = table[resolver.column_name]
60
- records.left_outer_joins(resolver.association_name.to_sym).order(order_node(node))
59
+ tracker = ActiveRecord::Associations::AliasTracker.create(
60
+ records.klass.connection_pool, records.klass.table_name, records.arel.join_sources
61
+ )
62
+ association = ActiveRecord::Associations::JoinDependency::JoinAssociation.new(resolver.reflection, [])
63
+ joins = association.join_constraints(records.klass.arel_table, records.klass, Arel::Nodes::OuterJoin, tracker) do |reflection, chain|
64
+ reflection.check_validity!
65
+ reflection.check_eager_loadable!
66
+ name = "typical_sort_relation_#{sort_index}_#{chain.length}"
67
+ [tracker.aliased_table_for(reflection.klass.arel_table, name) { name }, false]
68
+ end
69
+
70
+ records.joins(joins).order(order_node(association.table[resolver.column_name]))
61
71
  end
62
72
 
63
73
  def validate_resolver!(resolver)
@@ -79,21 +89,11 @@ module TypicalSort
79
89
  end
80
90
 
81
91
  def order_node(node)
82
- ordered = (direction == :desc) ? node.desc : node.asc
83
- nulls = nulls_position
84
-
85
- case nulls
86
- when :first
87
- ordered.nulls_first
88
- when :last
89
- ordered.nulls_last
90
- else
91
- raise ArgumentError, "Unsupported nulls position: #{nulls.inspect}"
92
- end
93
- end
94
-
95
- def nulls_position
96
- definition.nulls || configuration.nulls.fetch(direction)
92
+ Ordering.build(
93
+ node,
94
+ direction: direction,
95
+ nulls: definition.nulls || configuration.nulls.fetch(direction)
96
+ )
97
97
  end
98
98
  end
99
99
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypicalSort
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/typical_sort.rb CHANGED
@@ -6,7 +6,7 @@ require "typical_sort/engine"
6
6
  require "typical_sort/configuration"
7
7
  require "typical_sort/params"
8
8
  require "typical_sort/sort_set"
9
- require "typical_sort/sorter"
9
+ require "typical_sort/sort_query"
10
10
 
11
11
  module TypicalSort
12
12
  class Error < StandardError; end
@@ -49,53 +49,28 @@ module TypicalSort
49
49
  private
50
50
 
51
51
  def sort_records(records)
52
- sort_params = TypicalSort::Params.new(
52
+ configuration = request_configuration
53
+ sorts = Params.new(
53
54
  params: params,
54
- configuration: request_configuration,
55
+ configuration: configuration,
55
56
  default_attribute: default_sorting_attribute,
56
- default_direction: default_sorting_direction
57
- )
58
-
59
- sorts = sort_params.sorts.filter_map do |attribute, direction|
60
- definition = sort_definition_for(attribute)
61
- if definition
62
- [definition, direction]
63
- elsif TypicalSort.configuration.invalid_sort == :raise
64
- raise TypicalSort::InvalidSort, "Invalid sort: #{attribute}"
65
- end
66
- end
67
-
68
- sorted = TypicalSort::Sorter.prepare(records: records, definitions: sorts.map(&:first))
69
- sorts.each_with_index do |(definition, direction), index|
70
- sorted = TypicalSort::Sorter.new(
71
- records: sorted,
72
- definition: definition,
73
- direction: direction,
74
- configuration: request_configuration,
75
- sort_index: index
76
- ).call
77
- end
78
-
79
- append_sort_tiebreaker(sorted)
80
- end
81
-
82
- def sort_definition_for(attribute)
83
- typical_sort_set.fetch(attribute)
84
- end
85
-
86
- def append_sort_tiebreaker(records)
87
- return records unless TypicalSort.configuration.tie_breaker == :primary_key
88
-
89
- table = records.klass.arel_table
90
- records.order(table[records.klass.primary_key].asc)
57
+ default_direction: default_sorting_direction(configuration)
58
+ ).sorts
59
+
60
+ SortQuery.new(
61
+ records: records,
62
+ sorts: sorts,
63
+ definitions: typical_sort_set,
64
+ configuration: configuration
65
+ ).call
91
66
  end
92
67
 
93
68
  def default_sorting_attribute
94
69
  typical_sort_set.default_attribute || :created_at
95
70
  end
96
71
 
97
- def default_sorting_direction
98
- typical_sort_set.default_direction || TypicalSort.configuration.default_direction
72
+ def default_sorting_direction(configuration)
73
+ typical_sort_set.default_direction || configuration.default_direction
99
74
  end
100
75
 
101
76
  def request_configuration
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typical_sort
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wyatt Kirby
@@ -192,9 +192,11 @@ files:
192
192
  - lib/typical_sort/aggregate_sorter.rb
193
193
  - lib/typical_sort/configuration.rb
194
194
  - lib/typical_sort/engine.rb
195
+ - lib/typical_sort/ordering.rb
195
196
  - lib/typical_sort/params.rb
196
197
  - lib/typical_sort/path_resolver.rb
197
198
  - lib/typical_sort/sort_definition.rb
199
+ - lib/typical_sort/sort_query.rb
198
200
  - lib/typical_sort/sort_set.rb
199
201
  - lib/typical_sort/sorter.rb
200
202
  - lib/typical_sort/version.rb