001/*
002 * Stallion Core: A Modern Web Framework
003 *
004 * Copyright (C) 2015 - 2016 Stallion Software LLC.
005 *
006 * This program is free software: you can redistribute it and/or modify it under the terms of the
007 * GNU General Public License as published by the Free Software Foundation, either version 2 of
008 * the License, or (at your option) any later version. This program is distributed in the hope that
009 * it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
010 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public
011 * License for more details. You should have received a copy of the GNU General Public License
012 * along with this program.  If not, see <http://www.gnu.org/licenses/gpl-2.0.html>.
013 *
014 *
015 *
016 */
017
018package io.stallion.dataAccess.file;
019
020import io.stallion.dataAccess.*;
021import io.stallion.dataAccess.db.DefaultSort;
022import io.stallion.dataAccess.filtering.FilterChain;
023import io.stallion.exceptions.ConfigException;
024import io.stallion.exceptions.UsageException;
025import io.stallion.fileSystem.FileSystemWatcherService;
026import io.stallion.fileSystem.TreeVisitor;
027import io.stallion.reflection.PropertyComparator;
028import io.stallion.services.Log;
029import io.stallion.settings.Settings;
030import org.apache.commons.codec.digest.DigestUtils;
031import org.apache.commons.io.FilenameUtils;
032import org.apache.commons.lang3.ArrayUtils;
033import org.apache.commons.lang3.StringUtils;
034
035import java.io.File;
036import java.io.IOException;
037import java.nio.ByteBuffer;
038import java.nio.file.FileSystems;
039import java.nio.file.Files;
040import java.nio.file.Path;
041import java.util.*;
042
043import static io.stallion.utils.Literals.*;
044
045/**
046 * A base persister that handles retrieving and saving model objects to the file sytem.
047 *
048 * @param <T>
049 */
050public abstract class FilePersisterBase<T extends Model> extends BasePersister<T> {
051
052    private String bucketFolderPath = "";
053    private Map<String, Long> fileToIdMap = new HashMap<>();
054    private Map<String, Long> fileToTimestampMap = new HashMap<>();
055    private Map<Long, String> idToFileMap = new HashMap<>();
056    private boolean manyItemsPerFile = false;
057    private String itemArrayName = "";
058    protected String sortField = "lastModifiedMillis";
059    protected String sortDirection = "DESC";
060
061
062    @Override
063    public void init(DataAccessRegistration registration, ModelController<T> controller, Stash<T> stash) {
064        super.init(registration, controller, stash);
065        bucketFolderPath = registration.getAbsolutePath();
066        manyItemsPerFile = registration.isMultiplePerFile();
067        itemArrayName = registration.getItemArrayName();
068        idToFileMap = new HashMap<>();
069        fileToIdMap = new HashMap<>();
070
071        if (!StringUtils.isEmpty(registration.getAbsolutePath())) {
072            Boolean exists = new File(registration.getAbsolutePath()).isDirectory();
073            Log.fine("DAL target {0} exists? {1}", registration.getAbsolutePath(), exists);
074            if (!exists) {
075                new File(registration.getAbsolutePath()).mkdirs();
076            }
077        }
078
079        DefaultSort defaultSort = getModelClass().getAnnotation(DefaultSort.class);
080        if (defaultSort != null) {
081            sortField = defaultSort.field();
082            sortDirection = defaultSort.direction();
083        }
084
085    }
086
087    public abstract Set<String> getFileExtensions();
088
089    public boolean matchesExtension(String path) {
090        String extension = FilenameUtils.getExtension(path).toLowerCase();
091        return getFileExtensions().contains(extension);
092    }
093
094    @Override
095    public List<T> fetchAll()  {
096        File target = new File(Settings.instance().getTargetFolder());
097        if (!target.isDirectory()) {
098            if (getItemController().isWritable()) {
099                target.mkdirs();
100            } else {
101                throw new ConfigException(String.format("The JSON bucket %s (path %s) is read-only, but does not exist in the file system. Either create the folder, make it writable, or remove it from the configuration.", getItemController().getBucket(), getBucketFolderPath()));
102            }
103        }
104        TreeVisitor visitor = new TreeVisitor();
105        Path folderPath = FileSystems.getDefault().getPath(getBucketFolderPath());
106        try {
107            Files.walkFileTree(folderPath, visitor);
108        } catch (IOException e) {
109            throw new RuntimeException(e);
110        }
111        List<T> objects = new ArrayList<>();
112        for (Path path : visitor.getPaths()) {
113            if (!matchesExtension(path.toString())) {
114                continue;
115            }
116
117            if (path.toString().contains(".#")) {
118                continue;
119            }
120            if (path.getFileName().startsWith(".")) {
121                continue;
122            }
123            T o = fetchOne(path.toString());
124            if (o != null) {
125                objects.add(o);
126            }
127
128        }
129        objects.sort(new PropertyComparator<T>(sortField));
130        if (sortDirection.toLowerCase().equals("desc")) {
131            Collections.reverse(objects);
132        }
133
134        return objects;
135    }
136
137    @Override
138    public T fetchOne(T obj) {
139        return fetchOne(fullFilePathForObj(obj));
140    }
141
142
143    @Override
144    public T fetchOne(Long id) {
145        return fetchOne(fullFilePathForId(id));
146    }
147
148    public T fetchOne(String filePath) {
149        if (filePath.startsWith(".") || filePath.startsWith("#") || filePath.contains("..")) {
150            return null;
151        }
152        if (!matchesExtension(filePath)) {
153            return null;
154        }
155        File file = new File(filePath);
156        if (!file.exists() || !file.isFile()) {
157            return null;
158        }
159        T o = doFetchOne(file);
160        if (o == null) {
161            return null;
162        }
163        if (empty(o.getId())) {
164            o.setId(makeIdFromFilePath(filePath));
165        }
166
167        Long ts = file.lastModified();
168        o.setLastModifiedMillis(ts);
169
170        handleFetchOne(o);
171        onPostLoadFromFile(o, filePath);
172        return o;
173    }
174
175    public abstract T doFetchOne(File file);
176
177
178    public FilterChain<T> filterChain() {
179        throw new UsageException("File based persistence does not work with filter chains. You have to use a LocalStash, which will provide an in memory filter chain.");
180    }
181
182    @Override
183    public void hardDelete(T obj)  {
184        String filePath = fullFilePathForObj(obj);
185        File file = new File(filePath);
186        file.delete();
187    }
188
189
190    public void onPostLoadFromFile(T obj, String path) {
191        if (path.startsWith(getBucketFolderPath())) {
192            path = path.replace(getBucketFolderPath(), "");
193        }
194        if (path.startsWith("/")) {
195            path = StringUtils.stripStart(path, "/");
196        }
197        getIdToFileMap().put(obj.getId(), path);
198        getFileToIdMap().put(path, obj.getId());
199        if (obj instanceof ModelWithFilePath) {
200            ((ModelWithFilePath) obj).setFilePath(path);
201        }
202
203    }
204
205    public boolean reloadIfNewer(T obj) {
206        String path = fullFilePathForObj(obj);
207        File file = new File(path.toString());
208        Long currentTs = file.lastModified();
209        Long fileLastModified = or(obj.getLastModifiedMillis(), 0L);
210        if (currentTs >= fileLastModified) {
211            getStash().loadForId(obj.getId());
212            fileToTimestampMap.put(path.toString(), currentTs);
213            return true;
214        }
215        return false;
216    }
217
218    public String relativeFilePathForObj(T obj) {
219        if (obj instanceof ModelWithFilePath) {
220            if (!empty(((ModelWithFilePath) obj).getFilePath())) {
221                return ((ModelWithFilePath) obj).getFilePath();
222            } else {
223                String path = ((ModelWithFilePath) obj).generateFilePath();
224                ((ModelWithFilePath) obj).setFilePath(path);
225                return path;
226            }
227        } else if (getIdToFileMap().containsKey(obj.getId())) {
228            return getIdToFileMap().get(obj.getId());
229        } else {
230            return makePathForObject(obj);
231        }
232    }
233
234    public String makePathForObject(T obj) {
235        return obj.getId().toString() + ".json";
236    }
237
238    public String fullFilePathForObj(T obj) {
239        String path = getBucketFolderPath();
240        if (!path.endsWith("/")) {
241            path += "/";
242        }
243        return path + relativeFilePathForObj(obj);
244    }
245
246    public String fullFilePathForId(Long id) {
247        String path = getBucketFolderPath();
248        if (!path.endsWith("/")) {
249            path += "/";
250        }
251        if (!getIdToFileMap().containsKey(id)) {
252            return null;
253        }
254        return path + getIdToFileMap().get(id);
255    }
256
257    public void watchEventCallback(String filePath) {
258        if (getFileToIdMap().containsKey(filePath)) {
259            getStash().loadForId(getFileToIdMap().get(filePath));
260        } else {
261            String fullPath = getBucketFolderPath() + "/" + filePath;
262            T o = fetchOne(fullPath);
263            if (o != null) {
264                getStash().loadItem(o);
265            }
266        }
267    }
268
269    @Override
270    public void attachWatcher()  {
271        FileSystemWatcherService.instance().registerWatcher(
272                new ItemFileChangeEventHandler(this)
273                        .setWatchedFolder(this.getBucketFolderPath())
274                        .setWatchTree(true)
275        );
276    }
277
278    /**
279     * Derives a Long id by hashing the file path and then taking the first 8 bytes
280     * of the path.
281     *
282     * This is used if the model object doesn't have a defined id field.
283     *
284     * @param path
285     * @return
286     */
287    public Long makeIdFromFilePath(String path) {
288        path = path.toLowerCase();
289        path = path.replace(getBucketFolderPath().toLowerCase(), "");
290        path = StringUtils.stripStart(path, "/");
291        path = getBucket() + "-----" + path;
292        // Derive a long id by hashing the file path
293        byte[] bs = Arrays.copyOfRange(DigestUtils.md5(path), 0, 6);
294        bs = ArrayUtils.addAll(new byte[]{0,0}, bs);
295        ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES);
296        buffer.put(bs);
297        buffer.flip();//need flip
298        Long l = buffer.getLong();
299        if (l < 0) {
300            l = -l;
301        }
302        Log.finest("calculated id is {0}", l);
303        return l;
304    }
305
306
307    public String getBucketFolderPath() {
308        return bucketFolderPath;
309    }
310
311    public FilePersisterBase setBucketFolderPath(String bucketFolderPath) {
312        this.bucketFolderPath = bucketFolderPath;
313        return this;
314    }
315
316
317    public Map<String, Long> getFileToIdMap() {
318        return fileToIdMap;
319    }
320
321    public FilePersisterBase setFileToIdMap(Map<String, Long> fileToIdMap) {
322        this.fileToIdMap = fileToIdMap;
323        return this;
324    }
325
326
327    public Map<Long, String> getIdToFileMap() {
328        return idToFileMap;
329    }
330
331    public FilePersisterBase setIdToFileMap(Map<Long, String> idToFileMap) {
332        this.idToFileMap = idToFileMap;
333        return this;
334    }
335
336    public boolean isManyItemsPerFile() {
337        return manyItemsPerFile;
338    }
339
340    public FilePersisterBase setManyItemsPerFile(boolean manyItemsPerFile) {
341        this.manyItemsPerFile = manyItemsPerFile;
342        return this;
343    }
344
345    public String getItemArrayName() {
346        return itemArrayName;
347    }
348
349    public FilePersisterBase setItemArrayName(String itemArrayName) {
350        this.itemArrayName = itemArrayName;
351        return this;
352    }
353}