easy_tags 0.1.0.pre.alpha

Sign up to get free protection for your applications and to get access to all the features.
@@ -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