positioning Ruby Gem

repository·main·Indexed 19 days ago

https://github.com/brendon/positioning

A Ruby gem for managing sequential integer positioning for Active Record models within various scopes. It provides automatic position management during create, update, and destroy lifecycles, supporting global, relationship-based, and custom column scoping. Features include relative positioning (before/after), record locking to prevent race conditions, and a healer mechanism to repair gaps or inconsistencies in the position sequence.

Tokens
4.1K
Snippets
13
Records
16
Agent score
65%

What's inside positioning

  1. Overview of positioning logic

    main

    The positioning gem allows you to manage sequential integer positions (starting at 1) for Active Record model instances within a specific scope.

    To maintain consistency and ensure positions remain sequential, the gem attempts to perform changes within a transaction. Instead of manually assigning integer values to a position column, it is recommended to move items by declaring an item's prior or subsequent item in the list. This allows the gem to calculate the new position relative to the specified neighbor.

  2. Handle concurrency and race conditions in Positioning

    main

    The gem performs queries to find the next available position integer, which are susceptible to race conditions. To prevent this, positioning implements record locking to ensure model callbacks that assign positions run sequentially.

    • Scoped Records: If a scope is defined, the gem locks the records within that scope.
    • Global Scope: If no scope is defined, the gem locks all records in the table.

    Note for SQLite Users: SQLite does not support row locking. Under high load, you may encounter database lock errors, though SQLite's default non-concurrent write behavior mitigates most race condition risks.

  3. Set up the development environment

    main

    To set up the gem for local development:

    1. Run bin/setup to install necessary dependencies.
    2. Use bin/console to open an interactive prompt for experimentation.
    3. Run tests using rake test.

    To run tests against specific databases, prepend the DB environment variable:

    • SQLite: DB=sqlite rake test
    • PostgreSQL: DB=postgresql rake test

    Note: You must manually create a database named positioning_test in PostgreSQL and MySQL before running tests. Configuration for these environments is located in test/support/database.yml.

    bin/setup
    DB=sqlite rake test
  4. Configure database schema for Positioning

    main

    To use the Positioning gem, your database table must include a column to store the position.

    1. Column Name: By default, use a column named position of type integer. It should not allow NULL values.
    2. Uniqueness: You must add a unique index to ensure the position value is unique within its scope (e.g., within a specific list).
    3. Polymorphic Scopes: If using a polymorphic belongs_to relationship, include the type column in your unique index.

    Note on values: The gem uses 0 and negative integers for internal rearrangement. Do not add database constraints that restrict the column to positive integers only. If you attempt to set a position to 0 or a negative integer, it will be converted to 1.

    # Standard setup (assuming items belong to lists)
    add_column :items, :position, :integer, null: false
    add_index :items, [:list_id, :position], unique: true
    
    # Polymorphic setup
    add_index :items, [:listable_id, :listable_type, :position], unique: true
  5. Implement relative positioning in Rails forms

    main

    To allow users to select a position relative to another item (e.g., 'Add before Item X') in a web form, follow these steps:

    1. Permit Parameters: Allow both the scalar position and the nested before key in your Strong Parameters.
    2. Initialize Position: In your controller's new action, initialize the position with the parameter.
    3. Form Helper: Use Positioning::RelativePosition.new within your form fields to handle the nested structure. This ensures the before or after value is preserved even if the position column is an integer.

    Note: Adjust method names if your position column is not named position (e.g., use category_position_before_type_cast).

    # 1. Controller Params
    def item_params
      params.require(:item).permit(:name, :position, { position: :before })
    end
    
    # 2. Controller New Action
    def new
      item.position = { before: params[:before] }
    end
    
    # 3. View (ERB)
    <% if item.new_record? %>
      <%= form.fields :position, model: Positioning::RelativePosition.new(item.position_before_type_cast) do |fields|
        <%= fields.hidden_field :before %>
      <% end %>
    <% end %>
  6. How positioning scopes work

    main

    The positioned macro uses the on parameter to determine the uniqueness of a position. This ensures that two different lists (e.g., two different Project records) can both have a record at position: 1 without conflict.

    1. Association Scoping: If you pass a belongs_to association name to on, the gem identifies the foreign key (e.g., list_id) and uses it as the scope. If the association is polymorphic, it also uses the _type column.
    2. Column Scoping: If you pass a column name, the position is scoped to the values in that column.
    3. Global Scoping: If no scope is provided or the scope is not a belongs_to relationship, the positioning is effectively global across the table.

    When a record is duplicated via dup, the positioning columns are automatically reset to nil to prevent accidental assignment of existing positions to the new record.

  7. Manipulate record positions

    main

    You can assign positions during creation or updates using the position column.

    Available Position Values:

    • Integers/Strings: A specific position (e.g., 3). Values are automatically clamped between 1 and the end of the list.
    • Keywords: :first or :last (or strings 'first', 'last').
    • Nil/Empty: nil or "" places the record at the end of the list.
    • Relative: Use {before: record_or_id} or {after: record_or_id}. You can pass nil to these to place at the start or end respectively.

    Relative Accessors: The gem adds instance methods to find neighbors. These are named after the position column. For a column named position, use prior_position and subsequent_position. For category_position, use prior_category_position and subsequent_category_position.

    # Creating
    list.items.create(name: 'Item', position: 3)
    list.items.create(name: 'Item', position: :first)
    list.items.create(name: 'Item', position: { before: other_item })
    list.items.create(name: 'Item', position: { after: 22 })
    
    # Updating
    item.update(position: :last)
    item.update(position: { before: other_item })
    
    # Accessing neighbors
    item.prior_position
    item.subsequent_position
  8. Declare positioning in your model

    main

    Use the positioned method in your ActiveRecord model to enable positioning. You can define the scope (the grouping) and the database column used for the position.

    • Global Scope: All records in the table belong to the same list.
    • Relationship Scope: Use on: :relationship_name to scope records by a belongs_to association. The gem automatically uses the foreign key (e.g., list_id) as the scope.
    • Custom Column: Use column: :column_name if you want to track multiple positions on one model.
    • Arbitrary Column: The scope can be any database column (e.g., :type).
    • Complex Scopes: Pass an array of columns or relationships to create a composite scope.
    • Polymorphic: If the relationship is polymorphic, the gem automatically includes the _type column in the scope.
    # Global scope
    positioned
    
    # Scoped to a belongs_to relationship
    belongs_to :list
    positioned on: :list
    
    # Scoped to a relationship with a custom column name
    belongs_to :category
    positioned on: :category, column: :category_position
    
    # Scoped to an arbitrary column
    positioned on: :type
    
    # Complex composite scope
    belongs_to :list
    belongs_to :category
    positioned on: [:list, :category, :enabled]
    
    # Polymorphic scope
    belongs_to :listable, polymorphic: true
    positioned on: :listable
  9. Heal existing position columns

    main

    If you are adding positioning to an existing dataset or migrating from another gem, use the heal_position_column! method to reset positions to positive integers starting at 1 with no gaps.

    Method Naming: The method name is derived from the position column name. If your column is position, the method is heal_position_column!. If your column is category_position, the method is heal_category_position_column!.

    Usage:

    • Call the method on the class.
    • It iterates through every scope combination and resets positions based on their current order.
    • You can pass a custom order via the name: parameter (compatible with Active Record reorder).
    # Default column name
    Item.heal_position_column!
    
    # Custom order
    Item.heal_position_column!(name: :desc)
    
    # Custom column name
    Item.heal_category_position_column!
  10. Manage record positions with Positioning::Mechanisms

    main

    The Positioning::Mechanisms class provides the core logic for managing ordered positions within a specific scope. It handles the mathematical adjustments required when records are created, updated, or destroyed to ensure the integrity of the ordered list.

    Core Operations

    • create_position: Initializes a new position for a record. It locks the scope, solidifies the position (resolving symbolic or relative values), and expands the existing list to accommodate the new entry.
    • update_position: Adjusts the position of an existing record. It detects if the record has moved within its scope or if the scope itself has changed, then performs the necessary expand or contract operations to shift other records.
    • destroy_position: Removes a record from the ordering. It moves the record out of the way and contracts the remaining records to close the gap left by the deleted item.

    Position Resolution (Solidification)

    When calling create_position, the library can resolve several types of position inputs into a concrete integer:

    • Integer: The exact position requested.
    • :first, {after: nil}, or {after: ""}: Resolves to the first position (1).
    • :last, {before: nil}, {before: ""}, or nil: Resolves to the last available position in the scope.
    • Relative Hash: Using :before or :after with a record or ID (e.g., {after: some_record}) allows positioning relative to another item in the same scope.
    # Conceptual usage of the mechanism logic
    mechanisms = Positioning::Mechanisms.new(my_record, :position)
    
    # To create a new position
    mechanisms.create_position
    
    # To update an existing position
    mechanisms.update_position
    
    # To remove a record from the ordering
    mechanisms.destroy_position