chronicle-core 0.2.1 → 0.3.0

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