class Pakyow::Data::Sources::Relational

A relational data source through which you interact with a persistence layer such as a sql database, redis, or http. Defines the schema, queries, and other adapter-specific metadata (e.g. sql table).

Each adapter provides its own interface for interacting with the underlying persistence layer. For example, the sql adapter exposes Sequel::Dataset provided by the fantastic Sequel gem.

In normal use, the underlying dataset is inaccessible from outside of the source. Instead, access to the dataset occurs through queries defined on the source that interact with the dataset and return a result.

Results are always returned as a new source instance (or when used from the app, a {Pakyow::Data::Proxy} object). Access to the underlying value is provided through methods such as one, to_a, and each. (@see Pakyow::Data::Container#wrap_defined_queries!)

Mutations occur through commands. Commands do not implement validation other than checking for required attributes and checking that the given attributes are defined on the source. Use the input verifier pattern to verify and validate input before passing it to a command (@see Pakyow::Verifier).

@example

source :posts, adapter: :sql, connection: :default do
  table :posts

  primary_id
  timestamps

  attribute :title, :string

  command :create do |params|
    insert(params)
  end

  def by_id(id)
    where(id: id)
  end
end

data.posts.create(title: "foo")
data.posts.by_id(1).first
=> #<Pakyow::Data::Object @values={:id => 1, :title => "foo", :created_at => "2018-11-30 10:55:05 -0800", :updated_at => "2018-11-30 10:55:05 -0800"}>

Constants

IVARS_TO_RELOAD

@api private

MODIFIER_METHODS

@api private

NESTED_METHODS

@api private

Attributes

adapter[R]
connection[R]
name[R]
included[R]

@api private

Public Class Methods

new(*) click to toggle source
Calls superclass method Pakyow::Data::Sources::Base::new
# File lib/pakyow/data/sources/relational.rb, line 69
def initialize(*)
  super

  @wrap_as = self.class.singular_name
  @included = []

  if default_query = self.class.__default_query
    result = if default_query.is_a?(Proc)
      instance_exec(&default_query)
    else
      public_send(self.class.__default_query)
    end

    result = case result
    when self.class
      result.__getobj__
    else
      result
    end

    __setobj__(result)
  end
end

Private Class Methods

association_with_name?(name) click to toggle source

@api private

# File lib/pakyow/data/sources/relational.rb, line 582
def association_with_name?(name)
  associations.values.flatten.find { |association|
    association.name == name
  }
end
attribute(name, type = :string, **options) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 486
def attribute(name, type = :string, **options)
  attributes[name.to_sym] = {
    type: type,
    options: options
  }
end
belongs_to(association_name, query: nil, source: association_name) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 501
def belongs_to(association_name, query: nil, source: association_name)
  Associations::BelongsTo.new(
    name: association_name, query: query, source: self, associated_source_name: source
  ).tap do |association|
    @associations[:belongs_to] << association
  end
end
command(command_name, provides_dataset: true, creates: false, updates: false, deletes: false, &block) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 432
def command(command_name, provides_dataset: true, creates: false, updates: false, deletes: false, &block)
  @commands[command_name] = {
    block: block,
    provides_dataset: provides_dataset,
    creates: creates,
    updates: updates,
    deletes: deletes
  }
end
default_primary_key_type() click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 482
def default_primary_key_type
  :integer
end
find_association_to_source(source) click to toggle source

@api private

# File lib/pakyow/data/sources/relational.rb, line 575
def find_association_to_source(source)
  associations.values.flatten.find { |association|
    association.associated_source == source.class
  }
end
has_many(association_name, query: nil, source: association_name, as: singular_name, through: nil, dependent: :raise) click to toggle source

rubocop:disable Naming/PredicateName

# File lib/pakyow/data/sources/relational.rb, line 510
def has_many(association_name, query: nil, source: association_name, as: singular_name, through: nil, dependent: :raise)
  Associations::HasMany.new(
    name: association_name, query: query, source: self, associated_source_name: source, as: as, dependent: dependent
  ).tap do |association|
    @associations[:has_many] << association

    if through
      setup_as_through(association, through: through)
    end
  end
end
has_one(association_name, query: nil, source: association_name, as: singular_name, through: nil, dependent: :raise) click to toggle source

rubocop:disable Naming/PredicateName

# File lib/pakyow/data/sources/relational.rb, line 524
def has_one(association_name, query: nil, source: association_name, as: singular_name, through: nil, dependent: :raise)
  Associations::HasOne.new(
    name: association_name, query: query, source: self, associated_source_name: source, as: as, dependent: dependent
  ).tap do |association|
    @associations[:has_one] << association

    if through
      setup_as_through(association, through: through)
    end
  end
end
make(name, adapter: Pakyow.config.data.default_adapter, connection: Pakyow.config.data.default_connection, state: nil, parent: nil, primary_id: true, timestamps: true, **kwargs, &block) click to toggle source
Calls superclass method
# File lib/pakyow/data/sources/relational.rb, line 545
def make(name, adapter: Pakyow.config.data.default_adapter, connection: Pakyow.config.data.default_connection, state: nil, parent: nil, primary_id: true, timestamps: true, **kwargs, &block)
  super(name, state: state, parent: parent, adapter: adapter, connection: connection, attributes: {}, **kwargs) do
    adapter_class = Connection.adapter(adapter)

    if adapter_class.const_defined?("SourceExtension")
      # Extend the source with any adapter-specific behavior.
      #
      extension_module = adapter_class.const_get("SourceExtension")
      unless ancestors.include?(extension_module)
        include(extension_module)
      end

      # Define default fields
      #
      self.primary_id if primary_id
      self.timestamps if timestamps
    end

    # Call the original block.
    #
    class_eval(&block) if block_given?
  end
end
primary_id() click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 460
def primary_id
  primary_key :id
  attribute :id, default_primary_key_type
end
primary_key(field) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 465
def primary_key(field)
  @primary_key_field = field
end
primary_key_attribute() click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 478
def primary_key_attribute
  attributes[@primary_key_field]
end
primary_key_type() click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 469
def primary_key_type
  case primary_key_attribute
  when Hash
    primary_key_attribute[:type]
  else
    primary_key_attribute.meta[:mapping]
  end
end
qualifications(query_name) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 497
def qualifications(query_name)
  @qualifications.dig(query_name) || {}
end
queries() click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 442
def queries
  instance_methods - superclass.instance_methods
end
query(query_name = nil, &block) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 446
def query(query_name = nil, &block)
  @__default_query = query_name || block
end
setup_as_through(association, through:) click to toggle source

rubocop:enable Naming/PredicateName

# File lib/pakyow/data/sources/relational.rb, line 537
def setup_as_through(association, through:)
  Associations::Through.new(association, joining_source_name: through).tap do |through_association|
    associations[association.specific_type][
      associations[association.specific_type].index(association)
    ] = through_association
  end
end
source_from_source(*) click to toggle source

@api private

# File lib/pakyow/data/sources/relational.rb, line 570
def source_from_source(*)
  super.tap(&:reload)
end
subscribe(query_name, qualifications) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 493
def subscribe(query_name, qualifications)
  @qualifications[query_name] = qualifications
end
timestamps(create: :created_at, update: :updated_at) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 450
def timestamps(create: :created_at, update: :updated_at)
  @timestamp_fields = {
    create: create,
    update: update
  }

  attribute create, :datetime
  attribute update, :datetime
end

Public Instance Methods

all()
Alias for: to_a
as(object) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 117
def as(object)
  tap do
    @wrap_as = object
  end
end
block_for_nested_source?(maybe_nested_name) click to toggle source

@api private

# File lib/pakyow/data/sources/relational.rb, line 256
def block_for_nested_source?(maybe_nested_name)
  NESTED_METHODS.include?(maybe_nested_name)
end
command(command_name) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 184
def command(command_name)
  if command = self.class.commands[command_name]
    Command.new(
      command_name,
      block: command[:block],
      source: self,
      provides_dataset: command[:provides_dataset],
      creates: command[:creates],
      updates: command[:updates],
      deletes: command[:deletes]
    )
  else
    raise(
      UnknownCommand.new_with_message(command: command_name).tap do |error|
        error.context = self.class
      end
    )
  end
end
command?(maybe_command_name) click to toggle source

@api private

# File lib/pakyow/data/sources/relational.rb, line 237
def command?(maybe_command_name)
  self.class.commands.include?(maybe_command_name)
end
count() click to toggle source
Calls superclass method
# File lib/pakyow/data/sources/relational.rb, line 204
def count
  if self.class.respond_to?(:count)
    self.class.count(__getobj__)
  else
    super
  end
end
including(association_name, &block) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 93
def including(association_name, &block)
  tap do
    association_name = association_name.to_sym

    association_to_include = self.class.associations.values.flatten.find { |association|
      association.name == association_name
    } || raise(UnknownAssociation.new("unknown association `#{association_name}'").tap { |error| error.context = self.class })

    included_source = association_to_include.associated_source.instance

    if association_to_include.query
      included_source = included_source.send(association_to_include.query)
    end

    final_source = if block_given?
      included_source.instance_exec(&block) || included_source
    else
      included_source
    end

    @included << [association_to_include, final_source]
  end
end
limit(count) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 123
def limit(count)
  __setobj__(__getobj__.limit(count)); self
end
modifier?(maybe_modifier_name) click to toggle source

@api private

# File lib/pakyow/data/sources/relational.rb, line 249
def modifier?(maybe_modifier_name)
  MODIFIER_METHODS.include?(maybe_modifier_name)
end
on_commit(&block) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 176
def on_commit(&block)
  self.class.container.connection.adapter.connection.after_commit(&block)
end
on_rollback(&block) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 180
def on_rollback(&block)
  self.class.container.connection.adapter.connection.after_rollback(&block)
end
one() click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 156
def one
  return @results.first if instance_variable_defined?(:@results)
  return @result if instance_variable_defined?(:@result)

  if result = self.class.one(__getobj__)
    include_results!([result])
    @result = finalize(result)
  else
    nil
  end
end
order(*ordering) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 127
def order(*ordering)
  __setobj__(
    __getobj__.order(
      *ordering.flat_map { |order|
        case order
        when Array
          Sequel.public_send(order[1].to_sym, order[0].to_sym)
        when Hash
          order.each_pair.map { |key, value|
            Sequel.public_send(value.to_sym, key.to_sym)
          }
        else
          Sequel.asc(order.to_s.to_sym)
        end
      }
    )
  ); self
end
query?(maybe_query_name) click to toggle source

@api private

# File lib/pakyow/data/sources/relational.rb, line 242
def query?(maybe_query_name)
  self.class.queries.include?(maybe_query_name)
end
reload() click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 217
def reload
  IVARS_TO_RELOAD.select { |ivar|
    instance_variable_defined?(ivar)
  }.each do |ivar|
    remove_instance_variable(ivar)
  end

  self
end
source_name() click to toggle source

@api private

# File lib/pakyow/data/sources/relational.rb, line 232
def source_name
  self.class.__object_name.name
end
to_a() click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 146
def to_a
  return @results if instance_variable_defined?(:@results)
  @results = self.class.to_a(__getobj__)
  include_results!(@results)
  @results.map! { |result|
    finalize(result)
  }
end
Also aliased as: all
to_json(*) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 227
def to_json(*)
  to_a.to_json
end
transaction(&block) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 168
def transaction(&block)
  self.class.container.connection.transaction(&block)
end
transaction?() click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 172
def transaction?
  self.class.container.connection.adapter.connection.in_transaction?
end

Private Instance Methods

finalize(result) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 262
def finalize(result)
  wrap(typecast(result))
end
include_results!(results) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 290
def include_results!(results)
  @included.each do |association, combined_source|
    combined_source = combined_source.source_from_self(
      combined_source.__getobj__.dup
    )

    group_by_key, assign_by_key, remove_keys = if association.type == :through
      joining_source = association.joining_source.instance

      if combined_source.class == association.joining_source
        combined_source.__setobj__(
          combined_source.class.container.connection.adapter.result_for_attribute_value(
            combined_source.class.container.connection.adapter.qualify_attribute(
              association.right_foreign_key_field, combined_source
            ),
            results.map { |result| result[association.associated_query_field] },
            combined_source
          )
        )
      else
        aliased = SecureRandom.hex(4).to_sym

        if joining_source.class.container.connection == combined_source.class.container.connection
          # Optimize with joins.
          #
          combined_source.__setobj__(
            combined_source.class.container.connection.adapter.restrict_to_source(
              combined_source,
              combined_source.class.container.connection.adapter.result_for_attribute_value(
                combined_source.class.container.connection.adapter.qualify_attribute(
                  association.right_foreign_key_field, joining_source
                ),
                joining_source.class.container.connection.adapter.restrict_to_attribute(
                  association.query_field, source_from_self(__getobj__.dup)
                ),
                combined_source.class.container.connection.adapter.merge_results(
                  association.left_foreign_key_field,
                  association.associated_source.primary_key_field,
                  joining_source,
                  combined_source
                )
              ),
              combined_source.class.container.connection.adapter.alias_attribute(
                combined_source.class.container.connection.adapter.qualify_attribute(
                  association.right_foreign_key_field, joining_source
                ), aliased
              )
            )
          )
        else
          # Manually join.
          #
          self_ids = self.class.container.connection.adapter.restrict_to_attribute(
            self.class.primary_key_field, self
          ).map { |result|
            result[self.class.primary_key_field]
          }

          joined_results = joining_source.class.container.connection.adapter.restrict_to_attribute(
            [association.right_foreign_key_field, association.left_foreign_key_field],
            joining_source.class.container.connection.adapter.result_for_attribute_value(
              association.right_foreign_key_field, self_ids, joining_source
            )
          )

          combined_results = combined_source.class.container.connection.adapter.result_for_attribute_value(
            combined_source.class.primary_key_field, joined_results.map { |result| result[association.left_foreign_key_field] }, combined_source
          )

          combined_results = joined_results.map { |joined_result|
            combined_results.find { |result|
              result[combined_source.class.primary_key_field] == joined_result[association.left_foreign_key_field]
            }.dup.tap do |combined_result|
              combined_result[aliased] = joined_result[association.right_foreign_key_field]
            end
          }
        end
      end

      [aliased, association.name, [aliased]]
    else
      combined_source.__setobj__(
        combined_source.class.container.connection.adapter.result_for_attribute_value(
          association.associated_query_field,
          results.map { |result| result[association.query_field] },
          combined_source
        )
      )

      [association.associated_query_field, association.name, []]
    end

    # Group the raw results by associated column value.
    #
    combined_results = (combined_results || combined_source).to_a.group_by { |combined_result|
      combined_result[group_by_key]
    }

    # Add each result group to its associated object.
    #
    results.map! { |result|
      combined_results_for_result = combined_results[result[association.query_field]].to_a.map! { |combined_result|
        if combined_result.is_a?(Pakyow::Data::Object)
          combined_result = combined_result.values.dup
        end

        # Remove any keys, such as temporary values used for grouping.
        #
        remove_keys.each do |remove_key|
          combined_result.delete(remove_key)
        end

        # Wrap the result into the appropriate data object.
        #
        combined_source.send(:wrap, combined_result)
      }

      result[assign_by_key] = if association.result_type == :one
        combined_results_for_result[0]
      else
        combined_results_for_result
      end

      result
    }
  end
end
typecast(result) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 266
def typecast(result)
  result.each do |key, value|
    unless value.nil? || !self.class.attributes.include?(key)
      result[key] = self.class.attributes[key][value]
    end
  end

  result
end
wrap(result) click to toggle source
# File lib/pakyow/data/sources/relational.rb, line 276
def wrap(result)
  wrapped_result = if @wrap_as.is_a?(Class)
    @wrap_as.new(result)
  else
    self.class.container.object(@wrap_as).new(result)
  end

  if wrapped_result.is_a?(Object)
    wrapped_result.originating_source = self.class
  end

  wrapped_result
end