easy_tags 0.1.0.pre.alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ module EasyTags
2
+ module Options
3
+ # Represents collection of options
4
+ class Collection
5
+ def initialize(options)
6
+ @options = options.uniq
7
+ end
8
+
9
+ # @return [Boolean]
10
+ def valid?
11
+ filtered_options.all?(&:valid?)
12
+ end
13
+
14
+ # @return [String]
15
+ def items
16
+ filtered_options
17
+ end
18
+
19
+ private
20
+
21
+ def filtered_options
22
+ @filtered_options ||= @options.to_a.flatten.compact.map do |raw_option|
23
+ Item.new(raw_option)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,41 @@
1
+ module EasyTags
2
+ module Options
3
+ # Represents a single option item
4
+ class Item
5
+ def initialize(option)
6
+ @option = option
7
+ end
8
+
9
+ # @return [Boolean]
10
+ def valid?
11
+ /[@$"]/ !~ name.inspect
12
+ end
13
+
14
+ # @return [Symbol]
15
+ def name
16
+ @name ||= key.to_sym
17
+ end
18
+
19
+ # @return [Array<Callback>]
20
+ def callbacks
21
+ return [] unless callbacks?
22
+
23
+ @option.values.first.map do |type, callback|
24
+ Callback.new(callback: callback, type: type)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def key
31
+ return @option.keys.first if callbacks?
32
+
33
+ @option
34
+ end
35
+
36
+ def callbacks?
37
+ @option.is_a?(Hash)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,22 @@
1
+ module EasyTags
2
+ module Parsers
3
+ # Default parser for [String] -> [Array] conversion
4
+ class Default
5
+ class << self
6
+ # Returns a new TagList using the given tag string.
7
+ #
8
+ # @param [String] tag_list_string
9
+ # @return [Array<String>]
10
+ #
11
+ # Example:
12
+ # EasyTags::Parsers::Default.parse('One , Two, Three')
13
+ # ['One', 'Two', 'Three']
14
+ def parse(tag_list_string)
15
+ return [] if tag_list_string.to_s.empty?
16
+
17
+ tag_list_string.to_s.split(/,/).map(&:strip)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,36 @@
1
+ module EasyTags
2
+ # Tag model
3
+ class Tag < ::ActiveRecord::Base
4
+ self.table_name = EasyTags.tags_table
5
+
6
+ has_many :taggings, dependent: :destroy, class_name: '::EasyTags::Tagging', inverse_of: :tag
7
+
8
+ validates_presence_of :name
9
+ validates_uniqueness_of :name
10
+ validates_length_of :name, maximum: 255
11
+
12
+ after_commit :notify_add, on: :create
13
+ after_commit :notify_remove, on: :destroy
14
+
15
+ # cast object to string
16
+ def to_s
17
+ name
18
+ end
19
+
20
+ private
21
+
22
+ def notify_add
23
+ ActiveSupport::Notifications.instrument(
24
+ 'easy_tag.tag_added',
25
+ tag: self
26
+ )
27
+ end
28
+
29
+ def notify_remove
30
+ ActiveSupport::Notifications.instrument(
31
+ 'easy_tag.tag_removed',
32
+ tag: self
33
+ )
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,47 @@
1
+ module EasyTags
2
+ # Represents a tag list
3
+ class TagList < SimpleDelegator
4
+ def initialize(
5
+ *args,
6
+ generator: EasyTags.generator,
7
+ parser: EasyTags.parser
8
+ )
9
+ self.generator = generator
10
+ self.parser = parser
11
+ super([])
12
+
13
+ add(*args)
14
+ end
15
+
16
+ # Add tags to the tag_list. Duplicate or blank tags will be ignored.
17
+ #
18
+ # Example:
19
+ # tag_list.add('Fun', 'Happy')
20
+ def add(*names)
21
+ filter(names).each { |filtered_name| push(filtered_name) unless include?(filtered_name) }
22
+ end
23
+
24
+ # Transform the tag_list into a tag string
25
+ def to_s
26
+ generator.generate(self)
27
+ end
28
+
29
+ # Remove item from list
30
+ #
31
+ # Example:
32
+ # tag_list.remove('Issues')
33
+ def remove(value)
34
+ __getobj__.delete(value)
35
+ end
36
+
37
+ private
38
+
39
+ attr_accessor :generator, :parser
40
+
41
+ def filter(names)
42
+ names.to_a.flatten.compact.map do |name|
43
+ parser.parse(name)
44
+ end.flatten.compact.uniq
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,52 @@
1
+ # rubocop:disable Style/Documentation
2
+ module EasyTags
3
+ module Taggable
4
+ module ClassMethods
5
+ # rubocop:enable Style/Documentation
6
+ #
7
+ # Examples
8
+ #
9
+ # easy_tags_on :highlights
10
+ #
11
+ # with multiple contexts:
12
+ #
13
+ # easy_tags_on :highlights, :tags
14
+ #
15
+ # with callbacks:
16
+ #
17
+ # easy_tags_on(
18
+ # highlights: {
19
+ # after_add: :add_tag_callback, after_remove: ->(tagging) { puts "removed #{tagging.tag.name}" }
20
+ # }
21
+ # )
22
+ #
23
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
24
+ def easy_tags_on(*tagging_contexts_params)
25
+ cattr_accessor :tagging_contexts
26
+ cattr_accessor :tagging_callbacks
27
+
28
+ options = Options::Collection.new(tagging_contexts_params.to_a)
29
+ raise 'invalid options' unless options.valid?
30
+
31
+ self.tagging_contexts ||= []
32
+ self.tagging_callbacks ||= {}
33
+
34
+ options.items.each do |option|
35
+ tagging_contexts.push(option.name) unless tagging_contexts.include?(option.name)
36
+ tagging_callbacks[option.name] = option.callbacks
37
+
38
+ EasyTags::TaggableContextMethods.inject(
39
+ class_instance: self,
40
+ context: option.name
41
+ )
42
+ end
43
+ end
44
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
45
+ end
46
+
47
+ def self.included(base)
48
+ base.extend(ClassMethods)
49
+ EasyTags::TaggableMethods.inject(class_instance: base)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,56 @@
1
+ module EasyTags
2
+ # Handles tag context manipulation
3
+ class TaggableContext
4
+ # @param [String, Symbol] context
5
+ # @param [Proc] refresh_persisted_tags
6
+ # @param [Proc] on_change
7
+ def initialize(context:, refresh_persisted_tags:, on_change:)
8
+ self.context = context
9
+ self.refresh_persisted_tags = refresh_persisted_tags
10
+ self.on_change = on_change
11
+ end
12
+
13
+ # @return [true, false]
14
+ def changed?
15
+ tags.sort != persisted_tags.sort
16
+ end
17
+
18
+ # @return [TagList]
19
+ def tags
20
+ @tags ||= TagList.new(persisted_tags)
21
+ end
22
+
23
+ # @return [TagList]
24
+ def persisted_tags
25
+ @persisted_tags ||= TagList.new(refresh_persisted_tags.call)
26
+ end
27
+
28
+ # @param [String, Symbol] value
29
+ # @return [TagList]
30
+ def update(value)
31
+ @tags = TagList.new(value)
32
+
33
+ on_change.call(self) if changed?
34
+ end
35
+
36
+ # @return [TagList]
37
+ def new_tags
38
+ tags - persisted_tags
39
+ end
40
+
41
+ # @return [TagList]
42
+ def removed_tags
43
+ persisted_tags - tags
44
+ end
45
+
46
+ # clear memoized info and force a refresh
47
+ def refresh
48
+ @tags = nil
49
+ @persisted_tags = nil
50
+ end
51
+
52
+ private
53
+
54
+ attr_accessor :context, :refresh_persisted_tags, :on_change
55
+ end
56
+ end
@@ -0,0 +1,56 @@
1
+ module EasyTags
2
+ # Handles injecting of the dynamic context related methods
3
+ # Example:
4
+ # easy_tags_on :bees
5
+ #
6
+ # # will create:
7
+ #
8
+ # has_many :bees_taggings
9
+ # has_many :bees_tags
10
+ #
11
+ # def bees
12
+ # def bees=
13
+ # def bees_list
14
+ # def bees_list=
15
+ class TaggableContextMethods
16
+ class << self
17
+ def inject(class_instance:, context:)
18
+ class_instance.class_eval <<-RUBY, __FILE__, __LINE__ + 1
19
+ has_many(
20
+ :#{context}_taggings, -> { includes(:tag).where(context: :#{context}) },
21
+ as: :taggable,
22
+ class_name: 'EasyTags::Tagging',
23
+ dependent: :destroy
24
+ )
25
+
26
+ has_many(
27
+ :#{context}_tags,
28
+ class_name: 'EasyTags::Tag',
29
+ through: :#{context}_taggings,
30
+ source: :tag
31
+ )
32
+
33
+ attribute :#{context}_list, ActiveModel::Type::Value.new
34
+
35
+ def #{context}
36
+ _taggable_context(:#{context}).tags
37
+ end
38
+
39
+ def #{context}=(value)
40
+ _taggable_context(:#{context}).update(value)
41
+ #{context}
42
+ end
43
+
44
+ def #{context}_list
45
+ _taggable_context(:#{context}).tags.to_s
46
+ end
47
+
48
+ def #{context}_list=(value)
49
+ _taggable_context(:#{context}).update(value)
50
+ #{context}_list
51
+ end
52
+ RUBY
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,109 @@
1
+ module EasyTags
2
+ # Taggable instance methods
3
+ module TaggableMethods
4
+ class << self
5
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
6
+ def inject(class_instance:)
7
+ # rubocop:disable Metrics/BlockLength
8
+ class_instance.class_eval do
9
+ has_many(
10
+ :taggings,
11
+ as: :taggable,
12
+ dependent: :destroy,
13
+ class_name: '::EasyTags::Tagging',
14
+ inverse_of: :taggable
15
+ )
16
+
17
+ has_many(
18
+ :base_tags,
19
+ through: :taggings,
20
+ source: :tag,
21
+ class_name: '::EasyTags::Tag',
22
+ inverse_of: :tag
23
+ )
24
+
25
+ after_save :_update_taggings, :_refresh_tagging
26
+ after_find :_refresh_tagging
27
+
28
+ # override ActiveRecord::Persistence#reload
29
+ # to refresh tags each time the model instance gets reloaded
30
+ def reload
31
+ _refresh_tagging
32
+ super
33
+ end
34
+
35
+ private
36
+
37
+ attr_accessor :_taggable_contexts
38
+
39
+ def _taggable_contexts
40
+ @_taggable_contexts ||= {}
41
+ end
42
+
43
+ def _update_taggings
44
+ tagging_contexts.each do |context|
45
+ context_tags = _taggable_context(context)
46
+
47
+ next unless context_tags.changed?
48
+
49
+ context_tags.new_tags.each do |tag_name|
50
+ tag = Tag.find_or_create_by!(name: tag_name)
51
+ taggings.create!(context: context, tag: tag)
52
+ end
53
+
54
+ taggings
55
+ .joins(:tag)
56
+ .where(context: context)
57
+ .where(Tag.table_name => { name: context_tags.removed_tags })
58
+ .destroy_all
59
+ end
60
+ end
61
+
62
+ def _refresh_tagging
63
+ tagging_contexts.each do |context|
64
+ _taggable_context(context).refresh
65
+ end
66
+ end
67
+
68
+ def _mark_dirty(context:, taggable_context:)
69
+ write_attribute("#{context}_list", taggable_context.tags.to_s)
70
+ set_attribute_was("#{context}_list", taggable_context.persisted_tags.to_s)
71
+ attribute_will_change!("#{context}_list")
72
+ end
73
+
74
+ def _taggable_context(context)
75
+ _taggable_contexts[context] ||= TaggableContext.new(
76
+ context: context,
77
+ refresh_persisted_tags: lambda {
78
+ taggings.joins(:tag).where(context: context).pluck(:name)
79
+ },
80
+ on_change: lambda { |tag_context|
81
+ _mark_dirty(context: context, taggable_context: tag_context)
82
+ }
83
+ )
84
+ end
85
+
86
+ def _notify_tag_change(type:, tagging:)
87
+ callbacks_for_type = tagging_callbacks[tagging.context.to_sym].select do |callback|
88
+ callback.type == type
89
+ end
90
+
91
+ callbacks_for_type.each do |callback|
92
+ callback.run(taggable: self, tagging: tagging)
93
+ end
94
+ end
95
+
96
+ def _notify_tag_add(tagging)
97
+ _notify_tag_change(type: :after_add, tagging: tagging)
98
+ end
99
+
100
+ def _notify_tag_remove(tagging)
101
+ _notify_tag_change(type: :after_remove, tagging: tagging)
102
+ end
103
+ end
104
+ # rubocop:enable Metrics/BlockLength
105
+ end
106
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,34 @@
1
+ module EasyTags
2
+ # Tagging model
3
+ class Tagging < ::ActiveRecord::Base
4
+ self.table_name = EasyTags.taggings_table
5
+
6
+ belongs_to :tag, class_name: '::EasyTags::Tag', optional: false, inverse_of: :taggings
7
+ belongs_to :taggable, polymorphic: true, optional: false, inverse_of: :taggings
8
+
9
+ validates_uniqueness_of :tag_id, scope: %i[taggable_type taggable_id context]
10
+
11
+ after_commit :notify_add, on: :create
12
+ after_commit :notify_remove, on: :destroy
13
+
14
+ private
15
+
16
+ def notify_add
17
+ ActiveSupport::Notifications.instrument(
18
+ "easy_tag.tagging_added.#{taggable_type.to_s.tableize}.#{context}",
19
+ tagging: self
20
+ )
21
+
22
+ taggable.send(:_notify_tag_add, self)
23
+ end
24
+
25
+ def notify_remove
26
+ ActiveSupport::Notifications.instrument(
27
+ "easy_tag.tagging_removed.#{taggable_type.to_s.tableize}.#{context}",
28
+ tagging: self
29
+ )
30
+
31
+ taggable.send(:_notify_tag_remove, self)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module EasyTags
2
+ VERSION = '0.1.0-alpha'.freeze
3
+ end
data/lib/easy_tags.rb ADDED
@@ -0,0 +1,74 @@
1
+ require 'active_support/dependencies/autoload'
2
+ require 'active_record'
3
+
4
+ # rubocop:disable Style/Documentation
5
+ module EasyTags
6
+ extend ActiveSupport::Autoload
7
+
8
+ autoload :Tag, 'easy_tags/tag'
9
+ autoload :TaggableMethods, 'easy_tags/taggable_methods'
10
+ autoload :TaggableContextMethods, 'easy_tags/taggable_context_methods'
11
+ autoload :TaggableContext, 'easy_tags/taggable_context'
12
+ autoload :Taggable, 'easy_tags/taggable'
13
+ autoload :Tagging, 'easy_tags/tagging'
14
+ autoload :TagList, 'easy_tags/tag_list'
15
+ autoload :VERSION, 'easy_tags/version'
16
+
17
+ module Parsers
18
+ autoload :Default, 'easy_tags/parsers/default'
19
+ end
20
+
21
+ module Generators
22
+ autoload :Default, 'easy_tags/generators/default'
23
+ end
24
+
25
+ module Options
26
+ autoload :Callback, 'easy_tags/options/callback'
27
+ autoload :Item, 'easy_tags/options/item'
28
+ autoload :Collection, 'easy_tags/options/collection'
29
+ end
30
+ # rubocop:enable Style/Documentation
31
+
32
+ # handle lib configuration options
33
+ #
34
+ # Example
35
+ #
36
+ # EasyTags.setup do |config|
37
+ # config.tags_table = :tags
38
+ # config.taggings_table = :taggings
39
+ # config.parser = EasyTags::Parsers::Default
40
+ # config.generator = EasyTags::Generators::Default
41
+ # end
42
+ #
43
+ class Configuration
44
+ OPTIONS = %i[
45
+ tags_table
46
+ taggings_table
47
+ parser
48
+ generator
49
+ ].freeze
50
+
51
+ attr_accessor(
52
+ *OPTIONS
53
+ )
54
+
55
+ def initialize
56
+ self.tags_table = :tags
57
+ self.taggings_table = :taggings
58
+ self.parser = Parsers::Default
59
+ self.generator = Generators::Default
60
+ end
61
+ end
62
+
63
+ class << self
64
+ def configuration
65
+ @configuration ||= Configuration.new
66
+ end
67
+
68
+ def setup
69
+ yield(configuration)
70
+ end
71
+
72
+ delegate(*Configuration::OPTIONS, to: :configuration)
73
+ end
74
+ end
@@ -0,0 +1,30 @@
1
+ require 'rails/generators/active_record'
2
+
3
+ module EasyTags
4
+ module Generators
5
+ # generates database migration
6
+ #
7
+ # Usage
8
+ # rails g easy_tags:migration
9
+ #
10
+ class MigrationGenerator < Rails::Generators::Base
11
+ include ActiveRecord::Generators::Migration
12
+
13
+ source_root File.expand_path('templates', __dir__)
14
+
15
+ def copy_migrations
16
+ migration_template(
17
+ 'create_tables_migration.rb.erb',
18
+ "#{db_migrate_path}/easy_tags_create_tables.rb",
19
+ migration_version: migration_version
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def migration_version
26
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,17 @@
1
+ class EasyTagsCreateTables < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table <%= ":#{EasyTags.tags_table}" %> do |t|
4
+ t.string :name, index: true
5
+
6
+ t.timestamps null: false
7
+ end
8
+
9
+ create_table <%= ":#{EasyTags.taggings_table}" %> do |t|
10
+ t.references :tag, foreign_key: { to_table: <%= ":#{EasyTags.tags_table}" %> }, null: false, index: true
11
+ t.references :taggable, polymorphic: true, index: true, null: false
12
+ t.string :context, null: false, index: true
13
+
14
+ t.datetime :created_at, null: false
15
+ end
16
+ end
17
+ end