pennycress 0.0.1

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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "registry"
5
+
6
+ module Pennycress
7
+ # This module integrates Pennycress with a Rails application.
8
+ module Integration
9
+ # This module is a concern included on watched models that adds a
10
+ # post-commit hook to trigger invalidation.
11
+ module ModelCommitHandler
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ after_commit do
16
+ Integration.handle_commit(self)
17
+ end
18
+ end
19
+ end
20
+
21
+ class << self
22
+ # Respond to a commit on a watched model
23
+ #
24
+ # @param record [ActiveRecord::Base] a committed model instance
25
+ # @return [void]
26
+ def handle_commit(record)
27
+ action = commit_action(record)
28
+
29
+ model_handlers[record.class].each do |handler|
30
+ handler.call(record, action)
31
+ end
32
+ end
33
+
34
+ # Search for value files in the given directories
35
+ #
36
+ # The directories should contain Value definitions. When loaded, these
37
+ # definitions will be added to the value registry.
38
+ #
39
+ # @param directories [Array<String>] absolute directory paths
40
+ # @return [void]
41
+ # @raise [ArgumentError] if a path is not a directory
42
+ def load_values(directories)
43
+ directories.each do |directory|
44
+ unless File.directory?(directory)
45
+ raise ArgumentError, "path is not a directory: #{directory}"
46
+ end
47
+
48
+ Dir
49
+ .glob(File.join(directory, "**", "*.rb"))
50
+ .sort
51
+ .each { |file| load file }
52
+ end
53
+ end
54
+
55
+ # Add callbacks to all watched models
56
+ #
57
+ # @return [void]
58
+ def watch_models
59
+ model_handlers.clear
60
+
61
+ Registry.current.values.each do |value_class|
62
+ value_class.config.watches.each do |watch|
63
+ model_handlers[watch.model_class] << lambda do |record, action|
64
+ if watch.on.include?(action)
65
+ value_class.invalidate_model(watch, record)
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ model_handlers.each_key do |model|
72
+ unless model.include?(ModelCommitHandler)
73
+ model.include(ModelCommitHandler)
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ # @param record [ActiveRecord::Base] a committed model instance
81
+ # @return [Symbol] the commit action that triggered the callback
82
+ def commit_action(record)
83
+ if record.destroyed?
84
+ :destroy
85
+ elsif record.previously_new_record?
86
+ :create
87
+ else
88
+ :update
89
+ end
90
+ end
91
+
92
+ # @return [Hash{Class => Array<Proc>}] a mapping of model classes to invalidation handlers
93
+ def model_handlers
94
+ @model_handlers ||= Hash.new do |hash, key|
95
+ hash[key] = []
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Pennycress
6
+ # A model reference is a description of a model that can use either a model
7
+ # instance or its primary key.
8
+ class ModelReference
9
+ # @return [Object] the model's scalar or composite primary key
10
+ attr_reader :id
11
+
12
+ # @return [ActiveRecord::Base] the referenced ActiveRecord model class
13
+ attr_reader :model_class
14
+
15
+ class << self
16
+ # @param model_class [ActiveRecord::Base] a model class
17
+ # @param value [Object] a model instance or primary key value
18
+ # @param label [String] the input label used in error messages
19
+ # @return [ModelReference]
20
+ # @raise [ValidationError] if the value is invalid
21
+ def from(model_class, value, label:)
22
+ result =
23
+ if value.is_a?(model_class)
24
+ from_instance(model_class, value)
25
+ elsif model_class.composite_primary_key?
26
+ from_composite_key(model_class, value)
27
+ else
28
+ from_scalar_key(model_class, value)
29
+ end
30
+
31
+ raise ValidationError, "#{label} #{result}" if result.is_a?(String)
32
+
33
+ result
34
+ end
35
+
36
+ private
37
+
38
+ # @param model_class [ActiveRecord::Base]
39
+ # @param value [Object]
40
+ # @return [ModelReference, String]
41
+ def from_composite_key(model_class, value)
42
+ length = model_class.primary_key.length
43
+
44
+ unless value.is_a?(Array)
45
+ return "must be a #{model_class.name} or an array of #{length} key values"
46
+ end
47
+
48
+ if value.length != length
49
+ "must have #{length} primary key values, got #{value.length}"
50
+ elsif value.any? { |item| !scalar_key?(item) }
51
+ "primary keys must use scalar values"
52
+ else
53
+ new(id: value, model_class: model_class)
54
+ end
55
+ end
56
+
57
+ # @param model_class [ActiveRecord::Base]
58
+ # @param instance [ActiveRecord::Base]
59
+ # @return [ModelReference, String]
60
+ def from_instance(model_class, instance)
61
+ id =
62
+ begin
63
+ instance.id
64
+ rescue NoMethodError
65
+ nil
66
+ end
67
+
68
+ if id.nil? || (instance.respond_to?(:new_record?) && instance.new_record?)
69
+ return "must be persisted"
70
+ end
71
+
72
+ new(id: id, model_class: model_class, record: instance)
73
+ end
74
+
75
+ # @param model_class [ActiveRecord::Base]
76
+ # @param value [Object]
77
+ # @return [ModelReference, String]
78
+ def from_scalar_key(model_class, value)
79
+ if scalar_key?(value)
80
+ new(id: value, model_class: model_class)
81
+ else
82
+ "must be a #{model_class.name} or a primary key value: #{value.inspect}"
83
+ end
84
+ end
85
+
86
+ # @param value [Object]
87
+ # @return [Boolean]
88
+ def scalar_key?(value)
89
+ !value.nil? && !value.is_a?(Array) && !value.is_a?(Hash)
90
+ end
91
+ end
92
+
93
+ # Creates a model reference
94
+ #
95
+ # @param id [Object] the model's scalar or composite primary key
96
+ # @param model_class [Class] the referenced ActiveRecord model class
97
+ # @param record [ActiveRecord::Base, nil] a loaded record, when one is available
98
+ def initialize(id:, model_class:, record: nil)
99
+ @id = id
100
+ @model_class = model_class
101
+ @record = record
102
+ end
103
+
104
+ # @return [Array<String>] cache key segments for this reference's ID
105
+ def cache_key
106
+ Array(id).map(&:to_s)
107
+ end
108
+
109
+ # @return [ActiveRecord::Base] the referenced model instance
110
+ def record
111
+ @record ||= model_class.find(id)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ require_relative "errors"
7
+
8
+ module Pennycress
9
+ # This module contains helpers for working with references to ActiveRecord
10
+ # models that follow Rails naming conventions like `:uploaded_file`.
11
+ module Models
12
+ # Resolves a model ID to a class
13
+ #
14
+ # @param id [Symbol] a model ID (e.g., `:uploaded_file`)
15
+ # @return [ActiveRecord::Base] the model class
16
+ # @raise [ValidationError] if the model ID cannot be resolved
17
+ # @raise [ValidationError] if the resolved class is not a model
18
+ def self.resolve(id)
19
+ begin
20
+ model = id.to_s.classify.constantize
21
+ rescue NameError => e
22
+ raise ValidationError, "unknown model #{id.inspect}", cause: e
23
+ end
24
+
25
+ unless model < ActiveRecord::Base
26
+ raise ValidationError, "#{model.name} is not an ActiveRecord model"
27
+ end
28
+
29
+ model
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema"
4
+
5
+ module Pennycress
6
+ # An output describes the type or shape of a value's computed result.
7
+ class Output
8
+ # Creates a description of a value's output
9
+ #
10
+ # @param schema [Class, Hash{Symbol => Class}] a scalar type or shape hash
11
+ # @raise [ArgumentError] if the schema is invalid
12
+ def initialize(schema)
13
+ if schema.is_a?(Class)
14
+ @type = schema
15
+ elsif schema.is_a?(Hash)
16
+ @shape = schema
17
+ else
18
+ raise ArgumentError, "output must be a type or a shape hash"
19
+ end
20
+ end
21
+
22
+ # Validates a computed result against the output's schema
23
+ #
24
+ # @param value [Object] a computed result
25
+ # @return [Object] a value that conforms to the schema
26
+ # @raise [ValidationError] if the value is invalid
27
+ def validate(value)
28
+ if @type
29
+ Schema.validate_type(@type, value)
30
+ else
31
+ Schema.validate_shape(@shape, value)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "caching"
4
+ require_relative "model_reference"
5
+
6
+ module Pennycress
7
+ # An output reference identifies a cached output for a computed value produced
8
+ # from a single input.
9
+ class OutputReference
10
+ # Creates an output reference
11
+ #
12
+ # @param input [Hash] a validated input
13
+ # @param namespace [String, nil] a namespace that contains the value's outputs
14
+ def initialize(input:, namespace: nil)
15
+ @raw_input = input
16
+ @namespace = namespace
17
+ end
18
+
19
+ # @return [String] the cache key for this reference
20
+ def cache_key
21
+ @cache_key ||= Caching.key(@namespace, *input_key_segments)
22
+ end
23
+
24
+ # @return [Hash] the input in a form suitable for computing an output value
25
+ def input
26
+ @input ||= @raw_input.transform_values do |value|
27
+ value.is_a?(ModelReference) ? value.record : value
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # @return [Array<String>] segments in the cache key for the input
34
+ def input_key_segments
35
+ @raw_input.keys.sort.flat_map do |key|
36
+ value = @raw_input[key]
37
+ next [] unless value
38
+
39
+ value.cache_key.map(&:downcase)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require_relative "configuration"
5
+ require_relative "integration"
6
+ require_relative "registry"
7
+
8
+ module Pennycress
9
+ # This Railtie integrates Pennycress with Rails.
10
+ class Railtie < ::Rails::Railtie
11
+ DEFAULT_DIRECTORIES = ["app/values"].freeze
12
+ private_constant :DEFAULT_DIRECTORIES
13
+
14
+ config.to_prepare do
15
+ Registry.current.reset
16
+ Integration.load_values(Configuration.current.directories)
17
+ Integration.watch_models
18
+ end
19
+
20
+ initializer "pennycress.setup", before: :load_config_initializers do
21
+ Configuration.current.cache = Rails.cache
22
+ end
23
+
24
+ initializer "pennycress.expand_directories", after: :load_config_initializers do
25
+ configuration = Configuration.current
26
+
27
+ configuration.directories = Configuration.expand_paths(
28
+ Rails.root,
29
+ configuration.directories.empty? ? DEFAULT_DIRECTORIES : configuration.directories
30
+ )
31
+ end
32
+
33
+ rake_tasks do
34
+ load File.expand_path("../tasks/pennycress.rake", __dir__)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennycress
4
+ # A registry tracks value classes registered at load time.
5
+ class Registry
6
+ class << self
7
+ # @return [Registry] the current registry
8
+ def current
9
+ @current ||= new
10
+ end
11
+
12
+ # Runs a block with a temporary registry
13
+ #
14
+ # @param registry [Registry] the registry to use
15
+ # @yield run a block in which the given registry is the current one
16
+ # @return [Object] the block's return value
17
+ def override(registry)
18
+ previous = @current
19
+ @current = registry
20
+
21
+ yield
22
+ ensure
23
+ @current = previous
24
+ end
25
+ end
26
+
27
+ # Creates a registry
28
+ def initialize
29
+ @values = []
30
+ end
31
+
32
+ # @param value [Value] a value class
33
+ # @return [void]
34
+ def register(value)
35
+ @values << value
36
+ end
37
+
38
+ # Clears registered value classes
39
+ #
40
+ # @return [void]
41
+ def reset
42
+ @values = []
43
+ end
44
+
45
+ # @return [Array<Value>] registered value classes
46
+ def values
47
+ @values
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Pennycress
6
+ # This module provides a collection of helpers for validating user-provided
7
+ # values against type and shape constraints.
8
+ module Schema
9
+ class << self
10
+ # Ensures that a value conforms to a given shape
11
+ #
12
+ # When a block is given, it is called for each shaped field with the key
13
+ # and value. The block may return a normalized value or raise
14
+ # {ValidationError} for an invalid field.
15
+ #
16
+ # @param shape [Hash{Symbol => Class}] the shape to validate against
17
+ # @param value [Object] the value to validate
18
+ # @yieldparam key [Symbol] a shaped field key
19
+ # @yieldparam item [Object] the field value
20
+ # @yieldreturn [Object] a normalized field value
21
+ # @return [Hash] the input value, or normalized values when a block is given
22
+ # @raise [ValidationError] if the value is invalid
23
+ def validate_shape(shape, value)
24
+ unless value.is_a?(Hash)
25
+ raise ValidationError, "value is not a hash: #{value.inspect}"
26
+ end
27
+
28
+ errors = shape.keys.filter_map do |key|
29
+ "missing key: #{key}" unless value.key?(key)
30
+ end
31
+
32
+ normalized = {}
33
+
34
+ errors += value.filter_map do |key, item|
35
+ if shape.key?(key)
36
+ if block_given?
37
+ begin
38
+ normalized[key] = yield(key, item)
39
+ nil
40
+ rescue ValidationError => e
41
+ e.message
42
+ end
43
+ else
44
+ check_type(shape[key], item, label: key.to_s)
45
+ end
46
+ else
47
+ "unknown key: #{key}"
48
+ end
49
+ end
50
+
51
+ unless errors.empty?
52
+ raise ValidationError, errors.sort.join("\n")
53
+ end
54
+
55
+ block_given? ? normalized : value
56
+ end
57
+
58
+ # Ensures that a value is an instance of a type
59
+ #
60
+ # @param type [Class] the type to validate against
61
+ # @param value [Object] the value to validate
62
+ # @param label [String] a custom label for the value
63
+ # @return [Object] an instance of the type
64
+ # @raise [ValidationError] if the value is invalid
65
+ def validate_type(type, value, label: "value")
66
+ error = check_type(type, value, label: label)
67
+
68
+ raise ValidationError, error if error
69
+
70
+ value
71
+ end
72
+
73
+ private
74
+
75
+ # Produces an error message if a value is not a given type
76
+ #
77
+ # @param type [Class] the type to validate against
78
+ # @param value [Object] the value to validate
79
+ # @param label [String] a custom label for the value
80
+ # @return [String, nil] an error message if the value is not a given type
81
+ def check_type(type, value, label:)
82
+ return if value.is_a?(type)
83
+
84
+ "#{label} is not an instance of #{type.name}: #{value.inspect}"
85
+ end
86
+ end
87
+ end
88
+ end