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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f8a25629577c83f71a66b2451f2858cad759e9ace5547e8f9add4eb11be8771
4
- data.tar.gz: 978d36a75ebdb775b86698afd5f8f7659dc64b9d993b2046f45aaf979ca52549
3
+ metadata.gz: 3820f9aac588a93691f2c5d82b7f84ee4f5786dc5652baf301ce0dd5df7810aa
4
+ data.tar.gz: 7f57cb66da0f22510ba66c2ea5783428a6c8c2135ea9c311ddb9e8a1ee896f2f
5
5
  SHA512:
6
- metadata.gz: 6aff817c68c5d0795389456eafe32aa791f0dd45a3998bb8101a443dc7681b5aaaa719a0de4694c4af001eec98b66b81fffc24f4ed874d089e1951a1ae3d42b6
7
- data.tar.gz: 362d898e479fe85e629abf911e034d4c56236ef95e21b1000f2c839a12fc8d87b028f765bba661856c65bd77020e8f557ee29780df071a644829e26ea2e3d080
6
+ metadata.gz: 8bdf2808fd3a81d4a911825c4b8823bf9bff506a1203d9e282851bcb88058a9440821e762225c322aae3decf2c8490ecd566ffe7d6bcef21e7981da828339dc0
7
+ data.tar.gz: c22405deb6ceb94be72df6f40b32eb2f7dd950887412461f77f86506486dd7886a037f3ae3baba733db69be1bf104d7aec3a526592ae3ec5ad7955b7e129885b
@@ -13,7 +13,7 @@ jobs:
13
13
 
14
14
  strategy:
15
15
  matrix:
16
- ruby_version: ['2.7', '3.0']
16
+ ruby_version: ['3.1']
17
17
  fail-fast: false
18
18
 
19
19
  steps:
data/.gitignore CHANGED
@@ -8,4 +8,6 @@
8
8
  /tmp/
9
9
 
10
10
  Gemfile.lock
11
- .DS_Store
11
+ .DS_Store
12
+
13
+ .ruby-version
@@ -0,0 +1,4 @@
1
+ inherit_from: ./.rubocop.yml
2
+
3
+ Style/Documentation:
4
+ Enabled: false
data/.rubocop.yml CHANGED
@@ -1,5 +1,19 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 3.1
4
+ SuggestExtensions: false
5
+
1
6
  Style/FrozenStringLiteralComment:
2
7
  Enabled: false
3
8
 
4
- StringLiterals:
5
- Enabled: false
9
+ Metrics:
10
+ Enabled: false
11
+
12
+ Layout/ArgumentAlignment:
13
+ EnforcedStyle: with_fixed_indentation
14
+
15
+ Layout/MultilineMethodCallIndentation:
16
+ EnforcedStyle: indented
17
+
18
+ Gemspec/DevelopmentDependencies:
19
+ EnforcedStyle: gemspec
data/Gemfile CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- source "https://rubygems.org"
3
+ source 'https://rubygems.org'
4
4
 
5
5
  # Specify your gem's dependencies in chronicle-core.gemspec
6
6
  gemspec
7
7
 
8
- gem "rake", "~> 13.0"
8
+ gem 'rake', '~> 13.0'
data/Guardfile CHANGED
@@ -1,7 +1,7 @@
1
- guard :rspec, cmd: "bundle exec rspec" do
2
- require "guard/rspec/dsl"
1
+ guard :rspec, cmd: 'bundle exec rspec' do
2
+ require 'guard/rspec/dsl'
3
3
 
4
4
  watch(%r{^spec/.+_spec\.rb$})
5
5
  watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
6
- watch('spec/spec_helper.rb') { "spec" }
6
+ watch('spec/spec_helper.rb') { 'spec' }
7
7
  end
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2023 Andrew Louis
3
+ Copyright (c) 2024 Andrew Louis
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,3 +1,88 @@
1
- # Chronicle::Core
1
+ [![Gem Version](https://badge.fury.io/rb/chronicle-core.svg)](https://badge.fury.io/rb/chronicle-core) [![Ruby](https://github.com/chronicle-app/chronicle-core/actions/workflows/ruby.yml/badge.svg)](https://github.com/chronicle-app/chronicle-core/actions/workflows/ruby.yml) [![Docs](https://img.shields.io/badge/docs-rubydoc.info-blue)](https://www.rubydoc.info/gems/chronicle-core/)
2
+
3
+ Core schema-related code for [Chronicle](https://github.com/chronicle-app/)
4
+
5
+ ## Schema
6
+
7
+ Chronicle Schema is a set of types and properties that are used to describe personal digital history. The schema is a derivative of [Schema.org](https://schema.org). For the most part, it is a subset of Schema.org types and properties.
8
+
9
+ There are a few top-level properties that are unique to Chronicle:
10
+ - `source` - the source of the record (e.g. `spotify`, `twitter`, `instagram`)
11
+ - `sourceId` - the unique identifier of the record in the source system
12
+ - `slug` - a human-readable identifier for the record (e.g. `bridge-over-troubled-water`)
13
+
14
+ To view all valid types and properties in Chronicle Schema, see the `schema/` diretory.
15
+
16
+ TODO: generate doc files from schema automatically.
17
+
18
+ To update the schema, edit `schema/chronicle_schema_VERSION.rb` and run `rake schema:generate`.
19
+
20
+ ## Models
21
+
22
+ For each valid type in Chronicle Schema, there is a corresponding model class that can instantiated as an immutable Ruby object.
23
+
24
+ ### Usage
25
+
26
+ ```ruby
27
+ require 'chronicle/models'
28
+
29
+ song = Chronicle::Models::MusicRecording.new do |r|
30
+ r.name = 'Bridge Over Troubled Water'
31
+ r.in_album = 'Aretha Live at Fillmore West'
32
+ r.by_artist = [Chronicle::Models::MusicGroup.new(name: 'Aretha Franklin')]
33
+ r.duration = "PT5M49S"
34
+ r.source = 'spotify'
35
+ r.source_id = '0e0isrwFsu5W0KJqxkfPpX?si=3d2f08d6d63149bb'
36
+ end
37
+
38
+ puts song.to_json
39
+ # => {"@type":"MusicRecording","name":"Bridge Over Troubled Water","in_album":"Aretha Live at Fillmore West","by_artist":[{"@type":"MusicGroup","name":"Aretha Franklin"}],"duration":"PT5M49S","source":"spotify","sourceId":"0e0isrwFsu5W0KJqxkfPpX?si=3d2f08d6d63149bb"}
40
+ ```
41
+
42
+ ## Validation
43
+
44
+ chronicle-core provides a validator that checks if a given JSON object conforms to the Chronicle Schema. The validator assumes the JSON is serialized as JSON-LD.
45
+
46
+ ### Usage
47
+
48
+ ```ruby
49
+ # TODO
50
+ ```
51
+
52
+ ## Serialization
53
+
54
+ chronicle-core provides the following serializers for records:
55
+ - JSON
56
+ - JSONAPI
57
+ - JSON-LD
58
+
59
+ ## Development
60
+
61
+ To run the tests:
62
+
63
+ ```bash
64
+ # Run once
65
+ bundle exec rspec
66
+
67
+ # Run continuously
68
+ bundle exec guard
69
+ ```
70
+
71
+ ## Get in touch
72
+
73
+ - [@hyfen](https://twitter.com/hyfen) on Twitter
74
+ - [@hyfen](https://github.com/hyfen) on Github
75
+ - Email: andrew@hyfen.net
76
+
77
+ ## Contributing
78
+
79
+ Bug reports and pull requests are welcome on GitHub at https://github.com/chronicle-app/chronicle-core. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
80
+
81
+ ## License
82
+
83
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
84
+
85
+ ## Code of Conduct
86
+
87
+ Everyone interacting in the Chronicle::ETL project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/chronicle-app/chronicle-core/blob/main/CODE_OF_CONDUCT.md).
2
88
 
3
- Home of some shared code for [Chronicle](https://github.com/chronicle-app/). Currently, this means the models and a validator for Chronicle Schema.
data/Rakefile CHANGED
@@ -1,4 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
3
+ require 'bundler/gem_tasks'
4
4
  task default: %i[]
5
+
6
+ namespace :generate do
7
+ DEFAULT_SCHEMA_FILE = File.join(File.dirname(__FILE__), 'lib', 'chronicle', 'schema', 'data', 'schema.ttl')
8
+
9
+ desc 'Generate everything'
10
+ task :all, :version do |_t, args|
11
+ version = args[:version] || latest_version
12
+ Rake::Task['generate:generate'].invoke(version)
13
+ Rake::Task['generate:cache_schema'].invoke(version)
14
+ end
15
+
16
+ desc 'Generate schema'
17
+ task :generate, :version do |_t, args|
18
+ require 'pry'
19
+ require 'chronicle/schema'
20
+ require 'chronicle/schema/rdf_parsing'
21
+ require 'json'
22
+
23
+ version = args[:version] || latest_version
24
+ puts "Generating schema with version: #{version}"
25
+
26
+ schema_dsl_file = File.join(File.dirname(__FILE__), 'schema', "chronicle_schema_v#{version}.rb")
27
+
28
+ new_graph = Chronicle::Schema::RDFParsing::GraphTransformer.transform_from_file(schema_dsl_file)
29
+
30
+ ttl_str = Chronicle::Schema::RDFParsing::RDFSerializer.serialize(new_graph)
31
+ output_filename = schema_dsl_file.gsub(/\.rb$/, '.ttl')
32
+
33
+ File.write(output_filename, ttl_str)
34
+ end
35
+
36
+ desc 'Cache schema from ttl file'
37
+ task :cache_schema, :version do |_t, args|
38
+ require 'chronicle/schema'
39
+ require 'chronicle/schema/rdf_parsing'
40
+
41
+ version = args[:version] || latest_version
42
+ puts "Caching schema with version: #{version}"
43
+
44
+ ttl_file = File.join(File.dirname(__FILE__), 'schema', "chronicle_schema_v#{version}.ttl")
45
+ graph = Chronicle::Schema::RDFParsing::TTLGraphBuilder.build_from_file(ttl_file, default_namespace: 'https://schema.chronicle.app/')
46
+
47
+ output_filename = File.join(File.dirname(__FILE__), 'schema',
48
+ "chronicle_schema_v#{version}.json")
49
+
50
+ output_str = JSON.pretty_generate(graph.to_h)
51
+
52
+ File.write(output_filename, output_str)
53
+ end
54
+
55
+ # Highest version schema file in schema/ directory
56
+ def latest_version
57
+ schema_files = Dir.glob(File.join(File.dirname(__FILE__), 'schema', 'chronicle_schema_v*.rb'))
58
+ versions = schema_files.map { |f| f.match(/chronicle_schema_v(.*)\.rb/)&.captures&.first }
59
+ sorted_versions = versions.sort_by { |version| Gem::Version.new(version) }
60
+ sorted_versions.last
61
+ end
62
+ end
63
+
64
+ task :generate, :version do |_t, args|
65
+ Rake::Task['generate:all'].invoke(args[:version])
66
+ end
data/bin/console CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require "bundler/setup"
5
- require "chronicle/core"
4
+ require 'bundler/setup'
5
+ require 'chronicle/core'
6
6
 
7
7
  # You can add fixtures and/or initialization code here to make experimenting
8
8
  # with your gem easier. You can also use a different console, if you like.
9
9
 
10
10
  # (If you use this, don't forget to add pry to your Gemfile!)
11
- # require "pry"
12
- # Pry.start
11
+ require 'pry'
12
+ Pry.start
13
13
 
14
- require "irb"
15
- IRB.start(__FILE__)
14
+ # require "irb"
15
+ # IRB.start(__FILE__)
@@ -1,42 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "lib/chronicle/core/version"
3
+ require_relative 'lib/chronicle/core/version'
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = "chronicle-core"
6
+ spec.name = 'chronicle-core'
7
7
  spec.version = Chronicle::Core::VERSION
8
- spec.authors = ["Andrew Louis"]
9
- spec.email = ["andrew@hyfen.net"]
8
+ spec.authors = ['Andrew Louis']
9
+ spec.email = ['andrew@hyfen.net']
10
10
 
11
- spec.summary = "Core libraries for Chronicle"
12
- spec.description = "Core libraries for Chronicle including models and schema definitions."
13
- spec.homepage = "https://github.com/chronicle-app"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ">= 2.7"
11
+ spec.summary = 'Core libraries for Chronicle'
12
+ spec.description = 'Core libraries for Chronicle including models and schema definitions.'
13
+ spec.homepage = 'https://github.com/chronicle-app'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.1'
16
16
 
17
17
  if spec.respond_to?(:metadata)
18
- spec.metadata['allowed_push_host'] = "https://rubygems.org"
18
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
19
19
 
20
- spec.metadata["homepage_uri"] = spec.homepage
21
- spec.metadata["source_code_uri"] = "https://github.com/chronicle-app/chronicle-core"
22
- spec.metadata["changelog_uri"] = "https://github.com/chronicle-app/chronicle-core/releases"
20
+ spec.metadata['homepage_uri'] = spec.homepage
21
+ spec.metadata['source_code_uri'] = 'https://github.com/chronicle-app/chronicle-core'
22
+ spec.metadata['changelog_uri'] = 'https://github.com/chronicle-app/chronicle-core/releases'
23
23
  else
24
- raise "RubyGems 2.0 or newer is required to protect against " \
25
- "public gem pushes."
24
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
25
+ 'public gem pushes.'
26
26
  end
27
27
 
28
- spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
28
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
29
29
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
30
30
  end
31
- spec.bindir = "exe"
31
+ spec.bindir = 'exe'
32
32
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
- spec.require_paths = ["lib"]
34
-
35
- spec.add_dependency "dry-schema", "~> 1.13"
36
-
37
- spec.add_development_dependency "guard-rspec", "~> 4.7.3"
38
- spec.add_development_dependency "pry-byebug", "~> 3.9"
39
- spec.add_development_dependency "rubocop", "~> 1.25.1"
40
- spec.add_development_dependency "rspec", "~> 3.9"
41
- spec.add_development_dependency "simplecov", "~> 0.21"
33
+ spec.require_paths = ['lib']
34
+ spec.metadata['rubygems_mfa_required'] = 'true'
35
+
36
+ spec.add_dependency 'dry-struct', '~> 1.6'
37
+ spec.add_dependency 'dry-validation', '~> 1.10'
38
+
39
+ spec.add_development_dependency 'guard-rspec', '~> 4.7.3'
40
+ spec.add_development_dependency 'pry-byebug', '~> 3.9'
41
+ spec.add_development_dependency 'rake', '~> 13'
42
+ spec.add_development_dependency 'rdf-reasoner', '~> 0.9'
43
+ spec.add_development_dependency 'rdf-turtle', '~> 3.3'
44
+ spec.add_development_dependency 'rspec', '~> 3.9'
45
+ spec.add_development_dependency 'rubocop', '~> 1.57'
46
+ spec.add_development_dependency 'simplecov', '~> 0.21'
47
+ spec.add_development_dependency 'sparql', '~> 3.3'
42
48
  end
@@ -1,7 +1,5 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Chronicle
4
2
  module Core
5
- VERSION = "0.2.1"
3
+ VERSION = '0.3.0'.freeze
6
4
  end
7
5
  end
@@ -1,6 +1,4 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "core/version"
1
+ require_relative 'core/version'
4
2
 
5
3
  begin
6
4
  require 'pry'
@@ -0,0 +1,96 @@
1
+ require 'dry-struct'
2
+ require 'chronicle/schema/types'
3
+
4
+ module Chronicle
5
+ module Models
6
+ # The base class for all generated models
7
+ class Base < Dry::Struct
8
+ transform_keys(&:to_sym)
9
+ schema schema.strict
10
+
11
+ class << self
12
+ attr_reader :type_id
13
+ end
14
+
15
+ def properties
16
+ # FIXME: think about this more. Does dedupe belong in serialization
17
+ attributes.except(:dedupe_on)
18
+ end
19
+
20
+ # TODO: remove this from attributes and just do custom getters/setters
21
+ attribute(:id, Chronicle::Schema::Types::String.optional.default(nil).meta(many: false, required: false))
22
+
23
+ # set of properties to dedupe on
24
+ # each set of properties is an array of symbols representing the properties to dedupe on
25
+ # example: [[:slug, :source], [:url]]
26
+ attribute(
27
+ :dedupe_on,
28
+ Chronicle::Schema::Types::Array.of(
29
+ Chronicle::Schema::Types::Array.of(
30
+ Chronicle::Schema::Types::Symbol
31
+ )
32
+ ).optional.default([].freeze).meta(
33
+ many: false, required: false
34
+ )
35
+ )
36
+
37
+ def self.new(attributes = {})
38
+ if block_given?
39
+ attribute_struct = Struct.new(*schema.map(&:name)).new
40
+ attribute_struct.dedupe_on = []
41
+
42
+ yield(attribute_struct)
43
+ attributes = attribute_struct.to_h
44
+ end
45
+
46
+ super(attributes)
47
+ rescue Dry::Struct::Error, NoMethodError => e
48
+ # TODO: be more clear about the attribute that's invalid
49
+ raise AttributeError, e.message
50
+ end
51
+
52
+ def meta
53
+ output = {}
54
+ output[:dedupe_on] = dedupe_on unless dedupe_on.empty?
55
+ output
56
+ end
57
+
58
+ def type_id
59
+ self.class.type_id
60
+ end
61
+
62
+ def to_h
63
+ super.merge({ type: type_id })
64
+ .except(:dedupe_on)
65
+ .compact # TODO: don't know about this one
66
+ end
67
+
68
+ # TODO: exclude dedupe_on from serialization
69
+ def to_h_flattened
70
+ require 'chronicle/utils/hash_utils'
71
+ Chronicle::Utils::HashUtils.flatten_hash(to_h)
72
+ end
73
+
74
+ # FIXME: this isn't working
75
+ def self.many_cardinality_attributes
76
+ schema.type.select do |type|
77
+ type.meta[:many]
78
+ end.map(&:name)
79
+ end
80
+
81
+ def self.one_cardinality_attributes
82
+ schema.type.map(&:name) - many_cardinality_attributes
83
+ end
84
+ end
85
+
86
+ def self.schema_type(types)
87
+ Chronicle::Schema::Types::Instance(Chronicle::Models::Base).constructor do |input|
88
+ unless input.type_id && [types].flatten.include?(input.type_id)
89
+ raise Dry::Types::ConstraintError.new(:type?, input)
90
+ end
91
+
92
+ input
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,35 @@
1
+ module Chronicle
2
+ module Models
3
+ # Utility methods to build models from hash input
4
+ module Builder
5
+ # Build a nested a model with nested associated from a hash in json-ld format
6
+ def build(obj)
7
+ raise ArgumentError, 'Object must be a hash' unless obj.is_a?(Hash)
8
+
9
+ type = obj[:@type]
10
+ raise ArgumentError, 'Object must have a type' unless type
11
+
12
+ model_klass = const_get(type.to_sym)
13
+ raise ArgumentError, "Unknown model type: #{type}" unless model_klass
14
+
15
+ # recursively create nested models
16
+ # TODO: have a better way of detecting chronicle schema objects
17
+ begin
18
+ obj.each do |property, value|
19
+ if value.is_a?(Hash)
20
+ obj[property] = build(value)
21
+ elsif value.is_a?(Array)
22
+ obj[property] = value.map do |v|
23
+ v.is_a?(Hash) ? build(v) : v
24
+ end
25
+ end
26
+ end
27
+
28
+ model_klass.new(obj.except(:@type))
29
+ rescue Chronicle::Models::AttributeError => e
30
+ raise ArgumentError, e.message
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,89 @@
1
+ require 'chronicle/schema'
2
+ require_relative 'model_factory'
3
+ require_relative 'base'
4
+
5
+ module Chronicle
6
+ module Models
7
+ module Generation
8
+ @models_generated = false
9
+ @benchmark_enabled = false
10
+
11
+ class << self
12
+ attr_accessor :models_generated, :benchmark_enabled
13
+ end
14
+
15
+ def self.included(base)
16
+ base.extend ClassMethods
17
+ base.generate_models unless base.models_generated?
18
+ end
19
+
20
+ # Methods for generating models from the schema
21
+ module ClassMethods
22
+ def models_generated?
23
+ Chronicle::Models::Generation.models_generated
24
+ end
25
+
26
+ def extant_models
27
+ constants.select do |constant_name|
28
+ constant = const_get(constant_name)
29
+ constant.is_a?(Class) && constant.superclass == Chronicle::Models::Base
30
+ end
31
+ end
32
+
33
+ def generate_models(graph = nil)
34
+ graph ||= begin
35
+ require 'json'
36
+ schema_path = File.join(File.dirname(__FILE__), '..', '..', '..', 'schema', 'chronicle_schema_v1.json')
37
+ Chronicle::Schema::SchemaGraph.build_from_json(JSON.parse(File.read(schema_path)))
38
+ end
39
+
40
+ start_time = Time.now
41
+ graph.types.each do |klass|
42
+ type_id = graph.id_to_identifier(klass.id)
43
+
44
+ new_model_klass = Chronicle::Models::ModelFactory.new(
45
+ type_id: type_id.to_sym,
46
+ properties: klass.all_properties
47
+ ).generate
48
+
49
+ const_set(type_id, new_model_klass)
50
+ end
51
+ end_time = Time.now
52
+ duration_ms = (end_time - start_time) * 1000
53
+ handle_benchmark_data(graph.types.length, duration_ms) if Chronicle::Models::Generation.benchmark_enabled
54
+
55
+ Chronicle::Models::Generation.models_generated = true
56
+ end
57
+
58
+ def unload_models
59
+ constants.each do |constant_name|
60
+ constant = const_get(constant_name)
61
+ if constant.is_a?(Class) && constant.superclass == Chronicle::Models::Base
62
+ send(:remove_const,
63
+ constant_name)
64
+ end
65
+ end
66
+ Chronicle::Models::Generation.models_generated = false
67
+ end
68
+
69
+ def enable_benchmarking
70
+ Chronicle::Models::Generation.benchmark_enabled = true
71
+ end
72
+
73
+ private
74
+
75
+ def handle_benchmark_data(number_of_models, duration)
76
+ puts "Generated #{number_of_models} models in #{duration.round(2)}ms"
77
+ end
78
+ end
79
+
80
+ def self.reset
81
+ Chronicle::Models::Generation.models_generated = false
82
+ end
83
+
84
+ def self.suppress_model_generation
85
+ Chronicle::Models::Generation.models_generated = true
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,63 @@
1
+ module Chronicle
2
+ module Models
3
+ class ModelFactory
4
+ attr_reader :id
5
+
6
+ def initialize(type_id:, properties: [])
7
+ @type_id = type_id
8
+ @properties = properties
9
+ end
10
+
11
+ def generate
12
+ attribute_info = @properties.map do |property|
13
+ generate_attribute_info(property)
14
+ end
15
+
16
+ type_id = @type_id
17
+
18
+ Class.new(Chronicle::Models::Base) do
19
+ attribute_info.each do |a|
20
+ attribute(a[:name], a[:type])
21
+ end
22
+
23
+ @type_id = type_id
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def generate_attribute_info(property)
30
+ range = build_type(property)
31
+ type = if property.many?
32
+ Chronicle::Schema::Types::Array.of(range)
33
+ else
34
+ range
35
+ end
36
+
37
+ type = type.optional.default(nil) unless property.required?
38
+ type = type.meta(required: property.required?, many: property.many?)
39
+
40
+ {
41
+ name: property.id_snakecase.to_sym,
42
+ type:
43
+ }
44
+ end
45
+
46
+ def build_type(property)
47
+ type_values = []
48
+
49
+ full_range_identifiers = property.full_range_identifiers
50
+
51
+ type_values << Chronicle::Schema::Types::Params::Time if full_range_identifiers.include? :DateTime
52
+ type_values << Chronicle::Schema::Types::String if %i[Text URL Distance Duration Energy
53
+ Mass].intersect?(full_range_identifiers)
54
+ type_values << Chronicle::Schema::Types::Params::Integer if full_range_identifiers.include? :Integer
55
+ type_values << Chronicle::Models.schema_type(full_range_identifiers)
56
+
57
+ type_values.reduce do |memo, type|
58
+ memo | type
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,17 @@
1
+ require_relative 'models/generation'
2
+ require_relative 'models/base'
3
+ require_relative 'models/builder'
4
+
5
+ module Chronicle
6
+ module Models
7
+ class Error < StandardError; end
8
+ class AttributeError < Error; end
9
+
10
+ # Automatically generate models from the default schema
11
+ # We will do this dynamically whenever 'chronicle/models' is required
12
+ # until the performance becomes an issue (currently takes about ~10ms)
13
+ include Chronicle::Models::Generation
14
+
15
+ extend Chronicle::Models::Builder
16
+ end
17
+ end