Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Duplicate key warnings #4

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/config_hound/loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ class Loader
DEFAULT_INCLUDE_KEY = "_include"

attr_accessor :include_key
attr_accessor :allow_duplicate_keys

def initialize(options = {})
@include_key = DEFAULT_INCLUDE_KEY
@allow_duplicate_keys = true
options.each do |k, v|
public_send("#{k}=", v)
end
Expand All @@ -26,7 +28,7 @@ def load(sources)
def load_source(source)
return source if source.is_a?(Hash)
resource = Resource[source]
raw_data = resource.load
raw_data = resource.load(allow_duplicate_keys: allow_duplicate_keys)
includes = Array(raw_data.delete(include_key))
included_resources = includes.map do |relative_path|
resource.resolve(relative_path)
Expand Down
39 changes: 16 additions & 23 deletions lib/config_hound/parser.rb
Original file line number Diff line number Diff line change
@@ -1,36 +1,29 @@
module ConfigHound
require 'config_hound/parser/duplicate_key_error'
require 'config_hound/parser/json'
require 'config_hound/parser/toml'
require 'config_hound/parser/yaml'

module ConfigHound
class Parser

def self.parse(*args)
new.parse(*args)
end

def parse(raw, format)
parse_method = "parse_#{format}"
raise "unknown format: #{format}" unless respond_to?(parse_method, true)
send(parse_method, raw)
end
def parse(raw, format, options={})
begin
parser = eval("#{self.class}::#{format.upcase}")
rescue NameError
raise "unknown format: #{format}"
end

protected
if !options[:allow_duplicate_keys] && parser.respond_to?(:find_duplicate_keys)
duplicates = parser.find_duplicate_keys(raw)
raise DuplicateKeyError.new(duplicates) if duplicates.any?
end

def parse_yaml(raw)
require "yaml"
YAML.safe_load(raw, permitted_classes: [], permitted_symbols: [], aliases: true)
end

alias :parse_yml :parse_yaml

def parse_json(raw)
require "multi_json"
MultiJson.load(raw)
end

def parse_toml(raw)
require "toml"
TOML.load(raw)
parser.parse(raw)
end

end

end
12 changes: 12 additions & 0 deletions lib/config_hound/parser/duplicate_key_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module ConfigHound
class Parser
class DuplicateKeyError < ConfigHound::Error
attr_reader :duplicates

def initialize(duplicates)
@duplicates = duplicates
super(duplicates)
end
end
end
end
30 changes: 30 additions & 0 deletions lib/config_hound/parser/json.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module ConfigHound
class Parser
class JSON
def self.parse(raw)
require "multi_json"
MultiJson.load(raw)
end

def self.find_duplicate_keys(raw)
require "ext/duplicate_key_checking_hash"
require "multi_json"
parsed = MultiJson.load(raw, object_class: DuplicateKeyCheckingHash)
find_deep_duplicates([], parsed)
end

def self.find_deep_duplicates(parents, hash)
return [] unless hash.is_a?(Hash)

duplicates = hash.duplicate_keys.map{|d| [parents, d].join('.') }

hash.each do |key, value|
next unless value.is_a?(Hash)
duplicates += find_deep_duplicates(parents + [key], value)
end

duplicates
end
end
end
end
10 changes: 10 additions & 0 deletions lib/config_hound/parser/toml.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module ConfigHound
class Parser
class TOML
def self.parse(raw)
require "toml"
::TOML.load(raw)
end
end
end
end
40 changes: 40 additions & 0 deletions lib/config_hound/parser/yaml.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
module ConfigHound
class Parser
class YAML
def self.parse(raw)
require "yaml"
::YAML.safe_load(raw, permitted_classes: [], permitted_symbols: [], aliases: true)
end

def self.find_duplicate_keys(raw)
require "psych"

# Blatantly stolen from https://stackoverflow.com/a/55705853

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤣

duplicate_keys = []

validator = ->(node, parent_path) do
if node.is_a?(Psych::Nodes::Mapping)
children = node.children.each_slice(2)
duplicates = children.map { |key_node, _value_node| key_node }.group_by(&:value).select { |_value, nodes| nodes.size > 1 }

duplicates.each do |key, nodes|
duplicate_keys << (parent_path + [key]).join('.')
end

children.each { |key_node, value_node| validator.call(value_node, parent_path + [key_node&.value].compact) }
else
node.children.to_a.each { |child| validator.call(child, parent_path) }
end
end

ast = Psych.parse_stream(raw)
validator.call(ast, [])

duplicate_keys
end
end

class YML < YAML ; end

end
end
4 changes: 2 additions & 2 deletions lib/config_hound/resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ def format
File.extname(uri.path.to_s)[1..-1]
end

def load
Parser.parse(read, format)
def load(options={})
Parser.parse(read, format, options)
end

private
Expand Down
16 changes: 16 additions & 0 deletions lib/ext/duplicate_key_checking_hash.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class DuplicateKeyCheckingHash < Hash
attr_reader :duplicate_keys

def initialize(default=nil)
@duplicate_keys = []
super(default)
end

def []=(key, value)
if has_key?(key)
@duplicate_keys << key
end

super(key, value)
end
end
47 changes: 47 additions & 0 deletions spec/features/duplicate_keys_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
require "spec_helper"

require "config_hound"

describe ConfigHound, "duplicate_keys" do

let(:config) { ConfigHound.load(resource, options) }

given_resource "config.yaml", %{
foo:
bar: baz
bar: qux
}

given_resource "config.json", %[
{ "foo": { "bar": "baz", "bar": "qux" } }
]

%w(yaml json).each do |type|

context "in #{type}" do

let(:resource) { "config.#{type}" }

context "with duplicate keys allowed" do
let(:options) { { allow_duplicate_keys: true } }

it "takes the later key" do
expect(config).to eq("foo" => { "bar" => "qux" })
end
end

context "with duplicate keys disabled" do
let(:options) { { allow_duplicate_keys: false } }

it "raises a DuplicateKeyError" do
expect { config }.to raise_error(ConfigHound::Parser::DuplicateKeyError)
end

it "tells you which key is duplicated" do
expect { config }.to raise_error(ConfigHound::Parser::DuplicateKeyError, /foo\.bar/)
end
end
end

end
end