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,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