praxis 0.21 → 2.0.pre.3
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 +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
|