# encoding: UTF-8 module Spontaneous module Schema autoload :UID, 'spontaneous/schema/uid' autoload :UIDMap, 'spontaneous/schema/uid_map' autoload :SchemaModification, 'spontaneous/schema/schema_modification' # schema class <=> uid map backed by a file class PersistentMap attr_reader :map, :inverse_map, :uids def initialize(uids, path) @uids, @path = uids, path load_map end def to_id(obj) reference_to_id(obj.schema_name) end def reference_to_id(reference) uids.get_id(reference) end def to_class(id) if uid = uids[id] uid.target else nil end end def load_map if exists? && (map = parse_map) map.each do | uid, reference | uids.load(uid, reference) end end end def parse_map YAML.load_file(@path) end def exists? ::File.exists?(@path) end def valid? exists? && parse_map.is_a?(Hash) end # def invert_map # @inverse_map = generate_inverse # end # def generate_inverse # Hash[ UID.map { |uid| [uid.reference, uid]} ] # end def orphaned_ids uids.select { |uid| uid.orphaned? } end def reload! # invert_map end end # schema class <=> uid map with no backing, each run will generate different uids and # no schema validation errors will ever be thrown # used for tests class TransientMap < PersistentMap def initialize(uids, path) @uids = uids end def to_id(obj) if id = super id else uids.create(obj.schema_name) end end def orphaned_ids [] end def exists? true end def valid? true end end def self.new(root, schema_loader_class = Spontaneous::Schema::PersistentMap) Schema.new(root, schema_loader_class) end class Schema attr_accessor :schema_loader_class attr_reader :uids def initialize(root, schema_loader_class = Spontaneous::Schema::PersistentMap) @root = root @schema_loader_class = schema_loader_class @subclass_map = Hash.new { |h, k| h[k] = [] } initialize_uid_map end def schema_loader_class=(klass) initialize_uid_map @map = nil @schema_loader_class = klass end # validate the schema & attempt to fix anything that can be resolved without human # interaction (i.e. pure additions) def validate! begin validate_schema rescue Spontaneous::SchemaModificationError => e changes = e.modification # if the map file is missing, then this is a first run and we can just # create the thing by populating it with the current schema if !map.valid? logger.warn("Generating new schema") generate_new_schema else if changes.resolvable? logger.warn("Schema changed...") attempts = 0 while changes and changes.resolvable? do logger.warn("Fixing automatically") changes.resolve! map.reload! changes = perform_validation raise "Infinite loop in schema resolution" if (attempts += 1) >= 5 end write_schema reload! else logger.warn("Unable to resolve schema changes") raise e end end end end def apply(action) apply_fix(action.action, action.source, action.dest) end def apply_fix(action, source, dest=nil) uid = uids[source] case action when :delete uids.destroy(uid) when :rename uid.rewrite!(dest) end write_schema reload! validate! logger.info("✓ Schema updated successfully") end def generate_new_schema logger.info("Generating new schema map at #{schema_map_file}") self.schema_loader_class = TransientMap classes.each do | schema_class | generate_schema_for(schema_class) end write_schema self.schema_loader_class = PersistentMap end def write_schema File.atomic_write(schema_map_file) do |file| file.write(uids.export.to_yaml) end end def generate_schema_for(obj) uids.create_for(obj) [:boxes, :fields, :styles, :layouts].each do |category| if obj.respond_to?(category) objects = obj.send(category) objects.each do | c | generate_schema_for(c) end end end end # look for differences between identities found in schema map and # those defined in the schema classes and raise an error if any # are found def validate_schema modification = perform_validation unless modification.nil? raise Spontaneous::SchemaModificationError.new(modification) end end def perform_validation modification = nil @missing_from_map = Hash.new { |hash, key| hash[key] = [] } @missing_from_schema = [] validate_classes unless @missing_from_map.empty? and @missing_from_schema.empty? modification = SchemaModification.new(@missing_from_map, @missing_from_schema) end modification end def validate_classes # will check that each of the classes in the schema has a # corresponding id self.classes.each do | schema_class | schema_class.schema_validate(self) end # now check that each of the ids in the map has a # corresponding entry in the schema find_orphaned_ids end def find_orphaned_ids map.orphaned_ids.each do |uid| @missing_from_schema << uid end end def missing_id!(category, obj) @missing_from_map[category] << obj end def export(user = nil) self.content_classes.inject({}) do |hash, klass| hash[klass.ui_class] = klass.export(user) hash end end def serialise_http(user = nil) Spontaneous.serialise_http(export(user)) end def unfiltered_classes @classes ||= [] end # all classes including boxes def classes unfiltered_classes.reject { |c| is_excluded_type?(c) } end def inherited(supertype, type) inheritance_map[supertype.to_s] << type unfiltered_classes << type end def is_excluded_type?(type) excluded_types.include?(type) end def excluded_types [Spontaneous::Content, Spontaneous::Content::Page, Spontaneous::Content::Piece] end def inheritance_map @inheritance_map ||= empty_inheritance_map end def empty_inheritance_map Hash.new { |h, k| h[k] = [] } end def subclasses_of(type) inheritance_map[type.to_s].map { |subclass| subclass } end def descendents_of(type) subclasses_of(type).flat_map{ |x| [x] + descendents_of(x) } end alias_method :subclasses, :descendents_of # just subclasses of Content (excluding boxes) # only need this for the serialisation (which doesn't include boxes) # # TODO: Find a way to filter out the top-level classes without hard-coding # them here. def content_classes classes.reject { |k| k.is_box? }.uniq end def recurse_classes(root_class, list) root_class.subclasses.each do |klass| list << klass unless list.include?(klass) recurse_classes(klass, list) end end def reset! @classes = [] @inheritance_map = nil reload! end def reload! @map = nil initialize_uid_map end def initialize_uid_map @uids = Spontaneous::Schema::UIDMap.new end # It's obvious from this method that schema classes are # stored in too many ways def delete(klass) constants_of(klass).each { |const| delete(const) } unfiltered_classes.delete(klass) remove_group_members(klass) inheritance_map.delete(klass.to_s) inheritance_map.each do |supertype, subtypes| subtypes.delete(klass) end end def constants_of(klass) return [] unless klass.respond_to?(:constants) klass.constants. select { |c| klass.const_defined?(c, false) }. map { |c| klass.const_get(c) } end def schema_map_file @schema_map_file ||= @root / "config" / "schema.yml" end def schema_map_file=(path) # force a reloading of the schema map @map = nil @schema_map_file = path end def map @map ||= self.schema_loader_class.new(@uids, schema_map_file) end def to_id(obj) map.to_id(obj) end def to_class(id) map.to_class(id) end def groups @groups ||= Hash.new { |h, k| h[k] = [] } end def add_group_member(schema_class, group_names) group_names.each do |name| group = groups[name.to_sym] group << schema_class.to_s unless group.include?(schema_class.to_s) end end def is_group?(group_name) groups.key?(group_name.to_sym) end def group_memberships(klass) classname = klass.to_s groups.select { |group, members| members.include?(classname) }.keys end def remove_group_members(klass) type = klass.to_s groups.each do |group, members| members.delete(type) end end end end end