pluckr 0.1.0

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.
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ module Schema
5
+ # Evaluates the `schema do ... end` block and builds a Scope of AST nodes.
6
+ #
7
+ # All validation happens here, at class-definition time, so mistakes blow up
8
+ # when the query is loaded rather than when it is executed.
9
+ class Definition
10
+ # @param model [Class<ActiveRecord::Base>, nil] nil for aggregate-only queries
11
+ def self.build(model, label: "schema", &block)
12
+ raise ConfigurationError, "`#{label}` requires a block" unless block
13
+
14
+ definition = new(model)
15
+ definition.instance_eval(&block)
16
+ scope = definition.to_scope
17
+
18
+ raise ConfigurationError, "`#{label}` block is empty - declare at least one node" if scope.nodes.empty?
19
+
20
+ scope
21
+ end
22
+
23
+ def initialize(model)
24
+ @model = model
25
+ @nodes = []
26
+ end
27
+
28
+ def to_scope
29
+ Scope.new(@model, @nodes)
30
+ end
31
+
32
+ # ---------------------------------------------------------------- DSL --
33
+
34
+ def field(name, as: nil)
35
+ require_source!("field :#{name}")
36
+ validate_column!(@model, name)
37
+ add Field.new(column: name, output: as || name)
38
+ end
39
+
40
+ def one(name, via: nil, &block)
41
+ require_source!("one :#{name}")
42
+ raise ConfigurationError, "`one :#{name}` requires a block" unless block
43
+
44
+ association = Reflection::Association.for(@model, via || name)
45
+ unless association.singular?
46
+ raise InvalidAssociation,
47
+ "`#{@model.name}##{association.name}` is a #{association.macro} association " \
48
+ "and cannot be used with `one`"
49
+ end
50
+
51
+ add One.new(
52
+ output: name,
53
+ association: association,
54
+ scope: Definition.build(association.klass, label: "one :#{name}", &block)
55
+ )
56
+ end
57
+
58
+ # `first :comment, via: :comments do ... end` - the earliest row of a
59
+ # collection, by primary key unless `order:` says otherwise.
60
+ def first(name, via: nil, order: nil, &block)
61
+ pick(:first, name, via: via, order: order, &block)
62
+ end
63
+
64
+ # `last :comment, via: :comments, order: :created_at do ... end`
65
+ def last(name, via: nil, order: nil, &block)
66
+ pick(:last, name, via: via, order: order, &block)
67
+ end
68
+
69
+ def exists(name, via: nil, as: nil, where: nil, scope: nil)
70
+ require_source!("exists :#{name}")
71
+ association = Reflection::Association.for(@model, via || name)
72
+ validate_where!(association.klass, where)
73
+ validate_scope!("exists :#{name}", scope)
74
+
75
+ add Exists.new(
76
+ output: as || :"#{name}_exists",
77
+ association: association,
78
+ where: where,
79
+ scope: scope
80
+ )
81
+ end
82
+
83
+ def count(name, from: nil, via: nil, as: nil, where: nil, column: nil, scope: nil)
84
+ aggregate(:count, name, from: from, via: via, as: as, where: where, column: column, scope: scope)
85
+ end
86
+
87
+ def sum(name, from: nil, via: nil, as: nil, where: nil, column: nil, scope: nil)
88
+ aggregate(:sum, name, from: from, via: via, as: as, where: where, column: column, scope: scope)
89
+ end
90
+
91
+ def avg(name, from: nil, via: nil, as: nil, where: nil, column: nil, scope: nil)
92
+ aggregate(:avg, name, from: from, via: via, as: as, where: where, column: column, scope: scope)
93
+ end
94
+ alias average avg
95
+
96
+ def min(name, from: nil, via: nil, as: nil, where: nil, column: nil, scope: nil)
97
+ aggregate(:min, name, from: from, via: via, as: as, where: where, column: column, scope: scope)
98
+ end
99
+
100
+ def max(name, from: nil, via: nil, as: nil, where: nil, column: nil, scope: nil)
101
+ aggregate(:max, name, from: from, via: via, as: as, where: where, column: column, scope: scope)
102
+ end
103
+
104
+ # ------------------------------------------------------------ internals --
105
+
106
+ private
107
+
108
+ def pick(direction, name, via:, order:, &block)
109
+ require_source!("#{direction} :#{name}")
110
+ raise ConfigurationError, "`#{direction} :#{name}` requires a block" unless block
111
+
112
+ association = Reflection::Association.for(@model, via || name)
113
+ scope = Definition.build(association.klass, label: "#{direction} :#{name}", &block)
114
+ validate_picked_scope!(direction, name, scope)
115
+
116
+ add One.new(
117
+ output: name,
118
+ association: association,
119
+ scope: scope,
120
+ pick: direction,
121
+ order: pick_order(direction, name, order, association.klass)
122
+ )
123
+ end
124
+
125
+ # Every column of the picked row comes from its own subquery, so nesting
126
+ # anything that needs a joined table cannot work here.
127
+ def validate_picked_scope!(direction, name, scope)
128
+ offender = scope.nodes.reject { |node| node.is_a?(Field) }.first
129
+ return unless offender
130
+
131
+ raise ConfigurationError,
132
+ "`#{direction} :#{name}` can only contain `field` in v0.1, " \
133
+ "got `#{offender.class.name.split("::").last.downcase} :#{offender.output}`"
134
+ end
135
+
136
+ # Normalises `order:` into [[column, direction], ...].
137
+ #
138
+ # `last` reverses it, so `last ..., order: { created_at: :asc }` is the
139
+ # final row of that ordering - what `relation.order(...).last` returns. The
140
+ # primary key always ends the list, so ties cannot make two columns come
141
+ # from two different rows.
142
+ def pick_order(direction, name, order, model)
143
+ entries = Array(order_entries(direction, name, order))
144
+ entries.each { |column, _| validate_column!(model, column) }
145
+ entries = entries.map { |column, dir| [column, invert(dir)] } if direction == :last
146
+
147
+ primary_key = model.primary_key&.to_sym
148
+ tiebreaker = direction == :last ? :desc : :asc
149
+ entries << [primary_key, tiebreaker] if primary_key && entries.none? { |column, _| column == primary_key }
150
+ entries.freeze
151
+ end
152
+
153
+ def invert(direction)
154
+ direction == :asc ? :desc : :asc
155
+ end
156
+
157
+ def order_entries(direction, name, order)
158
+ case order
159
+ when nil then []
160
+ when Symbol, String then [[order.to_sym, :asc]]
161
+ when Hash then order.map { |column, dir| [column.to_sym, order_direction(direction, name, dir)] }
162
+ when Array then order.flat_map { |entry| order_entries(direction, name, entry) }
163
+ else
164
+ raise ConfigurationError,
165
+ "`#{direction} :#{name}, order:` expects a column, a Hash or an Array, got #{order.inspect}"
166
+ end
167
+ end
168
+
169
+ def order_direction(direction, name, value)
170
+ normalized = value.to_s.downcase.to_sym
171
+ return normalized if %i[asc desc].include?(normalized)
172
+
173
+ raise ConfigurationError,
174
+ "`#{direction} :#{name}, order:` expects :asc or :desc, got #{value.inspect}"
175
+ end
176
+
177
+ def aggregate(operation, name, from:, via:, as:, where:, column:, scope:)
178
+ validate_scope!("#{operation} :#{name}", scope)
179
+
180
+ if from
181
+ independent_aggregate(operation, name, from: from, as: as, where: where,
182
+ column: column, scope: scope)
183
+ else
184
+ correlated_aggregate(operation, name, via: via, as: as, where: where,
185
+ column: column, scope: scope)
186
+ end
187
+ end
188
+
189
+ def independent_aggregate(operation, name, from:, as:, where:, column:, scope:)
190
+ unless from.is_a?(Class) && from < ActiveRecord::Base
191
+ raise ConfigurationError,
192
+ "`#{operation} :#{name}, from:` expects an ActiveRecord model, got #{from.inspect}"
193
+ end
194
+
195
+ column = require_column!(operation, name, column, from)
196
+ validate_where!(from, where)
197
+
198
+ add Aggregate.new(
199
+ output: as || name,
200
+ operation: operation,
201
+ model: from,
202
+ column: column,
203
+ where: where,
204
+ scope: scope
205
+ )
206
+ end
207
+
208
+ def correlated_aggregate(operation, name, via:, as:, where:, column:, scope:)
209
+ require_source!("#{operation} :#{name}")
210
+
211
+ association = Reflection::Association.for(@model, via || name)
212
+ column = require_column!(operation, name, column, association.klass)
213
+ validate_where!(association.klass, where)
214
+
215
+ add Aggregate.new(
216
+ output: as || :"#{name}_#{operation}",
217
+ operation: operation,
218
+ association: association,
219
+ column: column,
220
+ where: where,
221
+ scope: scope
222
+ )
223
+ end
224
+
225
+ # count is the only operation that works without a column (COUNT(*)).
226
+ def require_column!(operation, name, column, model)
227
+ if column.nil?
228
+ return nil if operation == :count
229
+
230
+ raise ConfigurationError, "`#{operation} :#{name}` requires `column:`"
231
+ end
232
+
233
+ validate_column!(model, column)
234
+ column
235
+ end
236
+
237
+ def validate_column!(model, name)
238
+ return if model.column_names.include?(name.to_s)
239
+
240
+ raise UnknownField, "#{model.name} does not have column `#{name}`"
241
+ end
242
+
243
+ # A Hash is checked now; a callable can only be checked when it runs, on
244
+ # every compilation.
245
+ def validate_where!(model, where)
246
+ return if where.nil?
247
+
248
+ if where.respond_to?(:call)
249
+ return if !where.respond_to?(:arity) || where.arity <= 0
250
+
251
+ raise ConfigurationError,
252
+ "a `where:` callable takes no arguments and returns a Hash - use `scope:` if you " \
253
+ "need the relation"
254
+ end
255
+
256
+ unless where.is_a?(Hash)
257
+ raise ConfigurationError,
258
+ "`where:` expects a Hash of column => value or a callable returning one, " \
259
+ "got #{where.inspect}"
260
+ end
261
+
262
+ where.each_key { |key| validate_column!(model, key) }
263
+ end
264
+
265
+ # `scope:` takes the target model's relation and returns a narrowed one:
266
+ # count :active_accounts, from: Account, scope: ->(rel) { rel.active }
267
+ def validate_scope!(node, scope)
268
+ return if scope.nil? || scope.respond_to?(:call)
269
+
270
+ raise ConfigurationError, "`#{node}, scope:` expects a callable, got #{scope.inspect}"
271
+ end
272
+
273
+ def require_source!(node)
274
+ return unless @model.nil?
275
+
276
+ raise MissingSource, "`#{node}` needs a root record - declare `source SomeModel` on the query"
277
+ end
278
+
279
+ def add(node)
280
+ if @nodes.any? { |existing| existing.output == node.output }
281
+ raise ConfigurationError, "duplicate output name `#{node.output}` in schema"
282
+ end
283
+
284
+ @nodes << node
285
+ node
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ module Schema
5
+ # `exists :photos` -> correlated `EXISTS (SELECT 1 FROM photos WHERE ...)`
6
+ class Exists < Node
7
+ include Conditions
8
+
9
+ attr_reader :association, :where, :scope
10
+
11
+ def initialize(output:, association:, where: nil, scope: nil)
12
+ super(output: output)
13
+ @association = association
14
+ @where = where.respond_to?(:call) ? where : where&.dup&.freeze
15
+ @scope = scope
16
+ freeze
17
+ end
18
+
19
+ def target_model
20
+ association.klass
21
+ end
22
+
23
+ def relation_scoped?
24
+ !scope.nil? || !where.nil?
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ module Schema
5
+ # `field :email, as: :contact_email`
6
+ class Field < Node
7
+ attr_reader :column
8
+
9
+ def initialize(column:, output:)
10
+ super(output: output)
11
+ @column = column.to_sym
12
+ freeze
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ module Schema
5
+ # Base class for every AST node.
6
+ #
7
+ # `output` is the key the node produces on the result object.
8
+ class Node
9
+ attr_reader :output
10
+
11
+ def initialize(output:)
12
+ @output = output.to_sym
13
+ end
14
+
15
+ # Nodes that map to exactly one selected column/expression.
16
+ def scalar?
17
+ true
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ module Schema
5
+ # `one :plan, via: :subscription do ... end`
6
+ #
7
+ # Compiles to a LEFT OUTER JOIN plus a hidden presence marker so that
8
+ # "association missing" and "association present with NULL columns" stay
9
+ # distinguishable.
10
+ #
11
+ # `first :comment, via: :comments do ... end` and its `last` counterpart use
12
+ # the same node with `pick` and `order` set: one row out of a collection,
13
+ # compiled as correlated subqueries instead of a join.
14
+ class One < Node
15
+ attr_reader :association, :scope, :pick, :order
16
+
17
+ def initialize(output:, association:, scope:, pick: nil, order: nil)
18
+ super(output: output)
19
+ @association = association
20
+ @scope = scope
21
+ @pick = pick
22
+ @order = order&.freeze
23
+ freeze
24
+ end
25
+
26
+ # True when this node picks one row out of many rather than following a
27
+ # singular association.
28
+ def picked?
29
+ !pick.nil?
30
+ end
31
+
32
+ def scalar?
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ module Schema
5
+ # A level of the AST: the nodes selected for one model (the root source, or
6
+ # one nested singular association).
7
+ class Scope
8
+ attr_reader :model, :nodes
9
+
10
+ def initialize(model, nodes)
11
+ @model = model
12
+ @nodes = nodes.freeze
13
+ freeze
14
+ end
15
+
16
+ def fields
17
+ nodes.grep(Field)
18
+ end
19
+
20
+ def ones
21
+ nodes.grep(One)
22
+ end
23
+
24
+ def outputs
25
+ nodes.map(&:output)
26
+ end
27
+
28
+ # True when anything here needs a root record to hang off.
29
+ def rooted?
30
+ nodes.any? { |node| !(node.is_a?(Aggregate) && node.independent?) }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ VERSION = "0.1.0"
5
+ end
data/lib/pluckr.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/core_ext/object/json"
5
+ require "active_support/notifications"
6
+
7
+ require "pluckr/version"
8
+ require "pluckr/errors"
9
+ require "pluckr/aliases"
10
+ require "pluckr/reflection/association"
11
+ require "pluckr/schema/node"
12
+ require "pluckr/schema/conditions"
13
+ require "pluckr/schema/field"
14
+ require "pluckr/schema/one"
15
+ require "pluckr/schema/exists"
16
+ require "pluckr/schema/aggregate"
17
+ require "pluckr/schema/scope"
18
+ require "pluckr/schema/definition"
19
+ require "pluckr/compiler/sql"
20
+ require "pluckr/result/object"
21
+ require "pluckr/result/builder"
22
+ require "pluckr/relation"
23
+ require "pluckr/batch"
24
+ require "pluckr/query"
25
+
26
+ # A declarative read-query layer for ActiveRecord.
27
+ #
28
+ # DSL -> schema AST -> reflection -> SQL compiler -> flat row -> result object
29
+ module Pluckr
30
+ # Every read of a read model goes through here, so one subscription covers
31
+ # `fetch`, `find`, `for` and `Pluckr.batch` alike. The statements Pluckr does
32
+ # not compile are absent by design: `count`/`exists?` and the primary keys of
33
+ # a paginated `.for` are ActiveRecord's own, and arrive as `sql.active_record`.
34
+ #
35
+ # ActiveSupport::Notifications.subscribe("fetch.pluckr") do |*, payload|
36
+ # payload[:name] # => "UserSummary"
37
+ # payload[:sql] # => "SELECT ..."
38
+ # payload[:rows] # => 100
39
+ # end
40
+ def self.select_all(connection, sql, name:, label: "#{name} Pluckr")
41
+ ActiveSupport::Notifications.instrument("fetch.pluckr", name: name, sql: sql) do |payload|
42
+ connection.select_all(sql, label).tap { |result| payload[:rows] = result.length }
43
+ end
44
+ end
45
+ end
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pluckr
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Igor Kasyanchuk
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: irb
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rdoc
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: Pluckr lets you declare the shape of the data you need and compiles it
69
+ into a single SQL statement, returning lightweight immutable result objects instead
70
+ of ActiveRecord models. Works on PostgreSQL, MySQL and SQLite.
71
+ email:
72
+ - igorkasyanchuk@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - CHANGELOG.md
78
+ - LICENSE.txt
79
+ - PROMPT.md
80
+ - README.md
81
+ - lib/pluckr.rb
82
+ - lib/pluckr/aliases.rb
83
+ - lib/pluckr/batch.rb
84
+ - lib/pluckr/compiler/sql.rb
85
+ - lib/pluckr/errors.rb
86
+ - lib/pluckr/query.rb
87
+ - lib/pluckr/reflection/association.rb
88
+ - lib/pluckr/relation.rb
89
+ - lib/pluckr/result/builder.rb
90
+ - lib/pluckr/result/object.rb
91
+ - lib/pluckr/schema/aggregate.rb
92
+ - lib/pluckr/schema/conditions.rb
93
+ - lib/pluckr/schema/definition.rb
94
+ - lib/pluckr/schema/exists.rb
95
+ - lib/pluckr/schema/field.rb
96
+ - lib/pluckr/schema/node.rb
97
+ - lib/pluckr/schema/one.rb
98
+ - lib/pluckr/schema/scope.rb
99
+ - lib/pluckr/version.rb
100
+ homepage: https://github.com/igorkasyanchuk/pluckr
101
+ licenses:
102
+ - MIT
103
+ metadata:
104
+ homepage_uri: https://github.com/igorkasyanchuk/pluckr
105
+ source_code_uri: https://github.com/igorkasyanchuk/pluckr
106
+ changelog_uri: https://github.com/igorkasyanchuk/pluckr/blob/main/CHANGELOG.md
107
+ rubygems_mfa_required: 'true'
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: 3.2.0
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubygems_version: 3.7.2
123
+ specification_version: 4
124
+ summary: A declarative read-query layer for ActiveRecord.
125
+ test_files: []