Модуль:WDBackend (Bk;rl,&WDBackend)

Перейти к навигации Перейти к поиску
Документация

Модуль предназначен для получения информации из Викиданных по задаваемой схеме. Схема описывается в формате таблицы Lua.

Модуль используется в модуле Модуль:CiteGost/WDSource для получения информации об источниках информации. Модуль WDSource, в свою очередь, используется в модуле Модуль:CiteGost для оформления библиографических записей.

Использование

[править код]

Создать экземпляр объекта форматирования можно функцией модуля new() с указанием языка получения данных. Для получения данных по схеме можно использовать методы полученного объекта:

  • fetchEntity(таблица, элемент, схема) — получить данных по схеме из указанного элемента Викиданных.
  • fetch(таблица, схема) — получить данных по схеме (элементы Викиданных уже указаны в схеме).
  • ensureLang() — ассерт на то, что язык точно выбран.

Формат схемы

[править код]

Формат схемы в общем виде:

{
  -- Поле с указаным элементом Викиданных:
  {
    name = 'Имя поля с QID',
    -- Получить из элемента данные:
    get = {
      -- 1-е поле
      {
        name = 'Имя получаемого поля',

        property = 'P-идентификатор свойства',

        match = Значение, по которому будут обрабатываться квалификаторы (работает совместно с qualifiers),

        getValue = Опциональная функция получения значения (должна возвращать два аргумента: текст и язык текста),

        getData = Опциональная функция получения данных о значении (помимо значения, например, может возвращать его язык и флаг fromLabel получения данных из метки элемента),

        getLabel = Опциональный флаг получения метки элемента Викиданных по его идентификатору, заданному через параметр entity или уже присутствующему в таблице.

        max = Опциональное максимальное количество обрабатываемых значений,

        -- Подмена элементов Викиданных согласно отображению (ключ заменяется на значение):
        mapEntities = { QID 1 = 'QID 2', ... },

        -- Фильтрация по разрешённых элементам Викиданных:
        allowedEntities = { QID 1, QID 2, ... },

        defaultUnit = QID единицы измерения по умолчанию,

        -- Фильтрация по разрешённым единицам измерения:
        allowedUnits = { QID единицы измерения 1, QID единицы измерения 2, ... },

        -- Перезаписать поле, если у него уже задано значение:
        overwrite = true,

        -- Перезаписать идентификатор элемента Викиданных родительского поля (для безымянных полей):
        overwriteEntity = true,

        -- Перезаписать значение родительского поля (для безымянных полей):
        overwriteValue = true,

        -- Сделать поле вложенным полем для родительского (поместить в components):
        isLocal = true,

        -- Пометить значение как точное (например, как указано в источнике):
        exact = true,

        -- Принудительно делать из поля массив:
        isArray = true,

        -- Подставить значение текущего поля в другое по шаблону, заданному ещё одним полем:
        substInto = {
          name = 'Целевое поле (куда записываем)',
          template = {
            name = 'Имя поля, в значении которого записан шаблон',
          },
        }

        -- Получить другие поля из элемента Викиданных данного поля:
        get = {
          -- ...
        },

        -- Если текущее поле не удалось получить, то получить другие поля:
        elseGet = {
          -- ...
        },
      },
      -- ...
    },
  },
  -- ...
}

Общий формат отдельного поля:

{
  value = Значение (в случае даты  таблица)
  entity = Идентификатор элемента Викиданных
  unitEntity = Идентификатор элемента Викиданных единицы измерения, соответствующей значению value
  retrieved = Флаг, обозначающий, что поле было получено из Викиданных (а не заполнено вручную)
  exact = Флаг, обозначающий, что получено уточнённое значение поля
  lang = Язык, соответствующий значению
  components = {
    -- Вложенные поля
    -- ...
  },
}

Принцип работы

[править код]

При обходе полей схемы, если в поле уже есть значение, то второй раз оно уже не будет получено, если только не указано overwrite=true, что подразумевает перезапись ранее заданного или полученного значения. Таким образом, можно в одно и то же поле пытаться получать значение по очереди из разных свойств. Получено будет первое попавшееся значение. По этому принципу (порядок получения) можно выстраивать приоритет получения данных из разных ствойств.

Если при обходе элементов значение очередного элемента было получено (ранее не было задано), то обходятся квалификаторы соответствующего элемента. В противном случае квалификаторы не обходятся. В случае неименованных аргументов квалификаторы обходятся всегда.

Если в поле не задано имя, то полученное значение никуда не записывается. Если же задан параметр overwriteEntity, перезаписывается идентификатор родительского элемента. Если задан параметр overwriteValue, — значение родительского элемента. Безымянные поля удобны для перезаписи значений родительских полей из квалификаторов, либо же для получения свойств из полученного в безымянном поле элемента через get.

Внесение изменений

[править код]

При исправлении ошибки, пожалуйста, сначала добавьте тест, который будет проваливаться из-за обнаруженной ошибки, и только затем вносите исправление. При внесении исправления проверьте, чтобы все тесты проходили. Вносить исправление можно только, если оно не ломает другие тесты.

Добавление нового функционала рекомендуется делать у себя в песочнице, скопировав в неё модуль. В правке копирования необходимо указать тот факт, что делается копирование, и сделать ссылку на оригинальный модуль в виде викитекста. При добавлении нового функционала сначала желательно добавить тест на этот функционал, затем добавить сам функционал, убедившись, что все тесты при этом проходят.

✔ Все тесты пройдены.

Название Ожидается Фактически
✔ test_fetchEntity_array
✔ test_fetchEntity_baseTypes
✔ test_fetchEntity_components
✔ test_fetchEntity_defaultUnit
✔ test_fetchEntity_elseGet_exists
✔ test_fetchEntity_elseGet_in_inLocal_with_overwriteEntity
✔ test_fetchEntity_elseGet_notExists
✔ test_fetchEntity_forceGet
✔ test_fetchEntity_forceGet_predefined_value
✔ test_fetchEntity_get
✔ test_fetchEntity_getValue
✔ test_fetchEntity_has
✔ test_fetchEntity_isArray
✔ test_fetchEntity_isLocal_in_array
✔ test_fetchEntity_isLocal_in_unnamed
✔ test_fetchEntity_isLocal_with_qualifiers_and_get_by_entity_with_isLocal
✔ test_fetchEntity_map
✔ test_fetchEntity_max
✔ test_fetchEntity_noOverwrite
✔ test_fetchEntity_overwrite
✔ test_fetchEntity_overwriteByQualifier
✔ test_fetchEntity_overwriteEntity
✔ test_fetchEntity_overwriteValue
✔ test_fetchEntity_overwriteValueByQualifier
✔ test_fetchEntity_qualifiers
✔ test_fetchEntity_substInto


Разработка

[править код]
require('strict')

local p = {}

local NS_MODULE = 828 --: https://www.mediawiki.org/wiki/Extension_default_namespaces
local moduleNamespace = mw.site.namespaces[NS_MODULE].name

local base = require(moduleNamespace .. ':WDBase')

local Backend = {}

function Backend:new(lang)
	local defaultLangObj = mw.getContentLanguage()
	local defaultLang = defaultLangObj:getCode()
	local obj = {
		lang = lang,
		defaultLang = defaultLang
	}
	setmetatable(obj, self)
	self.__index = self
	
	return obj
end

function Backend:safeField(source, fieldName, parentField)
	if not parentField then
		parentField = source
	end
	local info = parentField[fieldName]
	if not info then
		info = {}
	end
	return info
end

function Backend:parseFieldPath(source, map, parentField)
	local fieldName = map.name
	local currParentComponents = source
	local currParentField = nil
	if map.isLocal or not fieldName then
		currParentField = parentField
		if parentField then
			currParentComponents = currParentField.components or {}
		end
	end
	if type(fieldName) == 'table' then
		local lastParentField = currParentField
		local lastParentComponents = currParentComponents
		local lastNotFound = false
		for i, name in ipairs(fieldName) do
			if i ~= 1 then
				-- It's much easier to break at last iteration, but it's a problem
				-- to get the count of parts if the map will be loaded by mw.loadData()
				if lastNotFound then
					return nil
				end
				currParentField = lastParentField
				currParentComponents = lastParentComponents
			end
			if lastParentComponents then
				lastParentField = lastParentComponents[name]
				lastParentComponents = lastParentField and lastParentField.components
			else
				lastNotFound = true
			end
			fieldName = name
		end
		currParentComponents = currParentComponents or {}
	end
	return currParentField, currParentComponents, fieldName
end

function Backend:trySetField(source, fieldName, info, parentField)
	if info.value or info.entity then
		if not parentField then
			parentField = source
		end
		parentField[fieldName] = info
	end
end

local function inArray(value, array)
	for _, currValue in ipairs(array) do
		if currValue == value then
			return true
		end
	end
	return false
end

function Backend:getLang(map)
	return (map.useDefaultLang and self.defaultLang) or self.lang
end

function Backend:fetchFieldsByQualifiers(source, fieldMap, qualifiers, parentField)
	for _, map in ipairs(fieldMap) do
		local qualifier = qualifiers[map.property]
		if qualifier then
			if map.filter then
				qualifier = map.filter(qualifier, self:getLang(map))
			end
			self:fetchFieldByMap(source, map, qualifier, base.dataBySnak, parentField)
		end
	end
end

function Backend:fetchFieldItem(source, map, statementOrSnak, getData)
	local lang = self:getLang(map)
	local item = getData(statementOrSnak, lang, map.cache)
	if item then
		item.retrieved = true
	else
		item = {}
	end
	if map.mapEntity then
		local entity = map.mapEntity[item.entity]
		if not entity then
			return nil
		end
		item = base.dataByEntity(entity, lang, map.cache)
		if item then
			item.retrieved = true
		end
	end

	if item.entity then
		if map.getData then
			item = map.getData(item.entity, lang)
			item.retrieved = true
		elseif map.getValue then
			item.value, item.lang = map.getValue(item.entity, lang)
		end
	end
	if map.allowedEntities and item.entity and not inArray(item.entity, map.allowedEntities) then
		return nil
	end

	if map.defaultUnit and not item.unitEntity then
		item.unitEntity = map.defaultUnit
	end
	if map.allowedUnits then
		if not item.unitEntity or not inArray(item.unitEntity, map.allowedUnits) then
			return nil
		end
	end

	if not item.value and not item.entity then
		return nil
	end
	
	if map.exact then
		item.exact = true
	end

	return item
end

local function skipGetIf(item, cond)
	if not cond then
		return false
	end
	
	for key, value in pairs(cond) do
		if item[key] == value then
			return true
		end
	end
	
	return false
end

function Backend:tryGet(source, map, getTable, items, currParentField)
	if not getTable then
		return
	end

	local fieldName = map.name
	if fieldName then
		for j, item in ipairs(items) do
			if not skipGetIf(item, map.skipGetIf) then
				self:fetchFieldsByMap(source, item.entity, getTable, item)
			end
		end
	else
		for j, item in ipairs(items) do
			if not skipGetIf(item, map.skipGetIf) then
				self:fetchFieldsByMap(source, item.entity, getTable, currParentField)
			end
		end
	end
end

function Backend:fetchFieldByMap(source, map, statementsOrSnaks, getData, parentField)
	local currParentField, currParentComponents, fieldName
			= self:parseFieldPath(source, map, parentField)
	if not currParentComponents then
		return
	end

	if fieldName then
		local fieldTable = self:safeField(source, fieldName, currParentField)
		if map.match and getData == base.dataByStatement and fieldTable.value then
			local statement = base.searchStatementByValue(statementsOrSnaks, fieldTable.value)
			if not statement then
				return
			end
			local item = self:fetchFieldItem(source, map, statement, getData)
			if item then
				currParentComponents[fieldName] = item
				if map.qualifiers and statement.qualifiers then
					self:fetchFieldsByQualifiers(source, map.qualifiers, statement.qualifiers, item)
				end
				local getTable = map.forceGet or map.get
				if getTable and not skipGetIf(item, map.skipGetIf) then
					self:fetchFieldsByMap(source, item.entity, getTable, currParentField)
				end
			else
				if map.elseGet then
					self:fetchFieldsByMap(source, parentField.entity, map.elseGet, currParentField)
				end
			end
			return
		end
		if fieldTable.value and not map.overwrite then
			if map.substInto and map.substInto.force then
				self:substFieldInto(source, map, parentField)
			end
			local items = fieldTable
			if table.getn(items) == 0 then
				items = { items }
			end
			self:tryGet(source, map, map.forceGet, items, currParentField)
			return
		end
	end

	local maxCount = map.max
	if not maxCount then
		maxCount = math.huge
	end

	local items = {}
	local indices = {}
	for i, statementOrSnak in ipairs(statementsOrSnaks) do
		if i > maxCount then
			break
		end
		if statementOrSnak ~= nil then
			local item = self:fetchFieldItem(source, map, statementOrSnak, getData)
			if item then
				table.insert(items, item)
				indices[i] = table.getn(items)
			else
				indices[i] = 0
			end
		end
	end

	local triggerElseGet = false
	if fieldName then
		if table.getn(items) == 1 and not map.isArray then
			currParentComponents[fieldName] = items[1]
		elseif next(items) ~= nil then
			currParentComponents[fieldName] = items
		else
			triggerElseGet = true
		end
	elseif next(items) ~= nil then
		if map.overwriteValue then
			parentField.value = items[1].value
			parentField.exact = map.exact
		end
		if map.overwriteEntity then
			parentField.entity = items[1].entity
		end
	else
		triggerElseGet = true
	end
	if currParentField and next(currParentComponents) ~= nil then
		currParentField.components = currParentComponents
	end

	if map.substInto then
		self:substFieldInto(source, map, parentField)
	end

	for i, statementOrSnak in ipairs(statementsOrSnaks) do
		if i > maxCount then
			break
		end
		local item = items[indices[i]]
		if map.qualifiers and statementOrSnak.qualifiers then
			self:fetchFieldsByQualifiers(source, map.qualifiers, statementOrSnak.qualifiers, item)
		end				
	end
	
	self:tryGet(source, map, map.forceGet or map.get, items, currParentField)

	if triggerElseGet and map.elseGet then
		self:fetchFieldsByMap(source, parentField.entity, map.elseGet, parentField)
	end
end

function Backend:fetchFieldByCustomFunc(source, entity, map, parentField)
	local currParentField, currParentComponents, fieldName
			= self:parseFieldPath(source, map, parentField)
	if not currParentComponents then
		return
	end

	local fieldTable = currParentComponents[fieldName]
	if fieldTable and fieldTable.value and not map.overwrite then
		return
	end

	local lang = self:getLang(map)
	if map.getData then
		fieldTable = map.getData(entity, lang)
	else
		fieldTable = fieldTable or {}
		fieldTable.value = map.getValue(entity, lang)
	end
	fieldTable.retrieved = true
	self:trySetField(source, fieldName, fieldTable, currParentComponents)
	if currParentField and next(currParentComponents) ~= nil then
		currParentField.components = currParentComponents
	end
end

function Backend:fetchFieldLabel(source, map, parentField)
	local currParentField, currParentComponents, fieldName
			= self:parseFieldPath(source, map, parentField)
	if not currParentComponents then
		return
	end

	local fieldTable = currParentComponents[fieldName]
	local entity
	if fieldTable then
		if fieldTable.value then
			return
		end
		entity = fieldTable.entity
	end
	entity = map.entity or fieldTable.entity
	if not entity then
		return
	end

	local components = fieldTable and fieldTable.components
	fieldTable = base.dataByEntity(entity, self:getLang(map), map.cache)
	fieldTable.components = components
	currParentComponents[fieldName] = fieldTable

	if currParentField and next(currParentComponents) ~= nil then
		currParentField.components = currParentComponents
	end
end

local function getPropertyByPath(source, propertyPath)
	local currField = source
	for _, pathEntry in ipairs(propertyPath) do
		currField = currField[pathEntry]
		if not currField then
			return nil
		end
	end
	return currField
end

local function filterStatementsByPropertiesAndValues(statements, properties)
	local filtered = {}
	local propsCount = table.getn(properties)
	for _, statement in ipairs(statements) do
		local matched = 0
		local qualifiers = statement.qualifiers
		if qualifiers then
			for _, propInfo in ipairs(properties) do
				local propQualifiers = qualifiers[propInfo.property]
				if propQualifiers then
					for _, propQualifier in ipairs(propQualifiers) do
						if propInfo.value then
							local value = base.valueBySnak(propQualifier)
							if value == propInfo.value then
								matched = matched + 1
							else
								break
							end
						else
							matched = matched + 1
						end
					end
				end
			end
		end
		if matched == propsCount then
			table.insert(filtered, statement)
		end
	end

	if not next(filtered) then
		return nil
	end
	return filtered
end

function Backend:tryForceGetByMap(source, map, parentField)
	local currParentField, currParentComponents, fieldName
			= self:parseFieldPath(source, map, parentField)
	if not currParentComponents then
		return
	end

	local fieldTable = self:safeField(source, fieldName, currParentComponents)
	local items = fieldTable
	if table.getn(items) == 0 then
		items = { fieldTable }
	end
	self:tryGet(source, map, map.forceGet, items, currParentField)
end

function Backend:substFieldInto(source, map, parentField)
	local currParentField, currParentComponents, fieldName
			= self:parseFieldPath(source, map, parentField)
	if not currParentComponents then
		return
	end

	local fieldTable = currParentComponents[fieldName]
	if not fieldTable then
		return
	end

	local targetCurrParentField, targetCurrParentComponents, targetFieldName
			= self:parseFieldPath(source, map.substInto, parentField)
	if not targetCurrParentComponents then
		return
	end

	local targetFieldTable = self:safeField(source, targetFieldName, targetCurrParentComponents)

	local templateCurrParentField, templateCurrParentComponents, templateFieldName
			= self:parseFieldPath(source, map.substInto.template, parentField)
	if not templateCurrParentComponents then
		return
	end

	local templateFieldTable = templateCurrParentComponents[templateFieldName]
	if not templateFieldTable then
		return
	end

	targetFieldTable.value = templateFieldTable.value:gsub('%$1', fieldTable.value)
	self:trySetField(source, targetFieldName, targetFieldTable, targetCurrParentComponents)
	if targetCurrParentField and next(targetCurrParentComponents) ~= nil then
		targetCurrParentField.components = targetCurrParentComponents
	end
end

function Backend:fetchFieldsByMap(source, entity, fieldMap, parentField)
	for _, map in ipairs(fieldMap) do
		local currEntity = entity
		if map.entity then
			currEntity = map.entity
		end
		if currEntity then
			local propertySpecified = false
			local statements

			if map.property then
				statements = base.statements(currEntity, map.property, map.cache)
				propertySpecified = true
			elseif map.properties then
				statements = base.statementsByProperties(currEntity, map.properties)
				propertySpecified = true
			elseif map.propertyPath then
				local property = getPropertyByPath(source, map.propertyPath)
				if property then
					statements = base.statements(currEntity, property, map.cache)
				end
				propertySpecified = true
			end

			if propertySpecified then
				if statements then
					if map.filter then
						statements = map.filter(statements, self:getLang(map))
					end
					if map.has then
						statements = filterStatementsByPropertiesAndValues(statements, map.has)
					end
					self:fetchFieldByMap(source, map, statements, base.dataByStatement, parentField)
				else
					if map.elseGet then
						self:fetchFieldsByMap(source, currEntity, map.elseGet, parentField)
					end
				end
			else
				if map.getData or map.getValue then
					self:fetchFieldByCustomFunc(source, currEntity, map, parentField)
				end
				if map.getLabel then
					self:fetchFieldLabel(source, map, parentField)
				end
			end
			if not propertySpecified or not statements then
				self:tryForceGetByMap(source, map, parentField)
			end
		else
			self:tryForceGetByMap(source, map, parentField)
		end
	end
end

function Backend:fetch(source, fieldsMap)
	for _, map in ipairs(fieldsMap) do
		local fieldTable = self:safeField(source, map.name)
		if next(fieldTable) ~= nil then
			if map.get then
				self:fetchFieldsByMap(source, fieldTable.entity, map.get, fieldTable)
			end
			if map.substInto then
				self:substFieldInto(source, map)
			end
		end
	end
end

function Backend:fetchEntity(source, entity, fieldMap)
	self:fetchFieldsByMap(source, entity, fieldMap)
end

function Backend:ensureLang()
	if self.lang then
		return
	end

	self.lang = self.defaultLang
end

function Backend:assertLang()
	assert(self.lang, 'No language selected.')
end

function p.new(lang)
	return Backend:new(lang)
end

return p