chronicle-core 0.2.1 → 0.3.0

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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rspec.yml +1 -1
  3. data/.gitignore +3 -1
  4. data/.rubocop-plugin.yml +4 -0
  5. data/.rubocop.yml +16 -2
  6. data/Gemfile +2 -2
  7. data/Guardfile +3 -3
  8. data/LICENSE.txt +1 -1
  9. data/README.md +87 -2
  10. data/Rakefile +63 -1
  11. data/bin/console +6 -6
  12. data/chronicle-core.gemspec +32 -26
  13. data/lib/chronicle/core/version.rb +1 -3
  14. data/lib/chronicle/core.rb +1 -3
  15. data/lib/chronicle/models/base.rb +96 -0
  16. data/lib/chronicle/models/builder.rb +35 -0
  17. data/lib/chronicle/models/generation.rb +89 -0
  18. data/lib/chronicle/models/model_factory.rb +63 -0
  19. data/lib/chronicle/models.rb +17 -0
  20. data/lib/chronicle/schema/rdf_parsing/graph_transformer.rb +122 -0
  21. data/lib/chronicle/schema/rdf_parsing/rdf_serializer.rb +138 -0
  22. data/lib/chronicle/schema/rdf_parsing/schemaorg.rb +50 -0
  23. data/lib/chronicle/schema/rdf_parsing/ttl_graph_builder.rb +142 -0
  24. data/lib/chronicle/schema/rdf_parsing.rb +11 -0
  25. data/lib/chronicle/schema/schema_graph.rb +145 -0
  26. data/lib/chronicle/schema/schema_property.rb +81 -0
  27. data/lib/chronicle/schema/schema_type.rb +110 -0
  28. data/lib/chronicle/schema/types.rb +9 -0
  29. data/lib/chronicle/schema/validation/base_contract.rb +22 -0
  30. data/lib/chronicle/schema/validation/contract_factory.rb +133 -0
  31. data/lib/chronicle/schema/validation/edge_validator.rb +53 -0
  32. data/lib/chronicle/schema/validation/generation.rb +29 -0
  33. data/lib/chronicle/schema/validation/validator.rb +23 -0
  34. data/lib/chronicle/schema/validation.rb +41 -0
  35. data/lib/chronicle/schema.rb +9 -2
  36. data/lib/chronicle/serialization/hash_serializer.rb +5 -11
  37. data/lib/chronicle/serialization/jsonapi_serializer.rb +41 -26
  38. data/lib/chronicle/serialization/jsonld_serializer.rb +38 -0
  39. data/lib/chronicle/serialization/record.rb +90 -0
  40. data/lib/chronicle/serialization/serializer.rb +31 -18
  41. data/lib/chronicle/serialization.rb +6 -4
  42. data/lib/chronicle/utils/hash_utils.rb +26 -0
  43. data/schema/chronicle_schema_v1.json +1008 -0
  44. data/schema/chronicle_schema_v1.rb +147 -0
  45. data/schema/chronicle_schema_v1.ttl +562 -0
  46. metadata +108 -16
  47. data/lib/chronicle/schema/activity.rb +0 -5
  48. data/lib/chronicle/schema/base.rb +0 -64
  49. data/lib/chronicle/schema/entity.rb +0 -5
  50. data/lib/chronicle/schema/raw.rb +0 -9
  51. data/lib/chronicle/schema/validator.rb +0 -55
  52. data/lib/chronicle/utils/hash.rb +0 -15
@@ -0,0 +1,110 @@
1
+ module Chronicle
2
+ module Schema
3
+ # Represents a type in the RDF graph
4
+ #
5
+ # TODO: rename `class` to `type` to match new class name
6
+ class SchemaType
7
+ attr_reader :id
8
+ attr_accessor :properties,
9
+ :subtype_ids,
10
+ :comment,
11
+ :namespace,
12
+ :subtypes,
13
+ :supertypes,
14
+ :see_also
15
+
16
+ def initialize(id)
17
+ @id = id
18
+ @subtype_ids = []
19
+ @properties = []
20
+
21
+ yield self if block_given?
22
+ end
23
+
24
+ def inspect
25
+ "#<SchemaType:#{id}>"
26
+ end
27
+
28
+ def pretty_print(pp)
29
+ pp.text("SchemaType: #{id}")
30
+ pp.nest(2) do
31
+ pp.breakable
32
+ pp.text("subtypeIds: #{subtype_ids.map(&:id)}")
33
+ pp.breakable
34
+ pp.text("Comment: #{comment}")
35
+ pp.breakable
36
+ pp.text("Properties: #{properties.map(&:id)}")
37
+ end
38
+ end
39
+
40
+ def short_id
41
+ id.gsub(@namespace, '')
42
+ end
43
+
44
+ # FIXME
45
+ def identifier
46
+ short_id.to_sym
47
+ end
48
+
49
+ def to_h
50
+ output = {
51
+ id:,
52
+ subtype_ids:
53
+ }
54
+ output[:see_also] = @see_also if @see_also
55
+ output[:comment] = @comment if @comment
56
+ output
57
+ end
58
+
59
+ def ==(other)
60
+ id == other.id
61
+ end
62
+
63
+ def add_subtype_id(subtype_id)
64
+ @subtype_ids << subtype_id unless @subtype_ids.include?(subtype_id)
65
+ end
66
+
67
+ def ancestors
68
+ @ancestors ||= begin
69
+ ancestors = []
70
+
71
+ queue = supertypes.dup
72
+ until queue.empty?
73
+ current = queue.shift
74
+ ancestors << current
75
+ queue.concat(current.supertypes)
76
+ end
77
+ ancestors
78
+ end
79
+ end
80
+
81
+ def descendants
82
+ @descendants ||= begin
83
+ descendants = []
84
+
85
+ queue = subtypes.dup
86
+ until queue.empty?
87
+ current = queue.shift
88
+ descendants << current
89
+ queue.concat(current.subtypes)
90
+ end
91
+ descendants
92
+ end
93
+ end
94
+
95
+ def all_properties
96
+ @all_properties ||= begin
97
+ properties = @properties.dup
98
+ ancestors.each do |ancestor|
99
+ properties.concat(ancestor.properties)
100
+ end
101
+ properties
102
+ end
103
+ end
104
+
105
+ def add_property(property)
106
+ @properties << property unless @properties.include?(property)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,9 @@
1
+ require 'dry-types'
2
+
3
+ module Chronicle
4
+ module Schema
5
+ module Types
6
+ include Dry.Types()
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ require 'dry/validation'
2
+
3
+ module Chronicle
4
+ module Schema
5
+ module Validation
6
+ class BaseContract < Dry::Validation::Contract
7
+ option :edge_validator, default: -> { Chronicle::Schema::Validation::EdgeValidator.new }
8
+
9
+ # I think this doesn't work for nested objects because
10
+ # we don't enumerate all the properties and rely on the edgevalidator
11
+ # to do the validation. Commenting out for now.
12
+ # config.validate_keys = true
13
+
14
+ attr_accessor :type_name
15
+
16
+ def self.type(type_name)
17
+ @type_name = type_name
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,133 @@
1
+ module Chronicle
2
+ module Schema
3
+ module Validation
4
+ class ContractFactory
5
+ def self.process_errors(errors)
6
+ messages = []
7
+ errors.each do |key, value|
8
+ base_path = key == :base ? [] : [key]
9
+ if value.is_a?(Hash)
10
+ value.each do |k, v|
11
+ messages << [base_path + [k], v.first.to_s]
12
+ end
13
+ elsif value.is_a?(Array)
14
+ messages << [base_path, value.first.to_s]
15
+ else
16
+ messages << [base_path, value.to_s]
17
+ end
18
+ end
19
+ messages
20
+ end
21
+
22
+ def self.create(type_id:, properties: [])
23
+ Class.new(Chronicle::Schema::Validation::BaseContract) do
24
+ type type_id
25
+
26
+ params(Chronicle::Schema::Validation::ContractFactory.create_schema(type_id:, properties:))
27
+
28
+ properties.each do |property|
29
+ edge_name = property.id_snakecase
30
+
31
+ if property.many?
32
+ rule(edge_name).each do |index:|
33
+ errors = edge_validator.validate(type_id, edge_name, value)
34
+
35
+ error_path = [edge_name, index]
36
+ messages = Chronicle::Schema::Validation::ContractFactory.process_errors(errors)
37
+ messages.each do |path, message|
38
+ key(error_path + path).failure(message)
39
+ end
40
+ end
41
+ else
42
+ rule(edge_name) do
43
+ # handle nils. FIXME: this is a hack
44
+ next unless value
45
+
46
+ errors = edge_validator.validate(type_id, edge_name, value)
47
+ error_path = [edge_name]
48
+ messages = Chronicle::Schema::Validation::ContractFactory.process_errors(errors)
49
+ messages.each do |path, message|
50
+ key(error_path + path).failure(message)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ def self.create_schema(type_id:, properties: [])
59
+ Dry::Schema.JSON do
60
+ required(:@type).value(:str?).filled(eql?: type_id.to_s)
61
+
62
+ before(:key_coercer) do |result|
63
+ result.to_h.transform_keys!(&:to_sym)
64
+
65
+ valid_property_ids = properties.map(&:id_snakecase)
66
+ valid_property_ids << :@type
67
+ invalid_properties = result.to_h.keys - valid_property_ids
68
+
69
+ invalid_properties.each do |invalid_property|
70
+ result.add_error([:unexpected_key, [invalid_property.to_sym, []]])
71
+ end
72
+ end
73
+
74
+ # Attempt to coerce chronicle edges recursively
75
+ # I tried to use a custom type in the schema and use the constructor
76
+ # method to do the coercion but it didn't work consistently
77
+ before(:value_coercer) do |obj|
78
+ obj.to_h.transform_values do |value|
79
+ case value
80
+ when ::Array
81
+ value.map do |v|
82
+ Chronicle::Schema::Validation::ContractFactory.coerce_chronicle_edge(v)
83
+ end
84
+ when ::Hash
85
+ Chronicle::Schema::Validation::ContractFactory.coerce_chronicle_edge(value)
86
+ else
87
+ value
88
+ end
89
+ end
90
+ end
91
+
92
+ properties.each do |property|
93
+ property_name = property.id_snakecase
94
+ type = Chronicle::Schema::Validation::ContractFactory.build_type(property.range_identifiers)
95
+
96
+ outer_macro = property.required? ? :required : :optional
97
+
98
+ if property.many?
99
+ send(outer_macro, property_name).value(:array).each(type)
100
+ else
101
+ send(outer_macro, property_name).value(type)
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ def self.coerce_chronicle_edge(value)
108
+ return value unless value.is_a?(Hash)
109
+
110
+ type = (value[:@type] || value['@type']).to_sym
111
+ contract_klass = Chronicle::Schema::Validation.get_contract(type)
112
+ return value unless contract_klass
113
+
114
+ result = contract_klass.schema.call(value)
115
+ result.success? ? result.to_h : value
116
+ end
117
+
118
+ def self.build_type(range)
119
+ literals = []
120
+ literals << :integer if range.include?(:Integer)
121
+ literals << :float if range.include?(:Float)
122
+ literals << :string if range.include?(:Text)
123
+ literals << :string if range.include?(:URL)
124
+ literals << :time if range.include?(:DateTime)
125
+
126
+ literals << :hash
127
+ # puts "building type for #{range}. literals: #{literals}"
128
+ literals.uniq
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,53 @@
1
+ module Chronicle
2
+ module Schema
3
+ module Validation
4
+ class EdgeValidator
5
+ def validate(type, edge, value)
6
+ errors = {}
7
+ return errors unless value.is_a?(Hash)
8
+
9
+ value_type = (value[:@type] || value['@type']).to_sym
10
+
11
+ property = fetch_property(type, edge)
12
+ unless property
13
+ errors[:base] = 'not a valid edge'
14
+ return errors
15
+ end
16
+
17
+ complete_range = fetch_complete_range(property)
18
+
19
+ unless complete_range.include?(value_type)
20
+ errors[:base] =
21
+ "#{value_type} is not a valid type for #{edge}. Valid types are #{complete_range.join(', ')}"
22
+ return errors
23
+ end
24
+
25
+ contract_klass = Chronicle::Schema::Validation.get_contract(value_type)
26
+ unless contract_klass
27
+ errors[:base] = "no contract found for #{value_type}"
28
+ return errors
29
+ end
30
+
31
+ result = contract_klass.new.call(value)
32
+
33
+ result.errors.to_h
34
+ end
35
+
36
+ private
37
+
38
+ def fetch_property(type, edge)
39
+ klass = Chronicle::Schema::Validation::Generation.graph.find_type(type)
40
+ klass&.all_properties&.find { |p| edge == p.id_snakecase }
41
+ end
42
+
43
+ def fetch_complete_range(property)
44
+ property.range.each_with_object([]) do |range, memo|
45
+ range_klass = Chronicle::Schema::Validation::Generation.graph.find_type_by_id(range)
46
+ memo << range_klass.short_id
47
+ memo.concat(range_klass.descendants.map(&:short_id))
48
+ end.uniq.map(&:to_sym)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,29 @@
1
+ module Chronicle
2
+ module Schema
3
+ module Validation
4
+ module Generation
5
+ @contracts_generated = false
6
+
7
+ def self.generate_contracts(graph)
8
+ return if @contracts_generated
9
+
10
+ @graph = graph
11
+
12
+ graph.types.each do |klass|
13
+ type_id = klass.short_id.to_sym
14
+ type_contract_class = Chronicle::Schema::Validation::ContractFactory.create(type_id:,
15
+ properties: klass.all_properties)
16
+
17
+ Chronicle::Schema::Validation.set_contract(type_id, type_contract_class)
18
+ end
19
+
20
+ @contracts_generated = true
21
+ end
22
+
23
+ def self.graph
24
+ @graph
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ module Chronicle
2
+ module Schema
3
+ module Validation
4
+ class Validator
5
+ def validate(data)
6
+ type = data[:@type] || data['@type']
7
+ raise Chronicle::Schema::ValidationError, 'Data does not contain a typed object' unless type
8
+
9
+ contract = Chronicle::Schema::Validation.get_contract(type.to_sym)
10
+
11
+ # binding.pry unless contract
12
+ raise Chronicle::Schema::ValidationError, "#{type} is not a valid type" unless contract
13
+
14
+ contract.new.call(data)
15
+ end
16
+
17
+ def self.call(data)
18
+ new.validate(data)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,41 @@
1
+ module Chronicle
2
+ module Schema
3
+ module Validation
4
+ # FIXME:
5
+ # - refactor all of this
6
+ # - handle different serialization flavours
7
+ # - move to model of memoizing individual contracts and generating them on demand
8
+ @contracts = {}
9
+ @graph = nil
10
+
11
+ class << self
12
+ attr_accessor :graph, :contracts
13
+ end
14
+
15
+ def self.unload_contracts
16
+ @contracts = {}
17
+ end
18
+
19
+ def self.set_contract(name, contract)
20
+ @contracts[name] = contract
21
+ end
22
+
23
+ def self.get_contract(name)
24
+ # FIXME:
25
+ # Chronicle::Schema::Validation::Generation.generate_contracts
26
+
27
+ @contracts[name]
28
+ end
29
+
30
+ def self.contracts_generated?
31
+ !@contracts_generated.nil?
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ require_relative 'validation/generation'
38
+ require_relative 'validation/validator'
39
+ require_relative 'validation/edge_validator'
40
+ require_relative 'validation/base_contract'
41
+ require_relative 'validation/contract_factory'
@@ -1,7 +1,14 @@
1
1
  module Chronicle
2
2
  module Schema
3
+ class Error < StandardError; end
4
+ class ValidationError < Error; end
3
5
  end
4
6
  end
5
7
 
6
- require_relative "schema/base"
7
- require_relative "schema/validator"
8
+ # require_relative "schema/validator"
9
+
10
+ require_relative 'schema/types'
11
+ require_relative 'schema/validation'
12
+ require_relative 'schema/schema_property'
13
+ require_relative 'schema/schema_type'
14
+ require_relative 'schema/schema_graph'
@@ -1,14 +1,8 @@
1
- module Chronicle::Serialization
2
- class HashSerializer < Chronicle::Serialization::Serializer
3
- def serializable_hash
4
- @record.properties.transform_values do |v|
5
- if v.is_a?(Array)
6
- v.map{|record| HashSerializer.new(record).serializable_hash}
7
- elsif v.is_a?(Chronicle::Schema::Base)
8
- HashSerializer.new(v).serializable_hash
9
- else
10
- v
11
- end
1
+ module Chronicle
2
+ module Serialization
3
+ class HashSerializer < Chronicle::Serialization::Serializer
4
+ def serializable_hash
5
+ @record.to_h
12
6
  end
13
7
  end
14
8
  end
@@ -1,34 +1,49 @@
1
- module Chronicle::Serialization
2
- class JSONAPISerializer < Chronicle::Serialization::Serializer
3
- def serializable_hash
4
- identifier_hash.merge({
5
- attributes: attribute_hash,
6
- relationships: associations_hash,
7
- meta: @record.meta
8
- })
9
- end
1
+ module Chronicle
2
+ module Serialization
3
+ class JSONAPISerializer < Chronicle::Serialization::Serializer
4
+ def serializable_hash
5
+ { data: serialize_record(@record) }
6
+ end
10
7
 
11
- private
8
+ private
12
9
 
13
- def identifier_hash
14
- {
15
- type: @record.class::TYPE,
16
- id: @record.id
17
- }.compact
18
- end
10
+ def serialize_record(record)
11
+ identifier_hash(record).merge({
12
+ attributes: attribute_hash(record),
13
+ relationships: associations_hash(record),
14
+ meta: record.meta
15
+ })
16
+ end
19
17
 
20
- def attribute_hash
21
- @record.attributes.compact
22
- end
18
+ def identifier_hash(record)
19
+ {
20
+ type: record.type.to_s,
21
+ id: record.id
22
+ }.compact
23
+ end
24
+
25
+ def attribute_hash(record)
26
+ record.attribute_properties.compact.transform_values do |v|
27
+ if v.is_a?(Chronicle::Serialization::Record)
28
+ serialize_record(v)
29
+ else
30
+ v
31
+ end
32
+ end
33
+ end
23
34
 
24
- def associations_hash
25
- @record.associations.map do |k, v|
26
- if v.is_a?(Array)
27
- [k, { data: v.map{|record| JSONAPISerializer.new(record).serializable_hash} }]
28
- else
29
- [k, { data: JSONAPISerializer.new(v).serializable_hash }]
35
+ def associations_hash(record)
36
+ record.association_properties.compact.to_h do |k, v|
37
+ if v.is_a?(Array)
38
+ [k, { data: v.map { |record| serialize_record(record) } }]
39
+ elsif v.is_a?(Chronicle::Serialization::Record)
40
+ [k, { data: serialize_record(v) }]
41
+ else
42
+ # [k, { data: v }]
43
+ [k, v]
44
+ end
30
45
  end
31
- end.to_h
46
+ end
32
47
  end
33
48
  end
34
49
  end
@@ -0,0 +1,38 @@
1
+ module Chronicle
2
+ module Serialization
3
+ class JSONLDSerializer < Chronicle::Serialization::Serializer
4
+ DEFAULT_CONTEXT = 'https://schema.chronicle.app/'.freeze
5
+
6
+ def serializable_hash
7
+ {
8
+ '@context': DEFAULT_CONTEXT
9
+ }.merge(serialize_record(@record))
10
+ end
11
+
12
+ private
13
+
14
+ def serialize_record(record)
15
+ properties = record.properties.to_h.compact.transform_values do |value|
16
+ if value.is_a?(Array)
17
+ value.map { |v| serialize_value(v) }
18
+ else
19
+ serialize_value(value)
20
+ end
21
+ end
22
+
23
+ {
24
+ '@type': record.type.to_s,
25
+ '@id': record.id
26
+ }.merge(properties).compact
27
+ end
28
+
29
+ def serialize_value(value)
30
+ if value.is_a?(Chronicle::Serialization::Record)
31
+ serialize_record(value)
32
+ else
33
+ value
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,90 @@
1
+ module Chronicle
2
+ module Serialization
3
+ class Record
4
+ attr_reader :properties,
5
+ :type,
6
+ :id,
7
+ :meta,
8
+ :schema
9
+
10
+ def initialize(properties: {}, type: nil, id: nil, meta: {}, schema: nil)
11
+ @properties = properties
12
+ @type = type
13
+ @id = id
14
+ @meta = meta
15
+ @schema = schema
16
+ end
17
+
18
+ def self.build_from_model(model)
19
+ raise ArgumentError, 'model must be a Chronicle::Models::Base' unless model.is_a?(Chronicle::Models::Base)
20
+
21
+ properties = model.properties.transform_values do |v|
22
+ if v.is_a?(Array)
23
+ v.map do |record|
24
+ if record.is_a?(Chronicle::Models::Base)
25
+ Record.build_from_model(record)
26
+ else
27
+ record
28
+ end
29
+ end
30
+ elsif v.is_a?(Chronicle::Models::Base)
31
+ Record.build_from_model(v)
32
+ else
33
+ v
34
+ end
35
+ end.compact
36
+
37
+ new(
38
+ properties:,
39
+ type: model.type_id,
40
+ id: model.id,
41
+ meta: model.meta,
42
+ schema: :chronicle
43
+ )
44
+ end
45
+
46
+ def association_properties
47
+ # select properties whose values are a record, or are an array that contain a record
48
+ properties.select do |_k, v|
49
+ if v.is_a?(Array)
50
+ v.any? { |record| record.is_a?(Chronicle::Serialization::Record) }
51
+ else
52
+ v.is_a?(Chronicle::Serialization::Record)
53
+ end
54
+ end
55
+ end
56
+
57
+ def attribute_properties
58
+ properties.except(*association_properties.keys)
59
+ end
60
+
61
+ def to_h
62
+ properties.transform_values do |v|
63
+ if v.is_a?(Array)
64
+ v.map do |item|
65
+ value_to_h(item)
66
+ end
67
+ else
68
+ value_to_h(v)
69
+ end
70
+ end.merge({ type:, id: }).compact
71
+ end
72
+
73
+ def value_to_h(v)
74
+ if v.is_a?(Array)
75
+ v.map do |item|
76
+ if item.is_a?(Chronicle::Serialization::Record)
77
+ item.to_h
78
+ else
79
+ item
80
+ end
81
+ end
82
+ elsif v.is_a?(Chronicle::Serialization::Record)
83
+ v.to_h
84
+ else
85
+ v
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end