<commit_before># encoding: UTF-8

require "redic"
require "securerandom"
require "ohm/command"
require "ohm/nest"
require "msgpack"

module Ohm
  LUA_CACHE   = Hash.new { |h, k| h[k] = Hash.new }
  LUA_SAVE    = File.expand_path("../ohm/lua/save.lua",   __FILE__)
  LUA_DELETE  = File.expand_path("../ohm/lua/delete.lua", __FILE__)

  # All of the known errors in Ohm can be traced back to one of these
  # exceptions.
  #
  # MissingID:
  #
  #   Comment.new.id # => Error
  #   Comment.new.key # => Error
  #
  #   Solution: you need to save your model first.
  #
  # IndexNotFound:
  #
  #   Comment.find(:foo => "Bar") # => Error
  #
  #   Solution: add an index with `Comment.index :foo`.
  #
  # UniqueIndexViolation:
  #
  #   Raised when trying to save an object with a `unique` index for
  #   which the value already exists.
  #
  #   Solution: rescue `Ohm::UniqueIndexViolation` during save, but
  #   also, do some validations even before attempting to save.
  #
  class Error < StandardError; end
  class MissingID < Error; end
  class IndexNotFound < Error; end
  class UniqueIndexViolation < Error; end

  # Instead of monkey patching Kernel or trying to be clever, it's
  # best to confine all the helper methods in a Utils module.
  module Utils

    # Used by: `attribute`, `counter`, `set`, `reference`,
    # `collection`.
    #
    # Employed as a solution to avoid `NameError` problems when trying
    # to load models referring to other models not yet loaded.
    #
    # Example:
    #
    #   class Comment < Ohm::Model
    #     reference :user, User # NameError undefined constant User.
    #   end
    #
    #   Instead of relying on some clever `const_missing` hack, we can
    #   simply use a Symbol.
    #
    #   class Comment < Ohm::Model
    #     reference :user, :User
    #   end
    #
    def self.const(context, name)
      case name
      when Symbol then context.const_get(name)
      else name
      end
    end

    def self.dict(arr)
      Hash[*arr]
    end

    def self.sort(redis, key, options)
      args = []

      args.concat(["BY", options[:by]]) if options[:by]
      args.concat(["GET", options[:get]]) if options[:get]
      args.concat(["LIMIT"] + options[:limit]) if options[:limit]
      args.concat(options[:order].split(" ")) if options[:order]
      args.concat(["STORE", options[:store]]) if options[:store]

      redis.call("SORT", key, *args)
    end
  end

  # Use this if you want to do quick ad hoc redis commands against the
  # defined Ohm connection.
  #
  # Examples:
  #
  #   Ohm.redis.call("SET", "foo", "bar")
  #   Ohm.redis.call("FLUSH")
  #
  def self.redis
    @redis ||= Redic.new
  end

  def self.redis=(redis)
    @redis = redis
  end

  # Wrapper for Ohm.redis.call("FLUSHDB").
  def self.flush
    redis.call("FLUSHDB")
  end

  module Collection
    include Enumerable

    def each
      if block_given?
        to_a.each { |element| yield element }
      else
        to_enum
      end
    end

    # Fetch the data from Redis in one go.
    def to_a
      fetch(ids)
    end

    def empty?
      size == 0
    end

    # Wraps the whole pipelining functionality.
    def fetch(ids)
      ids.each do |id|
        redis.write("HGETALL", namespace[id])
      end

      data = redis.run

      return [] if data.nil?

      [].tap do |result|
        data.each_with_index do |atts, idx|
          result << model.new(Utils.dict(atts).update(:id => ids[idx]))
        end
      end
    end
  end

  class List
    include Collection

    attr :key
    attr :namespace
    attr :model

    def initialize(key, namespace, model)
      @key = key
      @namespace = namespace
      @model = model
    end

    # Returns the total size of the list using LLEN.
    def size
      redis.call("LLEN", key)
    end
    alias :count :size

    # Returns the first element of the list using LINDEX.
    def first
      model[redis.call("LINDEX", key, 0)]
    end

    # Returns the last element of the list using LINDEX.
    def last
      model[redis.call("LINDEX", key, -1)]
    end

    # Checks if the model is part of this List.
    #
    # An important thing to note is that this method loads all of the
    # elements of the List since there is no command in Redis that
    # allows you to actually check the list contents efficiently.
    #
    # You may want to avoid doing this if your list has say, 10K entries.
    def include?(model)
      ids.include?(model.id.to_s)
    end

    # Replace all the existing elements of a list with a different
    # collection of models. This happens atomically in a MULTI-EXEC
    # block.
    #
    # Example:
    #
    #   user = User.create
    #   p1 = Post.create
    #   user.posts.push(p1)
    #
    #   p2, p3 = Post.create, Post.create
    #   user.posts.replace([p2, p3])
    #
    #   user.posts.include?(p1)
    #   # => false
    #
    def replace(models)
      ids = models.map { |model| model.id }

      redis.write("MULTI")
      redis.write("DEL", key)
      ids.each { |id| redis.write("RPUSH", key, id) }
      redis.write("EXEC")
      redis.run
    end

    # Pushes the model to the _end_ of the list using RPUSH.
    def push(model)
      redis.call("RPUSH", key, model.id)
    end

    # Pushes the model to the _beginning_ of the list using LPUSH.
    def unshift(model)
      redis.call("LPUSH", key, model.id)
    end

    # Delete a model from the list.
    #
    # Note: If your list contains the model multiple times, this method
    # will delete all instances of that model in one go.
    #
    # Example:
    #
    #   class Comment < Ohm::Model
    #   end
    #
    #   class Post < Ohm::Model
    #     list :comments, Comment
    #   end
    #
    #   p = Post.create
    #   c = Comment.create
    #
    #   p.comments.push(c)
    #   p.comments.push(c)
    #
    #   p.comments.delete(c)
    #
    #   p.comments.size == 0
    #   # => true
    #
    def delete(model)
      # LREM key 0 <id> means remove all elements matching <id>
      # @see http://redis.io/commands/lrem
      redis.call("LREM", key, 0, model.id)
    end

  private
    def ids
      redis.call("LRANGE", key, 0, -1)
    end

    def redis
      model.redis
    end
  end

  # Defines most of the methods used by `Set` and `MultiSet`.
  class BasicSet
    include Collection

    # Allows you to sort by any field in your model.
    #
    # Example:
    #
    #   class User < Ohm::Model
    #     attribute :name
    #   end
    #
    #   User.all.sort_by(:name, :order => "ALPHA")
    #   User.all.sort_by(:name, :order => "ALPHA DESC")
    #   User.all.sort_by(:name, :order => "ALPHA DESC", :limit => [0, 10])
    #
    # Note: This is slower compared to just doing `sort`, specifically
    # because Redis has to read each individual hash in order to sort
    # them.
    #
    def sort_by(att, options = {})
      sort(options.merge(:by => to_key(att)))
    end

    # Allows you to sort your models using their IDs. This is much
    # faster than `sort_by`. If you simply want to get records in
    # ascending or descending order, then this is the best method to
    # do that.
    #
    # Example:
    #
    #   class User < Ohm::Model
    #     attribute :name
    #   end
    #
    #   User.create(:name => "John")
    #   User.create(:name => "Jane")
    #
    #   User.all.sort.map(&:id) == ["1", "2"]
    #   # => true
    #
    #   User.all.sort(:order => "ASC").map(&:id) == ["1", "2"]
    #   # => true
    #
    #   User.all.sort(:order => "DESC").map(&:id) == ["2", "1"]
    #   # => true
    #
    def sort(options = {})
      if options.has_key?(:get)
        options[:get] = to_key(options[:get])
        return execute { |key| Utils.sort(redis, key, options) }
      end

      fetch(execute { |key| Utils.sort(redis, key, options) })
    end

    # Check if a model is included in this set.
    #
    # Example:
    #
    #   u = User.create
    #
    #   User.all.include?(u)
    #   # => true
    #
    # Note: Ohm simply checks that the model's ID is included in the
    # set. It doesn't do any form of type checking.
    #
    def include?(model)
      exists?(model.id)
    end

    # Returns the total size of the set using SCARD.
    def size
      execute { |key| redis.call("SCARD", key) }
    end
    alias :count :size

    # Syntactic sugar for `sort_by` or `sort` when you only need the
    # first element.
    #
    # Example:
    #
    #   User.all.first ==
    #     User.all.sort(:limit => [0, 1]).first
    #
    #   User.all.first(:by => :name, "ALPHA") ==
    #     User.all.sort_by(:name, :order => "ALPHA", :limit => [0, 1]).first
    #
    def first(options = {})
      opts = options.dup
      opts.merge!(:limit => [0, 1])

      if opts[:by]
        sort_by(opts.delete(:by), opts).first
      else
        sort(opts).first
      end
    end

    # Grab all the elements of this set using SMEMBERS.
    def ids
      execute { |key| redis.call("SMEMBERS", key) }
    end

    # Retrieve a specific element using an ID from this set.
    #
    # Example:
    #
    #   # Let's say we got the ID 1 from a request parameter.
    #   id = 1
    #
    #   # Retrieve the post if it's included in the user's posts.
    #   post = user.posts[id]
    #
    def [](id)
      model[id] if exists?(id)
    end

  private
    def exists?(id)
      execute { |key| redis.call("SISMEMBER", key, id) == 1 }
    end

    def to_key(att)
      if model.counters.include?(att)
        namespace["*:counters->%s" % att]
      else
        namespace["*->%s" % att]
      end
    end
  end

  class Set < BasicSet
    attr :key
    attr :namespace
    attr :model

    def initialize(key, namespace, model)
      @key = key
      @namespace = namespace
      @model = model
    end

    # Chain new fiters on an existing set.
    #
    # Example:
    #
    #   set = User.find(:name => "John")
    #   set.find(:age => 30)
    #
    def find(dict)
      MultiSet.new(
        namespace, model, Command[:sinterstore, key, *model.filters(dict)]
      )
    end

    # Reduce the set using any number of filters.
    #
    # Example:
    #
    #   set = User.find(:name => "John")
    #   set.except(:country => "US")
    #
    #   # You can also do it in one line.
    #   User.find(:name => "John").except(:country => "US")
    #
    def except(dict)
      MultiSet.new(namespace, model, key).except(dict)
    end

    # Do a union to the existing set using any number of filters.
    #
    # Example:
    #
    #   set = User.find(:name => "John")
    #   set.union(:name => "Jane")
    #
    #   # You can also do it in one line.
    #   User.find(:name => "John").union(:name => "Jane")
    #
    def union(dict)
      MultiSet.new(namespace, model, key).union(dict)
    end

  private
    def execute
      yield key
    end

    def redis
      model.redis
    end
  end

  class MutableSet < Set
    # Add a model directly to the set.
    #
    # Example:
    #
    #   user = User.create
    #   post = Post.create
    #
    #   user.posts.add(post)
    #
    def add(model)
      redis.call("SADD", key, model.id)
    end

    # Remove a model directly from the set.
    #
    # Example:
    #
    #   user = User.create
    #   post = Post.create
    #
    #   user.posts.delete(post)
    #
    def delete(model)
      redis.call("SREM", key, model.id)
    end

    # Replace all the existing elements of a set with a different
    # collection of models. This happens atomically in a MULTI-EXEC
    # block.
    #
    # Example:
    #
    #   user = User.create
    #   p1 = Post.create
    #   user.posts.add(p1)
    #
    #   p2, p3 = Post.create, Post.create
    #   user.posts.replace([p2, p3])
    #
    #   user.posts.include?(p1)
    #   # => false
    #
    def replace(models)
      ids = models.map { |model| model.id }

      redis.write("MULTI")
      redis.write("DEL", key)
      ids.each { |id| redis.write("SADD", key, id) }
      redis.write("EXEC")
      redis.run
    end
  end

  # Anytime you filter a set with more than one requirement, you
  # internally use a `MultiSet`. `MutiSet` is a bit slower than just
  # a `Set` because it has to `SINTERSTORE` all the keys prior to
  # retrieving the members, size, etc.
  #
  # Example:
  #
  #   User.all.kind_of?(Ohm::Set)
  #   # => true
  #
  #   User.find(:name => "John").kind_of?(Ohm::Set)
  #   # => true
  #
  #   User.find(:name => "John", :age => 30).kind_of?(Ohm::MultiSet)
  #   # => true
  #
  class MultiSet < BasicSet
    attr :namespace
    attr :model
    attr :command

    def initialize(namespace, model, command)
      @namespace = namespace
      @model = model
      @command = command
    end

    # Chain new fiters on an existing set.
    #
    # Example:
    #
    #   set = User.find(:name => "John", :age => 30)
    #   set.find(:status => 'pending')
    #
    def find(dict)
      MultiSet.new(
        namespace, model, Command[:sinterstore, command, intersected(dict)]
      )
    end

    # Reduce the set using any number of filters.
    #
    # Example:
    #
    #   set = User.find(:name => "John")
    #   set.except(:country => "US")
    #
    #   # You can also do it in one line.
    #   User.find(:name => "John").except(:country => "US")
    #
    def except(dict)
      MultiSet.new(
        namespace, model, Command[:sdiffstore, command, intersected(dict)]
      )
    end

    # Do a union to the existing set using any number of filters.
    #
    # Example:
    #
    #   set = User.find(:name => "John")
    #   set.union(:name => "Jane")
    #
    #   # You can also do it in one line.
    #   User.find(:name => "John").union(:name => "Jane")
    #
    def union(dict)
      MultiSet.new(
        namespace, model, Command[:sunionstore, command, intersected(dict)]
      )
    end

  private
    def redis
      model.redis
    end

    def intersected(dict)
      Command[:sinterstore, *model.filters(dict)]
    end

    def execute
      # namespace[:tmp] is where all the temp keys should be stored in.
      # redis will be where all the commands are executed against.
      res = command.call(namespace[:tmp], redis)

      begin

        # At this point, we have the final aggregated set, which we yield
        # to the caller. the caller can do all the normal set operations,
        # i.e. SCARD, SMEMBERS, etc.
        yield res

      ensure

        # We have to make sure we clean up the temporary keys to avoid
        # memory leaks and the unintended explosion of memory usage.
        command.clean
      end
    end
  end

  # The base class for all your models. In order to better understand
  # it, here is a semi-realtime explanation of the details involved
  # when creating a User instance.
  #<commit_msg>Rename variable to be more descriptive.<commit_after># encoding: UTF-8

require "redic"
require "securerandom"
require "ohm/command"
require "ohm/nest"
require "msgpack"

module Ohm
  LUA_CACHE   = Hash.new { |h, k| h[k] = Hash.new }
  LUA_SAVE    = File.expand_path("../ohm/lua/save.lua",   __FILE__)
  LUA_DELETE  = File.expand_path("../ohm/lua/delete.lua", __FILE__)

  # All of the known errors in Ohm can be traced back to one of these
  # exceptions.
  #
  # MissingID:
  #
  #   Comment.new.id # => Error
  #   Comment.new.key # => Error
  #
  #   Solution: you need to save your model first.
  #
  # IndexNotFound:
  #
  #   Comment.find(:foo => "Bar") # => Error
  #
  #   Solution: add an index with `Comment.index :foo`.
  #
  # UniqueIndexViolation:
  #
  #   Raised when trying to save an object with a `unique` index for
  #   which the value already exists.
  #
  #   Solution: rescue `Ohm::UniqueIndexViolation` during save, but
  #   also, do some validations even before attempting to save.
  #
  class Error < StandardError; end
  class MissingID < Error; end
  class IndexNotFound < Error; end
  class UniqueIndexViolation < Error; end

  # Instead of monkey patching Kernel or trying to be clever, it's
  # best to confine all the helper methods in a Utils module.
  module Utils

    # Used by: `attribute`, `counter`, `set`, `reference`,
    # `collection`.
    #
    # Employed as a solution to avoid `NameError` problems when trying
    # to load models referring to other models not yet loaded.
    #
    # Example:
    #
    #   class Comment < Ohm::Model
    #     reference :user, User # NameError undefined constant User.
    #   end
    #
    #   Instead of relying on some clever `const_missing` hack, we can
    #   simply use a Symbol.
    #
    #   class Comment < Ohm::Model
    #     reference :user, :User
    #   end
    #
    def self.const(context, name)
      case name
      when Symbol then context.const_get(name)
      else name
      end
    end

    def self.dict(arr)
      Hash[*arr]
    end

    def self.sort(redis, key, options)
      args = []

      args.concat(["BY", options[:by]]) if options[:by]
      args.concat(["GET", options[:get]]) if options[:get]
      args.concat(["LIMIT"] + options[:limit]) if options[:limit]
      args.concat(options[:order].split(" ")) if options[:order]
      args.concat(["STORE", options[:store]]) if options[:store]

      redis.call("SORT", key, *args)
    end
  end

  # Use this if you want to do quick ad hoc redis commands against the
  # defined Ohm connection.
  #
  # Examples:
  #
  #   Ohm.redis.call("SET", "foo", "bar")
  #   Ohm.redis.call("FLUSH")
  #
  def self.redis
    @redis ||= Redic.new
  end

  def self.redis=(redis)
    @redis = redis
  end

  # Wrapper for Ohm.redis.call("FLUSHDB").
  def self.flush
    redis.call("FLUSHDB")
  end

  module Collection
    include Enumerable

    def each
      if block_given?
        to_a.each { |element| yield element }
      else
        to_enum
      end
    end

    # Fetch the data from Redis in one go.
    def to_a
      fetch(ids)
    end

    def empty?
      size == 0
    end

    # Wraps the whole pipelining functionality.
    def fetch(ids)
      ids.each do |id|
        redis.write("HGETALL", namespace[id])
      end

      data = redis.run

      return [] if data.nil?

      [].tap do |result|
        data.each_with_index do |atts, idx|
          result << model.new(Utils.dict(atts).update(:id => ids[idx]))
        end
      end
    end
  end

  class List
    include Collection

    attr :key
    attr :namespace
    attr :model

    def initialize(key, namespace, model)
      @key = key
      @namespace = namespace
      @model = model
    end

    # Returns the total size of the list using LLEN.
    def size
      redis.call("LLEN", key)
    end
    alias :count :size

    # Returns the first element of the list using LINDEX.
    def first
      model[redis.call("LINDEX", key, 0)]
    end

    # Returns the last element of the list using LINDEX.
    def last
      model[redis.call("LINDEX", key, -1)]
    end

    # Checks if the model is part of this List.
    #
    # An important thing to note is that this method loads all of the
    # elements of the List since there is no command in Redis that
    # allows you to actually check the list contents efficiently.
    #
    # You may want to avoid doing this if your list has say, 10K entries.
    def include?(model)
      ids.include?(model.id.to_s)
    end

    # Replace all the existing elements of a list with a different
    # collection of models. This happens atomically in a MULTI-EXEC
    # block.
    #
    # Example:
    #
    #   user = User.create
    #   p1 = Post.create
    #   user.posts.push(p1)
    #
    #   p2, p3 = Post.create, Post.create
    #   user.posts.replace([p2, p3])
    #
    #   user.posts.include?(p1)
    #   # => false
    #
    def replace(models)
      ids = models.map { |model| model.id }

      redis.write("MULTI")
      redis.write("DEL", key)
      ids.each { |id| redis.write("RPUSH", key, id) }
      redis.write("EXEC")
      redis.run
    end

    # Pushes the model to the _end_ of the list using RPUSH.
    def push(model)
      redis.call("RPUSH", key, model.id)
    end

    # Pushes the model to the _beginning_ of the list using LPUSH.
    def unshift(model)
      redis.call("LPUSH", key, model.id)
    end

    # Delete a model from the list.
    #
    # Note: If your list contains the model multiple times, this method
    # will delete all instances of that model in one go.
    #
    # Example:
    #
    #   class Comment < Ohm::Model
    #   end
    #
    #   class Post < Ohm::Model
    #     list :comments, Comment
    #   end
    #
    #   p = Post.create
    #   c = Comment.create
    #
    #   p.comments.push(c)
    #   p.comments.push(c)
    #
    #   p.comments.delete(c)
    #
    #   p.comments.size == 0
    #   # => true
    #
    def delete(model)
      # LREM key 0 <id> means remove all elements matching <id>
      # @see http://redis.io/commands/lrem
      redis.call("LREM", key, 0, model.id)
    end

  private
    def ids
      redis.call("LRANGE", key, 0, -1)
    end

    def redis
      model.redis
    end
  end

  # Defines most of the methods used by `Set` and `MultiSet`.
  class BasicSet
    include Collection

    # Allows you to sort by any field in your model.
    #
    # Example:
    #
    #   class User < Ohm::Model
    #     attribute :name
    #   end
    #
    #   User.all.sort_by(:name, :order => "ALPHA")
    #   User.all.sort_by(:name, :order => "ALPHA DESC")
    #   User.all.sort_by(:name, :order => "ALPHA DESC", :limit => [0, 10])
    #
    # Note: This is slower compared to just doing `sort`, specifically
    # because Redis has to read each individual hash in order to sort
    # them.
    #
    def sort_by(att, options = {})
      sort(options.merge(:by => to_key(att)))
    end

    # Allows you to sort your models using their IDs. This is much
    # faster than `sort_by`. If you simply want to get records in
    # ascending or descending order, then this is the best method to
    # do that.
    #
    # Example:
    #
    #   class User < Ohm::Model
    #     attribute :name
    #   end
    #
    #   User.create(:name => "John")
    #   User.create(:name => "Jane")
    #
    #   User.all.sort.map(&:id) == ["1", "2"]
    #   # => true
    #
    #   User.all.sort(:order => "ASC").map(&:id) == ["1", "2"]
    #   # => true
    #
    #   User.all.sort(:order => "DESC").map(&:id) == ["2", "1"]
    #   # => true
    #
    def sort(options = {})
      if options.has_key?(:get)
        options[:get] = to_key(options[:get])
        return execute { |key| Utils.sort(redis, key, options) }
      end

      fetch(execute { |key| Utils.sort(redis, key, options) })
    end

    # Check if a model is included in this set.
    #
    # Example:
    #
    #   u = User.create
    #
    #   User.all.include?(u)
    #   # => true
    #
    # Note: Ohm simply checks that the model's ID is included in the
    # set. It doesn't do any form of type checking.
    #
    def include?(model)
      exists?(model.id)
    end

    # Returns the total size of the set using SCARD.
    def size
      execute { |key| redis.call("SCARD", key) }
    end
    alias :count :size

    # Syntactic sugar for `sort_by` or `sort` when you only need the
    # first element.
    #
    # Example:
    #
    #   User.all.first ==
    #     User.all.sort(:limit => [0, 1]).first
    #
    #   User.all.first(:by => :name, "ALPHA") ==
    #     User.all.sort_by(:name, :order => "ALPHA", :limit => [0, 1]).first
    #
    def first(options = {})
      opts = options.dup
      opts.merge!(:limit => [0, 1])

      if opts[:by]
        sort_by(opts.delete(:by), opts).first
      else
        sort(opts).first
      end
    end

    # Grab all the elements of this set using SMEMBERS.
    def ids
      execute { |key| redis.call("SMEMBERS", key) }
    end

    # Retrieve a specific element using an ID from this set.
    #
    # Example:
    #
    #   # Let's say we got the ID 1 from a request parameter.
    #   id = 1
    #
    #   # Retrieve the post if it's included in the user's posts.
    #   post = user.posts[id]
    #
    def [](id)
      model[id] if exists?(id)
    end

  private
    def exists?(id)
      execute { |key| redis.call("SISMEMBER", key, id) == 1 }
    end

    def to_key(att)
      if model.counters.include?(att)
        namespace["*:counters->%s" % att]
      else
        namespace["*->%s" % att]
      end
    end
  end

  class Set < BasicSet
    attr :key
    attr :namespace
    attr :model

    def initialize(key, namespace, model)
      @key = key
      @namespace = namespace
      @model = model
    end

    # Chain new fiters on an existing set.
    #
    # Example:
    #
    #   set = User.find(:name => "John")
    #   set.find(:age => 30)
    #
    def find(dict)
      MultiSet.new(
        namespace, model, Command[:sinterstore, key, *model.filters(dict)]
      )
    end

    # Reduce the set using any number of filters.
    #
    # Example:
    #
    #   set = User.find(:name => "John")
    #   set.except(:country => "US")
    #
    #   # You can also do it in one line.
    #   User.find(:name => "John").except(:country => "US")
    #
    def except(dict)
      MultiSet.new(namespace, model, key).except(dict)
    end

    # Do a union to the existing set using any number of filters.
    #
    # Example:
    #
    #   set = User.find(:name => "John")
    #   set.union(:name => "Jane")
    #
    #   # You can also do it in one line.
    #   User.find(:name => "John").union(:name => "Jane")
    #
    def union(dict)
      MultiSet.new(namespace, model, key).union(dict)
    end

  private
    def execute
      yield key
    end

    def redis
      model.redis
    end
  end

  class MutableSet < Set
    # Add a model directly to the set.
    #
    # Example:
    #
    #   user = User.create
    #   post = Post.create
    #
    #   user.posts.add(post)
    #
    def add(model)
      redis.call("SADD", key, model.id)
    end

    # Remove a model directly from the set.
    #
    # Example:
    #
    #   user = User.create
    #   post = Post.create
    #
    #   user.posts.delete(post)
    #
    def delete(model)
      redis.call("SREM", key, model.id)
    end

    # Replace all the existing elements of a set with a different
    # collection of models. This happens atomically in a MULTI-EXEC
    # block.
    #
    # Example:
    #
    #   user = User.create
    #   p1 = Post.create
    #   user.posts.add(p1)
    #
    #   p2, p3 = Post.create, Post.create
    #   user.posts.replace([p2, p3])
    #
    #   user.posts.include?(p1)
    #   # => false
    #
    def replace(models)
      ids = models.map { |model| model.id }

      redis.write("MULTI")
      redis.write("DEL", key)
      ids.each { |id| redis.write("SADD", key, id) }
      redis.write("EXEC")
      redis.run
    end
  end

  # Anytime you filter a set with more than one requirement, you
  # internally use a `MultiSet`. `MutiSet` is a bit slower than just
  # a `Set` because it has to `SINTERSTORE` all the keys prior to
  # retrieving the members, size, etc.
  #
  # Example:
  #
  #   User.all.kind_of?(Ohm::Set)
  #   # => true
  #
  #   User.find(:name => "John").kind_of?(Ohm::Set)
  #   # => true
  #
  #   User.find(:name => "John", :age => 30).kind_of?(Ohm::MultiSet)
  #   # => true
  #
  class MultiSet < BasicSet
    attr :namespace
    attr :model
    attr :command

    def initialize(namespace, model, command)
      @namespace = namespace
      @model = model
      @command = command
    end

    # Chain new fiters on an existing set.
    #
    # Example:
    #
    #   set = User.find(:name => "John", :age => 30)
    #   set.find(:status => 'pending')
    #
    def find(dict)
      MultiSet.new(
        namespace, model, Command[:sinterstore, command, intersected(dict)]
      )
    end

    # Reduce the set using any number of filters.
    #
    # Example:
    #
    #   set = User.find(:name => "John")
    #   set.except(:country => "US")
    #
    #   # You can also do it in one line.
    #   User.find(:name => "John").except(:country => "US")
    #
    def except(dict)
      MultiSet.new(
        namespace, model, Command[:sdiffstore, command, intersected(dict)]
      )
    end

    # Do a union to the existing set using any number of filters.
    #
    # Example:
    #
    #   set = User.find(:name => "John")
    #   set.union(:name => "Jane")
    #
    #   # You can also do it in one line.
    #   User.find(:name => "John").union(:name => "Jane")
    #
    def union(dict)
      MultiSet.new(
        namespace, model, Command[:sunionstore, command, intersected(dict)]
      )
    end

  private
    def redis
      model.redis
    end

    def intersected(dict)
      Command[:sinterstore, *model.filters(dict)]
    end

    def execute
      # namespace[:tmp] is where all the temp keys should be stored in.
      # redis will be where all the commands are executed against.
      response = command.call(namespace[:tmp], redis)

      begin

        # At this point, we have the final aggregated set, which we yield
        # to the caller. the caller can do all the normal set operations,
        # i.e. SCARD, SMEMBERS, etc.
        yield response

      ensure

        # We have to make sure we clean up the temporary keys to avoid
        # memory leaks and the unintended explosion of memory usage.
        command.clean
      end
    end
  end

  # The base class for all your models. In order to better understand
  # it, here is a semi-realtime explanation of the details involved
  # when creating a User instance.
  #
{'fraction_non_alphanumeric': 0.12060080016274496, 'fraction_numerical': 0.003322709703668543, 'mean_word_length': 2.0504705760678457, 'pattern_counts': {'":': 0, '<': 37, '<?xml version=': 0, '>': 117, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '1109614', 'n_tokens_mistral': 9963, 'n_tokens_neox': 9732, 'n_words': 2965}