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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +66 -0
- data/LICENSE.txt +21 -0
- data/PROMPT.md +112 -0
- data/README.md +436 -0
- data/lib/pluckr/aliases.rb +37 -0
- data/lib/pluckr/batch.rb +216 -0
- data/lib/pluckr/compiler/sql.rb +363 -0
- data/lib/pluckr/errors.rb +24 -0
- data/lib/pluckr/query.rb +207 -0
- data/lib/pluckr/reflection/association.rb +109 -0
- data/lib/pluckr/relation.rb +414 -0
- data/lib/pluckr/result/builder.rb +100 -0
- data/lib/pluckr/result/object.rb +78 -0
- data/lib/pluckr/schema/aggregate.rb +58 -0
- data/lib/pluckr/schema/conditions.rb +45 -0
- data/lib/pluckr/schema/definition.rb +289 -0
- data/lib/pluckr/schema/exists.rb +28 -0
- data/lib/pluckr/schema/field.rb +16 -0
- data/lib/pluckr/schema/node.rb +21 -0
- data/lib/pluckr/schema/one.rb +37 -0
- data/lib/pluckr/schema/scope.rb +34 -0
- data/lib/pluckr/version.rb +5 -0
- data/lib/pluckr.rb +45 -0
- metadata +125 -0
data/lib/pluckr/query.rb
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluckr
|
|
4
|
+
# Subclass this to declare a read model:
|
|
5
|
+
#
|
|
6
|
+
# class UserSummary < Pluckr::Query
|
|
7
|
+
# source User
|
|
8
|
+
#
|
|
9
|
+
# schema do
|
|
10
|
+
# field :id
|
|
11
|
+
# field :email
|
|
12
|
+
# one :subscription do
|
|
13
|
+
# field :name
|
|
14
|
+
# end
|
|
15
|
+
# exists :photos
|
|
16
|
+
# count :videos
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
class Query
|
|
20
|
+
class << self
|
|
21
|
+
# ------------------------------------------------------------- DSL --
|
|
22
|
+
|
|
23
|
+
def source(model)
|
|
24
|
+
unless model.is_a?(Class) && model < ActiveRecord::Base
|
|
25
|
+
raise ConfigurationError, "`source` expects an ActiveRecord model, got #{model.inspect}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@source_model = model
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def schema(&block)
|
|
32
|
+
@pluckr_schema = Schema::Definition.build(source_model, &block)
|
|
33
|
+
reset_compilation!
|
|
34
|
+
@pluckr_schema
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Declared on this class, or inherited from the query it subclasses.
|
|
38
|
+
def source_model
|
|
39
|
+
@source_model || inherited_from_query(:source_model)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def pluckr_schema
|
|
43
|
+
declared_schema || raise(ConfigurationError, "#{name} has no `schema` block")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The schema declared here or by a query this one subclasses, or nil.
|
|
47
|
+
# Kept separate from `pluckr_schema` so the error names the class that was
|
|
48
|
+
# actually used, not an intermediate base class.
|
|
49
|
+
def declared_schema
|
|
50
|
+
@pluckr_schema || inherited_from_query(:declared_schema)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Source-rooted queries return rows; aggregate-only queries return one object.
|
|
54
|
+
def source_rooted?
|
|
55
|
+
!source_model.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------- querying --
|
|
59
|
+
|
|
60
|
+
def all
|
|
61
|
+
raise MissingSource, "#{name} has no `source` model to query" unless source_rooted?
|
|
62
|
+
|
|
63
|
+
Relation.new(self, source_model.all)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def where(...)
|
|
67
|
+
all.where(...)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def order(...)
|
|
71
|
+
all.order(...)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def limit(value)
|
|
75
|
+
all.limit(value)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def offset(value)
|
|
79
|
+
all.offset(value)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def find(...)
|
|
83
|
+
all.find(...)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def find_by(...)
|
|
87
|
+
all.find_by(...)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def find_by!(...)
|
|
91
|
+
all.find_by!(...)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# `COUNT(*)`/`SELECT 1` over the root rows, not a fetch counted in Ruby.
|
|
95
|
+
def count(...)
|
|
96
|
+
all.count(...)
|
|
97
|
+
end
|
|
98
|
+
alias_method :size, :count
|
|
99
|
+
|
|
100
|
+
def exists?(...)
|
|
101
|
+
all.exists?(...)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def any?(...)
|
|
105
|
+
all.any?(...)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def none?(...)
|
|
109
|
+
all.none?(...)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def empty?
|
|
113
|
+
all.empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def find_each(...)
|
|
117
|
+
all.find_each(...)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def in_batches(...)
|
|
121
|
+
all.in_batches(...)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Read models for records already in memory. One statement for a collection.
|
|
125
|
+
#
|
|
126
|
+
# UserCard.for(user) # one object, like find(user.id)
|
|
127
|
+
# UserCard.for(users) # array, same order, one SQL
|
|
128
|
+
# UserCard.for(scope) # an unloaded relation stays a subquery
|
|
129
|
+
# UserCard.for(scope.limit(2)) # a page: its keys first, then one read
|
|
130
|
+
def for(records)
|
|
131
|
+
all.for(records)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def first(...)
|
|
135
|
+
all.first(...)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def first!
|
|
139
|
+
all.first!
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def last(...)
|
|
143
|
+
all.last(...)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def last!
|
|
147
|
+
all.last!
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def take(...)
|
|
151
|
+
all.take(...)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def take!
|
|
155
|
+
all.take!
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def one?(...)
|
|
159
|
+
all.one?(...)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def many?(...)
|
|
163
|
+
all.many?(...)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def fetch
|
|
167
|
+
source_rooted? ? all.fetch : fetch_aggregates
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def to_sql
|
|
171
|
+
source_rooted? ? all.to_sql : compiler.aggregate_only_sql
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def explain(...)
|
|
175
|
+
all.explain(...)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------- internals --
|
|
179
|
+
|
|
180
|
+
def compiler
|
|
181
|
+
@compiler ||= Compiler::Sql.new(schema: pluckr_schema, source_model: source_model)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def result_builder
|
|
185
|
+
@result_builder ||= Result::Builder.new(pluckr_schema, label: name || "Pluckr::Result")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def inherited_from_query(reader)
|
|
191
|
+
return nil unless superclass.respond_to?(reader) && superclass <= Query && superclass != Query
|
|
192
|
+
|
|
193
|
+
superclass.public_send(reader)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def fetch_aggregates
|
|
197
|
+
row = Pluckr.select_all(compiler.connection, to_sql, name: name).first
|
|
198
|
+
result_builder.build(row)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def reset_compilation!
|
|
202
|
+
@compiler = nil
|
|
203
|
+
@result_builder = nil
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluckr
|
|
4
|
+
module Reflection
|
|
5
|
+
# Thin wrapper over ActiveRecord association reflection.
|
|
6
|
+
#
|
|
7
|
+
# Everything Pluckr knows about tables, keys and join direction comes from
|
|
8
|
+
# here - developers never spell out foreign keys in the DSL.
|
|
9
|
+
class Association
|
|
10
|
+
SINGULAR_MACROS = %i[belongs_to has_one].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :owner, :name, :reflection
|
|
13
|
+
|
|
14
|
+
# @param owner [Class<ActiveRecord::Base>]
|
|
15
|
+
# @param name [Symbol] association name
|
|
16
|
+
def self.for(owner, name)
|
|
17
|
+
raise MissingSource, "Cannot resolve association `#{name}` without a `source` model" if owner.nil?
|
|
18
|
+
|
|
19
|
+
reflection = owner.reflect_on_association(name)
|
|
20
|
+
unless reflection
|
|
21
|
+
raise UnknownAssociation, "#{owner.name} does not have association `#{name}`"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
new(owner, name, reflection)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(owner, name, reflection)
|
|
28
|
+
@owner = owner
|
|
29
|
+
@name = name.to_sym
|
|
30
|
+
@reflection = reflection
|
|
31
|
+
validate_supported!
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def macro
|
|
36
|
+
reflection.macro
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def klass
|
|
40
|
+
reflection.klass
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def table_name
|
|
44
|
+
klass.table_name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def primary_key
|
|
48
|
+
klass.primary_key
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def singular?
|
|
52
|
+
SINGULAR_MACROS.include?(macro)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def collection?
|
|
56
|
+
macro == :has_many
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Column on the association's own table used in the join.
|
|
60
|
+
def association_key
|
|
61
|
+
reflection.join_primary_key.to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Column on the owner's table used in the join.
|
|
65
|
+
def owner_key
|
|
66
|
+
reflection.join_foreign_key.to_s
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Column holding the owner's type for `has_many/has_one ..., as: :thing`.
|
|
70
|
+
def type_column
|
|
71
|
+
reflection.type
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# ON clause for `owner_table` LEFT JOIN `association_table`.
|
|
75
|
+
def join_condition(association_table, owner_table)
|
|
76
|
+
condition = association_table[association_key].eq(owner_table[owner_key])
|
|
77
|
+
return condition unless type_column
|
|
78
|
+
|
|
79
|
+
# `has_many :likes, as: :likeable` also matches on likes.likeable_type.
|
|
80
|
+
condition.and(association_table[type_column].eq(owner.polymorphic_name))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def validate_supported!
|
|
86
|
+
if reflection.polymorphic?
|
|
87
|
+
raise UnsupportedAssociation,
|
|
88
|
+
"`#{owner.name}##{name}` is polymorphic, which Pluckr does not support yet"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if reflection.through_reflection?
|
|
92
|
+
raise UnsupportedAssociation,
|
|
93
|
+
"`#{owner.name}##{name}` is a :through association, which Pluckr does not support yet"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if klass.default_scopes.any?
|
|
97
|
+
raise UnsupportedAssociation,
|
|
98
|
+
"`#{owner.name}##{name}` targets #{klass.name}, which has a default_scope " \
|
|
99
|
+
"that Pluckr does not apply yet"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if reflection.scope
|
|
103
|
+
raise UnsupportedAssociation,
|
|
104
|
+
"`#{owner.name}##{name}` has a scope, which Pluckr does not apply yet"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|