Skip to content

Fixed some problems #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ val sttp = "3.5.0"
val anorm = "2.7.0"
val scalaTestPlusPlay = "6.0.0-M6"
val scalaTestPlusMockito = "3.2.15.0"
val reactAdmin = "4.14.3"
val reactAdmin = "4.14.4"

val consoleDisabledOptions = Seq("-Xfatal-warnings", "-Ywarn-unused", "-Ywarn-unused-import")

10 changes: 10 additions & 0 deletions spra-play-server/src/main/resources/application.conf
Original file line number Diff line number Diff line change
@@ -30,6 +30,16 @@ dataExplorer {
}
referenceDisplayField = "email"
}

images {
tableName = "images"
primaryKeyField = "image_id"
nonEditableColumns = ["image_id", "created_at"]
canBeDeleted = false
createFilter {
requiredColumns = ["name", "data"]
}
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package net.wiringbits.spra.admin.models

trait FieldValue[T] extends Serializable {
val value: T
}

case class StringValue(value: String) extends FieldValue[String]
case class ByteArrayValue(value: Array[Byte]) extends FieldValue[Array[Byte]]
Original file line number Diff line number Diff line change
@@ -2,8 +2,10 @@ package net.wiringbits.spra.admin.repositories

import net.wiringbits.spra.admin.config.{DataExplorerConfig, TableSettings}
import net.wiringbits.spra.admin.executors.DatabaseExecutionContext
import net.wiringbits.spra.admin.models.{ByteArrayValue, StringValue}
import net.wiringbits.spra.admin.repositories.daos.DatabaseTablesDAO
import net.wiringbits.spra.admin.repositories.models.{DatabaseTable, ForeignKey, TableColumn, TableData}
import net.wiringbits.spra.admin.utils.StringParse
import net.wiringbits.spra.admin.utils.models.QueryParameters
import play.api.db.Database

@@ -75,7 +77,10 @@ class DatabaseTablesRepository @Inject() (database: Database)(implicit
val fieldsAndValues = body.map { case (key, value) =>
val field =
columns.find(_.name == key).getOrElse(throw new RuntimeException(s"Invalid property in body request: $key"))
(field, value)
if (field.`type` == "bytea")
val byteaValue = StringParse.stringToByteArray(value)
(field, ByteArrayValue(byteaValue))
else (field, StringValue(value))
}
DatabaseTablesDAO.create(
tableName = tableName,
@@ -100,7 +105,10 @@ class DatabaseTablesRepository @Inject() (database: Database)(implicit
val fieldsAndValues = bodyWithoutNonEditableColumns.map { case (key, value) =>
val field =
columns.find(_.name == key).getOrElse(throw new RuntimeException(s"Invalid property in body request: $key"))
(field, value)
if (field.`type` == "bytea")
val byteaValue = StringParse.stringToByteArray(value)
(field, ByteArrayValue(byteaValue))
else (field, StringValue(value))
}
val primaryKeyType = settings.primaryKeyDataType
DatabaseTablesDAO.update(
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ package net.wiringbits.spra.admin.repositories.daos

import anorm.{SqlParser, SqlStringInterpolation}
import net.wiringbits.spra.admin.config.{CustomDataType, PrimaryKeyDataType, TableSettings}
import net.wiringbits.spra.admin.models.{ByteArrayValue, FieldValue, StringValue}
import net.wiringbits.spra.admin.repositories.models.*
import net.wiringbits.spra.admin.utils.models.{FilterParameter, QueryParameters}
import net.wiringbits.spra.admin.utils.{QueryBuilder, StringRegex}
@@ -10,7 +11,7 @@ import java.sql.{Connection, Date, PreparedStatement, ResultSet}
import java.time.LocalDate
import java.util.UUID
import scala.collection.mutable.ListBuffer
import scala.util.Try
import scala.util.{Failure, Success, Try}

object DatabaseTablesDAO {

@@ -73,6 +74,37 @@ object DatabaseTablesDAO {
""".as(foreignKeyParser.*)
}

private def columnTypeIsDouble(columnType: String): Boolean = {
// 'contains' is used because PostgreSQL types may include additional details like precision or scale
// https://www.postgresql.org/docs/8.1/datatype.html
List("float", "decimal").exists(columnType.contains)
}

private def columnTypeIsInt(columnType: String): Boolean = {
List("int", "serial").exists(columnType.contains)
}

private def isUUID(value: String, columnType: String): Boolean = {
Try(UUID.fromString(value)) match {
case Success(_) => columnType == "uuid"
case Failure(_) => false
}
}

private def isInt(value: String, columnType: String): Boolean = {
value.toIntOption.isDefined && columnTypeIsInt(columnType)
}

private def isDecimal(value: String, columnType: String): Boolean = {
value.toDoubleOption.isDefined && columnTypeIsDouble(columnType)
}

private def isNumberOrUUID(value: String, columnType: String): Boolean = {
isInt(value, columnType) ||
isDecimal(value, columnType) ||
isUUID(value, columnType)
}

def getTableData(
settings: TableSettings,
columns: List[TableColumn],
@@ -88,12 +120,15 @@ object DatabaseTablesDAO {

val conditionsSql = queryParameters.filters
.map { case FilterParameter(filterField, filterValue) =>
val columnType = columns.find(_.name == filterField) match {
case Some(column) => column.`type`
case None => throw Exception(s"Column with name '$filterField' not found.")
}
filterValue match {
case dateRegex(_, _, _) =>
case dateRegex(_, _, _) if columnType == "date" =>
s"DATE($filterField) = ?"

case _ =>
if (filterValue.toIntOption.isDefined || filterValue.toDoubleOption.isDefined)
if (isNumberOrUUID(filterValue, columnType))
s"$filterField = ?"
else
s"$filterField LIKE ?"
@@ -111,20 +146,25 @@ object DatabaseTablesDAO {
val preparedStatement = conn.prepareStatement(sql)

queryParameters.filters.zipWithIndex
.foreach { case (FilterParameter(_, filterValue), index) =>
.foreach { case (FilterParameter(filterField, filterValue), index) =>
// We have to increment index by 1 because SQL parameterIndex starts in 1
val sqlIndex = index + 1

val columnType = columns.find(_.name == filterField) match {
case Some(column) => column.`type`
case None => throw Exception(s"Column with name '$filterField' not found.")
}
filterValue match {
case dateRegex(year, month, day) =>
case dateRegex(year, month, day) if columnType == "date" =>
val parsedDate = LocalDate.of(year.toInt, month.toInt, day.toInt)
preparedStatement.setDate(sqlIndex, Date.valueOf(parsedDate))

case _ =>
if (filterValue.toIntOption.isDefined)
if (isInt(filterValue, columnType))
preparedStatement.setInt(sqlIndex, filterValue.toInt)
else if (filterValue.toDoubleOption.isDefined)
else if (isDecimal(filterValue, columnType))
preparedStatement.setDouble(sqlIndex, filterValue.toDouble)
else if (isUUID(filterValue, columnType))
preparedStatement.setObject(sqlIndex, UUID.fromString(filterValue))
else
preparedStatement.setString(sqlIndex, s"%$filterValue%")
}
@@ -230,7 +270,7 @@ object DatabaseTablesDAO {
}
def create(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
fieldsAndValues: Map[TableColumn, FieldValue[_]],
primaryKeyField: String,
primaryKeyType: PrimaryKeyDataType = PrimaryKeyDataType.UUID
)(implicit
@@ -250,7 +290,7 @@ object DatabaseTablesDAO {
// Postgres: INSERT INTO test_serial (id) VALUES(DEFAULT); MySQL: INSERT INTO table (id) VALUES(NULL)

for (j <- i + 1 to fieldsAndValues.size + i) {
val value = fieldsAndValues(fieldsAndValues.keys.toList(j - i - 1))
val value = fieldsAndValues(fieldsAndValues.keys.toList(j - i - 1)).value
preparedStatement.setObject(j, value)
}
val result = preparedStatement.executeQuery()
@@ -260,17 +300,17 @@ object DatabaseTablesDAO {

def update(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
fieldsAndValues: Map[TableColumn, FieldValue[_]],
primaryKeyField: String,
primaryKeyValue: String,
primaryKeyType: PrimaryKeyDataType = PrimaryKeyDataType.UUID
)(implicit conn: Connection): Unit = {
val sql = QueryBuilder.update(tableName, fieldsAndValues, primaryKeyField)
val preparedStatement = conn.prepareStatement(sql)

val notNullData = fieldsAndValues.filterNot { case (_, value) => value == "null" }
val notNullData = fieldsAndValues.filterNot { case (_, value) => value.value == "null" }
notNullData.zipWithIndex.foreach { case ((_, value), i) =>
preparedStatement.setObject(i + 1, value)
preparedStatement.setObject(i + 1, value.value)
}
// where ... = ?
setPreparedStatementKey(preparedStatement, primaryKeyValue, primaryKeyType, notNullData.size + 1)
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package net.wiringbits.spra.admin.utils

import net.wiringbits.spra.admin.config.PrimaryKeyDataType
import net.wiringbits.spra.admin.models.FieldValue
import net.wiringbits.spra.admin.repositories.models.TableColumn

import scala.collection.mutable

object QueryBuilder {
def create(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
fieldsAndValues: Map[TableColumn, FieldValue[_]],
primaryKeyField: String,
primaryKeyType: PrimaryKeyDataType = PrimaryKeyDataType.UUID
): String = {
@@ -33,10 +34,10 @@ object QueryBuilder {
|""".stripMargin
}

def update(tableName: String, body: Map[TableColumn, String], primaryKeyField: String): String = {
def update(tableName: String, body: Map[TableColumn, FieldValue[_]], primaryKeyField: String): String = {
val updateStatement = new mutable.StringBuilder("SET")
for ((tableField, value) <- body) {
val resultStatement = if (value == "null") "NULL" else s"?::${tableField.`type`}"
val resultStatement = if (value.value == "null") "NULL" else s"?::${tableField.`type`}"
val statement = s" ${tableField.name} = $resultStatement,"
updateStatement.append(statement)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package net.wiringbits.spra.admin.utils

import scala.util.{Failure, Success, Try}

object StringParse {

def stringToByteArray(value: String): Array[Byte] = {
// Removes whitespace characters (\\s) and brackets ([, ]) to prepare the string for byte array conversion
Try(value.replaceAll("[\\[\\]\\s]", "").split(",").map(_.toByte)) match
case Success(value) => value
case Failure(_) => Array.emptyByteArray
}

}
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ package net.wiringbits.spra.ui.web
import net.wiringbits.spra.api.models.AdminGetTables
import net.wiringbits.spra.ui.web.components.{CreateGuesser, EditGuesser, ListGuesser}
import net.wiringbits.spra.ui.web.facades.reactadmin.{Admin, Resource}
import net.wiringbits.spra.ui.web.facades.simpleRestProvider
import net.wiringbits.spra.ui.web.facades.createDataProvider
import net.wiringbits.spra.ui.web.models.DataExplorerSettings
import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global
import slinky.core.facade.{Hooks, ReactElement}
@@ -52,7 +52,7 @@ object AdminView {
}

div()(
Admin(simpleRestProvider(tablesUrl))(buildResources),
Admin(createDataProvider(tablesUrl))(buildResources),
error.map(h1(_))
)
}
Original file line number Diff line number Diff line change
@@ -29,7 +29,7 @@ object CreateGuesser {
case ColumnType.Email =>
TextInput(source = field.name, isRequired = isRequired, validate = required)
case ColumnType.Image =>
ImageField(source = field.name, isRequired = isRequired, validate = required)
ImageInput(source = field.name, isRequired = isRequired, validate = required)(ImageField(source = "src"))
case ColumnType.Number =>
NumberInput(source = field.name, isRequired = isRequired, validate = required)
case ColumnType.Reference(reference, source) =>
Original file line number Diff line number Diff line change
@@ -33,7 +33,7 @@ object EditGuesser {
case ColumnType.Date => DateTimeInput(source = field.name, disabled = field.disabled)
case ColumnType.Text => TextInput(source = field.name, disabled = field.disabled)
case ColumnType.Email => TextInput(source = field.name, disabled = field.disabled)
case ColumnType.Image => ImageField(source = field.name)
case ColumnType.Image => ImageInput(source = field.name)(ImageField(source = "src"))
case ColumnType.Number => NumberInput(source = field.name, disabled = field.disabled)
case ColumnType.Reference(reference, source) =>
ReferenceInput(
Original file line number Diff line number Diff line change
@@ -20,7 +20,7 @@ object ListGuesser {
val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props =>
val fields = ResponseGuesser.getTypesFromResponse(props.response)

def defaultField(reference: String, source: String)(children: ReactElement*): ReactElement =
def defaultField(reference: String, source: String)(children: ReactElement): ReactElement =
ReferenceField(reference = reference, source = source)(children)

val widgetFields: Seq[ReactElement] = fields.map { field =>
@@ -49,7 +49,14 @@ object ListGuesser {
case ColumnType.Image => Fragment()
case ColumnType.Number => NumberInput(source = field.name)
case ColumnType.Reference(reference, source) =>
defaultField(reference, field.name)(TextField(source = source))
ReferenceInput(
source = field.name,
reference = reference
)(
SelectInput(
optionText = props.response.referenceDisplayField.getOrElse(source)
)
)
}
}

Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
package net.wiringbits.spra.ui.web.facades

import scala.scalajs.js
import scala.scalajs.js.annotation.JSImport

@js.native
trait DataProvider extends js.Object

@js.native
@JSImport("ra-data-simple-rest", JSImport.Default)
// https://www.npmjs.com/package/ra-data-simple-rest
def simpleRestProvider(url: String): DataProvider = js.native

@js.native
@JSImport("react-admin", "withLifecycleCallbacks")
// https://marmelab.com/react-admin/withLifecycleCallbacks.html
object WithLifecycleCallbacks extends js.Object {
def apply(dataProvider: DataProvider, callbacks: js.Array[js.Object]): DataProvider = js.native
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
package net.wiringbits.spra.ui.web

import scala.scalajs.js
import scala.scalajs.js.annotation.JSImport
import net.wiringbits.spra.ui.web.utils.Images.*
import org.scalajs.dom.File

package object facades {
@js.native
@JSImport("ra-data-simple-rest", JSImport.Default)
def simpleRestProvider(url: String): DataProvider = js.native

def createDataProvider(url: String): DataProvider = {
val baseDataProvider = simpleRestProvider(url)
WithLifecycleCallbacks(
baseDataProvider,
js.Array(
js.Dynamic.literal(
resource = "images",
afterRead = (record: js.Dynamic, dataProvider: js.Any) => {
val hexImage = record.data.asInstanceOf[String]
val urlImage = convertHexToImage(hexImage)
record.updateDynamic("data")(urlImage)
record
},
beforeSave = (data: js.Dynamic, dataProvider: js.Any) => {
val rawFile = data.data.rawFile.asInstanceOf[File]
convertImageToByteArray(rawFile).`then` { value =>
data.updateDynamic("data")(value.asInstanceOf[js.Any])
data
}
}
)
)
)
}
}
Loading