From c8b770feff24d85e1343dc6404eb4e84edbcaa25 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Thu, 11 Jan 2024 10:40:57 +0100 Subject: [PATCH] feat: create an lightweight ORM for saving (#3759) * feat: create an lightweight ORM for saving * add rawget/rawset to lua global * remove previously used enums * chaining * annotations and do not run strip for extradata --- .luacheckrc | 2 + spec/orm_spec.lua | 73 +++++++++++++++++++++ standard/lpdb.lua | 160 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 spec/orm_spec.lua diff --git a/.luacheckrc b/.luacheckrc index cbf6f42883f..3fbb81b7c99 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -15,6 +15,8 @@ std = { "package", "pairs", "pcall", + "rawget", + "rawset", "require", "select", "setmetatable", diff --git a/spec/orm_spec.lua b/spec/orm_spec.lua new file mode 100644 index 00000000000..c52372b1d48 --- /dev/null +++ b/spec/orm_spec.lua @@ -0,0 +1,73 @@ +--- Triple Comment to Enable our LLS Plugin +describe('LPDB Object-Relational Mapping', function() + local Lpdb = require('Module:Lpdb') + + describe('setting data', function() + it('assign value on init', function() + local match2 = Lpdb.Match2:new({bestof = 10}) + assert.are_same({bestof = 10}, rawget(match2, 'fields')) + end) + + it('__newindex', function() + local match2 = Lpdb.Match2:new() + match2.bestof = 7 + assert.are_same({bestof = 7}, rawget(match2, 'fields')) + end) + + it('set()', function() + local match2 = Lpdb.Match2:new():set('bestof', 5) + assert.are_same({bestof = 5}, rawget(match2, 'fields')) + end) + + it('setMany()', function() + local match2 = Lpdb.Match2:new():setMany{bestof = 3, game = 'r6s'} + assert.are_same({bestof = 3, game = 'r6s'}, rawget(match2, 'fields')) + end) + + it('strip html tags', function() + local match2 = Lpdb.Match2:new():set('match2bracketdata', {header = 'Bo5'}) + assert.are_same({match2bracketdata = {header = 'Bo5'}}, rawget(match2, 'fields')) + end) + end) + + describe('saving data', function() + it('saving', function() + local stub = stub(mw.ext.LiquipediaDB, 'lpdb_match2') + Lpdb.Match2:new({match2id = 'Foo', match2bracketid = 'Bar', bestof = 3, game = 'r6s'}):save() + assert.stub(stub).called_with('Foo', { + bestof = 3, + date = 0, + dateexact = 0, + extradata = {}, + finished = 0, + game = 'r6s', + icon = '', + icondark = '', + links = {}, + liquipediatier = '', + liquipediatiertype = '', + match2bracketdata = {}, + match2bracketid = 'Bar', + match2games = {}, + match2id = 'Foo', + match2opponents = {}, + mode = '', + parent = '', + patch = '', + publishertier = '', + resulttype = '', + section = '', + series = '', + shortname = '', + stream = {}, + tickername = '', + tournament = '', + type = '', + vod = '', + walkover = '', + winner = '', + }) + stub:revert() + end) + end) +end) diff --git a/standard/lpdb.lua b/standard/lpdb.lua index 983fd93d391..fb0f008d86d 100644 --- a/standard/lpdb.lua +++ b/standard/lpdb.lua @@ -6,9 +6,15 @@ -- Please see https://github.com/Liquipedia/Lua-Modules to contribute -- +local Array = require('Module:Array') +local Class = require('Module:Class') +local FnUtil = require('Module:FnUtil') +local Table = require('Module:Table') +local TextSanitizer = require('Module:TextSanitizer') + local Lpdb = {} -local _MAXIMUM_QUERY_LIMIT = 5000 +local MAXIMUM_QUERY_LIMIT = 5000 -- Executes a mass query. --[==[ @@ -19,6 +25,7 @@ Loops LPDB queries to e.g. or additional limitations are reached example: +``` local foundMatchIds = {} local getMatchId = function(match) if #foundMatchIds < args.matchLimit then @@ -41,6 +48,7 @@ example: Lpdb.executeMassQuery('match2', queryParameters, getMatchId) return foundMatchIds +``` ]==] ---@param tableName string ---@param queryParameters table @@ -48,7 +56,7 @@ example: ---@param limit number? function Lpdb.executeMassQuery(tableName, queryParameters, itemChecker, limit) queryParameters.offset = queryParameters.offset or 0 - queryParameters.limit = queryParameters.limit or _MAXIMUM_QUERY_LIMIT + queryParameters.limit = queryParameters.limit or MAXIMUM_QUERY_LIMIT limit = limit or math.huge while queryParameters.offset < limit do @@ -69,4 +77,152 @@ function Lpdb.executeMassQuery(tableName, queryParameters, itemChecker, limit) end end +--- LPDB Object-Relational Mapping + +---@alias ModelColumnData {name: string, fieldType: string|any, default: any} + +---@class Model +---@field tableName string +---@field tableColumns ModelColumnData[] +local Model = Class.new(function(self, name, columns) + self.tableName = name + self.tableColumns = columns +end) + +---@class ModelRow +---@field private tableName string +---@field private tableColumns ModelColumnData[] +---@field private fields table +---@field [any] any +local ModelRow = Class.new(function(self, tableName, tableColumns) + rawset(self, 'tableName', tableName) + rawset(self, 'tableColumns', tableColumns) + rawset(self, 'fields', {}) +end) + +---@param initData table? +---@return ModelRow +function Model:new(initData) + local row = ModelRow(self.tableName, self.tableColumns) + if type(initData) == 'table' then + row:setMany(initData) + end + return row +end + +---@private +---@param columnData ModelColumnData +function ModelRow:_validateField(columnData) + if not self.fields[columnData.name] then + error(self.tableName .. ' expects ' .. columnData.name .. ' to be set') + end + -- TODO: Verify types (at least when running tests) +end + +---@private +---@param columnData ModelColumnData +function ModelRow:_prepareFieldForStorage(columnData) + -- Apply defaults + if not self.fields[columnData.name] then + if type(columnData.default) == 'function' then + self.fields[columnData.name] = columnData.default(self.fields) + else + self.fields[columnData.name] = columnData.default + end + end + + -- Validate that all fields are correct + self:_validateField(columnData) +end + +---@return self +function ModelRow:save() + Array.forEach(self.tableColumns, FnUtil.curry(ModelRow._prepareFieldForStorage, self)) + local objectName = Table.extract(self.fields, 'objectname') + mw.ext.LiquipediaDB['lpdb_' .. self.tableName](objectName, self.fields) + return self +end + +---@param key string +---@param value any +---@return self +function ModelRow:__newindex(key, value) + if key ~= 'extradata' then + -- Strip HTML from strings + -- We allow HTML in extradata though + local function stripHtml(str) + if type(str) == 'string' then + return TextSanitizer.stripHTML(str) + end + return str + end + + if type(value) == 'table' then + value = Table.mapValues(value, stripHtml) + else + value = stripHtml(value) + end + end + + self.fields[key] = value + return self +end + +---@param key string +---@param value any +---@return self +function ModelRow:set(key, value) + self:__newindex(key, value) + return self +end + +---@param tbl table +---@return self +function ModelRow:setMany(tbl) + Table.iter.forEachPair(tbl, FnUtil.curry(ModelRow.__newindex, self)) + return self +end + +---@class Match2Model:Model +Lpdb.Match2 = Model('match2', { + { + name = 'objectname', + fieldType = 'string', + default = function(fields) + return fields.match2id + end + }, + {name = 'match2id', fieldType = 'string'}, + {name = 'match2bracketid', fieldType = 'struct'}, + {name = 'winner', fieldType = 'string', default = ''}, + {name = 'walkover', fieldType = 'string', default = ''}, + {name = 'resulttype', fieldType = 'string', default = ''}, + {name = 'finished', fieldType = 'number', default = 0}, + {name = 'mode', fieldType = 'string', default = ''}, + {name = 'type', fieldType = 'string', default = ''}, + {name = 'section', fieldType = 'string', default = ''}, + {name = 'game', fieldType = 'string', default = ''}, + {name = 'patch', fieldType = 'string', default = ''}, + {name = 'date', fieldType = 'string', default = 0}, + {name = 'dateexact', fieldType = 'number', default = 0}, + {name = 'stream', fieldType = 'struct', default = {}}, + {name = 'links', fieldType = 'struct', default = {}}, + {name = 'bestof', fieldType = 'number', default = 0}, + {name = 'vod', fieldType = 'string', default = ''}, + {name = 'tournament', fieldType = 'string', default = ''}, + {name = 'parent', fieldType = 'pagename', default = ''}, + {name = 'tickername', fieldType = 'string', default = ''}, + {name = 'shortname', fieldType = 'string', default = ''}, + {name = 'series', fieldType = 'string', default = ''}, + {name = 'icon', fieldType = 'string', default = ''}, + {name = 'icondark', fieldType = 'string', default = ''}, + {name = 'liquipediatier', fieldType = 'string|number', default = ''}, + {name = 'liquipediatiertype', fieldType = 'string', default = ''}, + {name = 'publishertier', fieldType = 'string', default = ''}, + {name = 'extradata', fieldType = 'struct', default = {}}, + {name = 'match2bracketdata', fieldType = 'struct', default = {}}, + {name = 'match2opponents', fieldType = 'array', default = {}}, + {name = 'match2games', fieldType = 'array', default = {}}, +}) + return Lpdb