lib/torque/postgresql/auxiliary_statement.rb in torque-postgresql-0.2.16 vs lib/torque/postgresql/auxiliary_statement.rb in torque-postgresql-1.0.0

- old
+ new

@@ -4,40 +4,43 @@ module PostgreSQL class AuxiliaryStatement TABLE_COLUMN_AS_STRING = /\A(?:"?(\w+)"?\.)?"?(\w+)"?\z/.freeze class << self - # These attributes require that the class is setup - # - # The attributes separation means - # exposed_attributes -> Will be projected to the main query - # selected_attributes -> Will be selected on the configurated query - # join_attributes -> Will be used to join the the queries - [:exposed_attributes, :selected_attributes, :query, :join_attributes, - :join_type, :requires].each do |attribute| - define_method(attribute) do - setup - instance_variable_get("@#{attribute}") - end - end + attr_reader :config # Find or create the class that will handle statement def lookup(name, base) const = name.to_s.camelize << '_' << self.name.demodulize return base.const_get(const, false) if base.const_defined?(const, false) base.const_set(const, Class.new(AuxiliaryStatement)) end # Create a new instance of an auxiliary statement def instantiate(statement, base, options = nil) - klass = base.auxiliary_statements_list[statement] + klass = while base < ActiveRecord::Base + list = base.auxiliary_statements_list + break list[statement] if list.present? && list.key?(statement) + + base = base.superclass + end + return klass.new(options) unless klass.nil? - raise ArgumentError, <<-MSG.strip + raise ArgumentError, <<-MSG.squish There's no '#{statement}' auxiliary statement defined for #{base.class.name}. MSG end + # Fast access to statement build + def build(statement, base, options = nil, bound_attributes = []) + klass = instantiate(statement, base, options) + result = klass.build(base) + + bound_attributes.concat(klass.bound_attributes) + result + end + # Identify if the query set may be used as a relation def relation_query?(obj) !obj.nil? && obj.respond_to?(:ancestors) && \ obj.ancestors.include?(ActiveRecord::Base) end @@ -45,25 +48,49 @@ # Identify if the query set may be used as arel def arel_query?(obj) !obj.nil? && obj.is_a?(::Arel::SelectManager) end - # Set a configuration block, if the class is already set up, just clean - # the query and wait it to be setup again - def configurator(block) - @config = block - @query = nil + # A way to create auxiliary statements outside of models configurations, + # being able to use on extensions + def create(table_or_settings, &block) + klass = Class.new(AuxiliaryStatement) + + if block_given? + klass.instance_variable_set(:@table_name, table_or_settings) + klass.configurator(block) + elsif relation_query?(table_or_settings) + klass.configurator(query: table_or_settings) + else + klass.configurator(table_or_settings) + end + + klass end - # Get the base class associated to this statement - def base - self.parent + # Set a configuration block or static hash + def configurator(config) + if config.is_a?(Hash) + # Map the aliases + config[:attributes] = config.delete(:select) if config.key?(:select) + + # Create the struct that mocks a configuration result + config = OpenStruct.new(config) + table_name = config[:query]&.klass&.name&.underscore + instance_variable_set(:@table_name, table_name) + end + + @config = config end - # Get the name of the base class - def base_name - base.name + # Run a configuration block or get the static configuration + def configure(base, instance) + return @config unless @config.respond_to?(:call) + + settings = Settings.new(base, instance) + settings.instance_exec(settings, &@config) + settings end # Get the arel version of the statement table def table @table ||= ::Arel::Table.new(table_name) @@ -71,229 +98,205 @@ # Get the name of the table of the configurated statement def table_name @table_name ||= self.name.demodulize.split('_').first.underscore end + end - # Get the arel table of the base class - def base_table - @base_table ||= base.arel_table - end + delegate :config, :table, :table_name, :relation, :configure, :relation_query?, + to: :class - # Get the arel table of the query - def query_table - @query_table ||= query.arel_table - end + attr_reader :bound_attributes - # Project a column on a given table, or use the column table - def project(column, arel_table = nil) - if column.respond_to?(:as) - return column - elsif (as_string = TABLE_COLUMN_AS_STRING.match(column.to_s)) - column = as_string[2] - arel_table = ::Arel::Table.new(as_string[1]) unless as_string[1].nil? - end + # Start a new auxiliary statement giving extra options + def initialize(*args) + options = args.extract_options! + args_key = Torque::PostgreSQL.config.auxiliary_statement.send_arguments_key - arel_table ||= table - arel_table[column.to_s] - end + @join = options.fetch(:join, {}) + @args = options.fetch(args_key, {}) + @where = options.fetch(:where, {}) + @select = options.fetch(:select, {}) + @join_type = options.fetch(:join_type, nil) + @bound_attributes = [] + end - private - # Just setup the class if it's not setup - def setup - setup! unless setup? - end + # Build the statement on the given arel and return the WITH statement + def build(base) + prepare(base) - # Check if the class is setup - def setup? - defined?(@query) && @query - end + # Add the join condition to the list + base.joins_values += [build_join(base)] - # Setup the class - def setup! - settings = Settings.new(self) - settings.instance_exec(settings, &@config) + # Return the statement with its dependencies + [@dependencies, ::Arel::Nodes::As.new(table, build_query(base))] + end - @join_type = settings.join_type || :inner - @requires = Array[settings.requires].flatten.compact - @query = settings.query + private + # Setup the statement using the class configuration + def prepare(base) + settings = configure(base, self) + requires = Array.wrap(settings.requires).flatten.compact + @dependencies = ensure_dependencies(requires, base).flatten.compact - # Manually set the query table when it's not an relation query - @query_table = settings.query_table unless relation_query?(@query) + @join_type ||= settings.join_type || :inner + @query = settings.query - # Reset all the used attributes - @selected_attributes = [] - @exposed_attributes = [] - @join_attributes = [] + # Call a proc to get the real query + if @query.methods.include?(:call) + call_args = @query.try(:arity) === 0 ? [] : [OpenStruct.new(@args)] + @query = @query.call(*call_args) + @args = [] + end - # Generate attributes projections - attributes_projections(settings.attributes) + # Manually set the query table when it's not an relation query + @query_table = settings.query_table unless relation_query?(@query) + @select = settings.attributes.merge(@select) if settings.attributes.present? - # Generate join projections - if settings.join.present? - joins_projections(settings.join) - elsif relation_query?(@query) - check_auto_join(settings.polymorphic) - else - raise ArgumentError, <<-MSG.strip.gsub(/\n +/, ' ') - You must provide the join columns when using '#{query.class.name}' - as a query object on #{self.class.name}. - MSG + # Merge join settings + if settings.join.present? + @join = settings.join.merge(@join) + elsif settings.through.present? + @association = settings.through.to_s + elsif relation_query?(@query) + @association = base.reflections.find do |name, reflection| + break name if @query.klass.eql? reflection.klass end end + end - # Iterate the attributes settings - # Attributes (left => right) - # left -> query.selected_attributes AS right - # right -> table.exposed_attributes - def attributes_projections(list) - list.each do |left, right| - @exposed_attributes << project(right) - @selected_attributes << project(left, query_table).as(right.to_s) - end - end + # Build the string or arel query + def build_query(base) + # Expose columns and get the list of the ones for select + columns = expose_columns(base, @query.try(:arel_table)) - # Iterate the join settings - # Join (left => right) - # left -> base.join_attributes.eq(right) - # right -> table.selected_attributes - def joins_projections(list) - list.each do |left, right| - @selected_attributes << project(right, query_table) - @join_attributes << project(left, base_table).eq(project(right)) - end + # Prepare the query depending on its type + if @query.is_a?(String) + args = @args.map{ |k, v| [k, base.connection.quote(v)] }.to_h + ::Arel.sql("(#{@query})" % args) + elsif relation_query?(@query) + @query = @query.where(@where) if @where.present? + @bound_attributes.concat(@query.send(:bound_attributes)) + @query.select(*columns).arel + else + raise ArgumentError, <<-MSG.squish + Only String and ActiveRecord::Base objects are accepted as query objects, + #{@query.class.name} given for #{self.class.name}. + MSG end + end - # Check if it's possible to identify the connection between the main - # query and the statement query - # - # First, identify the foreign key column name, then check if it exists - # on the query and then create the projections - def check_auto_join(polymorphic) - foreign_key = (polymorphic.present? ? polymorphic : base_name) - foreign_key = foreign_key.to_s.foreign_key - if query.columns_hash.key?(foreign_key) - joins_projections(base.primary_key => foreign_key) - if polymorphic.present? - foreign_type = foreign_key.gsub(/_id$/, '_type') - @selected_attributes << project(foreign_type, query_table) - @join_attributes << project(foreign_type).eq(base_name) - end - end - end - end + # Build the join statement that will be sent to the main arel + def build_join(base) + conditions = table.create_and([]) + builder = base.predicate_builder + foreign_table = base.arel_table - delegate :exposed_attributes, :join_attributes, :selected_attributes, :join_type, :table, - :query_table, :base_table, :requires, :project, :relation_query?, to: :class + # Check if it's necessary to load the join from an association + if @association.present? + association = base.reflections[@association] - # Start a new auxiliary statement giving extra options - def initialize(*args) - options = args.extract_options! - args_key = Torque::PostgreSQL.config.auxiliary_statement.send_arguments_key + # Require source of a through reflection + if association.through_reflection? + base.joins(association.source_reflection_name) - @join = options.fetch(:join, {}) - @args = options.fetch(args_key, {}) - @select = options.fetch(:select, {}) - @join_type = options.fetch(:join_type, join_type) - end + # Changes the base of the connection to the reflection table + builder = association.klass.predicate_builder + foreign_table = ::Arel::Table.new(association.plural_name) + end - # Get the columns that will be selected for this statement - def columns - exposed_attributes + @select.values.map(&method(:project)) - end + # Add the scopes defined by the reflection + if association.respond_to?(:join_scope) + args = [@query.arel_table] + args << base if association.method(:join_scope).arity.eql?(2) + @query.merge(association.join_scope(*args)) + end - # Build the statement on the given arel and return the WITH statement - def build_arel(arel, base) - # Build the join for this statement - arel.join(table, arel_join).on(*join_columns) + # Add the join constraints + constraint = association.build_join_constraint(table, foreign_table) + constraint = constraint.children if constraint.is_a?(::Arel::Nodes::And) + conditions.children.concat(Array.wrap(constraint)) + end - # Return the subquery for this statement - ::Arel::Nodes::As.new(table, mount_query) - end + # Build all conditions for the join on statement + @join.inject(conditions.children) do |arr, (left, right)| + left = project(left, foreign_table) + item = right.is_a?(Symbol) ? project(right).eq(left) : builder.build(left, right) + arr.push(item) + end - # Get the bound attributes from statement qeury - def bound_attributes - return [] unless relation_query?(self.class.query) - self.class.query.send(:bound_attributes) - end + # Raise an error when there's no join conditions + raise ArgumentError, <<-MSG.squish if conditions.children.empty? + You must provide the join columns when using '#{@query.class.name}' + as a query object on #{self.class.name}. + MSG - # Ensure that all the dependencies are loaded in the base relation - def ensure_dependencies!(base) - requires.each do |dependent| - dependent_klass = base.model.auxiliary_statements_list[dependent] - next if base.auxiliary_statements_values.any? do |cte| - cte.is_a?(dependent_klass) + # Expose join columns + if relation_query?(@query) + query_table = @query.arel_table + conditions.children.each do |item| + @query.select_values += [query_table[item.left.name]] \ + if item.left.relation.eql?(table) + end end - instance = AuxiliaryStatement.instantiate(dependent, base) - instance.ensure_dependencies!(base) - base.auxiliary_statements_values += [instance] + # Build the join based on the join type + arel_join.new(table, table.create_on(conditions)) end - end - private - # Get the class of the join on arel def arel_join case @join_type when :inner then ::Arel::Nodes::InnerJoin when :left then ::Arel::Nodes::OuterJoin when :right then ::Arel::Nodes::RightOuterJoin when :full then ::Arel::Nodes::FullOuterJoin else - raise ArgumentError, <<-MSG.strip + raise ArgumentError, <<-MSG.squish The '#{@join_type}' is not implemented as a join type. MSG end end - # Mount the query base on it's class - def mount_query - klass = self.class - query = klass.query - args = @args - - # Call a proc to get the query - if query.methods.include?(:call) - call_args = query.try(:arity) === 0 ? [] : [OpenStruct.new(args)] - query = query.call(*call_args) - args = [] + # Mount the list of selected attributes + def expose_columns(base, query_table = nil) + # Add select columns to the query and get exposed columns + @select.map do |left, right| + base.select_extra_values += [table[right.to_s]] + project(left, query_table).as(right.to_s) if query_table end + end - # Prepare the query depending on its type - if query.is_a?(String) - args = args.map{ |k, v| [k, klass.parent.connection.quote(v)] }.to_h - ::Arel::Nodes::SqlLiteral.new("(#{query})" % args) - elsif relation_query?(query) - query.select(*select_columns).arel - else - raise ArgumentError, <<-MSG.strip - Only String and ActiveRecord::Base objects are accepted as query objects, - #{query.class.name} given for #{self.class.name}. + # Ensure that all the dependencies are loaded in the base relation + def ensure_dependencies(list, base) + with_options = list.extract_options!.to_a + (list + with_options).map do |dependent, options| + dependent_klass = base.model.auxiliary_statements_list[dependent] + + raise ArgumentError, <<-MSG.squish if dependent_klass.nil? + The '#{dependent}' auxiliary statement dependency can't found on + #{self.class.name}. MSG - end - end - # Mount the list of join attributes with the additional ones - def join_columns - join_attributes + @join.map do |left, right| - if right.is_a?(Symbol) - project(left, base_table).eq(project(right)) - else - project(left).eq(right) + next if base.auxiliary_statements_values.any? do |cte| + cte.is_a?(dependent_klass) end + + AuxiliaryStatement.build(dependent, base, options, bound_attributes) end end - # Mount the list of selected attributes with the additional ones - def select_columns - selected_attributes + @select.map do |left, right| - project(left, query_table).as(right.to_s) - end + @join.map do |left, right| - column = right.is_a?(Symbol) ? right : left - project(column, query_table) + # Project a column on a given table, or use the column table + def project(column, arel_table = nil) + if column.respond_to?(:as) + return column + elsif (as_string = TABLE_COLUMN_AS_STRING.match(column.to_s)) + column = as_string[2] + arel_table = ::Arel::Table.new(as_string[1]) unless as_string[1].nil? end - end + arel_table ||= table + arel_table[column.to_s] + end end end end