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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +333 -0
- data/lib/pennycress/cache.rb +80 -0
- data/lib/pennycress/caching.rb +18 -0
- data/lib/pennycress/configuration.rb +98 -0
- data/lib/pennycress/constraints.rb +15 -0
- data/lib/pennycress/errors.rb +6 -0
- data/lib/pennycress/input.rb +44 -0
- data/lib/pennycress/integration.rb +100 -0
- data/lib/pennycress/model_reference.rb +114 -0
- data/lib/pennycress/models.rb +32 -0
- data/lib/pennycress/output.rb +35 -0
- data/lib/pennycress/output_reference.rb +43 -0
- data/lib/pennycress/railtie.rb +37 -0
- data/lib/pennycress/registry.rb +50 -0
- data/lib/pennycress/schema.rb +88 -0
- data/lib/pennycress/value.rb +265 -0
- data/lib/pennycress/value_config.rb +49 -0
- data/lib/pennycress/version.rb +5 -0
- data/lib/pennycress/warm_seed_job.rb +19 -0
- data/lib/pennycress/warming.rb +30 -0
- data/lib/pennycress/watched_model.rb +68 -0
- data/lib/pennycress.rb +30 -0
- data/lib/tasks/pennycress.rake +8 -0
- metadata +77 -0
|
@@ -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
|