Class: ActiveRecord::Relation

Inherits:
Object
  • Object
show all
Defined in:
lib/brick/extensions.rb

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#_brick_page_numObject

Returns the value of attribute _brick_page_num.



410
411
412
# File 'lib/brick/extensions.rb', line 410

def _brick_page_num
  @_brick_page_num
end

Instance Method Details

Links from ActiveRecord association pathing names over to real table correlation names that get chosen when the AREL AST tree is walked.



414
415
416
# File 'lib/brick/extensions.rb', line 414

def brick_links
  @brick_links ||= { '' => table_name }
end

#brick_select(params, selects = [], order_by = nil, translations = {}, join_array = ::Brick::JoinArray.new) ⇒ Object



418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
# File 'lib/brick/extensions.rb', line 418

def brick_select(params, selects = [], order_by = nil, translations = {}, join_array = ::Brick::JoinArray.new)
  is_add_bts = is_add_hms = true

  # Build out cust_cols, bt_descrip and hm_counts now so that they are available on the
  # model early in case the user wants to do an ORDER BY based on any of that.
  model._brick_calculate_bts_hms(translations, join_array) if is_add_bts || is_add_hms

  is_postgres = ActiveRecord::Base.connection.adapter_name == 'PostgreSQL'
  is_mysql = ['Mysql2', 'Trilogy'].include?(ActiveRecord::Base.connection.adapter_name)
  is_mssql = ActiveRecord::Base.connection.adapter_name == 'SQLServer'
  is_distinct = nil
  wheres = {}
  params.each do |k, v|
    next if ['_brick_schema', '_brick_order',
             '_brick_erd', '_brick_exclude', '_brick_unexclude',
             '_brick_page', '_brick_page_size', '_brick_offset', '_brick_limit',
             '_brick_is_api', 'controller', 'action'].include?(k)

    if (where_col = (ks = k.split('.')).last)[-1] == '!'
      where_col = where_col[0..-2]
    end
    case ks.length
    when 1
      next unless klass.column_names.any?(where_col) || klass._brick_get_fks.include?(where_col)
    when 2
      assoc_name = ks.first.to_sym
      # Make sure it's a good association name and that the model has that column name
      next unless klass.reflect_on_association(assoc_name)&.klass&.column_names&.any?(where_col)

      join_array[assoc_name] = nil # Store this relation name in our special collection for .joins()
      is_distinct = true
      distinct!
    end
    wheres[k] = v.split(',')
  end

  # %%% Skip the metadata columns
  if selects.empty? # Default to all columns
    id_parts = (id_col = klass.primary_key).is_a?(Array) ? id_col : [id_col]
    tbl_no_schema = table.name.split('.').last
    # %%% Have once gotten this error with MSSQL referring to http://localhost:3000/warehouse/cold_room_temperatures__archive
    #     ActiveRecord::StatementInvalid (TinyTds::Error: DBPROCESS is dead or not enabled)
    #     Relevant info here:  https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/issues/402
    is_api = params['_brick_is_api']
    columns.each do |col|
      next if (col.type.nil? || col.type == :binary) && is_api

      col_alias = " AS #{col.name}_" if (col_name = col.name) == 'class'
      selects << if is_mysql
                   "`#{tbl_no_schema}`.`#{col_name}`#{col_alias}"
                 elsif is_postgres || is_mssql
                   if is_distinct # Postgres can not use DISTINCT with any columns that are XML or JSON
                     cast_as_text = if Brick.relations[klass.table_name]&.[](:cols)&.[](col_name)&.first == 'json'
                                      '::jsonb' # Convert JSON to JSONB
                                    elsif Brick.relations[klass.table_name]&.[](:cols)&.[](col_name)&.first&.start_with?('xml')
                                      '::text' # Convert XML to text
                                    end
                   end
                   "\"#{tbl_no_schema}\".\"#{col_name}\"#{cast_as_text}#{col_alias}"
                 elsif col.type # Could be Sqlite or Oracle
                   if col_alias || !(/^[a-z0-9_]+$/ =~ col_name)
                     "#{tbl_no_schema}.\"#{col_name}\"#{col_alias}"
                   else
                     "#{tbl_no_schema}.#{col_name}"
                   end
                 else # Oracle with a custom data type
                   typ = col.sql_type
                   "'<#{typ.end_with?('_TYP') ? typ[0..-5] : typ}>' AS #{col.name}"
                 end
    end
  end

  if join_array.present?
    left_outer_joins!(join_array)
    # Touching AREL AST walks the JoinDependency tree, and in that process uses our
    # "brick_links" patch to find how every AR chain of association names relates to exact
    # table correlation names chosen by AREL.  We use a duplicate relation object for this
    # because an important side-effect of referencing the AST is that the @arel instance
    # variable gets set, and this is a signal to ActiveRecord that a relation has now
    # become immutable.  (We aren't quite ready for our "real deal" relation object to be
    # set in stone ... still need to add .select(), and possibly .where() and .order()
    # things ... also if there are any HM counts then an OUTER JOIN for each of them out
    # to a derived table to do that counting.  All of these things need to know proper
    # table correlation names, which will now become available in brick_links on the
    # rel_dupe object.)
    (rel_dupe = dup).arel.ast

    core_selects = selects.dup
    id_for_tables = Hash.new { |h, k| h[k] = [] }
    field_tbl_names = Hash.new { |h, k| h[k] = {} }
    used_col_aliases = {} # Used to make sure there is not a name clash

    # CUSTOM COLUMNS
    # ==============
    klass._br_cust_cols.each do |k, cc|
      if rel_dupe.respond_to?(k) # Name already taken?
        # %%% Use ensure_unique here in this kind of fashion:
        # cnstr_name = ensure_unique(+"(brick) #{for_tbl}_#{pri_tbl}", bts, hms)
        # binding.pry
        next
      end

      key_klass = nil
      key_tbl_name = nil
      dest_pk = nil
      key_alias = nil
      cc.first.each do |cc_part|
        dest_klass = cc_part[0..-2].inject(klass) do |kl, cc_part_term|
          # %%% Clear column info properly so we can do multiple subsequent requests
          # binding.pry unless kl.reflect_on_association(cc_part_term)
          kl.reflect_on_association(cc_part_term)&.klass || klass
        end
        tbl_name = rel_dupe.brick_links[cc_part[0..-2].map(&:to_s).join('.')]
        # Deal with the conflict if there are two parts in the custom column named the same,
        # "category.name" and "product.name" for instance will end up with aliases of "name"
        # and "product__name".
        if (cc_part_idx = cc_part.length - 1).zero?
          col_alias = "br_cc_#{k}__#{table_name.tr('.', '_')}"
        else
          while cc_part_idx > 0 &&
                (col_alias = "br_cc_#{k}__#{cc_part[cc_part_idx..-1].map(&:to_s).join('__').tr('.', '_')}") &&
                used_col_aliases.key?(col_alias)
            cc_part_idx -= 1
          end
        end
        used_col_aliases[col_alias] = nil
        # Set up custom column links by preparing key_klass and key_alias
        # (If there are multiple different tables referenced in the DSL, we end up creating a link to the last one)
        if cc[2] && (dest_pk = dest_klass.primary_key)
          key_klass = dest_klass
          key_tbl_name = tbl_name
          cc_part_idx = cc_part.length - 1
          while cc_part_idx > 0 &&
                (key_alias = "br_cc_#{k}__#{(cc_part[cc_part_idx..-2] + [dest_pk]).map(&:to_s).join('__')}") &&
                key_alias != col_alias && # We break out if this key alias does exactly match the col_alias
                used_col_aliases.key?(key_alias)
            cc_part_idx -= 1
          end
        end
        selects << "#{tbl_name}.#{cc_part.last} AS #{col_alias}"
        cc_part << col_alias
      end
      # Add a key column unless we've already got it
      if key_alias && !used_col_aliases.key?(key_alias)
        selects << "#{key_tbl_name}.#{dest_pk} AS #{key_alias}"
        used_col_aliases[key_alias] = nil
      end
      cc[2] = key_alias ? [key_klass, key_alias] : nil
    end

    klass._br_bt_descrip.each do |v|
      v.last.each do |k1, v1| # k1 is class, v1 is array of columns to snag
        next unless (tbl_name = rel_dupe.brick_links[v.first.to_s]&.split('.')&.last)

        # If it's Oracle, quote any AREL aliases that had been applied
        tbl_name = "\"#{tbl_name}\"" if ::Brick.is_oracle && rel_dupe.brick_links.values.include?(tbl_name)
        field_tbl_name = nil
        v1.map { |x| [x[0..-2].map(&:to_s).join('.'), x.last] }.each_with_index do |sel_col, idx|
          # %%% Strangely in Rails 7.1 on a slower system then very rarely brick_link comes back nil...
          brick_link = rel_dupe.brick_links[sel_col.first]
          field_tbl_name = brick_link&.split('.')&.last ||
            # ... so here's a best-effort guess for what the table name might be.
            rel_dupe.klass.reflect_on_association(sel_col.first)&.klass&.table_name
          # If it's Oracle, quote any AREL aliases that had been applied
          field_tbl_name = "\"#{field_tbl_name}\"" if ::Brick.is_oracle && rel_dupe.brick_links.values.include?(field_tbl_name)

          # Postgres can not use DISTINCT with any columns that are XML, so for any of those just convert to text
          is_xml = is_distinct && Brick.relations[k1.table_name]&.[](:cols)&.[](sel_col.last)&.first&.start_with?('xml')
          # If it's not unique then also include the belongs_to association name before the column name
          if used_col_aliases.key?(col_alias = "br_fk_#{v.first}__#{sel_col.last}")
            col_alias = "br_fk_#{v.first}__#{v1[idx][-2..-1].map(&:to_s).join('__')}"
          end
          selects << if is_mysql
                       "`#{field_tbl_name}`.`#{sel_col.last}` AS `#{col_alias}`"
                     elsif is_postgres
                       "\"#{field_tbl_name}\".\"#{sel_col.last}\"#{'::text' if is_xml} AS \"#{col_alias}\""
                     elsif is_mssql
                       "\"#{field_tbl_name}\".\"#{sel_col.last}\" AS \"#{col_alias}\""
                     else
                       "#{field_tbl_name}.#{sel_col.last} AS \"#{col_alias}\""
                     end
          used_col_aliases[col_alias] = nil
          v1[idx] << col_alias
        end

        unless id_for_tables.key?(v.first)
          # Accommodate composite primary key by allowing id_col to come in as an array
          ((id_col = k1.primary_key).is_a?(Array) ? id_col : [id_col]).each do |id_part|
            id_for_tables[v.first] << if id_part
                                        selects << if is_mysql
                                                     "#{"`#{tbl_name}`.`#{id_part}`"} AS `#{(id_alias = "br_fk_#{v.first}__#{id_part}")}`"
                                                   elsif is_postgres || is_mssql
                                                     "#{"\"#{tbl_name}\".\"#{id_part}\""} AS \"#{(id_alias = "br_fk_#{v.first}__#{id_part}")}\""
                                                   else
                                                     "#{"#{tbl_name}.#{id_part}"} AS \"#{(id_alias = "br_fk_#{v.first}__#{id_part}")}\""
                                                   end
                                        id_alias
                                      end
          end
          v1 << id_for_tables[v.first].compact
        end
      end
    end
    join_array.each do |assoc_name|
      next unless assoc_name.is_a?(Symbol)

      table_alias = rel_dupe.brick_links[assoc_name.to_s]
      _assoc_names[assoc_name] = [table_alias, klass]
    end
  end
  # Add derived table JOIN for the has_many counts
  nix = []
  klass._br_hm_counts.each do |k, hm|
    count_column = if hm.options[:through]
                     # Build the chain of JOINs going to the final destination HMT table
                     # (Usually just one JOIN, but could be many.)
                     hmt_assoc = hm
                     through_sources = []
                     # %%% Inverse path back to the original object -- not yet used, but soon
                     # will be leveraged in order to build links with multi-table-hop filters.
                     link_back = []
                     # Track polymorphic type field if necessary
                     if hm.source_reflection.options[:as]
                       poly_ft = [hm.source_reflection.inverse_of.foreign_type, hmt_assoc.source_reflection.class_name]
                     end
                     # link_back << hm.source_reflection.inverse_of.name
                     while hmt_assoc.options[:through] && (hmt_assoc = klass.reflect_on_association(hmt_assoc.options[:through]))
                       through_sources.unshift(hmt_assoc)
                     end
                     # Turn the last member of link_back into a foreign key
                     link_back << hmt_assoc.source_reflection.foreign_key
                     # If it's a HMT based on a HM -> HM, must JOIN the last table into the mix at the end
                     through_sources.push(hm.source_reflection) unless hm.source_reflection.belongs_to?
                     from_clause = +"#{through_sources.first.table_name} br_t0"
                     fk_col = through_sources.shift.foreign_key

                     idx = 0
                     bail_out = nil
                     through_sources.map do |a|
                       from_clause << "\n LEFT OUTER JOIN #{a.table_name} br_t#{idx += 1} "
                       from_clause << if (src_ref = a.source_reflection).macro == :belongs_to
                                        nm = hmt_assoc.source_reflection.inverse_of&.name
                                        link_back << nm
                                        "ON br_t#{idx}.id = br_t#{idx - 1}.#{a.foreign_key}"
                                      elsif src_ref.options[:as]
                                        "ON br_t#{idx}.#{src_ref.type} = '#{src_ref.active_record.name}'" + # "polymorphable_type"
                                        " AND br_t#{idx}.#{src_ref.foreign_key} = br_t#{idx - 1}.id"
                                      elsif src_ref.options[:source_type]
                                        if a == hm.source_reflection
                                          print "Skipping #{hm.name} --HMT-> #{hm.source_reflection.name} as it uses source_type in a way which is not yet supported"
                                          nix << k
                                          bail_out = true
                                          break
                                          # "ON br_t#{idx}.#{a.foreign_type} = '#{src_ref.options[:source_type]}' AND " \
                                          #   "br_t#{idx}.#{a.foreign_key} = br_t#{idx - 1}.id"
                                        else # Works for HMT through a polymorphic HO
                                          link_back << hmt_assoc.source_reflection.inverse_of&.name # Some polymorphic "_able" thing
                                          "ON br_t#{idx - 1}.#{a.foreign_type} = '#{src_ref.options[:source_type]}' AND " \
                                            "br_t#{idx - 1}.#{a.foreign_key} = br_t#{idx}.id"
                                        end
                                      else # Standard has_many or has_one
                                        # binding.pry unless (
                                        nm = hmt_assoc.source_reflection.inverse_of&.name
                                        # )
                                        link_back << nm # if nm
                                        "ON br_t#{idx}.#{a.foreign_key} = br_t#{idx - 1}.id"
                                      end
                       link_back.unshift(a.source_reflection.name)
                       [a.table_name, a.foreign_key, a.source_reflection.macro]
                     end
                     next if bail_out

                     # puts "LINK BACK! #{k} : #{hm.table_name} #{link_back.map(&:to_s).join('.')}"
                     # count_column is determined from the originating HMT member
                     if (src_ref = hm.source_reflection).nil?
                       puts "*** Warning:  Could not determine destination model for this HMT association in model #{klass.name}:\n  has_many :#{hm.name}, through: :#{hm.options[:through]}"
                       puts
                       nix << k
                       next
                     elsif src_ref.macro == :belongs_to # Traditional HMT using an associative table
                       # binding.pry if link_back.length > 2
                       "br_t#{idx}.#{hm.foreign_key}"
                     else # A HMT that goes HM -> HM, something like Categories -> Products -> LineItems
                       # binding.pry if link_back.length > 2
                       "br_t#{idx}.#{src_ref.active_record.primary_key}"
                     end
                   else
                     fk_col = (inv = hm.inverse_of)&.foreign_key || hm.foreign_key
                     poly_type = inv.foreign_type if hm.options.key?(:as)
                     pk = hm.klass.primary_key
                     (pk.is_a?(Array) ? pk.first : pk) || '*'
                   end
    next unless count_column # %%% Would be able to remove this when multiple foreign keys to same destination becomes bulletproof

    tbl_alias = if is_mysql
                  "`b_r_#{hm.name}`"
                elsif is_postgres
                  "\"b_r_#{hm.name}\""
                else
                  "b_r_#{hm.name}"
                end
    pri_tbl = hm.active_record
    pri_tbl_name = is_mysql ? "`#{pri_tbl.table_name}`" : "\"#{pri_tbl.table_name.gsub('.', '"."')}\""
    pri_tbl_name = if is_mysql
                     "`#{pri_tbl.table_name}`"
                   elsif is_postgres || is_mssql
                     "\"#{pri_tbl.table_name.gsub('.', '"."')}\""
                   else
                     pri_tbl.table_name
                   end
    on_clause = []
    hm_selects = if fk_col.is_a?(Array) # Composite key?
                   fk_col.each_with_index { |fk_col_part, idx| on_clause << "#{tbl_alias}.#{fk_col_part} = #{pri_tbl_name}.#{pri_tbl.primary_key[idx]}" }
                   fk_col.dup
                 else
                   on_clause << "#{tbl_alias}.#{fk_col} = #{pri_tbl_name}.#{pri_tbl.primary_key}"
                   [fk_col]
                 end
    if poly_type
      hm_selects << poly_type
      on_clause << "#{tbl_alias}.#{poly_type} = '#{name}'"
    end
    unless from_clause
      tbl_nm = hm.macro == :has_and_belongs_to_many ? hm.join_table : hm.table_name
      hm_table_name = if is_mysql
                        "`#{tbl_nm}`"
                      elsif is_postgres || is_mssql
                        "\"#{(tbl_nm).gsub('.', '"."')}\""
                      else
                        tbl_nm
                      end
    end
    group_bys = ::Brick.is_oracle || is_mssql ? hm_selects : (1..hm_selects.length).to_a
    join_clause = "LEFT OUTER
JOIN (SELECT #{hm_selects.map { |s| "#{'br_t0.' if from_clause}#{s}" }.join(', ')}, COUNT(#{'DISTINCT ' if hm.options[:through]}#{count_column
      }) AS c_t_ FROM #{from_clause || hm_table_name} GROUP BY #{group_bys.join(', ')}) #{tbl_alias}"
    self.joins_values |= ["#{join_clause} ON #{on_clause.join(' AND ')}"] # Same as:  joins!(...)
  end
  while (n = nix.pop)
    klass._br_hm_counts.delete(n)
  end

  unless wheres.empty?
    # Rewrite the wheres to reference table and correlation names built out by AREL
    where_nots = {}
    wheres2 = wheres.each_with_object({}) do |v, s|
      is_not = if v.first[-1] == '!'
                 v[0] = v[0][0..-2] # Take off ending ! from column name
               end
      if (v_parts = v.first.split('.')).length == 1
        (is_not ? where_nots : s)[v.first] = v.last
      else
        tbl_name = rel_dupe.brick_links[v_parts.first].split('.').last
        (is_not ? where_nots : s)["#{tbl_name}.#{v_parts.last}"] = v.last
      end
    end
    if respond_to?(:where!)
      where!(wheres2) if wheres2.present?
      if where_nots.present?
        self.where_clause += WhereClause.new(predicate_builder.build_from_hash(where_nots)).invert
      end
    else # AR < 4.0
      self.where_values << build_where(wheres2)
    end
  end
  # Must parse the order_by and see if there are any symbols which refer to BT associations
  # or custom columns as they must be expanded to find the corresponding b_r_model__column
  # or br_cc_column naming for each.
  if order_by.present?
    final_order_by = *order_by.each_with_object([]) do |v, s|
      if v.is_a?(Symbol)
        # Add the ordered series of columns derived from the BT based on its DSL
        if (bt_cols = klass._br_bt_descrip[v])
          bt_cols.values.each do |v1|
            v1.each { |v2| s << "\"#{v2.last}\"" if v2.length > 1 }
          end
        elsif (cc_cols = klass._br_cust_cols[v])
          cc_cols.first.each { |v1| s << "\"#{v1.last}\"" if v1.length > 1 }
        else
          s << v
        end
      else # String stuff (which defines a custom ORDER BY) just comes straight through
        s << v
        # Avoid "PG::InvalidColumnReference: ERROR: for SELECT DISTINCT, ORDER BY expressions must appear in select list" in Postgres
        selects << v if is_distinct
      end
    end
    self.order_values |= final_order_by # Same as:  order!(*final_order_by)
  end
  # By default just 1000 rows
  row_limit = params['_brick_limit'] || params['_brick_page_size'] || 1000
  offset = if (page = params['_brick_page']&.to_i)
             page = 1 if page < 1
             (page - 1) * row_limit.to_i
           else
             params['_brick_offset']
           end
  if offset.is_a?(Numeric) || offset&.present?
    offset = offset.to_i
    self.offset_value = offset unless offset == 0
    @_brick_page_num = (offset / row_limit.to_i) + 1 if row_limit&.!= 0 && (offset % row_limit.to_i) == 0
  end
  # Setting limit_value= is the same as doing:  limit!(1000)  but this way is compatible with AR <= 4.2
  self.limit_value = row_limit.to_i unless row_limit.is_a?(String) && row_limit.empty?
  wheres unless wheres.empty? # Return the specific parameters that we did use
end