schemable 0.1.4 → 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +5 -1
- data/CHANGELOG.md +8 -3
- data/Gemfile +5 -5
- data/Gemfile.lock +0 -136
- data/README.md +327 -101
- data/lib/generators/schemable/install_generator.rb +3 -13
- data/lib/generators/schemable/model_generator.rb +1 -16
- data/lib/schemable/attribute_schema_generator.rb +146 -0
- data/lib/schemable/configuration.rb +114 -0
- data/lib/schemable/definition.rb +335 -0
- data/lib/schemable/included_schema_generator.rb +107 -0
- data/lib/schemable/relationship_schema_generator.rb +119 -0
- data/lib/schemable/request_schema_generator.rb +88 -0
- data/lib/schemable/response_schema_generator.rb +124 -0
- data/lib/schemable/schema_modifier.rb +248 -0
- data/lib/schemable/version.rb +1 -1
- data/lib/schemable.rb +60 -937
- data/lib/templates/schemable.rb +76 -0
- data/schemable.gemspec +2 -5
- data/sig/schemable/attribute_schema_generator.rbs +13 -0
- data/sig/schemable/configuration.rbs +21 -0
- data/sig/schemable/definition.rbs +33 -0
- data/sig/schemable/included_schema_generator.rbs +11 -0
- data/sig/schemable/relationship_schema_generator.rbs +11 -0
- data/sig/schemable/request_schema_generator.rbs +10 -0
- data/sig/schemable/response_schema_generator.rbs +13 -0
- data/sig/schemable/schema_modifier.rbs +9 -0
- data/sig/schemable.rbs +4 -1
- metadata +23 -36
- data/lib/templates/common_definitions.rb +0 -13
- data/lib/templates/serializers_helper.rb +0 -7
@@ -0,0 +1,107 @@
|
|
1
|
+
module Schemable
|
2
|
+
# The IncludedSchemaGenerator class is responsible for generating the 'included' part of a JSON:API compliant response.
|
3
|
+
# This class generates schemas for related resources that should be included in the response.
|
4
|
+
#
|
5
|
+
# @see Schemable
|
6
|
+
class IncludedSchemaGenerator
|
7
|
+
attr_reader :model_definition, :schema_modifier, :relationships
|
8
|
+
|
9
|
+
# Initializes a new IncludedSchemaGenerator instance.
|
10
|
+
#
|
11
|
+
# @param model_definition [ModelDefinition] The model definition to generate the schema for.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# generator = IncludedSchemaGenerator.new(model_definition)
|
15
|
+
def initialize(model_definition)
|
16
|
+
@model_definition = model_definition
|
17
|
+
@schema_modifier = SchemaModifier.new
|
18
|
+
@relationships = @model_definition.relationships
|
19
|
+
end
|
20
|
+
|
21
|
+
# Generates the 'included' part of the JSON:API response.
|
22
|
+
# It iterates over each relationship type (belongs_to, has_many) and for each relationship,
|
23
|
+
# it prepares a schema. If the 'expand' option is true, it also includes the relationships of the related resource in the schema.
|
24
|
+
# In that case, the 'addition_to_included' relationships are also included in the schema unless they are excluded from expansion.
|
25
|
+
#
|
26
|
+
# @param expand [Boolean] Whether to include the relationships of the related resource in the schema.
|
27
|
+
# @param relationships_to_exclude_from_expansion [Array] The relationships to exclude from the schema.
|
28
|
+
#
|
29
|
+
# @note Make sure to provide the names correctly in string format and pluralize them if necessary.
|
30
|
+
# For example, if you have a relationship named 'applicant', and an applicant has association
|
31
|
+
# named 'identity', you should provide 'identities' as the names of the relationship to exclude from expansion.
|
32
|
+
# In this case, the included schema of the applicant will not include the identity relationship.
|
33
|
+
#
|
34
|
+
# @example
|
35
|
+
# schema = generator.generate(expand: true, relationships_to_exclude_from_expansion: ['some_relationship'])
|
36
|
+
#
|
37
|
+
# @return [Hash] The generated schema.
|
38
|
+
def generate(expand: false, relationships_to_exclude_from_expansion: [])
|
39
|
+
return {} if @relationships.blank?
|
40
|
+
return {} if @relationships == { belongs_to: {}, has_many: {} }
|
41
|
+
|
42
|
+
definitions = []
|
43
|
+
|
44
|
+
%i[belongs_to has_many addition_to_included].each do |relation_type|
|
45
|
+
next if @relationships[relation_type].blank?
|
46
|
+
|
47
|
+
definitions << @relationships[relation_type].values
|
48
|
+
end
|
49
|
+
|
50
|
+
definitions.flatten!
|
51
|
+
|
52
|
+
included_schemas = definitions.map do |definition|
|
53
|
+
next if relationships_to_exclude_from_expansion.include?(definition.model_name)
|
54
|
+
|
55
|
+
if expand
|
56
|
+
definition_relations = definition.relationships[:belongs_to].values.map(&:model_name) + definition.relationships[:has_many].values.map(&:model_name)
|
57
|
+
relations_to_exclude = []
|
58
|
+
definition_relations.each do |relation|
|
59
|
+
relations_to_exclude << relation if relationships_to_exclude_from_expansion.include?(relation)
|
60
|
+
end
|
61
|
+
|
62
|
+
prepare_schema_for_included(definition, expand:, relationships_to_exclude_from_expansion: relations_to_exclude)
|
63
|
+
else
|
64
|
+
prepare_schema_for_included(definition)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
schema = {
|
69
|
+
included: {
|
70
|
+
type: :array,
|
71
|
+
items: {
|
72
|
+
anyOf: included_schemas.compact_blank
|
73
|
+
}
|
74
|
+
}
|
75
|
+
}
|
76
|
+
|
77
|
+
@schema_modifier.add_properties(schema, @model_definition.additional_response_included, 'included.items') if @model_definition.additional_response_included.present?
|
78
|
+
|
79
|
+
schema
|
80
|
+
end
|
81
|
+
|
82
|
+
# Prepares the schema for a related resource to be included in the response.
|
83
|
+
# It generates the attribute and relationship schemas for the related resource and combines them into a single schema.
|
84
|
+
#
|
85
|
+
# @param model_definition [ModelDefinition] The model definition of the related resource.
|
86
|
+
# @param expand [Boolean] Whether to include the relationships of the related resource in the schema.
|
87
|
+
# @param relationships_to_exclude_from_expansion [Array] The relationships to exclude from the schema.
|
88
|
+
#
|
89
|
+
# @example
|
90
|
+
# included_schema = generator.prepare_schema_for_included(related_model_definition, expand: true, relationships_to_exclude_from_expansion: ['some_relationship'])
|
91
|
+
#
|
92
|
+
# @return [Hash] The generated schema for the related resource.
|
93
|
+
def prepare_schema_for_included(model_definition, expand: false, relationships_to_exclude_from_expansion: [])
|
94
|
+
attributes_schema = AttributeSchemaGenerator.new(model_definition).generate
|
95
|
+
relationships_schema = RelationshipSchemaGenerator.new(model_definition).generate(relationships_to_exclude_from_expansion:, expand:)
|
96
|
+
|
97
|
+
{
|
98
|
+
type: :object,
|
99
|
+
properties: {
|
100
|
+
type: { type: :string, default: model_definition.model_name },
|
101
|
+
id: { type: :string },
|
102
|
+
attributes: attributes_schema
|
103
|
+
}.merge!(relationships_schema.blank? ? {} : { relationships: relationships_schema })
|
104
|
+
}.compact_blank
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
module Schemable
|
2
|
+
# The RelationshipSchemaGenerator class is responsible for generating the 'relationships' part of a JSON:API compliant response.
|
3
|
+
# This class generates schemas for each relationship of a model, including 'belongs_to' (and has_many) and 'has_many' relationships.
|
4
|
+
#
|
5
|
+
# @see Schemable
|
6
|
+
class RelationshipSchemaGenerator
|
7
|
+
attr_reader :model_definition, :schema_modifier, :relationships
|
8
|
+
|
9
|
+
# Initializes a new RelationshipSchemaGenerator instance.
|
10
|
+
#
|
11
|
+
# @param model_definition [ModelDefinition] The model definition to generate the schema for.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# generator = RelationshipSchemaGenerator.new(model_definition)
|
15
|
+
def initialize(model_definition)
|
16
|
+
@model_definition = model_definition
|
17
|
+
@schema_modifier = SchemaModifier.new
|
18
|
+
@relationships = model_definition.relationships
|
19
|
+
end
|
20
|
+
|
21
|
+
# Generates the 'relationships' part of the JSON:API response.
|
22
|
+
# It iterates over each relationship type (belongs_to, has_many) and for each relationship,
|
23
|
+
# it prepares a schema unless the relationship is excluded from expansion.
|
24
|
+
# If the 'expand' option is true, it changes the schema to include type and id properties inside the 'meta' property.
|
25
|
+
#
|
26
|
+
# @param relationships_to_exclude_from_expansion [Array] The relationships to exclude from expansion.
|
27
|
+
# @param expand [Boolean] Whether to include the relationships of the related resource in the schema.
|
28
|
+
#
|
29
|
+
# @example
|
30
|
+
# schema = generator.generate(expand: true, relationships_to_exclude_from_expansion: [:some_relationship])
|
31
|
+
#
|
32
|
+
# @return [Hash] The generated schema.
|
33
|
+
def generate(relationships_to_exclude_from_expansion: [], expand: false)
|
34
|
+
return {} if @relationships.blank? || @relationships == { belongs_to: {}, has_many: {} }
|
35
|
+
|
36
|
+
schema = {
|
37
|
+
type: :object,
|
38
|
+
properties: {}
|
39
|
+
}
|
40
|
+
|
41
|
+
%i[belongs_to has_many].each do |relation_type|
|
42
|
+
@relationships[relation_type]&.each do |relation, definition|
|
43
|
+
non_expanded_data_properties = {
|
44
|
+
type: :object,
|
45
|
+
properties: {
|
46
|
+
meta: {
|
47
|
+
type: :object,
|
48
|
+
properties: {
|
49
|
+
included: { type: :boolean, default: false }
|
50
|
+
}
|
51
|
+
}
|
52
|
+
}
|
53
|
+
}
|
54
|
+
|
55
|
+
result = relation_type == :belongs_to ? generate_schema(definition.model_name) : generate_schema(definition.model_name, collection: true)
|
56
|
+
|
57
|
+
result = non_expanded_data_properties if !expand || relationships_to_exclude_from_expansion.include?(definition.model_name)
|
58
|
+
|
59
|
+
schema[:properties].merge!(relation => result)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Modify the schema to include additional response relations
|
64
|
+
schema = @schema_modifier.add_properties(schema, @model_definition.additional_response_relations, 'properties')
|
65
|
+
|
66
|
+
# Modify the schema to exclude response relations
|
67
|
+
@model_definition.excluded_response_relations.each do |key|
|
68
|
+
schema = @schema_modifier.delete_properties(schema, "properties.#{key}")
|
69
|
+
end
|
70
|
+
|
71
|
+
schema
|
72
|
+
end
|
73
|
+
|
74
|
+
# Generates the schema for a specific relationship.
|
75
|
+
# If the 'collection' option is true, it generates a schema for a 'has_many' relationship,
|
76
|
+
# otherwise it generates a schema for a 'belongs_to' relationship. The difference between the two is that
|
77
|
+
# 'data' is an array in the 'has_many' relationship and an object in the 'belongs_to' relationship.
|
78
|
+
#
|
79
|
+
# @param type_name [String] The type of the related resource.
|
80
|
+
# @param collection [Boolean] Whether the relationship is a 'has_many' relationship.
|
81
|
+
#
|
82
|
+
# @example
|
83
|
+
# relationship_schema = generator.generate_schema('resource_type', collection: true)
|
84
|
+
#
|
85
|
+
# @return [Hash] The generated schema for the relationship.
|
86
|
+
def generate_schema(type_name, collection: false)
|
87
|
+
if collection
|
88
|
+
{
|
89
|
+
type: :object,
|
90
|
+
properties: {
|
91
|
+
data: {
|
92
|
+
type: :array,
|
93
|
+
items: {
|
94
|
+
type: :object,
|
95
|
+
properties: {
|
96
|
+
id: { type: :string },
|
97
|
+
type: { type: :string, default: type_name }
|
98
|
+
}
|
99
|
+
}
|
100
|
+
}
|
101
|
+
}
|
102
|
+
}
|
103
|
+
else
|
104
|
+
{
|
105
|
+
type: :object,
|
106
|
+
properties: {
|
107
|
+
data: {
|
108
|
+
type: :object,
|
109
|
+
properties: {
|
110
|
+
id: { type: :string },
|
111
|
+
type: { type: :string, default: type_name }
|
112
|
+
}
|
113
|
+
}
|
114
|
+
}
|
115
|
+
}
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Schemable
|
2
|
+
# The RequestSchemaGenerator class is responsible for generating JSON schemas for create and update requests.
|
3
|
+
# This class generates schemas based on the model definition, including additional and excluded attributes.
|
4
|
+
#
|
5
|
+
# @see Schemable
|
6
|
+
class RequestSchemaGenerator
|
7
|
+
attr_reader :model_definition, :schema_modifier
|
8
|
+
|
9
|
+
# Initializes a new RequestSchemaGenerator instance.
|
10
|
+
#
|
11
|
+
# @param model_definition [ModelDefinition] The model definition to generate the schema for.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# generator = RequestSchemaGenerator.new(model_definition)
|
15
|
+
def initialize(model_definition)
|
16
|
+
@model_definition = model_definition
|
17
|
+
@schema_modifier = SchemaModifier.new
|
18
|
+
end
|
19
|
+
|
20
|
+
# Generates the JSON schema for a create request.
|
21
|
+
# It generates a schema for the model attributes and then modifies it based on the additional and excluded attributes for create requests.
|
22
|
+
# It also determines the required attributes based on the optional and nullable attributes.
|
23
|
+
# Note that it is presumed that the model is using the same fields/columns for create as well as responses.
|
24
|
+
#
|
25
|
+
# @example
|
26
|
+
# schema = generator.generate_for_create
|
27
|
+
#
|
28
|
+
# @return [Hash] The generated schema for create requests.
|
29
|
+
def generate_for_create
|
30
|
+
schema = {
|
31
|
+
type: :object,
|
32
|
+
properties: {
|
33
|
+
data: AttributeSchemaGenerator.new(@model_definition).generate
|
34
|
+
}
|
35
|
+
}
|
36
|
+
|
37
|
+
@schema_modifier.add_properties(schema, @model_definition.additional_create_request_attributes, 'properties.data.properties')
|
38
|
+
|
39
|
+
@model_definition.excluded_create_request_attributes.each do |key|
|
40
|
+
@schema_modifier.delete_properties(schema, "properties.data.properties.#{key}")
|
41
|
+
end
|
42
|
+
|
43
|
+
required_attributes = {
|
44
|
+
required: (
|
45
|
+
schema.as_json['properties']['data']['properties'].keys -
|
46
|
+
@model_definition.optional_create_request_attributes.map(&:to_s) -
|
47
|
+
@model_definition.nullable_attributes.map(&:to_s)
|
48
|
+
).map { |key| key.to_s.camelize(:lower).to_sym }
|
49
|
+
}
|
50
|
+
|
51
|
+
@schema_modifier.add_properties(schema, required_attributes, 'properties.data')
|
52
|
+
end
|
53
|
+
|
54
|
+
# Generates the JSON schema for a update request.
|
55
|
+
# It generates a schema for the model attributes and then modifies it based on the additional and excluded attributes for update requests.
|
56
|
+
# It also determines the required attributes based on the optional and nullable attributes.
|
57
|
+
# Note that it is presumed that the model is using the same fields/columns for update as well as responses.
|
58
|
+
#
|
59
|
+
# @example
|
60
|
+
# schema = generator.generate_for_update
|
61
|
+
#
|
62
|
+
# @return [Hash] The generated schema for update requests.
|
63
|
+
def generate_for_update
|
64
|
+
schema = {
|
65
|
+
type: :object,
|
66
|
+
properties: {
|
67
|
+
data: AttributeSchemaGenerator.new(@model_definition).generate
|
68
|
+
}
|
69
|
+
}
|
70
|
+
|
71
|
+
@schema_modifier.add_properties(schema, @model_definition.additional_update_request_attributes, 'properties.data.properties')
|
72
|
+
|
73
|
+
@model_definition.excluded_update_request_attributes.each do |key|
|
74
|
+
@schema_modifier.delete_properties(schema, "properties.data.properties.#{key}")
|
75
|
+
end
|
76
|
+
|
77
|
+
required_attributes = {
|
78
|
+
required: (
|
79
|
+
schema.as_json['properties']['data']['properties'].keys -
|
80
|
+
@model_definition.optional_update_request_attributes.map(&:to_s) -
|
81
|
+
@model_definition.nullable_attributes.map(&:to_s)
|
82
|
+
).map { |key| key.to_s.camelize(:lower).to_sym }
|
83
|
+
}
|
84
|
+
|
85
|
+
@schema_modifier.add_properties(schema, required_attributes, 'properties.data')
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
module Schemable
|
2
|
+
# The ResponseSchemaGenerator class is responsible for generating JSON schemas for responses.
|
3
|
+
# This class generates schemas based on the model definition, including attributes, relationships, and included resources.
|
4
|
+
#
|
5
|
+
# @see Schemable
|
6
|
+
class ResponseSchemaGenerator
|
7
|
+
attr_reader :model_definition, :model, :schema_modifier, :configuration
|
8
|
+
|
9
|
+
# Initializes a new ResponseSchemaGenerator instance.
|
10
|
+
#
|
11
|
+
# @param model_definition [ModelDefinition] The model definition to generate the schema for.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# generator = ResponseSchemaGenerator.new(model_definition)
|
15
|
+
def initialize(model_definition)
|
16
|
+
@model_definition = model_definition
|
17
|
+
@model = model_definition.model
|
18
|
+
@schema_modifier = SchemaModifier.new
|
19
|
+
@configuration = Schemable.configuration
|
20
|
+
end
|
21
|
+
|
22
|
+
# Generates the JSON schema for a response.
|
23
|
+
# It generates a schema for the model attributes and relationships, and if the 'expand' option is true,
|
24
|
+
# it also includes the included resources in the schema.
|
25
|
+
# It also adds meta and jsonapi information to the schema.
|
26
|
+
#
|
27
|
+
# @param expand [Boolean] Whether to include the included resources in the schema.
|
28
|
+
# @param relationships_to_exclude_from_expansion [Array] The relationships to exclude from expansion in the schema.
|
29
|
+
# @param collection [Boolean] Whether the response is for a collection of resources.
|
30
|
+
#
|
31
|
+
# @example
|
32
|
+
# schema = generator.generate(expand: true, relationships_to_exclude_from_expansion: ['some_relationship'], collection: true)
|
33
|
+
#
|
34
|
+
# @return [Hash] The generated schema.
|
35
|
+
def generate(expand: false, relationships_to_exclude_from_expansion: [], collection: false)
|
36
|
+
data = {
|
37
|
+
type: :object,
|
38
|
+
properties: {
|
39
|
+
type: { type: :string, default: @model_definition.model_name },
|
40
|
+
id: { type: :string },
|
41
|
+
attributes: AttributeSchemaGenerator.new(@model_definition).generate
|
42
|
+
}.merge(
|
43
|
+
if @model_definition.relationships.blank? || @model_definition.relationships == { belongs_to: {}, has_many: {} }
|
44
|
+
{}
|
45
|
+
else
|
46
|
+
{ relationships: RelationshipSchemaGenerator.new(@model_definition).generate(expand:, relationships_to_exclude_from_expansion:) }
|
47
|
+
end
|
48
|
+
)
|
49
|
+
}
|
50
|
+
|
51
|
+
schema = collection ? { data: { type: :array, items: data } } : { data: }
|
52
|
+
|
53
|
+
if expand
|
54
|
+
included_schema = IncludedSchemaGenerator.new(@model_definition).generate(expand:, relationships_to_exclude_from_expansion:)
|
55
|
+
@schema_modifier.add_properties(schema, included_schema, '.')
|
56
|
+
end
|
57
|
+
|
58
|
+
@schema_modifier.add_properties(schema, { meta: }, '.') if collection
|
59
|
+
@schema_modifier.add_properties(schema, { jsonapi: }, '.')
|
60
|
+
|
61
|
+
{ type: :object, properties: schema }.compact_blank
|
62
|
+
end
|
63
|
+
|
64
|
+
# Generates the JSON schema for the 'meta' part of a response.
|
65
|
+
# It returns a custom meta response schema if one is defined in the configuration, otherwise it generates a default meta schema.
|
66
|
+
#
|
67
|
+
# @example
|
68
|
+
# meta_schema = generator.meta
|
69
|
+
#
|
70
|
+
# @return [Hash] The generated schema for the 'meta' part of a response.
|
71
|
+
def meta
|
72
|
+
return @configuration.custom_meta_response_schema if @configuration.custom_meta_response_schema.present?
|
73
|
+
|
74
|
+
if @configuration.pagination_enabled
|
75
|
+
{
|
76
|
+
type: :object,
|
77
|
+
properties: {
|
78
|
+
page: {
|
79
|
+
type: :object,
|
80
|
+
properties: {
|
81
|
+
totalPages: {
|
82
|
+
type: :integer,
|
83
|
+
default: 1
|
84
|
+
},
|
85
|
+
count: {
|
86
|
+
type: :integer,
|
87
|
+
default: 1
|
88
|
+
},
|
89
|
+
rowsPerPage: {
|
90
|
+
type: :integer,
|
91
|
+
default: 1
|
92
|
+
},
|
93
|
+
currentPage: {
|
94
|
+
type: :integer,
|
95
|
+
default: 1
|
96
|
+
}
|
97
|
+
}
|
98
|
+
}
|
99
|
+
}
|
100
|
+
}
|
101
|
+
else
|
102
|
+
{}
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Generates the JSON schema for the 'jsonapi' part of a response.
|
107
|
+
#
|
108
|
+
# @example
|
109
|
+
# jsonapi_schema = generator.jsonapi
|
110
|
+
#
|
111
|
+
# @return [Hash] The generated schema for the 'jsonapi' part of a response.
|
112
|
+
def jsonapi
|
113
|
+
{
|
114
|
+
type: :object,
|
115
|
+
properties: {
|
116
|
+
version: {
|
117
|
+
type: :string,
|
118
|
+
default: '1.0'
|
119
|
+
}
|
120
|
+
}
|
121
|
+
}
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,248 @@
|
|
1
|
+
module Schemable
|
2
|
+
# The SchemaModifier class provides methods for modifying a given schema.
|
3
|
+
# It includes methods for parsing paths, checking if a path exists in a schema,
|
4
|
+
# deeply merging hashes, adding properties to a schema, and deleting properties from a schema.
|
5
|
+
#
|
6
|
+
# @see Schemable
|
7
|
+
class SchemaModifier
|
8
|
+
|
9
|
+
# Parses a given path into an array of symbols.
|
10
|
+
#
|
11
|
+
# @note This method accepts paths in the following formats:
|
12
|
+
# - 'path.to.property'
|
13
|
+
# - 'path.to.array.[0].property'
|
14
|
+
#
|
15
|
+
# @example
|
16
|
+
# parse_path('path.to.property') #=> [:path, :to, :property]
|
17
|
+
# parse_path('path.to.array.[0].property') #=> [:path, :to, :array, :[0], :property]
|
18
|
+
#
|
19
|
+
# @param path [String] The path to parse.
|
20
|
+
# @return [Array<Symbol>] The parsed path.
|
21
|
+
def parse_path(path)
|
22
|
+
path.split('.').map(&:to_sym)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Checks if a given path exists in a schema.
|
26
|
+
#
|
27
|
+
# @example
|
28
|
+
# schema = {
|
29
|
+
# path: {
|
30
|
+
# type: :object,
|
31
|
+
# properties: {
|
32
|
+
# to: {
|
33
|
+
# type: :object,
|
34
|
+
# properties: {
|
35
|
+
# property: {
|
36
|
+
# type: :string
|
37
|
+
# }
|
38
|
+
# }
|
39
|
+
# }
|
40
|
+
# }
|
41
|
+
# }
|
42
|
+
# }
|
43
|
+
#
|
44
|
+
# path = 'path.properties.to.properties.property'
|
45
|
+
# incorrect_path = 'path.properties.to.properties.invalid'
|
46
|
+
# path_exists?(schema, path) #=> true
|
47
|
+
# path_exists?(schema, incorrect_path) #=> false
|
48
|
+
#
|
49
|
+
# @param schema [Hash, Array] The schema to check.
|
50
|
+
# @param path [String] The path to check for.
|
51
|
+
# @return [Boolean] True if the path exists in the schema, false otherwise.
|
52
|
+
def path_exists?(schema, path)
|
53
|
+
path_segments = parse_path(path)
|
54
|
+
|
55
|
+
path_segments.reduce(schema) do |current_segment, next_segment|
|
56
|
+
if current_segment.is_a?(Array)
|
57
|
+
# The regex pattern '/\[(\d+)\]|\d+/' matches square brackets containing one or more digits,
|
58
|
+
# or standalone digits. Used for parsing array indices in a path.
|
59
|
+
index = next_segment.to_s.match(/\[(\d+)\]|\d+/)[1]
|
60
|
+
# The regex pattern '/\A\d+\z/' matches a sequence of one or more digits from the start ('\A')
|
61
|
+
# to the end ('\z') of a string. It checks if a string consists of only digits.
|
62
|
+
return false if index.nil? || !index.match?(/\A\d+\z/) || index.to_i >= current_segment.length
|
63
|
+
|
64
|
+
current_segment[index.to_i]
|
65
|
+
else
|
66
|
+
return false unless current_segment.is_a?(Hash) && current_segment.key?(next_segment)
|
67
|
+
|
68
|
+
current_segment[next_segment]
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
true
|
73
|
+
end
|
74
|
+
|
75
|
+
# Deeply merges two hashes.
|
76
|
+
#
|
77
|
+
# @example
|
78
|
+
# destination = { level1: { level2: { level3: 'value' } } }
|
79
|
+
# new_data = { level1_again: 'value' }
|
80
|
+
# deep_merge_hashes(destination, new_data)
|
81
|
+
# #=> { level1: { level2: { level3: 'value' } }, level1_again: 'value' }
|
82
|
+
#
|
83
|
+
# new_destination = [{ object1: 'value' }, { object2: 'value' }]
|
84
|
+
# new_new_data = { object3: 'value' }
|
85
|
+
# deep_merge_hashes(new_destination, new_new_data)
|
86
|
+
# #=> [{ object1: 'value' }, { object2: 'value' }, { object3: 'value' }]
|
87
|
+
#
|
88
|
+
# new_destination = { object1: 'value' }
|
89
|
+
# new_new_data = [{ object2: 'value' }, { object3: 'value' }]
|
90
|
+
# deep_merge_hashes(new_destination, new_new_data)
|
91
|
+
# #=> { object1: 'value', object2: 'value', object3: 'value' }
|
92
|
+
#
|
93
|
+
# @param destination [Hash] The hash to merge into.
|
94
|
+
# @param new_data [Hash] The hash to merge from.
|
95
|
+
# @return [Hash] The merged hashes.
|
96
|
+
def deep_merge_hashes(destination, new_data)
|
97
|
+
if destination.is_a?(Hash) && new_data.is_a?(Array)
|
98
|
+
destination.merge(new_data)
|
99
|
+
elsif destination.is_a?(Array) && new_data.is_a?(Hash)
|
100
|
+
destination.push(new_data)
|
101
|
+
elsif destination.is_a?(Hash) && new_data.is_a?(Hash)
|
102
|
+
new_data.each do |key, value|
|
103
|
+
if destination[key].is_a?(Hash) && value.is_a?(Hash)
|
104
|
+
destination[key] = deep_merge_hashes(destination[key], value)
|
105
|
+
elsif destination[key].is_a?(Array) && value.is_a?(Array)
|
106
|
+
destination[key].concat(value)
|
107
|
+
elsif destination[key].is_a?(Array) && value.is_a?(Hash)
|
108
|
+
destination[key].push(value)
|
109
|
+
else
|
110
|
+
destination[key] = value
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
destination
|
116
|
+
end
|
117
|
+
|
118
|
+
# Adds properties to a schema at a given path.
|
119
|
+
#
|
120
|
+
# @example
|
121
|
+
# original_schema = { level1: { level2: { level3: 'value' } } }
|
122
|
+
# new_data = { L3: 'value' }
|
123
|
+
# path = 'level1.level2'
|
124
|
+
# add_properties(original_schema, new_schema, path)
|
125
|
+
# #=> { level1: { level2: { level3: 'value', L3: 'value' } } }
|
126
|
+
#
|
127
|
+
# new_original_schema = { test: [{ object1: 'value' }, { object2: 'value' }] }
|
128
|
+
# new_new_schema = { object2_again: 'value' }
|
129
|
+
# path = 'test.[1]'
|
130
|
+
# add_properties(new_original_schema, new_new_schema, path)
|
131
|
+
# #=> { test: [{ object1: 'value' }, { object2: 'value', object2_again: 'value' }] }
|
132
|
+
#
|
133
|
+
# @param original_schema [Hash] The original schema.
|
134
|
+
# @param new_schema [Hash] The new schema to add.
|
135
|
+
# @param path [String] The path at which to add the new schema.
|
136
|
+
# @note This method accepts paths in the following formats:
|
137
|
+
# - 'path.to.property'
|
138
|
+
# - 'path.to.array.[0].property'
|
139
|
+
# - '.'
|
140
|
+
#
|
141
|
+
# @return [Hash] The modified schema.
|
142
|
+
def add_properties(original_schema, new_schema, path)
|
143
|
+
return deep_merge_hashes(original_schema, new_schema) if path == '.'
|
144
|
+
|
145
|
+
unless path_exists?(original_schema, path)
|
146
|
+
puts "Error: Path '#{path}' does not exist in the original schema"
|
147
|
+
return original_schema
|
148
|
+
end
|
149
|
+
|
150
|
+
path_segments = parse_path(path)
|
151
|
+
current_segment = original_schema
|
152
|
+
last_segment = path_segments.pop
|
153
|
+
|
154
|
+
# Navigate to the specified location in the schema
|
155
|
+
path_segments.each do |segment|
|
156
|
+
if current_segment.is_a?(Array)
|
157
|
+
index = segment.to_s.match(/\[(\d+)\]|\d+/)[1]
|
158
|
+
if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length
|
159
|
+
current_segment = current_segment[index.to_i]
|
160
|
+
else
|
161
|
+
puts "Error: Invalid index in path '#{path}'"
|
162
|
+
return original_schema
|
163
|
+
end
|
164
|
+
elsif current_segment.is_a?(Hash) && current_segment.key?(segment)
|
165
|
+
current_segment = current_segment[segment]
|
166
|
+
else
|
167
|
+
puts "Error: Expected a Hash but found #{current_segment.class} in path '#{path}'"
|
168
|
+
return original_schema
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# Merge the new schema into the specified location
|
173
|
+
if current_segment.is_a?(Array)
|
174
|
+
index = last_segment.to_s.match(/\[(\d+)\]|\d+/)[1]
|
175
|
+
if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length
|
176
|
+
current_segment[index.to_i] = deep_merge_hashes(current_segment[index.to_i], new_schema)
|
177
|
+
else
|
178
|
+
puts "Error: Invalid index in path '#{path}'"
|
179
|
+
end
|
180
|
+
else
|
181
|
+
current_segment[last_segment] = deep_merge_hashes(current_segment[last_segment], new_schema)
|
182
|
+
end
|
183
|
+
|
184
|
+
original_schema
|
185
|
+
end
|
186
|
+
|
187
|
+
# Deletes properties from a schema at a given path.
|
188
|
+
#
|
189
|
+
# @example
|
190
|
+
# original_schema = { level1: { level2: { level3: 'value' } } }
|
191
|
+
# path = 'level1.level2'
|
192
|
+
# delete_properties(original_schema, path)
|
193
|
+
# #=> { level1: {} }
|
194
|
+
#
|
195
|
+
# new_original_schema = { test: [{ object1: 'value' }, { object2: 'value' }] }
|
196
|
+
# path = 'test.[1]'
|
197
|
+
# delete_properties(new_original_schema, path)
|
198
|
+
# #=> { test: [{ object1: 'value' }] }
|
199
|
+
#
|
200
|
+
# @param original_schema [Hash] The original schema.
|
201
|
+
# @param path [String] The path at which to delete properties.
|
202
|
+
# @return [Hash] The modified schema.
|
203
|
+
def delete_properties(original_schema, path)
|
204
|
+
return original_schema if path == '.'
|
205
|
+
|
206
|
+
unless path_exists?(original_schema, path)
|
207
|
+
puts "Error: Path '#{path}' does not exist in the original schema"
|
208
|
+
return original_schema
|
209
|
+
end
|
210
|
+
|
211
|
+
path_segments = parse_path(path)
|
212
|
+
current_segment = original_schema
|
213
|
+
last_segment = path_segments.pop
|
214
|
+
|
215
|
+
# Navigate to the parent of the last segment in the path
|
216
|
+
path_segments.each do |segment|
|
217
|
+
if current_segment.is_a?(Array)
|
218
|
+
index = segment.to_s.match(/\[(\d+)\]|\d+/)[1]
|
219
|
+
if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length
|
220
|
+
current_segment = current_segment[index.to_i]
|
221
|
+
else
|
222
|
+
puts "Error: Invalid index in path '#{path}'"
|
223
|
+
return original_schema
|
224
|
+
end
|
225
|
+
elsif current_segment.is_a?(Hash) && current_segment.key?(segment)
|
226
|
+
current_segment = current_segment[segment]
|
227
|
+
else
|
228
|
+
puts "Error: Expected a Hash but found #{current_segment.class} in path '#{path}'"
|
229
|
+
return original_schema
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# Delete the last segment in the path
|
234
|
+
if current_segment.is_a?(Array)
|
235
|
+
index = last_segment.to_s.match(/\[(\d+)\]|\d+/)[1]
|
236
|
+
if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length
|
237
|
+
current_segment.delete_at(index.to_i)
|
238
|
+
else
|
239
|
+
puts "Error: Invalid index in path '#{path}'"
|
240
|
+
end
|
241
|
+
else
|
242
|
+
current_segment.delete(last_segment)
|
243
|
+
end
|
244
|
+
|
245
|
+
original_schema
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
data/lib/schemable/version.rb
CHANGED