praxis 0.21 → 2.0.pre.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +8 -15
- data/CHANGELOG.md +328 -299
- data/CONTRIBUTING.md +4 -4
- data/README.md +11 -9
- data/lib/api_browser/app/js/directives/attribute_table.js +2 -1
- data/lib/api_browser/app/js/directives/conditional_requirements.js +13 -0
- data/lib/api_browser/app/js/directives/type_placeholder.js +10 -1
- data/lib/api_browser/app/js/factories/normalize_attributes.js +4 -2
- data/lib/api_browser/app/js/factories/template_for.js +5 -2
- data/lib/api_browser/app/js/filters/has_requirement.js +14 -0
- data/lib/api_browser/app/js/filters/tag_requirement.js +13 -0
- data/lib/api_browser/app/sass/praxis.scss +11 -0
- data/lib/api_browser/app/views/action.html +2 -2
- data/lib/api_browser/app/views/directives/attribute_description/member_options.html +2 -2
- data/lib/api_browser/app/views/directives/attribute_table.html +1 -1
- data/lib/api_browser/app/views/type.html +1 -1
- data/lib/api_browser/app/views/type/details.html +2 -2
- data/lib/api_browser/app/views/types/embedded/array.html +2 -0
- data/lib/api_browser/app/views/types/embedded/default.html +3 -1
- data/lib/api_browser/app/views/types/embedded/requirements.html +6 -0
- data/lib/api_browser/app/views/types/embedded/single_req.html +9 -0
- data/lib/api_browser/app/views/types/embedded/struct.html +14 -2
- data/lib/api_browser/app/views/types/standalone/array.html +1 -1
- data/lib/api_browser/app/views/types/standalone/struct.html +2 -1
- data/lib/api_browser/package.json +1 -1
- data/lib/praxis.rb +9 -3
- data/lib/praxis/action_definition.rb +1 -1
- data/lib/praxis/action_definition/headers_dsl_compiler.rb +1 -1
- data/lib/praxis/application.rb +1 -9
- data/lib/praxis/bootloader.rb +1 -4
- data/lib/praxis/config.rb +1 -1
- data/lib/praxis/dispatcher.rb +10 -6
- data/lib/praxis/docs/generator.rb +2 -1
- data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +180 -0
- data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +273 -0
- data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +125 -0
- data/lib/praxis/extensions/field_selection.rb +1 -9
- data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +51 -0
- data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +61 -0
- data/lib/praxis/extensions/rails_compat.rb +2 -0
- data/lib/praxis/extensions/rails_compat/request_methods.rb +19 -0
- data/lib/praxis/handlers/xml.rb +1 -1
- data/lib/praxis/mapper/active_model_compat.rb +98 -0
- data/lib/praxis/mapper/resource.rb +242 -0
- data/lib/praxis/mapper/selector_generator.rb +149 -0
- data/lib/praxis/mapper/sequel_compat.rb +76 -0
- data/lib/praxis/media_type_identifier.rb +2 -1
- data/lib/praxis/middleware_app.rb +20 -2
- data/lib/praxis/multipart/parser.rb +14 -2
- data/lib/praxis/notifications.rb +1 -1
- data/lib/praxis/plugins/mapper_plugin.rb +64 -0
- data/lib/praxis/plugins/rails_plugin.rb +104 -0
- data/lib/praxis/request.rb +7 -1
- data/lib/praxis/request_superclassing.rb +11 -0
- data/lib/praxis/resource_definition.rb +5 -5
- data/lib/praxis/response.rb +1 -1
- data/lib/praxis/route.rb +1 -1
- data/lib/praxis/routing_config.rb +1 -1
- data/lib/praxis/trait.rb +1 -1
- data/lib/praxis/types/media_type_common.rb +2 -2
- data/lib/praxis/types/multipart.rb +1 -1
- data/lib/praxis/types/multipart_array.rb +2 -2
- data/lib/praxis/types/multipart_array/part_definition.rb +1 -1
- data/lib/praxis/version.rb +1 -1
- data/praxis.gemspec +14 -13
- data/spec/functional_spec.rb +4 -7
- data/spec/praxis/action_definition_spec.rb +1 -1
- data/spec/praxis/application_spec.rb +1 -1
- data/spec/praxis/collection_spec.rb +3 -2
- data/spec/praxis/config_spec.rb +2 -2
- data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +106 -0
- data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +147 -0
- data/spec/praxis/extensions/field_selection/support/spec_resources_active_model.rb +130 -0
- data/spec/praxis/extensions/field_selection/support/spec_resources_sequel.rb +106 -0
- data/spec/praxis/handlers/xml_spec.rb +2 -2
- data/spec/praxis/mapper/resource_spec.rb +169 -0
- data/spec/praxis/mapper/selector_generator_spec.rb +293 -0
- data/spec/praxis/media_type_spec.rb +0 -10
- data/spec/praxis/middleware_app_spec.rb +29 -9
- data/spec/praxis/request_stages/action_spec.rb +8 -1
- data/spec/praxis/response_definition_spec.rb +7 -4
- data/spec/praxis/response_spec.rb +1 -1
- data/spec/praxis/responses/internal_server_error_spec.rb +2 -2
- data/spec/praxis/responses/validation_error_spec.rb +2 -2
- data/spec/praxis/router_spec.rb +1 -1
- data/spec/spec_app/app/controllers/instances.rb +1 -1
- data/spec/spec_app/config/environment.rb +3 -21
- data/spec/spec_helper.rb +11 -15
- data/spec/support/be_deep_equal_matcher.rb +39 -0
- data/spec/support/spec_resources.rb +124 -0
- data/tasks/thor/templates/generator/empty_app/Gemfile +3 -3
- metadata +102 -77
- data/.ruby-version +0 -1
- data/lib/praxis/extensions/mapper_selectors.rb +0 -16
- data/lib/praxis/media_type_collection.rb +0 -127
- data/lib/praxis/plugins/praxis_mapper_plugin.rb +0 -246
- data/lib/praxis/stats.rb +0 -113
- data/spec/praxis/media_type_collection_spec.rb +0 -157
- data/spec/praxis/plugins/praxis_mapper_plugin_spec.rb +0 -142
- data/spec/praxis/stats_spec.rb +0 -9
- data/spec/spec_app/app/models/person.rb +0 -3
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# rubocop:disable all
|
3
|
+
module Praxis
|
4
|
+
module Extensions
|
5
|
+
class SequelFilterQueryBuilder
|
6
|
+
attr_reader :query, :root
|
7
|
+
|
8
|
+
# Abstract class, which needs to be used by subclassing it through the .for method, to set the mapping of attributes
|
9
|
+
class << self
|
10
|
+
def for(definition)
|
11
|
+
Class.new(self) do
|
12
|
+
@attr_to_column = case definition
|
13
|
+
when Hash
|
14
|
+
definition
|
15
|
+
when Array
|
16
|
+
definition.each_with_object({}) { |item, hash| hash[item.to_sym] = item }
|
17
|
+
else
|
18
|
+
raise "Cannot use FilterQueryBuilder.of without passing an array or a hash (Got: #{definition.class.name})"
|
19
|
+
end
|
20
|
+
class << self
|
21
|
+
attr_reader :attr_to_column
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Base query to build upon
|
28
|
+
# table is necessary when use the strin queries, when the query has multiple tables involved
|
29
|
+
# (to disambiguate)
|
30
|
+
def initialize(query:, model: )
|
31
|
+
@query = query
|
32
|
+
@root = model.table_name
|
33
|
+
end
|
34
|
+
|
35
|
+
# By default we'll simply use the incoming op and value, and will map
|
36
|
+
# the attribute based on what's on the `attr_to_column` hash
|
37
|
+
def build_clause(filters)
|
38
|
+
seen_associations = Set.new
|
39
|
+
filters.each do |(attr, spec)|
|
40
|
+
column_name = attr_to_column[attr]
|
41
|
+
raise "Filtering by #{attr} not allowed (no mapping found)" unless column_name
|
42
|
+
if column_name.is_a?(Proc)
|
43
|
+
bindings = column_name.call(spec)
|
44
|
+
# A hash of bindings, consisting of a key with column name and a value to the query value
|
45
|
+
bindings.each{|col,val| expand_binding(column_name: col, op: spec[:op], value: val )}
|
46
|
+
else
|
47
|
+
expand_binding(column_name: column_name, **spec)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
query
|
51
|
+
end
|
52
|
+
|
53
|
+
def expand_binding(column_name:,op:,value:)
|
54
|
+
assoc_or_field, *rest = column_name.to_s.split('.')
|
55
|
+
if rest.empty?
|
56
|
+
column_name = Sequel.qualify(root,column_name)
|
57
|
+
else
|
58
|
+
puts "Adding eager graph for #{assoc_or_field} due to being used in filter"
|
59
|
+
# Ensure the joined table is aliased properly (to the association name) so we can add the condition appropriately
|
60
|
+
@query = query.eager_graph(Sequel.as(assoc_or_field.to_sym, assoc_or_field.to_sym) )
|
61
|
+
column_name = Sequel.qualify(assoc_or_field, rest.first)
|
62
|
+
end
|
63
|
+
add_clause(attr: column_name, op: op, value: value)
|
64
|
+
end
|
65
|
+
|
66
|
+
def attr_to_column
|
67
|
+
# Class method defined by the subclassing Class (using .for)
|
68
|
+
self.class.attr_to_column
|
69
|
+
end
|
70
|
+
|
71
|
+
# Private to try to funnel all column names through `build_clause` that restricts
|
72
|
+
# the attribute names better (to allow more difficult SQL injections )
|
73
|
+
private def add_clause(attr:, op:, value:)
|
74
|
+
# TODO: partial matching
|
75
|
+
#components = attr.to_s.split('.')
|
76
|
+
#attr_selector = Sequel.qualify(*components)
|
77
|
+
attr_selector = attr
|
78
|
+
# HERE!! if we have "association.name" we should properly join it ...!
|
79
|
+
|
80
|
+
#> ds.eager_graph(:device).where{{device[:name] => 'A%'}}.select(:accountID)
|
81
|
+
#=> #<Sequel::Mysql2::Dataset: "SELECT `accountID` FROM `EventData`
|
82
|
+
# LEFT OUTER JOIN `Device` AS `device` ON
|
83
|
+
# ((`device`.`accountID` = `EventData`.`accountID`) AND (`device`.`deviceID` = `EventData`.`deviceID`))
|
84
|
+
# WHERE (`device`.`name` = 'A%')">
|
85
|
+
likeval = get_like_value(value)
|
86
|
+
@query = case op
|
87
|
+
when '='
|
88
|
+
if likeval
|
89
|
+
query.where(Sequel.like(attr_selector, likeval))
|
90
|
+
else
|
91
|
+
query.where(attr_selector => value)
|
92
|
+
end
|
93
|
+
when '!='
|
94
|
+
if likeval
|
95
|
+
query.exclude(Sequel.like(attr_selector, likeval))
|
96
|
+
else
|
97
|
+
query.exclude(attr_selector => value)
|
98
|
+
end
|
99
|
+
when '>'
|
100
|
+
#query.where(Sequel.lit("#{attr_selector} > ?", value))
|
101
|
+
query.where{attr_selector > value}
|
102
|
+
when '<'
|
103
|
+
query.where{attr_selector < value}
|
104
|
+
when '>='
|
105
|
+
query.where{attr_selector >= value}
|
106
|
+
when '<='
|
107
|
+
query.where{attr_selector <= value}
|
108
|
+
else
|
109
|
+
raise "Unsupported Operator!!! #{op}"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Returns nil if the value was not a fuzzzy pattern
|
114
|
+
def get_like_value(value)
|
115
|
+
if value.is_a?(String) && (value[-1] == '*' || value[0] == '*')
|
116
|
+
likeval = value.dup
|
117
|
+
likeval[-1] = '%' if value[-1] == '*'
|
118
|
+
likeval[0] = '%' if value[0] == '*'
|
119
|
+
likeval
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
# rubocop:enable all
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Praxis
|
3
|
+
module Extensions
|
4
|
+
module FieldSelection
|
5
|
+
class ActiveRecordQuerySelector
|
6
|
+
attr_reader :selector, :query
|
7
|
+
# Gets a dataset, a selector...and should return a dataset with the selector definition applied.
|
8
|
+
def initialize(query:, selectors:)
|
9
|
+
@selector = selectors
|
10
|
+
@query = query
|
11
|
+
end
|
12
|
+
|
13
|
+
def generate(debug: false)
|
14
|
+
# TODO: unfortunately, I think we can only control the select clauses for the top model
|
15
|
+
# (as I'm not sure ActiveRecord supports expressing it in the join...)
|
16
|
+
@query = add_select(query: query, selector_node: selector)
|
17
|
+
eager_hash = _eager(selector)
|
18
|
+
|
19
|
+
@query = @query.includes(eager_hash)
|
20
|
+
explain_query(query, eager_hash) if debug
|
21
|
+
|
22
|
+
@query
|
23
|
+
end
|
24
|
+
|
25
|
+
def add_select(query:, selector_node:)
|
26
|
+
# We're gonna always require the PK of the model, as it is a special case for AR, and the app itself
|
27
|
+
# might assume it is always there and not be surprised by the fact that if it isn't, it won't blow up
|
28
|
+
# in the same way as any other attribute not being loaded...i.e., ActiveModel::MissingAttributeError: missing attribute: xyz
|
29
|
+
select_fields = selector_node.select + [selector_node.resource.model.primary_key.to_sym]
|
30
|
+
select_fields.empty? ? query : query.select(*select_fields)
|
31
|
+
end
|
32
|
+
|
33
|
+
def _eager(selector_node)
|
34
|
+
selector_node.tracks.each_with_object({}) do |(track_name, track_node), h|
|
35
|
+
h[track_name] = _eager(track_node)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def explain_query(query, eager_hash)
|
40
|
+
prev = ActiveRecord::Base.logger
|
41
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
42
|
+
ActiveRecord::Base.logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
|
43
|
+
ActiveRecord::Base.logger.debug(" ActiveRecord query: #{selector.resource.model}.includes(#{eager_hash})")
|
44
|
+
query.explain
|
45
|
+
ActiveRecord::Base.logger.debug("Query plan end")
|
46
|
+
ActiveRecord::Base.logger = prev
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sequel'
|
4
|
+
|
5
|
+
module Praxis
|
6
|
+
module Extensions
|
7
|
+
module FieldSelection
|
8
|
+
class SequelQuerySelector
|
9
|
+
attr_reader :selector, :query
|
10
|
+
# Gets a dataset, a selector...and should return a dataset with the selector definition applied.
|
11
|
+
def initialize(query:, selectors:)
|
12
|
+
@selector = selectors
|
13
|
+
@query = query
|
14
|
+
end
|
15
|
+
|
16
|
+
def generate(debug: false)
|
17
|
+
@query = add_select(query: query, selector_node: @selector)
|
18
|
+
|
19
|
+
@query = @selector.tracks.inject(@query) do |ds, (track_name, track_node)|
|
20
|
+
ds.eager(track_name => _eager(track_node) )
|
21
|
+
end
|
22
|
+
|
23
|
+
explain_query(query) if debug
|
24
|
+
@query
|
25
|
+
end
|
26
|
+
|
27
|
+
def _eager(selector_node)
|
28
|
+
lambda do |dset|
|
29
|
+
dset = add_select(query: dset, selector_node: selector_node)
|
30
|
+
|
31
|
+
dset = selector_node.tracks.inject(dset) do |ds, (track_name, track_node)|
|
32
|
+
ds.eager(track_name => _eager(track_node) )
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def add_select(query:, selector_node:)
|
39
|
+
# We're gonna always require the PK of the model, as it is a special case for Sequel, and the app itself
|
40
|
+
# might assume it is always there and not be surprised by the fact that if it isn't, it won't blow up
|
41
|
+
# in the same way as any other attribute not being loaded...i.e., NoMethodError: undefined method `foobar' for #<...>
|
42
|
+
select_fields = selector_node.select + [selector_node.resource.model.primary_key.to_sym]
|
43
|
+
|
44
|
+
table_name = selector_node.resource.model.table_name
|
45
|
+
qualified = select_fields.map { |f| Sequel.qualify(table_name, f) }
|
46
|
+
query.select(*qualified)
|
47
|
+
end
|
48
|
+
|
49
|
+
def explain_query(ds)
|
50
|
+
prev_loggers = Sequel::Model.db.loggers
|
51
|
+
stdout_logger = Logger.new($stdout)
|
52
|
+
Sequel::Model.db.loggers = [stdout_logger]
|
53
|
+
stdout_logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
|
54
|
+
ds.all
|
55
|
+
stdout_logger.debug("Query plan end")
|
56
|
+
Sequel::Model.db.loggers = prev_loggers
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# Make Praxis' request derive from ActionDispatch
|
2
|
+
if defined? Praxis::Request
|
3
|
+
puts "IT seems that we're trying to redefine Praxis' request parent too late."
|
4
|
+
puts "-> try to include the Rails compat pieces earlier in the bootstrap process (before Praxis::Request is requried)"
|
5
|
+
exit(-1)
|
6
|
+
end
|
7
|
+
|
8
|
+
begin
|
9
|
+
require 'praxis/request_superclassing'
|
10
|
+
|
11
|
+
module Praxis
|
12
|
+
require 'action_dispatch'
|
13
|
+
Praxis.request_superclass = ::ActionDispatch::Request
|
14
|
+
end
|
15
|
+
require 'praxis/request'
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
|
data/lib/praxis/handlers/xml.rb
CHANGED
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/concern'
|
4
|
+
|
5
|
+
require 'praxis/extensions/field_selection/active_record_query_selector'
|
6
|
+
|
7
|
+
module Praxis
|
8
|
+
module Mapper
|
9
|
+
module ActiveModelCompat
|
10
|
+
extend ActiveSupport::Concern
|
11
|
+
|
12
|
+
included do
|
13
|
+
attr_accessor :_resource
|
14
|
+
end
|
15
|
+
|
16
|
+
module ClassMethods
|
17
|
+
def _filter_query_builder_class
|
18
|
+
Praxis::Extensions::ActiveRecordFilterQueryBuilder
|
19
|
+
end
|
20
|
+
|
21
|
+
def _field_selector_query_builder_class
|
22
|
+
Praxis::Extensions::FieldSelection::ActiveRecordQuerySelector
|
23
|
+
end
|
24
|
+
|
25
|
+
def _praxis_associations
|
26
|
+
orig = self.reflections.clone
|
27
|
+
|
28
|
+
orig.each_with_object({}) do |(k, v), hash|
|
29
|
+
# Assume an 'id' primary key if the system is initializing without AR connected
|
30
|
+
# (or without the tables created). This probably means that it's a rake task initializing or so...
|
31
|
+
pkey = \
|
32
|
+
if v.klass.connected? && v.klass.table_exists?
|
33
|
+
v.klass.primary_key
|
34
|
+
else
|
35
|
+
'id'
|
36
|
+
end
|
37
|
+
info = { model: v.klass, primary_key: pkey }
|
38
|
+
info[:type] = \
|
39
|
+
case v
|
40
|
+
when ActiveRecord::Reflection::BelongsToReflection
|
41
|
+
:many_to_one
|
42
|
+
when ActiveRecord::Reflection::HasManyReflection, ActiveRecord::Reflection::HasOneReflection
|
43
|
+
:one_to_many
|
44
|
+
when ActiveRecord::Reflection::ThroughReflection
|
45
|
+
:many_to_many
|
46
|
+
else
|
47
|
+
raise "Unknown association type: #{v.class.name} on #{v.klass.name} for #{v.name}"
|
48
|
+
end
|
49
|
+
# Call out any local (i.e., of this model) columns that participate in the association
|
50
|
+
info[:local_key_columns] = local_columns_used_for_the_association(info[:type], v)
|
51
|
+
info[:remote_key_columns] = remote_columns_used_for_the_association(info[:type], v)
|
52
|
+
|
53
|
+
if v.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
54
|
+
info[:through] = v.through_reflection.name # TODO: is this correct?
|
55
|
+
end
|
56
|
+
hash[k.to_sym] = info
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
def local_columns_used_for_the_association(type, assoc_reflection)
|
62
|
+
case type
|
63
|
+
when :one_to_many
|
64
|
+
# The associated table will point to us by key (usually the PK, but not always)
|
65
|
+
[assoc_reflection.join_keys.foreign_key.to_sym]
|
66
|
+
when :many_to_one
|
67
|
+
# We have the FKs to the associated model
|
68
|
+
[assoc_reflection.join_keys.foreign_key.to_sym]
|
69
|
+
when :many_to_many
|
70
|
+
ref = resolve_closest_through_reflection(assoc_reflection)
|
71
|
+
# The associated middle table will point to us by key (usually the PK, but not always)
|
72
|
+
[ref.join_keys.foreign_key.to_sym] # The foreign key that the last through table points to
|
73
|
+
else
|
74
|
+
raise "association type #{type} not supported"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def remote_columns_used_for_the_association(type, assoc_reflection)
|
79
|
+
# It seems that since the reflection is the target of the association, using the join_keys.key
|
80
|
+
# will always get us the right column
|
81
|
+
case type
|
82
|
+
when :one_to_many, :many_to_one, :many_to_many
|
83
|
+
[assoc_reflection.join_keys.key.to_sym]
|
84
|
+
else
|
85
|
+
raise "association type #{type} not supported"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Keep following the association reflections as long as there are middle ones (i.e., through)
|
90
|
+
# until we come to the one next to the source
|
91
|
+
def resolve_closest_through_reflection(ref)
|
92
|
+
return ref unless ref.through_reflection?
|
93
|
+
resolve_closest_through_reflection( ref.through_reflection )
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,242 @@
|
|
1
|
+
# A resource creates a data store and instantiates a list of models that it wishes to load, building up the overall set of data that it will need.
|
2
|
+
# Once that is complete, the data set is iterated and a resultant view is generated.
|
3
|
+
module Praxis::Mapper
|
4
|
+
|
5
|
+
class Resource
|
6
|
+
extend Praxis::Finalizable
|
7
|
+
|
8
|
+
attr_accessor :record
|
9
|
+
|
10
|
+
@properties = {}
|
11
|
+
|
12
|
+
class << self
|
13
|
+
attr_reader :model_map
|
14
|
+
attr_reader :properties
|
15
|
+
end
|
16
|
+
|
17
|
+
# TODO: also support an attribute of sorts on the versioned resource module. ie, V1::Resources.api_version.
|
18
|
+
# replacing the self.superclass == Praxis::Mapper::Resource condition below.
|
19
|
+
def self.inherited(klass)
|
20
|
+
super
|
21
|
+
|
22
|
+
klass.instance_eval do
|
23
|
+
# It is expected that each versioned set of resources
|
24
|
+
# will have a common Base class, and so should share
|
25
|
+
# a model_map
|
26
|
+
if self.superclass == Praxis::Mapper::Resource
|
27
|
+
@model_map = Hash.new
|
28
|
+
else
|
29
|
+
@model_map = self.superclass.model_map
|
30
|
+
end
|
31
|
+
|
32
|
+
@properties = self.superclass.properties.clone
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
#TODO: Take symbol/string and resolve the klass (but lazily, so we don't care about load order)
|
38
|
+
def self.model(klass=nil)
|
39
|
+
if klass
|
40
|
+
raise "Model #{klass.name} must be compatible with Praxis. Use ActiveModelCompat or similar compatability plugin." unless klass.methods.include?(:_praxis_associations)
|
41
|
+
@model = klass
|
42
|
+
self.model_map[klass] = self
|
43
|
+
else
|
44
|
+
@model
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.property(name, **options)
|
49
|
+
self.properties[name] = options
|
50
|
+
end
|
51
|
+
|
52
|
+
def self._finalize!
|
53
|
+
finalize_resource_delegates
|
54
|
+
define_model_accessors
|
55
|
+
|
56
|
+
super
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.finalize_resource_delegates
|
60
|
+
return unless @resource_delegates
|
61
|
+
|
62
|
+
@resource_delegates.each do |record_name, record_attributes|
|
63
|
+
record_attributes.each do |record_attribute|
|
64
|
+
self.define_resource_delegate(record_name, record_attribute)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
|
70
|
+
def self.define_model_accessors
|
71
|
+
return if model.nil?
|
72
|
+
|
73
|
+
model._praxis_associations.each do |k,v|
|
74
|
+
unless self.instance_methods.include? k
|
75
|
+
define_model_association_accessor(k,v)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def self.for_record(record)
|
81
|
+
return record._resource if record._resource
|
82
|
+
|
83
|
+
if resource_class_for_record = model_map[record.class]
|
84
|
+
return record._resource = resource_class_for_record.new(record)
|
85
|
+
else
|
86
|
+
version = self.name.split("::")[0..-2].join("::")
|
87
|
+
resource_name = record.class.name.split("::").last
|
88
|
+
|
89
|
+
raise "No resource class corresponding to the model class '#{record.class}' is defined. (Did you forget to define '#{version}::#{resource_name}'?)"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
|
94
|
+
def self.wrap(records)
|
95
|
+
if records.nil?
|
96
|
+
return []
|
97
|
+
elsif( records.is_a?(Enumerable) )
|
98
|
+
return records.compact.map { |record| self.for_record(record) }
|
99
|
+
elsif ( records.respond_to?(:to_a) )
|
100
|
+
return records.to_a.compact.map { |record| self.for_record(record) }
|
101
|
+
else
|
102
|
+
return self.for_record(records)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
|
107
|
+
def self.get(condition)
|
108
|
+
record = self.model.get(condition)
|
109
|
+
|
110
|
+
self.wrap(record)
|
111
|
+
end
|
112
|
+
|
113
|
+
def self.all(condition={})
|
114
|
+
records = self.model.all(condition)
|
115
|
+
|
116
|
+
self.wrap(records)
|
117
|
+
end
|
118
|
+
|
119
|
+
|
120
|
+
def self.resource_delegates
|
121
|
+
@resource_delegates ||= {}
|
122
|
+
end
|
123
|
+
|
124
|
+
def self.resource_delegate(spec)
|
125
|
+
spec.each do |resource_name, attributes|
|
126
|
+
resource_delegates[resource_name] = attributes
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Defines wrappers for model associations that return Resources
|
131
|
+
def self.define_model_association_accessor(name, association_spec)
|
132
|
+
association_model = association_spec.fetch(:model)
|
133
|
+
association_resource_class = model_map[association_model]
|
134
|
+
|
135
|
+
if association_resource_class
|
136
|
+
module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
137
|
+
def #{name}
|
138
|
+
records = record.#{name}
|
139
|
+
return nil if records.nil?
|
140
|
+
@__#{name} ||= #{association_resource_class}.wrap(records)
|
141
|
+
end
|
142
|
+
RUBY
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def self.define_resource_delegate(resource_name, resource_attribute)
|
147
|
+
related_model = model._praxis_associations[resource_name][:model]
|
148
|
+
related_association = related_model._praxis_associations[resource_attribute]
|
149
|
+
|
150
|
+
if related_association
|
151
|
+
self.define_delegation_for_related_association(resource_name, resource_attribute, related_association)
|
152
|
+
else
|
153
|
+
self.define_delegation_for_related_attribute(resource_name, resource_attribute)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
|
158
|
+
def self.define_delegation_for_related_attribute(resource_name, resource_attribute)
|
159
|
+
module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
160
|
+
def #{resource_attribute}
|
161
|
+
@__#{resource_attribute} ||= if (rec = self.#{resource_name})
|
162
|
+
rec.#{resource_attribute}
|
163
|
+
end
|
164
|
+
end
|
165
|
+
RUBY
|
166
|
+
end
|
167
|
+
|
168
|
+
def self.define_delegation_for_related_association(resource_name, resource_attribute, related_association)
|
169
|
+
related_resource_class = model_map[related_association[:model]]
|
170
|
+
return unless related_resource_class
|
171
|
+
|
172
|
+
module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
173
|
+
def #{resource_attribute}
|
174
|
+
@__#{resource_attribute} ||= if (rec = self.#{resource_name})
|
175
|
+
if (related = rec.#{resource_attribute})
|
176
|
+
#{related_resource_class.name}.wrap(related)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
RUBY
|
181
|
+
end
|
182
|
+
|
183
|
+
def self.define_accessor(name)
|
184
|
+
if name.to_s =~ /\?/
|
185
|
+
ivar_name = "is_#{name.to_s[0..-2]}"
|
186
|
+
else
|
187
|
+
ivar_name = "#{name}"
|
188
|
+
end
|
189
|
+
|
190
|
+
module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
191
|
+
def #{name}
|
192
|
+
return @__#{ivar_name} if defined? @__#{ivar_name}
|
193
|
+
@__#{ivar_name} = record.#{name}
|
194
|
+
end
|
195
|
+
RUBY
|
196
|
+
end
|
197
|
+
|
198
|
+
# TODO: this shouldn't be needed if we incorporate it with the properties of the mapper...
|
199
|
+
def self.filters_mapping(hash)
|
200
|
+
@_filter_query_builder_class = model._filter_query_builder_class.for(**hash)
|
201
|
+
end
|
202
|
+
|
203
|
+
def self._filter_query_builder_class
|
204
|
+
@_filter_query_builder_class
|
205
|
+
end
|
206
|
+
|
207
|
+
def self.craft_filter_query(base_query, filters:) # rubocop:disable Metrics/AbcSize
|
208
|
+
if filters && _filter_query_builder_class
|
209
|
+
base_query = _filter_query_builder_class.new(query: base_query, model: model).build_clause(filters)
|
210
|
+
end
|
211
|
+
|
212
|
+
base_query
|
213
|
+
end
|
214
|
+
|
215
|
+
def self.craft_field_selection_query(base_query, selectors:) # rubocop:disable Metrics/AbcSize
|
216
|
+
if selectors && model._field_selector_query_builder_class
|
217
|
+
debug = Praxis::Application.instance.config.mapper.debug_queries
|
218
|
+
base_query = model._field_selector_query_builder_class.new(query: base_query, selectors: selectors).generate(debug: debug)
|
219
|
+
end
|
220
|
+
|
221
|
+
base_query
|
222
|
+
end
|
223
|
+
|
224
|
+
def initialize(record)
|
225
|
+
@record = record
|
226
|
+
end
|
227
|
+
|
228
|
+
def respond_to_missing?(name,*)
|
229
|
+
@record.respond_to?(name) || super
|
230
|
+
end
|
231
|
+
|
232
|
+
def method_missing(name,*args)
|
233
|
+
if @record.respond_to?(name)
|
234
|
+
self.class.define_accessor(name)
|
235
|
+
self.send(name)
|
236
|
+
else
|
237
|
+
super
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
end
|
242
|
+
end
|