summaryrefslogtreecommitdiffstats
path: root/acf/model
diff options
context:
space:
mode:
Diffstat (limited to 'acf/model')
-rw-r--r--acf/model/field.lua120
-rw-r--r--acf/model/init.lua206
-rw-r--r--acf/model/model.lua102
-rw-r--r--acf/model/node.lua129
4 files changed, 557 insertions, 0 deletions
diff --git a/acf/model/field.lua b/acf/model/field.lua
new file mode 100644
index 0000000..cfa14b9
--- /dev/null
+++ b/acf/model/field.lua
@@ -0,0 +1,120 @@
+--[[
+Copyright (c) 2012 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+module(..., package.seeall)
+
+local node = require('acf.model.node')
+
+local object = require('acf.object')
+local class = object.class
+local super = object.super
+
+local map = require('acf.util').map
+
+
+local function contains(list, value)
+ for k, v in ipairs(list) do if v == value then return true end end
+ return false
+end
+
+local function auto_ui_name(name)
+ if not name then return end
+ return string.gsub(string.upper(string.sub(name, 1, 1))..string.sub(name, 2),
+ '_', ' ')
+end
+
+
+Field = class()
+
+function Field:init(params)
+ for k, v in pairs(params or {}) do if not self[k] then self[k] = v end end
+
+ if self.choice and not self['ui-choice'] then
+ self['ui-choice'] = map(auto_ui_name, self.choice)
+ end
+
+ if not self.widget then
+ self.widget = self.choice and 'combobox' or 'field'
+ end
+end
+
+function Field:meta(txn, path, addr)
+ assert(self.dtype)
+ return {
+ name=self.name,
+ type=self.dtype,
+ required=self.required,
+ choice=self.choice,
+ description=self.description,
+ ['ui-name']=self['ui-name'] or auto_ui_name(self.name),
+ widget=self.widget,
+ ['ui-choice']=self['ui-choice']
+ }
+end
+
+function Field:load(txn, path, addr)
+ local value = txn:get(addr)
+ if value == nil then return self.default end
+ return value
+end
+
+function Field:_validate(txn, path, value)
+ if self.required and value == nil then
+ error('Required value not set: '..path)
+ end
+ if self.choice and value ~= nil and not contains(self.choice, value) then
+ error('Invalid value for '..path..': '..value)
+ end
+ if value ~= nil then self:validate(txn, path, value) end
+ return value
+end
+
+function Field:validate(txn, path, value) end
+
+function Field:save(txn, path, addr, value)
+ -- 2nd argument currenly not much used by backends
+ txn:set(addr, self.dtype, self:_validate(txn, path, value))
+end
+
+function Field:validate_saved(txn, path, addr)
+ self:save(txn, path, addr, self:load(txn, path, addr))
+end
+
+
+TreeNode = class(Field)
+
+function TreeNode:save(txn, path, addr, value)
+ -- TODO hack, allow preserving old instance on parent update
+ if value == path then return end
+
+ if object.isinstance(value, node.TreeNode) then
+ -- TODO clone if TreeNode has wrong path
+ assert(node.path(value) == path)
+ return
+ end
+
+ txn:set(addr)
+ if value then
+ assert(type(value) == 'table')
+ txn:set(addr, 'table')
+ local new = self:load(txn, path, addr, true)
+ for k, v in pairs(value) do new[k] = v end
+ end
+end
+
+
+Model = class(TreeNode)
+
+function Model:init(params)
+ super(self, Model):init(params)
+ assert(self.model)
+ self.dtype = 'model'
+ self.widget = self.dtype
+end
+
+function Model:load(txn, path, addr, create)
+ if not create and not txn:get(addr) then return end
+ return self.model(txn, path, addr)
+end
diff --git a/acf/model/init.lua b/acf/model/init.lua
new file mode 100644
index 0000000..6934796
--- /dev/null
+++ b/acf/model/init.lua
@@ -0,0 +1,206 @@
+--[[
+Copyright (c) 2012 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+module(..., package.seeall)
+
+local fld = require('acf.model.field')
+local Field = fld.Field
+
+local model = require('acf.model.model')
+new = model.new
+to_field = model.to_field
+
+node = require('acf.model.node')
+
+local object = require('acf.object')
+local class = object.class
+local super = object.super
+
+local pth = require('acf.path')
+local map = require('acf.util').map
+
+
+-- TODO object-specific actions
+
+-- TODO access control
+
+
+local Primitive = class(Field)
+
+function Primitive:validate(txn, path, value)
+ local t = self.dtype
+ if type(value) ~= t then error('Not a '..t..': '..tostring(value)) end
+end
+
+
+String = class(Primitive)
+
+function String:init(params)
+ super(self, String):init(params)
+ self.dtype = 'string'
+end
+
+function String:validate(txn, path, value)
+ if self['max-length'] and string.len(value) > self['max-length'] then
+ error('Maximum length exceeded: '..value)
+ end
+end
+
+function String:meta(txn, path, addr)
+ local res = super(self, String):meta(txn, path, addr)
+ res['max-length'] = self['max-length']
+ return res
+end
+
+
+Number = class(Primitive)
+
+function Number:init(params)
+ super(self, Number):init(params)
+ self.dtype = 'number'
+end
+
+function Number:_validate(txn, path, value)
+ return tonumber(super(self, Number):_validate(txn, path, value))
+end
+
+
+Integer = class(Number)
+
+function Integer:validate(txn, path, value)
+ if math.floor(value) ~= value then error('Not an integer: '..value) end
+end
+
+
+Boolean = class(Primitive)
+
+function Boolean:init(params)
+ super(self, Boolean):init(params)
+ self.dtype = 'boolean'
+ self.widget = self.dtype
+end
+
+
+Reference = class(Field)
+
+function Reference:init(params)
+ super(self, Reference):init(params)
+ self.dtype = 'reference'
+ if not self.scope then self.scope = '/' end
+end
+
+function Reference:abs_scope(path)
+ return pth.to_absolute(self.scope, pth.parent(pth.parent(path)))
+end
+
+function Reference:meta(txn, path, addr)
+ local res = super(self, Reference):meta(txn, path, addr)
+ res.scope = self:abs_scope(path)
+
+ local base = txn:search(res.scope)
+ local objs = base and txn:get(getmetatable(base).addr) or {}
+ res.choice = map(function(p) return pth.join(res.scope, p) end, objs)
+ res['ui-choice'] = objs
+
+ return res
+end
+
+function Reference:follow(txn, path, value)
+ return txn:search(pth.join(self:abs_scope(path), value))
+end
+
+function Reference:load(txn, path, addr)
+ local ref = super(self, Reference):load(txn, path, addr)
+ return ref and self:follow(txn, path, ref) or nil
+end
+
+function Reference:_validate(txn, path, value)
+ super(self, Reference):_validate(txn, path, value)
+
+ if value == nil then return end
+
+ if object.isinstance(value, node.TreeNode) then
+ value = getmetatable(value).path
+ end
+ if value and pth.is_absolute(value) then
+ local scope = self:abs_scope(path)
+ local prefix = scope..'/'
+ if not stringy.startswith(value, prefix) then
+ error('Reference out of scope ('..scope..')')
+ end
+ value = string.sub(value, string.len(prefix) + 1, -1)
+ end
+
+ -- assume one-level ref for now
+ assert(not string.find(value, '/'))
+ if not self:follow(txn, path, value) then
+ error('Does not exist: '..path)
+ end
+ -- TODO check instance type
+
+ return value
+end
+
+
+Model = fld.Model
+
+
+Collection = class(fld.TreeNode)
+
+function Collection:init(params, ctype)
+ super(self, Collection):init(params)
+ assert(self.type)
+ self.ctype = ctype or node.Collection
+ self.dtype = 'collection'
+ self.widget = self.dtype
+end
+
+function Collection:load(txn, path, addr)
+ -- automatically create missing collection (TODO: make this configurable?)
+ return self.ctype(txn, path, addr, to_field(self.type), self.required)
+end
+
+
+PrimitiveList = class(Collection)
+
+function PrimitiveList:init(params)
+ super(self, PrimitiveList):init(params, node.PrimitiveList)
+end
+
+
+-- experimental
+Mixed = class(Collection)
+
+function Mixed:init()
+ super(self, Mixed):init({type=Mixed}, node.Mixed)
+end
+
+function Mixed:load(txn, path, addr)
+ local value = Primitive.load(self, txn, path, addr)
+ if type(value) == 'table' then
+ return super(self, Mixed):load(txn, path, addr)
+ end
+ return value
+end
+
+function Mixed:save(txn, path, addr, value)
+ if type(value) == 'table' then
+ super(self, Mixed):save(txn, path, addr, value)
+ else Primitive.save(self, txn, path, addr, value) end
+end
+
+
+
+RootModel = new()
+
+function RootModel:init(txn) super(self, RootModel):init(txn, '/') end
+
+function register(name, path, field)
+ local field = to_field(field)
+ function field:compute(txn, pth, addr)
+ return self:load(txn, '/'..name, path)
+ end
+ RootModel[name] = field
+end
diff --git a/acf/model/model.lua b/acf/model/model.lua
new file mode 100644
index 0000000..2efcb6f
--- /dev/null
+++ b/acf/model/model.lua
@@ -0,0 +1,102 @@
+--[[
+Copyright (c) 2012 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+module(..., package.seeall)
+
+local fld = require('acf.model.field')
+local Field = fld.Field
+
+local node = require('acf.model.node')
+
+local object = require('acf.object')
+local class = object.class
+local super = object.super
+local isinstance = object.isinstance
+
+local util = require('acf.util')
+
+
+function to_field(obj)
+ if object.issubclass(obj, Model) then return fld.Model{model=obj} end
+ return getmetatable(obj).class and obj or obj()
+end
+
+
+function new(base)
+ if not base then base = Model end
+
+ local res = class(base)
+ res._fields = base == node.TreeNode and {} or util.copy(base._fields)
+
+ local mt = util.copy(getmetatable(res))
+
+ function mt.__index(t, k) return base[k] end
+
+ function mt.__newindex(t, k, v)
+ assert(v)
+
+ local override = t[k]
+ if type(v) == 'table' then v = to_field(v) end
+
+ rawset(t, k, v)
+
+ if isinstance(v, Field) then
+ v.name = k
+ if not override then table.insert(t._fields, k) end
+ end
+ end
+
+ setmetatable(res, mt)
+
+ if isinstance(base, Model) then util.setdefaults(res, base) end
+
+ return res
+end
+
+Model = new(node.TreeNode)
+
+function Model:init(txn, path, addr)
+ super(self, Model):init(txn, path, addr)
+
+ local mt = getmetatable(self)
+
+ function mt.field(name)
+ local res = mt.class[name]
+ return isinstance(res, Field) and node.BoundField(self, res) or nil
+ end
+
+ function mt.mmeta(name) return mt.field(name):meta() end
+
+ mt.meta = {type='model',
+ fields=util.map(function(f) return mt.mmeta(f) end,
+ self._fields)}
+
+ function mt.members()
+ return util.map(function(f) return f.name end, mt.meta.fields)
+ end
+
+ function mt.__index(t, k)
+ local f = mt.field(k)
+ if f then
+ if f.compute then return f:compute() end
+ return f:load()
+ end
+ return mt.class[k]
+ end
+
+ function mt.__newindex(t, k, v)
+ local f = mt.field(k)
+ if not f then error('Field named '..k..' does not exist') end
+ f:save(v)
+ txn.validate[mt.path] = function() self:validate() end
+ end
+end
+
+function Model:validate()
+ local mt = getmetatable(self)
+ for _, name in ipairs(mt.members()) do
+ mt.field(name):validate_saved()
+ end
+end
diff --git a/acf/model/node.lua b/acf/model/node.lua
new file mode 100644
index 0000000..39a0d92
--- /dev/null
+++ b/acf/model/node.lua
@@ -0,0 +1,129 @@
+--[[
+Copyright (c) 2012 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+module(..., package.seeall)
+
+local object = require('acf.object')
+local class = object.class
+local super = object.super
+
+local pth = require('acf.path')
+
+
+BoundField = class()
+
+function BoundField:init(parent, field)
+ local pmt = getmetatable(parent)
+ local mt = {}
+
+ function mt.__index(t, k)
+ local member = field[k]
+ if type(member) ~= 'function' then return member end
+ return function(self, ...)
+ local name
+ if field.name then name = field.name
+ else
+ name = arg[1]
+ table.remove(arg, 1)
+ end
+ return member(field,
+ pmt.txn,
+ pth.join(pmt.path, name),
+ pmt.addr and pth.join(pmt.addr, name),
+ unpack(arg))
+ end
+ end
+
+ setmetatable(self, mt)
+end
+
+
+TreeNode = class()
+
+function TreeNode:init(txn, path, addr)
+ local mt = getmetatable(self)
+ mt.txn = txn
+ mt.path = path
+ mt.addr = addr
+end
+
+function TreeNode:search(path)
+ if #path == 0 then return self end
+ local next = path[1]
+ table.remove(path, 1)
+ return TreeNode.search(self[next], path)
+end
+
+
+Collection = class(TreeNode)
+
+function Collection:init(txn, path, addr, field, required)
+ super(self, Collection):init(txn, path, addr)
+
+ if required then
+ txn.validate[path] = function()
+ if #txn:get(addr) == 0 then
+ error('Collection cannot be empty: '..path)
+ end
+ end
+ end
+
+ self.init = nil
+ self.search = nil
+
+ local mt = getmetatable(self)
+
+ mt.field = BoundField(self, field)
+ mt.meta = {type='collection', members=mt.field:meta()}
+ function mt.mmeta(name) return mt.meta.members end
+ function mt.members() return txn:get(addr) or {} end
+
+ function mt.__index(t, k) return mt.field:load(k) end
+ function mt.__newindex(t, k, v) mt.field:save(k, v) end
+end
+
+
+PrimitiveList = class(Collection)
+
+function PrimitiveList:init(txn, path, addr, field, required)
+ super(self, PrimitiveList):init(txn, path, addr, field, required)
+
+ local mt = getmetatable(self)
+ local index = mt.__index
+
+ function mt.__index(t, k)
+ if type(k) == 'number' then return index(t, k) end
+
+ for i, j in ipairs(txn:get(addr) or {}) do
+ assert(i == tonumber(j))
+ if mt.field:load(i) == k then return k end
+ end
+ error('Value does not exist: '..k)
+ end
+end
+
+
+-- experimental
+Mixed = class(Collection)
+
+function Mixed:init(txn, path, addr, field, required)
+ super(self, Mixed):init(txn, path, addr, field, required)
+ -- TODO dynamic meta: list non-leaf children
+ getmetatable(self).meta = {type='mixed'}
+end
+
+
+local function meta_func(attr)
+ return function(node, ...)
+ local res = getmetatable(node)[attr]
+ if type(res) == 'function' then return res(unpack(arg)) end
+ return res
+ end
+end
+
+members = meta_func('members')
+meta = meta_func('meta')
+mmeta = meta_func('mmeta')
+path = meta_func('path')